Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
7e7c8ff
feat(deploy): EYT-126 — Web und API als reproduzierbare OCI-Images bauen
BenPerro Aug 21, 2026
b1c313c
test(ci): EYT-126 — Container-Smoke an den exakten Head binden
BenPerro Aug 21, 2026
fbc7d2c
docs(EYT-142): den Staging-Weg auf den Containerpfad umschreiben
BenPerro Aug 21, 2026
ea9a392
docs(EYT-142): den Blockerbericht gegen die falsche Schlussfolgerung …
BenPerro Aug 21, 2026
0810098
docs(EYT-126): die Scope-Autoritaet fuer den Containerpfad entscheidu…
BenPerro Aug 21, 2026
913527b
chore(scope): EYT-126 — Revision 5 setzen und die veraltete Scope-Bas…
BenPerro Aug 21, 2026
7cf698c
docs(EYT-142): den Blocker eine Stufe genauer machen — und eine falsc…
BenPerro Aug 21, 2026
9716951
chore(scope): EYT-126 — Revision 6 setzen, sechs Pfade fuer den Laufz…
BenPerro Aug 21, 2026
56a8e0c
feat(web): EYT-126 Proxyziel zur Laufzeit lesen statt beim Bauen einb…
BenPerro Aug 21, 2026
9a9ab91
feat(web): EYT-126 Same-Origin-Proxy als Route Handler
BenPerro Aug 21, 2026
2cd5413
feat(web): EYT-126 Startsperre fuer ein fehlendes oder ungueltiges Pr…
BenPerro Aug 21, 2026
7e4b5a1
test(api): EYT-126 die eine Laufzeitstelle des Proxyziels in die Posi…
BenPerro Aug 21, 2026
1a3da07
refactor(web): EYT-126 Bauzeit-Rewrite und toten Cloudflare-Bauzeitpf…
BenPerro Aug 21, 2026
0647c8e
fix(deploy): EYT-126 Web-Image vom Bauzeitziel loesen und GIT_SHA erz…
BenPerro Aug 21, 2026
bc58fab
fix(deploy): EYT-126 GIT_SHA und Proxyziel im Compose-Pfad fail-closed
BenPerro Aug 21, 2026
cf88247
test(deploy): EYT-126 Fail-open in Compose und Dockerfiles dauerhaft …
BenPerro Aug 21, 2026
05ecb57
test(deploy): EYT-126 ein Image gegen zwei Ziele und Fail-closed im C…
BenPerro Aug 21, 2026
9c6798e
ci: EYT-126 Proxyziel nicht mehr beim Bauen setzen
BenPerro Aug 21, 2026
4c41cfa
docs(EYT-126): das Proxyziel als Laufzeitgroesse beschreiben
BenPerro Aug 21, 2026
4e16d8c
docs(EYT-126): zwei ueberlebende Aussagen ueber den alten Rewrite-Weg…
BenPerro Aug 21, 2026
18f5004
fix(web): EYT-126 kein gewoehnlicher Antwortkopf traegt mehr die inte…
BenPerro Aug 22, 2026
3b4bbdb
fix(web): EYT-126 der Kopf-Riegel trifft die Adresse und nicht das Wort
BenPerro Aug 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -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
65 changes: 57 additions & 8 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -629,6 +631,49 @@ jobs:
SUPABASE_URL: "http://127.0.0.1:54321"
SUPABASE_ANON_KEY: "ci-local-anon-key"
run: bash scripts/smoke-worker.sh
# EYT-126 — Container-Smoke: dieselbe Codebasis als OCI-Workloads.
#
# Er steht am ENDE von db-gates und bekommt bewusst KEINEN eigenen Job.
# Ein neuer Job waere so lange kein Pflichtcheck, bis jemand mit
# Repository-Adminrechten das Ruleset neu anwendet, und beide Kopien der
# Pflichtcheck-Liste (setup-/verify-branch-protection.sh) muessten
# mitgezogen werden. Dieselbe Begruendung steht schon bei den
# Cloudflare-Schritten in build-web und build-api.
#
# Und nur HIER gibt es, was der Smoke sonst nirgends bekommt: eine echte
# PostgreSQL-Instanz mit der Rolle easytree_app. Ohne sie waere `/ready`
# kein Nachweis, sondern eine Zusicherung ueber sich selbst.
- name: Head und CI-SHA muessen uebereinstimmen (EYT-126)
shell: bash
run: |
set -Eeuo pipefail
ist="$(git rev-parse HEAD)"
echo "checkout=${ist} github.sha=${{ github.sha }}"
test "${ist}" = "${{ github.sha }}"
- name: Container-Smoke — Web und API als OCI-Workloads (EYT-126)
shell: bash
env:
EASYTREE_CONTAINER_SMOKE: required
GIT_SHA: ${{ github.sha }}
# Aus Sicht des CONTAINERS, nicht des Runners: host.docker.internal
# zeigt ueber --add-host=...:host-gateway auf den Hostrechner, auf
# dem der Supabase-Stack seine Ports veroeffentlicht.
EASYTREE_SMOKE_DATABASE_URL: "postgresql://easytree_app:ci-local-app-password@host.docker.internal:54322/postgres"
EASYTREE_SMOKE_SUPABASE_URL: "http://host.docker.internal:54321"
EASYTREE_SMOKE_ANON_KEY: "ci-local-anon-key"
run: |
set -Eeuo pipefail
bash scripts/smoke-container.sh 2>&1 | tee /tmp/container-smoke.log
- name: Container-Gate-Zeile pruefen und berichten (EYT-126)
if: always()
shell: bash
run: |
set -Eeuo pipefail
# Die Zeile, nicht der gruene Haken: `skipped=0` ist die eigentliche
# Aussage. Ein Smoke, der sich mangels Docker weggeduckt hat, waere
# sonst nicht von einem bestandenen zu unterscheiden.
grep -E '\[container-smoke\] mode=required executed=[0-9]+ passed=[0-9]+ skipped=0' /tmp/container-smoke.log
grep -F '[container-smoke]' /tmp/container-smoke.log >> "$GITHUB_STEP_SUMMARY"

