Skip to content

Repository files navigation

🚜 ShareMate

Application de gestion collective de matériel partagé pour un petit groupe (2 à 5 voisins/amis) : minipelle, véhicule utilitaire, bétonnière… Réservations, suivi d'usage, alertes d'entretien et partage des frais façon Tricount.

Fonctionnalités (MVP)

  • Comptes et cercles : chaque membre a un compte (mot de passe, session). Le premier compte s'ouvre au premier démarrage ; les suivants entrent par un lien de première connexion que n'importe quel membre peut émettre et transmettre hors application. Un mot de passe perdu se redonne de la même façon, par un lien de réinitialisation que l'administrateur émet et transmet hors application : le compte est repris tel quel, il n'y a rien à recréer. Il n'y a pas de « groupe » au sens d'une entité : le cercle d'un équipement est la liste de ses membres, et un membre peut appartenir à plusieurs cercles sans qu'ils se voient entre eux.
  • Administration : le premier compte ouvert est l'administrateur de l'instance. Il est le seul à pouvoir redonner l'accès à un compte dont le mot de passe est perdu — un lien de reprise, valable 24 heures, à usage unique, qu'il transmet hors application ; jusqu'à ce qu'il soit consommé, l'ancien mot de passe et les sessions ouvertes continuent de valoir, et la consommation les révoque tous. Il est aussi le seul à pouvoir réunir deux comptes du même membre. Le doublon arrive : quand le dernier équipement qui reliait deux personnes disparaît, elles sortent du champ de vision l'une de l'autre, et il ne reste qu'à recréer l'autre — un compte porte alors l'historique, l'autre l'accès qui fonctionne. La fusion choisit lequel absorbe lequel, quel nom et quel email survivent, annonce ce qu'elle déplacera, puis le fait d'un seul geste : cercles, réservations, relevés, dépenses (parts additionnées là où les deux comptes figuraient dans une même répartition), messages, documents, notifications et invitations passent au compte conservé, dont les soldes restent inchangés. Le compte absorbé disparaît, avec son mot de passe et ses sessions.
  • Équipements : CRUD complet (nom, date d'acquisition, membres du cercle, type de compteur heures/km, et — facultatives — catégorie et valeur d'achat, qui ne décrivent que la fiche). Un équipement appartient à son cercle, pas à un propriétaire unique. Une personne entre dans un cercle dès sa création, sans attendre qu'elle ait consommé son lien de première connexion : elle y porte ses parts de dépense et pèse dans les soldes ; seule sa propre connexion attend. Tout changement de composition notifie les entrants, les sortants et ceux qui restent ; se retirer soi-même est un geste dédié (« quitter le cercle »).
  • Réservations : calendrier de créneaux par équipement, détection de conflit (409 en cas de chevauchement), vue calendrier commune à tous les cercles du membre, récurrences plafonnées à 52 occurrences.
  • Suivi d'usage : à chaque fin d'utilisation, saisie du compteur au départ et du relevé d'arrivée, carburant ajouté et remarques ; historique par équipement et par membre ; alertes d'entretien dès qu'un seuil d'heures/km est dépassé depuis la dernière maintenance déclarée. Un relevé porte sa propre durée, au lieu de la déduire du relevé précédent : celui qui oublie sa saisie ne fait plus porter ses heures au suivant. Quand le compteur trouvé au départ dépasse le dernier relevé connu, l'engin a tourné pour quelqu'un — ces heures deviennent un segment en attente d'attribution, que son auteur reconnaît (« c'était moi ») ou que le déclarant attribue aussitôt s'il sait à qui. Tout relevé se corrige et se réattribue par n'importe quel membre du cercle — un compteur mal recopié, un relevé porté par le mauvais membre —, dans la limite de ses voisins dans la chaîne : un relevé se corrige, il ne se déplace pas. Toute heure qu'une correction ou une suppression retire d'un relevé sans la donner à quelqu'un retourne en attente : elle reste au compteur, elle ne s'évapore pas de l'historique.
  • Frais partagés : dépenses (achat, assurance, carburant, entretien, réparation) avec justificatif image/PDF optionnel ; répartition par parts égales, au prorata du temps d'usage (calculé à partir des réservations) ou montants personnalisés ; soldes « qui doit combien à qui » avec minimisation du nombre de transactions ; historique des remboursements déclarés.
  • Contenu du lot : chaque équipement porte la liste de ce qui part avec lui — la remorque de la minipelle, ses godets, la pompe à graisse, la caisse à outils, un jerrican. Chaque élément a un nom, une quantité et une précision libre (dimensions, plaque, emplacement), et se déplie depuis la fiche de l'équipement. C'est un inventaire, pas un équipement en réduction : rien n'y est réservable et rien n'y porte de dépense — un godet ne se prête pas sans la pelle. Comme une checklist, le lot appartient au cercle : tout membre le complète, le corrige et le retire.
  • Checklists : une ou plusieurs checklists par équipement (ex. « Avant utilisation », « Hivernage ») avec leurs points de contrôle. Une checklist appartient au cercle, pas à son créateur : tout membre du cercle peut la cocher, la renommer, ajouter/modifier/supprimer ses points et la supprimer entièrement. Chaque coche garde la trace de qui l'a validée et quand, et le créateur reste affiché. Avancement affiché (3/7) et remise à zéro en un geste pour réutiliser la checklist à la prochaine sortie.
  • Documents : un dossier par équipement, où le cercle range ce qui s'y rattache — manuel d'utilisation, certificat d'assurance, facture d'achat, photos — sous forme de fichiers déposés ou de liens externes, dans une même liste. Chaque document porte une catégorie choisie à la main (Manuel, Assurance, Achat & garantie, Entretien, Photos, Autre) et se renomme. Comme une checklist, un document appartient au cercle et non à son déposant : tout membre peut le renommer, le reclasser et le supprimer, et le nom du déposant reste affiché. Les fichiers vivent dans un stockage d'objets S3/R2 (repli sur le disque si aucun bucket n'est configuré) ; la base n'en garde que les métadonnées.
  • Discussions : fils de discussion par équipement, avec sous-fils de réponses. Un message peut porter un fichier joint — la photo d'une panne, un devis — envoyé avec lui ou seul, sans texte. Le fil et le message se renomment, s'éditent et se suppriment par leur auteur seul ; tout le cercle lit et répond.
  • Notifications : centre in-app (cloche) et Web Push (PWA), réglables par type d'événement et par membre. Chaque notification se marque lue et s'efface — une par une ou tout le centre d'un coup, sans toucher à celles des autres membres. Détail et configuration dans docs/notifications.md.

