Added optionnal online storage and cross-client sync
All checks were successful
Tests et déploiement / validate (push) Successful in 2m44s
Tests et déploiement / deploy (push) Successful in 51s

This commit is contained in:
2026-10-10 16:01:06 +02:00
parent 277b9c887e
commit 5686ee5072
33 changed files with 2466 additions and 25 deletions

View File

@@ -9,3 +9,8 @@ dist
Dockerfile Dockerfile
docker-compose.yml docker-compose.yml
README.md README.md
sync-data
pardis-secours*.json
test-results
playwright-report

View File

@@ -16,3 +16,7 @@ LISTEN_IP=0.0.0.0
# Adresse/CIDR du proxy qui transmet le protocole PROXY à Caddy. # Adresse/CIDR du proxy qui transmet le protocole PROXY à Caddy.
# Remplacer cette adresse d’exemple par celle du proxy réellement utilisé. # Remplacer cette adresse d’exemple par celle du proxy réellement utilisé.
TRUST_PROXY=192.0.2.10/32 TRUST_PROXY=192.0.2.10/32
# Encrypted revision storage quota per vault (50 MiB), and total vault limit.
SYNC_MAX_BYTES=52428800
SYNC_MAX_VAULTS=10000

4
.gitignore vendored
View File

@@ -29,3 +29,7 @@ dist-ssr
# Browser test artifacts # Browser test artifacts
playwright-report/ playwright-report/
test-results/ test-results/
# Local synchronization service data and recovery files
sync-data/
pardis-secours*.json

128
PRODUCTION.md Normal file
View File

@@ -0,0 +1,128 @@
# Validation avant production — Pardis
## Vérifications exécutées dans cet environnement
- Dernière validation locale : 10 octobre 2026, avec `npm run validate` et la
suite Playwright utilisant le Chromium Headless Shell installé via
`PARDIS_CHROME_PATH`. Ces résultats ne remplacent pas une recette sur téléphones
réels ni le résultat du workflow CI du commit publié.
- 272 tests unitaires et IndexedDB simulée passent (15 fichiers).
- `npm run check` : aucune erreur ni avertissement.
- Build PWA complet : `sw.js`, Workbox, manifeste et précache générés.
- L’importeur de la version précédente, extrait du commit avant l’évolution du format, refuse réellement
un export v2 avec son message de mise à jour (sans importer les pauses comme travail).
- Cas couverts : 8h30 de travail / 30 minutes de pause, récapitulatif/copier et
suggestions sans pause, reprise continue et discontinue à 18h, reprise avec
dernier repas/pause, deux appels concurrents, aucune pause vide, refus de reprise
passée, annulation transactionnelle sur erreur, repas automatique pendant une
pause, clôture pendant une pause, ajout/correction/suppression sur journée close,
import v1 et aller-retour v2, préservation des intervalles vides, écriture de
réglages contre leur dernière valeur persistée et échec d’écriture.
Commande reproductible : `npm ci` puis `npm run validate`.
La CI Gitea exécute déjà tests, vérification Svelte et build avant déploiement.
## Parcours navigateur automatisés
Seize tests Playwright exécutent le build PWA réel dans Chrome, avec des profils
éphémères et des fixtures synthétiques à 320 px. Ils couvrent consultation et
correction, copie, statistiques, date et Retour après rechargement, focus et
Échap (y compris les panneaux imbriqués), annulation et double appui, deux onglets,
les reprises après repas/pause, export téléchargé puis vérifié, purge/restauration,
erreur IndexedDB puis Réessayer, erreur de réglage avec champ restauré, ajout
d’une pause dans un intervalle vide et rechargement hors ligne sous service worker.
L’écran **Données et sauvegardes** est vérifié depuis les réglages et le rappel,
avec Retour, rechargement, durée persistée et absence de débordement à 320 px.
Le bandeau de sauvegarde est aussi testé avec une police agrandie.
Les trois parcours d’erreur ajoutés couvrent :
- Échec d’écriture de la conservation : erreur visible, champ restauré,
base inchangée, valeur conservée après rechargement et nouvelle tentative réussie.
- Export JSON valide mais modifié : refus de vérification sans changement des
journées, postes, réglages ou statut de sauvegarde ; le fichier original peut
ensuite être vérifié.
- Échec de suppression des journées après écriture de la sauvegarde et suppression
des postes : annulation de toute la transaction, y compris restauration de la
sauvegarde précédente ; le dialogue reste ouvert et permet de réessayer.
Les captures MCP avaient été refusées par le contrôle d’autorisation. Les tests
Playwright ont été autorisés comme vérification automatisée isolée : aucune trace,
vidéo ou capture n’est produite, aucun contenu de page n’est transmis à MCP.
Commandes : `npx playwright install chromium` puis `npm run validate:all`.
Un navigateur installé peut être choisi avec `PARDIS_CHROME_PATH`.
La CI installe Chromium avec ses dépendances et lance les mêmes parcours.
`docker compose config --quiet` a été validé avec des variables d’exemple.
Le build et le lancement de l’image Docker n’ont pas été exécutés ici (accès au
socket Docker indisponible dans le bac à sable).
## Vérifications sur appareils réels à terminer avant publication
- [ ] Sur petit téléphone (320 px), parcourir Récap → Voir les détails → Corriger,
retourner au récapitulatif et vérifier date, copie et statistiques.
- [ ] Au clavier, vérifier les pages Détails, Automatismes, Jours non travaillés
et Données et sauvegardes, le focus initial, Tab et Maj+Tab dans chaque dialogue,
Échap et restitution au bouton d’origine. Inclure remplacement et purge.
- [ ] Vérifier Retour du navigateur et Retour de la PWA depuis les pages filles
des réglages et du récapitulatif.
- [ ] Annuler la reprise depuis chacun des deux boutons : aucun changement.
Double appui et deux onglets réels : un seul poste de reprise.
- [ ] Installer la PWA puis démarrer hors ligne, suspendre et rouvrir.
Tester une mise à jour sans interrompre une journée active.
- [ ] Exporter sur iOS/Safari et Android/Chrome, vérifier le fichier téléchargé,
restaurer par fusion puis remplacer sur un profil de test.
- [ ] Simuler IndexedDB inaccessible : erreur visible et Réessayer.
- [ ] Vérifier les libellés d’ajustement de l’import sur un historique endommagé.
- [ ] Vérifier les dates affichées avant purge et récupérer sa sauvegarde.
- [ ] Depuis les réglages puis le rappel, ouvrir Données et sauvegardes, modifier
la durée, recharger et vérifier sa persistance sans suppression de journées.
### Consigner la recette manuelle
Utiliser des profils et des données de test. Pour Android/Chrome et iOS/Safari,
prendre une version stable actuelle et consigner les versions exactes. Aucun de
ces contrôles n’a été exécuté sur appareil réel dans cet environnement.
| Environnement | Appareil et versions OS/navigateur | Date | Résultat |
| --- | --- | --- | --- |
| Android / Chrome, PWA installée | À renseigner | — | À effectuer |
| iOS / Safari, ajout à l’écran d’accueil | À renseigner | — | À effectuer |
| Chromium sur ordinateur, navigation au clavier | À renseigner | — | Recette manuelle à effectuer |
Pour chaque environnement, relever les résultats d’export/vérification/import
(fusion et remplacement), démarrage hors ligne, suspension/réouverture, Retour
et affichage étroit. Pour les dialogues de remplacement et de purge, relever
aussi le focus initial, Tab/Maj+Tab, Échap et la restitution du focus. Une mise
à jour doit être proposée sans interrompre une journée active. Ne cocher les
vérifications qu’après leur exécution ; consigner les anomalies avec le parcours
et les versions utilisées.
## Suivi de fiabilité et limites connues
- La purge au lancement est désactivée pour protéger les imports anciens.
La purge manuelle conserve une sauvegarde dans IndexedDB, récupérable depuis
**Données et sauvegardes**. Modifier la durée de conservation ne supprime rien.
Cette copie n’est pas une sauvegarde externe et disparaît si les
données du site sont effacées. Une seule sauvegarde avant purge est conservée.
- La date du dernier export **préparé** n’est pas une preuve que le
téléchargement a été conservé : le navigateur ne confirme pas cette étape
automatiquement. Une date distincte est marquée vérifiée après relecture du fichier
téléchargé et comparaison de son empreinte complète. Le rappel se déclenche
après sept jours sans export vérifié. La restauration par import et la
récupération après purge sont testées, mais conserver une copie externe reste
nécessaire.
- Les journées dont tous les postes sont automatiques sont signalées comme
déduites et à vérifier. Leur statut métier reste « terminée » ; une étape formelle
de validation et son intégration aux statistiques restent à définir. Cette
fonctionnalité reste au backlog ; la finalisation actuelle conserve les
règles de calcul existantes.
- Tester les navigateurs et téléphones réellement ciblés avec une matrice de
versions, notamment changement d’heure, minuit et suspension prolongée.
- Le nom IndexedDB `TimeLogDB` et l’identifiant `timelog-export` restent stables
pour préserver les données. Les noms visibles et les icônes utilisent Pardis.
- Avant changement de domaine : exporter sur l’ancien domaine, vérifier le fichier,
ouvrir le nouveau domaine, importer et comparer journées, postes et réglages.
Les données locales et le service worker ne migrent pas entre origines.
- La configuration Caddy conserve les règles de revalidation HTML/service worker
et de cache des fichiers hachés ; vérifier les en-têtes sur le domaine publié.

View File

@@ -4,8 +4,9 @@ Offline-first PWA for tracking hours on site. While the app is running, it can s
the day automatically and prompt for its end. Each task begins where the previous the day automatically and prompt for its end. Each task begins where the previous
one ended. Closed days have concise summaries ready to copy into a paper logbook. one ended. Closed days have concise summaries ready to copy into a paper logbook.
Built for use outdoors: high contrast, large targets, usable with gloves on. All data Built for use outdoors: high contrast, large targets, usable with gloves on. Data
stays on the device (IndexedDB); there is no backend and nothing is uploaded. is saved locally in IndexedDB. Optional end-to-end encrypted synchronization shares
it between authorized devices without e-mail or password registration.
## Development ## Development
@@ -27,7 +28,8 @@ Dans **Réglages → Gérer les données et sauvegardes**, l’écran **Données
sauvegardes** regroupe l’export, l’import, la vérification des sauvegardes et la sauvegardes** regroupe l’export, l’import, la vérification des sauvegardes et la
durée de conservation. Le rappel **Sauvegarder** ouvre aussi cet écran. durée de conservation. Le rappel **Sauvegarder** ouvre aussi cet écran.
Les données restent dans le navigateur sur cet appareil, sans synchronisation. Les données restent disponibles dans le navigateur sur cet appareil. La synchronisation
chiffrée peut être activée séparément pour les partager avec vos autres appareils.
Effacer les données du site ou perdre l’appareil peut faire perdre l’historique : Effacer les données du site ou perdre l’appareil peut faire perdre l’historique :
gardez une copie vérifiée du fichier exporté sur un autre support. gardez une copie vérifiée du fichier exporté sur un autre support.
@@ -213,7 +215,8 @@ modification sont refusés sans écriture partielle.
Ouvrez **Réglages → Consulter les statistiques**. Le bouton **Stats** de la barre Ouvrez **Réglages → Consulter les statistiques**. Le bouton **Stats** de la barre
du bas n'apparaît que sur cette page ; les autres écrans gardent leurs trois du bas n'apparaît que sur cette page ; les autres écrans gardent leurs trois
boutons habituels. La page fonctionne hors ligne et ne transmet aucune donnée. boutons habituels. La page fonctionne hors ligne. Les calculs de statistiques restent sur l’appareil ;
la synchronisation facultative peut partager leur historique source chiffré.
Les filtres couvrent tout l'historique, le mois, l'année, les 30 derniers jours Les filtres couvrent tout l'historique, le mois, l'année, les 30 derniers jours
ou une plage de dates inclusive. Les journées en cours sont exclues par défaut. ou une plage de dates inclusive. Les journées en cours sont exclues par défaut.
@@ -246,8 +249,10 @@ In dev the console exposes `timelog.seed()` to generate 20 days of realistic his
## Deployment ## Deployment
The stack is a single container: a multi-stage build compiles the app and Caddy serves The stack contains the static app served by Caddy and an optional-use synchronization
the static output, obtaining and renewing a Let's Encrypt certificate on its own. API backed by SQLite in a persistent volume. Caddy obtains and renews the HTTPS
certificate. Synchronization stays disabled in each client until explicitly activated.
See [SYNC.md](SYNC.md) for pairing, encryption, recovery, quotas and server backups.
```sh ```sh
cp .env.example .env cp .env.example .env
@@ -349,8 +354,8 @@ dans [PRODUCTION.md](PRODUCTION.md).
**Vérifier le fichier exporté…** relit le fichier téléchargé, contrôle qu’il est **Vérifier le fichier exporté…** relit le fichier téléchargé, contrôle qu’il est
importable et compare son contenu à l’export. La date de la dernière sauvegarde importable et compare son contenu à l’export. La date de la dernière sauvegarde
vérifiée est distincte de celle du dernier export préparé. Après sept jours sans vérifiée est distincte de celle du dernier export préparé. Après sept jours sans
export vérifié, Pardis affiche un rappel pendant son utilisation. Aucune donnée export vérifié, Pardis affiche un rappel pendant son utilisation. L’export reste local ; conservez le fichier hors de cet appareil. La synchronisation
n’est envoyée à un serveur ; conservez le fichier hors de cet appareil. facultative utilise un coffre distinct et ne remplace pas cette sauvegarde.
La purge affiche une liste figée de journées. Sauvegarde et suppression partagent La purge affiche une liste figée de journées. Sauvegarde et suppression partagent
une transaction ; une correction concurrente bloque la purge et demande une une transaction ; une correction concurrente bloque la purge et demande une
@@ -369,3 +374,21 @@ tests. Elle télécharge uniquement la variante utilisée par les tests sans int
et accorde 180 secondes à chaque connexion de téléchargement, avec les nouvelles et accorde 180 secondes à chaque connexion de téléchargement, avec les nouvelles
tentatives intégrées à Playwright. L’installation dispose de 20 minutes et le job tentatives intégrées à Playwright. L’installation dispose de 20 minutes et le job
de validation de 30 minutes. Une erreur d’installation bloque le déploiement. de validation de 30 minutes. Une erreur d’installation bloque le déploiement.
## Synchronisation facultative entre appareils
**Données et sauvegardes → Synchronisation et appareils** permet de créer un coffre
chiffré sans e-mail ni mot de passe, puis de lier des appareils avec un code temporaire
ou un QR code et une confirmation sur l’écran dédié. La saisie reste locale et
fonctionne hors ligne. Les journées concurrentes sont conservées pour résolution.
Un document de secours permet de retrouver l’accès après perte des appareils.
Les règles métier sont partagées ; le thème et le suivi des sauvegardes restent
locaux. Un appareil est responsable des automatismes, avec proposition de transfert
à l’ouverture sur un appareil secondaire. Les demandes d’association ne produisent
aucune alerte ailleurs dans l’application.
Le protocole, les limites de sécurité, le fonctionnement des suppressions et les
commandes de sauvegarde/restauration du serveur sont documentés dans [SYNC.md](SYNC.md).
Pour le développement, lancer aussi `npm run sync:server` avec Node.js 24.

219
SYNC.md Normal file
View File