# Integrierter Read-Through-Nachweis (EYT-50): Browser -> Same-Origin-Rewrite
# -> NestJS -> TenantQueryRunner -> RLS -> PostgreSQL.
Expand Down Expand Up @@ -699,10 +744,14 @@ jobs:
echo "EASYTREE_JOURNEY_ADMIN_DB_URL=postgresql://postgres:postgres@127.0.0.1:54322/postgres"
} >> "$GITHUB_ENV"
echo "Supabase-API unter ${API_URL} uebernommen (Anon-Key maskiert)."
- name: Bauen — ECHTE API und Web mit Rewrite auf ihren Port
# Das Proxyziel steht NICHT mehr hier: seit EYT-126 setzt es
# apps/web/e2e/auth-journey/config.ts in der webServer-Umgebung, also zur
# Laufzeit. Genau dieser Job beweist damit zugleich, dass Login, POST-
# Koerper und HttpOnly-Cookies durch die neue Durchreiche gehen.
- name: Bauen — ECHTE API und Web (das Proxyziel kommt erst beim Start)
run: |
pnpm --filter @easytree/api... build
EASYTREE_API_PROXY_TARGET="http://127.0.0.1:3101" pnpm --filter @easytree/web... build
pnpm --filter @easytree/web... build
- name: Chromium installieren
run: pnpm --filter @easytree/web exec playwright install --with-deps chromium
- name: Reise fahren
Expand Down
132 changes: 116 additions & 16 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand Down Expand Up @@ -341,14 +343,28 @@ guard that fails if the Supabase JS SDK name appears anywhere under `apps/web`
string is assembled from parts on purpose, so don't "fix" it by inlining the literal.