Architecture

DDD + architecture hexagonale, TypeScript de bout en bout, développé en TDD strict.

server/src/
├── domain/           # Entités, value objects, règles métier pures — AUCUNE dépendance externe
│   ├── shared/       # Money (centimes entiers), TimeRange (fin exclusive), erreurs métier,
│   │                 # StoredFile (référence d'un objet stocké, et ses bornes)
│   ├── member/       # Member (email validé : il sert d'identifiant de connexion ; rôle admin)
│   ├── auth/         # MemberCredential (mot de passe, invitation datée, réinitialisation datée),
│   │                 # Session
│   ├── equipment/    # Equipment (cercle des membres, compteur heures/km, seuil d'entretien),
│   │                 # SubEquipment (contenu du lot : remorque, godets, jerrican…)
│   ├── reservation/  # Reservation + règle de non-chevauchement, récurrences
│   ├── usage/        # UsageRecord + calcul des alertes de maintenance
│   ├── expense/      # Expense (règles de répartition), Reimbursement,
│   │                 # calcul des soldes + minimisation des transactions (type Tricount)
│   ├── discussion/   # Thread + Message (sous-fils de réponses, fichier joint)
│   ├── checklist/    # Checklist + ChecklistItem (points cochés, traçabilité de la coche)
│   ├── document/     # Document (fichier déposé ou lien externe, catégorie, borne de poids)
│   └── notification/ # Notification, NotificationPreference, types notifiables
├── application/      # Use cases + ports (repositories, Clock, IdGenerator, Notifier, AuditLogger)
│                     # equipment-access.ts : règle d'accès unique (cercle de l'équipement)
│                     # receipt-access.ts   : un justificatif suit la dépense qui le porte
│                     # document-access.ts  : purge des objets qu'aucun document ne nomme plus
└── infrastructure/   # Adapters
    ├── http/         # Fastify : app.ts (transverse) + plugins/ (un fichier par domaine)
    ├── persistence/  # SQLite (better-sqlite3), migrations versionnées par PRAGMA user_version
    └── tech/         # scrypt, UUID, horloge, push (Web Push)
                      # object-store.ts : magasin d'objets brut (disque ou bucket S3/R2),
                      # partagé par les justificatifs et les documents
web/src/              # Front React (Vite) — adapter de présentation

Les frontières sont vérifiées par oxlint : le domaine ne peut rien importer des couches application/infrastructure, l'application ne peut pas importer l'infrastructure.

