import { createHash } from "node:crypto"; import type { PrismaClient } from "@capakraken/db"; const DEFAULT_ANONYMIZATION_DOMAIN = "superhartmut.de"; const DEFAULT_ANONYMIZATION_SEED = "capakraken-superhartmut-global"; type AliasEntry = { name: string; slug: string; }; type ResourceIdentity = { id: string; eid?: string | null; displayName?: string | null; email?: string | null; }; type UserIdentity = { id?: string | null; name?: string | null; email?: string | null; }; type DirectoryResource = { id: string; eid: string; displayName: string; email: string; lcrCents: number; }; type DirectoryIdentity = Pick; export type AnonymizationConfig = { enabled: boolean; domain: string; seed: string; mode: "global"; }; export type ResourceAlias = { displayName: string; eid: string; email: string; }; type AnonymizationDirectory = { config: AnonymizationConfig; byResourceId: Map; byAliasEid: Map; }; type StoredAliasEntry = { displayName: string; eid: string; }; type StoredAliasMap = Record; const ALIAS_NAME_RE = /^[A-Za-z]+(?: [A-Za-z]+)*$/; const ALIAS_SLUG_RE = /^[a-z]+(?:\.[a-z]+)*$/; const ICONIC_ALIAS_NAMES = [ "Iron Man", "Spider Man", "Captain America", "Thor", "Hulk", "Black Widow", "Black Panther", "Doctor Strange", "Scarlet Witch", "Captain Marvel", "Wolverine", "Batman", "Superman", "Wonder Woman", "Flash", "Aquaman", "Deadpool", "Loki", "Thanos", "Joker", "Darth Vader", "Harley Quinn", "Doctor Doom", "Magneto", "Venom", "Elsa", "Mickey Mouse", "Moana", "Mulan", "Simba", "Stitch", "Jack Sparrow", "Maleficent", "Cruella", "Hades", "Ursula", "Luke Skywalker", "Leia Organa", "Han Solo", "Obi Wan Kenobi", "Yoda", "Darth Maul", "Buzz Lightyear", "Woody", "Belle", "Green Goblin", "Mystique", "Poison Ivy", "Ultron", "Robert Downey", "Scarlett Johansson", "Chris Hemsworth", "Ryan Reynolds", "Hugh Jackman", "Harrison Ford", ]; const LEGENDARY_ALIAS_NAMES = [ "Hawkeye", "Falcon", "Winter Soldier", "Vision", "Ant Man", "Wasp", "War Machine", "Shuri", "Shang Chi", "Gamora", "Star Lord", "Rocket", "Groot", "Nebula", "Daredevil", "Punisher", "Moon Knight", "She Hulk", "Ms Marvel", "Kate Bishop", "Yelena Belova", "Pepper Potts", "Nick Fury", "Phil Coulson", "Korg", "Mantis", "Drax", "Sif", "Professor X", "Jean Grey", "Cyclops", "Rogue", "Gambit", "Silver Surfer", "Ghost Rider", "Blade", "Killmonger", "Taskmaster", "Red Skull", "Kingpin", "Carnage", "Sandman", "Mysterio", "Sabretooth", "Abomination", "Ronan", "Scar", "Jafar", "Ursula", "Gaston", "Captain Hook", "Cruella", "Tiana", "Rapunzel", "Merida", "Aladdin", "Jasmine", "Nala", "Olaf", "Kristoff", "Maui", "Baymax", "Hercules", "Megara", "Tinker Bell", "Baloo", "Timon", "Pumbaa", "Ariel", "Aurora", "Pocahontas", "Rey Skywalker", "Kylo Ren", "Ahsoka Tano", "Chewbacca", "Lando Calrissian", "Mace Windu", "Anakin Skywalker", "Padme Amidala", "Chris Evans", "Tom Holland", "Zendaya", "Benedict Cumberbatch", "Tom Hiddleston", "Emma Stone", "Margot Robbie", "Christian Bale", "Gal Gadot", "Jason Momoa", "Pedro Pascal", "Keanu Reeves", "Ryan Reynolds", "Samuel Jackson", "Brie Larson", "Paul Rudd", ]; const EXTENDED_ALIAS_NAMES = [ "Okoye", "Mbaku", "Wong", "Monica Rambeau", "America Chavez", "Kate Pryde", "Nightcrawler", "Beast", "Elektra", "Jessica Jones", "Jessica Drew", "Colossus", "Domino", "Cable", "Nova", "Adam Warlock", "Clea", "Ancient One", "General Zod", "Lex Luthor", "Darkseid", "Bane", "Riddler", "Two Face", "Penguin", "Catwoman", "Mister Freeze", "Red Hood", "Nightwing", "Robin", "Supergirl", "Batgirl", "Green Lantern", "Black Adam", "Shazam", "Raven", "Starfire", "Beast Boy", "Doctor Octopus", "Sandman", "Mysterio", "Sabretooth", "Abomination", "Ronan", "Doctor Facilier", "Mother Gothel", "Prince Eric", "Snow White", "Cinderella", "Pinocchio", "Peter Pan", "Winnie Pooh", "Minnie Mouse", "Donald Duck", "Daisy Duck", "Goofy", "Pluto", "Milo Thatch", "Kida Nedakh", "Li Shang", "Genie", "Flynn Rider", "Prince Naveen", "Qui Gon", "Finn", "Poe Dameron", "Elizabeth Olsen", "Mark Ruffalo", "Natalie Portman", "Emma Watson", "Robert Pattinson", "Angelina Jolie", "Johnny Depp", "Jeremy Renner", "Tom Cruise", "Will Smith", "Dwayne Johnson", "Jennifer Lawrence", "Anne Hathaway", "Meryl Streep", "Charlize Theron", "Brad Pitt", ]; const COMPOSITE_GIVEN_NAMES = [ "Tony", "Peter", "Steve", "Natasha", "Carol", "Wanda", "Logan", "Bruce", "Diana", "Clark", "Barry", "Arthur", "Selina", "Harley", "Loki", "Thor", "Shuri", "Stephen", "Leia", "Luke", "Anakin", "Padme", "Elsa", "Moana", "Mulan", "Belle", "Ariel", "Simba", "Tiana", "Rapunzel", "Mickey", "Wade", "Victor", "Rey", "Robert", "Scarlett", "Chris", "Tom", "Zendaya", "Ryan", "Keanu", "Hugh", "Emma", "Pedro", "Angelina", "Brie", "Paul", "Jeremy", "Samuel", "Benedict", "Gal", "Jason", "Margot", "Christian", "Harrison", "Natalie", "Mark", "Elizabeth", "Anne", "Jennifer", "Charlize", ]; const COMPOSITE_SURNAMES = [ "Stark", "Parker", "Rogers", "Romanoff", "Danvers", "Maximoff", "Howlett", "Banner", "Wayne", "Kent", "Allen", "Curry", "Prince", "Quinn", "Wilson", "Odinson", "Strange", "Mouse", "Lightyear", "Skywalker", "Solo", "Kenobi", "Vader", "Ren", "Palmer", "Rambeau", "Dameron", "Tano", "Downey", "Evans", "Johansson", "Hemsworth", "Holland", "Reynolds", "Jackman", "Reeves", "Stone", "Watson", "Pascal", "Jolie", "Larson", "Rudd", "Renner", "Jackson", "Cumberbatch", "Hiddleston", "Gadot", "Momoa", "Robbie", "Bale", "Ford", "Portman", "Ruffalo", "Olsen", "Lawrence", "Hathaway", "Theron", "Pitt", ]; function normalizeAliasName(name: string): string { return name.replace(/[^A-Za-z\s]+/g, " ").replace(/\s+/g, " ").trim(); } function slugifyAliasName(name: string): string { return normalizeAliasName(name).toLowerCase().split(" ").filter(Boolean).join("."); } function buildAliasEntries(names: string[]): AliasEntry[] { const seen = new Set(); const entries: AliasEntry[] = []; for (const rawName of names) { const name = normalizeAliasName(rawName); const slug = slugifyAliasName(name); if (!name || !slug || seen.has(slug)) { continue; } if (!ALIAS_NAME_RE.test(name) || !ALIAS_SLUG_RE.test(slug)) { continue; } seen.add(slug); entries.push({ name, slug }); } return entries; } function buildCompositeAliasEntries(givenNames: string[], surnames: string[]): AliasEntry[] { const names: string[] = []; for (const givenName of givenNames) { for (const surname of surnames) { if (givenName === surname) { continue; } names.push(`${givenName} ${surname}`); } } return buildAliasEntries(names); } const ICONIC_CHARACTERS = buildAliasEntries(ICONIC_ALIAS_NAMES); const WELL_KNOWN_CHARACTERS = buildAliasEntries(LEGENDARY_ALIAS_NAMES); const COMPOSITE_CHARACTERS = buildCompositeAliasEntries(COMPOSITE_GIVEN_NAMES, COMPOSITE_SURNAMES); const SUPPORTING_CHARACTERS = buildAliasEntries([ ...EXTENDED_ALIAS_NAMES, ...COMPOSITE_CHARACTERS.map((entry) => entry.name), ]); const USER_CHARACTER_POOL = buildAliasEntries([ ...ICONIC_ALIAS_NAMES, ...LEGENDARY_ALIAS_NAMES, ...EXTENDED_ALIAS_NAMES, ]); function hashInt(input: string): number { const hex = createHash("sha256").update(input).digest("hex").slice(0, 8); return Number.parseInt(hex, 16); } function normalize(value: string | null | undefined): string { return (value ?? "").trim().toLowerCase(); } function isStoredAliasEntryValid(alias: StoredAliasEntry): boolean { const displayName = normalizeAliasName(alias.displayName); const eid = slugifyAliasName(alias.displayName); return ( alias.displayName === displayName && alias.eid === eid && ALIAS_NAME_RE.test(displayName) && ALIAS_SLUG_RE.test(alias.eid) && !/\d/.test(alias.displayName) && !/\d/.test(alias.eid) ); } function parseStoredAliases(value: unknown): StoredAliasMap { if (!value || typeof value !== "object" || Array.isArray(value)) { return {}; } const entries = Object.entries(value); const parsed: StoredAliasMap = {}; for (const [resourceId, alias] of entries) { if (!alias || typeof alias !== "object" || Array.isArray(alias)) { continue; } const displayName = typeof (alias as { displayName?: unknown }).displayName === "string" ? (alias as { displayName: string }).displayName.trim() : ""; const eid = typeof (alias as { eid?: unknown }).eid === "string" ? (alias as { eid: string }).eid.trim() : ""; if (!displayName || !eid) { continue; } parsed[resourceId] = { displayName, eid }; } return parsed; } function toAlias(entry: StoredAliasEntry, domain: string): ResourceAlias { return { displayName: entry.displayName, eid: entry.eid, email: `${entry.eid}@${domain}`, }; } function getCharacterPool(lcrCents: number): AliasEntry[] { if (lcrCents >= 12000) return ICONIC_CHARACTERS; if (lcrCents >= 9000) return WELL_KNOWN_CHARACTERS; return SUPPORTING_CHARACTERS; } function pickUniqueAlias( resource: DirectoryResource, config: AnonymizationConfig, usedSlugs: Set, ): AliasEntry { const primaryPool = getCharacterPool(resource.lcrCents); const orderedPools = [primaryPool, ICONIC_CHARACTERS, WELL_KNOWN_CHARACTERS, SUPPORTING_CHARACTERS]; const offset = hashInt(`${config.seed}:${resource.id}`); for (const pool of orderedPools) { for (let i = 0; i < pool.length; i += 1) { const candidate = pool[(offset + i) % pool.length]!; if (!usedSlugs.has(candidate.slug)) { return candidate; } } } for (const candidate of COMPOSITE_CHARACTERS) { if (!usedSlugs.has(candidate.slug)) { return candidate; } } const fallbackWords = [...COMPOSITE_GIVEN_NAMES, ...COMPOSITE_SURNAMES]; const firstOffset = hashInt(`${config.seed}:${resource.id}:fallback:first`); const secondOffset = hashInt(`${config.seed}:${resource.id}:fallback:second`); for (let i = 0; i < fallbackWords.length; i += 1) { for (let j = 0; j < fallbackWords.length; j += 1) { const first = fallbackWords[(firstOffset + i) % fallbackWords.length]!; const second = fallbackWords[(secondOffset + j) % fallbackWords.length]!; if (first === second) { continue; } const candidate = buildAliasEntries([`${first} ${second}`])[0]; if (candidate && !usedSlugs.has(candidate.slug)) { return candidate; } } } throw new Error("Unable to generate a unique anonymization alias without digits"); } async function loadDirectoryResources( db: Pick, ): Promise { if (!("resource" in db) || typeof db.resource?.findMany !== "function") { return []; } const resources = await db.resource.findMany({ select: { id: true, eid: true, displayName: true, email: true, lcrCents: true, }, orderBy: { id: "asc" }, }); return resources.map((resource) => ({ ...resource, lcrCents: resource.lcrCents ?? 0, })); } export async function getAnonymizationConfig( db: Pick, ): Promise { if (!("systemSettings" in db) || typeof db.systemSettings?.findUnique !== "function") { return { enabled: false, domain: DEFAULT_ANONYMIZATION_DOMAIN, seed: DEFAULT_ANONYMIZATION_SEED, mode: "global", }; } const settings = await db.systemSettings.findUnique({ where: { id: "singleton" }, select: { anonymizationEnabled: true, anonymizationDomain: true, anonymizationSeed: true, anonymizationMode: true, }, }); return { enabled: settings?.anonymizationEnabled ?? false, domain: settings?.anonymizationDomain?.trim() || DEFAULT_ANONYMIZATION_DOMAIN, seed: settings?.anonymizationSeed?.trim() || DEFAULT_ANONYMIZATION_SEED, mode: "global", }; } export async function getAnonymizationDirectory( db: Pick, ): Promise { if (!("systemSettings" in db) || typeof db.systemSettings?.findUnique !== "function") { return null; } const settings = await db.systemSettings.findUnique({ where: { id: "singleton" }, select: { anonymizationEnabled: true, anonymizationDomain: true, anonymizationSeed: true, anonymizationMode: true, anonymizationAliases: true, }, }); const config: AnonymizationConfig = { enabled: settings?.anonymizationEnabled ?? false, domain: settings?.anonymizationDomain?.trim() || DEFAULT_ANONYMIZATION_DOMAIN, seed: settings?.anonymizationSeed?.trim() || DEFAULT_ANONYMIZATION_SEED, mode: "global", }; if (!config.enabled) { return null; } const resources = await loadDirectoryResources(db); const usedSlugs = new Set(); const byResourceId = new Map(); const byAliasEid = new Map(); const storedAliases = parseStoredAliases(settings?.anonymizationAliases); let aliasesChanged = false; for (const [resourceId, storedAlias] of Object.entries(storedAliases)) { const normalizedEid = normalize(storedAlias.eid); if (!normalizedEid || usedSlugs.has(normalizedEid) || !isStoredAliasEntryValid(storedAlias)) { delete storedAliases[resourceId]; aliasesChanged = true; continue; } usedSlugs.add(normalizedEid); byResourceId.set(resourceId, toAlias(storedAlias, config.domain)); byAliasEid.set(normalizedEid, resourceId); } const rankedResources = [...resources].sort((left, right) => { if (right.lcrCents !== left.lcrCents) { return right.lcrCents - left.lcrCents; } return left.id.localeCompare(right.id); }); for (const resource of rankedResources) { const existing = byResourceId.get(resource.id); if (existing) { continue; } const alias = pickUniqueAlias(resource, config, usedSlugs); const entry = { displayName: alias.name, eid: alias.slug, email: `${alias.slug}@${config.domain}`, }; usedSlugs.add(normalize(alias.slug)); byResourceId.set(resource.id, entry); byAliasEid.set(normalize(alias.slug), resource.id); storedAliases[resource.id] = { displayName: alias.name, eid: alias.slug, }; aliasesChanged = true; } if (aliasesChanged && typeof db.systemSettings.update === "function") { await db.systemSettings.update({ where: { id: "singleton" }, data: { anonymizationAliases: storedAliases }, }); } return { config, byResourceId, byAliasEid, }; } export function anonymizeResource( resource: T, directory: AnonymizationDirectory | null, ): T { if (!directory) { return resource; } const alias = directory.byResourceId.get(resource.id); if (!alias) { return resource; } return { ...resource, displayName: alias.displayName, eid: alias.eid, ...(Object.prototype.hasOwnProperty.call(resource, "email") ? { email: alias.email } : {}), }; } export function anonymizeResources( resources: T[], directory: AnonymizationDirectory | null, ): T[] { if (!directory) { return resources; } return resources.map((resource) => anonymizeResource(resource, directory)); } export function anonymizeUser( user: T, directory: AnonymizationDirectory | null, ): T { if (!directory) { return user; } const stableKey = normalize(user.id) || normalize(user.email) || normalize(user.name); if (!stableKey) { return user; } const index = hashInt(`${directory.config.seed}:user:${stableKey}`) % USER_CHARACTER_POOL.length; const alias = USER_CHARACTER_POOL[index]!; return { ...user, name: alias.name, ...(Object.prototype.hasOwnProperty.call(user, "email") ? { email: `${alias.slug}@${directory.config.domain}` } : {}), }; } export function anonymizeSearchMatches( resource: DirectoryIdentity, alias: ResourceAlias | undefined, search: string, ): boolean { const query = normalize(search); if (!query) { return true; } const haystack = [ resource.displayName, resource.eid, resource.email, alias?.displayName, alias?.eid, alias?.email, ] .filter(Boolean) .map((value) => normalize(value)); return haystack.some((value) => value.includes(query)); } export function resolveResourceIdsByDisplayedEids( resources: DirectoryIdentity[], directory: AnonymizationDirectory | null, requestedEids: string[], ): string[] { if (requestedEids.length === 0) { return []; } const realByEid = new Map(resources.map((resource) => [normalize(resource.eid), resource.id])); const ids = requestedEids .map((value) => { const normalized = normalize(value); return directory?.byAliasEid.get(normalized) ?? realByEid.get(normalized) ?? null; }) .filter((value): value is string => value !== null); return [...new Set(ids)]; }