cd78f72f33
Complete rename of all technical identifiers across the codebase: Package names (11 packages): - @planarchy/* → @capakraken/* in all package.json, tsconfig, imports Import statements: 277 files, 548 occurrences replaced Database & Docker: - PostgreSQL user/db: planarchy → capakraken - Docker volumes: planarchy_pgdata → capakraken_pgdata - Connection strings updated in docker-compose, .env, CI CI/CD: - GitHub Actions workflow: all filter commands updated - Test database credentials updated Infrastructure: - Redis channel: planarchy:sse → capakraken:sse - Logger service name: planarchy-api → capakraken-api - Anonymization seed updated - Start/stop/restart scripts updated Test data: - Seed emails: @planarchy.dev → @capakraken.dev - E2E test credentials: all 11 spec files updated - Email defaults: @planarchy.app → @capakraken.app - localStorage keys: planarchy_* → capakraken_* Documentation: 30+ .md files updated Verification: - pnpm install: workspace resolution works - TypeScript: only pre-existing TS2589 (no new errors) - Engine: 310/310 tests pass - Staffing: 37/37 tests pass Co-Authored-By: claude-flow <ruv@ruv.net>
842 lines
18 KiB
TypeScript
842 lines
18 KiB
TypeScript
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<DirectoryResource, "id" | "eid" | "displayName" | "email">;
|
|
|
|
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<string, ResourceAlias>;
|
|
byAliasEid: Map<string, string>;
|
|
};
|
|
|
|
type StoredAliasEntry = {
|
|
displayName: string;
|
|
eid: string;
|
|
};
|
|
|
|
type StoredAliasMap = Record<string, StoredAliasEntry>;
|
|
|
|
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<string>();
|
|
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<string>,
|
|
): 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<PrismaClient, "resource">,
|
|
): Promise<DirectoryResource[]> {
|
|
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<PrismaClient, "systemSettings">,
|
|
): Promise<AnonymizationConfig> {
|
|
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<PrismaClient, "systemSettings" | "resource">,
|
|
): Promise<AnonymizationDirectory | null> {
|
|
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<string>();
|
|
const byResourceId = new Map<string, ResourceAlias>();
|
|
const byAliasEid = new Map<string, string>();
|
|
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<T extends ResourceIdentity>(
|
|
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<T extends ResourceIdentity>(
|
|
resources: T[],
|
|
directory: AnonymizationDirectory | null,
|
|
): T[] {
|
|
if (!directory) {
|
|
return resources;
|
|
}
|
|
return resources.map((resource) => anonymizeResource(resource, directory));
|
|
}
|
|
|
|
export function anonymizeUser<T extends UserIdentity>(
|
|
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)];
|
|
}
|