Choix notables

  • Les montants sont des centimes entiers (Money) ; les répartitions utilisent la méthode des plus forts restes — pas un centime perdu.
  • Les créneaux sont des intervalles à fin exclusive : deux réservations adjacentes ne se chevauchent pas.
  • Le relevé de compteur est monotone : un relevé inférieur au dernier connu est refusé.
  • Le cercle est porté par l'équipement, pas par une entité « groupe ». Deux personnes sans équipement commun ne se voient pas, ce qui donne le multi-cercles sans multi-tenant.
  • Une pièce jointe suit son message : au plus une, jamais remplacée par une édition (elle a déjà été vue par le cercle), et emportée par la suppression du message, de ses réponses, de son fil ou de l'équipement. Elle n'entre pas dans le dossier de l'équipement — ce sont deux gestes différents, montrer et ranger.
  • Un document est une entité, deux natures (FILE ou LINK) : le membre range un manuel PDF et un tutoriel vidéo côte à côte, et le code n'a qu'une liste, qu'une règle d'accès, qu'une suppression. La table l'écrit aussi, par une contrainte CHECK qui exclut la rangée hybride.
  • Le stockage d'objets est le même code pour R2 et S3 : R2 parle le protocole S3, seul l'endpoint change. Un port ObjectStorage côté application, un magasin brut côté infrastructure — que justificatifs et documents partagent —, et un repli disque quand les variables du bucket sont absentes : les tests et le développement tournent sans bucket, comme le push tourne sans clés VAPID.
  • Un chemin de justificatif (/uploads/<uuid>.<ext>) est un identifiant, pas une adresse : il n'a pas changé au passage dans le bucket, où il devient la clé receipts/<uuid>.<ext>. C'est ce qui permet de basculer sans réécrire une seule dépense, ni le schéma HTTP, ni le front.
  • La fusion de deux comptes est un geste du dépôt, pas de la couche application : elle touche quinze tables dont plusieurs se refusent à un repointage naïf (clés primaires composées du cercle et des préférences, remboursement devenu « de soi à soi », identifiants de expenses.split_json que ne suit aucune clé étrangère), et une fusion à moitié faite serait pire que pas de fusion — d'où la transaction unique. Son aperçu est la fusion elle-même, défaite avant de rendre la main : ce qui est annoncé ne peut pas s'écarter de ce qui sera fait.
  • Toutes les entrées HTTP sont validées par un schéma JSON (Ajv, embarqué dans Fastify) : objets fermés, bornes de longueur, énumérations tirées du domaine. Les types TypeScript des handlers décrivent donc ce qui arrive réellement.

Développement

npm install
npm test              # 877 tests : 624 serveur (Node) + 253 front (jsdom)
npm run test:coverage # Tests + seuils de couverture (90 % lignes/fonctions, 85 % branches)
npm run lint          # oxlint (frontières hexagonales + règles React)
npm run format        # Prettier (format:check en CI)
npm run typecheck     # tsc sur les deux workspaces
npm run audit:prod    # npm audit des dépendances de production (high+)
npm run migrate:receipts -- --dry  # transfert des justificatifs du volume vers le bucket
npm run admin:designate            # liste les comptes et dit lequel est administrateur
npm run admin:designate -- <id>    # désigne l'administrateur (base antérieure à ce rôle)
npm run dev:server    # API sur http://localhost:3000
npm run dev:web       # Front Vite sur http://localhost:5173 (proxy /api → 3000)
npm run build         # Build de production (server/dist + web/dist)
npm start             # Sert l'API + le front buildé

Conventions de code

  • Le français est la langue de ce qui se lit : commentaires, messages d'erreur, libellés d'interface, noms de tests et messages de commit. L'anglais est la langue de ce qui s'exécute : identifiants du code de production (variables, fonctions, types, clés de journal d'audit), au même titre que les mots-clés du langage et les noms des bibliothèques. Les fichiers de test s'autorisent des identifiants français, qui prolongent l'intention décrite par le titre du test.
  • Un commentaire dit pourquoi, jamais quoi : la contrainte, l'invariant, le piège que le code suivant évite. Ce que fait le code se lit dans le code.
  • Architecture hexagonale, vérifiée par oxlint : server/src/domain ne dépend de rien, server/src/application ne dépend pas de infrastructure.

Variables d'environnement du serveur :

Variable Défaut Rôle
PORT 3000 Port HTTP
DATA_DIR ./data Répertoire des données persistantes
DATABASE_PATH $DATA_DIR/sharemate.sqlite Fichier SQLite
UPLOADS_DIR $DATA_DIR/uploads Justificatifs, quand aucun bucket S3/R2 n'est configuré
DOCUMENTS_DIR $DATA_DIR/documents Documents, quand aucun bucket S3/R2 n'est configuré
ATTACHMENTS_DIR $DATA_DIR/attachments Pièces jointes, quand aucun bucket S3/R2 n'est configuré
S3_* — (repli sur le disque) Bucket des justificatifs et des documents : voir ci-dessous
WEB_DIST_DIR ../web/dist Front statique servi par le serveur
NODE_ENV production : cookie Secure, trustProxy, logs JSON
CORS_ORIGINS — (vide : pas de CORS) Origines cross-origin autorisées, séparées par des virgules
VAPID_* — (push désactivé) Push : voir docs/notifications.md

Stockage des fichiers (Cloudflare R2 ou Amazon S3)

Justificatifs de dépense, documents d'équipement et pièces jointes des messages partagent le même bucket compatible S3, sous trois préfixes distincts (receipts/, documents/, attachments/), dès que ces quatre variables sont présentes. Sinon ils tombent respectivement sur UPLOADS_DIR, DOCUMENTS_DIR et ATTACHMENTS_DIR, ce qui permet de développer et de tester sans bucket.

Variable Rôle
S3_BUCKET Nom du bucket
S3_ENDPOINT https://<id-de-compte>.r2.cloudflarestorage.com pour R2
S3_ACCESS_KEY_ID Identifiant du jeton d'accès
S3_SECRET_ACCESS_KEY Secret du jeton d'accès
S3_REGION auto par défaut (valeur documentée par Cloudflare pour R2)

Le bucket doit rester privé. L'application ne s'appuie jamais sur un accès public : elle émet une URL signée valable cinq minutes, après avoir vérifié que le demandeur appartient au cercle de l'équipement. Un bucket ouvert rendrait ce contrôle décoratif.

Plafonds et formats diffèrent selon la nature du fichier :

Justificatif de dépense Document d'équipement Pièce jointe de message
Poids maximal 10 Mo par fichier 25 Mo par fichier 25 Mo par fichier
Formats acceptés png, jpg, webp, pdf + gif, txt, csv, doc(x), xls(x), ppt(x), od[tsp] idem document

Les images sont compressées avant de partir, dans le navigateur de celui qui les dépose : ramenées à 2000 px sur leur plus grand côté et réencodées en WebP (qualité 0,82), une photo de téléphone tombe d'un ordre de grandeur. Le travail se fait avant la requête — c'est le seul endroit où il économise aussi le téléversement, souvent le trajet le plus lent. Rien n'est dégradé sans gain : si le résultat n'est pas plus léger, ou si le navigateur ne sait pas relire l'image, c'est le fichier d'origine qui part. Les plafonds ci-dessus, eux, ne bougent pas : ils bornent ce qui arrive, quoi qu'il arrive.

Documents et pièces jointes se partagent 500 Mo par équipement — c'est le même bucket, donc la même enveloppe. Deux budgets séparés en feraient deux fois plus, et ne plafonner que le dossier ferait des discussions la façon la moins chère de remplir le bucket. Le contrôle a lieu avant que l'octet n'atteigne le stockage ; il n'est pas atomique — deux dépôts simultanés peuvent dépasser le plafond d'un fichier au plus. C'est un garde-fou de facture, pas une réservation.

Ni exécutables, ni archives, ni HTML, ni SVG. Avec un bucket, le contenu est servi depuis un domaine distinct du nôtre, où une page fabriquée s'exécuterait dans son propre contexte ; en repli disque, l'API le relaie depuis notre origine, et un HTML inline y hériterait de la nôtre. La liste vaut donc pour le plus permissif des deux modes.

Faire passer les justificatifs existants dans le bucket

La bascule ne casse rien et ne demande aucune coupure : dès que les variables sont posées, les nouveaux fichiers vont dans le bucket, et ceux restés sur le volume continuent d'être lus de là. Le chemin public d'un justificatif (/uploads/<uuid>.<ext>) ne change pas — c'est son identifiant, pas l'endroit où il dort — donc aucune dépense n'est à réécrire.

Reste à retirer du volume les fichiers qui y dorment encore. Le volume, lui, reste indispensable : il porte la base SQLite. Le transfert ne fait que lui enlever ce qui n'a plus à y être.

# Depuis le dépôt, variables S3_* et DATA_DIR en place :
npm run migrate:receipts -- --dry   # dit ce qu'il ferait, sans rien écrire
npm run migrate:receipts            # transfère

Sur l'instance en service, la base est sur le volume et l'image ne contient ni les sources ni tsx : le script s'y appelle node server/dist/migrate-receipts.js. Procédure complète dans le runbook.

Le script copie, ne supprime rien, et se rejoue sans dommage : ce qui est déjà dans le bucket est laissé tel quel. Il signale à part les fichiers qu'aucune dépense ne nomme (orphelins d'anciennes suppressions) et ceux dont le nom n'est pas celui qu'un téléversement produit — ni les uns ni les autres ne sont transférés.

Supprimer les fichiers locaux reste un geste manuel, après avoir rouvert quelques justificatifs depuis l'application : jusque-là, le volume en détient la seule autre copie.

Sécurité

Modèle de menace retenu

