Docs menu
Architecture#
Ce document décrit la structure du dépôt, le chemin d'une requête, le modèle d'autorisation et les décisions qui coûteraient cher à défaire.
Vue d'ensemble#
Navigateur (Next.js) Application mobile (Expo)
│ │
cookie httpOnly Authorization: Bearer
│ │
└──────────── tRPC ───────────┘
│
packages/api ── contexte, procédures, règles métier
│
packages/auth ── mots de passe, sessions, principal
│
packages/db ── Drizzle, PostgreSQLUn seul serveur sert les deux clients. Le typage descend de l'API vers les applications : renommer une procédure casse la compilation des deux.
Paquets#
packages/db#
Schéma Drizzle, migrations et référentiels. 23 tables couvrant le compte, l'entreprise, le prestataire, la mission, la candidature, le pointage, le litige et l'ordre de règlement.
Aucune migration SQL n'est écrite à la main : le schéma TypeScript fait foi, et
pnpm db:generate produit la migration. Les migrations sont versionnées.
db:migrate est conditionnel — sans DATABASE_URL, il sort en code 0 sans
rien faire, de sorte qu'un build sans base ne casse pas.
packages/auth#
Cœur d'authentification, indépendant de tout framework et sans dépendance
native : scrypt et SHA-256 viennent de node:crypto, si bien que le même code
tourne en local, en intégration continue et sur l'hébergement.
Il expose le hachage des mots de passe, le cycle de vie des sessions, la réinitialisation par jeton, et la résolution du principal — le rattachement métier d'un utilisateur.
packages/api#
Routers tRPC et règles métier. C'est la seule couche qui décide. Voir
API.md.
packages/ui#
Types et jetons partagés entre le web et le mobile.
Chemin d'une requête#
- Le client appelle une procédure tRPC.
createContextlit le jeton — en-têteAuthorization, sinon cookie —, valide la session et résout le principal. Une fois par requête.- La procédure choisie impose le rattachement requis.
- La règle métier s'exécute, en transaction lorsqu'elle écrit plusieurs tables.
Modèle d'autorisation#
L'autorisation tient au choix de la procédure, jamais à un test recopié dans chaque route. C'est ce qui la rend difficile à oublier.
| Procédure | Garantit |
|---|---|
publicProcedure | rien |
protectedProcedure | une session valide |
companyProcedure | un principal entreprise (companyId, establishmentId) |
providerProcedure | un principal prestataire (providerId) |
adminProcedure | un compte d'administration |
Aucune requête métier ne filtre sur un identifiant fourni par le client. Elle filtre sur le principal résolu côté serveur. Un identifiant reçu du client ne prouve rien.
Quand une ressource appartient à quelqu'un d'autre, l'API répond NOT_FOUND
et non FORBIDDEN : distinguer les deux révélerait l'existence de la ressource.
Les espaces web sont en outre gardés dans leur layout, côté serveur, avant tout rendu. C'est de la défense en profondeur et de l'ergonomie — pas la frontière d'autorisation, qui reste dans l'API.
Sessions#
| Client | Transport | Raison |
|---|---|---|
| Web | cookie HttpOnly, SameSite=Lax | hors de portée du JavaScript de la page |
| Mobile | Authorization: Bearer | pas de cookie ; jeton en Keychain / Keystore |
Les mutations qui ouvrent une session posent le cookie et renvoient le jeton : un seul chemin de code sert les deux clients.
Ni le mot de passe ni le jeton ne sont stockés en clair — la base ne contient que des condensats. Une fuite de la table des sessions ne permet pas d'usurper.
Décisions structurantes#
Chacune de ces décisions serait coûteuse à défaire.
Les montants sont persistés, pas recalculés#
missions.estimated_total_cents est calculé par le serveur et conservé. C'est le
montant opposable : celui qui a été présenté au prestataire. Le recalculer à
l'affichage ouvrirait un écart entre ce qui a été montré et ce qui a été convenu.
Tous les montants sont en centimes. Aucun flottant sur de la monnaie.
La transparence de la rémunération est une règle bloquante#
Un prestataire ne peut pas accepter une mission dont le montant total estimé ne
lui a pas été présenté. La consultation de la fiche écrit une ligne dans
mission_disclosures ; la candidature est refusée sans elle, refusée si le
montant a changé depuis, et refusée si le montant confirmé ne correspond pas.
La candidature conserve la trace de ce qui a été montré. La règle est donc vérifiable après coup, et pas seulement appliquée à l'écran.
Le plafond de candidatures est appliqué sous verrou#
Le contrôle du plafond et l'insertion se font sous SELECT … FOR UPDATE sur la
mission. Sans ce verrou, des candidatures simultanées le franchissent — c'est
vérifié par un test qui en lance huit sur cinq places.
Le décompte horaire vient du serveur#
Le chronomètre affiché est calé sur l'horodatage renvoyé par l'API, jamais sur un compteur local. Fermer l'application ne fausse pas les heures.
Le code de pointage n'est stocké que sous forme de condensat : une fuite de table ne permet pas de pointer à distance. En régénérer un révoque le précédent.
Aucune commission n'est inventée#
Tant que le taux de commission n'est pas renseigné dans les paramètres de plateforme, les heures sont arrêtées mais l'ordre de règlement n'est pas établi. Un règlement en attente se rattrape ; un prélèvement au mauvais taux sur les revenus d'un indépendant, beaucoup moins. La console d'administration remonte le nombre de relevés dans ce cas.
Ce que la plateforme garantit sur le paiement#
L'engagement porte sur la transmission de l'ordre de règlement au prestataire
de paiement — pas sur l'exécution du virement, qui relève de ce tiers. La table
payment_orders journalise chaque transmission : c'est la preuve.
Identifiants d'entreprise et d'établissement#
Le SIREN identifie l'entreprise, le SIRET l'établissement. Les confondre rendrait impossible le rattachement de plusieurs points de vente à une même entreprise sans reprise du modèle. Le SIRET doit relever du SIREN déclaré.
Les clés de contrôle sont vérifiées hors ligne : une faute de frappe est signalée immédiatement, sans appel réseau. La vérification de l'existence au registre supposerait un service tiers, non retenu à ce jour ; l'API l'indique explicitement plutôt que de laisser croire à un contrôle qui n'a pas eu lieu.
Un établissement par entreprise#
Porté par un index unique. Le lever pour ouvrir les comptes multi-établissements consiste à supprimer cet index — aucune autre reprise.
Localisation#
La plateforme se limite au calcul de la distance entre le prestataire et le
lieu de la mission, aux fins de diffusion et de classement. Haversine est calculé
en SQL, sans extension PostgreSQL : earthdistance imposerait une extension à
installer pour un gain nul à l'échelle de dix kilomètres.
Le suivi de position en temps réel est hors périmètre. Ne rien ajouter ici qui y ressemblerait.
Ce qui est paramétrable#
Types de postes, rayon de diffusion, plafond de candidatures, fenêtre de contestation, durée minimale, barème : tout vient de la base, jamais figé dans le code. Ces valeurs sont destinées à être ajustées au vu des volumes réels.
Tests#
188 tests. Les suites d'intégration tournent contre PostgreSQL et sont ignorées
proprement sans DATABASE_URL.
Chaque paquet a sa propre base de test, créée et migrée au démarrage de la suite. Sans cela, deux paquets testés en parallèle se videraient les tables l'un de l'autre — des échecs qui n'apparaissent qu'en exécution groupée et disparaissent quand on relance le paquet seul.
Les garde-fous qui portent une règle sont éprouvés par mutation : on les neutralise et on vérifie qu'un test échoue.