338 lines
15 KiB
Markdown
338 lines
15 KiB
Markdown
# 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 (<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 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=<timestamp>` 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: `<NAS 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.
|