# AGENT.md – Operational notes for working on MC Cars A compact field guide for anyone (human or AI) making changes to this stack. Skim this first. --- ## 1. The portability contract (don't break it) 1. **No named volumes.** All runtime state bind-mounts to `./data/`. `docker compose down -v` must never be required to migrate: copy the folder, `docker compose up -d`, done. 2. **No absolute paths** in `docker-compose.yml` or any config. 3. **Secrets live in `.env`**; the `.env` ships with dev defaults and is safe to commit until real deploy. Any non-local deployment must rotate every key (see §6). 4. Anything that resets the DB in dev is `Remove-Item -Recurse -Force .\data` — not `docker volume rm`. --- ## 2. Migration lifecycle Migrations come in two flavours: ### First-boot only (runs iff `./data/db` is empty) - `supabase/migrations/00-run-init.sh` — wrapper that runs `01-init.sql` via `psql` so we can pre-create Supabase service roles (`anon`, `authenticated`, `service_role`, `authenticator`, `supabase_auth_admin`, `supabase_storage_admin`) that the official images otherwise assume already exist on their `supabase/postgres` image. We use plain `postgres:15-alpine`, so we create them ourselves. - `supabase/migrations/01-init.sql` — vehicles table, RLS, bucket row, seed demo vehicles. Postgres' official entrypoint only processes `/docker-entrypoint-initdb.d/` on an **empty** data dir. To re-run: wipe `./data/db`. ### Post-boot (every time, idempotent) - `supabase/migrations/post-boot.sql` — seeds the admin `auth.users` row with `must_change_password=true`, creates the `vehicle-photos` bucket row if missing. Parameterized: `psql -v admin_email=... -v admin_password=...`. - `supabase/migrations/02-leads.sql` — leads + customers + RPCs + `supabase_realtime` publication. Uses `create if not exists`, `create or replace`, and `do $$ ... $$` with exists-checks so every object is idempotent. Both are run by the `post-init` service, which gates on `auth.users` and `storage.buckets` being queryable before touching anything. **Adding a new migration:** - If it's schema the app can't start without on a fresh DB → extend `01-init.sql`. - If it's ongoing / idempotent → new file `03-*.sql`, mount it in `post-init`, append one more `psql -f` line to the entrypoint. --- ## 3. Roles, RLS, and the mental model | Role | How obtained | Can do | | ----------------- | ---------------------------------- | ------------------------------------ | | `anon` | Anon JWT (shipped to browser) | `select` on active `vehicles`; `insert` on `leads` only | | `authenticated` | JWT from `signInWithPassword` | Full CRUD on vehicles/leads/customers, `execute` RPCs | | `service_role` | Service JWT (never in browser) | Bypasses RLS | - Anon **cannot** read leads. The booking form is fire-and-forget by design. An abuser submitting with a stolen anon key sees only HTTP 201 with no row data. - RPCs are `SECURITY INVOKER`. They run as the caller, so `auth.uid()` returns the actual admin; RLS still applies. - `qualify_lead` is idempotent — calling it twice returns the existing customer row instead of raising. --- ## 4. Realtime wiring - Postgres command line forces `wal_level=logical`, `max_wal_senders=10`, `max_replication_slots=10`. Without `logical`, `supabase/realtime` crash-loops. - `supabase/realtime:v2.30.23` connects as **`DB_USER=postgres`** (not `supabase_admin`). We're on the plain `postgres:15-alpine` image; the `supabase_admin` role does not exist. - `SECRET_KEY_BASE` must be ≥ 64 chars; the hardcoded value in `docker-compose.yml` is a dev default — rotate it on real deploys. - Replica identity `full` is set on leads/customers/vehicles so updates broadcast the complete row, not just the PK. - Kong route `/realtime/v1/` → `http://realtime:4000/socket` (websocket upgrade headers handled by Kong's default config). --- ## 5. Kong traps - On Windows/WSL2, Docker Desktop's `wslrelay` intercepts `127.0.0.1:8000`. We expose Kong on host **`54321`** (container `8000`). Don't move it back to 8000. - Kong needs `KONG_PLUGINS: bundled,request-transformer,cors,key-auth,acl,basic-auth`. Omitting `acl` breaks Studio. - `kong.yml` has one `consumer` per role with `acl` groups; plugins `key-auth` + `acl` gate which `apikey` (anon vs service_role) can reach which route. --- ## 6. Password rotation & admin bootstrap The `.env` `ADMIN_PASSWORD` is a one-shot **bootstrap** seed. On first login: 1. `admin.js` reads `user.user_metadata.must_change_password`. 2. If true, the UI shows the "Passwort setzen" screen and refuses to proceed. 3. Submit calls `supabase.auth.updateUser({ password, data: { must_change_password: false } })`. 4. The frontend also rejects `newPassword === loginPassword` before sending. Result: the live admin password differs from `.env` from the very first session. The `.env` value is useful only for a brand-new `./data/db`. Password min length is enforced server-side by `GOTRUE_PASSWORD_MIN_LENGTH=10`. --- ## 7. Frontend conventions - **Only** the anon key leaves the server. `frontend/Dockerfile` writes `config.js` at container start from `$SUPABASE_URL` / `$SUPABASE_ANON_KEY`. The service_role key is not mounted into `web`. - `frontend/app.js` (public) creates a Supabase client with `persistSession: false` — the public site never needs a session. - `frontend/admin.js` uses `persistSession: true, storageKey: "mccars.auth"` and a single realtime channel `mccars-admin` that subscribes to leads/customers/vehicles. - `app.js` writes `vehicle_id` (uuid) **and** denormalized `vehicle_label` ("BMW M3") into the lead at submit time, so the admin UI renders even if the vehicle is later deleted. --- ## 8. Common failure modes | Symptom | Cause / Fix | | ------------------------------------------------------- | ---------------------------------------------------------------- | | `auth` container restart loop, "role supabase_auth_admin does not exist" | `00-run-init.sh` didn't run. Wipe `./data/db`, retry. | | `realtime` container exits, "wal_level must be logical" | `db` `command:` missing `wal_level=logical`. | | 502 on `/realtime/v1/` through Kong | Missing `acl` plugin in `KONG_PLUGINS`, or realtime not listed in `kong.yml` consumers. | | Admin login works but Leads tab is empty | RLS: session is `anon` instead of `authenticated`. Check that `signInWithPassword` succeeded and token is attached to subsequent requests. | | Booking form submits but no row appears | anon lacks `INSERT` grant on `public.leads`. Check `02-leads.sql` ran (see `post-init` logs). | | `.sh` files reject with `\r: command not found` | CRLF line endings. `dos2unix` or re-save as LF. | | Port 8000 "connection refused" on Windows | Docker Desktop wslrelay. Use `54321`. | --- ## 9. How to verify a working stack ```powershell docker compose ps # all services up (post-init exited 0) curl http://localhost:54321/rest/v1/vehicles?select=brand -H "apikey: $env:ANON" # 6 demo cars ``` In the browser: 1. Open http://localhost:8080, submit the booking form. 2. Open http://localhost:8080/admin.html, log in with `.env` bootstrap creds. 3. Rotate the password when prompted. 4. The lead you submitted appears in "Aktive Leads" — in real time. 5. Click **Qualifizieren**. Row disappears from active, a new row appears in **Kunden** with the `lead_id` displayed. 6. Open Supabase Studio at http://localhost:3000 and confirm the `customers.lead_id` FK matches. --- ## 10. Things explicitly NOT in this stack - No n8n. Email automation is out of scope here; do it downstream. - No Google Analytics / Google Reviews widget. - No "Anmelden/Registrieren" on the public site. Admin access is intentionally unlinked. - No logflare / analytics. - No edge-functions container — RPCs live in Postgres.