Déposer et consulter les pièces du dossier salarié

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
This commit is contained in:
Claude committed 2026-08-09 07:31:22 +00:00
1 parent 5a15a25145
commit 29369d4a4e
16 files changed
+1275

No files matched your search

+137
View File
@@ -0,0 +1,137 @@
/**
* Pièces du dossier salarié — PLAN.md §4.7 et §3.6.
*
* Un dossier RH contient des pièces d'identité, des RIB et des arrêts de
* travail. Les derniers sont des données de santé, catégorie particulière au
* sens du RGPD : leur simple **lecture** doit être journalisée, et leur
* stockage chiffré.
*/
export const DOCUMENT_CATEGORIES = [
'IDENTITY',
'BANK',
'CONTRACT',
'AMENDMENT',
'SICK_NOTE',
'WORK_PERMIT',
'REGISTER',
'OTHER',
] as const;
export type DocumentCategory = (typeof DOCUMENT_CATEGORIES)[number];
export const CATEGORY_LABELS: Record<DocumentCategory, string> = {
IDENTITY: 'Pièce d’identité',
BANK: 'Coordonnées bancaires',
CONTRACT: 'Contrat',
AMENDMENT: 'Avenant',
SICK_NOTE: 'Arrêt de travail',
WORK_PERMIT: 'Titre de séjour',
REGISTER: 'Registre',
OTHER: 'Autre',
};
/**
* Catégories relevant de la santé.
*
* Une liste plutôt qu'un drapeau saisi à la main : laisser l'utilisateur
* déclarer qu'un arrêt de travail n'est pas sensible reviendrait à lui laisser
* désactiver la journalisation.
*/
const HEALTH_CATEGORIES = new Set<DocumentCategory>(['SICK_NOTE']);
export function isSensitiveCategory(category: DocumentCategory): boolean {
return HEALTH_CATEGORIES.has(category);
}
/**
* Types de fichiers acceptés.
*
* Restreint volontairement : un dossier RH reçoit des scans et des PDF. Élargir
* aux documents bureautiques ferait entrer des formats à macros, qu'aucun usage
* ici ne justifie.
*/
export const ACCEPTED_MIME_TYPES = [
'application/pdf',
'image/jpeg',
'image/png',
'image/webp',
'image/heic',
] as const;
/** 20 Mio — un scan de plusieurs pages passe, une vidéo non. Choix produit. */
export const MAX_DOCUMENT_BYTES = 20 * 1024 * 1024;
export interface UploadCandidate {
name: string;
mimeType: string;
sizeBytes: number;
}
/** Motif de refus, ou `null` si la pièce est acceptable. */
export function uploadProblem(candidate: UploadCandidate): string | null {
if (candidate.sizeBytes <= 0) {
return 'Le fichier est vide.';
}
if (candidate.sizeBytes > MAX_DOCUMENT_BYTES) {
return `Le fichier dépasse ${Math.round(MAX_DOCUMENT_BYTES / 1024 / 1024)} Mio.`;
}
if (!(ACCEPTED_MIME_TYPES as readonly string[]).includes(candidate.mimeType)) {
return 'Format non accepté. Déposez un PDF ou une image (JPEG, PNG, WebP, HEIC).';
}
if (!candidate.name.trim()) {
return 'Le fichier n’a pas de nom.';
}
return null;
}
/**
* Nom d'affichage assaini.
*
* Le nom déposé par l'utilisateur ne sert **jamais** à désigner l'emplacement
* du fichier — celui-ci porte un identifiant tiré au sort. L'assainir reste
* utile pour l'en-tête de téléchargement, où un retour à la ligne ou un
* guillemet permettrait d'y injecter un second champ.
*/
export function sanitiseFileName(name: string): string {
const base = name.split(/[\\/]/).pop() ?? name;
const cleaned = base
.replace(/[\u0000-\u001f\u007f]/g, '')
.replace(/["\\]/g, '')
.trim();
return cleaned.slice(0, 120) || 'document';
}
/**
* Nom en ASCII pur, pour l'en-tête `Content-Disposition`.
*
* Les accents y sont transmis séparément, en `filename*` ; le repli ASCII sert
* aux clients qui ne lisent que `filename`.
*/
export function asciiFileName(name: string): string {
return (
sanitiseFileName(name)
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^\x20-\x7E]/g, '_') || 'document'
);
}
export function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} o`;
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} Kio`;
return `${(bytes / 1024 / 1024).toFixed(1)} Mio`;
}
/**
* Durée de validité d'un lien de téléchargement.
*
* Assez pour ouvrir la pièce depuis la liste, trop peu pour qu'un lien recopié
* dans un message reste exploitable. Le lien seul ne suffit d'ailleurs pas :
* la session et la capacité sont revérifiées à la lecture.
*/
export const DOWNLOAD_LINK_TTL_SECONDS = 120;
export function isLinkExpired(expiresAtSeconds: number, nowMs: number): boolean {
return expiresAtSeconds * 1000 <= nowMs;
}