@striae-org/striae 7.0.1 → 7.1.1

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 (36) hide show
  1. package/.env.example +8 -14
  2. package/functions/api/_shared/lists-client.ts +39 -0
  3. package/functions/api/_shared/registration-allowlist.ts +5 -4
  4. package/functions/api/auth/can-register.ts +7 -2
  5. package/functions/api/pdf/[[path]].ts +4 -1
  6. package/functions/api/user/[[path]].ts +11 -5
  7. package/package.json +14 -11
  8. package/scripts/delete-account.mjs +350 -0
  9. package/scripts/deploy-all.sh +3 -3
  10. package/scripts/deploy-config/modules/prompt.sh +43 -7
  11. package/scripts/deploy-config/modules/scaffolding.sh +19 -0
  12. package/scripts/deploy-config/modules/validation.sh +3 -0
  13. package/scripts/deploy-config.sh +0 -33
  14. package/scripts/deploy-pages-secrets.sh +1 -10
  15. package/scripts/deploy-worker-secrets.sh +19 -1
  16. package/scripts/install-workers.sh +4 -3
  17. package/scripts/update-markdown-versions.cjs +1 -0
  18. package/workers/audit-worker/package.json +2 -2
  19. package/workers/audit-worker/wrangler.jsonc.example +1 -1
  20. package/workers/data-worker/package.json +2 -2
  21. package/workers/data-worker/wrangler.jsonc.example +1 -1
  22. package/workers/image-worker/package.json +2 -2
  23. package/workers/image-worker/wrangler.jsonc.example +1 -1
  24. package/workers/lists-worker/package.json +13 -0
  25. package/workers/lists-worker/src/lists-worker.ts +97 -0
  26. package/workers/lists-worker/src/types.ts +4 -0
  27. package/workers/lists-worker/wrangler.jsonc.example +23 -0
  28. package/workers/pdf-worker/package.json +2 -2
  29. package/workers/pdf-worker/wrangler.jsonc.example +1 -1
  30. package/workers/user-worker/package.json +2 -2
  31. package/workers/user-worker/wrangler.jsonc.example +1 -1
  32. package/wrangler.toml.example +6 -2
  33. package/app/config-example/members.emails +0 -11
  34. package/app/config-example/primershear.emails +0 -6
  35. package/scripts/deploy-members-emails.sh +0 -102
  36. package/scripts/deploy-primershear-emails.sh +0 -101
package/.env.example CHANGED
@@ -110,17 +110,11 @@ PDF_WORKER_NAME=your_pdf_worker_name_here
110
110
  BROWSER_API_TOKEN=your_cloudflare_browser_rendering_api_token_here
111
111
 
112
112
  # ================================
