managarten/services/mana-analytics/src/lib/pseudonym.ts
Till JS 8b0a943e71 feat(mana-analytics): pseudonym + reactions + public feed + admin
Macht mana-analytics zur Backend-Heimat des Public-Community-Hubs.

- Pseudonym: createDisplayHash(userId, secret) + generateDisplayName()
  produzieren deterministisch "Wachsame Eule #4528" pro User. 100
  Adjektive × 80 Tieren × 10000 Suffixe → ~80M Kombinationen. 7 unit
  tests, 0 PII im Output.
- Schema-Erweiterung user_feedback: display_hash, display_name,
  module_context, parent_id (1-level Threading), reactions jsonb
  (cached emoji→count), score (cached weighted sort).
- feedback_votes ersetzt durch feedback_reactions (Slack-Pattern:
  unique(feedback_id, user_id, emoji), pro User mehrere Emojis möglich).
- Service: createFeedback stempelt display_hash + display_name. Neue
  Methoden getPublicFeed (redacted), getReplies, toggleReaction
  (rebuilt reactions+score). Admin-Methoden adminListAll/adminUpdate
  founder-tier-gated im Route-Layer.
- Routes:
  /api/v1/public/feedback/*  — anonymous reads (kein Auth, kein
                              userId/displayHash/deviceInfo im Output)
  /api/v1/feedback/*         — auth-required Submit/React/Manage,
                              plus :id/replies, :id/react, /admin
- Config: neuer FEEDBACK_PSEUDONYM_SECRET-Env-Var seedet die Hashes;
  Rotation re-keyt nur künftige Pseudonyme, alte Records bleiben stabil.

Migration 0002_public-community-foundation.sql idempotent, lokal +
prod (mana-server) eingespielt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 00:00:35 +02:00

241 lines
4.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Pseudonym generator for the public-community surface.
*
* Same hash-input always yields the same display_name → users see "their"
* pseudonym consistently across submissions, but real userId is never
* exposed.
*
* Generation:
* "{Adjektiv} {Tier} #{HashSuffix}"
* e.g. "Wachsame Eule #4528", "Heimliche Otter #0091"
*
* Naming space: 100 adjectives × 80 animals × 10000 numeric suffixes
* = 80 million combinations. Collision risk irrelevant in practice.
*
* The 4-digit suffix is the last 4 hex characters of the hash interpreted
* as decimal (modulo 10000). Adjective and animal indices are derived
* from disjoint hash slices so they vary independently.
*/
import { createHash } from 'crypto';
const ADJECTIVES = [
'Wachsame',
'Heimliche',
'Stille',
'Kühne',
'Sanfte',
'Wilde',
'Listige',
'Weise',
'Mutige',
'Träumerische',
'Neugierige',
'Treue',
'Verspielte',
'Bedächtige',
'Flinke',
'Helle',
'Dunkle',
'Goldene',
'Silberne',
'Funkelnde',
'Glühende',
'Kühle',
'Warme',
'Singende',
'Tanzende',
'Schweigende',
'Lauschende',
'Suchende',
'Findende',
'Wandernde',
'Schwebende',
'Tauchende',
'Träumende',
'Wache',
'Frohe',
'Ernste',
'Leise',
'Laute',
'Sanfte',
'Schnelle',
'Langsame',
'Geheime',
'Offene',
'Versteckte',
'Sichtbare',
'Strahlende',
'Glänzende',
'Matte',
'Helle',
'Schimmernde',
'Flackernde',
'Stetige',
'Bewegte',
'Ruhige',
'Stürmische',
'Friedliche',
'Kämpferische',
'Freundliche',
'Misstrauische',
'Vertrauende',
'Hoffende',
'Zweifelnde',
'Glaubende',
'Fragende',
'Antwortende',
'Lernende',
'Lehrende',
'Wachsende',
'Reife',
'Junge',
'Alte',
'Zeitlose',
'Frische',
'Müde',
'Wache',
'Schlafende',
'Erwachende',
'Träumende',
'Erinnernde',
'Vergessende',
'Zählende',
'Sammelnde',
'Verschenkende',
'Bewahrende',
'Suchende',
'Wartende',
'Eilende',
'Bleibende',
'Reisende',
'Heimkehrende',
'Aufbrechende',
'Ankommende',
'Lauschende',
'Singende',
'Pfeifende',
'Summende',
'Knurrende',
'Schnurrende',
'Lachende',
'Weinende',
'Schmunzelnde',
'Staunende',
];
const ANIMALS = [
'Eule',
'Otter',
'Fuchs',
'Wolf',
'Bär',
'Luchs',
'Adler',
'Reiher',
'Kranich',
'Schwalbe',
'Lerche',
'Amsel',
'Specht',
'Falke',
'Habicht',
'Sperber',
'Krähe',
'Rabe',
'Elster',
'Häher',
'Star',
'Drossel',
'Meise',
'Fink',
'Sperling',
'Schmetterling',
'Libelle',
'Hirsch',
'Reh',
'Gämse',
'Steinbock',
'Murmeltier',
'Eichhörnchen',
'Iltis',
'Marder',
'Dachs',
'Igel',
'Wiesel',
'Hermelin',
'Salamander',
'Molch',
'Frosch',
'Kröte',
'Eidechse',
'Schlange',
'Schildkröte',
'Karpfen',
'Hecht',
'Forelle',
'Lachs',
'Stör',
'Aal',
'Krebs',
'Schnecke',
'Spinne',
'Käfer',
'Hummel',
'Biene',
'Wespe',
'Ameise',
'Grashüpfer',
'Grille',
'Heuschrecke',
'Marienkäfer',
'Hirschkäfer',
'Robbe',
'Seehund',
'Delfin',
'Wal',
'Hai',
'Möwe',
'Albatros',
'Pelikan',
'Kormoran',
'Pinguin',
'Schwan',
'Gans',
'Ente',
'Storch',
'Ibis',
];
function hashToBigInt(hash: string): bigint {
// Strip non-hex chars, take first 16 hex chars (64-bit slice).
const slice = hash.replace(/[^0-9a-f]/gi, '').slice(0, 16);
return BigInt('0x' + slice);
}
/**
* Deterministically derive a display name from a display-hash.
*
* @param displayHash hex string from createDisplayHash().
* @returns "{Adjektiv} {Tier} #{NNNN}" — stable across calls for same hash.
*/
export function generateDisplayName(displayHash: string): string {
const big = hashToBigInt(displayHash);
const adj = ADJECTIVES[Number(big % BigInt(ADJECTIVES.length))];
const animal = ANIMALS[Number((big / BigInt(ADJECTIVES.length)) % BigInt(ANIMALS.length))];
const suffix = Number((big / BigInt(ADJECTIVES.length * ANIMALS.length)) % 10000n)
.toString()
.padStart(4, '0');
return `${adj} ${animal} #${suffix}`;
}
/**
* Derive a non-reversible display hash for a given userId.
* Same userId + same secret always produces the same hash.
*/
export function createDisplayHash(userId: string, secret: string): string {
return createHash('sha256').update(`${userId}:${secret}`).digest('hex');
}
// Exported for tests.
export const __TEST__ = { ADJECTIVES, ANIMALS };