15 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.from('leads').insert({...})
→ :55521/rest/v1/leads (POST, apikey=anon)
→ PostgREST → postgres
→ RLS "leads_anon_insert": insert allowed, select denied
← 201 (no body returned to anon)
The frontend never reads leads back. If the INSERT 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.
3. Database layout
postgres (database = "postgres")
├── auth schema (GoTrue)
├── storage schema (Storage API)
├── _realtime schema (Realtime bookkeeping)
└── public schema
├── vehicles (fleet)
├── leads (form submissions)
├── customers (spawned from qualified leads)
├── tg_touch_updated_at() trigger fn
├── qualify_lead() RPC
├── disqualify_lead() RPC
└── reopen_lead() 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,messagestatus check in ('new','qualified','disqualified')defaultnewis_active booldefaulttrue— filter for Active/Inactive tabsadmin_notes textsource textdefault'website'qualified_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_atnotesstatus check in ('active','inactive')created_by → auth.users(id)
3.4 RLS matrix
| Table | anon | authenticated |
|---|---|---|
| vehicles | SELECT where is_active=true |
full CRUD |
| leads | INSERT only | full CRUD |
| customers | denied | full CRUD |
3.5 RPCs
| Function | Role required | Semantics |
|---|---|---|
qualify_lead(uuid, text) |
authenticated | Locks lead row, flips to qualified+inactive, inserts matching customer. 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. |
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, persistSession:true)
├── i18n.js (DE/EN)
├── styles.css (copper/charcoal palette)
├── 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. - Force-password-change modal is the only state that can preempt the rest of the admin UI after login.
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. - Storage bucket is public-read (photos must render on the public site) but writes need authenticated, MIME-restricted, size-limited.