113
- # PRIMERSHEAR PDF FORMAT
114
- # ================================
115
- # Comma-separated list of email addresses that will receive the primershear PDF format.
116
- # Leave empty to disable the feature. Never commit this value to source control.
117
- # Example: PRIMERSHEAR_EMAILS=analyst@org.com,user2@org.com
118
- PRIMERSHEAR_EMAILS=
119
-
120
- # ================================
121
- # REGISTRATION EMAIL ALLOWLIST CONFIGURATION
122
- # ================================
123
- # Comma-separated list of email addresses that may register an account.
124
- # Leave empty to disable the feature. Never commit this value to source control.
125
- # Example: REGISTRATION_EMAILS=analyst@org.com,user2@org.com
126
- REGISTRATION_EMAILS=
113
+ # LISTS WORKER ENVIRONMENT VARIABLES
114
+ # ================================
115
+ # The lists-worker manages registration and PDF format allowlists via KV.
116
+ # STRIAE_LISTS_KV_ID is the KV namespace ID backing both lists.
117
+ # LISTS_ADMIN_SECRET guards write endpoints (POST/DELETE); use a strong random value.
118
+ LISTS_WORKER_NAME=your_lists_worker_name_here
119
+ STRIAE_LISTS_KV_ID=your_striae_lists_kv_id_here
120
+ LISTS_ADMIN_SECRET=your_lists_admin_secret_here
@@ -0,0 +1,39 @@
1
+ export type ListResult =
2
+ | { ok: true; list: string }
3
+ | { ok: false; error: string };
4
+
5
+ /**
6
+ * Client helper for reading email lists from the lists-worker via service binding.
7
+ * Returns a ListResult so callers can apply fail-open or fail-closed logic
8
+ * appropriate to their security context.
9
+ *
10
+ * - ok: true → list fetched successfully (may be empty string if list is empty)
11
+ * - ok: false → worker unreachable, auth failure, or unexpected response shape
12
+ */
13
+ export async function fetchListFromWorker(
14
+ binding: Fetcher,
15
+ list: 'members' | 'primershear',
16
+ secret: string
17
+ ): Promise<ListResult> {
18
+ try {
19
+ const response = await binding.fetch(`https://worker/${list}`, {
20
+ headers: { 'Authorization': `Bearer ${secret}` },
21
+ });
22
+ if (!response.ok) {
23
+ const msg = `lists-client: GET /${list} returned ${response.status}`;
24
+ console.error(msg);
25
+ return { ok: false, error: msg };
26
+ }
27
+ const data = await response.json() as { list?: unknown };
28
+ if (typeof data.list !== 'string') {
29
+ const msg = `lists-client: unexpected response shape for /${list}`;
30
+ console.error(msg);
31
+ return { ok: false, error: msg };
32
+ }
33
+ return { ok: true, list: data.list };
34
+ } catch (err) {
35
+ const msg = `lists-client: failed to fetch /${list}`;
36
+ console.error(msg, err);
37
+ return { ok: false, error: msg };
38
+ }
39
+ }
@@ -1,17 +1,18 @@
1
1
  /**
2
2
  * Checks whether the given email is permitted to register based on the
3
- * REGISTRATION_EMAILS secret (a comma-separated list of allowed entries).
3
+ * registration allowlist (a comma-separated list of allowed entries) sourced
4
+ * from the lists-worker KV (key: "allow").
4
5
  *
5
6
  * Each entry may be:
6
7
  * - An exact email address: user@example.com
7
8
  * - A domain wildcard: @example.com (matches any email from that domain)
8
9
  *
9
- * If registrationEmails is empty or unset, all registrations are allowed
10
- * (backward-compatible deploys without a members.emails file are unrestricted).
10
+ * If registrationEmails is empty or unset, registration is denied (fail closed).
11
+ * An empty list indicates the allowlist has not been populated, not that all are allowed.
11
12
  */
