**Le test des heures ne pouvait que tomber.** Il interrogeait le mois
précédent, alors que le seed ne pose que deux semaines : la courante et la
précédente. Hors des premiers jours d'un mois, le mois précédent est donc
vide. Mesuré sur une base semée à neuf : un seul mois porte des créneaux,
le mois courant. Le défaut ne se voyait pas en développement, où la base
garde les créneaux des exécutions antérieures — 39 créneaux de juillet
survivaient chez moi à des semis d'il y a plusieurs semaines.
**Le seed ne remettait pas l'état de publication.** Son `update` était vide,
si bien qu'une semaine déjà semée gardait le statut qu'elle avait alors : la
semaine précédente, publiée par définition, restait en brouillon dès qu'elle
avait été semée du temps où elle était la semaine courante. D'où des tests
qui échouent en local et passent en intégration continue — l'écart le plus
coûteux à diagnostiquer. Le statut est désormais réimposé.
**Ports internes.** L'application écoute sur 9317 et la base sur 5439,
jusque dans l'image. Sur un réseau Docker deux conteneurs peuvent écouter le
même port sans se gêner — ce n'est donc pas une correction de collision —
mais une valeur unique de bout en bout lève l'ambiguïté quand plusieurs
piles cohabitent derrière le même proxy, et la configuration du
reverse-proxy porte partout le même nombre. Publication, sonde de santé,
serveur, chaîne de connexion et scripts d'amorçage sont alignés sur une
seule variable par service.
`pnpm verify` : 450 tests. Playwright : 77/77 après remise à zéro du semis.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
`COPY` recopie le mode du fichier source, et ce mode se perd hors d'un clone
git : archive envoyée à Portainer, contexte reconstitué, système de fichiers
qui ne porte pas la permission.
La panne que cela produit ne ressemble à rien. La forme exec de `CMD` échoue
avant le premier octet de sortie, si bien que le conteneur s'arrête sans une
seule ligne de journal — et l'on cherche un défaut applicatif là où il n'y a
jamais eu de processus.
Mesuré : sans le bit, « Permission denied » et aucune sortie du script ;
avec, le script s'exécute.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
`db-init` suppose un orchestrateur qui honore
`depends_on: service_completed_successfully`. Swarm l'ignore, et une pile
déployée avant l'ajout du service ne le contient même pas. L'application
redémarrait alors en boucle sur un refus d'authentification que le
diagnostic ajouté précédemment décrivait sans que personne puisse le
corriger.
Elle le corrige donc elle-même au démarrage, si on lui confie les
identifiants d'amorçage — et se tait sinon, pour ne pas contrarier un
déploiement qui préfère les garder hors du conteneur applicatif. Le point
d'entrée retire ces variables avant de lancer le serveur : le processus qui
sert les requêtes ne les voit jamais.
Le branchement create/alter se fait côté client et non dans un bloc `DO` :
le corps d'un `DO` est une chaîne littérale, où `$1` n'est pas un paramètre
de requête. L'échappement du mot de passe est confié à `quote_literal`, et
le nom de rôle est refusé s'il n'a pas la forme d'un identifiant.
Éprouvé sous authentification scram réelle : après réalignement, l'ancien
mot de passe est refusé et le nouveau accepté, le rôle reste NOSUPERUSER
NOBYPASSRLS et devient propriétaire de la base.
Au passage, `withTenant` pose un budget de transaction explicite. Tout accès
aux données passe par lui, si bien que le défaut Prisma de 5 s plafonnait en
réalité chaque requête de l'application, et l'échec se présentait en `P2028`
qui ne désigne ni la requête ni la cause.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
Le conteneur démarrait puis s'arrêtait sur « Cannot find module
'@prisma/config' ». Le Dockerfile prélevait à la main quelques répertoires de
node_modules — `prisma`, `.bin/prisma`, `@prisma` — en supposant une disposition
plate. pnpm range les dépendances dans un magasin virtuel `.pnpm`, sous des
répertoires au nom haché : la CLI arrivait sans les siennes.
Elle est désormais installée par npm, qui produit une disposition plate,
copiable telle quelle. La version est lue dans package.json plutôt que figée,
pour qu'elle ne diverge pas au premier changement.
Tout ce qui sert aux migrations — modules, schéma, configuration — vit dans un
arbre séparé. Les superposer aux modules de l'application les ferait entrer en
collision : la sortie `standalone` porte `react` en lien symbolique vers le
magasin pnpm, là où l'installation npm l'apporte en répertoire réel. Deux arbres
n'ont rien à s'écraser.
`prisma.config.ts` n'importe plus `dotenv` de façon ferme : la sortie
`standalone` n'embarque que ce que le serveur utilise, et `dotenv` n'en fait pas
partie — l'import aurait fait échouer les migrations au démarrage.
Cette fois l'image a été reconstituée à l'identique et **démarrée** : clé
produite, migrations appliquées, serveur prêt, `/connexion` en 200 et
`/api/sante` rapportant `tenantIsolation: enforced`. C'est ce que j'aurais dû
faire aux trois tentatives précédentes, où je n'avais éprouvé que des morceaux.
La simulation a d'ailleurs trouvé un chemin `/migrator` en dur dans le point
d'entrée ; il est désormais surchargeable, comme l'emplacement de la clé.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
Le déploiement restait bloqué sur ENCRYPTION_KEY. J'ai tenu trop longtemps la
position « pas de valeur par défaut », en confondant deux choses : refuser une
clé livrée avec l'image — ce qui reste juste, une clé publiée dans un dépôt ne
protège rien — et exiger qu'un humain en fabrique une avant tout démarrage.
Une variable d'environnement n'est d'ailleurs pas un bon coffre : elle s'affiche
dans `docker inspect` et dans l'interface de gestion. Un fichier produit au
démarrage, dans un volume distinct de la base et des documents, n'est pas moins
protégé — et une sauvegarde de l'un n'emporte plus la clé de l'autre.
Le point d'entrée la produit donc si elle manque, l'écrit en 0600, et l'affiche
une fois dans les journaux avec ce qu'il faut en faire. Une clé fournie
explicitement l'emporte toujours : un déploiement qui gère ses secrets ailleurs
ne doit pas être contrarié. La pile démarre désormais sans aucune variable.
Éprouvé sur le script lui-même : première exécution, clé de 32 octets produite
et annoncée ; deuxième, reprise en silence depuis le fichier ; avec une clé
fournie, le fichier reste intact.
Corrigé au passage, révélé par un échec transitoire du test : une écriture
disque impossible — volume plein, droits, montage absent — remontait en
exception non traitée. L'écran restait muet, la pièce n'était pas déposée et
rien ne le disait.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
Trois problèmes, dont un grave, trouvés en corrigeant un échec de déploiement.
**Le compose connectait l'application en superutilisateur PostgreSQL.** Un
superutilisateur contourne toute politique de sécurité au niveau ligne, y
compris déclarée en FORCE : la seconde couche d'isolation était présente en
base et absente des faits. Le README l'interdisait déjà noir sur blanc ; le
chemin de déploiement que nous livrons faisait exactement l'inverse. Un script
d'initialisation crée désormais un rôle `planflow_app` NOSUPERUSER NOBYPASSRLS,
propriétaire de la base — il lui faut ce droit pour migrer, et les politiques
sont en FORCE précisément pour s'appliquer aussi au propriétaire. Mesuré : en
superutilisateur, deux lignes visibles sans compte courant ; avec le rôle
dédié, zéro.
Basculer la base de développement sur ce même rôle a révélé le défaut que le
superutilisateur masquait : `resolveSession` lisait `Membership`, table filtrée
par compte, sans périmètre. Avec la RLS active, plus personne ne pouvait se
connecter. La résolution passe maintenant par une porte étroite — une politique
qui n'ouvre que les lignes dont l'utilisateur est titulaire, sous `app.user_id`
— le temps de trouver le compte, puis repasse par le périmètre ordinaire. La
suite de tests traverse enfin la RLS au lieu de la contourner.
**Les pièces du dossier salarié n'avaient aucun volume.** Elles étaient écrites
dans la couche du conteneur et disparaissaient au premier redéploiement. Une
pièce d'identité perdue ne se reconstitue pas.
Le reste répond à la demande : Postgres préconfiguré — la base n'étant ni
publiée ni attachée au réseau du proxy, ce mot de passe protège d'un conteneur
voisin, pas d'Internet — réseau `nginx_default` déclaré externe avec
l'application seule dessus, et port publié peu courant.
ENCRYPTION_KEY reste la seule variable sans valeur par défaut, et n'en aura
pas : elle chiffre le NIR, l'IBAN et les arrêts de travail. Une clé livrée avec
l'image serait connue de quiconque lit ce dépôt.
Au démarrage, l'application contrôle ses propres privilèges : elle refuse de se
lancer si la base porte plus d'un compte, et se contente d'un avertissement
s'il n'y en a qu'un — bloquer une installation mono-compte fermerait l'accès de
l'entreprise à ses données pour une fuite entre clients qui ne peut pas se
produire.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
Scaffolds the project: Next.js 16 App Router with strict TypeScript,
Prisma 7 on PostgreSQL 16, Tailwind 4, Vitest, Playwright, CI, and a
standalone Docker image that applies migrations on boot.
Makes the no-tracker rule of PLAN.md 3.7 enforceable rather than
stated. A per-request nonce-based CSP names no external origin, a unit
test fails if any network directive gains one, and a second test fails
if a tracking package appears in package.json. The end-to-end test
drives the standalone server the Docker image runs, not `next dev`,
so a proxy matcher that stopped matching could not pass unnoticed.
Environment is validated at import, so a missing DATABASE_URL fails at
boot with a readable message instead of surfacing later as a driver
error mid-export. ENCRYPTION_KEY is checked to be 32 bytes.
Three deviations from the plan, recorded in PLAN.md and README:
Next 16 rather than 15, `proxy.ts` rather than the now-deprecated
`middleware.ts`, and database-backed sessions rather than Auth.js v5,
which is still beta and whose JWTs would make the session revocation
required by compliance item 23 awkward.
Verified locally against PostgreSQL 16: migrations apply, extensions
created, typecheck, lint, 9 unit tests and the end-to-end header test
all pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv