Le layout applicatif calcule l'écran d'enrôlement à son propre rendu, et
un layout n'est pas rejoué par la navigation client : une fois le second
facteur activé, l'écran restait posé sur toutes les routes, quel que soit
le menu choisi.
L'action d'enrôlement ne peut pas réactualiser la route elle-même sans
emporter les codes de secours, qui ne sont affichés qu'une fois. Le
rafraîchissement est donc déclenché par l'écran des codes, sur un bouton :
c'est leur destinataire qui décide du moment où il les a notés.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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
`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
`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
Le diagnostic disait « mot de passe d'amorçage erroné » sans dire pourquoi
cela arrive, et on cherchait du côté du rôle applicatif — qui n'y est pour
rien.
`POSTGRES_PASSWORD` n'est lu qu'à l'initialisation du volume de la base. Un
volume créé lors d'un déploiement antérieur, fût-il un déploiement qui avait
échoué pour une autre raison, garde le mot de passe d'alors ; le changer
dans la pile n'y touche pas, le volume ne se réinitialise jamais. Le compte
d'amorçage est alors refusé, l'application ne peut plus réparer le rôle
applicatif, et le seul symptôme visible est un P1000 qui désigne le mauvais
coupable.
Le message nomme désormais ce cas et donne les deux issues : repartir d'un
volume neuf quand la base est vide, ou réaligner le mot de passe par la
socket locale — que l'image PostgreSQL accepte sans l'ancien.
Éprouvé sur la panne réelle, sous authentification par mot de passe.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
Suite de PR #31. `PersistentForm` couvre désormais les formulaires où la
saisie compte : ajout d'un salarié, d'un établissement, d'une équipe, d'une
entrée de registre, demande d'absence, heures réelles, correspondances de
paie, réglages d'envoi, conservation, dépôt de pièce.
Le composant gère `select`, `textarea`, cases et boutons radio, et reçoit
`resetAfter` : passé l'état retourné par l'action en cas de succès, le
formulaire revient aux valeurs rendues par le serveur — vide pour un ajout,
la valeur enregistrée pour un champ d'édition. C'est ce qui rend visible une
normalisation faite côté serveur.
Les champs cachés sont exclus. Ils ne portent jamais une saisie mais l'état
de l'application, et les rétablir écrase ce que le serveur vient de
renvoyer. Le cas s'est produit sur `expectedVersion`, le verrou optimiste
d'une semaine de planning.
En éprouvant cela, un défaut plus grave est apparu, antérieur et sans
rapport avec la préservation : le formulaire de publication recevait tantôt
l'action de publication, tantôt celle de dépublication selon l'état, et
cessait de suivre le changement. Lecture de l'en-tête `Next-Action` sur
trois envois consécutifs :
1er envoi 60b8097e (dépublier) → brouillon
2e envoi 6012d40f (publier) → publiée
3e envoi 6012d40f (publier) → publiée
Le bouton affichait « Dépublier » et republiait, sans message d'erreur
puisque l'action réellement exécutée réussissait. Une seule action reçoit
maintenant l'intention, portée par le bouton déclencheur — un champ caché
serait remis à sa valeur d'origine par la réinitialisation que React
applique après chaque action, un bouton ne l'est jamais.
Le test du planning fait désormais deux allers-retours dans le même
chargement : le premier envoi d'une page emploie toujours la bonne action,
si bien qu'un seul aller-retour laissait passer le défaut selon l'état où
la semaine avait été trouvée. Vérifié : le test échoue sur l'ancien
câblage, passe sur le nouveau.
450 tests unitaires et d'intégration, 77 de bout en bout.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
Les migrations posent le schéma et rien d'autre : une instance neuve n'a
aucun compte et aucun utilisateur, donc personne ne peut se connecter. Le
seed n'y remédie pas — il installe une démonstration et refuse de tourner
en production, à raison.
La première visite est désormais redirigée vers `/installation` : nom de
l'entreprise, premier établissement avec son fuseau, et compte
administrateur. L'écran crée le compte, le catalogue des capacités, les
cinq rôles fournis, le propriétaire et son périmètre, puis ouvre la session
par le chemin ordinaire — le rôle propriétaire exige aussitôt un second
facteur, comme il se doit.
Il ne se rouvre pas. Une table `Installation` d'une seule ligne, contrainte
en base et protégée par un trigger append-only, marque l'instance. Elle est
délibérément hors RLS, et c'est sa raison d'être : la politique d'`Account`
ne laisse voir que le compte courant, si bien qu'une instance installée
paraîtrait vierge à qui n'a pas de session — et la création d'un
propriétaire se rouvrirait à tout venant. Le recensement des politiques
porte l'exception, affirmée dans les deux sens.
Deux défauts trouvés en éprouvant l'écran sur une base réellement vierge :
`INSERT ... RETURNING` sur `Account` était refusé. L'insertion est permise,
mais la relecture de la ligne écrite passe par la politique de lecture, qui
exige un compte courant. L'identifiant est donc tiré côté application et
annoncé avant la création — la règle de partout, appliquée à la
transaction qui crée le compte.
Et un défaut qui dépassait cet écran : React 19 vide les champs non
contrôlés dès qu'une action se termine, refus compris. Les champs vidés
portant `required`, le clic suivant était arrêté par la validation du
navigateur avant d'émettre un `submit` — le formulaire paraissait mort. Le
formulaire de connexion en souffrait aussi ; la suite l'avait manqué parce
qu'aucun test ne soumettait deux fois de suite. `PersistentForm`
photographie la saisie à l'envoi et la rétablit, mots de passe exclus.
Au passage, `Field` rattache son indication par `aria-describedby` : placée
dans le `<label>`, elle entrait dans le nom accessible du champ.
Éprouvé sur une base vierge, image de production, rôle NOSUPERUSER
NOBYPASSRLS : redirection, trois refus motivés, installation, second
facteur exigé, écran refermé pour le propriétaire comme pour un visiteur.
450 tests unitaires et d'intégration, 77 tests de bout en bout.
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
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
Le seed installe un premier compte de démonstration : il ne peut pas le
créer dans un contexte locataire qui n existe pas encore. Or il se
connectait avec le rôle applicatif, soumis à la row-level security en
FORCE — l upsert Prisma (INSERT ... ON CONFLICT) se heurtait à la politique
de lecture « id = planflow_current_account() », renvoyant NULL.
Utiliser ADMIN_DATABASE_URL (compte d amorçage), comme le fait déjà le
harnais de tests d intégration (tests/integration/admin-db.ts).
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
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
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.
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.
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
`pnpm db:generate` échouait avant même le build : `prisma.config.ts` résolvait
DATABASE_URL par `env()`, qui lève dès le **chargement** du fichier de
configuration quand la variable manque. Or ce fichier est lu par toutes les
commandes du CLI, y compris `generate`, qui ne se connecte à rien — la
construction de l'image exigeait donc une base.
Ma vérification précédente ne valait rien : j'avais éprouvé `pnpm build` seul,
pas `pnpm db:generate && pnpm build`, la ligne qui échoue. Cette fois c'est la
commande exacte du Dockerfile qui a été jouée, sans .env, et elle sort en 0.
Corrigé au passage, du même genre que la troncature de l'écran de conservation :
la liste des périodes de paie s'arrête aux 24 plus récentes sans le dire. Une
période créée hors de cette fenêtre semblait ne pas s'être enregistrée. Le total
est désormais rapporté.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
La construction échouait : `src/lib/env.ts` validait le contrat d'environnement
à l'import, et `src/server/db.ts` ouvrait la connexion Prisma de même. Next.js
évalue les modules serveur pendant la construction, où ni la base ni la clé
n'existent — l'image exigeait donc les secrets de production pour être bâtie,
c'est-à-dire de les confier au constructeur. C'était à l'envers : ce sont des
valeurs d'exécution.
Les deux sont désormais résolus à la première lecture. La garantie ne bouge
pas : le premier appel utile arrive bien avant qu'une requête aboutisse, et
échoue avec le même message. Éprouvé en construisant sans aucune variable, ce
que fait exactement Docker.
Les tests décrivaient l'ancien contrat — ils restauraient l'environnement avant
de lire, ce qui ne prouvait plus rien une fois la lecture différée. Réécrits
pour lire pendant que les valeurs sont posées, avec deux cas de plus :
l'import seul n'exige rien, et la lecture d'une configuration absente échoue.
Ajout d'un `.dockerignore`, absent jusqu'ici. `.env` en tête, et ce n'est pas un
détail : il porte la clé de chiffrement et les mots de passe de la base, et
copié dans le contexte il aurait fini dans une couche de l'image, lisible par
quiconque la récupère.
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
Critère d'acceptation de WP-01 resté sans écran : « un rôle personnalisé créé
par un client modifie effectivement l'accès, sans changement de code ». Le
catalogue de capacités existait, l'autorisation s'y adossait déjà, mais seul le
semis pouvait attribuer quoi que ce soit.
Deux dangers opposés, et il faut se protéger des deux. L'escalade d'abord : on
ne peut accorder qu'une capacité qu'on détient soi-même, sans quoi la première
personne autorisée à éditer un rôle s'accorde l'accès aux rémunérations en
trois clics et le catalogue ne sert plus à rien. Le retrait, lui, reste permis
même sur une capacité qu'on n'a pas — réduire un droit n'a jamais élargi le
sien, et l'interdire empêcherait de corriger un rôle trop large.
Le verrouillage ensuite : au moins un rôle doit conserver la gestion des
droits. La condition se juge sur l'ensemble des rôles après coup, non sur celui
qu'on édite — ce qui compte n'est pas que ce rôle-ci garde la capacité, mais
qu'un rôle la garde. Sans cela une organisation se fermerait dehors, et le seul
recours serait une intervention en base.
Un rôle naît sans aucune capacité : en hériter de celles de son créateur
distribuerait des droits que personne n'a demandés. Le niveau propriétaire se
délègue à part. Un rôle système ne se supprime pas, un rôle attribué non plus —
ses membres se retrouveraient dehors sans que personne l'ait décidé pour eux.
Le test va jusqu'au bout : créer le rôle, l'attribuer, se connecter, constater
qu'un écran s'ouvre et qu'un autre reste fermé. S'arrêter à « la case est
cochée » n'aurait rien dit du critère.
Trois défauts d'isolation des tests corrigés au passage, tous du même genre —
un état qui s'accumule d'une exécution à l'autre :
- la file des congés désignait « la première ligne de ce salarié » pour son
nettoyage, et tombait sur une ligne laissée par un passage antérieur ;
- l'écran de conservation tronquait à quarante pièces triées par ancienneté,
faisant disparaître les pièces échues derrière celles dont il n'y a rien à
dire — corrigé dans le produit, pas seulement dans le test ;
- le test de verrouillage de période consommait un mois par exécution sans
jamais le rendre.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
La table RetentionPolicy existait et cinq durées y étaient semées depuis la
matrice ; rien ne les appliquait. Une durée déclarée que personne n'exécute est
une conformité de papier.
Aucune durée par défaut n'est appliquée, et c'est le point central : la matrice
interdit explicitement d'aligner tout sur cinq ans. Un objet sans politique
déclarée se conserve, et l'écran le signale plutôt que de le taire. Symétrie
inverse, tout aussi importante : effacer faute de règle serait aussi fautif que
garder indéfiniment.
La justification est obligatoire au niveau du serveur. Une durée sans motif est
une durée qu'on ne saura pas défendre le jour d'un contrôle.
Les politiques sont effectif-datées comme le reste de l'application : une pièce
déposée en mars relève de la règle en vigueur en mars. Sans cela, un
durcissement rétroactif purgerait ce que la règle du moment autorisait à garder.
La résolution va du précis au général — Document:SICK_NOTE avant Document — car
un arrêt de travail et un contrat n'ont aucune raison de se conserver aussi
longtemps.
Trois refus distincts plutôt qu'un seul : absence de politique, conservation
suspendue à titre probatoire, échéance non atteinte. Les confondre sous « rien
à purger » empêcherait de vérifier que la conservation est réellement tenue. Un
quatrième existe : employee_departure est déclaré mais non calculable, PlanFlow
ne modélisant pas de date de départ — purger sur une date inventée serait pire
que ne pas purger, et l'écran l'affiche comme tel.
La purge s'exécute en ligne de commande pour une tâche planifiée, la matrice
demandant des purges automatiques ; un bouton qu'il faut penser à presser n'en
est pas une. Elle passe par le client scopé et la RLS, compte par compte. Les
tables append-only en sont exclues par construction : le journal d'audit doit
survivre aux données qu'il décrit, sans quoi on ne pourrait plus démontrer que
la purge a eu lieu.
L'échéance affichée est dérivée de la politique, jamais stockée — même
discipline que la péremption d'un export.
Deux pièges rencontrés : un objet de composants exporté depuis un module client
ne survit pas au passage par un composant serveur, React n'en recevant qu'un
undefined ; et le minLength du navigateur masquait le contrôle serveur de la
justification, que des espaces suffisent à contourner.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
Rien ne pouvait être téléversé jusqu'ici : ni pièce d'identité, ni relevé
d'identité bancaire, ni arrêt de travail. Le dossier RH n'existait qu'en
champs de formulaire.
Les fichiers vivent sur disque, chiffrés avec la clé qui protège déjà le NIR et
l'IBAN. Le plan n'exige le chiffrement que des pièces jointes de santé ; les
chiffrer toutes supprime une branche dont l'oubli serait silencieux et ne coûte
rien de plus. Leur emplacement est tiré au sort, jamais dérivé du nom déposé :
la traversée de chemin devient impossible par construction plutôt que par
filtrage, et un filtre s'oublie.
Le caractère sensible se déduit de la catégorie et n'est jamais saisi : laisser
déclarer qu'un arrêt de travail n'est pas une donnée de santé reviendrait à
laisser désactiver la journalisation de sa lecture. Cette lecture est inscrite
au journal avant d'être servie, et l'écran l'annonce — celui qui ouvre la pièce
doit savoir que sa consultation laisse une trace nominative.
Les liens sont signés et durent deux minutes, comme l'exige le plan. La
signature ne remplace pas le contrôle d'accès : la route revérifie session,
capacité et périmètre. Elle s'y ajoute pour qu'un lien recopié dans un message
cesse de fonctionner de lui-même, sans attendre qu'une session expire. La
signature est éprouvée avant l'échéance, sans quoi répondre « expiré » à un lien
fabriqué indiquerait qu'il aurait pu marcher.
L'empreinte du clair est conservée et revérifiée à chaque lecture : servir un
contenu qui ne correspond plus reviendrait à présenter comme authentique une
pièce altérée. Retirer une pièce efface le contenu mais garde la ligne : le
dossier doit conserver trace qu'elle a existé et qui l'a retirée.
Aucune durée de conservation n'est appliquée par défaut — le plan l'interdit
explicitement (§12.5). L'échéance reste nulle et l'écran le dit, plutôt que
d'inventer « cinq ans partout ».
Deux pièges d'outillage rencontrés et documentés dans les tests : le cookie de
session étant marqué Secure, le client HTTP de Playwright ne l'émet pas sur
http et faisait passer les refus pour de bonnes raisons sans rien prouver ; et
la visionneuse PDF intégrée de Chromium ne restitue pas le corps d'une
navigation, ce qui masquait la vérification d'intégrité du contenu.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
La matrice n° 15 l'impose ; les colonnes existaient en base depuis WP-01 mais
rien ne les remplissait. TOTP (RFC 6238) plutôt qu'un code envoyé par message :
le second facteur ne doit pas dépendre du canal qui sert déjà à réinitialiser le
mot de passe, faute de quoi un accès à la boîte donnerait les deux d'un coup.
L'algorithme est écrit ici plutôt qu'emprunté — trente lignes, et une dépendance
de moins sur le chemin d'authentification. Il est éprouvé contre les vecteurs
publiés de la RFC, ce qui distingue « le code change toutes les trente
secondes » de « le code est celui qu'attend l'application du téléphone ».
L'obligation est adossée aux capacités, pas à des rôles nommés qu'un client
renomme librement : distribuer les droits, ou lire les rémunérations. Elle est
tenue au point de passage de toutes les routes applicatives, où l'écran
d'enrôlement remplace le contenu. Il remplace plutôt qu'il ne redirige : une
redirection depuis un layout se joue aussi pendant la navigation qui suit la
connexion, et Next y répond par une page vide.
Le secret n'est enregistré qu'après qu'un code en a été tiré — l'enregistrer
d'avance laisserait des comptes porteurs d'un facteur que leur détenteur ne sait
pas produire, c'est-à-dire des comptes fermés. Le rejeu d'un code est refusé
dans sa propre fenêtre : sans cela le facteur protège d'un mot de passe volé,
pas d'un code lu par-dessus l'épaule.
Dix codes de secours accompagnent chaque activation, conservés hachés. Et parce
que PlanFlow est auto-hébergé et qu'il n'y a pas d'éditeur à appeler, un retrait
depuis le serveur existe — l'accès « break glass » que demande la même ligne de
la matrice. Sans lui, le second facteur deviendrait le risque principal plutôt
que la protection.
Un défaut trouvé par les tests, et il aurait été grave : la réactualisation de
la route après activation remplaçait l'écran par celui d'un compte déjà enrôlé,
emportant les codes de secours avant que leur destinataire ait pu les noter.
La suite de tests suit le produit : la mise en place enrôle réellement le compte
de direction et calcule les codes comme le ferait un téléphone. Les écrans qui
n'éprouvent pas l'authentification ont migré vers la session commune — deux
connexions parallèles sur un même compte se heurtent au refus de rejeu, ce qui
est le comportement voulu et non un défaut à contourner.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
Jusqu'ici personne ne pouvait entrer dans l'application : le modèle Invitation
existait en base mais aucun code ne s'en servait, et le seul moyen d'obtenir un
compte était le jeu de données de démonstration.
Le lien vaut autant qu'un mot de passe le temps de sa validité, d'où trois
règles : sept jours, un seul usage, et une révocation possible sans attendre
l'expiration. Seule l'empreinte du jeton est conservée — renvoyer une
invitation émet donc un nouveau lien et invalide le précédent, ce qui est aussi
la bonne réponse à « il a perdu le message ». Deux liens vivants pour un même
accès, ce sont deux portes dont une seule est tracée comme ayant servi.
Le compte est porté par le lien lui-même, préfixé au secret. La table est
protégée par RLS, laquelle exige de connaître le compte avant toute lecture :
sans ce préfixe il aurait fallu ouvrir la politique aux requêtes sans compte —
c'est-à-dire la vider de son sens. Divulguer un identifiant opaque à qui est
membre du compte ne coûte rien.
Le lien est aussi rendu une fois, à l'écran de celui qui l'émet. Sans cela un
déploiement neuf ne peut inviter personne : configurer le serveur d'envoi
demande d'être connecté, et être connecté demande une invitation. Il n'est ni
conservé ni journalisé.
Deux cas à l'acceptation, un seul demande un mot de passe. Si aucun compte
n'existe pour l'adresse, il est créé ; s'il en existe un, le salarié est
rattaché sans qu'on touche à son mot de passe — détenir le lien prouve l'accès
à la boîte, ce qui suffit à rattacher un accès mais ne justifie pas de
réinitialiser l'authentification d'un compte existant. Pas de connexion
automatique non plus : un mot de passe qu'on vient de choisir se fixe en s'en
servant.
`members.invite` est une capacité distincte de `members.edit` : ouvrir un accès
n'est pas modifier un dossier, et tel client voudra confier l'un sans l'autre.
Deux défauts trouvés par les tests plutôt qu'en production : le contrôle qui
refuse un mot de passe contenant le nom du salarié laissait passer « riviere »
pour « Rivière » faute de replier les accents — soit exactement la variante
qu'on tape au clavier ; et les contextes « visiteur » des tests héritaient de
la session du responsable, si bien que le parcours anonyme n'était pas éprouvé.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
Sans serveur SMTP, PlanFlow ne peut ni inviter un salarié, ni notifier une
publication de planning, ni délivrer l'information due au retour d'un arrêt.
L'application n'expédie rien par elle-même : elle se connecte au serveur du
client, pour que les messages partent de son domaine et que les salariés
reconnaissent l'expéditeur.
Le mot de passe SMTP est traité comme le NIR et l'IBAN — chiffré au repos avec
la même clé, jamais renvoyé à l'écran (la page ne le charge même pas chiffré),
jamais recopié dans un message d'erreur ni dans le journal d'audit. Les erreurs
SMTP passent par une passe de masquage : les serveurs renvoient volontiers la
commande AUTH en clair. Laisser le champ vide conserve le mot de passe
enregistré, sans quoi changer un numéro de port casserait l'envoi.
Enregistrer et éprouver sont deux gestes distincts : toute modification remet
le réglage en « non vérifié », car un réglage non éprouvé n'est pas un réglage,
c'est une intention. L'envoi de test vérifie d'abord la connexion — ce qui
sépare une adresse de serveur fautive d'un mot de passe faux — puis expédie
réellement.
Les messages sont rendus en texte et en HTML depuis le même contenu, sans
image, script ni ressource distante : la charte de télémétrie vaut aussi pour
le courrier, un pixel de suivi dans un message RH est une collecte que personne
n'a acceptée. Le journal d'envoi retient le destinataire et l'issue, pas le
corps du message.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
`src/lib/demo` n'existe plus. Aucun écran de PlanFlow ne lit désormais autre
chose que la base.
Un indicateur doit être explicable
Chaque tuile est un lien vers ses lignes sources : profils incomplets, fins de
période d'essai, titres de séjour, entrées, sorties, avenants, journal des
absences. Un chiffre qu'on ne peut pas ouvrir ne se corrige pas — il se
conteste. Et chaque ligne mène à la fiche du salarié.
Les manques sont **nommés**, pas comptés : « 6 profils incomplets » n'aide
personne à agir, « il manque l'IBAN de trois salariés » se règle en un message.
Le NIR et l'IBAN sont contrôlés par la présence de leur colonne chiffrée, jamais
déchiffrés — savoir qu'une valeur existe n'exige pas de la lire.
Des chiffres qui refusent de mentir
- La rotation moyenne entrées et sorties : compter seulement les départs
sous-estime la rotation d'une équipe qui recrute autant qu'elle perd.
- Sur un effectif nul, elle rend `—` et non « 0 % ». Zéro pour cent de rotation
sur un établissement vide est une affirmation fausse, pas une absence de
mouvement.
- L'absentéisme se rapporte aux jours **théoriquement travaillés**, pas aux
jours calendaires : rapporter à 30 jours ferait passer un problème réel pour
du bruit.
- Un taux horaire absent vaut zéro dans le coût, jamais une estimation :
afficher un coût inventé serait pire qu'un coût partiel.
Les échéances remontent avant de tomber
Périodes d'essai à 45 jours, titres de séjour à 90. Les échéances **dépassées**
sont conservées et placées en tête : une période d'essai qu'on a laissé filer
est plus urgente qu'une échéance à venir, et la masquer parce qu'elle est passée
est précisément ce qui la rend coûteuse.
Périmètre et confidentialité
Le filtrage par établissement s'applique **avant** l'agrégation : les mouvements
d'un autre établissement ne transparaissent pas, même fondus dans un total. Le
journal des absences ne porte jamais le motif médical — un tableau de bord n'en
a aucun besoin.
Un salarié, qui n'a pas accès à l'annuaire, reçoit un accueil adapté plutôt
qu'une erreur d'autorisation ou un tableau de bord vide.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
Les trois grandeurs
Sans pointeuse, le réalisé est saisi par le manager. La matrice impose de
distinguer trois grandeurs et non deux, avec deux règles qui ne se négocient
pas :
- **Sans heures réelles, le prévu fait foi.** Attendre une saisie qui ne
viendra pas ne produirait aucune paie. Une saisie partielle — un début sans
fin — ne suffit pas : elle donnerait une durée fantaisiste.
- **Le paiement ne dépend jamais de la validation.** Une ligne non validée part
en paie sur la base du réalisé. Bloquer le paiement d'heures accomplies faute
de validation est précisément ce que la matrice interdit ; l'écran l'énonce
pour qu'aucun manager ne croie l'inverse.
Toute correction d'heures déjà saisies exige un motif — sans lui, une
correction est indistinguable d'une erreur de saisie. Une première saisie n'en
demande pas : il n'y a rien à corriger, et exiger un motif à chaque ligne le
ferait remplir machinalement.
Le verrou de période
- Verrouiller fige les instantanés **et** ferme le mois : plus aucun créneau,
aucune absence, aucune heure réelle ne peut être écrit sur ces dates. Le
contrôle passe avant l'écriture, dans planning, absences et heures.
- Déverrouiller exige une justification. Rouvrir périme les fichiers déjà
transmis au cabinet, et six mois plus tard personne ne saurait pourquoi.
- La **péremption d'un export est déduite** de `generatedAt < unlockedAt`,
jamais stockée : la stocker obligerait à réécrire une trace qui doit rester
append-only, et une trace réécrite ne prouve plus rien.
- Supprimer une période demande de **retaper son libellé** : un « êtes-vous
sûr » se clique sans lire, et la suppression efface des instantanés sur
lesquels un export a pu être bâti.
Un seul calcul pour trois écrans
Les instantanés viennent de `buildPayrollPeriod`, la même fonction que le
rapport de paie et l'export. C'est ce qui satisfait le critère croisé : trois
calculs séparés divergeraient, et l'écart ne se verrait qu'au bulletin. Un test
d'intégration compare instantané et rapport sur le même jeu de données.
Tests enfin idempotents
La suite e2e échouait au second passage : les absences acceptées d'une
exécution bloquaient les demandes de la suivante. Trois corrections, dans
l'ordre d'importance — chaque test travaille sur **son** salarié (le
chevauchement se juge par personne), chaque test libère ce qu'il a créé, et les
fenêtres de dates sont écartées d'une exécution à l'autre. Trois passages
consécutifs passent désormais.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv