Co-authored-by: Copilot <copilot@github.com>
22 KiB
ARQUITECTURE.md – MC Cars stack
A thorough walkthrough of how the MC Cars system is wired. Complements AGENT.md (operational notes) and README.md (how to run).
1. High-level diagram
Browser (<host>)
│ │
:55580 (nginx) :55521 (Kong) :55530 (Studio)
│ │ │
┌──────┴──────┐ │ │
│ web │ │ │
│ (static) │ │ │
└─────────────┘ │ │
│ │
┌─────────────────────────┼─────────────────────────┤
│ │ │ │ │
/auth/v1 /rest/v1 /realtime/v1 /storage/v1 /pg/
│ │ │ │ │
┌────┴───┐ ┌─────┴────┐ ┌────┴────┐ ┌─────┴────┐ ┌────┴───┐
│ gotrue │ │ postgrest│ │ realtime│ │ storage │ │ meta │
└────┬───┘ └─────┬────┘ └────┬────┘ └─────┬────┘ └────┬───┘
│ │ │ │ │
└──────────┬─┴────────────┴─────────────┴───────────┘
│
┌────┴─────┐ ┌───────────┐
│ postgres │ │ imgproxy │
│ (wal: │ │ (resize) │
│ logical) │ └───────────┘
└──────────┘ ▲
│ │
/mnt/.../data/db /mnt/.../data/storage
Dashed lifetime services (exit 0 once done): post-init.
Network: single user-defined bridge mccars. No host networking, no ingress controller — everything is container-to-container DNS (db, auth, kong, …).
2. Request flows
2.1 Public visitor reads cars
browser → :55580/index.html (nginx, static)
browser → :55521/rest/v1/vehicles?select=*&is_active=eq.true
→ Kong → rest (PostgREST) → postgres
→ RLS: "vehicles_select_active" (for anon, is_active=true only)
← JSON
No auth header except the anon apikey. Kong strips the key and forwards with the service's internal Auth context.
2.2 Public visitor submits booking
browser → supabase.rpc('create_lead', {p_name, p_email, p_phone, p_vehicle_id,
p_vehicle_label, p_date_from, p_date_to, p_message, p_source,
p_ip_address, p_ip_country})
→ :55521/rest/v1/rpc/create_lead (POST, apikey=anon)
→ PostgREST → postgres.public.create_lead(...)
→ internally calls calculate_price() → computes pricing
→ INSERT INTO leads (with pricing columns)
← lead uuid
No price parameters are accepted from the client — all pricing is computed server-side. This prevents price-tampering. The frontend never reads leads back. If the RPC fails (validation, RLS), the UI shows a localized failure string.
2.3 Admin login
browser(admin.html) → supabase.auth.signInWithPassword
→ :55521/auth/v1/token?grant_type=password
→ Kong → gotrue → postgres(auth.users)
← access_token (JWT, aud=authenticated), refresh_token
← user.raw_user_meta_data.must_change_password (true on bootstrap)
if must_change_password:
user blocked in "Passwort setzen" screen
supabase.auth.updateUser({password, data:{must_change_password:false}})
→ :55521/auth/v1/user (PUT with Bearer access_token)
→ gotrue updates auth.users
2.4 Admin qualifies a lead
admin.js → supabase.rpc('qualify_lead', {p_lead_id, p_notes})
→ PostgREST → postgres.public.qualify_lead(uuid,text)
(SECURITY INVOKER, role=authenticated, auth.uid()=admin)
BEGIN
SELECT ... FOR UPDATE on leads
UPDATE leads SET status='qualified', is_active=false, ...
INSERT INTO customers (lead_id, name, email, phone, ...)
COMMIT
← customers row
Realtime publication fires:
postgres_changes(table=leads, event=UPDATE, ...) → admin.js channel
postgres_changes(table=customers, event=INSERT, ...) → admin.js channel
→ admin UI reloads tabs, badges update
2.5 Photo upload
admin.js → supabase.storage.from('vehicle-photos').upload(path, file)
→ :55521/storage/v1/object/vehicle-photos/<path>
→ Kong → storage-api → /mnt/.../data/storage
← public URL served via imgproxy for on-the-fly transforms
admin.js → UPDATE vehicles SET photo_url = <public url>
Bucket is public-read, write-authenticated, MIME-restricted to image/*, 50 MB cap.
2.6 Server-side price calculation
app.js → supabase.rpc('calculate_price', {p_vehicle_id, p_date_from, p_date_to})
→ :55521/rest/v1/rpc/calculate_price (POST, apikey=anon)
→ PostgREST → postgres.public.calculate_price(uuid, date, date)
← jsonb {daily_subtotal, weekend_subtotal, subtotal_eur, vat_eur, total_eur,
deposit_eur, total_days, weekday_count, weekend_day_count}
Pricing logic lives only in the database — the frontend never computes prices. create_lead also calls calculate_price internally to store an immutable pricing snapshot on the lead row.
2.7 Lead submission (server-computed prices)
app.js → supabase.rpc('create_lead', {p_name, p_email, p_phone, p_vehicle_id,
p_vehicle_label, p_date_from, p_date_to, p_message, p_source,
p_ip_address, p_ip_country})
→ PostgREST → postgres.public.create_lead(...)
→ internally calls calculate_price → stores pricing on lead row
← lead uuid
No price parameters are accepted from the client. This prevents price-tampering attacks.
2.8 Site settings & configurable hero image
app.js → supabase.from('site_settings').select('value').eq('key','hero_image_url').single()
→ :55521/rest/v1/site_settings?key=eq.hero_image_url&select=value
→ RLS: anon SELECT allowed
← { value: '/images/ferrari-main-car.png' }
→ app.js sets CSS variable --hero-bg on .hero element
Admin upload flow:
admin.js → supabase.storage.from('vehicle-photos').upload('site/hero.ext', file, {upsert:true})
→ storage API writes to bucket
← public URL
admin.js → supabase.from('site_settings').upsert({key:'hero_image_url', value: publicUrl})
→ PostgREST → UPDATE site_settings
← 200
The CSS uses var(--hero-bg, url('images/ferrari-main-car.png')) so the default hero image is shown until JavaScript loads and overrides it.
3. Database layout
postgres (database = "postgres")
├── auth schema (GoTrue)
├── storage schema (Storage API)
├── _realtime schema (Realtime bookkeeping)
└── public schema
├── vehicles (fleet)
├── leads (form submissions, server-computed pricing)
├── lead_attachments (id_document, income_proof per lead)
├── customers (spawned from qualified leads)
├── customer_attachments (admin-uploaded docs for customers)
├── sales_orders (rental orders linked to customer+lead)
├── sales_order_attachments
├── site_settings (key-value config, e.g. hero_image_url)
├── tg_touch_updated_at() trigger fn
├── calculate_price() RPC (public, read-only pricing)
├── create_lead() RPC (server-side price computation)
├── qualify_lead() RPC
├── disqualify_lead() RPC
├── reopen_lead() RPC
├── sales_order_toggle_kaution() RPC
├── sales_order_toggle_rental() RPC
├── sales_order_toggle_complete() RPC
├── customer_update_private_notes() RPC
├── sales_order_update_private_notes() RPC
└── sales_order_upload_attachment() RPC
3.1 public.vehicles
id uuid pkbrand,model,power_hp,top_speed_kmh,acceleration,seats,daily_price_eursort_order,locationdescription_de,description_enphoto_url,photo_path(storage object key for deletion)is_active bool— public visibility flagcreated_at,updated_at
3.2 public.leads
id uuid pkname,email,phonevehicle_id → vehicles(id) ON DELETE SET NULLvehicle_label text— denormalized at submit; survives vehicle deletiondate_from,date_to,message- Pricing (server-computed, immutable):
daily_subtotal,weekend_subtotal,subtotal_eur,vat_eur,total_eur,deposit_eur,total_days,weekday_count,weekend_day_count status check in ('new','qualified','disqualified')defaultnewis_active booldefaulttrue— filter for Active/Inactive tabsadmin_notes textsource textdefault'website'ip_address inet,ip_country text— geolocation info from submissionqualified_at,qualified_by → auth.users(id)
3.3 public.customers
id uuid pklead_id → leads(id) ON DELETE RESTRICT+unique index(exactly one customer per lead)name,email,phonefirst_contacted_atnotes,private_notesstatus check in ('active','inactive')created_by → auth.users(id)
3.4 public.lead_attachments
id uuid pklead_id → leads(id) ON DELETE CASCADEbucket,file_path,file_name,mime_typekind check in ('id_document', 'income_proof', 'other')- Unique partial index: max 1
id_document+ 1income_proofper lead
3.5 public.sales_orders
id uuid pkcustomer_id → customers(id),lead_id → leads(id)order_number,private_noteskaution_paid,rental_paid,rental_complete(booleans + timestamps)- Pricing snapshot:
daily_subtotal,weekend_subtotal,subtotal_eur,vat_eur,total_eur,deposit_eur total_days,weekday_count,weekend_day_count,date_from,date_to,vehicle_label
3.6 public.site_settings
key text pk— setting identifier (e.g.hero_image_url)value text— setting value (e.g. a public URL or path)updated_at timestamptz- RLS: public SELECT for all, authenticated-only INSERT/UPDATE/DELETE
3.7 RLS matrix
| Table | anon | authenticated |
|---|---|---|
| vehicles | SELECT where is_active=true |
full CRUD |
| leads | INSERT only | full CRUD |
| lead_attachments | INSERT only | SELECT + INSERT |
| customers | denied | full CRUD |
| sales_orders | denied | full CRUD |
| site_settings | SELECT only | full CRUD |
3.8 Storage buckets
| Bucket | Public read | anon writes | authenticated writes | Notes |
|---|---|---|---|---|
vehicle-photos |
yes | no | INSERT (MIME image/*, 50 MB) |
Fleet photos + site hero image |
customer-documents |
no | INSERT only | SELECT + INSERT | ID docs, income proofs |
Anon users may only INSERT into customer-documents (no SELECT/UPDATE/DELETE). Admin can SELECT + INSERT but not DELETE (prevents accidental evidence destruction).
3.9 RPCs
| Function | Role required | Semantics |
|---|---|---|
calculate_price(uuid, date, date) |
anon | Read-only pricing calculator. Returns jsonb: {daily_subtotal, weekend_subtotal, subtotal_eur, vat_eur, total_eur, deposit_eur, total_days, weekday_count, weekend_day_count}. Uses vehicle's daily_price_eur, applies 20% weekend surcharge, 19% VAT, 3× daily deposit. |
create_lead(...) |
anon | Creates a lead with server-computed pricing (calls calculate_price internally). Accepts: name, email, phone, vehicle_id, vehicle_label, date_from, date_to, message, source, ip_address, ip_country. No price params from client. |
qualify_lead(uuid, text) |
authenticated | Locks lead row, flips to qualified+inactive, inserts matching customer + sales_order with pricing snapshot. Idempotent: returns existing customer on second call. |
disqualify_lead(uuid, text) |
authenticated | Marks lead disqualified+inactive, stores notes. |
reopen_lead(uuid) |
authenticated | Resets lead to new+active, deletes the spawned customer if any. |
sales_order_toggle_kaution(uuid) |
authenticated | Toggles kaution_paid boolean + timestamp on a sales order. |
sales_order_toggle_rental(uuid) |
authenticated | Toggles rental_paid boolean + timestamp. |
sales_order_toggle_complete(uuid) |
authenticated | Toggles rental_complete boolean + timestamp. |
customer_update_private_notes(uuid, text) |
authenticated | Updates private_notes field on a customer. |
sales_order_update_private_notes(uuid, text) |
authenticated | Updates private_notes field on a sales order. |
All three are SECURITY INVOKER, so auth.uid() is the live admin and RLS applies.
4. Realtime
- Postgres runs with
wal_level=logical,max_wal_senders=10,max_replication_slots=10. supabase/realtime:v2.30.23on internal:4000, DB_USER=postgres(nosupabase_adminon plain postgres).- Publication
supabase_realtimedynamically addsleads,customers,vehicles. replica identity fullon all three — delivers the full row on updates (needed so the admin UI can show changed fields without re-fetching).- Frontend joins a single channel (
mccars-admin) and attaches threepostgres_changeshandlers.
5. API gateway (Kong)
Declarative config in supabase/kong.yml. One consumer per role:
| Consumer | apikey |
ACL groups | Routes accessible |
|---|---|---|---|
| anon | ANON_KEY |
anon |
/auth/v1/*, /rest/v1/* (rls-gated), /realtime/v1/*, /storage/v1/object/public/* |
| service_role | SERVICE_ROLE_KEY |
admin |
all of the above + /pg/* (postgres-meta), write paths |
Plugins pipeline: cors → key-auth → acl → proxy. Public routes (storage public objects) are whitelisted without key-auth.
Host port mapping: 55521:8000 (8000 blocked by Docker Desktop's wslrelay on Windows; 555xx range avoids collisions on busy Docker hosts).
6. Frontend architecture
frontend/
├── nginx.conf (serves /, gzip, cache headers)
├── index.html app.js (public site, anon key, persistSession:false)
├── admin.html admin.js (admin CRM + settings, persistSession:true)
├── i18n.js (DE/EN)
├── styles.css (copper/charcoal palette, CSS-variable hero image)
├── impressum.html
└── datenschutz.html
config.jsis generated at container start by an inlineentrypoint:command indocker-compose.yml. It writeswindow.MCCARS_CONFIG={SUPABASE_URL,SUPABASE_ANON_KEY}from the container's environment variables. No separate script file is used — Unraid's filesystem does not preserve Unix execute bits on bind mounts, so a mounted.shfile would be silently ignored.config.jsis git-ignored. The repo never ships a file with hardcoded URLs.- Both
index.htmlandadmin.htmlloadconfig.jswith?v=<timestamp>appended via an inlinedocument.writesnippet, ensuring proxies and browsers never cache a stale URL. - The
webservice usesnginx:1.27-alpinedirectly with bind-mounted files (nobuild:step). This is Portainer-compatible: updating the frontend isgit pull+docker compose up -d --force-recreate web. admin.jsis ES modules, imports@supabase/supabase-jsfromesm.sh(CDN, pinned version). One Supabase client per page.- State lives in a single
stateobject. Admin re-renders the active tab on realtime events. - Admin tabs: Aktive Leads, Inaktive Leads, Kunden, Fahrzeuge, Einstellungen (site settings: hero image upload).
- Force-password-change modal is the only state that can preempt the rest of the admin UI after login.
- Public site hero image is loaded dynamically from
site_settingstable at boot and applied via CSS variable--hero-bg. Fallback default is baked intostyles.css.
7. Deployment
Host root: /mnt/user/appdata/mc-cars
All bind mounts in docker-compose.yml use absolute paths under that root. The compose has no build: steps — every image is pulled from a registry, making it Portainer-compatible.
What's state (under /mnt/user/appdata/mc-cars/data/):
data/db/— Postgres clusterdata/storage/— object bucket files
What's code/config (committed):
docker-compose.yml,.env,supabase/,frontend/,AGENT.md,ARQUITECTURE.md,README.md,.gitattributes
Portainer deployment:
- Clone repo to
/mnt/user/appdata/mc-cars mkdir -p data/{db,storage}- Edit
.env: setSITE_URLandSUPABASE_PUBLIC_URLto your domain (see below) - Portainer → Stacks → Add stack → paste compose + env vars → Deploy
No
chmodneeded. The entrypoint that writesconfig.jsis an inline shell command indocker-compose.yml, not a bind-mounted script, so file permissions are irrelevant.
Environment: two variables to change per environment
| Variable | Local dev | Production (NAS) |
|---|---|---|
SITE_URL |
http://localhost:55580 |
https://your.domain.com |
SUPABASE_PUBLIC_URL |
http://localhost:55521 |
https://your.domain.com |
All other GoTrue vars (API_EXTERNAL_URL, GOTRUE_SITE_URL, GOTRUE_URI_ALLOW_LIST) are derived from these two in docker-compose.yml.
Nginx Proxy Manager (NPM) — required settings for single-domain HTTPS:
Create one proxy host for your domain (e.g. demo.lago.dev):
Details tab:
- Scheme:
http - Forward Hostname / IP:
<NAS IP>(e.g.192.168.178.3) - Forward Port:
55580 - Cache Assets: OFF (if left ON, NPM caches
config.jsand serves stale URLs) - Websockets Support: ON (required for Realtime)
- Block Common Exploits: ON
SSL tab:
- SSL Certificate: your domain cert
- Force SSL: ON
- HTTP/2 Support: ON
Advanced tab (⚙️) — paste this exactly:
location /auth/v1/ {
proxy_pass http://192.168.178.3:55521;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /rest/v1/ {
proxy_pass http://192.168.178.3:55521;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /realtime/v1/ {
proxy_pass http://192.168.178.3:55521;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
location /storage/v1/ {
proxy_pass http://192.168.178.3:55521;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
This routes https://your.domain.com/ → nginx (static site) and all API paths → Kong. Do not expose /pg/ or Studio publicly.
For real deployments:
- Generate a new
JWT_SECRETand matchingANON_KEY/SERVICE_ROLE_KEY(see Supabase self-hosting docs). - Set a strong
POSTGRES_PASSWORD,ADMIN_PASSWORD. - Wire real SMTP for password-reset mail (
SMTP_*). - Back up
/mnt/user/appdata/mc-cars/data/on a schedule.
8. Security model summary
- No admin keys in the browser, ever. Only the anon key reaches the client.
- Signup is disabled (
GOTRUE_DISABLE_SIGNUP=true). New admins are provisioned by inserting intoauth.usersviapost-boot.sqlor Studio. - Bootstrap password is forced out on first login (
must_change_passwordmetadata). Frontend refuses to reuse the bootstrap value. - RLS is the single source of authorization. The admin UI calling
.from('leads').select('*')works only because the authenticated policy allows it; without a valid Bearer JWT the same query returns zero rows. - RPCs are
SECURITY INVOKER— no role-escalation surface. - Server-side pricing —
create_leadcomputes prices internally viacalculate_price. No price parameters are accepted from the client, preventing price-tampering. - Document security —
customer-documentsbucket allows anon INSERT only (upload), no SELECT/DELETE. Admin can SELECT + INSERT but not DELETE. Unique partial indexes enforce max 1id_document+ 1income_proofper lead. - Storage bucket (
vehicle-photos) is public-read (photos must render on the public site) but writes need authenticated, MIME-restricted, size-limited.