@@ -0,0 +1,219 @@
# Synchronisation chiffrée
La synchronisation est facultative, désactivée après la migration de la base locale.
Sans activation, aucune requête n’est envoyée à l’API de synchronisation.
Une écriture de journée reste locale et transactionnelle, même sans réseau.
## Parcours
Dans **Données et sauvegardes → Synchronisation et appareils** :
- Préparer le coffre, télécharger `pardis-secours.json`, le conserver à part,
puis confirmer l’activation. Le document donne accès au coffre : il doit être
protégé comme un mot de passe de gestionnaire de secrets.
- Ajouter un appareil depuis ce même écran. L’invitation expire après cinq minutes,
n’accepte qu’un candidat et doit être renouvelée par l’écran visible toutes les
quelques secondes. Quitter ou masquer l’écran l’annule ; en cas de fermeture
brutale, le bail serveur expire sous 25 secondes.
- Saisir les huit caractères sur le nouvel appareil ou scanner le QR avec l’appareil
photo du téléphone. Le QR ouvre l’application web ; le navigateur/profil qui
reçoit ce lien doit être celui dans lequel on veut utiliser Pardis. Les copies
installées et les navigateurs peuvent avoir des stockages distincts, notamment
sur iOS ; le code manuel permet d’associer directement la bonne copie.
- Comparer **toute** l’empreinte de huit groupes hexadécimaux sur les deux écrans,
cocher leur égalité et confirmer sur l’appareil existant. Les demandes ne sont
ni surveillées ni affichées en dehors de l’écran dédié.
- Les données déjà présentes sur le nouvel appareil sont conservées. Les journées
distinctes sont réunies et les dates communes divergentes deviennent des conflits.
Les réglages déjà enregistrés peuvent eux aussi nécessiter un choix.
Les automatismes ont un responsable. Un autre appareil propose discrètement de
prendre le rôle ; ce transfert exige le réseau et un numéro de rôle encore actuel.
L’ancien responsable hors ligne ne peut pas apprendre immédiatement le transfert :
ses écritures restent conservées et peuvent produire un conflit à la reconnexion.
La saisie manuelle fonctionne sur chaque appareil. Les conflits suspendent les
transitions automatiques locales jusqu’à leur résolution.
La récupération avec le document de secours retire les anciens appareils, renouvelle
la clé de données et désigne l’appareil récupéré comme responsable. Elle fonctionne
aussi quand la limite d’appareils est atteinte. Les anciens appareils conservés
peuvent ensuite être associés à nouveau.
Mettre la synchronisation en pause conserve les clés et les changements à envoyer.
Déconnecter un appareil retire ses clés locales et propose séparément d’effacer ses
données. Pour couper aussi son autorisation serveur, le retirer depuis un autre
appareil. Supprimer le coffre supprime les données et les autorisations serveur ;
aucun mécanisme ne peut effacer les copies déjà téléchargées à distance.
## Modèle et garanties
La base Dexie passe de v2 à v3 sans conversion des journées. Les tables de
synchronisation sont distinctes de `settings`, donc un import en remplacement ne
peut pas effacer les clés. Un middleware écrit un marqueur durable dans la même
transaction IndexedDB que toute modification métier. Les données locales et leur
dernier instantané capturé permettent ensuite de matérialiser les révisions dans
la file d’envoi, sans fenêtre où une saisie validée serait perdue après un crash.
Chaque journée avec ses postes forme un objet indivisible. Chaque réglage métier
forme un objet ; thème et suivi des sauvegardes restent locaux. Les ID numériques
Dexie des postes ne traversent pas le réseau. Un identifiant HMAC masque la date ou
le nom du réglage au serveur. Les révisions forment un graphe de parents, indépendant
des horloges des appareils. Plusieurs têtes différentes déclenchent un conflit ;
la résolution crée une révision qui référence toutes les têtes. Des têtes au contenu
identique sont réunies sans demander un choix. Aucun tri par heure ne choisit un
« gagnant ». Les changements faits pendant une requête sont capturés avant
l’installation de sa réponse. Valeurs reçues, parents, signatures et cohérence des
journées actives sont vérifiés côté client ; l’API ne peut pas valider le texte chiffré.
La purge produit des tombstones : un ancien appareil ne peut pas ressusciter une
journée en renvoyant une ancienne révision. Un conflit suppression/modification
conserve les deux versions. **La purge retire les journées de l’historique courant,
mais les anciennes révisions chiffrées sont conservées jusqu’à la suppression du
coffre.** Cette première version ne compacte pas les révisions ; elles comptent
dans le quota de 50 Mio. Les sauvegardes de l’hébergeur ont une rétention distincte.
La sauvegarde locale avant purge et les exports indépendants restent disponibles.
Les exports JSON classiques sont en clair et ne contiennent pas les clés de sync.
La synchronisation fonctionne à l’ouverture, au retour au premier plan, au retour
du réseau, et toutes les 15 secondes tant que l’app est visible. La PWA fermée ou
suspendue n’offre aucune garantie d’exécution ni de synchronisation en arrière-plan.
Une requête échouée laisse les révisions en attente et n’empêche pas la saisie.
Web Locks sérialise les synchronisations entre onglets ; IDs idempotents et
transactions protègent aussi les nouvelles tentatives. Le curseur n’avance qu’avec
la validation et l’installation transactionnelle de la page reçue.
## Cryptographie et limites de confiance
- Données : AES-256-GCM via WebCrypto, IV aléatoire de 96 bits à chaque chiffrement,
en-tête de révision (coffre, objet, parents, époque et auteur) authentifié en AAD.
- Requêtes et révisions : ECDSA P-256/SHA-256. Chaque requête signe méthode, chemin
avec query, empreinte du corps, heure et nonce. Tolérance d’horloge de deux minutes,
nonces conservés quatre minutes en SQLite pour refuser les rejeux après redémarrage.
- Enveloppes de clés : HPKE RFC 9180 (`@hpke/core`), DHKEM P-256/HKDF-SHA-256 et
AES-256-GCM. Contexte lié au coffre et au destinataire. Le mode de base HPKE assure
la confidentialité ; l’authentification de l’association vient de la comparaison
de l’empreinte liant invitation et clés des deux participants. Le QR épingle en
plus l’identité de l’appareil parrain. Le code court ne dérive aucune clé.
- L’empreinte humaine a 128 bits. Elle n’est pas remplacée par un petit code décimal
qui, sans protocole d’engagement adapté, serait vulnérable à une recherche hors ligne.
- Chaque appareil a sa clé de signature et sa clé de réception. Le document de
secours possède une identité distincte et une clé privée de déchiffrement HPKE.
**Les appareils liés ne reçoivent que ses clés publiques.** La copie temporaire
du document préparé est supprimée de la base lors de la confirmation d’activation.
- La révocation renouvelle l’époque et la clé de données, enveloppe le trousseau
pour les appareils restants et le secours, et refuse les écritures d’une ancienne
époque. Un appareil encore autorisé peut reprendre ses changements hors ligne en
les chiffrant sous la nouvelle clé. Un appareil retiré garde ses données déjà lues.
S’il détenait aussi le document de secours externe, il faut renouveler ce document.
- Le renouvellement du secours n’affiche pas l’ancien secret : il produit un nouveau
document, à conserver avant confirmation, puis remplace atomiquement l’identité
de secours et les enveloppes serveur. L’ancien document ne permet plus de récupérer
le trousseau courant. Les anciennes copies chiffrées restent déchiffrables par qui
possédait déjà leurs clés.
Les noms des appareils sont aussi chiffrés. Le serveur connaît les IP, identifiants,
clés publiques, relations entre appareils/révisions, tailles et dates de connexion.
Le chiffrement ne masque pas ces métadonnées et ne garantit pas la disponibilité
contre un serveur qui supprime ou retient des messages. Le client détecte une
substitution de contenu et un retour à un trousseau déjà dépassé ; il ne garantit
pas la détection de toute omission ou divergence présentée à un nouveau client.
L’ouverture locale reste immédiate : données et clés sont accessibles au code de
l’origine et à un utilisateur du navigateur déverrouillé. Un navigateur compromis
ou du JavaScript malveillant servi par l’hébergeur peuvent lire les clés. HTTPS,
CSP et dépendances empaquetées limitent l’exposition sans supprimer cette limite
d’une application web. Pas de tiers chargé à l’exécution, pas d’analytique.
## API et exploitation
Node.js 24 minimum, SQLite embarquée, WAL et transactions. En développement :
```sh
npm run sync:server
# dans un autre terminal
npm run dev
```
Vite et son serveur de prévisualisation transmettent `/api/sync` au port local 3001.
`SYNC_DB` définit le chemin SQLite ; par défaut `./sync-data/pardis.sqlite` (ignoré
par Git et les images Docker). `SYNC_HOST` vaut `127.0.0.1` hors conteneur.
Le compose contient Caddy et le service `sync`, dont le port reste privé. Le volume
nommé `sync-data` est persistant. Caddy écrase `X-Pardis-Client-IP` avec l’adresse
client validée ; ne pas exposer directement l’API avec `SYNC_TRUST_PROXY=1`.
`SYNC_ORIGIN` protège l’origine des requêtes navigateur. HTTPS est nécessaire.
```sh
docker compose --env-file .env.example config --quiet
# Après configuration du domaine et des variables de déploiement :
docker compose up -d --build
```
API JSON versionnée dans les enveloppes (`v: 1`), authentifiée par
`X-Pardis-Proof` (JSON encodé base64url). Aucun cookie ou mot de passe utilisateur.
| Routes sous `/api/sync` | Usage |
| --- | --- |
| `POST /vaults` | Créer un coffre et son premier appareil |
| `GET /state` | Appareils, trousseau enveloppé et responsable |
| `POST /invitations`, `POST /join` | Invitation et candidature |
| `POST /invitations/:code/heartbeat`, `/approve` | Présence de l’écran et autorisation |
| `GET /invitations/:code/result`, `DELETE /invitations/:code` | Remise des clés ou annulation |
| `GET /revisions?after=…`, `POST /revisions` | Pages de 100 révisions et envoi idempotent |
| `POST /role`, `POST /revoke` | Transfert du rôle et révocation avec rotation |
| `GET /recovery`, `POST /recover`, `POST /recovery-reset` | Récupération et remplacement du secours |
| `DELETE /vault`, `GET /health` | Suppression du coffre et disponibilité |
Limites : 10 appareils actifs/coffre, 100 000 révisions/coffre, 50 Mio de révisions
par défaut (`SYNC_MAX_BYTES`), 10 000 coffres (`SYNC_MAX_VAULTS`), requêtes de 2 Mio
maximum et contenu chiffré d’une révision inférieur à 200 000 caractères. Un quota
atteint bloque les envois, conserve la saisie locale et expose une erreur explicite.
Les limites par minute sont 12 candidatures/IP, 5 créations/IP, 10 invitations/appareil,
600 requêtes/IP. Elles sont en mémoire et repartent de zéro au redémarrage ; les
nonces anti-rejeu sont persistants. Le service n’écrit pas de contenu métier ni de
secrets dans les logs. Le healthcheck contrôle l’HTTP ; superviser aussi l’espace
volume, la disponibilité et le succès des sauvegardes.
### Sauvegarde et restauration serveur
Sauvegarder avec l’API SQLite, qui inclut les transactions WAL ; ne pas copier
uniquement le fichier principal pendant que le serveur tourne.
```sh
docker compose exec sync node server/backup.js /data/backup.sqlite
docker compose cp sync:/data/backup.sqlite ./pardis-server-backup.sqlite
```
Déplacer la copie hors du serveur, sous contrôle d’accès. Définir explicitement la
rétention des sauvegardes chez l’hébergeur et expliquer que les suppressions logiques
peuvent y subsister pendant cette période. Un backup contient aussi les anciennes
autorisations et identités de secours ; le chiffrement n’annule pas ce fait.
Pour restaurer : arrêter `sync`, conserver une copie du volume courant, remplacer
la base avec une sauvegarde cohérente et retirer les anciens fichiers `-wal`/`-shm`
associés à la base remplacée, puis redémarrer. **Ne pas remettre en production une
sauvegarde ancienne sans contrôler les révocations/rotations intervenues depuis.**
Une restauration ancienne peut réactiver des autorisations retirées. Les clients
ayant vu de nouvelles clés refusent un trousseau plus ancien. Un retour arrière
nécessite une procédure de récupération coordonnée ; ce n’est pas une fusion
transparente. Tester régulièrement la restauration sur une instance isolée.
## Validation
`npm run validate` exécute les tests métier, cryptographiques et API, la vérification
Svelte et le build. `npm run test:sync` cible la synchronisation. L’API de test utilise
une base temporaire, jamais le volume du service. `npm run test:e2e` démarre aussi
un serveur de sync en mémoire pour les contextes navigateur isolés.
Les tests couvrent migration v2, journal et rollback, conflits, tombstones,
suppression concurrente, journées actives, données modifiées pendant une requête,
chiffrement falsifié, mauvais destinataire HPKE, anti-rejeu, séparation des coffres,
expiration/annulation, approbation exacte, reprise idempotente, récupération,
révocation, rotation et redémarrage SQLite. Les parcours navigateur couvrent les
empreintes sur deux appareils, l’absence d’alerte hors écran, les modifications
hors ligne, la résolution, le rôle, la révocation et la récupération.
La validation automatisée navigateur utilise Chromium. Vérifier aussi les parcours
sur de vrais appareils Safari/iOS et Android avant ouverture large du service ;
une revue cryptographique indépendante reste souhaitable avant de promettre une
protection contre un hébergeur activement malveillant.

View File

@@ -19,3 +19,25 @@ services:
volumes: volumes:
- ./caddy/data:/data - ./caddy/data:/data
- ./caddy/config:/config - ./caddy/config:/config
sync:
build:
context: .
dockerfile: docker/Sync.Dockerfile
restart: unless-stopped
environment:
SYNC_ORIGIN: https://${SITE_ADDRESS}
SYNC_TRUST_PROXY: "1"
SYNC_MAX_BYTES: ${SYNC_MAX_BYTES:-52428800}
SYNC_MAX_VAULTS: ${SYNC_MAX_VAULTS:-10000}
volumes:
- sync-data:/data
expose:
- "3001"
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
volumes:
sync-data:

View File

