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

+152
View File
@@ -0,0 +1,152 @@
// Ein HTTP-Server fuer Einrichtung, OAuth-Rueckruf und Statusabfrage.
// Bindet ausschliesslich auf 127.0.0.1 - siehe ADR-0005.
// Ab S5 kommt hier die Sichtungsseite fuer Clip-Kandidaten dazu.
import http from 'node:http';
import { logger } from '#core/logger.ts';
import { authorizeUrl, handleCallback, redirectUri } from '#twitch/oauth.ts';
import { secretStatus, updateSecrets, type Role } from '#twitch/secrets.ts';
import { setupPage } from './setup-page.ts';
const log = logger('web');
export type ServerDeps = {
channel: string;
port: number;
health: () => Record<string, unknown>;
};
function readBody(req: http.IncomingMessage): Promise<URLSearchParams> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
let size = 0;
req.on('data', (c: Buffer) => {
size += c.length;
if (size > 64 * 1024) { reject(new Error('Anfrage zu gross')); req.destroy(); return; }
chunks.push(c);
});
req.on('end', () => resolve(new URLSearchParams(Buffer.concat(chunks).toString('utf8'))));
req.on('error', reject);
});
}
function isRole(v: string | null): v is Role {
return v === 'broadcaster' || v === 'bot';
}
export function startServer(deps: ServerDeps): http.Server {
const server = http.createServer((req, res) => {
void handle(req, res, deps).catch((err: Error) => {
log.error(`Anfrage fehlgeschlagen: ${err.message}`);
if (!res.headersSent) res.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' });
res.end('Interner Fehler');
});
});
// Vorgabe ist die Loopback-Adresse. Im Container muss das 0.0.0.0 sein -
// dort ist 127.0.0.1 die Container-Loopback, die Dockers Portweiterleitung
// nie erreicht; die Abschottung kommt dann daher, dass Compose den Port
// ausschliesslich auf 127.0.0.1 des Hosts veroeffentlicht. Das Image setzt
// BIND_HOST entsprechend. Siehe ADR-0005.
const host = process.env.BIND_HOST || '127.0.0.1';
server.listen(deps.port, host, () => {
log.info(`Einrichtung und Status: http://localhost:${deps.port}/setup (gebunden an ${host})`);
});
return server;
}
async function handle(
req: http.IncomingMessage, res: http.ServerResponse, deps: ServerDeps
): Promise<void> {
const url = new URL(req.url ?? '/', `http://localhost:${deps.port}`);
const html = (body: string, code = 200) => {
res.writeHead(code, { 'content-type': 'text/html; charset=utf-8',
'cache-control': 'no-store', 'referrer-policy': 'no-referrer' });
res.end(body);
};
const back = (kind: 'ok' | 'err', text: string) => {
res.writeHead(303, { location: `/setup?${kind}=${encodeURIComponent(text)}` });
res.end();
};
// ---- Einrichtungsseite
if (url.pathname === '/' || url.pathname === '/setup') {
const ok = url.searchParams.get('ok');
const err = url.searchParams.get('err');
return html(setupPage(secretStatus(), {
channel: deps.channel,
redirectUri: redirectUri(deps.port),
...(ok ? { notice: { kind: 'ok' as const, text: ok } }
: err ? { notice: { kind: 'err' as const, text: err } } : {}),
}));
}
// ---- Client-ID und Secret speichern
if (url.pathname === '/setup/app' && req.method === 'POST') {
const form = await readBody(req);
const role = form.get('role');
const clientId = (form.get('clientId') ?? '').trim();
const clientSecret = (form.get('clientSecret') ?? '').trim();
if (!isRole(role)) return back('err', 'Unbekannte Rolle');
if (!clientId) return back('err', 'Client-ID fehlt');
updateSecrets((s) => {
const existing = s.apps[role];
// Leeres Secret-Feld heisst "unveraendert lassen", nicht "loeschen"
const secret = clientSecret || existing?.clientSecret;
if (!secret) throw new Error('Client-Secret fehlt');
s.apps[role] = { clientId, clientSecret: secret };
// Bei gewechselter App sind die alten Tokens wertlos
if (existing && existing.clientId !== clientId) delete s.tokens[role];
});
log.info(`Client-Daten fuer ${role} gespeichert`);
return back('ok', 'Gespeichert.');
}
// ---- OBS-Passwort
if (url.pathname === '/setup/obs' && req.method === 'POST') {
const password = ((await readBody(req)).get('password') ?? '').trim();
if (!password) return back('err', 'Kein Passwort eingegeben - unveraendert gelassen.');
updateSecrets((s) => { s.obs = { password }; });
log.info('OBS-Passwort gespeichert');
return back('ok', 'OBS-Passwort gespeichert.');
}
// ---- OAuth starten
if (url.pathname === '/setup/connect') {
const role = url.searchParams.get('role');
if (!isRole(role)) return back('err', 'Unbekannte Rolle');
try {
res.writeHead(303, { location: authorizeUrl(role, deps.port) });
res.end();
return;
} catch (err) {
return back('err', (err as Error).message);
}
}
// ---- OAuth-Rueckruf
if (url.pathname === '/callback') {
const error = url.searchParams.get('error_description') ?? url.searchParams.get('error');
if (error) return back('err', `Twitch hat abgelehnt: ${error}`);
const code = url.searchParams.get('code');
const state = url.searchParams.get('state');
if (!code || !state) return back('err', 'Antwort von Twitch war unvollstaendig');
try {
const role = await handleCallback(code, state, deps.port);
return back('ok', `${role === 'bot' ? 'Bot' : 'Broadcaster'}-Konto verbunden.`);
} catch (err) {
return back('err', (err as Error).message);
}
}
// ---- Status fuer Skripte und Dashboard
if (url.pathname === '/health') {
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' });
res.end(JSON.stringify(deps.health(), null, 2));
return;
}
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
res.end('Nicht gefunden');
}