@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.
Files changed (105) hide show
  1. package/.env.example +2 -10
  2. package/README.md +5 -46
  3. package/app/components/actions/case-export/core-export.ts +2 -174
  4. package/app/components/actions/case-export/download-handlers.ts +83 -750
  5. package/app/components/actions/case-export/index.ts +6 -30
  6. package/app/components/actions/case-export/metadata-helpers.ts +0 -78
  7. package/app/components/actions/case-export/types-constants.ts +0 -43
  8. package/app/components/actions/case-import/confirmation-import.ts +13 -14
  9. package/app/components/actions/case-import/zip-processing.ts +92 -12
  10. package/app/components/actions/generate-pdf.ts +3 -2
  11. package/app/components/audit/user-audit-viewer.tsx +0 -19
  12. package/app/components/audit/viewer/audit-viewer-header.tsx +0 -33
  13. package/app/components/navbar/case-modals/archive-case-modal.tsx +1 -1
  14. package/app/components/navbar/navbar.tsx +1 -1
  15. package/app/components/sidebar/case-import/case-import.module.css +35 -0
  16. package/app/components/sidebar/case-import/components/CasePreviewSection.tsx +59 -3
  17. package/app/components/sidebar/case-import/components/ConfirmationDialog.tsx +2 -4
  18. package/app/components/sidebar/case-import/components/ConfirmationPreviewSection.tsx +1 -1
  19. package/app/components/sidebar/notes/class-details-shared.ts +2 -2
  20. package/app/components/toast/toast.module.css +36 -0
  21. package/app/components/toast/toast.tsx +6 -2
  22. package/app/components/user/manage-profile.tsx +4 -3
  23. package/app/config-example/config.json +1 -2
  24. package/app/root.tsx +0 -7
  25. package/app/routes/_index.tsx +1 -1
  26. package/app/routes/auth/login.example.tsx +22 -103
  27. package/app/routes/auth/route.ts +1 -1
  28. package/app/routes/striae/striae.tsx +53 -59
  29. package/app/services/firebase/index.ts +0 -3
  30. package/app/types/export.ts +1 -2
  31. package/app/utils/auth/index.ts +0 -1
  32. package/app/utils/data/permissions.ts +3 -2
  33. package/package.json +9 -16
  34. package/public/_headers +0 -4
  35. package/public/_routes.json +0 -1
  36. package/worker-configuration.d.ts +20 -17
  37. package/workers/audit-worker/src/audit-worker.example.ts +9 -806
  38. package/workers/audit-worker/src/config.ts +7 -0
  39. package/workers/audit-worker/src/crypto/data-at-rest.ts +410 -0
  40. package/workers/audit-worker/src/handlers/audit-routes.ts +125 -0
  41. package/workers/audit-worker/src/storage/audit-storage.ts +99 -0
  42. package/workers/audit-worker/src/types.ts +56 -0
  43. package/workers/audit-worker/worker-configuration.d.ts +1 -1
  44. package/workers/audit-worker/wrangler.jsonc.example +1 -1
  45. package/workers/data-worker/src/config.ts +11 -0
  46. package/workers/data-worker/src/data-worker.example.ts +21 -942
  47. package/workers/data-worker/src/handlers/decrypt-export.ts +118 -0
  48. package/workers/data-worker/src/handlers/signing.ts +174 -0
  49. package/workers/data-worker/src/handlers/storage-routes.ts +129 -0
  50. package/workers/data-worker/src/registry/key-registry.ts +368 -0
  51. package/workers/data-worker/src/types.ts +46 -0
  52. package/workers/data-worker/worker-configuration.d.ts +1 -1
  53. package/workers/data-worker/wrangler.jsonc.example +1 -1
  54. package/workers/image-worker/worker-configuration.d.ts +1 -1
  55. package/workers/image-worker/wrangler.jsonc.example +1 -1
  56. package/workers/pdf-worker/worker-configuration.d.ts +2 -3
  57. package/workers/pdf-worker/wrangler.jsonc.example +1 -1
  58. package/workers/user-worker/src/auth.ts +30 -0
  59. package/workers/user-worker/src/cleanup/account-deletion.ts +337 -0
  60. package/workers/user-worker/src/config.ts +4 -0
  61. package/workers/user-worker/src/encryption-utils.ts +25 -0
  62. package/workers/user-worker/src/firebase/admin.ts +152 -0
  63. package/workers/user-worker/src/handlers/user-routes.ts +242 -0
  64. package/workers/user-worker/src/registry/user-kv.ts +172 -0
  65. package/workers/user-worker/src/storage/user-records.ts +34 -0
  66. package/workers/user-worker/src/types.ts +106 -0
  67. package/workers/user-worker/src/user-worker.example.ts +18 -964
  68. package/workers/user-worker/worker-configuration.d.ts +4 -2
  69. package/workers/user-worker/wrangler.jsonc.example +12 -1
  70. package/wrangler.toml.example +1 -1
  71. package/app/components/actions/case-export/data-processing.ts +0 -223
  72. package/app/components/sidebar/case-export/case-export.module.css +0 -418
  73. package/app/components/sidebar/case-export/case-export.tsx +0 -310
  74. package/app/types/exceljs-bare.d.ts +0 -9
  75. package/app/utils/auth/auth.ts +0 -11
  76. package/public/.well-known/security.txt +0 -6
  77. package/public/favicon.ico +0 -0
  78. package/public/icon-256.png +0 -0
  79. package/public/icon-512.png +0 -0
  80. package/public/manifest.json +0 -39
  81. package/public/shortcut.png +0 -0
  82. package/public/social-image.png +0 -0
  83. package/public/vendor/exceljs.LICENSE +0 -22
  84. package/public/vendor/exceljs.bare.min.js +0 -45
  85. package/scripts/deploy-all.sh +0 -166
  86. package/scripts/deploy-config/modules/env-utils.sh +0 -322
  87. package/scripts/deploy-config/modules/keys.sh +0 -404
  88. package/scripts/deploy-config/modules/prompt.sh +0 -372
  89. package/scripts/deploy-config/modules/scaffolding.sh +0 -344
  90. package/scripts/deploy-config/modules/validation.sh +0 -365
  91. package/scripts/deploy-config.sh +0 -236
  92. package/scripts/deploy-pages-secrets.sh +0 -231
  93. package/scripts/deploy-pages.sh +0 -34
  94. package/scripts/deploy-primershear-emails.sh +0 -167
  95. package/scripts/deploy-worker-secrets.sh +0 -374
  96. package/scripts/dev.cjs +0 -23
  97. package/scripts/install-workers.sh +0 -88
  98. package/scripts/run-eslint.cjs +0 -43
  99. package/scripts/update-compatibility-dates.cjs +0 -124
  100. package/scripts/update-markdown-versions.cjs +0 -43
  101. package/workers/keys-worker/package.json +0 -18
  102. package/workers/keys-worker/src/keys.example.ts +0 -67
  103. package/workers/keys-worker/src/keys.ts +0 -67
  104. package/workers/keys-worker/worker-configuration.d.ts +0 -7447
  105. 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
+ }