L'instance est une communauté de connaissances, pas un service ouvert. Elle protège un membre contre les autres membres de l'instance avec lesquels il ne partage rien, et contre un tiers non authentifié. Elle ne le protège pas contre les membres de ses propres cercles : entre eux, la confiance est totale et assumée. Le reste de cette section dit précisément où passe cette ligne.

Qui peut entrer

  • Le tout premier compte s'ouvre sans authentification, une seule fois, tant qu'aucun compte n'existe (POST /api/auth/bootstrap, insertion atomique : deux requêtes simultanées ne créent pas deux « premiers comptes »).
  • Ensuite, tout membre authentifié peut créer un compte et obtenir son lien de première connexion, qu'il transmet hors application. C'est le choix du produit : la communauté se coopte, personne n'ouvre les portes. Le garde-fou est un plafond de 20 créations par minute et par IP, pas un droit.
  • Il existe un rôle d'administrateur, et il n'ouvre que deux gestes : redonner l'accès à un compte dont le mot de passe est perdu, et réunir deux comptes du même membre. Il ne donne aucun accès supplémentaire aux cercles, aux dépenses ni aux discussions — l'administrateur reste cadré sur son périmètre comme tout le monde, à la seule exception de la liste des comptes de l'instance (GET /api/admin/members), sans laquelle les deux comptes à réunir ne s'afficheraient pas ensemble. Le rôle est porté par le premier compte ouvert ; sur une base antérieure à ce rôle, il est désigné par l'opérateur (npm run admin:designate) et jamais deviné. À tout autre, /api/admin/* répond 403 en parlant du geste, jamais des identifiants visés.
  • La fusion ne se défait pas : elle absorbe une identité entière, révoque ses sessions et supprime son accès. Elle est donc annoncée chiffrée avant confirmation, journalisée (membre.fusionne, acteur, comptes, compteurs par table), et refuse d'absorber l'administrateur lui-même — l'instance perdrait le seul compte qui autorise le geste.
  • Un lien de première connexion expire au bout de 7 jours, ne sert qu'une fois, et ne vaut que pour un compte qui n'a jamais eu de mot de passe. Régénérer un lien n'est possible que pour soi-même, pour un membre d'un cercle partagé, ou pour quelqu'un qu'on a soi-même invité ; hors de là, la réponse est celle d'un membre inexistant, et toute régénération visant un autre que soi est tracée en warn.
  • Un mot de passe perdu se redonne, et par un autre chemin que l'invitation. Le lien de réinitialisation porte son propre code, sa propre échéance (24 heures) et ses propres routes : les deux ne s'échangent pas, et un code d'invitation ne rouvre toujours pas un compte en service. Il est réservé à l'administrateur — ni le titulaire, ni un membre du cercle, ni même l'invitant ne peuvent l'émettre : ce code reprend un compte, et l'ouvrir à l'invitant lui donnerait sur son invité un pouvoir de reprise permanent, que celui-ci n'a jamais accordé et ne peut pas retirer. L'administrateur, lui, ne gagne rien : la fusion lui permettait déjà d'absorber une identité entière — c'était même l'unique recours, en recréant la personne puis en réunissant les deux comptes. Le geste est journalisé (membre.reinitialisation-emise, acteur et cible), et l'émission ne révoque rien : c'est la consommation qui remplace le mot de passe et révoque toutes les sessions du compte.
  • Ce qu'il reste d'assumé : la preuve d'identité est hors bande, humaine — l'administrateur reconnaît la personne qui lui demande un lien, et le lui transmet par un canal qu'il choisit (WhatsApp, SMS, de vive voix). Aucune vérification d'email n'existe pour la porter, et un lien transmis au mauvais destinataire donne le compte. C'est le même parti que pour l'invitation, sur un cercle où les gens se connaissent.

