Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 36 additions & 36 deletions src/lib/privacy-innovations.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { PrivacyLevel } from "./privacy-layer";
import type { PrivacyLevel } from "./privacy-layer";

// ============================================================
// 1. PRIVACY PERSONAS
Expand Down Expand Up @@ -148,7 +148,7 @@ export const PERSONAS: Record<PersonaId, PrivacyPersona> = {
*/
export function detectPersona(request: string, data?: string): PersonaDetectionResult {
const text = `${request} ${data || ""}`.toLowerCase();

let bestPersona: PersonaId = "auto";
let bestScore = 0;
const triggeredKeywords: string[] = [];
Expand All @@ -157,14 +157,14 @@ export function detectPersona(request: string, data?: string): PersonaDetectionR
if (id === "auto") continue;
let score = 0;
const hits: string[] = [];

for (const keyword of persona.autoDetectKeywords) {
if (text.includes(keyword.toLowerCase())) {
score += 1;
hits.push(keyword);
}
}

if (score > bestScore) {
bestScore = score;
bestPersona = id as PersonaId;
Expand All @@ -175,7 +175,7 @@ export function detectPersona(request: string, data?: string): PersonaDetectionR

// Confidence calculation
const confidence = Math.min(100, bestScore * 15);

return {
detectedPersona: bestPersona,
confidence,
Expand Down Expand Up @@ -209,7 +209,7 @@ export function applyPersonaRules(
const persona = PERSONAS[personaId];
const appliedRules: HardRule[] = [];
let explanation = `Base level (${baseLevel}) selected by sensitivity analysis.`;

// Apply hard rules in order, collecting all rules as we go
for (const rule of persona.hardRules) {
appliedRules.push(rule);
Expand Down Expand Up @@ -259,7 +259,7 @@ export function applyPersonaRules(
return { finalLevel: persona.defaultLevel, appliedRules, explanation };
}
}

return { finalLevel: baseLevel, appliedRules, explanation };
}

Expand Down Expand Up @@ -329,36 +329,36 @@ function extractEntities(text: string): EntityMapping[] {
const mappings: EntityMapping[] = [];
let nameIdx = 0;
let orgIdx = 0;

// Names
const names = extractNames(text);
for (const name of names) {
if (nameIdx < SYNTHETIC_NAMES.length) {
mappings.push({ original: name, synthetic: SYNTHETIC_NAMES[nameIdx++], type: "name" });
}
}

// SSN patterns — only match formatted SSNs (NNN-NN-NNNN) to avoid false positives on zip codes, IDs, etc.
const ssnPattern = /\b\d{3}-\d{2}-\d{4}\b/g;
let ssnMatch;
while ((ssnMatch = ssnPattern.exec(text)) !== null) {
mappings.push({ original: ssnMatch[0], synthetic: generateSyntheticId("ssn"), type: "ssn" });
}

// Email patterns
const emailPattern = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g;
let emailMatch;
while ((emailMatch = emailPattern.exec(text)) !== null) {
mappings.push({ original: emailMatch[0], synthetic: generateSyntheticId("email"), type: "email" });
}

// Phone patterns
const phonePattern = /\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}/g;
let phoneMatch;
while ((phoneMatch = phonePattern.exec(text)) !== null) {
mappings.push({ original: phoneMatch[0], synthetic: generateSyntheticId("phone"), type: "phone" });
}

// Organizations (capitalized multi-word phrases not caught as names)
const orgPattern = /(?:Corp|Inc|LLC|Ltd|Company|Ventures|Partners|Group|Systems|Labs|Industries|Technologies)/gi;
let orgMatch;
Expand All @@ -370,7 +370,7 @@ function extractEntities(text: string): EntityMapping[] {
mappings.push({ original: orgName, synthetic: SYNTHETIC_ORGS[orgIdx++ % SYNTHETIC_ORGS.length], type: "organization" });
}
}

// Dollar amounts (preserve range but mask exact)
const amountPattern = /\$[\d,]+(?:\.\d{2})?\s*(?:M|K|Billion|Million)?/gi;
let amountMatch;
Expand All @@ -384,21 +384,21 @@ function extractEntities(text: string): EntityMapping[] {
else synthetic = "$XXX.XX";
mappings.push({ original: amountMatch[0], synthetic, type: "amount" });
}

// Patient/record IDs
const idPattern = /(?:patient\s*id|mrn|record\s*#|id[:\s]+)\s*[:#]?\s*(\d+)/gi;
let idMatch;
while ((idMatch = idPattern.exec(text)) !== null) {
mappings.push({ original: idMatch[0], synthetic: `ID: ${generateSyntheticId("mrn")}`, type: "id" });
}

// API keys / credentials
const credPattern = /(?:AKIA[0-9A-Z]{16}|ghp_[a-zA-Z0-9]{36}|sk-[a-zA-Z0-9]{32,}|api[_-]?key[:\s]+[a-zA-Z0-9_-]{8,})/gi;
let credMatch;
while ((credMatch = credPattern.exec(text)) !== null) {
mappings.push({ original: credMatch[0], synthetic: "[REDACTED-CREDENTIAL]", type: "credential" });
}

return mappings;
}

Expand Down Expand Up @@ -443,24 +443,24 @@ function extractEntities(text: string): EntityMapping[] {
*/
export function applySyntheticSubstitution(text: string): SyntheticResult {
const mappings = extractEntities(text);

// Sort by length (descending) to avoid partial replacements
const sortedMappings = [...mappings].sort((a, b) => b.original.length - a.original.length);

let transformedText = text;
for (const mapping of sortedMappings) {
// Use a global replace with escape for regex special chars
const escaped = mapping.original.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
transformedText = transformedText.replace(new RegExp(escaped, "g"), mapping.synthetic);
}

// Collect stats preserved
const statsPreserved: string[] = [];
if (mappings.some(m => m.type === "name")) statsPreserved.push("Entity relationships preserved");
if (mappings.some(m => m.type === "amount")) statsPreserved.push("Value ranges preserved");
if (mappings.some(m => m.type === "ssn" || m.type === "id")) statsPreserved.push("Record structure preserved");
if (mappings.length > 0) statsPreserved.push("Semantic patterns maintained");

return {
transformedText,
mappings,
Expand Down Expand Up @@ -572,32 +572,32 @@ export function embedWatermark(
const timestamp = Date.now();
const queryHash = hashString(query).slice(0, 16);
const userFingerprint = hashString(navigator?.userAgent || "unknown").slice(0, 8);

const metadata: WatermarkMetadata = {
timestamp,
privacyLevel,
queryHash,
userFingerprint,
};

// Encode metadata as binary string
// Format: [timestamp(48 bits)][level(3 bits)][queryHash(16 bits)][fingerprint(8 bits)]
const tsBits = timestamp.toString(2).padStart(48, "0");
const levelBits = ["LOW", "STANDARD", "MEDIUM", "HIGH", "MAXIMUM"].indexOf(privacyLevel).toString(2).padStart(3, "0");
const hashBits = queryHash;
const fpBits = userFingerprint;

const fullBits = tsBits + levelBits + hashBits + fpBits;
const watermark = encodeBitsToWatermark(fullBits);

// Insert watermark after first sentence (or at end if no sentence break)
const sentenceEnd = text.search(/[.!?]\s/);
const insertPos = sentenceEnd > 0 ? sentenceEnd + 1 : Math.floor(text.length / 2);

const watermarkedText = text.slice(0, insertPos) + watermark + text.slice(insertPos);

const humanReadable = `Privacy: ${privacyLevel} | Query: ${queryHash.slice(0, 6)}... | Time: ${new Date(timestamp).toISOString()} | User: ${userFingerprint.slice(0, 4)}...`;

return {
watermarkedText,
metadata,
Expand Down Expand Up @@ -627,27 +627,27 @@ export function embedWatermark(
*/
export function detectWatermark(text: string): WatermarkDetection {
const bits = decodeWatermarkFromText(text);

if (bits.length < 48) {
return { detected: false, confidence: 0 };
}

// Check if the first 48 bits decode to a reasonable timestamp
const tsBits = bits.slice(0, 48);
const timestamp = parseInt(tsBits, 2);
const now = Date.now();
const isValidTimestamp = timestamp > 1700000000000 && timestamp <= now + 86400000;

if (!isValidTimestamp) {
return { detected: false, confidence: 0 };
}

const levelIdx = parseInt(bits.slice(48, 51), 2);
const allLevels: PrivacyLevel[] = ["LOW", "STANDARD", "MEDIUM", "HIGH", "MAXIMUM"];
const level: PrivacyLevel = allLevels[levelIdx] ?? "LOW";
const queryHash = bits.slice(51, 67);
const userFingerprint = bits.slice(67, 75);

return {
detected: true,
metadata: {
Expand Down Expand Up @@ -740,17 +740,17 @@ export async function processEnhanced(req: EnhancedProcessRequest): Promise<Enha
if (req.request.trim().length === 0) {
throw new Error("Request cannot be empty");
}

if (req.request.length > 10000) {
throw new Error("Request exceeds maximum length of 10,000 characters");
}

if (req.data && req.data.length > 100000) {
throw new Error("Data exceeds maximum length of 100,000 characters");
}

const { processWithPrivacy } = await import("./privacy-layer");

// 1. Detect persona if auto
const personaId = req.personaId || "auto";
let detectedPersona: PersonaId = personaId;
Expand Down
Loading