**There is no `NEXT_PUBLIC_API_URL` any more** (removed in EYT-50) — do not reintroduce it.
The browser calls **relative** paths and `providers.tsx` passes the empty origin; `next.config.ts`
rewrites `/api/:path*`, `/health` and `/ready` server-side to `EASYTREE_API_PROXY_TARGET`. Two
reasons, both deliberate: one visible origin means the API needs no CORS and no extra public
surface, and a `NEXT_PUBLIC_*` value would be baked into the browser bundle at build time.
`lib/api-proxy-target.ts` validates that target strictly (absolute http/https, no credentials,
no query/fragment, no trailing slash) and has **no default in production** — the build fails
there instead of silently proxying to localhost, which would look like an empty week in the
browser rather than an error. Each gateway's URL is assembled in exactly one place — its own
The browser calls **relative** paths and `providers.tsx` passes the empty origin; the Route
Handlers under `apps/web/app/api/[[...pfad]]`, `app/health` and `app/ready` forward them
server-side to `EASYTREE_API_PROXY_TARGET`, read fresh on **every request** (EYT-126 —
`next.config.ts` no longer carries a `rewrites()` block). Two reasons, both deliberate: one
visible origin means the API needs no CORS and no extra public surface, and a `NEXT_PUBLIC_*`
value would be baked into the browser bundle at build time. `lib/api-proxy-target.ts` validates
that target strictly (absolute http/https, no credentials, no query/fragment, no trailing slash)
and has **no default in production** — `apps/web/instrumentation.ts` therefore refuses the
server start instead of silently proxying to localhost, which would look like an empty week in
the browser rather than an error. The single pass-through is `lib/proxy-durchreichen.ts`; it is
also the one place that keeps the internal address off the wire — no `x-middleware-rewrite`
exists, an absolute `location` header pointing at the configured target is rewritten to a
relative path (an external redirect is left alone), **every other response header whose VALUE
names the configured target is dropped** rather than rewritten (`X-Upstream-Url`,
`Link: <…>; rel="self"`, `Content-Location`, a `Set-Cookie` carrying an internal `Domain` —
external addresses stay, multiple `set-cookie` stay multiple), and a connection failure becomes
a 502 that names no host. That match targets the **address, not the word**: header names are not
inspected at all, URLs in the value are parsed with `new URL()` and compared by origin, the
`host:port` authority matches only at a character boundary and only when the target declares a
port, and the bare hostname matches only as the **whole** value. A bare-substring match would
swallow `X-Api-Version: 1` for a target named `api` — the guard would then be a silent outage
rather than a protection. Each gateway's URL is assembled in exactly one place — its own
factory — because the test that checks it must call the same function production does; an
earlier version built its own gateway and would have stayed green whatever `providers.tsx` did.
There are **three** such factories (`lib/planning-gateway-factory.ts`,
Expand Down Expand Up @@ -401,7 +417,8 @@ proven in CI against a real Supavisor (`[tenant-pooling] …`). This layer sets
defense-in-depth. Repositories see only the `TenantQuery` interface, never a driver type.

**The read path is proven end to end, not assembled from unit tests** (EYT-50). Browser →
same-origin Next rewrite → NestJS → `TenantQueryRunner` → RLS → PostgreSQL, exercised by
same-origin Next Route Handler (`lib/proxy-durchreichen.ts`; it was a `rewrites()` entry until
EYT-126) → NestJS → `TenantQueryRunner` → RLS → PostgreSQL, exercised by
`scripts/read-through-harness.sh` (CI job `read-through`) with `apps/web/e2e/read-through.spec.ts`.
Only the subject resolver and the access policy are substituted (`apps/api/test/harness/server.ts`,
built via `pnpm --filter @easytree/api run build:harness`); repository, runner, pool and controller
Expand Down Expand Up @@ -539,6 +556,89 @@ gh run view <run> --log --job <db-gates-job-id> \
| grep -oE '\[[a-z-]+\] mode=[a-z]+ executed=[0-9]+ passed=[0-9]+ skipped=[0-9]+' | sort -u
```

## Deployment — Container first (Entscheidung 21.08.2026)

Kanonisch ist Confluence **"EasyTree – Deployment-Entscheidung 21.08.2026: VPS + Coolify +
Docker"** (Seite 30998530): **primär eigener VPS mit Coolify und Docker/OCI, sekundär Railway,
Cloudflare Workers ist kein Zielruntime mehr.** Coolify ist Orchestrator, nicht Teil der
Facharchitektur. Die Cloudflare-Artefakte (`apps/*/wrangler.jsonc`, `apps/web/open-next.config.ts`,
die Schritte in `build-web`/`build-api`) bleiben vorerst stehen und werden erst nach belegter
Container-Parität entfernt — das ist EYT-149, nicht dieser Slice.

Beide Workloads werden aus dem Wurzelverzeichnis gebaut (`apps/api/Dockerfile`,
`apps/web/Dockerfile`, Topologie in `docker-compose.yml`), mit `pnpm install --frozen-lockfile`
und `corepack`. Der Outbox-Worker benutzt **dasselbe** API-Image und überschreibt nur das
Kommando mit `node dist/worker.js`. Nur `web` veröffentlicht einen Port; die API hängt am
internen Netz. Beide Images laufen als `node`, nicht als root, und tragen den Commit als
OCI-Label `org.opencontainers.image.revision`.

**`EASYTREE_API_PROXY_TARGET` ist LAUFZEIT-Konfiguration — gemessen 22.08.2026 auf Next 16.2.11
(EYT-126).** Der Same-Origin-Proxy liegt seit EYT-126 in Route Handlern
(`apps/web/app/api/[[...pfad]]`, `app/health`, `app/ready`), nicht mehr in
`next.config.ts`-`rewrites()`. Gemessen im Container-Smoke: **ein** Web-Image wurde einmal
gebaut, sein Digest festgehalten, und derselbe Digest zweimal gegen verschiedene APIs gestartet —
`ziel=http://easytree-stub-a:3001 -> {"stub":"easytree-stub-a"}`,
`ziel=http://easytree-stub-b:3001 -> {"stub":"easytree-stub-b"}`, und
`vorher=<digest> nachher=<digest>` 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: <interne Adresse>` an den Browser — der Kopf lässt sich nicht entfernen,
ohne die Weiterleitung abzuschalten —, und `@opennextjs/cloudflare` bricht mit `Node.js
middleware is not currently supported` ab, was den Pflichtjob `build-web` rot machen würde.
Edge-Middleware scheidet aus, weil dort `process.env` beim Bauen eingebacken wird.

`scripts/smoke-container.sh` ist der Container-Smoke; er läuft am Ende von `db-gates` (kein
eigener Job — der wäre kein Pflichtcheck, bis jemand das Ruleset neu anwendet) und meldet
`[container-smoke] mode=required executed=… passed=… skipped=0`. Er prüft OCI-Label gegen den
Head, Nicht-root, `/health`, `/ready` mit echter Datenbank, den Weg Web→API über den
Dienstnamen, dass der Dienstname von aussen NICHT auflösbar ist, dass weder das ausgelieferte
HTML noch ein Client-Chunk noch ein Antwortkopf noch ein `location`-Kopf die interne Adresse
nennt, geheimnisfreie Protokolle, das Verweigern des Starts im Produktionsprofil ohne
`DATABASE_SSL_ROOT_CERT`, **ein Image gegen zwei Ziele bei unverändertem Digest**, das
Fail-closed-Verhalten bei fehlendem und bei ungültigem Proxyziel, und geordnetes Herunterfahren.
Der Antwortkopf-Teil ist dabei nicht auf `location` beschränkt: **jeder** übrige Kopf, dessen
**Wert** die interne Adresse nennt, fällt in `lib/proxy-durchreichen.ts` ersatzlos weg —
`X-Upstream-Url`, `Link: <…>; rel="self"`, `Content-Location`, ein `Set-Cookie` mit interner
`Domain`. Weggelassen und nicht umgeschrieben, weil ein Kopf ohne festgelegte Bedeutung keine
Struktur trägt, aus der sich eine Übersetzung ableiten ließe; fremde Adressen bleiben stehen,
mehrere `Set-Cookie` bleiben mehrere. **Gesucht wird die Adresse, nicht das Wort** — der
Kopfname wird gar nicht geprüft, eine URL im Wert wird mit `new URL()` gefunden und über ihre
Origin verglichen, die Autorität `host:port` nur an einer Zeichengrenze und nur bei
ausgewiesenem Port, der nackte Hostname nur als **ganzer** Wert. Ein Teilstringvergleich gegen
den nackten Dienstnamen wäre bei der vorgesehenen Topologie `http://api:3001` fatal: er
verschluckte `X-Api-Version: 1`, eine Doku-URL auf `api.example.org` und ein Cookie
`api_session` — kein Schutz, sondern ein stiller Ausfall. Die Stub-Container des Smokes senden
sowohl den leckenden als auch diese harmlosen Köpfe nachweislich; sonst wäre weder die
Abwesenheit der einen noch die Anwesenheit der anderen ein Nachweis.
Lokal gemessen 22.08.2026: `[container-smoke] mode=local executed=27 passed=27 skipped=0`.
Runbook:
[`docs/runbooks/staging-deploy.md`](docs/runbooks/staging-deploy.md).

**Ein Deploy ist trotzdem gesperrt.** `BLOCKER_ENVIRONMENT_SEPARATION`: es existiert keine
EasyTree-Datengrenze, deren `project_ref` von `inypnrvpawvhgiyagxbd` verschieden ist
(nachgemessen 21.08.2026 über `list_projects` und `list_branches`). Der **kostenlose** Weg dorthin
ist zusätzlich versperrt: ein `create_project`-Versuch für `easytree-staging` (Kosten gemessen
0 $/Monat) wurde mit einem Quota-Fehler abgelehnt — die Free-Projekt-Quota zählt **pro Nutzer**
über alle Organisationen hinweg, in denen er Owner oder Admin ist, und `DYAI2025` hat sie mit zwei
aktiven Free-Projekten ausgeschöpft; das zweite ist über diesen Zugang nicht sichtbar. Ein
pausiertes Projekt zählt laut Supabase-Doku nicht mit, das pausierte „Bazodiac" zu löschen hilft
also nicht. Siehe
[`docs/plans/2026-08-20-sprint-6-staging-blocker.md`](docs/plans/2026-08-20-sprint-6-staging-blocker.md).

## Deployment (Railway) — measured 01.08.2026

- The API runs as Railway service `EasyTree` (project `EasyTree`, environment `production`),
Expand Down
Loading
Loading