@pacamelo/core 1.0.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/dist/index.mjs ADDED
@@ -0,0 +1,4966 @@
1
+ // src/store/usePurgeStore.ts
2
+ import { create } from "zustand";
3
+ var getSensitivityThreshold = (sensitivity) => {
4
+ switch (sensitivity) {
5
+ case "low":
6
+ return 0.9;
7
+ case "medium":
8
+ return 0.7;
9
+ case "high":
10
+ return 0.5;
11
+ }
12
+ };
13
+ var defaultConfig = {
14
+ categories: {
15
+ person_name: true,
16
+ email: true,
17
+ phone: true,
18
+ address: true,
19
+ ssn: true,
20
+ credit_card: true,
21
+ ip_address: false,
22
+ date_of_birth: true,
23
+ custom: false
24
+ },
25
+ redactionStyle: "replacement",
26
+ replacementText: "[REDACTED]",
27
+ customPatterns: [],
28
+ sensitivity: "medium"
29
+ };
30
+ var defaultAdversarialConfig = {
31
+ enabled: true,
32
+ riskThreshold: 30,
33
+ maxIterations: 3,
34
+ autoApplyLowRisk: false,
35
+ analysisDepth: "standard",
36
+ enabledAnalyses: {
37
+ attributeLeakage: true,
38
+ semanticFingerprinting: true,
39
+ crossReferenceCheck: true
40
+ }
41
+ };
42
+ var initialState = {
43
+ state: "idle",
44
+ queuedFiles: [],
45
+ processedFiles: [],
46
+ detections: [],
47
+ selectedDetections: /* @__PURE__ */ new Set(),
48
+ config: defaultConfig,
49
+ progress: null,
50
+ // Column selection
51
+ columnConfig: null,
52
+ spreadsheetMetadata: null,
53
+ columnAccessProof: null,
54
+ // Adversarial verification
55
+ adversarialResult: null,
56
+ adversarialConfig: defaultAdversarialConfig,
57
+ isAnalyzingAdversarial: false,
58
+ // Privacy mode
59
+ paranoidMode: false
60
+ };
61
+ var usePurgeStore = create()((set) => ({
62
+ ...initialState,
63
+ // State management
64
+ setState: (state) => set({ state }),
65
+ // File management
66
+ feedDocuments: (files) => set((s) => ({
67
+ queuedFiles: [...s.queuedFiles, ...files],
68
+ state: "loaded"
69
+ })),
70
+ removeDocument: (id) => set((s) => {
71
+ const queuedFiles = s.queuedFiles.filter((f) => f.id !== id);
72
+ return {
73
+ queuedFiles,
74
+ state: queuedFiles.length === 0 ? "idle" : s.state
75
+ };
76
+ }),
77
+ updateFileStatus: (id, status, error) => set((s) => ({
78
+ queuedFiles: s.queuedFiles.map(
79
+ (f) => f.id === id ? { ...f, status, error } : f
80
+ )
81
+ })),
82
+ addProcessedFile: (file) => set((s) => ({
83
+ processedFiles: [...s.processedFiles, file]
84
+ })),
85
+ clearProcessedFiles: () => set({ processedFiles: [] }),
86
+ // Detection management
87
+ setDetections: (detections) => set({
88
+ detections,
89
+ selectedDetections: new Set(detections.map((d) => d.id))
90
+ }),
91
+ toggleDetection: (id) => set((s) => {
92
+ const selected = new Set(s.selectedDetections);
93
+ if (selected.has(id)) {
94
+ selected.delete(id);
95
+ } else {
96
+ selected.add(id);
97
+ }
98
+ return { selectedDetections: selected };
99
+ }),
100
+ selectAllDetections: (visibleIds) => set((s) => ({
101
+ selectedDetections: new Set(
102
+ visibleIds ?? s.detections.map((d) => d.id)
103
+ )
104
+ })),
105
+ deselectAllDetections: () => set({ selectedDetections: /* @__PURE__ */ new Set() }),
106
+ selectDetectionsByCategory: (category, selected) => set((s) => {
107
+ const newSelected = new Set(s.selectedDetections);
108
+ s.detections.filter((d) => d.category === category).forEach((d) => {
109
+ if (selected) {
110
+ newSelected.add(d.id);
111
+ } else {
112
+ newSelected.delete(d.id);
113
+ }
114
+ });
115
+ return { selectedDetections: newSelected };
116
+ }),
117
+ /**
118
+ * M-5 SECURITY FIX: Clear detections entirely after processing.
119
+ * Previously just masked values with '[CLEARED]' which left PII in memory.
120
+ * Now removes all detection data completely.
121
+ */
122
+ clearDetectionValues: () => set({
123
+ detections: [],
124
+ selectedDetections: /* @__PURE__ */ new Set()
125
+ }),
126
+ /**
127
+ * M-6 SECURITY FIX: Aggressively clear all sensitive data.
128
+ * Completely removes detections rather than just replacing values.
129
+ * Also clears queued files and spreadsheet metadata.
130
+ */
131
+ secureClear: () => set({
132
+ detections: [],
133
+ selectedDetections: /* @__PURE__ */ new Set(),
134
+ queuedFiles: [],
135
+ spreadsheetMetadata: null,
136
+ columnConfig: null,
137
+ columnAccessProof: null
138
+ }),
139
+ // Configuration management
140
+ updateConfig: (config) => set((s) => ({
141
+ config: { ...s.config, ...config }
142
+ })),
143
+ toggleCategory: (category) => set((s) => ({
144
+ config: {
145
+ ...s.config,
146
+ categories: {
147
+ ...s.config.categories,
148
+ [category]: !s.config.categories[category]
149
+ }
150
+ }
151
+ })),
152
+ setRedactionStyle: (style) => set((s) => ({
153
+ config: { ...s.config, redactionStyle: style }
154
+ })),
155
+ // Processing management
156
+ setProgress: (progress) => set({ progress }),
157
+ // Column selection management
158
+ setColumnConfig: (columnConfig) => set({ columnConfig }),
159
+ setSpreadsheetMetadata: (spreadsheetMetadata) => set({ spreadsheetMetadata }),
160
+ setColumnAccessProof: (columnAccessProof) => set({ columnAccessProof }),
161
+ // Adversarial verification management
162
+ setAdversarialResult: (adversarialResult) => set({ adversarialResult }),
163
+ setAdversarialConfig: (config) => set((s) => ({
164
+ adversarialConfig: { ...s.adversarialConfig, ...config }
165
+ })),
166
+ setIsAnalyzingAdversarial: (isAnalyzingAdversarial) => set({ isAnalyzingAdversarial }),
167
+ applySuggestion: (suggestion) => set((s) => {
168
+ if (!s.adversarialResult) return s;
169
+ const updatedSuggestions = s.adversarialResult.suggestions.map(
170
+ (sug) => sug.id === suggestion.id ? { ...sug, accepted: true } : sug
171
+ );
172
+ return {
173
+ adversarialResult: {
174
+ ...s.adversarialResult,
175
+ suggestions: updatedSuggestions
176
+ }
177
+ };
178
+ }),
179
+ clearAdversarialState: () => set({
180
+ adversarialResult: null,
181
+ isAnalyzingAdversarial: false
182
+ }),
183
+ // Privacy mode
184
+ setParanoidMode: (paranoidMode) => set({ paranoidMode }),
185
+ // Full reset
186
+ reset: () => set({
187
+ ...initialState,
188
+ selectedDetections: /* @__PURE__ */ new Set(),
189
+ columnConfig: null,
190
+ spreadsheetMetadata: null,
191
+ columnAccessProof: null
192
+ }),
193
+ // Soft reset - preserve config for multi-run workflow
194
+ softReset: () => set((s) => ({
195
+ state: "idle",
196
+ queuedFiles: [],
197
+ processedFiles: [],
198
+ detections: [],
199
+ selectedDetections: /* @__PURE__ */ new Set(),
200
+ progress: null,
201
+ columnConfig: null,
202
+ spreadsheetMetadata: null,
203
+ columnAccessProof: null,
204
+ adversarialResult: null,
205
+ isAnalyzingAdversarial: false,
206
+ // Preserve config settings between runs
207
+ config: s.config,
208
+ adversarialConfig: s.adversarialConfig
209
+ }))
210
+ }));
211
+
212
+ // src/services/detection/patterns.ts
213
+ var emailPattern = {
214
+ category: "email",
215
+ pattern: /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g,
216
+ priority: 100
217
+ };
218
+ var phonePattern = {
219
+ category: "phone",
220
+ pattern: /(?:\+?1[-.\s]?)?(?:\(?\d{3}\)?[-.\s]?)?\d{3}[-.\s]?\d{4}/g,
221
+ priority: 90,
222
+ validator: (match) => {
223
+ const digits = match.replace(/\D/g, "");
224
+ return digits.length >= 10 && digits.length <= 11;
225
+ }
226
+ };
227
+ var ssnPattern = {
228
+ category: "ssn",
229
+ pattern: /\b\d{3}[-\s]?\d{2}[-\s]?\d{4}\b/g,
230
+ priority: 100,
231
+ validator: (match) => {
232
+ const digits = match.replace(/\D/g, "");
233
+ if (digits.length !== 9) {
234
+ return false;
235
+ }
236
+ const area = digits.slice(0, 3);
237
+ const group = digits.slice(3, 5);
238
+ const serial = digits.slice(5, 9);
239
+ if (area === "000") {
240
+ return false;
241
+ }
242
+ if (area === "666") {
243
+ return false;
244
+ }
245
+ if (area >= "900") {
246
+ return false;
247
+ }
248
+ if (group === "00") {
249
+ return false;
250
+ }
251
+ if (serial === "0000") {
252
+ return false;
253
+ }
254
+ if (area === "987" && group === "65") {
255
+ const serialNum = parseInt(serial, 10);
256
+ if (serialNum >= 4320 && serialNum <= 4329) {
257
+ return false;
258
+ }
259
+ }
260
+ if (digits === "078051120") {
261
+ return false;
262
+ }
263
+ return true;
264
+ }
265
+ };
266
+ var creditCardPattern = {
267
+ category: "credit_card",
268
+ pattern: /\b(?:\d{4}[-\s]?){3}\d{4}\b|\b\d{15,16}\b/g,
269
+ priority: 95,
270
+ validator: (match) => {
271
+ const digits = match.replace(/\D/g, "");
272
+ if (digits.length < 15 || digits.length > 16) {
273
+ return false;
274
+ }
275
+ return luhnCheck(digits);
276
+ }
277
+ };
278
+ var ipAddressPattern = {
279
+ category: "ip_address",
280
+ pattern: /\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b/g,
281
+ priority: 80
282
+ };
283
+ var dateOfBirthPattern = {
284
+ category: "date_of_birth",
285
+ pattern: /\b(?:0?[1-9]|1[0-2])[-/](?:0?[1-9]|[12][0-9]|3[01])[-/](?:19|20)\d{2}\b|\b(?:19|20)\d{2}[-/](?:0?[1-9]|1[0-2])[-/](?:0?[1-9]|[12][0-9]|3[01])\b/g,
286
+ priority: 70
287
+ };
288
+ var addressPattern = {
289
+ category: "address",
290
+ pattern: /\b\d{1,5}\s+[A-Za-z]+(?:\s+[A-Za-z]+){0,3}\s+(?:Street|St|Avenue|Ave|Road|Rd|Boulevard|Blvd|Drive|Dr|Lane|Ln|Way|Court|Ct|Circle|Cir|Place|Pl)\.?\b/gi,
291
+ priority: 60
292
+ };
293
+ var personNameWithTitlePattern = {
294
+ category: "person_name",
295
+ pattern: /\b(?:Mr|Mrs|Ms|Miss|Dr|Prof)\.?\s+[A-Z][a-z]+(?:\s+[A-Z][a-z]+)+\b/g,
296
+ priority: 50
297
+ };
298
+ var personNameBroadPattern = {
299
+ category: "person_name",
300
+ pattern: /\b[A-Z][a-z]{2,15}\s+[A-Z][a-z]{2,20}\b/g,
301
+ priority: 30,
302
+ validator: (match) => {
303
+ const commonPhrases = [
304
+ // Place names
305
+ "New York",
306
+ "Los Angeles",
307
+ "San Francisco",
308
+ "San Diego",
309
+ "Las Vegas",
310
+ "United States",
311
+ "United Kingdom",
312
+ "North America",
313
+ "South America",
314
+ "North Carolina",
315
+ "South Carolina",
316
+ "New Jersey",
317
+ "New Mexico",
318
+ "New Hampshire",
319
+ "New Zealand",
320
+ // Company names
321
+ "General Electric",
322
+ "American Express",
323
+ // Common column headers / field labels (spreadsheet noise)
324
+ "Full Name",
325
+ "First Name",
326
+ "Last Name",
327
+ "Middle Name",
328
+ "Given Name",
329
+ "Family Name",
330
+ "Company Name",
331
+ "Business Name",
332
+ "Account Name",
333
+ "Customer Name",
334
+ "Employee Name",
335
+ "Contact Name",
336
+ "Display Name",
337
+ "Legal Name",
338
+ "Billing Name",
339
+ "Shipping Name",
340
+ "Primary Contact",
341
+ "Emergency Contact",
342
+ "Phone Number",
343
+ "Email Address",
344
+ "Street Address",
345
+ "Mailing Address",
346
+ "Billing Address",
347
+ "Shipping Address",
348
+ "Home Address",
349
+ "Work Address",
350
+ "Office Address",
351
+ "Physical Address",
352
+ "Postal Code",
353
+ "Zip Code",
354
+ "Area Code",
355
+ "Country Code",
356
+ "Report Date",
357
+ "Start Date",
358
+ "End Date",
359
+ "Due Date",
360
+ "Birth Date",
361
+ "Hire Date",
362
+ "Created Date",
363
+ "Modified Date",
364
+ "Last Modified",
365
+ "Date Created",
366
+ "Customer Service",
367
+ "Technical Support",
368
+ "Human Resources",
369
+ "Account Number",
370
+ "Reference Number",
371
+ "Order Number",
372
+ "Invoice Number",
373
+ "Serial Number",
374
+ "Tracking Number",
375
+ "Case Number",
376
+ "Report Number",
377
+ "Total Amount",
378
+ "Grand Total",
379
+ "Net Amount",
380
+ "Tax Amount"
381
+ ];
382
+ return !commonPhrases.includes(match);
383
+ }
384
+ };
385
+ var intlPhonePattern = {
386
+ category: "phone",
387
+ pattern: /\+[1-9]\d{6,14}\b/g,
388
+ priority: 85
389
+ };
390
+ var ukPostcodePattern = {
391
+ category: "address",
392
+ pattern: /\b[A-Z]{1,2}\d{1,2}[A-Z]?\s*\d[A-Z]{2}\b/gi,
393
+ priority: 70
394
+ };
395
+ var patterns = [
396
+ emailPattern,
397
+ ssnPattern,
398
+ creditCardPattern,
399
+ phonePattern,
400
+ intlPhonePattern,
401
+ ipAddressPattern,
402
+ ukPostcodePattern,
403
+ dateOfBirthPattern,
404
+ addressPattern,
405
+ personNameWithTitlePattern,
406
+ personNameBroadPattern
407
+ ].sort((a, b) => b.priority - a.priority);
408
+ var patternSetCache = /* @__PURE__ */ new Map();
409
+ function getPatternsForCategories(enabledCategories) {
410
+ const key = [...enabledCategories].sort().join("|");
411
+ let cached = patternSetCache.get(key);
412
+ if (cached) {
413
+ return cached;
414
+ }
415
+ cached = patterns.filter((p) => enabledCategories.includes(p.category));
416
+ patternSetCache.set(key, cached);
417
+ return cached;
418
+ }
419
+ function luhnCheck(cardNumber) {
420
+ let sum = 0;
421
+ let isEven = false;
422
+ for (let i = cardNumber.length - 1; i >= 0; i--) {
423
+ let digit = parseInt(cardNumber[i], 10);
424
+ if (isEven) {
425
+ digit *= 2;
426
+ if (digit > 9) {
427
+ digit -= 9;
428
+ }
429
+ }
430
+ sum += digit;
431
+ isEven = !isEven;
432
+ }
433
+ return sum % 10 === 0;
434
+ }
435
+
436
+ // src/utils/secureRandom.ts
437
+ function generateSecureId() {
438
+ const array = new Uint8Array(12);
439
+ crypto.getRandomValues(array);
440
+ return Array.from(array, (b) => b.toString(16).padStart(2, "0")).join("");
441
+ }
442
+ function generatePrefixedId(prefix) {
443
+ return `${prefix}-${generateSecureId()}`;
444
+ }
445
+
446
+ // src/utils/validateRegex.ts
447
+ var MAX_PATTERN_LENGTH = 500;
448
+ var DANGEROUS_PATTERNS = [
449
+ // Nested quantifiers: (a+)+ or (a*)*
450
+ /\([^)]*[+*]\)[+*]/,
451
+ // Overlapping alternation with quantifiers: (a|a)+
452
+ /\([^)]*\|[^)]*\)[+*]/,
453
+ // Quantifier on quantified group: (?:...){n}{m}
454
+ /\{[0-9,]+\}\s*\{/,
455
+ // (.*)+ or (.*)* - catastrophic backtracking
456
+ /\(\.\*\)[+*]/,
457
+ // (.+)+ or (.+)* - catastrophic backtracking
458
+ /\(\.\+\)[+*]/,
459
+ // Nested groups with + or * on inner and outer
460
+ /\([^)]*\([^)]*[+*][^)]*\)[^)]*\)[+*]/
461
+ ];
462
+ function validateRegex(pattern) {
463
+ if (pattern.length > MAX_PATTERN_LENGTH) {
464
+ return {
465
+ valid: false,
466
+ error: `Pattern too long (max ${MAX_PATTERN_LENGTH} characters, got ${pattern.length})`
467
+ };
468
+ }
469
+ if (pattern.trim().length === 0) {
470
+ return {
471
+ valid: false,
472
+ error: "Pattern cannot be empty"
473
+ };
474
+ }
475
+ for (const dangerous of DANGEROUS_PATTERNS) {
476
+ if (dangerous.test(pattern)) {
477
+ return {
478
+ valid: false,
479
+ error: "Pattern contains constructs that could cause slow matching. Avoid nested quantifiers like (a+)+ or (.*)+"
480
+ };
481
+ }
482
+ }
483
+ try {
484
+ new RegExp(pattern);
485
+ } catch (e) {
486
+ const message = e instanceof Error ? e.message : "Unknown error";
487
+ return {
488
+ valid: false,
489
+ error: `Invalid regex syntax: ${message}`
490
+ };
491
+ }
492
+ return { valid: true };
493
+ }
494
+ function safeCompileRegex(pattern, flags = "g") {
495
+ const validation = validateRegex(pattern);
496
+ if (!validation.valid) {
497
+ return { regex: null, error: validation.error };
498
+ }
499
+ try {
500
+ return { regex: new RegExp(pattern, flags) };
501
+ } catch (e) {
502
+ const message = e instanceof Error ? e.message : "Unknown error";
503
+ return { regex: null, error: `Failed to compile regex: ${message}` };
504
+ }
505
+ }
506
+
507
+ // src/utils/secureLogger.ts
508
+ function sanitizeError(error) {
509
+ if (error instanceof Error) {
510
+ const message = error.message.split("\n")[0].slice(0, 100);
511
+ return {
512
+ type: error.constructor.name,
513
+ message
514
+ };
515
+ }
516
+ if (typeof error === "string") {
517
+ return {
518
+ type: "String",
519
+ message: error.slice(0, 100)
520
+ };
521
+ }
522
+ return {
523
+ type: "Unknown",
524
+ message: "An error occurred"
525
+ };
526
+ }
527
+ function secureWarn(context, error) {
528
+ if (error !== void 0) {
529
+ const safe = sanitizeError(error);
530
+ console.warn(`PURGE: ${context} [${safe.type}]: ${safe.message}`);
531
+ } else {
532
+ console.warn(`PURGE: ${context}`);
533
+ }
534
+ }
535
+ function secureError(context, error) {
536
+ if (error !== void 0) {
537
+ const safe = sanitizeError(error);
538
+ console.error(`PURGE: ${context} [${safe.type}]: ${safe.message}`);
539
+ } else {
540
+ console.error(`PURGE: ${context}`);
541
+ }
542
+ }
543
+ function secureLog(message) {
544
+ console.log(`PURGE: ${message}`);
545
+ }
546
+ function safeFilename(filename) {
547
+ const name = filename.split("/").pop() || filename;
548
+ return name.slice(0, 50);
549
+ }
550
+
551
+ // src/workers/regexWorker.ts
552
+ var regexWorkerCode = `
553
+ self.onmessage = function(e) {
554
+ const { id, type, pattern, flags, text } = e.data;
555
+
556
+ if (type !== 'matchAll') {
557
+ self.postMessage({ id, success: false, error: 'Unknown message type' });
558
+ return;
559
+ }
560
+
561
+ try {
562
+ const regex = new RegExp(pattern, flags);
563
+ const matches = [];
564
+
565
+ // Reset lastIndex for global patterns
566
+ regex.lastIndex = 0;
567
+
568
+ let match;
569
+ let maxMatches = 10000; // Safety limit to prevent infinite loops
570
+ let count = 0;
571
+
572
+ while ((match = regex.exec(text)) !== null && count < maxMatches) {
573
+ matches.push({
574
+ match: match[0],
575
+ index: match.index,
576
+ groups: match.groups || undefined
577
+ });
578
+
579
+ // Prevent infinite loops for zero-length matches
580
+ if (match[0].length === 0) {
581
+ regex.lastIndex++;
582
+ }
583
+
584
+ count++;
585
+ }
586
+
587
+ self.postMessage({ id, success: true, matches });
588
+ } catch (error) {
589
+ self.postMessage({
590
+ id,
591
+ success: false,
592
+ error: error instanceof Error ? error.message : 'Unknown error'
593
+ });
594
+ }
595
+ };
596
+ `;
597
+ function createRegexWorkerUrl() {
598
+ const blob = new Blob([regexWorkerCode], { type: "application/javascript" });
599
+ return URL.createObjectURL(blob);
600
+ }
601
+ async function executeRegexInWorker(pattern, text, timeoutMs = 100) {
602
+ return new Promise((resolve) => {
603
+ let worker = null;
604
+ let timeoutId = null;
605
+ let workerUrl = null;
606
+ let resolved = false;
607
+ const cleanup = () => {
608
+ if (timeoutId) {
609
+ clearTimeout(timeoutId);
610
+ timeoutId = null;
611
+ }
612
+ if (worker) {
613
+ worker.terminate();
614
+ worker = null;
615
+ }
616
+ if (workerUrl) {
617
+ URL.revokeObjectURL(workerUrl);
618
+ workerUrl = null;
619
+ }
620
+ };
621
+ const resolveOnce = (result) => {
622
+ if (!resolved) {
623
+ resolved = true;
624
+ cleanup();
625
+ resolve(result);
626
+ }
627
+ };
628
+ try {
629
+ workerUrl = createRegexWorkerUrl();
630
+ worker = new Worker(workerUrl);
631
+ const messageId = crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36);
632
+ timeoutId = setTimeout(() => {
633
+ resolveOnce([]);
634
+ }, timeoutMs);
635
+ worker.onmessage = (e) => {
636
+ if (e.data.id !== messageId) return;
637
+ if (e.data.success && e.data.matches) {
638
+ const results = e.data.matches.map((m) => {
639
+ const arr = [m.match];
640
+ arr.index = m.index;
641
+ arr.input = text;
642
+ arr.groups = m.groups;
643
+ return arr;
644
+ });
645
+ resolveOnce(results);
646
+ } else {
647
+ resolveOnce([]);
648
+ }
649
+ };
650
+ worker.onerror = () => {
651
+ resolveOnce([]);
652
+ };
653
+ const message = {
654
+ id: messageId,
655
+ type: "matchAll",
656
+ pattern: pattern.source,
657
+ flags: pattern.flags,
658
+ text
659
+ };
660
+ worker.postMessage(message);
661
+ } catch {
662
+ resolveOnce([]);
663
+ }
664
+ });
665
+ }
666
+
667
+ // src/services/detection/RegexDetectionEngine.ts
668
+ var REGEX_TIMEOUT_MS = 100;
669
+ var USE_WORKER_REGEX = typeof Worker !== "undefined";
670
+ function generateId() {
671
+ return generatePrefixedId("det");
672
+ }
673
+ async function safeRegexMatchAll(pattern, text, timeoutMs = REGEX_TIMEOUT_MS) {
674
+ if (USE_WORKER_REGEX) {
675
+ try {
676
+ return await executeRegexInWorker(pattern, text, timeoutMs);
677
+ } catch {
678
+ secureWarn("Worker regex failed, falling back to synchronous");
679
+ }
680
+ }
681
+ return new Promise((resolve) => {
682
+ const timeout = setTimeout(() => {
683
+ secureWarn(`Regex pattern timed out after ${timeoutMs}ms (fallback mode)`);
684
+ resolve([]);
685
+ }, timeoutMs);
686
+ try {
687
+ pattern.lastIndex = 0;
688
+ const matches = Array.from(text.matchAll(pattern));
689
+ clearTimeout(timeout);
690
+ resolve(matches);
691
+ } catch (e) {
692
+ clearTimeout(timeout);
693
+ secureWarn("Regex execution error", e);
694
+ resolve([]);
695
+ }
696
+ });
697
+ }
698
+ function extractContext(text, startOffset, endOffset, contextChars = 25) {
699
+ const start = Math.max(0, startOffset - contextChars);
700
+ const end = Math.min(text.length, endOffset + contextChars);
701
+ let context = "";
702
+ if (start > 0) {
703
+ context += "...";
704
+ }
705
+ context += text.slice(start, end);
706
+ if (end < text.length) {
707
+ context += "...";
708
+ }
709
+ return context;
710
+ }
711
+ var RegexDetectionEngine = class {
712
+ constructor() {
713
+ this.version = "regex-v1.0.0";
714
+ }
715
+ async detect(content, config) {
716
+ const startTime = performance.now();
717
+ const detections = [];
718
+ const enabledCategories = Object.entries(config.categories).filter(([, enabled]) => enabled).map(([category]) => category);
719
+ const activePatterns = getPatternsForCategories(enabledCategories);
720
+ for (const section of content.sections) {
721
+ for (const patternDef of activePatterns) {
722
+ const matches = await safeRegexMatchAll(patternDef.pattern, section.text);
723
+ for (const match of matches) {
724
+ const value = match[0];
725
+ if (patternDef.validator && !patternDef.validator(value)) {
726
+ continue;
727
+ }
728
+ const confidence = this.calculateConfidence(
729
+ patternDef.category,
730
+ value,
731
+ config.sensitivity
732
+ );
733
+ const threshold = this.getSensitivityThreshold(config.sensitivity);
734
+ if (confidence < threshold) {
735
+ continue;
736
+ }
737
+ detections.push({
738
+ id: generateId(),
739
+ fileId: content.fileId,
740
+ sectionId: section.id,
741
+ category: patternDef.category,
742
+ value,
743
+ startOffset: match.index ?? 0,
744
+ endOffset: (match.index ?? 0) + value.length,
745
+ confidence,
746
+ context: extractContext(
747
+ section.text,
748
+ match.index ?? 0,
749
+ (match.index ?? 0) + value.length
750
+ ),
751
+ source: "regex",
752
+ location: section.location
753
+ });
754
+ }
755
+ }
756
+ if (config.categories.custom) {
757
+ for (const customPattern of config.customPatterns) {
758
+ if (!customPattern.enabled) continue;
759
+ const { regex, error } = safeCompileRegex(customPattern.regex, "g");
760
+ if (!regex) {
761
+ secureWarn(`Custom pattern rejected: ${error}`);
762
+ continue;
763
+ }
764
+ const matches = await safeRegexMatchAll(regex, section.text);
765
+ for (const match of matches) {
766
+ detections.push({
767
+ id: generateId(),
768
+ fileId: content.fileId,
769
+ sectionId: section.id,
770
+ category: "custom",
771
+ value: match[0],
772
+ startOffset: match.index ?? 0,
773
+ endOffset: (match.index ?? 0) + match[0].length,
774
+ confidence: 0.9,
775
+ // Custom patterns get high confidence
776
+ context: extractContext(
777
+ section.text,
778
+ match.index ?? 0,
779
+ (match.index ?? 0) + match[0].length
780
+ ),
781
+ source: "regex",
782
+ location: section.location
783
+ });
784
+ }
785
+ }
786
+ }
787
+ }
788
+ const uniqueDetections = this.deduplicateDetections(detections);
789
+ const endTime = performance.now();
790
+ return {
791
+ detections: uniqueDetections,
792
+ processingTimeMs: Math.round(endTime - startTime),
793
+ engineVersion: this.version
794
+ };
795
+ }
796
+ async isAvailable() {
797
+ return true;
798
+ }
799
+ getCapabilities() {
800
+ return {
801
+ supportedCategories: [
802
+ "person_name",
803
+ "email",
804
+ "phone",
805
+ "address",
806
+ "ssn",
807
+ "credit_card",
808
+ "ip_address",
809
+ "date_of_birth",
810
+ "custom"
811
+ ],
812
+ supportsCustomPatterns: true,
813
+ supportsContextualDetection: false,
814
+ // AI engine would support this
815
+ maxFileSizeMB: 50
816
+ };
817
+ }
818
+ /**
819
+ * Calculate confidence score based on pattern and context
820
+ */
821
+ calculateConfidence(category, value, sensitivity) {
822
+ const baseConfidence = {
823
+ email: 0.95,
824
+ ssn: 0.95,
825
+ credit_card: 0.9,
826
+ phone: 0.85,
827
+ ip_address: 0.9,
828
+ date_of_birth: 0.75,
829
+ address: 0.75,
830
+ person_name: 0.75,
831
+ custom: 0.9
832
+ };
833
+ let confidence = baseConfidence[category] ?? 0.5;
834
+ if (category === "email" && value.includes("+")) {
835
+ confidence += 0.02;
836
+ }
837
+ if (category === "phone") {
838
+ const digits = value.replace(/\D/g, "");
839
+ if (digits.length === 10) confidence += 0.05;
840
+ if (value.includes("(") && value.includes(")")) confidence += 0.03;
841
+ }
842
+ if (sensitivity === "high") {
843
+ confidence += 0.1;
844
+ } else if (sensitivity === "low") {
845
+ confidence -= 0.1;
846
+ }
847
+ return Math.min(1, Math.max(0, confidence));
848
+ }
849
+ /**
850
+ * Get minimum confidence threshold for sensitivity level
851
+ */
852
+ getSensitivityThreshold(sensitivity) {
853
+ switch (sensitivity) {
854
+ case "low":
855
+ return 0.9;
856
+ case "medium":
857
+ return 0.7;
858
+ case "high":
859
+ return 0.5;
860
+ default:
861
+ return 0.7;
862
+ }
863
+ }
864
+ /**
865
+ * Remove duplicate detections at the same position
866
+ */
867
+ deduplicateDetections(detections) {
868
+ const seen = /* @__PURE__ */ new Set();
869
+ return detections.filter((d) => {
870
+ const key = `${d.sectionId}:${d.startOffset}:${d.endOffset}:${d.category}`;
871
+ if (seen.has(key)) {
872
+ return false;
873
+ }
874
+ seen.add(key);
875
+ return true;
876
+ });
877
+ }
878
+ };
879
+ var regexDetectionEngine = new RegexDetectionEngine();
880
+
881
+ // src/services/processors/BaseProcessor.ts
882
+ function generateSectionId() {
883
+ return generatePrefixedId("section");
884
+ }
885
+ var BaseProcessor = class {
886
+ /**
887
+ * Check if this processor can handle the file
888
+ */
889
+ canProcess(file) {
890
+ const ext = "." + file.name.split(".").pop()?.toLowerCase();
891
+ return this.extensions.includes(ext) || this.mimeTypes.includes(file.type);
892
+ }
893
+ /**
894
+ * Get supported MIME types
895
+ */
896
+ getSupportedMimeTypes() {
897
+ return this.mimeTypes;
898
+ }
899
+ /**
900
+ * Helper to create a content section
901
+ */
902
+ createSection(text, type, location) {
903
+ return {
904
+ id: generateSectionId(),
905
+ text,
906
+ type,
907
+ location
908
+ };
909
+ }
910
+ /**
911
+ * Apply text replacements to a string
912
+ */
913
+ applyTextRedactions(text, redactions, sectionId) {
914
+ const sectionRedactions = redactions.filter((r) => r.sectionId === sectionId).sort((a, b) => b.startOffset - a.startOffset);
915
+ let result = text;
916
+ for (const redaction of sectionRedactions) {
917
+ result = result.slice(0, redaction.startOffset) + redaction.replacement + result.slice(redaction.endOffset);
918
+ }
919
+ return result;
920
+ }
921
+ };
922
+
923
+ // src/services/processors/XlsxProcessor.ts
924
+ import * as XLSX from "xlsx";
925
+ var XlsxProcessor = class extends BaseProcessor {
926
+ constructor() {
927
+ super(...arguments);
928
+ this.fileType = "xlsx";
929
+ this.mimeTypes = [
930
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
931
+ "application/vnd.ms-excel"
932
+ ];
933
+ this.extensions = [".xlsx", ".xls"];
934
+ }
935
+ /**
936
+ * Parse Excel file and extract text from cells.
937
+ * Supports selective column parsing with attestation generation.
938
+ *
939
+ * @param file - The Excel file to parse
940
+ * @param options - Optional parsing options for column selection
941
+ */
942
+ async parse(file, options = {}) {
943
+ const arrayBuffer = await file.arrayBuffer();
944
+ const workbook = XLSX.read(arrayBuffer, { type: "array" });
945
+ const sections = [];
946
+ const sheets = /* @__PURE__ */ new Map();
947
+ const attestations = [];
948
+ let formulaCellCount = 0;
949
+ const { columnSelection, generateAttestation = false } = options;
950
+ const processAllColumns = !columnSelection || columnSelection.mode === "all";
951
+ for (const sheetName of workbook.SheetNames) {
952
+ if (columnSelection?.mode === "selected" && columnSelection.selectedSheets.size > 0 && !columnSelection.selectedSheets.has(sheetName)) {
953
+ continue;
954
+ }
955
+ const sheet = workbook.Sheets[sheetName];
956
+ sheets.set(sheetName, sheet);
957
+ const range = XLSX.utils.decode_range(sheet["!ref"] || "A1");
958
+ for (let col = range.s.c; col <= range.e.c; col++) {
959
+ const colLetter = XLSX.utils.encode_col(col);
960
+ const qualifiedCol = `${sheetName}:${colLetter}`;
961
+ const shouldProcess = processAllColumns || columnSelection.selectedColumns.has(qualifiedCol) || columnSelection.selectedColumns.has(colLetter);
962
+ if (!shouldProcess) {
963
+ if (generateAttestation) {
964
+ const attestation = await this.generateColumnAttestation(
965
+ sheet,
966
+ sheetName,
967
+ col,
968
+ colLetter,
969
+ range
970
+ );
971
+ attestations.push(attestation);
972
+ }
973
+ continue;
974
+ }
975
+ for (let row = range.s.r; row <= range.e.r; row++) {
976
+ const cellAddress = XLSX.utils.encode_cell({ r: row, c: col });
977
+ const cell = sheet[cellAddress];
978
+ if (cell && cell.v !== void 0 && cell.v !== null) {
979
+ const text = String(cell.v);
980
+ if (cell.f) {
981
+ formulaCellCount++;
982
+ }
983
+ if (text.trim()) {
984
+ sections.push({
985
+ id: generateSectionId(),
986
+ text,
987
+ type: "cell",
988
+ location: {
989
+ sheet: sheetName,
990
+ cell: cellAddress
991
+ }
992
+ });
993
+ }
994
+ }
995
+ }
996
+ }
997
+ }
998
+ const content = {
999
+ fileId: `xlsx-${generateSecureId()}`,
1000
+ fileName: file.name,
1001
+ fileType: "xlsx",
1002
+ sections
1003
+ };
1004
+ const formulaWarning = formulaCellCount > 0 ? `Found ${formulaCellCount} formula cell(s). Formula values may contain PII from referenced cells. Review carefully.` : void 0;
1005
+ return {
1006
+ content,
1007
+ metadata: {
1008
+ sheetCount: workbook.SheetNames.length,
1009
+ sheetNames: workbook.SheetNames,
1010
+ columnAttestations: attestations.length > 0 ? attestations : void 0,
1011
+ formulaCellCount: formulaCellCount > 0 ? formulaCellCount : void 0,
1012
+ formulaWarning
1013
+ },
1014
+ rawData: {
1015
+ workbook,
1016
+ sheets,
1017
+ columnAttestations: attestations.length > 0 ? attestations : void 0,
1018
+ formulaCellCount,
1019
+ formulaWarning
1020
+ }
1021
+ };
1022
+ }
1023
+ /**
1024
+ * Generate metadata record for a skipped column.
1025
+ *
1026
+ * NOTE: This does NOT cryptographically prove anything meaningful.
1027
+ * It just records column metadata (position, cell count). The hash
1028
+ * is of this metadata string, not the actual cell values. This is
1029
+ * for internal tracking only - we removed the misleading
1030
+ * "cryptographic attestation" claim from the UI.
1031
+ */
1032
+ async generateColumnAttestation(sheet, sheetName, colIndex, colLetter, range) {
1033
+ let cellCount = 0;
1034
+ for (let row = range.s.r; row <= range.e.r; row++) {
1035
+ const cellAddress = XLSX.utils.encode_cell({ r: row, c: colIndex });
1036
+ if (sheet[cellAddress]) cellCount++;
1037
+ }
1038
+ const columnFingerprint = `${sheetName}:${colLetter}:${cellCount}:${range.e.r - range.s.r + 1}:${Date.now()}`;
1039
+ const hashBuffer = await crypto.subtle.digest(
1040
+ "SHA-256",
1041
+ new TextEncoder().encode(columnFingerprint)
1042
+ );
1043
+ const rawBytesHash = Array.from(new Uint8Array(hashBuffer)).map((b) => b.toString(16).padStart(2, "0")).join("");
1044
+ return {
1045
+ column: colLetter,
1046
+ sheet: sheetName,
1047
+ cellCount,
1048
+ rawBytesHash,
1049
+ wasAccessed: false
1050
+ };
1051
+ }
1052
+ /**
1053
+ * Apply redactions and generate new Excel file
1054
+ *
1055
+ * SECURITY: This method rebuilds the workbook from scratch to strip metadata.
1056
+ * Only cell values are copied - no formulas, comments, properties, or hidden sheets.
1057
+ */
1058
+ async applyRedactions(document2, redactions) {
1059
+ const { workbook } = document2.rawData;
1060
+ const redactionMap = /* @__PURE__ */ new Map();
1061
+ for (const redaction of redactions) {
1062
+ const existing = redactionMap.get(redaction.sectionId) || [];
1063
+ existing.push(redaction);
1064
+ redactionMap.set(redaction.sectionId, existing);
1065
+ }
1066
+ const cellRedactions = /* @__PURE__ */ new Map();
1067
+ for (const section of document2.content.sections) {
1068
+ const sectionRedactions = redactionMap.get(section.id);
1069
+ if (sectionRedactions && sectionRedactions.length > 0 && section.location.sheet && section.location.cell) {
1070
+ const key = `${section.location.sheet}!${section.location.cell}`;
1071
+ const newText = this.applyTextRedactions(
1072
+ section.text,
1073
+ sectionRedactions,
1074
+ section.id
1075
+ );
1076
+ cellRedactions.set(key, newText);
1077
+ }
1078
+ }
1079
+ const newWorkbook = XLSX.utils.book_new();
1080
+ for (const sheetName of workbook.SheetNames) {
1081
+ const originalSheet = workbook.Sheets[sheetName];
1082
+ const sheetState = originalSheet["!state"];
1083
+ if (sheetState === "hidden" || sheetState === "veryHidden") {
1084
+ continue;
1085
+ }
1086
+ const newSheet = {};
1087
+ const ref = originalSheet["!ref"];
1088
+ if (!ref) continue;
1089
+ const range = XLSX.utils.decode_range(ref);
1090
+ for (let row = range.s.r; row <= range.e.r; row++) {
1091
+ for (let col = range.s.c; col <= range.e.c; col++) {
1092
+ const cellAddress = XLSX.utils.encode_cell({ r: row, c: col });
1093
+ const originalCell = originalSheet[cellAddress];
1094
+ if (originalCell && originalCell.v !== void 0 && originalCell.v !== null) {
1095
+ const redactionKey = `${sheetName}!${cellAddress}`;
1096
+ const redactedValue = cellRedactions.get(redactionKey);
1097
+ const value = redactedValue !== void 0 ? redactedValue : String(originalCell.v);
1098
+ newSheet[cellAddress] = {
1099
+ t: "s",
1100
+ // String type
1101
+ v: value
1102
+ };
1103
+ }
1104
+ }
1105
+ }
1106
+ newSheet["!ref"] = ref;
1107
+ if (originalSheet["!cols"]) {
1108
+ newSheet["!cols"] = originalSheet["!cols"].map((col) => ({
1109
+ wch: col?.wch,
1110
+ wpx: col?.wpx
1111
+ }));
1112
+ }
1113
+ XLSX.utils.book_append_sheet(newWorkbook, newSheet, sheetName);
1114
+ }
1115
+ newWorkbook.Props = {
1116
+ Title: document2.content.fileName.replace(/\.[^/.]+$/, "") + " (PURGED)",
1117
+ Author: "PURGE - purgedata.app",
1118
+ Comments: `Processed locally by PURGE on ${(/* @__PURE__ */ new Date()).toISOString()}. No data was transmitted.`
1119
+ };
1120
+ const output = XLSX.write(newWorkbook, {
1121
+ type: "array",
1122
+ bookType: "xlsx"
1123
+ });
1124
+ return new Blob([output], {
1125
+ type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
1126
+ });
1127
+ }
1128
+ };
1129
+ var xlsxProcessor = new XlsxProcessor();
1130
+
1131
+ // src/services/processors/index.ts
1132
+ var processors = {
1133
+ xlsx: xlsxProcessor,
1134
+ docx: null,
1135
+ // TODO: Implement DocxProcessor
1136
+ pptx: null,
1137
+ // TODO: Implement PptxProcessor
1138
+ pdf: null
1139
+ // TODO: Implement PdfProcessor
1140
+ };
1141
+ function getProcessor(fileType) {
1142
+ return processors[fileType];
1143
+ }
1144
+ function getProcessorForFile(file) {
1145
+ for (const processor of Object.values(processors)) {
1146
+ if (processor?.canProcess(file)) {
1147
+ return processor;
1148
+ }
1149
+ }
1150
+ return null;
1151
+ }
1152
+
1153
+ // src/services/adversarial/AdversarialVerifier.ts
1154
+ var ATTRIBUTE_PATTERNS = [
1155
+ // Professions - highly identifying
1156
+ {
1157
+ type: "profession",
1158
+ patterns: [
1159
+ /\b(?:CEO|CFO|CTO|COO|CMO|CIO|CISO)\b/gi,
1160
+ /\b(?:chief|head|director|vp|vice president)\s+(?:of\s+)?[\w\s]+(?:officer|executive)?\b/gi,
1161
+ /\b(?:surgeon|doctor|physician|attorney|lawyer|judge|professor|dean)\b/gi,
1162
+ /\b(?:architect|engineer|scientist|researcher)\s+(?:at|for|of)\s+[\w\s]+\b/gi,
1163
+ /\bfounder\s+(?:of|and\s+CEO\s+of)\s+[\w\s]+\b/gi,
1164
+ /\b(?:partner|principal|managing\s+director)\s+at\s+[\w\s]+\b/gi
1165
+ ],
1166
+ narrowingFactor: 1e-4,
1167
+ // Very few people have specific C-suite titles
1168
+ explanationTemplate: 'Job title "{phrase}" significantly narrows identification',
1169
+ suggestionTemplate: "a senior executive"
1170
+ },
1171
+ // Affiliations - companies, universities, organizations
1172
+ {
1173
+ type: "affiliation",
1174
+ patterns: [
1175
+ /\bat\s+(?:Google|Apple|Microsoft|Amazon|Meta|Facebook|Netflix|Tesla|SpaceX)\b/gi,
1176
+ /\bat\s+(?:Harvard|Stanford|MIT|Yale|Princeton|Oxford|Cambridge)\b/gi,
1177
+ /\b(?:works?|worked|employed)\s+(?:at|for|with)\s+[\w\s]+(?:Inc|Corp|LLC|Ltd|University|College|Hospital)?\b/gi,
1178
+ /\b(?:member|fellow|associate)\s+of\s+(?:the\s+)?[\w\s]+(?:Association|Society|Institute|Academy)\b/gi
1179
+ ],
1180
+ narrowingFactor: 1e-3,
1181
+ explanationTemplate: 'Affiliation with "{phrase}" narrows potential matches',
1182
+ suggestionTemplate: "a technology company"
1183
+ },
1184
+ // Temporal markers - specific dates/events
1185
+ {
1186
+ type: "temporal_marker",
1187
+ patterns: [
1188
+ /\bin\s+(?:January|February|March|April|May|June|July|August|September|October|November|December)\s+(?:of\s+)?(?:19|20)\d{2}\b/gi,
1189
+ /\bon\s+(?:January|February|March|April|May|June|July|August|September|October|November|December)\s+\d{1,2}(?:st|nd|rd|th)?,?\s*(?:19|20)\d{2}\b/gi,
1190
+ /\bduring\s+(?:the\s+)?(?:19|20)\d{2}\s+[\w\s]+\b/gi,
1191
+ /\b(?:class|batch|cohort)\s+of\s+(?:19|20)\d{2}\b/gi,
1192
+ /\bQ[1-4]\s+(?:19|20)\d{2}\b/gi
1193
+ ],
1194
+ narrowingFactor: 0.1,
1195
+ explanationTemplate: 'Specific time reference "{phrase}" helps narrow search',
1196
+ suggestionTemplate: "during that period"
1197
+ },
1198
+ // Geographic signals
1199
+ {
1200
+ type: "geographic_signal",
1201
+ patterns: [
1202
+ /\b(?:based|located|headquartered)\s+in\s+[\w\s]+(?:,\s*[A-Z]{2})?\b/gi,
1203
+ /\b(?:downtown|midtown|uptown)\s+[\w\s]+\b/gi,
1204
+ /\b[\w\s]+(?:office|campus|facility|branch)\b/gi,
1205
+ /\bnative\s+of\s+[\w\s]+\b/gi,
1206
+ /\bfrom\s+(?:the\s+)?[\w\s]+(?:area|region|district|neighborhood)\b/gi
1207
+ ],
1208
+ narrowingFactor: 0.01,
1209
+ explanationTemplate: 'Location "{phrase}" limits geographic scope',
1210
+ suggestionTemplate: "a major metropolitan area"
1211
+ },
1212
+ // Relational context - family, colleagues
1213
+ {
1214
+ type: "relational_context",
1215
+ patterns: [
1216
+ /\b(?:his|her|their)\s+(?:wife|husband|spouse|partner|son|daughter|father|mother|brother|sister)\b/gi,
1217
+ /\b(?:married|divorced|widowed)\s+(?:to|from)\s+[\w\s]+\b/gi,
1218
+ /\b(?:colleague|mentor|mentee|protégé|advisor)\s+(?:of|to)\s+[\w\s]+\b/gi,
1219
+ /\bco-founder\s+with\s+[\w\s]+\b/gi
1220
+ ],
1221
+ narrowingFactor: 0.1,
1222
+ explanationTemplate: 'Relationship context "{phrase}" provides additional linkage',
1223
+ suggestionTemplate: "a family member"
1224
+ },
1225
+ // Unique events - testimony, awards, notable occurrences
1226
+ {
1227
+ type: "unique_event",
1228
+ patterns: [
1229
+ /\btestified\s+(?:before|at|to)\s+(?:the\s+)?[\w\s]+(?:Congress|Senate|Committee|Court)\b/gi,
1230
+ /\bwon\s+(?:the\s+)?[\w\s]+(?:Award|Prize|Medal|Trophy)\b/gi,
1231
+ /\bnamed\s+(?:to|in)\s+(?:the\s+)?[\w\s]+(?:list|ranking|100)\b/gi,
1232
+ /\bfirst\s+(?:woman|man|person|African.American|Asian)\s+to\b/gi,
1233
+ /\bonly\s+(?:person|individual|one)\s+to\s+(?:have\s+)?[\w\s]+\b/gi,
1234
+ /\brecord.setting\b/gi,
1235
+ /\bpioneer(?:ed|ing)?\s+[\w\s]+\b/gi
1236
+ ],
1237
+ narrowingFactor: 1e-5,
1238
+ // Unique events are extremely identifying
1239
+ explanationTemplate: 'Unique event "{phrase}" likely identifies a single individual',
1240
+ suggestionTemplate: "received recognition"
1241
+ },
1242
+ // Demographics - age, gender, ethnicity indicators
1243
+ {
1244
+ type: "demographic",
1245
+ patterns: [
1246
+ /\b(?:youngest|oldest|first)\s+(?:female|male|woman|man)\b/gi,
1247
+ /\b\d{2}.year.old\b/gi,
1248
+ /\bborn\s+in\s+(?:19|20)\d{2}\b/gi,
1249
+ /\bgeneration\s+(?:X|Y|Z|Alpha|Boomer|Millennial)\b/gi
1250
+ ],
1251
+ narrowingFactor: 0.05,
1252
+ explanationTemplate: 'Demographic detail "{phrase}" narrows population',
1253
+ suggestionTemplate: "an individual"
1254
+ },
1255
+ // Achievements - degrees, certifications, publications
1256
+ {
1257
+ type: "achievement",
1258
+ patterns: [
1259
+ /\bPhD\s+(?:in|from)\s+[\w\s]+\b/gi,
1260
+ /\bauthor(?:ed)?\s+(?:of\s+)?["']?[\w\s]+["']?\b/gi,
1261
+ /\bpublished\s+(?:in|by)\s+[\w\s]+\b/gi,
1262
+ /\bpatent(?:ed|s)?\s+(?:for|on)\s+[\w\s]+\b/gi,
1263
+ /\bfounded\s+[\w\s]+\b/gi,
1264
+ /\binvented\s+[\w\s]+\b/gi
1265
+ ],
1266
+ narrowingFactor: 1e-3,
1267
+ explanationTemplate: 'Achievement "{phrase}" is potentially searchable',
1268
+ suggestionTemplate: "has relevant credentials"
1269
+ },
1270
+ // Public roles - elected officials, public figures
1271
+ {
1272
+ type: "public_role",
1273
+ patterns: [
1274
+ /\b(?:senator|congressman|congresswoman|representative|mayor|governor|president)\b/gi,
1275
+ /\b(?:elected|appointed|nominated)\s+(?:to|as)\s+[\w\s]+\b/gi,
1276
+ /\bserved\s+(?:on|as|in)\s+(?:the\s+)?[\w\s]+(?:board|committee|council|commission)\b/gi,
1277
+ /\bformer\s+[\w\s]+(?:secretary|minister|ambassador|director)\b/gi
1278
+ ],
1279
+ narrowingFactor: 1e-4,
1280
+ explanationTemplate: 'Public role "{phrase}" is easily searchable',
1281
+ suggestionTemplate: "held a public position"
1282
+ }
1283
+ ];
1284
+ var SEARCHABILITY_PATTERNS = [
1285
+ // Trivially searchable - direct quotes, exact events
1286
+ {
1287
+ pattern: /["'][\w\s]{10,}["']/g,
1288
+ searchability: "trivial",
1289
+ reason: "Exact quote can be searched directly"
1290
+ },
1291
+ {
1292
+ pattern: /testified\s+(?:before|at|to)\s+[\w\s]+(?:on|about|regarding)\s+[\w\s]+/gi,
1293
+ searchability: "trivial",
1294
+ reason: "Congressional testimony is public record"
1295
+ },
1296
+ {
1297
+ pattern: /(?:filed|settled|won|lost)\s+(?:a\s+)?(?:lawsuit|case|suit)\s+(?:against|with)\s+[\w\s]+/gi,
1298
+ searchability: "trivial",
1299
+ reason: "Legal proceedings are searchable"
1300
+ },
1301
+ {
1302
+ pattern: /(?:awarded|received|won)\s+(?:the\s+)?[\w\s]+(?:Award|Prize|Medal)/gi,
1303
+ searchability: "trivial",
1304
+ reason: "Awards are typically announced publicly"
1305
+ },
1306
+ // Moderately searchable - combinations of attributes
1307
+ {
1308
+ pattern: /(?:CEO|CFO|CTO)\s+(?:of|at)\s+[\w\s]+/gi,
1309
+ searchability: "moderate",
1310
+ reason: "Executive titles at named companies are findable"
1311
+ },
1312
+ {
1313
+ pattern: /(?:professor|researcher)\s+(?:of|at)\s+[\w\s]+University/gi,
1314
+ searchability: "moderate",
1315
+ reason: "Academic positions are often listed online"
1316
+ },
1317
+ // Difficult but possible
1318
+ {
1319
+ pattern: /(?:worked|employed)\s+(?:at|for)\s+[\w\s]+\s+(?:from|during|in)\s+[\w\s]+/gi,
1320
+ searchability: "difficult",
1321
+ reason: "Employment history with dates may appear in professional profiles"
1322
+ }
1323
+ ];
1324
+ var VULNERABLE_SOURCES = [
1325
+ {
1326
+ name: "LinkedIn",
1327
+ dataTypes: ["employment history", "education", "skills", "connections"],
1328
+ matchPatterns: [
1329
+ /\bworked?\s+(?:at|for)\b/gi,
1330
+ /\bemployed\b/gi,
1331
+ /\b(?:CEO|CTO|CFO|Director|Manager|Engineer)\b/gi
1332
+ ]
1333
+ },
1334
+ {
1335
+ name: "Public Records",
1336
+ dataTypes: ["property ownership", "court records", "voter registration"],
1337
+ matchPatterns: [
1338
+ /\bowned\s+property\b/gi,
1339
+ /\bfiled\s+(?:lawsuit|bankruptcy)\b/gi,
1340
+ /\bregistered\s+voter\b/gi
1341
+ ]
1342
+ },
1343
+ {
1344
+ name: "News Archives",
1345
+ dataTypes: ["news mentions", "press releases", "interviews"],
1346
+ matchPatterns: [
1347
+ /\bannounced\b/gi,
1348
+ /\bquoted\b/gi,
1349
+ /\binterview(?:ed)?\b/gi,
1350
+ /\btestified\b/gi
1351
+ ]
1352
+ },
1353
+ {
1354
+ name: "Academic Databases",
1355
+ dataTypes: ["publications", "citations", "institutional affiliations"],
1356
+ matchPatterns: [
1357
+ /\bpublished\b/gi,
1358
+ /\bprofessor\b/gi,
1359
+ /\bresearcher\b/gi,
1360
+ /\bPhD\b/gi
1361
+ ]
1362
+ },
1363
+ {
1364
+ name: "Corporate Filings",
1365
+ dataTypes: ["SEC filings", "board memberships", "executive compensation"],
1366
+ matchPatterns: [
1367
+ /\b(?:CEO|CFO|CTO|COO|board)\b/gi,
1368
+ /\bfiled\s+with\b/gi,
1369
+ /\bpublic(?:ly)?\s+traded\b/gi
1370
+ ]
1371
+ }
1372
+ ];
1373
+ var WORLD_POPULATION = 8e9;
1374
+ var POPULATION_MULTIPLIERS = {
1375
+ profession: 1e-4,
1376
+ // ~800,000 for a specific job title
1377
+ affiliation: 1e-3,
1378
+ // ~8,000,000 for a major company
1379
+ temporal_marker: 0.1,
1380
+ // Narrows to a year or period
1381
+ geographic_signal: 0.01,
1382
+ // ~80,000,000 for a city
1383
+ relational_context: 0.1,
1384
+ // Provides linkage, not direct ID
1385
+ unique_event: 1e-5,
1386
+ // ~80,000 or less for unique achievements
1387
+ demographic: 0.05,
1388
+ // Age/gender combo
1389
+ achievement: 1e-3,
1390
+ // Specific degrees/publications
1391
+ public_role: 1e-4
1392
+ // Public figures are findable
1393
+ };
1394
+ var AdversarialVerifier = class {
1395
+ constructor(config) {
1396
+ this.config = {
1397
+ enabled: true,
1398
+ riskThreshold: 30,
1399
+ maxIterations: 3,
1400
+ autoApplyLowRisk: false,
1401
+ analysisDepth: "standard",
1402
+ enabledAnalyses: {
1403
+ attributeLeakage: true,
1404
+ semanticFingerprinting: true,
1405
+ crossReferenceCheck: true
1406
+ },
1407
+ ...config
1408
+ };
1409
+ }
1410
+ /**
1411
+ * Main entry point: analyze redacted content for re-identification risk
1412
+ */
1413
+ async analyze(sections, appliedRedactions) {
1414
+ const startTime = performance.now();
1415
+ const redactedText = this.simulateRedactedOutput(sections, appliedRedactions);
1416
+ const leakedAttributes = this.config.enabledAnalyses.attributeLeakage ? this.extractLeakedAttributes(redactedText, sections) : [];
1417
+ const semanticFingerprint = this.config.enabledAnalyses.semanticFingerprinting ? this.calculateSemanticFingerprint(redactedText, leakedAttributes) : this.emptySemanticFingerprint();
1418
+ const crossReferenceRisk = this.config.enabledAnalyses.crossReferenceCheck ? this.assessCrossReferenceRisk(redactedText) : this.emptyCrossReferenceRisk();
1419
+ const reidentificationConfidence = this.calculateOverallConfidence(
1420
+ leakedAttributes,
1421
+ semanticFingerprint,
1422
+ crossReferenceRisk
1423
+ );
1424
+ const riskLevel = this.classifyRiskLevel(reidentificationConfidence);
1425
+ const analysis = {
1426
+ id: generateSecureId(),
1427
+ timestamp: Date.now(),
1428
+ reidentificationConfidence,
1429
+ riskLevel,
1430
+ leakedAttributes,
1431
+ semanticFingerprint,
1432
+ crossReferenceRisk,
1433
+ sectionsAnalyzed: sections.map((s) => s.id),
1434
+ processingTimeMs: performance.now() - startTime
1435
+ };
1436
+ const suggestions = this.generateSuggestions(analysis);
1437
+ return {
1438
+ analysis,
1439
+ suggestions,
1440
+ passesThreshold: reidentificationConfidence <= this.config.riskThreshold,
1441
+ riskThreshold: this.config.riskThreshold,
1442
+ iteration: 1
1443
+ };
1444
+ }
1445
+ /**
1446
+ * Simulate what the document looks like after redaction
1447
+ */
1448
+ simulateRedactedOutput(sections, redactions) {
1449
+ const result = /* @__PURE__ */ new Map();
1450
+ for (const section of sections) {
1451
+ let text = section.text;
1452
+ const sectionRedactions = redactions.filter((r) => r.sectionId === section.id).sort((a, b) => b.startOffset - a.startOffset);
1453
+ for (const redaction of sectionRedactions) {
1454
+ text = text.slice(0, redaction.startOffset) + "[REDACTED]" + text.slice(redaction.endOffset);
1455
+ }
1456
+ result.set(section.id, text);
1457
+ }
1458
+ return result;
1459
+ }
1460
+ /**
1461
+ * Extract attributes that leak identity from redacted text
1462
+ */
1463
+ extractLeakedAttributes(redactedText, _sections) {
1464
+ const attributes = [];
1465
+ for (const [sectionId, text] of redactedText) {
1466
+ for (const pattern of ATTRIBUTE_PATTERNS) {
1467
+ for (const regex of pattern.patterns) {
1468
+ regex.lastIndex = 0;
1469
+ let match;
1470
+ while ((match = regex.exec(text)) !== null) {
1471
+ if (this.isInsideRedaction(text, match.index)) {
1472
+ continue;
1473
+ }
1474
+ const phrase = match[0].trim();
1475
+ if (phrase.length < 5) {
1476
+ continue;
1477
+ }
1478
+ attributes.push({
1479
+ type: pattern.type,
1480
+ phrase,
1481
+ narrowingFactor: pattern.narrowingFactor,
1482
+ explanation: pattern.explanationTemplate.replace("{phrase}", phrase),
1483
+ suggestion: pattern.suggestionTemplate,
1484
+ location: {
1485
+ sectionId,
1486
+ startOffset: match.index,
1487
+ endOffset: match.index + match[0].length
1488
+ }
1489
+ });
1490
+ }
1491
+ }
1492
+ }
1493
+ }
1494
+ const seen = /* @__PURE__ */ new Set();
1495
+ return attributes.filter((attr) => {
1496
+ const key = `${attr.type}:${attr.phrase.toLowerCase()}`;
1497
+ if (seen.has(key)) return false;
1498
+ seen.add(key);
1499
+ return true;
1500
+ });
1501
+ }
1502
+ /**
1503
+ * Check if a position is inside a [REDACTED] marker
1504
+ */
1505
+ isInsideRedaction(text, position) {
1506
+ const redactionPattern = /\[REDACTED\]/g;
1507
+ let match;
1508
+ while ((match = redactionPattern.exec(text)) !== null) {
1509
+ if (position >= match.index && position < match.index + match[0].length) {
1510
+ return true;
1511
+ }
1512
+ }
1513
+ return false;
1514
+ }
1515
+ /**
1516
+ * Calculate semantic fingerprint - how unique is this description?
1517
+ */
1518
+ calculateSemanticFingerprint(_redactedText, leakedAttributes) {
1519
+ let populationSize = WORLD_POPULATION;
1520
+ const drivers = [];
1521
+ for (const attr of leakedAttributes) {
1522
+ const narrowingFactor = POPULATION_MULTIPLIERS[attr.type] || attr.narrowingFactor;
1523
+ const newPopulation = populationSize * narrowingFactor;
1524
+ drivers.push({
1525
+ phrase: attr.phrase,
1526
+ impact: this.categorizeImpact(narrowingFactor),
1527
+ narrowingFactor,
1528
+ suggestion: attr.suggestion || "Consider generalizing this phrase"
1529
+ });
1530
+ populationSize = newPopulation;
1531
+ }
1532
+ drivers.sort((a, b) => {
1533
+ const impactOrder = { critical: 0, high: 1, medium: 2, low: 3 };
1534
+ return impactOrder[a.impact] - impactOrder[b.impact];
1535
+ });
1536
+ const riskScore = Math.min(
1537
+ 100,
1538
+ Math.max(0, 100 - Math.log10(Math.max(1, populationSize)) * 10)
1539
+ );
1540
+ return {
1541
+ estimatedPopulationSize: Math.max(1, Math.round(populationSize)),
1542
+ populationDescription: this.describePopulation(populationSize),
1543
+ uniquenessDrivers: drivers.slice(0, 10),
1544
+ // Top 10
1545
+ riskScore,
1546
+ riskLevel: this.classifyRiskLevel(riskScore)
1547
+ };
1548
+ }
1549
+ /**
1550
+ * Categorize the impact of a narrowing factor
1551
+ */
1552
+ categorizeImpact(factor) {
1553
+ if (factor <= 1e-4) return "critical";
1554
+ if (factor <= 1e-3) return "high";
1555
+ if (factor <= 0.01) return "medium";
1556
+ return "low";
1557
+ }
1558
+ /**
1559
+ * Create human-readable population description
1560
+ */
1561
+ describePopulation(size) {
1562
+ if (size <= 1) return "Single individual identifiable";
1563
+ if (size <= 10) return "Fewer than 10 people match";
1564
+ if (size <= 100) return "Fewer than 100 people match";
1565
+ if (size <= 1e3) return "Approximately 1,000 people match";
1566
+ if (size <= 1e4) return "Approximately 10,000 people match";
1567
+ if (size <= 1e5) return "Approximately 100,000 people match";
1568
+ if (size <= 1e6) return "Approximately 1 million people match";
1569
+ return "Large population matches (lower risk)";
1570
+ }
1571
+ /**
1572
+ * Assess cross-reference vulnerability
1573
+ */
1574
+ assessCrossReferenceRisk(redactedText) {
1575
+ const fullText = Array.from(redactedText.values()).join(" ");
1576
+ const searchableFragments = [];
1577
+ const vulnerableSources = [];
1578
+ for (const pattern of SEARCHABILITY_PATTERNS) {
1579
+ pattern.pattern.lastIndex = 0;
1580
+ let match;
1581
+ while ((match = pattern.pattern.exec(fullText)) !== null) {
1582
+ if (!this.isInsideRedaction(fullText, match.index)) {
1583
+ searchableFragments.push({
1584
+ fragment: match[0].trim(),
1585
+ searchability: pattern.searchability,
1586
+ reason: pattern.reason,
1587
+ predictedResults: this.predictSearchResults(
1588
+ match[0],
1589
+ pattern.searchability
1590
+ )
1591
+ });
1592
+ }
1593
+ }
1594
+ }
1595
+ for (const source of VULNERABLE_SOURCES) {
1596
+ const matchingDataPoints = [];
1597
+ for (const pattern of source.matchPatterns) {
1598
+ pattern.lastIndex = 0;
1599
+ if (pattern.test(fullText)) {
1600
+ matchingDataPoints.push(
1601
+ ...source.dataTypes.filter(
1602
+ (_dt) => source.matchPatterns.some((p) => {
1603
+ p.lastIndex = 0;
1604
+ return p.test(fullText);
1605
+ })
1606
+ )
1607
+ );
1608
+ }
1609
+ }
1610
+ if (matchingDataPoints.length > 0) {
1611
+ vulnerableSources.push({
1612
+ source: source.name,
1613
+ matchLikelihood: this.assessMatchLikelihood(matchingDataPoints.length),
1614
+ dataPoints: [...new Set(matchingDataPoints)]
1615
+ });
1616
+ }
1617
+ }
1618
+ const trivialCount = searchableFragments.filter(
1619
+ (f) => f.searchability === "trivial"
1620
+ ).length;
1621
+ const moderateCount = searchableFragments.filter(
1622
+ (f) => f.searchability === "moderate"
1623
+ ).length;
1624
+ const riskScore = Math.min(
1625
+ 100,
1626
+ trivialCount * 30 + moderateCount * 15 + vulnerableSources.length * 10
1627
+ );
1628
+ return {
1629
+ searchableFragments: searchableFragments.slice(0, 10),
1630
+ vulnerableSources,
1631
+ riskScore
1632
+ };
1633
+ }
1634
+ /**
1635
+ * Predict what a search might return
1636
+ */
1637
+ predictSearchResults(_fragment, searchability) {
1638
+ switch (searchability) {
1639
+ case "trivial":
1640
+ return "Direct search likely returns exact match";
1641
+ case "moderate":
1642
+ return "Search combined with other context may identify";
1643
+ case "difficult":
1644
+ return "Requires additional context to narrow results";
1645
+ }
1646
+ }
1647
+ /**
1648
+ * Assess likelihood of database match
1649
+ */
1650
+ assessMatchLikelihood(dataPointCount) {
1651
+ if (dataPointCount >= 3) return "certain";
1652
+ if (dataPointCount === 2) return "likely";
1653
+ if (dataPointCount === 1) return "possible";
1654
+ return "unlikely";
1655
+ }
1656
+ /**
1657
+ * Calculate overall re-identification confidence
1658
+ */
1659
+ calculateOverallConfidence(leakedAttributes, semanticFingerprint, crossReferenceRisk) {
1660
+ const weights = {
1661
+ semantic: 0.5,
1662
+ // How unique is the description?
1663
+ crossRef: 0.3,
1664
+ // How searchable?
1665
+ attributeCount: 0.2
1666
+ // Raw attribute count factor
1667
+ };
1668
+ const attributeRisk = Math.min(100, leakedAttributes.length * 10);
1669
+ return Math.round(
1670
+ weights.semantic * semanticFingerprint.riskScore + weights.crossRef * crossReferenceRisk.riskScore + weights.attributeCount * attributeRisk
1671
+ );
1672
+ }
1673
+ /**
1674
+ * Classify risk level based on confidence score
1675
+ */
1676
+ classifyRiskLevel(confidence) {
1677
+ if (confidence >= 80) return "critical";
1678
+ if (confidence >= 60) return "high";
1679
+ if (confidence >= 40) return "medium";
1680
+ if (confidence >= 20) return "low";
1681
+ return "minimal";
1682
+ }
1683
+ /**
1684
+ * Generate suggestions for reducing risk
1685
+ */
1686
+ generateSuggestions(analysis) {
1687
+ const suggestions = [];
1688
+ let priority = 1;
1689
+ for (const driver of analysis.semanticFingerprint.uniquenessDrivers) {
1690
+ if (driver.impact === "critical" || driver.impact === "high") {
1691
+ const attr = analysis.leakedAttributes.find(
1692
+ (a) => a.phrase === driver.phrase
1693
+ );
1694
+ if (attr) {
1695
+ suggestions.push({
1696
+ id: generateSecureId(),
1697
+ type: "generalize",
1698
+ priority: priority++,
1699
+ originalPhrase: driver.phrase,
1700
+ suggestedReplacement: driver.suggestion,
1701
+ expectedRiskReduction: this.estimateRiskReduction(driver.impact),
1702
+ location: attr.location,
1703
+ rationale: `This phrase narrows identification to approximately ${Math.round(
1704
+ analysis.semanticFingerprint.estimatedPopulationSize * driver.narrowingFactor
1705
+ ).toLocaleString()} people`,
1706
+ accepted: false
1707
+ });
1708
+ }
1709
+ }
1710
+ }
1711
+ for (const fragment of analysis.crossReferenceRisk.searchableFragments) {
1712
+ if (fragment.searchability === "trivial") {
1713
+ suggestions.push({
1714
+ id: generateSecureId(),
1715
+ type: "redact",
1716
+ priority: priority++,
1717
+ originalPhrase: fragment.fragment,
1718
+ suggestedReplacement: "[CONTEXT REDACTED]",
1719
+ expectedRiskReduction: 15,
1720
+ location: {
1721
+ sectionId: "",
1722
+ // Would need to track this
1723
+ startOffset: 0,
1724
+ endOffset: 0
1725
+ },
1726
+ rationale: fragment.reason,
1727
+ accepted: false
1728
+ });
1729
+ }
1730
+ }
1731
+ return suggestions.sort((a, b) => a.priority - b.priority).slice(0, 10);
1732
+ }
1733
+ /**
1734
+ * Estimate risk reduction from applying a suggestion
1735
+ */
1736
+ estimateRiskReduction(impact) {
1737
+ switch (impact) {
1738
+ case "critical":
1739
+ return 25;
1740
+ case "high":
1741
+ return 15;
1742
+ case "medium":
1743
+ return 8;
1744
+ case "low":
1745
+ return 3;
1746
+ }
1747
+ }
1748
+ /**
1749
+ * Empty semantic fingerprint for when analysis is disabled
1750
+ */
1751
+ emptySemanticFingerprint() {
1752
+ return {
1753
+ estimatedPopulationSize: WORLD_POPULATION,
1754
+ populationDescription: "Analysis disabled",
1755
+ uniquenessDrivers: [],
1756
+ riskScore: 0,
1757
+ riskLevel: "minimal"
1758
+ };
1759
+ }
1760
+ /**
1761
+ * Empty cross-reference risk for when analysis is disabled
1762
+ */
1763
+ emptyCrossReferenceRisk() {
1764
+ return {
1765
+ searchableFragments: [],
1766
+ vulnerableSources: [],
1767
+ riskScore: 0
1768
+ };
1769
+ }
1770
+ /**
1771
+ * Update configuration
1772
+ */
1773
+ setConfig(config) {
1774
+ this.config = { ...this.config, ...config };
1775
+ }
1776
+ /**
1777
+ * Get current configuration
1778
+ */
1779
+ getConfig() {
1780
+ return { ...this.config };
1781
+ }
1782
+ };
1783
+ var adversarialVerifier = new AdversarialVerifier();
1784
+
1785
+ // src/hooks/useDocumentProcessor.ts
1786
+ import { useCallback as useCallback3, useRef, useState as useState3, useEffect as useEffect2 } from "react";
1787
+
1788
+ // src/hooks/useFileEntropy.ts
1789
+ import { useCallback, useMemo, useState } from "react";
1790
+ var DEFAULT_BLOCK_SIZE = 256;
1791
+ var MAX_ENTROPY = 8;
1792
+ function calculateBlockEntropy(bytes) {
1793
+ if (bytes.length === 0) return 0;
1794
+ const freq = /* @__PURE__ */ new Map();
1795
+ for (let i = 0; i < bytes.length; i++) {
1796
+ const byte = bytes[i];
1797
+ freq.set(byte, (freq.get(byte) || 0) + 1);
1798
+ }
1799
+ let entropy = 0;
1800
+ const len = bytes.length;
1801
+ for (const count of freq.values()) {
1802
+ const p = count / len;
1803
+ entropy -= p * Math.log2(p);
1804
+ }
1805
+ return entropy;
1806
+ }
1807
+ function normalizeEntropy(entropy) {
1808
+ return Math.min(1, Math.max(0, entropy / MAX_ENTROPY));
1809
+ }
1810
+ function blockContainsPII(blockOffset, blockSize, detections) {
1811
+ const blockEnd = blockOffset + blockSize;
1812
+ for (const detection of detections) {
1813
+ if (detection.startOffset < blockEnd && detection.endOffset > blockOffset) {
1814
+ return true;
1815
+ }
1816
+ }
1817
+ return false;
1818
+ }
1819
+ function useFileEntropy(options = {}) {
1820
+ const { blockSize = DEFAULT_BLOCK_SIZE } = options;
1821
+ const [isCalculating, setIsCalculating] = useState(false);
1822
+ const [error, setError] = useState(null);
1823
+ const calculateEntropy = useCallback(
1824
+ (bytes, detections = []) => {
1825
+ if (bytes.length === 0) {
1826
+ return {
1827
+ blocks: [],
1828
+ globalEntropy: 0,
1829
+ maxEntropy: 0,
1830
+ minEntropy: 0,
1831
+ totalBytes: 0,
1832
+ blockSize
1833
+ };
1834
+ }
1835
+ const blocks = [];
1836
+ let minEntropy = MAX_ENTROPY;
1837
+ let maxEntropy = 0;
1838
+ const numBlocks = Math.ceil(bytes.length / blockSize);
1839
+ for (let i = 0; i < numBlocks; i++) {
1840
+ const offset = i * blockSize;
1841
+ const end = Math.min(offset + blockSize, bytes.length);
1842
+ const blockBytes = bytes.slice(offset, end);
1843
+ const entropy = calculateBlockEntropy(blockBytes);
1844
+ const normalizedEntropy = normalizeEntropy(entropy);
1845
+ minEntropy = Math.min(minEntropy, entropy);
1846
+ maxEntropy = Math.max(maxEntropy, entropy);
1847
+ blocks.push({
1848
+ index: i,
1849
+ entropy,
1850
+ normalizedEntropy,
1851
+ offset,
1852
+ size: end - offset,
1853
+ containsPII: blockContainsPII(offset, end - offset, detections)
1854
+ });
1855
+ }
1856
+ const globalEntropy = calculateBlockEntropy(bytes);
1857
+ return {
1858
+ blocks,
1859
+ globalEntropy,
1860
+ maxEntropy,
1861
+ minEntropy: blocks.length > 0 ? minEntropy : 0,
1862
+ totalBytes: bytes.length,
1863
+ blockSize
1864
+ };
1865
+ },
1866
+ [blockSize]
1867
+ );
1868
+ const calculateFileEntropy = useCallback(
1869
+ async (file, detections = []) => {
1870
+ setIsCalculating(true);
1871
+ setError(null);
1872
+ try {
1873
+ const arrayBuffer = await file.arrayBuffer();
1874
+ const bytes = new Uint8Array(arrayBuffer);
1875
+ return calculateEntropy(bytes, detections);
1876
+ } catch (err) {
1877
+ const error2 = err instanceof Error ? err : new Error("Failed to read file");
1878
+ setError(error2);
1879
+ throw error2;
1880
+ } finally {
1881
+ setIsCalculating(false);
1882
+ }
1883
+ },
1884
+ [calculateEntropy]
1885
+ );
1886
+ const calculateTextEntropy = useCallback(
1887
+ (text, detections = []) => {
1888
+ const encoder = new TextEncoder();
1889
+ const bytes = encoder.encode(text);
1890
+ return calculateEntropy(bytes, detections);
1891
+ },
1892
+ [calculateEntropy]
1893
+ );
1894
+ const compareEntropy = useCallback(
1895
+ (before, after) => {
1896
+ const changedRegions = [];
1897
+ if (before && after) {
1898
+ const minBlocks = Math.min(before.blocks.length, after.blocks.length);
1899
+ let regionStart = null;
1900
+ let regionDrop = 0;
1901
+ for (let i = 0; i < minBlocks; i++) {
1902
+ const entropyDrop = before.blocks[i].entropy - after.blocks[i].entropy;
1903
+ if (entropyDrop > 1) {
1904
+ if (regionStart === null) {
1905
+ regionStart = i;
1906
+ regionDrop = entropyDrop;
1907
+ } else {
1908
+ regionDrop = Math.max(regionDrop, entropyDrop);
1909
+ }
1910
+ } else if (regionStart !== null) {
1911
+ changedRegions.push({
1912
+ startBlock: regionStart,
1913
+ endBlock: i - 1,
1914
+ entropyDrop: regionDrop
1915
+ });
1916
+ regionStart = null;
1917
+ regionDrop = 0;
1918
+ }
1919
+ }
1920
+ if (regionStart !== null) {
1921
+ changedRegions.push({
1922
+ startBlock: regionStart,
1923
+ endBlock: minBlocks - 1,
1924
+ entropyDrop: regionDrop
1925
+ });
1926
+ }
1927
+ }
1928
+ return {
1929
+ before,
1930
+ after,
1931
+ changedRegions
1932
+ };
1933
+ },
1934
+ []
1935
+ );
1936
+ return useMemo(
1937
+ () => ({
1938
+ calculateEntropy,
1939
+ calculateFileEntropy,
1940
+ calculateTextEntropy,
1941
+ compareEntropy,
1942
+ isCalculating,
1943
+ error
1944
+ }),
1945
+ [
1946
+ calculateEntropy,
1947
+ calculateFileEntropy,
1948
+ calculateTextEntropy,
1949
+ compareEntropy,
1950
+ isCalculating,
1951
+ error
1952
+ ]
1953
+ );
1954
+ }
1955
+
1956
+ // src/hooks/useOfflineQuota.ts
1957
+ import { useState as useState2, useEffect, useCallback as useCallback2 } from "react";
1958
+
1959
+ // src/services/quota/DeviceFingerprint.ts
1960
+ async function generateDeviceFingerprint() {
1961
+ const components = [
1962
+ // User agent (browser + OS)
1963
+ navigator.userAgent,
1964
+ // Language preference
1965
+ navigator.language,
1966
+ // Screen dimensions (stable across sessions)
1967
+ `${screen.width}x${screen.height}x${screen.colorDepth}`,
1968
+ // Timezone
1969
+ Intl.DateTimeFormat().resolvedOptions().timeZone,
1970
+ // Platform
1971
+ navigator.platform,
1972
+ // Hardware concurrency (CPU cores)
1973
+ navigator.hardwareConcurrency?.toString() || "unknown",
1974
+ // Device memory (if available)
1975
+ navigator.deviceMemory?.toString() || "unknown"
1976
+ ];
1977
+ const canvasFingerprint = await getCanvasFingerprint();
1978
+ if (canvasFingerprint) {
1979
+ components.push(canvasFingerprint);
1980
+ }
1981
+ const combined = components.join("|");
1982
+ const hash = await sha256(combined);
1983
+ return hash.substring(0, 16);
1984
+ }
1985
+ async function getCanvasFingerprint() {
1986
+ try {
1987
+ const canvas = document.createElement("canvas");
1988
+ canvas.width = 200;
1989
+ canvas.height = 50;
1990
+ const ctx = canvas.getContext("2d");
1991
+ if (!ctx) return null;
1992
+ ctx.textBaseline = "top";
1993
+ ctx.font = "14px Arial";
1994
+ ctx.fillStyle = "#f60";
1995
+ ctx.fillRect(10, 1, 62, 20);
1996
+ ctx.fillStyle = "#069";
1997
+ ctx.fillText("PURGE", 15, 5);
1998
+ ctx.fillStyle = "rgba(102, 204, 0, 0.7)";
1999
+ ctx.fillText("quota", 60, 5);
2000
+ const dataUrl = canvas.toDataURL();
2001
+ return await sha256(dataUrl);
2002
+ } catch {
2003
+ return null;
2004
+ }
2005
+ }
2006
+ async function sha256(message) {
2007
+ const encoder = new TextEncoder();
2008
+ const data = encoder.encode(message);
2009
+ const hashBuffer = await crypto.subtle.digest("SHA-256", data);
2010
+ const hashArray = Array.from(new Uint8Array(hashBuffer));
2011
+ return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
2012
+ }
2013
+ async function verifyDeviceFingerprint(storedFingerprint) {
2014
+ const currentFingerprint = await generateDeviceFingerprint();
2015
+ if (currentFingerprint === storedFingerprint) {
2016
+ return { matches: true, similarity: 1 };
2017
+ }
2018
+ let matching = 0;
2019
+ for (let i = 0; i < Math.min(currentFingerprint.length, storedFingerprint.length); i++) {
2020
+ if (currentFingerprint[i] === storedFingerprint[i]) {
2021
+ matching++;
2022
+ }
2023
+ }
2024
+ const similarity = matching / Math.max(currentFingerprint.length, storedFingerprint.length);
2025
+ return {
2026
+ matches: similarity >= 0.75,
2027
+ similarity
2028
+ };
2029
+ }
2030
+
2031
+ // src/services/quota/OfflineQuotaStore.ts
2032
+ var LS_KEY = "purge_offline_quota";
2033
+ var IDB_NAME = "purge_quota_db";
2034
+ var IDB_STORE = "quota";
2035
+ var IDB_KEY = "current";
2036
+ var SCHEMA_VERSION = 1;
2037
+ var DEFAULT_TOKENS = 50;
2038
+ var HMAC_SALT = "purge_quota_v1_";
2039
+ async function deriveKey(fingerprint) {
2040
+ const encoder = new TextEncoder();
2041
+ const keyMaterial = encoder.encode(HMAC_SALT + fingerprint);
2042
+ const rawKey = await crypto.subtle.importKey(
2043
+ "raw",
2044
+ keyMaterial,
2045
+ { name: "HMAC", hash: "SHA-256" },
2046
+ false,
2047
+ ["sign", "verify"]
2048
+ );
2049
+ return rawKey;
2050
+ }
2051
+ async function signState(state, fingerprint) {
2052
+ const key = await deriveKey(fingerprint);
2053
+ const encoder = new TextEncoder();
2054
+ const data = encoder.encode(JSON.stringify(state));
2055
+ const signature = await crypto.subtle.sign("HMAC", key, data);
2056
+ const signatureArray = Array.from(new Uint8Array(signature));
2057
+ return signatureArray.map((b) => b.toString(16).padStart(2, "0")).join("");
2058
+ }
2059
+ async function verifySignature(signed, fingerprint) {
2060
+ try {
2061
+ const expectedSignature = await signState(signed.data, fingerprint);
2062
+ return expectedSignature === signed.signature;
2063
+ } catch {
2064
+ return false;
2065
+ }
2066
+ }
2067
+ function openDatabase() {
2068
+ return new Promise((resolve, reject) => {
2069
+ const request = indexedDB.open(IDB_NAME, 1);
2070
+ request.onerror = () => reject(request.error);
2071
+ request.onsuccess = () => resolve(request.result);
2072
+ request.onupgradeneeded = (event) => {
2073
+ const db = event.target.result;
2074
+ if (!db.objectStoreNames.contains(IDB_STORE)) {
2075
+ db.createObjectStore(IDB_STORE);
2076
+ }
2077
+ };
2078
+ });
2079
+ }
2080
+ async function readFromIDB() {
2081
+ try {
2082
+ const db = await openDatabase();
2083
+ return new Promise((resolve, reject) => {
2084
+ const tx = db.transaction(IDB_STORE, "readonly");
2085
+ const store = tx.objectStore(IDB_STORE);
2086
+ const request = store.get(IDB_KEY);
2087
+ request.onerror = () => reject(request.error);
2088
+ request.onsuccess = () => resolve(request.result || null);
2089
+ tx.oncomplete = () => db.close();
2090
+ });
2091
+ } catch {
2092
+ return null;
2093
+ }
2094
+ }
2095
+ async function writeToIDB(signed) {
2096
+ try {
2097
+ const db = await openDatabase();
2098
+ return new Promise((resolve, reject) => {
2099
+ const tx = db.transaction(IDB_STORE, "readwrite");
2100
+ const store = tx.objectStore(IDB_STORE);
2101
+ const request = store.put(signed, IDB_KEY);
2102
+ request.onerror = () => reject(request.error);
2103
+ tx.oncomplete = () => {
2104
+ db.close();
2105
+ resolve();
2106
+ };
2107
+ });
2108
+ } catch {
2109
+ }
2110
+ }
2111
+ async function clearIDB() {
2112
+ try {
2113
+ const db = await openDatabase();
2114
+ return new Promise((resolve, reject) => {
2115
+ const tx = db.transaction(IDB_STORE, "readwrite");
2116
+ const store = tx.objectStore(IDB_STORE);
2117
+ const request = store.delete(IDB_KEY);
2118
+ request.onerror = () => reject(request.error);
2119
+ tx.oncomplete = () => {
2120
+ db.close();
2121
+ resolve();
2122
+ };
2123
+ });
2124
+ } catch {
2125
+ }
2126
+ }
2127
+ var OfflineQuotaStore = class {
2128
+ constructor() {
2129
+ this.cachedState = null;
2130
+ this.fingerprint = null;
2131
+ }
2132
+ /**
2133
+ * Initializes the store, loading existing state or creating new
2134
+ */
2135
+ async initialize() {
2136
+ this.fingerprint = await generateDeviceFingerprint();
2137
+ const state = await this.load();
2138
+ if (state) {
2139
+ this.cachedState = state;
2140
+ return state;
2141
+ }
2142
+ const newState = await this.createInitialState();
2143
+ this.cachedState = newState;
2144
+ return newState;
2145
+ }
2146
+ /**
2147
+ * Gets current tokens remaining (cached for performance)
2148
+ */
2149
+ getTokens() {
2150
+ return this.cachedState?.tokensRemaining ?? 0;
2151
+ }
2152
+ /**
2153
+ * Checks if processing is allowed
2154
+ */
2155
+ canProcess() {
2156
+ return (this.cachedState?.tokensRemaining ?? 0) > 0;
2157
+ }
2158
+ /**
2159
+ * Decrements quota by 1 token. Returns new remaining count.
2160
+ * Throws if quota is exhausted.
2161
+ */
2162
+ async decrement() {
2163
+ if (!this.cachedState || !this.fingerprint) {
2164
+ throw new Error("QuotaStore not initialized");
2165
+ }
2166
+ if (this.cachedState.tokensRemaining <= 0) {
2167
+ throw new QuotaExhaustedError("No tokens remaining");
2168
+ }
2169
+ const newState = {
2170
+ ...this.cachedState,
2171
+ tokensRemaining: this.cachedState.tokensRemaining - 1
2172
+ };
2173
+ await this.save(newState);
2174
+ this.cachedState = newState;
2175
+ return newState.tokensRemaining;
2176
+ }
2177
+ /**
2178
+ * Resets quota to specified token count (used by refresh API)
2179
+ */
2180
+ async reset(tokens) {
2181
+ if (!this.fingerprint) {
2182
+ this.fingerprint = await generateDeviceFingerprint();
2183
+ }
2184
+ const newState = {
2185
+ tokensRemaining: Math.min(tokens, DEFAULT_TOKENS),
2186
+ lastRefresh: (/* @__PURE__ */ new Date()).toISOString(),
2187
+ deviceFingerprint: this.fingerprint,
2188
+ version: SCHEMA_VERSION
2189
+ };
2190
+ await this.save(newState);
2191
+ this.cachedState = newState;
2192
+ }
2193
+ /**
2194
+ * Loads and validates quota state from storage
2195
+ */
2196
+ async load() {
2197
+ if (!this.fingerprint) {
2198
+ this.fingerprint = await generateDeviceFingerprint();
2199
+ }
2200
+ const lsData = this.loadFromLocalStorage();
2201
+ const idbData = await readFromIDB();
2202
+ if (!lsData && !idbData) {
2203
+ return null;
2204
+ }
2205
+ if (lsData && !idbData) {
2206
+ const valid = await this.validateState(lsData);
2207
+ if (valid) {
2208
+ await writeToIDB(lsData);
2209
+ return lsData.data;
2210
+ }
2211
+ await this.handleTampering("localStorage signature invalid");
2212
+ return null;
2213
+ }
2214
+ if (!lsData && idbData) {
2215
+ const valid = await this.validateState(idbData);
2216
+ if (valid) {
2217
+ this.saveToLocalStorage(idbData);
2218
+ return idbData.data;
2219
+ }
2220
+ await this.handleTampering("IndexedDB signature invalid");
2221
+ return null;
2222
+ }
2223
+ if (lsData && idbData) {
2224
+ const lsValid = await this.validateState(lsData);
2225
+ const idbValid = await this.validateState(idbData);
2226
+ if (!lsValid && !idbValid) {
2227
+ await this.handleTampering("Both storage locations tampered");
2228
+ return null;
2229
+ }
2230
+ if (lsValid && !idbValid) {
2231
+ await writeToIDB(lsData);
2232
+ return lsData.data;
2233
+ }
2234
+ if (!lsValid && idbValid) {
2235
+ this.saveToLocalStorage(idbData);
2236
+ return idbData.data;
2237
+ }
2238
+ if (lsData.data.tokensRemaining !== idbData.data.tokensRemaining) {
2239
+ const trustedState = lsData.data.tokensRemaining < idbData.data.tokensRemaining ? lsData : idbData;
2240
+ this.saveToLocalStorage(trustedState);
2241
+ await writeToIDB(trustedState);
2242
+ return trustedState.data;
2243
+ }
2244
+ return lsData.data;
2245
+ }
2246
+ return null;
2247
+ }
2248
+ /**
2249
+ * Saves quota state to both storage locations
2250
+ */
2251
+ async save(state) {
2252
+ if (!this.fingerprint) {
2253
+ throw new Error("Fingerprint not initialized");
2254
+ }
2255
+ const signature = await signState(state, this.fingerprint);
2256
+ const signed = { data: state, signature };
2257
+ this.saveToLocalStorage(signed);
2258
+ await writeToIDB(signed);
2259
+ }
2260
+ /**
2261
+ * Creates initial state for new users
2262
+ */
2263
+ async createInitialState() {
2264
+ if (!this.fingerprint) {
2265
+ this.fingerprint = await generateDeviceFingerprint();
2266
+ }
2267
+ const state = {
2268
+ tokensRemaining: DEFAULT_TOKENS,
2269
+ lastRefresh: (/* @__PURE__ */ new Date()).toISOString(),
2270
+ deviceFingerprint: this.fingerprint,
2271
+ version: SCHEMA_VERSION
2272
+ };
2273
+ await this.save(state);
2274
+ return state;
2275
+ }
2276
+ /**
2277
+ * Validates a signed state
2278
+ */
2279
+ async validateState(signed) {
2280
+ if (!this.fingerprint) return false;
2281
+ const signatureValid = await verifySignature(signed, this.fingerprint);
2282
+ if (!signatureValid) return false;
2283
+ const fpResult = await verifyDeviceFingerprint(signed.data.deviceFingerprint);
2284
+ if (!fpResult.matches) return false;
2285
+ if (signed.data.version !== SCHEMA_VERSION) {
2286
+ return false;
2287
+ }
2288
+ if (signed.data.tokensRemaining < 0 || signed.data.tokensRemaining > DEFAULT_TOKENS) {
2289
+ return false;
2290
+ }
2291
+ return true;
2292
+ }
2293
+ /**
2294
+ * Handles detected tampering by resetting quota to 0
2295
+ */
2296
+ async handleTampering(reason) {
2297
+ secureWarn(`Quota tampering detected: ${reason}`);
2298
+ const zeroState = {
2299
+ tokensRemaining: 0,
2300
+ lastRefresh: (/* @__PURE__ */ new Date()).toISOString(),
2301
+ deviceFingerprint: this.fingerprint || await generateDeviceFingerprint(),
2302
+ version: SCHEMA_VERSION
2303
+ };
2304
+ await this.save(zeroState);
2305
+ this.cachedState = zeroState;
2306
+ }
2307
+ /**
2308
+ * LocalStorage helpers
2309
+ */
2310
+ loadFromLocalStorage() {
2311
+ try {
2312
+ const raw = localStorage.getItem(LS_KEY);
2313
+ if (!raw) return null;
2314
+ return JSON.parse(raw);
2315
+ } catch {
2316
+ return null;
2317
+ }
2318
+ }
2319
+ saveToLocalStorage(signed) {
2320
+ try {
2321
+ localStorage.setItem(LS_KEY, JSON.stringify(signed));
2322
+ } catch {
2323
+ }
2324
+ }
2325
+ /**
2326
+ * Clears all quota data (for testing/debugging)
2327
+ */
2328
+ async clear() {
2329
+ localStorage.removeItem(LS_KEY);
2330
+ await clearIDB();
2331
+ this.cachedState = null;
2332
+ }
2333
+ };
2334
+ var QuotaExhaustedError = class extends Error {
2335
+ constructor(message) {
2336
+ super(message);
2337
+ this.name = "QuotaExhaustedError";
2338
+ }
2339
+ };
2340
+ var offlineQuotaStore = new OfflineQuotaStore();
2341
+
2342
+ // src/services/quota/QuotaRefreshAPI.ts
2343
+ async function checkOnlineStatus() {
2344
+ if (!navigator.onLine) {
2345
+ return false;
2346
+ }
2347
+ return true;
2348
+ }
2349
+ async function requestQuotaRefresh() {
2350
+ const isOnline = await checkOnlineStatus();
2351
+ if (!isOnline) {
2352
+ return {
2353
+ success: false,
2354
+ reason: "offline"
2355
+ };
2356
+ }
2357
+ return {
2358
+ success: false,
2359
+ reason: "not_authenticated"
2360
+ };
2361
+ }
2362
+
2363
+ // src/hooks/useOfflineQuota.ts
2364
+ function useOfflineQuota() {
2365
+ const [tokensRemaining, setTokensRemaining] = useState2(0);
2366
+ const [isInitialized, setIsInitialized] = useState2(false);
2367
+ const [quotaError, setQuotaError] = useState2(null);
2368
+ const [lastRefresh, setLastRefresh] = useState2(null);
2369
+ useEffect(() => {
2370
+ let mounted = true;
2371
+ async function init() {
2372
+ try {
2373
+ const state = await offlineQuotaStore.initialize();
2374
+ if (mounted) {
2375
+ setTokensRemaining(state.tokensRemaining);
2376
+ setLastRefresh(state.lastRefresh);
2377
+ setIsInitialized(true);
2378
+ setQuotaError(null);
2379
+ }
2380
+ } catch (error) {
2381
+ if (mounted) {
2382
+ setQuotaError(
2383
+ error instanceof Error ? error.message : "Failed to initialize quota"
2384
+ );
2385
+ setIsInitialized(true);
2386
+ setTokensRemaining(0);
2387
+ }
2388
+ }
2389
+ }
2390
+ init();
2391
+ return () => {
2392
+ mounted = false;
2393
+ };
2394
+ }, []);
2395
+ const canProcessFile = useCallback2(() => {
2396
+ if (!isInitialized) return false;
2397
+ return offlineQuotaStore.canProcess();
2398
+ }, [isInitialized]);
2399
+ const consumeToken = useCallback2(async () => {
2400
+ try {
2401
+ const remaining = await offlineQuotaStore.decrement();
2402
+ setTokensRemaining(remaining);
2403
+ setQuotaError(null);
2404
+ } catch (error) {
2405
+ if (error instanceof QuotaExhaustedError) {
2406
+ setTokensRemaining(0);
2407
+ setQuotaError("Offline quota exhausted. Go online to continue.");
2408
+ } else {
2409
+ setQuotaError(
2410
+ error instanceof Error ? error.message : "Failed to update quota"
2411
+ );
2412
+ }
2413
+ throw error;
2414
+ }
2415
+ }, []);
2416
+ const requestRefresh = useCallback2(async () => {
2417
+ try {
2418
+ const result = await requestQuotaRefresh();
2419
+ if (result.success) {
2420
+ await offlineQuotaStore.reset(result.tokens);
2421
+ setTokensRemaining(result.tokens);
2422
+ setLastRefresh((/* @__PURE__ */ new Date()).toISOString());
2423
+ setQuotaError(null);
2424
+ }
2425
+ return result;
2426
+ } catch (error) {
2427
+ const errorResult = {
2428
+ success: false,
2429
+ reason: "api_error"
2430
+ };
2431
+ setQuotaError(
2432
+ error instanceof Error ? error.message : "Failed to refresh quota"
2433
+ );
2434
+ return errorResult;
2435
+ }
2436
+ }, []);
2437
+ return {
2438
+ tokensRemaining,
2439
+ isExhausted: tokensRemaining <= 0,
2440
+ isInitialized,
2441
+ canProcessFile,
2442
+ consumeToken,
2443
+ requestRefresh,
2444
+ quotaError,
2445
+ lastRefresh
2446
+ };
2447
+ }
2448
+
2449
+ // src/utils/partialMask.ts
2450
+ var MASK_CHAR = "*";
2451
+ function maskSSN(value) {
2452
+ const digits = value.replace(/\D/g, "");
2453
+ if (digits.length !== 9) {
2454
+ return MASK_CHAR.repeat(value.length);
2455
+ }
2456
+ const last4 = digits.slice(-4);
2457
+ return `${MASK_CHAR}${MASK_CHAR}${MASK_CHAR}-${MASK_CHAR}${MASK_CHAR}-${last4}`;
2458
+ }
2459
+ function maskCreditCard(value) {
2460
+ const digits = value.replace(/\D/g, "");
2461
+ if (digits.length < 15 || digits.length > 16) {
2462
+ return MASK_CHAR.repeat(value.length);
2463
+ }
2464
+ const last4 = digits.slice(-4);
2465
+ if (value.includes("-")) {
2466
+ if (digits.length === 16) {
2467
+ return `${MASK_CHAR.repeat(4)}-${MASK_CHAR.repeat(4)}-${MASK_CHAR.repeat(4)}-${last4}`;
2468
+ }
2469
+ return `${MASK_CHAR.repeat(4)}-${MASK_CHAR.repeat(6)}-${MASK_CHAR}${last4}`;
2470
+ } else if (value.includes(" ")) {
2471
+ if (digits.length === 16) {
2472
+ return `${MASK_CHAR.repeat(4)} ${MASK_CHAR.repeat(4)} ${MASK_CHAR.repeat(4)} ${last4}`;
2473
+ }
2474
+ return `${MASK_CHAR.repeat(4)} ${MASK_CHAR.repeat(6)} ${MASK_CHAR}${last4}`;
2475
+ }
2476
+ return MASK_CHAR.repeat(digits.length - 4) + last4;
2477
+ }
2478
+ function maskEmail(value) {
2479
+ const atIndex = value.indexOf("@");
2480
+ if (atIndex < 1) {
2481
+ return MASK_CHAR.repeat(value.length);
2482
+ }
2483
+ const localPart = value.slice(0, atIndex);
2484
+ const domain = value.slice(atIndex);
2485
+ if (localPart.length <= 1) {
2486
+ return localPart + MASK_CHAR.repeat(3) + domain;
2487
+ }
2488
+ const firstChar = localPart[0];
2489
+ const maskLength = Math.min(localPart.length - 1, 5);
2490
+ return firstChar + MASK_CHAR.repeat(maskLength) + domain;
2491
+ }
2492
+ function maskPhone(value) {
2493
+ const digits = value.replace(/\D/g, "");
2494
+ if (digits.length < 10) {
2495
+ return MASK_CHAR.repeat(value.length);
2496
+ }
2497
+ const last4 = digits.slice(-4);
2498
+ if (value.includes("(") && value.includes(")")) {
2499
+ return `(${MASK_CHAR.repeat(3)}) ${MASK_CHAR.repeat(3)}-${last4}`;
2500
+ } else if (value.startsWith("+")) {
2501
+ const countryCodeMatch = value.match(/^\+\d+/);
2502
+ const countryCode = countryCodeMatch ? countryCodeMatch[0] : "+1";
2503
+ return `${countryCode}-${MASK_CHAR.repeat(3)}-${MASK_CHAR.repeat(3)}-${last4}`;
2504
+ } else if (value.includes("-")) {
2505
+ return `${MASK_CHAR.repeat(3)}-${MASK_CHAR.repeat(3)}-${last4}`;
2506
+ } else if (value.includes(".")) {
2507
+ return `${MASK_CHAR.repeat(3)}.${MASK_CHAR.repeat(3)}.${last4}`;
2508
+ }
2509
+ return MASK_CHAR.repeat(digits.length - 4) + last4;
2510
+ }
2511
+ function maskAddress(value) {
2512
+ const match = value.match(/^(\d+)\s+(.+)$/);
2513
+ if (match) {
2514
+ const [, houseNum, streetPart] = match;
2515
+ return MASK_CHAR.repeat(houseNum.length) + " " + streetPart;
2516
+ }
2517
+ const words = value.split(/\s+/);
2518
+ if (words.length > 1) {
2519
+ words[0] = MASK_CHAR.repeat(words[0].length);
2520
+ return words.join(" ");
2521
+ }
2522
+ return MASK_CHAR.repeat(value.length);
2523
+ }
2524
+ function maskPersonName(value) {
2525
+ const parts = value.split(/\s+/);
2526
+ return parts.map((part) => {
2527
+ if (part.length === 0) return "";
2528
+ if (/^(Mr|Mrs|Ms|Miss|Dr|Prof)\.?$/i.test(part)) {
2529
+ return part;
2530
+ }
2531
+ if (part.length === 1) return part;
2532
+ const firstChar = part[0];
2533
+ const maskLength = Math.min(part.length - 1, 5);
2534
+ return firstChar + MASK_CHAR.repeat(maskLength);
2535
+ }).join(" ");
2536
+ }
2537
+ function maskIPAddress(value) {
2538
+ const octets = value.split(".");
2539
+ if (octets.length !== 4) {
2540
+ return MASK_CHAR.repeat(value.length);
2541
+ }
2542
+ return `${MASK_CHAR.repeat(3)}.${MASK_CHAR.repeat(3)}.${MASK_CHAR.repeat(3)}.${octets[3]}`;
2543
+ }
2544
+ function maskDateOfBirth(value) {
2545
+ if (/^\d{4}[-/]\d{1,2}[-/]\d{1,2}$/.test(value)) {
2546
+ const year = value.slice(0, 4);
2547
+ const sep = value.includes("-") ? "-" : "/";
2548
+ return `${year}${sep}${MASK_CHAR}${MASK_CHAR}${sep}${MASK_CHAR}${MASK_CHAR}`;
2549
+ }
2550
+ const match = value.match(/^(\d{1,2})([-/])(\d{1,2})\2(\d{4})$/);
2551
+ if (match) {
2552
+ const [, , sep, , year] = match;
2553
+ return `${MASK_CHAR}${MASK_CHAR}${sep}${MASK_CHAR}${MASK_CHAR}${sep}${year}`;
2554
+ }
2555
+ if (value.length >= 4) {
2556
+ return MASK_CHAR.repeat(value.length - 4) + value.slice(-4);
2557
+ }
2558
+ return MASK_CHAR.repeat(value.length);
2559
+ }
2560
+ function maskCustom(value) {
2561
+ if (value.length <= 4) {
2562
+ return MASK_CHAR.repeat(value.length);
2563
+ }
2564
+ const visibleCount = Math.min(2, Math.floor(value.length / 4));
2565
+ const start = value.slice(0, visibleCount);
2566
+ const end = value.slice(-visibleCount);
2567
+ const middleLength = value.length - visibleCount * 2;
2568
+ return start + MASK_CHAR.repeat(middleLength) + end;
2569
+ }
2570
+ function getPartialMask(category, value) {
2571
+ switch (category) {
2572
+ case "ssn":
2573
+ return maskSSN(value);
2574
+ case "credit_card":
2575
+ return maskCreditCard(value);
2576
+ case "email":
2577
+ return maskEmail(value);
2578
+ case "phone":
2579
+ return maskPhone(value);
2580
+ case "address":
2581
+ return maskAddress(value);
2582
+ case "person_name":
2583
+ return maskPersonName(value);
2584
+ case "ip_address":
2585
+ return maskIPAddress(value);
2586
+ case "date_of_birth":
2587
+ return maskDateOfBirth(value);
2588
+ case "custom":
2589
+ return maskCustom(value);
2590
+ default:
2591
+ if (value.length <= 2) {
2592
+ return MASK_CHAR.repeat(value.length);
2593
+ }
2594
+ return value[0] + MASK_CHAR.repeat(value.length - 2) + value[value.length - 1];
2595
+ }
2596
+ }
2597
+
2598
+ // src/hooks/useDocumentProcessor.ts
2599
+ function getPurgedFilename(originalName) {
2600
+ const safeName = originalName.replace(/[/\\]/g, "_");
2601
+ const lastDot = safeName.lastIndexOf(".");
2602
+ if (lastDot === -1) return `${safeName}_purged`;
2603
+ return `${safeName.slice(0, lastDot)}_purged${safeName.slice(lastDot)}`;
2604
+ }
2605
+ function useDocumentProcessor() {
2606
+ const [isProcessing, setIsProcessing] = useState3(false);
2607
+ const [progress, setProgress] = useState3(null);
2608
+ const [detections, setDetections] = useState3([]);
2609
+ const [memoryStats, setMemoryStats] = useState3({
2610
+ allocated: 0,
2611
+ wiped: 0,
2612
+ buffersCleared: 0,
2613
+ totalBuffers: 0
2614
+ });
2615
+ const [entropyComparison, setEntropyComparison] = useState3({
2616
+ before: null,
2617
+ after: null,
2618
+ changedRegions: []
2619
+ });
2620
+ const [isCalculatingEntropy, setIsCalculatingEntropy] = useState3(false);
2621
+ const [contentSections, setContentSections] = useState3([]);
2622
+ const buffersRef = useRef([]);
2623
+ const originalBytesRef = useRef(null);
2624
+ const parsedDocsRef = useRef(/* @__PURE__ */ new Map());
2625
+ const updateFileStatus = usePurgeStore((s) => s.updateFileStatus);
2626
+ const storeSetDetections = usePurgeStore((s) => s.setDetections);
2627
+ const { calculateEntropy, compareEntropy } = useFileEntropy();
2628
+ const { canProcessFile, consumeToken, tokensRemaining } = useOfflineQuota();
2629
+ useEffect2(() => {
2630
+ return () => {
2631
+ if (originalBytesRef.current) {
2632
+ originalBytesRef.current.fill(0);
2633
+ originalBytesRef.current = null;
2634
+ }
2635
+ buffersRef.current.forEach((buffer) => {
2636
+ new Uint8Array(buffer).fill(0);
2637
+ });
2638
+ buffersRef.current = [];
2639
+ parsedDocsRef.current.clear();
2640
+ secureLog("Memory cleanup on unmount");
2641
+ };
2642
+ }, []);
2643
+ const scanFiles = useCallback3(
2644
+ async (files, config, bypassQuota = false) => {
2645
+ if (!bypassQuota) {
2646
+ if (!canProcessFile()) {
2647
+ throw new QuotaExhaustedError(
2648
+ `Offline quota exhausted (${tokensRemaining} tokens remaining). Go online to refresh.`
2649
+ );
2650
+ }
2651
+ if (tokensRemaining < files.length) {
2652
+ throw new QuotaExhaustedError(
2653
+ `Insufficient quota: ${tokensRemaining} tokens remaining, ${files.length} files queued. Go online to refresh.`
2654
+ );
2655
+ }
2656
+ }
2657
+ setIsProcessing(true);
2658
+ setIsCalculatingEntropy(true);
2659
+ const allDetections = [];
2660
+ parsedDocsRef.current.clear();
2661
+ setContentSections([]);
2662
+ try {
2663
+ for (let i = 0; i < files.length; i++) {
2664
+ const file = files[i];
2665
+ if (!canProcessFile()) {
2666
+ updateFileStatus(file.id, "error", "Quota exhausted");
2667
+ break;
2668
+ }
2669
+ setProgress({
2670
+ currentFileIndex: i,
2671
+ totalFiles: files.length,
2672
+ currentFileName: file.name,
2673
+ phase: "detecting",
2674
+ percent: Math.round(i / files.length * 100)
2675
+ });
2676
+ updateFileStatus(file.id, "scanning");
2677
+ const processor = getProcessorForFile(file.file);
2678
+ if (!processor) {
2679
+ updateFileStatus(file.id, "error", "Unsupported file format");
2680
+ continue;
2681
+ }
2682
+ try {
2683
+ const parsed = await processor.parse(file.file);
2684
+ parsedDocsRef.current.set(file.id, parsed);
2685
+ setContentSections((prev) => [...prev, ...parsed.content.sections]);
2686
+ buffersRef.current.push(new ArrayBuffer(file.size));
2687
+ setMemoryStats((s) => ({
2688
+ ...s,
2689
+ allocated: s.allocated + file.size,
2690
+ totalBuffers: s.totalBuffers + 1
2691
+ }));
2692
+ if (i === 0) {
2693
+ try {
2694
+ const originalBytes = await file.file.arrayBuffer();
2695
+ originalBytesRef.current = new Uint8Array(originalBytes);
2696
+ const beforeEntropy = calculateEntropy(originalBytesRef.current, []);
2697
+ setEntropyComparison((prev) => ({
2698
+ ...prev,
2699
+ before: beforeEntropy
2700
+ }));
2701
+ } catch (entropyError) {
2702
+ secureWarn("Failed to calculate original entropy", entropyError);
2703
+ }
2704
+ }
2705
+ const scanConfig = { ...config, sensitivity: "high" };
2706
+ const result = await regexDetectionEngine.detect(parsed.content, scanConfig);
2707
+ allDetections.push(...result.detections);
2708
+ storeSetDetections([...allDetections]);
2709
+ updateFileStatus(file.id, "detected");
2710
+ if (!bypassQuota) {
2711
+ await consumeToken();
2712
+ }
2713
+ if (i === 0 && originalBytesRef.current) {
2714
+ const beforeEntropyWithPII = calculateEntropy(
2715
+ originalBytesRef.current,
2716
+ result.detections
2717
+ );
2718
+ setEntropyComparison((prev) => ({
2719
+ ...prev,
2720
+ before: beforeEntropyWithPII
2721
+ }));
2722
+ originalBytesRef.current.fill(0);
2723
+ originalBytesRef.current = null;
2724
+ }
2725
+ setProgress(
2726
+ (p) => p ? {
2727
+ ...p,
2728
+ percent: Math.round((i + 1) / files.length * 100)
2729
+ } : null
2730
+ );
2731
+ } catch (error) {
2732
+ secureWarn(`Error scanning file ${safeFilename(file.name)}`, error);
2733
+ updateFileStatus(
2734
+ file.id,
2735
+ "error",
2736
+ error instanceof Error ? error.message : "Processing failed"
2737
+ );
2738
+ }
2739
+ }
2740
+ setDetections(allDetections);
2741
+ return allDetections;
2742
+ } finally {
2743
+ setIsProcessing(false);
2744
+ setIsCalculatingEntropy(false);
2745
+ setProgress(null);
2746
+ }
2747
+ },
2748
+ [updateFileStatus, storeSetDetections, calculateEntropy, canProcessFile, consumeToken, tokensRemaining]
2749
+ );
2750
+ const processFiles = useCallback3(
2751
+ async (files, selectedDetections, config) => {
2752
+ setIsProcessing(true);
2753
+ const processedFiles = [];
2754
+ try {
2755
+ for (let i = 0; i < files.length; i++) {
2756
+ const file = files[i];
2757
+ setProgress({
2758
+ currentFileIndex: i,
2759
+ totalFiles: files.length,
2760
+ currentFileName: file.name,
2761
+ phase: "redacting",
2762
+ percent: Math.round(i / files.length * 50)
2763
+ });
2764
+ updateFileStatus(file.id, "purging");
2765
+ const processor = getProcessorForFile(file.file);
2766
+ if (!processor) {
2767
+ updateFileStatus(file.id, "error", "Unsupported file format");
2768
+ continue;
2769
+ }
2770
+ try {
2771
+ let parsed = parsedDocsRef.current.get(file.id);
2772
+ if (!parsed) {
2773
+ secureWarn(`No cached parse for file ${safeFilename(file.name)}, re-parsing`);
2774
+ parsed = await processor.parse(file.file);
2775
+ }
2776
+ const fileDetections = detections.filter(
2777
+ (d) => d.fileId === parsed.content.fileId && selectedDetections.has(d.id)
2778
+ );
2779
+ const redactions = fileDetections.map((d) => ({
2780
+ detectionId: d.id,
2781
+ sectionId: d.sectionId,
2782
+ startOffset: d.startOffset,
2783
+ endOffset: d.endOffset,
2784
+ replacement: getReplacementText(d, config)
2785
+ }));
2786
+ setProgress(
2787
+ (p) => p ? {
2788
+ ...p,
2789
+ phase: "redacting",
2790
+ percent: Math.round(50 + (i + 0.5) / files.length * 25)
2791
+ } : null
2792
+ );
2793
+ const blob = await processor.applyRedactions(parsed, redactions);
2794
+ setProgress(
2795
+ (p) => p ? {
2796
+ ...p,
2797
+ phase: "finalizing",
2798
+ percent: Math.round(75 + (i + 1) / files.length * 25)
2799
+ } : null
2800
+ );
2801
+ const processedFile = {
2802
+ id: generateSecureId(),
2803
+ originalName: file.name,
2804
+ purgedName: getPurgedFilename(file.name),
2805
+ originalSize: file.size,
2806
+ purgedSize: blob.size,
2807
+ type: file.type,
2808
+ blob,
2809
+ detectionsRemoved: redactions.length,
2810
+ timestamp: Date.now()
2811
+ };
2812
+ processedFiles.push(processedFile);
2813
+ updateFileStatus(file.id, "complete");
2814
+ if (i === 0) {
2815
+ try {
2816
+ setIsCalculatingEntropy(true);
2817
+ const processedBytes = await blob.arrayBuffer();
2818
+ const afterEntropy = calculateEntropy(new Uint8Array(processedBytes), []);
2819
+ setEntropyComparison((prev) => {
2820
+ const comparison = compareEntropy(prev.before, afterEntropy);
2821
+ return comparison;
2822
+ });
2823
+ } catch (entropyError) {
2824
+ secureWarn("Failed to calculate processed entropy", entropyError);
2825
+ } finally {
2826
+ setIsCalculatingEntropy(false);
2827
+ }
2828
+ }
2829
+ } catch (error) {
2830
+ secureWarn(`Error processing file ${safeFilename(file.name)}`, error);
2831
+ updateFileStatus(
2832
+ file.id,
2833
+ "error",
2834
+ error instanceof Error ? error.message : "Processing failed"
2835
+ );
2836
+ }
2837
+ }
2838
+ return processedFiles;
2839
+ } finally {
2840
+ setIsProcessing(false);
2841
+ setProgress(null);
2842
+ }
2843
+ },
2844
+ [detections, updateFileStatus, calculateEntropy, compareEntropy]
2845
+ );
2846
+ const wipeMemory = useCallback3(async () => {
2847
+ const buffers = buffersRef.current;
2848
+ const totalSize = memoryStats.allocated;
2849
+ if (originalBytesRef.current) {
2850
+ originalBytesRef.current.fill(0);
2851
+ originalBytesRef.current = null;
2852
+ }
2853
+ if (buffers.length === 0) return;
2854
+ let wiped = 0;
2855
+ for (let i = 0; i < buffers.length; i++) {
2856
+ const buffer = buffers[i];
2857
+ const view = new Uint8Array(buffer);
2858
+ view.fill(0);
2859
+ wiped += buffer.byteLength;
2860
+ setMemoryStats((s) => ({
2861
+ ...s,
2862
+ wiped,
2863
+ buffersCleared: i + 1
2864
+ }));
2865
+ await new Promise((r) => setTimeout(r, 50));
2866
+ }
2867
+ buffersRef.current = [];
2868
+ setMemoryStats({
2869
+ allocated: totalSize,
2870
+ wiped: totalSize,
2871
+ buffersCleared: buffers.length,
2872
+ totalBuffers: buffers.length
2873
+ });
2874
+ }, [memoryStats.allocated]);
2875
+ return {
2876
+ isProcessing,
2877
+ progress,
2878
+ detections,
2879
+ memoryStats,
2880
+ entropyComparison,
2881
+ isCalculatingEntropy,
2882
+ contentSections,
2883
+ scanFiles,
2884
+ processFiles,
2885
+ wipeMemory
2886
+ };
2887
+ }
2888
+ function getReplacementText(detection, config) {
2889
+ switch (config.redactionStyle) {
2890
+ case "blackout":
2891
+ return "\u2588".repeat(Math.min(detection.value.length, 20));
2892
+ case "replacement":
2893
+ return config.replacementText || "[REDACTED]";
2894
+ case "pseudonym":
2895
+ return generatePseudonym(detection.category);
2896
+ case "partial":
2897
+ return getPartialMask(detection.category, detection.value);
2898
+ default:
2899
+ return "[REDACTED]";
2900
+ }
2901
+ }
2902
+ function generatePseudonym(category) {
2903
+ const pseudonyms = {
2904
+ person_name: ["John Doe", "Jane Smith", "Bob Johnson", "Alice Williams"],
2905
+ email: ["user@example.com", "contact@company.org", "info@domain.net"],
2906
+ phone: ["(555) 000-0000", "555-123-4567", "+1-555-EXAMPLE"],
2907
+ address: ["123 Example St, Anytown, USA 12345"],
2908
+ ssn: ["XXX-XX-XXXX"],
2909
+ credit_card: ["XXXX-XXXX-XXXX-XXXX"],
2910
+ ip_address: ["0.0.0.0", "127.0.0.1"],
2911
+ date_of_birth: ["01/01/1900"],
2912
+ custom: ["[CUSTOM DATA]"]
2913
+ };
2914
+ const options = pseudonyms[category] || ["[REDACTED]"];
2915
+ return options[Math.floor(Math.random() * options.length)];
2916
+ }
2917
+
2918
+ // src/hooks/useOfflineEnforcement.ts
2919
+ import { useState as useState4, useEffect as useEffect3, useCallback as useCallback4, useRef as useRef2 } from "react";
2920
+ var EXTENSION_SECONDS = 30;
2921
+ var MAX_EXTENSIONS = 2;
2922
+ var WARNING_COUNTDOWN_SECONDS = 5;
2923
+ var ONLINE_CHECK_INTERVAL = 100;
2924
+ function forceCloseTab() {
2925
+ if (typeof window !== "undefined") {
2926
+ window.location.href = "about:blank";
2927
+ }
2928
+ }
2929
+ function getInitialOnlineState() {
2930
+ if (typeof navigator !== "undefined") {
2931
+ return navigator.onLine;
2932
+ }
2933
+ return true;
2934
+ }
2935
+ function useOfflineEnforcement() {
2936
+ const {
2937
+ tokensRemaining,
2938
+ isExhausted: quotaExhausted,
2939
+ requestRefresh,
2940
+ isInitialized: quotaInitialized
2941
+ } = useOfflineQuota();
2942
+ const [status, setStatus] = useState4(
2943
+ getInitialOnlineState() ? "online_blocked" : "offline_ready"
2944
+ );
2945
+ const [isOnline, setIsOnline] = useState4(getInitialOnlineState());
2946
+ const [countdownSeconds, setCountdownSeconds] = useState4(null);
2947
+ const [hasDownloaded, setHasDownloaded] = useState4(false);
2948
+ const [extensionsUsed, setExtensionsUsed] = useState4(0);
2949
+ const [startedOnline, setStartedOnline] = useState4(false);
2950
+ const countdownIntervalRef = useRef2(null);
2951
+ const onlineCheckIntervalRef = useRef2(null);
2952
+ const isStartingProcessingRef = useRef2(false);
2953
+ useEffect3(() => {
2954
+ if (quotaInitialized && quotaExhausted && !isOnline && status === "offline_ready") {
2955
+ setStatus("quota_exhausted");
2956
+ }
2957
+ }, [quotaInitialized, quotaExhausted, isOnline, status]);
2958
+ useEffect3(() => {
2959
+ let mounted = true;
2960
+ if (isOnline && quotaExhausted && quotaInitialized && (status === "online_blocked" || status === "quota_exhausted")) {
2961
+ secureLog("Online with exhausted quota, attempting refresh...");
2962
+ requestRefresh().then((result) => {
2963
+ if (!mounted) {
2964
+ secureLog("[CLEANUP] Quota refresh completed after unmount, ignoring");
2965
+ return;
2966
+ }
2967
+ if (result.success) {
2968
+ secureLog(`Quota refreshed: ${result.tokens} tokens`);
2969
+ setStatus((currentStatus) => {
2970
+ if (currentStatus === "online_acknowledged") {
2971
+ secureLog("[RACE PREVENTED] Quota refreshed but user acknowledged risk - preserving online_acknowledged");
2972
+ return currentStatus;
2973
+ }
2974
+ if (currentStatus !== "online_blocked" && currentStatus !== "quota_exhausted") {
2975
+ secureLog(`[RACE PREVENTED] Quota refreshed but state is ${currentStatus} - not updating to online_blocked`);
2976
+ return currentStatus;
2977
+ }
2978
+ return "online_blocked";
2979
+ });
2980
+ } else {
2981
+ secureLog(`Quota refresh failed: ${result.reason}`);
2982
+ }
2983
+ }).catch((error) => {
2984
+ if (!mounted) return;
2985
+ secureError("Quota refresh error:", error);
2986
+ });
2987
+ }
2988
+ return () => {
2989
+ mounted = false;
2990
+ };
2991
+ }, [isOnline, quotaExhausted, quotaInitialized, requestRefresh, status]);
2992
+ const canProcess = status === "online_acknowledged" || // Admin bypass - ignore quota
2993
+ status === "processing" || // Currently processing
2994
+ status === "offline_ready" && !quotaExhausted;
2995
+ const clearCountdownInterval = useCallback4(() => {
2996
+ if (countdownIntervalRef.current) {
2997
+ clearInterval(countdownIntervalRef.current);
2998
+ countdownIntervalRef.current = null;
2999
+ }
3000
+ }, []);
3001
+ const startCountdown = useCallback4((seconds) => {
3002
+ clearCountdownInterval();
3003
+ setCountdownSeconds(seconds);
3004
+ countdownIntervalRef.current = setInterval(() => {
3005
+ setCountdownSeconds((prev) => {
3006
+ if (prev === null || prev <= 1) {
3007
+ clearCountdownInterval();
3008
+ forceCloseTab();
3009
+ return null;
3010
+ }
3011
+ return prev - 1;
3012
+ });
3013
+ }, 1e3);
3014
+ }, [clearCountdownInterval]);
3015
+ const handleOnlineChange = useCallback4((online) => {
3016
+ setIsOnline(online);
3017
+ if (online) {
3018
+ switch (status) {
3019
+ case "offline_ready":
3020
+ setStatus("online_blocked");
3021
+ break;
3022
+ case "processing":
3023
+ if (!startedOnline) {
3024
+ setStatus("reconnected_abort");
3025
+ clearCountdownInterval();
3026
+ }
3027
+ break;
3028
+ case "awaiting_download":
3029
+ if (!startedOnline) {
3030
+ setStatus("reconnected_warning");
3031
+ startCountdown(WARNING_COUNTDOWN_SECONDS);
3032
+ }
3033
+ break;
3034
+ case "complete":
3035
+ setStatus("reconnected_warning");
3036
+ startCountdown(WARNING_COUNTDOWN_SECONDS);
3037
+ break;
3038
+ default:
3039
+ break;
3040
+ }
3041
+ } else {
3042
+ switch (status) {
3043
+ case "online_blocked":
3044
+ setStatus("offline_ready");
3045
+ break;
3046
+ case "reconnected_warning":
3047
+ setStatus("awaiting_download");
3048
+ clearCountdownInterval();
3049
+ setCountdownSeconds(null);
3050
+ break;
3051
+ default:
3052
+ break;
3053
+ }
3054
+ }
3055
+ }, [status, startedOnline, clearCountdownInterval, startCountdown]);
3056
+ useEffect3(() => {
3057
+ const handleOnline = () => handleOnlineChange(true);
3058
+ const handleOffline = () => handleOnlineChange(false);
3059
+ window.addEventListener("online", handleOnline);
3060
+ window.addEventListener("offline", handleOffline);
3061
+ onlineCheckIntervalRef.current = setInterval(() => {
3062
+ const currentOnline = navigator.onLine;
3063
+ if (currentOnline !== isOnline) {
3064
+ handleOnlineChange(currentOnline);
3065
+ }
3066
+ }, ONLINE_CHECK_INTERVAL);
3067
+ return () => {
3068
+ window.removeEventListener("online", handleOnline);
3069
+ window.removeEventListener("offline", handleOffline);
3070
+ if (onlineCheckIntervalRef.current) {
3071
+ clearInterval(onlineCheckIntervalRef.current);
3072
+ }
3073
+ };
3074
+ }, [isOnline, handleOnlineChange]);
3075
+ useEffect3(() => {
3076
+ return () => {
3077
+ clearCountdownInterval();
3078
+ if (onlineCheckIntervalRef.current) {
3079
+ clearInterval(onlineCheckIntervalRef.current);
3080
+ }
3081
+ };
3082
+ }, [clearCountdownInterval]);
3083
+ const actions = {
3084
+ startProcessing: useCallback4(async () => {
3085
+ if (isStartingProcessingRef.current) {
3086
+ secureWarn("startProcessing already in progress, ignoring duplicate call");
3087
+ return;
3088
+ }
3089
+ isStartingProcessingRef.current = true;
3090
+ try {
3091
+ if (status === "offline_ready" && !isOnline) {
3092
+ if ("serviceWorker" in navigator) {
3093
+ try {
3094
+ const registrations = await navigator.serviceWorker.getRegistrations();
3095
+ if (registrations.length > 0) {
3096
+ secureError("Service workers detected - blocking processing for privacy");
3097
+ setStatus("sw_blocked");
3098
+ return;
3099
+ }
3100
+ } catch (e) {
3101
+ secureWarn("Could not check service workers", e);
3102
+ }
3103
+ }
3104
+ setStartedOnline(false);
3105
+ setStatus("processing");
3106
+ } else if (status === "online_acknowledged") {
3107
+ setStartedOnline(true);
3108
+ setStatus("processing");
3109
+ }
3110
+ } finally {
3111
+ isStartingProcessingRef.current = false;
3112
+ }
3113
+ }, [status, isOnline]),
3114
+ completeProcessing: useCallback4(() => {
3115
+ if (status === "processing") {
3116
+ setStatus("awaiting_download");
3117
+ }
3118
+ }, [status]),
3119
+ markDownloaded: useCallback4(() => {
3120
+ setHasDownloaded(true);
3121
+ if (!startedOnline) {
3122
+ setStatus("complete");
3123
+ setTimeout(() => {
3124
+ forceCloseTab();
3125
+ }, 500);
3126
+ }
3127
+ }, [startedOnline]),
3128
+ extendCountdown: useCallback4(() => {
3129
+ if (extensionsUsed >= MAX_EXTENSIONS) {
3130
+ return false;
3131
+ }
3132
+ setExtensionsUsed((prev) => prev + 1);
3133
+ clearCountdownInterval();
3134
+ startCountdown(EXTENSION_SECONDS);
3135
+ return true;
3136
+ }, [extensionsUsed, clearCountdownInterval, startCountdown]),
3137
+ forceClose: useCallback4(() => {
3138
+ forceCloseTab();
3139
+ }, []),
3140
+ reset: useCallback4(() => {
3141
+ clearCountdownInterval();
3142
+ setStatus(navigator.onLine ? "online_blocked" : "offline_ready");
3143
+ setCountdownSeconds(null);
3144
+ setHasDownloaded(false);
3145
+ setExtensionsUsed(0);
3146
+ setStartedOnline(false);
3147
+ }, [clearCountdownInterval]),
3148
+ acknowledgeOnlineRisk: useCallback4(() => {
3149
+ console.log("[ADMIN BYPASS] acknowledgeOnlineRisk called", { status, isOnline });
3150
+ if (isOnline) {
3151
+ console.log("[ADMIN BYPASS] \u2705 Acknowledging online risk, transitioning to online_acknowledged");
3152
+ setStatus("online_acknowledged");
3153
+ } else {
3154
+ console.warn("[ADMIN BYPASS] \u274C Cannot acknowledge - browser reports offline", { status, isOnline });
3155
+ }
3156
+ }, [status, isOnline])
3157
+ };
3158
+ return {
3159
+ state: {
3160
+ status,
3161
+ isOnline,
3162
+ canProcess,
3163
+ countdownSeconds,
3164
+ hasDownloaded,
3165
+ extensionsUsed,
3166
+ maxExtensions: MAX_EXTENSIONS,
3167
+ startedOnline,
3168
+ quotaRemaining: tokensRemaining,
3169
+ quotaExhausted
3170
+ },
3171
+ actions
3172
+ };
3173
+ }
3174
+
3175
+ // src/hooks/useFileHash.ts
3176
+ import { useState as useState5, useCallback as useCallback5 } from "react";
3177
+ function arrayBufferToHex(buffer) {
3178
+ return Array.from(new Uint8Array(buffer)).map((b) => b.toString(16).padStart(2, "0")).join("");
3179
+ }
3180
+ async function calculateSHA256(buffer) {
3181
+ const hashBuffer = await crypto.subtle.digest("SHA-256", buffer);
3182
+ return arrayBufferToHex(hashBuffer);
3183
+ }
3184
+ function truncateHash(hash) {
3185
+ if (hash.length <= 20) return hash;
3186
+ return `${hash.slice(0, 8)}...${hash.slice(-8)}`;
3187
+ }
3188
+ function useFileHash() {
3189
+ const [state, setState] = useState5({
3190
+ originalHash: null,
3191
+ processedHash: null,
3192
+ timestamp: null,
3193
+ isHashing: false,
3194
+ error: null
3195
+ });
3196
+ const hashOriginalFile = useCallback5(async (file) => {
3197
+ setState((prev) => ({ ...prev, isHashing: true, error: null }));
3198
+ try {
3199
+ const buffer = await file.arrayBuffer();
3200
+ const hash = await calculateSHA256(buffer);
3201
+ setState((prev) => ({
3202
+ ...prev,
3203
+ originalHash: hash,
3204
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
3205
+ isHashing: false
3206
+ }));
3207
+ return hash;
3208
+ } catch (error) {
3209
+ const errorMessage = error instanceof Error ? error.message : "Failed to hash file";
3210
+ setState((prev) => ({
3211
+ ...prev,
3212
+ isHashing: false,
3213
+ error: errorMessage
3214
+ }));
3215
+ throw error;
3216
+ }
3217
+ }, []);
3218
+ const hashProcessedBlob = useCallback5(async (blob) => {
3219
+ setState((prev) => ({ ...prev, isHashing: true, error: null }));
3220
+ try {
3221
+ const buffer = await blob.arrayBuffer();
3222
+ const hash = await calculateSHA256(buffer);
3223
+ setState((prev) => ({
3224
+ ...prev,
3225
+ processedHash: hash,
3226
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
3227
+ isHashing: false
3228
+ }));
3229
+ return hash;
3230
+ } catch (error) {
3231
+ const errorMessage = error instanceof Error ? error.message : "Failed to hash blob";
3232
+ setState((prev) => ({
3233
+ ...prev,
3234
+ isHashing: false,
3235
+ error: errorMessage
3236
+ }));
3237
+ throw error;
3238
+ }
3239
+ }, []);
3240
+ const reset = useCallback5(() => {
3241
+ setState({
3242
+ originalHash: null,
3243
+ processedHash: null,
3244
+ timestamp: null,
3245
+ isHashing: false,
3246
+ error: null
3247
+ });
3248
+ }, []);
3249
+ const getComparisonResult = useCallback5(() => {
3250
+ const { originalHash, processedHash } = state;
3251
+ if (!originalHash && !processedHash) {
3252
+ return {
3253
+ hasBothHashes: false,
3254
+ hashesMatch: false,
3255
+ message: "No files have been hashed yet"
3256
+ };
3257
+ }
3258
+ if (!originalHash) {
3259
+ return {
3260
+ hasBothHashes: false,
3261
+ hashesMatch: false,
3262
+ message: "Original file hash not calculated"
3263
+ };
3264
+ }
3265
+ if (!processedHash) {
3266
+ return {
3267
+ hasBothHashes: false,
3268
+ hashesMatch: false,
3269
+ message: "Processed file hash not calculated"
3270
+ };
3271
+ }
3272
+ const hashesMatch = originalHash === processedHash;
3273
+ return {
3274
+ hasBothHashes: true,
3275
+ hashesMatch,
3276
+ message: hashesMatch ? "File was not modified during processing" : "File was modified during processing (redactions applied)"
3277
+ };
3278
+ }, [state]);
3279
+ return {
3280
+ state,
3281
+ hashOriginalFile,
3282
+ hashProcessedBlob,
3283
+ reset,
3284
+ getComparisonResult
3285
+ };
3286
+ }
3287
+
3288
+ // src/hooks/useAdversarialAnalysis.ts
3289
+ import { useCallback as useCallback6, useEffect as useEffect4, useRef as useRef3 } from "react";
3290
+ function useAdversarialAnalysis(options = {}) {
3291
+ const { autoAnalyze = false, debounceMs = 500 } = options;
3292
+ const result = usePurgeStore((s) => s.adversarialResult);
3293
+ const isAnalyzing = usePurgeStore((s) => s.isAnalyzingAdversarial);
3294
+ const config = usePurgeStore((s) => s.adversarialConfig);
3295
+ const detections = usePurgeStore((s) => s.detections);
3296
+ const selectedDetections = usePurgeStore((s) => s.selectedDetections);
3297
+ const setResult = usePurgeStore((s) => s.setAdversarialResult);
3298
+ const setIsAnalyzing = usePurgeStore((s) => s.setIsAnalyzingAdversarial);
3299
+ const setConfig = usePurgeStore((s) => s.setAdversarialConfig);
3300
+ const storeSuggestion = usePurgeStore((s) => s.applySuggestion);
3301
+ const clearState = usePurgeStore((s) => s.clearAdversarialState);
3302
+ const debounceRef = useRef3(null);
3303
+ const analyze = useCallback6(
3304
+ async (sections, selected) => {
3305
+ if (!config.enabled) {
3306
+ return;
3307
+ }
3308
+ setIsAnalyzing(true);
3309
+ try {
3310
+ adversarialVerifier.setConfig(config);
3311
+ const analysisResult = await adversarialVerifier.analyze(sections, selected);
3312
+ if (result) {
3313
+ analysisResult.previousConfidence = result.analysis.reidentificationConfidence;
3314
+ analysisResult.iteration = result.iteration + 1;
3315
+ }
3316
+ setResult(analysisResult);
3317
+ } catch (error) {
3318
+ console.error("[Adversarial] Analysis failed:", error);
3319
+ } finally {
3320
+ setIsAnalyzing(false);
3321
+ }
3322
+ },
3323
+ [config, result, setIsAnalyzing, setResult]
3324
+ );
3325
+ const applySuggestion = useCallback6(
3326
+ (suggestion) => {
3327
+ storeSuggestion(suggestion);
3328
+ },
3329
+ [storeSuggestion]
3330
+ );
3331
+ const applyAllSuggestions = useCallback6(() => {
3332
+ if (!result) return;
3333
+ for (const suggestion of result.suggestions) {
3334
+ if (!suggestion.accepted) {
3335
+ storeSuggestion(suggestion);
3336
+ }
3337
+ }
3338
+ }, [result, storeSuggestion]);
3339
+ const updateConfig = useCallback6(
3340
+ (newConfig) => {
3341
+ setConfig(newConfig);
3342
+ adversarialVerifier.setConfig(newConfig);
3343
+ },
3344
+ [setConfig]
3345
+ );
3346
+ const clear = useCallback6(() => {
3347
+ clearState();
3348
+ }, [clearState]);
3349
+ const dismiss = useCallback6(() => {
3350
+ clearState();
3351
+ }, [clearState]);
3352
+ useEffect4(() => {
3353
+ if (!autoAnalyze || !config.enabled) {
3354
+ return;
3355
+ }
3356
+ if (debounceRef.current) {
3357
+ clearTimeout(debounceRef.current);
3358
+ }
3359
+ const selected = detections.filter((d) => selectedDetections.has(d.id));
3360
+ if (selected.length === 0) {
3361
+ return;
3362
+ }
3363
+ debounceRef.current = setTimeout(() => {
3364
+ console.log("[Adversarial] Auto-analyze triggered with", selected.length, "detections");
3365
+ }, debounceMs);
3366
+ return () => {
3367
+ if (debounceRef.current) {
3368
+ clearTimeout(debounceRef.current);
3369
+ }
3370
+ };
3371
+ }, [autoAnalyze, config.enabled, debounceMs, detections, selectedDetections]);
3372
+ return {
3373
+ result,
3374
+ isAnalyzing,
3375
+ config,
3376
+ analyze,
3377
+ applySuggestion,
3378
+ applyAllSuggestions,
3379
+ updateConfig,
3380
+ clear,
3381
+ dismiss
3382
+ };
3383
+ }
3384
+
3385
+ // src/hooks/useSpreadsheetMetadata.ts
3386
+ import { useCallback as useCallback7 } from "react";
3387
+ import * as XLSX2 from "xlsx";
3388
+ function useSpreadsheetMetadata() {
3389
+ const extractMetadata = useCallback7(
3390
+ async (file, includeHeaders = true) => {
3391
+ const arrayBuffer = await file.arrayBuffer();
3392
+ const workbook = XLSX2.read(arrayBuffer, {
3393
+ type: "array",
3394
+ // Only read first row if headers needed, otherwise just structure
3395
+ sheetRows: includeHeaders ? 1 : 0
3396
+ });
3397
+ const sheets = [];
3398
+ let totalCells = 0;
3399
+ let globalMinCol = "Z";
3400
+ let globalMaxCol = "A";
3401
+ for (const sheetName of workbook.SheetNames) {
3402
+ const sheet = workbook.Sheets[sheetName];
3403
+ if (!sheet["!ref"]) {
3404
+ sheets.push({
3405
+ name: sheetName,
3406
+ columns: [],
3407
+ rowCount: 0,
3408
+ columnCount: 0
3409
+ });
3410
+ continue;
3411
+ }
3412
+ const range = XLSX2.utils.decode_range(sheet["!ref"]);
3413
+ const rowCount = range.e.r - range.s.r + 1;
3414
+ const columnCount = range.e.c - range.s.c + 1;
3415
+ const columns = [];
3416
+ for (let col = range.s.c; col <= range.e.c; col++) {
3417
+ const colLetter = XLSX2.utils.encode_col(col);
3418
+ columns.push(colLetter);
3419
+ if (colLetter < globalMinCol) globalMinCol = colLetter;
3420
+ if (colLetter > globalMaxCol) globalMaxCol = colLetter;
3421
+ }
3422
+ let sampleHeaders;
3423
+ if (includeHeaders && range.s.r <= range.e.r) {
3424
+ sampleHeaders = [];
3425
+ for (let col = range.s.c; col <= range.e.c; col++) {
3426
+ const cellAddr = XLSX2.utils.encode_cell({ r: range.s.r, c: col });
3427
+ const cell = sheet[cellAddr];
3428
+ sampleHeaders.push(
3429
+ cell?.v !== void 0 ? String(cell.v) : ""
3430
+ );
3431
+ }
3432
+ }
3433
+ totalCells += rowCount * columnCount;
3434
+ sheets.push({
3435
+ name: sheetName,
3436
+ columns,
3437
+ rowCount,
3438
+ columnCount,
3439
+ sampleHeaders
3440
+ });
3441
+ }
3442
+ return {
3443
+ sheets,
3444
+ totalCells,
3445
+ columnRange: {
3446
+ min: globalMinCol,
3447
+ max: globalMaxCol
3448
+ }
3449
+ };
3450
+ },
3451
+ []
3452
+ );
3453
+ return { extractMetadata };
3454
+ }
3455
+
3456
+ // src/hooks/useNetworkProof.ts
3457
+ import { useState as useState6, useEffect as useEffect5, useCallback as useCallback8, useRef as useRef4 } from "react";
3458
+ function generateId2() {
3459
+ return generatePrefixedId("req");
3460
+ }
3461
+ function useNetworkProof() {
3462
+ const [requests, setRequests] = useState6([]);
3463
+ const [isRecording, setIsRecording] = useState6(false);
3464
+ const observerRef = useRef4(null);
3465
+ const originalFetchRef = useRef4(null);
3466
+ const originalXHROpenRef = useRef4(null);
3467
+ const originalBeaconRef = useRef4(null);
3468
+ const originalWebSocketRef = useRef4(null);
3469
+ const originalWorkerRef = useRef4(null);
3470
+ const originalSharedWorkerRef = useRef4(null);
3471
+ const originalRTCRef = useRef4(null);
3472
+ const startRecording = useCallback8(() => {
3473
+ setRequests([]);
3474
+ setIsRecording(true);
3475
+ try {
3476
+ const observer = new PerformanceObserver((list) => {
3477
+ const entries = list.getEntries();
3478
+ entries.forEach((entry) => {
3479
+ if (entry.name.includes("chrome-extension://")) return;
3480
+ setRequests((prev) => [
3481
+ ...prev,
3482
+ {
3483
+ id: generateId2(),
3484
+ method: "GET",
3485
+ // PerformanceObserver doesn't provide method
3486
+ url: entry.name,
3487
+ size: entry.transferSize || 0,
3488
+ status: 200,
3489
+ // Approximate
3490
+ timestamp: entry.startTime
3491
+ }
3492
+ ]);
3493
+ });
3494
+ });
3495
+ observer.observe({ entryTypes: ["resource"] });
3496
+ observerRef.current = observer;
3497
+ } catch {
3498
+ console.warn("PerformanceObserver not supported");
3499
+ }
3500
+ if (!originalFetchRef.current) {
3501
+ originalFetchRef.current = window.fetch;
3502
+ window.fetch = async (input, init) => {
3503
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
3504
+ const method = init?.method || "GET";
3505
+ const startTime = performance.now();
3506
+ try {
3507
+ const response = await originalFetchRef.current(input, init);
3508
+ const clone = response.clone();
3509
+ const body = await clone.blob();
3510
+ setRequests((prev) => [
3511
+ ...prev,
3512
+ {
3513
+ id: generateId2(),
3514
+ method: method.toUpperCase(),
3515
+ url,
3516
+ size: body.size,
3517
+ status: response.status,
3518
+ timestamp: startTime
3519
+ }
3520
+ ]);
3521
+ return response;
3522
+ } catch (error) {
3523
+ setRequests((prev) => [
3524
+ ...prev,
3525
+ {
3526
+ id: generateId2(),
3527
+ method: method.toUpperCase(),
3528
+ url,
3529
+ size: 0,
3530
+ status: 0,
3531
+ timestamp: startTime
3532
+ }
3533
+ ]);
3534
+ throw error;
3535
+ }
3536
+ };
3537
+ }
3538
+ if (!originalXHROpenRef.current) {
3539
+ originalXHROpenRef.current = XMLHttpRequest.prototype.open;
3540
+ XMLHttpRequest.prototype.open = function(method, url, async, username, password) {
3541
+ const startTime = performance.now();
3542
+ this.addEventListener("load", () => {
3543
+ setRequests((prev) => [
3544
+ ...prev,
3545
+ {
3546
+ id: generateId2(),
3547
+ method: method.toUpperCase(),
3548
+ url: url.toString(),
3549
+ size: this.response?.length || 0,
3550
+ status: this.status,
3551
+ timestamp: startTime
3552
+ }
3553
+ ]);
3554
+ });
3555
+ return originalXHROpenRef.current.call(this, method, url, async ?? true, username, password);
3556
+ };
3557
+ }
3558
+ if (!originalBeaconRef.current && typeof navigator.sendBeacon === "function") {
3559
+ originalBeaconRef.current = navigator.sendBeacon.bind(navigator);
3560
+ navigator.sendBeacon = (url, data) => {
3561
+ const size = data ? typeof data === "string" ? data.length : data.size || 0 : 0;
3562
+ setRequests((prev) => [
3563
+ ...prev,
3564
+ {
3565
+ id: generateId2(),
3566
+ method: "BEACON",
3567
+ url,
3568
+ size,
3569
+ status: 0,
3570
+ // Beacon doesn't return status
3571
+ timestamp: performance.now()
3572
+ }
3573
+ ]);
3574
+ return originalBeaconRef.current(url, data);
3575
+ };
3576
+ }
3577
+ if (!originalWebSocketRef.current) {
3578
+ originalWebSocketRef.current = window.WebSocket;
3579
+ const OriginalWS = window.WebSocket;
3580
+ const PatchedWebSocket = class extends OriginalWS {
3581
+ constructor(url, protocols) {
3582
+ super(url, protocols);
3583
+ setRequests((prev) => [
3584
+ ...prev,
3585
+ {
3586
+ id: generateId2(),
3587
+ method: "WEBSOCKET",
3588
+ url: url.toString(),
3589
+ size: 0,
3590
+ status: 0,
3591
+ timestamp: performance.now()
3592
+ }
3593
+ ]);
3594
+ }
3595
+ };
3596
+ window.WebSocket = PatchedWebSocket;
3597
+ }
3598
+ if (!originalWorkerRef.current && typeof Worker !== "undefined") {
3599
+ originalWorkerRef.current = window.Worker;
3600
+ const OriginalWorker = window.Worker;
3601
+ const PatchedWorker = class extends OriginalWorker {
3602
+ constructor(scriptURL, options) {
3603
+ super(scriptURL, options);
3604
+ setRequests((prev) => [
3605
+ ...prev,
3606
+ {
3607
+ id: generateId2(),
3608
+ method: "WORKER",
3609
+ url: scriptURL.toString(),
3610
+ size: 0,
3611
+ status: 0,
3612
+ timestamp: performance.now()
3613
+ }
3614
+ ]);
3615
+ }
3616
+ };
3617
+ window.Worker = PatchedWorker;
3618
+ }
3619
+ if (!originalSharedWorkerRef.current && typeof SharedWorker !== "undefined") {
3620
+ originalSharedWorkerRef.current = window.SharedWorker;
3621
+ const OriginalSharedWorker = window.SharedWorker;
3622
+ const PatchedSharedWorker = class extends OriginalSharedWorker {
3623
+ constructor(scriptURL, options) {
3624
+ super(scriptURL, options);
3625
+ setRequests((prev) => [
3626
+ ...prev,
3627
+ {
3628
+ id: generateId2(),
3629
+ method: "SHAREDWORKER",
3630
+ url: scriptURL.toString(),
3631
+ size: 0,
3632
+ status: 0,
3633
+ timestamp: performance.now()
3634
+ }
3635
+ ]);
3636
+ }
3637
+ };
3638
+ window.SharedWorker = PatchedSharedWorker;
3639
+ }
3640
+ if (!originalRTCRef.current && typeof RTCPeerConnection !== "undefined") {
3641
+ originalRTCRef.current = window.RTCPeerConnection;
3642
+ const OriginalRTC = window.RTCPeerConnection;
3643
+ const PatchedRTC = class extends OriginalRTC {
3644
+ constructor(config) {
3645
+ super(config);
3646
+ const iceServers = config?.iceServers?.map(
3647
+ (s) => Array.isArray(s.urls) ? s.urls.join(", ") : s.urls
3648
+ ).join("; ") || "default";
3649
+ setRequests((prev) => [
3650
+ ...prev,
3651
+ {
3652
+ id: generateId2(),
3653
+ method: "WEBRTC",
3654
+ url: `ICE: ${iceServers}`,
3655
+ size: 0,
3656
+ status: 0,
3657
+ timestamp: performance.now()
3658
+ }
3659
+ ]);
3660
+ }
3661
+ };
3662
+ window.RTCPeerConnection = PatchedRTC;
3663
+ }
3664
+ }, []);
3665
+ const stopRecording = useCallback8(() => {
3666
+ setIsRecording(false);
3667
+ if (observerRef.current) {
3668
+ observerRef.current.disconnect();
3669
+ observerRef.current = null;
3670
+ }
3671
+ if (originalFetchRef.current) {
3672
+ window.fetch = originalFetchRef.current;
3673
+ originalFetchRef.current = null;
3674
+ }
3675
+ if (originalXHROpenRef.current) {
3676
+ XMLHttpRequest.prototype.open = originalXHROpenRef.current;
3677
+ originalXHROpenRef.current = null;
3678
+ }
3679
+ if (originalBeaconRef.current) {
3680
+ navigator.sendBeacon = originalBeaconRef.current;
3681
+ originalBeaconRef.current = null;
3682
+ }
3683
+ if (originalWebSocketRef.current) {
3684
+ window.WebSocket = originalWebSocketRef.current;
3685
+ originalWebSocketRef.current = null;
3686
+ }
3687
+ if (originalWorkerRef.current) {
3688
+ window.Worker = originalWorkerRef.current;
3689
+ originalWorkerRef.current = null;
3690
+ }
3691
+ if (originalSharedWorkerRef.current) {
3692
+ window.SharedWorker = originalSharedWorkerRef.current;
3693
+ originalSharedWorkerRef.current = null;
3694
+ }
3695
+ if (originalRTCRef.current) {
3696
+ window.RTCPeerConnection = originalRTCRef.current;
3697
+ originalRTCRef.current = null;
3698
+ }
3699
+ }, []);
3700
+ const clearRequests = useCallback8(() => {
3701
+ setRequests([]);
3702
+ }, []);
3703
+ useEffect5(() => {
3704
+ return () => {
3705
+ stopRecording();
3706
+ };
3707
+ }, [stopRecording]);
3708
+ const totalBytes = requests.reduce((sum, r) => sum + r.size, 0);
3709
+ const requestCount = requests.length;
3710
+ return {
3711
+ requests,
3712
+ isRecording,
3713
+ startRecording,
3714
+ stopRecording,
3715
+ clearRequests,
3716
+ totalBytes,
3717
+ requestCount
3718
+ };
3719
+ }
3720
+
3721
+ // src/hooks/useEnhancedNetworkProof.ts
3722
+ import { useState as useState7, useEffect as useEffect6, useCallback as useCallback9, useRef as useRef5 } from "react";
3723
+ function generateId3() {
3724
+ return generateSecureId();
3725
+ }
3726
+ function useEnhancedNetworkProof() {
3727
+ const [state, setState] = useState7({
3728
+ webSocketConnections: [],
3729
+ webRTCConnections: [],
3730
+ serviceWorkers: [],
3731
+ beaconRequests: [],
3732
+ hasConcerningActivity: false,
3733
+ summary: "Not monitoring"
3734
+ });
3735
+ const [isMonitoring, setIsMonitoring] = useState7(false);
3736
+ const originalWebSocket = useRef5(null);
3737
+ const originalRTCPeerConnection = useRef5(null);
3738
+ const originalSendBeacon = useRef5(null);
3739
+ const updateSummary = useCallback9((currentState) => {
3740
+ const issues = [];
3741
+ if (currentState.webSocketConnections.length > 0) {
3742
+ issues.push(`${currentState.webSocketConnections.length} WebSocket connection(s)`);
3743
+ }
3744
+ if (currentState.webRTCConnections.length > 0) {
3745
+ issues.push(`${currentState.webRTCConnections.length} WebRTC connection(s)`);
3746
+ }
3747
+ if (currentState.serviceWorkers.length > 0) {
3748
+ issues.push(`${currentState.serviceWorkers.length} Service Worker(s) registered`);
3749
+ }
3750
+ if (currentState.beaconRequests.length > 0) {
3751
+ issues.push(`${currentState.beaconRequests.length} Beacon request(s)`);
3752
+ }
3753
+ if (issues.length === 0) {
3754
+ return "No WebSocket, WebRTC, Service Workers, or Beacons detected";
3755
+ }
3756
+ return `Detected: ${issues.join(", ")}`;
3757
+ }, []);
3758
+ const startMonitoring = useCallback9(() => {
3759
+ setIsMonitoring(true);
3760
+ if (!originalWebSocket.current && typeof WebSocket !== "undefined") {
3761
+ originalWebSocket.current = WebSocket;
3762
+ const OriginalWebSocket = WebSocket;
3763
+ window.WebSocket = function(url, protocols) {
3764
+ const ws = new OriginalWebSocket(url, protocols);
3765
+ setState((prev) => {
3766
+ const newConnections = [
3767
+ ...prev.webSocketConnections,
3768
+ {
3769
+ id: generateId3(),
3770
+ url,
3771
+ timestamp: Date.now(),
3772
+ state: "connecting"
3773
+ }
3774
+ ];
3775
+ return {
3776
+ ...prev,
3777
+ webSocketConnections: newConnections,
3778
+ hasConcerningActivity: true,
3779
+ summary: updateSummary({ ...prev, webSocketConnections: newConnections })
3780
+ };
3781
+ });
3782
+ return ws;
3783
+ };
3784
+ Object.assign(window.WebSocket, OriginalWebSocket);
3785
+ }
3786
+ if (!originalRTCPeerConnection.current && typeof RTCPeerConnection !== "undefined") {
3787
+ originalRTCPeerConnection.current = RTCPeerConnection;
3788
+ const OriginalRTCPeerConnection = RTCPeerConnection;
3789
+ window.RTCPeerConnection = function(config) {
3790
+ const pc = new OriginalRTCPeerConnection(config);
3791
+ setState((prev) => {
3792
+ const newConnections = [
3793
+ ...prev.webRTCConnections,
3794
+ {
3795
+ id: generateId3(),
3796
+ timestamp: Date.now(),
3797
+ type: "offer"
3798
+ }
3799
+ ];
3800
+ return {
3801
+ ...prev,
3802
+ webRTCConnections: newConnections,
3803
+ hasConcerningActivity: true,
3804
+ summary: updateSummary({ ...prev, webRTCConnections: newConnections })
3805
+ };
3806
+ });
3807
+ return pc;
3808
+ };
3809
+ window.RTCPeerConnection.prototype = OriginalRTCPeerConnection.prototype;
3810
+ Object.assign(window.RTCPeerConnection, OriginalRTCPeerConnection);
3811
+ }
3812
+ if (!originalSendBeacon.current && typeof navigator.sendBeacon !== "undefined") {
3813
+ originalSendBeacon.current = navigator.sendBeacon.bind(navigator);
3814
+ navigator.sendBeacon = (url, data) => {
3815
+ setState((prev) => {
3816
+ const newBeacons = [
3817
+ ...prev.beaconRequests,
3818
+ {
3819
+ id: generateId3(),
3820
+ url,
3821
+ timestamp: Date.now()
3822
+ }
3823
+ ];
3824
+ return {
3825
+ ...prev,
3826
+ beaconRequests: newBeacons,
3827
+ hasConcerningActivity: true,
3828
+ summary: updateSummary({ ...prev, beaconRequests: newBeacons })
3829
+ };
3830
+ });
3831
+ return originalSendBeacon.current(url, data);
3832
+ };
3833
+ }
3834
+ setState((prev) => ({
3835
+ ...prev,
3836
+ summary: "Monitoring WebSocket, WebRTC, Beacons..."
3837
+ }));
3838
+ }, [updateSummary]);
3839
+ const stopMonitoring = useCallback9(() => {
3840
+ setIsMonitoring(false);
3841
+ if (originalWebSocket.current) {
3842
+ window.WebSocket = originalWebSocket.current;
3843
+ originalWebSocket.current = null;
3844
+ }
3845
+ if (originalRTCPeerConnection.current) {
3846
+ window.RTCPeerConnection = originalRTCPeerConnection.current;
3847
+ originalRTCPeerConnection.current = null;
3848
+ }
3849
+ if (originalSendBeacon.current) {
3850
+ navigator.sendBeacon = originalSendBeacon.current;
3851
+ originalSendBeacon.current = null;
3852
+ }
3853
+ setState((prev) => ({
3854
+ ...prev,
3855
+ summary: updateSummary(prev)
3856
+ }));
3857
+ }, [updateSummary]);
3858
+ const checkServiceWorkers = useCallback9(async () => {
3859
+ if (!("serviceWorker" in navigator)) {
3860
+ return;
3861
+ }
3862
+ try {
3863
+ const registrations = await navigator.serviceWorker.getRegistrations();
3864
+ const swInfo = registrations.map((reg) => ({
3865
+ scriptURL: reg.active?.scriptURL || reg.installing?.scriptURL || reg.waiting?.scriptURL || "unknown",
3866
+ state: reg.active?.state || reg.installing?.state || reg.waiting?.state || "unknown",
3867
+ timestamp: Date.now()
3868
+ }));
3869
+ setState((prev) => {
3870
+ const hasSW = swInfo.length > 0;
3871
+ return {
3872
+ ...prev,
3873
+ serviceWorkers: swInfo,
3874
+ hasConcerningActivity: prev.hasConcerningActivity || hasSW,
3875
+ summary: updateSummary({ ...prev, serviceWorkers: swInfo })
3876
+ };
3877
+ });
3878
+ } catch (error) {
3879
+ console.warn("Could not check service workers:", error);
3880
+ }
3881
+ }, [updateSummary]);
3882
+ const reset = useCallback9(() => {
3883
+ stopMonitoring();
3884
+ setState({
3885
+ webSocketConnections: [],
3886
+ webRTCConnections: [],
3887
+ serviceWorkers: [],
3888
+ beaconRequests: [],
3889
+ hasConcerningActivity: false,
3890
+ summary: "Not monitoring"
3891
+ });
3892
+ }, [stopMonitoring]);
3893
+ useEffect6(() => {
3894
+ return () => {
3895
+ stopMonitoring();
3896
+ };
3897
+ }, [stopMonitoring]);
3898
+ return {
3899
+ state,
3900
+ startMonitoring,
3901
+ stopMonitoring,
3902
+ checkServiceWorkers,
3903
+ reset,
3904
+ isMonitoring
3905
+ };
3906
+ }
3907
+
3908
+ // src/hooks/useStorageProof.ts
3909
+ import { useState as useState8, useCallback as useCallback10, useRef as useRef6 } from "react";
3910
+ var WATERMARK_KEY = "purge_watermark_test";
3911
+ function countLocalStorage() {
3912
+ try {
3913
+ return localStorage.length;
3914
+ } catch {
3915
+ return 0;
3916
+ }
3917
+ }
3918
+ function countSessionStorage() {
3919
+ try {
3920
+ return sessionStorage.length;
3921
+ } catch {
3922
+ return 0;
3923
+ }
3924
+ }
3925
+ async function countIndexedDB() {
3926
+ try {
3927
+ if ("indexedDB" in window && "databases" in indexedDB) {
3928
+ const databases = await indexedDB.databases();
3929
+ return databases.length;
3930
+ }
3931
+ } catch {
3932
+ }
3933
+ return 0;
3934
+ }
3935
+ function countCookies() {
3936
+ try {
3937
+ const cookies = document.cookie;
3938
+ if (!cookies) return 0;
3939
+ return cookies.split(";").filter((c) => c.trim()).length;
3940
+ } catch {
3941
+ return 0;
3942
+ }
3943
+ }
3944
+ async function countCacheAPI() {
3945
+ if (!("caches" in window)) return 0;
3946
+ try {
3947
+ const cacheNames = await caches.keys();
3948
+ return cacheNames.length;
3949
+ } catch {
3950
+ return 0;
3951
+ }
3952
+ }
3953
+ async function takeSnapshot() {
3954
+ const [indexedDBCount, cacheAPICount] = await Promise.all([
3955
+ countIndexedDB(),
3956
+ countCacheAPI()
3957
+ ]);
3958
+ return {
3959
+ localStorage: countLocalStorage(),
3960
+ sessionStorage: countSessionStorage(),
3961
+ indexedDB: indexedDBCount,
3962
+ cookies: countCookies(),
3963
+ cacheAPI: cacheAPICount,
3964
+ watermarkPlanted: false,
3965
+ watermarkVerified: false
3966
+ };
3967
+ }
3968
+ function useStorageProof() {
3969
+ const [beforeSnapshot, setBeforeSnapshot] = useState8(null);
3970
+ const [afterSnapshot, setAfterSnapshot] = useState8(null);
3971
+ const [watermarkStatus, setWatermarkStatus] = useState8("not_planted");
3972
+ const watermarkValueRef = useRef6("");
3973
+ const takeBeforeSnapshot = useCallback10(async () => {
3974
+ const snapshot = await takeSnapshot();
3975
+ setBeforeSnapshot(snapshot);
3976
+ setAfterSnapshot(null);
3977
+ setWatermarkStatus("not_planted");
3978
+ }, []);
3979
+ const takeAfterSnapshot = useCallback10(async () => {
3980
+ const snapshot = await takeSnapshot();
3981
+ setAfterSnapshot(snapshot);
3982
+ }, []);
3983
+ const plantWatermark = useCallback10(() => {
3984
+ const value = `purge_test_${generateSecureId()}`;
3985
+ watermarkValueRef.current = value;
3986
+ try {
3987
+ sessionStorage.setItem(WATERMARK_KEY, value);
3988
+ setWatermarkStatus("planted");
3989
+ setBeforeSnapshot(
3990
+ (prev) => prev ? {
3991
+ ...prev,
3992
+ sessionStorage: prev.sessionStorage + 1,
3993
+ watermarkPlanted: true
3994
+ } : null
3995
+ );
3996
+ } catch {
3997
+ setWatermarkStatus("failed");
3998
+ }
3999
+ }, []);
4000
+ const verifyWatermark = useCallback10(() => {
4001
+ try {
4002
+ const storedValue = sessionStorage.getItem(WATERMARK_KEY);
4003
+ if (storedValue === watermarkValueRef.current) {
4004
+ sessionStorage.removeItem(WATERMARK_KEY);
4005
+ setWatermarkStatus("verified");
4006
+ setAfterSnapshot(
4007
+ (prev) => prev ? {
4008
+ ...prev,
4009
+ watermarkVerified: true
4010
+ } : null
4011
+ );
4012
+ return true;
4013
+ } else {
4014
+ setWatermarkStatus("failed");
4015
+ return false;
4016
+ }
4017
+ } catch {
4018
+ setWatermarkStatus("failed");
4019
+ return false;
4020
+ }
4021
+ }, []);
4022
+ const isDifferent = (() => {
4023
+ if (!beforeSnapshot || !afterSnapshot) return false;
4024
+ const expectedLocalStorage = beforeSnapshot.localStorage;
4025
+ const expectedSessionStorage = beforeSnapshot.watermarkPlanted ? beforeSnapshot.sessionStorage - 1 : beforeSnapshot.sessionStorage;
4026
+ const expectedIndexedDB = beforeSnapshot.indexedDB;
4027
+ const expectedCookies = beforeSnapshot.cookies;
4028
+ const expectedCacheAPI = beforeSnapshot.cacheAPI;
4029
+ return afterSnapshot.localStorage !== expectedLocalStorage || afterSnapshot.sessionStorage !== expectedSessionStorage || afterSnapshot.indexedDB !== expectedIndexedDB || afterSnapshot.cookies !== expectedCookies || afterSnapshot.cacheAPI !== expectedCacheAPI;
4030
+ })();
4031
+ return {
4032
+ beforeSnapshot,
4033
+ afterSnapshot,
4034
+ isDifferent,
4035
+ watermarkStatus,
4036
+ takeBeforeSnapshot,
4037
+ takeAfterSnapshot,
4038
+ plantWatermark,
4039
+ verifyWatermark
4040
+ };
4041
+ }
4042
+
4043
+ // src/hooks/useAirplaneMode.ts
4044
+ import { useState as useState9, useEffect as useEffect7, useCallback as useCallback11, useRef as useRef7 } from "react";
4045
+ function detectPlatform() {
4046
+ const ua = navigator.userAgent.toLowerCase();
4047
+ const platform = navigator.platform?.toLowerCase() || "";
4048
+ if (/iphone|ipad|android/i.test(ua)) return "mobile";
4049
+ if (platform.includes("mac") || ua.includes("mac")) return "mac";
4050
+ if (platform.includes("win") || ua.includes("win")) return "windows";
4051
+ if (platform.includes("linux") || ua.includes("linux")) return "linux";
4052
+ return "unknown";
4053
+ }
4054
+ function getOfflineInstructionsForPlatform(platform) {
4055
+ switch (platform) {
4056
+ case "mac":
4057
+ return "Click the WiFi icon in your menu bar \u2192 Turn Wi-Fi Off, or press Option+click WiFi \u2192 Disconnect";
4058
+ case "windows":
4059
+ return "Click the WiFi icon in your taskbar \u2192 Click the connection \u2192 Disconnect, or enable Airplane Mode";
4060
+ case "linux":
4061
+ return "Click the network icon \u2192 Disable Wi-Fi, or run: nmcli radio wifi off";
4062
+ case "mobile":
4063
+ return "Swipe down for Control Center/Quick Settings \u2192 Enable Airplane Mode";
4064
+ default:
4065
+ return "Disable your WiFi or enable Airplane Mode in your system settings";
4066
+ }
4067
+ }
4068
+ function useAirplaneMode() {
4069
+ const [state, setState] = useState9({
4070
+ isOnline: typeof navigator !== "undefined" ? navigator.onLine : true,
4071
+ processingStartedOffline: false,
4072
+ stayedOfflineDuringProcessing: false,
4073
+ challengeAccepted: false,
4074
+ isProcessing: false,
4075
+ connectionLog: [],
4076
+ proofInvalidated: false
4077
+ });
4078
+ const platform = useRef7(detectPlatform());
4079
+ const logEvent = useCallback11((status, event) => {
4080
+ setState((prev) => ({
4081
+ ...prev,
4082
+ connectionLog: [
4083
+ ...prev.connectionLog,
4084
+ {
4085
+ timestamp: Date.now(),
4086
+ status,
4087
+ event
4088
+ }
4089
+ ]
4090
+ }));
4091
+ }, []);
4092
+ const prevOnlineRef = useRef7(null);
4093
+ useEffect7(() => {
4094
+ const initialOnline = navigator.onLine;
4095
+ prevOnlineRef.current = initialOnline;
4096
+ logEvent(initialOnline ? "online" : "offline", "initial");
4097
+ const handleOnline = () => {
4098
+ if (prevOnlineRef.current === true) return;
4099
+ prevOnlineRef.current = true;
4100
+ setState((prev) => {
4101
+ const newState = { ...prev, isOnline: true };
4102
+ if (prev.isProcessing && prev.processingStartedOffline) {
4103
+ newState.proofInvalidated = true;
4104
+ newState.stayedOfflineDuringProcessing = false;
4105
+ }
4106
+ return newState;
4107
+ });
4108
+ logEvent("online", "change");
4109
+ };
4110
+ const handleOffline = () => {
4111
+ if (prevOnlineRef.current === false) return;
4112
+ prevOnlineRef.current = false;
4113
+ setState((prev) => ({ ...prev, isOnline: false }));
4114
+ logEvent("offline", "change");
4115
+ };
4116
+ window.addEventListener("online", handleOnline);
4117
+ window.addEventListener("offline", handleOffline);
4118
+ const pollInterval = setInterval(() => {
4119
+ const currentOnline = navigator.onLine;
4120
+ if (currentOnline !== prevOnlineRef.current) {
4121
+ if (currentOnline) {
4122
+ handleOnline();
4123
+ } else {
4124
+ handleOffline();
4125
+ }
4126
+ }
4127
+ }, 2e3);
4128
+ return () => {
4129
+ window.removeEventListener("online", handleOnline);
4130
+ window.removeEventListener("offline", handleOffline);
4131
+ clearInterval(pollInterval);
4132
+ };
4133
+ }, [logEvent]);
4134
+ const acceptChallenge = useCallback11(() => {
4135
+ setState((prev) => ({ ...prev, challengeAccepted: true }));
4136
+ }, []);
4137
+ const startProcessing = useCallback11(() => {
4138
+ const isCurrentlyOffline = !navigator.onLine;
4139
+ setState((prev) => ({
4140
+ ...prev,
4141
+ isProcessing: true,
4142
+ processingStartedOffline: isCurrentlyOffline,
4143
+ stayedOfflineDuringProcessing: isCurrentlyOffline,
4144
+ proofInvalidated: false
4145
+ }));
4146
+ logEvent(isCurrentlyOffline ? "offline" : "online", "processing_start");
4147
+ }, [logEvent]);
4148
+ const endProcessing = useCallback11(() => {
4149
+ const isCurrentlyOffline = !navigator.onLine;
4150
+ setState((prev) => ({
4151
+ ...prev,
4152
+ isProcessing: false,
4153
+ // Only valid if started offline AND still offline
4154
+ stayedOfflineDuringProcessing: prev.processingStartedOffline && isCurrentlyOffline && !prev.proofInvalidated
4155
+ }));
4156
+ logEvent(isCurrentlyOffline ? "offline" : "online", "processing_end");
4157
+ }, [logEvent]);
4158
+ const reset = useCallback11(() => {
4159
+ setState({
4160
+ isOnline: navigator.onLine,
4161
+ processingStartedOffline: false,
4162
+ stayedOfflineDuringProcessing: false,
4163
+ challengeAccepted: false,
4164
+ isProcessing: false,
4165
+ connectionLog: [],
4166
+ proofInvalidated: false
4167
+ });
4168
+ }, []);
4169
+ const getStatusMessage = useCallback11(() => {
4170
+ if (state.proofInvalidated) {
4171
+ return "Connection was restored during processing - offline proof invalidated";
4172
+ }
4173
+ if (state.stayedOfflineDuringProcessing) {
4174
+ return "Processed with NO INTERNET CONNECTION - strong privacy assurance";
4175
+ }
4176
+ if (state.processingStartedOffline && state.isProcessing) {
4177
+ return "Processing offline - stay disconnected for valid proof";
4178
+ }
4179
+ if (!state.isOnline) {
4180
+ return "You're offline - drop your file now for maximum privacy";
4181
+ }
4182
+ if (state.challengeAccepted) {
4183
+ return "Go offline to prove files stay local";
4184
+ }
4185
+ return "Currently online - consider going offline for proof";
4186
+ }, [state]);
4187
+ const getOfflineInstructions = useCallback11(() => {
4188
+ return getOfflineInstructionsForPlatform(platform.current);
4189
+ }, []);
4190
+ return {
4191
+ state,
4192
+ acceptChallenge,
4193
+ startProcessing,
4194
+ endProcessing,
4195
+ reset,
4196
+ getStatusMessage,
4197
+ getOfflineInstructions
4198
+ };
4199
+ }
4200
+
4201
+ // src/hooks/useFirstTimeUser.ts
4202
+ import { useState as useState10, useEffect as useEffect8, useCallback as useCallback12 } from "react";
4203
+ var STORAGE_KEY = "purge_onboarding_completed";
4204
+ function useFirstTimeUser() {
4205
+ const [isFirstTime, setIsFirstTime] = useState10(false);
4206
+ const [isLoading, setIsLoading] = useState10(true);
4207
+ useEffect8(() => {
4208
+ try {
4209
+ const hasCompleted = localStorage.getItem(STORAGE_KEY);
4210
+ setIsFirstTime(hasCompleted !== "true");
4211
+ } catch {
4212
+ setIsFirstTime(false);
4213
+ }
4214
+ setIsLoading(false);
4215
+ }, []);
4216
+ const markOnboardingComplete = useCallback12(() => {
4217
+ try {
4218
+ localStorage.setItem(STORAGE_KEY, "true");
4219
+ } catch {
4220
+ }
4221
+ setIsFirstTime(false);
4222
+ }, []);
4223
+ const resetOnboarding = useCallback12(() => {
4224
+ try {
4225
+ localStorage.removeItem(STORAGE_KEY);
4226
+ } catch {
4227
+ }
4228
+ setIsFirstTime(true);
4229
+ }, []);
4230
+ return {
4231
+ isFirstTime,
4232
+ isLoading,
4233
+ markOnboardingComplete,
4234
+ resetOnboarding
4235
+ };
4236
+ }
4237
+
4238
+ // src/utils/maskPII.ts
4239
+ function maskPII(category, value) {
4240
+ switch (category) {
4241
+ case "email": {
4242
+ const atIndex = value.indexOf("@");
4243
+ if (atIndex === -1) return maskGeneric(value);
4244
+ const local = value.slice(0, atIndex);
4245
+ const domain = value.slice(atIndex + 1);
4246
+ const maskedLocal = local.slice(0, 2) + "**";
4247
+ const dotIndex = domain.lastIndexOf(".");
4248
+ if (dotIndex === -1) return `${maskedLocal}@**`;
4249
+ const ext = domain.slice(dotIndex);
4250
+ const domainName = domain.slice(0, dotIndex);
4251
+ const maskedDomain = "**" + domainName.slice(-2) + ext;
4252
+ return `${maskedLocal}@${maskedDomain}`;
4253
+ }
4254
+ case "phone": {
4255
+ const digits = value.replace(/\D/g, "");
4256
+ if (digits.length < 4) return maskGeneric(value);
4257
+ const lastFour = digits.slice(-4);
4258
+ return `***-***-${lastFour.slice(0, 2)}${lastFour.slice(2)}`;
4259
+ }
4260
+ case "ssn": {
4261
+ const digits = value.replace(/\D/g, "");
4262
+ if (digits.length < 4) return maskGeneric(value);
4263
+ return `***-**-**${digits.slice(-2)}`;
4264
+ }
4265
+ case "credit_card": {
4266
+ const digits = value.replace(/\D/g, "");
4267
+ if (digits.length < 4) return maskGeneric(value);
4268
+ return `****-****-****-${digits.slice(-4)}`;
4269
+ }
4270
+ case "person_name": {
4271
+ return value.split(/\s+/).map((part) => part.length > 0 ? part[0] + "***" : "").join(" ");
4272
+ }
4273
+ case "address": {
4274
+ const words = value.split(/\s+/);
4275
+ if (words.length < 2) return maskGeneric(value);
4276
+ return words.map((w, i) => {
4277
+ if (/^\d+$/.test(w)) return "***";
4278
+ if (i === 0) return "***";
4279
+ if (w.length > 4) return w.slice(0, 4) + "***";
4280
+ return w;
4281
+ }).join(" ");
4282
+ }
4283
+ case "date_of_birth": {
4284
+ if (value.includes("/")) {
4285
+ const parts = value.split("/");
4286
+ if (parts.length === 3) {
4287
+ return `**/${parts[1]}/**${parts[2].slice(-2)}`;
4288
+ }
4289
+ }
4290
+ if (value.includes("-")) {
4291
+ const parts = value.split("-");
4292
+ if (parts.length === 3) {
4293
+ return `****-${parts[1]}-**`;
4294
+ }
4295
+ }
4296
+ return maskGeneric(value);
4297
+ }
4298
+ case "ip_address": {
4299
+ const parts = value.split(".");
4300
+ if (parts.length === 4) {
4301
+ return `${parts[0]}.${parts[1]}.***.***`;
4302
+ }
4303
+ return maskGeneric(value);
4304
+ }
4305
+ case "custom":
4306
+ default:
4307
+ return maskGeneric(value);
4308
+ }
4309
+ }
4310
+ function maskGeneric(value) {
4311
+ const len = value.length;
4312
+ if (len <= 4) return "****";
4313
+ return value.slice(0, 2) + "***" + value.slice(-2);
4314
+ }
4315
+ function getCategoryLabel(category) {
4316
+ const labels = {
4317
+ person_name: "NAME",
4318
+ email: "EMAIL",
4319
+ phone: "PHONE",
4320
+ address: "ADDRESS",
4321
+ ssn: "SSN",
4322
+ credit_card: "CARD",
4323
+ ip_address: "IP",
4324
+ date_of_birth: "DOB",
4325
+ custom: "CUSTOM"
4326
+ };
4327
+ return labels[category] || "UNKNOWN";
4328
+ }
4329
+ function getCategoryIcon(category) {
4330
+ const icons = {
4331
+ person_name: "[N]",
4332
+ email: "[@]",
4333
+ phone: "[#]",
4334
+ address: "[A]",
4335
+ ssn: "[S]",
4336
+ credit_card: "[$]",
4337
+ ip_address: "[I]",
4338
+ date_of_birth: "[D]",
4339
+ custom: "[*]"
4340
+ };
4341
+ return icons[category] || "[?]";
4342
+ }
4343
+
4344
+ // src/utils/fileMagic.ts
4345
+ var MAGIC_SIGNATURES = {
4346
+ // Office Open XML formats (DOCX, XLSX, PPTX) are ZIP archives
4347
+ // They all start with PK (0x50 0x4B 0x03 0x04)
4348
+ docx: [[80, 75, 3, 4]],
4349
+ xlsx: [[80, 75, 3, 4]],
4350
+ pptx: [[80, 75, 3, 4]],
4351
+ // PDF starts with %PDF-
4352
+ pdf: [[37, 80, 68, 70, 45]]
4353
+ // %PDF-
4354
+ };
4355
+ var EXPECTED_MIMES = {
4356
+ docx: [
4357
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
4358
+ "application/octet-stream"
4359
+ // Some browsers report this
4360
+ ],
4361
+ xlsx: [
4362
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
4363
+ "application/vnd.ms-excel",
4364
+ "application/octet-stream"
4365
+ ],
4366
+ pptx: [
4367
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation",
4368
+ "application/octet-stream"
4369
+ ],
4370
+ pdf: [
4371
+ "application/pdf",
4372
+ "application/octet-stream"
4373
+ ]
4374
+ };
4375
+ function matchesMagicBytes(bytes, signatures) {
4376
+ for (const signature of signatures) {
4377
+ if (bytes.length < signature.length) continue;
4378
+ let matches = true;
4379
+ for (let i = 0; i < signature.length; i++) {
4380
+ if (bytes[i] !== signature[i]) {
4381
+ matches = false;
4382
+ break;
4383
+ }
4384
+ }
4385
+ if (matches) return true;
4386
+ }
4387
+ return false;
4388
+ }
4389
+ function getExtension(filename) {
4390
+ const lastDot = filename.lastIndexOf(".");
4391
+ if (lastDot === -1) return "";
4392
+ return filename.slice(lastDot).toLowerCase();
4393
+ }
4394
+ var EXTENSION_MAP = {
4395
+ ".docx": "docx",
4396
+ ".xlsx": "xlsx",
4397
+ ".pptx": "pptx",
4398
+ ".pdf": "pdf"
4399
+ };
4400
+ async function validateFileType(file) {
4401
+ const warnings = [];
4402
+ const ext = getExtension(file.name);
4403
+ const typeByExt = EXTENSION_MAP[ext];
4404
+ if (!typeByExt) {
4405
+ return {
4406
+ valid: false,
4407
+ fileType: null,
4408
+ error: `Unsupported file extension: ${ext}`,
4409
+ warnings
4410
+ };
4411
+ }
4412
+ const expectedMimes = EXPECTED_MIMES[typeByExt];
4413
+ if (!expectedMimes.includes(file.type) && file.type !== "") {
4414
+ warnings.push(`Unexpected MIME type: ${file.type} (expected ${expectedMimes[0]})`);
4415
+ }
4416
+ try {
4417
+ const headerSize = Math.max(...MAGIC_SIGNATURES[typeByExt].map((s) => s.length));
4418
+ const header = await file.slice(0, headerSize).arrayBuffer();
4419
+ const bytes = new Uint8Array(header);
4420
+ const expectedSignatures = MAGIC_SIGNATURES[typeByExt];
4421
+ if (!matchesMagicBytes(bytes, expectedSignatures)) {
4422
+ const officeTypes = ["docx", "xlsx", "pptx"];
4423
+ if (officeTypes.includes(typeByExt)) {
4424
+ const isZip = matchesMagicBytes(bytes, [[80, 75, 3, 4]]);
4425
+ if (!isZip) {
4426
+ return {
4427
+ valid: false,
4428
+ fileType: null,
4429
+ error: `File header does not match ${ext.toUpperCase()} format. File may be corrupted or misnamed.`,
4430
+ warnings
4431
+ };
4432
+ }
4433
+ warnings.push("File is a valid ZIP archive but specific Office format cannot be verified without parsing");
4434
+ } else {
4435
+ return {
4436
+ valid: false,
4437
+ fileType: null,
4438
+ error: `File header does not match ${ext.toUpperCase()} format. File may be corrupted or misnamed.`,
4439
+ warnings
4440
+ };
4441
+ }
4442
+ }
4443
+ } catch (error) {
4444
+ warnings.push("Could not verify file header (file may be too small or corrupted)");
4445
+ }
4446
+ return {
4447
+ valid: true,
4448
+ fileType: typeByExt,
4449
+ warnings
4450
+ };
4451
+ }
4452
+ function hasValidExtension(filename) {
4453
+ const ext = getExtension(filename);
4454
+ return ext in EXTENSION_MAP;
4455
+ }
4456
+ function getFileTypeFromExtension(filename) {
4457
+ const ext = getExtension(filename);
4458
+ return EXTENSION_MAP[ext] || null;
4459
+ }
4460
+
4461
+ // src/utils/download.ts
4462
+ function downloadFile(file) {
4463
+ const url = URL.createObjectURL(file.blob);
4464
+ const a = document.createElement("a");
4465
+ a.href = url;
4466
+ a.download = file.purgedName;
4467
+ document.body.appendChild(a);
4468
+ a.click();
4469
+ document.body.removeChild(a);
4470
+ URL.revokeObjectURL(url);
4471
+ }
4472
+ async function downloadFilesAsZip(files, zipName = "purged_files.zip") {
4473
+ if (files.length === 0) return;
4474
+ if (files.length === 1) {
4475
+ downloadFile(files[0]);
4476
+ return;
4477
+ }
4478
+ try {
4479
+ const { zipSync, strToU8 } = await import("fflate");
4480
+ const zipContents = {};
4481
+ for (const file of files) {
4482
+ const arrayBuffer = await file.blob.arrayBuffer();
4483
+ zipContents[file.purgedName] = new Uint8Array(arrayBuffer);
4484
+ }
4485
+ const manifest = {
4486
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
4487
+ files: files.map((f) => ({
4488
+ originalName: f.originalName,
4489
+ purgedName: f.purgedName,
4490
+ originalSize: f.originalSize,
4491
+ purgedSize: f.purgedSize,
4492
+ detectionsRemoved: f.detectionsRemoved
4493
+ })),
4494
+ notice: "Files processed locally with PURGE. No data was transmitted to any server."
4495
+ };
4496
+ zipContents["_purge_manifest.json"] = strToU8(
4497
+ JSON.stringify(manifest, null, 2)
4498
+ );
4499
+ const zipped = zipSync(zipContents, {
4500
+ level: 6
4501
+ // Balanced compression
4502
+ });
4503
+ const blob = new Blob([new Uint8Array(zipped)], { type: "application/zip" });
4504
+ const url = URL.createObjectURL(blob);
4505
+ const a = document.createElement("a");
4506
+ a.href = url;
4507
+ a.download = zipName;
4508
+ document.body.appendChild(a);
4509
+ a.click();
4510
+ document.body.removeChild(a);
4511
+ URL.revokeObjectURL(url);
4512
+ } catch (error) {
4513
+ secureWarn("ZIP creation failed, falling back to individual downloads", error);
4514
+ await downloadFilesSequentially(files);
4515
+ }
4516
+ }
4517
+ async function downloadFilesSequentially(files, delayMs = 500) {
4518
+ for (const file of files) {
4519
+ downloadFile(file);
4520
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
4521
+ }
4522
+ }
4523
+
4524
+ // src/utils/hilbertCurve.ts
4525
+ function hilbertD2XY(n, d) {
4526
+ if (n <= 0 || (n & n - 1) !== 0) {
4527
+ throw new Error("Grid size n must be a positive power of 2");
4528
+ }
4529
+ if (d < 0 || d >= n * n) {
4530
+ throw new Error(`Index d must be between 0 and ${n * n - 1}`);
4531
+ }
4532
+ let x = 0;
4533
+ let y = 0;
4534
+ let s = 1;
4535
+ let rx;
4536
+ let ry;
4537
+ let t = d;
4538
+ while (s < n) {
4539
+ rx = 1 & t >> 1;
4540
+ ry = 1 & (t ^ rx);
4541
+ if (ry === 0) {
4542
+ if (rx === 1) {
4543
+ x = s - 1 - x;
4544
+ y = s - 1 - y;
4545
+ }
4546
+ [x, y] = [y, x];
4547
+ }
4548
+ x += s * rx;
4549
+ y += s * ry;
4550
+ t = Math.floor(t / 4);
4551
+ s *= 2;
4552
+ }
4553
+ return { x, y };
4554
+ }
4555
+ function hilbertXY2D(n, x, y) {
4556
+ if (n <= 0 || (n & n - 1) !== 0) {
4557
+ throw new Error("Grid size n must be a positive power of 2");
4558
+ }
4559
+ if (x < 0 || x >= n || y < 0 || y >= n) {
4560
+ throw new Error(`Coordinates must be between 0 and ${n - 1}`);
4561
+ }
4562
+ let d = 0;
4563
+ let s = n >> 1;
4564
+ let rx;
4565
+ let ry;
4566
+ let tempX = x;
4567
+ let tempY = y;
4568
+ while (s > 0) {
4569
+ rx = (tempX & s) > 0 ? 1 : 0;
4570
+ ry = (tempY & s) > 0 ? 1 : 0;
4571
+ d += s * s * (3 * rx ^ ry);
4572
+ if (ry === 0) {
4573
+ if (rx === 1) {
4574
+ tempX = s - 1 - tempX;
4575
+ tempY = s - 1 - tempY;
4576
+ }
4577
+ [tempX, tempY] = [tempY, tempX];
4578
+ }
4579
+ s >>= 1;
4580
+ }
4581
+ return d;
4582
+ }
4583
+ function getOptimalGridSize(blockCount) {
4584
+ if (blockCount <= 0) return 1;
4585
+ let n = 1;
4586
+ while (n * n < blockCount) {
4587
+ n *= 2;
4588
+ }
4589
+ return n;
4590
+ }
4591
+
4592
+ // src/constants/categories.ts
4593
+ var categories = [
4594
+ {
4595
+ id: "person_name",
4596
+ name: "Names",
4597
+ description: "Personal names, first/last names",
4598
+ icon: "N",
4599
+ examples: ["John Smith", "Jane Doe", "Dr. Robert Johnson"],
4600
+ defaultEnabled: true
4601
+ },
4602
+ {
4603
+ id: "email",
4604
+ name: "Email",
4605
+ description: "Email addresses",
4606
+ icon: "@",
4607
+ examples: ["john@example.com", "jane.doe@company.org"],
4608
+ defaultEnabled: true
4609
+ },
4610
+ {
4611
+ id: "phone",
4612
+ name: "Phone",
4613
+ description: "Phone numbers in various formats",
4614
+ icon: "#",
4615
+ examples: ["(555) 123-4567", "+1-555-123-4567", "555.123.4567"],
4616
+ defaultEnabled: true
4617
+ },
4618
+ {
4619
+ id: "address",
4620
+ name: "Address",
4621
+ description: "Physical addresses, street addresses",
4622
+ icon: "A",
4623
+ examples: ["123 Main St, Anytown, USA 12345"],
4624
+ defaultEnabled: true
4625
+ },
4626
+ {
4627
+ id: "ssn",
4628
+ name: "SSN",
4629
+ description: "Social Security Numbers",
4630
+ icon: "S",
4631
+ examples: ["123-45-6789", "123456789"],
4632
+ defaultEnabled: true
4633
+ },
4634
+ {
4635
+ id: "credit_card",
4636
+ name: "Credit Card",
4637
+ description: "Credit/debit card numbers",
4638
+ icon: "$",
4639
+ examples: ["4111-1111-1111-1111", "4111111111111111"],
4640
+ defaultEnabled: true
4641
+ },
4642
+ {
4643
+ id: "ip_address",
4644
+ name: "IP Address",
4645
+ description: "IPv4 and IPv6 addresses",
4646
+ icon: "IP",
4647
+ examples: ["192.168.1.1", "10.0.0.1"],
4648
+ defaultEnabled: false
4649
+ },
4650
+ {
4651
+ id: "date_of_birth",
4652
+ name: "Date of Birth",
4653
+ description: "Birth dates in various formats",
4654
+ icon: "D",
4655
+ examples: ["01/15/1990", "January 15, 1990", "1990-01-15"],
4656
+ defaultEnabled: true
4657
+ }
4658
+ ];
4659
+ var categoryMap = new Map(categories.map((c) => [c.id, c]));
4660
+ function getCategoryName(id) {
4661
+ return categoryMap.get(id)?.name ?? id;
4662
+ }
4663
+ function getCategoryIcon2(id) {
4664
+ return categoryMap.get(id)?.icon ?? "?";
4665
+ }
4666
+
4667
+ // src/constants/platformInstructions.ts
4668
+ function detectPlatform2() {
4669
+ if (typeof navigator === "undefined") return "unknown";
4670
+ const ua = navigator.userAgent.toLowerCase();
4671
+ if (/iphone|ipad|ipod/.test(ua)) return "ios";
4672
+ if (/android/.test(ua)) return "android";
4673
+ if (/macintosh|mac os x/.test(ua)) return "mac";
4674
+ if (/windows/.test(ua)) return "windows";
4675
+ return "unknown";
4676
+ }
4677
+ var PLATFORM_INSTRUCTIONS = {
4678
+ mac: {
4679
+ icon: "",
4680
+ steps: [
4681
+ "Click the WiFi icon in the menu bar",
4682
+ 'Select "Turn Wi-Fi Off"',
4683
+ "Or press Option + click WiFi icon for quick toggle"
4684
+ ]
4685
+ },
4686
+ windows: {
4687
+ icon: "",
4688
+ steps: [
4689
+ "Click the network icon in the taskbar",
4690
+ 'Toggle "Wi-Fi" to Off',
4691
+ 'Or enable "Airplane mode"'
4692
+ ]
4693
+ },
4694
+ ios: {
4695
+ icon: "",
4696
+ steps: [
4697
+ "Swipe down from top-right for Control Center",
4698
+ "Tap the airplane icon",
4699
+ "Or go to Settings > Airplane Mode"
4700
+ ]
4701
+ },
4702
+ android: {
4703
+ icon: "",
4704
+ steps: [
4705
+ "Swipe down from the top of the screen",
4706
+ 'Tap "Airplane mode" in Quick Settings',
4707
+ "Or go to Settings > Network > Airplane mode"
4708
+ ]
4709
+ },
4710
+ unknown: {
4711
+ icon: "",
4712
+ steps: [
4713
+ "Disconnect from WiFi",
4714
+ "Or enable Airplane Mode",
4715
+ "Or unplug your ethernet cable"
4716
+ ]
4717
+ }
4718
+ };
4719
+
4720
+ // src/services/sessionSummary.ts
4721
+ import { jsPDF } from "jspdf";
4722
+ var DISCLAIMER = `
4723
+ IMPORTANT DISCLAIMER
4724
+
4725
+ This document is a SESSION SUMMARY, NOT a proof certificate.
4726
+ All metrics shown are SELF-REPORTED by the PURGE application.
4727
+
4728
+ For INDEPENDENT VERIFICATION, please:
4729
+ 1. Enable Airplane Mode before processing files (strongest privacy indicator)
4730
+ 2. Check your browser's Developer Tools > Network tab during processing
4731
+ 3. Review the open-source code at github.com/Pacamelo/forge
4732
+
4733
+ This summary cannot prove that files were not copied or uploaded.
4734
+ It only records what our monitoring detected during the session.
4735
+ `;
4736
+ function generateSessionId() {
4737
+ return generatePrefixedId("PURGE");
4738
+ }
4739
+ function formatBytes(bytes) {
4740
+ if (bytes === 0) return "0 bytes";
4741
+ if (bytes < 1024) return `${bytes} bytes`;
4742
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
4743
+ return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
4744
+ }
4745
+ function truncateHash2(hash) {
4746
+ if (!hash || hash.length <= 20) return hash || "N/A";
4747
+ return `${hash.slice(0, 12)}...${hash.slice(-12)}`;
4748
+ }
4749
+ function generateSessionSummaryPDF(data) {
4750
+ const doc = new jsPDF();
4751
+ const pageWidth = doc.internal.pageSize.getWidth();
4752
+ let yPos = 20;
4753
+ const margin = 15;
4754
+ const lineHeight = 6;
4755
+ const addText = (text, fontSize = 10, bold = false) => {
4756
+ doc.setFontSize(fontSize);
4757
+ if (bold) {
4758
+ doc.setFont("helvetica", "bold");
4759
+ } else {
4760
+ doc.setFont("helvetica", "normal");
4761
+ }
4762
+ doc.text(text, margin, yPos);
4763
+ yPos += lineHeight;
4764
+ };
4765
+ const addSection = (title) => {
4766
+ yPos += 4;
4767
+ doc.setDrawColor(0, 255, 0);
4768
+ doc.line(margin, yPos, pageWidth - margin, yPos);
4769
+ yPos += 6;
4770
+ addText(title, 12, true);
4771
+ yPos += 2;
4772
+ };
4773
+ const checkPage = () => {
4774
+ if (yPos > 270) {
4775
+ doc.addPage();
4776
+ yPos = 20;
4777
+ }
4778
+ };
4779
+ doc.setTextColor(0, 255, 0);
4780
+ doc.setFontSize(18);
4781
+ doc.setFont("helvetica", "bold");
4782
+ doc.text("PURGE SESSION SUMMARY", margin, yPos);
4783
+ yPos += 8;
4784
+ doc.setFillColor(255, 200, 0);
4785
+ doc.rect(margin, yPos, pageWidth - margin * 2, 12, "F");
4786
+ doc.setTextColor(0, 0, 0);
4787
+ doc.setFontSize(10);
4788
+ doc.setFont("helvetica", "bold");
4789
+ doc.text("NOT A PROOF CERTIFICATE - SELF-REPORTED DATA ONLY", pageWidth / 2, yPos + 8, {
4790
+ align: "center"
4791
+ });
4792
+ yPos += 18;
4793
+ doc.setTextColor(50, 50, 50);
4794
+ addSection("SESSION INFORMATION");
4795
+ addText(`Session ID: ${data.sessionId}`);
4796
+ addText(`Timestamp: ${new Date(data.timestamp).toLocaleString()}`);
4797
+ addText(`Browser: ${data.environment.browser}`);
4798
+ addText(`Platform: ${data.environment.platform}`);
4799
+ checkPage();
4800
+ addSection("FILES PROCESSED");
4801
+ if (data.files.length === 0) {
4802
+ addText("No files were processed in this session.");
4803
+ } else {
4804
+ data.files.forEach((file, index) => {
4805
+ checkPage();
4806
+ addText(`${index + 1}. ${file.name}`, 10, true);
4807
+ addText(` Original Size: ${formatBytes(file.originalSize)}`);
4808
+ addText(` Processed Size: ${formatBytes(file.processedSize)}`);
4809
+ addText(` Detections Removed: ${file.detectionsRemoved}`);
4810
+ addText(` Original Hash: ${truncateHash2(file.originalHash)}`);
4811
+ addText(` Processed Hash: ${truncateHash2(file.processedHash)}`);
4812
+ yPos += 2;
4813
+ });
4814
+ }
4815
+ checkPage();
4816
+ addSection("SELF-REPORTED METRICS");
4817
+ const { selfReportedMetrics } = data;
4818
+ const networkStatus = selfReportedMetrics.networkRequestsDetected === 0 ? "\u2713" : "\u26A0";
4819
+ addText(
4820
+ `${networkStatus} Network Requests Detected: ${selfReportedMetrics.networkRequestsDetected}`
4821
+ );
4822
+ const storageStatus = !selfReportedMetrics.storageChangesDetected ? "\u2713" : "\u26A0";
4823
+ addText(
4824
+ `${storageStatus} Storage Changes: ${selfReportedMetrics.storageChangesDetected ? "Detected" : "None detected"}`
4825
+ );
4826
+ const memoryStatus = selfReportedMetrics.memoryWipeCompleted ? "\u2713" : "\u25CB";
4827
+ addText(
4828
+ `${memoryStatus} Buffer Cleanup: ${selfReportedMetrics.memoryWipeCompleted ? "Completed (visual only)" : "Not completed"}`
4829
+ );
4830
+ const offlineStatus = selfReportedMetrics.wasOfflineDuringProcessing ? "\u2713" : "\u25CB";
4831
+ addText(
4832
+ `${offlineStatus} Offline Processing: ${selfReportedMetrics.wasOfflineDuringProcessing ? "YES - Processed with no internet" : "No - Was online during processing"}`
4833
+ );
4834
+ if (selfReportedMetrics.wasOfflineDuringProcessing) {
4835
+ yPos += 2;
4836
+ doc.setTextColor(0, 150, 0);
4837
+ addText(" \u2192 Offline processing provides the strongest privacy indication.", 9);
4838
+ doc.setTextColor(50, 50, 50);
4839
+ }
4840
+ checkPage();
4841
+ addSection("HOW TO VERIFY INDEPENDENTLY");
4842
+ addText("For stronger assurance that files never left your browser:", 10, true);
4843
+ yPos += 2;
4844
+ addText("1. AIRPLANE MODE CHALLENGE");
4845
+ addText(" Enable Airplane Mode before dropping files into PURGE.");
4846
+ addText(" Offline processing provides the strongest privacy assurance.");
4847
+ yPos += 2;
4848
+ addText("2. BROWSER DEVTOOLS");
4849
+ addText(" Open Developer Tools (F12) \u2192 Network tab before processing.");
4850
+ addText(" Watch for any requests containing your file data.");
4851
+ yPos += 2;
4852
+ addText("3. SOURCE CODE AUDIT");
4853
+ addText(" Review the open-source code at github.com/Pacamelo/forge");
4854
+ addText(" Verify there are no hidden network calls in the processing logic.");
4855
+ checkPage();
4856
+ addSection("DISCLAIMER");
4857
+ doc.setFontSize(8);
4858
+ const disclaimerLines = DISCLAIMER.trim().split("\n");
4859
+ disclaimerLines.forEach((line) => {
4860
+ if (yPos > 280) {
4861
+ doc.addPage();
4862
+ yPos = 20;
4863
+ }
4864
+ doc.text(line.trim(), margin, yPos);
4865
+ yPos += 4;
4866
+ });
4867
+ const pageCount = doc.getNumberOfPages();
4868
+ for (let i = 1; i <= pageCount; i++) {
4869
+ doc.setPage(i);
4870
+ doc.setFontSize(8);
4871
+ doc.setTextColor(150, 150, 150);
4872
+ doc.text(`Generated by PURGE | Page ${i} of ${pageCount}`, pageWidth / 2, 290, {
4873
+ align: "center"
4874
+ });
4875
+ }
4876
+ return doc.output("blob");
4877
+ }
4878
+ function downloadSessionSummary(data) {
4879
+ const blob = generateSessionSummaryPDF(data);
4880
+ const url = URL.createObjectURL(blob);
4881
+ const a = document.createElement("a");
4882
+ a.href = url;
4883
+ a.download = `PURGE-Session-${data.sessionId}.pdf`;
4884
+ document.body.appendChild(a);
4885
+ a.click();
4886
+ document.body.removeChild(a);
4887
+ URL.revokeObjectURL(url);
4888
+ }
4889
+ export {
4890
+ BaseProcessor,
4891
+ OfflineQuotaStore,
4892
+ PLATFORM_INSTRUCTIONS,
4893
+ QuotaExhaustedError,
4894
+ QuotaExhaustedError as QuotaExhaustedErrorClass,
4895
+ RegexDetectionEngine,
4896
+ XlsxProcessor,
4897
+ adversarialVerifier,
4898
+ categories,
4899
+ categoryMap,
4900
+ detectPlatform2 as detectPlatform,
4901
+ downloadFile,
4902
+ downloadFilesAsZip,
4903
+ downloadSessionSummary,
4904
+ generateDeviceFingerprint,
4905
+ generatePrefixedId,
4906
+ generateSectionId,
4907
+ generateSecureId,
4908
+ generateSessionId,
4909
+ generateSessionSummaryPDF,
4910
+ getCategoryIcon,
4911
+ getCategoryIcon2 as getCategoryIconFromCategories,
4912
+ getCategoryLabel,
4913
+ getCategoryName,
4914
+ getFileTypeFromExtension,
4915
+ getOptimalGridSize,
4916
+ getPartialMask,
4917
+ getPatternsForCategories,
4918
+ getProcessor,
4919
+ getProcessorForFile,
4920
+ getSensitivityThreshold,
4921
+ hasValidExtension,
4922
+ hilbertD2XY,
4923
+ hilbertXY2D,
4924
+ maskPII,
4925
+ offlineQuotaStore,
4926
+ regexDetectionEngine,
4927
+ requestQuotaRefresh,
4928
+ safeCompileRegex,
4929
+ safeFilename,
4930
+ secureError,
4931
+ secureLog,
4932
+ secureWarn,
4933
+ truncateHash,
4934
+ useAdversarialAnalysis,
4935
+ useAirplaneMode,
4936
+ useDocumentProcessor,
4937
+ useEnhancedNetworkProof,
4938
+ useFileEntropy,
4939
+ useFileHash,
4940
+ useFirstTimeUser,
4941
+ useNetworkProof,
4942
+ useOfflineEnforcement,
4943
+ useOfflineQuota,
4944
+ usePurgeStore,
4945
+ useSpreadsheetMetadata,
4946
+ useStorageProof,
4947
+ validateFileType,
4948
+ verifyDeviceFingerprint,
4949
+ xlsxProcessor
4950
+ };
4951
+ /**
4952
+ * @pacamelo/core
4953
+ * Core PII detection and redaction engine for PURGE
4954
+ *
4955
+ * This package contains the data-processing logic for PURGE:
4956
+ * - PII detection patterns and engine
4957
+ * - Document parsers (XLSX, etc.)
4958
+ * - Masking and redaction utilities
4959
+ * - Security infrastructure
4960
+ *
4961
+ * Open source for transparency - users can verify exactly
4962
+ * how their data is processed.
4963
+ *
4964
+ * @see https://github.com/Pacamelo/purge-core
4965
+ * @license MIT
4966
+ */