Skip to content

feat(deploy): EYT-126 — Web und API als OCI-Workloads, Container-Smoke an den Head gebunden - #89

Merged
DYAI2025 merged 22 commits into
masterfrom
feat/eyt-126-container-staging
Aug 22, 2026
Merged

feat(deploy): EYT-126 — Web und API als OCI-Workloads, Container-Smoke an den Head gebunden#89
DYAI2025 merged 22 commits into
masterfrom
feat/eyt-126-container-staging

Conversation

@DYAI2025

@DYAI2025 DYAI2025 commented Aug 21, 2026

Copy link
Copy Markdown
Owner

Was dieser PR tut

Er macht EasyTree containerfähig — und zwar genau so weit, wie es ohne eine
Non-Production-Datengrenze zulässig ist. Grundlage ist Confluence
„EasyTree – Deployment-Entscheidung 21.08.2026: VPS + Coolify + Docker"
(Seite 30998530): primär eigener VPS mit Coolify, sekundär Railway, Cloudflare
Workers ist kein Zielruntime mehr.

Er deployt nichts. BLOCKER_ENVIRONMENT_SEPARATION gilt unverändert — siehe unten.

Fachlicher Inhalt

  • apps/api/Dockerfile, apps/web/Dockerfile, docker-compose.yml, .dockerignore:
    reproduzierbare OCI-Builds aus dem gepinnten pnpm-lock.yaml, corepack-aktiviertes
    pnpm@10.28.0, beide Laufzeit-Images als node statt root, beide mit dem Commit als
    OCI-Label org.opencontainers.image.revision. Der Outbox-Worker benutzt dasselbe
    API-Image und überschreibt nur das Kommando.
  • Nur web veröffentlicht einen Port. Die API hängt am internen Netz — der Browser
    sieht genau eine Origin und niemals eine interne Adresse.
  • scripts/smoke-container.sh + CI-Schritte am Ende von db-gates.
  • apps/api/test/deploy-authority.test.ts deckt jetzt auch Dockerfiles und Compose ab:
    weder Coolify noch Railway dürfen eine zweite Migrationsautorität werden.
  • docs/runbooks/staging-deploy.md auf den Containerpfad umgeschrieben; CLAUDE.md
    bekommt den Abschnitt dazu.

Die Messung, die diesen PR zuerst getragen und dann gedreht hat

Frühere Fassung dieses PR-Textes sagte: EASYTREE_API_PROXY_TARGET ist
Bauzeit-Konfiguration. Diese Messung war korrekt — für den Weg, den es nicht mehr gibt.

Gemessen am 21.08.2026 auf Next 16.2.11: Web gebaut mit
http://buildtime-marker.invalid:9999, gestartet mit einem anderen Wert → HTTP 500 mit
getaddrinfo ENOTFOUND buildtime-marker.invalid. next start las die Weiterleitungen aus
dem gebauten .next/routes-manifest.json. Das galt dem rewrites()-Block in
next.config.ts — und genau dieser Block ist jetzt weg.

Seit EYT-126 ist der Wert LAUFZEIT-Konfiguration. Der Same-Origin-Proxy liegt in Route
Handlern (apps/web/app/api/[[...pfad]], app/health, app/ready), die auf eine
gemeinsame Durchreiche zeigen. Route Handler sind dynamischer Servercode: sie lesen die
Variable bei jeder Anfrage neu; apps/web/instrumentation.ts prüft sie zusätzlich
einmal beim Serverstart.

Der Gegenbeleg: EIN Digest, ZWEI Ziele, KEIN Neubau

Der Container-Smoke baut ein Web-Image, hält seinen Digest fest und startet denselben
Digest
zweimal gegen zwei verschiedene Stub-APIs. Zwei Stubs und nicht zwei echte APIs,
weil der Nachweis über die Unterscheidung läuft.

Web-Image-Digest: sha256:974caa9a2b396779063f9c24211b23d25066579445e13091264305a0d2485c57
ziel=http://easytree-stub-a:3001 -> {"stub":"easytree-stub-a","pfad":"/health"}
ziel=http://easytree-stub-b:3001 -> {"stub":"easytree-stub-b","pfad":"/health"}
vorher=sha256:974caa9a…485c57  nachher=sha256:974caa9a…485c57

Der Digest zählt und nicht der Tag: ein Tag lässt sich zwischen zwei Starts umhängen,
ein Digest nicht — und „kein Neubau dazwischen" ist die eigentliche Aussage.

Konsequenz: das Web-Image ist weder an ein Ziel noch an einen Anbieter gebunden.
Dieselbe Datei und derselbe Startpfad liefern es für Coolify/VPS wie für Railway, und es
gibt kein --build-arg mehr, das sich unterscheiden könnte.

Fail-closed, an zwei Stellen

Ziel fehlt:      / -> HTTP 500
Ziel ungueltig:  / -> HTTP 500

Nicht irgendein 500: der Smoke verlangt, dass das Containerprotokoll
EASYTREE_API_PROXY_TARGET nennt. Geprüft wird /, nicht /health — die Zusage
lautet, dass kein normaler Anwendungsverkehr bedient wird.

Ehrliche Grenze, die auch im Code, in CLAUDE.md und im Runbook so steht: der Prozess
bleibt am Leben und hält den Port. Der Compose-Healthcheck auf / schlägt fehl und der
Container gilt als unhealthy — aber „der Container startet nicht" wäre falsch.

Die interne Adresse erreicht den Browser auf keinem Weg

x-middleware-rewrite zu vermeiden genügt nicht. Antwortet die API mit
Location: http://<interner-host>:3001/foo?x=1, trägt der Browser die interne Adresse in
die Adresszeile und ruft sie als nächstes selbst auf. Die Durchreiche übersetzt deshalb
einen location-Kopf, der auf das konfigurierte Ziel zeigt, in einen relativen Pfad —
Pfad, Query und Fragment bleiben erhalten. Ein externes Weiterleitungsziel bleibt
unangetastet; es spekulativ umzuschreiben wäre schlimmer als das Leck, das es nicht ist.

location=/foo?x=1                                     (Ziel zeigte auf sich selbst)
location=https://login.example.org/oauth?state=xyz    (extern, unverändert)
10 Client-Chunks geprueft

Geprüft werden HTML, jeder referenzierte Client-Chunk, die Antwortköpfe und der
location-Kopf.

Zwei Sackgassen, die niemand wiederholen sollte

Beide gemessen, beide in lib/proxy-durchreichen.ts als Kommentar konserviert:

  1. Eine Next-16-proxy.ts (Node-Middleware) funktioniert zur Laufzeit, sendet aber
    x-middleware-rewrite: <interne Adresse> an den Browser. Der Kopf lässt sich nicht
    entfernen, ohne die Weiterleitung abzuschalten — er ist der Mechanismus.
  2. @opennextjs/cloudflare bricht mit Node.js middleware is not currently supported ab.
    Das lief im Pflichtjob build-web und hätte EYT-149 in diesen Slice gezogen.

Edge-Middleware scheidet aus, weil process.env dort beim Bauen eingebacken wird — genau
das Problem, das dieser Slice löst.

Was der Container-Smoke prüft

24 Prüfungen, skipped=0. Er läuft am Ende von db-gates, weil es nur dort eine echte
PostgreSQL-Instanz mit der Rolle easytree_app gibt — bewusst kein eigener Job: der
wäre kein Pflichtcheck, bis jemand mit Adminrechten das Ruleset neu anwendet, und beide
Kopien der Pflichtcheck-Liste müssten mitgezogen werden.

  1. Beide Images tragen den Head als OCI-Revision, beide laufen als node.
  2. /health = 200, /ready = 200 mit {"name":"database","ready":true}.
  3. Web reicht /health und /ready über den internen Dienstnamen an die API weiter.
  4. Gegenprobe dazu: derselbe Dienstname ist vom Host aus nicht auflösbar — ohne diese
    Prüfung bewiese (3) nichts über den internen Weg.
  5. Weder das ausgelieferte HTML noch ein Client-Chunk noch ein Antwortkopf noch ein
    location-Kopf nennt die interne Adresse; kein x-middleware-rewrite.
  6. Weder Datenbankpasswort noch Anon-Key stehen in den Protokollen.
  7. Gegenprobe zur Konfigurationsprüfung: mit NODE_ENV=production und ohne
    DATABASE_SSL_ROOT_CERT verweigert der Container den Start und benennt die Variable.
  8. Ein Image, zwei Ziele: derselbe Digest erreicht zwei verschiedene APIs, ohne Neubau,
    und der Digest ist vorher wie nachher identisch. Ohne diesen Punkt bliebe
    „anbieterneutral" eine Behauptung.
  9. Gegenprobe dazu: fehlt das Ziel oder ist es ungültig, wird kein Anwendungsverkehr
    bedient und das Protokoll nennt EASYTREE_API_PROXY_TARGET.
  10. SIGTERM: API beendet sich mit 0, Web mit 143 — geprüft wird ausdrücklich, dass es
    nicht 137 ist (SIGKILL nach Fristablauf).

Evidenz

Lokal ausgeführt (arm64/colima, Postgres 17 mit easytree_app NOSUPERUSER NOBYPASSRLS
NOINHERIT), zuletzt am 22.08.2026 mit dem erweiterten Smoke:

[container-smoke] mode=local executed=24 passed=24 skipped=0

mode=local, weil der lokale Lauf keine CI-Zusage ist — die maßgebliche Zahl steht unten im
Abschnitt zum finalen Head und lautet dort mode=required.

Lokale Gatespnpm exec turbo run lint typecheck test --force --concurrency=2:

Tasks:    22 successful, 22 total
Cached:    0 cached, 22 total

Cached: 0 steht hier absichtlich: ein Turbo-Replay wäre kein Lauf.

Gegenmutationen aus der ersten Runde dieses PR — ausgeführt und wieder
zurückgenommen, nicht ausgedacht.
Die vier Gegenmutationen zum Laufzeit-Proxyziel
stehen in der Tabelle im Abschnitt zum finalen Head.

Mutation Beobachtung Folge
GIT_SHA=deadbeef… beim Bauen label=deadbeef… ≠ Head Bindungsprüfung wäre rot
USER node aus dem runner entfernt user='' Nicht-root-Prüfung wäre rot
Web-Image auf http://127.0.0.1:3001 gebaut über Web: HTTP 500, direkt: {"status":"ok"} Weiterleitungsprüfung wäre rot
throw in resolveNextOutputreturn {} „expected function to throw an error, but it didn't" next-output.test.ts rot

Jede Rücknahme mit leerem diff gegen die Sicherungskopie belegt.

Was hier NICHT belegt ist: die lokalen Images sind arm64. Der amd64-Build ist Sache
des CI-Laufs an diesem Head — er ist die maßgebliche Evidenz, nicht der lokale Lauf.

Der Blocker bleibt