12
13
  export function isEmailAllowed(email: string, registrationEmails: string): boolean {
13
14
  if (!registrationEmails || registrationEmails.trim().length === 0) {
14
- return true;
15
+ return false;
15
16
  }
16
17
 
17
18
  const normalizedEmail = email.toLowerCase().trim();
@@ -1,4 +1,5 @@
1
1
  import { isEmailAllowed } from '../_shared/registration-allowlist';
2
+ import { fetchListFromWorker } from '../_shared/lists-client';
2
3
 
3
4
  interface CanRegisterContext {
4
5
  request: Request;
@@ -49,9 +50,13 @@ export const onRequest = async ({ request, env }: CanRegisterContext): Promise<R
49
50
  return textResponse('Missing required parameter: email', 400);
50
51
  }
51
52
 
52
- const registrationEmails = env.REGISTRATION_EMAILS ?? '';
53
+ const listResult = await fetchListFromWorker(env.LISTS_WORKER, 'members', env.LISTS_ADMIN_SECRET);
54
+ if (!listResult.ok) {
55
+ // Fail closed: cannot verify allowlist, deny to prevent bypass.
56
+ return textResponse('Unable to verify registration eligibility', 503);
57
+ }
53
58
 
54
- if (isEmailAllowed(email, registrationEmails)) {
59
+ if (isEmailAllowed(email, listResult.list)) {
55
60
  return jsonResponse({ allowed: true });
56
61
  }
57
62
 
@@ -1,4 +1,5 @@
1
1
  import { verifyFirebaseIdentityFromRequest } from '../_shared/firebase-auth';
2
+ import { fetchListFromWorker } from '../_shared/lists-client';
2
3
 
3
4
  interface PdfProxyContext {
4
5
  request: Request;
@@ -97,9 +98,11 @@ export const onRequest = async ({ request, env }: PdfProxyContext): Promise<Resp
97
98
 
98
99
  // Resolve the report format server-side based on the verified user email.
99
100
  // This prevents email lists from ever being exposed in the client bundle.
101
+ // Fail-open: if the lists-worker is unavailable, fall back to the default format.
102
+ const primershearResult = await fetchListFromWorker(env.LISTS_WORKER, 'primershear', env.LISTS_ADMIN_SECRET);
100
103
  const reportFormat = resolveReportFormat(
101
104
  identity.email,
102
- env.PRIMERSHEAR_EMAILS ?? ''
105
+ primershearResult.ok ? primershearResult.list : ''
103
106
  );
104
107
 
105
108
  let upstreamBody: BodyInit;
@@ -1,5 +1,6 @@
1
1
  import { verifyFirebaseIdentityFromRequest } from '../_shared/firebase-auth';
2
2
  import { isEmailAllowed } from '../_shared/registration-allowlist';
3
+ import { fetchListFromWorker } from '../_shared/lists-client';
3
4
 
4
5
  interface UserProxyContext {
5
6
  request: Request;
@@ -142,9 +143,14 @@ export const onRequest = async ({ request, env }: UserProxyContext): Promise<Res
142
143
  }
143
144
 
144
145
  // Registration gateway: for PUT requests, check if this is a new user creation.
145
- // If REGISTRATION_EMAILS is set and the user record does not yet exist, enforce the allowlist.
146
+ // Always enforce the allowlist for new users isEmailAllowed fails closed for empty lists.
146
147
  // This is defense-in-depth — the primary check runs client-side in the login flow.
147
- if (request.method === 'PUT' && env.REGISTRATION_EMAILS && env.REGISTRATION_EMAILS.trim().length > 0) {
148
+ if (request.method === 'PUT') {
149
+ const listResult = await fetchListFromWorker(env.LISTS_WORKER, 'members', env.LISTS_ADMIN_SECRET);
150
+ if (!listResult.ok) {
151
+ // Fail closed: cannot verify allowlist, reject to prevent bypass.
152
+ return textResponse('Unable to verify registration eligibility', 503);
153
+ }
148
154
  try {
149
155
  const existenceResponse = await env.USER_WORKER.fetch(
150
156
  `https://worker/${encodeURIComponent(requestedUserId)}`,
@@ -158,8 +164,8 @@ export const onRequest = async ({ request, env }: UserProxyContext): Promise<Res
158
164
 
159
165
  if (existenceResponse.status === 404) {
160
166
  // User does not exist yet — this is a registration PUT.
161
- // Enforce the email allowlist.
162
- if (!isEmailAllowed(identity.email ?? '', env.REGISTRATION_EMAILS)) {
167
+ // Enforce the email allowlist (isEmailAllowed returns false for empty list).
168
+ if (!isEmailAllowed(identity.email ?? '', listResult.list)) {
163
169
  return textResponse('Registration is not permitted for this email address', 403);
164
170
  }
165
171
  } else if (!existenceResponse.ok) {
@@ -169,7 +175,7 @@ export const onRequest = async ({ request, env }: UserProxyContext): Promise<Res
169
175
  }
170
176
  // If user already exists (200), proceed normally.
171
177
  } catch {
172
- // Fail closed: on network error with allowlist active, reject the request.
178
+ // Fail closed: on network error, reject the request.
173
179
  return textResponse('Unable to verify registration eligibility', 502);
174
180
  }
175
181
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@striae-org/striae",
3
- "version": "7.0.1",
3
+ "version": "7.1.1",
4
4
  "private": false,
5
5
  "description": "Striae is a specialized, cloud-native platform designed to streamline forensic firearms identification by providing an intuitive environment for digital comparison image annotation, authenticated confirmations, and automated report generation.",
6
6
  "license": "Apache-2.0",
@@ -82,6 +82,7 @@
82
82
  "test:workers:data": "vitest run --config tests/workers/data/vitest.config.mjs",
83
83
  "test:watch": "vitest --config tests/app/vitest.config.ts",
84
84
  "test:coverage": "vitest run --config tests/app/vitest.config.ts --coverage",
85
+ "delete-account": "node ./scripts/delete-account.mjs",
85
86
  "enable-totp-mfa": "node ./scripts/enable-totp-mfa.mjs",
86
87
  "unenroll-totp-mfa": "node ./scripts/unenroll-totp-mfa.mjs",
87
88
  "update-versions": "node ./scripts/update-markdown-versions.cjs",
@@ -89,16 +90,15 @@
89
90
  "deploy-config": "bash ./scripts/deploy-config.sh",
90
91
  "update-env": "bash ./scripts/deploy-config.sh --update-env",
91
92
  "install-workers": "bash ./scripts/install-workers.sh",
92
- "deploy-workers": "npm run deploy-workers:audit && npm run deploy-workers:data && npm run deploy-workers:image && npm run deploy-workers:pdf && npm run deploy-workers:user",
93
+ "deploy-workers": "npm run deploy-workers:audit && npm run deploy-workers:data && npm run deploy-workers:image && npm run deploy-workers:lists && npm run deploy-workers:pdf && npm run deploy-workers:user",
93
94
  "deploy-workers:secrets": "bash ./scripts/deploy-worker-secrets.sh",
94
95
  "deploy-pages:secrets": "bash ./scripts/deploy-pages-secrets.sh",
95
96
  "deploy-pages": "bash ./scripts/deploy-pages.sh",
96
- "deploy-primershear": "bash ./scripts/deploy-primershear-emails.sh",
97
- "deploy-members": "bash ./scripts/deploy-members-emails.sh",
98
97
  "deploy-workers:audit": "cd workers/audit-worker && npm run deploy",
99
98
  "deploy-workers:data": "cd workers/data-worker && npm run deploy",
100
99
  "deploy-workers:image": "cd workers/image-worker && npm run deploy",
101
100
  "deploy-workers:pdf": "cd workers/pdf-worker && npm run deploy",
101
+ "deploy-workers:lists": "cd workers/lists-worker && npm run deploy",
102
102
  "deploy-workers:user": "cd workers/user-worker && npm run deploy"
103
103
  },
104
104
  "dependencies": {
@@ -112,14 +112,14 @@
112
112
  "react-router": "^7.14.2"
113
113
  },
114
114
  "devDependencies": {
115
- "@cloudflare/vitest-pool-workers": "^0.14.9",
115
+ "@cloudflare/vitest-pool-workers": "^0.15.0",
116
116
  "@react-router/dev": "^7.14.2",
117
117
  "@react-router/fs-routes": "^7.14.2",
118
118
  "@types/qrcode": "^1.5.6",
119
119
  "@types/react": "^19.2.14",
120
120
  "@types/react-dom": "^19.2.3",
121
- "@typescript-eslint/eslint-plugin": "^8.59.0",
122
- "@typescript-eslint/parser": "^8.59.0",
121
+ "@typescript-eslint/eslint-plugin": "^8.59.1",
122
+ "@typescript-eslint/parser": "^8.59.1",
123
123
  "@vitest/coverage-v8": "^4.1.5",
124
124
  "eslint": "^9.39.4",
125
125
  "eslint-import-resolver-typescript": "^4.4.4",
@@ -130,15 +130,18 @@
130
130
  "firebase-admin": "^13.8.0",
131
131
  "modern-normalize": "^3.0.1",
132
132
  "typescript": "^6.0.3",
133
- "vite": "^8.0.9",
133
+ "vite": "^8.0.10",
134
134
  "vitest": "^4.1.5",
135
- "wrangler": "^4.84.1"
135
+ "wrangler": "^4.85.0"
136
136
  },
137
137
  "overrides": {
138
- "@tootallnate/once": "3.0.1"
138
+ "@tootallnate/once": "3.0.1",
139
+ "firebase-admin": {
140
+ "uuid": "^14.0.0"
141
+ }
139
142
  },
140
143
  "engines": {
141
144
  "node": ">=20.19.0"
142
145
  },
143
- "packageManager": "npm@11.12.0"
146
+ "packageManager": "npm@11.13.0"
144
147
  }
@@ -0,0 +1,350 @@
1
+ /**
2
+ * Admin script to permanently delete a user account via the user worker's delete endpoint.
3
+ * Run with: npm run delete-account -- <uid> --confirm [--url <base-url>]
4
+ *
5
+ * The pages.dev URL is derived automatically from PAGES_PROJECT_NAME in .env.
6
+ * Use --url to override (e.g. --url https://your-project.pages.dev).
7
+ * The custom domain blocks automated requests via Cloudflare Bot Fight Mode.
8
+ *
9
+ * Requires:
10
+ * - app/config/admin-service.json (gitignored service account key)
11
+ * - app/config/firebase.ts (gitignored Firebase config, used for apiKey)
12
+ * - .env (PAGES_PROJECT_NAME used to construct the default pages.dev URL)
13
+ *
14
+ * This script creates a short-lived custom token for the target UID, exchanges it for a
15
+ * Firebase ID token, then calls the Pages DELETE /api/user/:uid endpoint which runs the
16
+ * full account deletion routine (KV, R2 files, R2 case data, Firebase Auth).
17
+ */
18
+
19
+ import { createRequire } from 'module';
20
+ import { fileURLToPath } from 'url';
21
+ import { dirname, resolve } from 'path';
22
+ import { readFileSync } from 'fs';
23
+ import { createInterface } from 'readline';
24
+ import { initializeApp, cert, getApps } from 'firebase-admin/app';
25
+ import { getAuth } from 'firebase-admin/auth';
26
+
27
+ const require = createRequire(import.meta.url);
28
+ const __dirname = dirname(fileURLToPath(import.meta.url));
29
+
30
+ // --- Argument parsing ---
31
+
32
+ const USAGE = 'Usage: npm run delete-account -- <uid> --confirm [--url <base-url>]';
33
+
34
+ let uid = null;
35
+ let confirmed = false;
36
+ let urlOverride = null;
37
+
38
+ {
39
+ const args = process.argv.slice(2);
40
+ let i = 0;
41
+ while (i < args.length) {
42
+ const arg = args[i];
43
+ if (arg === '--confirm') {
44
+ confirmed = true;
45
+ i++;
46
+ } else if (arg === '--url') {
47
+ if (i + 1 >= args.length || args[i + 1].startsWith('--')) {
48
+ console.error('\n❌ --url requires a value (e.g. --url https://your-project.pages.dev)');
49
+ console.error(USAGE);
50
+ process.exit(1);
51
+ }
52
+ urlOverride = args[i + 1];
53
+ i += 2;
54
+ } else if (arg.startsWith('--')) {
55
+ console.error(`\n❌ Unknown flag: ${arg}`);
56
+ console.error(USAGE);
57
+ process.exit(1);
58
+ } else if (uid === null) {
59
+ uid = arg;
60
+ i++;
61
+ } else {
62
+ console.error(`\n❌ Unexpected argument: ${arg}`);
63
+ console.error(USAGE);
64
+ process.exit(1);
65
+ }
66
+ }
67
+ }
68
+
69
+ if (!uid) {
70
+ console.error('\n❌ No UID provided.');
71
+ console.error(`\n${USAGE}`);
72
+ console.error('\n --url Override the pages.dev URL (e.g. https://your-project.pages.dev)');
73
+ console.error(' Defaults to https://<PAGES_PROJECT_NAME>.pages.dev from .env');
74
+ process.exit(1);
75
+ }
76
+
77
+ if (!confirmed) {
78
+ console.error('\n❌ Missing --confirm flag.');
79
+ console.error('\nThis operation permanently deletes the account and all associated data.');
80
+ console.error('Re-run with --confirm to proceed:');
81
+ console.error(`\n npm run delete-account -- ${uid} --confirm\n`);
82
+ process.exit(1);
83
+ }
84
+
85
+ // --- Load service account ---
86
+
87
+ const serviceAccountPath = resolve(__dirname, '../app/config/admin-service.json');
88
+
89
+ let serviceAccount;
90
+ try {
91
+ serviceAccount = require(serviceAccountPath);
92
+ } catch {
93
+ console.error(`\n❌ Could not load service account key from:\n ${serviceAccountPath}`);
94
+ console.error('\nMake sure app/config/admin-service.json exists (it is gitignored).');
95
+ process.exit(1);
96
+ }
97
+
98
+ // --- Resolve app URL ---
99
+ // Default: derive from PAGES_PROJECT_NAME in .env → https://<name>.pages.dev
100
+ // Override: --url flag
101
+ // Fallback: interactive prompt
102
+
103
+ let appUrl;
104
+ if (urlOverride) {
105
+ appUrl = urlOverride.replace(/\/+$/, '');
106
+ console.log(`\nℹ️ Using URL: ${appUrl}`);
107
+ } else {
108
+ let pagesProjectName = null;
109
+ const envPath = resolve(__dirname, '../.env');
110
+ try {
111
+ const envContent = readFileSync(envPath, 'utf8');
112
+ const match = envContent.match(/^PAGES_PROJECT_NAME=(.+)$/m);
113
+ if (match && match[1].trim()) {
114
+ pagesProjectName = match[1].trim();
115
+ }
116
+ } catch {
117
+ // .env not found or unreadable — will fall through to prompt
118
+ }
119
+
120
+ if (pagesProjectName) {
121
+ appUrl = `https://${pagesProjectName}.pages.dev`;
122
+ console.log(`\nℹ️ Using derived pages.dev URL: ${appUrl}`);
123
+ } else {
124
+ console.warn('\n⚠️ Could not read PAGES_PROJECT_NAME from .env.');
125
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
126
+ appUrl = await new Promise((res) => {
127
+ rl.question(
128
+ 'Enter the pages.dev URL (e.g. https://<project>.pages.dev): ',
129
+ (answer) => { rl.close(); res(answer.trim().replace(/\/+$/, '')); }
130
+ );
131
+ });
132
+ if (!appUrl) {
133
+ console.error('\n❌ No URL provided. Aborting.');
134
+ process.exit(1);
135
+ }
136
+ }
137
+ }
138
+
139
+ // --- Load Firebase API key from firebase.ts ---
140
+
141
+ const firebaseTsPath = resolve(__dirname, '../app/config/firebase.ts');
142
+ let apiKey;
143
+ try {
144
+ const firebaseTsContent = readFileSync(firebaseTsPath, 'utf8');
145
+ const match = firebaseTsContent.match(/apiKey:\s*["']([^"']+)["']/);
146
+ if (!match) {
147
+ throw new Error('apiKey not found in firebase.ts');
148
+ }
149
+ apiKey = match[1];
150
+ if (apiKey.startsWith('YOUR_')) {
151
+ throw new Error('apiKey is still a placeholder value');
152
+ }
153
+ } catch (err) {
154
+ console.error(`\n❌ Could not read Firebase API key from:\n ${firebaseTsPath}`);
155
+ console.error('\nMake sure app/config/firebase.ts exists and contains a valid apiKey.');
156
+ console.error(err?.message ?? err);
157
+ process.exit(1);
158
+ }
159
+
160
+ // --- Initialize Firebase Admin ---
161
+
162
+ if (getApps().length === 0) {
163
+ initializeApp({ credential: cert(serviceAccount) });
164
+ }
165
+
166
+ const auth = getAuth();
167
+
168
+ // --- Verify user exists ---
169
+
170
+ console.log(`\n🔍 Fetching user record for UID: ${uid}...`);
171
+
172
+ let userRecord;
173
+ try {
174
+ userRecord = await auth.getUser(uid);
175
+ } catch (err) {
176
+ console.error(`\n❌ Could not fetch user record for UID: ${uid}`);
177
+ console.error(err?.message ?? err);
178
+ process.exit(1);
179
+ }
180
+
181
+ console.log(`\n⚠️ About to permanently delete account:`);
182
+ console.log(` UID: ${userRecord.uid}`);
183
+ console.log(` Email: ${userRecord.email ?? '(no email)'}`);
184
+
185
+ // --- Interactive confirmation ---
186
+
187
+ {
188
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
189
+ const answer = await new Promise((res) => {
190
+ rl.question('\nType "DELETE" to confirm permanent deletion, or anything else to abort: ', (a) => {
191
+ rl.close();
192
+ res(a.trim());
193
+ });
194
+ });
195
+ if (answer !== 'DELETE') {
196
+ console.log('\nAborted. No changes were made.');
197
+ process.exit(0);
198
+ }
199
+ }
200
+
201
+ // --- Create custom token and exchange for ID token ---
202
+
203
+ console.log('\n🔑 Obtaining ID token via custom token exchange...');
204
+
205
+ let customToken;
206
+ try {
207
+ customToken = await auth.createCustomToken(uid);
208
+ } catch (err) {
209
+ console.error('\n❌ Failed to create custom token:');
210
+ console.error(err?.message ?? err);
211
+ process.exit(1);
212
+ }
213
+
214
+ let idToken;
215
+ try {
216
+ const signInResponse = await fetch(
217
+ `https://identitytoolkit.googleapis.com/v1/accounts:signInWithCustomToken?key=${apiKey}`,
218
+ {
219
+ method: 'POST',
220
+ headers: { 'Content-Type': 'application/json' },
221
+ body: JSON.stringify({ token: customToken, returnSecureToken: true }),
222
+ }
223
+ );
224
+
225
+ if (!signInResponse.ok) {
226
+ const errorBody = await signInResponse.text();
227
+ throw new Error(`Firebase REST sign-in failed (${signInResponse.status}): ${errorBody}`);
228
+ }
229
+
230
+ const signInData = await signInResponse.json();
231
+ idToken = signInData.idToken;
232
+ if (!idToken) {
233
+ throw new Error('No idToken in sign-in response');
234
+ }
235
+ } catch (err) {
236
+ console.error('\n❌ Failed to exchange custom token for ID token:');
237
+ console.error(err?.message ?? err);
238
+ process.exit(1);
239
+ }
240
+
241
+ // --- Call the delete endpoint with streaming ---
242
+
243
+ const deleteUrl = `${appUrl}/api/user/${encodeURIComponent(uid)}?stream=true`;
244
+ console.log(`\n🗑️ Sending DELETE request to: ${deleteUrl}`);
245
+
246
+ let deleteResponse;
247
+ try {
248
+ deleteResponse = await fetch(deleteUrl, {
249
+ method: 'DELETE',
250
+ headers: {
251
+ 'Authorization': `Bearer ${idToken}`,
252
+ 'Accept': 'text/event-stream',
253
+ },
254
+ });
255
+ } catch (err) {
256
+ console.error('\n❌ Network error during DELETE request:');
257
+ console.error(err?.message ?? err);
258
+ process.exit(1);
259
+ }
260
+
261
+ if (!deleteResponse.ok) {
262
+ const body = await deleteResponse.text();
263
+ console.error(`\n❌ DELETE request failed (${deleteResponse.status}): ${body}`);
264
+ process.exit(1);
265
+ }
266
+
267
+ // --- Parse SSE stream ---
268
+
269
+ const contentType = deleteResponse.headers.get('content-type') ?? '';
270
+ const isStream = contentType.includes('text/event-stream');
271
+
272
+ if (!isStream) {
273
+ // Non-streaming response (fallback)
274
+ const result = await deleteResponse.json();
275
+ if (result.success) {
276
+ console.log('\n✅ Account deleted successfully.');
277
+ } else {
278
+ console.error('\n❌ Deletion reported failure:', result.message ?? result);
279
+ process.exit(1);
280
+ }
281
+ process.exit(0);
282
+ }
283
+
284
+ // Stream processing
285
+ if (!deleteResponse.body) {
286
+ console.error('\n❌ DELETE response has no body/stream. Cannot read SSE output.');
287
+ process.exit(1);
288
+ }
289
+
290
+ const reader = deleteResponse.body.getReader();
291
+ const decoder = new TextDecoder();
292
+ let buffer = '';
293
+ let failed = false;
294
+ let completed = false;
295
+ let caseIndex = 0;
296
+
297
+ console.log('');
298
+
299
+ outer: while (true) {
300
+ const { done, value } = await reader.read();
301
+ if (done) break;
302
+
303
+ buffer += decoder.decode(value, { stream: true });
304
+
305
+ const lines = buffer.split('\n');
306
+ buffer = lines.pop() ?? '';
307
+
308
+ for (const line of lines) {
309
+ if (!line.startsWith('data: ')) continue;
310
+
311
+ let event;
312
+ try {
313
+ event = JSON.parse(line.slice(6));
314
+ } catch {
315
+ continue;
316
+ }
317
+
318
+ switch (event.event) {
319
+ case 'start':
320
+ console.log(` Starting deletion (${event.totalCases ?? 0} case(s) to remove)...`);
321
+ break;
322
+ case 'case-start':
323
+ caseIndex++;
324
+ console.log(` [${caseIndex}/${event.totalCases}] Deleting case ${event.currentCaseNumber}...`);
325
+ break;
326
+ case 'case-complete':
327
+ if (event.success === false) {
328
+ console.error(` [${event.completedCases}/${event.totalCases}] Case cleanup failed: ${event.message ?? 'Unknown error'}`);
329
+ } else {
330
+ console.log(` [${event.completedCases}/${event.totalCases}] Case deleted.`);
331
+ }
332
+ break;
333
+ case 'complete':
334
+ completed = true;
335
+ console.log('\n✅ Account deleted successfully.');
336
+ break outer;
337
+ case 'error':
338
+ console.error(`\n❌ Deletion failed: ${event.message ?? 'Unknown error'}`);
339
+ failed = true;
340
+ break outer;
341
+ }
342
+ }
343
+ }
344
+
345
+ if (!completed && !failed) {
346
+ console.error('\n❌ SSE stream ended without a completion event (possible network cut or worker crash).');
347
+ process.exit(1);
348
+ }
349
+
350
+ process.exit(failed ? 1 : 0);
@@ -110,7 +110,7 @@ if ! npx wrangler types; then
110
110
  echo -e "${RED}❌ Root wrangler types generation failed!${NC}"
111
111
  exit 1
112
112
  fi
113
- for WORKER in audit-worker data-worker image-worker pdf-worker user-worker; do
113
+ for WORKER in audit-worker data-worker image-worker lists-worker pdf-worker user-worker; do
114
114
  echo -e "${YELLOW} → Generating types for ${WORKER}...${NC}"
115
115
  if ! (cd "workers/$WORKER" && npx wrangler types); then
116
116
  echo -e "${RED}❌ wrangler types failed for ${WORKER}!${NC}"
@@ -123,7 +123,7 @@ echo ""
123
123
  # Step 4: Deploy Workers
124
124
  echo -e "${PURPLE}Step 4/7: Deploying Workers${NC}"
125
125
  echo "----------------------------"
126
- echo -e "${YELLOW}🔧 Deploying all 5 Cloudflare Workers...${NC}"
126
+ echo -e "${YELLOW}🔧 Deploying all 6 Cloudflare Workers...${NC}"
127
127
  if ! npm run deploy-workers; then
128
128
  echo -e "${RED}❌ Worker deployment failed!${NC}"
129
129
  exit 1
@@ -172,7 +172,7 @@ echo ""
172
172
  echo -e "${BLUE}Deployed Components:${NC}"
173
173
  echo " ✅ Worker dependencies (npm install)"
174
174
  echo " ✅ Wrangler types (root + all workers)"
175
- echo " ✅ 5 Cloudflare Workers"
175
+ echo " ✅ 6 Cloudflare Workers"
176
176
  echo " ✅ Worker environment variables"
177
177
  echo " ✅ Pages environment variables"
178
178
  echo " ✅ Cloudflare Pages frontend"