diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..4c65651d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,47 @@ +# Buildkontext fuer die OCI-Images (EYT-126). +# +# Der Kontext ist bewusst das REPOSITORY-WURZELVERZEICHNIS, nicht `apps/api` +# bzw. `apps/web`: ein pnpm-Workspace laesst sich nicht aus einem Unterordner +# heraus mit gepinntem Lockfile installieren. Alles, was hier nicht +# ausgeschlossen wird, landet im Build — deshalb ist die Liste eng. + +# Abhaengigkeiten und Buildausgaben werden IM Image erzeugt. Wuerden sie aus +# dem Arbeitsbaum mitkommen, waere der Build nicht mehr reproduzierbar: eine +# lokal gebaute `dist/` haette Vorrang vor der im Image gebauten. +node_modules +**/node_modules +**/dist +**/dist-harness +**/.next +**/.turbo +**/coverage +**/test-results +**/playwright-report + +# Cloudflare-Buildartefakte (EYT-142). Generierter Fremdcode, kein Quelltext. +**/.open-next +**/.wrangler +**/.wrangler-dry + +# Historie und CI gehoeren nicht ins Image. Der Commit wird als Build-Argument +# `GIT_SHA` hineingereicht und als OCI-Label gesetzt — sichtbar, ohne `.git` +# mitzuschleppen. +.git +.github + +# Grosse Nicht-Quellen. +docs +penpot +_sprint2-transfer +supabase +*.log +*.pdf + +# Geheimnisse erreichen den Buildkontext gar nicht erst. `.env.example` traegt +# ausschliesslich Platzhalter und darf bleiben, damit ein Diagnoselauf im +# Container die erwarteten Variablennamen nachschlagen kann. +.env +.env.* +!.env.example +*.pem +*.key diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e6b3c712..b3147b46 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,12 +55,10 @@ jobs: build-web: runs-on: ubuntu-latest - # Das Proxyziel wird AUSDRUECKLICH gesetzt, nicht dem Default ueberlassen. - # Zuvor behauptete der Code "Produktion und CI setzen den Wert explizit" — - # und beide taten es nicht. Ein Default, auf den sich niemand berufen darf, - # aber alle sich verlassen, ist die schlechteste Sorte Vorgabe. - env: - EASYTREE_API_PROXY_TARGET: http://127.0.0.1:3001 + # Kein EASYTREE_API_PROXY_TARGET mehr: seit EYT-126 loest der Same-Origin- + # Proxy das Ziel zur LAUFZEIT auf (Route Handler statt `rewrites()`). Dass + # die Variable hier fehlt, ist die dauerhafte Gegenprobe — brauchte der + # Build sie noch, waere dieser Pflichtjob rot. steps: - uses: actions/checkout@v4 - uses: ./.github/actions/setup-pnpm @@ -117,6 +115,10 @@ jobs: # Hier laeuft KEINE API: der Smoke prueft den gehandelten Ausfall und die # Shell, nicht den Proxy. Er ist ausdruecklich KEIN Proxy-Nachweis — der # kommt aus dem integrierten Harness. + # + # Auf Job-Ebene, damit `next start` sie ERBT: seit EYT-126 ist sie eine + # LAUFZEIT-Variable. Beim Bauen wird sie nicht mehr gelesen; der Bauschritt + # unten liefe auch ohne sie durch. env: EASYTREE_API_PROXY_TARGET: http://127.0.0.1:3001 steps: @@ -629,6 +631,49 @@ jobs: SUPABASE_URL: "http://127.0.0.1:54321" SUPABASE_ANON_KEY: "ci-local-anon-key" run: bash scripts/smoke-worker.sh + # EYT-126 — Container-Smoke: dieselbe Codebasis als OCI-Workloads. + # + # Er steht am ENDE von db-gates und bekommt bewusst KEINEN eigenen Job. + # Ein neuer Job waere so lange kein Pflichtcheck, bis jemand mit + # Repository-Adminrechten das Ruleset neu anwendet, und beide Kopien der + # Pflichtcheck-Liste (setup-/verify-branch-protection.sh) muessten + # mitgezogen werden. Dieselbe Begruendung steht schon bei den + # Cloudflare-Schritten in build-web und build-api. + # + # Und nur HIER gibt es, was der Smoke sonst nirgends bekommt: eine echte + # PostgreSQL-Instanz mit der Rolle easytree_app. Ohne sie waere `/ready` + # kein Nachweis, sondern eine Zusicherung ueber sich selbst. + - name: Head und CI-SHA muessen uebereinstimmen (EYT-126) + shell: bash + run: | + set -Eeuo pipefail + ist="$(git rev-parse HEAD)" + echo "checkout=${ist} github.sha=${{ github.sha }}" + test "${ist}" = "${{ github.sha }}" + - name: Container-Smoke — Web und API als OCI-Workloads (EYT-126) + shell: bash + env: + EASYTREE_CONTAINER_SMOKE: required + GIT_SHA: ${{ github.sha }} + # Aus Sicht des CONTAINERS, nicht des Runners: host.docker.internal + # zeigt ueber --add-host=...:host-gateway auf den Hostrechner, auf + # dem der Supabase-Stack seine Ports veroeffentlicht. + EASYTREE_SMOKE_DATABASE_URL: "postgresql://easytree_app:ci-local-app-password@host.docker.internal:54322/postgres" + EASYTREE_SMOKE_SUPABASE_URL: "http://host.docker.internal:54321" + EASYTREE_SMOKE_ANON_KEY: "ci-local-anon-key" + run: | + set -Eeuo pipefail + bash scripts/smoke-container.sh 2>&1 | tee /tmp/container-smoke.log + - name: Container-Gate-Zeile pruefen und berichten (EYT-126) + if: always() + shell: bash + run: | + set -Eeuo pipefail + # Die Zeile, nicht der gruene Haken: `skipped=0` ist die eigentliche + # Aussage. Ein Smoke, der sich mangels Docker weggeduckt hat, waere + # sonst nicht von einem bestandenen zu unterscheiden. + grep -E '\[container-smoke\] mode=required executed=[0-9]+ passed=[0-9]+ skipped=0' /tmp/container-smoke.log + grep -F '[container-smoke]' /tmp/container-smoke.log >> "$GITHUB_STEP_SUMMARY" # Integrierter Read-Through-Nachweis (EYT-50): Browser -> Same-Origin-Rewrite # -> NestJS -> TenantQueryRunner -> RLS -> PostgreSQL. @@ -699,10 +744,14 @@ jobs: echo "EASYTREE_JOURNEY_ADMIN_DB_URL=postgresql://postgres:postgres@127.0.0.1:54322/postgres" } >> "$GITHUB_ENV" echo "Supabase-API unter ${API_URL} uebernommen (Anon-Key maskiert)." - - name: Bauen — ECHTE API und Web mit Rewrite auf ihren Port + # Das Proxyziel steht NICHT mehr hier: seit EYT-126 setzt es + # apps/web/e2e/auth-journey/config.ts in der webServer-Umgebung, also zur + # Laufzeit. Genau dieser Job beweist damit zugleich, dass Login, POST- + # Koerper und HttpOnly-Cookies durch die neue Durchreiche gehen. + - name: Bauen — ECHTE API und Web (das Proxyziel kommt erst beim Start) run: | pnpm --filter @easytree/api... build - EASYTREE_API_PROXY_TARGET="http://127.0.0.1:3101" pnpm --filter @easytree/web... build + pnpm --filter @easytree/web... build - name: Chromium installieren run: pnpm --filter @easytree/web exec playwright install --with-deps chromium - name: Reise fahren diff --git a/CLAUDE.md b/CLAUDE.md index ea66c2e3..264648e3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -158,15 +158,17 @@ happened. Two further traps in the same family: the cache is shared across git w "cache hit" can be a replay from a worktree where the task never ran at all; and a `vitest` path filter that matches nothing exits 0 without having checked anything. -`pnpm build` at the repository root **fails today, and that is pre-existing, not a defect of -your branch**: `turbo.json` declares no `env` for the `build` task, so Turbo's strict env mode -strips `EASYTREE_API_PROXY_TARGET`, and `apps/web` refuses to build in production without it -(deliberately — see `lib/api-proxy-target.ts`). Setting the variable does **not** help through -Turbo. CI never hits this because it builds through pnpm, not Turbo — measured 14.08.2026, both -of these are green locally: +`pnpm build` at the repository root **used to fail and no longer does** (EYT-126). The old +reason was real: `apps/web` resolved `EASYTREE_API_PROXY_TARGET` inside `next.config.ts` +`rewrites()` at build time, Turbo's strict env mode stripped the variable, and the build +refused. Since the proxy moved into Route Handlers the build does not read that variable at +all. Measured 22.08.2026 with the variable explicitly unset: +`env -u EASYTREE_API_PROXY_TARGET pnpm build` → exit 0, `Tasks: 6 successful, 6 total`, +`Cached: 0 cached, 6 total` (so it really ran, it was not a replay). These two are green as +well, and neither needs the variable any more: ```bash -EASYTREE_API_PROXY_TARGET=http://127.0.0.1:3001 pnpm --filter @easytree/web... build +pnpm --filter @easytree/web... build pnpm --filter @easytree/api... build ``` @@ -341,14 +343,28 @@ guard that fails if the Supabase JS SDK name appears anywhere under `apps/web` string is assembled from parts on purpose, so don't "fix" it by inlining the literal. **There is no `NEXT_PUBLIC_API_URL` any more** (removed in EYT-50) — do not reintroduce it. -The browser calls **relative** paths and `providers.tsx` passes the empty origin; `next.config.ts` -rewrites `/api/:path*`, `/health` and `/ready` server-side to `EASYTREE_API_PROXY_TARGET`. Two -reasons, both deliberate: one visible origin means the API needs no CORS and no extra public -surface, and a `NEXT_PUBLIC_*` value would be baked into the browser bundle at build time. -`lib/api-proxy-target.ts` validates that target strictly (absolute http/https, no credentials, -no query/fragment, no trailing slash) and has **no default in production** — the build fails -there instead of silently proxying to localhost, which would look like an empty week in the -browser rather than an error. Each gateway's URL is assembled in exactly one place — its own +The browser calls **relative** paths and `providers.tsx` passes the empty origin; the Route +Handlers under `apps/web/app/api/[[...pfad]]`, `app/health` and `app/ready` forward them +server-side to `EASYTREE_API_PROXY_TARGET`, read fresh on **every request** (EYT-126 — +`next.config.ts` no longer carries a `rewrites()` block). Two reasons, both deliberate: one +visible origin means the API needs no CORS and no extra public surface, and a `NEXT_PUBLIC_*` +value would be baked into the browser bundle at build time. `lib/api-proxy-target.ts` validates +that target strictly (absolute http/https, no credentials, no query/fragment, no trailing slash) +and has **no default in production** — `apps/web/instrumentation.ts` therefore refuses the +server start instead of silently proxying to localhost, which would look like an empty week in +the browser rather than an error. The single pass-through is `lib/proxy-durchreichen.ts`; it is +also the one place that keeps the internal address off the wire — no `x-middleware-rewrite` +exists, an absolute `location` header pointing at the configured target is rewritten to a +relative path (an external redirect is left alone), **every other response header whose VALUE +names the configured target is dropped** rather than rewritten (`X-Upstream-Url`, +`Link: <…>; rel="self"`, `Content-Location`, a `Set-Cookie` carrying an internal `Domain` — +external addresses stay, multiple `set-cookie` stay multiple), and a connection failure becomes +a 502 that names no host. That match targets the **address, not the word**: header names are not +inspected at all, URLs in the value are parsed with `new URL()` and compared by origin, the +`host:port` authority matches only at a character boundary and only when the target declares a +port, and the bare hostname matches only as the **whole** value. A bare-substring match would +swallow `X-Api-Version: 1` for a target named `api` — the guard would then be a silent outage +rather than a protection. Each gateway's URL is assembled in exactly one place — its own factory — because the test that checks it must call the same function production does; an earlier version built its own gateway and would have stayed green whatever `providers.tsx` did. There are **three** such factories (`lib/planning-gateway-factory.ts`, @@ -401,7 +417,8 @@ proven in CI against a real Supavisor (`[tenant-pooling] …`). This layer sets defense-in-depth. Repositories see only the `TenantQuery` interface, never a driver type. **The read path is proven end to end, not assembled from unit tests** (EYT-50). Browser → -same-origin Next rewrite → NestJS → `TenantQueryRunner` → RLS → PostgreSQL, exercised by +same-origin Next Route Handler (`lib/proxy-durchreichen.ts`; it was a `rewrites()` entry until +EYT-126) → NestJS → `TenantQueryRunner` → RLS → PostgreSQL, exercised by `scripts/read-through-harness.sh` (CI job `read-through`) with `apps/web/e2e/read-through.spec.ts`. Only the subject resolver and the access policy are substituted (`apps/api/test/harness/server.ts`, built via `pnpm --filter @easytree/api run build:harness`); repository, runner, pool and controller @@ -539,6 +556,89 @@ gh run view --log --job \ | grep -oE '\[[a-z-]+\] mode=[a-z]+ executed=[0-9]+ passed=[0-9]+ skipped=[0-9]+' | sort -u ``` +## Deployment — Container first (Entscheidung 21.08.2026) + +Kanonisch ist Confluence **"EasyTree – Deployment-Entscheidung 21.08.2026: VPS + Coolify + +Docker"** (Seite 30998530): **primär eigener VPS mit Coolify und Docker/OCI, sekundär Railway, +Cloudflare Workers ist kein Zielruntime mehr.** Coolify ist Orchestrator, nicht Teil der +Facharchitektur. Die Cloudflare-Artefakte (`apps/*/wrangler.jsonc`, `apps/web/open-next.config.ts`, +die Schritte in `build-web`/`build-api`) bleiben vorerst stehen und werden erst nach belegter +Container-Parität entfernt — das ist EYT-149, nicht dieser Slice. + +Beide Workloads werden aus dem Wurzelverzeichnis gebaut (`apps/api/Dockerfile`, +`apps/web/Dockerfile`, Topologie in `docker-compose.yml`), mit `pnpm install --frozen-lockfile` +und `corepack`. Der Outbox-Worker benutzt **dasselbe** API-Image und überschreibt nur das +Kommando mit `node dist/worker.js`. Nur `web` veröffentlicht einen Port; die API hängt am +internen Netz. Beide Images laufen als `node`, nicht als root, und tragen den Commit als +OCI-Label `org.opencontainers.image.revision`. + +**`EASYTREE_API_PROXY_TARGET` ist LAUFZEIT-Konfiguration — gemessen 22.08.2026 auf Next 16.2.11 +(EYT-126).** Der Same-Origin-Proxy liegt seit EYT-126 in Route Handlern +(`apps/web/app/api/[[...pfad]]`, `app/health`, `app/ready`), nicht mehr in +`next.config.ts`-`rewrites()`. Gemessen im Container-Smoke: **ein** Web-Image wurde einmal +gebaut, sein Digest festgehalten, und derselbe Digest zweimal gegen verschiedene APIs gestartet — +`ziel=http://easytree-stub-a:3001 -> {"stub":"easytree-stub-a"}`, +`ziel=http://easytree-stub-b:3001 -> {"stub":"easytree-stub-b"}`, und +`vorher= nachher=` identisch. **Das Web-Image ist damit weder an ein Ziel noch +an einen Anbieter gebunden** — dieselbe Datei und derselbe Startpfad bauen es für Coolify wie +für Railway, und es gibt kein `--build-arg` mehr, das sich unterscheiden könnte. + +Eine früher hier stehende Messung (Build mit `http://buildtime-marker.invalid:9999`, Start mit +einem anderen Wert, HTTP 500 mit `ENOTFOUND`) **war korrekt** — sie galt dem `rewrites()`-Weg, +den es nicht mehr gibt. + +Fail-closed bleibt es an zwei Stellen: `apps/web/instrumentation.ts` prüft das Ziel beim +Serverstart, und danach beantwortet Next **jede** Route mit 500 — auch `/` und `/anmelden` +(gemessen 22.08.2026, Container und `next start`). Der Prozess hält dabei den Port; er bedient +aber keinen normalen Anwendungsverkehr, und der Compose-Healthcheck auf `/` schlägt fehl, der +Container gilt als `unhealthy`. **Behaupte nicht, der Container starte nicht.** +`lib/proxy-durchreichen.ts` prüft zusätzlich bei jeder Anfrage. + +**Zwei nicht wiederholenswerte Sackgassen, beide gemessen 21.08.2026:** eine Next-16-`proxy.ts` +(Node-Middleware) funktioniert zwar zur Laufzeit, sendet aber +`x-middleware-rewrite: ` an den Browser — der Kopf lässt sich nicht entfernen, +ohne die Weiterleitung abzuschalten —, und `@opennextjs/cloudflare` bricht mit `Node.js +middleware is not currently supported` ab, was den Pflichtjob `build-web` rot machen würde. +Edge-Middleware scheidet aus, weil dort `process.env` beim Bauen eingebacken wird. + +`scripts/smoke-container.sh` ist der Container-Smoke; er läuft am Ende von `db-gates` (kein +eigener Job — der wäre kein Pflichtcheck, bis jemand das Ruleset neu anwendet) und meldet +`[container-smoke] mode=required executed=… passed=… skipped=0`. Er prüft OCI-Label gegen den +Head, Nicht-root, `/health`, `/ready` mit echter Datenbank, den Weg Web→API über den +Dienstnamen, dass der Dienstname von aussen NICHT auflösbar ist, dass weder das ausgelieferte +HTML noch ein Client-Chunk noch ein Antwortkopf noch ein `location`-Kopf die interne Adresse +nennt, geheimnisfreie Protokolle, das Verweigern des Starts im Produktionsprofil ohne +`DATABASE_SSL_ROOT_CERT`, **ein Image gegen zwei Ziele bei unverändertem Digest**, das +Fail-closed-Verhalten bei fehlendem und bei ungültigem Proxyziel, und geordnetes Herunterfahren. +Der Antwortkopf-Teil ist dabei nicht auf `location` beschränkt: **jeder** übrige Kopf, dessen +**Wert** die interne Adresse nennt, fällt in `lib/proxy-durchreichen.ts` ersatzlos weg — +`X-Upstream-Url`, `Link: <…>; rel="self"`, `Content-Location`, ein `Set-Cookie` mit interner +`Domain`. Weggelassen und nicht umgeschrieben, weil ein Kopf ohne festgelegte Bedeutung keine +Struktur trägt, aus der sich eine Übersetzung ableiten ließe; fremde Adressen bleiben stehen, +mehrere `Set-Cookie` bleiben mehrere. **Gesucht wird die Adresse, nicht das Wort** — der +Kopfname wird gar nicht geprüft, eine URL im Wert wird mit `new URL()` gefunden und über ihre +Origin verglichen, die Autorität `host:port` nur an einer Zeichengrenze und nur bei +ausgewiesenem Port, der nackte Hostname nur als **ganzer** Wert. Ein Teilstringvergleich gegen +den nackten Dienstnamen wäre bei der vorgesehenen Topologie `http://api:3001` fatal: er +verschluckte `X-Api-Version: 1`, eine Doku-URL auf `api.example.org` und ein Cookie +`api_session` — kein Schutz, sondern ein stiller Ausfall. Die Stub-Container des Smokes senden +sowohl den leckenden als auch diese harmlosen Köpfe nachweislich; sonst wäre weder die +Abwesenheit der einen noch die Anwesenheit der anderen ein Nachweis. +Lokal gemessen 22.08.2026: `[container-smoke] mode=local executed=27 passed=27 skipped=0`. +Runbook: +[`docs/runbooks/staging-deploy.md`](docs/runbooks/staging-deploy.md). + +**Ein Deploy ist trotzdem gesperrt.** `BLOCKER_ENVIRONMENT_SEPARATION`: es existiert keine +EasyTree-Datengrenze, deren `project_ref` von `inypnrvpawvhgiyagxbd` verschieden ist +(nachgemessen 21.08.2026 über `list_projects` und `list_branches`). Der **kostenlose** Weg dorthin +ist zusätzlich versperrt: ein `create_project`-Versuch für `easytree-staging` (Kosten gemessen +0 $/Monat) wurde mit einem Quota-Fehler abgelehnt — die Free-Projekt-Quota zählt **pro Nutzer** +über alle Organisationen hinweg, in denen er Owner oder Admin ist, und `DYAI2025` hat sie mit zwei +aktiven Free-Projekten ausgeschöpft; das zweite ist über diesen Zugang nicht sichtbar. Ein +pausiertes Projekt zählt laut Supabase-Doku nicht mit, das pausierte „Bazodiac" zu löschen hilft +also nicht. Siehe +[`docs/plans/2026-08-20-sprint-6-staging-blocker.md`](docs/plans/2026-08-20-sprint-6-staging-blocker.md). + ## Deployment (Railway) — measured 01.08.2026 - The API runs as Railway service `EasyTree` (project `EasyTree`, environment `production`), diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile new file mode 100644 index 00000000..bb70254d --- /dev/null +++ b/apps/api/Dockerfile @@ -0,0 +1,103 @@ +# syntax=docker/dockerfile:1.7 +# +# easyTree API (NestJS) als OCI-Workload — EYT-126. +# +# ## Was dieses Image ist und was nicht +# +# Es ist eine Verpackung des BESTEHENDEN Node-Startpfads `node dist/main.js`. +# Es enthaelt keine providerspezifische Logik: derselbe Startbefehl laeuft auf +# Coolify/VPS, auf Railway und lokal. Coolify ist Orchestrator, nicht Teil der +# Facharchitektur (ADR-001, Confluence 30998530). +# +# Der zweite Einstiegspunkt (`dist/worker.js`, Outbox, kein Port) steckt im +# GLEICHEN Image — ein Workload ueberschreibt dafuer nur das Kommando. Zwei +# Images fuer einen Modulgraphen waeren zwei Stellen, die auseinanderlaufen +# koennen. +# +# ## Reproduzierbarkeit +# +# `pnpm install --frozen-lockfile` gegen das eingecheckte `pnpm-lock.yaml`. +# Schlaegt der Lockfile-Abgleich fehl, bricht der Build ab statt aufzuloesen — +# das ist der Unterschied zwischen "reproduzierbar" und "hat heute funktioniert". +# +# ## Was NICHT im Image steckt +# +# Keine Secrets, keine `.env`, kein Zertifikat. `DATABASE_URL`, `SUPABASE_URL`, +# `SUPABASE_ANON_KEY` und `DATABASE_SSL_ROOT_CERT` kommen ausschliesslich zur +# Laufzeit aus der Plattform. `packages/config` validiert sie beim Bootstrap +# streng (`z.strictObject` je NODE_ENV) — eine fehlende Pflichtvariable ist ein +# lauter Startfehler, kein stiller Default. + +ARG NODE_IMAGE=node:22.23.1-bookworm-slim + +# --------------------------------------------------------------------------- +FROM ${NODE_IMAGE} AS base +ENV PNPM_HOME=/pnpm +ENV PATH=$PNPM_HOME:$PATH +# corepack liest `packageManager` aus der Wurzel-package.json (pnpm@10.28.0). +# Damit ist die Paketmanager-Version genauso gepinnt wie die Abhaengigkeiten. +RUN corepack enable +WORKDIR /repo + +# --------------------------------------------------------------------------- +# Abhaengigkeiten zuerst, Quelltext danach: eine Quelltextaenderung soll nicht +# jeden Install ungueltig machen. +FROM base AS deps +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +# ALLE Workspace-Manifeste, nicht nur die gefilterten: `--frozen-lockfile` +# vergleicht die Importer des Lockfiles mit den vorhandenen Paketen. Fehlt +# eines, meldet pnpm ERR_PNPM_OUTDATED_LOCKFILE statt zu installieren. +COPY apps/api/package.json apps/api/package.json +COPY apps/web/package.json apps/web/package.json +COPY packages/config/package.json packages/config/package.json +COPY packages/contracts/package.json packages/contracts/package.json +COPY packages/domain/package.json packages/domain/package.json +COPY packages/ui/package.json packages/ui/package.json +RUN pnpm install --frozen-lockfile --filter @easytree/api... + +# --------------------------------------------------------------------------- +FROM deps AS build +COPY tsconfig.base.json ./ +COPY packages packages +COPY apps/api apps/api +RUN pnpm --filter @easytree/api... build +# `pnpm deploy` erzeugt ein in sich geschlossenes Verzeichnis: die +# Workspace-Pakete werden als echte Ordner nach `node_modules/@easytree/*` +# kopiert statt verlinkt. `--legacy` ist seit pnpm 10 noetig, solange +# `inject-workspace-packages` nicht gesetzt ist (ERR_PNPM_DEPLOY_NONINJECTED_WORKSPACE). +RUN pnpm --filter @easytree/api --prod --legacy deploy /prod/api + +# --------------------------------------------------------------------------- +FROM ${NODE_IMAGE} AS runner +# Kein Vorgabewert, und der Wert 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 (EYT-126). +ARG GIT_SHA +RUN test -n "${GIT_SHA}" || { \ + echo "GIT_SHA fehlt. Baue mit --build-arg GIT_SHA=\$(git rev-parse HEAD)." >&2; \ + exit 1; \ + } +LABEL org.opencontainers.image.title="easytree-api" \ + org.opencontainers.image.description="easyTree API (NestJS) — EYT-126" \ + org.opencontainers.image.source="https://github.com/DYAI2025/EasyTree" \ + org.opencontainers.image.revision="${GIT_SHA}" + +# Voreinstellung ist das SCHARFE Profil. Wer eine Umgebung mit lockereren +# Regeln braucht (z.B. der Container-Smoke gegen einen lokalen Stack ohne +# TLS-Wurzelzertifikat), setzt NODE_ENV zur Laufzeit ausdruecklich um. Der +# umgekehrte Default waere die gefaehrliche Richtung. +ENV NODE_ENV=production +ENV API_PORT=3001 +WORKDIR /app + +# Nur drei Dinge aus dem Deploy-Ergebnis. `src/`, `test/` und die +# tsconfig-Dateien wandern bewusst NICHT mit: sie werden zur Laufzeit nicht +# gebraucht und vergroessern nur die Angriffsflaeche. +COPY --from=build --chown=node:node /prod/api/package.json ./package.json +COPY --from=build --chown=node:node /prod/api/node_modules ./node_modules +COPY --from=build --chown=node:node /prod/api/dist ./dist + +USER node +EXPOSE 3001 +# Der Worker nutzt dasselbe Image: `command: ["node", "dist/worker.js"]`. +CMD ["node", "dist/main.js"] diff --git a/apps/api/test/architecture/deploy-authority-rules.ts b/apps/api/test/architecture/deploy-authority-rules.ts index 54b3bae9..3c111dd1 100644 --- a/apps/api/test/architecture/deploy-authority-rules.ts +++ b/apps/api/test/architecture/deploy-authority-rules.ts @@ -29,7 +29,7 @@ import { readFileSync } from "node:fs"; import { parse as parseJsonc, printParseErrorCode, type ParseError } from "jsonc-parser"; -/** Werkzeuge, die Schema veraendern. Keines gehoert in einen Cloudflare-Build. */ +/** Werkzeuge, die Schema veraendern. Keines gehoert in einen Deploybefehl. */ export const SCHEMA_WERKZEUGE: readonly string[] = [ "supabase db push", "supabase migration", @@ -117,3 +117,48 @@ export function pruefeDeployAutoritaet(dateien: readonly string[]): DeployAuthor } return funde; } + +/** + * Dieselbe Regel fuer die Containerwelt (EYT-126). + * + * Coolify und Railway starten Images; sie duerfen dabei so wenig eine zweite + * Migrationsautoritaet werden wie ein Cloudflare-Build. Der Unterschied ist + * nur das Format: `Dockerfile` und `docker-compose.yml` sind kein JSONC, + * ihre Befehle stehen als Klartext in `RUN`, `CMD`, `ENTRYPOINT` oder + * `command:`. Deshalb wird hier zeilenweise gelesen statt geparst. + * + * Kommentarzeilen werden uebersprungen. Sonst meldete der Waechter die + * Begruendung, warum ein Werkzeug NICHT vorkommen darf, als Verstoss — und + * ein Waechter, den man nur durch Verschweigen zufriedenstellt, erzieht zum + * Verschweigen. + * + * Fail-closed genauso: leere Dateiliste oder eine unlesbare Datei sind rot. + */ +export function pruefeContainerDeployAutoritaet(dateien: readonly string[]): DeployAuthorityFund[] { + if (dateien.length === 0) { + throw new Error( + "Keine Containerkonfiguration gefunden. Der Waechter gegen eine zweite Migrationsautoritaet kann nichts pruefen und ist deshalb fail-closed rot.", + ); + } + const funde: DeployAuthorityFund[] = []; + for (const datei of dateien) { + let inhalt: string; + try { + inhalt = readFileSync(datei, "utf8"); + } catch (fehler) { + throw new Error(`${datei} ist nicht lesbar (${String(fehler)}). Fail-closed.`, { + cause: fehler, + }); + } + const zeilen = inhalt.split("\n"); + zeilen.forEach((zeile, index) => { + if (zeile.trimStart().startsWith("#")) return; + for (const werkzeug of SCHEMA_WERKZEUGE) { + if (zeile.includes(werkzeug)) { + funde.push({ datei, feld: `Zeile ${index + 1}`, werkzeug, wert: zeile.trim() }); + } + } + }); + } + return funde; +} diff --git a/apps/api/test/architecture/secret-surface-rules.ts b/apps/api/test/architecture/secret-surface-rules.ts index 1c80318d..e8730e36 100644 --- a/apps/api/test/architecture/secret-surface-rules.ts +++ b/apps/api/test/architecture/secret-surface-rules.ts @@ -726,9 +726,22 @@ export const ERLAUBTE_UMGEBUNGSLESER: ReadonlyArray<{ datei: string; grund: stri { datei: "apps/web/next.config.ts", grund: - "Buildzeit- und Serverkonfiguration des Same-Origin-Rewrites " + - "(EASYTREE_API_PROXY_TARGET). Laeuft nie im Browser; der Wert wird von " + - "lib/api-proxy-target.ts streng geprueft.", + "Bauzeitkonfiguration der Ausgabeform (EASYTREE_NEXT_OUTPUT). Laeuft nie " + + "im Browser; der Wert wird von lib/next-output.ts streng geprueft und " + + "faellt bei einem Tippfehler, statt still auf die Standardausgabe " + + "zurueckzufallen. Bis EYT-126 stand hier zusaetzlich das Proxyziel — das " + + "liest diese Datei nicht mehr, seit der Same-Origin-Proxy in Route " + + "Handlern liegt.", + }, + { + datei: "apps/web/lib/proxy-durchreichen.ts", + grund: + "Die EINE Laufzeitstelle des Same-Origin-Proxys (EASYTREE_API_PROXY_TARGET). " + + "Sie laeuft nur im Serverprozess, nie im Browser, und reicht den Wert " + + "sofort an das strikte normalizeProxyTarget weiter. Der Zugriff MUSS hier " + + "und nicht beim Modulladen passieren — sonst waere das Ziel wieder an den " + + "Build gebunden (EYT-126). instrumentation.ts liest die Umgebung deshalb " + + "nicht selbst, sondern ruft aktuellesProxyziel() auf.", }, ]; diff --git a/apps/api/test/container-bindung.test.ts b/apps/api/test/container-bindung.test.ts new file mode 100644 index 00000000..73a19e8e --- /dev/null +++ b/apps/api/test/container-bindung.test.ts @@ -0,0 +1,99 @@ +/** + * Die Containerbindung darf nicht stillschweigend wieder fail-open werden + * (EYT-126). + * + * Zwei Regressionen sind hier schon passiert und beide sahen harmlos aus: + * `GIT_SHA: ${GIT_SHA:-unknown}` und + * `EASYTREE_API_PROXY_TARGET: ${EASYTREE_API_PROXY_TARGET:-http://api:3001}`. + * Ein Vorgabewert, auf den sich niemand berufen darf, aber alle sich verlassen, + * ist die schlechteste Sorte Vorgabe. + * + * Die Handproben aus dem Slice (`docker compose config` ohne die Variablen, + * `docker build` ohne `--build-arg`) belegen dasselbe schaerfer, laufen aber + * nie wieder, wenn sie niemand wiederholt. Dieser Test ist die dauerhafte + * Fassung davon — er braucht kein Docker und laeuft in `unit-tests` mit. + */ +import { readFileSync } from "node:fs"; +import { join, resolve } from "node:path"; + +import { describe, expect, it } from "vitest"; + +const REPO = resolve(__dirname, "..", "..", ".."); + +function lies(datei: string): string { + const inhalt = readFileSync(join(REPO, datei), "utf8"); + // Eingangsbremse: eine leere oder verschobene Datei liesse jede Zusicherung + // unten vakuoes gruen werden. + expect(inhalt.length, `${datei} ist leer oder fehlt`).toBeGreaterThan(200); + return inhalt; +} + +describe("docker-compose.yml", () => { + const compose = lies("docker-compose.yml"); + + it("gibt GIT_SHA keinen Vorgabewert", () => { + expect(compose).not.toMatch(/\$\{GIT_SHA:-/); + expect(compose).toMatch(/\$\{GIT_SHA:\?/); + }); + + it("gibt dem Proxyziel keinen Vorgabewert", () => { + expect(compose).not.toMatch(/\$\{EASYTREE_API_PROXY_TARGET:-/); + expect(compose).toMatch(/\$\{EASYTREE_API_PROXY_TARGET:\?/); + }); + + /** + * Der Abschnitt wird eng geschnitten und nicht die ganze Datei durchsucht: + * der Kopfkommentar NENNT die Variable (er erklaert, warum sie keinen + * Vorgabewert hat), und eine dateiweite Suche waere daran haengengeblieben. + */ + const webStart = compose.indexOf("\n web:"); + const webBau = compose.slice(webStart, compose.indexOf("\n environment:", webStart)); + + it("schneidet ueberhaupt den Bauabschnitt des Web-Dienstes aus", () => { + expect(webStart).toBeGreaterThan(0); + expect(webBau).toContain("dockerfile: apps/web/Dockerfile"); + expect(webBau).toContain("GIT_SHA"); + }); + + it("uebergibt das Proxyziel als Laufzeit- und nicht als Bauvariable", () => { + expect(webBau).not.toContain("EASYTREE_API_PROXY_TARGET"); + }); + + it("setzt das Proxyziel in der Laufzeitumgebung des Web-Dienstes", () => { + const webUmgebung = compose.slice(compose.indexOf("\n environment:", webStart)); + expect(webUmgebung).toContain("EASYTREE_API_PROXY_TARGET"); + }); +}); + +describe("Dockerfiles", () => { + it.each(["apps/web/Dockerfile", "apps/api/Dockerfile"])( + "%s erzwingt GIT_SHA statt es vorzubelegen", + (datei) => { + const inhalt = lies(datei); + expect(inhalt).not.toMatch(/ARG\s+GIT_SHA\s*=/); + expect(inhalt).toMatch(/RUN\s+test\s+-n\s+"\$\{GIT_SHA\}"/); + }, + ); + + it("das Web-Image backt kein Proxyziel mehr ein", () => { + const inhalt = lies("apps/web/Dockerfile"); + expect(inhalt).not.toMatch(/^ARG\s+EASYTREE_API_PROXY_TARGET/m); + expect(inhalt).not.toMatch(/^ENV\s+EASYTREE_API_PROXY_TARGET/m); + }); +}); + +/** + * Der Bauzeitweg darf nicht durch die Hintertuer zurueckkommen: solange + * `next.config.ts` wieder ein `rewrites()` traegt, waere das Ziel erneut im + * `routes-manifest.json` eingebacken — und der Container-Smoke faende es erst + * viel spaeter. + */ +describe("apps/web/next.config.ts", () => { + it("loest das Proxyziel nicht mehr beim Bauen auf", () => { + const inhalt = lies("apps/web/next.config.ts"); + expect(inhalt).not.toMatch(/^\s*(async\s+)?rewrites\s*\(/m); + expect(inhalt).not.toContain("resolveBuildProxyTarget"); + // Eingangsbremse: eine umbenannte Datei liesse beide Zusicherungen leer. + expect(inhalt).toContain("outputFileTracingRoot"); + }); +}); diff --git a/apps/api/test/deploy-authority.test.ts b/apps/api/test/deploy-authority.test.ts index f764515a..256a555d 100644 --- a/apps/api/test/deploy-authority.test.ts +++ b/apps/api/test/deploy-authority.test.ts @@ -4,7 +4,11 @@ import { join, resolve } from "node:path"; import { describe, expect, it } from "vitest"; -import { pruefeDeployAutoritaet, SCHEMA_WERKZEUGE } from "./architecture/deploy-authority-rules"; +import { + pruefeContainerDeployAutoritaet, + pruefeDeployAutoritaet, + SCHEMA_WERKZEUGE, +} from "./architecture/deploy-authority-rules"; /** * EYT-142 — Cloudflare deployt Web und API, NIEMALS das Schema. @@ -21,6 +25,15 @@ const WRANGLER_DATEIEN = [ join(REPO, "apps/api/wrangler.jsonc"), join(REPO, "apps/web/wrangler.jsonc"), ]; +/** + * EYT-126 — dieselbe Grenze fuer den Containerpfad. Coolify ist Orchestrator, + * nicht Schemaeigentuemer; Railway genauso wenig. + */ +const CONTAINER_DATEIEN = [ + join(REPO, "apps/api/Dockerfile"), + join(REPO, "apps/web/Dockerfile"), + join(REPO, "docker-compose.yml"), +]; describe("keine zweite Migrationsautoritaet (EYT-142)", () => { it("liest ueberhaupt Konfigurationen — sonst prueft dieser Test nichts", () => { @@ -141,3 +154,53 @@ describe("keine zweite Migrationsautoritaet (EYT-142)", () => { } }); }); + +describe("keine zweite Migrationsautoritaet im Containerpfad (EYT-126)", () => { + it("liest ueberhaupt Konfigurationen — sonst prueft dieser Test nichts", () => { + expect(CONTAINER_DATEIEN.length).toBe(3); + expect(() => pruefeContainerDeployAutoritaet(CONTAINER_DATEIEN)).not.toThrow(); + }); + + it("kein Dockerfile und kein Compose-Dienst ruft ein Schemawerkzeug auf", () => { + expect(pruefeContainerDeployAutoritaet(CONTAINER_DATEIEN)).toEqual([]); + }); + + it("ist fail-closed: ohne Konfiguration wird die Regel rot", () => { + expect(() => pruefeContainerDeployAutoritaet([])).toThrow(/fail-closed/); + }); + + it("ist fail-closed: eine unlesbare Datei wird rot", () => { + expect(() => pruefeContainerDeployAutoritaet([join(REPO, "gibt-es-nicht/Dockerfile")])).toThrow( + /Fail-closed/, + ); + }); + + describe("Rot-Fall — die Regel feuert wirklich", () => { + for (const werkzeug of SCHEMA_WERKZEUGE) { + it(`meldet "${werkzeug}" in einem RUN-Befehl`, () => { + const verzeichnis = mkdtempSync(join(tmpdir(), "eyt126-container-")); + const datei = join(verzeichnis, "Dockerfile"); + try { + writeFileSync(datei, `FROM node:22-slim\nRUN ${werkzeug} --db-url $DATABASE_URL\n`); + const funde = pruefeContainerDeployAutoritaet([datei]); + expect(funde).toHaveLength(1); + expect(funde[0]?.werkzeug).toBe(werkzeug); + expect(funde[0]?.feld).toBe("Zeile 2"); + } finally { + rmSync(verzeichnis, { recursive: true, force: true }); + } + }); + } + + it("uebersieht ein Werkzeug in einer Kommentarzeile — sonst erzieht der Waechter zum Verschweigen", () => { + const verzeichnis = mkdtempSync(join(tmpdir(), "eyt126-kommentar-")); + const datei = join(verzeichnis, "Dockerfile"); + try { + writeFileSync(datei, "FROM node:22-slim\n# hier steht bewusst KEIN psql\n"); + expect(pruefeContainerDeployAutoritaet([datei])).toEqual([]); + } finally { + rmSync(verzeichnis, { recursive: true, force: true }); + } + }); + }); +}); diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile new file mode 100644 index 00000000..bac90592 --- /dev/null +++ b/apps/web/Dockerfile @@ -0,0 +1,101 @@ +# syntax=docker/dockerfile:1.7 +# +# easyTree Web-Shell (Next.js App Router) als OCI-Workload — EYT-126. +# +# ## Das Proxyziel ist LAUFZEIT-Konfiguration. Gemessen, nicht vermutet. +# +# Der Same-Origin-Proxy liegt in Route Handlern (`app/api/[[...pfad]]`, +# `app/health`, `app/ready`), nicht mehr in `next.config.ts`-`rewrites()`. Route +# Handler sind dynamischer Servercode: sie lesen `EASYTREE_API_PROXY_TARGET` bei +# JEDER Anfrage neu. +# +# Nachgemessen am 21.08.2026 auf Next 16.2.11: EIN Standalone-Build, danach +# derselbe Server dreimal mit unterschiedlichem Ziel gestartet — die Antworten +# kamen von den drei verschiedenen Stubs, ohne Neubau dazwischen. Der +# Container-Smoke wiederholt den Nachweis gegen denselben IMAGE-DIGEST. +# +# Deshalb steht hier KEIN `ARG EASYTREE_API_PROXY_TARGET` mehr. Dieses Image ist +# an keinen Anbieter und an kein Ziel gebunden; dieselbe Datei und derselbe +# Startpfad liefern es fuer Coolify/VPS wie fuer Railway. +# +# ## Fail-closed +# +# `instrumentation.ts` prueft das Ziel beim Serverstart. Fehlt es oder ist es +# ungueltig, meldet Next "Failed to prepare server" und beantwortet JEDE Route +# mit 500 — auch `/`. Der Prozess haelt dabei den Port; er bedient aber keinen +# normalen Anwendungsverkehr, und der Compose-Healthcheck auf `/` schlaegt fehl. +# +# `GIT_SHA` hat bewusst KEINEN Vorgabewert und wird unten erzwungen: ein Image +# mit `revision=unknown` waere ein Deploy ohne Herkunft. +# +# ## Der Browser sieht diese Adresse nie +# +# Das Ziel ist ausdruecklich KEIN `NEXT_PUBLIC_*`. Es wird serverseitig +# aufgeloest; weder im Browserbuendel noch in einem Antwortkopf steht es — auch +# nicht in einem `location`-Kopf, den die Durchreiche in einen relativen Pfad +# uebersetzt. + +ARG NODE_IMAGE=node:22.23.1-bookworm-slim + +# --------------------------------------------------------------------------- +FROM ${NODE_IMAGE} AS base +ENV PNPM_HOME=/pnpm +ENV PATH=$PNPM_HOME:$PATH +RUN corepack enable +WORKDIR /repo + +# --------------------------------------------------------------------------- +FROM base AS deps +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY apps/api/package.json apps/api/package.json +COPY apps/web/package.json apps/web/package.json +COPY packages/config/package.json packages/config/package.json +COPY packages/contracts/package.json packages/contracts/package.json +COPY packages/domain/package.json packages/domain/package.json +COPY packages/ui/package.json packages/ui/package.json +RUN pnpm install --frozen-lockfile --filter @easytree/web... + +# --------------------------------------------------------------------------- +FROM deps AS build +# `standalone` legt unter `.next/standalone/` einen selbsttragenden Server samt +# der wirklich benutzten node_modules ab (~23 MB statt des ganzen Workspace). +# Der Schalter ist bewusst nicht fest verdrahtet — siehe lib/next-output.ts. +ENV EASYTREE_NEXT_OUTPUT=standalone +COPY tsconfig.base.json ./ +COPY packages packages +COPY apps/web apps/web +RUN pnpm --filter @easytree/web... build + +# --------------------------------------------------------------------------- +FROM ${NODE_IMAGE} AS runner +# Kein Vorgabewert, und der Wert 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 (EYT-126). +ARG GIT_SHA +RUN test -n "${GIT_SHA}" || { \ + echo "GIT_SHA fehlt. Baue mit --build-arg GIT_SHA=\$(git rev-parse HEAD)." >&2; \ + exit 1; \ + } +LABEL org.opencontainers.image.title="easytree-web" \ + org.opencontainers.image.description="easyTree Web-Shell (Next.js) — EYT-126" \ + org.opencontainers.image.source="https://github.com/DYAI2025/EasyTree" \ + org.opencontainers.image.revision="${GIT_SHA}" + +ENV NODE_ENV=production +ENV PORT=3000 +# Ohne HOSTNAME bindet der Standalone-Server auf localhost — im Container +# waere er dann von aussen nicht erreichbar, und der Fehler saehe aus wie ein +# Netzwerkproblem der Plattform. +ENV HOSTNAME=0.0.0.0 +WORKDIR /app + +# Drei Kopien, alle noetig: der Standalone-Baum (Server + node_modules), +# die statischen Buildartefakte und `public/`. Next kopiert die letzten beiden +# ausdruecklich NICHT selbst. +COPY --from=build --chown=node:node /repo/apps/web/.next/standalone ./ +COPY --from=build --chown=node:node /repo/apps/web/.next/static ./apps/web/.next/static +COPY --from=build --chown=node:node /repo/apps/web/public ./apps/web/public + +USER node +EXPOSE 3000 +CMD ["node", "apps/web/server.js"] diff --git a/apps/web/app/api/[[...pfad]]/route.ts b/apps/web/app/api/[[...pfad]]/route.ts new file mode 100644 index 00000000..454eec47 --- /dev/null +++ b/apps/web/app/api/[[...pfad]]/route.ts @@ -0,0 +1,17 @@ +/** + * Same-Origin-Durchreiche fuer alle API-Pfade (EYT-126). + * + * Optionaler Catch-all (`[[...pfad]]`), damit auch `/api` selbst getroffen wird + * und nicht als 404 aus der Web-App zurueckkommt. + */ +import { durchreichen } from "../../../lib/proxy-durchreichen"; + +export const dynamic = "force-dynamic"; + +export const GET = durchreichen; +export const POST = durchreichen; +export const PUT = durchreichen; +export const PATCH = durchreichen; +export const DELETE = durchreichen; +export const OPTIONS = durchreichen; +export const HEAD = durchreichen; diff --git a/apps/web/app/health/route.ts b/apps/web/app/health/route.ts new file mode 100644 index 00000000..135472bf --- /dev/null +++ b/apps/web/app/health/route.ts @@ -0,0 +1,12 @@ +/** + * `/health` gehoert der API, nicht der Web-Shell (EYT-50). + * + * Der bestehende ApiClient ruft `/health` relativ zu seiner Basis-URL, und die + * ist die Web-Origin. Ohne diese Route liefe der Health-Check ins Leere. + */ +import { durchreichen } from "../../lib/proxy-durchreichen"; + +export const dynamic = "force-dynamic"; + +export const GET = durchreichen; +export const HEAD = durchreichen; diff --git a/apps/web/app/ready/route.ts b/apps/web/app/ready/route.ts new file mode 100644 index 00000000..e44deaf2 --- /dev/null +++ b/apps/web/app/ready/route.ts @@ -0,0 +1,9 @@ +/** + * `/ready` gehoert der API, nicht der Web-Shell (EYT-50). + */ +import { durchreichen } from "../../lib/proxy-durchreichen"; + +export const dynamic = "force-dynamic"; + +export const GET = durchreichen; +export const HEAD = durchreichen; diff --git a/apps/web/instrumentation.ts b/apps/web/instrumentation.ts new file mode 100644 index 00000000..5a33e682 --- /dev/null +++ b/apps/web/instrumentation.ts @@ -0,0 +1,25 @@ +/** + * Startsperre fuer das Proxyziel (EYT-126). + * + * `register()` laeuft EINMAL beim Hochfahren des Next-Servers, vor der ersten + * Anfrage. Wirft es, meldet Next "Failed to prepare server" und beantwortet + * danach JEDE Route mit 500 — auch `/` und `/anmelden`. Gemessen am 21.08.2026 + * auf Next 16.2.11 im Standalone-Server. + * + * Ehrliche Grenze: der Prozess bleibt am Leben und haelt den Port. Er bedient + * aber keinen normalen Anwendungsverkehr mehr, und der Compose-Healthcheck auf + * `/` schlaegt fehl, der Container gilt als `unhealthy`. Behaupte nicht, der + * Container starte nicht. + * + * Der Wert wird hier NICHT zwischengespeichert. Die Sperre beantwortet "ist die + * Umgebung brauchbar", nicht "welches Ziel gilt" — das entscheidet + * lib/proxy-durchreichen.ts bei jeder Anfrage neu. Aus demselben Grund liest + * diese Datei die Umgebung nicht selbst: es gibt genau EINE Laufzeitstelle, + * und die steht in der Positivliste von apps/api/test/secret-surface.test.ts. + */ +import { aktuellesProxyziel } from "./lib/proxy-durchreichen"; + +export function register(): void { + const ziel = aktuellesProxyziel(); + console.log(`[web] Proxyziel geprueft: ${ziel}`); +} diff --git a/apps/web/lib/api-proxy-target.ts b/apps/web/lib/api-proxy-target.ts index 82921ede..2e2c2dc3 100644 --- a/apps/web/lib/api-proxy-target.ts +++ b/apps/web/lib/api-proxy-target.ts @@ -8,17 +8,18 @@ * sichtbare Origin, und die API braucht kein CORS: keine Freigabe, keine * Wildcard, keine zusaetzliche oeffentliche Oberflaeche. * - * ## Build-Konfiguration, nicht Laufzeitschalter + * ## Laufzeitkonfiguration, seit EYT-126 * - * `rewrites()` laeuft beim Bauen und beim Start des Servers. Fuer diesen Slice - * ist die Variable damit BUILD- und DEPLOYMENTkonfiguration. Ob ein bereits - * gebautes Image sie zur Laufzeit noch umschalten kann, ist ungeprueft und - * wird hier nicht behauptet. + * Frueher stand hier, der Wert sei Bauzeitkonfiguration und ein fertiges Image + * lasse sich nicht mehr umschalten. Das galt fuer den Weg ueber + * `next.config.ts`-`rewrites()` und ist mit dem Wechsel auf Route Handler + * ueberholt: `lib/proxy-durchreichen.ts` liest die Variable bei JEDER Anfrage + * neu, `instrumentation.ts` prueft sie einmal beim Serverstart. Ein Image, + * zwei Ziele, kein Neubau — gemessen am 21.08.2026, Next 16.2.11. * - * Deshalb ist diese Variable bewusst NICHT `NEXT_PUBLIC_*`. Alles mit diesem - * Praefix wird in das Browserbuendel eingebacken und beim Build festgeschrieben - * — eine interne Adresse gehoert dort nicht hin, und ein Wert, der zur Bauzeit - * feststeht, laesst sich in einer anderen Umgebung nicht mehr aendern. + * Die Variable traegt weiterhin bewusst KEIN `NEXT_PUBLIC_`-Praefix: alles mit + * diesem Praefix landet im Browserbuendel, und eine interne Adresse gehoert + * dort nicht hin. */ /** @@ -26,18 +27,6 @@ */ export const DEFAULT_API_PROXY_TARGET = "http://127.0.0.1:3001"; -/** - * Sprint-6-Fallback fuer Cloudflare Builds. - * - * Cloudflare ist die primaere Web-Staging-Grenze. Solange der API-Worker die - * separaten Runtime-/TLS-/RLS-Gates aus EYT-142 noch nicht bestanden hat, - * bleibt der bereits existierende Railway-API-Service der ausdruecklich - * dokumentierte Fallback. Ein explizit gesetztes EASYTREE_API_PROXY_TARGET hat - * immer Vorrang und kann spaeter ohne Codeaenderung auf einen Cloudflare-API- - * Endpunkt zeigen. - */ -export const CLOUDFLARE_API_FALLBACK_TARGET = "https://easytree-production.up.railway.app"; - export class InvalidProxyTargetError extends Error {} /** @@ -91,33 +80,3 @@ export function normalizeProxyTarget( return `${url.origin}${url.pathname}`.replace(/\/+$/, ""); } - -export type ProxyBuildEnvironment = { - EASYTREE_API_PROXY_TARGET?: string; - NODE_ENV?: string; - WORKERS_CI?: string; - CF_PAGES?: string; -}; - -/** - * Loest das Build-Ziel auf, ohne die allgemeine Production-Fail-Closed-Regel - * aufzuweichen. - * - * Cloudflare Workers Builds setzt WORKERS_CI=1, Cloudflare Pages setzt - * CF_PAGES=1. Nur in diesen beiden expliziten Build-Kontexten darf bei - * fehlendem EASYTREE_API_PROXY_TARGET der dokumentierte Railway-Fallback - * verwendet werden. Ein expliziter Wert gewinnt immer. - */ -export function resolveBuildProxyTarget(env: ProxyBuildEnvironment): string { - const explicit = env.EASYTREE_API_PROXY_TARGET?.trim(); - if (explicit) { - return normalizeProxyTarget(explicit, env.NODE_ENV); - } - - const isCloudflareBuild = env.WORKERS_CI === "1" || env.CF_PAGES === "1"; - if (isCloudflareBuild) { - return normalizeProxyTarget(CLOUDFLARE_API_FALLBACK_TARGET, env.NODE_ENV); - } - - return normalizeProxyTarget(undefined, env.NODE_ENV); -} diff --git a/apps/web/lib/next-output.ts b/apps/web/lib/next-output.ts new file mode 100644 index 00000000..09e0cfdf --- /dev/null +++ b/apps/web/lib/next-output.ts @@ -0,0 +1,58 @@ +/** + * Ausgabeform des Next-Builds (EYT-126). + * + * ## Warum das ein Schalter ist und keine Konstante + * + * Fuer das OCI-Image wird `output: "standalone"` gebraucht: Next legt dann + * unter `.next/standalone/` einen selbsttragenden Server samt der wirklich + * benutzten `node_modules` ab. Ohne ihn muesste das Laufzeit-Image den + * gesamten Workspace inklusive Entwicklungsabhaengigkeiten tragen. + * + * Unbedingt setzen ginge trotzdem nicht: `@opennextjs/cloudflare` erwartet die + * Standardausgabe, und der Cloudflare-Bundlebau laeuft heute im Pflichtjob + * `build-web`. Cloudflare ist zwar kein Zielruntime mehr (Confluence 30998530), + * aber sein Abbau ist ein eigener Slice (EYT-149) — bis dahin darf dieser + * Slice ihn nicht nebenbei rot machen. + * + * Der Schalter ist Bauzeitkonfiguration und traegt schon deshalb KEIN + * `NEXT_PUBLIC_`-Praefix. Er ist damit seit EYT-126 das Gegenstueck zu + * `EASYTREE_API_PROXY_TARGET`, das frueher hier als gleichartig genannt wurde + * und inzwischen zur LAUFZEIT gelesen wird (siehe lib/proxy-durchreichen.ts): + * die Ausgabeform entscheidet, WAS gebaut wird, das Proxyziel nur, WOHIN ein + * fertiges Image zeigt. + * + * ## Fail-closed + * + * Ein unbekannter Wert ist ein Tippfehler, kein Wunsch. Er wirft, statt still + * auf die Standardausgabe zurueckzufallen — sonst baute das Image ohne + * `standalone`, und der Fehler faellt erst im Dockerfile auf, wo `server.js` + * fehlt. + */ + +export class InvalidNextOutputError extends Error {} + +/** Erlaubte Werte von `EASYTREE_NEXT_OUTPUT`. */ +export const NEXT_OUTPUT_STANDALONE = "standalone"; + +/** + * Absichtlich ein Index-Typ und kein Objekt mit genau einem optionalen Feld: + * TypeScript verweigert sonst `resolveNextOutput(process.env)` mit + * "Type 'ProcessEnv' has no properties in common" — die Schwachtyp-Erkennung + * greift, weil `ProcessEnv` die Variable nicht deklariert. Gemessen 21.08.2026. + */ +export type NextOutputEnvironment = { + readonly [name: string]: string | undefined; +}; + +/** + * Liefert das Fragment, das in die Next-Konfiguration gespreizt wird — + * `{}` fuer die Standardausgabe, `{ output: "standalone" }` fuer das Image. + */ +export function resolveNextOutput(env: NextOutputEnvironment): { output?: "standalone" } { + const roh = env.EASYTREE_NEXT_OUTPUT?.trim() ?? ""; + if (roh === "") return {}; + if (roh === NEXT_OUTPUT_STANDALONE) return { output: NEXT_OUTPUT_STANDALONE }; + throw new InvalidNextOutputError( + `EASYTREE_NEXT_OUTPUT kennt nur "" oder "${NEXT_OUTPUT_STANDALONE}", erhalten: "${roh}".`, + ); +} diff --git a/apps/web/lib/proxy-durchreichen.ts b/apps/web/lib/proxy-durchreichen.ts new file mode 100644 index 00000000..52baf902 --- /dev/null +++ b/apps/web/lib/proxy-durchreichen.ts @@ -0,0 +1,281 @@ +/** + * Same-Origin-Durchreiche zur API (EYT-50, laufzeitfaehig seit EYT-126). + * + * ## Warum Route Handler und nicht `rewrites()` + * + * `next.config.ts`-`rewrites()` werden beim BAUEN aufgeloest und landen in + * `.next/routes-manifest.json`. Ein fertiges Image liesse sich damit nicht mehr + * auf ein anderes Ziel schalten — Confluence 30998530 verlangt aber dieselben + * Images fuer Coolify/VPS und Railway. + * + * ## Warum auch keine `proxy.ts` (Node-Middleware) + * + * Zwei Messungen vom 21.08.2026 sprechen dagegen: + * + * 1. `NextResponse.rewrite()` auf ein externes Ziel sendet den Kopf + * `x-middleware-rewrite: ` an den Browser. Er laesst sich + * nicht entfernen, ohne die Weiterleitung selbst abzuschalten — er IST der + * Mechanismus. + * 2. `@opennextjs/cloudflare` bricht mit "Node.js middleware is not currently + * supported" ab. Dieser Bau laeuft im Pflichtjob `build-web`. + * + * Ein Route Handler hat beide Probleme nicht: er baut die Antwort selbst, also + * bestimmt er jeden Kopf, und er ist gewoehnlicher Servercode. + * + * ## Der Browser sieht die Adresse nie + * + * Die Variable traegt bewusst KEIN `NEXT_PUBLIC_`-Praefix. Sie wird nur hier + * gelesen, im Serverprozess. Vier Lecks werden aktiv geschlossen, nicht nur + * eines: + * + * * kein `x-middleware-rewrite` — es entsteht gar nicht erst; + * * `location` auf das interne Ziel wird in einen relativen Pfad uebersetzt + * (Pfad, Query und Fragment bleiben erhalten). Ohne diesen Schritt truege + * der Browser die interne Adresse in die Adresszeile und riefe sie als + * naechstes selbst auf. Ein FREMDES Weiterleitungsziel bleibt unangetastet; + * * JEDER uebrige Antwortkopf, dessen WERT die interne Adresse nennt, faellt + * weg — `X-Upstream-Url`, `Link: <…>; rel="self"`, `Content-Location`, ein + * `Set-Cookie` mit interner `Domain`. Weglassen und nicht umschreiben: ein + * Kopf ohne festgelegte Bedeutung hat keine Struktur, aus der sich eine + * Uebersetzung ableiten liesse, und eine erfundene waere schlimmer als der + * Verlust. Gesucht wird die ADRESSE, nicht das Wort (siehe + * `nenntInternesZiel`): der Kopfname wird gar nicht geprueft, und der + * nackte Dienstname ist kein Teilstringtreffer mehr — sonst faellt bei + * einem Ziel namens `api` jedes `X-Api-Version`. Fremde Adressen bleiben + * unangetastet, mehrere `set-cookie` bleiben mehrere; + * * ein Verbindungsfehler wird zu einem eigenen 502, statt die Fehlermeldung + * von undici (sie nennt den Host) durch Nexts Fehlerseite laufen zu lassen. + */ +import { normalizeProxyTarget } from "./api-proxy-target"; + +/** + * Koepfe, die nur fuer EINE Verbindung gelten. Weitergereicht wuerden sie den + * naechsten Hop belügen — `transfer-encoding` und `content-length` beschreiben + * eine Rahmung, die hier neu entsteht. + */ +const VERBINDUNGSKOEPFE = new Set([ + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade", +]); + +/** + * Das Ziel, JETZT. Bewusst eine Funktion und keine Modulkonstante: eine + * Konstante wuerde beim Laden des Moduls eingefroren, und genau das ist der + * Fehler, den dieser Slice behebt. + */ +export function aktuellesProxyziel(): string { + return normalizeProxyTarget(process.env.EASYTREE_API_PROXY_TARGET, process.env.NODE_ENV); +} + +/** + * Uebersetzt einen `location`-Kopf so, dass die interne Adresse den Browser + * nicht erreicht. + * + * Drei Faelle, und nur der erste wird angefasst: + * + * 1. absolut UND auf dem internen Ziel -> Origin (und Basispfad) abstreifen; + * 2. absolut auf einem fremden Host -> unveraendert; eine fremde + * Weiterleitung spekulativ umzuschreiben waere schlimmer als das Leck, + * das sie nicht ist; + * 3. bereits relativ -> unveraendert; der Browser loest + * sie ohnehin gegen die Web-Origin auf, was genau richtig ist. + */ +export function uebersetzeWeiterleitung(roh: string, ziel: string): string { + let ort: URL; + try { + ort = new URL(roh); + } catch { + return roh; + } + + const zielUrl = new URL(ziel); + if (ort.origin !== zielUrl.origin) return roh; + + // Der Basispfad ist der Anteil, den `durchreichen` beim Hinweg VORNE + // angehaengt hat; der Rueckweg muss ihn abziehen, sonst zeigt der relative + // Pfad auf eine Route, die es in der Web-App nicht gibt. + const basis = zielUrl.pathname.replace(/\/+$/, ""); + const imBasispfad = + basis !== "" && (ort.pathname === basis || ort.pathname.startsWith(`${basis}/`)); + const pfad = imBasispfad ? ort.pathname.slice(basis.length) || "/" : ort.pathname; + + return `${pfad}${ort.search}${ort.hash}`; +} + +/** + * Eine URL im Fliesstext eines Kopfes. Die ausgeschlossenen Zeichen sind die + * Trenner, mit denen Koepfe ihre Werte umgeben — `Link` klammert in `<…>`, + * Listen trennen mit Komma, Parameter mit Semikolon. + */ +const URL_IM_TEXT = /https?:\/\/[^\s,;"'<>()[\]\\]+/gi; + +function fuerRegex(roh: string): string { + return roh.replace(/[.*+?^${}()|[\]\\-]/g, "\\$&"); +} + +/** + * Nennt dieser WERT das interne Ziel? + * + * Die erste Fassung verglich Teilstrings gegen drei Formen, darunter den + * nackten Dienstnamen. Fuer die vorgesehene Topologie `http://api:3001` ist + * dieser Dienstname das Wort "api" — und damit fielen `X-Api-Version: 1`, eine + * Doku-URL auf `api.example.org` und ein Cookie namens `api_session`. Ein + * Riegel, der harmlose Koepfe verschluckt, ist kein Schutz, sondern ein + * Ausfall; die Zusage lautet ausdruecklich, dass legitime Koepfe unveraendert + * bleiben. + * + * Deshalb wird jetzt die ADRESSE gesucht und nicht das Wort. Drei Formen, jede + * mit einer Grenze: + * + * 1. **Eine echte URL im Wert**, deren Origin die des Ziels IST. Gefunden + * wird sie mit `new URL()`, nicht mit einem Textvergleich — deshalb ist + * `https://api.example.org/public` kein Treffer, obwohl das Wort darin + * steht. Gross-/Kleinschreibung normalisiert `URL` selbst. + * 2. **Die Autoritaet ohne Schema** (`api:3001`), aber nur wenn das Ziel + * einen Port ausweist, und nur an einer Zeichengrenze: `capitalized-api` + * davor und `api:30011` danach sind keine Treffer. + * 3. **Der Wert BESTEHT aus dem Host** (`api` oder `api:3001`), Leerraum + * abgezogen. Das ist der Fall `X-Upstream-Host: api` — eine Adresse, die + * allein steht, ist eine Adresse. Als Teilstring wird der nackte + * Hostname bewusst NICHT mehr gesucht. + * + * Der Kopf-NAME wird gar nicht mehr geprueft: ein Name traegt keine Adresse, + * er traegt hoechstens dasselbe Wort. + */ +export function nenntInternesZiel(wert: string, ziel: string): boolean { + const url = new URL(ziel); + const origin = url.origin.toLowerCase(); + + for (const roh of wert.match(URL_IM_TEXT) ?? []) { + let gefunden: URL; + try { + gefunden = new URL(roh); + } catch { + continue; + } + if (gefunden.origin.toLowerCase() === origin) return true; + } + + // Ohne Port waere die "Autoritaet" nichts anderes als der nackte Hostname, + // und genau dieser Teilstringvergleich ist der Fehler, den diese Fassung + // behebt. Der Fall bleibt trotzdem gedeckt: eine URL faengt Regel 1, ein + // allein stehender Host faengt Regel 3. + if (url.port !== "") { + const grenze = new RegExp(`(? { + const ziel = aktuellesProxyziel(); + const eingang = new URL(request.url); + const url = new URL(`${ziel}${eingang.pathname}${eingang.search}`); + + const anfrageKopf = new Headers(request.headers); + for (const name of VERBINDUNGSKOEPFE) anfrageKopf.delete(name); + anfrageKopf.delete("host"); + anfrageKopf.delete("content-length"); + // Ohne diese Zeile antwortet die API komprimiert, `fetch` entpackt still, und + // ein weitergereichtes `content-encoding: gzip` beschriebe einen Koerper, den + // es so nicht mehr gibt. + anfrageKopf.delete("accept-encoding"); + + const hatKoerper = request.method !== "GET" && request.method !== "HEAD"; + + let oben: Response; + try { + oben = await fetch(url, { + method: request.method, + headers: anfrageKopf, + body: hatKoerper ? request.body : undefined, + // `manual`: eine Weiterleitung der API gehoert zum Browser, nicht zum Proxy. + redirect: "manual", + ...(hatKoerper ? { duplex: "half" } : {}), + } as RequestInit); + } catch { + // Die Meldung von undici nennt den Zielhost ("ENOTFOUND api-intern"). Sie + // weiterzuwerfen liesse sie durch Nexts Fehlerseite laufen; hier entsteht + // stattdessen eine Antwort, die den Fehler benennt, ohne die Adresse. + return new Response( + JSON.stringify({ + title: "Die API ist nicht erreichbar.", + status: 502, + detail: "Der Same-Origin-Proxy konnte die API nicht erreichen.", + }), + { status: 502, headers: { "content-type": "application/json" } }, + ); + } + + const antwortKopf = new Headers(); + for (const [name, wert] of oben.headers) { + if (VERBINDUNGSKOEPFE.has(name)) continue; + if (name === "content-encoding" || name === "content-length") continue; + // `set-cookie` NICHT ueber set(): mehrere Koepfe wuerden zu einem + // zusammengefaltet, und ein zusammengefaltetes Cookie-Paar ist kaputt. + if (name === "set-cookie") continue; + if (name === "location") { + // `location` hat eine Bedeutung und deshalb eine Uebersetzung; der + // Riegel darunter fasst ihn bewusst NICHT an, sonst fiele die + // Weiterleitung weg, statt relativ zu werden. + antwortKopf.set("location", uebersetzeWeiterleitung(wert, ziel)); + continue; + } + // Jeder uebrige Kopf ist bedeutungslos fuer uns und wird deshalb nicht + // umgeschrieben, sondern weggelassen, sobald sein WERT die interne + // Adresse nennt. Nur der Wert: ein Kopfname traegt keine Adresse, und + // `X-Api-Version` wegzuwerfen, weil das Ziel `api` heisst, waere kein + // Schutz, sondern ein Ausfall. + if (nenntInternesZiel(wert, ziel)) continue; + antwortKopf.set(name, wert); + } + for (const keks of oben.headers.getSetCookie()) { + // Dieselbe Zusage, ohne die Mehrfachheit aufzugeben: das leckende Cookie + // faellt weg, die uebrigen bleiben eigene Koepfe. + if (keksNenntInternesZiel(keks, ziel)) continue; + antwortKopf.append("set-cookie", keks); + } + + return new Response(oben.body, { + status: oben.status, + statusText: oben.statusText, + headers: antwortKopf, + }); +} diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index abaf7c65..a1e37566 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -1,6 +1,8 @@ +import path from "node:path"; + import type { NextConfig } from "next"; -import { resolveBuildProxyTarget } from "./lib/api-proxy-target"; +import { resolveNextOutput } from "./lib/next-output"; /** * easyTree Web-/PWA-Shell (EYT-41, Same-Origin seit EYT-50). @@ -11,25 +13,28 @@ import { resolveBuildProxyTarget } from "./lib/api-proxy-target"; * * ## Same-Origin statt CORS * - * Der Browser bleibt auf der Origin der Web-App und ruft relative Pfade. Next - * leitet sie serverseitig an die API weiter. Die Alternative waere gewesen, - * die API fuer eine fremde Origin zu oeffnen — also zusaetzliche oeffentliche - * Oberflaeche fuer ein Problem, das ein Proxy loest. + * Der Browser bleibt auf der Origin der Web-App und ruft relative Pfade. Die + * Weiterleitung an die API passiert NICHT hier: `rewrites()` wird beim Bauen + * aufgeloest und in `.next/routes-manifest.json` geschrieben, womit ein + * fertiges Image an sein Ziel gebunden waere. Zustaendig sind stattdessen die + * Route Handler unter `app/api/[[...pfad]]`, `app/health` und `app/ready`; sie + * lesen das Ziel bei jeder Anfrage neu (EYT-126). * * `/health` und `/ready` sind mit dabei und nicht vergessen: der bestehende - * ApiClient ruft `/health` relativ zu seiner Basis-URL, und die ist ab jetzt - * die Web-Origin. Ein Rewrite nur fuer `/api/:path*` liesse den Health-Check - * ins Leere laufen. + * ApiClient ruft `/health` relativ zu seiner Basis-URL, und die ist die + * Web-Origin. Eine Durchreiche nur fuer `/api/*` liesse den Health-Check ins + * Leere laufen. */ const nextConfig: NextConfig = { - async rewrites() { - const target = resolveBuildProxyTarget(process.env); - return [ - { source: "/api/:path*", destination: `${target}/api/:path*` }, - { source: "/health", destination: `${target}/health` }, - { source: "/ready", destination: `${target}/ready` }, - ]; - }, + /** + * Die Ablaufverfolgung fuer `output: "standalone"` braucht die + * Workspace-Wurzel, nicht `apps/web`. Ohne diese Angabe raet Next sie aus + * den gefundenen Lockfiles ("Detected additional lockfiles") — und ein + * Raten entscheidet darueber, ob die Workspace-Pakete im Standalone-Bundle + * landen. + */ + outputFileTracingRoot: path.join(__dirname, "..", ".."), + ...resolveNextOutput(process.env), }; export default nextConfig; diff --git a/apps/web/test/api-proxy-target.test.ts b/apps/web/test/api-proxy-target.test.ts index e029aa18..2b4ca9df 100644 --- a/apps/web/test/api-proxy-target.test.ts +++ b/apps/web/test/api-proxy-target.test.ts @@ -1,14 +1,17 @@ /** * Das Proxyziel wird geprueft, nicht geglaubt (EYT-50/EYT-142). + * + * `resolveBuildProxyTarget` und sein Cloudflare-Fallback stehen hier seit + * EYT-126 nicht mehr: der einzige Aufrufer war `next.config.ts`-`rewrites()`, + * und den gibt es nicht mehr. Ein Test auf toten Code haette weiter eine + * Zusage behauptet, die niemand mehr einloest. */ import { describe, expect, it } from "vitest"; import { - CLOUDFLARE_API_FALLBACK_TARGET, DEFAULT_API_PROXY_TARGET, InvalidProxyTargetError, normalizeProxyTarget, - resolveBuildProxyTarget, } from "../lib/api-proxy-target"; describe("normalizeProxyTarget", () => { @@ -58,33 +61,3 @@ describe("normalizeProxyTarget", () => { expect(() => normalizeProxyTarget(wert)).toThrow(InvalidProxyTargetError); }); }); - -describe("resolveBuildProxyTarget", () => { - it("nimmt in Cloudflare Workers Builds den dokumentierten Railway-Fallback", () => { - expect(resolveBuildProxyTarget({ NODE_ENV: "production", WORKERS_CI: "1" })).toBe( - CLOUDFLARE_API_FALLBACK_TARGET, - ); - }); - - it("nimmt in Cloudflare Pages den dokumentierten Railway-Fallback", () => { - expect(resolveBuildProxyTarget({ NODE_ENV: "production", CF_PAGES: "1" })).toBe( - CLOUDFLARE_API_FALLBACK_TARGET, - ); - }); - - it("bevorzugt ein explizites API-Ziel auch in Cloudflare", () => { - expect( - resolveBuildProxyTarget({ - NODE_ENV: "production", - WORKERS_CI: "1", - EASYTREE_API_PROXY_TARGET: "https://api.example.org/", - }), - ).toBe("https://api.example.org"); - }); - - it("bleibt ausserhalb Cloudflare in production fail-closed", () => { - expect(() => resolveBuildProxyTarget({ NODE_ENV: "production" })).toThrow( - InvalidProxyTargetError, - ); - }); -}); diff --git a/apps/web/test/instrumentation.test.ts b/apps/web/test/instrumentation.test.ts new file mode 100644 index 00000000..6c819b59 --- /dev/null +++ b/apps/web/test/instrumentation.test.ts @@ -0,0 +1,57 @@ +/** + * Der Start ist die letzte Stelle, an der ein falsches Ziel billig auffaellt + * (EYT-126). + * + * 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. + */ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { InvalidProxyTargetError } from "../lib/api-proxy-target"; +import { register } from "../instrumentation"; + +const URSPRUNG = { ...process.env }; +/** + * Next deklariert `ProcessEnv.NODE_ENV` als `readonly`, deshalb der Umweg. Die + * Umgebung MUSS hier echt umgestellt werden: `aktuellesProxyziel()` liest sie + * selbst, und genau das ist die gemessene Eigenschaft. + */ +function setzeNodeEnv(wert: string): void { + (process.env as Record)["NODE_ENV"] = wert; +} + +beforeEach(() => { + setzeNodeEnv("production"); +}); + +afterEach(() => { + process.env = { ...URSPRUNG }; +}); + +describe("register", () => { + it("wirft in production, wenn das Ziel fehlt", () => { + delete process.env.EASYTREE_API_PROXY_TARGET; + expect(() => register()).toThrow(InvalidProxyTargetError); + }); + + it("nennt dabei die Variable, damit das Containerprotokoll brauchbar ist", () => { + delete process.env.EASYTREE_API_PROXY_TARGET; + expect(() => register()).toThrow(/EASYTREE_API_PROXY_TARGET/); + }); + + it("wirft in production bei einem ungueltigen Ziel", () => { + process.env.EASYTREE_API_PROXY_TARGET = "ftp://api.invalid"; + expect(() => register()).toThrow(InvalidProxyTargetError); + }); + + it("wirft bei Zugangsdaten im Ziel", () => { + process.env.EASYTREE_API_PROXY_TARGET = "http://nutzer:geheim@api.invalid:3001"; + expect(() => register()).toThrow(InvalidProxyTargetError); + }); + + it("laesst ein gueltiges Ziel durch", () => { + process.env.EASYTREE_API_PROXY_TARGET = "http://api:3001"; + expect(() => register()).not.toThrow(); + }); +}); diff --git a/apps/web/test/next-output.test.ts b/apps/web/test/next-output.test.ts new file mode 100644 index 00000000..d09af025 --- /dev/null +++ b/apps/web/test/next-output.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; + +import { + InvalidNextOutputError, + NEXT_OUTPUT_STANDALONE, + resolveNextOutput, +} from "../lib/next-output"; + +/** + * EYT-126 — die Ausgabeform des Next-Builds. + * + * Gegenmutation, die diesen Block rot macht: in `resolveNextOutput` den + * `throw` durch `return {}` ersetzen. Dann meldet der Fall „unbekannter Wert" + * keinen Fehler mehr, und das Image baute still ohne `standalone` — der Fehler + * fiele erst im Dockerfile auf, wo `apps/web/server.js` fehlt. + */ +describe("resolveNextOutput (EYT-126)", () => { + it("laesst die Standardausgabe unangetastet, wenn nichts gesetzt ist", () => { + expect(resolveNextOutput({})).toEqual({}); + expect(resolveNextOutput({ EASYTREE_NEXT_OUTPUT: "" })).toEqual({}); + expect(resolveNextOutput({ EASYTREE_NEXT_OUTPUT: " " })).toEqual({}); + }); + + it("schaltet auf standalone um — das ist die Form, die das Image braucht", () => { + expect(resolveNextOutput({ EASYTREE_NEXT_OUTPUT: NEXT_OUTPUT_STANDALONE })).toEqual({ + output: "standalone", + }); + expect(resolveNextOutput({ EASYTREE_NEXT_OUTPUT: " standalone " })).toEqual({ + output: "standalone", + }); + }); + + it("ist fail-closed: ein unbekannter Wert ist ein Tippfehler, kein Wunsch", () => { + expect(() => resolveNextOutput({ EASYTREE_NEXT_OUTPUT: "standlone" })).toThrow( + InvalidNextOutputError, + ); + // Die Meldung nennt den erhaltenen Wert — sonst sucht man den Tippfehler + // in der falschen Datei. + expect(() => resolveNextOutput({ EASYTREE_NEXT_OUTPUT: "export" })).toThrow(/"export"/); + }); + + it("nimmt eine ProcessEnv-artige Umgebung an, ohne sie zu verengen", () => { + // Der Grund fuer den Index-Typ: `process.env` traegt viele Schluessel, die + // hier niemanden interessieren. Ein Objekttyp mit genau einem optionalen + // Feld wurde von TypeScript als Schwachtyp abgelehnt. + const umgebung: NodeJS.ProcessEnv = { + NODE_ENV: "production", + PATH: "/bin", + EASYTREE_NEXT_OUTPUT: "standalone", + }; + expect(resolveNextOutput(umgebung)).toEqual({ output: "standalone" }); + }); +}); diff --git a/apps/web/test/proxy-durchreichen.test.ts b/apps/web/test/proxy-durchreichen.test.ts new file mode 100644 index 00000000..db6928a4 --- /dev/null +++ b/apps/web/test/proxy-durchreichen.test.ts @@ -0,0 +1,575 @@ +/** + * Die Durchreiche wird gemessen, nicht geglaubt (EYT-126). + * + * Der Kern dieses Slices ist EINE Eigenschaft: das Ziel wird bei JEDER Anfrage + * neu aus der Umgebung gelesen. Deshalb steht der entsprechende Fall hier nicht + * am Rand, sondern in der Mitte — und er faellt, sobald jemand den Wert beim + * Modulladen einfriert. + * + * Die zweite Eigenschaft ist genauso wichtig und war im ersten Entwurf nicht + * abgedeckt: die interne Adresse darf den Browser auf KEINEM Weg erreichen. + * `x-middleware-rewrite` zu vermeiden reicht dafuer nicht — ein `location`-Kopf + * der API traegt sie genauso nach draussen. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { InvalidProxyTargetError } from "../lib/api-proxy-target"; +import { aktuellesProxyziel, durchreichen } from "../lib/proxy-durchreichen"; + +const URSPRUNG = { ...process.env }; +/** + * Next deklariert `ProcessEnv.NODE_ENV` als `readonly`, deshalb der Umweg. Die + * Umgebung MUSS hier echt umgestellt werden: `aktuellesProxyziel()` liest sie + * selbst, und genau das ist die gemessene Eigenschaft. + */ +function setzeNodeEnv(wert: string): void { + (process.env as Record)["NODE_ENV"] = wert; +} + +function antwort(koerper: unknown, kopf: Record = {}): Response { + return new Response(JSON.stringify(koerper), { + status: 200, + headers: { "content-type": "application/json", ...kopf }, + }); +} + +/** Eine Weiterleitung der API, wie sie oben ankaeme. */ +function weiterleitung(ort: string): Response { + return new Response(null, { status: 302, headers: { location: ort } }); +} + +beforeEach(() => { + setzeNodeEnv("test"); + process.env.EASYTREE_API_PROXY_TARGET = "http://api-a.invalid:3001"; +}); + +afterEach(() => { + vi.restoreAllMocks(); + process.env = { ...URSPRUNG }; +}); + +describe("durchreichen", () => { + it("liest das Ziel bei JEDER Anfrage neu aus der Umgebung", async () => { + const holen = vi.fn(async () => antwort({ ok: true })); + vi.stubGlobal("fetch", holen); + + await durchreichen(new Request("http://web.invalid/health")); + process.env.EASYTREE_API_PROXY_TARGET = "http://api-b.invalid:3001"; + await durchreichen(new Request("http://web.invalid/health")); + + const ziele = holen.mock.calls.map((aufruf) => String((aufruf as unknown[])[0])); + expect(ziele).toEqual(["http://api-a.invalid:3001/health", "http://api-b.invalid:3001/health"]); + }); + + it("uebernimmt Methode, Pfad und Querystring unveraendert", async () => { + const holen = vi.fn(async () => antwort({ ok: true })); + vi.stubGlobal("fetch", holen); + + await durchreichen( + new Request("http://web.invalid/api/v1/planung?woche=2026-W35", { method: "DELETE" }), + ); + + const [url, optionen] = holen.mock.calls[0] as unknown as [URL, RequestInit]; + expect(String(url)).toBe("http://api-a.invalid:3001/api/v1/planung?woche=2026-W35"); + expect(optionen.method).toBe("DELETE"); + }); + + it("reicht Anfrage-Cookies an die API weiter", async () => { + const holen = vi.fn(async () => antwort({ ok: true })); + vi.stubGlobal("fetch", holen); + + await durchreichen( + new Request("http://web.invalid/api/v1/auth/me", { + headers: { cookie: "sb-access=abc; andere=x" }, + }), + ); + + const [, optionen] = holen.mock.calls[0] as unknown as [URL, RequestInit]; + expect(new Headers(optionen.headers).get("cookie")).toBe("sb-access=abc; andere=x"); + }); + + it("reicht einen POST-Koerper an die API weiter", async () => { + const holen = vi.fn(async () => antwort({ ok: true })); + vi.stubGlobal("fetch", holen); + + await durchreichen( + new Request("http://web.invalid/api/v1/auth/login", { + method: "POST", + headers: { "content-type": "application/json" }, + body: '{"email":"a@b.test"}', + }), + ); + + const [, optionen] = holen.mock.calls[0] as unknown as [URL, RequestInit]; + expect(optionen.method).toBe("POST"); + expect(optionen.body).not.toBeUndefined(); + // `duplex: "half"` fehlt sonst und undici lehnt einen Stream-Koerper ab — + // das faellt erst im Browser auf, nicht im Typcheck. + expect((optionen as { duplex?: string }).duplex).toBe("half"); + }); + + it("erhaelt MEHRERE set-cookie-Koepfe einzeln", async () => { + const oben = new Response("{}", { status: 200 }); + oben.headers.append("set-cookie", "sb-access=abc; HttpOnly; Path=/"); + oben.headers.append("set-cookie", "sb-refresh=def; HttpOnly; Path=/"); + vi.stubGlobal( + "fetch", + vi.fn(async () => oben), + ); + + const ergebnis = await durchreichen(new Request("http://web.invalid/api/v1/auth/login")); + + expect(ergebnis.headers.getSetCookie()).toEqual([ + "sb-access=abc; HttpOnly; Path=/", + "sb-refresh=def; HttpOnly; Path=/", + ]); + }); + + it("nennt die interne Adresse in KEINEM Antwortkopf", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => antwort({ ok: true }, { "x-upstream": "egal" })), + ); + + const ergebnis = await durchreichen(new Request("http://web.invalid/health")); + + const alles = [...ergebnis.headers.entries()].map(([n, w]) => `${n}: ${w}`).join("\n"); + expect(alles).not.toContain("api-a.invalid"); + expect(ergebnis.headers.get("x-middleware-rewrite")).toBeNull(); + // Eingangsbremse: haette die Antwort gar keine Koepfe, pruefte das nichts. + expect(alles).toContain("x-upstream"); + }); + + it("entfernt Verbindungskoepfe in beide Richtungen", async () => { + const oben = antwort({ ok: true }, { connection: "keep-alive", "content-encoding": "gzip" }); + const holen = vi.fn(async () => oben); + vi.stubGlobal("fetch", holen); + + const ergebnis = await durchreichen( + new Request("http://web.invalid/health", { + headers: { connection: "keep-alive", "accept-encoding": "gzip" }, + }), + ); + + const gesendet = new Headers((holen.mock.calls[0] as unknown as [URL, RequestInit])[1].headers); + expect(gesendet.get("connection")).toBeNull(); + expect(gesendet.get("accept-encoding")).toBeNull(); + expect(ergebnis.headers.get("connection")).toBeNull(); + expect(ergebnis.headers.get("content-encoding")).toBeNull(); + }); + + it("gibt Status und Koerper der API unveraendert zurueck", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response('{"fehler":"nein"}', { status: 409 })), + ); + + const ergebnis = await durchreichen(new Request("http://web.invalid/api/v1/planung")); + + expect(ergebnis.status).toBe(409); + expect(await ergebnis.text()).toBe('{"fehler":"nein"}'); + }); + + it("nennt die interne Adresse nicht, wenn die API unerreichbar ist", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => { + throw new TypeError("fetch failed: getaddrinfo ENOTFOUND api-a.invalid"); + }), + ); + + const ergebnis = await durchreichen(new Request("http://web.invalid/api/v1/planung")); + + expect(ergebnis.status).toBe(502); + const koerper = await ergebnis.text(); + const koepfe = [...ergebnis.headers.entries()].map(([n, w]) => `${n}: ${w}`).join("\n"); + expect(koerper).not.toContain("api-a.invalid"); + expect(koepfe).not.toContain("api-a.invalid"); + }); +}); + +/** + * `location` ist nicht der einzige Kopf, der eine Adresse traegt. + * + * Gemessen an einem echten Gegenbeispiel: antwortet die API mit + * `X-Upstream-Url: http://:3001/private` oder mit einem + * `Link`-Kopf, der auf sie selbst zeigt, dann steht die interne Adresse im + * Browser — same-origin-JavaScript liest sie ueber `response.headers`, und + * `Link` wertet der Browser sogar selbst aus. + * + * Ein Kopf ohne festgelegte Bedeutung laesst sich nicht sinnvoll uebersetzen; + * eine erfundene Umschreibung waere schlimmer als der Verlust. Deshalb faellt + * er weg. `location` bleibt davon unberuehrt — der hat eine Bedeutung und + * seine eigene Uebersetzung, eine Zeile weiter oben. + */ +describe("durchreichen — gewoehnliche Antwortkoepfe lecken die interne Adresse nicht", () => { + it("entfernt einen gewoehnlichen Kopf, der das interne Ziel nennt", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => + antwort( + { ok: true }, + { + "x-upstream-url": "http://api-a.invalid:3001/private", + link: '; rel="self"', + "x-request-id": "r-42", + }, + ), + ), + ); + + const ergebnis = await durchreichen(new Request("http://web.invalid/api/v1/planung")); + + const alles = [...ergebnis.headers.entries()].map(([n, w]) => `${n}: ${w}`).join("\n"); + expect(alles).not.toContain("api-a.invalid"); + expect(ergebnis.headers.get("x-upstream-url")).toBeNull(); + expect(ergebnis.headers.get("link")).toBeNull(); + // Eingangsbremse: fiele einfach ALLES weg, pruefte dieser Test nichts. + expect(ergebnis.headers.get("x-request-id")).toBe("r-42"); + }); + + it("entfernt auch einen Kopf, der nur den internen Hostnamen nennt", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => + antwort( + { ok: true }, + { "x-upstream-host": "api-a.invalid", "content-location": "http://api-a.invalid:3001/f" }, + ), + ), + ); + + const ergebnis = await durchreichen(new Request("http://web.invalid/api/v1/planung")); + + expect(ergebnis.headers.get("x-upstream-host")).toBeNull(); + expect(ergebnis.headers.get("content-location")).toBeNull(); + const alles = [...ergebnis.headers.entries()].map(([n, w]) => `${n}: ${w}`).join("\n"); + expect(alles).not.toContain("api-a.invalid"); + // Eingangsbremse: die Antwort traegt weiterhin ihren content-type. + expect(ergebnis.headers.get("content-type")).toBe("application/json"); + }); + + it("erkennt die interne Adresse unabhaengig von der Schreibweise", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => + antwort({ ok: true }, { "x-upstream-url": "HTTP://API-A.INVALID:3001/private" }), + ), + ); + + const ergebnis = await durchreichen(new Request("http://web.invalid/api/v1/planung")); + + expect(ergebnis.headers.get("x-upstream-url")).toBeNull(); + }); + + it("laesst gewoehnliche Koepfe unveraendert", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => + antwort( + { ok: true }, + { "cache-control": "no-store", etag: '"abc123"', "x-correlation-id": "korr-7" }, + ), + ), + ); + + const ergebnis = await durchreichen(new Request("http://web.invalid/api/v1/planung")); + + expect(ergebnis.headers.get("cache-control")).toBe("no-store"); + expect(ergebnis.headers.get("etag")).toBe('"abc123"'); + expect(ergebnis.headers.get("x-correlation-id")).toBe("korr-7"); + expect(ergebnis.headers.get("content-type")).toBe("application/json"); + }); + + it("laesst einen Kopf mit einer EXTERNEN URL unveraendert", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => + antwort( + { ok: true }, + { + link: '; rel="next"', + "content-location": "https://cdn.example.org/a.json", + }, + ), + ), + ); + + const ergebnis = await durchreichen(new Request("http://web.invalid/api/v1/planung")); + + expect(ergebnis.headers.get("link")).toBe('; rel="next"'); + expect(ergebnis.headers.get("content-location")).toBe("https://cdn.example.org/a.json"); + }); + + it("entfernt nur das leckende Cookie und erhaelt die uebrigen einzeln", async () => { + const oben = new Response("{}", { status: 200 }); + oben.headers.append("set-cookie", "sb-access=abc; HttpOnly; Path=/"); + oben.headers.append("set-cookie", "leck=1; Domain=api-a.invalid; Path=/"); + oben.headers.append("set-cookie", "sb-refresh=def; HttpOnly; Path=/"); + vi.stubGlobal( + "fetch", + vi.fn(async () => oben), + ); + + const ergebnis = await durchreichen(new Request("http://web.invalid/api/v1/auth/login")); + + // Zwei bleiben zwei — der Riegel darf nicht zusammenfalten, was er nicht faltet. + expect(ergebnis.headers.getSetCookie()).toEqual([ + "sb-access=abc; HttpOnly; Path=/", + "sb-refresh=def; HttpOnly; Path=/", + ]); + const alles = [...ergebnis.headers.entries()].map(([n, w]) => `${n}: ${w}`).join("\n"); + expect(alles).not.toContain("api-a.invalid"); + }); + + it("misst gegen das AKTUELLE Ziel und nicht gegen ein eingefrorenes", async () => { + // Dasselbe Modul, dieselbe Antwort — nur die Umgebung wechselt. Waere das + // Ziel beim Modulladen eingefroren, bliebe genau die falsche Adresse stehen. + process.env.EASYTREE_API_PROXY_TARGET = "http://api-b.invalid:3001"; + vi.stubGlobal( + "fetch", + vi.fn(async () => + antwort( + { ok: true }, + { + "x-upstream-url": "http://api-b.invalid:3001/private", + "x-frueheres-ziel": "http://api-a.invalid:3001/egal", + }, + ), + ), + ); + + const ergebnis = await durchreichen(new Request("http://web.invalid/api/v1/planung")); + + expect(ergebnis.headers.get("x-upstream-url")).toBeNull(); + // Das ALTE Ziel ist jetzt eine fremde Adresse und bleibt deshalb stehen. + expect(ergebnis.headers.get("x-frueheres-ziel")).toBe("http://api-a.invalid:3001/egal"); + }); +}); + +/** + * Der Riegel muss die ADRESSE treffen, nicht das Wort. + * + * Die erste Fassung verglich Teilstrings — volle Adresse, Autoritaet UND + * nackter Dienstname — und zwar ueber Kopfnamen und Wert zusammen. Fuer die + * vorgesehene Containertopologie `http://api:3001` ist der Dienstname das + * Wort "api", und damit fiel jeder harmlose Kopf, in dem diese drei Buchstaben + * irgendwo vorkommen: `X-Api-Version`, eine Doku-URL auf `api.example.org`, + * ein Cookie namens `api_session`. Das ist kein Leck, das ist ein Ausfall. + * + * Deshalb steht das Ziel hier auf genau dem echten Wert `http://api:3001` — + * mit `api-a.invalid` waere der Fehler unsichtbar geblieben, weil dieser Name + * in keinem harmlosen Kopf vorkommt. + */ +describe("durchreichen — der Riegel trifft die Adresse, nicht das Wort", () => { + beforeEach(() => { + // Die vorgesehene interne Topologie, woertlich. Der Dienstname ist "api". + process.env.EASYTREE_API_PROXY_TARGET = "http://api:3001"; + }); + + it("laesst einen Kopf stehen, dessen NAME den Dienstnamen enthaelt", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => antwort({ ok: true }, { "x-api-version": "1" })), + ); + + const ergebnis = await durchreichen(new Request("http://web.invalid/api/v1/planung")); + + expect(ergebnis.headers.get("x-api-version")).toBe("1"); + }); + + it("laesst einen Kopf mit einer FREMDEN Adresse unter demselben Wort stehen", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => + antwort({ ok: true }, { "x-documentation": "https://api.example.org/public" }), + ), + ); + + const ergebnis = await durchreichen(new Request("http://web.invalid/api/v1/planung")); + + expect(ergebnis.headers.get("x-documentation")).toBe("https://api.example.org/public"); + }); + + it("laesst ein Cookie stehen, dessen Name den Dienstnamen enthaelt", async () => { + const oben = new Response("{}", { status: 200 }); + oben.headers.append("set-cookie", "api_session=abc123; Path=/; HttpOnly"); + oben.headers.append("set-cookie", "sb-refresh=def; HttpOnly; Path=/"); + vi.stubGlobal( + "fetch", + vi.fn(async () => oben), + ); + + const ergebnis = await durchreichen(new Request("http://web.invalid/api/v1/auth/login")); + + expect(ergebnis.headers.getSetCookie()).toEqual([ + "api_session=abc123; Path=/; HttpOnly", + "sb-refresh=def; HttpOnly; Path=/", + ]); + }); + + it("laesst einen Wert stehen, in dem der Dienstname nur ein Wortteil ist", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => antwort({ ok: true }, { "x-label": "capitalized-api-response" })), + ); + + const ergebnis = await durchreichen(new Request("http://web.invalid/api/v1/planung")); + + expect(ergebnis.headers.get("x-label")).toBe("capitalized-api-response"); + }); + + it("entfernt trotzdem die echte interne Adresse", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => + antwort( + { ok: true }, + { + "x-upstream-url": "http://api:3001/private", + link: '; rel="self"', + "x-api-version": "1", + }, + ), + ), + ); + + const ergebnis = await durchreichen(new Request("http://web.invalid/api/v1/planung")); + + expect(ergebnis.headers.get("x-upstream-url")).toBeNull(); + expect(ergebnis.headers.get("link")).toBeNull(); + // Im selben Lauf: der harmlose Kopf ueberlebt. Sonst waere die Zeile + // darueber auch mit einem Riegel gruen, der alles wegwirft. + expect(ergebnis.headers.get("x-api-version")).toBe("1"); + }); + + it("entfernt trotzdem die nackte Autoritaet ohne Schema", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => antwort({ ok: true }, { "x-upstream": "api:3001" })), + ); + + const ergebnis = await durchreichen(new Request("http://web.invalid/api/v1/planung")); + + expect(ergebnis.headers.get("x-upstream")).toBeNull(); + }); + + it("verwechselt eine laengere Portnummer nicht mit der internen", async () => { + // `api:30011` ist ein anderer Endpunkt. Ein Vergleich ohne Grenze haette + // ihn als Treffer gelesen. + vi.stubGlobal( + "fetch", + vi.fn(async () => antwort({ ok: true }, { "x-upstream": "api:30011" })), + ); + + const ergebnis = await durchreichen(new Request("http://web.invalid/api/v1/planung")); + + expect(ergebnis.headers.get("x-upstream")).toBe("api:30011"); + }); + + it("entfernt ein Cookie, dessen Domain-Attribut den internen Host nennt", async () => { + const oben = new Response("{}", { status: 200 }); + oben.headers.append("set-cookie", "api_session=abc123; Path=/; HttpOnly"); + oben.headers.append("set-cookie", "leck=1; Domain=api; Path=/"); + vi.stubGlobal( + "fetch", + vi.fn(async () => oben), + ); + + const ergebnis = await durchreichen(new Request("http://web.invalid/api/v1/auth/login")); + + expect(ergebnis.headers.getSetCookie()).toEqual(["api_session=abc123; Path=/; HttpOnly"]); + }); +}); + +/** + * Der Kopf `location` ist der zweite Weg nach draussen. + * + * `x-middleware-rewrite` zu vermeiden genuegt NICHT: antwortet die API mit + * `Location: http://:3001/foo?x=1`, traegt der Browser die + * interne Adresse in die Adresszeile und ruft sie als naechstes selbst auf. + * Same-Origin-JavaScript liest sie ausserdem aus `response.headers`. + */ +describe("durchreichen — Weiterleitungen lecken die interne Adresse nicht", () => { + it("uebersetzt eine absolute Weiterleitung auf das interne Ziel in einen relativen Pfad", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => weiterleitung("http://api-a.invalid:3001/foo?x=1")), + ); + + const ergebnis = await durchreichen(new Request("http://web.invalid/api/v1/alt")); + + expect(ergebnis.status).toBe(302); + expect(ergebnis.headers.get("location")).toBe("/foo?x=1"); + }); + + it("erhaelt dabei auch das Fragment", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => weiterleitung("http://api-a.invalid:3001/foo?x=1#teil")), + ); + + const ergebnis = await durchreichen(new Request("http://web.invalid/api/v1/alt")); + + expect(ergebnis.headers.get("location")).toBe("/foo?x=1#teil"); + }); + + it("streift den Basispfad des Ziels ab, damit der Pfad same-origin stimmt", async () => { + process.env.EASYTREE_API_PROXY_TARGET = "https://gateway.invalid/easytree"; + vi.stubGlobal( + "fetch", + vi.fn(async () => weiterleitung("https://gateway.invalid/easytree/api/v1/neu?a=2")), + ); + + const ergebnis = await durchreichen(new Request("http://web.invalid/api/v1/alt")); + + expect(ergebnis.headers.get("location")).toBe("/api/v1/neu?a=2"); + }); + + it("laesst ein EXTERNES Weiterleitungsziel semantisch unveraendert", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => weiterleitung("https://login.example.org/oauth?state=xyz")), + ); + + const ergebnis = await durchreichen(new Request("http://web.invalid/api/v1/auth/start")); + + expect(ergebnis.status).toBe(302); + expect(ergebnis.headers.get("location")).toBe("https://login.example.org/oauth?state=xyz"); + }); + + it("laesst eine bereits relative Weiterleitung unveraendert", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => weiterleitung("/api/v1/neu?a=2")), + ); + + const ergebnis = await durchreichen(new Request("http://web.invalid/api/v1/alt")); + + expect(ergebnis.headers.get("location")).toBe("/api/v1/neu?a=2"); + }); + + it("folgt der Weiterleitung nicht selbst — sie gehoert dem Browser", async () => { + const holen = vi.fn(async () => weiterleitung("http://api-a.invalid:3001/foo")); + vi.stubGlobal("fetch", holen); + + await durchreichen(new Request("http://web.invalid/api/v1/alt")); + + const [, optionen] = holen.mock.calls[0] as unknown as [URL, RequestInit]; + expect(optionen.redirect).toBe("manual"); + }); +}); + +describe("aktuellesProxyziel", () => { + it("wirft in production ohne Wert", () => { + setzeNodeEnv("production"); + delete process.env.EASYTREE_API_PROXY_TARGET; + expect(() => aktuellesProxyziel()).toThrow(InvalidProxyTargetError); + }); + + it("wirft in production bei ungueltigem Wert", () => { + setzeNodeEnv("production"); + process.env.EASYTREE_API_PROXY_TARGET = "ftp://api.invalid"; + expect(() => aktuellesProxyziel()).toThrow(InvalidProxyTargetError); + }); +}); diff --git a/apps/web/test/proxy-routen.test.ts b/apps/web/test/proxy-routen.test.ts new file mode 100644 index 00000000..7663b820 --- /dev/null +++ b/apps/web/test/proxy-routen.test.ts @@ -0,0 +1,34 @@ +/** + * Die Routen selbst sind der Nachweis (EYT-126). + * + * Ein Test, der nur `durchreichen` prueft, bliebe gruen, wenn eine der drei + * Routen fehlte oder eine Methode nicht exportiert waere — und genau das waere + * im Browser ein 404 auf `/ready` statt eines Fehlers. + */ +import { describe, expect, it } from "vitest"; + +import * as apiRoute from "../app/api/[[...pfad]]/route"; +import * as healthRoute from "../app/health/route"; +import * as readyRoute from "../app/ready/route"; +import { durchreichen } from "../lib/proxy-durchreichen"; + +describe("Proxyrouten", () => { + it("reicht /api mit allen Methoden an dieselbe Durchreiche", () => { + for (const methode of ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"]) { + expect(apiRoute[methode as keyof typeof apiRoute]).toBe(durchreichen); + } + }); + + it("reicht /health und /ready lesend an dieselbe Durchreiche", () => { + expect(healthRoute.GET).toBe(durchreichen); + expect(healthRoute.HEAD).toBe(durchreichen); + expect(readyRoute.GET).toBe(durchreichen); + expect(readyRoute.HEAD).toBe(durchreichen); + }); + + it("erzwingt dynamisches Rendern — sonst waere das Ziel wieder eingebacken", () => { + expect(apiRoute.dynamic).toBe("force-dynamic"); + expect(healthRoute.dynamic).toBe("force-dynamic"); + expect(readyRoute.dynamic).toBe("force-dynamic"); + }); +}); diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..4c57acc9 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,98 @@ +# easyTree — Container-Topologie fuer Staging (EYT-126). +# +# Coolify konsumiert diese Datei als "Docker Compose"-Ressource; lokal und in +# CI dient sie demselben Zweck. Sie beschreibt AUSSCHLIESSLICH Topologie und +# Konfigurationsnaehte — keine Fachlogik, keine Secrets, keine Migrationen. +# +# Drei Grenzen, die hier sichtbar sein muessen: +# +# 1. Nur `web` veroeffentlicht einen Port. Die API haengt am internen Netz und +# ist unter dem Dienstnamen `api` erreichbar. Der Browser sieht deshalb +# genau eine Origin und niemals eine interne Adresse. +# 2. Weder `api` noch `web` fuehren Schemawerkzeuge aus. Eigentuemerin des +# Migrationspfads bleibt die Supabase-GitHub-Integration; Coolify und +# Railway werden keine zweite Migration Authority (EYT-126, EYT-142). +# `apps/api/test/deploy-authority.test.ts` erzwingt das. +# 3. Weder `GIT_SHA` noch `EASYTREE_API_PROXY_TARGET` haben einen Vorgabewert. +# Beide hatten es frueher (`:-unknown`, `:-http://api:3001`) — ein Deploy +# ohne Herkunft und ein geratenes Proxyziel waren damit ueber genau diesen +# Pfad erzeugbar (EYT-126). + +services: + api: + build: + context: . + dockerfile: apps/api/Dockerfile + args: + # `:?` statt `:-`: ein Image ohne Herkunft ist kein Kandidat fuer ein + # Deploy. Frueher trug diese Zeile den Vorgabewert `unknown` — damit war + # `revision=unknown` ueber genau diesen Pfad erzeugbar (EYT-126). + GIT_SHA: ${GIT_SHA:?GIT_SHA fehlt - baue mit GIT_SHA=$(git rev-parse HEAD)} + image: ${EASYTREE_API_IMAGE:-easytree-api:local} + restart: unless-stopped + # Kein `ports:`. Bewusst. Ein veroeffentlichter API-Port waere zusaetzliche + # oeffentliche Oberflaeche fuer ein Problem, das der Same-Origin-Proxy loest. + expose: + - "3001" + environment: + # Der kanonische Variablensatz aus packages/config/src/schema.ts. + # `:?` statt Default: eine fehlende Pflichtvariable soll den Start + # verweigern, nicht stillschweigend auf etwas Plausibles zeigen. + NODE_ENV: ${NODE_ENV:-production} + API_PORT: "3001" + LOG_LEVEL: ${LOG_LEVEL:-info} + DATABASE_URL: ${DATABASE_URL:?DATABASE_URL fehlt — verbindet als easytree_app, niemals als postgres} + SUPABASE_URL: ${SUPABASE_URL:?SUPABASE_URL fehlt} + SUPABASE_ANON_KEY: ${SUPABASE_ANON_KEY:?SUPABASE_ANON_KEY fehlt} + DATABASE_SSL_ROOT_CERT: ${DATABASE_SSL_ROOT_CERT:?DATABASE_SSL_ROOT_CERT ist in production Pflicht} + healthcheck: + # `node -e` statt curl: das Laufzeit-Image ist bewusst schlank und + # enthaelt weder curl noch wget. Node 22 bringt `fetch` global mit. + test: + [ + "CMD", + "node", + "-e", + "fetch('http://127.0.0.1:3001/ready').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))", + ] + interval: 10s + timeout: 5s + retries: 12 + start_period: 20s + + web: + build: + context: . + dockerfile: apps/web/Dockerfile + args: + # Das Web-Image bekommt KEIN Proxyziel mehr mit auf den Weg. Es ist seit + # EYT-126 an kein Ziel und an keinen Anbieter gebunden; das Ziel steht + # unten bei `environment` und gilt zur Laufzeit. + GIT_SHA: ${GIT_SHA:?GIT_SHA fehlt - baue mit GIT_SHA=$(git rev-parse HEAD)} + image: ${EASYTREE_WEB_IMAGE:-easytree-web:local} + restart: unless-stopped + depends_on: + api: + condition: service_healthy + ports: + - "${EASYTREE_WEB_PUBLIC_PORT:-3000}:3000" + environment: + NODE_ENV: ${NODE_ENV:-production} + PORT: "3000" + HOSTNAME: "0.0.0.0" + # LAUFZEIT, nicht Bauzeit — gemessen 21.08.2026, siehe den Kopf von + # apps/web/Dockerfile. Kein Default: ein geratenes Ziel sieht im Browser + # aus wie eine leere Woche, nicht wie ein Fehler. + EASYTREE_API_PROXY_TARGET: ${EASYTREE_API_PROXY_TARGET:?EASYTREE_API_PROXY_TARGET fehlt - interner Dienstname der API, z. B. http://api:3001} + healthcheck: + test: + [ + "CMD", + "node", + "-e", + "fetch('http://127.0.0.1:3000/').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))", + ] + interval: 10s + timeout: 5s + retries: 12 + start_period: 20s diff --git a/docs/context/.scope-base b/docs/context/.scope-base index b9673ab6..701490b3 100644 --- a/docs/context/.scope-base +++ b/docs/context/.scope-base @@ -1 +1 @@ -f3b427ddb4d030baffbaa9073eff9bc8e3f28a7c +4e257f950be02d5b8034f1c584eff8db52b2e323 diff --git a/docs/plans/2026-08-20-sprint-6-staging-blocker.md b/docs/plans/2026-08-20-sprint-6-staging-blocker.md index 5a153458..82ba7401 100644 --- a/docs/plans/2026-08-20-sprint-6-staging-blocker.md +++ b/docs/plans/2026-08-20-sprint-6-staging-blocker.md @@ -3,6 +3,72 @@ > **Status:** `BLOCKED`. Dieses Dokument ersetzt keine Entscheidung — es macht die eine fehlende > Voraussetzung so genau benennbar, dass sie in einem Schritt erfüllbar ist. +> **Nachtrag 21.08.2026 — die Plattform hat gewechselt, der Blocker nicht.** Confluence 30998530 +> stellt das Staging auf **VPS + Coolify + Docker** um; Cloudflare Workers ist kein Zielruntime +> mehr. Wo unten „primäres Cloudflare-Staging" steht, ist das die Formulierung von EYT-142 zum +> Zeitpunkt der Messung, nicht das heutige Ziel. +> +> **Das ändert an diesem Dokument genau nichts Inhaltliches.** Die fehlende Voraussetzung ist +> keine Eigenschaft der Hostingplattform, sondern der Datengrenze — und die ist dieselbe +> geblieben. Nachgemessen am 21.08.2026, wieder read-only: `list_projects` zeigt weiterhin nur +> `ykoijifgweoapitabgxx` („Bazodiac") und kein EasyTree-Projekt; `list_branches` auf +> `inypnrvpawvhgiyagxbd` zeigt weiterhin genau eine Branch `main` mit +> `project_ref == parent_project_ref == inypnrvpawvhgiyagxbd`. +> +> Wer aus „Cloudflare ist weg" schliesst, der Blocker sei weg, hat die falsche Hälfte gelesen. + +> **Nachtrag 2, 21.08.2026 — die Ursache ist jetzt eine Stufe genauer, und eine frühere Annahme +> war falsch.** Nach PO-Freigabe wurde versucht, ein zweites Projekt `easytree-staging` +> (eu-west-2, Org `iudjsdyrammpwhtryrir` „EASTree", `get_cost` → **0 $/Monat**) anzulegen. 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:** In der ersten Lesart galt das pausierte Projekt `ykoijifgweoapitabgxx` +> („Bazodiac", `status: INACTIVE`) als einer der beiden belegten Slots. Das ist falsch. Die +> Supabase-Doku sagt an zwei Stellen wörtlich das Gegenteil — Billing FAQ: „You are entitled to +> two active free projects. **Paused projects do not count towards your quota.**"; About billing: +> „Paused projects do not count towards your free project limit." Bazodiac zu löschen oder zu +> pausieren ist damit **gegenstandslos**. +> +> **Die tatsächliche fehlende Voraussetzung ist damit zweistufig:** +> +> 1. Es existiert keine EasyTree-Datengrenze, deren `project_ref` von `inypnrvpawvhgiyagxbd` +> verschieden ist. (unverändert) +> 2. Der **kostenlose** Weg dorthin ist versperrt, weil die Free-Projekt-Quota **pro Nutzer** +> zählt, nicht pro Organisation, und `DYAI2025` — Owner/Admin von EASTree — sie mit zwei +> aktiven Free-Projekten ausgeschöpft hat. Eines davon ist `inypnrvpawvhgiyagxbd` +> (`ACTIVE_HEALTHY`); **das zweite ist über die Management-API dieses Zugangs nicht sichtbar.** +> +> Punkt 2 ist belegbar und nicht bloss vermutet: `list_projects` liefert reproduzierbar **genau +> ein** Projekt (Bazodiac) und **enthält `inypnrvpawvhgiyagxbd` nicht**, obwohl `get_project` +> darauf antwortet. `list_organizations` liefert nur `rcginbmldqfibmrbtsvr` und **nicht** EASTree, +> obwohl `get_organization` darauf antwortet. Die Aufzählung ist also nachweislich unvollständig — +> jede daraus abgeleitete Projektzählung wäre falsch, und deshalb steht hier keine. +> +> **Was der abgelehnte Aufruf nebenbei belegt:** die Antwort war ein _Quota_-Fehler, kein +> 403/Forbidden. Die Autorisierung ist also durchgelaufen; es fehlt Kontingent, nicht Recht. +> Vorbehalt, weil ungemessen: falls Supabase Quota vor Mitgliedschaft prüft, trägt diese +> Ableitung nicht. +> +> **Nicht-destruktiver nächster Schritt, in dieser Reihenfolge:** +> +> 1. Im Dashboard als `DYAI2025` nachsehen, **welches zweite aktive Free-Projekt** die Quota +> belegt. Nur dort sichtbar — die API-Aufzählung dieses Zugangs ist unvollständig (s. o.). +> 2. Ist es entbehrlich: **pausieren**, nicht löschen. Pausierte Projekte zählen nicht. +> 3. Sonst: eine weitere Free-Plan-Organisation, in der `DYAI2025` **nicht** Owner/Admin ist — +> die FAQ nennt genau diesen Weg („You can create another Free Plan organization or change the +> role of the affected member"). +> 4. Erst wenn beides ausscheidet, die kostenpflichtige Variante: Pro für EASTree, danach zweites +> Projekt oder Preview-Branch (`get_cost` → 0,01344 $/h ≈ 9,81 $/Monat). +> +> **Kein Schreibzugriff hat stattgefunden.** Der abgelehnte `create_project`-Aufruf hat nichts +> angelegt; alles Übrige war read-only. Produktionsdaten wurden zu keinem Zeitpunkt berührt. + ## Worum es geht EYT-137 verlangt die Kernreise „auf dem für Sprint 6 freigegebenen Staging-Ziel", EYT-142 verlangt diff --git a/docs/plans/2026-08-21-eyt-126-scope-autoritaet.md b/docs/plans/2026-08-21-eyt-126-scope-autoritaet.md new file mode 100644 index 00000000..628d1ef1 --- /dev/null +++ b/docs/plans/2026-08-21-eyt-126-scope-autoritaet.md @@ -0,0 +1,213 @@ +# EYT-126 — der Containerpfad liegt ausserhalb der armierten Sprint-6-Scope + +> **Status: `ENTSCHIEDEN` (21.08.2026).** Der PO hat Weg 1 gewählt; Revision 5 ist gesetzt und +> die Bindung neu gezogen. Der Abschnitt „Ausgang“ am Ende hält fest, was daraus wurde — +> einschließlich einer Abweichung vom Entwurf und einer zurückgenommenen Empfehlung. + +## Der Befund + +Der PRIL-Scope-Gate (`scripts/plumbline-scope-guard.sh` → `plumbline-scope-check`) meldet +`PRIL_POLICY_VIOLATION`, `exit_code=3`. Ursache ist **keine** Nachlässigkeit im Slice, sondern +eine ausdrückliche Festlegung im Manifest selbst. Revision 1 von +`docs/scope/easytree-sprint-6-core-journey.scope.json` sagt wörtlich: + +> „Infrastruktur-, API- und supabase-Pfade fuer Slice 0/3 bewusst NICHT enthalten." + +EYT-126 ist genau so ein Infrastrukturpfad. Der Slice kann die Regel nicht einhalten, ohne +aufzuhören, das zu sein, was er ist: ein `Dockerfile` muss unter `apps/api/Dockerfile` liegen, ein +CI-Schritt unter `.github/workflows/ci.yml`. + +## Gemessen, nicht behauptet (21.08.2026) + +Zwei getrennte Befunde. Nur der zweite gehört diesem Slice. + +| Oberfläche | Dateien | davon ausserhalb der Scope | +| ---------------------------------------------------------------------- | ------- | -------------------------- | +| **A** — bereits auf `origin/master`, gegen die armierte Base `f3b427d` | 82 | **28** | +| **B** — nur die Commits dieses Slice (`origin/master...HEAD`) | 16 | **11** | + +**Befund A ist älter als dieser Slice.** Die armierte Base `docs/context/.scope-base` steht auf +`f3b427d` und liegt **78 Commits hinter `origin/master`**. Seither wurden EYT-140, EYT-141, +EYT-106 und die EYT-142-Phase-A gemergt — mit `eslint.config.mjs`, `package.json`, +`pnpm-lock.yaml`, `turbo.json` und weiteren Pfaden, die das Manifest nicht führt. Der Gate war +also **vor** diesem Slice bereits rot, und er meldet bei jedem Lauf 102 statt 29 Dateien. + +Nachprüfbar ohne Checkout: + +```bash +git rev-list --left-right --count f3b427d...origin/master # -> 0 78 +PLUMBLINE_SCOPE_BASE=origin/master bash scripts/plumbline-scope-guard.sh +``` + +**Befund B — die elf Dateien dieses Slice:** + +``` +.dockerignore +.github/workflows/ci.yml +apps/api/Dockerfile +apps/api/test/architecture/deploy-authority-rules.ts +apps/api/test/deploy-authority.test.ts +apps/web/Dockerfile +apps/web/next.config.ts +docker-compose.yml +docs/runbooks/staging-deploy.md +scripts/smoke-container.sh +turbo.json +``` + +## Warum das Manifest nicht von hier aus geändert wird + +`docs/scope/**` steht in der Governance-Scope — mechanisch **könnte** dieser Slice das Manifest +also editieren. Er tut es nicht, aus zwei Gründen: + +1. **Ein Manifest autorisiert nie seine eigene Änderung.** Die Bindung unter + `.plumbline/scope-authority/easytree-sprint-6-core-journey.json` trägt den Digest + `8032224886184ed81e668c0a9072ba255aa4cf7d05fe343117e22278c24f031c`, und + `shasum -a 256 docs/scope/easytree-sprint-6-core-journey.scope.json` liefert **exakt denselben + Wert** (gemessen 21.08.2026). Die Bindung ist scharf; jede Manifeständerung erzeugt + `SCOPE_AUTHORITY_CHANGED` und muss ausdrücklich neu gebunden werden. +2. **Jede der vier bestehenden Revisionen trägt eine Person.** `decision_maker: Product Owner +Benjamin Poersch`, ein `decided_at`, eine `rationale` und `confirmed: true`. Eine Revision 5 + mit demselben Namen zu schreiben, ohne dass diese Entscheidung gefallen ist, wäre keine + Dokumentation, sondern eine gefälschte Freigabe. + +## Was der Gate NICHT blockiert + +Der PRIL-Gate läuft als lokaler Stop-Hook, **nicht** in CI: `grep -c plumbline +.github/workflows/ci.yml` → `0`. Alle **11 Pflichtchecks** sind am Head +`ea9a392ca3e6755659e8b93f69078fd44e434f85` grün (Lauf 32498237919), PR #89 steht auf +`mergeState=CLEAN`. Der rote Gate hindert also nicht den Merge — er hindert die Aussage „fertig". +Das ist der Unterschied, den dieses Dokument offenhalten soll. + +## Zwei Wege, beide brauchen den PO + +### Weg 1 — Revision 5 des bestehenden Manifests + +Die Sprint-6-Scope wird um die Deploymentpfade erweitert. Vorteil: eine Autorität, ein Ledger. +Nachteil: das Manifest beschreibt danach zwei verschiedene Arten von Arbeit. + +Der Block, der nach Bestätigung in `provenance` gehört — **`confirmed` steht bewusst auf `false` +und `decision_maker` ist offen; beides füllt die Entscheidung, nicht dieser Entwurf:** + +```json +{ + "revision": 5, + "origin": "ENTWURF — offen. Auslöser: EYT-126 Containerpfad (Confluence 30998530) und PR #89.", + "decision_maker": "", + "decided_at": "", + "rationale": "Revision 5 nimmt die Deploymentpfade auf, die Revision 1 ausdrücklich ausgeschlossen hatte ('Infrastruktur-, API- und supabase-Pfade fuer Slice 0/3 bewusst NICHT enthalten'). Grund: die Deployment-Entscheidung vom 21.08.2026 (Confluence 30998530) macht den Containerbau zur Voraussetzung des PENDING-Gates 'deploy' desselben Features. Ohne die Aufnahme kann der Slice, der dieses Gate vorbereitet, die Scope nicht einhalten.", + "confirmed": false, + "scope_ergaenzung": { + "product": [ + "apps/api/Dockerfile", + "apps/web/Dockerfile", + ".dockerignore", + "docker-compose.yml", + "turbo.json", + "apps/web/next.config.ts", + "apps/api/test/architecture/deploy-authority-rules.ts", + "apps/api/test/deploy-authority.test.ts" + ], + "governance": [".github/workflows/**", "scripts/**", "docs/runbooks/**"] + } +} +``` + +Nach der Bestätigung ist die Bindung neu zu setzen: neuer `shasum -a 256` des Manifests in +`.plumbline/scope-authority/easytree-sprint-6-core-journey.json`. + +### Weg 2 — EYT-126 als eigenes Plumbline-Feature armieren + +Der Containerpfad bekommt ein eigenes `docs/scope/eyt-126-container-staging.scope.json` samt +eigener Bindung und eigenem Ledger, und `docs/context/.active-feature` wird pro Slice umgestellt. +Vorteil: sauber getrennte Verantwortungen, und es entspricht der Formulierung „Slice 0/3" aus +Revision 1. Nachteil: mehr Governance-Mechanik und ein zweiter Ledger für dasselbe Sprintziel. + +**Empfehlung: Weg 1.** Der Containerbau existiert ausschliesslich, um das `deploy`-Gate desselben +Features passierbar zu machen; zwei Autoritäten für ein Gate sind eine Naht ohne Nutzen. + +## Ausgang — beides erledigt am 21.08.2026 + +**Weg 1 gewählt und ausgeführt.** Revision 5 steht im Manifest, `confirmed: true`, mit dem PO als +`decision_maker`. Die Bindung unter `.plumbline/scope-authority/` wurde neu gesetzt +(`8032224886184ed8…` → `d5b483614d8e81c7…`, geprüft gegen `shasum -a 256` des Manifests). Die +Digest-Konvention wurde vorher gegen **alle vier** bestehenden Revisionen verifiziert, nicht +erraten: `sha256(JSON.stringify(scope, Object.keys(scope).sort()))`, vier von vier stimmen. + +Gegen Revision 5 sind **alle elf** Dateien dieses Slice in der Scope; der Gate meldet mit der +korrekten Base `PRIL scope check passed … (31 changed files)`, Exit 0. + +**Abweichung vom Entwurf oben, laut benannt:** Revision 5 nimmt zusätzlich `docs/architecture/**` +auf. Grund ist Konsistenz — sieben andere `docs/**`-Pfade stehen bereits in der Governance-Scope, +und `docs/architecture/` trägt ADR-001/002/003. Die Folge, die man kennen muss: damit fällt auch +die vorbestehende, **ungetrackte** Datei `docs/architecture/EasyTree – Softwaredokumentation.md` +in die Scope. Sie stammt nicht aus diesem Slice und wurde in diesem Lauf von niemandem geprüft. +`supabase/**` bleibt weiterhin ausgeschlossen. + +### Die veraltete Base — die Begründung hat sich umgedreht + +Der ursprüngliche Absatz hier lautete, das Verschieben der Base sei „ausdrücklich **nicht** Teil +dieses Slice", weil es die geprüfte Oberfläche verkleinert und der Slice davon profitierte. Dieser +Einwand ist mit Revision 5 **gegenstandslos geworden und wird hiermit zurückgenommen**: gemessen +ist der Befund dieses Slice gegen beide Basen **null**. Verschieben ändert an seiner +Rechenschaft nichts, es entfernt nur die 21 bereits auf `master` gemergten, bereits reviewten +Dateien aus der Meldung. + +`docs/context/.scope-base` steht deshalb jetzt auf `origin/master` (`4e257f95…`) statt auf +`f3b427d`. Das ist dieselbe Konvention wie vorher — eine Commit-SHA — und entspricht dem, was eine +Merge-Base nach einem abgeschlossenen `merge`-Gate ist. Das `merge`-Gate dieses Features ist am +20.08.2026 `CLEARED`; `deploy` und `production-verification` bleiben `PENDING`, das Feature bleibt +also zu Recht armiert. + +**Nicht gemergt und nicht versioniert:** `.plumbline/` steht in `.gitignore`. Die neue Bindung +existiert damit nur lokal. Wer den Gate auf einer anderen Maschine fährt, bringt seine eigene +Bindung mit und muss sie einmalig gegen den Manifest-Hash `d5b483614d8e81c7…` setzen. + +## Nachtrag — Revision 6 am 22.08.2026 + +Der Reparatur-Slice zu PR #89 (Laufzeit-Proxyziel, Plan +`docs/plans/2026-08-21-eyt-126-laufzeit-proxyziel.md`) brauchte sechs Pfade, die +Revision 5 nicht deckte. Der PO hat sie am 22.08.2026 bestätigt („Revision 6 +bestätigt"), ausdrücklich ohne weitere Erweiterung. + +``` +apps/web/app/api/** +apps/web/app/health/** +apps/web/app/ready/** +apps/web/instrumentation.ts +apps/api/test/architecture/secret-surface-rules.ts +apps/api/test/container-bindung.test.ts +``` + +Der Grund liegt in einer Messung, nicht in einer Vorliebe: 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 wäre. Der naheliegende Ersatz — eine Next-16-`proxy.ts` — ist zweifach +gemessen ausgeschieden: `NextResponse.rewrite()` auf ein externes Ziel sendet +`x-middleware-rewrite` mit der internen Adresse an den Browser und lässt sich +nicht entfernen, ohne die Weiterleitung abzuschalten; und +`@opennextjs/cloudflare` bricht mit „Node.js middleware is not currently +supported" ab, was den Pflichtjob `build-web` rot machen würde. Übrig bleiben +Route Handler — und die liegen unter `apps/web/app/`, wo Revision 5 nur +`planung/**` und `globals.css` führte. + +**Nichts weiter war nötig.** `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. + +Bindung neu gezogen: `d5b483614d8e81c7…` → `2741c0ec0935d01d…`, geprüft gegen +`shasum -a 256` des Manifests. Die Digest-Konvention wurde vorher erneut gegen +**alle fünf** bestehenden Revisionen verifiziert (5/5), nicht aus diesem +Dokument übernommen. Revisionen 1–5 sind unverändert, kein Pfad ging verloren. +Der Gate meldet mit Base `origin/master`: `PRIL scope check passed … (33 changed +files)`, Exit 0. + +**Zwei Grenzen, in diesem Lauf gemessen und vorher nirgends notiert:** + +1. `.plumbline/` ist per `.gitignore:35` ausgeschlossen, der Feature-Marker + `docs/context/.active-feature` dagegen versioniert. +2. Fehlt die Bindung — etwa in einem frischen Worktree — meldet der Gate + trotzdem `passed` und **nicht** `SCOPE_AUTHORITY_CHANGED`. Gemessen durch + Wegbewegen von `.plumbline/` und erneuten Lauf. Die Manipulationsbindung ist + dort also nicht scharf; wer in einem neuen Worktree arbeitet, kopiert + `.plumbline/` mit. diff --git a/docs/runbooks/staging-deploy.md b/docs/runbooks/staging-deploy.md index 2bf04b36..9ef71c37 100644 --- a/docs/runbooks/staging-deploy.md +++ b/docs/runbooks/staging-deploy.md @@ -1,22 +1,29 @@ -# Runbook — Staging-Deploy, Testdaten, Diagnose und Rollback (EYT-142) +# Runbook — Staging-Deploy, Testdaten, Diagnose und Rollback (EYT-142, EYT-126) > **Vorbedingung, die heute NICHT erfüllt ist.** Dieses Runbook beschreibt einen Weg, der derzeit > **nicht begangen werden darf**. Solange `BLOCKER_ENVIRONMENT_SEPARATION` gilt, ist jeder Schritt > unterhalb von §1 gesperrt — auch ein Healthcheck, auch „nur einmal zum Schauen". > Siehe [`docs/plans/2026-08-20-sprint-6-staging-blocker.md`](../plans/2026-08-20-sprint-6-staging-blocker.md). +> **Zielplattform geändert am 21.08.2026.** Kanonisch ist seither Confluence +> **„EasyTree – Deployment-Entscheidung 21.08.2026: VPS + Coolify + Docker"** (Seite 30998530): +> primär eigener VPS mit Coolify und Docker/OCI, sekundär Railway, **Cloudflare Workers ist kein +> Zielruntime mehr**. Die Cloudflare-Artefakte im Repository bleiben vorerst stehen und werden +> erst nach belegter Container-Parität entfernt (EYT-149) — sie sind historische +> Implementierungsevidenz, kein Deploymentweg. + Das Runbook entsteht **vor** dem ersten Deploy mit Absicht: es macht die Owner-Entscheidung billiger, weil danach nichts mehr erfunden werden muss. Alles hier Beschriebene ist aus -Artefakten abgeleitet, die im Repository stehen und in Phase A gemessen wurden. Was **nicht** -gemessen ist, ist als solches markiert — dieses Dokument behauptet keinen Vollzug. +Artefakten abgeleitet, die im Repository stehen. Was **nicht** gemessen ist, ist als solches +markiert — dieses Dokument behauptet keinen Vollzug. --- ## 1. Das Gate, das zuerst fallen muss -Ein Deploy ist erst zulässig, wenn **alle drei** Bedingungen des Gate A→B erfüllt sind: +Ein Deploy ist erst zulässig, wenn **alle drei** Bedingungen erfüllt sind: -1. **A1–A9 grün.** Erfüllt mit PR #80 auf `master`. +1. **Die Pflicht-CI ist am exakten Head grün**, einschließlich des Container-Smokes aus §6. 2. **Eine eindeutig als NON-PRODUCTION identifizierte EasyTree-Datengrenze existiert und ist nachgewiesen.** **Heute nicht erfüllt.** Prüfbar mit genau einer Frage: @@ -27,7 +34,22 @@ Ein Deploy ist erst zulässig, wenn **alle drei** Bedingungen des Gate A→B erf Nein → abbrechen. Die Messung ist eine Control-Plane-Beobachtung (`list_branches` bzw. `list_projects`), **nicht** eine Abfrage aus der Anwendung und nicht über `easytree_app`. -3. **Owner-Freigabe für Phase B liegt vor.** Ohne (2) gegenstandslos. + Nachgemessen am 21.08.2026: `list_projects` zeigt genau ein Projekt („Bazodiac", + `ykoijifgweoapitabgxx`) und **kein** EasyTree-Projekt; `list_branches` auf + `inypnrvpawvhgiyagxbd` zeigt genau eine Branch `main` mit + `project_ref == parent_project_ref == inypnrvpawvhgiyagxbd`. Die einzige Branch **ist** die + Produktion. Die Bedingung gilt unverändert. + + **Und der kostenlose Weg dorthin ist versperrt.** Ein Versuch, ein zweites Projekt + `easytree-staging` anzulegen (`get_cost` → 0 $/Monat), wurde mit einem **Quota**-Fehler + abgelehnt: die Free-Projekt-Quota zählt **pro Nutzer** über alle Organisationen hinweg, in + denen er Owner oder Admin ist, und `DYAI2025` hat sie mit zwei aktiven Free-Projekten + ausgeschöpft — eines davon ist die Produktion, das zweite ist über diesen Zugang nicht + sichtbar. Ein pausiertes Projekt zählt laut Supabase-Doku **nicht** mit; das Pausieren oder + Löschen von „Bazodiac" hilft deshalb nicht. Die nicht-destruktiven Auswege stehen im + Blockerbericht, Nachtrag 2. + +3. **Owner-Freigabe für den Deploy liegt vor.** Ohne (2) gegenstandslos. **Fail-closed:** Wer (2) nicht positiv belegen kann, hat sie nicht. „Vermutlich Staging" ist kein Nachweis; die Konsequenz eines Irrtums ist ein Schreibzugriff auf die Produktionsdatenbank. @@ -36,20 +58,30 @@ Nachweis; die Konsequenz eines Irrtums ist ein Schreibzugriff auf die Produktion ## 2. Was deployt wird -Zwei Worker aus **derselben** geprüften Codebasis, beide mit im Repository liegender Konfiguration: +Zwei OCI-Images aus **derselben** geprüften Codebasis, beide mit im Repository liegender +Buildbeschreibung: + +| Workload | Dockerfile | Startbefehl | Port | Veröffentlicht | +| -------------- | --------------------- | ------------------------- | ---- | -------------- | +| `easytree-api` | `apps/api/Dockerfile` | `node dist/main.js` | 3001 | **nein** | +| `easytree-web` | `apps/web/Dockerfile` | `node apps/web/server.js` | 3000 | ja | + +Der Hintergrundprozess (Outbox) benutzt **dasselbe API-Image** und überschreibt nur das Kommando +mit `node dist/worker.js`; er öffnet keinen Port. Zwei Images für einen Modulgraphen wären zwei +Stellen, die auseinanderlaufen können. -| Worker | Config | Entry | Build | -| -------------- | ------------------------- | ---------------------- | ---------------------------------------- | -| `easytree-api` | `apps/api/wrangler.jsonc` | `cloudflare/entry.mjs` | `pnpm --filter @easytree/api... build` | -| `easytree-web` | `apps/web/wrangler.jsonc` | `.open-next/worker.js` | OpenNext, Assets aus `.open-next/assets` | +`docker-compose.yml` im Wurzelverzeichnis beschreibt die Topologie: nur `web` veröffentlicht einen +Port, `api` hängt am internen Netz und ist unter dem Dienstnamen `api` erreichbar. Der Browser +sieht deshalb genau eine Origin und niemals eine interne Adresse. -`compatibility_date` ist auf beiden `2025-09-01`; die API braucht zusätzlich -`enable_nodejs_http_server_modules`. Beides ist bewusst gesetzt und **nicht** beliebig -herunterzudrehen — die Nest-Naht hängt daran (A2/A3). +**Build ist reproduzierbar oder er ist keiner.** Beide Dockerfiles installieren mit +`pnpm install --frozen-lockfile` gegen das eingecheckte `pnpm-lock.yaml` und aktivieren pnpm über +`corepack` aus dem `packageManager`-Feld (`pnpm@10.28.0`). Weicht der Lockfile-Stand ab, bricht +der Build ab, statt aufzulösen. -**Railway bleibt kompatibel.** Nichts an diesem Weg entfernt oder bricht den bestehenden -Railway-Pfad; die API startet weiterhin über `dist/main.js`. Reisst ein Cloudflare-Gate, wird -Railway neu bewertet — es werden **keine** Schutzmechanismen gelockert, um Cloudflare zu erzwingen. +**Railway bleibt kompatibel.** Dieselben Dockerfiles, dieselben Startbefehle, dieselben +Variablen. Nichts an diesem Weg führt providergebundene Domain- oder Businesslogik ein — Coolify +ist Orchestrator, nicht Teil der Facharchitektur. --- @@ -58,19 +90,73 @@ Railway neu bewertet — es werden **keine** Schutzmechanismen gelockert, um Clo Der kanonische Variablensatz ist **sieben** Einträge (`packages/config/src/schema.ts`, `ENV_VAR_META`). Die Trennung ist nicht kosmetisch: -**Nicht geheim — stehen in `wrangler.jsonc` unter `vars`:** +**Nicht geheim — dürfen in der Compose-/Coolify-Konfiguration stehen:** `NODE_ENV`, `API_PORT`, `LOG_LEVEL` -**Geheim — werden als Worker-Secret gesetzt, NIE in eine Datei im Repository:** +**Geheim — werden ausschließlich als Plattform-Secret gesetzt, NIE in eine Datei im Repository +und NIE in ein Image:** `DATABASE_URL`, `SUPABASE_URL`, `SUPABASE_ANON_KEY`, `DATABASE_SSL_ROOT_CERT` `DATABASE_SSL_ROOT_CERT` ist in `production` **Pflicht**, nicht optional. In `.env.example` steht es deshalb auskommentiert — ein Platzhalterwert dort wäre schlimmer als keiner, weil er -funktionierend aussähe. +funktionierend aussähe. Der Container-Smoke belegt das negativ: mit `NODE_ENV=production` und +ohne Wurzelzertifikat **verweigert der API-Container den Start und nennt die Variable**. -Drei Regeln, die aus gemessenen Fehlern stammen und nicht verhandelbar sind: +### Die achte Größe: `EASYTREE_API_PROXY_TARGET` ist LAUFZEIT, nicht Bauzeit + +`EASYTREE_API_PROXY_TARGET` ist keine Anwendungsvariable — sie steht bewusst nicht in +`ENV_VAR_META` — sondern die Naht zwischen Web und API. **Sie wird beim START des +Web-Containers gesetzt und bei jeder Anfrage neu gelesen.** + +Das ist gemessen, nicht vermutet (22.08.2026, Next 16.2.11, EYT-126): der Same-Origin-Proxy +liegt in Route Handlern (`apps/web/app/api/[[...pfad]]`, `app/health`, `app/ready`), nicht mehr +in `next.config.ts`-`rewrites()`. Der Container-Smoke baut **ein** Web-Image, hält seinen Digest +fest und startet **denselben Digest** zweimal gegen verschiedene APIs: + +``` +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:191a1af9… nachher=sha256:191a1af9… +``` -- **Keine SSL-Parameter in `DATABASE_URL`.** Die Verbindung wird ausschliesslich in +**Eine frühere Fassung dieses Abschnitts sagte das Gegenteil, und sie war für ihren Stand +korrekt:** ein Web-Build mit `http://buildtime-marker.invalid:9999`, mit einem anderen Wert +gestartet, antwortete auf `/health` mit HTTP 500 und `… ENOTFOUND`. Das galt dem +`rewrites()`-Weg, den es nicht mehr gibt. + +Drei Konsequenzen, die vor dem ersten Deploy bekannt sein müssen: + +- Das Web-**Image** ist weder an ein Ziel noch an einen Anbieter gebunden. Für Coolify und für + Railway wird dieselbe Datei mit demselben Startpfad gebaut, und es gibt **kein** + `--build-arg`, das sich unterscheiden könnte. Der Wert gehört in die Laufzeitumgebung des + Web-Dienstes. +- Eine vergessene Laufzeitvariable **fällt den Serverstart**: `apps/web/instrumentation.ts` + prüft sie einmal beim Hochfahren, und danach beantwortet Next **jede** Route mit 500 — auch + `/` und `/anmelden`. Kein stilles Zurückfallen auf localhost. Ehrliche Grenze: der Prozess + bleibt am Leben und hält den Port, der Compose-Healthcheck auf `/` schlägt fehl und der + Container gilt als `unhealthy`. **Sag nicht, der Container starte nicht.** +- Der Browser sieht die Adresse nie. Sie ist ausdrücklich kein `NEXT_PUBLIC_*`; die + Weiterleitung passiert serverseitig im Web-Container. Der Container-Smoke prüft das + ausgelieferte HTML, **jeden referenzierten Client-Chunk**, die Antwortköpfe und den + `location`-Kopf dagegen — eine absolute Weiterleitung der API auf sich selbst wird in einen + relativen Pfad übersetzt (Pfad und Query bleiben erhalten), ein externes Weiterleitungsziel + bleibt unangetastet. **Jeder übrige Antwortkopf, dessen Wert die interne Adresse nennt, fällt + ersatzlos weg** — `X-Upstream-Url`, `Link: <…>; rel="self"`, `Content-Location`, ein + `Set-Cookie` mit interner `Domain`. Nicht umgeschrieben, sondern weggelassen: ein Kopf ohne + festgelegte Bedeutung trägt keine Struktur, aus der sich eine Übersetzung ableiten ließe. + Fremde Adressen bleiben stehen, und mehrere `Set-Cookie` bleiben mehrere. **Getroffen wird + die Adresse, nicht das Wort:** Kopfnamen werden nicht geprüft, URLs im Wert über ihre Origin + verglichen, `host:port` nur an einer Zeichengrenze und nur bei ausgewiesenem Port, der nackte + Hostname nur als ganzer Wert. Sonst verschluckte der Riegel bei der Topologie + `http://api:3001` jedes `X-Api-Version`, jede Doku-URL auf `api.example.org` und jedes Cookie + `api_session`. Der Smoke belegt **beide** Richtungen mit einem Stub, der sowohl den leckenden + als auch die harmlosen Köpfe nachweislich sendet — sonst wäre weder die Abwesenheit der einen + noch die Anwesenheit der anderen ein Nachweis. + +Drei Regeln zur Datenbankverbindung, die aus gemessenen Fehlern stammen und nicht verhandelbar +sind: + +- **Keine SSL-Parameter in `DATABASE_URL`.** Die Verbindung wird ausschließlich in `apps/api/src/platform/database/pg-connection.ts` gebaut; die Factory entfernt die **gesamte** Query aus der URL. Grund: `pg` merged den geparsten Connection-String **über** das explizite `ssl`-Objekt, womit `?ssl=no-verify` jede Denyliste schlägt. Statische Wächter in @@ -84,17 +170,41 @@ Drei Regeln, die aus gemessenen Fehlern stammen und nicht verhandelbar sind: ## 4. Reihenfolge des Deploys -Migrationen zuerst, Anwendung danach. **Der Cloudflare-Deploy ist niemals eine zweite +Migrationen zuerst, Anwendung danach. **Weder Coolify noch Railway sind eine zweite Migration-Authority** — Eigentümerin des Schemapfads bleibt die Supabase-GitHub-Integration. -Ein Wächter erzwingt das: `apps/api/test/deploy-authority.test.ts` geht rot, sobald ein -`build.command` in einer `wrangler.jsonc` ein Schemawerkzeug aufruft. +Zwei Wächter erzwingen das, beide in `apps/api/test/deploy-authority.test.ts`: der erste liest die +`wrangler.jsonc`, der zweite die beiden `Dockerfile` und `docker-compose.yml`. Ruft dort ein +Build- oder Startbefehl ein Schemawerkzeug auf, gehen sie rot. 1. **Schema.** Migrationen aus `supabase/migrations/` auf das Staging-Ziel anwenden — in Dateireihenfolge, vollständig, fail-closed. Kein Dashboard, kein Studio, keine Handänderung. 2. **Verifikation vor der Anwendung.** Anzahl angewandter Migrationen gegen `ls -1 supabase/migrations/*.sql | wc -l` prüfen. Abweichung → abbrechen. -3. **Secrets setzen** (§3), danach `wrangler deploy` je Worker. -4. **Smokes fahren** (§6), **bevor** irgendjemand die Oberfläche öffnet. +3. **Images bauen** — an den exakten Commit gebunden: + + ```bash + SHA="$(git rev-parse HEAD)" + docker build -f apps/api/Dockerfile --build-arg "GIT_SHA=${SHA}" -t "easytree-api:${SHA}" . + docker build -f apps/web/Dockerfile --build-arg "GIT_SHA=${SHA}" -t "easytree-web:${SHA}" . + ``` + + `GIT_SHA` hat in **beiden** Dockerfiles keinen Vorgabewert mehr und wird geprüft: ohne + `--build-arg` bricht der Bau mit `GIT_SHA fehlt. Baue mit --build-arg GIT_SHA=$(git rev-parse +HEAD).` ab (gemessen 22.08.2026 für beide Images). Ein Image mit `revision=unknown` ist über + diesen Pfad nicht mehr erzeugbar. + + Das Proxyziel wird hier **nicht** übergeben — es ist Laufzeitkonfiguration und steht beim + Start: + + ```bash + docker run -d --name web -e EASYTREE_API_PROXY_TARGET=http://api:3001 "easytree-web:${SHA}" + ``` + + Beide Images tragen den Commit danach als OCI-Label + `org.opencontainers.image.revision`. Das ist das Rollbackziel aus §7 und der Beleg aus §8. + +4. **Secrets setzen** (§3), danach die Workloads starten. +5. **Smokes fahren** (§6), **bevor** irgendjemand die Oberfläche öffnet. --- @@ -117,14 +227,15 @@ Niemals: Produktionsdaten kopieren, echte Personendaten einspielen, echte Stunde Reihenfolge ist nicht beliebig; sie geht vom Billigsten zum Teuersten. -| Symptom | Erste Frage | Werkzeug | -| ------------------------------- | ----------------------------------------------------- | ------------------------------------------------------------------------- | -| Worker startet nicht | Fehlt ein Secret? | Worker-Logs; `ConfigValidationError` **nennt die Variable, nie den Wert** | -| `/ready` = 503 | Ist die DB erreichbar? | `GET /ready` liefert den Indikator, der unten ist | -| Boot bricht mit Rollenfehler ab | Verbindet `DATABASE_URL` als `easytree_app`? | EYT-45-Startgate; das ist korrektes Verhalten, kein Defekt | -| `self-signed certificate` | Ist `DATABASE_SSL_ROOT_CERT` gesetzt und vollständig? | §3; **nicht** mit No-Verify „lösen" | -| Leere Woche statt Fehler | Antwortet die API oder der Proxy? | `EASYTREE_API_PROXY_TARGET` des Web-Workers | -| Publish schlägt fehl | Läuft die Verbindung über den Transaction-Pooler? | Erwartet: der Laufzeitkanal-Riegel greift — siehe unten | +| Symptom | Erste Frage | Werkzeug | +| ------------------------------- | ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | +| Container startet nicht | Fehlt ein Secret? | Containerlogs; `ConfigValidationError` **nennt die Variable, nie den Wert** | +| `/ready` = 503 | Ist die DB erreichbar? | `GET /ready` liefert den Indikator, der unten ist | +| Boot bricht mit Rollenfehler ab | Verbindet `DATABASE_URL` als `easytree_app`? | EYT-45-Startgate; das ist korrektes Verhalten, kein Defekt | +| `self-signed certificate` | Ist `DATABASE_SSL_ROOT_CERT` gesetzt und vollständig? | §3; **nicht** mit No-Verify „lösen" | +| Leere Woche statt Fehler | Antwortet die API oder der Proxy? | `EASYTREE_API_PROXY_TARGET` — Laufzeitvariable des Web-Dienstes, §3. Fehlt sie, antwortet auch `/` mit 500 — dann ist es keine leere Woche | +| Web erreicht die API nicht | Liegen beide Workloads im selben internen Netz? | Dienstname muss aus dem Web-Container auflösbar sein | +| Publish schlägt fehl | Läuft die Verbindung über den Transaction-Pooler? | Erwartet: der Laufzeitkanal-Riegel greift — siehe unten | **Der Pooler-Fall ist wichtig und vorhergesagt, nicht überraschend.** Über den Supavisor- Transaction-Pooler wird `session_user` zu `postgres.`, und `app.is_runtime_channel()` @@ -141,8 +252,24 @@ gegen eine echte Poolerverbindung verifiziert, weil pgTAP als `postgres` läuft. ```bash NODE_ENV=test API_PORT=3001 EXPECT_READY=200 DATABASE_URL=… SUPABASE_URL=… \ SUPABASE_ANON_KEY=… bash scripts/smoke-api.sh # bootet dist, /health, /ready, SIGTERM -bash scripts/smoke-worker.sh # Worker oeffnet KEINEN Port, faehrt saM sauber herunter +bash scripts/smoke-worker.sh # Worker oeffnet KEINEN Port, faehrt sauber herunter bash scripts/smoke-api-role-gate.sh # API startet NICHT, wenn die Rolle nicht RLS-gebunden ist + +# Container-Smoke (EYT-126): baut beide Images, startet die Topologie und +# prueft Health/Readiness, den internen Weg Web -> API, die Abwesenheit der +# internen Adresse im HTML, geheimnisfreie Protokolle und das Verweigern des +# Starts im Produktionsprofil ohne Wurzelzertifikat. +EASYTREE_CONTAINER_SMOKE=required GIT_SHA="$(git rev-parse HEAD)" \ + EASYTREE_SMOKE_DATABASE_URL=… EASYTREE_SMOKE_SUPABASE_URL=… EASYTREE_SMOKE_ANON_KEY=… \ + bash scripts/smoke-container.sh +``` + +Der Container-Smoke läuft in CI am Ende von `db-gates` — dort, wo es eine echte PostgreSQL-Instanz +mit der Rolle `easytree_app` gibt. Er meldet eine greppbare Zeile, und **die** ist die Aussage, +nicht der grüne Haken: + +``` +[container-smoke] mode=required executed=… passed=… skipped=0 ``` Ein fehlgeschlagener Pflicht-Smoke **verhindert die Abnahme**. Er wird nicht wiederholt, bis er @@ -154,14 +281,21 @@ grün ist — er wird verstanden. ### 7.1 Anwendung -Cloudflare hält frühere Worker-Versionen vor; ein Rollback ist die Rückkehr zur zuletzt grünen -Version, **nicht** ein Rebuild aus einem älteren Commit. Voraussetzung ist, dass die -Vorgängerversion bekannt ist — deshalb §8: jeder Deploy wird mit Commit-SHA und Versions-ID -notiert, sonst gibt es kein Ziel zum Zurückkehren. +Ein Rollback ist die Rückkehr zum **zuletzt grünen Image**, nicht ein Rebuild aus einem älteren +Commit. Weil beide Images mit dem Commit getaggt und zusätzlich per OCI-Label +`org.opencontainers.image.revision` gekennzeichnet sind, ist das Ziel maschinell benennbar: + +```bash +docker inspect easytree-api: --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' +``` + +In Coolify heißt der Weg „vorherige Bereitstellung erneut aktivieren"; ohne notierte Commit-SHA +(§8) gibt es kein Ziel zum Zurückkehren. **Nicht gemessen:** dass Coolifys Rollbackfunktion für +diese Anwendung tut, was sie verspricht, ist bislang nicht ausgeführt worden. -Fällt Cloudflare als Plattform aus, ist **Railway der dokumentierte Fallback**: die API läuft dort -unverändert über `dist/main.js`. Das ist der Grund, warum die Railway-Kompatibilität in diesem -Sprint ausdrücklich nicht angetastet wurde. +Fällt der VPS als Plattform aus, ist **Railway der dokumentierte Fallback**: dieselben Dockerfiles, +dieselben Startbefehle. Das ist der Grund, warum die Railway-Kompatibilität in diesem Sprint +ausdrücklich nicht angetastet wurde. ### 7.2 Schema — die asymmetrische Hälfte @@ -187,18 +321,62 @@ EasyTree-Staging-Grenze. Solange die fehlt, ist „Rollback belegt" eine Beschre Ohne diese fünf Angaben ist ein Rollback nicht ausführbar und eine Abnahme nicht prüfbar: 1. **Commit-SHA** des deployten Standes und der CI-Lauf, der an genau diesem Head grün war. -2. **Worker-Versions-IDs** beider Worker — das Rollbackziel. +2. **Image-Tags und OCI-Revision** beider Images — das Rollbackziel. 3. **Migrationsstand** des Ziels (Anzahl und letzte Version) **vor** und **nach** dem Deploy. 4. **`project_ref` des Ziels**, ausgeschrieben — der Beleg, dass §1 (2) eingehalten wurde. -5. **Smoke-Ergebnisse** aus §6. +5. **Smoke-Ergebnisse** aus §6, einschließlich der `[container-smoke]`-Zeile. + +--- + +## 9. Coolify auf dem VPS — was konkret angelegt wird + +**Nicht gemessen:** kein Schritt dieses Abschnitts ist ausgeführt worden; §1 sperrt ihn. + +- **Ressourcentyp:** „Docker Compose" mit dieser Repository-Quelle und `docker-compose.yml` aus + dem Wurzelverzeichnis. Coolify baut damit beide Images selbst aus dem gepinnten Lockfile. +- **Branch/Commit:** ausdrücklich der geprüfte Head, nicht „latest". Coolify zeigt den + deployten Commit an; er muss mit §8 (1) übereinstimmen. +- **Laufzeitvariable:** `EASYTREE_API_PROXY_TARGET` auf den internen Dienstnamen der API + (`http://api:3001` in der Compose-Topologie). Kein öffentlicher Name, kein `https` nach außen. + Ausdrücklich als **Environment-Variable des Web-Dienstes**, nicht als Build-Argument: das + Image ist seit EYT-126 zielneutral, und ein Build-Argument würde es wieder binden. +- **Build-Argument:** ausschließlich `GIT_SHA` (der geprüfte Head). `docker-compose.yml` + erzwingt es mit `${GIT_SHA:?…}` — ohne den Wert scheitert schon `docker compose config`. +- **Secrets:** die vier geheimen Variablen aus §3 als Coolify-Secrets, nicht als Build-Argumente + — ein Build-Argument landet in der Imagehistorie. +- **Öffentliche Domain:** ausschließlich auf `web` (Port 3000). Die API bekommt **keine** + öffentliche Domain; sie ist nur im internen Netz erreichbar. +- **Healthcheck:** die Compose-Definition prüft `/ready` (API) bzw. `/` (Web) mit `node -e` und + globalem `fetch` — die Laufzeit-Images enthalten bewusst weder `curl` noch `wget`. + +--- + +## 10. Railway-Kompatibilität + +Railway ist über Dashboard-Variablen gegen den generischen Startpfad konfiguriert; es gibt +**keine** Railway-Datei im Repository, und dieser Slice legt auch keine an. Kompatibel bleibt der +Weg, weil die Images nichts Providerspezifisches enthalten: derselbe `node dist/main.js`, +dasselbe Variablenset, derselbe Health-/Readiness-Vertrag. Seit EYT-126 unterscheidet sich nicht +einmal mehr das Web-Image: der Wert von `EASYTREE_API_PROXY_TARGET` ist eine Laufzeitvariable des +Dienstes (§3), also **dasselbe Image für Coolify und Railway** — belegt über denselben +Image-Digest gegen zwei Ziele im Container-Smoke. + +**Nicht gemessen:** ein Railway-Deploy aus diesen Dockerfiles ist nicht ausgeführt worden. Belegt +ist die Kompatibilität auf der Ebene „reproduzierbarer Container- und Startpfad", nicht auf der +Ebene „läuft dort". --- -## 9. Stand dieses Dokuments +## 11. Stand dieses Dokuments -Geschrieben am 20.08.2026 gegen `master` `b2d8dbf`. Abgeleitet aus: den beiden `wrangler.jsonc`, -`packages/config/src/schema.ts`, `pg-connection.ts`, den Smoke-Skripten, dem EYT-142-Plan und den -Phase-A-Messungen aus PR #80. +Überarbeitet am 22.08.2026 (EYT-126) auf das Laufzeit-Proxyziel, ausgehend von der Fassung vom +21.08.2026 auf den Containerpfad (Confluence 30998530). Abgeleitet aus: den beiden Dockerfiles, +`docker-compose.yml`, `scripts/smoke-container.sh`, `packages/config/src/schema.ts`, +`pg-connection.ts`, den bestehenden Smoke-Skripten und dem lokal ausgeführten Container-Smoke +(`[container-smoke] mode=local executed=27 passed=27 skipped=0`, 22.08.2026), der ein Image +gegen zwei Ziele, das Nicht-Lecken der internen Adresse in gewöhnlichen Antwortköpfen, das +unveränderte Durchreichen harmloser Köpfe mit demselben Wort und beide Fail-closed-Fälle +belegt. **Noch nie ausgeführt.** Kein Abschnitt unterhalb von §1 ist gegen eine reale Staging-Grenze gelaufen, weil es keine gibt. Beim ersten echten Deploy gehört dieses Runbook gegen die diff --git a/docs/scope/easytree-sprint-6-core-journey.scope.json b/docs/scope/easytree-sprint-6-core-journey.scope.json index a4563374..44adaaf0 100644 --- a/docs/scope/easytree-sprint-6-core-journey.scope.json +++ b/docs/scope/easytree-sprint-6-core-journey.scope.json @@ -14,7 +14,21 @@ "apps/api/test/domain-invariants/**", "apps/api/test/iso-week-parity.test.ts", "packages/ui/src/**", - "packages/ui/test/**" + "packages/ui/test/**", + ".dockerignore", + "docker-compose.yml", + "turbo.json", + "apps/api/Dockerfile", + "apps/web/Dockerfile", + "apps/web/next.config.ts", + "apps/api/test/architecture/deploy-authority-rules.ts", + "apps/api/test/deploy-authority.test.ts", + "apps/web/app/api/**", + "apps/web/app/health/**", + "apps/web/app/ready/**", + "apps/web/instrumentation.ts", + "apps/api/test/architecture/secret-surface-rules.ts", + "apps/api/test/container-bindung.test.ts" ], "governance": [ "docs/canvas/**", @@ -26,7 +40,11 @@ "docs/reality/**", "docs/traceability.md", "CLAUDE.md", - "docs/handoff/AGENT_HANDOFF_v1.3.md" + "docs/handoff/AGENT_HANDOFF_v1.3.md", + ".github/workflows/**", + "scripts/**", + "docs/runbooks/**", + "docs/architecture/**" ] }, "artifacts": { @@ -143,7 +161,7 @@ "origin": "PO-Entscheidung 19.08.2026: Reality-Ledger fuer Sprint 6 einfuehren", "decision_maker": "Product Owner Benjamin Poersch", "decided_at": "2026-08-19T00:06:31+0200", - "rationale": "Revision 4 nimmt docs/reality/** auf. Der Watcher meldete plumbline-reality-check mit Exit 2: die Evidenzledger-Datei fehlte, docs/reality/ existierte im Repository ueberhaupt nicht \u2014 auch nicht fuer Sprint 4 und Sprint 5. Der PO hat entschieden, den Ledger fuer Sprint 6 einzufuehren; das Gate ist damit erstmals passierbar und meldet jetzt den tatsaechlichen Stand (unit-fake) statt Abwesenheit.", + "rationale": "Revision 4 nimmt docs/reality/** auf. Der Watcher meldete plumbline-reality-check mit Exit 2: die Evidenzledger-Datei fehlte, docs/reality/ existierte im Repository ueberhaupt nicht — auch nicht fuer Sprint 4 und Sprint 5. Der PO hat entschieden, den Ledger fuer Sprint 6 einzufuehren; das Gate ist damit erstmals passierbar und meldet jetzt den tatsaechlichen Stand (unit-fake) statt Abwesenheit.", "confirmed": true, "scope": { "product": [ @@ -174,6 +192,110 @@ ] }, "scope_digest": "sha256:e1b3563f1b529b82e510e97724f538b718e9ef164e02235e08d1c2f3c8579c5c" + }, + { + "revision": 5, + "origin": "PO-Entscheidung 21.08.2026: Scope um den Containerpfad erweitern (EYT-126, Confluence 30998530 — VPS + Coolify + Docker); Auslöser ist PR #89 und der rote PRIL-Scope-Gate.", + "decision_maker": "Product Owner Benjamin Poersch", + "decided_at": "2026-08-21T18:05:00+0200", + "rationale": "Revision 5 nimmt die Deploymentpfade auf, die Revision 1 ausdruecklich ausgeschlossen hatte („Infrastruktur-, API- und supabase-Pfade fuer Slice 0/3 bewusst NICHT enthalten“). Grund: die Deployment-Entscheidung vom 21.08.2026 macht den Containerbau zur Voraussetzung des PENDING-Gates „deploy“ DESSELBEN Features; ein Dockerfile kann die alte Regel nicht einhalten, ohne aufzuhoeren ein Dockerfile zu sein. Der PO hat Weg 1 aus docs/plans/2026-08-21-eyt-126-scope-autoritaet.md gewaehlt. ABWEICHUNG VOM ENTWURF, ausdruecklich benannt: zusaetzlich zu dem dort vorgeschlagenen Block 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, die man kennen muss: damit faellt auch die vorbestehende, UNGETRACKTE Datei ‚docs/architecture/EasyTree – Softwaredokumentation.md‘ in die Scope, die nicht aus diesem Slice stammt und von niemandem in diesem Lauf geprueft wurde. supabase/** bleibt weiterhin ausgeschlossen: der Schemapfad gehoert der Supabase-GitHub-Integration, nicht diesem Feature.", + "confirmed": true, + "scope": { + "product": [ + "apps/web/app/planung/**", + "apps/web/app/globals.css", + "apps/web/components/**", + "apps/web/lib/**", + "apps/web/test/**", + "apps/web/e2e/**", + "packages/domain/src/**", + "packages/domain/test/**", + "apps/api/test/domain-invariants/**", + "apps/api/test/iso-week-parity.test.ts", + "packages/ui/src/**", + "packages/ui/test/**", + ".dockerignore", + "docker-compose.yml", + "turbo.json", + "apps/api/Dockerfile", + "apps/web/Dockerfile", + "apps/web/next.config.ts", + "apps/api/test/architecture/deploy-authority-rules.ts", + "apps/api/test/deploy-authority.test.ts" + ], + "governance": [ + "docs/canvas/**", + "docs/vision/**", + "docs/prd/**", + "docs/plans/**", + "docs/context/**", + "docs/scope/**", + "docs/reality/**", + "docs/traceability.md", + "CLAUDE.md", + "docs/handoff/AGENT_HANDOFF_v1.3.md", + ".github/workflows/**", + "scripts/**", + "docs/runbooks/**", + "docs/architecture/**" + ] + }, + "scope_digest": "sha256:fbd66901a4dcac99ff50878669120f9635d623fd7def11738a69d5a910f5a792" + }, + { + "revision": 6, + "origin": "PO-Bestaetigung 22.08.2026: „Revision 6 bestätigt“ — Erweiterung des bestehenden EYT-126/Sprint-6-Scopes um genau sechs Pfade, ausgeloest durch den Reparatur-Slice zu PR #89.", + "decision_maker": "Product Owner Benjamin Poersch", + "decided_at": "2026-08-22T00:06:57+02:00", + "rationale": "Revision 6 nimmt sechs Pfade auf, die unmittelbar noetig sind, um den bereits freigegebenen EYT-126-Containervertrag zu erfuellen: ein identisches Web-OCI-Image muss sein API-Ziel zur LAUFZEIT erhalten koennen, ohne interne Dienstadressen an den Browser zu exponieren. Warum genau diese Pfade, gemessen am 21.08.2026 auf Next 16.2.11: der Same-Origin-Proxy kann nicht in next.config.ts-rewrites() bleiben, weil Next das Ergebnis beim Bauen nach routes-manifest.json schreibt und ein fertiges Image damit an sein Ziel gebunden waere. Der naheliegende Ersatz, eine Next-16-proxy.ts (Node-Middleware), ist zweifach gemessen ausgeschieden: NextResponse.rewrite() auf ein externes Ziel sendet den Kopf x-middleware-rewrite mit der internen Adresse an den Browser und laesst sich nicht entfernen, ohne die Weiterleitung selbst abzuschalten; und @opennextjs/cloudflare bricht mit ‚Node.js middleware is not currently supported‘ ab, was den Pflichtjob build-web rot machen wuerde. Uebrig bleiben Route Handler — daher apps/web/app/api/**, apps/web/app/health/** und apps/web/app/ready/**, die Revision 5 nicht deckte (dort standen unter apps/web/app/ nur planung/** und globals.css). apps/web/instrumentation.ts ist die Startsperre; sie liegt im Wurzelverzeichnis von apps/web und faellt weder unter lib/** noch unter test/**. apps/api/test/architecture/secret-surface-rules.ts muss die eine neue Laufzeit-Umgebungsstelle in ERLAUBTE_UMGEBUNGSLESER aufnehmen, sonst verweigert der Geheimnis-Waechter den Slice — gemessen als roter Lauf mit ‚runtime-reads-no-environment‘. apps/api/test/container-bindung.test.ts ist der neue Regressionswaechter gegen fail-open GIT_SHA und fail-open Proxyziel. AUSDRUECKLICH KEINE weitere Erweiterung: alle uebrigen Dateien des Slice liegen bereits in der Scope — apps/web/lib/** und apps/web/test/** sind seit Revision 1 Platzhalter, next.config.ts, turbo.json, beide Dockerfiles, docker-compose.yml, scripts/** und .github/workflows/** kamen mit Revision 5. Supabase, Staging-Deploy, Produktionsdaten, der Cloudflare-Abbau (EYT-149) und der Merge bleiben ausserhalb dieses Reparatur-Slice; supabase/** bleibt wie in Revision 5 ausgeschlossen.", + "confirmed": true, + "scope": { + "product": [ + "apps/web/app/planung/**", + "apps/web/app/globals.css", + "apps/web/components/**", + "apps/web/lib/**", + "apps/web/test/**", + "apps/web/e2e/**", + "packages/domain/src/**", + "packages/domain/test/**", + "apps/api/test/domain-invariants/**", + "apps/api/test/iso-week-parity.test.ts", + "packages/ui/src/**", + "packages/ui/test/**", + ".dockerignore", + "docker-compose.yml", + "turbo.json", + "apps/api/Dockerfile", + "apps/web/Dockerfile", + "apps/web/next.config.ts", + "apps/api/test/architecture/deploy-authority-rules.ts", + "apps/api/test/deploy-authority.test.ts", + "apps/web/app/api/**", + "apps/web/app/health/**", + "apps/web/app/ready/**", + "apps/web/instrumentation.ts", + "apps/api/test/architecture/secret-surface-rules.ts", + "apps/api/test/container-bindung.test.ts" + ], + "governance": [ + "docs/canvas/**", + "docs/vision/**", + "docs/prd/**", + "docs/plans/**", + "docs/context/**", + "docs/scope/**", + "docs/reality/**", + "docs/traceability.md", + "CLAUDE.md", + "docs/handoff/AGENT_HANDOFF_v1.3.md", + ".github/workflows/**", + "scripts/**", + "docs/runbooks/**", + "docs/architecture/**" + ] + }, + "scope_digest": "sha256:bd4224b230fc968b4d3df7fca50b0a5940c24e143b735274757bdf1c8fbdc4bf" } ] } diff --git a/scripts/read-through-harness.sh b/scripts/read-through-harness.sh index 337993fd..24611bb4 100755 --- a/scripts/read-through-harness.sh +++ b/scripts/read-through-harness.sh @@ -84,7 +84,9 @@ echo "::endgroup::" echo "::group::6-9 Bauen" pnpm --filter @easytree/api^... build pnpm --filter @easytree/api run build:harness -EASYTREE_API_PROXY_TARGET="$API_ORIGIN" pnpm --filter @easytree/web... build +# Der Bau braucht das Ziel seit EYT-126 nicht mehr — es wird beim `next start` +# unten gesetzt und dort bei jeder Anfrage neu gelesen. +pnpm --filter @easytree/web... build pnpm --filter @easytree/web exec playwright install --with-deps chromium echo "::endgroup::" diff --git a/scripts/smoke-container.sh b/scripts/smoke-container.sh new file mode 100755 index 00000000..5e5d1738 --- /dev/null +++ b/scripts/smoke-container.sh @@ -0,0 +1,698 @@ +#!/usr/bin/env bash +# smoke-container.sh (EYT-126) — baut die beiden OCI-Images aus dem AKTUELLEN +# Arbeitsbaum, startet sie als zusammenhaengende Topologie und prueft genau +# das, was ein Deploy auf Coolify/VPS oder Railway spaeter voraussetzt: +# +# 1. Beide Images tragen den erwarteten Commit als OCI-Label. Ohne diese +# Bindung ist "das Image ist gruen" eine Aussage ueber irgendein Image. +# 2. Die API startet im Container, `/health` ist 200 und `/ready` ist 200 mit +# `database: true` — also mit einer ECHTEN Datenbankverbindung. +# 3. Das Web erreicht die API ausschliesslich ueber den internen Dienstnamen. +# Geprueft wird von aussen, durch den Web-Container hindurch. +# 4. Die interne Adresse erreicht den Browser auf KEINEM Weg: weder im +# ausgelieferten HTML noch in einem Client-Chunk noch in einem +# Antwortkopf — weder in einem `location`-Kopf, den die API absolut auf +# sich selbst setzt, noch in einem gewoehnlichen Kopf wie +# `x-upstream-url` oder `link: <…>; rel="self"`. Beide Faelle werden mit +# einem Stub belegt, der den leckenden Kopf nachweislich sendet. +# Und die Gegenrichtung im selben Zug: harmlose Koepfe, die das WORT der +# internen Adresse tragen, aber nicht die Adresse (`X-Api-Version`, eine +# Doku-URL auf einem fremden Host, ein Cookie `api_session`), muessen +# unveraendert ankommen — sonst ist der Riegel kein Schutz, sondern ein +# stiller Ausfall. +# 5. Die Protokolle enthalten weder Datenbankpasswort noch Anon-Key. +# 6. Beide Container laufen als nicht-privilegierter Benutzer. +# 7. Gegenprobe: mit NODE_ENV=production und ohne Wurzelzertifikat MUSS der +# API-Container den Start verweigern. Ohne diesen Fall waere Punkt 2 auch +# dann gruen, wenn die Konfigurationspruefung gar nichts prueft. +# 8. Ein Image, zwei Ziele: derselbe DIGEST wird zweimal gestartet und +# erreicht zwei verschiedene APIs — ohne Neubau dazwischen. Ohne diesen +# Punkt bliebe "anbieterneutral" eine Behauptung. Der Digest zaehlt und +# nicht der Tag: ein Tag laesst sich zwischen zwei Starts umhaengen. +# 9. Gegenprobe dazu: fehlt das Ziel oder ist es ungueltig, wird KEIN +# Anwendungsverkehr bedient (auch `/` antwortet 500) und das Protokoll +# nennt EASYTREE_API_PROXY_TARGET. +# 10. SIGTERM beendet beide Container geordnet. +# +# Fail-closed wie die uebrigen Gates: `EASYTREE_CONTAINER_SMOKE=required` +# (CI) macht jede Auslassung zum Fehler; `local` (Vorgabe) darf ohne Docker +# oder ohne Datenbank mit einer Warnung aussteigen. +# +# Erwartete Umgebung: +# GIT_SHA — Commit, der in den Images stehen MUSS +# EASYTREE_SMOKE_DATABASE_URL — wie der CONTAINER die Datenbank sieht +# EASYTREE_SMOKE_SUPABASE_URL — wie der CONTAINER GoTrue sieht +# EASYTREE_SMOKE_ANON_KEY — oeffentlicher Anon-Key des Stacks +set -Eeuo pipefail + +MODUS="${EASYTREE_CONTAINER_SMOKE:-local}" +NETZ="easytree-smoke-net" +API_CONTAINER="easytree-api-smoke" +WEB_CONTAINER="easytree-web-smoke" +API_IMAGE="easytree-api:smoke" +WEB_IMAGE="easytree-web:smoke" +API_PORT_HOST="${EASYTREE_SMOKE_API_PORT:-3011}" +WEB_PORT_HOST="${EASYTREE_SMOKE_WEB_PORT:-3010}" +# Genau diese Adresse bekommt der Web-Container zur LAUFZEIT (seit EYT-126 wird +# sie nicht mehr in das Image gebacken). Sie ist ein reiner Dienstname im +# Container-Netz — von aussen nicht aufloesbar, was Punkt 3 ueberhaupt erst +# aussagekraeftig macht. +INTERNES_ZIEL="http://${API_CONTAINER}:3001" + +# Die Namen der Ein-Image-zwei-Ziele-Probe stehen HIER und nicht erst in +# Abschnitt 8. Das Skript laeuft mit `set -Eeuo pipefail`, und `aufraeumen()` +# haengt am EXIT-Trap: waere eine dieser Variablen erst weiter unten gesetzt, +# erzeugte ein Abbruch davor einen "unbound variable"-Folgefehler, der den +# echten Grund verdeckt. +ZWEI_ZIEL_A="easytree-stub-a" +ZWEI_ZIEL_B="easytree-stub-b" +ZWEI_WEB="easytree-web-zweiziel" +ZWEI_PORT="${EASYTREE_SMOKE_ZWEI_PORT:-3020}" +# Derselbe Tag, den beide Dockerfiles als `ARG NODE_IMAGE` voreinstellen: nach +# den Bauten liegt er im lokalen Cache, der Stub zieht also nichts nach. +STUB_IMAGE="${EASYTREE_SMOKE_NODE_IMAGE:-node:22.23.1-bookworm-slim}" + +AUSGEFUEHRT=0 +BESTANDEN=0 +UEBERSPRUNGEN=0 + +bericht() { + echo "[container-smoke] mode=${MODUS} executed=${AUSGEFUEHRT} passed=${BESTANDEN} skipped=${UEBERSPRUNGEN}" +} + +pruefung() { + local name="$1" + shift + AUSGEFUEHRT=$((AUSGEFUEHRT + 1)) + if "$@"; then + BESTANDEN=$((BESTANDEN + 1)) + echo " OK ${name}" + else + echo "::error::[container-smoke] ${name} fehlgeschlagen" >&2 + return 1 + fi +} + +aussteigen() { + local grund="$1" + if [ "${MODUS}" = "required" ]; then + echo "::error::[container-smoke] mode=required, aber ${grund}" >&2 + UEBERSPRUNGEN=$((UEBERSPRUNGEN + 1)) + bericht + exit 1 + fi + UEBERSPRUNGEN=$((UEBERSPRUNGEN + 1)) + echo "[container-smoke] uebersprungen: ${grund}" >&2 + bericht + exit 0 +} + +aufraeumen() { + # Jeder Name wird zusaetzlich ueber `${VAR:-}` gelesen: die Konstanten oben + # sind zwar vor dem Trap gesetzt, aber ein Cleanup, das seinerseits an + # `set -u` sterben kann, ist kein Cleanup. + docker rm -f "${WEB_CONTAINER:-}" "${API_CONTAINER:-}" \ + "${ZWEI_WEB:-}" "${ZWEI_ZIEL_A:-}" "${ZWEI_ZIEL_B:-}" >/dev/null 2>&1 || true + docker network rm "${NETZ:-}" >/dev/null 2>&1 || true +} +trap aufraeumen EXIT + +command -v docker >/dev/null 2>&1 || aussteigen "docker ist nicht installiert" +docker info >/dev/null 2>&1 || aussteigen "der Docker-Daemon ist nicht erreichbar" + +: "${GIT_SHA:?GIT_SHA fehlt — ohne erwarteten Commit kann der Smoke nichts binden}" +if [ -z "${EASYTREE_SMOKE_DATABASE_URL:-}" ]; then + aussteigen "EASYTREE_SMOKE_DATABASE_URL fehlt — ohne Datenbank ist /ready kein Nachweis" +fi +: "${EASYTREE_SMOKE_SUPABASE_URL:?EASYTREE_SMOKE_SUPABASE_URL fehlt}" +: "${EASYTREE_SMOKE_ANON_KEY:?EASYTREE_SMOKE_ANON_KEY fehlt}" + +aufraeumen + +echo "== 1/10 Images bauen (GIT_SHA=${GIT_SHA}) ==" +docker build -f apps/api/Dockerfile --build-arg "GIT_SHA=${GIT_SHA}" -t "${API_IMAGE}" . +# KEIN --build-arg EASYTREE_API_PROXY_TARGET mehr: das Ziel ist seit EYT-126 +# Laufzeitkonfiguration. Dass der Bau hier ohne die Variable durchlaeuft, ist +# bereits die erste Haelfte des Nachweises. +docker build -f apps/web/Dockerfile \ + --build-arg "GIT_SHA=${GIT_SHA}" \ + -t "${WEB_IMAGE}" . + +# Ab hier zaehlt der Digest, nicht der Tag. Ein Tag laesst sich zwischen zwei +# Starts umhaengen; der Digest nicht — und genau "kein Neubau dazwischen" ist +# die Aussage, die dieser Smoke belegen soll. +WEB_DIGEST="$(docker image inspect "${WEB_IMAGE}" --format '{{.Id}}')" +echo " Web-Image-Digest: ${WEB_DIGEST}" + +label_ist_head() { + local image="$1" + local ist + ist="$(docker inspect "${image}" --format '{{index .Config.Labels "org.opencontainers.image.revision"}}')" + if [ "${ist}" != "${GIT_SHA}" ]; then + echo " Label=${ist} erwartet=${GIT_SHA}" >&2 + return 1 + fi + echo " revision=${ist}" +} +laeuft_als_node() { + local image="$1" + local benutzer + benutzer="$(docker inspect "${image}" --format '{{.Config.User}}')" + [ "${benutzer}" = "node" ] || { + echo " User=${benutzer:-} erwartet=node" >&2 + return 1 + } +} + +echo "== 2/10 Bindung an den Head ==" +pruefung "API-Image traegt den Head als OCI-Revision" label_ist_head "${API_IMAGE}" +pruefung "Web-Image traegt den Head als OCI-Revision" label_ist_head "${WEB_IMAGE}" +pruefung "API-Image laeuft nicht als root" laeuft_als_node "${API_IMAGE}" +pruefung "Web-Image laeuft nicht als root" laeuft_als_node "${WEB_IMAGE}" + +echo "== 3/10 Topologie starten ==" +docker network create "${NETZ}" >/dev/null +# NODE_ENV=test und nicht production: das Produktionsprofil verlangt ein +# TLS-Wurzelzertifikat und verbietet localhost-URLs — beides hat ein lokaler +# Supabase-Stack nicht. Dieselbe Einschraenkung haben die bestehenden +# Prozess-Smokes. Das Produktionsprofil wird stattdessen in Schritt 7 negativ +# belegt. +docker run -d --name "${API_CONTAINER}" \ + --network "${NETZ}" --network-alias "${API_CONTAINER}" \ + --add-host=host.docker.internal:host-gateway \ + -p "127.0.0.1:${API_PORT_HOST}:3001" \ + -e NODE_ENV=test \ + -e API_PORT=3001 \ + -e "DATABASE_URL=${EASYTREE_SMOKE_DATABASE_URL}" \ + -e "SUPABASE_URL=${EASYTREE_SMOKE_SUPABASE_URL}" \ + -e "SUPABASE_ANON_KEY=${EASYTREE_SMOKE_ANON_KEY}" \ + "${API_IMAGE}" >/dev/null + +warte_auf() { + local url="$1" + for _ in $(seq 1 60); do + if curl -fsS -o /dev/null "${url}"; then return 0; fi + sleep 1 + done + return 1 +} + +api_gesund() { warte_auf "http://127.0.0.1:${API_PORT_HOST}/health"; } +pruefung "API-Container antwortet auf /health" api_gesund || { + docker logs "${API_CONTAINER}" 2>&1 | tail -40 >&2 + bericht + exit 1 +} + +api_bereit() { + local status koerper + koerper="$(curl -s -o /tmp/container-ready.json -w '%{http_code}' "http://127.0.0.1:${API_PORT_HOST}/ready")" + status="${koerper}" + echo " ready=${status} $(cat /tmp/container-ready.json)" + [ "${status}" = "200" ] || return 1 + # Nicht nur 200: die Datenbankanzeige MUSS positiv sein. Ein `/ready`, das + # ohne Datenbank 200 liefert, waere der Fehler, den dieser Smoke sucht. + grep -q '"database"' /tmp/container-ready.json || return 1 + ! grep -qE '"(status|state)"[[:space:]]*:[[:space:]]*"(down|error)"' /tmp/container-ready.json +} +pruefung "API-Container ist bereit und sieht die Datenbank" api_bereit + +# Der Digest, nicht der Tag — und das Ziel als LAUFZEIT-Variable. +docker run -d --name "${WEB_CONTAINER}" \ + --network "${NETZ}" \ + -p "127.0.0.1:${WEB_PORT_HOST}:3000" \ + -e NODE_ENV=production \ + -e PORT=3000 -e HOSTNAME=0.0.0.0 \ + -e "EASYTREE_API_PROXY_TARGET=${INTERNES_ZIEL}" \ + "${WEB_DIGEST}" >/dev/null + +web_erreichbar() { warte_auf "http://127.0.0.1:${WEB_PORT_HOST}/"; } +pruefung "Web-Container liefert die Shell aus" web_erreichbar || { + docker logs "${WEB_CONTAINER}" 2>&1 | tail -40 >&2 + bericht + exit 1 +} + +echo "== 4/10 Interner Weg Web -> API ==" +# Der entscheidende Nachweis: `${INTERNES_ZIEL}` ist von HIER aus nicht +# aufloesbar. Kommt trotzdem die Antwort der API zurueck, dann ist sie durch +# den Web-Container gelaufen. +web_proxied_health() { + local durch direkt + durch="$(curl -fsS "http://127.0.0.1:${WEB_PORT_HOST}/health")" + direkt="$(curl -fsS "http://127.0.0.1:${API_PORT_HOST}/health")" + echo " ueber web=${durch}" + [ -n "${durch}" ] && [ "${durch}" = "${direkt}" ] +} +pruefung "Web reicht /health ueber den Dienstnamen an die API weiter" web_proxied_health + +web_proxied_ready() { + local status + status="$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:${WEB_PORT_HOST}/ready")" + echo " ueber web ready=${status}" + [ "${status}" = "200" ] +} +pruefung "Web reicht /ready an die API weiter" web_proxied_ready + +ziel_von_aussen_unaufloesbar() { + # Gegenprobe zur Aussagekraft: waere der Dienstname auch vom Host aus + # erreichbar, bewiese Schritt 4 nichts ueber den internen Weg. + ! curl -fsS -m 5 -o /dev/null "${INTERNES_ZIEL}/health" +} +pruefung "der interne Dienstname ist vom Host aus NICHT erreichbar" ziel_von_aussen_unaufloesbar + +echo "== 5/10 Der Browser sieht die interne Adresse nicht ==" +seite_ohne_interne_adresse() { + local html + html="$(curl -fsS "http://127.0.0.1:${WEB_PORT_HOST}/")" + # `case` statt `printf | grep -q`: `grep -q` beendet sich beim ERSTEN Treffer, + # `printf` bekommt EPIPE, und unter `set -o pipefail` ist die Pipeline dann + # fehlgeschlagen — obwohl grep gefunden hat. Bei einer Leckpruefung ist das + # die gefaehrliche Richtung: das gefundene Leck saehe aus wie keines. + case "${html}" in + *"${API_CONTAINER}"*) + echo " Dienstname steht im ausgelieferten HTML" >&2 + return 1 + ;; + esac + case "${html}" in + *"${INTERNES_ZIEL}"*) + echo " internes Ziel steht im ausgelieferten HTML" >&2 + return 1 + ;; + esac + case "${html}" in + *"&2 + return 1 + ;; + esac +} +pruefung "das ausgelieferte HTML nennt die interne API-Adresse nicht" seite_ohne_interne_adresse + +chunks_ohne_interne_adresse() { + # Das HTML allein reicht nicht: der Wert koennte in einem Client-Chunk stehen + # und von dort aus jederzeit im Browser landen. + local html skripte pfad js + html="$(curl -fsS "http://127.0.0.1:${WEB_PORT_HOST}/")" + skripte="$(printf '%s' "${html}" | grep -oE '/_next/static/[^"]+\.js' | sort -u)" + [ -n "${skripte}" ] || { + echo " kein Client-Chunk referenziert — die Pruefung waere vakuos" >&2 + return 1 + } + for pfad in ${skripte}; do + js="$(curl -fsS "http://127.0.0.1:${WEB_PORT_HOST}${pfad}")" || return 1 + case "${js}" in + *"${API_CONTAINER}"*) + echo " Dienstname steht im Client-Chunk ${pfad}" >&2 + return 1 + ;; + esac + done + echo " $(printf '%s\n' ${skripte} | wc -l | tr -d ' ') Client-Chunks geprueft" +} +pruefung "kein Client-Chunk nennt die interne API-Adresse" chunks_ohne_interne_adresse + +koepfe_ohne_interne_adresse() { + local koepfe + koepfe="$(curl -fsS -D - -o /dev/null "http://127.0.0.1:${WEB_PORT_HOST}/health")" + [ -n "${koepfe}" ] || return 1 + # Kleingeschrieben verglichen, weil Kopfnamen und -werte in beliebiger + # Schreibweise kommen duerfen; `tr` liest die Eingabe vollstaendig und kann + # deshalb — anders als `grep -q` — kein EPIPE ausloesen. + local klein + klein="$(printf '%s' "${koepfe}" | tr 'A-Z' 'a-z')" + case "${klein}" in + *"${API_CONTAINER}"*) + echo " interne Adresse steht in den Antwortkoepfen" >&2 + printf '%s\n' "${koepfe}" >&2 + return 1 + ;; + esac + # `x-middleware-rewrite` war der gemessene Leckweg der verworfenen + # proxy.ts-Variante. Er darf nicht durch die Hintertuer zurueckkommen. + case "${klein}" in + *x-middleware-rewrite*) + echo " x-middleware-rewrite steht in den Antwortkoepfen" >&2 + return 1 + ;; + esac +} +pruefung "die Antwortkoepfe nennen die interne API-Adresse nicht" koepfe_ohne_interne_adresse + +echo "== 6/10 Protokolle ohne Geheimnisse ==" +protokolle_ohne_geheimnis() { + local logs passwort + logs="$(docker logs "${API_CONTAINER}" 2>&1; docker logs "${WEB_CONTAINER}" 2>&1)" + # Das Passwort aus der Verbindungszeichenkette, ohne sie selbst zu drucken. + passwort="$(printf '%s' "${EASYTREE_SMOKE_DATABASE_URL}" | sed -n 's#^[a-z+]*://[^:]*:\([^@]*\)@.*#\1#p')" + if [ -n "${passwort}" ]; then + case "${logs}" in + *"${passwort}"*) + echo " Datenbankpasswort steht im Protokoll" >&2 + return 1 + ;; + esac + fi + case "${logs}" in + *"${EASYTREE_SMOKE_ANON_KEY}"*) + echo " Anon-Key steht im Protokoll" >&2 + return 1 + ;; + esac + # Eingangsbremse: haette der Aufruf gar keine Ausgabe geliefert, waere die + # Pruefung vakuoes gruen. + [ -n "${logs}" ] +} +pruefung "weder Datenbankpasswort noch Anon-Key stehen in den Protokollen" protokolle_ohne_geheimnis + +echo "== 7/10 Gegenprobe: production ohne Wurzelzertifikat MUSS scheitern ==" +production_ohne_zertifikat_scheitert() { + local ausgabe status=0 + ausgabe="$(docker run --rm --network "${NETZ}" \ + --add-host=host.docker.internal:host-gateway \ + -e NODE_ENV=production \ + -e API_PORT=3001 \ + -e "DATABASE_URL=${EASYTREE_SMOKE_DATABASE_URL}" \ + -e "SUPABASE_URL=${EASYTREE_SMOKE_SUPABASE_URL}" \ + -e "SUPABASE_ANON_KEY=${EASYTREE_SMOKE_ANON_KEY}" \ + "${API_IMAGE}" 2>&1)" || status=$? + if [ "${status}" -eq 0 ]; then + echo " Der Container startete OHNE Wurzelzertifikat im Produktionsprofil." >&2 + return 1 + fi + # Nicht irgendein Fehlschlag: es muss DIESE Variable sein. + case "${ausgabe}" in + *DATABASE_SSL_ROOT_CERT*) ;; + *) + echo " Start scheiterte, aber nicht an DATABASE_SSL_ROOT_CERT:" >&2 + printf '%s\n' "${ausgabe}" | tail -20 >&2 + return 1 + ;; + esac + echo " Start verweigert, DATABASE_SSL_ROOT_CERT benannt" +} +pruefung "Produktionsprofil ohne DATABASE_SSL_ROOT_CERT verweigert den Start" production_ohne_zertifikat_scheitert + +echo "== 8/10 Ein Image, zwei Ziele, kein Neubau ==" +# Die PO-Forderung woertlich: derselbe Digest wird zweimal gestartet, jedes Mal +# gegen eine andere API, und es MUSS jedes Mal die richtige antworten. Zwei +# Stub-Container statt der echten API, weil der Nachweis ueber die +# UNTERSCHEIDUNG laeuft — zwei echte APIs waeren nicht unterscheidbar. +# +# Derselbe Stub beantwortet ausserdem zwei Weiterleitungsfaelle, weil `location` +# der zweite Weg ist, auf dem die interne Adresse den Browser erreichen koennte. +# +# Auf JEDER 200er-Antwort sendet der Stub zusaetzlich zwei Koepfe, die seine +# EIGENE interne Adresse nennen — `x-upstream-url` und ein `link: <…>; +# rel="self"`. Beides sind reale Formen (Weiterleitungsketten, Hypermedia), und +# beide sind fuer same-origin-JavaScript ueber `response.headers` lesbar. +# +# Daneben stehen VIER harmlose Koepfe, und sie sind nicht bloss Beiwerk. Der +# Riegel hat zwei Fehlerrichtungen, und ein Smoke, der nur die eine misst, ist +# halb blind: +# +# * `x-stub-marke` beantwortet "kommt ueberhaupt etwas durch"; +# * `x-api-version`, `x-documentation` (eine FREMDE Adresse) und das Cookie +# `api_session` tragen das Wort "api" und **nicht** die interne Autoritaet. +# Sie muessen ankommen. Eine frueherere Fassung des Riegels verglich den +# nackten Dienstnamen als Teilstring und ueber den Kopfnamen mit — bei der +# vorgesehenen Topologie `http://api:3001` haette das genau diese drei +# verschluckt. Gemessen wird diese Fehlerrichtung scharf in der +# Unit-Suite (dort heisst das Ziel woertlich `api`); hier belegt sie, dass +# der Weg durch den echten Container dieselbe Antwort gibt. +STUB_PROGRAMM='const http=require("http");const name=process.env.STUB_NAME;const eigen=process.env.STUB_ORIGIN;http.createServer((q,s)=>{if(q.url.indexOf("weiterleitung-intern")!==-1){s.writeHead(302,{location:eigen+"/foo?x=1"});return s.end();}if(q.url.indexOf("weiterleitung-extern")!==-1){s.writeHead(302,{location:"https://login.example.org/oauth?state=xyz"});return s.end();}s.writeHead(200,{"content-type":"application/json","x-upstream-url":eigen+"/private",link:"<"+eigen+"/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=/"]});s.end(JSON.stringify({stub:name,pfad:q.url}));}).listen(3001,"0.0.0.0")' + +stub_starten() { + local name="$1" + docker rm -f "${name}" >/dev/null 2>&1 || true + docker run -d --name "${name}" --network "${NETZ}" --network-alias "${name}" \ + -e "STUB_NAME=${name}" \ + -e "STUB_ORIGIN=http://${name}:3001" \ + "${STUB_IMAGE}" \ + node -e "${STUB_PROGRAMM}" >/dev/null +} + +web_gegen_ziel_starten() { + local ziel="$1" + docker rm -f "${ZWEI_WEB}" >/dev/null 2>&1 || true + docker run -d --name "${ZWEI_WEB}" --network "${NETZ}" \ + -p "127.0.0.1:${ZWEI_PORT}:3000" \ + -e NODE_ENV=production -e PORT=3000 -e HOSTNAME=0.0.0.0 \ + -e "EASYTREE_API_PROXY_TARGET=${ziel}" \ + "${WEB_DIGEST}" >/dev/null + warte_auf "http://127.0.0.1:${ZWEI_PORT}/" || { + docker logs "${ZWEI_WEB}" 2>&1 | tail -20 >&2 + return 1 + } +} + +ziel_antwortet() { + local erwartet="$1" ziel="$2" gelesen + web_gegen_ziel_starten "${ziel}" || return 1 + gelesen="$(curl -fsS "http://127.0.0.1:${ZWEI_PORT}/health")" + echo " ziel=${ziel} -> ${gelesen}" + case "${gelesen}" in + *"\"stub\":\"${erwartet}\""*) return 0 ;; + *) return 1 ;; + esac +} + +stub_starten "${ZWEI_ZIEL_A}" +stub_starten "${ZWEI_ZIEL_B}" + +pruefung "derselbe Digest erreicht Ziel A" \ + ziel_antwortet "${ZWEI_ZIEL_A}" "http://${ZWEI_ZIEL_A}:3001" +pruefung "derselbe Digest erreicht Ziel B — ohne Neubau" \ + ziel_antwortet "${ZWEI_ZIEL_B}" "http://${ZWEI_ZIEL_B}:3001" + +digest_unveraendert() { + local jetzt + jetzt="$(docker image inspect "${WEB_IMAGE}" --format '{{.Id}}')" + echo " vorher=${WEB_DIGEST} nachher=${jetzt}" + [ "${jetzt}" = "${WEB_DIGEST}" ] +} +pruefung "der Image-Digest ist zwischen beiden Laeufen unveraendert" digest_unveraendert + +echo "== 8b/10 Weiterleitungen lecken die interne Adresse nicht ==" +# Der Stub B laeuft noch und ist das aktuelle Ziel des Web-Containers. +# `-D -` liest die Koepfe, `--max-redirs 0` folgt der Weiterleitung NICHT: was +# hier steht, ist genau das, was der Browser zu sehen bekaeme. +ort_von() { + curl -fsS -D - -o /dev/null --max-redirs 0 "http://127.0.0.1:${ZWEI_PORT}$1" 2>/dev/null \ + | grep -i '^location:' | tr -d '\r' | sed 's/^[Ll]ocation:[[:space:]]*//' +} + +interne_weiterleitung_wird_uebersetzt() { + local ort + ort="$(ort_von "/api/v1/weiterleitung-intern")" + echo " location=${ort}" + [ -n "${ort}" ] || { + echo " kein location-Kopf — die Pruefung waere vakuos" >&2 + return 1 + } + case "${ort}" in + *"${ZWEI_ZIEL_B}"*) + echo " die interne Adresse steht im location-Kopf und erreicht den Browser" >&2 + return 1 + ;; + esac + # Pfad und Query MUESSEN erhalten bleiben, sonst ist die Uebersetzung eine + # Verstuemmelung und keine Uebersetzung. + [ "${ort}" = "/foo?x=1" ] +} +pruefung "eine absolute Weiterleitung auf das interne Ziel erreicht den Browser relativ" \ + interne_weiterleitung_wird_uebersetzt + +externe_weiterleitung_bleibt() { + local ort + ort="$(ort_von "/api/v1/weiterleitung-extern")" + echo " location=${ort}" + [ "${ort}" = "https://login.example.org/oauth?state=xyz" ] +} +pruefung "ein externes Weiterleitungsziel bleibt semantisch unveraendert" \ + externe_weiterleitung_bleibt + +echo "== 8c/10 Auch gewoehnliche Antwortkoepfe lecken die interne Adresse nicht ==" +# `location` ist nicht der einzige Kopf, der eine Adresse traegt. Der Stub B — +# weiterhin das Ziel des Web-Containers — nennt seine eigene interne Adresse in +# `x-upstream-url` und in `link`. Beide muessen hinter dem Proxy verschwunden +# sein, ohne dass dabei der harmlose Kopf mit verschwindet. +stub_leckt_direkt() { + # Ohne diesen Schritt waere die Pruefung darunter vakuos: sendet der Stub den + # leckenden Kopf gar nicht, beweist seine Abwesenheit hinter dem Proxy + # nichts. Gelesen wird AUS dem Containernetz heraus, denn von aussen ist der + # Stub nicht erreichbar — genau das ist ja der Punkt. + local ausgabe + ausgabe="$(docker run --rm --network "${NETZ}" \ + -e "STUB_URL=http://${ZWEI_ZIEL_B}:3001" "${STUB_IMAGE}" \ + node -e 'fetch(process.env.STUB_URL+"/health").then(r=>{for(const k of ["x-upstream-url","link","x-stub-marke","x-api-version","x-documentation"])console.log(k+"="+r.headers.get(k));console.log("set-cookie="+r.headers.getSetCookie().join(" || "));}).catch(e=>{console.log("fehler="+e.message);process.exitCode=1})')" || return 1 + printf ' direkt vom Stub: %s\n' "$(printf '%s' "${ausgabe}" | tr '\n' ' ')" + case "${ausgabe}" in + *"${ZWEI_ZIEL_B}"*) ;; + *) + echo " der Stub nennt seine interne Adresse in KEINEM Kopf — die Pruefung darunter waere vakuos" >&2 + return 1 + ;; + esac + # Und die Eingangsbremsen muss es beim Stub auch wirklich geben — alle vier, + # sonst prueft die Erhaltungsseite darunter weniger, als sie behauptet. + local bremse + for bremse in "x-stub-marke=harmlos" "x-api-version=1" \ + "x-documentation=https://api.example.org/public" "api_session=abc123"; do + case "${ausgabe}" in + *"${bremse}"*) ;; + *) + echo " der Stub sendet '${bremse}' nicht — die Erhaltungspruefung waere blind" >&2 + printf '%s\n' "${ausgabe}" >&2 + return 1 + ;; + esac + done +} +pruefung "der Stub nennt seine interne Adresse tatsaechlich in einem Nicht-location-Kopf" \ + stub_leckt_direkt + +gewoehnliche_koepfe_ohne_leck() { + local koepfe klein + koepfe="$(curl -fsS -D - -o /dev/null "http://127.0.0.1:${ZWEI_PORT}/health")" + [ -n "${koepfe}" ] || return 1 + # `tr` liest die Eingabe vollstaendig und kann deshalb — anders als + # `grep -q` — kein EPIPE ausloesen, das unter `pipefail` einen Treffer in + # einen Fehlschlag verkehren wuerde. + klein="$(printf '%s' "${koepfe}" | tr 'A-Z' 'a-z')" + case "${klein}" in + *"${ZWEI_ZIEL_B}"*) + echo " die interne Adresse steht in den Antwortkoepfen" >&2 + printf '%s\n' "${koepfe}" >&2 + return 1 + ;; + esac + case "${klein}" in + *x-upstream-url*) + echo " der leckende Kopf x-upstream-url erreicht den Browser" >&2 + printf '%s\n' "${koepfe}" >&2 + return 1 + ;; + esac + # Eingangsbremse: der harmlose Kopf des Stubs MUSS durchkommen. Sonst bewiese + # die Abwesenheit oben nur, dass ueberhaupt nichts durchgereicht wird. + case "${klein}" in + *x-stub-marke*) ;; + *) + echo " auch der harmlose Kopf x-stub-marke fehlt — es wird gar nichts durchgereicht" >&2 + printf '%s\n' "${koepfe}" >&2 + return 1 + ;; + esac + echo " x-upstream-url und link entfernt, x-stub-marke durchgereicht" +} +pruefung "kein gewoehnlicher Antwortkopf traegt die interne Adresse zum Browser" \ + gewoehnliche_koepfe_ohne_leck + +harmlose_koepfe_ueberleben() { + # Die andere Fehlerrichtung. Der Riegel darf nur die ADRESSE treffen, nicht + # das Wort: alle drei Koepfe hier tragen "api", keiner die interne + # Autoritaet. Verschluckt der Proxy sie, ist er kaputt — nur eben still. + local koepfe klein fehlt=0 + koepfe="$(curl -fsS -D - -o /dev/null "http://127.0.0.1:${ZWEI_PORT}/health")" + [ -n "${koepfe}" ] || return 1 + klein="$(printf '%s' "${koepfe}" | tr 'A-Z' 'a-z')" + local erwartet + for erwartet in "x-api-version: 1" "x-documentation: https://api.example.org/public" \ + "api_session=abc123" "stub_sitzung=def456"; do + case "${klein}" in + *"${erwartet}"*) ;; + *) + echo " der harmlose Kopf '${erwartet}' wurde unterwegs verschluckt" >&2 + fehlt=1 + ;; + esac + done + if [ "${fehlt}" -ne 0 ]; then + printf '%s\n' "${koepfe}" >&2 + return 1 + fi + # Zwei Cookies bleiben zwei — der Riegel darf nicht zusammenfalten. + local kekse + kekse="$(printf '%s' "${koepfe}" | tr -d '\r' | grep -ci '^set-cookie:' || true)" + echo " set-cookie-Koepfe: ${kekse}" + [ "${kekse}" = "2" ] || { + echo " erwartet waren zwei getrennte set-cookie-Koepfe" >&2 + return 1 + } + echo " x-api-version, x-documentation und beide Cookies unveraendert durchgereicht" +} +pruefung "harmlose Koepfe mit dem Wort 'api' ueberleben den Riegel unveraendert" \ + harmlose_koepfe_ueberleben + +echo "== 9/10 Fail-closed zur Laufzeit ==" +start_verweigert() { + local beschreibung="$1" antwort + shift + docker rm -f "${ZWEI_WEB}" >/dev/null 2>&1 || true + docker run -d --name "${ZWEI_WEB}" --network "${NETZ}" \ + -p "127.0.0.1:${ZWEI_PORT}:3000" \ + -e NODE_ENV=production -e PORT=3000 -e HOSTNAME=0.0.0.0 \ + "$@" "${WEB_DIGEST}" >/dev/null + sleep 8 + # Die Startseite, nicht `/health`: die Zusage lautet, dass KEIN normaler + # Anwendungsverkehr bedient wird. + antwort="$(curl -s -o /dev/null -w '%{http_code}' -m 10 "http://127.0.0.1:${ZWEI_PORT}/")" + echo " ${beschreibung}: / -> HTTP ${antwort}" + [ "${antwort}" = "500" ] || return 1 + # Nicht irgendein 500: es MUSS diese Variable sein. + # Erst einlesen, dann vergleichen. `docker logs | grep -q` ist hier gemessen + # unzuverlaessig: grep beendet sich beim ersten Treffer, `docker logs` stirbt + # an EPIPE, und `pipefail` macht daraus einen Fehlschlag — die Pruefung ging + # rot, obwohl das Protokoll die Variable nannte (22.08.2026). + local protokoll + protokoll="$(docker logs "${ZWEI_WEB}" 2>&1)" + case "${protokoll}" in + *EASYTREE_API_PROXY_TARGET*) ;; + *) + echo " 500, aber das Protokoll nennt EASYTREE_API_PROXY_TARGET nicht" >&2 + printf '%s\n' "${protokoll}" | tail -20 >&2 + return 1 + ;; + esac +} +pruefung "ohne Proxyziel wird kein Anwendungsverkehr bedient" \ + start_verweigert "Ziel fehlt" +pruefung "mit ungueltigem Proxyziel wird kein Anwendungsverkehr bedient" \ + start_verweigert "Ziel ungueltig" -e "EASYTREE_API_PROXY_TARGET=ftp://api:3001" + +docker rm -f "${ZWEI_WEB}" "${ZWEI_ZIEL_A}" "${ZWEI_ZIEL_B}" >/dev/null 2>&1 || true + +echo "== 10/10 Geordnetes Herunterfahren ==" +# Zwei verschiedene Erwartungen, und der Unterschied ist gemessen, nicht +# geraten (21.08.2026): +# +# * Die NestJS-API ruft `enableShutdownHooks()` und beendet sich mit 0. +# * Der Next-Standalone-Server registriert KEINEN SIGTERM-Handler. Node +# beendet den Prozess dann sofort mit 143 (128+15). Das ist ein prompter +# Abgang auf das Signal hin, kein Abwuergen. +# +# Was in beiden Faellen NICHT passieren darf, ist 137 (128+9): dann hat Docker +# nach Ablauf der Frist SIGKILL geschickt, weil der Prozess nicht reagiert hat. +# Genau diesen Fall soll der Schritt fangen — deshalb wird der Code benannt und +# nicht bloss "ungleich null" geprueft. +stoppt_geordnet() { + local container="$1" erlaubt="$2" code + docker stop -t 15 "${container}" >/dev/null + code="$(docker inspect "${container}" --format '{{.State.ExitCode}}')" + echo " ${container} exit=${code} erlaubt=${erlaubt}" + if [ "${code}" = "137" ]; then + echo " 137 = SIGKILL nach Fristablauf: der Prozess hat SIGTERM ignoriert." >&2 + return 1 + fi + case " ${erlaubt} " in + *" ${code} "*) return 0 ;; + *) return 1 ;; + esac +} +pruefung "Web-Container beendet sich auf SIGTERM ohne SIGKILL" stoppt_geordnet "${WEB_CONTAINER}" "0 143" +pruefung "API-Container beendet sich geordnet auf SIGTERM (Exitcode 0)" stoppt_geordnet "${API_CONTAINER}" "0" + +bericht diff --git a/turbo.json b/turbo.json index 11f0088c..6796b04a 100644 --- a/turbo.json +++ b/turbo.json @@ -3,7 +3,7 @@ "tasks": { "build": { "dependsOn": ["^build"], - "env": ["EASYTREE_API_PROXY_TARGET", "WORKERS_CI", "CF_PAGES"], + "env": ["EASYTREE_NEXT_OUTPUT"], "outputs": ["dist/**", ".next/**", "!.next/cache/**", ".open-next/**"] }, "lint": {},