capitalisation: intégration ~60 entrées RL799_V2 (triage 2026-05-02)

Triage du 95_a_capitaliser.md (~75 propositions) :
- 60 entrées intégrées dans knowledge/ (backend, frontend, workflow)
- 4 nouveaux fichiers : backend/patterns/tests.md, backend/risques/tests.md,
  frontend/patterns/general.md, workflow/patterns/general.md
- 6 doublons rejetés
- Mise à jour des READMEs index pour refléter les nouvelles entrées
- 95_a_capitaliser.md restauré à sa structure initiale
- 40_decisions_et_archi.md : décision mono-tenant déployable vs SaaS multi-tenant
- 90_debug_et_postmortem.md : sub-agents Write indisponible, effet iceberg CI,
  prisma migrate diffs cosmétiques

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MaksTinyWorkshop
2026-05-02 22:12:44 +02:00
parent 02ad0de258
commit b3417ad77b
31 changed files with 5370 additions and 12 deletions

View File

@@ -59,3 +59,541 @@ Préférer étendre le service avec un nouveau type (enum/Set) et ajuster les re
### Signal review
- Nouveau service qui réplique le CRUD d'un service existant avec un filtre additionnel → candidat à la fusion par type
---
<a id="pattern-build-packages-workspace-amont-tests-ci"></a>
## Pattern : Builder les packages workspace en amont des tests CI (monorepo pnpm)
- Objectif : garantir qu'un package workspace compilé (TypeScript → `dist/`) est construit avant que les apps consommatrices lancent leurs tests dans le CI.
- Contexte : monorepo pnpm où `apps/*` consomme `packages/<lib>` via `"workspace:*"`, et où le `package.json` du package pointe sur un build artifact (`"main": "dist/index.js"`, `"types": "dist/index.d.ts"`).
- Quand l'utiliser : dans tout pipeline CI/CD qui lance des tests sur les apps consommatrices d'un package compilé.
- Quand l'éviter : si tous les packages workspace sont consommés en source directement (TS sans build, ex. via `tsx`, `vite-node`, `@swc-node`).
- Validé le : 30-04-2026
- Contexte technique : pnpm monorepo / GitHub Actions / Node 22 / TypeScript — RL799_V2
### Règle
Ajouter une étape `Build workspace packages` entre `pnpm install` et le premier `pnpm run test:*` du pipeline. Préférer un build complet du workspace plutôt qu'un build ciblé : `pnpm -r --filter '<scope>/*' build` (ou `pnpm run build` si un script racine équivalent existe). `pnpm -r` respecte le graphe de dépendances et bâtit les libs avant les apps.
### Anti-pattern à éviter
Ne pas placer le `build` du package partagé **dans** la commande de tests de ce package (`"test:shared": "pnpm -C packages/shared build && pnpm -C packages/shared test"`). Si le script `test:shared` tourne après `test:api` dans la séquence CI, le build arrive trop tard pour les tests qui consomment `dist/`. Garder le build comme étape CI explicite et amont, et garder `test:<package>` minimaliste.
### Signal review
- Pipeline CI qui enchaîne `pnpm install` puis directement `pnpm run test:api` (ou équivalent) sans étape de build intermédiaire, alors que le package workspace consommé pointe sur un `dist/` compilé.
- Bug type : `Cannot find module '<scope>/<package>'` ou `Cannot find module '<scope>/<package>/dist/...'` au démarrage des tests CI, alors que les tests passent en local.
### Pourquoi ce bug est silencieux en local
En local, `dist/` existe presque toujours (build précédent) — donc les tests passent. Le pipeline CI part d'un environnement vierge et casse. Test de fumée local avant un push CI sensible : `rm -rf packages/<lib>/dist && pnpm run build && pnpm run test:<app>`.
---
<a id="pattern-ordre-canonique-gates-http"></a>
## Pattern : Ordre canonique des gates dans un handler HTTP
- Objectif : éviter les fuites d'information via les codes HTTP — un user non authentifié ne doit jamais distinguer "ressource n'existe pas" de "ressource existe mais non autorisée".
- Contexte : tout handler HTTP qui combine validation de payload, authentification, autorisation, vérification d'existence de ressource et gates métier.
- Quand l'utiliser : systématique sur tout handler HTTP exposé.
- Quand l'éviter : jamais.
- Avantage :
- la sémantique HTTP reste cohérente entre endpoints
- aucune énumération possible via les codes 4xx
- Limites / vigilance :
- test "non auth + payload problématique → 401, pas 400" obligatoire pour verrouiller l'ordre
- Validé le : 27-04-2026
- Contexte technique : Next.js / NestJS / API HTTP — RL799_V2
### Ordre canonique (du plus permissif au plus restrictif)
1. **Parsing du body** (400 VALIDATION_ERROR si malformé)
2. **Validation du schéma** (400 VALIDATION_ERROR si payload invalide)
3. **Auth** (401 si non authentifié)
4. **Autz** (403 si rôle insuffisant)
5. **Existence ressource** (404 si l'id n'existe pas)
6. **Gates métier** (400/403 si règle business violée)
7. **Mutation**
### Anti-pattern
```typescript
// ❌ Gate métier AVANT auth — leak l'existence de la route + de la règle
if (payload.contains_locked_field) return 400 LOCKED;
const auth = requireAuth(...); // jamais atteint si payload contient le champ
// ✅ Auth AVANT gate métier
const auth = requireAuth(...);
if (auth instanceof Response) return auth;
if (payload.contains_locked_field) return 400 LOCKED;
```
### Test associé
Ajouter systématiquement un test `non auth + payload problématique → 401, pas 400`. Sans ce test, la régression passe.
---
<a id="pattern-delegation-endpoint-agrege"></a>
## Pattern : Délégation au niveau d'un agrégat → endpoint agrégé serveur
- Objectif : éviter les cascades de 403 silencieux côté client quand un user "délégué" doit accéder à des entités liées hors de l'agrégat parent.
- Contexte : user avec un rôle "délégué" rattaché à un agrégat parent (`Soiree.secretaireDeSeanceId`), qui doit pouvoir lire/écrire sur des entités liées au-delà de l'agrégat (tenues précédentes du même grade).
- Quand l'utiliser : la délégation ouvre l'accès à plusieurs entités liées qui sont rendues ensemble dans une vue unique.
- Quand l'éviter : si le frontend a vraiment besoin des entités séparément (rare).
- Avantage :
- une seule réponse hydrate toute la vue → pas de cascade de 403
- guard centrale au niveau du parent réutilisée en lecture ET en écriture
- source de vérité unique de la "fenêtre légitime" (un repo helper)
- Limites / vigilance :
- les codes d'erreur restent standards (`403 FORBIDDEN`), pas de codes ad hoc `FORBIDDEN_OUT_OF_DELEGATION_SCOPE`
- Validé le : 27-04-2026
- Contexte technique : Next.js / API HTTP — RL799_V2
### Le pattern
1. **Endpoint agrégé côté serveur** : `GET /api/<parent>/[id]/<vue-agrégée>` qui hydrate en une seule réponse toutes les entités liées dont la vue a besoin.
2. **Guard centrale au niveau du parent** : `requireAccessForParent(request, parentId, { roleSet })` retourne `{ userId, role, viaDelegation, delegatedParentId? }` ou `Response 403/404`.
3. **Si la guard sur l'entité enfant doit aussi reconnaître la délégation** (cas d'écriture) : étendre avec un slow-path qui appelle la résolution serveur — *« cet enfant fait-il partie de la fenêtre légitime ouverte par la délégation ? »*. Pas de re-vérification dispersée dans chaque service.
4. **Single source of truth de la "fenêtre légitime"** : la même fonction de résolution est utilisée par l'endpoint agrégé (lecture) ET par la guard d'écriture.
### Ce qu'on évite
- Cascade de N requêtes côté client → autant de chances de 403/erreurs silencieuses
- Logique métier dupliquée dans la guard et dans le service de lecture (dérive garantie)
---
<a id="pattern-anti-enumeration-delete-204"></a>
## Pattern : Anti-énumération sur DELETE — 204 systématique
- Objectif : empêcher un user authentifié d'énumérer les ressources d'autres users via les codes HTTP du DELETE (204 vs 404 fuite l'existence).
- Contexte : endpoint DELETE qui révoque/supprime une ressource identifiée par un identifiant connu uniquement de son propriétaire (push endpoint, refresh token, magic link, OAuth state).
- Quand l'utiliser : la ressource est identifiée par un secret/identifiant non énumérable.
- Quand l'éviter : ressource identifiée par un ID séquentiel public — fixer d'abord l'autorisation.
- Avantage :
- aucune information ne fuit (inconnue / à un autre / déjà révoquée → indistinguables)
- simplifie le code : pas de branchement 404 vs 204
- idempotence naturelle (rejouer un DELETE n'a aucun effet observable)
- Limites / vigilance :
- garder une validation de format en amont (400 si body malformé) — c'est le format qui ne fuit rien, pas l'existence
- le filtre SQL DOIT inclure `userId = currentUser` — sinon on révoque la ressource d'un autre user
- Validé le : 28-04-2026
- Contexte technique : API HTTP / Prisma — RL799_V2
### Implémentation
```typescript
export const handleDelete = async (request: Request): Promise<Response> => {
const auth = requireAuthenticatedUser(request, ROLES);
if ('status' in auth) return auth;
const parsed = bodySchema.safeParse(await request.json());
if (!parsed.success) {
return errorResponse(400, 'VALIDATION_ERROR', 'Body invalide');
}
try {
await revokeByOwner({ userId: auth.userId, ...parsed.data });
} catch {
/* logger côté serveur, retourner 204 quand même */
}
// 204 systématique : inconnu / autre user / déjà révoqué → indistinguables
return new Response(null, { status: 204 });
};
// Repository
export const revokeByOwner = async (input: {
userId: string;
endpoint: string;
}): Promise<void> => {
await prisma.subscription.updateMany({
where: {
userId: input.userId, // filtre propriétaire OBLIGATOIRE
endpoint: input.endpoint,
revokedAt: null, // idempotent
},
data: { revokedAt: new Date() },
});
};
```
### Anti-patterns
- `findUnique` + `if (!sub) return 404` : fuit l'existence
- `findUnique` + check `userId === auth.userId` + 403 : fuit l'existence (la 403 vs 404 vs 204 distingue)
- `prisma.delete({ where: { endpoint } })` sans filtre `userId` : un user peut révoquer la sub d'un autre
### Tests minimaux
DELETE inconnu / DELETE autre user / DELETE déjà révoqué → tous 204, état inchangé pour les autres users.
---
<a id="pattern-lazy-init-memoize-libs-config-globale"></a>
## Pattern : Lazy init memoizé pour libs avec config globale
- Objectif : initialiser une lib qui exige une config globale (clés API, credentials) seulement au premier appel utile pour permettre feature flag, tests isolés et démarrage gracieux sans env complet.
- Contexte : libs externes type `web-push.setVapidDetails`, `Stripe(secret)`, `S3Client({ region })`, `Resend(apiKey)`.
- Quand l'utiliser : lib avec init globale + feature flag possible + tests qui doivent reset l'état.
- Quand l'éviter : lib qui réclame son init au boot (ex : connexion persistante TCP), libs trivialement instanciables par appel.
- Avantage :
- le service ne crash pas au boot si l'env n'est pas encore configuré
- feature flag respecté de bout en bout (`isEnabled()` court-circuite avant init)
- tests parallèles peuvent reset l'état via `__resetForTests()`
- `setX` n'est appelé qu'une fois par process (memoization)
- Limites / vigilance :
- state module-level partagé entre tous les imports — bien isoler la lib derrière un service
- `__resetForTests` doit être gardé par `NODE_ENV === 'test'` pour éviter l'usage en prod
- Validé le : 28-04-2026
- Contexte technique : Node.js / SDK avec config globale — RL799_V2
### Implémentation
```typescript
let initialized = false;
const isEnabled = (): boolean =>
process.env.FEATURE_FLAG === 'true' &&
Boolean(process.env.API_KEY && process.env.SECRET);
const ensureInit = (): boolean => {
if (initialized) return true;
if (!isEnabled()) return false;
externalLib.configure({
apiKey: process.env.API_KEY!,
secret: process.env.SECRET!,
});
initialized = true;
return true;
};
export const callExternal = async (input: Input): Promise<void> => {
if (!ensureInit()) return; // no-op silencieux si flag off ou env manquant
await externalLib.send(input);
};
/** Reset interne — usage tests uniquement. */
export const __resetInitForTests = (): void => {
if (process.env.NODE_ENV !== 'test') return;
initialized = false;
};
```
### Checklist
- `isEnabled()` vérifie flag ET présence env complète
- `ensureInit()` retourne booléen, no-op silencieux si non activé
- `__resetForTests` gardé par `NODE_ENV === 'test'`
- Pas de log "skipped" en cas de flag off (silencieux par design — c'est le contrat du flag)
---
<a id="pattern-cap-lru-ressources-par-user"></a>
## Pattern : Cap LRU sur ressources par-user avec contrainte d'unicité externe
- Objectif : empêcher un user (malveillant ou bugué) de générer un nombre illimité de ressources par-user en exploitant l'unicité d'un identifiant externe.
- Contexte : ressources où chaque insert produit une row unique côté DB (push subscription endpoint, refresh token, device fingerprint, OAuth state).
- Quand l'utiliser : ressource sans limite naturelle côté usage, où chaque action utilisateur peut créer une nouvelle row.
- Quand l'éviter : ressource intrinsèquement bornée (1 par user — utiliser une PK composite), ou ressource où l'historique compte (audit, logs).
- Avantage :
- cap dur prévisible (10 actives max, p.ex.) — borne supérieure de coût stockage connue
- LRU eviction naturelle : les anciennes subs (devices oubliés, browsers réinstallés) sont nettoyées automatiquement
- pas besoin de TTL global, le user peut garder ses N appareils légitimes
- Limites / vigilance :
- faire le check + révocation **avant** l'insert, pas après (sinon `unique constraint` violation possible si race)
- choisir entre `revoked` (soft delete) et `delete` selon les besoins audit
- le cap doit être au-dessus de l'usage légitime max (10 pour push = laptop + perso + pro + mobile + tablette + marges)
- Validé le : 28-04-2026
- Contexte technique : Prisma — RL799_V2
### Implémentation
```typescript
const MAX_ACTIVE_PER_USER = 10;
export const handleCreate = async (userId: string, input: CreateInput) => {
const active = await prisma.resource.count({
where: { userId, revokedAt: null },
});
if (active >= MAX_ACTIVE_PER_USER) {
const oldest = await prisma.resource.findFirst({
where: { userId, revokedAt: null },
orderBy: { lastSeenAt: 'asc' },
select: { id: true },
});
if (oldest) {
await prisma.resource.update({
where: { id: oldest.id },
data: { revokedAt: new Date() },
});
}
}
return prisma.resource.upsert({
where: { externalKey: input.externalKey },
create: { userId, ...input },
update: { userId, revokedAt: null, lastSeenAt: new Date() },
});
};
```
### Checklist
- [ ] Cap (constante) défini + commenté avec justification du chiffre
- [ ] Eviction LRU faite **avant** l'insert
- [ ] `lastSeenAt` bumpé à chaque usage légitime, pas juste à la création
- [ ] Test : seed cap-1 actives + 1 nouvel insert → cap respecté, plus ancienne révoquée
---
<a id="pattern-convention-dot-notation-audit"></a>
## Pattern : Convention dot-notation pour audit events
- Objectif : aligner le nommage des audit events avec la convention des outils observables (segment.io, datadog, posthog) qui utilisent tous la dot notation.
- Contexte : projet avec un `AUDIT_ACTION_CATALOG` ou équivalent listant les actions auditées.
- Quand l'utiliser : tout nouvel audit event, et migration progressive des events legacy en colon (`document:delete`).
- Quand l'éviter : projets dont l'outil d'observabilité impose un autre séparateur.
- Avantage :
- cohérence inter-outils (segment.io, datadog, posthog)
- lecture humaine plus fluide (`document.soft_delete` > `document:soft-delete`)
- multi-niveaux possible (`planche.tronc.admin_override`) sans confusion avec un séparateur de namespace JS
- Limites / vigilance :
- migration en PR atomique (call sites + catalog ensemble) pour éviter les events orphelins en filtrage UI
- Validé le : 20-04-2026
- Contexte technique : audit / observabilité — RL799_V2
### Convention
- **Préférer la dot notation** : `<entity>.<action_detail>` (ex : `document.update_metadata`, `document.soft_delete`, `cotisation_payment.created`)
- **Legacy colon** (`document:delete`) toléré pour rétrocompatibilité — migration encouragée lors du prochain touchement du module concerné
### Comment migrer
1. Vérifier qu'il n'y a plus de call site (`grep -rn 'document:delete'`)
2. Retirer l'entrée du `AUDIT_ACTION_CATALOG`
3. Si des call sites existent, les migrer en même temps que le retrait (PR atomique)
---
<a id="pattern-whitelist-explicite-audit-fields"></a>
## Pattern : Whitelist explicite pour audit metadata fields
- Objectif : empêcher qu'un futur dev ajoutant un champ secret au schema (`backupPassword`, `apiToken`) ne le voie automatiquement loggé dans le journal d'audit.
- Contexte : PATCH d'admin qui logge `metadata: { fields: Object.keys(payload) }` pour tracer ce qui a changé.
- Quand l'utiliser : tout audit qui veut tracer "quels champs ont été modifiés".
- Quand l'éviter : audit qui ne logge que l'action et l'id cible (pas les champs).
- Avantage :
- pas de fuite silencieuse dans le journal d'audit
- `satisfies readonly (keyof typeof baseShape)[]` garantit que la whitelist ne peut pas contenir de champ inexistant (typo-safe)
- Limites / vigilance :
- test "PATCH multi-champs valides → tous présents dans `metadata.fields`" pour vérifier que la whitelist couvre 100 % des champs PATCH-ables légitimes (silence par omission, pas de fuite)
- Validé le : 27-04-2026
- Contexte technique : audit / observabilité — RL799_V2
### Implémentation
```typescript
// packages/shared/src/validation/<entity>Schemas.ts
export const AUDITABLE_LODGE_SETTINGS_FIELDS = [
'nameLong',
'nameShort',
// … uniquement les champs SAFE à logger
] as const satisfies readonly (keyof typeof baseShape)[];
// Côté service
const fields = AUDITABLE_LODGE_SETTINGS_FIELDS.filter((k) => k in payload);
await logActionSync(tx, userId, '<entity>.update', '<entity>', id, { fields });
```
### Test obligatoire
```typescript
test('AUDITABLE_<X>_FIELDS couvre tous les champs PATCH-ables légitimes', () => {
const allPatchableFields = Object.keys(updateXxxSchema.shape);
const sensitiveFields = ['secretToken', 'backupPassword'];
const expected = allPatchableFields.filter((k) => !sensitiveFields.includes(k));
expect([...AUDITABLE_X_FIELDS]).toEqual(expected);
});
```
---
<a id="pattern-singleton-db-config-globale"></a>
## Pattern : Singleton DB pour config globale d'instance
- Objectif : stocker une configuration applicative qui doit être éditable runtime, unique pour l'instance, et protégée contre toute création accidentelle de doublon.
- Contexte : application mono-tenant déployable où chaque instance a sa propre DB et expose une config globale (paramètres de l'instance, branding, identité).
- Quand l'utiliser : config (a) en DB pour être éditable runtime sans rebuild, (b) unique pour l'instance, (c) protégée par contrainte DB.
- Quand l'éviter : SaaS multi-tenant — chaque tenant a sa propre row.
- Avantage :
- le `CHECK` SQL est la dernière ligne de défense même si un dev contourne le repo
- le repo centralise le `where: { id: 'singleton' }` — première ligne d'abstraction
- Limites / vigilance :
- `@default("singleton")` seul ne suffit pas — un dev peut créer une row avec `id: 'other'`
- cache mémoire à invalider AVANT mutation (cf. pattern `pattern-invalidation-cache-avant-mutation`)
- Validé le : 27-04-2026
- Contexte technique : Prisma / Postgres — RL799_V2
### Implémentation
```prisma
model LodgeSettings {
id String @id @default("singleton")
// …champs
@@map("lodge_settings")
}
```
Garde-fou SQL au niveau migration (édition manuelle du `.sql` après `prisma migrate dev --create-only`) :
```sql
ALTER TABLE "lodge_settings"
ADD CONSTRAINT "lodge_settings_singleton_check"
CHECK (id = 'singleton');
```
Repository centralisé : tout accès passe par `getXxx()` / `updateXxx()` qui prennent un client transactionnel optionnel pour permettre l'atomicité mutation + audit. Jamais de `prisma.xxx.create()` direct depuis ailleurs.
### Tests d'intégration
```typescript
test('rejette la création d\'une 2e row', async () => {
await expect(
prisma.lodgeSettings.create({ data: { id: 'other', ... } }),
).rejects.toThrow('lodge_settings_singleton_check');
});
```
---
<a id="pattern-invalidation-cache-avant-mutation"></a>
## Pattern : Invalidation cache mémoire AVANT mutation atomique
- Objectif : éviter qu'un GET parallèle pendant la transaction re-cache l'ancienne valeur jusqu'à expiration TTL.
- Contexte : config en cache mémoire (settings, feature flags, catalog) modifiée par une mutation atomique.
- Quand l'utiliser : tout flow `cache + mutation + audit` où le cache vit dans le même process que la mutation.
- Quand l'éviter : cache distribué Redis avec `SETEX` — la TTL gère naturellement.
- Avantage :
- cohérence forte (au pire, GET parallèle bloque sur fetch DB → trade-off latence acceptable)
- pas de fenêtre où le client a vu une valeur déjà obsolète après ACK serveur
- Limites / vigilance :
- garde-fou `cacheVersion` (compteur incrémenté à chaque `invalidate()`) recommandé : tout fetch en vol capture la version au démarrage et n'écrit le cache que si la version est inchangée au retour
- Validé le : 27-04-2026
- Contexte technique : Node.js / cache mémoire — RL799_V2
### Séquence sûre
```
1. invalidateCache() // AVANT toute mutation
2. transaction { // Prisma $transaction
update(...)
logActionSync(tx, ...)
}
3. return // Le prochain GET re-fetch fresh DB
```
### Pourquoi avant et pas après
Si on invalide après mutation :
1. GET parallèle pendant la transaction hit le cache (ancienne valeur) → return cachedValue
2. Si la transaction commit, le cache contient l'ancienne valeur jusqu'à TTL
3. Le client a vu une valeur déjà obsolète après ACK serveur → race condition
Si on invalide avant mutation :
1. Cache vide pendant la transaction
2. GET parallèle fait un fetch DB (peut renvoyer l'ancienne ou la nouvelle selon timing)
3. Au pire, GET parallèle bloque sur fetch DB → cohérence forte
### Anti-pattern à éviter
- Auto-chaînage entre invalidations couplées de caches dont les timings doivent diverger (ex : cache settings DTO à invalider AVANT la mutation, cache logo filesystem à invalider APRÈS pour éviter un re-cache d'un fichier supprimé). Chaque cache expose son `invalidate()` propre, le caller décide explicitement du moment.
### TTL recommandé
TTL court (60 s) plutôt que long (5 min) : fenêtre de stale plus courte si plusieurs admins éditent.
---
<a id="pattern-pipeline-cicd-github-actions-vps"></a>
## Pattern : Pipeline CI/CD GitHub Actions → VPS (compose externe + GHCR + SSH)
- Objectif : déployer automatiquement un monorepo Node + Postgres + Docker à chaque merge sur main vers un VPS hébergeant déjà des stacks globales (Traefik + Postgres).
- Contexte : VPS multi-apps avec Traefik global + Postgres global sur réseaux Docker externes (`traefik`, `stack`). Repo GitHub privé, image GHCR privée, user SSH dédié `deploy` (pas superuser).
- Quand l'utiliser : projet ≥ 1 app + ≥ 1 service partagé sur un VPS, sans Kubernetes ni service externe (pas de Vercel/Render/Railway).
- Quand l'éviter : déploiement sur un seul app/VPS dédié (compose simple suffit), ou besoin de blue/green strict (k8s ou Nomad plus adaptés).
- Avantage :
- réutilisation des stacks Traefik + Postgres existantes via réseaux externes
- pas de superuser, juste membre du groupe `docker`
- migrations exécutées AVANT le redémarrage applicatif (séquence `pull → migrate → up -d`)
- Limites / vigilance :
- plan GitHub gratuit pour repos privés : pas de gating manuel possible — compenser par `workflow_dispatch` only sur le job deploy
- `pnpm run <script>` dans le service `migrate` peut casser si le script dépend de `scripts/` non embarqué dans l'image — appeler les binaires directement
- Validé le : 02-05-2026
- Contexte technique : pnpm monorepo / Next.js / Vue / Prisma / Postgres / Docker — RL799_V2
### `compose.vps.yml` — compose dédié CI/CD
```yaml
services:
api:
image: ${API_IMAGE:?API_IMAGE is required}
env_file: [.env]
networks: [app_net, stack_net, traefik_net]
labels:
- traefik.enable=true
- traefik.http.routers.api.rule=Host(`${APP_DOMAIN}`) && PathPrefix(`/api`)
frontend:
image: ${FRONTEND_IMAGE:?FRONTEND_IMAGE is required}
networks: [app_net, traefik_net]
migrate:
image: ${API_IMAGE:?API_IMAGE is required}
profiles: [ops]
# Appel DIRECT du binaire Prisma — éviter `pnpm run prisma:migrate` qui
# exécute scripts/check-node-version.mjs absent de l'image API
command: ['pnpm', '-C', 'apps/api', 'exec', 'prisma', 'migrate', 'deploy']
networks: [stack_net]
networks:
traefik_net:
external: true
name: traefik
stack_net:
external: true
name: stack
```
### Workflow CI/CD en 2 jobs
- `build-and-push` : `docker buildx` → push image sur GHCR (tag = SHA court)
- `deploy` : SSH au VPS, `scp compose.vps.yml`, `pull` + `migrate` + `up -d` + healthcheck retries
Trigger : `workflow_run` du workflow `Tests` quand vert sur main, OU `workflow_dispatch` manuel.
### Secrets GitHub à configurer
| Secret | Rôle | Exemple |
|---|---|---|
| `VPS_HOST` | hostname réel (pas alias `~/.ssh/config`) | `82.x.x.x` |
| `VPS_USER` | `deploy` |
| `VPS_SSH_PORT` | port custom éventuel | `2287` |
| `VPS_SSH_PRIVATE_KEY` | clé privée multi-lignes | `-----BEGIN OPENSSH...` |
| `VPS_SSH_KNOWN_HOSTS` | `ssh-keyscan -p <port> <host>` | 3 lignes |
| `VPS_APP_DIR` | chemin app sur VPS | `/srv/sites/<app>` |
### Pièges anticipés
1. **`pnpm run <script>` dans le service `migrate`** : si le script appelle un wrapper (`pnpm run env:node`) qui exécute `scripts/check-node-version.mjs`, et que `scripts/` n'est pas embarqué dans l'image API → 500 au boot du job migrate. **Toujours appeler les binaires directement** dans les services Docker.
2. **Image GHCR privée + `docker login` côté VPS** : credentials par-user (`~/.docker/config.json`). Faire `docker login` **en tant que `deploy`** via SSH, pas via `sudo -u deploy`.
3. **Hostname réel vs alias `~/.ssh/config`** : `VPS_HOST` doit être l'hostname résolu (`ssh -G <alias> | grep ^hostname`), pas l'alias.
4. **Permissions dossier `/srv/sites/<app>/`** : si owner historique = autre user, `deploy` ne peut pas `scp`. Solution propre = groupe partagé avec `setgid`.
5. **Healthcheck timeout** : si l'API met longtemps à boot (Chromium/Puppeteer lazy load, migrations longues), augmenter au-delà du défaut 60 s.