S1: Einrichtungs-Assistent, Twitch-Auth, Datenbank und Chat-Aufnahme

Zugangsdaten kommen jetzt aus der App statt aus einer .env: ein
Einrichtungs-Assistent auf http://localhost:4792/setup nimmt Client-ID,
Secret und OBS-Passwort entgegen, der OAuth-Rueckruf laeuft auf denselben
Server. Gespeichert wird verschlueselt (AES-256-GCM) in einem Volume,
bewusst nicht in der Datenbank - die wird auf die Windows-Seite gesichert.
Siehe ADR-0005.

Technikbasis vereinfacht: Node 24 bringt node:sqlite, WebSocket, fetch und
die Ausfuehrung von TypeScript mit. Das Projekt hat damit null
Laufzeitabhaengigkeiten und keinen Build-Schritt; pnpm-Workspaces und die
Paketmanifeste sind entfallen, Importe laufen ueber die imports-Map der
Wurzel-package.json. erasableSyntaxOnly verhindert Syntax, die das Type
Stripping stillschweigend entfernen wuerde. Siehe Nachtrag 2 in ADR-0001.

Enthalten:
- packages/core: Config, Layout, Logger mit Secret-Maskierung, Retry, YAML
- packages/db: Migrationen, Repositories, alle SQL-Anweisungen an einem Ort
- packages/twitch: Secret-Speicher, OAuth, Helix mit Token-Bucket, IRC
- apps/ingest: Daemon, Einrichtungsseite, Spike-Erkennung
- 20 Tests (IRC-Parser, Spike-Detektor, Datenbankschicht)

Der Chat verbindet sich anonym, solange nichts hinterlegt ist - damit laesst
sich die Datenaufnahme pruefen, bevor die Twitch-Apps existieren.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013VTGjd92LsDDWF3frd9FTB
This commit is contained in:
ZeasyAlexandClaude Opus 5 committed 2026-09-09 09:56:55 +02:00
1 parent 4ceb61f095
commit d07c18441b
82 files changed
+2775 -838

No files matched your search

+122
View File
@@ -0,0 +1,122 @@
// Verschluesselter Speicher fuer Zugangsdaten. Siehe ADR-0005.
//
// Bewusst NICHT in der SQLite-Datenbank: die wird per VACUUM INTO auf die
// Windows-Seite gesichert, wo sie in einer Cloud-Synchronisation landen kann.
//
// Der Schluessel liegt im selben Volume wie die Daten. Das schuetzt nicht
// gegen jemanden mit Zugriff auf das Volume, und tut auch nicht so. Es
// schuetzt gegen versehentliche Anzeige im Stream, gegen verirrte Kopien und
// gegen Grep-Unfaelle - und genau darum geht es hier.
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { paths } from '#core/config.ts';
import { logger } from '#core/logger.ts';
const log = logger('secrets');
const FILE = path.join(paths.db, 'secrets.json');
const KEYFILE = path.join(paths.db, '.secret-key');
export type Role = 'broadcaster' | 'bot';
export type TokenSet = {
accessToken: string;
refreshToken: string;
expiresAt: string; // ISO
scopes: string[];
userId: string;
login: string;
};
export type SecretStore = {
apps: Partial<Record<Role, { clientId: string; clientSecret: string }>>;
tokens: Partial<Record<Role, TokenSet>>;
obs?: { password: string };
discord?: { goLiveWebhook?: string; recapWebhook?: string };
bluesky?: { handle: string; appPassword: string };
};
const EMPTY: SecretStore = { apps: {}, tokens: {} };
function key(): Buffer {
fs.mkdirSync(paths.db, { recursive: true });
if (fs.existsSync(KEYFILE)) return Buffer.from(fs.readFileSync(KEYFILE, 'utf8').trim(), 'base64');
const k = crypto.randomBytes(32);
fs.writeFileSync(KEYFILE, k.toString('base64'), { mode: 0o600 });
log.info('Neuer Verschluesselungsschluessel erzeugt');
return k;
}
function encrypt(plain: string): string {
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv('aes-256-gcm', key(), iv);
const body = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]);
return [iv.toString('base64'), cipher.getAuthTag().toString('base64'), body.toString('base64')]
.join('.');
}
function decrypt(blob: string): string {
const [ivB64, tagB64, bodyB64] = blob.split('.');
if (!ivB64 || !tagB64 || !bodyB64) throw new Error('secrets.json ist beschaedigt');
const decipher = crypto.createDecipheriv('aes-256-gcm', key(), Buffer.from(ivB64, 'base64'));
decipher.setAuthTag(Buffer.from(tagB64, 'base64'));
return Buffer.concat([decipher.update(Buffer.from(bodyB64, 'base64')), decipher.final()])
.toString('utf8');
}
let cache: SecretStore | null = null;
export function readSecrets(): SecretStore {
if (cache) return cache;
if (!fs.existsSync(FILE)) { cache = structuredClone(EMPTY); return cache; }
try {
cache = JSON.parse(decrypt(fs.readFileSync(FILE, 'utf8'))) as SecretStore;
} catch (err) {
log.error(`secrets.json nicht lesbar (${(err as Error).message}) - starte mit leerem Speicher`);
cache = structuredClone(EMPTY);
}
return cache;
}
export function writeSecrets(next: SecretStore): void {
fs.mkdirSync(paths.db, { recursive: true });
fs.writeFileSync(FILE, encrypt(JSON.stringify(next)), { mode: 0o600 });
cache = next;
}
export function updateSecrets(fn: (s: SecretStore) => void): SecretStore {
const s = structuredClone(readSecrets());
fn(s);
writeSecrets(s);
return s;
}
/** Zustand fuer die Einrichtungsseite - enthaelt nie einen Geheimwert. */
export type SecretStatus = {
apps: Record<Role, { clientId: string; hasSecret: boolean }>;
tokens: Record<Role, { connected: boolean; login?: string; expiresAt?: string; scopes?: number }>;
obs: boolean;
ready: boolean;
};
export function secretStatus(): SecretStatus {
const s = readSecrets();
const roles: Role[] = ['broadcaster', 'bot'];
const apps = {} as SecretStatus['apps'];
const tokens = {} as SecretStatus['tokens'];
for (const r of roles) {
// Die Client-ID ist kein Geheimnis und wird angezeigt, damit man sieht,
// welche App hinterlegt ist. Das Secret nie.
apps[r] = { clientId: s.apps[r]?.clientId ?? '', hasSecret: Boolean(s.apps[r]?.clientSecret) };
const t = s.tokens[r];
tokens[r] = t
? { connected: true, login: t.login, expiresAt: t.expiresAt, scopes: t.scopes.length }
: { connected: false };
}
return {
apps, tokens,
obs: Boolean(s.obs?.password),
ready: Boolean(s.apps.broadcaster?.clientId && s.tokens.broadcaster),
};
}