Files
Laufzettel/src/app/api/laeufe/[id]/zustand/route.ts
T
Florian WildeandClaude Opus 5 80d97467da
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
fix: Reviewbefunde aus der Durchsicht von Phase 2 (#036, #027)
Acht Meilensteine lagen ohne fremden Blick auf main. Ein Review mit eigener
Datenbank, Gegenproben und vollem Suitenlauf hat sie gegen die
Akzeptanzkriterien und gegen CLAUDE.md geprueft.

Die Kernregeln halten: fuenf von fuenf Gegenproben gegen Versionierung,
Mandantengrenze, Abschlussregel und Selbstauskunft wurden rot. Alle Befunde
lagen am Rand — und die drei wertvollsten waren Gegenproben, die gruen blieben.

Schwer:

- Der Link in der Mail "Verzeichnis-Abgleich fehlgeschlagen" war ein 404
  (/verwaltung/verzeichnis statt /admin/verzeichnis). Die eine Meldung, die an
  alle Administratoren geht, im Moment eines Ausfalls. Ein Test prueft jetzt
  jeden Meldungspfad gegen die vorhandenen Routen.
- Der Export eines Entwurfs mit Baustein lieferte einen leeren Abschnitt unter
  dem Platzhalter "wird ersetzt" — der Zweck des Formats ist, eine Vorlage in
  eine zweite Installation zu bringen. Bausteine werden jetzt aufgeloest.

Mittel:

- Der Anhang-Handler hatte keinen Test; die ganze Rechtepruefung liess sich
  durch einen hartcodierten ADMIN ersetzen, Suite gruen. Es ist die einzige
  Stelle, die rohe Dateibytes ausliefert. tests/anhang-route.test.ts faehrt ihn
  jetzt echt, acht Pruefungen.
- /api/scim in der Proxy-Allowlist stand in keinem Test. OEFFENTLICH ist
  exportiert, der Test vergleicht beide Richtungen: eine Liste, die nur in einer
  Richtung geprueft wird, laeuft dem Code hinterher.
- Ein offener Ereignisstrom ueberlebte Abmelden, Deaktivieren und
  Sitzungswiderruf. Der Herzschlag prueft die Sitzung jetzt nach. Dass es kein
  Test zeigte, lag an einer Attrappe `sitzung: {}` — eine Attrappe ohne die Form
  der Wirklichkeit prueft etwas anderes als die Wirklichkeit.
- README kannte MITARBEITER nicht und behauptete, unbekannte Benutzer wuerden
  angelegt. Wer auf v0.2.0 hebt und nur das README liest, haette genau diese
  Leute ausgesperrt.
- sicherung.md nannte einen Anhaenge-Pfad, den es nirgends gibt — als
  Rueckfallwert in einem Skript mit rm -rf. Jetzt bricht es ab statt zu raten.
- Attributnamen wurden nur in der Server Action geprueft, waehrend der
  Graph-Leser sich im Kommentar darauf berief.

Dazu #027: die Ende-zu-Ende-Ablaeufe raeumen jetzt zentral auf, vor und nach
dem Lauf, und ein struktureller Test bewacht es. Beim ersten echten Lauf seit
dem 26. September waren sieben Ablaeufe rot, vier Ursachen, keine ein
Produktfehler — unbemerkt, weil pnpm test:e2e in keinem Tor steht. Das ist jetzt
#038 und keine Fussnote.

Ein Befund des Reviews war falsch (der Zustands-Handler habe keinen Test; er hat
acht direkte Aufrufe). Nachgeprueft, bevor etwas geaendert wurde.

1454 Vitest-Tests (vorher 1432), 113 E2E-Ablaeufe gruen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-11 01:32:09 +02:00

210 lines
8.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.
import { RechteFehler, rechtErforderlich, sitzungNochGueltig } from "@/server/auth/sitzungspflicht";
import type { Sitzung } from "@/lib/sitzung";
import { laufZustand } from "@/server/queries/laeufe";
import { HERZSCHLAG, herzschlagMs, maxStroeme, sseEreignis, willStrom } from "@/lib/sse";
import { aufLaufHoeren, stroemeOffen } from "@/server/lauf-ereignisse";
/**
* Der Zustand eines Laufs – als Strom oder als einzelne Antwort (#013, #033).
*
* **Route Handler pruefen selbst.** Die Huelle unter `src/app/(app)/layout.tsx`
* deckt sie nicht ab — sie liegt in einem anderen Zweig des Baums, und der Proxy
* sieht nur, **ob** ein Cookie da ist, nie was drinsteht (CLAUDE.md). Ein
* offener Strom ist ein Leseweg wie jeder andere; geprueft wird **bevor** das
* erste Ereignis fliesst.
*
* **Eine Adresse fuer beide Wege.** `EventSource` schickt
* `Accept: text/event-stream`, sonst kommt eine gewoehnliche Anfrage — dieselbe
* Rechtepruefung, dasselbe Lesemodell, dieselbe Nutzlast. Ein zweiter Endpunkt
* waere eine zweite Stelle, an der jemand das Recht vergessen kann, und zwei
* Wahrheiten, die auseinanderlaufen. Das Polling bleibt damit ein echter
* Rueckfall und nicht ein zweiter Codepfad, den niemand mehr faehrt.
*
* Die Antwort enthaelt **keine Befehle**. Ein `{{generiertes_passwort}}`
* entsteht bei jeder Aufloesung neu; mitgeliefert, tauschte es dem Techniker das
* Passwort unter den Haenden weg, waehrend er es abtippt. Zurueck kommen nur
* Status, Schrittzustaende und die Zahl der Kommentare und Anhaenge — genau das,
* was zwei Browser am selben Lauf voneinander wissen muessen.
*/
/*
* Sammelfenster. Wer einen Abschnitt abschnittsweise abhakt, loest ein Dutzend
* NOTIFY in einer Sekunde aus; ohne Fenster waere das ein Dutzend Abfragen des
* Lesemodells je Zuschauer. 150 ms sind unter der Wahrnehmungsschwelle und
* fassen einen Schwung zusammen.
*/
const SAMMELFENSTER_MS = 150;
export async function GET(
anfrage: Request,
{ params }: { params: Promise<{ id: string }> },
): Promise<Response> {
let benutzerId: string;
let rolle: Parameters<typeof laufZustand>[1]["rolle"];
// Die Sitzung wird festgehalten, weil der Strom sie spaeter noch braucht:
// `cookies()` gibt es in seinen Rueckrufen nicht mehr.
let sitzung: Sitzung;
try {
const zugang = await rechtErforderlich("lesen");
benutzerId = zugang.benutzer.id;
rolle = zugang.benutzer.rolle;
sitzung = zugang.sitzung;
} catch (fehler) {
if (fehler instanceof RechteFehler) {
// 401 und nicht 403: der Client soll die Aktualisierung einstellen und
// den Benutzer zur Anmeldung schicken, statt weiter zu fragen.
return Response.json({ fehler: "Nicht angemeldet." }, { status: 401 });
}
throw fehler;
}
const { id } = await params;
let benutzer = { id: benutzerId, rolle };
const zustand = await laufZustand(id, benutzer);
if (!zustand) {
// Nicht vorhanden und nicht sichtbar sind dieselbe Antwort – die
// Unterscheidung waere schon eine Auskunft ueber fremde Kunden.
return Response.json({ fehler: "Diesen Lauf gibt es nicht." }, { status: 404 });
}
/*
* Kein Strom gewuenscht – oder keiner mehr frei. Bei erreichter Obergrenze
* antwortet der Handler **gewoehnlich**: `EventSource` verlangt
* `text/event-stream`, bekommt JSON, meldet `error` und versucht es nicht
* wieder. Der Client fuellt damit von sich aus auf Polling zurueck, ohne dass
* es dafuer einen Sonderfall oder einen Fehlercode braucht.
*/
if (!willStrom(anfrage.headers.get("accept")) || stroemeOffen() >= maxStroeme()) {
return Response.json(zustand, {
// Nichts zwischenspeichern: die Antwort ist genau dann wertlos, wenn sie
// aus einem Zwischenspeicher kommt.
headers: { "Cache-Control": "no-store" },
});
}
const kodierer = new TextEncoder();
let abmelden: (() => void) | undefined;
let herzschlag: ReturnType<typeof setInterval> | undefined;
let sammler: ReturnType<typeof setTimeout> | undefined;
const strom = new ReadableStream<Uint8Array>({
async start(steuerung) {
let offen = true;
function schicken(text: string): void {
if (!offen) return;
try {
steuerung.enqueue(kodierer.encode(text));
} catch {
// Gegenseite ist weg, bevor `abort` ankam.
offen = false;
}
}
function aufraeumen(): void {
if (!offen) return;
offen = false;
abmelden?.();
if (herzschlag) clearInterval(herzschlag);
if (sammler) clearTimeout(sammler);
try {
steuerung.close();
} catch {
// Schon geschlossen.
}
}
async function standSchicken(): Promise<void> {
if (!offen) return;
try {
const jetzt = await laufZustand(id, benutzer);
if (!jetzt) {
/*
* Lauf weg oder nicht mehr sichtbar — etwa weil jemandem der Kunde
* entzogen wurde. Der Strom endet; der Client holt sich an der
* gewoehnlichen Adresse die 404 und stellt ein.
*/
schicken(sseEreignis("ende", JSON.stringify({ grund: "nicht-sichtbar" })));
aufraeumen();
return;
}
schicken(sseEreignis("zustand", JSON.stringify(jetzt)));
} catch (fehler) {
console.error("Zustand fuer den Strom konnte nicht gelesen werden:", fehler);
}
}
// Der erste Stand sofort, damit der Client einen Vergleichspunkt hat,
// ohne zusaetzlich zu pollen.
schicken(sseEreignis("zustand", JSON.stringify(zustand)));
try {
abmelden = await aufLaufHoeren(id, () => {
if (sammler) return;
sammler = setTimeout(() => {
sammler = undefined;
void standSchicken();
}, SAMMELFENSTER_MS);
});
} catch (fehler) {
/*
* Keine LISTEN-Verbindung zu bekommen ist kein Grund, die Seite blind
* zu lassen: Strom beenden, der Client fuellt auf Polling zurueck.
*/
console.error("LISTEN fuer den Strom nicht moeglich:", fehler);
aufraeumen();
return;
}
/*
* **Der Herzschlag prueft auch die Sitzung nach** (Reviewbefund zu #029).
* Vorher wurde das Recht genau einmal geprueft, beim Aufbau, und das Paar
* aus Kennung und Rolle fuer die Lebensdauer des Stroms festgehalten — ein
* deaktiviertes Konto bekam weiter live Daten, bis jemand die Lasche
* schloss. Die Pruefung kostet eine Abfrage je offenem Strom und
* Herzschlag (25 s); dieselbe Abfrage macht jede gewoehnliche Anfrage
* ohnehin.
*
* Eine **Herabstufung** beendet den Strom nicht, sondern engt ihn ein: die
* neue Rolle gilt ab dem naechsten Ereignis. Das ist dieselbe Regel wie
* „Rolle pro Anfrage aus der Datenbank“ (CLAUDE.md).
*/
herzschlag = setInterval(() => {
void (async () => {
const noch = await sitzungNochGueltig(sitzung, "lesen");
if (!noch) {
schicken(sseEreignis("ende", JSON.stringify({ grund: "nicht-angemeldet" })));
aufraeumen();
return;
}
if (noch.rolle !== benutzer.rolle) benutzer = { id: benutzerId, rolle: noch.rolle };
schicken(HERZSCHLAG);
})();
}, herzschlagMs());
anfrage.signal.addEventListener("abort", aufraeumen);
},
cancel() {
abmelden?.();
if (herzschlag) clearInterval(herzschlag);
if (sammler) clearTimeout(sammler);
},
});
return new Response(strom, {
headers: {
"Content-Type": "text/event-stream; charset=utf-8",
"Cache-Control": "no-store, no-transform",
Connection: "keep-alive",
/*
* Fuer nginx: schaltet die Pufferung fuer **diese** Antwort ab, auch wenn
* in der Konfiguration `proxy_buffering on` steht. Ohne das sammelt nginx
* den Strom und gibt ihn erst am Ende heraus — die Seite sieht dann aus,
* als sei sie hangen geblieben. Die Einstellung steht trotzdem in
* `docs/betrieb/reverse-proxy.md`: diese Kopfzeile versteht nur nginx.
*/
"X-Accel-Buffering": "no",
},
});
}