INVARIANTS — Contrat d’architecture (Template Apps)

Introduction

Ce document est le contrat d’architecture à respecter pour toutes les applications issues du template (MDP, Calendrier, futures apps).

Règles générales :

  • Toute proposition doit être compatible avec le code existant (pas de régression).
  • Les correctifs doivent être minimaux, incrémentaux et réversibles.
  • Les secrets ne doivent jamais apparaître dans des fichiers .env versionnés.
  • Tout snippet fourni doit être autosuffisant et prêt à copier (avec le chemin si partiel).
  • En production, Traefik est le frontal unique (ne jamais supposer Apache/Nginx directement sur 80/443).

1) Nommage et arborescence

1.1 Variables canoniques

  • APP_DEPOT : nom du dépôt GitHub (= répertoire en DEV) → ~/projets/${APP_DEPOT}.
  • APP_SLUG : préfixe stable pour images/containers/volumes/réseau et répertoire PROD → /opt/apps/${APP_SLUG}.
  • APP_ENV{dev, prod}.
  • APP_NAME : nom humain (entêtes/logs/outils).

1.2 Compose — services et ressources Docker

  • Noms de services fixes (cross-apps) : db, backend, vite, frontend.
  • Ressources Docker : ${APP_SLUG}_<service>_${APP_ENV}.
  • Réseau par défaut : ${APP_SLUG}_appnet.

2) Ports DEV dérivés de APP_NO

À partir de APP_NO = N (ex. N=1) :

  • DEV_DB_PORT = 5432 + N (ex. 5433)
  • DEV_API_PORT = 8001 + N (ex. 8002)
  • DEV_VITE_PORT = 5173 + N (ex. 5174)

Ces ports sont la source de vérité : utilisés dans Compose et par les scripts.


2bis) Dérivations depuis APP_SLUG et APP_NO

2bis.1 Variables canoniques

  • APP_SLUG : identifiant court de l’app (ex. mdp, cal) ; préfixe pour images/containers/volumes/réseaux et dossier PROD /opt/apps/${APP_SLUG}.
  • APP_NO = N : index numérique pour dériver les ports hôte en DEV et éviter les collisions inter-apps.
  • APP_ENV ∈ {dev, prod} : sélection d’environnement.

2bis.2 Dérivées à partir de APP_SLUG

PostgreSQL

  • POSTGRES_USER = ${APP_SLUG}_pg_user
  • POSTGRES_DB = ${APP_SLUG}_pg_db
  • POSTGRES_PASSWORD : uniquement dans .env.$(APP_ENV).local (non commit)
  • Host interne (Docker DNS) : db
  • Port interne : 5432

Nommage Docker

  • Containers : ${APP_SLUG}_<service>_${APP_ENV} (services = db, backend, vite, frontend)
  • Réseau par défaut : ${APP_SLUG}_appnet
  • Volume DB recommandé : ${APP_SLUG}_db_data

Répertoires

  • DEV : ~/projets/${APP_DEPOT} (où ${APP_DEPOT} = nom du repo Git)
  • PROD : /opt/apps/${APP_SLUG}

2bis.3 Dérivées à partir de APP_NO

Ports hôte (DEV)

  • DEV_DB_PORT = 5432 + N
  • DEV_API_PORT = 8001 + N
  • DEV_VITE_PORT = 5173 + N

Accès extérieur (DEV, optionnel) : pour se connecter à Postgres depuis l’hôte (ou pgAdmin), publier ${DEV_DB_PORT}:5432 (avec ${DEV_DB_PORT}=5432+N). Les autres conteneurs utilisent toujours db:5432.

2bis.4 Rappel des ports internes

  • DB 5432, backend 8000, vite 5173fixes dans les conteneurs ; seul le port hôte varie via APP_NO.

3) Environnements et secrets

3.1 Fichiers conservés (canon)

  • .env.dev, .env.prod : variables non sensibles (versionnées).
  • .env.local.example : modèle de secrets (non sensible) pour guider la création de .env.local non versionné.

Interdit : secrets dans .env.dev / .env.prod.

Les secrets réels doivent être conservés uniquement dans *.local (non commit) :

  • POSTGRES_PASSWORD
  • DJANGO_SECRET_KEY
  • ADMIN_USERNAME
  • ADMIN_PASSWORD
  • ADMIN_EMAIL
  • etc.

Un symlink .env pointe toujours vers .env.$(APP_ENV).


3bis) Postgres — Host, rôle, DB et connexion standard