@@ -31,6 +31,12 @@ http://{$SITE_ADDRESS} {
} }
{$SITE_ADDRESS} { {$SITE_ADDRESS} {
handle /api/sync/* {
reverse_proxy sync:3001 {
header_up X-Pardis-Client-IP {client_ip}
}
}
root * /srv root * /srv
encode zstd gzip encode zstd gzip
@@ -55,6 +61,7 @@ http://{$SITE_ADDRESS} {
header @manifest Content-Type "application/manifest+json" header @manifest Content-Type "application/manifest+json"
header { header {
Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'"
X-Content-Type-Options nosniff X-Content-Type-Options nosniff
X-Frame-Options DENY X-Frame-Options DENY
Referrer-Policy strict-origin-when-cross-origin Referrer-Policy strict-origin-when-cross-origin
@@ -62,6 +69,8 @@ http://{$SITE_ADDRESS} {
} }
# The app routes on the hash, so this only catches someone typing a stray path. # The app routes on the hash, so this only catches someone typing a stray path.
handle {
try_files {path} /index.html try_files {path} /index.html
file_server file_server
} }
}

13
docker/Sync.Dockerfile Normal file
View File

@@ -0,0 +1,13 @@
FROM node:24-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY server ./server
COPY src/lib/sync/crypto.js src/lib/sync/model.js ./src/lib/sync/
COPY src/lib/backup.js src/lib/time.js src/lib/automation-options.js ./src/lib/
RUN mkdir /data && chown node:node /data
USER node
ENV PORT=3001 SYNC_HOST=0.0.0.0 SYNC_DB=/data/pardis.sqlite
EXPOSE 3001
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 CMD node -e "fetch('http://127.0.0.1:3001/api/sync/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"
CMD ["node", "server/index.js"]

342
package-lock.json generated
View File

@@ -8,11 +8,14 @@
"name": "pardis", "name": "pardis",
"version": "0.8.0", "version": "0.8.0",
"dependencies": { "dependencies": {
"dexie": "^4.4.4" "@hpke/core": "^1.9.0",
"dexie": "^4.4.4",
"qrcode": "^1.5.4"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.64.0", "@playwright/test": "^1.64.0",
"@sveltejs/vite-plugin-svelte": "^7.1.2", "@sveltejs/vite-plugin-svelte": "^7.1.2",
"@types/qrcode": "^1.5.6",
"@vite-pwa/assets-generator": "^1.0.2", "@vite-pwa/assets-generator": "^1.0.2",
"fake-indexeddb": "^6.2.5", "fake-indexeddb": "^6.2.5",
"svelte": "^5.56.4", "svelte": "^5.56.4",
@@ -1649,6 +1652,27 @@
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
}, },
"node_modules/@hpke/common": {
"version": "1.10.1",
"resolved": "https://registry.npmjs.org/@hpke/common/-/common-1.10.1.tgz",
"integrity": "sha512-moJwhmtLtuxiUzzNp1jpfBfx8yefKoO9D/RCR9dmwrnc7qjJqId1rEtQz+lSlU5cabX8daToMSx/7HayXOiaFw==",
"license": "MIT",
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/@hpke/core": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@hpke/core/-/core-1.9.0.tgz",
"integrity": "sha512-pFxWl1nNJeQCSUFs7+GAblHvXBCjn9EPN65vdKlYQil2aURaRxfGMO6vBKGqm1YHTKwiAxJQNEI70PbSowMP9Q==",
"license": "MIT",
"dependencies": {
"@hpke/common": "^1.10.0"
},
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/@img/sharp-darwin-arm64": { "node_modules/@img/sharp-darwin-arm64": {
"version": "0.33.5", "version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz",
@@ -2996,6 +3020,26 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/node": {
"version": "26.6.5",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.5.tgz",
"integrity": "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~8.9.0"
}
},
"node_modules/@types/qrcode": {
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/resolve": { "node_modules/@types/resolve": {
"version": "1.20.2", "version": "1.20.2",
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz",
@@ -3102,6 +3146,30 @@
"url": "https://github.com/sponsors/epoberezkin" "url": "https://github.com/sponsors/epoberezkin"
} }
}, },
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/aria-query": { "node_modules/aria-query": {
"version": "5.3.1", "version": "5.3.1",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz",
@@ -3403,6 +3471,15 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/caniuse-lite": { "node_modules/caniuse-lite": {
"version": "1.0.30001810", "version": "1.0.30001810",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz",
@@ -3450,6 +3527,17 @@
"url": "https://paulmillr.com/funding/" "url": "https://paulmillr.com/funding/"
} }
}, },
"node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/clsx": { "node_modules/clsx": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@@ -3478,7 +3566,6 @@
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"color-name": "~1.1.4" "color-name": "~1.1.4"
@@ -3491,7 +3578,6 @@
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/color-string": { "node_modules/color-string": {
@@ -3660,6 +3746,15 @@
} }
} }
}, },
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/decode-bmp": { "node_modules/decode-bmp": {
"version": "0.2.1", "version": "0.2.1",
"resolved": "https://registry.npmjs.org/decode-bmp/-/decode-bmp-0.2.1.tgz", "resolved": "https://registry.npmjs.org/decode-bmp/-/decode-bmp-0.2.1.tgz",
@@ -3765,6 +3860,12 @@
"integrity": "sha512-wWCHdihT3dmlUSuNhn5mMZDWSpG0suxjAni7YjjiZdzabZcKmy3uNZGZ7AeYYXBoLbpSXX35fikPLkPC44Osiw==", "integrity": "sha512-wWCHdihT3dmlUSuNhn5mMZDWSpG0suxjAni7YjjiZdzabZcKmy3uNZGZ7AeYYXBoLbpSXX35fikPLkPC44Osiw==",
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT"
},
"node_modules/dunder-proto": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -3803,6 +3904,12 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/es-abstract": { "node_modules/es-abstract": {
"version": "1.24.2", "version": "1.24.2",
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz",
@@ -4145,6 +4252,19 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/for-each": { "node_modules/for-each": {
"version": "0.3.5", "version": "0.3.5",
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
@@ -4273,6 +4393,15 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/get-intrinsic": { "node_modules/get-intrinsic": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
@@ -4686,6 +4815,15 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/is-generator-function": { "node_modules/is-generator-function": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz",
@@ -5343,6 +5481,18 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/lodash.debounce": { "node_modules/lodash.debounce": {
"version": "4.0.8", "version": "4.0.8",
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
@@ -5529,6 +5679,42 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/package-json-from-dist": { "node_modules/package-json-from-dist": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
@@ -5536,6 +5722,15 @@
"dev": true, "dev": true,
"license": "BlueOak-1.0.0" "license": "BlueOak-1.0.0"
}, },
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/path-key": { "node_modules/path-key": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
@@ -5629,6 +5824,15 @@
"node": ">=20" "node": ">=20"
} }
}, },
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"license": "MIT",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/possible-typed-array-names": { "node_modules/possible-typed-array-names": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
@@ -5681,6 +5885,23 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/quansync": { "node_modules/quansync": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/quansync/-/quansync-1.0.0.tgz", "resolved": "https://registry.npmjs.org/quansync/-/quansync-1.0.0.tgz",
@@ -5814,6 +6035,15 @@
"regjsparser": "bin/parser" "regjsparser": "bin/parser"
} }
}, },
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/require-from-string": { "node_modules/require-from-string": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@@ -5824,6 +6054,12 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
"node_modules/resolve": { "node_modules/resolve": {
"version": "1.22.12", "version": "1.22.12",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
@@ -6017,6 +6253,12 @@
"node": ">=20.0.0" "node": ">=20.0.0"
} }
}, },
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
"node_modules/set-function-length": { "node_modules/set-function-length": {
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
@@ -6326,6 +6568,20 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/string.prototype.matchall": { "node_modules/string.prototype.matchall": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.1.0.tgz", "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.1.0.tgz",
@@ -6429,6 +6685,18 @@
"node": ">=4" "node": ">=4"
} }
}, },
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-comments": { "node_modules/strip-comments": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/strip-comments/-/strip-comments-2.0.1.tgz", "resolved": "https://registry.npmjs.org/strip-comments/-/strip-comments-2.0.1.tgz",
@@ -6770,6 +7038,13 @@
"url": "https://github.com/sponsors/antfu" "url": "https://github.com/sponsors/antfu"
} }
}, },
"node_modules/undici-types": {
"version": "8.9.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz",
"integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==",
"dev": true,
"license": "MIT"
},
"node_modules/unicode-canonical-property-names-ecmascript": { "node_modules/unicode-canonical-property-names-ecmascript": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz",
@@ -7174,6 +7449,12 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC"
},
"node_modules/which-typed-array": { "node_modules/which-typed-array": {
"version": "1.1.22", "version": "1.1.22",
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz",
@@ -7433,6 +7714,26 @@
"workbox-core": "7.4.1" "workbox-core": "7.4.1"
} }
}, },
"node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC"
},
"node_modules/yallist": { "node_modules/yallist": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
@@ -7440,6 +7741,41 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/zimmerframe": { "node_modules/zimmerframe": {
"version": "1.1.5", "version": "1.1.5",
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.5.tgz", "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.5.tgz",

View File

@@ -12,11 +12,14 @@
"test:watch": "vitest", "test:watch": "vitest",
"check": "svelte-check --tsconfig ./jsconfig.json", "check": "svelte-check --tsconfig ./jsconfig.json",
"test:e2e": "playwright test", "test:e2e": "playwright test",
"validate:all": "npm run validate && npm run test:e2e" "validate:all": "npm run validate && npm run test:e2e",
"sync:server": "node server/index.js",
"test:sync": "vitest run src/lib/sync server"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.64.0", "@playwright/test": "^1.64.0",
"@sveltejs/vite-plugin-svelte": "^7.1.2", "@sveltejs/vite-plugin-svelte": "^7.1.2",
"@types/qrcode": "^1.5.6",
"@vite-pwa/assets-generator": "^1.0.2", "@vite-pwa/assets-generator": "^1.0.2",
"fake-indexeddb": "^6.2.5", "fake-indexeddb": "^6.2.5",
"svelte": "^5.56.4", "svelte": "^5.56.4",
@@ -26,6 +29,8 @@
"vitest": "^5.0.0" "vitest": "^5.0.0"
}, },
"dependencies": { "dependencies": {
"dexie": "^4.4.4" "@hpke/core": "^1.9.0",
"dexie": "^4.4.4",
"qrcode": "^1.5.4"
} }
} }

View File

@@ -14,9 +14,13 @@ export default defineConfig({
trace: 'off', screenshot: 'off', video: 'off', trace: 'off', screenshot: 'off', video: 'off',
launchOptions: process.env.PARDIS_CHROME_PATH ? { executablePath: process.env.PARDIS_CHROME_PATH } : {}, launchOptions: process.env.PARDIS_CHROME_PATH ? { executablePath: process.env.PARDIS_CHROME_PATH } : {},
}, },
webServer: { webServer: [{
command: 'npm run preview -- --host 127.0.0.1 --port 4183 --strictPort', command: 'npm run preview -- --host 127.0.0.1 --port 4183 --strictPort',
url: 'http://127.0.0.1:4183', url: 'http://127.0.0.1:4183',
reuseExistingServer: false, reuseExistingServer: false,
}, }, {
command: 'node tests/sync-server.js',
url: 'http://127.0.0.1:3001/api/sync/health',
reuseExistingServer: false,
}],
}); });

256
server/app.js Normal file
View File

@@ -0,0 +1,256 @@
import { createServer } from 'node:http';
import { DatabaseSync } from 'node:sqlite';
import { randomBytes } from 'node:crypto';
import { canonical, digest, verify } from '../src/lib/sync/crypto.js';
import { revisionHeader } from '../src/lib/sync/model.js';
const PREFIX = '/api/sync';
const parse = value => JSON.parse(value);
const json = value => JSON.stringify(value);
const fail = (status, message, code = undefined) => { throw Object.assign(new Error(message), { status, code }); };
const uuid = value => typeof value === 'string' && /^[a-f\d-]{36}$/.test(value);
const envelope = value => value && typeof value.ciphertext === 'string' && value.ciphertext.length < 200_000 && (typeof value.iv === 'string' || typeof value.enc === 'string');
const publicDevice = d => d && uuid(d.id) && d.publicKey?.kty === 'EC' && d.publicKey.crv === 'P-256' && !d.publicKey.d && typeof d.wrapPublic === 'string' && d.wrapPublic.length < 200;
export function createSyncServer({ filename = ':memory:', origin = '', maxBytes = 50 * 1024 * 1024, maxVaults = 10000, trustProxy = false, now = Date.now } = {}) {
const db = new DatabaseSync(filename);
db.exec(`PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON; PRAGMA busy_timeout=5000;
CREATE TABLE IF NOT EXISTS vaults (id TEXT PRIMARY KEY, recovery_pub TEXT NOT NULL, recovery_envelope TEXT NOT NULL, epoch TEXT NOT NULL, owner TEXT NOT NULL, role_version INTEGER NOT NULL DEFAULT 1);
CREATE TABLE IF NOT EXISTS devices (id TEXT PRIMARY KEY, vault TEXT NOT NULL REFERENCES vaults(id) ON DELETE CASCADE, pub TEXT NOT NULL, wrap TEXT NOT NULL, label TEXT NOT NULL, envelope TEXT NOT NULL, revoked INTEGER NOT NULL DEFAULT 0, seen INTEGER NOT NULL);
CREATE TABLE IF NOT EXISTS revisions (seq INTEGER PRIMARY KEY AUTOINCREMENT, vault TEXT NOT NULL REFERENCES vaults(id) ON DELETE CASCADE, id TEXT NOT NULL, object TEXT NOT NULL, data TEXT NOT NULL, size INTEGER NOT NULL, UNIQUE(vault,id));
CREATE INDEX IF NOT EXISTS revisions_vault ON revisions(vault,seq);
CREATE TABLE IF NOT EXISTS invitations (code TEXT PRIMARY KEY, vault TEXT NOT NULL REFERENCES vaults(id) ON DELETE CASCADE, sponsor TEXT NOT NULL REFERENCES devices(id) ON DELETE CASCADE, expires INTEGER NOT NULL, lease INTEGER NOT NULL, applicant TEXT, approved INTEGER NOT NULL DEFAULT 0);
CREATE TABLE IF NOT EXISTS nonces (id TEXT PRIMARY KEY, expires INTEGER NOT NULL);
CREATE INDEX IF NOT EXISTS nonces_expiration ON nonces(expires);
`);
const get = (sql, ...args) => db.prepare(sql).get(...args);
const all = (sql, ...args) => db.prepare(sql).all(...args);
const run = (sql, ...args) => db.prepare(sql).run(...args);
const atomic = action => { db.exec('BEGIN IMMEDIATE'); try { const result = action(); db.exec('COMMIT'); return result; } catch (error) { db.exec('ROLLBACK'); throw error; } };
const limits = new Map();
function limit(key, count) {
const time = now();
if (limits.size > 10000) for (const [k, v] of limits) if (v.end < time) limits.delete(k);
const state = limits.get(key) ?? { count: 0, end: time + 60_000 };
if (time > state.end) { state.count = 0; state.end = time + 60_000; }
state.count++; limits.set(key, state);
if (state.count > count || limits.size > 20000) fail(429, 'Trop de tentatives. Réessayez dans une minute.');
}
function liveInvite(code, approved = false) {
const invite = get('SELECT * FROM invitations WHERE code=?', code);
if (!invite || invite.expires < now() || (!invite.approved && invite.lease < now()) || (!approved && invite.approved)) fail(410, 'Invitation expirée ou fermée.');
return invite;
}
function addDevice(vault, device, label, wrapped) {
if (!publicDevice(device) || !envelope(label) || !envelope(wrapped)) fail(400, 'Appareil invalide.');
const existing = get('SELECT * FROM devices WHERE id=?', device.id);
if (existing) {
if (existing.vault === vault && !existing.revoked && canonical(parse(existing.pub)) === canonical(device.publicKey) && existing.wrap === device.wrapPublic) return;
fail(409, 'Identifiant déjà utilisé.');
}
if (get('SELECT count(*) n FROM devices WHERE vault=? AND revoked=0', vault).n >= 10) fail(409, 'Dix appareils maximum par coffre.');
run('INSERT INTO devices(id,vault,pub,wrap,label,envelope,seen) VALUES(?,?,?,?,?,?,?)', device.id, vault, json(device.publicKey), device.wrapPublic, json(label), json(wrapped), now());
}
async function authenticate(req, path, raw, body) {
let auth;
try { auth = parse(Buffer.from(String(req.headers['x-pardis-proof'] ?? ''), 'base64url').toString()); } catch { fail(401, 'Authentification requise.'); }
const { proof, signature } = auth ?? {};
if (!proof || typeof proof.who !== 'string' || !uuid(proof.nonce) || !Number.isSafeInteger(proof.time) || Math.abs(now() - proof.time) > 120_000 || proof.method !== req.method || proof.path !== path || proof.hash !== await digest(raw)) fail(401, 'Requête expirée ou invalide. Vérifiez l’horloge de cet appareil.');
let pub, device, vault;
if (path === `${PREFIX}/vaults` && req.method === 'POST') {
if (!publicDevice(body.device) || proof.who !== body.device.id) fail(400, 'Identité invalide.');
pub = body.device.publicKey;
} else if (proof.who.startsWith('recovery:')) {
vault = get('SELECT * FROM vaults WHERE id=?', proof.who.slice(9));
if (!['/recovery', '/recover'].some(p => path === PREFIX + p) || !vault) fail(401, 'Secours invalide.');
pub = parse(vault.recovery_pub);
} else if (proof.who.startsWith('pair:')) {
const code = proof.who.slice(5);
const invite = liveInvite(code, true);
if (path !== `${PREFIX}/invitations/${code}/result` || !invite.applicant) fail(401, 'Association invalide.');
pub = parse(invite.applicant).publicKey;
} else {
device = get('SELECT * FROM devices WHERE id=? AND revoked=0', proof.who);
if (!device) fail(403, 'Cet appareil n’est plus autorisé.', 'DEVICE_REVOKED');
vault = get('SELECT * FROM vaults WHERE id=?', device.vault);
pub = parse(device.pub);
}
if (!await verify(pub, proof, signature)) fail(401, 'Signature invalide.');
run('DELETE FROM nonces WHERE expires<?', now());
if (get('SELECT id FROM nonces WHERE id=?', proof.nonce)) fail(409, 'Requête déjà utilisée.');
run('INSERT INTO nonces VALUES(?,?)', proof.nonce, now() + 240_000);
if (device) {
device = get('SELECT * FROM devices WHERE id=? AND revoked=0', device.id);
if (!device) fail(403, 'Cet appareil n’est plus autorisé.', 'DEVICE_REVOKED');
vault = get('SELECT * FROM vaults WHERE id=?', device.vault);
run('UPDATE devices SET seen=? WHERE id=?', now(), device.id);
} else if (vault) {
const fresh = get('SELECT * FROM vaults WHERE id=?', vault.id);
if (!fresh || fresh.recovery_pub !== vault.recovery_pub) fail(401, 'Secret de secours remplacé.');
vault = fresh;
}
return { device, vault };
}
async function route(req, url, raw, body, ip) {
const path = url.pathname;
if (path === `${PREFIX}/health` && req.method === 'GET') return { ok: true };
if (path === `${PREFIX}/join` && req.method === 'POST') {
limit(`join:${ip}`, 12);
if (!publicDevice(body.device)) fail(400, 'Identité invalide.');
const invite = liveInvite(String(body.code));
const sponsor = get('SELECT * FROM devices WHERE id=? AND revoked=0', invite.sponsor);
if (!sponsor) fail(410, 'Invitation fermée.');
if (invite.applicant && canonical(parse(invite.applicant)) !== canonical(body.device)) fail(409, 'Une demande est déjà en cours.');
run('UPDATE invitations SET applicant=? WHERE code=?', json(body.device), invite.code);
return { code: invite.code, expires: invite.expires, sponsor: { id: sponsor.id, publicKey: parse(sponsor.pub), wrapPublic: sponsor.wrap }, vault: invite.vault };
}
const { device, vault } = await authenticate(req, path + url.search, raw, body);
if (path === `${PREFIX}/vaults` && req.method === 'POST') {
limit(`create:${ip}`, 5);
if (!uuid(body.id) || !uuid(body.epoch) || !body.recoveryPublic || body.recoveryPublic.d || body.recoveryPublic.kty !== 'EC' || !envelope(body.recoveryEnvelope)) fail(400, 'Coffre invalide.');
const existing = get('SELECT * FROM vaults WHERE id=?', body.id);
if (existing) {
const member = get('SELECT * FROM devices WHERE id=? AND vault=? AND revoked=0', body.device.id, body.id);
if (member && canonical(parse(member.pub)) === canonical(body.device.publicKey)) return { id: body.id };
fail(409, 'Coffre déjà existant.');
}
if (get('SELECT count(*) n FROM vaults').n >= maxVaults) fail(507, 'Capacité du service atteinte.');
atomic(() => {
run('INSERT INTO vaults(id,recovery_pub,recovery_envelope,epoch,owner) VALUES(?,?,?,?,?)', body.id, json(body.recoveryPublic), json(body.recoveryEnvelope), body.epoch, body.device.id);
addDevice(body.id, body.device, body.label, body.envelope);
});
return { id: body.id };
}
if (path === `${PREFIX}/recovery` && req.method === 'GET') return { id: vault.id, epoch: vault.epoch, envelope: parse(vault.recovery_envelope) };
if (path === `${PREFIX}/recover` && req.method === 'POST') {
const existing = publicDevice(body.device) && get('SELECT * FROM devices WHERE id=? AND vault=? AND revoked=0', body.device.id, vault.id);
if (existing && canonical(parse(existing.pub)) === canonical(body.device.publicKey)) return { ok: true };
if (body.expectedEpoch !== vault.epoch || !uuid(body.epoch) || body.epoch === vault.epoch || !envelope(body.recoveryEnvelope)) fail(409, 'Les clés ont changé. Recommencez la récupération.');
// Possession of the recovery document restores access even at the device
// limit. Lost devices lose authorization and cannot read future changes.
atomic(() => {
run('UPDATE devices SET revoked=1 WHERE vault=?', vault.id);
run('DELETE FROM invitations WHERE vault=?', vault.id);
addDevice(vault.id, body.device, body.label, body.envelope);
run('UPDATE vaults SET epoch=?, recovery_envelope=?, owner=?, role_version=role_version+1 WHERE id=?', body.epoch, json(body.recoveryEnvelope), body.device.id, vault.id);
});
return { ok: true };
}
if (path.endsWith('/result') && req.method === 'GET') {
const invite = liveInvite(path.split('/').at(-2), true);
if (!invite.approved) return { waiting: true };
const applicant = parse(invite.applicant);
const added = get('SELECT * FROM devices WHERE id=? AND revoked=0', applicant.id);
if (!added) fail(410, 'Appareil retiré.');
return { vault: invite.vault, envelope: parse(added.envelope) };
}
if (!device || !vault) fail(403, 'Action non autorisée.');
if (path === `${PREFIX}/state` && req.method === 'GET') return {
vault: vault.id, epoch: vault.epoch, owner: vault.owner, roleVersion: vault.role_version,
envelope: parse(device.envelope), devices: all('SELECT * FROM devices WHERE vault=?', vault.id).map(d => ({ id: d.id, publicKey: parse(d.pub), wrapPublic: d.wrap, label: parse(d.label), revoked: !!d.revoked, seen: d.seen })),
};
if (path === `${PREFIX}/role` && req.method === 'POST') {
if (body.expected !== vault.role_version) fail(409, 'Le responsable a changé. Actualisez avant de réessayer.');
run('UPDATE vaults SET owner=?, role_version=role_version+1 WHERE id=?', device.id, vault.id);
return { owner: device.id, roleVersion: vault.role_version + 1 };
}
if (path === `${PREFIX}/revisions` && req.method === 'GET') {
const after = Number(url.searchParams.get('after') ?? 0);
if (!Number.isSafeInteger(after) || after < 0) fail(400, 'Curseur invalide.');
const records = all('SELECT seq,data FROM revisions WHERE vault=? AND seq>? ORDER BY seq LIMIT 100', vault.id, after);
return { records: records.map(r => ({ seq: r.seq, ...parse(r.data) })), more: records.length === 100 };
}
if (path === `${PREFIX}/revisions` && req.method === 'POST') {
const v = body;
if (v.v !== 1 || v.vault !== vault.id || v.device !== device.id || !uuid(v.id) || typeof v.object !== 'string' || !/^[\w-]{43}$/.test(v.object) || !Array.isArray(v.parents) || v.parents.length > 100 || !v.parents.every(uuid) || new Set(v.parents).size !== v.parents.length || !envelope(v)) fail(400, 'Révision invalide.');
const signed = { ...revisionHeader(v), iv: v.iv, ciphertext: v.ciphertext };
if (!await verify(parse(device.pub), signed, v.signature)) fail(401, 'Révision non signée.');
const existing = get('SELECT * FROM revisions WHERE vault=? AND id=?', vault.id, v.id);
if (existing) {
const old = parse(existing.data);
if (old.device !== device.id || old.object !== v.object || canonical(old.parents) !== canonical(v.parents)) fail(409, 'Identifiant déjà utilisé.');
return { seq: existing.seq };
}
if (!get('SELECT id FROM devices WHERE id=? AND revoked=0', device.id)) fail(403, 'Appareil révoqué.', 'DEVICE_REVOKED');
if (v.epoch !== get('SELECT epoch FROM vaults WHERE id=?', vault.id)?.epoch) fail(409, 'Les clés ont changé. Relancez la synchronisation.');
for (const parent of v.parents) if (!get('SELECT id FROM revisions WHERE vault=? AND id=? AND object=?', vault.id, parent, v.object)) fail(409, 'Révision parente manquante.');
const total = get('SELECT coalesce(sum(size),0) bytes, count(*) n FROM revisions WHERE vault=?', vault.id);
if (total.bytes + raw.length > maxBytes || total.n >= 100000) fail(507, 'Quota du coffre atteint. Exportez vos données et contactez l’hébergeur.');
const stored = { ...signed, signature: v.signature };
const result = run('INSERT INTO revisions(vault,id,object,data,size) VALUES(?,?,?,?,?)', vault.id, v.id, v.object, json(stored), Buffer.byteLength(raw));
return { seq: Number(result.lastInsertRowid) };
}
if (path === `${PREFIX}/invitations` && req.method === 'POST') {
limit(`invite:${device.id}`, 10);
run('DELETE FROM invitations WHERE expires<? OR sponsor=?', now(), device.id);
const alphabet = '23456789ABCDEFGHJKLMNPQRSTUVWXYZ';
let code;
do { code = Array.from(randomBytes(8), n => alphabet[n % alphabet.length]).join(''); } while (get('SELECT code FROM invitations WHERE code=?', code));
run('INSERT INTO invitations(code,vault,sponsor,expires,lease) VALUES(?,?,?,?,?)', code, vault.id, device.id, now() + 300000, now() + 25000);
return { code, expires: now() + 300000, sponsor: { id: device.id, publicKey: parse(device.pub), wrapPublic: device.wrap } };
}
const match = path.match(/^\/api\/sync\/invitations\/([A-Z2-9]{8})(?:\/(approve|heartbeat))?$/);
if (match) {
const invite = liveInvite(match[1], true);
if (invite.sponsor !== device.id) fail(403, 'Invitation d’un autre appareil.');
if (req.method === 'DELETE') { run('DELETE FROM invitations WHERE code=?', invite.code); return { ok: true }; }
if (match[2] === 'heartbeat' && req.method === 'POST') {
if (!invite.approved) run('UPDATE invitations SET lease=? WHERE code=?', now() + 25000, invite.code);
return { applicant: invite.applicant ? parse(invite.applicant) : null, approved: !!invite.approved };
}
if (match[2] === 'approve' && req.method === 'POST') {
if (invite.approved) return { ok: true };
if (!invite.applicant || body.epoch !== vault.epoch || canonical(body.device) !== canonical(parse(invite.applicant))) fail(409, 'Demande modifiée. Recommencez l’association.');
atomic(() => { addDevice(vault.id, body.device, body.label, body.envelope); run('UPDATE invitations SET approved=1 WHERE code=?', invite.code); });
return { ok: true };
}
}
if ([`${PREFIX}/revoke`, `${PREFIX}/recovery-reset`].includes(path) && req.method === 'POST') {
if (body.target === device.id) fail(400, 'Utilisez un autre appareil pour révoquer celui-ci.');
const reset = path.endsWith('/recovery-reset');
if (reset && (body.target !== null || !body.recoveryPublic || body.recoveryPublic.d || body.recoveryPublic.kty !== 'EC')) fail(400, 'Secours invalide.');
const target = reset || get('SELECT * FROM devices WHERE id=? AND vault=? AND revoked=0', body.target, vault.id);
if (!target || body.expectedEpoch !== vault.epoch || !uuid(body.epoch) || body.epoch === vault.epoch || !envelope(body.recoveryEnvelope)) fail(409, 'Appareils ou clés modifiés. Actualisez la liste.');
const remaining = all('SELECT id FROM devices WHERE vault=? AND revoked=0 AND id<>?', vault.id, body.target ?? '');
if (!body.envelopes || Object.keys(body.envelopes).length !== remaining.length || remaining.some(d => !envelope(body.envelopes[d.id]))) fail(400, 'Clés manquantes pour un appareil.');
atomic(() => {
if (!reset) run('UPDATE devices SET revoked=1 WHERE id=?', body.target);
else run('UPDATE vaults SET recovery_pub=? WHERE id=?', json(body.recoveryPublic), vault.id);
run('DELETE FROM invitations WHERE vault=?', vault.id);
for (const d of remaining) run('UPDATE devices SET envelope=? WHERE id=?', json(body.envelopes[d.id]), d.id);
run('UPDATE vaults SET epoch=?,recovery_envelope=?,owner=?,role_version=role_version+1 WHERE id=?', body.epoch, json(body.recoveryEnvelope), vault.owner === body.target ? device.id : vault.owner, vault.id);
});
return { ok: true };
}
if (path === `${PREFIX}/vault` && req.method === 'DELETE') { run('DELETE FROM vaults WHERE id=?', vault.id); return { ok: true }; }
fail(404, 'Route inconnue.');
}
const server = createServer(async (req, res) => {
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader('Cache-Control', 'no-store');
res.setHeader('X-Content-Type-Options', 'nosniff');
try {
if (origin && req.headers.origin && req.headers.origin !== origin) fail(403, 'Origine refusée.');
if (req.headers['sec-fetch-site'] === 'cross-site') fail(403, 'Origine refusée.');
const ip = trustProxy ? String(req.headers['x-pardis-client-ip'] ?? req.socket.remoteAddress) : req.socket.remoteAddress;
limit(`all:${ip}`, 600);
if (req.method !== 'GET' && req.headers['content-type'] !== 'application/json') fail(415, 'JSON requis.');
const chunks = []; let size = 0;
for await (const chunk of req) { size += chunk.length; if (size > 2 * 1024 * 1024) fail(413, 'Requête trop volumineuse.'); chunks.push(chunk); }
const raw = Buffer.concat(chunks).toString('utf8');
let body = {};
try { if (raw) body = parse(raw); } catch { fail(400, 'JSON invalide.'); }
if (!body || typeof body !== 'object' || Array.isArray(body)) fail(400, 'Objet JSON requis.');
const url = new URL(req.url, 'http://localhost');
if (!url.pathname.startsWith(PREFIX)) fail(404, 'Route inconnue.');
const result = await route(req, url, raw, body, ip);
res.end(json(result));
} catch (error) {
res.statusCode = error.status ?? (String(error.code).startsWith('ERR_SQLITE') ? 409 : 500);
res.end(json({ error: error.status ? error.message : 'Opération impossible. Réessayez.', ...(error.code === 'DEVICE_REVOKED' ? { code: error.code } : {}) }));
}
});
server.requestTimeout = 15000; server.headersTimeout = 10000;
server.on('close', () => db.close());
return server;
}

203
server/app.test.js Normal file
View File

@@ -0,0 +1,203 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { once } from 'node:events';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { DatabaseSync } from 'node:sqlite';
import { createSyncServer } from './app.js';
import { b64, bytes, canonical, decrypt, encrypt, identity, open, publicIdentity, randomSecret, requestProof, seal, sign } from '../src/lib/sync/crypto.js';
import { revisionHeader } from '../src/lib/sync/model.js';
let server, base, directory, filename, offset;
async function start() { server = createSyncServer({ filename, now: () => Date.now() + offset, origin: 'https://pardis.example' }); server.listen(0, '127.0.0.1'); await once(server, 'listening'); base = `http://127.0.0.1:${server.address().port}/api/sync`; }
beforeEach(async () => { directory = mkdtempSync(join(tmpdir(), 'pardis-sync-test-')); filename = join(directory, 'test.sqlite'); offset = 0; await start(); });
afterEach(async () => { await new Promise(resolve => server.close(resolve)); rmSync(directory, { recursive: true, force: true }); });
async function request(path, device = null, body, method = body === undefined ? 'GET' : 'POST', who = device?.id, extraHeaders = {}) {
const raw = body === undefined ? '' : canonical(body);
const headers = { 'Content-Type': 'application/json', ...extraHeaders };
if (device) headers['X-Pardis-Proof'] = b64(bytes(canonical(await requestProof(device.privateKey, method, `/api/sync${path}`, raw, who))));
const response = await fetch(base + path, { method, headers, ...(raw ? { body: raw } : {}) });
return { status: response.status, data: await response.json(), headers, raw };
}
async function vault() {
const device = await identity('Phone'), recovery = await identity('Recovery');
const id = crypto.randomUUID(), epoch = crypto.randomUUID();
const ring = { v: 1, epoch, keys: { [epoch]: randomSecret() }, routingKey: randomSecret(), recovery: publicIdentity(recovery) };
const body = { id, epoch, device: publicIdentity(device), recoveryPublic: recovery.publicKey,
envelope: await seal(device.wrapPublic, ring, `pardis-device-v1:${id}:${device.id}`),
label: { epoch, ...await encrypt(ring.keys[epoch], device.name, `pardis-label-v1:${id}:${device.id}`) },
recoveryEnvelope: await seal(recovery.wrapPublic, ring, `pardis-recovery-v1:${id}`) };
expect((await request('/vaults', device, body)).status).toBe(200);
return { device, recovery, id, epoch, ring, body };
}
async function linked(v) {
const b = await identity('Computer');
const invite = (await request('/invitations', v.device, {})).data;
expect((await request('/join', null, { code: invite.code, device: publicIdentity(b) })).status).toBe(200);
const body = { device: publicIdentity(b), epoch: v.epoch,
label: { epoch: v.epoch, ...await encrypt(v.ring.keys[v.epoch], b.name, `pardis-label-v1:${v.id}:${b.id}`) },
envelope: await seal(b.wrapPublic, v.ring, `pardis-device-v1:${v.id}:${b.id}`) };
expect((await request(`/invitations/${invite.code}/approve`, v.device, body)).status).toBe(200);
return { b, invite, body };
}
async function revision(v, device = v.device, parents = [], object = b64(new Uint8Array(32))) {
const header = revisionHeader({ vault: v.id, id: crypto.randomUUID(), object, epoch: v.epoch, device: device.id, parents });
const encrypted = await encrypt(v.ring.keys[v.epoch], { key: 'day:2026-10-10', value: 'Private chantier' }, canonical(header));
const signed = { ...header, ...encrypted };
return { ...signed, signature: await sign(device.privateKey, signed) };
}
describe('synchronization API', () => {
it('stores only encrypted data and scopes every read to the authenticated vault', async () => {
const a = await vault(), b = await vault();
const v = await revision(a);
expect((await request('/revisions', a.device, v)).status).toBe(200);
expect((await request('/revisions?after=0', b.device)).data.records).toEqual([]);
const result = await request('/revisions?after=0', a.device);
expect(result.data.records).toHaveLength(1);
const database = new DatabaseSync(filename, { readOnly: true });
const data = database.prepare('SELECT data FROM revisions').get().data;
expect(data).not.toContain('Private chantier'); expect(data).not.toContain('2026-10-10');
const storedVault = database.prepare('SELECT * FROM vaults').get();
expect(JSON.stringify(storedVault)).not.toContain(a.ring.keys[a.epoch]);
database.close();
});
it('requires signed, fresh requests and refuses replay or changed payloads', async () => {
const a = await vault();
expect((await request('/state')).status).toBe(401);
const response = await request('/state', a.device);
const replay = await fetch(base + '/state', { headers: response.headers }); expect(replay.status).toBe(409);
const spoofed = await identity('Spoof'); spoofed.id = a.device.id;
expect((await request('/state', spoofed)).status).toBe(401);
expect((await request('/state', a.device, undefined, 'GET', a.device.id, { Origin: 'https://evil.example' })).status).toBe(403);
offset = 180000;
expect((await request('/state', a.device)).status).toBe(401);
});
it('is idempotent on vault creation, revision upload and recovery retries', async () => {
const a = await vault();
expect((await request('/vaults', a.device, a.body)).status).toBe(200);
const v = await revision(a);
const first = await request('/revisions', a.device, v);
expect((await request('/revisions', a.device, v)).data.seq).toBe(first.data.seq);
expect((await request('/revisions?after=0', a.device)).data.records).toHaveLength(1);
const { b, body } = await linked(a);
expect((await request('/recover', a.recovery, body, 'POST', `recovery:${a.id}`)).status).toBe(200);
expect((await request('/state', b)).data.devices).toHaveLength(2);
});
it('does not disclose keys before explicit approval, and encrypts the result only to the applicant', async () => {
const a = await vault(); const b = await identity('New phone');
const invite = (await request('/invitations', a.device, {})).data;
expect((await request('/join', null, { code: invite.code, device: publicIdentity(b) })).status).toBe(200);
const waiting = await request(`/invitations/${invite.code}/result`, b, undefined, 'GET', `pair:${invite.code}`);
expect(waiting.data).toEqual({ waiting: true });
expect((await request('/state', b)).status).toBe(403);
const heartbeat = await request(`/invitations/${invite.code}/heartbeat`, a.device, {});
expect(heartbeat.data.applicant).toEqual(publicIdentity(b));
const label = await encrypt(a.ring.keys[a.epoch], 'New', 'label');
const wrapped = await seal(b.wrapPublic, a.ring, `pardis-device-v1:${a.id}:${b.id}`);
expect((await request(`/invitations/${invite.code}/approve`, a.device, { epoch: a.epoch, device: publicIdentity(b), label, envelope: wrapped })).status).toBe(200);
const result = (await request(`/invitations/${invite.code}/result`, b, undefined, 'GET', `pair:${invite.code}`)).data;
expect(await open(b.wrapPrivate, result.envelope, `pardis-device-v1:${a.id}:${b.id}`)).toEqual(a.ring);
await expect(open(a.device.wrapPrivate, result.envelope, `pardis-device-v1:${a.id}:${b.id}`)).rejects.toThrow();
});
it('expires pairing when the dedicated screen stops renewing its lease', async () => {
const a = await vault(); const b = await identity('B');
const invite = (await request('/invitations', a.device, {})).data;
offset = 26000;
expect((await request('/join', null, { code: invite.code, device: publicIdentity(b) })).status).toBe(410);
offset = 0;
const fresh = (await request('/invitations', a.device, {})).data;
await request(`/invitations/${fresh.code}`, a.device, {}, 'DELETE');
expect((await request('/join', null, { code: fresh.code, device: publicIdentity(b) })).status).toBe(410);
});
it('binds invitation approval to its sponsor and the exact applicant keys', async () => {
const a = await vault(), unrelated = await vault();
const b = await identity('B'), c = await identity('C');
const invite = (await request('/invitations', a.device, {})).data;
await request('/join', null, { code: invite.code, device: publicIdentity(b) });
expect((await request('/join', null, { code: invite.code, device: publicIdentity(c) })).status).toBe(409);
expect((await request(`/invitations/${invite.code}/heartbeat`, unrelated.device, {})).status).toBe(403);
expect((await request(`/invitations/${invite.code}/approve`, a.device, { epoch: a.epoch, device: publicIdentity(c) })).status).toBe(409);
});
it('recovers the current keyring only with the private recovery document', async () => {
const a = await vault();
const bad = await identity('Wrong secret');
expect((await request('/recovery', bad, undefined, 'GET', `recovery:${a.id}`)).status).toBe(401);
const result = await request('/recovery', a.recovery, undefined, 'GET', `recovery:${a.id}`);
expect(await open(a.recovery.wrapPrivate, result.data.envelope, `pardis-recovery-v1:${a.id}`)).toEqual(a.ring);
expect(a.ring.recovery).not.toHaveProperty('privateKey');
expect(a.ring.recovery).not.toHaveProperty('wrapPrivate');
});
it('atomically revokes access and rotates keys without giving the removed device the new key', async () => {
const a = await vault(); const { b } = await linked(a);
const epoch = crypto.randomUUID();
const ring = { ...a.ring, epoch, keys: { ...a.ring.keys, [epoch]: randomSecret() } };
const result = await request('/revoke', a.device, { target: b.id, expectedEpoch: a.epoch, epoch,
envelopes: { [a.device.id]: await seal(a.device.wrapPublic, ring, `pardis-device-v1:${a.id}:${a.device.id}`) },
recoveryEnvelope: await seal(a.recovery.wrapPublic, ring, `pardis-recovery-v1:${a.id}`) });
expect(result.status).toBe(200);
expect((await request('/state', b)).status).toBe(403);
expect((await request('/revisions', b, await revision(a, b))).status).toBe(403);
expect((await request('/revisions', a.device, await revision(a))).status).toBe(409);
const state = (await request('/state', a.device)).data;
expect(state.epoch).toBe(epoch);
const newRing = await open(a.device.wrapPrivate, state.envelope, `pardis-device-v1:${a.id}:${a.device.id}`);
expect(newRing.keys[epoch]).toBe(ring.keys[epoch]);
const protectedValue = await encrypt(ring.keys[epoch], 'Future data', 'data');
await expect(decrypt(a.ring.keys[a.epoch], protectedValue, 'data')).rejects.toThrow();
});
it('recovers at the device limit, revoking lost devices and rotating the epoch atomically', async () => {
const a = await vault();
for (let i = 0; i < 9; i++) await linked(a);
const replacement = await identity('Recovered');
const epoch = crypto.randomUUID();
const ring = { ...a.ring, epoch, keys: { ...a.ring.keys, [epoch]: randomSecret() } };
const body = { expectedEpoch: a.epoch, epoch, device: publicIdentity(replacement),
label: { epoch, ...await encrypt(ring.keys[epoch], replacement.name, 'label') },
envelope: await seal(replacement.wrapPublic, ring, `pardis-device-v1:${a.id}:${replacement.id}`),
recoveryEnvelope: await seal(a.recovery.wrapPublic, ring, `pardis-recovery-v1:${a.id}`) };
expect((await request('/recover', a.recovery, body, 'POST', `recovery:${a.id}`)).status).toBe(200);
expect((await request('/recover', a.recovery, body, 'POST', `recovery:${a.id}`)).status).toBe(200);
expect((await request('/state', a.device)).status).toBe(403);
const state = (await request('/state', replacement)).data;
expect(state.devices.filter(d => !d.revoked)).toHaveLength(1);
expect(state.owner).toBe(replacement.id); expect(state.epoch).toBe(epoch);
expect(await open(replacement.wrapPrivate, state.envelope, `pardis-device-v1:${a.id}:${replacement.id}`)).toEqual(ring);
});
it('replaces the recovery identity and rejects the old secret', async () => {
const a = await vault(); const next = await identity('New recovery'); const epoch = crypto.randomUUID();
const ring = { ...a.ring, epoch, keys: { ...a.ring.keys, [epoch]: randomSecret() }, recovery: publicIdentity(next) };
expect((await request('/recovery-reset', a.device, { target: null, expectedEpoch: a.epoch, epoch, recoveryPublic: next.publicKey,
envelopes: { [a.device.id]: await seal(a.device.wrapPublic, ring, `pardis-device-v1:${a.id}:${a.device.id}`) },
recoveryEnvelope: await seal(next.wrapPublic, ring, `pardis-recovery-v1:${a.id}`) })).status).toBe(200);
expect((await request('/recovery', a.recovery, undefined, 'GET', `recovery:${a.id}`)).status).toBe(401);
const result = await request('/recovery', next, undefined, 'GET', `recovery:${a.id}`);
expect(await open(next.wrapPrivate, result.data.envelope, `pardis-recovery-v1:${a.id}`)).toEqual(ring);
});
it('checks role revisions, ancestry and revision signatures', async () => {
const a = await vault(); const { b } = await linked(a);
expect((await request('/role', b, { expected: 1 })).status).toBe(200);
expect((await request('/role', a.device, { expected: 1 })).status).toBe(409);
expect((await request('/state', b)).data.owner).toBe(b.id);
const missing = await revision(a, a.device, [crypto.randomUUID()]);
expect((await request('/revisions', a.device, missing)).status).toBe(409);
const v = await revision(a); v.ciphertext = v.ciphertext.slice(1);
expect((await request('/revisions', a.device, v)).status).toBe(401);
});
it('persists encrypted records and replay protection across service restarts', async () => {
const a = await vault(); const record = await revision(a);
await request('/revisions', a.device, record);
const state = await request('/state', a.device);
await new Promise(resolve => server.close(resolve)); await start();
expect((await request('/revisions?after=0', a.device)).data.records).toHaveLength(1);
expect((await fetch(base + '/state', { headers: state.headers })).status).toBe(409);
});
it('deletes the vault, its ciphertext and all device access', async () => {
const a = await vault(); await request('/revisions', a.device, await revision(a));
expect((await request('/vault', a.device, {}, 'DELETE')).status).toBe(200);
expect((await request('/state', a.device)).status).toBe(403);
expect((await request('/recovery', a.recovery, undefined, 'GET', `recovery:${a.id}`)).status).toBe(401);
const database = new DatabaseSync(filename, { readOnly: true });
expect(database.prepare('SELECT count(*) n FROM revisions').get().n).toBe(0); database.close();
});
});

9
server/backup.js Normal file
View File

@@ -0,0 +1,9 @@
// Online SQLite backup includes committed WAL transactions. Run in the service
// container, then copy the resulting file to independent, protected storage.
import { DatabaseSync, backup } from 'node:sqlite';
const source = process.env.SYNC_DB ?? './sync-data/pardis.sqlite';
const destination = process.argv[2];
if (!destination || destination === source) throw new Error('Usage: node server/backup.js /data/backup.sqlite (distinct destination)');
const db = new DatabaseSync(source, { readOnly: true });
try { await backup(db, destination); console.info('Encrypted database backup completed.'); }
finally { db.close(); }

8
server/index.js Normal file
View File

@@ -0,0 +1,8 @@
import { mkdirSync } from 'node:fs';
import { dirname } from 'node:path';
import { createSyncServer } from './app.js';
const filename = process.env.SYNC_DB ?? './sync-data/pardis.sqlite';
mkdirSync(dirname(filename), { recursive: true });
const server = createSyncServer({ filename, origin: process.env.SYNC_ORIGIN ?? '', trustProxy: process.env.SYNC_TRUST_PROXY === '1', maxBytes: Number(process.env.SYNC_MAX_BYTES ?? 52428800), maxVaults: Number(process.env.SYNC_MAX_VAULTS ?? 10000) });
server.listen(Number(process.env.PORT ?? 3001), process.env.SYNC_HOST ?? '127.0.0.1', () => console.info('Pardis sync listening'));
for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => server.close());

View File

@@ -1,6 +1,6 @@
<script> <script>
import { db } from './lib/db.js'; import { db } from './lib/db.js';
import { dayState, reconcile } from './lib/day.svelte.js'; import { dayState, reconcile, refresh } from './lib/day.svelte.js';
import { backupState, loadBackupStatus } from './lib/backup-state.svelte.js'; import { backupState, loadBackupStatus } from './lib/backup-state.svelte.js';
import { backupReminderDue } from './lib/backup-safety.js'; import { backupReminderDue } from './lib/backup-safety.js';
import { go, router } from './lib/router.svelte.js'; import { go, router } from './lib/router.svelte.js';
@@ -14,12 +14,23 @@
import NonWorkingSettings from './routes/NonWorkingSettings.svelte'; import NonWorkingSettings from './routes/NonWorkingSettings.svelte';
import Stats from './routes/Stats.svelte'; import Stats from './routes/Stats.svelte';
import DataSettings from './routes/DataSettings.svelte'; import DataSettings from './routes/DataSettings.svelte';
import SyncSettings from './routes/SyncSettings.svelte';
import { liveQuery } from 'dexie';
import { loadSyncStatus, synchronize, takeAutomationRole } from './lib/sync/client.js';
import { syncState } from './lib/sync/state.svelte.js';
let roleDismissed = $state(false);
let roleError = $state('');
async function takeRole() {
try { await takeAutomationRole(); roleError = ''; await tick(); }
catch (error) { roleError = error.message; }
}
let ready = $state(false); let ready = $state(false);
let bootError = $state(''); let bootError = $state('');
let retry = $state(0); let retry = $state(0);
let clockNow = $state(Date.now()); let clockNow = $state(Date.now());
async function tick() { if (!ready) return; try { await db.open(); await reconcile(); await loadBackupStatus(); clockNow = Date.now(); bootError = ''; } catch (error) { bootError = `Actualisation impossible : ${error.message}`; } } async function tick() { if (!ready) return; try { await db.open(); if (router.route === 'sync' && !syncState.linked) await refresh(); else await reconcile(); await loadBackupStatus(); clockNow = Date.now(); bootError = ''; } catch (error) { bootError = `Actualisation impossible : ${error.message}`; } }
$effect(() => { $effect(() => {
retry; retry;
@@ -34,7 +45,8 @@
const { installDevSeed } = await import('./lib/seed.js'); const { installDevSeed } = await import('./lib/seed.js');
installDevSeed(); installDevSeed();
} }
await reconcile(); await loadSyncStatus();
if (router.route === 'sync' && !syncState.linked) await refresh(); else await reconcile();
if (alive) ready = true; if (alive) ready = true;
} catch (error) { if (alive) bootError = `Accès aux données impossible : ${error.message}`; } } catch (error) { if (alive) bootError = `Accès aux données impossible : ${error.message}`; }
})(); })();
@@ -52,6 +64,25 @@
}; };
}); });
$effect(() => {
if (!ready) return;
const sync = async () => {
if (document.visibilityState === 'hidden' || dayState.importing) return;
try { await synchronize(); if (syncState.linked) { await loadSettings(); await tick(); } }
catch (error) { syncState.error = error.message; }
};
const subscription = liveQuery(async () => {
await db.syncState.toArray(); await db.syncOutbox.count(); await db.syncConflicts.count();
return true;
}).subscribe({ next: () => { void loadSyncStatus().catch(() => {}); }, error: () => {} });
const id = setInterval(() => { void sync(); }, 15000);
const onOnline = () => { void sync(); };
window.addEventListener('online', onOnline);
document.addEventListener('visibilitychange', onOnline);
void sync();
return () => { clearInterval(id); subscription.unsubscribe(); window.removeEventListener('online', onOnline); document.removeEventListener('visibilitychange', onOnline); };
});
/** No explicit route: land on whichever screen matches the state of the day. */ /** No explicit route: land on whichever screen matches the state of the day. */
const view = $derived(router.route ?? (dayState.day ? 'today' : 'resume')); const view = $derived(router.route ?? (dayState.day ? 'today' : 'resume'));
</script> </script>
@@ -59,6 +90,13 @@
{#if !ready} {#if !ready}
<div class="boot">{#if bootError}<div class="stack"><p role="alert">{bootError}</p><button onclick={() => (retry += 1)}>Réessayer</button></div>{:else}<p>Chargement…</p>{/if}</div> <div class="boot">{#if bootError}<div class="stack"><p role="alert">{bootError}</p><button onclick={() => (retry += 1)}>Réessayer</button></div>{:else}<p>Chargement…</p>{/if}</div>
{:else} {:else}
{#if syncState.linked && !['sync', 'data'].includes(view)}
<div class="update"><button onclick={() => go('sync')}>{syncState.conflicts.length ? `${syncState.conflicts.length} conflit(s) à résoudre` : syncState.revoked ? 'Appareil révoqué' : !syncState.enabled ? 'Synchronisation en pause' : syncState.pending ? 'Enregistré ici · en attente de synchronisation' : 'Synchronisation'}</button></div>
{/if}
{#if syncState.enabled && syncState.owner && syncState.owner !== syncState.deviceId && !roleDismissed && view !== 'sync'}
<div class="update"><span>Les automatismes sont exécutés sur un autre appareil.</span><button disabled={syncState.busy} onclick={takeRole}>Utiliser cet appareil pour les automatismes</button><button onclick={() => { roleDismissed = true; }}>Plus tard</button></div>
{#if roleError}<p role="status">{roleError}</p>{/if}
{/if}
{#if backupState.loaded && backupReminderDue(backupState.status, clockNow) && !['settings', 'data'].includes(view)} {#if backupState.loaded && backupReminderDue(backupState.status, clockNow) && !['settings', 'data'].includes(view)}
<div class="update"><span>Sauvegarde à vérifier</span><button disabled={dayState.importing} onclick={() => go('data')}>Sauvegarder</button></div> <div class="update"><span>Sauvegarde à vérifier</span><button disabled={dayState.importing} onclick={() => go('data')}>Sauvegarder</button></div>
{/if} {/if}
@@ -80,6 +118,8 @@
<NonWorkingSettings /> <NonWorkingSettings />
{:else if view === 'data'} {:else if view === 'data'}
<DataSettings /> <DataSettings />
{:else if view === 'sync'}
<SyncSettings />
{:else if ['settings', 'automations', 'non-working'].includes(view)} {:else if ['settings', 'automations', 'non-working'].includes(view)}
<Settings /> <Settings />
{:else if view === 'today'} {:else if view === 'today'}
@@ -107,9 +147,9 @@
> >
<button <button
type="button" type="button"
class:sel={['settings', 'automations', 'non-working', 'data'].includes(view)} class:sel={['settings', 'automations', 'non-working', 'data', 'sync'].includes(view)}
disabled={dayState.importing} disabled={dayState.importing}
aria-current={['settings', 'automations', 'non-working', 'data'].includes(view)} aria-current={['settings', 'automations', 'non-working', 'data', 'sync'].includes(view)}
onclick={() => go('settings')}>Réglages</button onclick={() => go('settings')}>Réglages</button
> >
{#if view === 'stats'} {#if view === 'stats'}

View File

@@ -16,6 +16,7 @@
import { prepareVerifiedExport, verifyExportFile, backupReminderDue } from '../lib/backup-safety.js'; import { prepareVerifiedExport, verifyExportFile, backupReminderDue } from '../lib/backup-safety.js';
import { backupState, loadBackupStatus } from '../lib/backup-state.svelte.js'; import { backupState, loadBackupStatus } from '../lib/backup-state.svelte.js';
import { db } from '../lib/db.js'; import { db } from '../lib/db.js';
import { syncState } from '../lib/sync/state.svelte.js';
import { fmtDayLabel, fmtTime, localDateKey } from '../lib/time.js'; import { fmtDayLabel, fmtTime, localDateKey } from '../lib/time.js';
/** @type {HTMLInputElement} */ /** @type {HTMLInputElement} */
@@ -170,6 +171,7 @@
> >
{/if} {/if}
</p> </p>
{#if syncState.linked}<p>Les modifications issues de cet import seront partagées avec les autres appareils. Un remplacement transmettra aussi les corrections et suppressions.</p>{/if}
{#if staged.dropped} {#if staged.dropped}
<p class="dim small">{staged.dropped} entrée(s) illisible(s) ignorée(s).</p> <p class="dim small">{staged.dropped} entrée(s) illisible(s) ignorée(s).</p>
{/if} {/if}
@@ -196,6 +198,7 @@
Toutes les journées et tous les réglages de cet appareil seront remplacés par ceux du fichier. Toutes les journées et tous les réglages de cet appareil seront remplacés par ceux du fichier.
{#if dayState.day}La journée en cours sera également remplacée.{/if} {#if dayState.day}La journée en cours sera également remplacée.{/if}
</p> </p>
{#if syncState.linked}<p>Journées supprimées ici puis dans le coffre : {[...dayState.closedDays, ...(dayState.day ? [dayState.day] : [])].filter(day => !staged.days.some(incoming => incoming.date === day.date)).map(day => day.date).join(', ') || 'aucune'}. Les dates présentes dans le fichier seront remplacées et synchronisées. Les clés d’accès au coffre restent conservées.</p>{/if}
<div class="btn-row"> <div class="btn-row">
<button type="button" disabled={busy} onclick={() => (confirmingReplace = false)}>Annuler</button> <button type="button" disabled={busy} onclick={() => (confirmingReplace = false)}>Annuler</button>
<button type="button" class="btn-danger" disabled={busy} onclick={() => run('replace')}> <button type="button" class="btn-danger" disabled={busy} onclick={() => run('replace')}>

View File

@@ -22,6 +22,7 @@ import {
import { buildModel, suggestClients, suggestTasks, TRANSPORT_TASK } from './predict.js'; import { buildModel, suggestClients, suggestTasks, TRANSPORT_TASK } from './predict.js';
import { automateDay, manualAdvance, previousContext } from './automation.js'; import { automateDay, manualAdvance, previousContext } from './automation.js';
import { loadBackupStatus } from './backup-state.svelte.js'; import { loadBackupStatus } from './backup-state.svelte.js';
import { canAutomate } from './sync/store.js';
import { applyTheme, loadSettings, settings } from './settings.svelte.js'; import { applyTheme, loadSettings, settings } from './settings.svelte.js';
import { atLocalTime, HOUR, localDateKey, roundTs, weekday } from './time.js'; import { atLocalTime, HOUR, localDateKey, roundTs, weekday } from './time.js';
@@ -74,6 +75,7 @@ async function reconcileDay(now) {
const persisted = await readSettings(); const persisted = await readSettings();
if (persisted) { Object.assign(settings, persisted); applyTheme(); } if (persisted) { Object.assign(settings, persisted); applyTheme(); }
dayState.today = localDateKey(now); dayState.today = localDateKey(now);
if (!await canAutomate()) return refresh(now);
const active = await getActiveDay(); const active = await getActiveDay();
// Settle stale work before retention, so an open day cannot vanish on return. // Settle stale work before retention, so an open day cannot vanish on return.

View File

@@ -49,6 +49,11 @@ import { DAY, HOUR, localDateKey } from './time.js';
* days: import('dexie').Table<DayRecord, string>, * days: import('dexie').Table<DayRecord, string>,
* entries: import('dexie').Table<EntryRecord, number>, * entries: import('dexie').Table<EntryRecord, number>,
* settings: import('dexie').Table<SettingsRecord, string>, * settings: import('dexie').Table<SettingsRecord, string>,
* syncState: import('dexie').Table<any, string>,
* syncObjects: import('dexie').Table<any, string>,
* syncVersions: import('dexie').Table<any, string>,
* syncOutbox: import('dexie').Table<any, string>,
* syncConflicts: import('dexie').Table<any, string>,
* }} TimeLogDB * }} TimeLogDB
*/ */
@@ -67,6 +72,38 @@ db.version(2).stores({
settings: 'key', settings: 'key',
}); });
db.version(3).stores({
syncState: 'key', syncObjects: 'key', syncVersions: 'id, key',
syncOutbox: 'id', syncConflicts: 'key',
});
// Journal every local mutation in its native IndexedDB transaction, including
// direct writes by editing, automation, import and purge. The durable source
// rows + last captured values are the outbox until revisions are materialised.
db.use({
stack: 'dbcore', name: 'sync-dirty-journal',
create(core) {
return {
...core,
transaction(stores, mode, options) {
const names = mode === 'readwrite' && stores.some(s => ['days', 'entries', 'settings'].includes(s))
? [...new Set([...stores, 'syncState'])] : stores;
return core.transaction(names, mode, options);
},
table(name) {
const table = core.table(name);
if (!['days', 'entries', 'settings'].includes(name)) return table;
return { ...table, async mutate(req) {
const result = await table.mutate(req);
const marker = await core.table('syncState').mutate({ type: 'put', trans: req.trans, values: [{ key: 'dirty', value: true }] });
if (marker.numFailures) throw marker.failures[0];
return result;
} };
},
};
},
});
/* ------------------------------------------------------------------ settings */ /* ------------------------------------------------------------------ settings */
export async function readSettings() { export async function readSettings() {

View File

@@ -6,7 +6,7 @@
* worker should land on. * worker should land on.
*/ */
const ROUTES = ['resume', 'today', 'settings', 'stats', 'details', 'automations', 'non-working', 'data']; const ROUTES = ['resume', 'today', 'settings', 'stats', 'details', 'automations', 'non-working', 'data', 'sync'];
function fromHash() { function fromHash() {
const [path, query = ''] = location.hash.replace(/^#\/?/, '').split('?'); const [path, query = ''] = location.hash.replace(/^#\/?/, '').split('?');

297
src/lib/sync/client.js Normal file
View File

@@ -0,0 +1,297 @@
import { db } from '../db.js';
import { canonical, identity, publicIdentity, randomSecret, requestProof, b64, bytes, encrypt, decrypt, seal, open, sign, verify, objectId, fingerprint, pairingTranscript } from './crypto.js';
import { revisionHeader, validateValue } from './model.js';
import { captureChanges, detach, getConfig, receiveVersions, saveConfig, tables } from './store.js';
import { syncState } from './state.svelte.js';
const API = '/api/sync';
let running = null;
let invitation = null;
export async function api(path, { method = 'GET', body = undefined, device = null, who = device?.id } = {}) {
const raw = body === undefined ? '' : canonical(body);
const headers = { 'Content-Type': 'application/json' };
if (device) headers['X-Pardis-Proof'] = b64(bytes(canonical(await requestProof(device.privateKey, method, API + path, raw, who))));
const response = await fetch(API + path, { method, headers, ...(raw ? { body: raw } : {}), cache: 'no-store', signal: AbortSignal.timeout(15000) });
let result;
try { result = await response.json(); } catch { throw new Error('Service de synchronisation indisponible.'); }
if (!response.ok) throw Object.assign(new Error(result.error ?? 'Synchronisation impossible.'), { status: response.status, code: result.code });
return result;
}
const wrapContext = (vault, device) => `pardis-device-v1:${vault}:${device}`;
const recoveryContext = vault => `pardis-recovery-v1:${vault}`;
const labelContext = (vault, device) => `pardis-label-v1:${vault}:${device}`;
function validateRing(ring) {
if (ring?.v !== 1 || typeof ring.epoch !== 'string' || typeof ring.keys?.[ring.epoch] !== 'string' || typeof ring.routingKey !== 'string' || !ring.recovery?.publicKey || typeof ring.recovery.wrapPublic !== 'string' || ring.recovery.privateKey || ring.recovery.wrapPrivate) throw new Error('Clés de synchronisation invalides.');
return ring;
}
async function label(config, device) {
return { epoch: config.ring.epoch, ...await encrypt(config.ring.keys[config.ring.epoch], device.name, labelContext(config.vault, device.id)) };
}
export async function loadSyncStatus() {
const config = await getConfig();
const cached = (await db.syncState.get('server'))?.value;
const devices = [];
if (config && cached) for (const d of cached.devices) {
let name = 'Appareil';
try { name = await decrypt(config.ring.keys[d.label.epoch], d.label, labelContext(config.vault, d.id)); } catch { /* Don't make an unreadable label hide revocation controls. */ }
devices.push({ ...d, name: String(name) });
}
Object.assign(syncState, { loaded: true, linked: !!config, enabled: !!config?.enabled, deviceId: config?.device.id ?? '', owner: config?.owner ?? '', revoked: !!config?.revoked, devices,
pending: !!config && (!!(await db.syncState.get('dirty'))?.value || await db.syncOutbox.count() > 0),
lastSync: (await db.syncState.get('lastSync'))?.value ?? null, conflicts: await db.syncConflicts.toArray() });
}
export async function prepareVault(name) {
if (await getConfig()) throw new Error('Cet appareil est déjà lié à un coffre.');
const previous = (await db.syncState.get('setup'))?.value;
if (previous) return recoveryDocument(previous);
const device = await identity(name.trim() || 'Mon appareil');
const epoch = crypto.randomUUID();
const recoverySecret = await identity('Secours');
const config = { vault: crypto.randomUUID(), device, enabled: true, owner: device.id, recoverySecret,
ring: { v: 1, epoch, keys: { [epoch]: randomSecret() }, routingKey: randomSecret(), recovery: publicIdentity(recoverySecret) } };
await db.syncState.put({ key: 'setup', value: config });
return recoveryDocument(config);
}
export function recoveryDocument(config) {
return JSON.stringify({ format: 'pardis-recovery', version: 1, vault: config.vault, recovery: config.recoverySecret }, null, 2);
}
export async function prepareRecoveryReset() {
const config = await getConfig();
if (!config?.enabled) throw new Error('Activez la synchronisation.');
const recoverySecret = await identity('Secours');
await db.syncState.put({ key: 'recovery-reset', value: recoverySecret });
return recoveryDocument({ ...config, recoverySecret });
}
export async function commitRecoveryReset() {
const recoverySecret = (await db.syncState.get('recovery-reset'))?.value;
if (!recoverySecret) throw new Error('Préparez un nouveau document de secours.');
await rotateKeys(null, recoverySecret);
await db.syncState.delete('recovery-reset');
}
export async function activateVault() {
const config = (await db.syncState.get('setup'))?.value;
if (!config || await getConfig()) throw new Error('Préparez d’abord le nécessaire de secours.');
await api('/vaults', { method: 'POST', device: config.device, body: {
id: config.vault, device: publicIdentity(config.device), epoch: config.ring.epoch,
label: await label(config, config.device), envelope: await seal(config.device.wrapPublic, config.ring, wrapContext(config.vault, config.device.id)),
recoveryPublic: config.ring.recovery.publicKey, recoveryEnvelope: await seal(config.ring.recovery.wrapPublic, config.ring, recoveryContext(config.vault)),
} });
const { recoverySecret, ...linked } = config;
await db.transaction('rw', db.syncState, async () => { await saveConfig(linked); await db.syncState.delete('setup'); });
await synchronize();
}
async function attach(config) {
await db.transaction('rw', tables(db), async () => {
if (await getConfig()) throw new Error('Cet appareil est déjà lié.');
for (const t of [db.syncObjects, db.syncVersions, db.syncOutbox, db.syncConflicts]) await t.clear();
await db.syncState.delete('cursor'); await db.syncState.delete('server'); await db.syncState.delete('lastSync');
await saveConfig(config);
await captureChanges(); // Existing local dates become independent versions, never silently replaced.
await db.syncState.delete('joining'); await db.syncState.delete('setup'); await db.syncState.delete('recovery-reset');
});
}
export async function recoverVault(text, name) {
if (await getConfig()) throw new Error('Déconnectez cet appareil avant de récupérer un autre coffre.');
let recovery;
try { recovery = JSON.parse(text); } catch { throw new Error('Document de secours illisible.'); }
if (recovery.format !== 'pardis-recovery' || recovery.version !== 1 || !recovery.recovery?.privateKey || !recovery.vault) throw new Error('Document de secours invalide.');
const auth = { device: recovery.recovery, who: `recovery:${recovery.vault}` };
const server = await api('/recovery', auth);
let ring = validateRing(await open(recovery.recovery.wrapPrivate, server.envelope, recoveryContext(recovery.vault)));
if (ring.epoch !== server.epoch) throw new Error('Clés de secours incohérentes.');
const pending = (await db.syncState.get('recovering'))?.value;
const device = pending?.vault === recovery.vault ? pending.device : await identity(name.trim() || 'Appareil récupéré');
await db.syncState.put({ key: 'recovering', value: { vault: recovery.vault, device } });
const epoch = crypto.randomUUID();
ring = { ...ring, epoch, keys: { ...ring.keys, [epoch]: randomSecret() } };
const config = { vault: recovery.vault, device, ring, enabled: true, owner: device.id };
await api('/recover', { ...auth, method: 'POST', body: {
expectedEpoch: server.epoch, epoch, device: publicIdentity(device), label: await label(config, device),
envelope: await seal(device.wrapPublic, ring, wrapContext(config.vault, device.id)),
recoveryEnvelope: await seal(ring.recovery.wrapPublic, ring, recoveryContext(config.vault)),
} });
// A lost response may make the recovery POST an idempotent retry. Use the
// authoritative envelope rather than a newly generated, unused candidate key.
const restored = await api('/state', { device });
config.ring = validateRing(await open(device.wrapPrivate, restored.envelope, wrapContext(config.vault, device.id)));
config.owner = restored.owner;
await attach(config); await db.syncState.delete('recovering'); await synchronize();
}
export async function startInvitation() {
await synchronize();
const config = await getConfig();
if (!config?.enabled || config.revoked) throw new Error('Activez la synchronisation.');
const invite = await api('/invitations', { method: 'POST', body: {}, device: config.device });
invitation = { ...invite, device: config.device };
const pin = await fingerprint(invite.sponsor);
return { ...invite, url: `${location.origin}${location.pathname}#/sync?invite=${invite.code}&pin=${encodeURIComponent(pin)}` };
}
export async function cancelInvitation() {
const current = invitation; invitation = null;
if (current) await api(`/invitations/${current.code}`, { method: 'DELETE', body: {}, device: current.device }).catch(() => {});
}
export async function pollInvitation(code) {
if (invitation?.code !== code || document.visibilityState === 'hidden') throw new Error('Écran d’association fermé.');
const result = await api(`/invitations/${code}/heartbeat`, { method: 'POST', body: {}, device: invitation.device });
return { ...result, fingerprint: result.applicant ? await fingerprint(pairingTranscript(invitation, result.applicant)) : '' };
}
export async function joinInvitation(code, name, pin = '') {
if (await getConfig()) throw new Error('Cet appareil est déjà lié.');
const normalized = code.toUpperCase().replace(/[\s-]/g, '');
if (!/^[A-Z2-9]{8}$/.test(normalized)) throw new Error('Saisissez les huit caractères du code.');
const old = (await db.syncState.get('joining'))?.value;
const device = old?.code === normalized ? old.device : await identity(name.trim() || 'Nouvel appareil');
await db.syncState.put({ key: 'joining', value: { code: normalized, device } });
const invite = await api('/join', { method: 'POST', body: { code: normalized, device: publicIdentity(device) } });
if (pin && pin !== await fingerprint(invite.sponsor)) throw new Error('L’identité ne correspond pas au QR code. Annulez l’association.');
const pending = { ...invite, device, fingerprint: await fingerprint(pairingTranscript(invite, device)) };
await db.syncState.put({ key: 'joining', value: pending });
return pending;
}
export async function approveInvitation(code, applicant, name) {
if (invitation?.code !== code || document.visibilityState === 'hidden') throw new Error('Écran d’association fermé.');
const config = await getConfig();
await api(`/invitations/${code}/approve`, { method: 'POST', device: config.device, body: {
epoch: config.ring.epoch, device: publicIdentity(applicant),
label: await label(config, { ...applicant, name: name.trim() || 'Nouvel appareil' }),
envelope: await seal(applicant.wrapPublic, config.ring, wrapContext(config.vault, applicant.id)),
} });
// Keep the approved result until it is fetched or expires. Closing the screen
// now must not destroy the successful key transfer.
invitation = null;
await synchronize();
}
export async function finishJoining() {
const pending = (await db.syncState.get('joining'))?.value;
if (!pending?.vault) throw new Error('Aucune association en cours.');
const result = await api(`/invitations/${pending.code}/result`, { device: pending.device, who: `pair:${pending.code}` });
if (result.waiting) return false;
if (result.vault !== pending.vault) throw new Error('Coffre inattendu.');
const ring = validateRing(await open(pending.device.wrapPrivate, result.envelope, wrapContext(result.vault, pending.device.id)));
await attach({ vault: result.vault, device: pending.device, enabled: true, owner: '', ring });
await synchronize();
return true;
}
async function fetchState(config) {
const state = await api('/state', { device: config.device });
if (state.vault !== config.vault) throw new Error('Coffre inattendu.');
const ring = validateRing(await open(config.device.wrapPrivate, state.envelope, wrapContext(config.vault, config.device.id)));
if (ring.epoch !== state.epoch || ring.routingKey !== config.ring.routingKey || Object.entries(config.ring.keys).some(([epoch, key]) => ring.keys[epoch] !== key)) throw new Error('Les clés reçues sont anciennes ou incohérentes.');
await db.transaction('rw', db.syncState, async () => {
const current = await getConfig();
if (!current || current.vault !== config.vault || !current.enabled) throw new Error('Synchronisation interrompue.');
if (Object.entries(current.ring.keys).some(([epoch, key]) => ring.keys[epoch] !== key) || (current.roleVersion ?? 0) > state.roleVersion) throw new Error('État serveur ancien. Réessayez la synchronisation.');
Object.assign(current, { ring, owner: state.owner, roleVersion: state.roleVersion });
await saveConfig(current); await db.syncState.put({ key: 'server', value: state });
const pendingRecovery = (await db.syncState.get('recovery-reset'))?.value;
if (pendingRecovery?.id === ring.recovery.id) await db.syncState.delete('recovery-reset');
});
return { config: await getConfig(), state };
}
async function syncOnce() {
let config = await getConfig();
if (!config?.enabled || config.revoked) { await loadSyncStatus(); return; }
syncState.busy = true; syncState.error = '';
try {
const fetched = await fetchState(config); config = fetched.config;
await captureChanges();
let pending = await db.syncOutbox.toArray();
while (pending.length) {
const ids = new Set(pending.map(v => v.id));
const version = pending.find(v => v.parents.every(p => !ids.has(p)));
if (!version) throw new Error('Historique local cyclique.');
if (!(await getConfig())?.enabled) throw new Error('Synchronisation interrompue.');
const header = revisionHeader({ ...version, vault: config.vault, epoch: config.ring.epoch, object: await objectId(config.ring.routingKey, version.key) });
const encrypted = await encrypt(config.ring.keys[config.ring.epoch], { key: version.key, value: version.value, modifiedAt: version.modifiedAt }, canonical(header));
const signed = { ...header, ...encrypted };
await api('/revisions', { method: 'POST', device: config.device, body: { ...signed, signature: await sign(config.device.privateKey, signed) } });
await db.syncOutbox.delete(version.id);
pending = pending.filter(v => v.id !== version.id);
}
let more = true;
while (more) {
const cursor = (await db.syncState.get('cursor'))?.value ?? 0;
const page = await api(`/revisions?after=${cursor}`, { device: config.device });
const records = [];
let nextCursor = cursor;
for (const encrypted of page.records) {
if (!Number.isSafeInteger(encrypted.seq) || encrypted.seq <= nextCursor) throw new Error('Ordre des révisions invalide.');
nextCursor = encrypted.seq;
const header = revisionHeader(encrypted);
const author = fetched.state.devices.find(d => d.id === encrypted.device);
if (header.vault !== config.vault || !author || !await verify(author.publicKey, { ...header, iv: encrypted.iv, ciphertext: encrypted.ciphertext }, encrypted.signature)) throw new Error('Signature de révision invalide.');
const plain = await decrypt(config.ring.keys[header.epoch], encrypted, canonical(header));
validateValue(plain.key, plain.value);
if (await objectId(config.ring.routingKey, plain.key) !== header.object) throw new Error('Objet chiffré incohérent.');
records.push({ id: header.id, key: plain.key, value: plain.value, parents: header.parents, device: header.device, modifiedAt: plain.modifiedAt });
}
await receiveVersions(records, nextCursor, config.vault);
more = page.more;
if (more && !records.length) throw new Error('Réponse de synchronisation incomplète.');
}
await db.syncState.put({ key: 'lastSync', value: Date.now() });
} catch (error) {
syncState.error = error.message;
if (error.code === 'DEVICE_REVOKED' && config) {
const current = await getConfig();
if (current?.vault === config.vault) await saveConfig({ ...current, enabled: false, revoked: true });
}
} finally { syncState.busy = false; await loadSyncStatus(); }
}
export function synchronize() {
if (running) return running;
const work = () => syncOnce();
// One uploader per origin. Durable IDs and transactions also protect retry
// and browsers that do not implement Web Locks.
running = (globalThis.navigator?.locks ? navigator.locks.request('pardis-sync', work) : work()).finally(() => { running = null; });
return running;
}
export async function takeAutomationRole() {
await synchronize();
const config = await getConfig();
if (!config?.enabled) throw new Error('Activez la synchronisation.');
const result = await api('/role', { method: 'POST', device: config.device, body: { expected: config.roleVersion } });
await saveConfig({ ...config, owner: result.owner, roleVersion: result.roleVersion });
await loadSyncStatus();
}
export async function setSyncEnabled(enabled) {
const config = await getConfig();
if (!config || config.revoked) throw new Error('Appareil non autorisé.');
await saveConfig({ ...config, enabled });
await loadSyncStatus();
if (enabled) await synchronize();
}
export const revokeDevice = target => rotateKeys(target);
async function rotateKeys(target, recoverySecret = null) {
await synchronize();
const config = await getConfig();
if (!config?.enabled) throw new Error('Activez la synchronisation.');
const state = (await db.syncState.get('server'))?.value;
if (!state || target === config.device.id) throw new Error('Choisissez un autre appareil.');
const epoch = crypto.randomUUID();
const ring = { ...config.ring, epoch, keys: { ...config.ring.keys, [epoch]: randomSecret() }, ...(recoverySecret ? { recovery: publicIdentity(recoverySecret) } : {}) };
const envelopes = {};
for (const d of state.devices.filter(d => !d.revoked && d.id !== target)) envelopes[d.id] = await seal(d.wrapPublic, ring, wrapContext(config.vault, d.id));
await api(recoverySecret ? '/recovery-reset' : '/revoke', { method: 'POST', device: config.device, body: {
target, expectedEpoch: config.ring.epoch, epoch, envelopes,
recoveryEnvelope: await seal(ring.recovery.wrapPublic, ring, recoveryContext(config.vault)),
...(recoverySecret ? { recoveryPublic: recoverySecret.publicKey } : {}),
} });
// Read the authoritative envelope after the atomic server rotation. If the
// response is lost, the same fetch on the next sync recovers the new keys.
await synchronize();
}
export async function disconnect(erase = false) {
await setSyncEnabled(false).catch(() => {});
if (running) await running;
await cancelInvitation();
await detach(erase); syncState.error = ''; await loadSyncStatus();
}
export async function deleteVault() {
const config = await getConfig();
if (!config) throw new Error('Aucun coffre lié.');
await setSyncEnabled(false);
if (running) await running;
await api('/vault', { method: 'DELETE', device: config.device, body: {} });
await detach(); syncState.error = ''; await loadSyncStatus();
}

76
src/lib/sync/crypto.js Normal file
View File

@@ -0,0 +1,76 @@
import { Aes256Gcm, CipherSuite, DhkemP256HkdfSha256, HkdfSha256 } from '@hpke/core';
// HPKE RFC 9180; key transport is authenticated by the pairing fingerprint and
// signed requests. No secret is derived from the short rendezvous code.
const suite = new CipherSuite({ kem: new DhkemP256HkdfSha256(), kdf: new HkdfSha256(), aead: new Aes256Gcm() });
const encoder = new TextEncoder();
export const bytes = (value) => encoder.encode(value);
export const b64 = (value) => {
const data = new Uint8Array(value);
let binary = '';
for (let i = 0; i < data.length; i += 8192) binary += String.fromCharCode(...data.subarray(i, i + 8192));
return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', '');
};
export const unb64 = (value) => Uint8Array.from(atob(value.replaceAll('-', '+').replaceAll('_', '/')), c => c.charCodeAt(0));
export const randomSecret = () => b64(crypto.getRandomValues(new Uint8Array(32)));
export const digest = async (value) => b64(await crypto.subtle.digest('SHA-256', bytes(value)));
export function canonical(value) {
if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
if (value && typeof value === 'object') return `{${Object.keys(value).filter(k => value[k] !== undefined).sort().map(k => `${JSON.stringify(k)}:${canonical(value[k])}`).join(',')}}`;
return JSON.stringify(value);
}
export async function signingKeys() {
const pair = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify']);
return { privateKey: await crypto.subtle.exportKey('jwk', pair.privateKey), publicKey: await crypto.subtle.exportKey('jwk', pair.publicKey) };
}
export async function identity(name) {
const keys = await signingKeys();
const pair = await suite.kem.generateKeyPair();
return { id: crypto.randomUUID(), name, ...keys,
wrapPublic: b64(await suite.kem.serializePublicKey(pair.publicKey)),
wrapPrivate: b64(await suite.kem.serializePrivateKey(pair.privateKey)) };
}
export const publicIdentity = ({ id, publicKey, wrapPublic }) => ({ id, publicKey, wrapPublic });
export async function sign(privateKey, value) {
const key = await crypto.subtle.importKey('jwk', privateKey, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['sign']);
return b64(await crypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, key, bytes(canonical(value))));
}
export async function verify(publicKey, value, signature) {
try {
const key = await crypto.subtle.importKey('jwk', publicKey, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['verify']);
return await crypto.subtle.verify({ name: 'ECDSA', hash: 'SHA-256' }, key, unb64(signature), bytes(canonical(value)));
} catch { return false; }
}
export async function encrypt(secret, value, context) {
const key = await crypto.subtle.importKey('raw', unb64(secret), 'AES-GCM', false, ['encrypt']);
const iv = crypto.getRandomValues(new Uint8Array(12));
return { iv: b64(iv), ciphertext: b64(await crypto.subtle.encrypt({ name: 'AES-GCM', iv, additionalData: bytes(context) }, key, bytes(canonical(value)))) };
}
export async function decrypt(secret, value, context) {
const key = await crypto.subtle.importKey('raw', unb64(secret), 'AES-GCM', false, ['decrypt']);
const plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: unb64(value.iv), additionalData: bytes(context) }, key, unb64(value.ciphertext));
return JSON.parse(new TextDecoder().decode(plain));
}
export async function seal(publicKey, value, context) {
const sender = await suite.createSenderContext({ recipientPublicKey: await suite.kem.deserializePublicKey(unb64(publicKey)), info: bytes(context) });
return { enc: b64(sender.enc), ciphertext: b64(await sender.seal(bytes(canonical(value)))) };
}
export async function open(privateKey, envelope, context) {
const recipient = await suite.createRecipientContext({ recipientKey: await suite.kem.deserializePrivateKey(unb64(privateKey)), enc: unb64(envelope.enc), info: bytes(context) });
return JSON.parse(new TextDecoder().decode(await recipient.open(unb64(envelope.ciphertext))));
}
export async function objectId(secret, key) {
const k = await crypto.subtle.importKey('raw', unb64(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
return b64(await crypto.subtle.sign('HMAC', k, bytes(key)));
}
export async function fingerprint(value) {
const hash = new Uint8Array(await crypto.subtle.digest('SHA-256', bytes(canonical(value))));
// 128 bits, compared in full: short decimal SAS without a commitment phase
// would let an active relay search for a colliding key offline.
return [...hash.slice(0, 16)].map(n => n.toString(16).padStart(2, '0')).join('').match(/.{4}/g).join(' ');
}
export const pairingTranscript = (invite, applicant) => ({ protocol: 'pardis-pair-v1', code: invite.code, sponsor: invite.sponsor, applicant: publicIdentity(applicant) });
export async function requestProof(privateKey, method, path, body, who) {
const proof = { who, time: Date.now(), nonce: crypto.randomUUID(), method, path, hash: await digest(body) };
return { proof, signature: await sign(privateKey, proof) };
}

View File

@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest';
import { canonical, decrypt, encrypt, identity, fingerprint, objectId, open, publicIdentity, randomSecret, seal, sign, verify } from './crypto.js';
describe('end-to-end cryptography', () => {
it('authenticates ciphertext and binds it to its vault, revision and parents', async () => {
const key = randomSecret();
const ciphertext = await encrypt(key, { task: 'Secret chantier' }, 'vault:revision');
expect(await decrypt(key, ciphertext, 'vault:revision')).toEqual({ task: 'Secret chantier' });
await expect(decrypt(key, ciphertext, 'another-vault')).rejects.toThrow();
await expect(decrypt(randomSecret(), ciphertext, 'vault:revision')).rejects.toThrow();
await expect(decrypt(key, { ...ciphertext, ciphertext: ciphertext.ciphertext.slice(0, -5) + 'AAAAA' }, 'vault:revision')).rejects.toThrow();
});
it('wraps keys using RFC9180 HPKE for only the intended recipient', async () => {
const a = await identity('Téléphone'); const b = await identity('Ordinateur');
const envelope = await seal(a.wrapPublic, { secret: randomSecret() }, 'pairing-session');
expect(await open(a.wrapPrivate, envelope, 'pairing-session')).toHaveProperty('secret');
await expect(open(b.wrapPrivate, envelope, 'pairing-session')).rejects.toThrow();
await expect(open(a.wrapPrivate, envelope, 'other-session')).rejects.toThrow();
expect(publicIdentity(a)).not.toHaveProperty('privateKey');
expect(publicIdentity(a)).not.toHaveProperty('wrapPrivate');
});
it('verifies canonical signed records, key-bound fingerprints and opaque object identifiers', async () => {
const a = await identity('A'); const b = await identity('B');
const signature = await sign(a.privateKey, { b: 2, a: 1 });
expect(await verify(a.publicKey, { a: 1, b: 2 }, signature)).toBe(true);
expect(await verify(b.publicKey, { a: 1, b: 2 }, signature)).toBe(false);
expect(await verify(a.publicKey, { a: 1, b: 3 }, signature)).toBe(false);
expect(await fingerprint(publicIdentity(a))).not.toEqual(await fingerprint(publicIdentity(b)));
const key = randomSecret();
expect(await objectId(key, 'day:2026-10-10')).toEqual(await objectId(key, 'day:2026-10-10'));
expect(await objectId(randomSecret(), 'day:2026-10-10')).not.toEqual(await objectId(key, 'day:2026-10-10'));
expect(canonical({ z: null, a: ['x'] })).toBe('{"a":["x"],"z":null}');
});
});

40
src/lib/sync/model.js Normal file
View File

@@ -0,0 +1,40 @@
import { canonical } from './crypto.js';
import { parseSettings } from '../backup.js';
import { AUTOMATION_DEFAULTS } from '../automation-options.js';
export const settingKeys = ['workdays', 'nonWorkingDates', 'dayStart', 'dayEnd', 'lunchStart', 'lunchMinutes', 'lunchType', 'roundingMinutes', 'retentionDays', ...Object.keys(AUTOMATION_DEFAULTS)];
export const same = (a, b) => canonical(a) === canonical(b);
export function dayValue(day, entries) {
return day ? { day, entries: entries.filter(e => e.date === day.date).sort((a, b) => a.seq - b.seq).map(({ id, ...entry }) => entry) } : null;
}
export function validateValue(key, value) {
if (typeof key !== 'string') throw new Error('Objet synchronisé invalide.');
if (key.startsWith('setting:')) {
const name = key.slice(8);
if (!settingKeys.includes(name)) throw new Error('Réglage synchronisé inconnu.');
if (value !== null && !Object.hasOwn(parseSettings({ [name]: value }), name)) throw new Error('Réglage inconnu.');
return;
}
const date = key.slice(4);
if (!key.startsWith('day:') || !/^\d{4}-\d{2}-\d{2}$/.test(date) || new Date(`${date}T00:00:00Z`).toISOString().slice(0, 10) !== date) throw new Error('Date synchronisée invalide.');
if (value === null) return;
const { day, entries } = value;
const ts = n => typeof n === 'number' && Number.isFinite(n) && Math.abs(n) <= 8640000000000000;
if (!day || day.date !== date || !['active', 'closed'].includes(day.status) || !Array.isArray(entries) || !entries.length ||
!['startedTs', 'plannedStartTs', 'plannedEndTs'].every(k => ts(day[k])) ||
![null, 'panier', 'restaurant'].includes(day.lunchType) || !(day.restaurant == null || typeof day.restaurant === 'string')) throw new Error('Journée synchronisée invalide.');
for (let i = 0; i < entries.length; i++) {
const e = entries[i];
if (e.date !== date || e.seq !== i || !['work', 'travel', 'break', 'lunch'].includes(e.kind) || typeof e.task !== 'string' || !(e.client == null || typeof e.client === 'string') || !ts(e.startTs) ||
!(e.endTs === null ? i === entries.length - 1 && day.status === 'active' : ts(e.endTs) && e.endTs >= e.startTs) ||
(i > 0 && (entries[i - 1].endTs == null || entries[i - 1].endTs > e.startTs))) throw new Error('Postes synchronisés incohérents.');
}
if (day.startedTs !== entries[0].startTs || (day.status === 'active' ? day.endedTs !== null || entries.at(-1).endTs !== null : !ts(day.endedTs) || day.endedTs !== entries.at(-1).endTs)) throw new Error('Bornes de journée incohérentes.');
}
export function headsOf(versions) {
const parents = new Set(versions.flatMap(v => v.parents));
return versions.filter(v => !parents.has(v.id));
}
export function revisionHeader(v) {
return { v: 1, vault: v.vault, id: v.id, object: v.object, parents: v.parents, epoch: v.epoch, device: v.device };
}

View File

@@ -0,0 +1,4 @@
export const syncState = $state({
loaded: false, enabled: false, linked: false, busy: false, error: '',
pending: false, lastSync: null, deviceId: '', owner: '', devices: [], conflicts: [], revoked: false,
});

139
src/lib/sync/store.js Normal file
View File

@@ -0,0 +1,139 @@
import { db } from '../db.js';
import { DEFAULT_SETTINGS } from '../settings.svelte.js';
import { dayValue, headsOf, same, settingKeys, validateValue } from './model.js';
export const tables = database => [database.days, database.entries, database.settings, database.syncState, database.syncObjects, database.syncVersions, database.syncOutbox, database.syncConflicts];
export async function getConfig(database = db) { return (await database.syncState.get('config'))?.value ?? null; }
export async function saveConfig(value, database = db) { await database.syncState.put({ key: 'config', value }); }
async function values(database) {
const [days, entries, settings] = await Promise.all([database.days.toArray(), database.entries.toArray(), database.settings.get('app')]);
const result = new Map(days.map(day => [`day:${day.date}`, dayValue(day, entries)]));
// A joining device with no saved preferences must not overwrite existing rules.
if (settings) for (const key of settingKeys) result.set(`setting:${key}`, settings.value[key] ?? DEFAULT_SETTINGS[key]);
return result;
}
async function localRevision(database, config, key, value, parents) {
const version = { id: crypto.randomUUID(), key, value, parents, device: config.device.id, modifiedAt: Date.now() };
await database.syncVersions.add(version);
await database.syncOutbox.add(version);
await database.syncObjects.put({ key, value, localHeads: [version.id] });
return version;
}
// Called under the same transaction used to receive remote changes. Persisted
// object snapshots let a crash at any point reconstruct every unsent change.
export async function captureChanges(database = db) {
return database.transaction('rw', tables(database), async () => {
const config = await getConfig(database);
if (!config?.enabled) return;
const current = await values(database);
const previous = await database.syncObjects.toArray();
const byKey = new Map(previous.map(o => [o.key, o]));
for (const old of previous) if (!current.has(old.key)) current.set(old.key, null);
for (const [key, value] of current) {
const old = byKey.get(key);
if (same(value, old?.value ?? null)) continue;
validateValue(key, value);
await localRevision(database, config, key, value, old?.localHeads ?? []);
}
await database.syncState.put({ key: 'dirty', value: false });
});
}
async function install(database, key, value) {
validateValue(key, value);
if (key.startsWith('setting:')) {
const rec = await database.settings.get('app');
const next = { ...DEFAULT_SETTINGS, ...rec?.value, [key.slice(8)]: value ?? DEFAULT_SETTINGS[key.slice(8)] };
await database.settings.put({ key: 'app', value: next });
} else {
const date = key.slice(4);
const active = await database.days.where('status').equals('active').toArray();
if (value?.day.status === 'active' && active.some(d => d.date !== date)) return false;
await database.entries.where('date').equals(date).delete();
if (value === null) await database.days.delete(date);
else {
await database.days.put(value.day);
await database.entries.bulkAdd(value.entries.map(({ id, ...e }) => e));
}
}
return true;
}
async function reconcileObjects(database) {
const versions = await database.syncVersions.toArray();
const grouped = new Map();
for (const version of versions) {
if (!grouped.has(version.key)) grouped.set(version.key, []);
grouped.get(version.key).push(version);
}
const headsByKey = new Map([...grouped].map(([key, list]) => [key, headsOf(list)]));
const keys = [...grouped.keys()];
// Apply closures/deletions before active days to allow a normal day rollover.
keys.sort((a, b) => Number(headsByKey.get(a).some(v => v.value?.day?.status === 'active')) - Number(headsByKey.get(b).some(v => v.value?.day?.status === 'active')));
for (const key of keys) {
const heads = headsByKey.get(key);
if (!heads.length) throw new Error('Historique de synchronisation invalide.');
const object = await database.syncObjects.get(key);
const equivalent = heads.every(v => same(v.value, heads[0].value));
const installed = equivalent && (same(object?.value ?? null, heads[0].value) || await install(database, key, heads[0].value));
if (installed) {
await database.syncObjects.put({ key, value: heads[0].value, localHeads: heads.map(v => v.id) });
await database.syncConflicts.delete(key);
} else {
await database.syncConflicts.put({ key, versions: heads, localValue: object?.value ?? null,
reason: equivalent ? 'Une autre journée est déjà en cours sur cet appareil.' : 'Plusieurs versions ont été modifiées indépendamment.' });
}
}
// Installing a single shared setting supplies defaults for the others. Record
// them as baselines, not as new user edits to be pushed on the next scan.
const current = await values(database);
for (const [key, value] of current) if (key.startsWith('setting:') && !await database.syncObjects.get(key)) await database.syncObjects.put({ key, value, localHeads: [] });
await database.syncState.put({ key: 'dirty', value: false });
}
export async function receiveVersions(records, cursor, vault, database = db) {
return database.transaction('rw', tables(database), async () => {
const config = await getConfig(database);
if (!config?.enabled || config.vault !== vault) throw new Error('Synchronisation interrompue.');
await captureChanges(database);
for (const record of records) {
validateValue(record.key, record.value);
const known = await database.syncVersions.get(record.id);
if (known) {
if (!same(known, record)) throw new Error('Une révision reçue a changé de contenu.');
continue;
}
for (const id of record.parents) {
const parent = await database.syncVersions.get(id);
if (!parent || parent.key !== record.key) throw new Error('Historique incomplet : révision parente absente.');
}
await database.syncVersions.add(record);
}
await reconcileObjects(database);
await database.syncState.put({ key: 'cursor', value: Math.max(cursor, (await database.syncState.get('cursor'))?.value ?? 0) });
});
}
export async function resolveConflict(key, chosenId, expectedHeads, database = db) {
return database.transaction('rw', tables(database), async () => {
await captureChanges(database);
const config = await getConfig(database);
if (!config?.enabled) throw new Error('Activez la synchronisation avant de résoudre un conflit.');
const heads = headsOf(await database.syncVersions.where('key').equals(key).toArray());
if (!same(heads.map(v => v.id).sort(), [...expectedHeads].sort())) throw new Error('Les versions ont changé. Actualisez la liste.');
const value = chosenId === 'local' ? (await values(database)).get(key) ?? null : heads.find(v => v.id === chosenId)?.value;
if (value === undefined) throw new Error('Version inconnue.');
if (!await install(database, key, value)) throw new Error('Terminez d’abord la journée déjà en cours, puis réessayez.');
await localRevision(database, config, key, value, heads.map(v => v.id));
await database.syncConflicts.delete(key);
await reconcileObjects(database);
});
}
export async function canAutomate(database = db) {
const config = await getConfig(database);
if (!config) return true;
// Pausing transport does not turn a secondary device into an automation owner.
return config.owner === config.device.id && !config.revoked && await database.syncConflicts.count() === 0;
}
export async function detach(erase = false, database = db) {
await database.transaction('rw', tables(database), async () => {
for (const table of [database.syncState, database.syncObjects, database.syncVersions, database.syncOutbox, database.syncConflicts]) await table.clear();
if (erase) { await database.days.clear(); await database.entries.clear(); await database.settings.clear(); }
});
}

116
src/lib/sync/store.test.js Normal file
View File

@@ -0,0 +1,116 @@
import 'fake-indexeddb/auto';
import Dexie from 'dexie';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { db, importBackup, patchEntry } from '../db.js';
import { captureChanges, canAutomate, detach, getConfig, receiveVersions, resolveConflict, saveConfig, tables } from './store.js';
import { validateValue } from './model.js';
const vault = 'vault-test';
const day = (date = '2026-10-08', task = 'Local', active = false) => {
const start = new Date(`${date}T07:00:00Z`).getTime(), end = start + 3600000;
return { day: { date, status: active ? 'active' : 'closed', plannedStartTs: start, plannedEndTs: end, startedTs: start, endedTs: active ? null : end, lunchType: null, restaurant: null, closePromptedTs: null },
entries: [{ date, seq: 0, kind: 'work', task, client: null, startTs: start, endTs: active ? null : end }] };
};
const remote = (key, value, parents = []) => ({ id: crypto.randomUUID(), key, value, parents, device: 'remote', modifiedAt: Date.now() });
async function insert(value) { await db.transaction('rw', db.days, db.entries, async () => { await db.days.put(value.day); await db.entries.bulkAdd(value.entries); }); }
beforeEach(async () => { await detach(true); await saveConfig({ enabled: true, vault, device: { id: 'local' }, owner: 'local' }); });
afterEach(() => vi.restoreAllMocks());
describe('durable offline synchronization', () => {
it('upgrades an existing v2 database without altering its history or enabling sync', async () => {
await db.delete();
const legacy = new Dexie('TimeLogDB');
legacy.version(2).stores({ days: 'date, status', entries: '++id, date, [date+seq], startTs', settings: 'key' });
const value = day();
await legacy.table('days').put(value.day); await legacy.table('entries').bulkAdd(value.entries);
await legacy.table('settings').put({ key: 'app', value: { theme: 'nuit' } });
legacy.close();
await db.open();
expect((await db.days.toArray())[0]).toEqual(value.day);
expect((await db.entries.toArray())[0].task).toBe('Local');
expect((await db.settings.get('app')).value.theme).toBe('nuit');
expect(await getConfig()).toBeNull();
expect(await canAutomate()).toBe(true);
await patchEntry((await db.entries.toArray())[0].id, { task: 'After upgrade' });
expect((await db.syncState.get('dirty')).value).toBe(true);
});
it('journals ordinary writes atomically and survives reopening IndexedDB', async () => {
await insert(day());
expect((await db.syncState.get('dirty')).value).toBe(true);
db.close(); await db.open();
await captureChanges();
expect(await db.syncOutbox.count()).toBe(1);
const original = await db.syncOutbox.toArray();
await captureChanges(); expect(await db.syncOutbox.toArray()).toEqual(original);
await expect(db.transaction('rw', db.entries, async () => { await db.entries.clear(); throw new Error('rollback'); })).rejects.toThrow('rollback');
expect(await db.entries.count()).toBe(1);
expect((await db.syncState.get('dirty')).value).toBe(false);
});
it('keeps concurrent versions and resolves them with both parents', async () => {
await insert(day()); await captureChanges();
const [local] = await db.syncOutbox.toArray();
const incoming = remote(local.key, day(undefined, 'Remote'));
await receiveVersions([incoming], 1, vault);
expect((await db.entries.toArray())[0].task).toBe('Local');
const conflict = await db.syncConflicts.get(local.key);
expect(conflict.versions).toHaveLength(2);
expect(await canAutomate()).toBe(false);
await resolveConflict(local.key, incoming.id, conflict.versions.map(v => v.id));
expect((await db.entries.toArray())[0].task).toBe('Remote');
expect(await db.syncConflicts.count()).toBe(0);
expect((await db.syncOutbox.toArray()).find(v => v.parents.length === 2).parents.sort()).toEqual([local.id, incoming.id].sort());
});
it('applies distinct days, active records, and shared settings without copying theme or backup state', async () => {
const a = remote('day:2026-10-08', day());
const b = remote('day:2026-10-09', day('2026-10-09', 'Current', true));
await receiveVersions([a, b, remote('setting:roundingMinutes', 5)], 3, vault);
expect(await db.days.count()).toBe(2);
expect((await db.days.get('2026-10-09')).status).toBe('active');
expect((await db.settings.get('app')).value.roundingMinutes).toBe(5);
expect(await db.syncOutbox.count()).toBe(0);
await captureChanges(); expect(await db.syncOutbox.count()).toBe(0);
expect(() => validateValue('setting:theme', 'nuit')).toThrow();
expect(() => validateValue('setting:backup-status', {})).toThrow();
});
it('does not install a second active day', async () => {
await insert(day('2026-10-08', 'Local active', true)); await captureChanges();
const other = remote('day:2026-10-09', day('2026-10-09', 'Remote active', true));
await receiveVersions([other], 1, vault);
expect(await db.days.where('status').equals('active').count()).toBe(1);
expect((await db.syncConflicts.get(other.key)).reason).toMatch(/autre journée/);
await expect(resolveConflict(other.key, other.id, [other.id])).rejects.toThrow(/Terminez/);
await resolveConflict(other.key, 'local', [other.id]);
expect(await db.syncConflicts.count()).toBe(0);
});
it('propagates tombstones without resurrecting old revisions, retaining deletion/edit conflicts', async () => {
const first = remote('day:2026-10-08', day()); await receiveVersions([first], 1, vault);
const deleted = remote(first.key, null, [first.id]); await receiveVersions([deleted], 2, vault);
expect(await db.days.count()).toBe(0);
await receiveVersions([first], 2, vault); expect(await db.days.count()).toBe(0);
const offlineEdit = remote(first.key, day(undefined, 'Offline'), [first.id]); await receiveVersions([offlineEdit], 3, vault);
expect((await db.syncConflicts.get(first.key)).versions).toHaveLength(2);
expect(await db.days.count()).toBe(0);
});
it('captures edits made during network I/O before receiving a remote version', async () => {
const base = remote('day:2026-10-08', day()); await receiveVersions([base], 1, vault);
const entry = (await db.entries.toArray())[0]; await patchEntry(entry.id, { task: 'Concurrent local' });
await receiveVersions([remote(base.key, day(undefined, 'Concurrent remote'), [base.id])], 2, vault);
expect((await db.entries.toArray())[0].task).toBe('Concurrent local');
expect(await db.syncConflicts.count()).toBe(1);
});
it('rolls back cursor, history and data together if installing a remote day fails', async () => {
vi.spyOn(db.entries, 'bulkAdd').mockRejectedValueOnce(new Error('Disk full'));
await expect(receiveVersions([remote('day:2026-10-08', day())], 1, vault)).rejects.toThrow('Disk full');
expect(await db.days.count()).toBe(0); expect(await db.syncVersions.count()).toBe(0); expect(await db.syncState.get('cursor')).toBeUndefined();
});
it('preserves credentials through backup replacement and disables secondary automations', async () => {
await saveConfig({ enabled: true, vault, device: { id: 'secondary' }, owner: 'primary' });
await importBackup({ days: [], entries: [], settings: null }, 'replace');
expect((await getConfig()).vault).toBe(vault); expect(await canAutomate()).toBe(false);
await detach(); expect(await canAutomate()).toBe(true);
});
it('refuses changed revision content and unknown ancestry', async () => {
const first = remote('day:2026-10-08', day()); await receiveVersions([first], 1, vault);
await expect(receiveVersions([{ ...first, value: null }], 2, vault)).rejects.toThrow(/changé/);
await expect(receiveVersions([remote(first.key, null, ['unknown'])], 2, vault)).rejects.toThrow(/parente/);
});
});

View File

@@ -6,6 +6,8 @@
import { dayState, refresh } from '../lib/day.svelte.js'; import { dayState, refresh } from '../lib/day.svelte.js';
import { settings, settingsError, updateSettings } from '../lib/settings.svelte.js'; import { settings, settingsError, updateSettings } from '../lib/settings.svelte.js';
import { syncState } from '../lib/sync/state.svelte.js';
let purged = $state(null); let purged = $state(null);
let purgeOpen = $state(false); let purgeOpen = $state(false);
let purgeError = $state(''); let purgeError = $state('');
@@ -43,7 +45,7 @@
<section class="card stack"> <section class="card stack">
<h2>Vos données sur cet appareil</h2> <h2>Vos données sur cet appareil</h2>
<p class="small">Vos journées, postes et réglages sont enregistrés dans ce navigateur, sur cet appareil, et restent accessibles hors ligne. Ils ne sont pas synchronisés entre vos appareils.</p> <p class="small">Vos journées, postes et réglages sont enregistrés dans ce navigateur, sur cet appareil, et restent accessibles hors ligne. {syncState.linked ? 'Cet appareil est lié à un coffre chiffré.' : 'La synchronisation entre appareils est facultative.'}</p>
<p class="small">Effacer les données du navigateur ou perdre cet appareil peut vous faire perdre votre historique. Exportez régulièrement un fichier, vérifiez-le puis gardez une copie ailleurs : vous pourrez le réimporter pour retrouver vos données ou les transférer sur un autre appareil.</p> <p class="small">Effacer les données du navigateur ou perdre cet appareil peut vous faire perdre votre historique. Exportez régulièrement un fichier, vérifiez-le puis gardez une copie ailleurs : vous pourrez le réimporter pour retrouver vos données ou les transférer sur un autre appareil.</p>
</section> </section>
@@ -51,6 +53,8 @@
<p class="error card" role="alert">{settingsError.message}</p> <p class="error card" role="alert">{settingsError.message}</p>
{/if} {/if}
<section class="card stack"><h2>Synchronisation facultative</h2><p>Retrouvez vos journées sur vos appareils avec un coffre chiffré, sans e-mail ni mot de passe.</p><button onclick={() => go('sync')}>Synchronisation et appareils</button></section>
<BackupPanel /> <BackupPanel />
<section class="card stack"> <section class="card stack">
@@ -87,6 +91,7 @@
</fieldset> </fieldset>
<Sheet open={purgeOpen} title="Confirmer la purge" onclose={() => { if (!purgeBusy) purgeOpen = false; }}> <Sheet open={purgeOpen} title="Confirmer la purge" onclose={() => { if (!purgeBusy) purgeOpen = false; }}>
{#if syncState.linked}<p>Ces journées seront aussi supprimées du coffre et des autres appareils à leur prochaine synchronisation. Les modifications concurrentes seront conservées comme conflits. L’historique des révisions et les sauvegardes serveur suivent la politique de conservation de l’hébergeur.</p>{/if}
<p>Journées concernées : {reviewedPurge.length ? reviewedPurge.map(item => item.day.date).join(', ') : 'aucune'}.</p> <p>Journées concernées : {reviewedPurge.length ? reviewedPurge.map(item => item.day.date).join(', ') : 'aucune'}.</p>
<p>Une sauvegarde récupérable sera conservée sur cet appareil avant la suppression.</p> <p>Une sauvegarde récupérable sera conservée sur cet appareil avant la suppression.</p>
{#if purgeError}<p role="alert">{purgeError}</p>{/if} {#if purgeError}<p role="alert">{purgeError}</p>{/if}

View File

@@ -0,0 +1,260 @@
<script>
import { onMount } from 'svelte';
import QRCode from 'qrcode';
import { go } from '../lib/router.svelte.js';
import { dayState, refresh } from '../lib/day.svelte.js';
import { loadSettings, WEEKDAY_LABELS } from '../lib/settings.svelte.js';
import { AUTOMATION_OPTIONS } from '../lib/automation-options.js';
import { syncState } from '../lib/sync/state.svelte.js';
import { prepareVault, activateVault, prepareRecoveryReset, commitRecoveryReset, recoverVault, startInvitation, cancelInvitation, pollInvitation, joinInvitation, approveInvitation, finishJoining, synchronize, setSyncEnabled, revokeDevice, disconnect, deleteVault, loadSyncStatus, takeAutomationRole } from '../lib/sync/client.js';
import { resolveConflict } from '../lib/sync/store.js';
const query = new URLSearchParams(location.hash.split('?')[1] ?? '');
let name = $state('Mon appareil');
let code = $state(query.get('invite') ?? '');
let error = $state('');
let busy = $state(false);
let recovery = $state('');
let recoveryReset = $state(false);
let savedRecovery = $state(false);
let recoveryInput = $state('');
let invite = $state(null);
let qr = $state('');
let applicant = $state(null);
let comparison = $state('');
let compared = $state(false);
let applicantName = $state('Nouvel appareil');
let joining = $state(null);
let message = $state('');
let action = $state('');
let target = $state('');
let deleteText = $state('');
let erase = $state(false);
let alive = true;
let polling = false;
async function run(work) {
if (busy || dayState.importing) return;
busy = true; error = ''; message = '';
try { await work(); await loadSyncStatus(); await loadSettings(); await refresh(); }
catch (cause) { error = cause.message; }
finally { busy = false; }
}
function download(text, filename) {
const url = URL.createObjectURL(new Blob([text], { type: 'application/json' }));
const link = document.createElement('a'); link.href = url; link.download = filename;
document.body.appendChild(link); link.click(); link.remove();
setTimeout(() => URL.revokeObjectURL(url), 60000);
}
async function newInvitation() {
invite = await startInvitation(); applicant = null; comparison = ''; compared = false;
qr = await QRCode.toDataURL(invite.url, { width: 260, margin: 2, errorCorrectionLevel: 'M' });
}
async function stopInvitation() {
invite = null; applicant = null; comparison = ''; compared = false; qr = '';
await cancelInvitation();
}
async function poll() {
if (polling || !alive || document.visibilityState === 'hidden') return;
polling = true;
try {
if (invite) {
const current = invite.code;
const result = await pollInvitation(current);
if (alive && invite?.code === current) {
if (comparison !== result.fingerprint) compared = false;
applicant = result.applicant; comparison = result.fingerprint;
}
}
if (joining && await finishJoining()) {
joining = null; message = 'Appareil lié. Les journées distinctes sont réunies ; les versions concurrentes sont conservées ci-dessous.';
await loadSettings(); await refresh();
}
} catch (cause) {
if (alive) { error = cause.message; await stopInvitation(); joining = null; }
} finally { polling = false; }
}
onMount(() => {
void loadSyncStatus();
const timer = setInterval(poll, 3000);
const hide = () => { if (document.visibilityState === 'hidden' && invite) void stopInvitation(); };
document.addEventListener('visibilitychange', hide);
const leave = () => { void stopInvitation(); };
window.addEventListener('pagehide', leave);
return () => { alive = false; clearInterval(timer); document.removeEventListener('visibilitychange', hide); window.removeEventListener('pagehide', leave); void cancelInvitation(); };
});
const settingLabels = {
workdays: 'Jours travaillés', nonWorkingDates: 'Jours non travaillés',
dayStart: 'Début de journée', dayEnd: 'Fin de journée', lunchStart: 'Début du repas',
lunchMinutes: 'Durée du repas', lunchType: 'Type de repas', roundingMinutes: 'Arrondi',
retentionDays: 'Durée de conservation', ...Object.fromEntries(AUTOMATION_OPTIONS.map(option => [option.key, `Automatisme : ${option.name}`])),
};
function settingValue(key, value) {
const name = key.slice(8);
if (typeof value === 'boolean') return value ? 'Activé' : 'Désactivé';
if (name === 'workdays') return value.map(day => WEEKDAY_LABELS.find(label => label.value === day)?.label).join(', ') || 'Aucun jour';
if (name === 'nonWorkingDates') return value.join(', ') || 'Aucune date';
if (name === 'lunchType') return value === 'panier' ? 'Panier' : 'Restaurant';
if (name === 'retentionDays') return value ? `${value} jours` : 'Sans limite';
if (['lunchMinutes', 'roundingMinutes'].includes(name)) return `${value} minutes`;
return String(value);
}
const when = value => value ? new Date(value).toLocaleString('fr-FR') : 'jamais';
const deviceName = id => syncState.devices.find(d => d.id === id)?.name ?? 'Appareil';
</script>
<fieldset class="screen" disabled={busy || dayState.importing}>
<h1>Synchronisation et appareils</h1>
<button onclick={() => go('data')}>Retour aux données</button>
<p>Vos saisies sont toujours enregistrées sur cet appareil. La synchronisation facultative partage vos journées et règles avec vos appareils autorisés, sous forme chiffrée.</p>
{#if error}<p class="notice" role="alert">{error}</p>{/if}
{#if message}<p role="status">{message}</p>{/if}
{#if busy}<p role="status">Opération en cours…</p>{/if}
{#if !syncState.linked}
<label>Nom de cet appareil<input bind:value={name} maxlength="80" autocomplete="off" /></label>
<section class="card stack">
<h2>Créer mon coffre</h2>
<p>Le serveur ne peut pas lire votre historique. Sans appareil autorisé ni document de secours, l’accès sera perdu.</p>
<button onclick={() => run(async () => { recovery = await prepareVault(name); recoveryReset = false; savedRecovery = false; })}>Préparer la synchronisation</button>
</section>
<section class="card stack">
<h2>Lier à un appareil existant</h2>
<p>Sur l’appareil déjà lié, ouvrez cet écran puis « Ajouter un appareil ». Saisissez son code ici ou scannez son QR code avec l’appareil photo de votre téléphone.</p>
<p>Les journées déjà présentes ici seront conservées et fusionnées. Les dates communes et les règles différentes pourront nécessiter un choix.</p>
<label>Code d’association<input bind:value={code} maxlength="12" autocomplete="off" autocapitalize="characters" spellcheck="false" /></label>
<button disabled={!!joining} onclick={() => run(async () => { joining = await joinInvitation(code, name, query.get('pin') ?? ''); await poll(); })}>Demander la liaison</button>
{#if joining}
<p>Comparez toute cette empreinte avec celle affichée sur l’appareil d’origine, puis confirmez sur celui-ci :</p>
<code class="fingerprint">{joining.fingerprint}</code>
<p role="status">En attente de confirmation sur l’autre appareil…</p>
<button onclick={() => { joining = null; }}>Arrêter l’attente</button>
{/if}
</section>
<section class="card stack">
<h2>Récupérer mon coffre</h2>
<p>Utilisez votre document de secours. Il donne accès à tout votre coffre ; gardez-le à l’abri.</p>
<label>Document de secours<input type="file" accept="application/json,.json" onchange={event => run(async () => {
const file = event.currentTarget.files?.[0];
if (file) { if (file.size > 50000) throw new Error('Document trop volumineux.'); recoveryInput = await file.text(); }
})} /></label>
<label>Ou coller le document<textarea bind:value={recoveryInput} rows="3" spellcheck="false" autocomplete="off"></textarea></label>
<p>Les données présentes ici seront conservées. La récupération retire les anciens appareils et renouvelle les clés ; vous pourrez les associer à nouveau.</p>
<button disabled={!recoveryInput} onclick={() => run(async () => { await recoverVault(recoveryInput, name); recoveryInput = ''; message = 'Accès récupéré. Les anciens appareils ont été retirés.'; })}>Récupérer l’accès</button>
</section>
{:else}
<section class="card stack">
<h2>État de la synchronisation</h2>
<p>{syncState.revoked ? 'Cet appareil a été révoqué.' : syncState.enabled ? syncState.busy ? 'Synchronisation en cours…' : syncState.pending ? 'Enregistré sur cet appareil · en attente d’envoi' : 'Modifications envoyées' : 'Synchronisation en pause'}</p>
<p>Dernière synchronisation : {when(syncState.lastSync)}.</p>
{#if syncState.error}<p role="status">{syncState.error} Vos données restent enregistrées ici.</p>{/if}
{#if !syncState.revoked}
<button disabled={!syncState.enabled || syncState.busy} onclick={() => run(synchronize)}>Synchroniser maintenant</button>
<button onclick={() => run(() => setSyncEnabled(!syncState.enabled))}>{syncState.enabled ? 'Mettre en pause' : 'Reprendre la synchronisation'}</button>
{/if}
</section>
<section class="card stack">
<h2>Appareils autorisés</h2>
{#each syncState.devices as device}
<div class="device stack">
<strong>{device.name}{device.id === syncState.deviceId ? ' (cet appareil)' : ''}</strong>
<span>{device.revoked ? 'Révoqué' : device.id === syncState.owner ? 'Responsable des automatismes' : 'Saisie manuelle et consultation'}</span>
<span class="dim">Dernier contact : {when(device.seen)}</span>
{#if !device.revoked && device.id !== syncState.deviceId}<button disabled={!syncState.enabled} onclick={() => { action = 'revoke'; target = device.id; }}>Retirer cet appareil…</button>{/if}
</div>
{/each}
{#if syncState.owner !== syncState.deviceId && !syncState.revoked}<button disabled={!syncState.enabled} onclick={() => run(takeAutomationRole)}>Utiliser cet appareil pour les automatismes</button>{/if}
<p class="dim">Les automatismes nécessitent que l’appareil responsable puisse exécuter l’application. Un ancien responsable hors ligne apprend le transfert à sa reconnexion ; ses changements éventuels sont conservés.</p>
<button disabled={!syncState.enabled || !!invite} onclick={() => run(newInvitation)}>Ajouter un appareil</button>
{#if invite}
<p>Invitation valable jusqu’à {new Date(invite.expires).toLocaleTimeString('fr-FR')}. Gardez cet écran visible ; le quitter annule l’invitation.</p>
<strong class="code">{invite.code.slice(0, 4)} {invite.code.slice(4)}</strong>
{#if qr}<img src={qr} width="260" height="260" alt="QR code d’association à scanner sur le nouvel appareil" />{/if}
{#if applicant}
<p>Une demande est arrivée. Comparez l’empreinte complète sur les deux écrans :</p>
<code class="fingerprint">{comparison}</code>
<label>Nom du nouvel appareil<input bind:value={applicantName} maxlength="80" /></label>
<label class="check"><input type="checkbox" bind:checked={compared} /> Les deux empreintes sont identiques</label>
<button disabled={!compared} onclick={() => run(async () => { await approveInvitation(invite.code, applicant, applicantName); invite = null; applicant = null; message = 'Appareil autorisé.'; })}>Autoriser cet appareil</button>
{:else}<p role="status">En attente d’une demande sur cet écran…</p>{/if}
<button onclick={() => run(stopInvitation)}>Annuler l’invitation</button>
{/if}
</section>
<section class="card stack">
<h2>Versions à départager ({syncState.conflicts.length})</h2>
{#if !syncState.conflicts.length}<p>Aucun conflit.</p>{/if}
{#each syncState.conflicts as conflict}
<article class="stack conflict">
<h3>{conflict.key.startsWith('day:') ? `Journée du ${conflict.key.slice(4)}` : settingLabels[conflict.key.slice(8)]}</h3>
<p>{conflict.reason} Les automatismes sont suspendus sur cet appareil pendant la résolution.</p>
{#each conflict.versions as version}
<div class="version stack">
<strong>{deviceName(version.device)} · {when(version.modifiedAt)}</strong>
{#if version.value === null}<p>Suppression</p>
{:else if conflict.key.startsWith('day:')}
<p>{version.value.day.status === 'active' ? 'En cours' : 'Terminée'}</p>
<ul>{#each version.value.entries as entry}<li>{new Date(entry.startTs).toLocaleTimeString('fr-FR', {hour: '2-digit', minute: '2-digit'})}–{entry.endTs === null ? 'en cours' : new Date(entry.endTs).toLocaleTimeString('fr-FR', {hour: '2-digit', minute: '2-digit'})} · {entry.task} {entry.client ?? ''}</li>{/each}</ul>
{:else}<p>{settingValue(conflict.key, version.value)}</p>{/if}
<button disabled={!syncState.enabled} onclick={() => run(async () => { await resolveConflict(conflict.key, version.id, conflict.versions.map(v => v.id)); await synchronize(); })}>Conserver cette version</button>
</div>
{/each}
<button disabled={!syncState.enabled} onclick={() => run(async () => { await resolveConflict(conflict.key, 'local', conflict.versions.map(v => v.id)); await synchronize(); })}>Conserver l’état actuel de cet appareil</button>
<p class="dim">Les autres versions restent dans l’historique chiffré. Vous pouvez ensuite corriger la journée dans son écran habituel.</p>
</article>
{/each}
</section>
<section class="card stack">
<h2>Secours et déconnexion</h2>
<p>Le document de secours n’est pas conservé sur les appareils liés. Vous pouvez en préparer un nouveau ; après validation, le précédent ne permettra plus de récupérer le coffre.</p>
<button disabled={!syncState.enabled} onclick={() => run(async () => { recovery = await prepareRecoveryReset(); recoveryReset = true; savedRecovery = false; })}>Renouveler le document de secours…</button>
<button onclick={() => { action = 'disconnect'; erase = false; }}>Déconnecter cet appareil…</button>
<button disabled={syncState.revoked} class="btn-danger" onclick={() => { action = 'delete'; deleteText = ''; }}>Supprimer le coffre en ligne…</button>
</section>
{/if}
{#if recovery}
<section class="card stack">
<h2>Conserver le document de secours</h2>
<p>Enregistrez-le ailleurs que sur cet appareil, par exemple dans un gestionnaire de mots de passe. Toute personne qui le possède peut accéder au coffre.</p>
<button onclick={() => download(recovery, 'pardis-secours.json')}>Télécharger le document de secours</button>
<details><summary>Afficher le document pour le copier</summary><textarea readonly value={recovery} rows="6" aria-label="Contenu du document de secours" spellcheck="false"></textarea></details>
<label class="check"><input type="checkbox" bind:checked={savedRecovery} /> J’ai conservé une copie du document à part</label>
<button disabled={!savedRecovery} onclick={() => run(async () => { if (recoveryReset) await commitRecoveryReset(); else await activateVault(); recovery = ''; message = 'Synchronisation configurée.'; })}>{recoveryReset ? 'Valider le nouveau secours' : 'Activer la synchronisation'}</button>
<button onclick={() => { recovery = ''; }}>Fermer</button>
</section>
{/if}
{#if action}
<section class="card stack" aria-label="Confirmation">
{#if action === 'revoke'}
<h2>Retirer {deviceName(target)} ?</h2>
<p>Son accès sera coupé et les clés renouvelées. Ses copies déjà téléchargées ne peuvent pas être effacées à distance. S’il possède le document de secours, renouvelez-le aussi.</p>
<button class="btn-danger" onclick={() => run(async () => { await revokeDevice(target); action = ''; })}>Confirmer le retrait</button>
{:else if action === 'disconnect'}
<h2>Déconnecter cet appareil ?</h2>
<p>Les changements non envoyés ne seront plus synchronisés. Les autres appareils et le coffre restent accessibles.</p>
<label class="check"><input type="checkbox" bind:checked={erase} /> Effacer aussi toutes les journées, réglages et sauvegardes locales de cet appareil</label>
<button class="btn-danger" onclick={() => run(async () => { await disconnect(erase); action = ''; })}>Confirmer la déconnexion</button>
{:else}
<h2>Supprimer le coffre en ligne ?</h2>
<p>Les données en ligne et les accès des appareils seront supprimés. Les copies déjà téléchargées restent sur les appareils. Les sauvegardes de l’hébergeur suivent sa durée de conservation.</p>
<label>Tapez SUPPRIMER<input bind:value={deleteText} autocomplete="off" /></label>
<button disabled={deleteText !== 'SUPPRIMER'} class="btn-danger" onclick={() => run(async () => { await deleteVault(); action = ''; })}>Confirmer la suppression du coffre</button>
{/if}
<button onclick={() => { action = ''; }}>Annuler</button>
</section>
{/if}
</fieldset>
<style>
fieldset.screen { border: 0; margin: 0; min-width: 0; }
label { display: flex; flex-direction: column; gap: var(--gap); }
.check { flex-direction: row; align-items: start; }
.check input { width: auto; min-height: auto; margin-top: .3rem; }
textarea { width: 100%; box-sizing: border-box; font: inherit; }
.fingerprint, .code { display: block; overflow-wrap: anywhere; font-size: 1.15rem; font-weight: bold; }
img { max-width: 100%; height: auto; align-self: center; }
.device, .version, .conflict { border: var(--border) solid var(--line); border-radius: .4rem; padding: .6rem; }
.notice { color: var(--danger-fg); background: var(--danger); padding: .6rem; }
ul { padding-left: 1.2rem; overflow-wrap: anywhere; }
</style>

133
tests/e2e/sync.spec.js Normal file
View File

@@ -0,0 +1,133 @@
import { test, expect } from '@playwright/test';
// Helpers only touch synthetic data in fresh browser contexts.
async function store(page, name, operation, value = null) {
return page.evaluate(({ name, operation, value }) => new Promise((resolve, reject) => {
const request = indexedDB.open('TimeLogDB');
request.onerror = () => reject(request.error);
request.onsuccess = () => {
const database = request.result;
const tx = database.transaction(name, operation === 'getAll' ? 'readonly' : 'readwrite');
const table = tx.objectStore(name);
const query = operation === 'getAll' ? table.getAll() : operation === 'put' ? table.put(value) : table.clear();
let result;
query.onsuccess = () => { result = query.result; };
tx.oncomplete = () => { database.close(); resolve(result); };
tx.onabort = () => { database.close(); reject(tx.error); };
};
}), { name, operation, value });
}
async function ready(page) {
await page.goto('/#/sync');
await expect(page.getByRole('heading', { name: 'Synchronisation et appareils' })).toBeVisible();
await store(page, 'settings', 'put', { key: 'app', value: { autoStartDay: false } });
// A real clock is necessary for signed HTTP requests.
}
async function create(page) {
await ready(page);
await page.getByLabel('Nom de cet appareil', { exact: true }).fill('Téléphone test');
await page.getByRole('button', { name: 'Préparer la synchronisation', exact: true }).click();
await page.getByText('Afficher le document pour le copier', { exact: true }).click();
const recovery = await page.getByLabel('Contenu du document de secours').inputValue();
await page.getByRole('checkbox', { name: 'J’ai conservé une copie du document à part' }).check();
await page.getByRole('button', { name: 'Activer la synchronisation', exact: true }).click();
await expect(page.getByRole('heading', { name: 'Appareils autorisés', exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: 'Synchroniser maintenant', exact: true })).toBeEnabled();
await expect(page.locator('fieldset')).not.toContainText('Opération impossible');
return recovery;
}
async function sync(page) {
await page.getByRole('button', { name: 'Synchroniser maintenant', exact: true }).click();
await expect(page.getByRole('button', { name: 'Synchroniser maintenant', exact: true })).toBeEnabled();
await expect(page.getByText('Modifications envoyées', { exact: true })).toBeVisible();
}
async function pair(a, b) {
await a.getByRole('button', { name: 'Ajouter un appareil', exact: true }).click();
await expect(a.locator('.code')).toBeVisible();
const code = await a.locator('.code').innerText();
await ready(b);
await b.getByLabel('Code d’association', { exact: true }).fill(code);
await b.getByRole('button', { name: 'Demander la liaison', exact: true }).click();
await expect(b.locator('.fingerprint')).toBeVisible();
await expect(a.locator('.fingerprint')).toBeVisible({ timeout: 15000 });
expect(await a.locator('.fingerprint').innerText()).toBe(await b.locator('.fingerprint').innerText());
await a.getByLabel('Nom du nouvel appareil', { exact: true }).fill('Ordinateur test');
await a.getByRole('checkbox', { name: 'Les deux empreintes sont identiques' }).check();
await a.getByRole('button', { name: 'Autoriser cet appareil', exact: true }).click();
await expect(b.getByRole('heading', { name: 'Appareils autorisés', exact: true })).toBeVisible({ timeout: 15000 });
await sync(b);
}
async function addDay(page, task) {
const date = '2026-10-08', start = Date.parse(`${date}T08:00:00Z`), end = start + 3600000;
await store(page, 'days', 'put', { date, status: 'closed', startedTs: start, endedTs: end, plannedStartTs: start, plannedEndTs: end, lunchType: null, restaurant: null, closePromptedTs: null });
await store(page, 'entries', 'put', { date, seq: 0, kind: 'work', task, client: 'Client synthétique', startTs: start, endTs: end });
}
async function editTask(page, task) {
const entry = (await store(page, 'entries', 'getAll'))[0];
await store(page, 'entries', 'put', { ...entry, task });
}
test('association, modification hors ligne, conflit, rôle et révocation entre deux appareils', async ({ page, browser }) => {
test.setTimeout(90000);
const errors = []; page.on('pageerror', error => errors.push(error.message));
const otherContext = await browser.newContext({ baseURL: 'http://127.0.0.1:4183', viewport: { width: 320, height: 640 } });
const other = await otherContext.newPage(); other.on('pageerror', error => errors.push(error.message));
try {
await create(page); await addDay(page, 'Version initiale'); await sync(page);
await pair(page, other);
expect((await store(other, 'entries', 'getAll'))[0].task).toBe('Version initiale');
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
expect(await other.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
await other.evaluate(() => navigator.serviceWorker.ready.then(() => true));
await page.context().setOffline(true); await otherContext.setOffline(true);
await other.reload();
await expect(other.getByRole('heading', { name: 'Appareils autorisés', exact: true })).toBeVisible();
await editTask(page, 'Modification téléphone'); await editTask(other, 'Modification ordinateur');
await page.context().setOffline(false); await sync(page);
await otherContext.setOffline(false); await sync(other); await sync(page);
await expect(page.getByRole('heading', { name: 'Versions à départager (1)', exact: true })).toBeVisible();
await expect(page.locator('.conflict')).toContainText('Modification téléphone');
await expect(page.locator('.conflict')).toContainText('Modification ordinateur');
const remote = page.locator('.version').filter({ hasText: 'Modification ordinateur' });
await remote.getByRole('button', { name: 'Conserver cette version', exact: true }).click();
await expect(page.getByRole('heading', { name: 'Versions à départager (0)', exact: true })).toBeVisible();
await sync(other);
expect((await store(page, 'entries', 'getAll'))[0].task).toBe('Modification ordinateur');
expect((await store(other, 'entries', 'getAll'))[0].task).toBe('Modification ordinateur');
await other.getByRole('button', { name: 'Utiliser cet appareil pour les automatismes', exact: true }).click();
await expect(other.getByRole('button', { name: 'Utiliser cet appareil pour les automatismes', exact: true })).toHaveCount(0);
await sync(page);
await expect(page.getByRole('button', { name: 'Utiliser cet appareil pour les automatismes', exact: true })).toBeVisible();
await page.getByRole('button', { name: 'Retirer cet appareil…', exact: true }).click();
await page.getByRole('button', { name: 'Confirmer le retrait', exact: true }).click();
await expect(page.getByRole('button', { name: 'Synchroniser maintenant', exact: true })).toBeEnabled();
await other.getByRole('button', { name: 'Synchroniser maintenant', exact: true }).click();
await expect(other.getByText('Cet appareil a été révoqué.', { exact: true })).toBeVisible();
expect((await store(other, 'entries', 'getAll'))[0].task).toBe('Modification ordinateur');
expect(errors).toEqual([]);
} finally { await otherContext.close(); }
});
test('secours après perte des appareils et annulation de l’invitation en quittant l’écran', async ({ page, browser }) => {
test.setTimeout(60000);
const recovery = await create(page); await addDay(page, 'Historique récupérable'); await sync(page);
await page.getByRole('button', { name: 'Ajouter un appareil', exact: true }).click();
await expect(page.locator('.code')).toBeVisible(); const code = await page.locator('.code').innerText();
await page.getByRole('button', { name: 'Retour aux données', exact: true }).click();
const context = await browser.newContext({ baseURL: 'http://127.0.0.1:4183' }); const replacement = await context.newPage();
try {
await ready(replacement); await replacement.getByLabel('Code d’association', { exact: true }).fill(code);
await replacement.getByRole('button', { name: 'Demander la liaison', exact: true }).click();
await expect(replacement.getByRole('alert')).toContainText('expirée');
await expect(page.getByRole('dialog')).toHaveCount(0);
await expect(page.getByText('Une demande est arrivée.', { exact: false })).toHaveCount(0);
await replacement.getByLabel('Ou coller le document').fill(recovery);
await replacement.getByRole('button', { name: 'Récupérer l’accès', exact: true }).click();
await expect(replacement.getByRole('heading', { name: 'Appareils autorisés', exact: true })).toBeVisible();
await sync(replacement);
expect((await store(replacement, 'entries', 'getAll'))[0].task).toBe('Historique récupérable');
const config = (await store(replacement, 'syncState', 'getAll')).find(r => r.key === 'config').value;
expect(config.ring.recovery.privateKey).toBeUndefined();
expect(config.ring.recovery.wrapPrivate).toBeUndefined();
} finally { await context.close(); }
});

5
tests/sync-server.js Normal file
View File

@@ -0,0 +1,5 @@
// Isolated, disposable API for browser tests. Never opens the production DB.
import { createSyncServer } from '../server/app.js';
const server = createSyncServer({ maxVaults: 100 });
server.listen(3001, '127.0.0.1');
for (const signal of ['SIGTERM', 'SIGINT']) process.on(signal, () => server.close());

View File

@@ -5,7 +5,9 @@ import { VitePWA } from 'vite-plugin-pwa';
export default defineConfig({ export default defineConfig({
server: { server: {
host: '0.0.0.0', host: '0.0.0.0',
proxy: { '/api/sync': 'http://127.0.0.1:3001' },
}, },
preview: { proxy: { '/api/sync': 'http://127.0.0.1:3001' } },
plugins: [ plugins: [
svelte(), svelte(),
VitePWA({ VitePWA({
@@ -47,7 +49,7 @@ export default defineConfig({
}), }),
], ],
test: { test: {
include: ['src/**/*.test.js'], include: ['src/**/*.test.js', 'server/**/*.test.js'],
environment: 'node', environment: 'node',
}, },
}); });