@striae-org/striae 5.2.1 → 5.3.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 +2 -10
- package/README.md +5 -46
- package/app/components/actions/case-export/core-export.ts +2 -174
- package/app/components/actions/case-export/download-handlers.ts +83 -750
- package/app/components/actions/case-export/index.ts +6 -30
- package/app/components/actions/case-export/metadata-helpers.ts +0 -78
- package/app/components/actions/case-export/types-constants.ts +0 -43
- package/app/components/actions/case-import/confirmation-import.ts +13 -14
- package/app/components/actions/case-import/zip-processing.ts +92 -12
- package/app/components/actions/generate-pdf.ts +3 -2
- package/app/components/audit/user-audit-viewer.tsx +0 -19
- package/app/components/audit/viewer/audit-viewer-header.tsx +0 -33
- package/app/components/navbar/case-modals/archive-case-modal.tsx +1 -1
- package/app/components/navbar/navbar.tsx +1 -1
- package/app/components/sidebar/case-import/case-import.module.css +35 -0
- package/app/components/sidebar/case-import/components/CasePreviewSection.tsx +59 -3
- package/app/components/sidebar/case-import/components/ConfirmationDialog.tsx +2 -4
- package/app/components/sidebar/case-import/components/ConfirmationPreviewSection.tsx +1 -1
- package/app/components/sidebar/notes/class-details-shared.ts +2 -2
- package/app/components/toast/toast.module.css +36 -0
- package/app/components/toast/toast.tsx +6 -2
- package/app/components/user/manage-profile.tsx +4 -3
- package/app/config-example/config.json +1 -2
- package/app/root.tsx +0 -7
- package/app/routes/_index.tsx +1 -1
- package/app/routes/auth/login.example.tsx +22 -103
- package/app/routes/auth/route.ts +1 -1
- package/app/routes/striae/striae.tsx +53 -59
- package/app/services/firebase/index.ts +0 -3
- package/app/types/export.ts +1 -2
- package/app/utils/auth/index.ts +0 -1
- package/app/utils/data/permissions.ts +3 -2
- package/package.json +9 -16
- package/public/_headers +0 -4
- package/public/_routes.json +0 -1
- package/worker-configuration.d.ts +20 -17
- package/workers/audit-worker/src/audit-worker.example.ts +9 -806
- package/workers/audit-worker/src/config.ts +7 -0
- package/workers/audit-worker/src/crypto/data-at-rest.ts +410 -0
- package/workers/audit-worker/src/handlers/audit-routes.ts +125 -0
- package/workers/audit-worker/src/storage/audit-storage.ts +99 -0
- package/workers/audit-worker/src/types.ts +56 -0
- package/workers/audit-worker/worker-configuration.d.ts +1 -1
- package/workers/audit-worker/wrangler.jsonc.example +1 -1
- package/workers/data-worker/src/config.ts +11 -0
- package/workers/data-worker/src/data-worker.example.ts +21 -942
- package/workers/data-worker/src/handlers/decrypt-export.ts +118 -0
- package/workers/data-worker/src/handlers/signing.ts +174 -0
- package/workers/data-worker/src/handlers/storage-routes.ts +129 -0
- package/workers/data-worker/src/registry/key-registry.ts +368 -0
- package/workers/data-worker/src/types.ts +46 -0
- package/workers/data-worker/worker-configuration.d.ts +1 -1
- package/workers/data-worker/wrangler.jsonc.example +1 -1
- package/workers/image-worker/worker-configuration.d.ts +1 -1
- package/workers/image-worker/wrangler.jsonc.example +1 -1
- package/workers/pdf-worker/worker-configuration.d.ts +2 -3
- package/workers/pdf-worker/wrangler.jsonc.example +1 -1
- package/workers/user-worker/src/auth.ts +30 -0
- package/workers/user-worker/src/cleanup/account-deletion.ts +337 -0
- package/workers/user-worker/src/config.ts +4 -0
- package/workers/user-worker/src/encryption-utils.ts +25 -0
- package/workers/user-worker/src/firebase/admin.ts +152 -0
- package/workers/user-worker/src/handlers/user-routes.ts +242 -0
- package/workers/user-worker/src/registry/user-kv.ts +172 -0
- package/workers/user-worker/src/storage/user-records.ts +34 -0
- package/workers/user-worker/src/types.ts +106 -0
- package/workers/user-worker/src/user-worker.example.ts +18 -964
- package/workers/user-worker/worker-configuration.d.ts +4 -2
- package/workers/user-worker/wrangler.jsonc.example +12 -1
- package/wrangler.toml.example +1 -1
- package/app/components/actions/case-export/data-processing.ts +0 -223
- package/app/components/sidebar/case-export/case-export.module.css +0 -418
- package/app/components/sidebar/case-export/case-export.tsx +0 -310
- package/app/types/exceljs-bare.d.ts +0 -9
- package/app/utils/auth/auth.ts +0 -11
- package/public/.well-known/security.txt +0 -6
- package/public/favicon.ico +0 -0
- package/public/icon-256.png +0 -0
- package/public/icon-512.png +0 -0
- package/public/manifest.json +0 -39
- package/public/shortcut.png +0 -0
- package/public/social-image.png +0 -0
- package/public/vendor/exceljs.LICENSE +0 -22
- package/public/vendor/exceljs.bare.min.js +0 -45
- package/scripts/deploy-all.sh +0 -166
- package/scripts/deploy-config/modules/env-utils.sh +0 -322
- package/scripts/deploy-config/modules/keys.sh +0 -404
- package/scripts/deploy-config/modules/prompt.sh +0 -372
- package/scripts/deploy-config/modules/scaffolding.sh +0 -344
- package/scripts/deploy-config/modules/validation.sh +0 -365
- package/scripts/deploy-config.sh +0 -236
- package/scripts/deploy-pages-secrets.sh +0 -231
- package/scripts/deploy-pages.sh +0 -34
- package/scripts/deploy-primershear-emails.sh +0 -167
- package/scripts/deploy-worker-secrets.sh +0 -374
- package/scripts/dev.cjs +0 -23
- package/scripts/install-workers.sh +0 -88
- package/scripts/run-eslint.cjs +0 -43
- package/scripts/update-compatibility-dates.cjs +0 -124
- package/scripts/update-markdown-versions.cjs +0 -43
- package/workers/keys-worker/package.json +0 -18
- package/workers/keys-worker/src/keys.example.ts +0 -67
- package/workers/keys-worker/src/keys.ts +0 -67
- package/workers/keys-worker/worker-configuration.d.ts +0 -7447
- package/workers/keys-worker/wrangler.jsonc.example +0 -15
|
@@ -1,126 +1,14 @@
|
|
|
1
|
+
import { authenticate, requireUserKvReadConfig, requireUserKvWriteConfig } from './auth';
|
|
2
|
+
import { USER_CASES_SEGMENT } from './config';
|
|
1
3
|
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
USER_DB_AUTH: string;
|
|
11
|
-
USER_DB: KVNamespace;
|
|
12
|
-
R2_KEY_SECRET: string;
|
|
13
|
-
IMAGES_API_TOKEN: string;
|
|
14
|
-
DATA_WORKER_DOMAIN?: string;
|
|
15
|
-
IMAGES_WORKER_DOMAIN?: string;
|
|
16
|
-
PROJECT_ID: string;
|
|
17
|
-
FIREBASE_SERVICE_ACCOUNT_EMAIL: string;
|
|
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>;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
type DecryptionTelemetryOutcome = 'primary-hit' | 'fallback-hit' | 'all-failed';
|
|
37
|
-
|
|
38
|
-
interface UserData {
|
|
39
|
-
uid: string;
|
|
40
|
-
email: string;
|
|
41
|
-
firstName: string;
|
|
42
|
-
lastName: string;
|
|
43
|
-
company: string;
|
|
44
|
-
badgeId?: string;
|
|
45
|
-
permitted: boolean;
|
|
46
|
-
cases: CaseItem[];
|
|
47
|
-
readOnlyCases?: ReadOnlyCaseItem[];
|
|
48
|
-
createdAt?: string;
|
|
49
|
-
updatedAt?: string;
|
|
50
|
-
}
|
|
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
|
-
|
|
69
|
-
interface CaseItem {
|
|
70
|
-
caseNumber: string;
|
|
71
|
-
caseName?: string;
|
|
72
|
-
[key: string]: unknown;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
interface ReadOnlyCaseItem {
|
|
76
|
-
caseNumber: string;
|
|
77
|
-
caseName?: string;
|
|
78
|
-
[key: string]: unknown;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
interface UserRequestData {
|
|
82
|
-
email?: string;
|
|
83
|
-
firstName?: string;
|
|
84
|
-
lastName?: string;
|
|
85
|
-
company?: string;
|
|
86
|
-
badgeId?: string;
|
|
87
|
-
permitted?: boolean;
|
|
88
|
-
readOnlyCases?: ReadOnlyCaseItem[];
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
interface AddCasesRequest {
|
|
92
|
-
cases: CaseItem[];
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
interface DeleteCasesRequest {
|
|
96
|
-
casesToDelete: string[];
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
interface CaseData {
|
|
100
|
-
files?: Array<{ id: string; [key: string]: unknown }>;
|
|
101
|
-
[key: string]: unknown;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
interface AccountDeletionProgressEvent {
|
|
105
|
-
event: 'start' | 'case-start' | 'case-complete' | 'complete' | 'error';
|
|
106
|
-
totalCases: number;
|
|
107
|
-
completedCases: number;
|
|
108
|
-
currentCaseNumber?: string;
|
|
109
|
-
success?: boolean;
|
|
110
|
-
message?: string;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
interface GoogleOAuthTokenResponse {
|
|
114
|
-
access_token?: string;
|
|
115
|
-
error?: string;
|
|
116
|
-
error_description?: string;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
interface FirebaseDeleteAccountErrorResponse {
|
|
120
|
-
error?: {
|
|
121
|
-
message?: string;
|
|
122
|
-
};
|
|
123
|
-
}
|
|
4
|
+
handleAddCases,
|
|
5
|
+
handleAddUser,
|
|
6
|
+
handleDeleteCases,
|
|
7
|
+
handleDeleteUser,
|
|
8
|
+
handleDeleteUserWithProgress,
|
|
9
|
+
handleGetUser
|
|
10
|
+
} from './handlers/user-routes';
|
|
11
|
+
import type { Env } from './types';
|
|
124
12
|
|
|
125
13
|
const corsHeaders: Record<string, string> = {
|
|
126
14
|
'Access-Control-Allow-Origin': 'PAGES_CUSTOM_DOMAIN',
|
|
@@ -129,842 +17,6 @@ const corsHeaders: Record<string, string> = {
|
|
|
129
17
|
'Content-Type': 'application/json'
|
|
130
18
|
};
|
|
131
19
|
|
|
132
|
-
// Worker URLs - configure these for deployment
|
|
133
|
-
const DEFAULT_DATA_WORKER_BASE_URL = 'DATA_WORKER_DOMAIN';
|
|
134
|
-
const DEFAULT_IMAGE_WORKER_BASE_URL = 'IMAGES_WORKER_DOMAIN';
|
|
135
|
-
|
|
136
|
-
const GOOGLE_OAUTH_TOKEN_URL = 'https://oauth2.googleapis.com/token';
|
|
137
|
-
const FIREBASE_IDENTITY_TOOLKIT_BASE_URL = 'https://identitytoolkit.googleapis.com/v1/projects';
|
|
138
|
-
const GOOGLE_IDENTITY_TOOLKIT_SCOPE = 'https://www.googleapis.com/auth/identitytoolkit';
|
|
139
|
-
const textEncoder = new TextEncoder();
|
|
140
|
-
|
|
141
|
-
async function authenticate(request: Request, env: Env): Promise<void> {
|
|
142
|
-
const authKey = request.headers.get('X-Custom-Auth-Key');
|
|
143
|
-
if (authKey !== env.USER_DB_AUTH) throw new Error('Unauthorized');
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
function normalizeWorkerBaseUrl(workerDomain: string): string {
|
|
147
|
-
const trimmedDomain = workerDomain.trim().replace(/\/+$/, '');
|
|
148
|
-
if (trimmedDomain.startsWith('http://') || trimmedDomain.startsWith('https://')) {
|
|
149
|
-
return trimmedDomain;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
return `https://${trimmedDomain}`;
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
function resolveDataWorkerBaseUrl(env: Env): string {
|
|
156
|
-
const configuredDomain = typeof env.DATA_WORKER_DOMAIN === 'string' ? env.DATA_WORKER_DOMAIN.trim() : '';
|
|
157
|
-
if (configuredDomain.length > 0) {
|
|
158
|
-
return normalizeWorkerBaseUrl(configuredDomain);
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
return normalizeWorkerBaseUrl(DEFAULT_DATA_WORKER_BASE_URL);
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
function resolveImageWorkerBaseUrl(env: Env): string {
|
|
165
|
-
const configuredDomain = typeof env.IMAGES_WORKER_DOMAIN === 'string' ? env.IMAGES_WORKER_DOMAIN.trim() : '';
|
|
166
|
-
if (configuredDomain.length > 0) {
|
|
167
|
-
return normalizeWorkerBaseUrl(configuredDomain);
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
return normalizeWorkerBaseUrl(DEFAULT_IMAGE_WORKER_BASE_URL);
|
|
171
|
-
}
|
|
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
|
-
|
|
420
|
-
function base64UrlEncode(value: string | Uint8Array): string {
|
|
421
|
-
const bytes = typeof value === 'string' ? textEncoder.encode(value) : value;
|
|
422
|
-
let binary = '';
|
|
423
|
-
|
|
424
|
-
for (const byte of bytes) {
|
|
425
|
-
binary += String.fromCharCode(byte);
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
return btoa(binary)
|
|
429
|
-
.replace(/\+/g, '-')
|
|
430
|
-
.replace(/\//g, '_')
|
|
431
|
-
.replace(/=+$/g, '');
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
function parsePkcs8PrivateKey(privateKey: string): ArrayBuffer {
|
|
435
|
-
const normalizedKey = privateKey
|
|
436
|
-
.trim()
|
|
437
|
-
.replace(/^['"]|['"]$/g, '')
|
|
438
|
-
.replace(/\\n/g, '\n');
|
|
439
|
-
|
|
440
|
-
const pemBody = normalizedKey
|
|
441
|
-
.replace('-----BEGIN PRIVATE KEY-----', '')
|
|
442
|
-
.replace('-----END PRIVATE KEY-----', '')
|
|
443
|
-
.replace(/\s+/g, '');
|
|
444
|
-
|
|
445
|
-
if (!pemBody) {
|
|
446
|
-
throw new Error('Firebase service account private key is invalid');
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
const binary = atob(pemBody);
|
|
450
|
-
const bytes = new Uint8Array(binary.length);
|
|
451
|
-
|
|
452
|
-
for (let index = 0; index < binary.length; index += 1) {
|
|
453
|
-
bytes[index] = binary.charCodeAt(index);
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
return bytes.buffer;
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
async function buildServiceAccountAssertion(env: Env): Promise<string> {
|
|
460
|
-
const issuedAt = Math.floor(Date.now() / 1000);
|
|
461
|
-
const header = {
|
|
462
|
-
alg: 'RS256',
|
|
463
|
-
typ: 'JWT'
|
|
464
|
-
};
|
|
465
|
-
const payload = {
|
|
466
|
-
iss: env.FIREBASE_SERVICE_ACCOUNT_EMAIL,
|
|
467
|
-
scope: GOOGLE_IDENTITY_TOOLKIT_SCOPE,
|
|
468
|
-
aud: GOOGLE_OAUTH_TOKEN_URL,
|
|
469
|
-
iat: issuedAt,
|
|
470
|
-
exp: issuedAt + 3600
|
|
471
|
-
};
|
|
472
|
-
const unsignedToken = `${base64UrlEncode(JSON.stringify(header))}.${base64UrlEncode(JSON.stringify(payload))}`;
|
|
473
|
-
|
|
474
|
-
let signingKey: CryptoKey;
|
|
475
|
-
|
|
476
|
-
try {
|
|
477
|
-
signingKey = await crypto.subtle.importKey(
|
|
478
|
-
'pkcs8',
|
|
479
|
-
parsePkcs8PrivateKey(env.FIREBASE_SERVICE_ACCOUNT_PRIVATE_KEY),
|
|
480
|
-
{
|
|
481
|
-
name: 'RSASSA-PKCS1-v1_5',
|
|
482
|
-
hash: 'SHA-256'
|
|
483
|
-
},
|
|
484
|
-
false,
|
|
485
|
-
['sign']
|
|
486
|
-
);
|
|
487
|
-
} catch {
|
|
488
|
-
throw new Error('Invalid Firebase service account private key format. Use the service account JSON private_key value (PKCS8) and keep newline markers as \\n.');
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
const signature = await crypto.subtle.sign(
|
|
492
|
-
{ name: 'RSASSA-PKCS1-v1_5' },
|
|
493
|
-
signingKey,
|
|
494
|
-
textEncoder.encode(unsignedToken)
|
|
495
|
-
);
|
|
496
|
-
|
|
497
|
-
return `${unsignedToken}.${base64UrlEncode(new Uint8Array(signature))}`;
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
async function getGoogleAccessToken(env: Env): Promise<string> {
|
|
501
|
-
const assertion = await buildServiceAccountAssertion(env);
|
|
502
|
-
const body = new URLSearchParams({
|
|
503
|
-
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
|
504
|
-
assertion
|
|
505
|
-
});
|
|
506
|
-
|
|
507
|
-
const tokenResponse = await fetch(GOOGLE_OAUTH_TOKEN_URL, {
|
|
508
|
-
method: 'POST',
|
|
509
|
-
headers: {
|
|
510
|
-
'Content-Type': 'application/x-www-form-urlencoded'
|
|
511
|
-
},
|
|
512
|
-
body
|
|
513
|
-
});
|
|
514
|
-
|
|
515
|
-
const tokenData = await tokenResponse.json().catch(() => ({})) as GoogleOAuthTokenResponse;
|
|
516
|
-
if (!tokenResponse.ok || !tokenData.access_token) {
|
|
517
|
-
const errorReason = tokenData.error_description || tokenData.error || `HTTP ${tokenResponse.status}`;
|
|
518
|
-
throw new Error(`Failed to authorize Firebase admin deletion: ${errorReason}`);
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
return tokenData.access_token;
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
async function deleteFirebaseAuthUser(env: Env, userUid: string): Promise<void> {
|
|
525
|
-
if (!env.PROJECT_ID || !env.FIREBASE_SERVICE_ACCOUNT_EMAIL || !env.FIREBASE_SERVICE_ACCOUNT_PRIVATE_KEY) {
|
|
526
|
-
throw new Error('Firebase Auth deletion is not configured in User Worker secrets');
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
const accessToken = await getGoogleAccessToken(env);
|
|
530
|
-
const deleteResponse = await fetch(
|
|
531
|
-
`${FIREBASE_IDENTITY_TOOLKIT_BASE_URL}/${encodeURIComponent(env.PROJECT_ID)}/accounts:delete`,
|
|
532
|
-
{
|
|
533
|
-
method: 'POST',
|
|
534
|
-
headers: {
|
|
535
|
-
'Authorization': `Bearer ${accessToken}`,
|
|
536
|
-
'Content-Type': 'application/json'
|
|
537
|
-
},
|
|
538
|
-
body: JSON.stringify({ localId: userUid })
|
|
539
|
-
}
|
|
540
|
-
);
|
|
541
|
-
|
|
542
|
-
if (deleteResponse.ok) {
|
|
543
|
-
return;
|
|
544
|
-
}
|
|
545
|
-
|
|
546
|
-
const deleteErrorPayload = await deleteResponse.json().catch(() => ({})) as FirebaseDeleteAccountErrorResponse;
|
|
547
|
-
const deleteErrorMessage = deleteErrorPayload.error?.message || '';
|
|
548
|
-
|
|
549
|
-
if (deleteErrorMessage.includes('USER_NOT_FOUND')) {
|
|
550
|
-
return;
|
|
551
|
-
}
|
|
552
|
-
|
|
553
|
-
throw new Error(deleteErrorMessage ? `Firebase Auth deletion failed: ${deleteErrorMessage}` : `Firebase Auth deletion failed with status ${deleteResponse.status}`);
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
async function handleGetUser(env: Env, userUid: string): Promise<Response> {
|
|
557
|
-
try {
|
|
558
|
-
const userData = await readUserRecord(env, userUid);
|
|
559
|
-
if (userData === null) {
|
|
560
|
-
return new Response('User not found', {
|
|
561
|
-
status: 404,
|
|
562
|
-
headers: corsHeaders
|
|
563
|
-
});
|
|
564
|
-
}
|
|
565
|
-
return new Response(JSON.stringify(userData), {
|
|
566
|
-
status: 200,
|
|
567
|
-
headers: corsHeaders
|
|
568
|
-
});
|
|
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
|
|
576
|
-
});
|
|
577
|
-
}
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
async function handleAddUser(request: Request, env: Env, userUid: string): Promise<Response> {
|
|
581
|
-
try {
|
|
582
|
-
const requestData: UserRequestData = await request.json();
|
|
583
|
-
const { email, firstName, lastName, company, badgeId, permitted } = requestData;
|
|
584
|
-
const normalizedBadgeId = typeof badgeId === 'string' ? badgeId.trim() : undefined;
|
|
585
|
-
|
|
586
|
-
// Check for existing user
|
|
587
|
-
const existingUser = await readUserRecord(env, userUid);
|
|
588
|
-
|
|
589
|
-
let userData: UserData;
|
|
590
|
-
if (existingUser !== null) {
|
|
591
|
-
// Update existing user, preserving cases
|
|
592
|
-
userData = {
|
|
593
|
-
...existingUser,
|
|
594
|
-
// Preserve all existing fields
|
|
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,
|
|
601
|
-
updatedAt: new Date().toISOString()
|
|
602
|
-
};
|
|
603
|
-
if (requestData.readOnlyCases !== undefined) {
|
|
604
|
-
userData.readOnlyCases = requestData.readOnlyCases;
|
|
605
|
-
}
|
|
606
|
-
} else {
|
|
607
|
-
// Create new user
|
|
608
|
-
userData = {
|
|
609
|
-
uid: userUid,
|
|
610
|
-
email: email || '',
|
|
611
|
-
firstName: firstName || '',
|
|
612
|
-
lastName: lastName || '',
|
|
613
|
-
company: company || '',
|
|
614
|
-
badgeId: normalizedBadgeId ?? '',
|
|
615
|
-
permitted: permitted !== undefined ? permitted : true,
|
|
616
|
-
cases: [],
|
|
617
|
-
createdAt: new Date().toISOString()
|
|
618
|
-
};
|
|
619
|
-
if (requestData.readOnlyCases !== undefined) {
|
|
620
|
-
userData.readOnlyCases = requestData.readOnlyCases;
|
|
621
|
-
}
|
|
622
|
-
}
|
|
623
|
-
|
|
624
|
-
// Store value in KV
|
|
625
|
-
await writeUserRecord(env, userUid, userData);
|
|
626
|
-
|
|
627
|
-
return new Response(JSON.stringify(userData), {
|
|
628
|
-
status: existingUser !== null ? 200 : 201,
|
|
629
|
-
headers: corsHeaders
|
|
630
|
-
});
|
|
631
|
-
} catch {
|
|
632
|
-
return new Response('Failed to save user data', {
|
|
633
|
-
status: 500,
|
|
634
|
-
headers: corsHeaders
|
|
635
|
-
});
|
|
636
|
-
}
|
|
637
|
-
}
|
|
638
|
-
|
|
639
|
-
// Function to delete a single case (similar to case-manage.ts deleteCase)
|
|
640
|
-
async function deleteSingleCase(env: Env, userUid: string, caseNumber: string): Promise<void> {
|
|
641
|
-
const dataApiKey = env.R2_KEY_SECRET;
|
|
642
|
-
const imageApiKey = env.IMAGES_API_TOKEN;
|
|
643
|
-
|
|
644
|
-
const dataWorkerBaseUrl = resolveDataWorkerBaseUrl(env);
|
|
645
|
-
const imageWorkerBaseUrl = resolveImageWorkerBaseUrl(env);
|
|
646
|
-
const encodedUserId = encodeURIComponent(userUid);
|
|
647
|
-
const encodedCaseNumber = encodeURIComponent(caseNumber);
|
|
648
|
-
|
|
649
|
-
const caseResponse = await fetch(`${dataWorkerBaseUrl}/${encodedUserId}/${encodedCaseNumber}/data.json`, {
|
|
650
|
-
headers: { 'X-Custom-Auth-Key': dataApiKey }
|
|
651
|
-
});
|
|
652
|
-
|
|
653
|
-
if (caseResponse.status === 404) {
|
|
654
|
-
return;
|
|
655
|
-
}
|
|
656
|
-
|
|
657
|
-
if (!caseResponse.ok) {
|
|
658
|
-
throw new Error(`Failed to load case data for deletion (${caseNumber}): ${caseResponse.status}`);
|
|
659
|
-
}
|
|
660
|
-
|
|
661
|
-
const caseData = await caseResponse.json() as CaseData;
|
|
662
|
-
const deletionErrors: string[] = [];
|
|
663
|
-
|
|
664
|
-
// Delete all files associated with this case
|
|
665
|
-
if (caseData.files && caseData.files.length > 0) {
|
|
666
|
-
for (const file of caseData.files) {
|
|
667
|
-
const encodedFileId = encodeURIComponent(file.id);
|
|
668
|
-
|
|
669
|
-
try {
|
|
670
|
-
const imageDeleteResponse = await fetch(`${imageWorkerBaseUrl}/${encodedFileId}`, {
|
|
671
|
-
method: 'DELETE',
|
|
672
|
-
headers: {
|
|
673
|
-
'Authorization': `Bearer ${imageApiKey}`
|
|
674
|
-
}
|
|
675
|
-
});
|
|
676
|
-
|
|
677
|
-
if (!imageDeleteResponse.ok && imageDeleteResponse.status !== 404) {
|
|
678
|
-
deletionErrors.push(`image ${file.id} delete failed (${imageDeleteResponse.status})`);
|
|
679
|
-
}
|
|
680
|
-
} catch (error) {
|
|
681
|
-
const message = error instanceof Error ? error.message : 'unknown image delete error';
|
|
682
|
-
deletionErrors.push(`image ${file.id} delete threw (${message})`);
|
|
683
|
-
}
|
|
684
|
-
|
|
685
|
-
try {
|
|
686
|
-
const notesDeleteResponse = await fetch(
|
|
687
|
-
`${dataWorkerBaseUrl}/${encodedUserId}/${encodedCaseNumber}/${encodedFileId}/data.json`,
|
|
688
|
-
{
|
|
689
|
-
method: 'DELETE',
|
|
690
|
-
headers: { 'X-Custom-Auth-Key': dataApiKey }
|
|
691
|
-
}
|
|
692
|
-
);
|
|
693
|
-
|
|
694
|
-
if (!notesDeleteResponse.ok && notesDeleteResponse.status !== 404) {
|
|
695
|
-
deletionErrors.push(`annotation ${file.id} delete failed (${notesDeleteResponse.status})`);
|
|
696
|
-
}
|
|
697
|
-
} catch (error) {
|
|
698
|
-
const message = error instanceof Error ? error.message : 'unknown annotation delete error';
|
|
699
|
-
deletionErrors.push(`annotation ${file.id} delete threw (${message})`);
|
|
700
|
-
}
|
|
701
|
-
}
|
|
702
|
-
}
|
|
703
|
-
|
|
704
|
-
// Delete case data file
|
|
705
|
-
try {
|
|
706
|
-
const caseDeleteResponse = await fetch(
|
|
707
|
-
`${dataWorkerBaseUrl}/${encodedUserId}/${encodedCaseNumber}/data.json`,
|
|
708
|
-
{
|
|
709
|
-
method: 'DELETE',
|
|
710
|
-
headers: { 'X-Custom-Auth-Key': dataApiKey }
|
|
711
|
-
}
|
|
712
|
-
);
|
|
713
|
-
|
|
714
|
-
if (!caseDeleteResponse.ok && caseDeleteResponse.status !== 404) {
|
|
715
|
-
deletionErrors.push(`case ${caseNumber} delete failed (${caseDeleteResponse.status})`);
|
|
716
|
-
}
|
|
717
|
-
} catch (error) {
|
|
718
|
-
const message = error instanceof Error ? error.message : 'unknown case delete error';
|
|
719
|
-
deletionErrors.push(`case ${caseNumber} delete threw (${message})`);
|
|
720
|
-
}
|
|
721
|
-
|
|
722
|
-
if (deletionErrors.length > 0) {
|
|
723
|
-
throw new Error(`Case cleanup incomplete for ${caseNumber}: ${deletionErrors.join('; ')}`);
|
|
724
|
-
}
|
|
725
|
-
}
|
|
726
|
-
|
|
727
|
-
async function deleteUserConfirmationSummary(env: Env, userUid: string): Promise<void> {
|
|
728
|
-
const dataApiKey = env.R2_KEY_SECRET;
|
|
729
|
-
const dataWorkerBaseUrl = resolveDataWorkerBaseUrl(env);
|
|
730
|
-
const encodedUserId = encodeURIComponent(userUid);
|
|
731
|
-
const confirmationSummaryPath = `${dataWorkerBaseUrl}/${encodedUserId}/meta/confirmation-status.json`;
|
|
732
|
-
|
|
733
|
-
const response = await fetch(confirmationSummaryPath, {
|
|
734
|
-
method: 'DELETE',
|
|
735
|
-
headers: { 'X-Custom-Auth-Key': dataApiKey }
|
|
736
|
-
});
|
|
737
|
-
|
|
738
|
-
if (!response.ok && response.status !== 404) {
|
|
739
|
-
throw new Error(`Failed to delete confirmation summary metadata: ${response.status}`);
|
|
740
|
-
}
|
|
741
|
-
}
|
|
742
|
-
|
|
743
|
-
async function executeUserDeletion(
|
|
744
|
-
env: Env,
|
|
745
|
-
userUid: string,
|
|
746
|
-
reportProgress?: (progress: AccountDeletionProgressEvent) => void
|
|
747
|
-
): Promise<{ success: boolean; message: string; totalCases: number; completedCases: number }> {
|
|
748
|
-
const userData = await readUserRecord(env, userUid);
|
|
749
|
-
if (userData === null) {
|
|
750
|
-
throw new Error('User not found');
|
|
751
|
-
}
|
|
752
|
-
|
|
753
|
-
const ownedCases = (userData.cases || []).map((caseItem) => caseItem.caseNumber);
|
|
754
|
-
const readOnlyCases = (userData.readOnlyCases || []).map((caseItem) => caseItem.caseNumber);
|
|
755
|
-
const allCaseNumbers = Array.from(new Set([...ownedCases, ...readOnlyCases]));
|
|
756
|
-
const totalCases = allCaseNumbers.length;
|
|
757
|
-
let completedCases = 0;
|
|
758
|
-
const caseCleanupErrors: string[] = [];
|
|
759
|
-
|
|
760
|
-
reportProgress?.({
|
|
761
|
-
event: 'start',
|
|
762
|
-
totalCases,
|
|
763
|
-
completedCases
|
|
764
|
-
});
|
|
765
|
-
|
|
766
|
-
for (const caseNumber of allCaseNumbers) {
|
|
767
|
-
reportProgress?.({
|
|
768
|
-
event: 'case-start',
|
|
769
|
-
totalCases,
|
|
770
|
-
completedCases,
|
|
771
|
-
currentCaseNumber: caseNumber
|
|
772
|
-
});
|
|
773
|
-
|
|
774
|
-
let caseDeletionError: string | null = null;
|
|
775
|
-
try {
|
|
776
|
-
await deleteSingleCase(env, userUid, caseNumber);
|
|
777
|
-
} catch (error) {
|
|
778
|
-
caseDeletionError = error instanceof Error ? error.message : `Case cleanup failed for ${caseNumber}`;
|
|
779
|
-
caseCleanupErrors.push(caseDeletionError);
|
|
780
|
-
console.error(`Case cleanup error for ${caseNumber}:`, error);
|
|
781
|
-
}
|
|
782
|
-
|
|
783
|
-
completedCases += 1;
|
|
784
|
-
|
|
785
|
-
reportProgress?.({
|
|
786
|
-
event: 'case-complete',
|
|
787
|
-
totalCases,
|
|
788
|
-
completedCases,
|
|
789
|
-
currentCaseNumber: caseNumber,
|
|
790
|
-
success: caseDeletionError === null,
|
|
791
|
-
message: caseDeletionError || undefined
|
|
792
|
-
});
|
|
793
|
-
}
|
|
794
|
-
|
|
795
|
-
if (caseCleanupErrors.length > 0) {
|
|
796
|
-
throw new Error(`Failed to fully delete all case data: ${caseCleanupErrors.join(' | ')}`);
|
|
797
|
-
}
|
|
798
|
-
|
|
799
|
-
await deleteUserConfirmationSummary(env, userUid);
|
|
800
|
-
await deleteFirebaseAuthUser(env, userUid);
|
|
801
|
-
|
|
802
|
-
// Delete the user account from the database
|
|
803
|
-
await env.USER_DB.delete(userUid);
|
|
804
|
-
|
|
805
|
-
return {
|
|
806
|
-
success: true,
|
|
807
|
-
message: 'Account successfully deleted',
|
|
808
|
-
totalCases,
|
|
809
|
-
completedCases
|
|
810
|
-
};
|
|
811
|
-
}
|
|
812
|
-
|
|
813
|
-
async function handleDeleteUser(env: Env, userUid: string): Promise<Response> {
|
|
814
|
-
try {
|
|
815
|
-
const result = await executeUserDeletion(env, userUid);
|
|
816
|
-
|
|
817
|
-
return new Response(JSON.stringify({
|
|
818
|
-
success: result.success,
|
|
819
|
-
message: result.message
|
|
820
|
-
}), {
|
|
821
|
-
status: 200,
|
|
822
|
-
headers: corsHeaders
|
|
823
|
-
});
|
|
824
|
-
} catch (error) {
|
|
825
|
-
console.error('Delete user error:', error);
|
|
826
|
-
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
|
|
827
|
-
|
|
828
|
-
if (errorMessage === 'User not found') {
|
|
829
|
-
return new Response('User not found', {
|
|
830
|
-
status: 404,
|
|
831
|
-
headers: corsHeaders
|
|
832
|
-
});
|
|
833
|
-
}
|
|
834
|
-
|
|
835
|
-
return new Response(JSON.stringify({
|
|
836
|
-
success: false,
|
|
837
|
-
message: 'Failed to delete user account'
|
|
838
|
-
}), {
|
|
839
|
-
status: 500,
|
|
840
|
-
headers: corsHeaders
|
|
841
|
-
});
|
|
842
|
-
}
|
|
843
|
-
}
|
|
844
|
-
|
|
845
|
-
function handleDeleteUserWithProgress(env: Env, userUid: string): Response {
|
|
846
|
-
const sseHeaders: Record<string, string> = {
|
|
847
|
-
...corsHeaders,
|
|
848
|
-
'Content-Type': 'text/event-stream',
|
|
849
|
-
'Cache-Control': 'no-cache, no-transform',
|
|
850
|
-
'Connection': 'keep-alive'
|
|
851
|
-
};
|
|
852
|
-
|
|
853
|
-
const encoder = new TextEncoder();
|
|
854
|
-
|
|
855
|
-
const stream = new ReadableStream<Uint8Array>({
|
|
856
|
-
async start(controller) {
|
|
857
|
-
const sendEvent = (payload: AccountDeletionProgressEvent) => {
|
|
858
|
-
controller.enqueue(encoder.encode(`event: ${payload.event}\ndata: ${JSON.stringify(payload)}\n\n`));
|
|
859
|
-
};
|
|
860
|
-
|
|
861
|
-
try {
|
|
862
|
-
const result = await executeUserDeletion(env, userUid, sendEvent);
|
|
863
|
-
sendEvent({
|
|
864
|
-
event: 'complete',
|
|
865
|
-
totalCases: result.totalCases,
|
|
866
|
-
completedCases: result.completedCases,
|
|
867
|
-
success: result.success,
|
|
868
|
-
message: result.message
|
|
869
|
-
});
|
|
870
|
-
} catch (error) {
|
|
871
|
-
const errorMessage = error instanceof Error ? error.message : 'Failed to delete user account';
|
|
872
|
-
|
|
873
|
-
sendEvent({
|
|
874
|
-
event: 'error',
|
|
875
|
-
totalCases: 0,
|
|
876
|
-
completedCases: 0,
|
|
877
|
-
success: false,
|
|
878
|
-
message: errorMessage
|
|
879
|
-
});
|
|
880
|
-
} finally {
|
|
881
|
-
controller.close();
|
|
882
|
-
}
|
|
883
|
-
}
|
|
884
|
-
});
|
|
885
|
-
|
|
886
|
-
return new Response(stream, {
|
|
887
|
-
status: 200,
|
|
888
|
-
headers: sseHeaders
|
|
889
|
-
});
|
|
890
|
-
}
|
|
891
|
-
|
|
892
|
-
async function handleAddCases(request: Request, env: Env, userUid: string): Promise<Response> {
|
|
893
|
-
try {
|
|
894
|
-
const { cases = [] }: AddCasesRequest = await request.json();
|
|
895
|
-
|
|
896
|
-
// Get current user data
|
|
897
|
-
const userData = await readUserRecord(env, userUid);
|
|
898
|
-
if (!userData) {
|
|
899
|
-
return new Response('User not found', {
|
|
900
|
-
status: 404,
|
|
901
|
-
headers: corsHeaders
|
|
902
|
-
});
|
|
903
|
-
}
|
|
904
|
-
|
|
905
|
-
// Update cases
|
|
906
|
-
const existingCases = userData.cases || [];
|
|
907
|
-
|
|
908
|
-
// Filter out duplicates
|
|
909
|
-
const newCases = cases.filter(newCase =>
|
|
910
|
-
!existingCases.some(existingCase =>
|
|
911
|
-
existingCase.caseNumber === newCase.caseNumber
|
|
912
|
-
)
|
|
913
|
-
);
|
|
914
|
-
|
|
915
|
-
// Update user data
|
|
916
|
-
userData.cases = [...existingCases, ...newCases];
|
|
917
|
-
userData.updatedAt = new Date().toISOString();
|
|
918
|
-
|
|
919
|
-
// Save to KV
|
|
920
|
-
await writeUserRecord(env, userUid, userData);
|
|
921
|
-
|
|
922
|
-
return new Response(JSON.stringify(userData), {
|
|
923
|
-
status: 200,
|
|
924
|
-
headers: corsHeaders
|
|
925
|
-
});
|
|
926
|
-
} catch {
|
|
927
|
-
return new Response('Failed to add cases', {
|
|
928
|
-
status: 500,
|
|
929
|
-
headers: corsHeaders
|
|
930
|
-
});
|
|
931
|
-
}
|
|
932
|
-
}
|
|
933
|
-
|
|
934
|
-
async function handleDeleteCases(request: Request, env: Env, userUid: string): Promise<Response> {
|
|
935
|
-
try {
|
|
936
|
-
const { casesToDelete }: DeleteCasesRequest = await request.json();
|
|
937
|
-
|
|
938
|
-
// Get current user data
|
|
939
|
-
const userData = await readUserRecord(env, userUid);
|
|
940
|
-
if (!userData) {
|
|
941
|
-
return new Response('User not found', {
|
|
942
|
-
status: 404,
|
|
943
|
-
headers: corsHeaders
|
|
944
|
-
});
|
|
945
|
-
}
|
|
946
|
-
|
|
947
|
-
// Update user data
|
|
948
|
-
userData.cases = userData.cases.filter(c =>
|
|
949
|
-
!casesToDelete.includes(c.caseNumber)
|
|
950
|
-
);
|
|
951
|
-
userData.updatedAt = new Date().toISOString();
|
|
952
|
-
|
|
953
|
-
// Save to KV
|
|
954
|
-
await writeUserRecord(env, userUid, userData);
|
|
955
|
-
|
|
956
|
-
return new Response(JSON.stringify(userData), {
|
|
957
|
-
status: 200,
|
|
958
|
-
headers: corsHeaders
|
|
959
|
-
});
|
|
960
|
-
} catch {
|
|
961
|
-
return new Response('Failed to delete cases', {
|
|
962
|
-
status: 500,
|
|
963
|
-
headers: corsHeaders
|
|
964
|
-
});
|
|
965
|
-
}
|
|
966
|
-
}
|
|
967
|
-
|
|
968
20
|
export default {
|
|
969
21
|
async fetch(request: Request, env: Env): Promise<Response> {
|
|
970
22
|
if (request.method === 'OPTIONS') {
|
|
@@ -984,7 +36,7 @@ export default {
|
|
|
984
36
|
const url = new URL(request.url);
|
|
985
37
|
const parts = url.pathname.split('/');
|
|
986
38
|
const userUid = parts[1];
|
|
987
|
-
const isCasesEndpoint = parts[2] ===
|
|
39
|
+
const isCasesEndpoint = parts[2] === USER_CASES_SEGMENT;
|
|
988
40
|
|
|
989
41
|
if (!userUid) {
|
|
990
42
|
return new Response('Not Found', { status: 404 });
|
|
@@ -993,8 +45,8 @@ export default {
|
|
|
993
45
|
// Handle regular cases endpoint
|
|
994
46
|
if (isCasesEndpoint) {
|
|
995
47
|
switch (request.method) {
|
|
996
|
-
case 'PUT': return handleAddCases(request, env, userUid);
|
|
997
|
-
case 'DELETE': return handleDeleteCases(request, env, userUid);
|
|
48
|
+
case 'PUT': return handleAddCases(request, env, userUid, corsHeaders);
|
|
49
|
+
case 'DELETE': return handleDeleteCases(request, env, userUid, corsHeaders);
|
|
998
50
|
default: return new Response('Method not allowed', {
|
|
999
51
|
status: 405,
|
|
1000
52
|
headers: corsHeaders
|
|
@@ -1007,9 +59,11 @@ export default {
|
|
|
1007
59
|
const streamProgress = url.searchParams.get('stream') === 'true' || acceptsEventStream;
|
|
1008
60
|
|
|
1009
61
|
switch (request.method) {
|
|
1010
|
-
case 'GET': return handleGetUser(env, userUid);
|
|
1011
|
-
case 'PUT': return handleAddUser(request, env, userUid);
|
|
1012
|
-
case 'DELETE': return streamProgress
|
|
62
|
+
case 'GET': return handleGetUser(env, userUid, corsHeaders);
|
|
63
|
+
case 'PUT': return handleAddUser(request, env, userUid, corsHeaders);
|
|
64
|
+
case 'DELETE': return streamProgress
|
|
65
|
+
? handleDeleteUserWithProgress(env, userUid, corsHeaders)
|
|
66
|
+
: handleDeleteUser(env, userUid, corsHeaders);
|
|
1013
67
|
default: return new Response('Method not allowed', {
|
|
1014
68
|
status: 405,
|
|
1015
69
|
headers: corsHeaders
|