3bis.1 Invariants de connexion

  • Host (réseau Compose) : db
  • Port : 5432
  • Utilisateur : ${POSTGRES_USER} (= ${APP_SLUG}_pg_user)
  • Base : ${POSTGRES_DB} (= ${APP_SLUG}_pg_db)
  • Accès hors conteneur (DEV, optionnel) : localhost:${DEV_DB_PORT} (mapping ${DEV_DB_PORT}:5432).

3bis.2 Exemples .env.dev / .env.prod (versionnés, sans secrets)

# .env.dev (exemple)
APP_ENV=dev
APP_SLUG=mdp
APP_DEPOT=gestionnaire_mdp_zero_knowledge
APP_NO=1

# Dérivées DB (sans mot de passe ici)
POSTGRES_USER=${APP_SLUG}_pg_user
POSTGRES_DB=${APP_SLUG}_pg_db

# Ports hôte (DEV)
DEV_DB_PORT=$((5432 + APP_NO))
DEV_API_PORT=$((8001 + APP_NO))
DEV_VITE_PORT=$((5173 + APP_NO))

# Frontend: base API toujours relative
VITE_API_BASE=/api

3bis.2bis Fichiers non versionnés (secrets)

# .env.local (exemple — non commit)
POSTGRES_PASSWORD=change_me
DJANGO_SECRET_KEY=django-unsafe-dev-…
ADMIN_USERNAME=admin
ADMIN_EMAIL=admin@example.test
ADMIN_PASSWORD=adminpass

3bis.3 Compose (DEV) — fragments normatifs

Service DB (host interne = db)

services:
  db:
    image: postgres:16-alpine
    container_name: ${APP_SLUG}_db_${APP_ENV}
    environment:
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: ${POSTGRES_DB}
    ports:
      - "${DEV_DB_PORT}:5432"   # 5432 interne → 5432+N côté hôte (si besoin)
    volumes:
      - ${APP_SLUG}_db_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB -h 127.0.0.1"]
      interval: 5s
      timeout: 3s
      retries: 10

Service backend (Django)

services:
  backend:
    depends_on:
      db:
        condition: service_healthy
    environment:
      DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
    ports:
      - "${DEV_API_PORT}:8000"  # 8000 interne → 8001+N côté hôte

Service vite (dev server + proxy API)

services:
  vite:
    environment:
      VITE_API_BASE: "/api"      # chemin relatif (constant)
    ports:
      - "${DEV_VITE_PORT}:5173"  # 5173 interne → 5173+N côté hôte

Rappel : en PROD, on ne publie jamais 80/443 depuis les apps — Traefik est le frontal unique. Un bind 127.0.0.1:${PROD_API_PORT}→8000 peut exister pour debug/health local uniquement, jamais exposé publiquement.

3bis.4 Commandes psql canoniques

Depuis le conteneur backend (recommandé) :

docker compose --env-file .env.dev -f docker-compose.dev.yml exec backend \
  psql -h db -p 5432 -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c '\\dt'

Depuis l’hôte (si port DEV DB publié) :

psql -h localhost -p "$DEV_DB_PORT" -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c 'select 1;'

3bis.5 Accès externe et tunnels

  • DEV : publier ${DEV_DB_PORT}:5432 uniquement si nécessaire. Exemple : psql -h localhost -p "$DEV_DB_PORT" ....
  • PROD (recommandé) : ne pas publier la DB. Utiliser un tunnel SSH ponctuel :
ssh -N -L 15433:127.0.0.1:5432 user@mon-serveur
# côté local :
psql -h localhost -p 15433 -U "$POSTGRES_USER" -d "$POSTGRES_DB"
  • Si publication PROD imposée (debug court) : binder sur 127.0.0.1:PORT seulement, restreindre via UFW, et retirer l’exposition ensuite.

4) Conteneurisation (Compose)

4.1 Services

  • db : Postgres 16-alpine
  • backend : Django ; image ${APP_SLUG}-backend:dev|prod
  • vite : Node 20-alpine ; dev server
  • frontend : optionnel en dev ; en prod, build statique servi derrière Traefik

4.2 Dépendances et healthchecks

  • backend dépend de db (healthy)
  • vite peut dépendre de backend en dev

4.3 Réseau et noms

Tous les containers/volumes/réseaux suivent ${APP_SLUG}_<nom>_${APP_ENV}.


5) Backend Django (API)

  • Commande dev : python manage.py runserver 0.0.0.0:8000.
  • Base API : préfixe /api/ (dans urls.py du projet).

