13 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.
0. Operating mode for agents
When working in this repository, act as an orchestrator rather than a single-threaded implementer:
- Always make a short plan before changing code or configuration.
- Delegate both targeted exploration and targeted implementation work to local models when available; the current default is Qwen 3.6 27B.
- Do not treat delegation as optional for substantive work: research, code changes, refactors, and test updates should be routed through an agent first, then reviewed by the orchestrator.
- Review delegated output critically, then apply, refine, or reject the result yourself.
- Keep final responsibility for correctness, verification, and repo consistency in the orchestrating agent.
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.supabase/migrations/08-backend-pricing-and-security.sql—calculate_priceRPC (public, read-only pricing), refactoredcreate_leadRPC (accepts no price params, computes server-side), unique partial indexes onlead_attachments(max 1 id_document + 1 income_proof per lead), hardened storage RLS (anon INSERT-only on customer-documents, admin SELECT+INSERT only).supabase/migrations/09-site-settings.sql—site_settingskey-value table + RLS (public SELECT, admin full CRUD) + seed rowhero_image_url = '/images/ferrari-main-car.png'.
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
NN-*.sql, mount it inpost-init, append one morepsql -fline to the entrypoint indocker-compose.yml. - Current numbering:
01,02,08,09. Use the next available number for new migrations.
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; select on site_settings; insert on customer-documents bucket; execute calculate_price and create_lead RPCs |
authenticated |
JWT from signInWithPassword |
Full CRUD on vehicles/leads/customers/sales_orders/site_settings, execute RPCs, storage uploads |
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.
- Anon cannot read/delete from
customer-documentsbucket — only INSERT (upload). Admin can SELECT + INSERT but not DELETE (prevents evidence destruction). - 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.create_leadcomputes prices server-side viacalculate_price— no price params accepted from client (prevents price-tampering).
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.
config.jsis generated at container start via an inlineentrypoint:command indocker-compose.yml(writeswindow.MCCARS_CONFIG={SUPABASE_URL,SUPABASE_ANON_KEY}). The service_role key is never mounted into thewebcontainer. - No entrypoint script file. An earlier design used
frontend/99-config.shbind-mounted into/docker-entrypoint.d/. Abandoned because Unraid's filesystem does not preserve Unix execute bits on bind mounts — the script was silently skipped. The inlineentrypoint:in compose needs no file permissions. frontend/config.jsis git-ignored (generated at runtime, would commit stalelocalhostvalues otherwise).index.html/admin.htmlloadconfig.jswith a?v=<timestamp>query string (generated by a small inlinedocument.writesnippet) to defeat both browser and proxy caching.- The
webservice usesnginx:1.27-alpinedirectly (nobuild:). Static files andnginx.confare bind-mounted. Updating the frontend isgit pull+docker compose up -d --force-recreate web. 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.- Admin tabs: Aktive Leads, Inaktive Leads, Kunden, Fahrzeuge, Einstellungen. The Einstellungen (Settings) tab manages site-wide configuration (currently: hero image upload).
app.jscallscalculate_priceRPC for sidebar pricing display — no client-side price computation.app.jscallscreate_leadRPC for form submission — prices are computed server-side and stored on the lead.app.jsat boot loadshero_image_urlfromsite_settingsand applies it via CSS variable--hero-bgon.hero. Thestyles.cssfallback isurl('images/ferrari-main-car.png').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). |
config.js not generated, website uses fallback URL |
Unraid doesn't preserve execute bits — entrypoint script skipped. Fixed: config.js is now written by an inline entrypoint: command in docker-compose, no file needed. |
| Website loads but API calls fail ("Failed to fetch") | config.js cached by NPM or browser with old localhost URL. The ?v=timestamp cache-buster on the <script> tag prevents this. Hard-refresh or check NPM "Cache Assets" toggle. |
| Git pull fails with "Your local changes would be overwritten" | .env was modified on the NAS. Use git checkout -- .env && git pull, then re-apply the two URL lines with sed. |
calculate_price returns PGRST202 "Could not find the function" |
Migration 08-backend-pricing-and-security.sql not applied. Check post-init logs or run it manually. |
| Hero image not changing after admin upload | Browser/CDN caching the old image. The URL includes a unique path (site/hero.ext) with upsert — hard-refresh the public page. |
create_lead fails with "function does not exist" |
Same as calculate_price — migration 08 not applied. |
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
curl -X POST http://localhost:55521/rest/v1/rpc/calculate_price \
-H "apikey: $ANON" -H "Content-Type: application/json" \
-d '{"p_vehicle_id":"<uuid>","p_date_from":"2025-01-06","p_date_to":"2025-01-10"}' # pricing JSON
curl http://localhost:55521/rest/v1/site_settings?select=value&key=eq.hero_image_url \
-H "apikey: $ANON" # returns hero image URL
In the browser:
- Open http://<host>:55580, verify the hero image loads (dynamic from settings).
- Submit the booking form — sidebar shows server-computed pricing.
- 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, with pricing columns.
- Click Qualifizieren. Row disappears from active, a new row appears in Kunden with the
lead_iddisplayed. - Go to Einstellungen tab. Upload a new hero image. Verify it appears on the public site after refresh.
- Open Supabase Studio at http://<host>:55530 and confirm
site_settings,leadspricing columns,customers.lead_idFK.
10. Things explicitly NOT in this stack
- 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.
- No client-side price calculation — all pricing is server-side via
calculate_priceRPC.