@striae-org/striae 7.1.0 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@striae-org/striae",
3
- "version": "7.1.0",
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",
@@ -117,8 +118,8 @@
117
118
  "@types/qrcode": "^1.5.6",
118
119
  "@types/react": "^19.2.14",
119
120
  "@types/react-dom": "^19.2.3",
120
- "@typescript-eslint/eslint-plugin": "^8.59.0",
121
- "@typescript-eslint/parser": "^8.59.0",
121
+ "@typescript-eslint/eslint-plugin": "^8.59.1",
122
+ "@typescript-eslint/parser": "^8.59.1",
122
123
  "@vitest/coverage-v8": "^4.1.5",
123
124
  "eslint": "^9.39.4",
124
125
  "eslint-import-resolver-typescript": "^4.4.4",
@@ -142,5 +143,5 @@
142
143
  "engines": {
143
144
  "node": ">=20.19.0"
144
145
  },
145
- "packageManager": "npm@11.12.0"
146
+ "packageManager": "npm@11.13.0"
146
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);
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "audit-worker",
3
- "version": "7.1.0",
3
+ "version": "7.1.1",
4
4
  "private": true,
5
5
  "scripts": {
6
6
  "deploy": "wrangler deploy",
@@ -3,7 +3,7 @@
3
3
  "account_id": "ACCOUNT_ID",
4
4
  "main": "src/audit-worker.ts",
5
5
  "workers_dev": false,
6
- "compatibility_date": "2026-04-25",
6
+ "compatibility_date": "2026-04-27",
7
7
  "compatibility_flags": [
8
8
  "nodejs_compat"
9
9
  ],
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "data-worker",
3
- "version": "7.1.0",
3
+ "version": "7.1.1",
4
4
  "private": true,
5
5
  "scripts": {
6
6
  "deploy": "wrangler deploy",
@@ -3,7 +3,7 @@
3
3
  "account_id": "ACCOUNT_ID",
4
4
  "main": "src/data-worker.ts",
5
5
  "workers_dev": false,
6
- "compatibility_date": "2026-04-25",
6
+ "compatibility_date": "2026-04-27",
7
7
  "compatibility_flags": [
8
8
  "nodejs_compat"
9
9
  ],
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "image-worker",
3
- "version": "7.1.0",
3
+ "version": "7.1.1",
4
4
  "private": true,
5
5
  "scripts": {
6
6
  "deploy": "wrangler deploy",
@@ -3,7 +3,7 @@
3
3
  "account_id": "ACCOUNT_ID",
4
4
  "main": "src/image-worker.ts",
5
5
  "workers_dev": false,
6
- "compatibility_date": "2026-04-25",
6
+ "compatibility_date": "2026-04-27",
7
7
  "compatibility_flags": [
8
8
  "nodejs_compat"
9
9
  ],
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lists-worker",
3
- "version": "7.1.0",
3
+ "version": "7.1.1",
4
4
  "private": true,
5
5
  "scripts": {
6
6
  "deploy": "wrangler deploy",
@@ -3,7 +3,7 @@
3
3
  "account_id": "ACCOUNT_ID",
4
4
  "main": "src/lists-worker.ts",
5
5
  "workers_dev": false,
6
- "compatibility_date": "2026-04-25",
6
+ "compatibility_date": "2026-04-27",
7
7
  "compatibility_flags": [
8
8
  "nodejs_compat"
9
9
  ],
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pdf-worker",
3
- "version": "7.1.0",
3
+ "version": "7.1.1",
4
4
  "private": true,
5
5
  "scripts": {
6
6
  "generate:assets": "node scripts/generate-assets.js",
@@ -3,7 +3,7 @@
3
3
  "account_id": "ACCOUNT_ID",
4
4
  "main": "src/pdf-worker.ts",
5
5
  "workers_dev": false,
6
- "compatibility_date": "2026-04-25",
6
+ "compatibility_date": "2026-04-27",
7
7
  "compatibility_flags": [
8
8
  "nodejs_compat"
9
9
  ],
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "user-worker",
3
- "version": "7.1.0",
3
+ "version": "7.1.1",
4
4
  "private": true,
5
5
  "scripts": {
6
6
  "deploy": "wrangler deploy",
@@ -3,7 +3,7 @@
3
3
  "account_id": "ACCOUNT_ID",
4
4
  "main": "src/user-worker.ts",
5
5
  "workers_dev": false,
6
- "compatibility_date": "2026-04-25",
6
+ "compatibility_date": "2026-04-27",
7
7
  "compatibility_flags": [
8
8
  "nodejs_compat"
9
9
  ],
@@ -1,6 +1,6 @@
1
1
  #:schema node_modules/wrangler/config-schema.json
2
2
  name = "PAGES_PROJECT_NAME"
3
- compatibility_date = "2026-04-25"
3
+ compatibility_date = "2026-04-27"
4
4
  compatibility_flags = ["nodejs_compat"]
5
5
  pages_build_output_dir = "./build/client"
6
6