3298efe54b
Co-authored-by: Copilot <copilot@github.com>
13 KiB
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.
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.