BLOCKER_ENVIRONMENT_SEPARATION, nachgemessen am 21.08.2026 read-only über die
Control-Plane:

  • list_projects → genau ein Projekt sichtbar (ykoijifgweoapitabgxx, „Bazodiac"),
    kein EasyTree-Projekt.
  • list_branches auf inypnrvpawvhgiyagxbd → genau eine Branch main mit
    project_ref == parent_project_ref == inypnrvpawvhgiyagxbd. Die einzige Branch ist
    die Produktion.

Die exakte fehlende Voraussetzung: es existiert keine EasyTree-Datengrenze, deren
project_ref von inypnrvpawvhgiyagxbd verschieden ist.

Solange sie fehlt, sind Deploy, reale Browserreise gegen Staging, Staging-Screenshots und
Rollback-Evidenz gesperrt — das ist eine Owner-Entscheidung mit Kosten- und
Infrastrukturwirkung, und eine Supabase-Branch hängt an derselben GitHub-Integration, die
beim Merge auf master Produktionsmigrationen fährt. Optionen unverändert in
docs/plans/2026-08-20-sprint-6-staging-blocker.md.

Nicht in diesem PR

Cloudflare-Cleanup (EYT-149) — er kommt erst nach belegter Container-Parität. wrangler.jsonc,
open-next.config.ts und die Cloudflare-Schritte in build-web/build-api bleiben deshalb
unangetastet und grün.

Refs EYT-126, EYT-142, EYT-137.

🤖 Generated with Claude Code

Summary by Sourcery

Enable provider-neutral OCI deployment for the web and API while proving runtime proxy portability and enforcing container safety through CI smoke tests.

New Features:

  • Add OCI container builds and Compose topology for the API, web application, and shared outbox worker image.
  • Move the web-to-API same-origin proxy to runtime Route Handlers so one image can target different APIs without rebuilding.
  • Add fail-closed proxy validation and protection against exposing internal addresses through HTML, client chunks, redirects, or response headers.

Bug Fixes:

  • Prevent internal service addresses and sensitive configuration details from leaking to browser responses or container logs.
  • Prevent container deployment paths from becoming a second database migration authority.

Enhancements:

  • Run the container smoke test as a required gate in db-gates, covering image provenance, non-root execution, health, readiness, networking, runtime configuration, digest portability, and graceful shutdown.
  • Require OCI revision labels tied to the checked-out commit and remove unsafe defaults for image provenance and proxy targets.
  • Update the staging deployment runbook and project guidance for VPS/Coolify-first container deployment while retaining Railway compatibility and the existing environment-separation blocker.

Build:

  • Add reproducible multi-stage Dockerfiles, Docker Compose configuration, and Docker build context exclusions using the pinned pnpm lockfile and package manager.

CI:

  • Integrate the required container smoke and exact-head checks into the db-gates workflow, and run web builds without a build-time proxy target.

Documentation:

  • Document the container-first deployment model, runtime proxy configuration, fail-closed behavior, rollback metadata, and the unchanged staging data-boundary blocker.

Tests:

  • Add coverage for container configuration binding, deployment authority rules, runtime proxy routes, fail-closed startup, dynamic target resolution, response-header filtering, redirects, and Next standalone output.

CI-Evidenz am damaligen Head ea9a392ca3e6755659e8b93f69078fd44e434f85

Überholt. Dieser Abschnitt gilt für einen früheren Head und für den
Container-Smoke in seiner damaligen Fassung (executed=15). Maßgeblich ist
„Stand am finalen Head 4e16d8c…" ganz unten.

Lauf 32498237919alle 11
Pflichtjobs grün
, conclusion=success. Der Container-Smoke lief dort auf amd64 gegen den
echten Supabase-Stack:

[container-smoke] mode=required executed=15 passed=15 skipped=0

Alle 14 Gate-Zeilen aus db-gates, jede mode=required, passed=executed, skipped=0:

[container-smoke] [cost-access] [cost-snapshot] [planning-invariants] [planning-publish]
[planning-published-reads] [planning-write] [rate-http-contract] [rate-succession]
[rate-timestamp] [snapshot-http] [snapshot-immutability] [tenant-isolation] [tenant-pooling]

Der Schritt „Container-Smoke" lief 15:40:00Z→15:41:00Z, der Gate-Zeilen-Check danach — beide
success. Damit ist der amd64-Build belegt; der lokale arm64-Lauf war nur die Vorprüfung.

Nicht gemerged, absichtlich. Der Merge ist eine Owner-Entscheidung. Dieser PR bringt keine
Migration mit, aber der Merge auf master ist der Pfad, den die Supabase-GitHub-Integration
für Produktionsmigrationen benutzt — das entscheidet niemand nebenbei.


Stand am damaligen Head 7cf698c42b1f5832aef0f9e53346dc8dd4486a14

Überholt. Dieser Abschnitt gilt für einen früheren Head und für den
Container-Smoke in seiner damaligen Fassung (executed=15). Maßgeblich ist
„Stand am finalen Head 4e16d8c…" ganz unten.

CI-Lauf 32516908989 — 11/11 grün,
[container-smoke] mode=required executed=15 passed=15 skipped=0.

PRIL-Scope-Gate: grün. Der PO hat am 21.08.2026 Weg 1 aus
docs/plans/2026-08-21-eyt-126-scope-autoritaet.md gewählt. Revision 5 des Sprint-6-Manifests
nimmt die Deploymentpfade auf, die Revision 1 ausdrücklich ausgeschlossen hatte; die Bindung unter
.plumbline/scope-authority/ wurde neu gezogen. Die Digest-Konvention wurde vorher gegen alle
vier
bestehenden Revisionen verifiziert statt geraten. Gate danach:
PRIL scope check passed … (32 changed files), Exit 0.

Der Blocker ist eine Stufe genauer — und eine Annahme war falsch. Ein Versuch, ein zweites
Projekt easytree-staging anzulegen (Kosten für diese Org gemessen: 0 $/Monat), wurde mit
einem Quota-Fehler abgelehnt. Die Free-Projekt-Quota zählt pro Nutzer über alle
Organisationen hinweg; DYAI2025 hat sie mit zwei aktiven Free-Projekten ausgeschöpft, und das
zweite ist über diesen Zugang nicht sichtbar.

Zurückgenommen: das pausierte „Bazodiac" (status: INACTIVE) belegt keinen Slot — die
Supabase-Doku sagt an zwei Stellen „Paused projects do not count towards your quota". Es zu
löschen hätte nichts gebracht.

Vollständig, mit den Gegenproben, in docs/plans/2026-08-20-sprint-6-staging-blocker.md,
Nachtrag 2. Kein Schreibzugriff hat stattgefunden.


Stand am damaligen Head 4e16d8c17f505e3db5fa3dd91c5530d9a792fef9 — überholt durch 18f5004

CI-Lauf 32536007202
status=completed, conclusion=success, 11 von 11 Jobs grün, direkt über
gh run view gegengelesen und nicht nur über den Exit-Code des Wartewerkzeugs.

Alle 14 Gate-Zeilen aus db-gates, jede mode=required, passed == executed,
skipped=0:

[container-smoke] mode=required executed=24 passed=24 skipped=0
[cost-access] [cost-snapshot] [planning-invariants] [planning-publish]
[planning-published-reads] [planning-write] [rate-http-contract] [rate-succession]
[rate-timestamp] [snapshot-http] [snapshot-immutability] [tenant-isolation] [tenant-pooling]

Der Container-Smoke war vorher bei executed=15; die neun zusätzlichen Prüfungen sind die
Client-Chunk-Leckprobe, die Antwortkopf-Leckprobe, die beiden Weiterleitungsfälle, die
beiden Ziele, die Digest-Gleichheit und die beiden Fail-closed-Fälle.

Der Nachweis aus genau diesem Lauf (amd64, echter Supabase-Stack):

Web-Image-Digest: sha256:974caa9a2b396779063f9c24211b23d25066579445e13091264305a0d2485c57
ziel=http://easytree-stub-a:3001 -> {"stub":"easytree-stub-a","pfad":"/health"}
ziel=http://easytree-stub-b:3001 -> {"stub":"easytree-stub-b","pfad":"/health"}
vorher=sha256:974caa9a…485c57 nachher=sha256:974caa9a…485c57
location=/foo?x=1
location=https://login.example.org/oauth?state=xyz
Ziel fehlt: / -> HTTP 500
Ziel ungueltig: / -> HTTP 500
10 Client-Chunks geprueft

auth-journey grün — der einzige Job, der Identität beweist. Er baut Web jetzt ohne
das Proxyziel (der Schritt heißt „Bauen — ECHTE API und Web (das Proxyziel kommt erst beim
Start)") und setzt es erst in der webServer-Umgebung. Die reale Reise gegen echte GoTrue
läuft damit vollständig durch die neue Durchreiche: 2 passed (25.2s), inklusive der
Zusicherung, dass beide Sitzungscookies (eyt_access, eyt_refresh) HttpOnly und
SameSite=Strict sind — also zwei getrennte Set-Cookie-Köpfe, die nicht zu einem
zusammengefaltet werden dürfen.

build-web grün ohne gesetztes EASYTREE_API_PROXY_TARGET. Der env:-Block des Jobs
ist ersatzlos entfallen; bräuchte der Build die Variable noch, wäre dieser Pflichtjob rot.
Der Cloudflare-Bundlebau bleibt dabei grün (Worker saved in .open-next/worker.js,
OpenNext build complete., gzip 1184.28 KiB gegen das 3072-KiB-Limit) — EYT-149 wird von
diesem Slice nicht angefasst.

Gegenmutationen — ausgeführt, gemessen, zurückgenommen

Mutation Beobachtung Rücknahme belegt
Proxyziel beim Modulladen in einer Modulkonstante einfrieren rot in „liest das Ziel bei JEDER Anfrage neu aus der Umgebung": expected [ Array(2) ] to deeply equal [ …(2) ], dazu sechs Folgefälle — darunter alle drei Weiterleitungsfälle, weil ein eingefrorenes Ziel den location-Vergleich gegen die falsche Origin führt und die interne Adresse dann leckt shasum -a 256 gleich (ddfcba34…), danach 17/17 grün
instrumentation.ts liest die Umgebung selbst Geheimnis-Wächter rot: apps/web/instrumentation.ts:24 [runtime-reads-no-environment] — die Positivliste gibt also nicht pauschal apps/web frei Datei hashgleich, Wächter wieder 39/39 grün
${GIT_SHA:?…}${GIT_SHA:-unknown} in docker-compose.yml rot in „gibt GIT_SHA keinen Vorgabewert": not to match /\$\{GIT_SHA:-/ d7fcb173…, 9/9 grün
RUN test -n "${GIT_SHA}"ARG GIT_SHA=unknown in apps/web/Dockerfile rot in „apps/web/Dockerfile erzwingt GIT_SHA statt es vorzubelegen" 65df23db…, 9/9 grün

Fail-closed-Proben außerhalb des Smokes

docker compose config, alle drei Fälle gemessen — die dritte, positive Probe steht dabei,
damit die ersten beiden nicht von „kaputte Datei" zu unterscheiden wären:

ohne GIT_SHA        → exit 1: required variable GIT_SHA is missing a value: GIT_SHA fehlt …
ohne Proxyziel      → exit 1: required variable EASYTREE_API_PROXY_TARGET is missing a value …
mit beidem          → exit 0, GIT_SHA am Head, EASYTREE_API_PROXY_TARGET unter web.environment

docker build ohne --build-arg GIT_SHA, für beide Images → exit 1 mit
GIT_SHA fehlt. Baue mit --build-arg GIT_SHA=$(git rev-parse HEAD).

Zwei Reparaturen am Smoke-Skript, beide beim Ausführen gefunden

  1. set -u im Cleanup. Die Container-Namen der Zwei-Ziele-Probe stehen bei den
    Konstanten und nicht erst im Abschnitt, der sie benutzt; zusätzlich liest aufraeumen()
    jeden über ${VAR:-}. Ein Abbruch vor diesem Abschnitt meldet gemessen 0 Treffer
    „unbound variable" — vorher hätte er den echten Grund hinter einem Folgefehler versteckt.
  2. grep -q hinter einer Pipe unter pipefail. docker logs … | grep -qF … ging rot,
    obwohl das Protokoll den gesuchten Text enthielt: grep -q beendet sich beim ersten
    Treffer, die schreibende Seite bekommt EPIPE, und pipefail macht daraus einen
    Fehlschlag. Acht solche Stellen sind auf case-Vergleiche umgestellt. Bei einer
    Leck-Prüfung ist dieselbe Mechanik die gefährliche Richtung: das gefundene Leck sähe
    aus wie keines.

Was dieser Slice ausdrücklich NICHT belegt

  • Keine Staging-Evidenz. BLOCKER_ENVIRONMENT_SEPARATION steht unverändert: es gibt
    keine EasyTree-Datengrenze mit einem anderen project_ref als inypnrvpawvhgiyagxbd.
    Es wurde nichts deployt, nichts konfiguriert und kein Supabase-Projekt angelegt.
  • CI-echt ist nicht Staging-echt. Ein echter Stack in CI belegt Funktion, nicht
    Bereitstellung.
  • Kein Cloudflare-Abbau. build:cf und cf:dry-run bleiben im Pflichtjob; dass sie
    grün bleiben, ist hier Nebenbedingung, nicht Ziel (EYT-149).
  • Der Ausdruck „Same-Origin-Rewrite" steht weiter als Wegbeschreibung in
    apps/web/e2e/**, scripts/read-through-harness.sh, .github/workflows/ci.yml und zwei
    Runbooks; apps/web/app/providers.tsx, apps/web/open-next.config.ts und
    apps/web/playwright.harness.config.ts verweisen weiter auf „das Rewrite in
    next.config.ts". Diese drei liegen außerhalb der Scope-Revision 6 und sind hier bewusst
    nicht angefasst.

PRIL-Scope-Gate grün ohne Erweiterung: PRIL scope check passed … (45 changed files),
Exit 0, gegen Revision 6. Es wurde keine Revision 7 gebraucht.

Nicht gemerged, absichtlich. Der Merge ist eine Owner-Entscheidung.


Stand am damaligen Head 18f50047fb96529306310e85b8e7717c8146e8ba — überholt durch 3b4bbdb

CI-Lauf 32540049734
status=completed, conclusion=success, headSha=18f50047…, 11 von 11 Jobs grün, direkt
über gh run view gegengelesen und nicht nur über den Exit-Code des Wartewerkzeugs. Der
Abschnitt darüber (4e16d8c, Lauf 32536007202) bleibt als Historie stehen und ist durch
diesen hier überholt.

Was dazugekommen ist — ein letztes Loch in derselben Zusage. location war übersetzt,
x-middleware-rewrite entstand nie, der 502 nannte keinen Host. Jeder übrige Antwortkopf
wurde dagegen unverändert weitergereicht. Antwortet die API mit
X-Upstream-Url: http://<intern>:3001/private oder mit
Link: <http://<intern>:3001/foo>; rel="self", dann steht die interne Adresse im Browser —
same-origin-JavaScript liest sie über response.headers, und Link wertet der Browser sogar
selbst aus. Die Zusage lautet „weder HTML, noch Client-JS, noch irgendein Antwortkopf,
noch location"; sie war an genau dieser Stelle noch nicht eingelöst.

Der Riegel lässt weg, statt umzuschreiben. Ein Kopf ohne festgelegte Bedeutung trägt
keine Struktur, aus der sich eine Übersetzung ableiten ließe; eine erfundene wäre schlimmer
als der Verlust. Erkannt wird das Ziel in drei Formen — volle Adresse, Autorität
(host:port), nackter Dienstname —, schreibungsunabhängig, und der Kopfname zählt mit.
Bewusst unangetastet: die Sonderbehandlung von location (sie hat eine Bedeutung und ihre
eigene Übersetzung), fremde Adressen, Content-Type, gewöhnliche Anwendungsköpfe, Status und
Körperstrom. Ein leckendes Set-Cookie fällt einzeln weg, ohne die übrigen
zusammenzufalten.

Drei Dateien am Code, zwei an der Doku:
apps/web/lib/proxy-durchreichen.ts, apps/web/test/proxy-durchreichen.test.ts,
scripts/smoke-container.sh, CLAUDE.md, docs/runbooks/staging-deploy.md.

Rot vor dem Riegel, grün danach — beides ausgeführt

Die sieben neuen Fälle in apps/web/test/proxy-durchreichen.test.ts wurden vor der
Reparatur geschrieben und liefen gegen den unreparierten Stand:

× entfernt einen gewoehnlichen Kopf, der das interne Ziel nennt
× entfernt auch einen Kopf, der nur den internen Hostnamen nennt
× erkennt die interne Adresse unabhaengig von der Schreibweise
× entfernt nur das leckende Cookie und erhaelt die uebrigen einzeln
× misst gegen das AKTUELLE Ziel und nicht gegen ein eingefrorenes
Tests  5 failed | 19 passed (24)

Die beiden Erhaltungsfälle („lässt gewöhnliche Köpfe unverändert", „lässt einen Kopf mit
einer EXTERNEN URL unverändert") waren schon vorher grün — sie belegen nicht den Riegel,
sondern dass er nicht pauschal alles wegwirft. Nach der Reparatur: Tests 24 passed (24).

Der letzte Fall ist der schärfste: dieselbe Antwort, nur die Umgebung wechselt auf
api-b.invalid — der Kopf mit api-b fällt, der Kopf mit dem alten api-a bleibt
stehen. Ein beim Modulladen eingefrorenes Ziel drehte genau das um.

Die bestehenden location-Fälle sind unverändert und weiterhin grün: interne Weiterleitung →
relativ (mit Query und Fragment), Basispfad abgestreift, externes Ziel unangetastet, relative
Weiterleitung unangetastet, redirect: "manual". Ebenso der Set-Cookie-Fall „zwei bleiben
zwei".

Gegenmutation — zweimal ausgeführt, nicht ausgedacht

Ebene Mutation Beobachtung Rücknahme belegt
Unit if (nenntInternesZiel(…)) continue; an beiden Stellen entfernt dieselben 5 Fälle wieder rot, 5 failed | 19 passed (24) cmp leer, shasum -a 256 = 3690b3cf…
Container nur den generischen Riegel entfernt, Images neu gebaut, Smoke gefahren exit 1 in Abschnitt 8c, die Köpfe wörtlich in der Antwort des Web-Containers Datei hashgleich mit dem Stand, der den grünen Lauf erzeugt hat

Die rohe Beobachtung der Container-Gegenmutation, ungekürzt aus dem Protokoll:

  direkt vom Stub: x-upstream-url=http://easytree-stub-b:3001/private link=<http://easytree-stub-b:3001/foo>; rel="self" x-stub-marke=harmlos
  OK   der Stub nennt seine interne Adresse tatsaechlich in einem Nicht-location-Kopf
  die interne Adresse steht in den Antwortkoepfen
HTTP/1.1 200 OK
link: <http://easytree-stub-b:3001/foo>; rel="self"
x-stub-marke: harmlos
x-upstream-url: http://easytree-stub-b:3001/private
::error::[container-smoke] kein gewoehnlicher Antwortkopf traegt die interne Adresse zum Browser fehlgeschlagen

Der Container-Nachweis ist nicht vakuos

scripts/smoke-container.sh hat einen neuen Abschnitt 8c und dort zwei Prüfungen statt
einer. Die erste ist die Eingangsbremse für die zweite: sie liest den Stub direkt aus dem
Containernetz
(von außen ist er nicht erreichbar — genau das ist der Punkt) und verlangt,
dass er den leckenden Kopf tatsächlich sendet. Ohne sie bewiese dessen Abwesenheit hinter dem
Proxy nichts. Die zweite verlangt zusätzlich, dass der harmlose x-stub-marke durchkommt
sonst wäre „der leckende Kopf fehlt" auch dann grün, wenn überhaupt nichts durchgereicht wird.

Aus genau diesem CI-Lauf (amd64, echter Supabase-Stack), Job db-gates:

Web-Image-Digest: sha256:ecfe41e8006906e6f8db4269992b3e2546e14dede437604eb2ae00429c1ae21b
ziel=http://easytree-stub-a:3001 -> {"stub":"easytree-stub-a","pfad":"/health"}
ziel=http://easytree-stub-b:3001 -> {"stub":"easytree-stub-b","pfad":"/health"}
vorher=sha256:ecfe41e8…1ae21b nachher=sha256:ecfe41e8…1ae21b
location=/foo?x=1
location=https://login.example.org/oauth?state=xyz
== 8c/10 Auch gewoehnliche Antwortkoepfe lecken die interne Adresse nicht ==
  direkt vom Stub: x-upstream-url=http://easytree-stub-b:3001/private link=<http://easytree-stub-b:3001/foo>; rel="self" x-stub-marke=harmlos
  x-upstream-url und link entfernt, x-stub-marke durchgereicht
  OK   kein gewoehnlicher Antwortkopf traegt die interne Adresse zum Browser

Alle 14 Gate-Zeilen aus db-gates, jede mode=required, passed == executed,
skipped=0 — der Container-Smoke steht jetzt bei 26 statt 24:

[container-smoke] mode=required executed=26 passed=26 skipped=0
[cost-access] [cost-snapshot] [planning-invariants] [planning-publish]
[planning-published-reads] [planning-write] [rate-http-contract] [rate-succession]
[rate-timestamp] [snapshot-http] [snapshot-immutability] [tenant-isolation] [tenant-pooling]

Die übrigen Gates an diesem Head

  • pnpm exec turbo run lint typecheck test --force22 successful, 22 total,
    Cached: 0 cached, 22 total — also wirklich gelaufen und kein Turbo-Replay.
    @easytree/web 28 Dateien / 301 Tests, @easytree/api 76 / 770.
  • PRIL-Scope-Gate grün ohne Erweiterung: PRIL scope check passed … (45 changed files),
    Exit 0, gegen Revision 6. Auch dieser Nachtrag brauchte keine Revision 7 — alle fünf
    Dateien liegen in bestehenden Scope-Pfaden (apps/web/lib/**, apps/web/test/**,
    scripts/**, CLAUDE.md, docs/runbooks/**).
  • Cloudflare bleibt grün: OpenNext build complete., Total Upload: 5910.26 KiB / gzip: 1184.39 KiB gegen das 3072-KiB-Limit (vorher 1184.28 KiB). EYT-149 wird weiterhin nicht
    angefasst.

Doku nachgezogen, keine Evidenz gelöscht

CLAUDE.md und docs/runbooks/staging-deploy.md nannten den Antwortkopf-Schutz bisher nur
für location und führten executed=24 als gemessene Zahl. Beides ist jetzt korrekt: die
Beschreibung nennt den generischen Fall, die Zahl steht auf 26. Ältere Messungen in diesem
PR-Text bleiben unverändert stehen und sind oben als überholt gekennzeichnet.

Nicht gemerged, absichtlich. Der Merge ist eine Owner-Entscheidung.


Stand am finalen Head 3b4bbdb5ee88260000be404c86c9dac40e92b591

CI-Lauf 32542186429
status=completed, conclusion=success, headSha=3b4bbdb5…, 11 von 11 Jobs grün, direkt
über gh run view gegengelesen. Der Abschnitt darüber (18f5004, Lauf 32540049734) bleibt als
Historie stehen und ist durch diesen hier überholt.

Der Riegel aus 18f5004 war richtig, aber zu breit gefasst. Er verglich Teilstrings gegen
drei Formen — volle Adresse, Autorität und nackter Dienstname — und zwar über Kopfnamen und
Wert zusammen. Für die vorgesehene Containertopologie http://api:3001 ist der Dienstname das
Wort „api". Damit fiel jeder harmlose Kopf, in dem diese drei Buchstaben irgendwo vorkommen:

X-Api-Version: 1
X-Documentation: https://api.example.org/public
Set-Cookie: api_session=abc123; Path=/; HttpOnly

Das ist kein Schutz, sondern ein stiller Ausfall — und es verletzt die Zusage im selben
Absatz, dass legitime Köpfe unverändert bleiben. Der echte Leckschutz aus 18f5004 bleibt
dabei vollständig erhalten; kein einziger Leck-Nachweis wurde entschärft oder gelöscht.

Gesucht wird die ADRESSE, nicht das Wort

Drei Formen, jede mit einer Grenze — und der Kopfname wird gar nicht mehr geprüft, weil ein
Name keine Adresse trägt:

Form Grenze fällt bleibt
URL im Wert mit new URL() geparst, über die Origin verglichen X-Upstream-Url: http://api:3001/private, Link: <http://api:3001/foo>; rel="self" https://api.example.org/public
Autorität ohne Schema nur bei ausgewiesenem Port, nur an einer Zeichengrenze X-Upstream: api:3001 api:30011, capitalized-api-response
nackter Host nur als ganzer Wert, Leerraum abgezogen X-Upstream-Host: api X-Api-Version: 1

Für set-cookie kommt das Domain-Attribut hinzu — strukturell ausgelesen und verglichen
statt im Text gesucht. Das erste Segment ist das Name/Wert-Paar und damit kein Attribut, sonst
wäre ein Cookie namens domain ein falscher Treffer. location ist unverändert: interne
absolute Weiterleitung → relativ, externe → unangetastet.

Rot vor der Reparatur — mit dem ECHTEN Ziel gemessen

Die sieben neuen Fälle setzen EASYTREE_API_PROXY_TARGET wörtlich auf http://api:3001. Mit
dem sonst benutzten api-a.invalid wäre der Fehler unsichtbar geblieben, weil dieser Name in
keinem harmlosen Kopf vorkommt — das ist der eigentliche Grund, warum er durchgerutscht ist.

× laesst einen Kopf stehen, dessen NAME den Dienstnamen enthaelt
× laesst einen Kopf mit einer FREMDEN Adresse unter demselben Wort stehen
× laesst ein Cookie stehen, dessen Name den Dienstnamen enthaelt
× laesst einen Wert stehen, in dem der Dienstname nur ein Wortteil ist
× entfernt trotzdem die echte interne Adresse
× verwechselt eine laengere Portnummer nicht mit der internen
× entfernt ein Cookie, dessen Domain-Attribut den internen Host nennt
Tests  7 failed | 25 passed (32)

Nach der Reparatur 32 passed (32). Bemerkenswert ist der fünfte Fall: „entfernt trotzdem die
echte interne Adresse" ging rot an seiner Erhaltungszusicherung — der leckende Kopf wurde
korrekt entfernt, aber das harmlose X-Api-Version: 1 gleich mit. Genau diese Kopplung im
selben Lauf verhindert, dass ein Riegel grün wird, der einfach alles wegwirft.

Gegenmutation — ausgeführt, nicht ausgedacht

Die überbreite Fassung wurde wörtlich wiederhergestellt — nackter Hostname als Teilstring,
Kopfname im Vergleich, Cookie über denselben generischen Weg — und die Testsuite gefahren:

Mutation Beobachtung Rücknahme belegt
Teilstringvergleich + Kopfname + generischer Cookieweg zurück dieselben 7 Fälle wieder rot, 7 failed | 25 passed (32) cmp leer, shasum -a 256 = f38874cb…

Der Container misst jetzt BEIDE Fehlerrichtungen

Der Stub sendet neben dem leckenden Kopf vier harmlose, die das Wort „api" tragen und nicht
die interne Autorität: x-api-version, eine Doku-URL auf einem fremden Host und zwei getrennte
Cookies (eines davon api_session). Abschnitt 8c hat dadurch drei Prüfungen statt zwei — die
Nicht-Vakuositätsprobe liest den Stub weiterhin direkt aus dem Containernetz und verlangt
jetzt alle vier Eingangsbremsen, nicht nur eine.

Aus genau diesem CI-Lauf (amd64, echter Supabase-Stack), Job db-gates:

Web-Image-Digest: sha256:a7729631b0bf177196b6dcc355e44ff71fb57d8872dd9100ef158e96150ed891
ziel=http://easytree-stub-a:3001 -> {"stub":"easytree-stub-a","pfad":"/health"}
ziel=http://easytree-stub-b:3001 -> {"stub":"easytree-stub-b","pfad":"/health"}
vorher=sha256:a7729631…0ed891 nachher=sha256:a7729631…0ed891
location=/foo?x=1
location=https://login.example.org/oauth?state=xyz
== 8c/10 Auch gewoehnliche Antwortkoepfe lecken die interne Adresse nicht ==
  direkt vom Stub: x-upstream-url=http://easytree-stub-b:3001/private link=<http://easytree-stub-b:3001/foo>; rel="self" x-stub-marke=harmlos x-api-version=1 x-documentation=https://api.example.org/public set-cookie=api_session=abc123; Path=/; HttpOnly || stub_sitzung=def456; Path=/
  x-upstream-url und link entfernt, x-stub-marke durchgereicht
  OK   kein gewoehnlicher Antwortkopf traegt die interne Adresse zum Browser
  set-cookie-Koepfe: 2
  x-api-version, x-documentation und beide Cookies unveraendert durchgereicht
  OK   harmlose Koepfe mit dem Wort 'api' ueberleben den Riegel unveraendert

Ehrliche Grenze dieses Container-Belegs: die Stub-Hostnamen heißen easytree-stub-a/b und
nicht api, also ist 8c hier ein Erhaltungsnachweis und keine Gegenmutation der
Falsch-positiv-Klasse. Scharf gemessen wird diese Fehlerrichtung in der Unit-Suite, wo das Ziel
wörtlich http://api:3001 heißt. Die Stubs umzubenennen wäre das falsche Mittel gewesen: die
Leckprüfung im Skript sucht den Dienstnamen im Kopf-Dump, und mit dem Namen api liefe sie
selbst in dieselbe Teilstringfalle.

Alle 14 Gate-Zeilen aus db-gates, jede mode=required, passed == executed,
skipped=0 — der Container-Smoke steht jetzt bei 27 statt 26:

[container-smoke] mode=required executed=27 passed=27 skipped=0
[cost-access] [cost-snapshot] [planning-invariants] [planning-publish]
[planning-published-reads] [planning-write] [rate-http-contract] [rate-succession]
[rate-timestamp] [snapshot-http] [snapshot-immutability] [tenant-isolation] [tenant-pooling]

Die übrigen Gates an diesem Head

  • pnpm exec turbo run lint typecheck test --force22 successful, 22 total,
    Cached: 0 cached, 22 total. @easytree/web 28 Dateien, @easytree/api 76.
  • PRIL-Scope-Gate grün ohne Erweiterung: PRIL scope check passed … (45 changed files),
    Exit 0, gegen Revision 6. Weiterhin keine Revision 7.
  • Cloudflare bleibt grün: OpenNext build complete.,
    Total Upload: 5912.63 KiB / gzip: 1184.94 KiB gegen das 3072-KiB-Limit.

Eine Messfalle, die hier gemeldet gehört

Der erste lokale Gesamtlauf nach dem Container-Smoke ging rot: 13 Fälle in sieben
API-Integrationsdateien, alle mit error: role "authenticated" does not exist. Ursache war
nicht die Änderung, sondern die Umgebung — der für den Smoke gestartete nackte
postgres:17 belegte Port 54322, worauf die Tenant-Suiten aufhörten zu überspringen und gegen
eine Datenbank ohne EasyTree-Schema liefen. Container entfernt, Lauf wiederholt: 22 successful, 0 cached. Festgehalten, weil dieselbe Falle beim nächsten lokalen Smoke wieder zuschnappt.

Nicht gemerged, absichtlich. Der Merge ist eine Owner-Entscheidung.


Unabhängige Nachmessung am Head 3b4bbdb (22.08.2026)

Diese Runde hat nichts geändert — kein Commit, kein Push, keine Revision 7. Der Auftrag ging
von 18f5004 als Head aus; die Reparatur des Falsch-positiv-Riegels lag zu diesem Zeitpunkt
bereits als 3b4bbdb auf dem Branch. Gemessen wurde deshalb, ob die oben protokollierten
Zusagen an diesem Head tatsächlich halten — die Belege oben sind Behauptungen, bis sie jemand
unabhängig nachrechnet.

HEAD   = 3b4bbdb5ee88260000be404c86c9dac40e92b591
origin = 3b4bbdb5ee88260000be404c86c9dac40e92b591
PR #89 = 3b4bbdb5ee88260000be404c86c9dac40e92b591
verfolgte Arbeitsbaum-Änderungen: 0

CI an genau diesem Head

Lauf 32542186429, headSha = 3b4bbdb, conclusion = success, 11 von 11 Jobs grün:
format, lint, typecheck, unit-tests, build-web, web-smoke, build-api,
secret-scan, db-gates, read-through, auth-journey.

Gegenmutation — ein zweites Mal ausgeführt, nicht abgeschrieben

Die überbreite Fassung wurde erneut wörtlich eingespielt (Teilstringvergleich gegen volle
Adresse, Origin, Autorität und nackten Hostnamen; keksNenntInternesZiel auf denselben
generischen Weg zurückgesetzt; Aufrufstelle wieder auf `${name}: ${wert}`):

Tests  7 failed | 25 passed (32)

Dieselben sieben Fälle, alle im Block „der Riegel trifft die Adresse, nicht das Wort":

laesst einen Kopf stehen, dessen NAME den Dienstnamen enthaelt
laesst einen Kopf mit einer FREMDEN Adresse unter demselben Wort stehen
laesst ein Cookie stehen, dessen Name den Dienstnamen enthaelt
laesst einen Wert stehen, in dem der Dienstname nur ein Wortteil ist
entfernt trotzdem die echte interne Adresse
verwechselt eine laengere Portnummer nicht mit der internen
entfernt ein Cookie, dessen Domain-Attribut den internen Host nennt

Bemerkenswert bleibt, dass die 25 übrigen Fälle unter der Mutation grün blieben — darunter
sämtliche Leck-Nachweise. Das ist der Beleg dafür, dass die Mutation genau eine Fehlerrichtung
trifft: sie schwächt den Schutz nicht, sie verbreitert ihn.

Rücknahme aus einer Sicherungskopie (nicht git checkout), Beleg auf die mutierte Datei
eingegrenzt:

cmp: identisch
shasum -a 256  f38874cb6f101817d177889a3f1a2aa7fa810e022b3044f0100653b76aa5544c
git diff -- apps/web/lib/proxy-durchreichen.ts   ->  0 Zeilen

Gates, lokal nachgemessen (macOS, arm64)

Prüfung Ergebnis
turbo run typecheck --force --concurrency=1 10 successful, 10 total, Cached: 0 cached
turbo run lint --force --concurrency=1 6 successful, 6 total, Cached: 0 cached
turbo run test --force --concurrency=1 10 successful, 10 total, Cached: 0 cached
Testzahlen config 36 · contracts 201 · domain 246 · ui 16 · web 309 · api 770 bestanden / 95 übersprungen
prettier --check apps packages docs scripts 8 Warnungen, alle acht untracked (docs/plans/…) → null verfolgte Verstöße
plumbline-scope-guard.sh PRIL scope check passed … (45 changed files), Exit 0, gegen Revision 6
env -u EASYTREE_API_PROXY_TARGET pnpm --filter @easytree/web... build Done — der Bau liest die Variable nicht
OpenNext + Wrangler-Dry-Run OpenNext build complete., Total Upload: 5912.63 KiB / gzip: 1184.93 KiB (Grenze 3072 KiB)
Baumscannende Wächter mit .open-next/.wrangler-dry auf der Platte architecture + architecture-red-case 40 Fälle grün, no-supabase-import + api-base-path 6 Fälle grün

Die 95 übersprungenen API-Fälle sind die Tenant-Suiten im Modus local — auf diesem Rechner
läuft kein Supabase-Stack. Scharf gemessen werden sie ausschließlich in db-gates.

Container-Smoke, lokal wiederholt

Gegen einen nackten postgres:17 mit einer RLS-gebundenen Rolle
(rolsuper=f, rolbypassrls=f, rolcanlogin=t):

[container-smoke] mode=local executed=27 passed=27 skipped=0

Ein Image, zwei Ziele, unveränderter Digest:

vorher=sha256:a56f9234c52c618d11a8a2da46da8b6303cfcabe8c034f7ee77b275d18cf760b
nachher=sha256:a56f9234c52c618d11a8a2da46da8b6303cfcabe8c034f7ee77b275d18cf760b

Abschnitt 8c, beide Fehlerrichtungen im selben Lauf — zuerst die Nicht-Vakuositätsprobe direkt
aus dem Containernetz, dann das Ergebnis hinter dem Proxy:

  direkt vom Stub: x-upstream-url=http://easytree-stub-b:3001/private link=<http://easytree-stub-b:3001/foo>; rel="self" x-stub-marke=harmlos x-api-version=1 x-documentation=https://api.example.org/public set-cookie=api_session=abc123; Path=/; HttpOnly || stub_sitzung=def456; Path=/
  OK   der Stub nennt seine interne Adresse tatsaechlich in einem Nicht-location-Kopf
  x-upstream-url und link entfernt, x-stub-marke durchgereicht
  OK   kein gewoehnlicher Antwortkopf traegt die interne Adresse zum Browser
  set-cookie-Koepfe: 2
  x-api-version, x-documentation und beide Cookies unveraendert durchgereicht
  OK   harmlose Koepfe mit dem Wort 'api' ueberleben den Riegel unveraendert

Die oben protokollierte ehrliche Grenze bleibt bestehen: die Stubs heißen
easytree-stub-a/b und nicht api, 8c ist also ein Erhaltungs- und kein
Falsch-positiv-Gegenmutationsnachweis. Diese Fehlerrichtung wird in der Unit-Suite scharf
gemessen, wo das Ziel wörtlich http://api:3001 heißt.

location ist unangetastet: /foo?x=1 relativ, https://login.example.org/oauth?state=xyz
semantisch unverändert — im Container wie in den sechs Weiterleitungsfällen der Unit-Suite.

Eine Flakiness, die hier gemeldet gehört

Der erste kombinierte Lauf turbo run lint typecheck test --force (volle Nebenläufigkeit,
22 Aufgaben) ging lokal rot: ein fast-check-Eigenschaftstest lief in den 5-s-Timeout, dazu
Folgefehler in typecheck/lint/test. Es lief dabei kein Postgres auf 54322, die oben
protokollierte Ursache greift hier also nicht. Die Wiederholung desselben Befehls war grün
(22 successful, 22 total, Cached: 0 cached), und alle drei nach Aufgabe getrennten,
serialisierten Läufe waren beide Male grün. Festgehalten als Messfalle unter Last, nicht als
Befund am Code — eine Ursache wird hier ausdrücklich nicht behauptet.

Weiterhin nicht gemerged. Kein Deploy, keine Supabase-Mutation, kein Jira-Übergang,
keine Revision 7. BLOCKER_ENVIRONMENT_SEPARATION bleibt offen.

Confluence 30998530 stellt das Staging auf VPS + Coolify + Docker um. Damit
brauchen beide Workloads eine Buildbeschreibung im Repository; bisher war der
Deploymentpfad ausschliesslich ueber Dashboard-Variablen konfiguriert.

Beide Dockerfiles bauen aus dem Wurzelverzeichnis mit
`pnpm install --frozen-lockfile` und `corepack`, laufen als `node` statt als
root und tragen den Commit als OCI-Label `org.opencontainers.image.revision`.
Der Outbox-Worker benutzt dasselbe API-Image und ueberschreibt nur das
Kommando. In `docker-compose.yml` veroeffentlicht nur `web` einen Port; die API
haengt am internen Netz, damit der Browser genau eine Origin sieht.

`EASYTREE_API_PROXY_TARGET` ist dabei BAUZEIT-Konfiguration, und das ist jetzt
gemessen statt offen: gebaut mit `http://buildtime-marker.invalid:9999` und
gestartet mit `EASYTREE_API_PROXY_TARGET=http://127.0.0.1:3999` antwortet
`/health` mit HTTP 500 und protokolliert `ENOTFOUND buildtime-marker.invalid`.
`next start` liest die Weiterleitungen aus dem gebauten routes-manifest.json.
Der Kommentar in `lib/api-proxy-target.ts` nannte das bisher ausdruecklich
ungeprueft.

`output: "standalone"` ist ein Schalter und keine Konstante: der
Cloudflare-Bundlebau in `build-web` erwartet die Standardausgabe, und sein
Abbau ist EYT-149. `resolveNextOutput` ist fail-closed — ein unbekannter Wert
wirft, statt still auf die Standardausgabe zurueckzufallen. Gegenmutation
ausgefuehrt: `throw` durch `return {}` ersetzt macht
`test/next-output.test.ts` rot ("expected function to throw an error, but it
didn't"), Ruecknahme-Diff leer.
Ein Image, das baut, ist noch kein Image, das laeuft. Der Smoke startet beide
Container als zusammenhaengende Topologie und prueft, was ein Deploy spaeter
voraussetzt: OCI-Label gegen den Head, nicht-privilegierter Benutzer,
`/health`, `/ready` mit ECHTER Datenbank, den Weg Web -> API ausschliesslich
ueber den internen Dienstnamen, dass derselbe Dienstname vom Host aus NICHT
aufloesbar ist (sonst bewiese der vorige Punkt nichts), dass das ausgelieferte
HTML die interne Adresse nicht nennt, geheimnisfreie Protokolle und
geordnetes Herunterfahren.

Dazu die Gegenprobe, ohne die die Konfigurationspruefung auch dann gruen waere,
wenn sie nichts prueft: mit `NODE_ENV=production` und ohne
`DATABASE_SSL_ROOT_CERT` MUSS der API-Container den Start verweigern und die
Variable benennen.

Der Smoke haengt am Ende von `db-gates` und bekommt bewusst keinen eigenen Job
— ein neuer Job waere kein Pflichtcheck, bis jemand mit Adminrechten das
Ruleset neu anwendet, und beide Kopien der Pflichtcheck-Liste muessten
mitgezogen werden. Nur dort gibt es ausserdem eine echte PostgreSQL-Instanz mit
der Rolle `easytree_app`. Er meldet
`[container-smoke] mode=required executed=… passed=… skipped=0`; ein separater
Schritt prueft die Zeile, weil ein weggeduckter Smoke sonst wie ein
bestandener aussaehe.

`pruefeContainerDeployAutoritaet` zieht die Regel gegen eine zweite
Migrationsautoritaet auf Dockerfiles und Compose nach: Coolify und Railway
duerfen so wenig Schemawerkzeuge aufrufen wie ein Cloudflare-Build.
Kommentarzeilen bleiben aussen vor, sonst erzieht der Waechter zum
Verschweigen.

Lokal gemessen (arm64, colima, Postgres 17 mit easytree_app):
`[container-smoke] mode=required executed=15 passed=15 skipped=0`.
Drei Gegenmutationen ausgefuehrt und wieder zurueckgenommen:
GIT_SHA=deadbeef… setzt das Label auf deadbeef… (Bindungspruefung waere rot);
`USER node` entfernt ergibt `user=''` (Nicht-root-Pruefung waere rot);
Web-Image auf `http://127.0.0.1:3001` gebaut liefert ueber den Web-Container
HTTP 500 statt der API-Antwort (Weiterleitungspruefung waere rot).
Das Runbook beschrieb zwei Cloudflare-Worker als Deployziel. Seit der
Entscheidung vom 21.08.2026 (Confluence 30998530) ist das falsch, und ein
falsches Runbook ist schlechter als keines — es wird beim ersten Deploy
befolgt.

Neu beschrieben: die beiden Images und ihre Startbefehle, die Bauzeitnatur von
`EASYTREE_API_PROXY_TARGET` samt Messung, der Container-Smoke unter den
Pflicht-Smokes, das Rollback ueber Imagetag und OCI-Revision, was in Coolify
konkret angelegt wird und woran die Railway-Kompatibilitaet haengt.

Unveraendert bleibt, was unveraendert gilt: das Gate aus §1. Die Messung vom
21.08.2026 ist eingetragen — `list_projects` zeigt kein EasyTree-Projekt,
`list_branches` auf inypnrvpawvhgiyagxbd zeigt genau eine Branch, deren
project_ref und parent_project_ref identisch sind. Es gibt also weiterhin keine
Non-Production-Datengrenze, und jeder Schritt unterhalb von §1 bleibt gesperrt.

Was nicht ausgefuehrt ist, steht weiterhin als "nicht gemessen" da: der
Coolify-Abschnitt, das Rollback, der Railway-Deploy und das
Testdaten-Bootstrapskript. Das Dokument behauptet keinen Vollzug.

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry @DYAI2025, you have reached your weekly rate limit of 500000 diff characters.

Please try again later or upgrade to continue using Sourcery

@sourcery-ai

sourcery-ai Bot commented Aug 21, 2026

Copy link
Copy Markdown

Reviewer's Guide

Adds first-class Docker/OCI support for the API and Web apps plus a container smoke test wired into CI, while enforcing that container-based deployments cannot run schema/migration tools and keeping deployment documentation aligned with a container-first strategy.

Sequence diagram for CI container smoke test flow

sequenceDiagram
  participant GH as GitHubActions_db-gates_job
  participant Smoke as scripts/smoke-container.sh
  participant Docker as Docker_Daemon
  participant API as ApiContainer
  participant Web as WebContainer
  participant DB as PostgreSQL_easytree_app
  participant SA as Supabase_Auth

  GH->>Smoke: run smoke-container.sh\n(EASYTREE_CONTAINER_SMOKE=required, GIT_SHA)
  Smoke->>Docker: docker build apps/api/Dockerfile\n--build-arg GIT_SHA
  Smoke->>Docker: docker build apps/web/Dockerfile\n--build-arg GIT_SHA\n--build-arg EASYTREE_API_PROXY_TARGET
  Smoke->>Docker: docker inspect easytree-api:smoke\n(check OCI revision, user=node)
  Smoke->>Docker: docker inspect easytree-web:smoke

  Smoke->>Docker: docker run ApiContainer\n(NODE_ENV=test, DATABASE_URL,...)
  Docker-->>API: start dist/main.js
  API->>DB: connect using DATABASE_URL
  API->>DB: /ready health probe

  Smoke->>API: HTTP GET /health
  API-->>Smoke: 200 OK
  Smoke->>API: HTTP GET /ready
  API-->>Smoke: 200 OK {database:true}

  Smoke->>Docker: docker run WebContainer\n(NODE_ENV=production)
  Docker-->>Web: start apps/web/server.js
  Web->>API: /health, /ready via\nEASYTREE_API_PROXY_TARGET

  Smoke->>Web: HTTP GET /health
  Web-->>Smoke: proxied API /health
  Smoke->>Web: HTTP GET /ready
  Web-->>Smoke: 200 OK

  Smoke->>Web: HTTP GET /
  Web-->>Smoke: HTML (no internal address)

  Smoke->>Docker: docker logs ApiContainer, WebContainer
  Docker-->>Smoke: logs (checked for secrets)

  Smoke->>Docker: docker run --rm ApiContainer\n(NODE_ENV=production, no DATABASE_SSL_ROOT_CERT)
  Docker-->>Smoke: non-zero exit, error\nmentions DATABASE_SSL_ROOT_CERT

  Smoke->>Docker: docker stop WebContainer
  Docker-->>Smoke: exit code 0 or 143
  Smoke->>Docker: docker stop ApiContainer
  Docker-->>Smoke: exit code 0

  Smoke-->>GH: [container-smoke] mode=required\nexecuted=… passed=… skipped=0
Loading

File-Level Changes

Change Details Files
Introduce reproducible Docker/OCI builds for API and Web, including non-root images labelled with the Git revision and a same-origin topology defined via docker-compose.
  • Add multi-stage Dockerfile for the NestJS API that builds via pnpm with a frozen lockfile, produces a deployable /prod/api bundle, and runs as user node with NODE_ENV=production by default.
  • Add multi-stage Dockerfile for the Next.js Web shell that builds a standalone output, bakes EASYTREE_API_PROXY_TARGET as a build arg, and runs a standalone server as user node listening on 0.0.0.0:3000.
  • Add docker-compose topology where only web exposes a public port, the API is only reachable via the internal service name, healthchecks use node+fetch instead of curl, and environment variables are wired according to packages/config schema with strict required secrets.
apps/api/Dockerfile
apps/web/Dockerfile
docker-compose.yml
.dockerignore
Add a container smoke test that builds and runs both images against a real Postgres, validates labels, topology, secrecy, config strictness, and graceful shutdown, and wire it into the db-gates CI job.
  • Implement scripts/smoke-container.sh to build both images from the working tree, verify OCI revision labels against GIT_SHA, run them in a dedicated Docker network against a supplied Supabase/Postgres stack, and perform 8 groups of checks including health/readiness, internal-only routing, absence of secrets in logs, and SIGTERM behaviour.
  • Extend the db-gates job in GitHub Actions to run the container smoke with EASYTREE_CONTAINER_SMOKE=required, pass in database and Supabase endpoints from the local CI stack, assert HEAD matches github.sha, and enforce that the smoke line shows skipped=0.
  • Update turbo pipeline config (not fully shown) so the new script integrates correctly into existing task orchestration.
scripts/smoke-container.sh
.github/workflows/ci.yml
turbo.json
Extend deploy-authority guards to cover Dockerfiles and docker-compose so container builds cannot invoke schema-changing tools, with tests to ensure fail-closed behaviour.
  • Refine deploy-authority rules comment to forbid schema tools in any deploy command, not just Cloudflare build commands.
  • Add pruefeContainerDeployAutoritaet to scan Dockerfile/docker-compose text (skipping comments) for known schema tools, failing closed on missing/unreadable configuration, and returning structured findings.
  • Extend deploy-authority tests to cover container configs, including positive detection cases, fail-closed cases, and confirmation that tools inside comments are ignored.
apps/api/test/architecture/deploy-authority-rules.ts
apps/api/test/deploy-authority.test.ts
Make Next.js build output configurable for container vs Cloudflare targets and document that EASYTREE_API_PROXY_TARGET is build-time only, with tests to keep this behaviour fail-closed.
  • Document in api-proxy-target comments that rewrites are baked at build time into routes-manifest.json and update the description of EASYTREE_API_PROXY_TARGET accordingly.
  • Introduce lib/next-output.ts with resolveNextOutput, an InvalidNextOutputError, and a NEXT_OUTPUT_STANDALONE constant so Next output can be switched via EASYTREE_NEXT_OUTPUT while failing on unknown values.
  • Wire resolveNextOutput and an explicit outputFileTracingRoot into next.config.ts and add tests for resolveNextOutput, including behaviour with ProcessEnv-like objects and unknown values.
apps/web/lib/api-proxy-target.ts
apps/web/lib/next-output.ts
apps/web/next.config.ts
apps/web/test/next-output.test.ts
Align documentation and internal guidelines with the container-first deployment path, including the new smoke, environment rules, and rollback strategy.
  • Add a "Deployment — Container first" section to CLAUDE.md describing Coolify+Docker as primary, Railway as secondary, explaining the new Dockerfiles, container smoke, and the unchanged environment separation blocker.
  • Rewrite and extend the staging deploy runbook to describe Docker-based workloads, the build-time nature of EASYTREE_API_PROXY_TARGET, container smoke execution and interpretation, rollback based on image labels, and Coolify/Railway specifics, while keeping the environment separation gate explicit.
  • Add Coolify/Railway compatibility and limitations notes, clearly marking which parts are measured versus not yet executed in a real staging environment.
CLAUDE.md
docs/runbooks/staging-deploy.md

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

BenPerro and others added 19 commits August 21, 2026 17:26
…absichern

Der Bericht vom 20.08.2026 zitiert EYT-142 mit "primaerem Cloudflare-Staging".
Seit dem 21.08.2026 ist Cloudflare kein Zielruntime mehr — und genau daraus
laesst sich der falsche Schluss ziehen, mit der Plattform sei auch der Blocker
erledigt.

Ist er nicht. Die fehlende Voraussetzung ist keine Eigenschaft der
Hostingplattform, sondern der Datengrenze. Der Nachtrag sagt das und traegt die
Wiederholungsmessung vom 21.08.2026 ein: list_projects zeigt weiterhin kein
EasyTree-Projekt, list_branches auf inypnrvpawvhgiyagxbd weiterhin genau eine
Branch, deren project_ref und parent_project_ref identisch sind.

Der Rest des Dokuments bleibt unangetastet — es ist ein datierter Messbericht,
kein lebendes Dokument.
…ngsreif machen

Der PRIL-Scope-Gate meldet PRIL_POLICY_VIOLATION (exit 3). Die Ursache ist keine
Nachlaessigkeit im Slice, sondern eine ausdrueckliche Festlegung: Revision 1 des
Sprint-6-Manifests schliesst "Infrastruktur-, API- und supabase-Pfade fuer Slice
0/3 bewusst" aus. Ein Dockerfile kann diese Regel nicht einhalten, ohne
aufzuhoeren ein Dockerfile zu sein.

Gemessen und getrennt, damit die Entscheidung auf Zahlen steht statt auf einer
Fehlermeldung mit 102 Dateien:

* Oberflaeche A (bereits auf origin/master, gegen die armierte Base f3b427d):
  82 Dateien, davon 28 ausserhalb der Scope. Die Base liegt 78 Commits zurueck,
  der Gate war also VOR diesem Slice bereits rot.
* Oberflaeche B (nur dieser Slice): 16 Dateien, davon 11 ausserhalb — genau die
  elf Deploymentpfade.

Das Manifest wird von hier aus NICHT geaendert, obwohl docs/scope/** in der
Governance-Scope steht. Die Bindung unter .plumbline/scope-authority/ ist scharf
(Digest und shasum stimmen ueberein, gemessen), und jede der vier bestehenden
Revisionen traegt den PO namentlich mit confirmed:true. Eine fuenfte zu
schreiben, ohne dass die Entscheidung gefallen ist, waere eine gefaelschte
Freigabe, keine Dokumentation.

Das Dokument enthaelt deshalb den fertigen Revisionsblock mit confirmed:false und
offenem decision_maker, die Alternative "EYT-126 eigenstaendig armieren", und den
Hinweis, dass der Gate ein lokaler Stop-Hook ist und nicht in ci.yml steht — die
11 Pflichtchecks sind gruen, PR #89 ist mergeState=CLEAN.
…e nachziehen

PO-Entscheidung vom 21.08.2026: Weg 1 aus
docs/plans/2026-08-21-eyt-126-scope-autoritaet.md. Revision 5 nimmt die
Deploymentpfade auf, die Revision 1 ausdruecklich ausgeschlossen hatte — der
Containerbau existiert ausschliesslich, um das PENDING-Gate "deploy" desselben
Features passierbar zu machen.

Die Digest-Konvention wurde vorher gegen ALLE VIER bestehenden Revisionen
verifiziert statt erraten:
sha256(JSON.stringify(scope, Object.keys(scope).sort())) — vier von vier
stimmen. Revision 5 rechnet nach derselben Regel, und der Top-Level-scope ist
identisch mit dem der letzten Revision.

Abweichung vom freigegebenen Entwurf, laut benannt statt still: zusaetzlich ist
docs/architecture/** aufgenommen. Grund ist Konsistenz — sieben andere
docs/**-Pfade stehen bereits in der Governance-Scope, docs/architecture/ traegt
ADR-001/002/003. Folge: damit faellt auch die vorbestehende, UNGETRACKTE Datei
"docs/architecture/EasyTree – Softwaredokumentation.md" in die Scope; sie stammt
nicht aus diesem Slice. supabase/** bleibt ausgeschlossen.

Zweitens die Base. docs/context/.scope-base stand auf f3b427d und lag 78 Commits
hinter origin/master; der Gate meldete deshalb dauerhaft 21 bereits gemergte,
bereits reviewte Dateien mit. Das Plandokument hatte das Verschieben zunaechst
abgelehnt, weil es die gepruefte Oberflaeche verkleinert und ausgerechnet der
Slice davon profitiert haette. Dieser Einwand ist mit Revision 5 gegenstandslos:
gemessen ist der Befund dieses Slice gegen BEIDE Basen null. Die Ruecknahme steht
im Dokument, nicht nur im Ergebnis.

Gate danach: "PRIL scope check passed … (32 changed files)", Exit 0.

Nicht versioniert: .plumbline/ steht in .gitignore, die neu gezogene Bindung auf
den Manifest-Hash d5b483614d8e81c7… existiert nur lokal.
…he Annahme zuruecknehmen

Nach PO-Freigabe wurde versucht, ein zweites Projekt easytree-staging
anzulegen (get_cost fuer diese Org: 0 $/Monat). Der Aufruf wurde abgelehnt:

  BadRequestException: The following organization members have reached their
  maximum limits for the number of active free projects within organizations
  where they are an administrator or owner: DYAI2025 (2 project limit).

Korrektur: die erste Lesart fuehrte das pausierte Projekt "Bazodiac"
(status INACTIVE) als einen der beiden belegten Slots. Das ist falsch. Die
Supabase-Doku sagt an zwei Stellen woertlich das Gegenteil — Billing FAQ:
"Paused projects do not count towards your quota"; About billing: "Paused
projects do not count towards your free project limit". Bazodiac zu loeschen
oder zu pausieren ist damit gegenstandslos, und das steht jetzt so da, statt
als stille Streichung.

Die fehlende Voraussetzung ist damit zweistufig: (1) es gibt keine
EasyTree-Datengrenze mit abweichendem project_ref, unveraendert; (2) der
KOSTENLOSE Weg dorthin ist versperrt, weil die Free-Projekt-Quota pro NUTZER
zaehlt und DYAI2025 sie mit zwei aktiven Projekten ausgeschoepft hat — eines ist
die Produktion, das zweite ist ueber diesen Zugang nicht sichtbar.

Punkt 2 ist belegt und nicht vermutet: list_projects liefert reproduzierbar
genau ein Projekt und enthaelt inypnrvpawvhgiyagxbd NICHT, obwohl get_project
darauf antwortet; list_organizations enthaelt EASTree nicht, obwohl
get_organization darauf antwortet. Die Aufzaehlung ist nachweislich
unvollstaendig, deshalb steht im Bericht ausdruecklich KEINE daraus abgeleitete
Projektzaehlung.

Der abgelehnte Aufruf belegt nebenbei, dass die Autorisierung durchlaeuft: die
Antwort war ein Quota-Fehler, kein 403. Der Vorbehalt dazu ist notiert.

Kein Schreibzugriff hat stattgefunden. Der abgelehnte create_project-Aufruf hat
nichts angelegt, alles Uebrige war read-only, Produktionsdaten wurden nicht
beruehrt.
…eit-Proxy

Der PO hat den Sprint-6-Scope am 22.08.2026 um genau sechs Pfade erweitert:
apps/web/app/{api,health,ready}/**, apps/web/instrumentation.ts,
apps/api/test/architecture/secret-surface-rules.ts und
apps/api/test/container-bindung.test.ts. Keine weitere Erweiterung.

Der Grund ist gemessen, nicht bevorzugt: der Same-Origin-Proxy muss aus
next.config.ts-rewrites() heraus, weil Next das Ergebnis beim Bauen nach
routes-manifest.json schreibt und ein fertiges Image damit an sein Ziel
gebunden waere. Eine Next-16-proxy.ts scheidet zweifach aus — sie sendet
x-middleware-rewrite mit der internen Adresse an den Browser, und
@opennextjs/cloudflare bricht mit "Node.js middleware is not currently
supported" ab, was den Pflichtjob build-web rot machte. Uebrig bleiben Route
Handler unter apps/web/app/, wo Revision 5 nur planung/** fuehrte.

Nichts weiter war noetig: apps/web/lib/** und apps/web/test/** stehen seit
Revision 1 als Platzhalter in der Scope, die Container- und CI-Pfade kamen mit
Revision 5.

Die Digest-Konvention wurde vor dem Schreiben gegen alle fuenf bestehenden
Revisionen nachgerechnet (5/5), nicht aus dem Dokument uebernommen, das sie
behauptet. Revisionen 1-5 sind unveraendert, kein Pfad ging verloren. Die
Bindung unter .plumbline/scope-authority/ wurde neu gezogen
(d5b483614d8e81c7... -> 2741c0ec0935d01d...), geprueft gegen shasum -a 256.

Im Nachtrag zusaetzlich festgehalten, in diesem Lauf gemessen: .plumbline/ ist
per .gitignore:35 ausgeschlossen, der Feature-Marker docs/context/.active-feature
dagegen versioniert. Fehlt die Bindung — etwa in einem frischen Worktree —
meldet der Gate trotzdem "passed" und nicht SCOPE_AUTHORITY_CHANGED.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…acken

Der Same-Origin-Proxy bekommt eine eigene Durchreiche, die
EASYTREE_API_PROXY_TARGET bei JEDER Anfrage neu aus der Umgebung liest.
Bisher loeste next.config.ts-rewrites() den Wert beim Bauen auf und schrieb
ihn nach .next/routes-manifest.json — ein fertiges Web-Image war damit an sein
Ziel gebunden, was Confluence 30998530 (dieselben Images fuer Coolify/VPS und
Railway) nicht erfuellt.

Die Datei schliesst zugleich drei Wege, auf denen die interne API-Adresse den
Browser erreichen koennte:

  * kein x-middleware-rewrite — anders als bei einer Next-16-proxy.ts baut ein
    Route Handler die Antwort selbst und bestimmt jeden Kopf;
  * ein absoluter location-Kopf auf das konfigurierte Ziel wird in einen
    relativen Pfad uebersetzt, Pfad, Query und Fragment bleiben erhalten. Ein
    EXTERNES Weiterleitungsziel bleibt unveraendert;
  * ein Verbindungsfehler wird zu einem eigenen 502, statt die undici-Meldung
    (sie nennt den Zielhost) durch Nexts Fehlerseite laufen zu lassen.

Gegenmutation ausgefuehrt und zurueckgenommen: friert man das Ziel beim
Modulladen in einer Modulkonstante ein, faellt "liest das Ziel bei JEDER
Anfrage neu aus der Umgebung" mit
`AssertionError: expected [ Array(2) ] to deeply equal [ …(2) ]`, dazu sechs
Folgefaelle — darunter die drei Weiterleitungsfaelle, weil ein eingefrorenes
Ziel den location-Vergleich gegen die falsche Origin fuehrt und die interne
Adresse dann leckt. Nach der Ruecknahme ist die Datei hashgleich
(sha256:ddfcba34…) und die Suite wieder 17/17 gruen.
Drei Route Handler ersetzen den Bauzeit-Rewrite: /api/[[...pfad]] als
optionaler Catch-all (damit auch /api selbst nicht als 404 aus der Web-App
zurueckkommt), /health und /ready lesend. Alle drei zeigen auf dieselbe
Durchreiche und setzen `dynamic = "force-dynamic"` — ohne das koennte eine
Antwort aus dem Cache kommen und das alte Ziel weiterreichen.

Der Test prueft die Routen selbst und nicht nur die Durchreiche: eine fehlende
Route oder eine nicht exportierte Methode waere im Browser ein 404 auf /ready
statt eines Fehlers, und ein Test auf die Durchreiche allein bliebe dabei
gruen.
…oxyziel

register() laeuft einmal beim Hochfahren des Next-Servers. Wirft es, meldet
Next "Failed to prepare server" und beantwortet danach JEDE Route mit 500 —
auch / und /anmelden. Ohne diese Sperre antworteten nur /api, /health und
/ready mit einem Fehler; die Anwendung selbst saehe gesund aus und zeigte im
Browser eine leere Woche.

Gemessen 22.08.2026 auf Next 16.2.11, sowohl im Container als auch mit
`next start`: ohne Wert /=500, /anmelden=500, /health=500 und im Protokoll
"EASYTREE_API_PROXY_TARGET ist in production Pflicht. Es gibt hier bewusst
keinen Default."; mit ftp:-Ziel dieselben Codes und "erlaubt nur http: oder
https:, erhalten: \"ftp:\"".

Ehrliche Grenze, die auch in CLAUDE.md und im Runbook so steht: der Prozess
bleibt am Leben und haelt den Port. Er bedient aber keinen normalen
Anwendungsverkehr, und der Compose-Healthcheck auf / schlaegt fehl. Die
Behauptung "der Container startet nicht" waere falsch.

Die Datei liest die Umgebung NICHT selbst, sondern ruft aktuellesProxyziel()
auf — es soll genau eine Laufzeitstelle geben, nicht zwei.
…tivliste

Der Geheimnis-Waechter meldete vor dieser Zeile genau EINEN Fund:
"apps/web/lib/proxy-durchreichen.ts:63 [runtime-reads-no-environment]". Dass
es genau einer war und nicht zwei, ist die eigentliche Aussage — instrumentation.ts
liest die Umgebung nicht selbst.

Gegenmutation ausgefuehrt und zurueckgenommen: liest instrumentation.ts
versuchsweise selbst per normalizeProxyTarget(process.env…), meldet der
Waechter "apps/web/instrumentation.ts:24 [runtime-reads-no-environment]" und
geht rot. Die Positivliste gibt also nicht pauschal apps/web frei. Nach der
Ruecknahme ist die Datei hashgleich und der Waechter wieder 39/39 gruen.
…ad entfernen

next.config.ts traegt keinen rewrites()-Block mehr. Damit verlieren
resolveBuildProxyTarget, CLOUDFLARE_API_FALLBACK_TARGET und ProxyBuildEnvironment
ihren einzigen Aufrufer; toter Code mit gruenen Tests behauptet eine Zusage,
die niemand mehr einloest, also gehen sie samt ihrem describe-Block mit.
turbo.json braucht EASYTREE_API_PROXY_TARGET, WORKERS_CI und CF_PAGES nicht
mehr im Cache-Schluessel der build-Aufgabe.

Zwei Aussagen, die dieser Commit ueberholt, jeweils an ihrer Stelle korrigiert
statt stehengelassen: der Dateikopf von api-proxy-target.ts nannte den Wert
Bauzeitkonfiguration, und next-output.ts nannte ihn als gleichartig zu seinem
eigenen Bauzeitschalter.

Gemessen 22.08.2026:
  * `env -u EASYTREE_API_PROXY_TARGET pnpm --filter @easytree/web... build` →
    exit 0, Routentabelle mit `ƒ /api/[[...pfad]]`, `ƒ /health`, `ƒ /ready`,
    keine Zeile `ƒ Proxy (Middleware)`. Vor dieser Aenderung brach derselbe
    Befehl mit "EASYTREE_API_PROXY_TARGET ist in production Pflicht" ab.
  * `env -u EASYTREE_API_PROXY_TARGET pnpm build` (Wurzel, ueber Turbo) →
    exit 0, `Tasks: 6 successful`, `Cached: 0 cached, 6 total`. Der in
    CLAUDE.md dokumentierte Wurzelbau-Bruch ist damit weg, nicht bloss
    umgangen.
  * `pnpm --filter @easytree/web run build:cf` → "Worker saved in
    .open-next/worker.js" / "OpenNext build complete."; cf:dry-run meldet
    gzip 1184.28 KiB gegen das 3072-KiB-Limit.
  * Web-Suite 294 Faelle (273 + 25 neue - 4 entfernte).
…wingen

apps/web/Dockerfile traegt kein `ARG EASYTREE_API_PROXY_TARGET` mehr. Das
Image ist damit an kein Ziel und an keinen Anbieter gebunden; dieselbe Datei
und derselbe Startpfad liefern es fuer Coolify/VPS wie fuer Railway.

GIT_SHA verliert in BEIDEN Dockerfiles seinen Vorgabewert und wird geprueft.
Ein leeres ARG faellt sonst still auf ein Label ohne Inhalt zurueck, und
"das Image ist gruen" waere eine Aussage ueber irgendein Image.

Gegenprobe ausgefuehrt (22.08.2026, colima):
  * `docker build -f apps/web/Dockerfile .` ohne --build-arg → exit 1 mit
    "GIT_SHA fehlt. Baue mit --build-arg GIT_SHA=$(git rev-parse HEAD)."
  * dasselbe fuer apps/api/Dockerfile → exit 1, gleiche Zeile.
  * mit --build-arg GIT_SHA=<head> gebaut, danach
    `docker inspect --format '{{index .Config.Labels "org.opencontainers.image.revision"}}'`
    → exakt der uebergebene Head.
Zwei fail-open-Stellen geschlossen. `:?` statt `:-`, und das Proxyziel wandert
vom Build-Arg in die Laufzeitumgebung des Web-Dienstes. Ein Deploy ohne
Herkunft und ein geratenes Proxyziel waren bis hierher ueber genau diesen Pfad
erzeugbar; ein geratenes Ziel sieht im Browser aus wie eine leere Woche, nicht
wie ein Fehler.

Gemessen 22.08.2026, alle drei Faelle:
  * ohne GIT_SHA → exit 1, "error while interpolating
    services.api.build.args.GIT_SHA: required variable GIT_SHA is missing a
    value: GIT_SHA fehlt - baue mit GIT_SHA=$(git rev-parse HEAD)"
  * mit GIT_SHA, ohne Ziel → exit 1, "error while interpolating
    services.web.environment.EASYTREE_API_PROXY_TARGET: required variable
    EASYTREE_API_PROXY_TARGET is missing a value: …"
  * mit beidem → exit 0, und `docker compose config` zeigt GIT_SHA am Head
    sowie EASYTREE_API_PROXY_TARGET unter web.environment. Ohne diese dritte,
    positive Probe waeren die ersten beiden nicht von "kaputte Datei" zu
    unterscheiden.
…ausschliessen

Die Handproben aus den beiden vorigen Commits belegen dasselbe schaerfer,
laufen aber nie wieder, wenn sie niemand wiederholt. Dieser Waechter braucht
kein Docker und laeuft im Pflichtjob unit-tests mit.

Der Bauabschnitt des Web-Dienstes wird eng geschnitten und nicht die ganze
Datei durchsucht: der Kopfkommentar NENNT die Variable (er erklaert, warum sie
keinen Vorgabewert hat), eine dateiweite Suche waere daran haengengeblieben.
Drei Eingangsbremsen halten den Test davon ab, vakuos gruen zu werden — eine
Mindestlaenge je Datei, ein Schnitt-Check auf den Bauabschnitt und die
Gegenrichtung "das Ziel steht in der Laufzeitumgebung".

Gegenmutation ausgefuehrt und zurueckgenommen, in beiden Haelften:
  * `${GIT_SHA:?…}` → `${GIT_SHA:-unknown}` in docker-compose.yml → rot in
    "gibt GIT_SHA keinen Vorgabewert" mit
    `AssertionError: expected '# easyTree — Container-Topologie fuer…' not to
    match /\$\{GIT_SHA:-/`
  * die RUN-Pruefung in apps/web/Dockerfile → `ARG GIT_SHA=unknown` → rot in
    "apps/web/Dockerfile erzwingt GIT_SHA statt es vorzubelegen"
Beide Dateien sind nach der Ruecknahme hashgleich
(d7fcb173… bzw. 65df23db…), der Waechter wieder 9/9 gruen.
…ontainer belegen

Der Kern der PO-Forderung: EIN Image-Digest, ZWEI Starts, ZWEI Ziele, KEIN
Neubau dazwischen. Der Digest zaehlt und nicht der Tag — ein Tag laesst sich
zwischen zwei Starts umhaengen, ein Digest nicht, und genau "kein Neubau
dazwischen" ist die Aussage. Zwei Stub-Container statt der echten API, weil
der Nachweis ueber die UNTERSCHEIDUNG laeuft; zwei echte APIs waeren nicht
unterscheidbar.

Neu ausserdem, weil x-middleware-rewrite zu vermeiden NICHT genuegt: derselbe
Stub liefert `Location: http://<interner-host>:3001/foo?x=1`. Diese Adresse
darf den Browser nicht erreichen, und ein EXTERNES Weiterleitungsziel darf
nicht spekulativ veraendert werden. Die Leckpruefung deckt jetzt HTML,
jeden referenzierten Client-Chunk, die Antwortkoepfe und den location-Kopf ab.

Zwei Reparaturen am Skript, die beim Ausfuehren aufgefallen sind:

  * Die Namen der Ein-Image-zwei-Ziele-Probe stehen bei den Konstanten und
    nicht erst in Abschnitt 8. Das Skript laeuft mit `set -Eeuo pipefail` und
    `aufraeumen()` haengt am EXIT-Trap; waeren sie erst weiter unten gesetzt,
    erzeugte ein Abbruch davor einen "unbound variable"-Folgefehler, der den
    echten Grund verdeckt. Zusaetzlich liest das Cleanup jeden Namen ueber
    `${VAR:-}`. Gemessen: ein Abbruch vor Abschnitt 8 (fehlende
    EASYTREE_SMOKE_DATABASE_URL) meldet 0 Treffer "unbound variable".
  * Acht `printf|grep -q`-Pruefungen auf `case`-Vergleiche umgestellt.
    `grep -q` beendet sich beim ERSTEN Treffer, die schreibende Seite bekommt
    EPIPE, und unter `pipefail` ist die Pipeline dann fehlgeschlagen — obwohl
    grep gefunden hat. Gemessen an `docker logs … | grep -qF
    EASYTREE_API_PROXY_TARGET`: die Pruefung ging rot, obwohl das Protokoll
    die Variable nannte. Bei einer LECK-Pruefung ist dieselbe Mechanik die
    gefaehrliche Richtung — das gefundene Leck saehe aus wie keines.

Lokal ausgefuehrt 22.08.2026 gegen colima und eine echte PostgreSQL-Instanz
(Rolle easytree_app, NOSUPERUSER/NOBYPASSRLS, damit das EYT-45-Startgate
greift):

  [container-smoke] mode=local executed=24 passed=24 skipped=0

  ziel=http://easytree-stub-a:3001 -> {"stub":"easytree-stub-a","pfad":"/health"}
  ziel=http://easytree-stub-b:3001 -> {"stub":"easytree-stub-b","pfad":"/health"}
  vorher=sha256:191a1af9bd22422634586cdb460bd832781e1e8f6fcf96528eddcdc4ab5d8a88
  nachher=sha256:191a1af9bd22422634586cdb460bd832781e1e8f6fcf96528eddcdc4ab5d8a88
  location=/foo?x=1
  location=https://login.example.org/oauth?state=xyz
  Ziel fehlt: / -> HTTP 500
  Ziel ungueltig: / -> HTTP 500
  10 Client-Chunks geprueft

Lokal ist nicht CI: der Nachweis in mode=required kommt aus db-gates.
Solange CI die Variable beim Bauen setzt, koennte niemand bemerken, dass der
Build sie nicht mehr braucht. Sie dort zu entfernen ist die staerkste
dauerhafte Gegenprobe: brauchte der Build sie noch, waere der Pflichtjob
build-web rot.

  * build-web: der env-Block entfaellt ersatzlos.
  * auth-journey: der Bauschritt setzt sie nicht mehr. Das Ziel kommt aus
    apps/web/e2e/auth-journey/config.ts:136 in der webServer-Umgebung, also
    zur Laufzeit — womit genau dieser Job zugleich belegt, dass Login,
    POST-Koerper und mehrere HttpOnly-Set-Cookie durch die neue Durchreiche
    gehen.
  * read-through-harness.sh: die Bauzeile verliert sie; Zeile 109 (`next
    start`) behaelt sie und ist ab jetzt die einzige Stelle, an der sie zaehlt.
  * web-smoke: NICHT angefasst. Dort steht sie auf Job-Ebene, damit `next
    start` sie erbt — seit dieser Aenderung braucht der Server sie zum
    Hochfahren, nicht nur zum Weiterleiten. Der Kommentar sagt das jetzt, weil
    ein Entfernen "als Bauzeit-Ueberbleibsel" den Job komplett faellen wuerde.
Beide Dokumente behaupteten wortreich das Gegenteil des neuen Verhaltens.
Jede Fundstelle einzeln entschieden statt gesucht-und-ersetzt: Absolutaussagen
ueberleben sonst in Zusammenfassungen, Tabellen und Fehlersuchspalten.

CLAUDE.md: der BAUZEIT-Absatz im Deployment-Abschnitt, der rewrites()-Satz in
"Web never talks to Supabase", die Container-Smoke-Aufzaehlung, und der
Wurzelbau-Absatz. Letzterer wurde NACHGEMESSEN statt umformuliert:
`env -u EASYTREE_API_PROXY_TARGET pnpm build` → exit 0, `Tasks: 6 successful`,
`Cached: 0 cached, 6 total`.

docs/runbooks/staging-deploy.md: §3 "Die achte Groesse", das Bauexempel in §5,
die Zeile "Leere Woche statt Fehler" in der Fehlersuchtabelle, das
Coolify-Build-Argument in §9, die Railway-Kompatibilitaet in §10 und der Stand
in §11.

Die frueheren Messungen werden nicht geloescht, sondern als das gekennzeichnet,
was sie waren: fuer den rewrites()-Weg korrekt. Ebenso bleibt die ehrliche
Grenze der Startsperre stehen — der Prozess haelt den Port, der Container gilt
als unhealthy, aber er "startet" durchaus.

Keine Staging-Evidenz: BLOCKER_ENVIRONMENT_SEPARATION bleibt unberuehrt, und
ein CI-echter Stack belegt Funktion, nicht Bereitstellung.
… korrigieren

Beide beschreiben einen Mechanismus, den dieser Slice ersetzt hat, und beide
stehen an Stellen, die als Autoritaet gelesen werden:

  * Der Grund fuer apps/web/next.config.ts in ERLAUBTE_UMGEBUNGSLESER nannte
    EASYTREE_API_PROXY_TARGET. Die Datei liest die Variable nicht mehr; sie
    liest EASYTREE_NEXT_OUTPUT. Der Eintrag bleibt noetig, seine Begruendung
    war falsch — in einer Sicherheits-Positivliste ist das kein Schoenheits-
    fehler.
  * CLAUDE.md beschrieb den belegten Lesepfad als "same-origin Next rewrite".

Nicht angefasst und darum hier benannt statt stillschweigend gelassen: der
Ausdruck "Same-Origin-Rewrite" steht weiter als Wegbeschreibung in
apps/web/e2e/**, scripts/read-through-harness.sh, .github/workflows/ci.yml und
zwei Runbooks; und apps/web/app/providers.tsx, apps/web/open-next.config.ts
sowie apps/web/playwright.harness.config.ts verweisen weiter auf
"das Rewrite in next.config.ts". Diese drei liegen ausserhalb der
Scope-Revision 6 und werden hier nicht angefasst.
…rne Adresse

Der Same-Origin-Proxy schloss bisher drei Lecks: `x-middleware-rewrite`
entsteht gar nicht erst, ein `location` auf das interne Ziel wird relativ, und
ein Verbindungsfehler wird ein eigener 502 ohne Hostnamen. Jeder UEBRIGE
Antwortkopf wurde dagegen unveraendert weitergereicht. Damit blieb genau ein
Weg nach draussen offen: `X-Upstream-Url: http://<intern>:3001/private` oder
`Link: <http://<intern>:3001/foo>; rel="self"` erreichen den Browser, sind fuer
same-origin-JavaScript ueber `response.headers` lesbar, und `Link` wertet der
Browser sogar selbst aus.

Der Riegel laesst solche Koepfe WEG statt sie umzuschreiben. Ein Kopf ohne
festgelegte Bedeutung traegt keine Struktur, aus der sich eine Uebersetzung
ableiten liesse; eine erfundene waere schlimmer als der Verlust. Erkannt wird
das Ziel in drei Formen — volle Adresse, Autoritaet, nackter Dienstname —,
schreibungsunabhaengig, und der Kopfname zaehlt mit. `location` bleibt
unberuehrt: er hat eine Bedeutung und seine eigene Uebersetzung. Fremde
Adressen bleiben stehen; ein leckendes `Set-Cookie` faellt einzeln weg, ohne
die uebrigen zusammenzufalten.

Gemessen, nicht behauptet:

* Unit: sieben neue Faelle in `apps/web/test/proxy-durchreichen.test.ts`. Vor
  dem Riegel 5 rot / 19 gruen, danach 24/24. Die zwei Erhaltungsfaelle
  (harmloser Kopf, externe URL) waren schon vorher gruen — sie sichern, dass
  der Riegel nicht pauschal alles wegwirft.
* Gegenmutation ausgefuehrt: den Riegel entfernt -> genau dieselben 5 Faelle
  wieder rot; zurueckgenommen, `shasum -a 256` gleich (3690b3cf…).
* Container: `scripts/smoke-container.sh` Abschnitt 8c. Der Stub sendet den
  leckenden Kopf nachweislich (direkt aus dem Containernetz gelesen), hinter
  dem Proxy ist er fort, der harmlose `x-stub-marke` kommt durch.
  `[container-smoke] mode=local executed=26 passed=26 skipped=0`.
* Gegenmutation im Container ausgefuehrt: ohne den Riegel faellt 8c mit exit 1
  und zeigt die Koepfe woertlich —
  `x-upstream-url: http://easytree-stub-b:3001/private`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Der Riegel aus 18f5004 schloss das echte Leck, war aber zu breit gefasst. Er
verglich Teilstrings gegen drei Formen — volle Adresse, Autoritaet UND nackter
Dienstname — und zwar ueber Kopfname und Wert zusammen. Fuer die vorgesehene
Containertopologie `http://api:3001` ist der Dienstname das Wort "api". Damit
fiel jeder harmlose Kopf, in dem diese drei Buchstaben irgendwo vorkommen:

  X-Api-Version: 1
  X-Documentation: https://api.example.org/public
  Set-Cookie: api_session=abc123; Path=/; HttpOnly

Das ist kein Schutz, sondern ein stiller Ausfall — und es verletzt die Zusage,
dass legitime Koepfe unveraendert bleiben.

Gesucht wird jetzt die ADRESSE. Drei Formen, jede mit einer Grenze:

  1. eine echte URL im WERT, deren Origin die des Ziels ist — gefunden mit
     `new URL()` statt mit einem Textvergleich, deshalb ist
     `https://api.example.org/public` kein Treffer;
  2. die Autoritaet ohne Schema (`api:3001`), nur bei ausgewiesenem Port und
     nur an einer Zeichengrenze — `capitalized-api` und `api:30011` nicht;
  3. der Wert BESTEHT aus dem Host — `X-Upstream-Host: api` bleibt gedeckt,
     als Teilstring wird der nackte Hostname nicht mehr gesucht.

Der Kopf-NAME wird gar nicht mehr geprueft: ein Name traegt keine Adresse. Fuer
`set-cookie` kommt das `Domain`-Attribut hinzu, strukturell ausgelesen und
verglichen statt im Text gesucht; das erste Segment ist das Name/Wert-Paar und
damit kein Attribut. `location` ist unveraendert.

Gemessen, nicht behauptet:

* Sieben neue Faelle mit dem Ziel woertlich auf `http://api:3001` — mit
  `api-a.invalid` waere der Fehler unsichtbar geblieben. Vorher
  `7 failed | 25 passed (32)`, danach 32/32. Alle bisherigen
  Leck-Nachweise bleiben gruen, keiner wurde entschaerft.
* Gegenmutation ausgefuehrt: die ueberbreite Fassung woertlich
  wiederhergestellt (nackter Hostname als Teilstring, Kopfname im Vergleich,
  Cookie ueber denselben Weg) -> dieselben 7 Faelle wieder rot;
  zurueckgenommen, `cmp` leer, `shasum -a 256` = f38874cb…
* Container: der Stub sendet jetzt neben dem leckenden Kopf auch
  `x-api-version`, eine fremde Doku-URL und zwei Cookies. Beide Richtungen
  belegt, beide Eingangsbremsen scharf.
  `[container-smoke] mode=local executed=27 passed=27 skipped=0`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@DYAI2025
DYAI2025 merged commit 3ce222a into master Aug 22, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants