# ARQUITECTURE.md – MC Cars stack A thorough walkthrough of how the MC Cars system is wired. Complements [AGENT.md](AGENT.md) (operational notes) and [README.md](README.md) (how to run). --- ## 1. High-level diagram ``` Browser () │ │ :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/ → Kong → storage-api → /mnt/.../data/storage ← public URL served via imgproxy for on-the-fly transforms admin.js → UPDATE vehicles SET photo_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 pk` - `brand`, `model`, `power_hp`, `top_speed_kmh`, `acceleration`, `seats`, `daily_price_eur` - `sort_order`, `location` - `description_de`, `description_en` - `photo_url`, `photo_path` (storage object key for deletion) - `is_active bool` — public visibility flag - `created_at`, `updated_at` ### 3.2 `public.leads` - `id uuid pk` - `name`, `email`, `phone` - `vehicle_id → vehicles(id) ON DELETE SET NULL` - `vehicle_label text` — denormalized at submit; survives vehicle deletion - `date_from`, `date_to`, `message` - `status check in ('new','qualified','disqualified')` default `new` - `is_active bool` default `true` — filter for Active/Inactive tabs - `admin_notes text` - `source text` default `'website'` - `qualified_at`, `qualified_by → auth.users(id)` ### 3.3 `public.customers` - `id uuid pk` - `lead_id → leads(id) ON DELETE RESTRICT` + `unique index` (exactly one customer per lead) - `name`, `email`, `phone` - `first_contacted_at` - `notes` - `status 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.23` on internal `:4000`, DB_USER=`postgres` (no `supabase_admin` on plain postgres). - Publication `supabase_realtime` dynamically adds `leads`, `customers`, `vehicles`. - `replica identity full` on 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 three `postgres_changes` handlers. --- ## 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.js` is generated at container start by an **inline `entrypoint:` command** in `docker-compose.yml`. It writes `window.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 `.sh` file would be silently ignored. - `config.js` is **git-ignored**. The repo never ships a file with hardcoded URLs. - Both `index.html` and `admin.html` load `config.js` with `?v=` appended via an inline `document.write` snippet, ensuring proxies and browsers never cache a stale URL. - The `web` service uses `nginx:1.27-alpine` directly with bind-mounted files (no `build:` step). This is Portainer-compatible: updating the frontend is `git pull` + `docker compose up -d --force-recreate web`. - `admin.js` is ES modules, imports `@supabase/supabase-js` from `esm.sh` (CDN, pinned version). One Supabase client per page. - State lives in a single `state` object. 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 cluster - `data/storage/` — object bucket files What's code/config (committed): - `docker-compose.yml`, `.env`, `supabase/`, `frontend/`, `AGENT.md`, `ARQUITECTURE.md`, `README.md`, `.gitattributes` **Portainer deployment:** 1. Clone repo to `/mnt/user/appdata/mc-cars` 2. `mkdir -p data/{db,storage}` 3. Edit `.env`: set `SITE_URL` and `SUPABASE_PUBLIC_URL` to your domain (see below) 4. Portainer → Stacks → Add stack → paste compose + env vars → Deploy > No `chmod` needed. The entrypoint that writes `config.js` is an inline shell command in `docker-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: `` (e.g. `192.168.178.3`) - Forward Port: `55580` - Cache Assets: **OFF** (if left ON, NPM caches `config.js` and 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:* ```nginx 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: 1. Generate a new `JWT_SECRET` and matching `ANON_KEY` / `SERVICE_ROLE_KEY` (see Supabase self-hosting docs). 2. Set a strong `POSTGRES_PASSWORD`, `ADMIN_PASSWORD`. 3. Wire real SMTP for password-reset mail (`SMTP_*`). 4. 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 into `auth.users` via `post-boot.sql` or Studio. - **Bootstrap password is forced out** on first login (`must_change_password` metadata). 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.