Files
mc_cars_gmbh_infraestructure/ARQUITECTURE.md
T

13 KiB
Raw Blame History

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 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: corskey-authacl → 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.js is generated at container start by 99-config.sh (bind-mounted into /docker-entrypoint.d/) from $SUPABASE_URL and $SUPABASE_ANON_KEY only. The service_role key is never mounted into the web container.
  • 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 + container restart.
  • 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. chmod +x the .sh files
  3. mkdir -p data/{db,storage}
  4. 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 .env URLs to https://cars.yourdomain.com

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.