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:
1 parent
4ceb61f095
commit
d07c18441b
82 files changed
+2775
-838
No files matched your search
@@ -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),
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user