From 7e7c8ff6d5ec751395ab3eb550b9bef07f9f9d50 Mon Sep 17 00:00:00 2001 From: DYAI2025 Date: Fri, 21 Aug 2026 17:23:02 +0200 Subject: [PATCH 01/22] =?UTF-8?q?feat(deploy):=20EYT-126=20=E2=80=94=20Web?= =?UTF-8?q?=20und=20API=20als=20reproduzierbare=20OCI-Images=20bauen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Confluence 30998530 stellt das Staging auf VPS + Coolify + Docker um. Damit brauchen beide Workloads eine Buildbeschreibung im Repository; bisher war der Deploymentpfad ausschliesslich ueber Dashboard-Variablen konfiguriert. Beide Dockerfiles bauen aus dem Wurzelverzeichnis mit `pnpm install --frozen-lockfile` und `corepack`, laufen als `node` statt als root und tragen den Commit als OCI-Label `org.opencontainers.image.revision`. Der Outbox-Worker benutzt dasselbe API-Image und ueberschreibt nur das Kommando. In `docker-compose.yml` veroeffentlicht nur `web` einen Port; die API haengt am internen Netz, damit der Browser genau eine Origin sieht. `EASYTREE_API_PROXY_TARGET` ist dabei BAUZEIT-Konfiguration, und das ist jetzt gemessen statt offen: gebaut mit `http://buildtime-marker.invalid:9999` und gestartet mit `EASYTREE_API_PROXY_TARGET=http://127.0.0.1:3999` antwortet `/health` mit HTTP 500 und protokolliert `ENOTFOUND buildtime-marker.invalid`. `next start` liest die Weiterleitungen aus dem gebauten routes-manifest.json. Der Kommentar in `lib/api-proxy-target.ts` nannte das bisher ausdruecklich ungeprueft. `output: "standalone"` ist ein Schalter und keine Konstante: der Cloudflare-Bundlebau in `build-web` erwartet die Standardausgabe, und sein Abbau ist EYT-149. `resolveNextOutput` ist fail-closed — ein unbekannter Wert wirft, statt still auf die Standardausgabe zurueckzufallen. Gegenmutation ausgefuehrt: `throw` durch `return {}` ersetzt macht `test/next-output.test.ts` rot ("expected function to throw an error, but it didn't"), Ruecknahme-Diff leer. --- .dockerignore | 47 +++++++++++++++ apps/api/Dockerfile | 96 +++++++++++++++++++++++++++++++ apps/web/Dockerfile | 93 ++++++++++++++++++++++++++++++ apps/web/lib/api-proxy-target.ts | 18 ++++-- apps/web/lib/next-output.ts | 55 ++++++++++++++++++ apps/web/next.config.ts | 12 ++++ apps/web/test/next-output.test.ts | 53 +++++++++++++++++ docker-compose.yml | 88 ++++++++++++++++++++++++++++ turbo.json | 2 +- 9 files changed, 459 insertions(+), 5 deletions(-) create mode 100644 .dockerignore create mode 100644 apps/api/Dockerfile create mode 100644 apps/web/Dockerfile create mode 100644 apps/web/lib/next-output.ts create mode 100644 apps/web/test/next-output.test.ts create mode 100644 docker-compose.yml 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/apps/api/Dockerfile b/apps/api/Dockerfile new file mode 100644 index 00000000..3bd91844 --- /dev/null +++ b/apps/api/Dockerfile @@ -0,0 +1,96 @@ +# 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 +ARG GIT_SHA=unknown +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/web/Dockerfile b/apps/web/Dockerfile new file mode 100644 index 00000000..9616d2f2 --- /dev/null +++ b/apps/web/Dockerfile @@ -0,0 +1,93 @@ +# syntax=docker/dockerfile:1.7 +# +# easyTree Web-Shell (Next.js App Router) als OCI-Workload — EYT-126. +# +# ## Das Proxyziel ist BAUZEIT-Konfiguration. Gemessen, nicht vermutet. +# +# `next.config.ts` loest `EASYTREE_API_PROXY_TARGET` in `rewrites()` auf. Next +# schreibt das Ergebnis beim Bauen nach `.next/routes-manifest.json`; `next +# start` liest die Weiterleitungen aus diesem Manifest und NICHT erneut aus der +# Umgebung. +# +# Nachgemessen am 21.08.2026 auf Next 16.2.11: gebaut mit +# `http://buildtime-marker.invalid:9999`, gestartet mit +# `EASYTREE_API_PROXY_TARGET=http://127.0.0.1:3999` — der Server antwortete auf +# `/health` mit HTTP 500 und protokollierte +# `Failed to proxy http://buildtime-marker.invalid:9999/health … ENOTFOUND`. +# Der Laufzeitwert wurde also ignoriert. +# +# Konsequenz, die hier ehrlich stehen muss: das Web-IMAGE ist an sein Ziel +# gebunden, nicht an einen Anbieter. Dieselbe Datei, derselbe Startpfad und +# dieselben Argumente bauen es fuer Coolify/VPS wie fuer Railway — nur der +# Wert unterscheidet sich. "Ein Image fuer beide Plattformen gleichzeitig" +# waere dagegen eine Behauptung, die die Messung nicht deckt. +# +# Fail-closed ist es trotzdem: `next build` setzt NODE_ENV=production, und +# `normalizeProxyTarget` wirft dort ohne expliziten Wert. Ein vergessenes +# `--build-arg` faellt den Build, statt still auf localhost zu zeigen. +# +# ## Der Browser sieht diese Adresse nie +# +# Das Ziel ist ausdruecklich KEIN `NEXT_PUBLIC_*`. Es wird serverseitig +# aufgeloest; im Browserbuendel steht es nicht. Die Weiterleitung passiert im +# Next-Server im Container. + +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 +ARG EASYTREE_API_PROXY_TARGET +ENV EASYTREE_API_PROXY_TARGET=${EASYTREE_API_PROXY_TARGET} +# `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 +ARG GIT_SHA=unknown +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/lib/api-proxy-target.ts b/apps/web/lib/api-proxy-target.ts index 82921ede..5e86874e 100644 --- a/apps/web/lib/api-proxy-target.ts +++ b/apps/web/lib/api-proxy-target.ts @@ -10,10 +10,20 @@ * * ## Build-Konfiguration, nicht Laufzeitschalter * - * `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. + * `rewrites()` laeuft beim BAUEN. Next schreibt das Ergebnis nach + * `.next/routes-manifest.json`, und `next start` liest die Weiterleitungen von + * dort — NICHT erneut aus der Umgebung. + * + * Das ist gemessen und nicht mehr offen (21.08.2026, Next 16.2.11, EYT-126): + * gebaut mit `http://buildtime-marker.invalid:9999`, gestartet mit + * `EASYTREE_API_PROXY_TARGET=http://127.0.0.1:3999` — der Server antwortete auf + * `/health` mit HTTP 500 und protokollierte + * `Failed to proxy http://buildtime-marker.invalid:9999/health … ENOTFOUND`. + * Eine fruehere Fassung dieses Kommentars liess die Frage ausdruecklich offen. + * + * Fuer den Containerbetrieb heisst das: der Wert wird als `--build-arg` in das + * Web-Image gebacken. Ein bereits gebautes Image laesst sich zur Laufzeit NICHT + * auf ein anderes Ziel umschalten. * * Deshalb ist diese Variable bewusst NICHT `NEXT_PUBLIC_*`. Alles mit diesem * Praefix wird in das Browserbuendel eingebacken und beim Build festgeschrieben diff --git a/apps/web/lib/next-output.ts b/apps/web/lib/next-output.ts new file mode 100644 index 00000000..5209d17a --- /dev/null +++ b/apps/web/lib/next-output.ts @@ -0,0 +1,55 @@ +/** + * 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 deshalb Bauzeitkonfiguration, genau wie + * `EASYTREE_API_PROXY_TARGET`, und traegt aus demselben Grund KEIN + * `NEXT_PUBLIC_`-Praefix. + * + * ## 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/next.config.ts b/apps/web/next.config.ts index abaf7c65..10864e04 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -1,6 +1,9 @@ +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). @@ -22,6 +25,15 @@ import { resolveBuildProxyTarget } from "./lib/api-proxy-target"; * ins Leere laufen. */ const nextConfig: NextConfig = { + /** + * 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), async rewrites() { const target = resolveBuildProxyTarget(process.env); return [ 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/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..374188ad --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,88 @@ +# 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. +# +# Zwei 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. + +services: + api: + build: + context: . + dockerfile: apps/api/Dockerfile + args: + GIT_SHA: ${GIT_SHA:-unknown} + 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: + GIT_SHA: ${GIT_SHA:-unknown} + # BAUZEIT, nicht Laufzeit — gemessen am 21.08.2026, siehe den Kopf von + # apps/web/Dockerfile. `next start` liest die Weiterleitungen aus dem + # gebauten routes-manifest.json und ignoriert die Umgebung. + EASYTREE_API_PROXY_TARGET: ${EASYTREE_API_PROXY_TARGET:-http://api:3001} + 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" + 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/turbo.json b/turbo.json index 11f0088c..35888106 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_API_PROXY_TARGET", "EASYTREE_NEXT_OUTPUT", "WORKERS_CI", "CF_PAGES"], "outputs": ["dist/**", ".next/**", "!.next/cache/**", ".open-next/**"] }, "lint": {}, From b1c313cc9fb9d511310258af5e30feeb559e19b4 Mon Sep 17 00:00:00 2001 From: DYAI2025 Date: Fri, 21 Aug 2026 17:23:23 +0200 Subject: [PATCH 02/22] =?UTF-8?q?test(ci):=20EYT-126=20=E2=80=94=20Contain?= =?UTF-8?q?er-Smoke=20an=20den=20exakten=20Head=20binden?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ein Image, das baut, ist noch kein Image, das laeuft. Der Smoke startet beide Container als zusammenhaengende Topologie und prueft, was ein Deploy spaeter voraussetzt: OCI-Label gegen den Head, nicht-privilegierter Benutzer, `/health`, `/ready` mit ECHTER Datenbank, den Weg Web -> API ausschliesslich ueber den internen Dienstnamen, dass derselbe Dienstname vom Host aus NICHT aufloesbar ist (sonst bewiese der vorige Punkt nichts), dass das ausgelieferte HTML die interne Adresse nicht nennt, geheimnisfreie Protokolle und geordnetes Herunterfahren. Dazu die Gegenprobe, ohne die die Konfigurationspruefung auch dann gruen waere, wenn sie nichts prueft: mit `NODE_ENV=production` und ohne `DATABASE_SSL_ROOT_CERT` MUSS der API-Container den Start verweigern und die Variable benennen. Der Smoke haengt am Ende von `db-gates` und bekommt bewusst keinen eigenen Job — ein neuer Job waere kein Pflichtcheck, bis jemand mit Adminrechten das Ruleset neu anwendet, und beide Kopien der Pflichtcheck-Liste muessten mitgezogen werden. Nur dort gibt es ausserdem eine echte PostgreSQL-Instanz mit der Rolle `easytree_app`. Er meldet `[container-smoke] mode=required executed=… passed=… skipped=0`; ein separater Schritt prueft die Zeile, weil ein weggeduckter Smoke sonst wie ein bestandener aussaehe. `pruefeContainerDeployAutoritaet` zieht die Regel gegen eine zweite Migrationsautoritaet auf Dockerfiles und Compose nach: Coolify und Railway duerfen so wenig Schemawerkzeuge aufrufen wie ein Cloudflare-Build. Kommentarzeilen bleiben aussen vor, sonst erzieht der Waechter zum Verschweigen. Lokal gemessen (arm64, colima, Postgres 17 mit easytree_app): `[container-smoke] mode=required executed=15 passed=15 skipped=0`. Drei Gegenmutationen ausgefuehrt und wieder zurueckgenommen: GIT_SHA=deadbeef… setzt das Label auf deadbeef… (Bindungspruefung waere rot); `USER node` entfernt ergibt `user=''` (Nicht-root-Pruefung waere rot); Web-Image auf `http://127.0.0.1:3001` gebaut liefert ueber den Web-Container HTTP 500 statt der API-Antwort (Weiterleitungspruefung waere rot). --- .github/workflows/ci.yml | 43 +++ .../architecture/deploy-authority-rules.ts | 47 ++- apps/api/test/deploy-authority.test.ts | 65 +++- scripts/smoke-container.sh | 313 ++++++++++++++++++ 4 files changed, 466 insertions(+), 2 deletions(-) create mode 100755 scripts/smoke-container.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e6b3c712..50f31518 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -629,6 +629,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. 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/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/scripts/smoke-container.sh b/scripts/smoke-container.sh new file mode 100755 index 00000000..8aa77437 --- /dev/null +++ b/scripts/smoke-container.sh @@ -0,0 +1,313 @@ +#!/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 ausgelieferte Seite nennt die interne Adresse nicht. +# 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. 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 wird in das Web-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" + +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() { + docker rm -f "${WEB_CONTAINER}" "${API_CONTAINER}" >/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/8 Images bauen (GIT_SHA=${GIT_SHA}) ==" +docker build -f apps/api/Dockerfile --build-arg "GIT_SHA=${GIT_SHA}" -t "${API_IMAGE}" . +docker build -f apps/web/Dockerfile \ + --build-arg "GIT_SHA=${GIT_SHA}" \ + --build-arg "EASYTREE_API_PROXY_TARGET=${INTERNES_ZIEL}" \ + -t "${WEB_IMAGE}" . + +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/8 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/8 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 + +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 \ + "${WEB_IMAGE}" >/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/8 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/8 Der Browser sieht die interne Adresse nicht ==" +seite_ohne_interne_adresse() { + local html + html="$(curl -fsS "http://127.0.0.1:${WEB_PORT_HOST}/")" + if printf '%s' "${html}" | grep -qF "${API_CONTAINER}"; then + echo " Dienstname steht im ausgelieferten HTML" >&2 + return 1 + fi + if printf '%s' "${html}" | grep -qF "${INTERNES_ZIEL}"; then + echo " internes Ziel steht im ausgelieferten HTML" >&2 + return 1 + fi + printf '%s' "${html}" | grep -qF "&2 + return 1 + } +} +pruefung "das ausgelieferte HTML nennt die interne API-Adresse nicht" seite_ohne_interne_adresse + +echo "== 6/8 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}" ] && printf '%s' "${logs}" | grep -qF "${passwort}"; then + echo " Datenbankpasswort steht im Protokoll" >&2 + return 1 + fi + if printf '%s' "${logs}" | grep -qF "${EASYTREE_SMOKE_ANON_KEY}"; then + echo " Anon-Key steht im Protokoll" >&2 + return 1 + fi + # 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/8 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. + printf '%s' "${ausgabe}" | grep -qF "DATABASE_SSL_ROOT_CERT" || { + echo " Start scheiterte, aber nicht an DATABASE_SSL_ROOT_CERT:" >&2 + printf '%s\n' "${ausgabe}" | tail -20 >&2 + return 1 + } + echo " Start verweigert, DATABASE_SSL_ROOT_CERT benannt" +} +pruefung "Produktionsprofil ohne DATABASE_SSL_ROOT_CERT verweigert den Start" production_ohne_zertifikat_scheitert + +echo "== 8/8 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 From fbc7d2c4b38a50873ee5fb7aefe52038a7aad560 Mon Sep 17 00:00:00 2001 From: DYAI2025 Date: Fri, 21 Aug 2026 17:23:37 +0200 Subject: [PATCH 03/22] docs(EYT-142): den Staging-Weg auf den Containerpfad umschreiben MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Das Runbook beschrieb zwei Cloudflare-Worker als Deployziel. Seit der Entscheidung vom 21.08.2026 (Confluence 30998530) ist das falsch, und ein falsches Runbook ist schlechter als keines — es wird beim ersten Deploy befolgt. Neu beschrieben: die beiden Images und ihre Startbefehle, die Bauzeitnatur von `EASYTREE_API_PROXY_TARGET` samt Messung, der Container-Smoke unter den Pflicht-Smokes, das Rollback ueber Imagetag und OCI-Revision, was in Coolify konkret angelegt wird und woran die Railway-Kompatibilitaet haengt. Unveraendert bleibt, was unveraendert gilt: das Gate aus §1. Die Messung vom 21.08.2026 ist eingetragen — `list_projects` zeigt kein EasyTree-Projekt, `list_branches` auf inypnrvpawvhgiyagxbd zeigt genau eine Branch, deren project_ref und parent_project_ref identisch sind. Es gibt also weiterhin keine Non-Production-Datengrenze, und jeder Schritt unterhalb von §1 bleibt gesperrt. Was nicht ausgefuehrt ist, steht weiterhin als "nicht gemessen" da: der Coolify-Abschnitt, das Rollback, der Railway-Deploy und das Testdaten-Bootstrapskript. Das Dokument behauptet keinen Vollzug. --- CLAUDE.md | 41 ++++++ docs/runbooks/staging-deploy.md | 226 +++++++++++++++++++++++++------- 2 files changed, 216 insertions(+), 51 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ea66c2e3..94cd4846 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -539,6 +539,47 @@ 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 BAUZEIT-Konfiguration, nicht Laufzeitschalter — gemessen +21.08.2026 auf Next 16.2.11.** Ein Web-Build mit `http://buildtime-marker.invalid:9999`, +gestartet mit `EASYTREE_API_PROXY_TARGET=http://127.0.0.1:3999`, antwortete auf `/health` mit +HTTP 500 und protokollierte `Failed to proxy http://buildtime-marker.invalid:9999/health … +ENOTFOUND`. `next start` liest die Weiterleitungen aus dem gebauten `.next/routes-manifest.json`. +Der Kommentar in `lib/api-proxy-target.ts` nannte das früher "ungeprüft"; jetzt ist es geprüft. +Konsequenz: das Web-**Image** ist an sein Ziel gebunden, nicht an einen Anbieter — dieselbe Datei +und derselbe Startpfad bauen es für Coolify wie für Railway, nur das `--build-arg` unterscheidet +sich. Fail-closed bleibt es, weil `next build` NODE_ENV=production setzt und +`normalizeProxyTarget` dort ohne expliziten Wert wirft. + +`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 das ausgelieferte HTML die +interne Adresse nicht nennt, geheimnisfreie Protokolle, das Verweigern des Starts im +Produktionsprofil ohne `DATABASE_SSL_ROOT_CERT` und geordnetes Herunterfahren. 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`). 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/docs/runbooks/staging-deploy.md b/docs/runbooks/staging-deploy.md index 2bf04b36..0d1c34c6 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,13 @@ 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. + +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 +49,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 +81,48 @@ 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. - -Drei Regeln, die aus gemessenen Fehlern stammen und nicht verhandelbar sind: - -- **Keine SSL-Parameter in `DATABASE_URL`.** Die Verbindung wird ausschliesslich in +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**. + +### Die achte Größe: `EASYTREE_API_PROXY_TARGET` ist BAUZEIT, nicht Laufzeit + +`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 Bauen des Web-Images +festgeschrieben.** + +Das ist gemessen, nicht vermutet (21.08.2026, Next 16.2.11): ein Web-Build mit +`http://buildtime-marker.invalid:9999`, gestartet mit +`EASYTREE_API_PROXY_TARGET=http://127.0.0.1:3999`, antwortete auf `/health` mit HTTP 500 und +protokollierte `Failed to proxy http://buildtime-marker.invalid:9999/health … ENOTFOUND`. `next +start` liest die Weiterleitungen aus dem gebauten `.next/routes-manifest.json` und **nicht** +erneut aus der Umgebung. + +Drei Konsequenzen, die vor dem ersten Deploy bekannt sein müssen: + +- Das Web-**Image** ist an sein Ziel gebunden, nicht an einen Anbieter. Für Coolify und für + Railway wird dieselbe Datei mit demselben Startpfad gebaut — nur das Argument unterscheidet + sich (`--build-arg EASYTREE_API_PROXY_TARGET=…`). „Ein Image für beide Plattformen + gleichzeitig" wäre eine Behauptung, die die Messung nicht deckt. +- Ein vergessenes Build-Argument **fällt den Build**: `next build` setzt `NODE_ENV=production`, + und `normalizeProxyTarget` wirft dort ohne expliziten Wert. Kein stilles Zurückfallen auf + localhost. +- Der Browser sieht die Adresse trotzdem nie. Sie ist ausdrücklich kein `NEXT_PUBLIC_*`; die + Weiterleitung passiert serverseitig im Web-Container. Der Container-Smoke prüft das ausgeliefert + HTML dagegen. + +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 +136,30 @@ 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}" \ + --build-arg "EASYTREE_API_PROXY_TARGET=http://api:3001" -t "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 +182,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` — Bauzeitwert des Web-Images, §3 | +| 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 +207,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 +236,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: -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. +```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 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 +276,53 @@ 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. +- **Build-Argument:** `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. +- **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. Der einzige Unterschied ist der Wert +von `EASYTREE_API_PROXY_TARGET` beim Bauen des Web-Images (§3). + +**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 21.08.2026 auf den Containerpfad (Confluence 30998530), ausgehend von der Fassung +vom 20.08.2026 gegen `master` `b2d8dbf`. 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 der Messung zum Bauzeitverhalten von +`EASYTREE_API_PROXY_TARGET`. **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 From ea9a392ca3e6755659e8b93f69078fd44e434f85 Mon Sep 17 00:00:00 2001 From: DYAI2025 Date: Fri, 21 Aug 2026 17:26:18 +0200 Subject: [PATCH 04/22] docs(EYT-142): den Blockerbericht gegen die falsche Schlussfolgerung absichern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Der Bericht vom 20.08.2026 zitiert EYT-142 mit "primaerem Cloudflare-Staging". Seit dem 21.08.2026 ist Cloudflare kein Zielruntime mehr — und genau daraus laesst sich der falsche Schluss ziehen, mit der Plattform sei auch der Blocker erledigt. Ist er nicht. Die fehlende Voraussetzung ist keine Eigenschaft der Hostingplattform, sondern der Datengrenze. Der Nachtrag sagt das und traegt die Wiederholungsmessung vom 21.08.2026 ein: list_projects zeigt weiterhin kein EasyTree-Projekt, list_branches auf inypnrvpawvhgiyagxbd weiterhin genau eine Branch, deren project_ref und parent_project_ref identisch sind. Der Rest des Dokuments bleibt unangetastet — es ist ein datierter Messbericht, kein lebendes Dokument. --- docs/plans/2026-08-20-sprint-6-staging-blocker.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) 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..02da72a3 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,20 @@ > **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. + ## Worum es geht EYT-137 verlangt die Kernreise „auf dem für Sprint 6 freigegebenen Staging-Ziel", EYT-142 verlangt From 081009899a2186b66ad2a6fef0e1e7f59b1cf915 Mon Sep 17 00:00:00 2001 From: DYAI2025 Date: Fri, 21 Aug 2026 17:49:58 +0200 Subject: [PATCH 05/22] docs(EYT-126): die Scope-Autoritaet fuer den Containerpfad entscheidungsreif machen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Der PRIL-Scope-Gate meldet PRIL_POLICY_VIOLATION (exit 3). Die Ursache ist keine Nachlaessigkeit im Slice, sondern eine ausdrueckliche Festlegung: Revision 1 des Sprint-6-Manifests schliesst "Infrastruktur-, API- und supabase-Pfade fuer Slice 0/3 bewusst" aus. Ein Dockerfile kann diese Regel nicht einhalten, ohne aufzuhoeren ein Dockerfile zu sein. Gemessen und getrennt, damit die Entscheidung auf Zahlen steht statt auf einer Fehlermeldung mit 102 Dateien: * Oberflaeche A (bereits auf origin/master, gegen die armierte Base f3b427d): 82 Dateien, davon 28 ausserhalb der Scope. Die Base liegt 78 Commits zurueck, der Gate war also VOR diesem Slice bereits rot. * Oberflaeche B (nur dieser Slice): 16 Dateien, davon 11 ausserhalb — genau die elf Deploymentpfade. Das Manifest wird von hier aus NICHT geaendert, obwohl docs/scope/** in der Governance-Scope steht. Die Bindung unter .plumbline/scope-authority/ ist scharf (Digest und shasum stimmen ueberein, gemessen), und jede der vier bestehenden Revisionen traegt den PO namentlich mit confirmed:true. Eine fuenfte zu schreiben, ohne dass die Entscheidung gefallen ist, waere eine gefaelschte Freigabe, keine Dokumentation. Das Dokument enthaelt deshalb den fertigen Revisionsblock mit confirmed:false und offenem decision_maker, die Alternative "EYT-126 eigenstaendig armieren", und den Hinweis, dass der Gate ein lokaler Stop-Hook ist und nicht in ci.yml steht — die 11 Pflichtchecks sind gruen, PR #89 ist mergeState=CLEAN. --- .../2026-08-21-eyt-126-scope-autoritaet.md | 134 ++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 docs/plans/2026-08-21-eyt-126-scope-autoritaet.md 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..74866d73 --- /dev/null +++ b/docs/plans/2026-08-21-eyt-126-scope-autoritaet.md @@ -0,0 +1,134 @@ +# EYT-126 — der Containerpfad liegt ausserhalb der armierten Sprint-6-Scope + +> **Status: `ENTSCHEIDUNG_OFFEN`.** Dieses Dokument entscheidet nichts. Es macht eine +> PO-Entscheidung in einem Schritt ausführbar und belegt, warum sie nötig ist. + +## 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. + +## Unabhängig davon: die veraltete Base + +`docs/context/.scope-base` auf `f3b427d` stehen zu lassen, macht jeden künftigen Lauf des Gates +unlesbar — er meldet dauerhaft die 28 bereits gemergten Dateien mit. Das ist ein eigener, +kleinerer Beschluss und ausdrücklich **nicht** Teil dieses Slice: eine Base zu verschieben +verkleinert die geprüfte Oberfläche, und das entscheidet niemand nebenbei, schon gar nicht der +Slice, dessen Befund dadurch kleiner würde. From 913527ba2130856391fcc4457c2fe57dbc925fb4 Mon Sep 17 00:00:00 2001 From: DYAI2025 Date: Fri, 21 Aug 2026 18:35:28 +0200 Subject: [PATCH 06/22] =?UTF-8?q?chore(scope):=20EYT-126=20=E2=80=94=20Rev?= =?UTF-8?q?ision=205=20setzen=20und=20die=20veraltete=20Scope-Base=20nachz?= =?UTF-8?q?iehen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PO-Entscheidung vom 21.08.2026: Weg 1 aus docs/plans/2026-08-21-eyt-126-scope-autoritaet.md. Revision 5 nimmt die Deploymentpfade auf, die Revision 1 ausdruecklich ausgeschlossen hatte — der Containerbau existiert ausschliesslich, um das PENDING-Gate "deploy" desselben Features passierbar zu machen. Die Digest-Konvention wurde vorher gegen ALLE VIER bestehenden Revisionen verifiziert statt erraten: sha256(JSON.stringify(scope, Object.keys(scope).sort())) — vier von vier stimmen. Revision 5 rechnet nach derselben Regel, und der Top-Level-scope ist identisch mit dem der letzten Revision. Abweichung vom freigegebenen Entwurf, laut benannt statt still: zusaetzlich ist docs/architecture/** aufgenommen. Grund ist Konsistenz — sieben andere docs/**-Pfade stehen bereits in der Governance-Scope, docs/architecture/ traegt ADR-001/002/003. Folge: damit faellt auch die vorbestehende, UNGETRACKTE Datei "docs/architecture/EasyTree – Softwaredokumentation.md" in die Scope; sie stammt nicht aus diesem Slice. supabase/** bleibt ausgeschlossen. Zweitens die Base. docs/context/.scope-base stand auf f3b427d und lag 78 Commits hinter origin/master; der Gate meldete deshalb dauerhaft 21 bereits gemergte, bereits reviewte Dateien mit. Das Plandokument hatte das Verschieben zunaechst abgelehnt, weil es die gepruefte Oberflaeche verkleinert und ausgerechnet der Slice davon profitiert haette. Dieser Einwand ist mit Revision 5 gegenstandslos: gemessen ist der Befund dieses Slice gegen BEIDE Basen null. Die Ruecknahme steht im Dokument, nicht nur im Ergebnis. Gate danach: "PRIL scope check passed … (32 changed files)", Exit 0. Nicht versioniert: .plumbline/ steht in .gitignore, die neu gezogene Bindung auf den Manifest-Hash d5b483614d8e81c7… existiert nur lokal. --- docs/context/.scope-base | 2 +- .../2026-08-21-eyt-126-scope-autoritaet.md | 48 ++++++++++--- .../easytree-sprint-6-core-journey.scope.json | 67 ++++++++++++++++++- 3 files changed, 104 insertions(+), 13 deletions(-) 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-21-eyt-126-scope-autoritaet.md b/docs/plans/2026-08-21-eyt-126-scope-autoritaet.md index 74866d73..d54494c8 100644 --- a/docs/plans/2026-08-21-eyt-126-scope-autoritaet.md +++ b/docs/plans/2026-08-21-eyt-126-scope-autoritaet.md @@ -1,7 +1,8 @@ # EYT-126 — der Containerpfad liegt ausserhalb der armierten Sprint-6-Scope -> **Status: `ENTSCHEIDUNG_OFFEN`.** Dieses Dokument entscheidet nichts. Es macht eine -> PO-Entscheidung in einem Schritt ausführbar und belegt, warum sie nötig ist. +> **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 @@ -125,10 +126,39 @@ Revision 1. Nachteil: mehr Governance-Mechanik und ein zweiter Ledger für dasse **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. -## Unabhängig davon: die veraltete Base - -`docs/context/.scope-base` auf `f3b427d` stehen zu lassen, macht jeden künftigen Lauf des Gates -unlesbar — er meldet dauerhaft die 28 bereits gemergten Dateien mit. Das ist ein eigener, -kleinerer Beschluss und ausdrücklich **nicht** Teil dieses Slice: eine Base zu verschieben -verkleinert die geprüfte Oberfläche, und das entscheidet niemand nebenbei, schon gar nicht der -Slice, dessen Befund dadurch kleiner würde. +## 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. diff --git a/docs/scope/easytree-sprint-6-core-journey.scope.json b/docs/scope/easytree-sprint-6-core-journey.scope.json index a4563374..a124e5f8 100644 --- a/docs/scope/easytree-sprint-6-core-journey.scope.json +++ b/docs/scope/easytree-sprint-6-core-journey.scope.json @@ -14,7 +14,15 @@ "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" ], "governance": [ "docs/canvas/**", @@ -26,7 +34,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 +155,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 +186,55 @@ ] }, "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" } ] } From 7cf698c42b1f5832aef0f9e53346dc8dd4486a14 Mon Sep 17 00:00:00 2001 From: DYAI2025 Date: Fri, 21 Aug 2026 21:08:11 +0200 Subject: [PATCH 07/22] =?UTF-8?q?docs(EYT-142):=20den=20Blocker=20eine=20S?= =?UTF-8?q?tufe=20genauer=20machen=20=E2=80=94=20und=20eine=20falsche=20An?= =?UTF-8?q?nahme=20zuruecknehmen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nach PO-Freigabe wurde versucht, ein zweites Projekt easytree-staging anzulegen (get_cost fuer diese Org: 0 $/Monat). Der Aufruf wurde abgelehnt: BadRequestException: The following organization members have reached their maximum limits for the number of active free projects within organizations where they are an administrator or owner: DYAI2025 (2 project limit). Korrektur: die erste Lesart fuehrte das pausierte Projekt "Bazodiac" (status INACTIVE) als einen der beiden belegten Slots. Das ist falsch. Die Supabase-Doku sagt an zwei Stellen woertlich das Gegenteil — Billing FAQ: "Paused projects do not count towards your quota"; About billing: "Paused projects do not count towards your free project limit". Bazodiac zu loeschen oder zu pausieren ist damit gegenstandslos, und das steht jetzt so da, statt als stille Streichung. Die fehlende Voraussetzung ist damit zweistufig: (1) es gibt keine EasyTree-Datengrenze mit abweichendem project_ref, unveraendert; (2) der KOSTENLOSE Weg dorthin ist versperrt, weil die Free-Projekt-Quota pro NUTZER zaehlt und DYAI2025 sie mit zwei aktiven Projekten ausgeschoepft hat — eines ist die Produktion, das zweite ist ueber diesen Zugang nicht sichtbar. Punkt 2 ist belegt und nicht vermutet: list_projects liefert reproduzierbar genau ein Projekt und enthaelt inypnrvpawvhgiyagxbd NICHT, obwohl get_project darauf antwortet; list_organizations enthaelt EASTree nicht, obwohl get_organization darauf antwortet. Die Aufzaehlung ist nachweislich unvollstaendig, deshalb steht im Bericht ausdruecklich KEINE daraus abgeleitete Projektzaehlung. Der abgelehnte Aufruf belegt nebenbei, dass die Autorisierung durchlaeuft: die Antwort war ein Quota-Fehler, kein 403. Der Vorbehalt dazu ist notiert. Kein Schreibzugriff hat stattgefunden. Der abgelehnte create_project-Aufruf hat nichts angelegt, alles Uebrige war read-only, Produktionsdaten wurden nicht beruehrt. --- CLAUDE.md | 8 ++- .../2026-08-20-sprint-6-staging-blocker.md | 52 +++++++++++++++++++ docs/runbooks/staging-deploy.md | 9 ++++ 3 files changed, 68 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 94cd4846..9d49dffa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -577,7 +577,13 @@ Produktionsprofil ohne `DATABASE_SSL_ROOT_CERT` und geordnetes Herunterfahren. R **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`). Siehe +(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 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 02da72a3..82ba7401 100644 --- a/docs/plans/2026-08-20-sprint-6-staging-blocker.md +++ b/docs/plans/2026-08-20-sprint-6-staging-blocker.md @@ -17,6 +17,58 @@ > > 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/runbooks/staging-deploy.md b/docs/runbooks/staging-deploy.md index 0d1c34c6..4be4d0ef 100644 --- a/docs/runbooks/staging-deploy.md +++ b/docs/runbooks/staging-deploy.md @@ -40,6 +40,15 @@ Ein Deploy ist erst zulässig, wenn **alle drei** Bedingungen erfüllt sind: `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 From 9716951c7363e3e929adc89602817ac6843132ce Mon Sep 17 00:00:00 2001 From: DYAI2025 Date: Sat, 22 Aug 2026 00:13:25 +0200 Subject: [PATCH 08/22] =?UTF-8?q?chore(scope):=20EYT-126=20=E2=80=94=20Rev?= =?UTF-8?q?ision=206=20setzen,=20sechs=20Pfade=20fuer=20den=20Laufzeit-Pro?= =?UTF-8?q?xy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Der PO hat den Sprint-6-Scope am 22.08.2026 um genau sechs Pfade erweitert: apps/web/app/{api,health,ready}/**, apps/web/instrumentation.ts, apps/api/test/architecture/secret-surface-rules.ts und apps/api/test/container-bindung.test.ts. Keine weitere Erweiterung. Der Grund ist gemessen, nicht bevorzugt: der Same-Origin-Proxy muss aus next.config.ts-rewrites() heraus, weil Next das Ergebnis beim Bauen nach routes-manifest.json schreibt und ein fertiges Image damit an sein Ziel gebunden waere. Eine Next-16-proxy.ts scheidet zweifach aus — sie sendet x-middleware-rewrite mit der internen Adresse an den Browser, und @opennextjs/cloudflare bricht mit "Node.js middleware is not currently supported" ab, was den Pflichtjob build-web rot machte. Uebrig bleiben Route Handler unter apps/web/app/, wo Revision 5 nur planung/** fuehrte. Nichts weiter war noetig: apps/web/lib/** und apps/web/test/** stehen seit Revision 1 als Platzhalter in der Scope, die Container- und CI-Pfade kamen mit Revision 5. Die Digest-Konvention wurde vor dem Schreiben gegen alle fuenf bestehenden Revisionen nachgerechnet (5/5), nicht aus dem Dokument uebernommen, das sie behauptet. Revisionen 1-5 sind unveraendert, kein Pfad ging verloren. Die Bindung unter .plumbline/scope-authority/ wurde neu gezogen (d5b483614d8e81c7... -> 2741c0ec0935d01d...), geprueft gegen shasum -a 256. Im Nachtrag zusaetzlich festgehalten, in diesem Lauf gemessen: .plumbline/ ist per .gitignore:35 ausgeschlossen, der Feature-Marker docs/context/.active-feature dagegen versioniert. Fehlt die Bindung — etwa in einem frischen Worktree — meldet der Gate trotzdem "passed" und nicht SCOPE_AUTHORITY_CHANGED. Co-Authored-By: Claude Opus 5 (1M context) --- .../2026-08-21-eyt-126-scope-autoritaet.md | 49 +++++++++++++++ .../easytree-sprint-6-core-journey.scope.json | 63 ++++++++++++++++++- 2 files changed, 111 insertions(+), 1 deletion(-) diff --git a/docs/plans/2026-08-21-eyt-126-scope-autoritaet.md b/docs/plans/2026-08-21-eyt-126-scope-autoritaet.md index d54494c8..628d1ef1 100644 --- a/docs/plans/2026-08-21-eyt-126-scope-autoritaet.md +++ b/docs/plans/2026-08-21-eyt-126-scope-autoritaet.md @@ -162,3 +162,52 @@ 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/scope/easytree-sprint-6-core-journey.scope.json b/docs/scope/easytree-sprint-6-core-journey.scope.json index a124e5f8..44adaaf0 100644 --- a/docs/scope/easytree-sprint-6-core-journey.scope.json +++ b/docs/scope/easytree-sprint-6-core-journey.scope.json @@ -22,7 +22,13 @@ "apps/web/Dockerfile", "apps/web/next.config.ts", "apps/api/test/architecture/deploy-authority-rules.ts", - "apps/api/test/deploy-authority.test.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/**", @@ -235,6 +241,61 @@ ] }, "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" } ] } From 56a8e0c05670353382632f095681dc6214eaf702 Mon Sep 17 00:00:00 2001 From: DYAI2025 Date: Sat, 22 Aug 2026 00:59:08 +0200 Subject: [PATCH 09/22] feat(web): EYT-126 Proxyziel zur Laufzeit lesen statt beim Bauen einbacken MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Der Same-Origin-Proxy bekommt eine eigene Durchreiche, die EASYTREE_API_PROXY_TARGET bei JEDER Anfrage neu aus der Umgebung liest. Bisher loeste next.config.ts-rewrites() den Wert beim Bauen auf und schrieb ihn nach .next/routes-manifest.json — ein fertiges Web-Image war damit an sein Ziel gebunden, was Confluence 30998530 (dieselben Images fuer Coolify/VPS und Railway) nicht erfuellt. Die Datei schliesst zugleich drei Wege, auf denen die interne API-Adresse den Browser erreichen koennte: * kein x-middleware-rewrite — anders als bei einer Next-16-proxy.ts baut ein Route Handler die Antwort selbst und bestimmt jeden Kopf; * ein absoluter location-Kopf auf das konfigurierte Ziel wird in einen relativen Pfad uebersetzt, Pfad, Query und Fragment bleiben erhalten. Ein EXTERNES Weiterleitungsziel bleibt unveraendert; * ein Verbindungsfehler wird zu einem eigenen 502, statt die undici-Meldung (sie nennt den Zielhost) durch Nexts Fehlerseite laufen zu lassen. Gegenmutation ausgefuehrt und zurueckgenommen: friert man das Ziel beim Modulladen in einer Modulkonstante ein, faellt "liest das Ziel bei JEDER Anfrage neu aus der Umgebung" mit `AssertionError: expected [ Array(2) ] to deeply equal [ …(2) ]`, dazu sechs Folgefaelle — darunter die drei Weiterleitungsfaelle, weil ein eingefrorenes Ziel den location-Vergleich gegen die falsche Origin fuehrt und die interne Adresse dann leckt. Nach der Ruecknahme ist die Datei hashgleich (sha256:ddfcba34…) und die Suite wieder 17/17 gruen. --- apps/web/lib/proxy-durchreichen.ts | 161 +++++++++++++ apps/web/test/proxy-durchreichen.test.ts | 281 +++++++++++++++++++++++ 2 files changed, 442 insertions(+) create mode 100644 apps/web/lib/proxy-durchreichen.ts create mode 100644 apps/web/test/proxy-durchreichen.test.ts diff --git a/apps/web/lib/proxy-durchreichen.ts b/apps/web/lib/proxy-durchreichen.ts new file mode 100644 index 00000000..8972d09d --- /dev/null +++ b/apps/web/lib/proxy-durchreichen.ts @@ -0,0 +1,161 @@ +/** + * 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. Drei 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; + * * 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}`; +} + +export async function durchreichen(request: Request): Promise { + 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") { + antwortKopf.set("location", uebersetzeWeiterleitung(wert, ziel)); + continue; + } + antwortKopf.set(name, wert); + } + for (const keks of oben.headers.getSetCookie()) antwortKopf.append("set-cookie", keks); + + return new Response(oben.body, { + status: oben.status, + statusText: oben.statusText, + headers: antwortKopf, + }); +} diff --git a/apps/web/test/proxy-durchreichen.test.ts b/apps/web/test/proxy-durchreichen.test.ts new file mode 100644 index 00000000..6ba37fb4 --- /dev/null +++ b/apps/web/test/proxy-durchreichen.test.ts @@ -0,0 +1,281 @@ +/** + * 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"); + }); +}); + +/** + * 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); + }); +}); From 9a9ab910fb1efc5ea8c572315562252663291006 Mon Sep 17 00:00:00 2001 From: DYAI2025 Date: Sat, 22 Aug 2026 00:59:24 +0200 Subject: [PATCH 10/22] feat(web): EYT-126 Same-Origin-Proxy als Route Handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drei Route Handler ersetzen den Bauzeit-Rewrite: /api/[[...pfad]] als optionaler Catch-all (damit auch /api selbst nicht als 404 aus der Web-App zurueckkommt), /health und /ready lesend. Alle drei zeigen auf dieselbe Durchreiche und setzen `dynamic = "force-dynamic"` — ohne das koennte eine Antwort aus dem Cache kommen und das alte Ziel weiterreichen. Der Test prueft die Routen selbst und nicht nur die Durchreiche: eine fehlende Route oder eine nicht exportierte Methode waere im Browser ein 404 auf /ready statt eines Fehlers, und ein Test auf die Durchreiche allein bliebe dabei gruen. --- apps/web/app/api/[[...pfad]]/route.ts | 17 ++++++++++++++ apps/web/app/health/route.ts | 12 ++++++++++ apps/web/app/ready/route.ts | 9 +++++++ apps/web/test/proxy-routen.test.ts | 34 +++++++++++++++++++++++++++ 4 files changed, 72 insertions(+) create mode 100644 apps/web/app/api/[[...pfad]]/route.ts create mode 100644 apps/web/app/health/route.ts create mode 100644 apps/web/app/ready/route.ts create mode 100644 apps/web/test/proxy-routen.test.ts 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/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"); + }); +}); From 2cd541309c9899670ed05af9898b8e8a311afe6f Mon Sep 17 00:00:00 2001 From: DYAI2025 Date: Sat, 22 Aug 2026 00:59:24 +0200 Subject: [PATCH 11/22] feat(web): EYT-126 Startsperre fuer ein fehlendes oder ungueltiges Proxyziel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit register() laeuft einmal beim Hochfahren des Next-Servers. Wirft es, meldet Next "Failed to prepare server" und beantwortet danach JEDE Route mit 500 — auch / und /anmelden. Ohne diese Sperre antworteten nur /api, /health und /ready mit einem Fehler; die Anwendung selbst saehe gesund aus und zeigte im Browser eine leere Woche. Gemessen 22.08.2026 auf Next 16.2.11, sowohl im Container als auch mit `next start`: ohne Wert /=500, /anmelden=500, /health=500 und im Protokoll "EASYTREE_API_PROXY_TARGET ist in production Pflicht. Es gibt hier bewusst keinen Default."; mit ftp:-Ziel dieselben Codes und "erlaubt nur http: oder https:, erhalten: \"ftp:\"". Ehrliche Grenze, die auch in CLAUDE.md und im Runbook so steht: der Prozess bleibt am Leben und haelt den Port. Er bedient aber keinen normalen Anwendungsverkehr, und der Compose-Healthcheck auf / schlaegt fehl. Die Behauptung "der Container startet nicht" waere falsch. Die Datei liest die Umgebung NICHT selbst, sondern ruft aktuellesProxyziel() auf — es soll genau eine Laufzeitstelle geben, nicht zwei. --- apps/web/instrumentation.ts | 25 ++++++++++++ apps/web/test/instrumentation.test.ts | 57 +++++++++++++++++++++++++++ 2 files changed, 82 insertions(+) create mode 100644 apps/web/instrumentation.ts create mode 100644 apps/web/test/instrumentation.test.ts 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/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(); + }); +}); From 7e4b5a11674207489c38a0f6986d141d1eda007a Mon Sep 17 00:00:00 2001 From: DYAI2025 Date: Sat, 22 Aug 2026 00:59:44 +0200 Subject: [PATCH 12/22] test(api): EYT-126 die eine Laufzeitstelle des Proxyziels in die Positivliste MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Der Geheimnis-Waechter meldete vor dieser Zeile genau EINEN Fund: "apps/web/lib/proxy-durchreichen.ts:63 [runtime-reads-no-environment]". Dass es genau einer war und nicht zwei, ist die eigentliche Aussage — instrumentation.ts liest die Umgebung nicht selbst. Gegenmutation ausgefuehrt und zurueckgenommen: liest instrumentation.ts versuchsweise selbst per normalizeProxyTarget(process.env…), meldet der Waechter "apps/web/instrumentation.ts:24 [runtime-reads-no-environment]" und geht rot. Die Positivliste gibt also nicht pauschal apps/web frei. Nach der Ruecknahme ist die Datei hashgleich und der Waechter wieder 39/39 gruen. --- apps/api/test/architecture/secret-surface-rules.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/apps/api/test/architecture/secret-surface-rules.ts b/apps/api/test/architecture/secret-surface-rules.ts index 1c80318d..b32d2501 100644 --- a/apps/api/test/architecture/secret-surface-rules.ts +++ b/apps/api/test/architecture/secret-surface-rules.ts @@ -730,6 +730,16 @@ export const ERLAUBTE_UMGEBUNGSLESER: ReadonlyArray<{ datei: string; grund: stri "(EASYTREE_API_PROXY_TARGET). Laeuft nie im Browser; der Wert wird von " + "lib/api-proxy-target.ts streng geprueft.", }, + { + 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.", + }, ]; const ERLAUBTE_LESER_DATEIEN = new Set(ERLAUBTE_UMGEBUNGSLESER.map((e) => e.datei)); From 1a3da07ef6ef6eda7561230a16c5a13cff17ff5d Mon Sep 17 00:00:00 2001 From: DYAI2025 Date: Sat, 22 Aug 2026 00:59:44 +0200 Subject: [PATCH 13/22] refactor(web): EYT-126 Bauzeit-Rewrite und toten Cloudflare-Bauzeitpfad entfernen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit next.config.ts traegt keinen rewrites()-Block mehr. Damit verlieren resolveBuildProxyTarget, CLOUDFLARE_API_FALLBACK_TARGET und ProxyBuildEnvironment ihren einzigen Aufrufer; toter Code mit gruenen Tests behauptet eine Zusage, die niemand mehr einloest, also gehen sie samt ihrem describe-Block mit. turbo.json braucht EASYTREE_API_PROXY_TARGET, WORKERS_CI und CF_PAGES nicht mehr im Cache-Schluessel der build-Aufgabe. Zwei Aussagen, die dieser Commit ueberholt, jeweils an ihrer Stelle korrigiert statt stehengelassen: der Dateikopf von api-proxy-target.ts nannte den Wert Bauzeitkonfiguration, und next-output.ts nannte ihn als gleichartig zu seinem eigenen Bauzeitschalter. Gemessen 22.08.2026: * `env -u EASYTREE_API_PROXY_TARGET pnpm --filter @easytree/web... build` → exit 0, Routentabelle mit `ƒ /api/[[...pfad]]`, `ƒ /health`, `ƒ /ready`, keine Zeile `ƒ Proxy (Middleware)`. Vor dieser Aenderung brach derselbe Befehl mit "EASYTREE_API_PROXY_TARGET ist in production Pflicht" ab. * `env -u EASYTREE_API_PROXY_TARGET pnpm build` (Wurzel, ueber Turbo) → exit 0, `Tasks: 6 successful`, `Cached: 0 cached, 6 total`. Der in CLAUDE.md dokumentierte Wurzelbau-Bruch ist damit weg, nicht bloss umgangen. * `pnpm --filter @easytree/web run build:cf` → "Worker saved in .open-next/worker.js" / "OpenNext build complete."; cf:dry-run meldet gzip 1184.28 KiB gegen das 3072-KiB-Limit. * Web-Suite 294 Faelle (273 + 25 neue - 4 entfernte). --- apps/web/lib/api-proxy-target.ts | 71 ++++---------------------- apps/web/lib/next-output.ts | 9 ++-- apps/web/next.config.ts | 25 ++++----- apps/web/test/api-proxy-target.test.ts | 37 ++------------ turbo.json | 2 +- 5 files changed, 31 insertions(+), 113 deletions(-) diff --git a/apps/web/lib/api-proxy-target.ts b/apps/web/lib/api-proxy-target.ts index 5e86874e..2e2c2dc3 100644 --- a/apps/web/lib/api-proxy-target.ts +++ b/apps/web/lib/api-proxy-target.ts @@ -8,27 +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. Next schreibt das Ergebnis nach - * `.next/routes-manifest.json`, und `next start` liest die Weiterleitungen von - * dort — NICHT erneut aus der Umgebung. + * 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. * - * Das ist gemessen und nicht mehr offen (21.08.2026, Next 16.2.11, EYT-126): - * gebaut mit `http://buildtime-marker.invalid:9999`, gestartet mit - * `EASYTREE_API_PROXY_TARGET=http://127.0.0.1:3999` — der Server antwortete auf - * `/health` mit HTTP 500 und protokollierte - * `Failed to proxy http://buildtime-marker.invalid:9999/health … ENOTFOUND`. - * Eine fruehere Fassung dieses Kommentars liess die Frage ausdruecklich offen. - * - * Fuer den Containerbetrieb heisst das: der Wert wird als `--build-arg` in das - * Web-Image gebacken. Ein bereits gebautes Image laesst sich zur Laufzeit NICHT - * auf ein anderes Ziel umschalten. - * - * 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. */ /** @@ -36,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 {} /** @@ -101,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 index 5209d17a..09e0cfdf 100644 --- a/apps/web/lib/next-output.ts +++ b/apps/web/lib/next-output.ts @@ -14,9 +14,12 @@ * aber sein Abbau ist ein eigener Slice (EYT-149) — bis dahin darf dieser * Slice ihn nicht nebenbei rot machen. * - * Der Schalter ist deshalb Bauzeitkonfiguration, genau wie - * `EASYTREE_API_PROXY_TARGET`, und traegt aus demselben Grund KEIN - * `NEXT_PUBLIC_`-Praefix. + * 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 * diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 10864e04..a1e37566 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -2,7 +2,6 @@ import path from "node:path"; import type { NextConfig } from "next"; -import { resolveBuildProxyTarget } from "./lib/api-proxy-target"; import { resolveNextOutput } from "./lib/next-output"; /** @@ -14,15 +13,17 @@ import { resolveNextOutput } from "./lib/next-output"; * * ## 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 = { /** @@ -34,14 +35,6 @@ const nextConfig: NextConfig = { */ outputFileTracingRoot: path.join(__dirname, "..", ".."), ...resolveNextOutput(process.env), - 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` }, - ]; - }, }; 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/turbo.json b/turbo.json index 35888106..6796b04a 100644 --- a/turbo.json +++ b/turbo.json @@ -3,7 +3,7 @@ "tasks": { "build": { "dependsOn": ["^build"], - "env": ["EASYTREE_API_PROXY_TARGET", "EASYTREE_NEXT_OUTPUT", "WORKERS_CI", "CF_PAGES"], + "env": ["EASYTREE_NEXT_OUTPUT"], "outputs": ["dist/**", ".next/**", "!.next/cache/**", ".open-next/**"] }, "lint": {}, From 0647c8efe3838c0a1bb8bb9c18b3b7e607ef8358 Mon Sep 17 00:00:00 2001 From: DYAI2025 Date: Sat, 22 Aug 2026 01:00:16 +0200 Subject: [PATCH 14/22] fix(deploy): EYT-126 Web-Image vom Bauzeitziel loesen und GIT_SHA erzwingen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit apps/web/Dockerfile traegt kein `ARG EASYTREE_API_PROXY_TARGET` mehr. Das Image ist damit an kein Ziel und an keinen Anbieter gebunden; dieselbe Datei und derselbe Startpfad liefern es fuer Coolify/VPS wie fuer Railway. GIT_SHA verliert in BEIDEN Dockerfiles seinen Vorgabewert und wird geprueft. Ein leeres ARG faellt sonst still auf ein Label ohne Inhalt zurueck, und "das Image ist gruen" waere eine Aussage ueber irgendein Image. Gegenprobe ausgefuehrt (22.08.2026, colima): * `docker build -f apps/web/Dockerfile .` ohne --build-arg → exit 1 mit "GIT_SHA fehlt. Baue mit --build-arg GIT_SHA=$(git rev-parse HEAD)." * dasselbe fuer apps/api/Dockerfile → exit 1, gleiche Zeile. * mit --build-arg GIT_SHA= gebaut, danach `docker inspect --format '{{index .Config.Labels "org.opencontainers.image.revision"}}'` → exakt der uebergebene Head. --- apps/api/Dockerfile | 9 +++++++- apps/web/Dockerfile | 56 ++++++++++++++++++++++++++------------------- 2 files changed, 40 insertions(+), 25 deletions(-) diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile index 3bd91844..bb70254d 100644 --- a/apps/api/Dockerfile +++ b/apps/api/Dockerfile @@ -69,7 +69,14 @@ RUN pnpm --filter @easytree/api --prod --legacy deploy /prod/api # --------------------------------------------------------------------------- FROM ${NODE_IMAGE} AS runner -ARG GIT_SHA=unknown +# 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" \ diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index 9616d2f2..bac90592 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -2,35 +2,38 @@ # # easyTree Web-Shell (Next.js App Router) als OCI-Workload — EYT-126. # -# ## Das Proxyziel ist BAUZEIT-Konfiguration. Gemessen, nicht vermutet. +# ## Das Proxyziel ist LAUFZEIT-Konfiguration. Gemessen, nicht vermutet. # -# `next.config.ts` loest `EASYTREE_API_PROXY_TARGET` in `rewrites()` auf. Next -# schreibt das Ergebnis beim Bauen nach `.next/routes-manifest.json`; `next -# start` liest die Weiterleitungen aus diesem Manifest und NICHT erneut aus der -# Umgebung. +# 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: gebaut mit -# `http://buildtime-marker.invalid:9999`, gestartet mit -# `EASYTREE_API_PROXY_TARGET=http://127.0.0.1:3999` — der Server antwortete auf -# `/health` mit HTTP 500 und protokollierte -# `Failed to proxy http://buildtime-marker.invalid:9999/health … ENOTFOUND`. -# Der Laufzeitwert wurde also ignoriert. +# 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. # -# Konsequenz, die hier ehrlich stehen muss: das Web-IMAGE ist an sein Ziel -# gebunden, nicht an einen Anbieter. Dieselbe Datei, derselbe Startpfad und -# dieselben Argumente bauen es fuer Coolify/VPS wie fuer Railway — nur der -# Wert unterscheidet sich. "Ein Image fuer beide Plattformen gleichzeitig" -# waere dagegen eine Behauptung, die die Messung nicht deckt. +# 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 ist es trotzdem: `next build` setzt NODE_ENV=production, und -# `normalizeProxyTarget` wirft dort ohne expliziten Wert. Ein vergessenes -# `--build-arg` faellt den Build, statt still auf localhost zu zeigen. +# ## 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; im Browserbuendel steht es nicht. Die Weiterleitung passiert im -# Next-Server im Container. +# 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 @@ -54,8 +57,6 @@ RUN pnpm install --frozen-lockfile --filter @easytree/web... # --------------------------------------------------------------------------- FROM deps AS build -ARG EASYTREE_API_PROXY_TARGET -ENV EASYTREE_API_PROXY_TARGET=${EASYTREE_API_PROXY_TARGET} # `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. @@ -67,7 +68,14 @@ RUN pnpm --filter @easytree/web... build # --------------------------------------------------------------------------- FROM ${NODE_IMAGE} AS runner -ARG GIT_SHA=unknown +# 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" \ From bc58fabd0df8e35a45658dc72db95400917a82ea Mon Sep 17 00:00:00 2001 From: DYAI2025 Date: Sat, 22 Aug 2026 01:00:16 +0200 Subject: [PATCH 15/22] fix(deploy): EYT-126 GIT_SHA und Proxyziel im Compose-Pfad fail-closed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zwei fail-open-Stellen geschlossen. `:?` statt `:-`, und das Proxyziel wandert vom Build-Arg in die Laufzeitumgebung des Web-Dienstes. Ein Deploy ohne Herkunft und ein geratenes Proxyziel waren bis hierher ueber genau diesen Pfad erzeugbar; ein geratenes Ziel sieht im Browser aus wie eine leere Woche, nicht wie ein Fehler. Gemessen 22.08.2026, alle drei Faelle: * ohne GIT_SHA → exit 1, "error while interpolating services.api.build.args.GIT_SHA: required variable GIT_SHA is missing a value: GIT_SHA fehlt - baue mit GIT_SHA=$(git rev-parse HEAD)" * mit GIT_SHA, ohne Ziel → exit 1, "error while interpolating services.web.environment.EASYTREE_API_PROXY_TARGET: required variable EASYTREE_API_PROXY_TARGET is missing a value: …" * mit beidem → exit 0, und `docker compose config` zeigt GIT_SHA am Head sowie EASYTREE_API_PROXY_TARGET unter web.environment. Ohne diese dritte, positive Probe waeren die ersten beiden nicht von "kaputte Datei" zu unterscheiden. --- docker-compose.yml | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 374188ad..4c57acc9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,7 +4,7 @@ # CI dient sie demselben Zweck. Sie beschreibt AUSSCHLIESSLICH Topologie und # Konfigurationsnaehte — keine Fachlogik, keine Secrets, keine Migrationen. # -# Zwei Grenzen, die hier sichtbar sein muessen: +# 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 @@ -13,6 +13,10 @@ # 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: @@ -20,7 +24,10 @@ services: context: . dockerfile: apps/api/Dockerfile args: - GIT_SHA: ${GIT_SHA:-unknown} + # `:?` 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 @@ -58,11 +65,10 @@ services: context: . dockerfile: apps/web/Dockerfile args: - GIT_SHA: ${GIT_SHA:-unknown} - # BAUZEIT, nicht Laufzeit — gemessen am 21.08.2026, siehe den Kopf von - # apps/web/Dockerfile. `next start` liest die Weiterleitungen aus dem - # gebauten routes-manifest.json und ignoriert die Umgebung. - EASYTREE_API_PROXY_TARGET: ${EASYTREE_API_PROXY_TARGET:-http://api:3001} + # 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: @@ -74,6 +80,10 @@ services: 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: [ From cf882473f6525fba7ef608a9726cb2c6b9c27de7 Mon Sep 17 00:00:00 2001 From: DYAI2025 Date: Sat, 22 Aug 2026 01:00:17 +0200 Subject: [PATCH 16/22] test(deploy): EYT-126 Fail-open in Compose und Dockerfiles dauerhaft ausschliessen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die Handproben aus den beiden vorigen Commits belegen dasselbe schaerfer, laufen aber nie wieder, wenn sie niemand wiederholt. Dieser Waechter braucht kein Docker und laeuft im Pflichtjob unit-tests mit. Der Bauabschnitt des Web-Dienstes wird eng geschnitten und nicht die ganze Datei durchsucht: der Kopfkommentar NENNT die Variable (er erklaert, warum sie keinen Vorgabewert hat), eine dateiweite Suche waere daran haengengeblieben. Drei Eingangsbremsen halten den Test davon ab, vakuos gruen zu werden — eine Mindestlaenge je Datei, ein Schnitt-Check auf den Bauabschnitt und die Gegenrichtung "das Ziel steht in der Laufzeitumgebung". Gegenmutation ausgefuehrt und zurueckgenommen, in beiden Haelften: * `${GIT_SHA:?…}` → `${GIT_SHA:-unknown}` in docker-compose.yml → rot in "gibt GIT_SHA keinen Vorgabewert" mit `AssertionError: expected '# easyTree — Container-Topologie fuer…' not to match /\$\{GIT_SHA:-/` * die RUN-Pruefung in apps/web/Dockerfile → `ARG GIT_SHA=unknown` → rot in "apps/web/Dockerfile erzwingt GIT_SHA statt es vorzubelegen" Beide Dateien sind nach der Ruecknahme hashgleich (d7fcb173… bzw. 65df23db…), der Waechter wieder 9/9 gruen. --- apps/api/test/container-bindung.test.ts | 99 +++++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 apps/api/test/container-bindung.test.ts 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"); + }); +}); From 05ecb576ba619d7617844e65b228afb30043d200 Mon Sep 17 00:00:00 2001 From: DYAI2025 Date: Sat, 22 Aug 2026 01:00:58 +0200 Subject: [PATCH 17/22] test(deploy): EYT-126 ein Image gegen zwei Ziele und Fail-closed im Container belegen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Der Kern der PO-Forderung: EIN Image-Digest, ZWEI Starts, ZWEI Ziele, KEIN Neubau dazwischen. Der Digest zaehlt und nicht der Tag — ein Tag laesst sich zwischen zwei Starts umhaengen, ein Digest nicht, und genau "kein Neubau dazwischen" ist die Aussage. Zwei Stub-Container statt der echten API, weil der Nachweis ueber die UNTERSCHEIDUNG laeuft; zwei echte APIs waeren nicht unterscheidbar. Neu ausserdem, weil x-middleware-rewrite zu vermeiden NICHT genuegt: derselbe Stub liefert `Location: http://:3001/foo?x=1`. Diese Adresse darf den Browser nicht erreichen, und ein EXTERNES Weiterleitungsziel darf nicht spekulativ veraendert werden. Die Leckpruefung deckt jetzt HTML, jeden referenzierten Client-Chunk, die Antwortkoepfe und den location-Kopf ab. Zwei Reparaturen am Skript, die beim Ausfuehren aufgefallen sind: * Die Namen der Ein-Image-zwei-Ziele-Probe stehen bei den Konstanten und nicht erst in Abschnitt 8. Das Skript laeuft mit `set -Eeuo pipefail` und `aufraeumen()` haengt am EXIT-Trap; waeren sie erst weiter unten gesetzt, erzeugte ein Abbruch davor einen "unbound variable"-Folgefehler, der den echten Grund verdeckt. Zusaetzlich liest das Cleanup jeden Namen ueber `${VAR:-}`. Gemessen: ein Abbruch vor Abschnitt 8 (fehlende EASYTREE_SMOKE_DATABASE_URL) meldet 0 Treffer "unbound variable". * Acht `printf|grep -q`-Pruefungen auf `case`-Vergleiche umgestellt. `grep -q` beendet sich beim ERSTEN Treffer, die schreibende Seite bekommt EPIPE, und unter `pipefail` ist die Pipeline dann fehlgeschlagen — obwohl grep gefunden hat. Gemessen an `docker logs … | grep -qF EASYTREE_API_PROXY_TARGET`: die Pruefung ging rot, obwohl das Protokoll die Variable nannte. Bei einer LECK-Pruefung ist dieselbe Mechanik die gefaehrliche Richtung — das gefundene Leck saehe aus wie keines. Lokal ausgefuehrt 22.08.2026 gegen colima und eine echte PostgreSQL-Instanz (Rolle easytree_app, NOSUPERUSER/NOBYPASSRLS, damit das EYT-45-Startgate greift): [container-smoke] mode=local executed=24 passed=24 skipped=0 ziel=http://easytree-stub-a:3001 -> {"stub":"easytree-stub-a","pfad":"/health"} ziel=http://easytree-stub-b:3001 -> {"stub":"easytree-stub-b","pfad":"/health"} vorher=sha256:191a1af9bd22422634586cdb460bd832781e1e8f6fcf96528eddcdc4ab5d8a88 nachher=sha256:191a1af9bd22422634586cdb460bd832781e1e8f6fcf96528eddcdc4ab5d8a88 location=/foo?x=1 location=https://login.example.org/oauth?state=xyz Ziel fehlt: / -> HTTP 500 Ziel ungueltig: / -> HTTP 500 10 Client-Chunks geprueft Lokal ist nicht CI: der Nachweis in mode=required kommt aus db-gates. --- scripts/smoke-container.sh | 306 +++++++++++++++++++++++++++++++++---- 1 file changed, 276 insertions(+), 30 deletions(-) diff --git a/scripts/smoke-container.sh b/scripts/smoke-container.sh index 8aa77437..2b735039 100755 --- a/scripts/smoke-container.sh +++ b/scripts/smoke-container.sh @@ -9,13 +9,23 @@ # `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 ausgelieferte Seite nennt die interne Adresse nicht. +# 4. Die interne Adresse erreicht den Browser auf KEINEM Weg: weder im +# ausgelieferten HTML noch in einem Client-Chunk noch in einem +# Antwortkopf — und auch nicht in einem `location`-Kopf, den die API +# absolut auf sich selbst setzt. # 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. SIGTERM beendet beide Container geordnet. +# 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 @@ -36,11 +46,25 @@ 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 wird in das Web-Image gebacken. Sie ist ein reiner -# Dienstname im Container-Netz — von aussen nicht aufloesbar, was Punkt 3 -# ueberhaupt erst aussagekraeftig macht. +# 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 @@ -77,8 +101,12 @@ aussteigen() { } aufraeumen() { - docker rm -f "${WEB_CONTAINER}" "${API_CONTAINER}" >/dev/null 2>&1 || true - docker network rm "${NETZ}" >/dev/null 2>&1 || true + # 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 @@ -94,13 +122,21 @@ fi aufraeumen -echo "== 1/8 Images bauen (GIT_SHA=${GIT_SHA}) ==" +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}" \ - --build-arg "EASYTREE_API_PROXY_TARGET=${INTERNES_ZIEL}" \ -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 @@ -121,13 +157,13 @@ laeuft_als_node() { } } -echo "== 2/8 Bindung an den Head ==" +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/8 Topologie starten ==" +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 @@ -174,12 +210,14 @@ api_bereit() { } 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 \ - "${WEB_IMAGE}" >/dev/null + -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 || { @@ -188,7 +226,7 @@ pruefung "Web-Container liefert die Shell aus" web_erreichbar || { exit 1 } -echo "== 4/8 Interner Weg Web -> API ==" +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. @@ -216,46 +254,113 @@ ziel_von_aussen_unaufloesbar() { } pruefung "der interne Dienstname ist vom Host aus NICHT erreichbar" ziel_von_aussen_unaufloesbar -echo "== 5/8 Der Browser sieht die interne Adresse nicht ==" +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}/")" - if printf '%s' "${html}" | grep -qF "${API_CONTAINER}"; then + # `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 - fi - if printf '%s' "${html}" | grep -qF "${INTERNES_ZIEL}"; then + ;; + esac + case "${html}" in + *"${INTERNES_ZIEL}"*) echo " internes Ziel steht im ausgelieferten HTML" >&2 return 1 - fi - printf '%s' "${html}" | grep -qF "&2 return 1 - } + ;; + esac } pruefung "das ausgelieferte HTML nennt die interne API-Adresse nicht" seite_ohne_interne_adresse -echo "== 6/8 Protokolle ohne Geheimnisse ==" +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}" ] && printf '%s' "${logs}" | grep -qF "${passwort}"; then - echo " Datenbankpasswort steht im Protokoll" >&2 - return 1 + if [ -n "${passwort}" ]; then + case "${logs}" in + *"${passwort}"*) + echo " Datenbankpasswort steht im Protokoll" >&2 + return 1 + ;; + esac fi - if printf '%s' "${logs}" | grep -qF "${EASYTREE_SMOKE_ANON_KEY}"; then + case "${logs}" in + *"${EASYTREE_SMOKE_ANON_KEY}"*) echo " Anon-Key steht im Protokoll" >&2 return 1 - fi + ;; + 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/8 Gegenprobe: production ohne Wurzelzertifikat MUSS scheitern ==" +echo "== 7/10 Gegenprobe: production ohne Wurzelzertifikat MUSS scheitern ==" production_ohne_zertifikat_scheitert() { local ausgabe status=0 ausgabe="$(docker run --rm --network "${NETZ}" \ @@ -271,16 +376,157 @@ production_ohne_zertifikat_scheitert() { return 1 fi # Nicht irgendein Fehlschlag: es muss DIESE Variable sein. - printf '%s' "${ausgabe}" | grep -qF "DATABASE_SSL_ROOT_CERT" || { + 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/8 Geordnetes Herunterfahren ==" +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. +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"});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 "== 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): # From 9c6798ed1713f27c258fa962483f806687a5863e Mon Sep 17 00:00:00 2001 From: DYAI2025 Date: Sat, 22 Aug 2026 01:00:58 +0200 Subject: [PATCH 18/22] ci: EYT-126 Proxyziel nicht mehr beim Bauen setzen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Solange CI die Variable beim Bauen setzt, koennte niemand bemerken, dass der Build sie nicht mehr braucht. Sie dort zu entfernen ist die staerkste dauerhafte Gegenprobe: brauchte der Build sie noch, waere der Pflichtjob build-web rot. * build-web: der env-Block entfaellt ersatzlos. * auth-journey: der Bauschritt setzt sie nicht mehr. Das Ziel kommt aus apps/web/e2e/auth-journey/config.ts:136 in der webServer-Umgebung, also zur Laufzeit — womit genau dieser Job zugleich belegt, dass Login, POST-Koerper und mehrere HttpOnly-Set-Cookie durch die neue Durchreiche gehen. * read-through-harness.sh: die Bauzeile verliert sie; Zeile 109 (`next start`) behaelt sie und ist ab jetzt die einzige Stelle, an der sie zaehlt. * web-smoke: NICHT angefasst. Dort steht sie auf Job-Ebene, damit `next start` sie erbt — seit dieser Aenderung braucht der Server sie zum Hochfahren, nicht nur zum Weiterleiten. Der Kommentar sagt das jetzt, weil ein Entfernen "als Bauzeit-Ueberbleibsel" den Job komplett faellen wuerde. --- .github/workflows/ci.yml | 22 ++++++++++++++-------- scripts/read-through-harness.sh | 4 +++- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 50f31518..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: @@ -742,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/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::" From 4c41cfa39dc9ac4c63c040d36ed525e0af5ba378 Mon Sep 17 00:00:00 2001 From: DYAI2025 Date: Sat, 22 Aug 2026 01:00:58 +0200 Subject: [PATCH 19/22] docs(EYT-126): das Proxyziel als Laufzeitgroesse beschreiben MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Beide Dokumente behaupteten wortreich das Gegenteil des neuen Verhaltens. Jede Fundstelle einzeln entschieden statt gesucht-und-ersetzt: Absolutaussagen ueberleben sonst in Zusammenfassungen, Tabellen und Fehlersuchspalten. CLAUDE.md: der BAUZEIT-Absatz im Deployment-Abschnitt, der rewrites()-Satz in "Web never talks to Supabase", die Container-Smoke-Aufzaehlung, und der Wurzelbau-Absatz. Letzterer wurde NACHGEMESSEN statt umformuliert: `env -u EASYTREE_API_PROXY_TARGET pnpm build` → exit 0, `Tasks: 6 successful`, `Cached: 0 cached, 6 total`. docs/runbooks/staging-deploy.md: §3 "Die achte Groesse", das Bauexempel in §5, die Zeile "Leere Woche statt Fehler" in der Fehlersuchtabelle, das Coolify-Build-Argument in §9, die Railway-Kompatibilitaet in §10 und der Stand in §11. Die frueheren Messungen werden nicht geloescht, sondern als das gekennzeichnet, was sie waren: fuer den rewrites()-Weg korrekt. Ebenso bleibt die ehrliche Grenze der Startsperre stehen — der Prozess haelt den Port, der Container gilt als unhealthy, aber er "startet" durchaus. Keine Staging-Evidenz: BLOCKER_ENVIRONMENT_SEPARATION bleibt unberuehrt, und ein CI-echter Stack belegt Funktion, nicht Bereitstellung. --- CLAUDE.md | 86 +++++++++++++++++--------- docs/runbooks/staging-deploy.md | 106 +++++++++++++++++++++----------- 2 files changed, 127 insertions(+), 65 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9d49dffa..d009961c 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,20 @@ 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), and a connection failure becomes a 502 that +names no host. 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`, @@ -555,24 +563,46 @@ Kommando mit `node dist/worker.js`. Nur `web` veröffentlicht einen Port; die AP 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 BAUZEIT-Konfiguration, nicht Laufzeitschalter — gemessen -21.08.2026 auf Next 16.2.11.** Ein Web-Build mit `http://buildtime-marker.invalid:9999`, -gestartet mit `EASYTREE_API_PROXY_TARGET=http://127.0.0.1:3999`, antwortete auf `/health` mit -HTTP 500 und protokollierte `Failed to proxy http://buildtime-marker.invalid:9999/health … -ENOTFOUND`. `next start` liest die Weiterleitungen aus dem gebauten `.next/routes-manifest.json`. -Der Kommentar in `lib/api-proxy-target.ts` nannte das früher "ungeprüft"; jetzt ist es geprüft. -Konsequenz: das Web-**Image** ist an sein Ziel gebunden, nicht an einen Anbieter — dieselbe Datei -und derselbe Startpfad bauen es für Coolify wie für Railway, nur das `--build-arg` unterscheidet -sich. Fail-closed bleibt es, weil `next build` NODE_ENV=production setzt und -`normalizeProxyTarget` dort ohne expliziten Wert wirft. +**`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 das ausgelieferte HTML die -interne Adresse nicht nennt, geheimnisfreie Protokolle, das Verweigern des Starts im -Produktionsprofil ohne `DATABASE_SSL_ROOT_CERT` und geordnetes Herunterfahren. Runbook: +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. +Lokal gemessen 22.08.2026: `[container-smoke] mode=local executed=24 passed=24 skipped=0`. +Runbook: [`docs/runbooks/staging-deploy.md`](docs/runbooks/staging-deploy.md). **Ein Deploy ist trotzdem gesperrt.** `BLOCKER_ENVIRONMENT_SEPARATION`: es existiert keine diff --git a/docs/runbooks/staging-deploy.md b/docs/runbooks/staging-deploy.md index 4be4d0ef..459052d4 100644 --- a/docs/runbooks/staging-deploy.md +++ b/docs/runbooks/staging-deploy.md @@ -102,31 +102,45 @@ es deshalb auskommentiert — ein Platzhalterwert dort wäre schlimmer als keine 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**. -### Die achte Größe: `EASYTREE_API_PROXY_TARGET` ist BAUZEIT, nicht Laufzeit +### 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 Bauen des Web-Images -festgeschrieben.** +`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 (21.08.2026, Next 16.2.11): ein Web-Build mit -`http://buildtime-marker.invalid:9999`, gestartet mit -`EASYTREE_API_PROXY_TARGET=http://127.0.0.1:3999`, antwortete auf `/health` mit HTTP 500 und -protokollierte `Failed to proxy http://buildtime-marker.invalid:9999/health … ENOTFOUND`. `next -start` liest die Weiterleitungen aus dem gebauten `.next/routes-manifest.json` und **nicht** -erneut aus der Umgebung. +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… +``` + +**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 an sein Ziel gebunden, nicht an einen Anbieter. Für Coolify und für - Railway wird dieselbe Datei mit demselben Startpfad gebaut — nur das Argument unterscheidet - sich (`--build-arg EASYTREE_API_PROXY_TARGET=…`). „Ein Image für beide Plattformen - gleichzeitig" wäre eine Behauptung, die die Messung nicht deckt. -- Ein vergessenes Build-Argument **fällt den Build**: `next build` setzt `NODE_ENV=production`, - und `normalizeProxyTarget` wirft dort ohne expliziten Wert. Kein stilles Zurückfallen auf - localhost. -- Der Browser sieht die Adresse trotzdem nie. Sie ist ausdrücklich kein `NEXT_PUBLIC_*`; die - Weiterleitung passiert serverseitig im Web-Container. Der Container-Smoke prüft das ausgeliefert - HTML dagegen. +- 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. Drei Regeln zur Datenbankverbindung, die aus gemessenen Fehlern stammen und nicht verhandelbar sind: @@ -160,8 +174,19 @@ Build- oder Startbefehl ein Schemawerkzeug auf, gehen sie rot. ```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}" \ - --build-arg "EASYTREE_API_PROXY_TARGET=http://api:3001" -t "easytree-web:${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 @@ -191,15 +216,15 @@ Niemals: Produktionsdaten kopieren, echte Personendaten einspielen, echte Stunde Reihenfolge ist nicht beliebig; sie geht vom Billigsten zum Teuersten. -| 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` — Bauzeitwert des Web-Images, §3 | -| 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 | +| 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()` @@ -300,8 +325,12 @@ Ohne diese fünf Angaben ist ein Rollback nicht ausführbar und eine Abnahme nic 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. -- **Build-Argument:** `EASYTREE_API_PROXY_TARGET` auf den internen Dienstnamen der API +- **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** @@ -316,8 +345,10 @@ Ohne diese fünf Angaben ist ein Rollback nicht ausführbar und eine Abnahme nic 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. Der einzige Unterschied ist der Wert -von `EASYTREE_API_PROXY_TARGET` beim Bauen des Web-Images (§3). +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 @@ -327,11 +358,12 @@ Ebene „läuft dort". ## 11. Stand dieses Dokuments -Überarbeitet am 21.08.2026 auf den Containerpfad (Confluence 30998530), ausgehend von der Fassung -vom 20.08.2026 gegen `master` `b2d8dbf`. Abgeleitet aus: den beiden Dockerfiles, +Ü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 der Messung zum Bauzeitverhalten von -`EASYTREE_API_PROXY_TARGET`. +`pg-connection.ts`, den bestehenden Smoke-Skripten und dem lokal ausgeführten Container-Smoke +(`[container-smoke] mode=local executed=24 passed=24 skipped=0`, 22.08.2026), der ein Image +gegen zwei Ziele 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 From 4e16d8c17f505e3db5fa3dd91c5530d9a792fef9 Mon Sep 17 00:00:00 2001 From: DYAI2025 Date: Sat, 22 Aug 2026 01:12:13 +0200 Subject: [PATCH 20/22] docs(EYT-126): zwei ueberlebende Aussagen ueber den alten Rewrite-Weg korrigieren MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Beide beschreiben einen Mechanismus, den dieser Slice ersetzt hat, und beide stehen an Stellen, die als Autoritaet gelesen werden: * Der Grund fuer apps/web/next.config.ts in ERLAUBTE_UMGEBUNGSLESER nannte EASYTREE_API_PROXY_TARGET. Die Datei liest die Variable nicht mehr; sie liest EASYTREE_NEXT_OUTPUT. Der Eintrag bleibt noetig, seine Begruendung war falsch — in einer Sicherheits-Positivliste ist das kein Schoenheits- fehler. * CLAUDE.md beschrieb den belegten Lesepfad als "same-origin Next rewrite". Nicht angefasst und darum hier benannt statt stillschweigend gelassen: der Ausdruck "Same-Origin-Rewrite" steht weiter als Wegbeschreibung in apps/web/e2e/**, scripts/read-through-harness.sh, .github/workflows/ci.yml und zwei Runbooks; und apps/web/app/providers.tsx, apps/web/open-next.config.ts sowie apps/web/playwright.harness.config.ts verweisen weiter auf "das Rewrite in next.config.ts". Diese drei liegen ausserhalb der Scope-Revision 6 und werden hier nicht angefasst. --- CLAUDE.md | 3 ++- apps/api/test/architecture/secret-surface-rules.ts | 9 ++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d009961c..11bcae80 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -409,7 +409,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 diff --git a/apps/api/test/architecture/secret-surface-rules.ts b/apps/api/test/architecture/secret-surface-rules.ts index b32d2501..e8730e36 100644 --- a/apps/api/test/architecture/secret-surface-rules.ts +++ b/apps/api/test/architecture/secret-surface-rules.ts @@ -726,9 +726,12 @@ 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", From 18f50047fb96529306310e85b8e7717c8146e8ba Mon Sep 17 00:00:00 2001 From: DYAI2025 Date: Sat, 22 Aug 2026 02:20:56 +0200 Subject: [PATCH 21/22] fix(web): EYT-126 kein gewoehnlicher Antwortkopf traegt mehr die interne Adresse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Der Same-Origin-Proxy schloss bisher drei Lecks: `x-middleware-rewrite` entsteht gar nicht erst, ein `location` auf das interne Ziel wird relativ, und ein Verbindungsfehler wird ein eigener 502 ohne Hostnamen. Jeder UEBRIGE Antwortkopf wurde dagegen unveraendert weitergereicht. Damit blieb genau ein Weg nach draussen offen: `X-Upstream-Url: http://:3001/private` oder `Link: :3001/foo>; rel="self"` erreichen den Browser, sind fuer same-origin-JavaScript ueber `response.headers` lesbar, und `Link` wertet der Browser sogar selbst aus. Der Riegel laesst solche Koepfe WEG statt sie umzuschreiben. Ein Kopf ohne festgelegte Bedeutung traegt keine Struktur, aus der sich eine Uebersetzung ableiten liesse; eine erfundene waere schlimmer als der Verlust. Erkannt wird das Ziel in drei Formen — volle Adresse, Autoritaet, nackter Dienstname —, schreibungsunabhaengig, und der Kopfname zaehlt mit. `location` bleibt unberuehrt: er hat eine Bedeutung und seine eigene Uebersetzung. Fremde Adressen bleiben stehen; ein leckendes `Set-Cookie` faellt einzeln weg, ohne die uebrigen zusammenzufalten. Gemessen, nicht behauptet: * Unit: sieben neue Faelle in `apps/web/test/proxy-durchreichen.test.ts`. Vor dem Riegel 5 rot / 19 gruen, danach 24/24. Die zwei Erhaltungsfaelle (harmloser Kopf, externe URL) waren schon vorher gruen — sie sichern, dass der Riegel nicht pauschal alles wegwirft. * Gegenmutation ausgefuehrt: den Riegel entfernt -> genau dieselben 5 Faelle wieder rot; zurueckgenommen, `shasum -a 256` gleich (3690b3cf…). * Container: `scripts/smoke-container.sh` Abschnitt 8c. Der Stub sendet den leckenden Kopf nachweislich (direkt aus dem Containernetz gelesen), hinter dem Proxy ist er fort, der harmlose `x-stub-marke` kommt durch. `[container-smoke] mode=local executed=26 passed=26 skipped=0`. * Gegenmutation im Container ausgefuehrt: ohne den Riegel faellt 8c mit exit 1 und zeigt die Koepfe woertlich — `x-upstream-url: http://easytree-stub-b:3001/private`. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 14 +- apps/web/lib/proxy-durchreichen.ts | 58 ++++++++- apps/web/test/proxy-durchreichen.test.ts | 159 +++++++++++++++++++++++ docs/runbooks/staging-deploy.md | 13 +- scripts/smoke-container.sh | 86 +++++++++++- 5 files changed, 320 insertions(+), 10 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 11bcae80..2c076432 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -355,7 +355,10 @@ server start instead of silently proxying to localhost, which would look like an 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), and a connection failure becomes a 502 that +relative path (an external redirect is left alone), **every other response header naming 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. 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. @@ -602,7 +605,14 @@ HTML noch ein Client-Chunk noch ein Antwortkopf noch ein `location`-Kopf die int 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. -Lokal gemessen 22.08.2026: `[container-smoke] mode=local executed=24 passed=24 skipped=0`. +Der Antwortkopf-Teil ist dabei nicht auf `location` beschränkt: **jeder** übrige Kopf, der 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. Die beiden Stub-Container des Smokes senden diesen Kopf nachweislich, sonst +wäre seine Abwesenheit hinter dem Proxy kein Nachweis. +Lokal gemessen 22.08.2026: `[container-smoke] mode=local executed=26 passed=26 skipped=0`. Runbook: [`docs/runbooks/staging-deploy.md`](docs/runbooks/staging-deploy.md). diff --git a/apps/web/lib/proxy-durchreichen.ts b/apps/web/lib/proxy-durchreichen.ts index 8972d09d..2056a020 100644 --- a/apps/web/lib/proxy-durchreichen.ts +++ b/apps/web/lib/proxy-durchreichen.ts @@ -25,7 +25,7 @@ * ## Der Browser sieht die Adresse nie * * Die Variable traegt bewusst KEIN `NEXT_PUBLIC_`-Praefix. Sie wird nur hier - * gelesen, im Serverprozess. Drei Lecks werden aktiv geschlossen, nicht nur + * gelesen, im Serverprozess. Vier Lecks werden aktiv geschlossen, nicht nur * eines: * * * kein `x-middleware-rewrite` — es entsteht gar nicht erst; @@ -33,6 +33,13 @@ * (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, der die interne Adresse nennt, faellt weg — + * `X-Upstream-Url`, `Link: <…>; rel="self"`, `Content-Location`, ein + * `Set-Cookie` mit interner `Domain`. Das gilt fuer den Kopfnamen wie fuer + * den Wert. 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. 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. */ @@ -98,6 +105,41 @@ export function uebersetzeWeiterleitung(roh: string, ziel: string): string { return `${pfad}${ort.search}${ort.hash}`; } +/** + * Alles, woran der Browser das interne Ziel wiedererkennen koennte — klein + * geschrieben, weil Hostnamen ohnehin schreibungsunabhaengig sind und ein Kopf + * in beliebiger Schreibweise ankommen darf. + * + * Drei Formen, weil ein Leck in jeder von ihnen auftritt: die volle Adresse + * (`http://api:3001/basis`), die Autoritaet (`api:3001`) und der nackte + * Dienstname (`api`). Im Containernetz ist schon der Dienstname die interne + * Adresse — genau ihn sucht auch `scripts/smoke-container.sh`. + */ +function leckmuster(ziel: string): string[] { + const url = new URL(ziel); + const muster = new Set([ + ziel.toLowerCase(), + url.origin.toLowerCase(), + url.host.toLowerCase(), + url.hostname.toLowerCase(), + ]); + muster.delete(""); + return [...muster]; +} + +/** + * Nennt dieser Text das interne Ziel? + * + * Bewusst ein Teilstringvergleich und keine URL-Analyse: ein Kopf ohne + * festgelegte Bedeutung hat keine Struktur, auf die man sich verlassen + * koennte. Die Richtung des Irrtums ist damit die sichere — ein zu breiter + * Treffer kostet einen Kopf, ein zu enger kostet die Zusage. + */ +export function nenntInternesZiel(text: string, ziel: string): boolean { + const klein = text.toLowerCase(); + return leckmuster(ziel).some((m) => klein.includes(m)); +} + export async function durchreichen(request: Request): Promise { const ziel = aktuellesProxyziel(); const eingang = new URL(request.url); @@ -146,12 +188,24 @@ export async function durchreichen(request: Request): Promise { // 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 er die interne Adresse + // nennt. Auch der NAME zaehlt mit: `x-api-intern: 1` leckt genauso. + if (nenntInternesZiel(`${name}: ${wert}`, ziel)) continue; antwortKopf.set(name, wert); } - for (const keks of oben.headers.getSetCookie()) antwortKopf.append("set-cookie", keks); + for (const keks of oben.headers.getSetCookie()) { + // Dieselbe Zusage, ohne die Mehrfachheit aufzugeben: das leckende Cookie + // faellt weg, die uebrigen bleiben eigene Koepfe. + if (nenntInternesZiel(keks, ziel)) continue; + antwortKopf.append("set-cookie", keks); + } return new Response(oben.body, { status: oben.status, diff --git a/apps/web/test/proxy-durchreichen.test.ts b/apps/web/test/proxy-durchreichen.test.ts index 6ba37fb4..657f0074 100644 --- a/apps/web/test/proxy-durchreichen.test.ts +++ b/apps/web/test/proxy-durchreichen.test.ts @@ -188,6 +188,165 @@ describe("durchreichen", () => { }); }); +/** + * `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 Kopf `location` ist der zweite Weg nach draussen. * diff --git a/docs/runbooks/staging-deploy.md b/docs/runbooks/staging-deploy.md index 459052d4..9608b1f5 100644 --- a/docs/runbooks/staging-deploy.md +++ b/docs/runbooks/staging-deploy.md @@ -140,7 +140,13 @@ Drei Konsequenzen, die vor dem ersten Deploy bekannt sein müssen: 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. + bleibt unangetastet. **Jeder übrige Antwortkopf, der 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. Der Smoke belegt + das mit einem Stub, der den leckenden Kopf nachweislich sendet — sonst wäre seine + Abwesenheit hinter dem Proxy kein Nachweis. Drei Regeln zur Datenbankverbindung, die aus gemessenen Fehlern stammen und nicht verhandelbar sind: @@ -362,8 +368,9 @@ Ebene „läuft dort". 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=24 passed=24 skipped=0`, 22.08.2026), der ein Image -gegen zwei Ziele und beide Fail-closed-Fälle belegt. +(`[container-smoke] mode=local executed=26 passed=26 skipped=0`, 22.08.2026), der ein Image +gegen zwei Ziele, das Nicht-Lecken der internen Adresse in gewöhnlichen Antwortköpfen 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/scripts/smoke-container.sh b/scripts/smoke-container.sh index 2b735039..dbf2db03 100755 --- a/scripts/smoke-container.sh +++ b/scripts/smoke-container.sh @@ -11,8 +11,10 @@ # 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 — und auch nicht in einem `location`-Kopf, den die API -# absolut auf sich selbst setzt. +# 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. # 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 @@ -396,7 +398,14 @@ echo "== 8/10 Ein Image, zwei Ziele, kein Neubau ==" # # Derselbe Stub beantwortet ausserdem zwei Weiterleitungsfaelle, weil `location` # der zweite Weg ist, auf dem die interne Adresse den Browser erreichen koennte. -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"});s.end(JSON.stringify({stub:name,pfad:q.url}));}).listen(3001,"0.0.0.0")' +# +# 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. Der +# dritte Kopf `x-stub-marke` ist harmlos und dient als Eingangsbremse: ohne ihn +# waere "der leckende Kopf fehlt" auch dann gruen, wenn gar nichts durchkommt. +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"});s.end(JSON.stringify({stub:name,pfad:q.url}));}).listen(3001,"0.0.0.0")' stub_starten() { local name="$1" @@ -488,6 +497,77 @@ externe_weiterleitung_bleibt() { 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=>{console.log("x-upstream-url="+r.headers.get("x-upstream-url"));console.log("link="+r.headers.get("link"));console.log("x-stub-marke="+r.headers.get("x-stub-marke"));}).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 Eingangsbremse muss es beim Stub auch wirklich geben. + case "${ausgabe}" in + *x-stub-marke=harmlos*) ;; + *) + echo " der Stub sendet den harmlosen Kopf nicht — die Eingangsbremse waere blind" >&2 + return 1 + ;; + esac +} +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 + echo "== 9/10 Fail-closed zur Laufzeit ==" start_verweigert() { local beschreibung="$1" antwort From 3b4bbdb5ee88260000be404c86c9dac40e92b591 Mon Sep 17 00:00:00 2001 From: DYAI2025 Date: Sat, 22 Aug 2026 03:01:14 +0200 Subject: [PATCH 22/22] fix(web): EYT-126 der Kopf-Riegel trifft die Adresse und nicht das Wort MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Der Riegel aus 18f5004 schloss das echte Leck, war aber zu breit gefasst. Er verglich Teilstrings gegen drei Formen — volle Adresse, Autoritaet UND nackter Dienstname — und zwar ueber Kopfname und Wert zusammen. Fuer die vorgesehene Containertopologie `http://api:3001` ist der Dienstname das Wort "api". Damit fiel jeder harmlose Kopf, in dem diese drei Buchstaben irgendwo vorkommen: X-Api-Version: 1 X-Documentation: https://api.example.org/public Set-Cookie: api_session=abc123; Path=/; HttpOnly Das ist kein Schutz, sondern ein stiller Ausfall — und es verletzt die Zusage, dass legitime Koepfe unveraendert bleiben. Gesucht wird jetzt die ADRESSE. Drei Formen, jede mit einer Grenze: 1. eine echte URL im WERT, deren Origin die des Ziels ist — gefunden mit `new URL()` statt mit einem Textvergleich, deshalb ist `https://api.example.org/public` kein Treffer; 2. die Autoritaet ohne Schema (`api:3001`), nur bei ausgewiesenem Port und nur an einer Zeichengrenze — `capitalized-api` und `api:30011` nicht; 3. der Wert BESTEHT aus dem Host — `X-Upstream-Host: api` bleibt gedeckt, als Teilstring wird der nackte Hostname nicht mehr gesucht. Der Kopf-NAME wird gar nicht mehr geprueft: ein Name traegt keine Adresse. Fuer `set-cookie` kommt das `Domain`-Attribut hinzu, strukturell ausgelesen und verglichen statt im Text gesucht; das erste Segment ist das Name/Wert-Paar und damit kein Attribut. `location` ist unveraendert. Gemessen, nicht behauptet: * Sieben neue Faelle mit dem Ziel woertlich auf `http://api:3001` — mit `api-a.invalid` waere der Fehler unsichtbar geblieben. Vorher `7 failed | 25 passed (32)`, danach 32/32. Alle bisherigen Leck-Nachweise bleiben gruen, keiner wurde entschaerft. * Gegenmutation ausgefuehrt: die ueberbreite Fassung woertlich wiederhergestellt (nackter Hostname als Teilstring, Kopfname im Vergleich, Cookie ueber denselben Weg) -> dieselben 7 Faelle wieder rot; zurueckgenommen, `cmp` leer, `shasum -a 256` = f38874cb… * Container: der Stub sendet jetzt neben dem leckenden Kopf auch `x-api-version`, eine fremde Doku-URL und zwei Cookies. Beide Richtungen belegt, beide Eingangsbremsen scharf. `[container-smoke] mode=local executed=27 passed=27 skipped=0`. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 38 ++++--- apps/web/lib/proxy-durchreichen.ts | 138 +++++++++++++++++------ apps/web/test/proxy-durchreichen.test.ts | 135 ++++++++++++++++++++++ docs/runbooks/staging-deploy.md | 20 ++-- scripts/smoke-container.sh | 85 +++++++++++--- 5 files changed, 347 insertions(+), 69 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2c076432..264648e3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -355,11 +355,16 @@ server start instead of silently proxying to localhost, which would look like an 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 naming 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. Each gateway's URL is assembled in exactly one place — its own +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`, @@ -605,14 +610,21 @@ HTML noch ein Client-Chunk noch ein Antwortkopf noch ein `location`-Kopf die int 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, der 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. Die beiden Stub-Container des Smokes senden diesen Kopf nachweislich, sonst -wäre seine Abwesenheit hinter dem Proxy kein Nachweis. -Lokal gemessen 22.08.2026: `[container-smoke] mode=local executed=26 passed=26 skipped=0`. +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). diff --git a/apps/web/lib/proxy-durchreichen.ts b/apps/web/lib/proxy-durchreichen.ts index 2056a020..52baf902 100644 --- a/apps/web/lib/proxy-durchreichen.ts +++ b/apps/web/lib/proxy-durchreichen.ts @@ -33,13 +33,16 @@ * (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, der die interne Adresse nennt, faellt weg — - * `X-Upstream-Url`, `Link: <…>; rel="self"`, `Content-Location`, ein - * `Set-Cookie` mit interner `Domain`. Das gilt fuer den Kopfnamen wie fuer - * den Wert. 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. Fremde - * Adressen bleiben unangetastet, mehrere `set-cookie` bleiben mehrere; + * * 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. */ @@ -106,38 +109,99 @@ export function uebersetzeWeiterleitung(roh: string, ziel: string): string { } /** - * Alles, woran der Browser das interne Ziel wiedererkennen koennte — klein - * geschrieben, weil Hostnamen ohnehin schreibungsunabhaengig sind und ein Kopf - * in beliebiger Schreibweise ankommen darf. - * - * Drei Formen, weil ein Leck in jeder von ihnen auftritt: die volle Adresse - * (`http://api:3001/basis`), die Autoritaet (`api:3001`) und der nackte - * Dienstname (`api`). Im Containernetz ist schon der Dienstname die interne - * Adresse — genau ihn sucht auch `scripts/smoke-container.sh`. + * 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. */ -function leckmuster(ziel: string): string[] { +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 muster = new Set([ - ziel.toLowerCase(), - url.origin.toLowerCase(), - url.host.toLowerCase(), - url.hostname.toLowerCase(), - ]); - muster.delete(""); - return [...muster]; + 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(`(? klein.includes(m)); +export function keksNenntInternesZiel(keks: string, ziel: string): boolean { + if (nenntInternesZiel(keks, ziel)) return true; + + const hostname = new URL(ziel).hostname.toLowerCase(); + for (const attribut of keks.split(";").slice(1)) { + const trenner = attribut.indexOf("="); + if (trenner === -1) continue; + if (attribut.slice(0, trenner).trim().toLowerCase() !== "domain") continue; + // Ein fuehrender Punkt ist die alte Schreibweise fuer dieselbe Domain. + const wert = attribut + .slice(trenner + 1) + .trim() + .toLowerCase() + .replace(/^\./, ""); + if (wert === hostname) return true; + } + return false; } export async function durchreichen(request: Request): Promise { @@ -195,15 +259,17 @@ export async function durchreichen(request: Request): Promise { continue; } // Jeder uebrige Kopf ist bedeutungslos fuer uns und wird deshalb nicht - // umgeschrieben, sondern weggelassen, sobald er die interne Adresse - // nennt. Auch der NAME zaehlt mit: `x-api-intern: 1` leckt genauso. - if (nenntInternesZiel(`${name}: ${wert}`, ziel)) continue; + // 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 (nenntInternesZiel(keks, ziel)) continue; + if (keksNenntInternesZiel(keks, ziel)) continue; antwortKopf.append("set-cookie", keks); } diff --git a/apps/web/test/proxy-durchreichen.test.ts b/apps/web/test/proxy-durchreichen.test.ts index 657f0074..db6928a4 100644 --- a/apps/web/test/proxy-durchreichen.test.ts +++ b/apps/web/test/proxy-durchreichen.test.ts @@ -347,6 +347,141 @@ describe("durchreichen — gewoehnliche Antwortkoepfe lecken die interne Adresse }); }); +/** + * 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. * diff --git a/docs/runbooks/staging-deploy.md b/docs/runbooks/staging-deploy.md index 9608b1f5..9ef71c37 100644 --- a/docs/runbooks/staging-deploy.md +++ b/docs/runbooks/staging-deploy.md @@ -140,13 +140,18 @@ Drei Konsequenzen, die vor dem ersten Deploy bekannt sein müssen: 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, der die interne Adresse nennt, fällt + 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. Der Smoke belegt - das mit einem Stub, der den leckenden Kopf nachweislich sendet — sonst wäre seine - Abwesenheit hinter dem Proxy kein Nachweis. + 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: @@ -368,9 +373,10 @@ Ebene „läuft dort". 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=26 passed=26 skipped=0`, 22.08.2026), der ein Image -gegen zwei Ziele, das Nicht-Lecken der internen Adresse in gewöhnlichen Antwortköpfen und -beide Fail-closed-Fälle belegt. +(`[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/scripts/smoke-container.sh b/scripts/smoke-container.sh index dbf2db03..5e5d1738 100755 --- a/scripts/smoke-container.sh +++ b/scripts/smoke-container.sh @@ -15,6 +15,11 @@ # 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 @@ -402,10 +407,22 @@ echo "== 8/10 Ein Image, zwei Ziele, kein Neubau ==" # 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. Der -# dritte Kopf `x-stub-marke` ist harmlos und dient als Eingangsbremse: ohne ihn -# waere "der leckende Kopf fehlt" auch dann gruen, wenn gar nichts durchkommt. -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"});s.end(JSON.stringify({stub:name,pfad:q.url}));}).listen(3001,"0.0.0.0")' +# 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" @@ -510,7 +527,7 @@ stub_leckt_direkt() { 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=>{console.log("x-upstream-url="+r.headers.get("x-upstream-url"));console.log("link="+r.headers.get("link"));console.log("x-stub-marke="+r.headers.get("x-stub-marke"));}).catch(e=>{console.log("fehler="+e.message);process.exitCode=1})')" || return 1 + 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}"*) ;; @@ -519,14 +536,20 @@ stub_leckt_direkt() { return 1 ;; esac - # Und die Eingangsbremse muss es beim Stub auch wirklich geben. - case "${ausgabe}" in - *x-stub-marke=harmlos*) ;; - *) - echo " der Stub sendet den harmlosen Kopf nicht — die Eingangsbremse waere blind" >&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 @@ -568,6 +591,42 @@ gewoehnliche_koepfe_ohne_leck() { 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