8.7 KiB
8.7 KiB
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 deployment model
- Portainer-native. No
build:steps. Every service pulls a pre-built image. Thewebservice usesnginx:1.27-alpinewith bind-mounted static files. - Absolute host paths. All bind mounts point to
/mnt/user/appdata/mc-cars/.... Runtime state lives under/mnt/user/appdata/mc-cars/data/. - No named volumes. Wiping data means
rm -rf /mnt/user/appdata/mc-cars/data/db— notdocker volume rm. - Secrets live in
.env; the.envships with dev defaults. Any non-local deployment must rotate every key (see §6). - External ports are in the
555xxrange to avoid collisions on a busy host (55521Kong,55530Studio,55532Postgres,55543Kong TLS,55580web).
2. Migration lifecycle
Migrations come in two flavours:
First-boot only (runs iff /mnt/user/appdata/mc-cars/data/db is empty)
supabase/migrations/00-run-init.sh— wrapper that runs01-init.sqlviapsqlso 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 theirsupabase/postgresimage. We use plainpostgres: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 /mnt/user/appdata/mc-cars/data/db.
Post-boot (every time, idempotent)
supabase/migrations/post-boot.sql— seeds the adminauth.usersrow withmust_change_password=true, creates thevehicle-photosbucket row if missing. Parameterized:psql -v admin_email=... -v admin_password=....supabase/migrations/02-leads.sql— leads + customers + RPCs +supabase_realtimepublication. Usescreate if not exists,create or replace, anddo $$ ... $$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 inpost-init, append one morepsql -fline 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, soauth.uid()returns the actual admin; RLS still applies. qualify_leadis 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. Withoutlogical,supabase/realtimecrash-loops. supabase/realtime:v2.30.23connects asDB_USER=postgres(notsupabase_admin). We're on the plainpostgres:15-alpineimage; thesupabase_adminrole does not exist.SECRET_KEY_BASEmust be ≥ 64 chars; the hardcoded value indocker-compose.ymlis a dev default — rotate it on real deploys.- Replica identity
fullis 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
wslrelayintercepts127.0.0.1:8000. On the production host Kong is exposed on55521(container8000). - On a busy Unraid/Docker host all MC Cars ports live in the
555xxrange to avoid collisions. - Kong needs
KONG_PLUGINS: bundled,request-transformer,cors,key-auth,acl,basic-auth. Omittingaclbreaks Studio. kong.ymlhas oneconsumerper role withaclgroups; pluginskey-auth+aclgate whichapikey(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:
admin.jsreadsuser.user_metadata.must_change_password.- If true, the UI shows the "Passwort setzen" screen and refuses to proceed.
- Submit calls
supabase.auth.updateUser({ password, data: { must_change_password: false } }). - The frontend also rejects
newPassword === loginPasswordbefore 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/99-config.sh(bind-mounted into the nginx entrypoint dir) writesconfig.jsat container start from$SUPABASE_URL/$SUPABASE_ANON_KEY. The service_role key is not mounted intoweb. - The
webservice usesnginx:1.27-alpinedirectly (nobuild:). Static files + nginx.conf + the entrypoint script are bind-mounted read-only. Updating the frontend isgit pull+ restart the container. .gitattributesenforceseol=lfon*.shfiles so the entrypoint doesn't break with CRLF on Windows checkouts.frontend/app.js(public) creates a Supabase client withpersistSession: false— the public site never needs a session.frontend/admin.jsusespersistSession: true, storageKey: "mccars.auth"and a single realtime channelmccars-adminthat subscribes to leads/customers/vehicles.app.jswritesvehicle_id(uuid) and denormalizedvehicle_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 /mnt/user/appdata/mc-cars/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 55521 (production port). |
9. How to verify a working stack
docker compose ps # all services up (post-init exited 0)
curl http://localhost:55521/rest/v1/vehicles?select=brand -H "apikey: $ANON" # 6 demo cars
In the browser:
- Open http://<host>:55580, submit the booking form.
- Open http://<host>:55580/admin.html, log in with
.envbootstrap creds. - Rotate the password when prompted.
- The lead you submitted appears in "Aktive Leads" — in real time.
- Click Qualifizieren. Row disappears from active, a new row appears in Kunden with the
lead_iddisplayed. - Open Supabase Studio at http://<host>:55530 and confirm the
customers.lead_idFK 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.