Qui voit qui

  • L'annuaire (GET /api/members) est cadré sur le périmètre du demandeur : lui-même, les membres des cercles qu'il partage, et ceux qu'il a invités tant qu'aucun équipement ne les réunit encore. Hors de ce périmètre, un membre n'apprend ni l'existence, ni le nom, ni l'email des autres.
  • Tout ce qui pend à un équipement (réservations, usage, dépenses, soldes, justificatifs, discussions, checklists, documents, contenu du lot) n'est lisible et modifiable que par les membres de son cercle. La règle est unique et vit dans la couche application (equipment-access.ts, receipt-access.ts) ; les tests d'intégration la vérifient route par route. Les vues transverses (liste des équipements, calendrier, alertes d'entretien, historique d'un membre) sont cadrées sur le périmètre du demandeur, et le cadrage descend jusqu'aux requêtes SQL.
  • Une notification est personnelle : seul son destinataire la lit et la marque lue. Celle d'un autre membre répond comme un identifiant inconnu.
  • Anti-énumération : hors du cercle, la ressource n'est pas refusée, elle est masquée — même code (404) et même message que si elle n'existait pas, y compris sur un justificatif dont on détiendrait le chemin. Une réponse ne permet donc pas de distinguer « cet identifiant n'existe pas » de « il existe mais pas pour vous ». La trace serveur (warn) conserve la vérité pour l'exploitant. Un geste réservé à l'auteur d'un fil, lui, répond 403 : la ressource est bien visible du cercle, seul le geste est refusé.
  • La connexion ne dit rien non plus : identifiant inconnu, invitation jamais consommée et mot de passe faux donnent le même message et le même temps de réponse (une dérivation scrypt leurre est faite quand il n'y a rien à vérifier).

Ce qui est protégé

  • Mots de passe : scrypt N = 2¹⁷ (recommandation OWASP), paramètres écrits dans le hachage, comparaison à temps constant. Durcir le coût plus tard n'invalide rien : chaque membre est re-haché silencieusement à sa connexion suivante.
  • Sessions : jetons de 32 octets aléatoires stockés hachés (SHA-256), cookie httpOnly + SameSite=Lax (+ Secure en production), 30 jours en expiration glissante. Un changement de mot de passe ou la consommation d'une invitation révoque toutes les sessions du membre, et en rouvre une seule pour l'auteur du geste.
  • Entrées : schéma JSON sur le corps, les paramètres et la querystring de chaque route ; objets fermés, longueurs bornées. Un chemin de justificatif n'est accepté que sous la forme exacte que produit le téléversement, ce qui interdit d'afficher une URL externe sous couvert de reçu.
  • Justificatifs : déposés avec la dépense qui les porte, en une seule requête — un fichier déposé seul survivrait au refus de la dépense sans que rien ne le nomme, donc hors de portée de la purge. Servis par une route applicative qui remonte à la dépense qui les porte, jamais mis en cache par le client (Cache-Control: private, no-store, NetworkOnly côté service worker), supprimés avec la dépense — du bucket et du volume, puisqu'après une bascule on ne sait plus lequel des deux les porte. La déconnexion vide les caches sharemate-* de l'appareil.
  • Bucket : il n'est jamais public. Un contenu se demande toujours par l'identifiant de la ressource applicative qui le porte — la dépense pour un justificatif, le document pour un fichier du dossier, le message pour une pièce jointe — jamais par la clé de l'objet, qui ne sort pas du serveur. L'API vérifie le cercle, puis redirige vers une URL signée de cinq minutes, jamais mise en cache (Cache-Control: private, no-store). Recopiée, elle expire ; un lien de bucket ouvert, lui, n'expire jamais. Le type MIME servi est déduit de l'extension acceptée et jamais celui annoncé par le client, et ni HTML, ni SVG, ni archive, ni exécutable n'entrent — servi depuis le domaine du bucket, un tel contenu s'y exécuterait.
  • Liens du dossier : seuls http: et https: sont acceptés. Un lien est cliquable par tout le cercle : javascript: y exécuterait du code dans la session de celui qui clique, et data: y afficherait une page fabriquée sous l'apparence de l'application.
  • Rate-limit par IP et par minute : 300 en global sur toute route, 10 sur les routes d'authentification publiques (force brute), 20 sur la création de compte et le téléversement. trustProxy est activé en production pour lire la vraie IP derrière le proxy Railway.
  • En-têtes : @fastify/helmet — CSP default-src 'self', frame-ancestors 'none', object-src 'none', nosniff, HSTS.
  • Logs : pino JSON en production ; cookie, set-cookie et authorization expurgés — mais pas l'URL, qui porte les codes d'invitation et de reprise (voir plus bas). Les changements de composition d'un cercle, les fusions et les liens de réinitialisation émis partent dans le journal du serveur, hors de portée des membres concernés.
  • Conteneur : image non-root (USER node), HEALTHCHECK intégré.
  • Chaîne d'appro : audit npm en CI (bloquant à partir de high), CodeQL hebdomadaire, Dependabot (npm, GitHub Actions, image Docker de base).

Ce qui n'est pas protégé — et pourquoi

  • Aucun rôle, aucun administrateur. Tous les membres d'un cercle ont exactement les mêmes pouvoirs : modifier l'équipement, en changer la composition, le supprimer avec tout son historique de dépenses et de soldes. Un membre peut en évincer un autre. Ce n'est pas empêché, c'est rendu visible : tout changement de composition notifie les entrants, les sortants et les témoins, et laisse une entrée dans le journal du serveur.
  • Tout membre peut peupler l'instance de nouveaux comptes. Cela ne lui ouvre aucun cercle existant, mais rien n'en borne le nombre au-delà du plafond par minute.
  • Pas de chiffrement au repos. La base SQLite est en clair sur le volume, les justificatifs et les documents le sont dans le bucket (ou sur le volume, à défaut) ; qui y a accès — ou à une sauvegarde — a accès à tout. Le contrôle d'accès est applicatif, pas cryptographique.
  • Un document appartient au cercle, pas à son déposant. N'importe quel membre peut supprimer le manuel ou l'attestation d'assurance qu'un autre a déposés, définitivement. C'est le même parti que pour les checklists et les équipements : entre membres d'un cercle, la confiance est totale et assumée. Ce qui est fait, en revanche, est visible — le nom du déposant reste affiché.
  • Pas de vérification d'email. L'adresse sert d'identifiant de connexion, elle n'est jamais confirmée : rien ne peut donc porter automatiquement la preuve d'identité d'une réinitialisation, qui repose sur l'administrateur et le canal par lequel il transmet le lien.
  • Les codes transmis hors bande sont en clair de bout en bout. Invitation comme réinitialisation, le code est stocké tel quel en base et voyage dans le chemin de l'URL (/api/auth/invites/:code, /api/auth/password-resets/:code) : le journal d'accès de Fastify écrit donc chaque ouverture de lien, code compris — seuls cookie, set-cookie et authorization sont expurgés. Les jetons de session, eux, ne sont stockés que hachés et ne quittent jamais le cookie. Conséquences : qui lit les logs, ou une sauvegarde SQLite prise pendant la fenêtre de validité (24 h pour une reprise, 7 jours pour une invitation), tient un lien directement utilisable. La nuance entre les deux codes compte — une invitation ouvre un compte vide, une reprise prend un compte en service —, mais le défaut est le même et se corrigera pour les deux à la fois : hacher le code au repos et le sortir du chemin d'URL (voir la feuille de route).
  • trustProxy fait confiance à toute la chaîne X-Forwarded-For. Si le service devient joignable autrement que par le proxy Railway, un client peut forger l'en-tête et contourner le plafond par IP. Tant que l'accès passe exclusivement par le proxy, le risque est nul.
  • Pas de quota de stockage par membre. Documents et pièces jointes sont bornés par équipement (500 Mo, partagés), mais un seul membre peut remplir ce quota et empêcher les autres de déposer quoi que ce soit. Les justificatifs de dépense, eux, ne sont bornés que par fichier (10 Mo) et par débit (20 téléversements par minute et par IP) : rien ne borne leur total.

Déploiement sur Railway

Le dépôt contient un Dockerfile multi-stage et un railway.json (healthcheck sur /api/health).

  1. Créer un projet Railway et le connecter à ce dépôt GitHub — le Dockerfile est détecté automatiquement.
  2. Ajouter un volume monté sur /data (Service → Settings → Volumes) : c'est là que vivent la base SQLite et les justificatifs. Sans volume, les données sont perdues à chaque déploiement. ⚠️ Les volumes Railway sont montés root alors que l'image tourne en node : définir la variable de service RAILWAY_RUN_UID=0 (doc Railway), sinon SQLite ne pourra pas écrire dans /data.
  3. Générer un domaine public (Settings → Networking). Railway injecte PORT automatiquement.

Le service est connecté au dépôt GitHub (BadRom1/ShareMate, branche main) : chaque push sur main déclenche automatiquement un déploiement. Un déploiement manuel ponctuel reste possible avec railway up depuis la racine.

Une fois l'instance en service, les gestes d'exploitation — ouvrir un shell, désigner l'administrateur, consulter la base, sauvegarder, restaurer — sont rassemblés dans le runbook. Les commandes y diffèrent de celles du dépôt : l'image de production ne contient ni les sources, ni tsx, ni le client sqlite3.

Migrations de schéma

Le schéma est versionné par PRAGMA user_version, en face d'une liste ordonnée de migrations (server/src/infrastructure/persistence/sqlite/database.ts). Au démarrage, seules les étapes manquantes sont appliquées, chacune dans sa propre transaction : la version n'avance que si l'étape a réussi entièrement, et une base déjà à jour ne rejoue rien.

Pour faire évoluer le schéma, ajouter une étape à la fin de MIGRATIONS — jamais modifier une étape existante, son rang est sa version. Chaque apply doit rester idempotent : les bases antérieures au versionnement valent 0 et rejouent la liste entière.

Un schéma reconnu comme incompatible (tables du modèle « collectif » abandonné, mur de messages plat antérieur aux fils) fait échouer le démarrage au lieu de supprimer les tables comme le faisaient les versions antérieures. Le message nomme la table en cause : c'est à l'opérateur de trancher, sauvegarde en main.

Désigner l'administrateur

Sur une base créée par une version antérieure au rôle d'administrateur, personne ne l'est : la migration n'attribue rien, faute de repère fiable — members.invited_by vaut NULL sur tous les membres d'alors, et deviner donnerait à un inconnu le pouvoir d'absorber n'importe quel compte. Tant que la désignation n'a pas eu lieu, la fusion de comptes n'est ouverte à personne, et rien d'autre ne change.

npm run admin:designate            # liste les comptes dans l'ordre de création, propose un candidat
npm run admin:designate -- <id>    # désigne ce compte (le rôle est retiré à tout autre)

Sans argument, le script n'écrit rien : il montre les comptes, marque celui qui est déjà administrateur, et signale le candidat le plus probable — le premier compte inséré qui porte un mot de passe. Le choix reste à l'opérateur. Un compte jamais ouvert est refusé : il ne pourrait pas se connecter pour exercer le rôle. Le changement est immédiat, sans redémarrage.

C'est aussi la seule façon de savoir qui est administrateur sans l'être soi-même : l'écran d'administration n'est visible que par son titulaire.

Sur l'instance en service, où la base vit sur le volume, le script s'appelle node server/dist/designate-admin.js — voir le runbook, qui donne les deux formes de chaque geste d'exploitation.

Sauvegarder le volume

La base et les justificatifs vivent sur le volume Railway, qu'aucune sauvegarde ne couvre par défaut. Avant toute migration de schéma, toute suppression manuelle de table et toute restauration, prendre une copie.

L'API de sauvegarde de SQLite est la seule façon correcte de copier une base ouverte : cp d'un fichier en mode WAL peut produire une copie incohérente (le WAL n'est pas repris). L'image de production ne contient pas le client sqlite3, mais better-sqlite3 y est :

# Depuis un shell sur le service (railway ssh) :
node -e "require('better-sqlite3')('/data/sharemate.sqlite').backup('/data/sauvegarde-'+new Date().toISOString().slice(0,10)+'.sqlite').then(r=>console.log(r.totalPages,'pages'))"

Le fichier produit est cohérent et autonome : il se rapatrie ensuite par n'importe quel moyen. Vérifier la copie avant de s'y fier — une sauvegarde jamais relue n'est pas une sauvegarde.

La procédure complète — vérification de la copie, restauration, fichiers -wal/-shm à écarter — est dans le runbook. Les justificatifs ($DATA_DIR/uploads) sont à sauvegarder séparément : la base n'en contient que les chemins.

CI

GitHub Actions :

  • ci.yml : lint → format → typecheck → tests avec couverture (seuils bloquants) → audit npm → build, plus un job de build de l'image Docker — à chaque push sur main et pull request.
  • codeql.yml : analyse statique de sécurité (push, PR, et chaque lundi).
  • Dependabot (.github/dependabot.yml) : mises à jour hebdomadaires groupées des dépendances npm, des actions GitHub et de l'image Docker de base.

Feuille de route

  1. PWA : manifest + service worker (Vite PWA), installable sur mobile. ✅ Fait — vite-plugin-pwa (autoUpdate), manifest + icônes générées depuis web/public/logo.svg (npm run generate-pwa-assets), shell préchargé (offline), API en NetworkFirst, justificatifs jamais mis en cache.
  2. Application mobile empaquetée : une encapsulation Capacitor (Android) a existé puis a été retirée, faute d'usage — la PWA installable couvre le besoin mobile aujourd'hui. À reprendre depuis cap add le jour où un magasin d'applications devient nécessaire.
  3. Authentification ✅ Faite, mais pas sous la forme prévue : ni magic link ni email, un lien de première connexion transmis hors application (voir Sécurité).
  4. Notifications de rappel d'entretien ✅ Faites — MAINTENANCE_ALERT, in-app et push, parmi cinq autres types.
  5. Réinitialisation de mot de passe ✅ Faite, mais pas sous la forme prévue : ni magic link ni email, un lien de reprise émis par l'administrateur et transmis hors application, comme l'est déjà un lien de première connexion (voir Sécurité). La preuve d'identité reste humaine ; c'est le seul geste d'un membre qui passe encore par quelqu'un d'autre.
  6. Multi-cercles assumé : le modèle le permet déjà (un membre, plusieurs équipements, des cercles disjoints) ; ce qui manque est l'interface — rien ne montre à un membre qu'il vit dans plusieurs cercles étanches.
  7. Codes hors bande hachés, et hors de l'URL : invitation et reprise partagent le même défaut — code en clair en base, et dans le chemin de l'URL, donc dans le journal d'accès (voir Sécurité). Le correctif vaut pour les deux à la fois : empreinte au repos comme pour les jetons de session, et code passé dans le corps de la requête plutôt que dans le chemin. Ce qui le rend prioritaire n'est pas l'invitation, qui n'ouvre qu'un compte vide, mais la reprise, qui prend un compte en service — une sauvegarde de la base prise pendant ses 24 h de validité livre un lien utilisable.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages