Commit Graph
12 Commits
Author SHA1 Message Date
Claude 607de2a1e7 Attacher la base au réseau du reverse-proxy, à la demande
L'exploitant veut `db` sur `nginx_default`. Ce n'est pas requis pour que
l'application joigne la base — les deux partagent déjà `interne`, et le
journal le prouve : « password authentication failed for user "planflow" »
suppose que le nom a été résolu, la connexion établie et le dialogue
d'authentification engagé. Une absence de réseau commun donnerait une
résolution de nom impossible, pas un refus d'identifiants.

Le commentaire dit donc ce que cela coûte : la base devient joignable par
tous les conteneurs que sert ce proxy, `POSTGRES_PASSWORD` cesse d'être une
formalité, et le nom `db` publié sur un réseau partagé peut entrer en
collision avec celui d'une autre pile.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
2026-08-10 07:31:55 +00:00
Claude 859e51ea20 Faire réparer le mot de passe d'amorçage par le conteneur de la base
`POSTGRES_PASSWORD` n'est lu qu'à la création du volume. Un volume né d'un
déploiement antérieur garde le mot de passe d'alors, et plus rien dans la
pile ne peut le corriger : ni l'application, ni `db-init`, tous deux refusés
à l'entrée. Le symptôme visible — un P1000 sur `planflow_app` — désigne le
mauvais coupable, et la seule issue était du SQL à la main.

La seule position d'où la réparation est possible est l'intérieur du
conteneur de la base, où PostgreSQL accepte la socket locale sans mot de
passe. Le service y réaligne donc le compte d'amorçage sur la valeur de la
pile à chaque démarrage, avant de céder la main au point d'entrée officiel.

Le SQL passe par l'entrée standard et non par `-c` : `psql -c` n'interpole
pas les variables, si bien que `:'pw'` y partait littéralement — mesuré,
« syntax error at or near ":" ». Par l'entrée standard, c'est psql qui met
le mot de passe entre guillemets, et une apostrophe dans le mot de passe ne
casse rien. Vérifié avec un mot de passe en contenant une.

Éprouvé en exécutant le corps de la commande contre un vrai serveur, avec un
point d'entrée factice : la reprise attend le serveur, le réalignement
aboutit, et le mot de passe est bien posé.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
2026-08-10 07:30:47 +00:00
Claude 49618c57bf Faire poser le rôle applicatif par l'application elle-même
`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
2026-08-09 18:32:38 +00:00
Claude 4767d10507 Dire ce qu'il faut faire quand la connexion à la base est refusée
L'application redémarrait en boucle sur « P1000 » sans rien indiquer. Le point
d'entrée distingue désormais les deux causes et donne le remède.

Les deux codes ne disent pas la même chose, et c'est la mesure qui a permis de
les séparer : **P1010** signifie que le rôle n'existe pas, **P1000** qu'il existe
mais que son mot de passe ne correspond pas à celui que porte la pile — le cas
d'un rôle posé lors d'un déploiement antérieur avec une autre valeur. Le remède
est le même : `db-init` crée le rôle ou réaligne son mot de passe.

Le script d'initialisation lui-même a été éprouvé une fois de plus, y compris
avec l'argument parasite que Docker ajoute quand `entrypoint` est surchargé sans
`command` : il aboutit et pose le rôle.

Ce que je ne peux pas voir d'ici, et qu'il faut regarder sur le serveur : si le
service `db-init` figure dans la pile déployée, et ce qu'il a journalisé. Un
rôle dont le mot de passe ne correspond pas est précisément ce qu'il corrige.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
2026-08-09 17:20:04 +00:00
Claude 4e61d9d4e5 Fusionner le provisionnement du rôle applicatif
Deux implémentations du même correctif se sont croisées. La fusion garde de
chaque côté ce qui manquait à l'autre : le traitement explicite de PGHOST venu
de main, les valeurs par défaut et le transfert de propriété des objets déjà
présents venus d'ici — sans quoi une base ayant tourné avant l'existence du rôle
resterait inexploitable par lui.

Le service db-init figurait deux fois après la fusion automatique ; il est
dédupliqué.

Le port PostgreSQL publié est **lié à la boucle locale**. Publier sert à se
connecter depuis le serveur — sauvegarde, psql — pas depuis le réseau : sans
127.0.0.1, Docker ouvre le port sur toutes les interfaces, et un jeu de données
RH derrière un mot de passe par défaut devient joignable de l'extérieur.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
2026-08-09 15:52:54 +00:00
Claude 72809cefb6 Créer le rôle applicatif à chaque démarrage, pas seulement au premier
Les migrations échouaient sur « Authentication failed for planflow_app ». Le
script qui crée ce rôle était monté dans /docker-entrypoint-initdb.d, lequel ne
s'exécute qu'à la **toute première** initialisation du volume : une pile dont le
volume existait déjà — créé par une tentative antérieure — n'a jamais vu passer
le rôle. Je l'avais noté dans le README au lieu de le traiter.

Un service `db-init` rejoue désormais le script à chaque démarrage, et le script
est rendu rejouable : il crée le rôle ou aligne son mot de passe, transfère la
propriété de la base, du schéma et des objets déjà présents. Les identifiants du
superutilisateur restent dans ce service ; les confier au conteneur applicatif
lui donnerait de quoi contourner la row-level security, ce que tout ce montage
cherche à empêcher.

Éprouvé sur une base créée par le superutilisateur, comme celle du client :
rôle posé en NOSUPERUSER NOBYPASSRLS, propriétaire de la base et des tables
préexistantes, capable de se connecter et d'exécuter du DDL ; les 22 migrations
s'appliquent ; la RLS le filtre — deux comptes visibles par le superutilisateur,
zéro par lui hors périmètre. Rejoué une seconde fois sans effet de bord.

Non vérifiable ici : l'authentification par mot de passe, mon cluster de
développement étant en mode `trust`.

Corrigé au passage une interférence entre tests : celui qui retire « Gérer les
rôles » aux rôles semés, pour atteindre le refus de verrouillage, s'exécutait en
parallèle de ceux qui s'appuient sur cette capacité et les faisait échouer par
intermittence. Le fichier passe en série.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
2026-08-09 15:50:23 +00:00
Michael 2c3c67b949 Provisionner le rôle applicatif à chaque docker compose up
Le rôle planflow_app n etait créé qu à la première initialisation du volume :
une base existante au rôle absent (ou au mot de passe changé) bloquait l app
en échec P1000 sans remède automatique.

Ajouter un service db-init, idempotent, qui rejoue docker/init-app-role.sh à
chaque up — branche ELSE re-synchronisant le mot de passe — avant que l app
ne démarre.
2026-08-09 14:22:05 +02:00
Michael f4e3608d72 Publier PostgreSQL sur un port hôte non commun
Plusieurs instances PostgreSQL coexistent sur le serveur : publier la base
sur 55432 par défaut (configurable via POSTGRES_PORT), sans changer le port
interne 5432 utilisé par l'application.
2026-08-09 13:50:50 +02:00
Claude e5524c6299 Produire la clé de chiffrement au premier démarrage
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
2026-08-09 09:41:24 +00:00
Claude ef2868b980 Rendre le déploiement Docker correct, et la RLS réellement active
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
2026-08-09 08:39:54 +00:00
Claude 93852c2602 Fix four defects the screenshots and tests exposed
Verifying the rendered pages rather than the build turned up real bugs:

- The WP-00 health page still sat at src/app/page.tsx and silently won
  the route over the new Aperçu screen, so the home page was a database
  status readout. Moved to /api/sante, where a probe belongs, and wired
  into the compose healthcheck.
- The unassigned row showed a +14 h delta against a contract of zero,
  reading as an overshoot when it is simply the volume left to staff.
  It now shows what there is to fill.
- Two sidebar entries lit at once: an anchor link matched its own page,
  and /equipe matched an employee record. Highlighting now resolves to
  the most specific match, and a test asserts exactly one entry lights
  per screen.
- Section tabs with no built screen pointed at the home page, which
  reads as a broken tab. They now lead to their first entry's
  placeholder.

Also gives truncated compliance alerts a title attribute, so a narrow
cell no longer says there is a problem without saying which.

Playwright can reuse a preinstalled browser through
PLAYWRIGHT_CHROMIUM_PATH when its revision differs from the bundled one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
2026-08-07 22:26:38 +00:00
Claude dd639a86f5 WP-00: application foundation
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
2026-08-07 17:54:11 +00:00