5.1 Auth

  • SimpleJWT activé (rest_framework_simplejwt, JWTAuthentication).

5.2 Endpoints JWT

  • /api/auth/jwt/create/
  • /api/auth/jwt/refresh/
  • /api/auth/jwt/verify/

Whoami JWT :

  • canon : /api/whoami/
  • alias compat : /api/auth/whoami/

5.3 CORS/CSRF en dev

  • CORS_ALLOWED_ORIGINS = http://localhost:${DEV_VITE_PORT}
  • CSRF_TRUSTED_ORIGINS = http://localhost, http://127.0.0.1, http://localhost:${DEV_VITE_PORT}[, http://localhost:${DEV_API_PORT}]

5.4 ALLOWED_HOSTS (dev minimal)

  • localhost,127.0.0.1,0.0.0.0,<noms des containers>.

5.5 Compat sessions (optionnel)

Les endpoints csrf/, login/, logout/ peuvent subsister mais les nouvelles features doivent viser JWT.


6) Frontend (Vite + React + Axios)

6.1 Vite (dev) — frontend/vite.config.js

export default defineConfig({
  plugins: [react()],
  server: {
    host: true,
    port: 5173, // mappé -> 5173+N côté hôte
    proxy: { '/api': { target: 'http://backend:8000', changeOrigin: false } },
  },
})

6.2 Variables front

  • VITE_API_BASE = /api (chemin relatif en dev & prod) — injectée via Compose dans vite.

6.3 Axios (frontend/src/api.js) — points clés

  • BASE = normalizeBase(import.meta.env.VITE_API_BASE)
  • api = axios.create({ baseURL: BASE })
  • Login : api.post('auth/jwt/create/', { username, password })
  • Intercepteur 401 → purge Authorization + redirection /login.
  • Tokens : source unique localStorage.setItem('mdp.jwt', JSON.stringify({ access, refresh })) (+ compat token en lecture si présent).

7) Flux dev attendu

  1. vite écoute 5173 (conteneur) → 5173+N (hôte).
  2. Le navigateur appelle /api/...Vite proxy vers http://backend:8000.
  3. Django répond ; CORS/CSRF/ALLOWED_HOSTS alignés sur l’origine dev.

8) Compose (dev) — points d’attention

  • env_file: .env.dev et .env.local pour db, backend, vite.
  • backend.ports: "${DEV_API_PORT}:8000"
  • vite.ports: "${DEV_VITE_PORT}:5173"
  • VITE_API_BASE: "/api" injecté dans vite.
  • Jamais de secrets dans .env.dev (ni en clair dans la doc/exemples).

9) Vérification automatique des invariants

Script : scripts/verifier-invariants.sh (à lancer avant toute PR).

9.1 Vérifications

  • Vite proxy (/api, target backend:8000, changeOrigin:false).
  • frontend/src/api.js (import axios, VITE_API_BASE, axios.create, endpoint JWT).
  • Django (SimpleJWT, URLs JWT/whoami, runserver 0.0.0.0:8000).
  • Compose (ports, VITE_API_BASE:"/api").

9.2 Smoke tests

  • création JWT via http://localhost:${DEV_VITE_PORT}/api/auth/jwt/create/
  • verify + appel endpoint protégé (/api/whoami/).

Toute PR doit passer ce script en local.


9bis) Vérification automatique — ajouts

Le script doit aussi valider :

  • cohérence POSTGRES_USER=${APP_SLUG}_pg_user et POSTGRES_DB=${APP_SLUG}_pg_db ;
  • host DB du backend = db:5432 (pas d’IP en dur) ;
  • ports hôte DEV respectant 5432+N, 8001+N, 5173+N ;
  • VITE_API_BASE = /api (relatif) en dev et en prod.

10) Production (survol)

  • Code déployé sous /opt/apps/${APP_SLUG} ; .env symlink → .env.prod.
  • VITE_API_BASE reste /api.
  • Traefik publie /api → backend (gunicorn/uwsgi), et les assets statiques du front.
  • Jamais de bind direct 80/443 dans les containers applicatifs.
  • ALLOWED_HOSTS contient APP_HOST (+ alias).

11) Règles Do / Don’t

Do

  • Garder /api relatif côté front (pas d’URL absolue).
  • Conserver les noms de services Compose (db, backend, vite, frontend).
  • Centraliser les secrets dans *.local (non commit).
  • Utiliser mdp.jwt comme source unique pour les tokens.
  • Respecter les chemins de travail : ~/projets/${APP_DEPOT} (dev) ; /opt/apps/${APP_SLUG} (prod).
  • Préférer JWT pour les nouveaux flux d’auth.
  • Toujours référencer la DB via db:5432 dans les apps.
  • Toujours dériver POSTGRES_USER / POSTGRES_DB depuis APP_SLUG.
  • Toujours dériver les ports DEV depuis APP_NO ; publier ${DEV_DB_PORT}:5432 uniquement si nécessaire.

Don’t

  • Pas de secrets dans .env.dev / .env.prod.
  • Pas de changeOrigin:true en dev pour /api.
  • Pas d’URL API codée en dur (toujours VITE_API_BASE).
  • Pas d’écoute 80/443 par l’app en prod (Traefik front-only).
  • Pas d’hôte DB en dur différent de db.
  • Pas de POSTGRES_USER / POSTGRES_DB hors schéma ${APP_SLUG}_pg_*.
  • Pas d’URL API absolue côté front ; pas de 80/443 exposés par les apps en PROD.

12) Checklist de revue (avant merge)

  • scripts/verifier-invariants.sh OK
  • frontend/vite.config.js conforme
  • frontend/src/api.js conforme (BASE, axios, login endpoint, interceptor)
  • Backend dev : runserver 0.0.0.0:8000
  • CORS_ALLOWED_ORIGINS inclut http://localhost:${DEV_VITE_PORT}
  • CSRF_TRUSTED_ORIGINS listées (localhost/127.0.0.1/ports dev)
  • ALLOWED_HOSTS minimal
  • Secrets présents uniquement dans *.local (non commit)
  • Chemins conformes (dev : ~/projets/${APP_DEPOT}, prod : /opt/apps/${APP_SLUG})

13) Emplacement du document

  • Chemin recommandé : docs/INVARIANTS.md.
  • Lien depuis README.md.

14) Extension cross-apps

Ce document sert de template pour d’autres applications : mêmes noms de services, mêmes conventions d’API/auth, ports dérivés via APP_NO, mêmes scripts de vérification. Seules changent les valeurs d’APP_* et le métier.


Annexes

A) Makefile — cibles standard

  • But par défaut : help (liste des cibles).

Garde-fous :

  • envlink : .env -> .env.$(APP_ENV)
  • ensure-env : symlink valide, pas de dev sur hôte prod
  • ensure-edge : réseau edge

Stack : up, down, restart, ps, logs.

Django : makemigrations, migrate, createsuperuser, psql.

Prod : prod-deploy (build+up+migrate+collectstatic), prod-health, prod-logs.

Utilitaires : dps (tri par NAMES, filtré app courante), dps-all.

Nota : LOAD_LOCAL charge automatiquement .env.$(APP_ENV).local (pas de ;;).

B) URLs Django — fragment de référence

# backend/api/urls.py
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import CategoryViewSet, PasswordViewSet, healthz
from .views_auth import csrf, login_view, logout_view  # compat sessions
from api.views_jwt_whoami import jwt_whoami
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView, TokenVerifyView

app_name = "api"
router = DefaultRouter()
router.register(r"categories", CategoryViewSet, basename="category")
router.register(r"passwords",  PasswordViewSet, basename="password")

urlpatterns = [
    path("", include(router.urls)),
    path("healthz/", healthz, name="api-healthz"),
    path("csrf/",   csrf,        name="api-csrf"),   # compat
    path("login/",  login_view,  name="api-login"),  # compat
    path("logout/", logout_view, name="api-logout"), # compat
    path("whoami/", jwt_whoami, name="api-whoami"),
    path("auth/jwt/create/",  TokenObtainPairView.as_view(), name="jwt-create"),
    path("auth/jwt/refresh/", TokenRefreshView.as_view(),    name="jwt-refresh"),
    path("auth/jwt/verify/",  TokenVerifyView.as_view(),     name="jwt-verify"),
    path("auth/whoami/", jwt_whoami, name="jwt-whoami"),     # alias
]

C) Vite (dev) — fragment de référence

// frontend/vite.config.js
export default defineConfig({
  plugins: [react()],
  server: {
    host: true,
    port: 5173, // mappé -> 5173+N côté hôte
    proxy: { '/api': { target: 'http://backend:8000', changeOrigin: false } },
  },
})

Conclusion

Ces invariants constituent la base commune de toutes les apps du template. Toute évolution doit préserver :

  • la dérivation stable des noms et des ports,
  • l’usage strict de db:5432 côté réseau Compose,
  • l’absence de secrets dans les fichiers versionnés,
  • et l’exposition uniquement via Traefik en production.