13 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/
├── Dockerfile (legacy, not used in Portainer deploy)
├── 99-config.sh (entrypoint: writes config.js at boot)
├── 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 by99-config.sh(bind-mounted into/docker-entrypoint.d/) from$SUPABASE_URLand$SUPABASE_ANON_KEYonly. The service_role key is never mounted into the web container.- The
webservice usesnginx:1.27-alpinedirectly with bind-mounted files (nobuild:step). This is Portainer-compatible: updating the frontend isgit pull+ container restart. 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 chmod +xthe.shfilesmkdir -p data/{db,storage}- Portainer → Stacks → Add stack → paste compose + env vars → Deploy
Nginx Proxy Manager (single public domain):
- Proxy
/→mccars-web:80(or<host>:55580) - Custom locations
/auth/v1/,/rest/v1/,/realtime/v1/,/storage/v1/→mccars-kong:8000(or<host>:55521) - Do not expose
/pg/or Studio publicly - Update
.envURLs tohttps://cars.yourdomain.com
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.