@striae-org/striae 5.2.0 → 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 +36 -33
- 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 +10 -17
- 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 -336
- 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
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
import { decryptJsonFromStorage, type DataAtRestEnvelope } from '../encryption-utils';
|
|
2
|
+
import { deleteFirebaseAuthUser } from '../firebase/admin';
|
|
3
|
+
import { readUserRecord } from '../storage/user-records';
|
|
4
|
+
import type {
|
|
5
|
+
AccountDeletionProgressEvent,
|
|
6
|
+
Env,
|
|
7
|
+
KeyRegistryPayload,
|
|
8
|
+
PrivateKeyRegistry,
|
|
9
|
+
StoredCaseData
|
|
10
|
+
} from '../types';
|
|
11
|
+
|
|
12
|
+
function getNonEmptyString(value: unknown): string | null {
|
|
13
|
+
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function normalizePrivateKeyPem(rawValue: string): string {
|
|
17
|
+
return rawValue.trim().replace(/^['"]|['"]$/g, '').replace(/\\n/g, '\n');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function parseDataAtRestPrivateKeyRegistry(env: Env): PrivateKeyRegistry {
|
|
21
|
+
const keys: Record<string, string> = {};
|
|
22
|
+
const configuredActiveKeyId = getNonEmptyString(env.DATA_AT_REST_ENCRYPTION_ACTIVE_KEY_ID);
|
|
23
|
+
const registryJson = getNonEmptyString(env.DATA_AT_REST_ENCRYPTION_KEYS_JSON);
|
|
24
|
+
|
|
25
|
+
if (registryJson) {
|
|
26
|
+
let parsedRegistry: unknown;
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
parsedRegistry = JSON.parse(registryJson) as unknown;
|
|
30
|
+
} catch {
|
|
31
|
+
throw new Error('DATA_AT_REST_ENCRYPTION_KEYS_JSON is not valid JSON');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (!parsedRegistry || typeof parsedRegistry !== 'object') {
|
|
35
|
+
throw new Error('DATA_AT_REST_ENCRYPTION_KEYS_JSON must be an object');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const payload = parsedRegistry as KeyRegistryPayload;
|
|
39
|
+
const payloadActiveKeyId = getNonEmptyString(payload.activeKeyId);
|
|
40
|
+
const rawKeys = payload.keys && typeof payload.keys === 'object'
|
|
41
|
+
? payload.keys as Record<string, unknown>
|
|
42
|
+
: parsedRegistry as Record<string, unknown>;
|
|
43
|
+
|
|
44
|
+
for (const [keyId, pemValue] of Object.entries(rawKeys)) {
|
|
45
|
+
if (keyId === 'activeKeyId' || keyId === 'keys') {
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const normalizedKeyId = getNonEmptyString(keyId);
|
|
50
|
+
const normalizedPem = getNonEmptyString(pemValue);
|
|
51
|
+
|
|
52
|
+
if (!normalizedKeyId || !normalizedPem) {
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
keys[normalizedKeyId] = normalizePrivateKeyPem(normalizedPem);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const resolvedActiveKeyId = configuredActiveKeyId ?? payloadActiveKeyId;
|
|
60
|
+
|
|
61
|
+
if (Object.keys(keys).length === 0) {
|
|
62
|
+
throw new Error('DATA_AT_REST_ENCRYPTION_KEYS_JSON does not contain any usable keys');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (resolvedActiveKeyId && !keys[resolvedActiveKeyId]) {
|
|
66
|
+
throw new Error('DATA_AT_REST_ENCRYPTION active key ID is not present in registry');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
activeKeyId: resolvedActiveKeyId ?? null,
|
|
71
|
+
keys
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const legacyKeyId = getNonEmptyString(env.DATA_AT_REST_ENCRYPTION_KEY_ID);
|
|
76
|
+
const legacyPrivateKey = getNonEmptyString(env.DATA_AT_REST_ENCRYPTION_PRIVATE_KEY);
|
|
77
|
+
|
|
78
|
+
if (!legacyKeyId || !legacyPrivateKey) {
|
|
79
|
+
throw new Error('Data-at-rest decryption key registry is not configured');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
keys[legacyKeyId] = normalizePrivateKeyPem(legacyPrivateKey);
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
activeKeyId: configuredActiveKeyId ?? legacyKeyId,
|
|
86
|
+
keys
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function buildPrivateKeyCandidates(
|
|
91
|
+
recordKeyId: string | null,
|
|
92
|
+
registry: PrivateKeyRegistry
|
|
93
|
+
): Array<{ keyId: string; privateKeyPem: string }> {
|
|
94
|
+
const candidates: Array<{ keyId: string; privateKeyPem: string }> = [];
|
|
95
|
+
const seen = new Set<string>();
|
|
96
|
+
|
|
97
|
+
const appendCandidate = (candidateKeyId: string | null): void => {
|
|
98
|
+
if (!candidateKeyId || seen.has(candidateKeyId)) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const privateKeyPem = registry.keys[candidateKeyId];
|
|
103
|
+
if (!privateKeyPem) {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
seen.add(candidateKeyId);
|
|
108
|
+
candidates.push({ keyId: candidateKeyId, privateKeyPem });
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
appendCandidate(recordKeyId);
|
|
112
|
+
appendCandidate(registry.activeKeyId);
|
|
113
|
+
|
|
114
|
+
for (const keyId of Object.keys(registry.keys)) {
|
|
115
|
+
appendCandidate(keyId);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return candidates;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function extractDataAtRestEnvelope(file: R2ObjectBody): DataAtRestEnvelope | null {
|
|
122
|
+
const metadata = file.customMetadata;
|
|
123
|
+
|
|
124
|
+
if (!metadata) {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const algorithm = getNonEmptyString(metadata.algorithm);
|
|
129
|
+
const encryptionVersion = getNonEmptyString(metadata.encryptionVersion);
|
|
130
|
+
const keyId = getNonEmptyString(metadata.keyId);
|
|
131
|
+
const dataIv = getNonEmptyString(metadata.dataIv);
|
|
132
|
+
const wrappedKey = getNonEmptyString(metadata.wrappedKey);
|
|
133
|
+
|
|
134
|
+
if (!algorithm || !encryptionVersion || !keyId || !dataIv || !wrappedKey) {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
algorithm,
|
|
140
|
+
encryptionVersion,
|
|
141
|
+
keyId,
|
|
142
|
+
dataIv,
|
|
143
|
+
wrappedKey
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function decryptCaseDataWithRegistry(
|
|
148
|
+
ciphertext: ArrayBuffer,
|
|
149
|
+
envelope: DataAtRestEnvelope,
|
|
150
|
+
env: Env
|
|
151
|
+
): Promise<string> {
|
|
152
|
+
const keyRegistry = parseDataAtRestPrivateKeyRegistry(env);
|
|
153
|
+
const candidates = buildPrivateKeyCandidates(getNonEmptyString(envelope.keyId), keyRegistry);
|
|
154
|
+
let lastError: unknown;
|
|
155
|
+
|
|
156
|
+
for (const candidate of candidates) {
|
|
157
|
+
try {
|
|
158
|
+
return await decryptJsonFromStorage(ciphertext, envelope, candidate.privateKeyPem);
|
|
159
|
+
} catch (error) {
|
|
160
|
+
lastError = error;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
throw new Error(
|
|
165
|
+
`Failed to decrypt case data after ${candidates.length} key attempt(s): ${
|
|
166
|
+
lastError instanceof Error ? lastError.message : 'unknown decryption error'
|
|
167
|
+
}`
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function extractFileIdsFromCaseData(caseData: StoredCaseData): string[] {
|
|
172
|
+
if (!Array.isArray(caseData.files)) {
|
|
173
|
+
return [];
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return caseData.files
|
|
177
|
+
.map((file) => getNonEmptyString(file?.id))
|
|
178
|
+
.filter((fileId): fileId is string => fileId !== null);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function readCaseFileIds(env: Env, caseDataKey: string): Promise<string[]> {
|
|
182
|
+
const file = await env.STRIAE_DATA.get(caseDataKey);
|
|
183
|
+
if (!file) {
|
|
184
|
+
return [];
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const atRestEnvelope = extractDataAtRestEnvelope(file);
|
|
188
|
+
const fileText = atRestEnvelope
|
|
189
|
+
? await decryptCaseDataWithRegistry(await file.arrayBuffer(), atRestEnvelope, env)
|
|
190
|
+
: await file.text();
|
|
191
|
+
|
|
192
|
+
const parsed = JSON.parse(fileText) as StoredCaseData;
|
|
193
|
+
return extractFileIdsFromCaseData(parsed);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function deleteSingleCase(env: Env, userUid: string, caseNumber: string): Promise<void> {
|
|
197
|
+
const encodedUserId = encodeURIComponent(userUid);
|
|
198
|
+
const encodedCaseNumber = encodeURIComponent(caseNumber);
|
|
199
|
+
const casePrefix = `${encodedUserId}/${encodedCaseNumber}/`;
|
|
200
|
+
const caseDataKey = `${casePrefix}data.json`;
|
|
201
|
+
const deletionErrors: string[] = [];
|
|
202
|
+
const dataKeys: string[] = [];
|
|
203
|
+
const fileIds = new Set<string>();
|
|
204
|
+
let dataCursor: string | undefined;
|
|
205
|
+
|
|
206
|
+
do {
|
|
207
|
+
const listed = await env.STRIAE_DATA.list({ prefix: casePrefix, cursor: dataCursor, limit: 1000 });
|
|
208
|
+
|
|
209
|
+
for (const obj of listed.objects) {
|
|
210
|
+
dataKeys.push(obj.key);
|
|
211
|
+
|
|
212
|
+
const segments = obj.key.split('/');
|
|
213
|
+
if (segments.length === 4 && segments[3] === 'data.json') {
|
|
214
|
+
try {
|
|
215
|
+
fileIds.add(decodeURIComponent(segments[2]));
|
|
216
|
+
} catch {
|
|
217
|
+
fileIds.add(segments[2]);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
dataCursor = listed.truncated ? listed.cursor : undefined;
|
|
223
|
+
} while (dataCursor !== undefined);
|
|
224
|
+
|
|
225
|
+
if (dataKeys.includes(caseDataKey)) {
|
|
226
|
+
try {
|
|
227
|
+
for (const fileId of await readCaseFileIds(env, caseDataKey)) {
|
|
228
|
+
fileIds.add(fileId);
|
|
229
|
+
}
|
|
230
|
+
} catch (error) {
|
|
231
|
+
const message = error instanceof Error ? error.message : 'unknown case data read error';
|
|
232
|
+
throw new Error(`Failed to read case file references for ${caseNumber}: ${message}`);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
for (const fileId of fileIds) {
|
|
237
|
+
try {
|
|
238
|
+
await env.STRIAE_FILES.delete(fileId);
|
|
239
|
+
} catch (error) {
|
|
240
|
+
const message = error instanceof Error ? error.message : 'unknown file delete error';
|
|
241
|
+
deletionErrors.push(`file ${fileId} delete threw (${message})`);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (dataKeys.length > 0) {
|
|
246
|
+
try {
|
|
247
|
+
await env.STRIAE_DATA.delete(dataKeys);
|
|
248
|
+
} catch (error) {
|
|
249
|
+
const message = error instanceof Error ? error.message : 'unknown data delete error';
|
|
250
|
+
deletionErrors.push(`case data delete threw (${message})`);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (deletionErrors.length > 0) {
|
|
255
|
+
throw new Error(`Case cleanup incomplete for ${caseNumber}: ${deletionErrors.join('; ')}`);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async function deleteUserConfirmationSummary(env: Env, userUid: string): Promise<void> {
|
|
260
|
+
const encodedUserId = encodeURIComponent(userUid);
|
|
261
|
+
const key = `${encodedUserId}/meta/confirmation-status.json`;
|
|
262
|
+
|
|
263
|
+
try {
|
|
264
|
+
await env.STRIAE_DATA.delete(key);
|
|
265
|
+
} catch (error) {
|
|
266
|
+
throw new Error(`Failed to delete confirmation summary metadata: ${error instanceof Error ? error.message : 'unknown error'}`);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export async function executeUserDeletion(
|
|
271
|
+
env: Env,
|
|
272
|
+
userUid: string,
|
|
273
|
+
reportProgress?: (progress: AccountDeletionProgressEvent) => void
|
|
274
|
+
): Promise<{ success: boolean; message: string; totalCases: number; completedCases: number }> {
|
|
275
|
+
const userData = await readUserRecord(env, userUid);
|
|
276
|
+
if (userData === null) {
|
|
277
|
+
throw new Error('User not found');
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const ownedCases = (userData.cases || []).map((caseItem) => caseItem.caseNumber);
|
|
281
|
+
const readOnlyCases = (userData.readOnlyCases || []).map((caseItem) => caseItem.caseNumber);
|
|
282
|
+
const allCaseNumbers = Array.from(new Set([...ownedCases, ...readOnlyCases]));
|
|
283
|
+
const totalCases = allCaseNumbers.length;
|
|
284
|
+
let completedCases = 0;
|
|
285
|
+
const caseCleanupErrors: string[] = [];
|
|
286
|
+
|
|
287
|
+
reportProgress?.({
|
|
288
|
+
event: 'start',
|
|
289
|
+
totalCases,
|
|
290
|
+
completedCases
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
for (const caseNumber of allCaseNumbers) {
|
|
294
|
+
reportProgress?.({
|
|
295
|
+
event: 'case-start',
|
|
296
|
+
totalCases,
|
|
297
|
+
completedCases,
|
|
298
|
+
currentCaseNumber: caseNumber
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
let caseDeletionError: string | null = null;
|
|
302
|
+
try {
|
|
303
|
+
await deleteSingleCase(env, userUid, caseNumber);
|
|
304
|
+
} catch (error) {
|
|
305
|
+
caseDeletionError = error instanceof Error ? error.message : `Case cleanup failed for ${caseNumber}`;
|
|
306
|
+
caseCleanupErrors.push(caseDeletionError);
|
|
307
|
+
console.error(`Case cleanup error for ${caseNumber}:`, error);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
completedCases += 1;
|
|
311
|
+
|
|
312
|
+
reportProgress?.({
|
|
313
|
+
event: 'case-complete',
|
|
314
|
+
totalCases,
|
|
315
|
+
completedCases,
|
|
316
|
+
currentCaseNumber: caseNumber,
|
|
317
|
+
success: caseDeletionError === null,
|
|
318
|
+
message: caseDeletionError || undefined
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (caseCleanupErrors.length > 0) {
|
|
323
|
+
throw new Error(`Failed to fully delete all case data: ${caseCleanupErrors.join(' | ')}`);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
await deleteUserConfirmationSummary(env, userUid);
|
|
327
|
+
|
|
328
|
+
await deleteFirebaseAuthUser(env, userUid);
|
|
329
|
+
await env.USER_DB.delete(userUid);
|
|
330
|
+
|
|
331
|
+
return {
|
|
332
|
+
success: true,
|
|
333
|
+
message: 'Account successfully deleted',
|
|
334
|
+
totalCases,
|
|
335
|
+
completedCases
|
|
336
|
+
};
|
|
337
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export const USER_CASES_SEGMENT = 'cases';
|
|
2
|
+
export const GOOGLE_OAUTH_TOKEN_URL = 'https://oauth2.googleapis.com/token';
|
|
3
|
+
export const FIREBASE_IDENTITY_TOOLKIT_BASE_URL = 'https://identitytoolkit.googleapis.com/v1/projects';
|
|
4
|
+
export const GOOGLE_IDENTITY_TOOLKIT_SCOPE = 'https://www.googleapis.com/auth/identitytoolkit';
|
|
@@ -7,6 +7,14 @@ export interface UserKvEncryptedRecord {
|
|
|
7
7
|
ciphertext: string;
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
+
export interface DataAtRestEnvelope {
|
|
11
|
+
algorithm: string;
|
|
12
|
+
encryptionVersion: string;
|
|
13
|
+
keyId: string;
|
|
14
|
+
dataIv: string;
|
|
15
|
+
wrappedKey: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
10
18
|
const USER_KV_ENCRYPTION_ALGORITHM = 'RSA-OAEP-AES-256-GCM';
|
|
11
19
|
const USER_KV_ENCRYPTION_VERSION = '1.0';
|
|
12
20
|
|
|
@@ -242,3 +250,20 @@ export async function decryptJsonFromUserKv(
|
|
|
242
250
|
|
|
243
251
|
return new TextDecoder().decode(plaintext);
|
|
244
252
|
}
|
|
253
|
+
|
|
254
|
+
export async function decryptJsonFromStorage(
|
|
255
|
+
ciphertext: ArrayBuffer,
|
|
256
|
+
envelope: DataAtRestEnvelope,
|
|
257
|
+
privateKeyPem: string
|
|
258
|
+
): Promise<string> {
|
|
259
|
+
const aesKey = await unwrapAesKey(envelope.wrappedKey, privateKeyPem);
|
|
260
|
+
const iv = base64UrlDecode(envelope.dataIv);
|
|
261
|
+
|
|
262
|
+
const plaintext = await crypto.subtle.decrypt(
|
|
263
|
+
{ name: 'AES-GCM', iv: iv as BufferSource },
|
|
264
|
+
aesKey,
|
|
265
|
+
ciphertext as BufferSource
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
return new TextDecoder().decode(plaintext);
|
|
269
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import {
|
|
2
|
+
FIREBASE_IDENTITY_TOOLKIT_BASE_URL,
|
|
3
|
+
GOOGLE_IDENTITY_TOOLKIT_SCOPE,
|
|
4
|
+
GOOGLE_OAUTH_TOKEN_URL
|
|
5
|
+
} from '../config';
|
|
6
|
+
import type {
|
|
7
|
+
Env,
|
|
8
|
+
FirebaseDeleteAccountErrorResponse,
|
|
9
|
+
GoogleOAuthTokenResponse
|
|
10
|
+
} from '../types';
|
|
11
|
+
|
|
12
|
+
const textEncoder = new TextEncoder();
|
|
13
|
+
|
|
14
|
+
function base64UrlEncode(value: string | Uint8Array): string {
|
|
15
|
+
const bytes = typeof value === 'string' ? textEncoder.encode(value) : value;
|
|
16
|
+
let binary = '';
|
|
17
|
+
|
|
18
|
+
for (const byte of bytes) {
|
|
19
|
+
binary += String.fromCharCode(byte);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return btoa(binary)
|
|
23
|
+
.replace(/\+/g, '-')
|
|
24
|
+
.replace(/\//g, '_')
|
|
25
|
+
.replace(/=+$/g, '');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function parsePkcs8PrivateKey(privateKey: string): ArrayBuffer {
|
|
29
|
+
const normalizedKey = privateKey
|
|
30
|
+
.trim()
|
|
31
|
+
.replace(/^['"]|['"]$/g, '')
|
|
32
|
+
.replace(/\\n/g, '\n');
|
|
33
|
+
|
|
34
|
+
const pemBody = normalizedKey
|
|
35
|
+
.replace('-----BEGIN PRIVATE KEY-----', '')
|
|
36
|
+
.replace('-----END PRIVATE KEY-----', '')
|
|
37
|
+
.replace(/\s+/g, '');
|
|
38
|
+
|
|
39
|
+
if (!pemBody) {
|
|
40
|
+
throw new Error('Firebase service account private key is invalid');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const binary = atob(pemBody);
|
|
44
|
+
const bytes = new Uint8Array(binary.length);
|
|
45
|
+
|
|
46
|
+
for (let index = 0; index < binary.length; index += 1) {
|
|
47
|
+
bytes[index] = binary.charCodeAt(index);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return bytes.buffer;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function buildServiceAccountAssertion(env: Env): Promise<string> {
|
|
54
|
+
const issuedAt = Math.floor(Date.now() / 1000);
|
|
55
|
+
const header = {
|
|
56
|
+
alg: 'RS256',
|
|
57
|
+
typ: 'JWT'
|
|
58
|
+
};
|
|
59
|
+
const payload = {
|
|
60
|
+
iss: env.FIREBASE_SERVICE_ACCOUNT_EMAIL,
|
|
61
|
+
scope: GOOGLE_IDENTITY_TOOLKIT_SCOPE,
|
|
62
|
+
aud: GOOGLE_OAUTH_TOKEN_URL,
|
|
63
|
+
iat: issuedAt,
|
|
64
|
+
exp: issuedAt + 3600
|
|
65
|
+
};
|
|
66
|
+
const unsignedToken = `${base64UrlEncode(JSON.stringify(header))}.${base64UrlEncode(JSON.stringify(payload))}`;
|
|
67
|
+
|
|
68
|
+
let signingKey: CryptoKey;
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
signingKey = await crypto.subtle.importKey(
|
|
72
|
+
'pkcs8',
|
|
73
|
+
parsePkcs8PrivateKey(env.FIREBASE_SERVICE_ACCOUNT_PRIVATE_KEY),
|
|
74
|
+
{
|
|
75
|
+
name: 'RSASSA-PKCS1-v1_5',
|
|
76
|
+
hash: 'SHA-256'
|
|
77
|
+
},
|
|
78
|
+
false,
|
|
79
|
+
['sign']
|
|
80
|
+
);
|
|
81
|
+
} catch {
|
|
82
|
+
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.');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const signature = await crypto.subtle.sign(
|
|
86
|
+
{ name: 'RSASSA-PKCS1-v1_5' },
|
|
87
|
+
signingKey,
|
|
88
|
+
textEncoder.encode(unsignedToken)
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
return `${unsignedToken}.${base64UrlEncode(new Uint8Array(signature))}`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function getGoogleAccessToken(env: Env): Promise<string> {
|
|
95
|
+
const assertion = await buildServiceAccountAssertion(env);
|
|
96
|
+
const body = new URLSearchParams({
|
|
97
|
+
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
|
98
|
+
assertion
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const tokenResponse = await fetch(GOOGLE_OAUTH_TOKEN_URL, {
|
|
102
|
+
method: 'POST',
|
|
103
|
+
headers: {
|
|
104
|
+
'Content-Type': 'application/x-www-form-urlencoded'
|
|
105
|
+
},
|
|
106
|
+
body
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const tokenData = await tokenResponse.json().catch(() => ({})) as GoogleOAuthTokenResponse;
|
|
110
|
+
if (!tokenResponse.ok || !tokenData.access_token) {
|
|
111
|
+
const errorReason = tokenData.error_description || tokenData.error || `HTTP ${tokenResponse.status}`;
|
|
112
|
+
throw new Error(`Failed to authorize Firebase admin deletion: ${errorReason}`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return tokenData.access_token;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function deleteFirebaseAuthUser(env: Env, userUid: string): Promise<void> {
|
|
119
|
+
if (!env.PROJECT_ID || !env.FIREBASE_SERVICE_ACCOUNT_EMAIL || !env.FIREBASE_SERVICE_ACCOUNT_PRIVATE_KEY) {
|
|
120
|
+
throw new Error('Firebase Auth deletion is not configured in User Worker secrets');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const accessToken = await getGoogleAccessToken(env);
|
|
124
|
+
const deleteResponse = await fetch(
|
|
125
|
+
`${FIREBASE_IDENTITY_TOOLKIT_BASE_URL}/${encodeURIComponent(env.PROJECT_ID)}/accounts:delete`,
|
|
126
|
+
{
|
|
127
|
+
method: 'POST',
|
|
128
|
+
headers: {
|
|
129
|
+
Authorization: `Bearer ${accessToken}`,
|
|
130
|
+
'Content-Type': 'application/json'
|
|
131
|
+
},
|
|
132
|
+
body: JSON.stringify({ localId: userUid })
|
|
133
|
+
}
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
if (deleteResponse.ok) {
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const deleteErrorPayload = await deleteResponse.json().catch(() => ({})) as FirebaseDeleteAccountErrorResponse;
|
|
141
|
+
const deleteErrorMessage = deleteErrorPayload.error?.message || '';
|
|
142
|
+
|
|
143
|
+
if (deleteErrorMessage.includes('USER_NOT_FOUND')) {
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
throw new Error(
|
|
148
|
+
deleteErrorMessage
|
|
149
|
+
? `Firebase Auth deletion failed: ${deleteErrorMessage}`
|
|
150
|
+
: `Firebase Auth deletion failed with status ${deleteResponse.status}`
|
|
151
|
+
);
|
|
152
|
+
}
|