From bd4d902ec0b51ee43ea3ec15b6308cf53ae6102b Mon Sep 17 00:00:00 2001
From: iPoower <101516206+iPoower@users.noreply.github.com>
Date: Sat, 24 Oct 2026 13:32:24 +0200
Subject: [PATCH 1/7] feat(backup): durable encrypted outbox, session unlock
and recovery history
---
.github/workflows/tests.yml | 3 +
TESTING.md | 16 +
app.js | 60 +++-
cloud-transport.js | 147 +++++----
cloud-ui.js | 287 +++++++++++++-----
docs/GO_FINAL_CLOUD_20261024.md | 28 ++
docs/cloud-sync.md | 120 ++++----
package-lock.json | 12 +-
package.json | 1 +
...20261024112625_encrypted_cloud_history.sql | 84 +++++
tests/cloud-final-mile-browser.cjs | 101 ++++++
tests/cloud-final-mile.cjs | 92 ++++++
tests/cloud-history.cjs | 51 ++++
tests/cloud-sync.spec.cjs | 8 +-
tests/cloud-ui-browser.cjs | 7 +-
tests/cloud-ui-races.cjs | 15 +-
16 files changed, 818 insertions(+), 214 deletions(-)
create mode 100644 docs/GO_FINAL_CLOUD_20261024.md
create mode 100644 supabase/migrations/20261024112625_encrypted_cloud_history.sql
create mode 100644 tests/cloud-final-mile-browser.cjs
create mode 100644 tests/cloud-final-mile.cjs
create mode 100644 tests/cloud-history.cjs
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index c5321f9..f1abd05 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -92,6 +92,9 @@ jobs:
node tests/deep-checks.cjs
node tests/project-checks.cjs
node tests/cloud-sync.spec.cjs
+ node tests/cloud-final-mile.cjs
+ node tests/cloud-history.cjs
+ BROWSER=${{ matrix.browser }} node tests/cloud-final-mile-browser.cjs
node tests/atomic-restoration.cjs
BROWSER=${{ matrix.browser }} node tests/atomic-restoration-browser.cjs
BROWSER=${{ matrix.browser }} node tests/storage-multitab.cjs
diff --git a/TESTING.md b/TESTING.md
index 5374dad..07c5fea 100644
--- a/TESTING.md
+++ b/TESTING.md
@@ -60,3 +60,19 @@ Le téléchargement local des navigateurs Playwright a échoué (archive reçue
`node tests/project-checks.cjs` vérifie le schéma optionnel du dossier, les preuves datées, les montants inconnus, le zéro explicite, les dépassements, le rejet des entrées invalides et l’échappement HTML. `node tests/project.spec.cjs` ajoute une validation Chromium/WebKit de l’import de profil privé sans perte de cours/labs/preuves, de l’export/import complet, de l’ancien format et du rejet atomique, puis des contrôles de mise en page à 320, 414 et 1280 px. Le Service Worker conserve le nouvel onglet hors ligne. Ces suites rejoignent la CI existante.
Le dossier est conservé lors d’un import ancien qui ne possède pas de champ `career`. Le fichier de profil est un import de cadre personnel uniquement ; il ne remplace aucune progression ni réponse d’école. Les données de test sont fictives. Aucun essai sur iPhone physique ni activation cloud réelle n’est déduit des simulations.
+
+## Cloud final mile — 24/10/2026 (données fictives uniquement)
+
+```sh
+node tests/cloud-sync.spec.cjs
+node tests/cloud-final-mile.cjs
+node tests/cloud-history.cjs
+BROWSER=chromium node tests/cloud-final-mile-browser.cjs
+BROWSER=webkit node tests/cloud-final-mile-browser.cjs
+```
+
+Le troisième test exécute le SQL proposé dans PostgreSQL WASM (PGlite 0.5.11 verrouillé), avec rôles distincts, RLS réelle et fonctions Auth simulées. Il vérifie propriétaire, utilisateur B, anon, anonymous-auth, immutabilité de l'historique, révisions, bornes et rollback transactionnel. Ce n'est pas un test du projet Supabase hébergé.
+
+Le test navigateur utilise deux profils indépendants, la vraie app, AES-GCM et localStorage. Toutes les requêtes fournisseur sont interceptées : choix initial, aller-retour, fermeture/réouverture, session chiffrée, réponse perdue après commit, reprise idempotente, conflit, stockage défaillant, format futur et récupération historique. Les quinze cas N03 restent exécutés ; leur amorçage est adapté à la nouvelle confirmation explicite de premier envoi et leur watchdog évite une fin Node trompeuse lorsqu'une promesse reste suspendue.
+
+La matrice existante conserve provenance, pédagogie, dossier, navigation, stockage/concurrence, Chromium/WebKit, service worker Chromium et tests de publication Pages. Les nouveaux tests ne désactivent aucun contrôle. WebKit n'est pas une recette sur iPhone physique. Recette réelle préparée dans `docs/GO_FINAL_CLOUD_20261024.md`.
diff --git a/app.js b/app.js
index f2a7082..81286fc 100644
--- a/app.js
+++ b/app.js
@@ -188,7 +188,9 @@ function atomicCommit(nextMain,nextDaily){
if(localStorage.getItem(ATOMIC_KEY)!==raw)throw Error("Relecture différente");
atomicBaseline=raw;
atomicSnapshot=JSON.parse(raw);
- storageStatus(ATOMIC_KEY,false);return true;
+ storageStatus(ATOMIC_KEY,false);
+ if(typeof syncBridge!=="undefined"&&syncBridge&&typeof queueMicrotask==="function")queueMicrotask(function(){syncBridge.changed();});
+ return true;
}catch(e){
if(wrote){
try{
@@ -301,7 +303,7 @@ function dailySanitize(value,strict){
});
return out;
}
-function dailyLoad(){try{var data=atomicSnapshot?atomicSnapshot.daily:JSON.parse(storageGet(DAILY_KEY)||"null");return data===null?{days:{}}:dailySanitize(data,true);}catch(e){dailyUnread=true;storageStatus(DAILY_KEY,true);return{days:{}};}}
+function dailyLoad(){try{var data=atomicSnapshot?atomicSnapshot.daily:JSON.parse(storageGet(DAILY_KEY)||"null");if(data===null)return{days:{}};var clean=dailySanitize(data,true);assertSnapshotFields(data,clean);return clean;}catch(e){dailyUnread=true;storageStatus(DAILY_KEY,true);return{days:{}};}}
function dailySave(replace){if(storageSet(DAILY_KEY,JSON.stringify(dailyState),replace)&&syncBridge)syncBridge.changed();}
function dailyRecord(date){return dailyState.days[date]||{};}
function ensureDailyRecord(date,selection){
@@ -359,6 +361,7 @@ function load(){
if(j.status==="all"||statuses.indexOf(j.status)>=0)initial.status=j.status;
var saved=progression({progress:plainObject(j.progress)?j.progress:{},review:plainObject(j.review)?j.review:{}},true);initial.progress=saved.progress;initial.review=saved.review;
initial.labs=labStateSanitize(j.labs,true);initial.proofs=proofStateSanitize(j.proofs,true);initial.labChecks=labChecksSanitize(j.labChecks,true);initial.career=RC_PROJECT_PLAN.sanitize(j.career,true);
+ var original={...j};if(!atomicSnapshot)delete original.version;assertSnapshotFields(original,initial);
}catch(e){progressUnread=true;storageStatus(KEY,true);}return initial;
}
function save(replace){if(storageSet(KEY,JSON.stringify(state),replace)&&syncBridge)syncBridge.changed();}
@@ -663,21 +666,55 @@ function reviewRow(x){
function cloudSnapshot(){
if(!storageWriter||storageConflict||atomicUnread||storageUnavailable)throw Error("Progression locale non confirmée : synchronisation suspendue");
- return {version:2,progress:state.progress,review:state.review,labs:state.labs,proofs:state.proofs,labChecks:state.labChecks,dailyFive:dailyState,career:state.career||RC_PROJECT_PLAN.empty()};
+ var raw=localStorage.getItem(ATOMIC_KEY);
+ if(raw!==atomicBaseline||raw===null)throw Error("Instantané local modifié ou absent");
+ var confirmed=JSON.parse(raw);
+ if(JSON.stringify(confirmed.main)!==JSON.stringify(state)||JSON.stringify(confirmed.daily)!==JSON.stringify(dailyState))throw Error("Modifications en mémoire non enregistrées : export indépendant nécessaire");
+ // The existing cloud V2 DTO is a complete business projection of atomic V3.
+ // Device navigation, authentication and cloud journals never enter the DTO.
+ return cloudValidate({version:2,progress:confirmed.main.progress,review:confirmed.main.review,labs:confirmed.main.labs,proofs:confirmed.main.proofs,labChecks:confirmed.main.labChecks,dailyFive:confirmed.daily,career:confirmed.main.career});
+}
+function assertSnapshotFields(original,target){
+ if(original&&typeof original==="object"){
+ if(!target||typeof target!=="object"||Array.isArray(original)!==Array.isArray(target))throw Error("Sauvegarde incompatible");
+ Object.keys(original).forEach(function(k){if(["__proto__","constructor","prototype"].indexOf(k)>=0||!Object.prototype.hasOwnProperty.call(target,k))throw Error("Champ ou historique inconnu : sauvegarde conservée, restauration refusée");assertSnapshotFields(original[k],target[k]);});
+ }else if(original!==target)throw Error("Sauvegarde modifiée par validation : restauration refusée");
+}
+function cloudValidate(o){
+ if(!plainObject(o)||o.version!==2)throw Error("Sauvegarde cloud incompatible");
+ var v=progression(o,true),clean={version:2,progress:v.progress,review:v.review,labs:labStateSanitize(o.labs,true),proofs:proofStateSanitize(o.proofs,true),labChecks:labChecksSanitize(o.labChecks,true),dailyFive:dailySanitize(o.dailyFive,true),career:RC_PROJECT_PLAN.sanitize(o.career,true)};
+ assertSnapshotFields(o,clean);return clean;
+}
+function cloudEmpty(o){return ![o.progress,o.review,o.labs,o.proofs,o.labChecks,o.dailyFive.days].some(function(v){return Object.keys(v).length;})&&RC_CLOUD_TRANSPORT.serialize(o.career)===RC_CLOUD_TRANSPORT.serialize(RC_PROJECT_PLAN.empty());}
+
+function cloudPreserve(data){
+ var key="reconversion-control.cloud.recovery.v1",raw=localStorage.getItem(key),ring=raw===null?{v:1,copies:[]}:JSON.parse(raw),copy=cloudValidate(data);
+ if(!plainObject(ring)||ring.v!==1||!Array.isArray(ring.copies))throw Error("Copies de récupération illisibles");
+ var encoded=RC_CLOUD_TRANSPORT.serialize(copy);
+ if(ring.copies.some(function(item){return RC_CLOUD_TRANSPORT.serialize(item.data)===encoded;}))return;
+ var next=JSON.stringify({v:1,copies:[{at:new Date().toISOString(),data:copy}].concat(ring.copies).slice(0,3)});
+ localStorage.setItem(key,next);if(localStorage.getItem(key)!==next)throw Error("Copie de récupération non confirmée");
}
function cloudRestore(o){
- if(!plainObject(o)||o.version!==2)throw Error("Sauvegarde cloud incompatible");
- var v=progression(o,true),labs=labStateSanitize(o.labs,true),proofs=proofStateSanitize(o.proofs,true),checks=labChecksSanitize(o.labChecks,true),daily=dailySanitize(o.dailyFive,true),career=o.career===undefined?state.career:RC_PROJECT_PLAN.sanitize(o.career,true);
- atomicCommit(state,dailyState); // Establish a confirmed source before any replacing restore.
- var nextState={...state,progress:v.progress,review:v.review,labs:labs,proofs:proofs,labChecks:checks,career:career};
- atomicCommit(nextState,daily); // No live change or cloud acknowledgement until one verified commit.
- state=nextState;dailyState=daily;mirrorLegacy(state,dailyState);render();
+ var clean=cloudValidate(o);
+ atomicCommit(state,dailyState); // Confirm source before replacement, preserving #31.
+ var nextState={...state,progress:clean.progress,review:clean.review,labs:clean.labs,proofs:clean.proofs,labChecks:clean.labChecks,career:clean.career};
+ atomicCommit(nextState,clean.dailyFive);
+ state=nextState;dailyState=clean.dailyFive;mirrorLegacy(state,dailyState);render();
+}
+function exportRecovery(){
+ try{
+ var ring=JSON.parse(localStorage.getItem("reconversion-control.cloud.recovery.v1")||"null");
+ if(!ring||ring.v!==1||!ring.copies.length)throw Error("Aucune copie de récupération disponible");
+ var data=cloudValidate(ring.copies[0].data),blob=new Blob([JSON.stringify(data,null,2)],{type:"application/json"}),url=URL.createObjectURL(blob),a=document.createElement("a");
+ a.href=url;a.download="reconversion-control-recuperation.json";a.click();setTimeout(function(){URL.revokeObjectURL(url);},60000);
+ }catch(e){alert("Export de récupération refusé : "+e.message);}
}
function privacy(){
var historical=RC_SUPPORTS.filter(function(source){return source.status!=="public";});
var syncBlock=''+(syncBridge?syncBridge.render():'Synchronisation non initialisée')+'';
- return'
Sources pédagogiques
'+historical.length+' SUPPORTS HISTORIQUES
32 chapitres historiques adaptés du cours Race Control, recoupés avec le dossier prod15, puis '+RC_LESSONS.filter(function(lesson){return !!lesson.provenance;}).length+' cas récents issus de sources publiques. Les PDF/DOCX originaux et la sauvegarde privée restent hors de l’application.
'+historical.map(function(s){return'
'+esc(s.title)+'
'+esc(s.format)+' · '+esc(s.version)+'
'+esc(s.coverage)+'
';}).join("")+'
Bibliothèque générale à compléter
Les 42 fiches initiales sont des repères synthétiques. Les supports Race Control ne remplacent pas les cours complets de Linux, réseau, pentest, Kubernetes ou sécurité IA. Les références ci-dessous figuraient dans la bibliothèque ; leurs textes complets ne sont pas fournis dans ce lot.
'+RC_SOURCES.filter(function(s){return s!=="Race Control expliqué de zéro à DevSecOps";}).map(function(s){return'
'+esc(s)+'
';}).join("")+'
Confidentialité
BY DESIGN
✅ Sources privées conservées hors du site Aucun PDF/DOCX brut, aucune URL privée ni donnée de la sauvegarde dans le contenu publié.
✅ Progression locale par défaut Statuts et révisions restent dans localStorage. Une copie chiffrée n’est transmise qu’après connexion cloud volontaire.
✅ Aucun analytics · cloud facultatif Aucune progression envoyée tant que la synchronisation personnelle n’est pas activée.
✅ Cours hors ligne Après un premier chargement complet avec réseau, les cours sont conservés sur l’appareil. Le navigateur peut effacer son cache ; ce n’est pas une sauvegarde de ta progression.
✅ Bibliothèque Drive cloisonnée Les labs peuvent citer des références sélectionnées, mais aucun ID, URL Drive privée ni contenu intégral des livres n’est publié.
'+syncBlock+'
'+publicSourcesHtml();
+ return'
Sources pédagogiques
'+historical.length+' SUPPORTS HISTORIQUES
32 chapitres historiques adaptés du cours Race Control, recoupés avec le dossier prod15, puis '+RC_LESSONS.filter(function(lesson){return !!lesson.provenance;}).length+' cas récents issus de sources publiques. Les PDF/DOCX originaux et la sauvegarde privée restent hors de l’application.
'+historical.map(function(s){return'
'+esc(s.title)+'
'+esc(s.format)+' · '+esc(s.version)+'
'+esc(s.coverage)+'
';}).join("")+'
Bibliothèque générale à compléter
Les 42 fiches initiales sont des repères synthétiques. Les supports Race Control ne remplacent pas les cours complets de Linux, réseau, pentest, Kubernetes ou sécurité IA. Les références ci-dessous figuraient dans la bibliothèque ; leurs textes complets ne sont pas fournis dans ce lot.
'+RC_SOURCES.filter(function(s){return s!=="Race Control expliqué de zéro à DevSecOps";}).map(function(s){return'
'+esc(s)+'
';}).join("")+'
Confidentialité
BY DESIGN
✅ Sources privées conservées hors du site Aucun PDF/DOCX brut, aucune URL privée ni donnée de la sauvegarde dans le contenu publié.
✅ Progression locale par défaut Statuts et révisions restent dans localStorage. Une copie chiffrée n’est transmise qu’après connexion cloud volontaire.
✅ Aucun analytics · cloud facultatif Aucune progression envoyée tant que la synchronisation personnelle n’est pas activée.
✅ Cours hors ligne Après un premier chargement complet avec réseau, les cours sont conservés sur l’appareil. Le navigateur peut effacer son cache ; ce n’est pas une sauvegarde de ta progression.
✅ Bibliothèque Drive cloisonnée Les labs peuvent citer des références sélectionnées, mais aucun ID, URL Drive privée ni contenu intégral des livres n’est publié.
'+syncBlock+'
'+publicSourcesHtml();
}
function detailProgress(ps){
return'';
@@ -762,6 +799,7 @@ function bind(){
var q=$("#search"); if(q)q.oninput=function(){state.search=q.value;save();updateLibrary();};
var st=$("#status"); if(st)st.onchange=function(){state.status=st.value;save();updateLibrary();};
if($("#export"))$("#export").onclick=exportState;
+ if($("#export-recovery"))$("#export-recovery").onclick=exportRecovery;
if($("#import"))$("#import").onclick=function(){$("#importer").click();};
if($("#reset"))$("#reset").onclick=function(){if(confirm("Réinitialiser toute la progression locale ?")){var blank=JSON.parse(JSON.stringify(defaults)),empty={days:{}};try{atomicCommit(blank,empty);state=blank;dailyState=empty;storageRemove(KEY);storageRemove(DAILY_KEY);render();}catch(e){alert("Réinitialisation non confirmée : les données existantes sont conservées.");}}};
}
@@ -795,6 +833,6 @@ if("serviceWorker" in navigator){
navigator.serviceWorker.register("./sw.js",{updateViaCache:"none"}).then(function(reg){if(reg.waiting&&hadController)showUpdateNotice();}).catch(function(){});
}
acquireStorageWriter();
-syncBridge=RC_CLOUD_UI.attach({snapshot:cloudSnapshot,restore:cloudRestore,escape:esc});
+syncBridge=RC_CLOUD_UI.attach({snapshot:cloudSnapshot,validate:cloudValidate,empty:cloudEmpty,preserve:cloudPreserve,restore:cloudRestore,escape:esc});
applyRoute();
})();
diff --git a/cloud-transport.js b/cloud-transport.js
index 9c02e46..eb122e2 100644
--- a/cloud-transport.js
+++ b/cloud-transport.js
@@ -1,80 +1,123 @@
"use strict";
-/* Optional Supabase transport. No network unless explicitly configured and authenticated.
- Public publishable key only; SQL RLS mandatory; payload encrypted client-side. */
+/* Optional, same-origin-independent Supabase REST transport. No default provider.
+ Browser encryption, conditional writes and read-after-write acknowledgement. */
const RC_CLOUD_TRANSPORT = (() => {
- const te=new TextEncoder(),td=new TextDecoder();
- const b64=bytes=>btoa(String.fromCharCode(...bytes));
- const un64=s=>Uint8Array.from(atob(s),c=>c.charCodeAt(0));
- const aad=te.encode("reconversion-control-progress-v1");
+ const te=new TextEncoder(),td=new TextDecoder("utf-8",{fatal:true}),MAX=2097136;
+ function serialize(value){
+ function sort(v){if(Array.isArray(v))return v.map(sort);if(v&&typeof v==="object"){const out=Object.create(null);Object.keys(v).sort().forEach(k=>{if(["__proto__","prototype","constructor"].includes(k))throw Error("Champ interdit");out[k]=sort(v[k]);});return out;}return v;}
+ return JSON.stringify(sort(value));
+ }
+ function bounded(value){const raw=serialize(value);if(!raw||te.encode(raw).length>MAX)throw Error("Sauvegarde trop volumineuse (2 Mio maximum)");return raw;}
+ function b64(bytes){let s="";for(let i=0;iMath.ceil(max/3)*4||s.length%4||!/^[A-Za-z0-9+/]*={0,2}$/.test(s))throw Error("Base64 invalide");const bytes=Uint8Array.from(atob(s),c=>c.charCodeAt(0));if(bytes.length>max||b64(bytes)!==s)throw Error("Base64 invalide");return bytes;}
+ async function fingerprint(raw){return Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256",te.encode(raw))),n=>n.toString(16).padStart(2,"0")).join("");}
function config(url,key){
const u=new URL(String(url||"").trim());
- if(u.protocol!=="https:"||!/^[-a-z0-9]+\.supabase\.co$/i.test(u.hostname)||u.username||u.password||u.search||u.hash)throw Error("URL Supabase invalide");
+ if(u.protocol!=="https:"||!/^[-a-z0-9]+\.supabase\.co$/i.test(u.hostname)||u.username||u.password||u.search||u.hash||u.pathname!=="/"||u.port)throw Error("URL Supabase invalide");
if(!/^sb_publishable_[A-Za-z0-9_-]+$/.test(String(key||"")))throw Error("Utiliser uniquement une clé publique sb_publishable_… (jamais une clé secret/service_role)");
return {url:u.origin,key};
}
- function create(cfg){
- const base=config(cfg.url,cfg.key);let session=null;
+ function validRevision(n){return Number.isSafeInteger(n)&&n>0&&nctrl.abort(),20000):null;
+ try{
+ const res=await fetch(base.url+path,{method:init.method||"GET",headers,body:init.body?JSON.stringify(init.body):undefined,cache:"no-store",credentials:"omit",redirect:"error",...(ctrl?{signal:ctrl.signal}:{})});
+ if(!res.ok)throw Object.assign(Error("Service cloud : erreur HTTP "+res.status),{status:res.status});
+ // Limit memory even when a malicious/intermediary response has no length header.
+ let raw="";
+ if(res.body&&res.body.getReader){const reader=res.body.getReader();let size=0;const chunks=[];while(true){const chunk=await reader.read();if(chunk.done)break;size+=chunk.value.length;if(size>4200000){await reader.cancel();throw Error("Réponse cloud trop volumineuse");}chunks.push(chunk.value);}const bytes=new Uint8Array(size);let offset=0;chunks.forEach(c=>{bytes.set(c,offset);offset+=c.length;});raw=td.decode(bytes);}
+ else{raw=await res.text();if(raw.length>4200000)throw Error("Réponse cloud trop volumineuse");}
+ return raw?JSON.parse(raw):null;
+ }finally{if(timeout)clearTimeout(timeout);}
}
- async function verifyCode(email,token){
- if(!/^\d{6,8}$/.test(token))throw Error("Code OTP attendu (6 à 8 chiffres)");
- const j=await request("/auth/v1/verify",{method:"POST",body:{email,token,type:"email"}});
- if(!j||!j.access_token||!j.refresh_token||!j.user||!j.user.id)throw Error("Authentification non confirmée");
- session={access_token:j.access_token,refresh_token:j.refresh_token,expires_at:Date.now()+(j.expires_in||3600)*1000,user:j.user.id};return session.user;
+ function validSession(s){return s&&s.project===base.url&&typeof s.access_token==="string"&&s.access_token.length>0&&s.access_token.length<16384&&typeof s.refresh_token==="string"&&s.refresh_token.length>0&&s.refresh_token.length<16384&&Number.isFinite(s.expires_at)&&typeof s.user==="string"&&/^[0-9a-f-]{36}$/i.test(s.user);}
+ async function accept(j,ticket){
+ if(ticket!==generation)throw Error("Session annulée");
+ if(!j||!j.user||j.user.is_anonymous===true)throw Error("Authentification non confirmée");
+ const next={project:base.url,access_token:j.access_token,refresh_token:j.refresh_token,expires_at:Date.now()+Number(j.expires_in||3600)*1000,user:j.user.id};
+ if(!validSession(next)||session&&session.user!==next.user)throw Error("Authentification non confirmée");
+ session=next;
+ if(options.sessionChanged)await options.sessionChanged({...next});
+ if(ticket!==generation)throw Error("Session annulée");
+ return session.user;
}
+ async function sendCode(email){if(!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email))throw Error("Adresse email incorrecte");await request("/auth/v1/otp",{method:"POST",body:{email,create_user:true}});}
+ async function verifyCode(email,token){if(!/^\d{6,8}$/.test(token))throw Error("Code OTP attendu (6 à 8 chiffres)");const ticket=generation;return accept(await request("/auth/v1/verify",{method:"POST",body:{email,token,type:"email"}}),ticket);}
async function access(){
if(!session)throw Error("Non connecté");
if(Date.now(){
+ try{const j=await request("/auth/v1/token?grant_type=refresh_token",{method:"POST",body:{refresh_token:previous.refresh_token}});await accept(j,ticket);return session.access_token;}
+ catch(e){if(ticket===generation&&(e.status===400||e.status===401||e.status===403)){session=null;throw Error("Session expirée ou révoquée : reconnecte-toi");}throw e;}
+ finally{refreshing=null;}
+ })();}
+ return refreshing;
+ }
+ async function resume(saved){
+ if(!validSession(saved))throw Error("Session mémorisée incompatible");session={...saved};const ticket=generation;
+ const user=await request("/auth/v1/user",{secure:true});
+ if(ticket!==generation||!session||!user||user.id!==session.user||user.is_anonymous===true){session=null;throw Error("Session non confirmée");}return session.user;
}
async function read(){
if(!session)throw Error("Non connecté");
const rows=await request("/rest/v1/reconversion_progress?user_id=eq."+encodeURIComponent(session.user)+"&select=revision,payload",{secure:true});
- if(!Array.isArray(rows))throw Error("Réponse cloud invalide");
- return rows[0]||null;
+ if(!Array.isArray(rows)||rows.length>1)throw Error("Réponse cloud invalide");return rows.length?row(rows[0]):null;
}
async function write(payload,revision){
+ if(!session)throw Error("Non connecté");if(revision!==null&&!validRevision(revision))throw Error("Révision invalide");bounded(payload);
+ const expected=revision===null?1:revision+1;
+ const path="/rest/v1/reconversion_progress"+(revision===null?"":"?user_id=eq."+encodeURIComponent(session.user)+"&revision=eq."+revision);
+ let cause;
+ try{
+ const rows=await request(path,{secure:true,method:revision===null?"POST":"PATCH",headers:{Prefer:"return=representation"},body:revision===null?{user_id:session.user,revision:expected,payload}:{revision:expected,payload}});
+ if(!Array.isArray(rows)||rows.length!==1||row(rows[0]).revision!==expected||serialize(rows[0].payload)!==serialize(payload))throw Error("Écriture non confirmée");
+ }catch(e){cause=e;}
+ // Even an HTTP error/empty response does NOT prove that a write failed.
+ // The durable outbox retries the SAME ciphertext, never a new random envelope.
+ let head;try{head=await read();}catch(e){throw cause||e;}
+ if(head&&head.revision===expected&&serialize(head.payload)===serialize(payload))return expected;
+ if(head&&head.revision!==revision)throw Error("Conflit de version : modification distante détectée ; aucune donnée écrasée");
+ throw cause||Error("Écriture non confirmée : réessayer avec la même version");
+ }
+ async function history(){
if(!session)throw Error("Non connecté");
- const initial=revision===null;
- const path="/rest/v1/reconversion_progress"+(initial?"":"?user_id=eq."+encodeURIComponent(session.user)+"&revision=eq."+revision);
- const headers={"Prefer":"return=representation"};
- const body=initial?{user_id:session.user,revision:1,payload}:{revision:revision+1,payload};
- const rows=await request(path,{secure:true,method:initial?"POST":"PATCH",headers,body});
- if(!Array.isArray(rows)||rows.length!==1)throw Error("Conflit de version : modification distante détectée ; aucune donnée écrasée");
- return rows[0].revision;
+ const rows=await request("/rest/v1/reconversion_progress_history?user_id=eq."+encodeURIComponent(session.user)+"&select=revision&order=revision.desc&limit=20",{secure:true});
+ if(!Array.isArray(rows)||rows.length>20||rows.some(r=>!validRevision(r.revision)))throw Error("Historique invalide");return rows.map(r=>({revision:r.revision}));
+ }
+ async function historyVersion(revision){
+ if(!session||!validRevision(revision))throw Error("Révision inaccessible");
+ const rows=await request("/rest/v1/reconversion_progress_history?user_id=eq."+encodeURIComponent(session.user)+"&revision=eq."+revision+"&select=revision,payload&limit=1",{secure:true});
+ if(!Array.isArray(rows)||rows.length!==1||rows[0].revision!==revision)throw Error("Version historique absente");return row(rows[0]);
}
- function logout(){session=null;}
- return {sendCode,verifyCode,read,write,logout};
+ async function logout(){const previous=session;generation++;session=null;refreshing=null;if(previous){try{await request("/auth/v1/logout?scope=local",{method:"POST",headers:{Authorization:"Bearer "+previous.access_token}});return true;}catch(e){return false;}}return true;}
+ return {sendCode,verifyCode,resume,read,write,history,historyVersion,logout};
}
- async function seal(snapshot,phrase){
- if(typeof phrase!=="string"||phrase.length<12)throw Error("Phrase de chiffrement : 12 caractères minimum");
- const salt=crypto.getRandomValues(new Uint8Array(16)),iv=crypto.getRandomValues(new Uint8Array(12));
- const base=await crypto.subtle.importKey("raw",te.encode(phrase),"PBKDF2",false,["deriveKey"]);
- const key=await crypto.subtle.deriveKey({name:"PBKDF2",salt,iterations:250000,hash:"SHA-256"},base,{name:"AES-GCM",length:256},false,["encrypt"]);
- const cipher=await crypto.subtle.encrypt({name:"AES-GCM",iv,additionalData:aad},key,te.encode(JSON.stringify(snapshot)));
+ async function encrypt(snapshot,phrase,context){
+ if(typeof phrase!=="string"||phrase.length<12||phrase.length>1024)throw Error("Phrase de chiffrement : 12 à 1024 caractères");
+ // Freeze/validate before the first asynchronous KDF.
+ const raw=bounded(snapshot),salt=crypto.getRandomValues(new Uint8Array(16)),iv=crypto.getRandomValues(new Uint8Array(12));
+ const material=await crypto.subtle.importKey("raw",te.encode(phrase),"PBKDF2",false,["deriveKey"]);
+ const key=await crypto.subtle.deriveKey({name:"PBKDF2",salt,iterations:250000,hash:"SHA-256"},material,{name:"AES-GCM",length:256},false,["encrypt"]);
+ const cipher=await crypto.subtle.encrypt({name:"AES-GCM",iv,additionalData:te.encode(context)},key,te.encode(raw));
return {v:1,kdf:"PBKDF2-SHA256",iterations:250000,salt:b64(salt),iv:b64(iv),ciphertext:b64(new Uint8Array(cipher))};
}
- async function open(envelope,phrase){
- if(!envelope||envelope.v!==1||envelope.kdf!=="PBKDF2-SHA256"||envelope.iterations!==250000)throw Error("Format de sauvegarde cloud inconnu");
+ async function decrypt(envelope,phrase,context){
+ if(!envelope||envelope.v!==1||envelope.kdf!=="PBKDF2-SHA256"||envelope.iterations!==250000||Object.keys(envelope).sort().join(",")!=="ciphertext,iterations,iv,kdf,salt,v")throw Error("Format de sauvegarde cloud inconnu");
try{
- const salt=un64(envelope.salt),iv=un64(envelope.iv),cipher=un64(envelope.ciphertext);
- if(salt.length!==16||iv.length!==12||cipher.length>2097152)throw Error("Format");
- const base=await crypto.subtle.importKey("raw",te.encode(phrase),"PBKDF2",false,["deriveKey"]);
- const key=await crypto.subtle.deriveKey({name:"PBKDF2",salt,iterations:250000,hash:"SHA-256"},base,{name:"AES-GCM",length:256},false,["decrypt"]);
- return JSON.parse(td.decode(await crypto.subtle.decrypt({name:"AES-GCM",iv,additionalData:aad},key,cipher)));
+ if(typeof phrase!=="string"||phrase.length<12||phrase.length>1024)throw Error("Phrase");
+ const salt=un64(envelope.salt,16),iv=un64(envelope.iv,12),cipher=un64(envelope.ciphertext,2097152);
+ if(salt.length!==16||iv.length!==12||cipher.length<16)throw Error("Format");
+ const material=await crypto.subtle.importKey("raw",te.encode(phrase),"PBKDF2",false,["deriveKey"]);
+ const key=await crypto.subtle.deriveKey({name:"PBKDF2",salt,iterations:250000,hash:"SHA-256"},material,{name:"AES-GCM",length:256},false,["decrypt"]);
+ const value=JSON.parse(td.decode(await crypto.subtle.decrypt({name:"AES-GCM",iv,additionalData:te.encode(context)},key,cipher)));bounded(value);return value;
}catch(e){throw Error("Déchiffrement impossible : mauvaise phrase ou données altérées");}
}
- return Object.freeze({create,seal,open,config});
+ const progress="reconversion-control-progress-v1",auth="reconversion-control-session-v1";
+ return Object.freeze({create,config,serialize,fingerprint,seal:(s,p)=>encrypt(s,p,progress),open:(s,p)=>decrypt(s,p,progress),sealSession:(s,p)=>encrypt(s,p,auth),openSession:(s,p)=>decrypt(s,p,auth)});
})();
diff --git a/cloud-ui.js b/cloud-ui.js
index 6b91218..013615d 100644
--- a/cloud-ui.js
+++ b/cloud-ui.js
@@ -1,120 +1,241 @@
"use strict";
-/* Cloud opt-in UI. Privacy-first defaults: nothing uploaded until email OTP + passphrase
- and an explicit initial source selection. Remote version conflicts never silently overwrite. */
+/* Opt-in controller over the existing Supabase transport and atomic V3 adapter.
+ Durable encrypted outbox; receipts scoped to provider + authenticated user. */
const RC_CLOUD_UI = (() => {
- let env=null,client=null,email="",phrase="",remote=null,remoteData=null,revision=null,baseline="",busy=false,applying=false,mode="setup",status="Non connecté : ta progression reste locale.",timer=null;
+ const CONFIG="reconversion-control.cloud.config.v1",SESSION="reconversion-control.cloud.session.v1";
+ let env,client,cfg,email="",phrase="",remember=false,user="",scope="",meta={v:1},remote=null,remoteData=null;
+ let revision=null,baseline="",busy=false,applying=false,mode="setup",status="Local uniquement · cloud non configuré.",timer=null,pending=null,epoch=0,retries=0,historyRows=[];
+ const T=()=>RC_CLOUD_TRANSPORT,now=()=>new Date().toISOString();
function safe(s){return env.escape(String(s||""));}
- function local(){return JSON.stringify(env.snapshot());}
- async function upload(expectedRevision){
- // cloudSnapshot shares nested objects with the live state. Detach before the
- // first crypto/network await and acknowledge only these exact serialized bytes.
- const sent=local(),snapshot=JSON.parse(sent);
- const payload=await RC_CLOUD_TRANSPORT.seal(snapshot,phrase);
- revision=await client.write(payload,expectedRevision);
- baseline=sent;
- if(dirty())schedule();
- }
+ function load(key){const raw=localStorage.getItem(key);if(raw===null)return null;if(raw.length>4200000)throw Error("Métadonnées trop volumineuses");return JSON.parse(raw);}
+ function save(key,value){const raw=JSON.stringify(value);if(raw.length>4200000)throw Error("Stockage cloud trop volumineux");localStorage.setItem(key,raw);if(localStorage.getItem(key)!==raw)throw Error("Relecture du journal cloud non confirmée");}
+ function remove(key){localStorage.removeItem(key);if(localStorage.getItem(key)!==null)throw Error("Suppression de session non confirmée");}
+ function local(){return T().serialize(env.snapshot());}
+ function validate(data){return env.validate?env.validate(data):data;}
+ function guard(ticket){if(ticket!==epoch)throw Error("Opération annulée");}
+ function persist(next){save(scope,next);meta=next;}
+ function dirty(){return local()!==baseline;}
+ function delayed(){if(dirty())schedule();}
function uploadStatus(message){return message+(dirty()?" De nouvelles modifications locales restent à synchroniser.":"");}
+ function receipt(){return meta.ack?'
Dernière sauvegarde cloud confirmée : '+safe(meta.ack.at)+' · révision '+safe(meta.ack.revision)+'.
':"";}
function view(){
- const intro='
☁️ Synchronisation chiffrée PC ↔ iPhone (optionnelle)
Aucune donnée envoyée avant configuration. Une fois activée : progression, labs, révisions, Daily Five et dossier Projet 2027 sont synchronisés lors des connexions. GPS et documents privés exclus. La phrase de chiffrement et les jetons de session ne sont jamais stockés durablement.
';
- const msg='
'+safe(status)+'
';
- if(mode==="setup")return intro+'
Projet Supabase personnel requis. Utiliser uniquement son URL et sa clé PUBLIQUE sb_publishable_… ; jamais de service_role.
'+msg;
- if(mode==="otp")return intro+'
Un code à usage unique a été demandé. La configuration Supabase doit utiliser le modèle de courriel {{ .Token }}.
'+msg;
- let controls='';
- if(mode==="choose"||mode==="conflict")controls='
'+safe(mode==="choose"?"Deux progressions existent. Choisis la source initiale.":"Conflit : les deux appareils ont changé. Aucun écrasement automatique.")+'
Avant de remplacer une version, exporte une sauvegarde JSON. Le choix remplace les données concernées, sans fusion automatique ambiguë.
';
- return intro+msg+controls;
+ const intro='
☁️ Synchronisation chiffrée PC ↔ iPhone (optionnelle)
Local d’abord. Après activation et déverrouillage, la progression complète est chiffrée avant envoi. Phrase en mémoire seulement. Une app iPhone fermée reprend à la réouverture.
URL et clé PUBLIQUE Supabase personnelles ; jamais de service_role.
Facultatif : évite un nouveau code email à chaque ouverture. La phrase reste nécessaire. À éviter sur un PC partagé ; un accès malveillant à cette page peut lire les secrets une fois déverrouillés.
'+safe(mode==="choose"?'Choisis explicitement la source initiale.':'Conflit : aucune version remplacée automatiquement.')+'
'+disconnect+'
Chaque remplacement nécessite confirmation et copie locale de récupération. Exporte aussi une sauvegarde indépendante.
';
+ if(mode==="error")controls='
'+disconnect+'
';
+ const history=user?''+historyRows.map((r,i)=>'').join(""):"";
+ return intro+msg+controls+history;
}
function repaint(){
- const root=typeof document!=="undefined"&&document.querySelector("#rc-sync");
- if(!root||!env)return;
- const label=document.querySelector("#rc-storage-label");
- if(label)label.textContent=mode==="active"?"CLOUD CHIFFRÉ · synchronisation active":mode==="setup"?"LOCAL · aucune progression envoyée":"LOCAL + CLOUD · connexion ou conflit en cours";
+ const root=typeof document!=="undefined"&&document.querySelector("#rc-sync");if(!root||!env)return;
+ const label=document.querySelector("#rc-storage-label");if(label)label.textContent=mode==="active"?"LOCAL + CLOUD CHIFFRÉ · voir l’état de sauvegarde":"LOCAL · cloud "+(cfg?"configuré":"non configuré");
root.innerHTML=view();bind();
}
function warn(t){status=t;repaint();}
+ function makeClient(ticket){
+ return T().create(cfg,{sessionChanged:async session=>{
+ guard(ticket);if(!remember)return;
+ const encrypted=await T().sealSession(session,phrase);guard(ticket);save(SESSION,{v:1,project:cfg.url,payload:encrypted});
+ }});
+ }
async function start(){
+ if(busy)return;busy=true;const ticket=++epoch;
try{
- const url=document.querySelector("#rc-sync-url").value,key=document.querySelector("#rc-sync-key").value;
const mail=document.querySelector("#rc-sync-email").value.trim();
- client=RC_CLOUD_TRANSPORT.create({url,key});await client.sendCode(mail);
- email=mail;mode="otp";warn("Vérifie ta boîte mail puis saisis le code reçu.");
- }catch(e){warn("Connexion impossible : "+e.message);}
+ cfg=T().config(document.querySelector("#rc-sync-url").value,document.querySelector("#rc-sync-key").value);
+ remember=!!document.querySelector("#rc-sync-remember").checked;save(CONFIG,cfg);
+ client=makeClient(ticket);await client.sendCode(mail);guard(ticket);email=mail;mode="otp";status="Vérifie ta boîte mail puis saisis le code reçu.";
+ }catch(e){if(ticket===epoch)status="Connexion impossible : "+e.message;}
+ finally{if(ticket===epoch){busy=false;repaint();}}
+ }
+ function loadMeta(){
+ scope="reconversion-control.cloud.sync.v1:"+cfg.url+":"+user;meta=load(scope)||{v:1};
+ if(meta.v!==1||meta.ack&&(!Number.isSafeInteger(meta.ack.revision)||!/^[a-f0-9]{64}$/.test(meta.ack.hash))||meta.pending&&(!/^[a-f0-9]{64}$/.test(meta.pending.hash)||!(meta.pending.expected===null||Number.isSafeInteger(meta.pending.expected))))throw Error("Journal cloud incompatible : récupération manuelle requise");
+ }
+ async function acknowledge(sent,next,ticket,payload){
+ const hash=await T().fingerprint(sent),payloadHash=payload?await T().fingerprint(T().serialize(payload)):null;guard(ticket);
+ persist({...meta,ack:{revision:next,hash,payloadHash,at:now()},pending:null});revision=next;baseline=sent;retries=0;
+ }
+ async function flush(ticket){
+ const job=meta.pending;if(!job)return;
+ const data=validate(await T().open(job.payload,phrase));guard(ticket);
+ const sent=T().serialize(data);if(await T().fingerprint(sent)!==job.hash)throw Error("Journal d’envoi altéré");guard(ticket);
+ local(); // A failed/unconfirmed local write must suspend network transmission.
+ const next=await client.write(job.payload,job.expected);guard(ticket);await acknowledge(sent,next,ticket,job.payload);
+ }
+ async function upload(expected,ticket){
+ if(meta.pending)throw Error("Un envoi précédent doit être résolu avant un nouvel envoi");
+ const sent=local(),snapshot=JSON.parse(sent),payload=await T().seal(snapshot,phrase);guard(ticket);
+ const hash=await T().fingerprint(sent);guard(ticket);local();
+ persist({...meta,pending:{expected,hash,payload}});
+ await flush(ticket);delayed();
+ }
+ async function inspect(ticket){
+ let latest=await client.read();guard(ticket);
+ if(meta.pending){
+ const next=meta.pending.expected===null?1:meta.pending.expected+1;
+ const matches=latest&&latest.revision===next&&T().serialize(latest.payload)===T().serialize(meta.pending.payload);
+ const retry=(!latest&&meta.pending.expected===null)||(latest&&latest.revision===meta.pending.expected);
+ if(matches||retry){await flush(ticket);latest=await client.read();guard(ticket);}
+ else{remote=latest;remoteData=latest?validate(await T().open(latest.payload,phrase)):null;guard(ticket);mode="conflict";status="Conflit détecté : envoi précédent conservé, décision nécessaire.";return;}
+ }
+ remote=latest;
+ if(!latest){mode=meta.ack?"conflict":"initial";remoteData=null;status=meta.ack?"La sauvegarde cloud a disparu. Aucun changement local effacé.":"Connecté et déverrouillé · aucune sauvegarde cloud. Confirmation initiale requise.";return;}
+ remoteData=validate(await T().open(latest.payload,phrase));guard(ticket);
+ const here=local(),hash=await T().fingerprint(here),there=T().serialize(remoteData);guard(ticket);
+ // No automatic source selection without a durable receipt from this account.
+ if(!meta.ack){mode="choose";status="Une progression cloud existe déjà. Choisis laquelle conserver.";return;}
+ if(latest.revision1024)throw Error("Phrase secrète : 12 à 1024 caractères.");
+ user=await client.verifyCode(email,document.querySelector("#rc-sync-otp").value.trim());guard(ticket);loadMeta();await inspect(ticket);
+ }catch(e){if(ticket===epoch){status="Échec : "+e.message;if(user)mode="error";}}
+ finally{if(ticket===epoch){busy=false;repaint();}}
}
- function activate(){
- if(timer)clearInterval(timer);
- timer=setInterval(tick,30000);
+ async function unlock(){
+ if(busy)return;busy=true;const ticket=++epoch;
+ try{
+ phrase=document.querySelector("#rc-sync-phrase").value;remember=true;
+ const saved=load(SESSION);if(!saved||saved.project!==cfg.url)throw Error("Session mémorisée absente ou incompatible");
+ const session=await T().openSession(saved.payload,phrase);guard(ticket);client=makeClient(ticket);
+ user=await client.resume(session);guard(ticket);loadMeta();await inspect(ticket);
+ }catch(e){if(ticket===epoch){status="Déverrouillage refusé : "+e.message;if(user)mode="error";}}
+ finally{if(ticket===epoch){busy=false;repaint();}}
}
- function dirty(){return local()!==baseline;}
+ function activate(){if(timer)clearInterval(timer);timer=setInterval(tick,30000);}
+ function schedule(delay){if(pending)clearTimeout(pending);pending=setTimeout(()=>{pending=null;return tick();},delay||2000);}
function changed(){
if(!env||applying||mode!=="active")return;
- if(dirty())schedule();
+ try{if(dirty()){status="Modifications locales en attente de sauvegarde cloud.";schedule();repaint();}}
+ catch(e){warn("Sauvegarde cloud suspendue : "+e.message);}
}
- let pending=null;
- function schedule(){if(pending)clearTimeout(pending);pending=setTimeout(tick,2000);}
- async function apply(data){
- applying=true;
- try{env.restore(data);baseline=local();}finally{applying=false;}
+ async function apply(data,next,ticket){
+ guard(ticket);const expected=local(),valid=validate(data);applying=true;
+ try{
+ if(env.preserve)env.preserve(JSON.parse(expected));
+ guard(ticket);if(local()!==expected)throw Error("Progression locale modifiée pendant la réception");
+ env.restore(valid);
+ const restored=local();if(restored!==T().serialize(valid))throw Error("Relecture restaurée différente");
+ await acknowledge(restored,next,ticket,remote&&remote.payload);guard(ticket);persist({...meta,restore:{hash:meta.ack.hash,revision:next}});
+ }finally{applying=false;}
}
async function tick(){
- if(!client||busy||mode!=="active"||!navigator.onLine)return;
- busy=true;
+ if(!client||busy||!["active","conflict","error"].includes(mode))return;
+ if(!navigator.onLine){warn("Hors connexion · progression locale conservée, reprise à la reconnexion.");return;}
+ busy=true;const ticket=epoch;
try{
- const latest=await client.read();
- if(!latest){mode="conflict";status="La sauvegarde cloud a disparu. Aucun changement local effacé.";return;}
+ if(mode!=="active"){await inspect(ticket);return;}
+ if(meta.pending)await flush(ticket);
+ const latest=await client.read();guard(ticket);
+ if(!latest){mode="conflict";remote=remoteData=null;status="La sauvegarde cloud a disparu. Aucun changement local effacé.";return;}
if(latest.revision!==revision){
- const decrypted=await RC_CLOUD_TRANSPORT.open(latest.payload,phrase);
- remote=latest;remoteData=decrypted;
+ const decrypted=validate(await T().open(latest.payload,phrase));guard(ticket);remote=latest;remoteData=decrypted;
+ if(latest.revision{const el=document.querySelector("#"+id);if(el)el.onclick=fn;};
- on("rc-sync-request",start);on("rc-sync-verify",confirmOtp);on("rc-sync-take",chooseCloud);on("rc-sync-push",chooseLocal);on("rc-sync-disconnect",disconnect);on("rc-sync-now",tick);
+ on("rc-sync-request",start);on("rc-sync-verify",confirmOtp);on("rc-sync-unlock",unlock);on("rc-sync-create",createFirst);on("rc-sync-take",chooseCloud);on("rc-sync-push",chooseLocal);on("rc-sync-disconnect",disconnect);on("rc-sync-now",tick);on("rc-sync-history",history);
+ if(document.querySelectorAll)document.querySelectorAll("[data-sync-history]").forEach(el=>{el.onclick=()=>restoreHistory(Number(el.dataset.syncHistory));});
+ }
+ function attach(options){
+ env=options;
+ try{const saved=load(CONFIG);if(saved)cfg=T().config(saved.url,saved.key);if(cfg&&load(SESSION)){mode="locked";status="Session mémorisée chiffrée · déverrouillage requis, aucun envoi automatique.";}}
+ catch(e){status="Cloud suspendu : configuration locale illisible. Progression locale conservée.";}
+ if(typeof window!=="undefined"&&window.addEventListener){
+ window.addEventListener("online",tick);
+ window.addEventListener("offline",()=>{if(mode==="active")warn("Hors connexion · progression locale conservée, reprise à la reconnexion.");});
+ window.addEventListener("pageshow",()=>{if(mode==="active")tick();});
+ }
+ if(document.addEventListener)document.addEventListener("visibilitychange",()=>{if(!document.hidden&&mode==="active")tick();});
+ return {render:view,bind,changed};
}
- function attach(options){env=options;return{render:view,bind,changed};}
return {attach};
})();
diff --git a/docs/GO_FINAL_CLOUD_20261024.md b/docs/GO_FINAL_CLOUD_20261024.md
new file mode 100644
index 0000000..acd6900
--- /dev/null
+++ b/docs/GO_FINAL_CLOUD_20261024.md
@@ -0,0 +1,28 @@
+# GO FINAL personnel — guide préparé, non exécuté
+
+Auteur : Bryan Fiarda · 24 octobre 2026.
+
+**Aucun GO de fusion, publication, modification administrative, activation ou données réelles n’est déduit de ce guide.**
+
+1. Vérifier ensemble la PR finale, son SHA exact et les trois jobs CI réussis. Contrôler en lecture administrative l’approbateur `iPoower` et l’interdiction du contournement dans `github-pages`. Revoir les risques résiduels ; fusion et déploiement exigent leurs GO séparés.
+2. Vérifier le statut du projet Supabase existant. Toute reprise ou réactivation nécessite GO administratif. Lire la table, les politiques, les grants, l’exposition Data API, les réglages Auth/SMTP et les éventuelles lignes existantes sans exporter de données ou de secrets dans le dépôt. L’utilisateur A doit être isolé de B ; rôle anon et anonymous-auth refusés. Ne jamais corriger en désactivant RLS.
+3. Dans un projet **de test distinct**, appliquer le schéma de base puis la migration proposée. Tester deux comptes fictifs, OTP, expiration, rotation, déconnexion et révocation ; révision conditionnelle ; réponse perdue et rejeu ; 20 versions ; corruption, récupération et indisponibilité. Les contraintes ajoutées `NOT VALID` exigent une validation séparée après examen des anciennes enveloppes. Vérifier aussi les advisors Supabase. Aucun GO production n’est impliqué.
+4. Avant toute donnée personnelle : exporter séparément **PC et iPhone**. Nommer les deux fichiers avec appareil et date ; conserver une copie hors de ces appareils. Vérifier leur réimport dans deux profils de test isolés et comparer toutes les données : cours, SRS, labs, preuves, validations, Daily Five, dossier et historiques. Désigner la source initiale ; ne pas fusionner deux progressions divergentes à la main sans sauvegardes.
+5. Après GO explicite de première sauvegarde : connecter le PC dans Sources & privacy, URL et clé publique uniquement. OTP puis phrase longue unique. Mémoriser la session chiffrée seulement sur un appareil privé protégé. Confirmer le premier envoi et relever la révision, l’heure et le reçu. Contrôler le contenu déchiffré dans un profil isolé ; une étiquette verte seule n’est pas une recette.
+6. Connecter l’iPhone **physique**, avec le même compte et la même phrase. Lire les deux sources et confirmer la copie voulue. Vérifier tous les domaines, fermer complètement puis rouvrir la PWA et déverrouiller. Vérifier à nouveau les données, le reçu et le message de restauration après réouverture. Ne jamais remplacer une source existante sans ses exports indépendants.
+7. Ajouter une progression fictive identifiable et réversible sur iPhone puis la retrouver sur PC ; vérifier le trajet inverse. Tester mode avion, fermeture pendant envoi, modifications hors ligne, reprise et conflit simultané. Consigner séparément : Chromium, WebKit, Supabase de test, iPhone physique, puis données personnelles autorisées. Toute erreur bloque la certification opérationnelle.
+8. Après GO final explicite et recette probante, conserver le mode automatique sur les deux appareils. À chaque ouverture, déverrouiller ; attendre la confirmation cloud avant de changer d’appareil. Garder des exports périodiques indépendants. Si la phrase est perdue, exporter immédiatement tout appareil encore lisible ; la sauvegarde chiffrée seule ne permet pas de la retrouver.
+
+## Si un incident survient
+
+- Couper la synchronisation (Déconnecter), conserver les deux appareils et leurs journaux. Exporter la progression visible, même si une bannière signale qu’elle est seulement en mémoire. Ne pas effacer le stockage ni désinstaller.
+- Réponse perdue : rouvrir/déverrouiller ; le même paquet de l’outbox est réconcilié avec la révision distante. Ne pas supprimer le journal pour forcer un vert.
+- Conflit : comparer les exports, choisir explicitement une source ; les versions remplacées sont conservées avant mutation. Pas de fusion automatique.
+- Tête cloud corrompue ou incompatible : « Versions cloud précédentes », choisir une version validée et exporter avant de décider du nouvel état cloud. Une migration d’historique absente signifie que cette option n’est pas disponible ; utiliser l’export indépendant.
+- Restauration locale non confirmée : la transaction V3 tente de préserver/restituer la copie antérieure et refuse le succès. Exporter ce qui est encore lisible et conserver les octets d’origine ; ne pas réinitialiser en aveugle.
+
+## Rollback technique
+
+Avant fusion : fermer/laisser la PR non fusionnée, sans impact production. Après une future fusion autorisée : revert ciblé via nouvelle branche et PR, CI complète, déploiement approuvé séparément. Ne jamais supprimer la clé V3, les copies de récupération ou l’outbox pour revenir en arrière. L’ancien client ne connaît pas le nouveau journal : exporter/réconcilier d’abord les envois incertains avant de reprendre un cloud avec cet ancien client.
+
+Le rollback SQL n’est pas une suppression de table : conserver l’historique et les contraintes, arrêter les écritures si nécessaire, puis revoir le trigger séparément avec GO administratif. Ne pas détruire les seules copies chiffrées.
diff --git a/docs/cloud-sync.md b/docs/cloud-sync.md
index 5d21278..03601b6 100644
--- a/docs/cloud-sync.md
+++ b/docs/cloud-sync.md
@@ -1,55 +1,65 @@
-# Synchronisation PC / iPhone — configuration privée
-
-Le client est livré **désactivé**. La V2.1 locale reste utilisable normalement,
-y compris sans réseau. Il n'existe pas de serveur Supabase configuré par défaut :
-les travaux sur ce dépôt ne créent pas de projet Supabase.
-
-## Configuration (une seule fois par projet)
-
-1. Crée un projet personnel sur , active Auth → Email.
-2. Dans les modèles d'email, remplace le Magic Link par une notification contenant
- le jeton **`{{ .Token }}`**. Sans ce changement, la page de connexion OTP
- ne fonctionne pas : le modèle par défaut peut envoyer un lien à cliquer.
-3. Dans le SQL Editor privé, exécute le fichier `sql/reconversion_progress.sql`.
- Il crée une table avec **RLS** : chaque utilisateur authentifié n'accède
- qu'à sa propre ligne. Teste séparément les politiques avec deux comptes.
-4. Dans Reconversion Control → Sources & privacy → Synchronisation, saisis
- l'URL **https://.supabase.co**, la clé publique **sb_publishable_…**
- et une adresse email. **Ne jamais** entrer une clé `service_role`,
- `sb_secret_`, un mot de passe ou une clé d'administration dans le dépôt.
-5. Saisis le code reçu puis la **même phrase de chiffrement** (12 caractères
- minimum) sur PC et iPhone. Une copie chiffrée est créée pour le premier
- appareil. Sur le second, sélectionne explicitement la progression à conserver.
-
-## Contrat technique
-
-- Authentification Supabase email OTP, bearer JWT ; table restreinte par RLS.
-- Chiffrement **dans le navigateur** : PBKDF2-SHA256 250 000 itérations,
- sel aléatoire, AES-256-GCM et nonce aléatoire par version.
-- Le cloud stocke uniquement une enveloppe chiffrée et une révision.
- Les données excluent GPS, agenda, documents Drive, fichiers joints, email.
-- La phrase de chiffrement et les jetons d'authentification ne sont gardés
- qu'en **mémoire de l'onglet** ; ils sont perdus au rechargement. Il faut
- donc se reconnecter / déverrouiller à chaque nouvelle session.
-- Après activation : comparaison des révisions environ toutes les 30 secondes
- lorsque la page est ouverte ; tentative d'envoi des changements locaux
- après 2 secondes. **Aucune synchronisation en arrière-plan PWA fermée**.
-- Conflit avec modifications locales et distantes : **jamais de
- remplacement silencieux**. L'utilisateur choisit explicitement la copie,
- et peut toujours exporter un JSON avant de le faire.
-- Hors réseau : le stockage local continue ; la synchronisation attend
- le retour du réseau.
-- Les tests CI utilisent des services simulés, **pas un vrai Supabase**.
- Tests E2E réels avec un projet privé et un iPhone physique restent nécessaires.
-
-## Limites
-
-Cette version synchronise les statuts des cours, le SRS, les états de labs,
-les empreintes des preuves, le portfolio et le Daily Five. Elle n'est pas
-un moteur de fusion par champ : en cas de concurrence, priorité à la
-préservation et au choix explicite. La configuration côté fournisseur,
-l'email OTP, la RLS et le HTTPS doivent être vérifiés avant utilisation.
-
-Références :
--
--
+# Sauvegarde chiffrée PC ↔ iPhone — candidat du 24 octobre 2026
+
+Auteur : Bryan Fiarda. Développement autorisé, **aucune mise en service personnelle**.
+Base vérifiée : `2ab4c0f74c74b5ad430e10032ae810708113b3f0` (PR #31).
+
+## Fonctionnement quotidien prévu
+
+1. Les saisies sont d’abord enregistrées dans `reconversion-control.snapshot.v3` et relues. Un seul onglet a le Web Lock d’écriture. Un onglet obsolète, un quota ou un stockage illisible suspendent le cloud.
+2. Après activation volontaire et déverrouillage, les modifications persistantes déclenchent un envoi différé d’environ 2 secondes. Vérification périodique toutes les 30 secondes au premier plan ; reprise aux événements réseau, visibilité et réouverture. Échecs : reprise progressive jusqu’à 60 secondes.
+3. Le paquet cloud existant V2 contient la projection métier complète de l’instantané local V3 : progression, révisions SRS, labs, preuves, validations, Daily Five et dossier. La navigation propre à l’appareil, les secrets et journaux cloud sont exclus. Pas de second format concurrent ni de migration des enveloppes existantes.
+4. Le JSON est figé avant toute attente cryptographique. L’enveloppe chiffrée et l’empreinte sont écrites/reluées dans une outbox locale avant la requête. Une modification survenue ensuite reste en attente.
+5. Une révision est acquittée seulement après relecture du **même** paquet sur le serveur à la révision attendue. Une réponse perdue peut signifier que l’écriture a réussi. La reprise réutilise exactement le même ciphertext ; elle ne génère pas une nouvelle sauvegarde au hasard.
+6. Les reçus sont persistants et séparés par projet + utilisateur authentifié. Sans reçu antérieur, un choix de source est obligatoire ; même un cloud vide demande confirmation avant le premier envoi. Au retour, une divergence impose un choix, sans fusion automatique. Une version vide ne remplace pas automatiquement une progression existante.
+7. Une restauration est validée avant mutation, passe par le commit atomique et sa relecture, puis est comparée intégralement. Une copie de récupération indépendante du snapshot principal est conservée avant remplacement. L’interface distingue relecture immédiate et vérification après réouverture.
+
+## Connexion et secrets
+
+Par défaut, la session reste en mémoire. Option explicite « Mémoriser la session chiffrée sur cet appareil privé » : access token + refresh token sont persistés **chiffrés par la phrase**, avec un domaine AAD distinct de la progression. L’URL et la clé publique sont mémorisées sans secret. Aucun mot de passe, phrase ou jeton en clair n’entre dans les exports de progression, journaux ou URL.
+
+Après fermeture : saisir la phrase, puis le client vérifie la session auprès de Supabase et la renouvelle si nécessaire. Un code email redevient nécessaire si le fournisseur l’a révoquée, si la session mémorisée a disparu ou si l’utilisateur se déconnecte. La rotation des refresh tokens est sérialisée dans ce client ; l’application n’autorise qu’un onglet écrivain. Aucun déverrouillage automatique après redémarrage sans secret local.
+
+La mémorisation chiffrée réduit le risque d’extraction au repos ; elle ne protège pas d’un script malveillant exécuté dans la page déverrouillée. Ce risque existe aussi pour la progression locale en clair. Utiliser une phrase longue et unique et le verrouillage système. Éviter le PC partagé. Sur un iPhone perdu, révoquer la session chez Supabase ; un access token déjà émis peut rester valable jusqu’à expiration. La déconnexion de l’application supprime la session locale et demande la révocation de cette session seulement (`scope=local`), en signalant un éventuel échec réseau.
+
+## Chiffrement et limites de taille
+
+- AES-256-GCM, AAD authentifié, PBKDF2-SHA256 250 000 itérations conservées pour compatibilité V1, sel aléatoire de 16 octets et IV aléatoire de 12 octets à chaque paquet. La phrase n’est jamais envoyée.
+- Validation stricte de l’enveloppe, base64 canonique, refus des versions inconnues, des données altérées, des champs perdus par normalisation et des clés dangereuses. Validation métier avant toute restauration.
+- 2 097 136 octets de JSON maximum avant chiffrement ; 2 097 152 octets chiffrés. Base64 traité par blocs. Réponses HTTP bornées et délai de 20 secondes ; un timeout d’écriture reste ambigu.
+- Seuls ciphertext, métadonnées cryptographiques, identifiant utilisateur, révision et dates sont visibles côté serveur. Ces métadonnées ne sont pas anonymes.
+- Une phrase perdue empêche de déchiffrer cloud et session mémorisée. Une copie locale encore lisible peut être exportée avant réinitialisation ; sans copie lisible ni phrase, aucune récupération magique.
+
+## Historique et récupération
+
+Migration proposée dans `supabase/migrations/*_encrypted_cloud_history.sql`, **non appliquée**. Elle conserve les 20 dernières révisions chiffrées par utilisateur, dont la tête, dans une table en lecture seule pour le client. Un trigger privé contrôle le propriétaire, l’incrément exact des révisions et l’archivage transactionnel ; les champs métier en clair sont refusés. Son `SECURITY DEFINER` est limité à cette fonction de trigger dans un schéma non exposé, `search_path` vide, droits d’exécution retirés aux rôles publics. La RLS reste active ; les utilisateurs anonymes et les connexions anonymes authentifiées sont exclus.
+
+L’archivage démarre au prochain envoi : il ne recrée aucune version passée. Vingt versions rapprochées ne remplacent pas un export durable hors appareil. La liste ne télécharge que les numéros ; un seul paquet est chargé pour une récupération. Une version historique restaurée suspend la synchronisation et nécessite une nouvelle décision.
+
+Trois copies locales de récupération au maximum sont conservées séparément du snapshot V3 ; tout échec de leur écriture bloque le remplacement. « Exporter la dernière copie de récupération » produit un JSON réimportable par l’import existant. Elles restent sur l’appareil et disparaissent aussi si son stockage est entièrement supprimé. Toujours conserver un export indépendant sur un autre support avant première synchronisation.
+
+## État administratif constaté
+
+Au 24/10/2026, le connecteur Supabase retourne le projet `reconversion-control`, créé le 08/10/2026, région `eu-west-3`, statut **INACTIVE**. Il n’a pas été réactivé. Son schéma, ses politiques effectives et son SMTP actuels n’ont pas été certifiés. Aucun OTP personnel ni accès à une progression personnelle n’a été déclenché.
+
+Le dépôt conserve le workflow Pages sur `github-pages`, les SHA d’actions et le contrôle de provenance, sans changement du workflow de publication. La lecture de la configuration administrative de cet environnement n’est pas disponible via le connecteur GitHub : les exigences approbateur `iPoower` / interdiction du contournement restent à revérifier avant une future fusion. Aucun paramètre n’a été modifié.
+
+## Configuration — préparée, GO distinct requis
+
+Voir `GO_FINAL_CLOUD_20261024.md`. Avant activation : vérifier le statut projet, le schéma existant, les grants **et** la RLS, les sessions multiappareils et l’envoi d’un OTP. Le modèle email doit fournir `{{ .Token }}`. Depuis le 03/06/2026, la personnalisation des modèles de nouveaux projets Free utilisant le SMTP par défaut peut être restreinte ; vérifier l’offre et le SMTP effectifs. Ne pas supposer que le projet d’octobre peut être configuré comme un ancien projet.
+
+Ne jamais saisir une clé `service_role` ou `sb_secret_` dans le navigateur ; le client n’accepte que `sb_publishable_…`. Ne jamais désactiver RLS pour résoudre une erreur. Les politiques de test isolé ne certifient pas celles du projet personnel.
+
+## Validation et limites réelles
+
+Commandes reproductibles dans `TESTING.md`. Tests HTTP simulés + vraie cryptographie, contrôleur, DOM, localStorage, deux profils Chromium/WebKit ; SQL PostgreSQL WASM isolé avec `auth.uid()`/JWT simulés. Ces preuves ne valident ni le service Auth hébergé, ni le SMTP, ni l’état RLS de production, ni les pannes physiques du téléphone.
+
+Une PWA suspendue ou fermée sur iOS n’exécute pas continuellement le réseau : reprise après réouverture et déverrouillage. La relecture après fermeture de page prouve la persistance du profil navigateur de test ; elle ne prouve pas un iPhone physique ni un redémarrage matériel. Si un navigateur ne propose pas Web Locks ou perd le verrou après suspension, exporter toute saisie en mémoire puis fermer les autres onglets et recharger.
+
+Références officielles consultées le 24/10/2026 :
+- https://supabase.com/docs/guides/auth/sessions
+- https://supabase.com/docs/guides/auth/signout
+- https://supabase.com/docs/guides/database/postgres/row-level-security
+- https://supabase.com/docs/guides/api/securing-your-api
+- https://supabase.com/changelog/46599-changes-to-email-template-customisation-on-free-tier
+- https://supabase.com/changelog/cbc-tls-ciphers-deprecated (retrait annoncé de suites TLS anciennes à partir du 10/11/2026 ; compatibilité appareil réel à vérifier)
+- https://pglite.dev/docs/ (moteur SQL isolé, dépendance de test uniquement)
diff --git a/package-lock.json b/package-lock.json
index f892d99..aa0b416 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,5 +1,6 @@
{
"name": "reconversion-control",
+ "version": "3.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
@@ -7,9 +8,17 @@
"name": "reconversion-control",
"version": "3.0.0",
"devDependencies": {
+ "@electric-sql/pglite": "0.5.11",
"playwright": "1.63.0"
}
},
+ "node_modules/@electric-sql/pglite": {
+ "version": "0.5.11",
+ "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.11.tgz",
+ "integrity": "sha512-SWuqsTjA4DDWqgZ05PC1RJaq8e00p5KACxbqaikDFQ8dIGA0SLRzuPniky7IW+q/c5RDNMdEXwv7mFBWIx9Hxw==",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
"node_modules/playwright": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
@@ -39,6 +48,5 @@
"node": ">=20"
}
}
- },
- "version": "1.0.0"
+ }
}
diff --git a/package.json b/package.json
index 6e9822b..6de4b32 100644
--- a/package.json
+++ b/package.json
@@ -9,6 +9,7 @@
"test:sw": "node tests/sw.spec.cjs"
},
"devDependencies": {
+ "@electric-sql/pglite": "0.5.11",
"playwright": "1.63.0"
}
}
diff --git a/supabase/migrations/20261024112625_encrypted_cloud_history.sql b/supabase/migrations/20261024112625_encrypted_cloud_history.sql
new file mode 100644
index 0000000..437a3e1
--- /dev/null
+++ b/supabase/migrations/20261024112625_encrypted_cloud_history.sql
@@ -0,0 +1,84 @@
+-- PREPARED ONLY. Execute in an isolated test database first; production needs GO ADMIN.
+-- Requires the existing sql/reconversion_progress.sql. No personal rows are read here.
+begin;
+-- Refuse unexpected permissive policies: PostgreSQL OR-combines permissive policies.
+do $$ begin
+ if exists(select 1 from pg_policies where schemaname='public' and tablename='reconversion_progress' and policyname not in ('progress_read_self','progress_insert_self','progress_update_self')) then
+ raise exception 'Unexpected progress policy: review before migration';
+ end if;
+end $$;
+alter table public.reconversion_progress enable row level security;
+revoke all on public.reconversion_progress from public, anon, authenticated;
+grant select, insert, update on public.reconversion_progress to authenticated;
+drop policy if exists "progress_read_self" on public.reconversion_progress;
+drop policy if exists "progress_insert_self" on public.reconversion_progress;
+drop policy if exists "progress_update_self" on public.reconversion_progress;
+create policy "progress_read_self" on public.reconversion_progress for select to authenticated
+ using ((select auth.uid())=user_id and coalesce((select auth.jwt()->>'is_anonymous'),'false')='false');
+create policy "progress_insert_self" on public.reconversion_progress for insert to authenticated
+ with check ((select auth.uid())=user_id and coalesce((select auth.jwt()->>'is_anonymous'),'false')='false');
+create policy "progress_update_self" on public.reconversion_progress for update to authenticated
+ using ((select auth.uid())=user_id and coalesce((select auth.jwt()->>'is_anonymous'),'false')='false')
+ with check ((select auth.uid())=user_id and coalesce((select auth.jwt()->>'is_anonymous'),'false')='false');
+
+-- New writes must be bounded opaque envelopes; existing rows need explicit validation.
+alter table public.reconversion_progress add constraint progress_safe_revision
+ check (revision between 1 and 9007199254740990) not valid;
+alter table public.reconversion_progress add constraint progress_encrypted_envelope check (
+ jsonb_typeof(payload)='object'
+ and payload ?& array['v','kdf','iterations','salt','iv','ciphertext']
+ and (payload - array['v','kdf','iterations','salt','iv','ciphertext'])='{}'::jsonb
+ and payload->'v'='1'::jsonb and payload->>'kdf'='PBKDF2-SHA256' and payload->'iterations'='250000'::jsonb
+ and jsonb_typeof(payload->'salt')='string' and (payload->>'salt') ~ '^[A-Za-z0-9+/]{22}==$'
+ and jsonb_typeof(payload->'iv')='string' and (payload->>'iv') ~ '^[A-Za-z0-9+/]{16}$'
+ and jsonb_typeof(payload->'ciphertext')='string' and length(payload->>'ciphertext') between 24 and 2796204
+ and (payload->>'ciphertext') ~ '^[A-Za-z0-9+/]+={0,2}$'
+ and octet_length(payload::text)<=2800000
+) not valid;
+
+create schema if not exists rc_backup_private;
+revoke all on schema rc_backup_private from public, anon, authenticated;
+create table public.reconversion_progress_history (
+ user_id uuid not null references auth.users(id) on delete cascade,
+ revision bigint not null,
+ payload jsonb not null,
+ saved_at timestamptz not null default now(),
+ primary key(user_id,revision)
+);
+alter table public.reconversion_progress_history enable row level security;
+revoke all on public.reconversion_progress_history from public, anon, authenticated;
+grant select on public.reconversion_progress_history to authenticated;
+create policy "history_read_self" on public.reconversion_progress_history for select to authenticated
+ using ((select auth.uid())=user_id and coalesce((select auth.jwt()->>'is_anonymous'),'false')='false');
+
+-- A private, non-callable trigger is the narrowly scoped privileged operation.
+-- Users cannot forge/delete history through REST. It cannot write arbitrary tables.
+create function rc_backup_private.capture_progress() returns trigger
+ language plpgsql security definer set search_path='' as $$
+begin
+ if auth.uid() is null or auth.uid()<>new.user_id or coalesce(auth.jwt()->>'is_anonymous','false')<>'false' then
+ raise exception 'Authenticated owner required' using errcode='42501';
+ end if;
+ if tg_op='INSERT' then
+ if new.revision<>1 then raise exception 'Initial revision must be 1' using errcode='23514'; end if;
+ else
+ if new.user_id<>old.user_id or new.revision<>old.revision+1 then
+ raise exception 'Revision must advance exactly once' using errcode='23514';
+ end if;
+ insert into public.reconversion_progress_history(user_id,revision,payload) values(old.user_id,old.revision,old.payload)
+ on conflict(user_id,revision) do nothing;
+ end if;
+ new.updated_at=now();
+ insert into public.reconversion_progress_history(user_id,revision,payload) values(new.user_id,new.revision,new.payload);
+ delete from public.reconversion_progress_history h where h.user_id=new.user_id and h.revision not in
+ (select keep.revision from public.reconversion_progress_history keep where keep.user_id=new.user_id order by keep.revision desc limit 20);
+ return new;
+end $$;
+revoke all on function rc_backup_private.capture_progress() from public, anon, authenticated;
+create trigger progress_capture before insert or update on public.reconversion_progress
+ for each row execute function rc_backup_private.capture_progress();
+commit;
+-- After inspecting existing envelope compatibility, separately run:
+-- ALTER TABLE public.reconversion_progress VALIDATE CONSTRAINT progress_safe_revision;
+-- ALTER TABLE public.reconversion_progress VALIDATE CONSTRAINT progress_encrypted_envelope;
+-- The archive starts with the current version on its next write, not past history.
diff --git a/tests/cloud-final-mile-browser.cjs b/tests/cloud-final-mile-browser.cjs
new file mode 100644
index 0000000..4768ffe
--- /dev/null
+++ b/tests/cloud-final-mile-browser.cjs
@@ -0,0 +1,101 @@
+"use strict";
+// Real application/crypto/Storage in TWO isolated profiles; all provider HTTP intercepted.
+const assert=require("node:assert/strict"),{chromium,webkit,devices}=require("playwright"),{startServer}=require("./server.cjs");
+const B={chromium,webkit}[process.env.BROWSER||"chromium"],CANON="reconversion-control.snapshot.v3",PHRASE="fictional-end-to-end-phrase-2026",USER="11111111-1111-4111-8111-111111111111";
+const pause=()=>{let resolve;const promise=new Promise(r=>{resolve=r;});return{promise,resolve};};
+async function run(){
+ const server=await startServer(),browser=await B.launch({headless:true}),contexts=[],errors=[],unexpected=[];
+ let row=null,history=[],lose=false,failReads=false,writeGate=null,writes=0;
+ const methods=[];
+ async function context(mobile=false){
+ const c=await browser.newContext({...(mobile?devices["iPhone 11 Pro Max"]:{}),serviceWorkers:"block",acceptDownloads:true});contexts.push(c);
+ await c.route("**/*",async route=>{
+ const req=route.request(),url=new URL(req.url());if(url.origin===server.origin)return route.continue();
+ if(url.origin!=="https://fixture.supabase.co"){unexpected.push(req.url());return route.abort();}
+ const reply=(code,data)=>route.fulfill({status:code,contentType:"application/json",headers:{"access-control-allow-origin":server.origin,"access-control-allow-methods":"GET,POST,PATCH,OPTIONS","access-control-allow-headers":"apikey,authorization,content-type,prefer"},body:JSON.stringify(data)});
+ if(req.method()==="OPTIONS")return reply(200,{});methods.push(url.pathname+" "+req.method());
+ if(url.pathname==="/auth/v1/otp")return reply(200,{});
+ if(url.pathname==="/auth/v1/verify"||url.pathname==="/auth/v1/token")return reply(200,{access_token:"test-access",refresh_token:"test-refresh",expires_in:3600,user:{id:USER}});
+ if(url.pathname==="/auth/v1/user")return reply(200,{id:USER});
+ if(url.pathname==="/auth/v1/logout")return reply(200,{});
+ if(url.pathname==="/rest/v1/reconversion_progress_history"){const versions=url.searchParams.has("revision")?history.filter(r=>"eq."+r.revision===url.searchParams.get("revision")):[...history].reverse().slice(0,20);return reply(200,url.searchParams.get("select")==="revision"?versions.map(r=>({revision:r.revision})):versions);}
+ if(url.pathname!=="/rest/v1/reconversion_progress"){unexpected.push(req.url());return route.abort();}
+ if(req.method()==="GET"){if(failReads)return route.abort("failed");return reply(200,row?[row]:[]);}
+ const data=req.postDataJSON(),first=req.method()==="POST";
+ if(first&&row||!first&&(!row||url.searchParams.get("revision")!=="eq."+row.revision))return reply(409,{});
+ if(writeGate){const held=writeGate;writeGate=null;held.started.resolve();await held.release.promise;}
+ row={revision:first?1:row.revision+1,payload:data.payload};history.push(row);writes++;
+ if(lose){lose=false;return route.abort("failed");}return reply(200,[row]);
+ });
+ return c;
+ }
+ async function page(c){const p=await c.newPage();p.on("pageerror",e=>errors.push(e.message));p.on("dialog",d=>d.accept());await p.goto(server.url+"/#privacy");await p.waitForFunction(k=>!!localStorage.getItem(k),CANON);return p;}
+ async function click(p,id){await p.evaluate(id=>document.querySelector("#rc-sync-"+id).onclick(),id);}
+ async function connect(p,remember=true){await p.locator("#rc-sync-url").fill("https://fixture.supabase.co");await p.locator("#rc-sync-key").fill("sb_publishable_fixture");await p.locator("#rc-sync-email").fill("test@example.invalid");if(remember)await p.locator("#rc-sync-remember").check();await click(p,"request");await p.locator("#rc-sync-otp").fill("123456");await p.locator("#rc-sync-phrase").fill(PHRASE);await click(p,"verify");}
+ async function unlock(p){await p.locator("#rc-sync-phrase").fill(PHRASE);await click(p,"unlock");}
+ async function read(p){return p.evaluate(k=>JSON.parse(localStorage.getItem(k)),CANON);}
+ async function snapshot(p){return p.evaluate(k=>{const s=JSON.parse(localStorage.getItem(k));return {version:2,progress:s.main.progress,review:s.main.review,labs:s.main.labs,proofs:s.main.proofs,labChecks:s.main.labChecks,dailyFive:s.daily,career:s.main.career};},CANON);}
+ async function edit(p,value){await p.locator('#nav [data-view="library"]').click();await p.locator('[data-card="dns"] [data-prog="'+value+'"]').click();await p.locator('#nav [data-view="privacy"]').click();}
+ async function clearDebounce(p){await p.evaluate(()=>{const old=window.setTimeout;window.__restoreTimeout=()=>{window.setTimeout=old;};window.setTimeout=(fn,delay,...args)=>delay>=2000&&delay<=60000?old(()=>{},delay):old(fn,delay,...args);});}
+ try{
+ const pc=await page(await context());
+ // A complete, fictitious business export; real validator supplies durable V3.
+ const seed=await pc.evaluate(()=>{
+ const career=RC_PROJECT_PLAN.empty();career.profile={diploma:"Bac fictif",history:"Dossier de test sans données personnelles"};career.schools.jedha.costs={tuition:12000};
+ const day=RC_DAILY_FIVE[0];
+ return {version:2,progress:{dns:"mastered","rc-09":"progress"},review:{dns:{step:2,due:"2026-10-25",lapses:1,lastRating:"good"}},labs:{[RC_LABS[0].id]:"progress"},proofs:{"rc-09":{status:"mastered",evidence:"preuve fictive"}},labChecks:{"lab-02":{sha256:"a".repeat(64),at:"2026-10-24T06:00:00Z"}},dailyFive:{days:{"2026-10-23":{sessionId:day.id,reason:"Rotation",quizChoice:day.quiz.answer,quizCorrect:true,missionDone:true,englishChoice:day.english.answer,englishCorrect:true,completed:true,xp:140}}},career};
+ });
+ // Obtain valid lab/proof IDs from the existing public teaching catalog.
+ await pc.locator("#importer").setInputFiles({name:"fictional-progression.json",mimeType:"application/json",buffer:Buffer.from(JSON.stringify(seed))});
+ await pc.waitForFunction(()=>document.querySelector("#importer").value==="");
+ const initial=await snapshot(pc);assert.equal(initial.progress.dns,"mastered");
+ await connect(pc);assert.equal(row,null,"OTP/unlock alone must not upload a first real snapshot");
+ await click(pc,"create");assert.equal(row.revision,1);assert(!JSON.stringify(row).includes("mastered"));
+ const remote=await pc.evaluate(async({p,phrase})=>RC_CLOUD_TRANSPORT.open(p,phrase),{p:row.payload,phrase:PHRASE});assert.deepEqual(remote,initial);
+ console.log("PASS browser cloud: explicit first source; complete atomic V3 business snapshot encrypted before send");
+ const phone=await page(await context(true));await connect(phone);assert(await phone.locator("#rc-sync-take").isVisible());assert.notEqual((await read(phone)).main.progress.dns,"mastered");
+ await click(phone,"take");assert.deepEqual(await snapshot(phone),initial);
+ // Actual page close/reopen discards memory/keys and retains browser Storage.
+ const phoneContext=phone.context();await phone.close();const reopened=await page(phoneContext),beforeOTP=methods.filter(x=>x.includes("/verify")).length;
+ assert(await reopened.locator("#rc-sync-unlock").isVisible());await unlock(reopened);assert.equal(methods.filter(x=>x.includes("/verify")).length,beforeOTP);
+ assert.match(await reopened.locator("#rc-sync-status").textContent(),/Restauration vérifiée après réouverture/);assert.deepEqual(await snapshot(reopened),initial);
+ const persistent=await reopened.evaluate(()=>Object.keys(localStorage).filter(k=>k.includes("cloud")).map(k=>localStorage.getItem(k)).join("\n"));
+ assert(!persistent.includes(PHRASE));assert(!persistent.includes("test-access"));assert(!persistent.includes("test-refresh"));
+ console.log("PASS browser cloud: second device complete restore; page close/reopen; encrypted session resumes without OTP, phrase still required");
+ await edit(reopened,"progress");await click(reopened,"now");await click(pc,"now");assert.deepEqual(await snapshot(pc),await snapshot(reopened));
+ console.log("PASS browser cloud: reverse phone-to-PC synchronization through independent profiles");
+ // Lost mutation response AND failed reconciliation. Outbox must survive a restart.
+ await clearDebounce(pc);await edit(pc,"learn");lose=true;failReads=false;
+ // Intercept only read-after-mutation by setting failReads when write reaches its gate.
+ const held={started:pause(),release:pause()};writeGate=held;
+ const sending=click(pc,"now");await held.started.promise;failReads=true;held.release.resolve();await sending;
+ assert.match(await pc.locator("#rc-sync-status").textContent(),/en attente/);assert.equal((await read(pc)).main.progress.dns,"learn");const committed=row.revision;
+ await pc.reload();failReads=false;await unlock(pc);assert.equal(row.revision,committed,"uncertain upload retry must be idempotent");
+ assert.equal((await snapshot(pc)).progress.dns,"learn");
+ console.log("PASS browser cloud: response lost after server commit + failed readback; durable outbox reconciles after reload without a duplicate revision");
+ await click(reopened,"now");
+ // Both devices diverge; no field-level last-write-wins or silent replacement.
+ await clearDebounce(pc);await clearDebounce(reopened);await edit(pc,"progress");await edit(reopened,"mastered");await click(pc,"now");await click(reopened,"now");
+ assert.match(await reopened.locator("#rc-sync-status").textContent(),/Conflit détecté/);assert.equal((await read(reopened)).main.progress.dns,"mastered");assert.equal((await read(pc)).main.progress.dns,"progress");
+ await click(reopened,"push");await click(pc,"now");assert.equal((await read(pc)).main.progress.dns,"mastered");
+ assert(await reopened.evaluate(()=>JSON.parse(localStorage.getItem("reconversion-control.cloud.recovery.v1")).copies.length>=1));
+ console.log("PASS browser cloud: concurrent device divergence requires choice; replaced version preserved before write");
+ // Local write failure cannot manufacture a confirmed upload or discard V3.
+ const saved=await pc.evaluate(k=>localStorage.getItem(k),CANON),previousWrites=writes;
+ await pc.evaluate(k=>{const original=Storage.prototype.setItem;Storage.prototype.setItem=function(key,v){if(key===k)throw new DOMException("synthetic quota","QuotaExceededError");return original.call(this,key,v);};},CANON);
+ await edit(pc,"learn");await click(pc,"now");assert.equal(writes,previousWrites);assert.equal(await pc.evaluate(k=>localStorage.getItem(k),CANON),saved);
+ assert.match(await pc.locator("#rc-sync-status").textContent(),/non confirmée|non enregistrées/);
+ await pc.reload();await unlock(pc);assert.equal((await read(pc)).main.progress.dns,"mastered");
+ console.log("PASS browser cloud: failed local write blocks transmission and preserves last confirmed progress across reload");
+ // Future remote format is rejected without writes, including a new client.
+ const stable=await snapshot(pc);row={revision:row.revision+1,payload:await pc.evaluate(async phrase=>RC_CLOUD_TRANSPORT.seal({version:99,progress:{}},phrase),PHRASE)};
+ await click(pc,"now");assert.deepEqual(await snapshot(pc),stable);assert.match(await pc.locator("#rc-sync-status").textContent(),/incompatible/);
+ // Recover an older valid cloud version through the existing transport, explicitly.
+ await click(pc,"history");await pc.locator("[data-sync-history]").first().click();assert.equal((await read(pc)).main.progress.dns,"mastered");assert.match(await pc.locator("#rc-sync-status").textContent(),/Ancienne révision/);
+ console.log("PASS browser cloud: future/corrupt head cannot erase local progress; bounded previous cloud version is explicitly recoverable");
+ await click(pc,"disconnect");assert.equal(await pc.evaluate(()=>localStorage.getItem("reconversion-control.cloud.session.v1")),null);assert.deepEqual(await snapshot(pc),stable);
+ assert.deepEqual(errors,[]);assert.deepEqual(unexpected,[]);
+ console.log("PASS browser cloud: explicit logout deletes remembered secrets and preserves local progress; no unmocked provider requests");
+ }finally{if(writeGate)writeGate.release.resolve();for(const c of contexts)await c.close();await browser.close();await server.close();}
+}
+const watchdog=setTimeout(()=>{throw Error("Final-mile browser suite stalled");},180000);run().catch(e=>{console.error(e);process.exitCode=1;}).finally(()=>clearTimeout(watchdog));
diff --git a/tests/cloud-final-mile.cjs b/tests/cloud-final-mile.cjs
new file mode 100644
index 0000000..dd51593
--- /dev/null
+++ b/tests/cloud-final-mile.cjs
@@ -0,0 +1,92 @@
+"use strict";
+// Isolated Supabase HTTP simulation, actual production crypto/transport. No credentials.
+const assert=require("node:assert/strict"),vm=require("node:vm"),fs=require("node:fs"),{webcrypto}=require("node:crypto");
+const a="11111111-1111-4111-8111-111111111111",b="22222222-2222-4222-8222-222222222222",phrase="isolated-fictional-phrase-2026";
+const clone=v=>JSON.parse(JSON.stringify(v)),response=(status,j)=>({ok:status>=200&&status<300,status,text:async()=>JSON.stringify(j)});
+let passed=0;
+async function check(name,fn){await fn();passed++;console.log("PASS cloud final mile: "+name);}
+function fixture(){
+ const rows=new Map(),calls=[];let lost=false,failRead=false,empty=false,expired=false,refreshes=0,revoke=false,anon=false,clock=0;
+ async function fetch(input,init){
+ const url=new URL(input),body=init.body?JSON.parse(init.body):{},token=init.headers.Authorization;
+ assert.equal(url.origin,"https://fixture.supabase.co");calls.push({path:url.pathname,method:init.method});
+ if(url.pathname==="/auth/v1/verify")return response(200,{access_token:body.email.startsWith("b")?b:a,refresh_token:"refresh-fixture",expires_in:expired?1:3600,user:{id:body.email.startsWith("b")?b:a,is_anonymous:anon}});
+ if(url.pathname==="/auth/v1/token"){
+ refreshes++;await Promise.resolve();
+ if(revoke)return response(400,{});
+ return response(200,{access_token:a,refresh_token:"rotated-fixture",expires_in:3600,user:{id:a}});
+ }
+ if(url.pathname==="/auth/v1/user")return response(200,{id:token.slice(7)});
+ if(url.pathname==="/auth/v1/logout")return response(204,null);
+ if(url.pathname!=="/rest/v1/reconversion_progress")return response(404,{});
+ const uid=token&&token.slice(7),target=url.searchParams.get("user_id");
+ if(![a,b].includes(uid)||target&&target!=="eq."+uid)return response(403,{});
+ if(init.method==="GET"){if(failRead)throw Error("synthetic offline read");return response(200,rows.has(uid)?[rows.get(uid)]:[]);}
+ let current=rows.get(uid);
+ if(init.method==="POST"){
+ if(body.user_id!==uid)return response(403,{});if(current)return response(409,{});current={revision:1,payload:body.payload};
+ }else{
+ if(!current||url.searchParams.get("revision")!=="eq."+current.revision)return response(200,[]);
+ current={revision:current.revision+1,payload:body.payload};
+ }
+ rows.set(uid,current);
+ if(lost){lost=false;throw Error("synthetic lost response AFTER commit");}
+ return response(200,empty?[]:[current]);
+ }
+ const sandbox={URL,crypto:webcrypto,TextEncoder,TextDecoder,fetch,Date:class extends Date{static now(){return Date.now()+clock;}},btoa:s=>Buffer.from(s,"binary").toString("base64"),atob:s=>Buffer.from(s,"base64").toString("binary")};
+ vm.createContext(sandbox);vm.runInContext(fs.readFileSync(require("node:path").join(__dirname,"../cloud-transport.js"),"utf8")+"\nglobalThis.T=RC_CLOUD_TRANSPORT",sandbox);
+ const T=sandbox.T,cfg={url:"https://fixture.supabase.co",key:"sb_publishable_fixture"};
+ return {T,cfg,rows,calls,client:options=>T.create(cfg,options),lose:()=>{lost=true;},offline:v=>{failRead=v;},empty:()=>{empty=true;},expire:()=>{expired=true;},advance:()=>{clock+=3600001;},revoke:()=>{revoke=true;},anonymous:()=>{anon=true;},get refreshes(){return refreshes;}};
+}
+async function run(){
+ await check("legacy AES-256-GCM envelope, independent IV/salt and tamper refusal",async()=>{
+ const {T}=fixture(),data={version:2,progress:{dns:"mastered"},dailyFive:{days:{"2026-10-24":{marker:"synthetic"}}}};
+ const x=await T.seal(data,phrase),y=await T.seal(data,phrase);assert.notEqual(x.iv,y.iv);assert.notEqual(x.salt,y.salt);assert.deepEqual(clone(await T.open(x,phrase)),data);
+ await assert.rejects(T.open(x,"wrong-fictional-phrase"));
+ for(const field of ["iv","salt","ciphertext"]){const changed={...x,[field]:(x[field][0]==="A"?"B":"A")+x[field].slice(1)};await assert.rejects(T.open(changed,phrase));}
+ await assert.rejects(T.open({...x,v:2},phrase));await assert.rejects(T.open({...x,unknown:true},phrase));await assert.rejects(T.open({...x,ciphertext:"A".repeat(3000000)},phrase));
+ });
+ await check("serialization freezes before KDF and supports large histories without spread overflow",async()=>{
+ const {T}=fixture(),data={version:2,progress:{dns:"progress"},history:"x".repeat(300000)};
+ const pending=T.seal(data,phrase);data.progress.dns="mastered";const sealed=await pending;
+ assert.equal((await T.open(sealed,phrase)).progress.dns,"progress");await assert.rejects(T.seal({large:"x".repeat(2100000)},phrase));
+ await assert.rejects(T.seal(JSON.parse('{"__proto__":{"polluted":true}}'),phrase));
+ });
+ await check("lost upload response is reconciled by exact ciphertext and revision",async()=>{
+ const f=fixture(),c=f.client();await c.verifyCode("a@example.invalid","123456");const payload=await f.T.seal({marker:"A"},phrase);
+ f.lose();assert.equal(await c.write(payload,null),1);assert.equal(f.rows.get(a).revision,1);
+ assert.equal(await c.write(payload,null),1);assert.equal(f.rows.get(a).revision,1,"idempotent retry must not add a revision");
+ });
+ await check("uncertain commit stays retryable after read failure and complete client restart",async()=>{
+ const f=fixture(),c=f.client();await c.verifyCode("a@example.invalid","123456");const payload=await f.T.seal({marker:"restart"},phrase);
+ f.lose();f.offline(true);await assert.rejects(c.write(payload,null));assert.equal(f.rows.get(a).revision,1);
+ const reboot=f.client();await reboot.verifyCode("a@example.invalid","123456");f.offline(false);assert.equal(await reboot.write(payload,null),1);assert.equal(f.rows.get(a).revision,1);
+ });
+ await check("empty mutation response is accepted only after confirmed exact readback",async()=>{
+ const f=fixture(),c=f.client();await c.verifyCode("a@example.invalid","123456");const p=await f.T.seal({marker:"only-readback"},phrase);f.empty();assert.equal(await c.write(p,null),1);
+ });
+ await check("two independent users and simultaneous clients preserve conflict copies",async()=>{
+ const f=fixture(),x=f.client(),y=f.client(),other=f.client();await x.verifyCode("a@example.invalid","123456");await y.verifyCode("a@example.invalid","123456");await other.verifyCode("b@example.invalid","123456");
+ const p=await f.T.seal({marker:"first"},phrase),q=await f.T.seal({marker:"second"},phrase);await x.write(p,null);
+ assert.equal(await other.read(),null);await other.write(q,null);assert.equal((await x.read()).payload.ciphertext,p.ciphertext);
+ const attempts=await Promise.allSettled([x.write(q,1),y.write(p,1)]);assert.equal(attempts.filter(r=>r.status==="fulfilled").length,1);assert.equal(f.rows.get(a).revision,2);assert.equal(f.rows.get(b).revision,1);
+ });
+ await check("refresh is single-flight, encrypted remembered session resumes without OTP",async()=>{
+ const f=fixture();let saved;const x=f.client({sessionChanged:async session=>{saved=await f.T.sealSession(session,phrase);}});
+ await x.verifyCode("a@example.invalid","123456");assert(!JSON.stringify(saved).includes("refresh-fixture"));
+ await assert.rejects(f.T.open(saved,phrase),/Déchiffrement/);await assert.rejects(f.T.openSession(saved,"wrong-fictional-phrase"));
+ const session=await f.T.openSession(saved,phrase),before=f.calls.length,y=f.client();await y.resume(session);
+ assert(!f.calls.slice(before).some(c=>c.path==="/auth/v1/verify"));f.advance();await Promise.all([y.read(),y.read()]);assert.equal(f.refreshes,1);
+ });
+ await check("revoked session and anonymous sign-in cannot write",async()=>{
+ const f=fixture(),x=f.client();f.expire();await x.verifyCode("a@example.invalid","123456");f.revoke();await assert.rejects(x.read(),/expirée|révoquée/);assert.equal(f.rows.size,0);
+ const g=fixture();g.anonymous();await assert.rejects(g.client().verifyCode("a@example.invalid","123456"),/Authentification/);
+ });
+ await check("explicit logout clears client; unsafe revisions and secret keys rejected",async()=>{
+ const f=fixture(),c=f.client();await c.verifyCode("a@example.invalid","123456");await c.logout();await assert.rejects(c.read(),/Non connecté/);
+ await c.verifyCode("a@example.invalid","123456");for(const revision of [-1,0,1.2,"1",Number.MAX_SAFE_INTEGER])await assert.rejects(c.write({},revision),/Révision/);
+ assert.throws(()=>f.T.config(f.cfg.url,"sb_secret_do_not_use"));assert.throws(()=>f.T.config(f.cfg.url+"/other",f.cfg.key));
+ });
+ console.log("Cloud final-mile transport/crypto: "+passed+" groups passed; simulated HTTP only (not hosted RLS).");
+}
+const watchdog=setTimeout(()=>{throw Error("Final-mile transport suite stalled");},60000);run().catch(e=>{console.error(e);process.exitCode=1;}).finally(()=>clearTimeout(watchdog));
diff --git a/tests/cloud-history.cjs b/tests/cloud-history.cjs
new file mode 100644
index 0000000..a2dfe65
--- /dev/null
+++ b/tests/cloud-history.cjs
@@ -0,0 +1,51 @@
+"use strict";
+// Real PostgreSQL engine compiled to WASM; mock auth schema, no Supabase endpoint.
+const {PGlite}=require("@electric-sql/pglite"),assert=require("node:assert/strict"),fs=require("node:fs"),path=require("node:path");
+const root=path.resolve(__dirname,".."),a="11111111-1111-4111-8111-111111111111",b="22222222-2222-4222-8222-222222222222";
+const payload={v:1,kdf:"PBKDF2-SHA256",iterations:250000,salt:"AAAAAAAAAAAAAAAAAAAAAA==",iv:"AAAAAAAAAAAAAAAA",ciphertext:"AAAAAAAAAAAAAAAAAAAAAA=="};
+async function run(){
+ const db=new PGlite();let count=0;
+ const check=async(name,fn)=>{await fn();count++;console.log("PASS isolated SQL: "+name);};
+ async function owner(id,anonymous=false){await db.exec("reset role");await db.query("select set_config('request.jwt.claims',$1,false)",[JSON.stringify({sub:id,is_anonymous:anonymous})]);await db.exec("set role authenticated");}
+ try{
+ await db.exec("create role anon nologin; create role authenticated nologin; create schema auth; create table auth.users(id uuid primary key); grant usage on schema public,auth to anon,authenticated; create function auth.uid() returns uuid language sql stable as $$ select (nullif(current_setting('request.jwt.claims',true),'')::jsonb->>'sub')::uuid $$; create function auth.jwt() returns jsonb language sql stable as $$ select coalesce(nullif(current_setting('request.jwt.claims',true),'')::jsonb,'{}'::jsonb) $$;");
+ await db.query("insert into auth.users values ($1),($2)",[a,b]);
+ await db.exec(fs.readFileSync(path.join(root,"sql/reconversion_progress.sql"),"utf8"));
+ const migrations=fs.readdirSync(path.join(root,"supabase/migrations")).filter(n=>n.endsWith("_encrypted_cloud_history.sql"));assert.equal(migrations.length,1);
+ await db.exec(fs.readFileSync(path.join(root,"supabase/migrations",migrations[0]),"utf8"));
+ await check("authenticated owner can create and read encrypted progress",async()=>{await owner(a);await db.query("insert into public.reconversion_progress(user_id,payload) values($1,$2)",[a,payload]);assert.equal((await db.query("select * from public.reconversion_progress")).rows.length,1);});
+ await check("second user cannot see or change first user's progress/history",async()=>{
+ await owner(b);assert.equal((await db.query("select * from public.reconversion_progress")).rows.length,0);assert.equal((await db.query("select * from public.reconversion_progress_history")).rows.length,0);
+ assert.equal((await db.query("update public.reconversion_progress set revision=2 returning *")).rows.length,0);
+ await assert.rejects(db.query("insert into public.reconversion_progress(user_id,payload) values($1,$2)",[a,payload]),e=>e.code==="42501");
+ await db.query("insert into public.reconversion_progress(user_id,payload) values($1,$2)",[b,payload]);
+ });
+ await check("anonymous and anonymous-auth sessions have no private access",async()=>{
+ await db.exec("reset role; set role anon");await assert.rejects(db.query("select * from public.reconversion_progress"),e=>e.code==="42501");await assert.rejects(db.query("select * from public.reconversion_progress_history"),e=>e.code==="42501");
+ await owner(a,true);assert.equal((await db.query("select * from public.reconversion_progress")).rows.length,0);assert.equal((await db.query("select * from public.reconversion_progress_history")).rows.length,0);
+ await assert.rejects(db.query("insert into public.reconversion_progress(user_id,payload) values($1,$2)",[a,payload]),e=>e.code==="42501");
+ });
+ await check("server rejects revision jumps, ownership changes and plaintext payloads",async()=>{
+ await owner(a);await assert.rejects(db.query("update public.reconversion_progress set revision=3 where user_id=$1",[a]),e=>e.code==="23514");
+ await assert.rejects(db.query("update public.reconversion_progress set user_id=$1,revision=2 where user_id=$2",[b,a]));
+ await assert.rejects(db.query("update public.reconversion_progress set revision=2,payload=$1 where user_id=$2",[{progress:{dns:"mastered"}},a]),e=>e.code==="23514");
+ assert.equal((await db.query("select revision from public.reconversion_progress")).rows[0].revision,1);
+ });
+ await check("conditional stale update returns zero rows; history is immutable to clients",async()=>{
+ await db.query("update public.reconversion_progress set revision=2 where user_id=$1 and revision=1",[a]);
+ assert.equal((await db.query("update public.reconversion_progress set revision=2 where user_id=$1 and revision=1 returning *",[a])).rows.length,0);
+ for(const query of ["delete from public.reconversion_progress_history","update public.reconversion_progress_history set revision=99","insert into public.reconversion_progress_history(user_id,revision,payload) select user_id,99,payload from public.reconversion_progress"])
+ await assert.rejects(db.query(query),e=>e.code==="42501");
+ await assert.rejects(db.query("select rc_backup_private.capture_progress()"),e=>e.code==="42501");
+ });
+ await check("history keeps exactly 20 coherent versions per user and rolls back failed writes",async()=>{
+ for(let n=3;n<=24;n++)await db.query("update public.reconversion_progress set revision=$1 where user_id=$2 and revision=$3",[n,a,n-1]);
+ const history=(await db.query("select revision from public.reconversion_progress_history order by revision")).rows.map(x=>x.revision);assert.deepEqual(history,Array.from({length:20},(_,i)=>i+5));
+ await assert.rejects(db.query("update public.reconversion_progress set revision=25,payload='{}'::jsonb where user_id=$1",[a]));
+ assert.equal((await db.query("select max(revision) n from public.reconversion_progress_history")).rows[0].n,24);
+ await owner(b);assert.equal((await db.query("select count(*)::int n from public.reconversion_progress_history")).rows[0].n,1);
+ });
+ console.log("Isolated PostgreSQL/RLS: "+count+" groups passed. Mock auth.uid()/JWT only; hosted Supabase integration remains separate.");
+ }finally{await db.close();}
+}
+run().catch(e=>{console.error(e);process.exitCode=1;});
diff --git a/tests/cloud-sync.spec.cjs b/tests/cloud-sync.spec.cjs
index 43b4d6c..01ecb87 100644
--- a/tests/cloud-sync.spec.cjs
+++ b/tests/cloud-sync.spec.cjs
@@ -33,7 +33,7 @@ async function run(){
const original={version:2,career:{profile:{diploma:"Diplôme fictif"},schools:{jedha:{answers:{title:{status:"received",evidence:"Preuve fictive",date:"2026-10-15"}}}}},progress:{dns:"mastered"},dailyFive:{days:{"2026-10-08":{xp:20}}}};
const enc=await T.seal(original,"phrase secrète assez longue");
assert.equal(enc.v,1);assert(enc.ciphertext.length>20);
- const plain=await T.open(enc,"phrase secrète assez longue");assert.equal(JSON.stringify(plain),JSON.stringify(original));
+ const plain=await T.open(enc,"phrase secrète assez longue");assert.deepEqual(JSON.parse(JSON.stringify(plain)),original);
await assert.rejects(T.open(enc,"mauvaise phrase secrète"),/Déchiffrement impossible/);
const a=T.create(cfg),b=T.create(cfg);
await a.sendCode("test@example.com");
@@ -43,10 +43,12 @@ async function run(){
assert.equal(await a.write(enc,null),1);
assert.equal((await b.read()).revision,1);
assert.equal(await a.write(enc,1),2);
- await assert.rejects(b.write(enc,1),/Conflit/);
+ const other=await T.seal({...original,progress:{dns:"progress"}},"phrase secrète assez longue");
+ await assert.rejects(b.write(other,1),/Conflit/);
assert.equal((await b.read()).revision,2);
a.logout();await assert.rejects(a.read(),/Non connecté/);
console.log("PASS: crypto AES-GCM, rejet mauvaises phrases, OTP, conflit de révision, refus de clé secrète.");
await require("./cloud-ui-races.cjs").run();
}
-run().catch(e=>{console.error(e);process.exitCode=1;});
+const watchdog=setTimeout(()=>{throw Error("Cloud suite stalled");},30000);
+run().catch(e=>{console.error(e);process.exitCode=1;}).finally(()=>clearTimeout(watchdog));
diff --git a/tests/cloud-ui-browser.cjs b/tests/cloud-ui-browser.cjs
index 356df67..bd857e7 100644
--- a/tests/cloud-ui-browser.cjs
+++ b/tests/cloud-ui-browser.cjs
@@ -46,11 +46,12 @@ async function run(browser, server) {
await page.locator('#nav [data-view="privacy"]').click();
};
const open = payload => page.evaluate(({payload, phrase}) => RC_CLOUD_TRANSPORT.open(payload, phrase), {payload, phrase: PHRASE});
- if (kind === "choice") row = {revision: 4, payload: await page.evaluate(phrase => RC_CLOUD_TRANSPORT.seal({version: 2, progress: {dns: "learn"}, review: {}, labs: {}, proofs: {}, labChecks: {}, dailyFive: {}, career: RC_PROJECT_PLAN.empty()}, phrase), PHRASE)};
- if (kind !== "first") await page.evaluate(() => document.querySelector("#rc-sync-verify").onclick());
+ if (kind === "choice") row = {revision: 4, payload: await page.evaluate(phrase => RC_CLOUD_TRANSPORT.seal({version: 2, progress: {dns: "learn"}, review: {}, labs: {}, proofs: {}, labChecks: {}, dailyFive: {days:{}}, career: RC_PROJECT_PLAN.empty()}, phrase), PHRASE)};
+ await page.evaluate(() => document.querySelector("#rc-sync-verify").onclick());
+ if(kind === "automatic") { page.once("dialog", dialog => dialog.accept()); await page.evaluate(() => document.querySelector("#rc-sync-create").onclick()); }
if (kind !== "first") await edit("progress");
const held = {started: deferred(), release: deferred()}; gate = held;
- const selector = "#rc-sync-" + (kind === "first" ? "verify" : kind === "choice" ? "push" : "now");
+ const selector = "#rc-sync-" + (kind === "first" ? "create" : kind === "choice" ? "push" : "now");
page.on("dialog", dialog => dialog.accept());
const sending = page.evaluate(selector => document.querySelector(selector).onclick(), selector);
let deadline;
diff --git a/tests/cloud-ui-races.cjs b/tests/cloud-ui-races.cjs
index 765dae0..5438f53 100644
--- a/tests/cloud-ui-races.cjs
+++ b/tests/cloud-ui-races.cjs
@@ -21,7 +21,7 @@ function fixture() {
if (gate) { gates.delete(stage); gate.started.resolve(); await gate.release.promise; }
}
const client = {
- sendCode: async () => {}, verifyCode: async () => {}, logout: () => {},
+ sendCode: async () => {}, verifyCode: async () => "00000000-0000-4000-8000-000000000001", logout: () => {},
async read() { const result = copy(row); await wait("read"); return result; },
async write(payload, revision) {
await wait("write");
@@ -31,12 +31,16 @@ function fixture() {
writes.push(copy(row)); return row.revision;
}
};
+ const store = new Map();
const sandbox = {
+ localStorage: {getItem:k=>store.get(k)||null,setItem:(k,v)=>store.set(k,v),removeItem:k=>store.delete(k)},
+ Date,
navigator: {onLine: true}, document: {querySelector: node}, confirm: () => true,
setInterval: callback => { interval = callback; return 1; }, clearInterval: () => { interval = null; },
setTimeout: callback => { timeout = callback; return 2; }, clearTimeout: () => { timeout = null; },
RC_CLOUD_TRANSPORT: {
- create: () => client,
+ config: (url,key)=>({url,key}), create: () => client,
+ serialize: JSON.stringify, fingerprint: async s => require("node:crypto").createHash("sha256").update(s).digest("hex"),
// Serialize AFTER the wait, just as real seal() does after its asynchronous KDF.
async seal(snapshot) { await wait("seal"); return {snapshot: copy(snapshot)}; },
async open(payload) { await wait("open"); return copy(payload.snapshot); }
@@ -56,9 +60,10 @@ function fixture() {
async prepare(kind) {
if (kind === "choice") row = {revision: 4, payload: {snapshot: {version: 2, progress: {dns: "remote-fixture"}}}};
await click("request");
- if (kind !== "first") await click("verify");
+ await click("verify");
+ if(kind === "automatic") await click("create");
},
- send: kind => kind === "first" ? click("verify") : kind === "choice" ? click("push") : interval(),
+ send: kind => kind === "first" ? click("create") : kind === "choice" ? click("push") : interval(),
edit(value) { state.progress.dns = value; api.changed(); },
pause(stage) { const gate = {started: deferred(), release: deferred()}; gates.set(stage, gate); return {started: gate.started.promise, release: gate.release.resolve}; },
remote(value) { row = {revision: (row ? row.revision : 0) + 1, payload: {snapshot: {version: 2, progress: {dns: value}}}}; },
@@ -120,4 +125,4 @@ async function run() {
if (failures.length) throw new AggregateError(failures, failures.length + " N03 regression cases failed");
}
module.exports = {run};
-if (require.main === module) run().catch(error => { console.error(error); process.exitCode = 1; });
+if (require.main === module) { const watchdog=setTimeout(()=>{throw Error("N03 suite stalled");},30000); run().catch(error => { console.error(error); process.exitCode = 1; }).finally(()=>clearTimeout(watchdog)); }
From 3efefba80497f64682ec64b8c57cc1b801d4b72e Mon Sep 17 00:00:00 2001
From: iPoower <101516206+iPoower@users.noreply.github.com>
Date: Sat, 24 Oct 2026 13:35:04 +0200
Subject: [PATCH 2/7] fix(backup): retain rotated sessions on storage errors
and await recovery checks
---
.github/workflows/tests.yml | 5 +++++
cloud-transport.js | 14 ++++++++++----
tests/cloud-final-mile-browser.cjs | 2 +-
tests/cloud-final-mile.cjs | 12 ++++++++++++
4 files changed, 28 insertions(+), 5 deletions(-)
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index f1abd05..4cadc98 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -20,6 +20,7 @@ jobs:
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
+ ref: ${{ github.event.pull_request.head.sha || github.sha }}
persist-credentials: false
- name: Tests de sécurité de publication
run: python3 -m unittest discover -s tests -p 'test_pages*.py'
@@ -58,6 +59,9 @@ jobs:
browser: [chromium, webkit]
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
+ with:
+ ref: ${{ github.event.pull_request.head.sha || github.sha }}
+ persist-credentials: false
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
node-version: 20
@@ -68,6 +72,7 @@ jobs:
env:
BROWSER: ${{ matrix.browser }}
run: |
+ test "$(git rev-parse HEAD)" = "${{ github.event.pull_request.head.sha || github.sha }}"
node tests/browser-runtime.spec.cjs
node tests/browser-runtime.cjs
- name: Syntaxe
diff --git a/cloud-transport.js b/cloud-transport.js
index eb122e2..ab99ca4 100644
--- a/cloud-transport.js
+++ b/cloud-transport.js
@@ -20,10 +20,10 @@ const RC_CLOUD_TRANSPORT = (() => {
function validRevision(n){return Number.isSafeInteger(n)&&n>0&&nctrl.abort(),20000):null;
try{
@@ -37,19 +37,25 @@ const RC_CLOUD_TRANSPORT = (() => {
}finally{if(timeout)clearTimeout(timeout);}
}
function validSession(s){return s&&s.project===base.url&&typeof s.access_token==="string"&&s.access_token.length>0&&s.access_token.length<16384&&typeof s.refresh_token==="string"&&s.refresh_token.length>0&&s.refresh_token.length<16384&&Number.isFinite(s.expires_at)&&typeof s.user==="string"&&/^[0-9a-f-]{36}$/i.test(s.user);}
+ async function persistSession(ticket){
+ if(!options.sessionChanged){sessionNeedsSave=false;return;}
+ if(!saving)saving=(async()=>{try{await options.sessionChanged({...session});if(ticket!==generation)throw Error("Session annulée");sessionNeedsSave=false;}finally{saving=null;}})();
+ return saving;
+ }
async function accept(j,ticket){
if(ticket!==generation)throw Error("Session annulée");
if(!j||!j.user||j.user.is_anonymous===true)throw Error("Authentification non confirmée");
const next={project:base.url,access_token:j.access_token,refresh_token:j.refresh_token,expires_at:Date.now()+Number(j.expires_in||3600)*1000,user:j.user.id};
if(!validSession(next)||session&&session.user!==next.user)throw Error("Authentification non confirmée");
- session=next;
- if(options.sessionChanged)await options.sessionChanged({...next});
+ session=next;sessionNeedsSave=true;await persistSession(ticket);
if(ticket!==generation)throw Error("Session annulée");
return session.user;
}
async function sendCode(email){if(!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email))throw Error("Adresse email incorrecte");await request("/auth/v1/otp",{method:"POST",body:{email,create_user:true}});}
async function verifyCode(email,token){if(!/^\d{6,8}$/.test(token))throw Error("Code OTP attendu (6 à 8 chiffres)");const ticket=generation;return accept(await request("/auth/v1/verify",{method:"POST",body:{email,token,type:"email"}}),ticket);}
async function access(){
+ if(!session)throw Error("Non connecté");
+ if(sessionNeedsSave)await persistSession(generation);
if(!session)throw Error("Non connecté");
if(Date.now(){
diff --git a/tests/cloud-final-mile-browser.cjs b/tests/cloud-final-mile-browser.cjs
index 4768ffe..a8ae046 100644
--- a/tests/cloud-final-mile-browser.cjs
+++ b/tests/cloud-final-mile-browser.cjs
@@ -91,7 +91,7 @@ async function run(){
const stable=await snapshot(pc);row={revision:row.revision+1,payload:await pc.evaluate(async phrase=>RC_CLOUD_TRANSPORT.seal({version:99,progress:{}},phrase),PHRASE)};
await click(pc,"now");assert.deepEqual(await snapshot(pc),stable);assert.match(await pc.locator("#rc-sync-status").textContent(),/incompatible/);
// Recover an older valid cloud version through the existing transport, explicitly.
- await click(pc,"history");await pc.locator("[data-sync-history]").first().click();assert.equal((await read(pc)).main.progress.dns,"mastered");assert.match(await pc.locator("#rc-sync-status").textContent(),/Ancienne révision/);
+ await click(pc,"history");await pc.evaluate(()=>document.querySelector("[data-sync-history]").onclick());assert.equal((await read(pc)).main.progress.dns,"mastered");assert.match(await pc.locator("#rc-sync-status").textContent(),/Ancienne révision/);
console.log("PASS browser cloud: future/corrupt head cannot erase local progress; bounded previous cloud version is explicitly recoverable");
await click(pc,"disconnect");assert.equal(await pc.evaluate(()=>localStorage.getItem("reconversion-control.cloud.session.v1")),null);assert.deepEqual(await snapshot(pc),stable);
assert.deepEqual(errors,[]);assert.deepEqual(unexpected,[]);
diff --git a/tests/cloud-final-mile.cjs b/tests/cloud-final-mile.cjs
index dd51593..410c3b0 100644
--- a/tests/cloud-final-mile.cjs
+++ b/tests/cloud-final-mile.cjs
@@ -82,6 +82,18 @@ async function run(){
const f=fixture(),x=f.client();f.expire();await x.verifyCode("a@example.invalid","123456");f.revoke();await assert.rejects(x.read(),/expirée|révoquée/);assert.equal(f.rows.size,0);
const g=fixture();g.anonymous();await assert.rejects(g.client().verifyCode("a@example.invalid","123456"),/Authentification/);
});
+ await check("failed rotated-session persistence blocks requests until durable retry succeeds",async()=>{
+ const f=fixture();let fail=false,saves=0;
+ const c=f.client({sessionChanged:async()=>{saves++;if(fail)throw Error("synthetic session storage quota");}});
+ await c.verifyCode("a@example.invalid","123456");f.advance();fail=true;
+ await assert.rejects(c.read(),/storage quota/);await assert.rejects(c.read(),/storage quota/);
+ assert.equal(f.calls.filter(x=>x.path==="/rest/v1/reconversion_progress").length,0);
+ fail=false;assert.equal(await c.read(),null);assert.equal(f.refreshes,1);assert(saves>=4);
+ });
+ await check("logout while authentication yields cancels a not-yet-sent data request",async()=>{
+ const f=fixture(),c=f.client();await c.verifyCode("a@example.invalid","123456");const reading=c.read();await c.logout();await assert.rejects(reading,/annulée|Non connecté/);
+ assert.equal(f.calls.filter(x=>x.path==="/rest/v1/reconversion_progress").length,0);
+ });
await check("explicit logout clears client; unsafe revisions and secret keys rejected",async()=>{
const f=fixture(),c=f.client();await c.verifyCode("a@example.invalid","123456");await c.logout();await assert.rejects(c.read(),/Non connecté/);
await c.verifyCode("a@example.invalid","123456");for(const revision of [-1,0,1.2,"1",Number.MAX_SAFE_INTEGER])await assert.rejects(c.write({},revision),/Révision/);
From 2ec068d8a249bcf4e790562411c821515ec08953 Mon Sep 17 00:00:00 2001
From: iPoower <101516206+iPoower@users.noreply.github.com>
Date: Sat, 24 Oct 2026 13:37:43 +0200
Subject: [PATCH 3/7] test(backup): exercise offline resume, journal failures
and empty cloud protection
---
.github/workflows/tests.yml | 2 +-
tests/cloud-final-mile-browser.cjs | 23 +++++++++++++++++++++--
2 files changed, 22 insertions(+), 3 deletions(-)
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 4cadc98..dfb5d4d 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -72,7 +72,7 @@ jobs:
env:
BROWSER: ${{ matrix.browser }}
run: |
- test "$(git rev-parse HEAD)" = "${{ github.event.pull_request.head.sha || github.sha }}"
+ test "$(git -c safe.directory="$GITHUB_WORKSPACE" rev-parse HEAD)" = "${{ github.event.pull_request.head.sha || github.sha }}"
node tests/browser-runtime.spec.cjs
node tests/browser-runtime.cjs
- name: Syntaxe
diff --git a/tests/cloud-final-mile-browser.cjs b/tests/cloud-final-mile-browser.cjs
index a8ae046..57cbfd2 100644
--- a/tests/cloud-final-mile-browser.cjs
+++ b/tests/cloud-final-mile-browser.cjs
@@ -57,13 +57,21 @@ async function run(){
await click(phone,"take");assert.deepEqual(await snapshot(phone),initial);
// Actual page close/reopen discards memory/keys and retains browser Storage.
const phoneContext=phone.context();await phone.close();const reopened=await page(phoneContext),beforeOTP=methods.filter(x=>x.includes("/verify")).length;
- assert(await reopened.locator("#rc-sync-unlock").isVisible());await unlock(reopened);assert.equal(methods.filter(x=>x.includes("/verify")).length,beforeOTP);
+ assert(await reopened.locator("#rc-sync-unlock").isVisible());
+ const noNetworkBefore=methods.length;await reopened.locator("#rc-sync-phrase").fill("wrong-fictional-passphrase");await click(reopened,"unlock");assert.match(await reopened.locator("#rc-sync-status").textContent(),/refusé/);assert.equal(methods.length,noNetworkBefore);assert.deepEqual(await snapshot(reopened),initial);
+ await unlock(reopened);assert.equal(methods.filter(x=>x.includes("/verify")).length,beforeOTP);
assert.match(await reopened.locator("#rc-sync-status").textContent(),/Restauration vérifiée après réouverture/);assert.deepEqual(await snapshot(reopened),initial);
const persistent=await reopened.evaluate(()=>Object.keys(localStorage).filter(k=>k.includes("cloud")).map(k=>localStorage.getItem(k)).join("\n"));
assert(!persistent.includes(PHRASE));assert(!persistent.includes("test-access"));assert(!persistent.includes("test-refresh"));
console.log("PASS browser cloud: second device complete restore; page close/reopen; encrypted session resumes without OTP, phrase still required");
await edit(reopened,"progress");await click(reopened,"now");await click(pc,"now");assert.deepEqual(await snapshot(pc),await snapshot(reopened));
console.log("PASS browser cloud: reverse phone-to-PC synchronization through independent profiles");
+ const offlineWrites=writes;await reopened.context().setOffline(true);await edit(reopened,"mastered");await click(reopened,"now");
+ assert.match(await reopened.locator("#rc-sync-status").textContent(),/Hors connexion/);assert.equal(writes,offlineWrites);assert.equal((await read(reopened)).main.progress.dns,"mastered");
+ await reopened.context().setOffline(false);await click(reopened,"now");
+ await reopened.waitForFunction(()=>/Modifications locales sauvegardées|À jour sur cet appareil/.test(document.querySelector("#rc-sync-status").textContent));
+ await click(pc,"now");assert.deepEqual(await snapshot(pc),await snapshot(reopened));
+ console.log("PASS browser cloud: offline edits remain local and synchronize on reconnect");
// Lost mutation response AND failed reconciliation. Outbox must survive a restart.
await clearDebounce(pc);await edit(pc,"learn");lose=true;failReads=false;
// Intercept only read-after-mutation by setting failReads when write reaches its gate.
@@ -87,11 +95,22 @@ async function run(){
assert.match(await pc.locator("#rc-sync-status").textContent(),/non confirmée|non enregistrées/);
await pc.reload();await unlock(pc);assert.equal((await read(pc)).main.progress.dns,"mastered");
console.log("PASS browser cloud: failed local write blocks transmission and preserves last confirmed progress across reload");
+ const beforeJournal=writes;await pc.evaluate(()=>{const original=Storage.prototype.setItem;Storage.prototype.setItem=function(key,v){if(key.startsWith("reconversion-control.cloud.sync.v1:"))throw new DOMException("synthetic journal quota","QuotaExceededError");return original.call(this,key,v);};});
+ await edit(pc,"progress");await click(pc,"now");assert.equal(writes,beforeJournal);assert.match(await pc.locator("#rc-sync-status").textContent(),/en attente/);
+ await pc.reload();await unlock(pc);await click(pc,"now");await pc.waitForFunction(()=>/sauvegardées|À jour sur cet appareil/.test(document.querySelector("#rc-sync-status").textContent));
+ assert.equal((await read(pc)).main.progress.dns,"progress");
+ console.log("PASS browser cloud: durable journal failure blocks upload; saved local edit resumes after reload");
+ // Valid but empty higher revision still requires an explicit source decision.
+ const beforeEmpty=await snapshot(pc);
+ row={revision:row.revision+1,payload:await pc.evaluate(async phrase=>RC_CLOUD_TRANSPORT.seal({version:2,progress:{},review:{},labs:{},proofs:{},labChecks:{},dailyFive:{days:{}},career:RC_PROJECT_PLAN.empty()},phrase),PHRASE)};
+ await click(pc,"now");assert.deepEqual(await snapshot(pc),beforeEmpty);assert.match(await pc.locator("#rc-sync-status").textContent(),/vide nécessite une sélection explicite/);
+ await click(pc,"push");
+ console.log("PASS browser cloud: an empty higher revision cannot silently replace a complete local snapshot");
// Future remote format is rejected without writes, including a new client.
const stable=await snapshot(pc);row={revision:row.revision+1,payload:await pc.evaluate(async phrase=>RC_CLOUD_TRANSPORT.seal({version:99,progress:{}},phrase),PHRASE)};
await click(pc,"now");assert.deepEqual(await snapshot(pc),stable);assert.match(await pc.locator("#rc-sync-status").textContent(),/incompatible/);
// Recover an older valid cloud version through the existing transport, explicitly.
- await click(pc,"history");await pc.evaluate(()=>document.querySelector("[data-sync-history]").onclick());assert.equal((await read(pc)).main.progress.dns,"mastered");assert.match(await pc.locator("#rc-sync-status").textContent(),/Ancienne révision/);
+ await click(pc,"history");await pc.evaluate(()=>document.querySelector("[data-sync-history]").onclick());assert.equal((await read(pc)).main.progress.dns,stable.progress.dns);assert.match(await pc.locator("#rc-sync-status").textContent(),/Ancienne révision/);
console.log("PASS browser cloud: future/corrupt head cannot erase local progress; bounded previous cloud version is explicitly recoverable");
await click(pc,"disconnect");assert.equal(await pc.evaluate(()=>localStorage.getItem("reconversion-control.cloud.session.v1")),null);assert.deepEqual(await snapshot(pc),stable);
assert.deepEqual(errors,[]);assert.deepEqual(unexpected,[]);
From fcafff0454f8cf978e6520e8f7c056e1890120ca Mon Sep 17 00:00:00 2001
From: iPoower <101516206+iPoower@users.noreply.github.com>
Date: Sat, 24 Oct 2026 13:38:56 +0200
Subject: [PATCH 4/7] fix(auth): keep malformed provider response bodies out of
user-facing errors
---
cloud-transport.js | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/cloud-transport.js b/cloud-transport.js
index ab99ca4..b223545 100644
--- a/cloud-transport.js
+++ b/cloud-transport.js
@@ -33,7 +33,7 @@ const RC_CLOUD_TRANSPORT = (() => {
let raw="";
if(res.body&&res.body.getReader){const reader=res.body.getReader();let size=0;const chunks=[];while(true){const chunk=await reader.read();if(chunk.done)break;size+=chunk.value.length;if(size>4200000){await reader.cancel();throw Error("Réponse cloud trop volumineuse");}chunks.push(chunk.value);}const bytes=new Uint8Array(size);let offset=0;chunks.forEach(c=>{bytes.set(c,offset);offset+=c.length;});raw=td.decode(bytes);}
else{raw=await res.text();if(raw.length>4200000)throw Error("Réponse cloud trop volumineuse");}
- return raw?JSON.parse(raw):null;
+ try{return raw?JSON.parse(raw):null;}catch(e){throw Error("Réponse cloud JSON invalide");}
}finally{if(timeout)clearTimeout(timeout);}
}
function validSession(s){return s&&s.project===base.url&&typeof s.access_token==="string"&&s.access_token.length>0&&s.access_token.length<16384&&typeof s.refresh_token==="string"&&s.refresh_token.length>0&&s.refresh_token.length<16384&&Number.isFinite(s.expires_at)&&typeof s.user==="string"&&/^[0-9a-f-]{36}$/i.test(s.user);}
From b58ffe4d510c2a1cb85a2b28ec11e85e5156335d Mon Sep 17 00:00:00 2001
From: iPoower <101516206+iPoower@users.noreply.github.com>
Date: Sat, 24 Oct 2026 13:42:43 +0200
Subject: [PATCH 5/7] test(storage): await native Web Lock initialization and
verify recovery export
---
app.js | 2 +-
tests/cloud-final-mile-browser.cjs | 5 ++++-
tests/mobile.spec.cjs | 2 ++
3 files changed, 7 insertions(+), 2 deletions(-)
diff --git a/app.js b/app.js
index 81286fc..979560c 100644
--- a/app.js
+++ b/app.js
@@ -707,7 +707,7 @@ function exportRecovery(){
var ring=JSON.parse(localStorage.getItem("reconversion-control.cloud.recovery.v1")||"null");
if(!ring||ring.v!==1||!ring.copies.length)throw Error("Aucune copie de récupération disponible");
var data=cloudValidate(ring.copies[0].data),blob=new Blob([JSON.stringify(data,null,2)],{type:"application/json"}),url=URL.createObjectURL(blob),a=document.createElement("a");
- a.href=url;a.download="reconversion-control-recuperation.json";a.click();setTimeout(function(){URL.revokeObjectURL(url);},60000);
+ a.href=url;a.download="reconversion-control-recuperation.json";a.hidden=true;document.body.appendChild(a);a.click();a.remove();setTimeout(function(){URL.revokeObjectURL(url);},60000);
}catch(e){alert("Export de récupération refusé : "+e.message);}
}
diff --git a/tests/cloud-final-mile-browser.cjs b/tests/cloud-final-mile-browser.cjs
index 57cbfd2..da46de1 100644
--- a/tests/cloud-final-mile-browser.cjs
+++ b/tests/cloud-final-mile-browser.cjs
@@ -1,6 +1,6 @@
"use strict";
// Real application/crypto/Storage in TWO isolated profiles; all provider HTTP intercepted.
-const assert=require("node:assert/strict"),{chromium,webkit,devices}=require("playwright"),{startServer}=require("./server.cjs");
+const fs=require("node:fs/promises"),assert=require("node:assert/strict"),{chromium,webkit,devices}=require("playwright"),{startServer}=require("./server.cjs");
const B={chromium,webkit}[process.env.BROWSER||"chromium"],CANON="reconversion-control.snapshot.v3",PHRASE="fictional-end-to-end-phrase-2026",USER="11111111-1111-4111-8111-111111111111";
const pause=()=>{let resolve;const promise=new Promise(r=>{resolve=r;});return{promise,resolve};};
async function run(){
@@ -112,6 +112,9 @@ async function run(){
// Recover an older valid cloud version through the existing transport, explicitly.
await click(pc,"history");await pc.evaluate(()=>document.querySelector("[data-sync-history]").onclick());assert.equal((await read(pc)).main.progress.dns,stable.progress.dns);assert.match(await pc.locator("#rc-sync-status").textContent(),/Ancienne révision/);
console.log("PASS browser cloud: future/corrupt head cannot erase local progress; bounded previous cloud version is explicitly recoverable");
+ const expectedRecovery=await pc.evaluate(()=>JSON.parse(localStorage.getItem("reconversion-control.cloud.recovery.v1")).copies[0].data);
+ const downloading=pc.waitForEvent("download");await pc.locator("#export-recovery").click();const download=await downloading;assert.deepEqual(JSON.parse(await fs.readFile(await download.path(),"utf8")),expectedRecovery);
+ console.log("PASS browser cloud: independent recovery export downloads every preserved business field");
await click(pc,"disconnect");assert.equal(await pc.evaluate(()=>localStorage.getItem("reconversion-control.cloud.session.v1")),null);assert.deepEqual(await snapshot(pc),stable);
assert.deepEqual(errors,[]);assert.deepEqual(unexpected,[]);
console.log("PASS browser cloud: explicit logout deletes remembered secrets and preserves local progress; no unmocked provider requests");
diff --git a/tests/mobile.spec.cjs b/tests/mobile.spec.cjs
index 928a26e..e702d31 100644
--- a/tests/mobile.spec.cjs
+++ b/tests/mobile.spec.cjs
@@ -121,6 +121,7 @@ async function main() {
passed("five-minute daily quest completes three playful rounds, persists XP and updates the cockpit");
const tamperedDaily = await open();
+ await tamperedDaily.page.waitForFunction(key=>localStorage.getItem(key)!==null,ATOMIC_KEY);
await tamperedDaily.page.evaluate(({key,value})=>{const snapshot=JSON.parse(localStorage.getItem(key));snapshot.daily=value;localStorage.setItem(key,JSON.stringify(snapshot));},{key:ATOMIC_KEY,value:{days:{"2026-10-04":{completed:true,xp:200}}}});
await tamperedDaily.page.reload();
await nav(tamperedDaily.page,"daily");
@@ -137,6 +138,7 @@ async function main() {
assert.equal(dueRecord.sessionId,"d01");assert.equal(dueRecord.reason,"Révision due");
const adaptiveError = await open({view:"daily",section:"all",search:"",status:"all",progress:{},review:{}});
+ await adaptiveError.page.waitForFunction(key=>localStorage.getItem(key)!==null,ATOMIC_KEY);
await adaptiveError.page.evaluate(key=>{const d=new Date();d.setDate(d.getDate()-1);const date=d.getFullYear()+"-"+String(d.getMonth()+1).padStart(2,"0")+"-"+String(d.getDate()).padStart(2,"0");const snapshot=JSON.parse(localStorage.getItem(key));snapshot.daily={days:{[date]:{sessionId:"d03",reason:"Rotation",quizChoice:0,quizCorrect:false}}};localStorage.setItem(key,JSON.stringify(snapshot));},ATOMIC_KEY);
await adaptiveError.page.reload();
assert.equal(await adaptiveError.page.locator(".daily-title-card h2").textContent(),"AuthN ou AuthZ ?");
From ca77c2d0c3643418ca46ddd6b8db28ac11768d84 Mon Sep 17 00:00:00 2001
From: iPoower <101516206+iPoower@users.noreply.github.com>
Date: Sat, 24 Oct 2026 13:45:35 +0200
Subject: [PATCH 6/7] fix(sync): fail closed on future journals and block auth
from stale tabs
---
cloud-ui.js | 7 ++++++-
tests/cloud-ui-races.cjs | 7 ++++++-
2 files changed, 12 insertions(+), 2 deletions(-)
diff --git a/cloud-ui.js b/cloud-ui.js
index 013615d..2e80d6d 100644
--- a/cloud-ui.js
+++ b/cloud-ui.js
@@ -13,7 +13,7 @@ const RC_CLOUD_UI = (() => {
function local(){return T().serialize(env.snapshot());}
function validate(data){return env.validate?env.validate(data):data;}
function guard(ticket){if(ticket!==epoch)throw Error("Opération annulée");}
- function persist(next){save(scope,next);meta=next;}
+ function persist(next){if(next.v!==1)throw Error("Journal cloud incompatible : écriture refusée");save(scope,next);meta=next;}
function dirty(){return local()!==baseline;}
function delayed(){if(dirty())schedule();}
function uploadStatus(message){return message+(dirty()?" De nouvelles modifications locales restent à synchroniser.":"");}
@@ -46,6 +46,7 @@ const RC_CLOUD_UI = (() => {
async function start(){
if(busy)return;busy=true;const ticket=++epoch;
try{
+ local(); // Read-only/stale tabs must not rotate a shared authentication session.
const mail=document.querySelector("#rc-sync-email").value.trim();
cfg=T().config(document.querySelector("#rc-sync-url").value,document.querySelector("#rc-sync-key").value);
remember=!!document.querySelector("#rc-sync-remember").checked;save(CONFIG,cfg);
@@ -76,6 +77,7 @@ const RC_CLOUD_UI = (() => {
await flush(ticket);delayed();
}
async function inspect(ticket){
+ loadMeta();
let latest=await client.read();guard(ticket);
if(meta.pending){
const next=meta.pending.expected===null?1:meta.pending.expected+1;
@@ -104,6 +106,7 @@ const RC_CLOUD_UI = (() => {
async function confirmOtp(){
if(busy)return;busy=true;const ticket=epoch;
try{
+ local();
phrase=document.querySelector("#rc-sync-phrase").value;
if(phrase.length<12||phrase.length>1024)throw Error("Phrase secrète : 12 à 1024 caractères.");
user=await client.verifyCode(email,document.querySelector("#rc-sync-otp").value.trim());guard(ticket);loadMeta();await inspect(ticket);
@@ -113,6 +116,7 @@ const RC_CLOUD_UI = (() => {
async function unlock(){
if(busy)return;busy=true;const ticket=++epoch;
try{
+ local();
phrase=document.querySelector("#rc-sync-phrase").value;remember=true;
const saved=load(SESSION);if(!saved||saved.project!==cfg.url)throw Error("Session mémorisée absente ou incompatible");
const session=await T().openSession(saved.payload,phrase);guard(ticket);client=makeClient(ticket);
@@ -142,6 +146,7 @@ const RC_CLOUD_UI = (() => {
if(!navigator.onLine){warn("Hors connexion · progression locale conservée, reprise à la reconnexion.");return;}
busy=true;const ticket=epoch;
try{
+ loadMeta();
if(mode!=="active"){await inspect(ticket);return;}
if(meta.pending)await flush(ticket);
const latest=await client.read();guard(ticket);
diff --git a/tests/cloud-ui-races.cjs b/tests/cloud-ui-races.cjs
index 5438f53..9e1706a 100644
--- a/tests/cloud-ui-races.cjs
+++ b/tests/cloud-ui-races.cjs
@@ -71,7 +71,8 @@ function fixture() {
async pending() { assert(timeout, "concurrent edits must schedule another attempt, including first backup"); const run = timeout; timeout = null; await run(); },
failWrite: () => { failWrite = true; }, failRestore: () => { failRestore = true; },
get local() { return state.progress.dns; }, get remoteValue() { return row && row.payload.snapshot.progress.dns; },
- get html() { return node("#rc-sync").innerHTML; }, get writes() { return writes; }
+ corruptJournal() { for(const key of store.keys())if(key.startsWith("reconversion-control.cloud.sync.v1:"))store.set(key,JSON.stringify({v:99,retained:"future-journal"})); },
+ get html() { return node("#rc-sync").innerHTML; }, get writes() { return writes; }, get journal() { return Array.from(store.values()).join("\n"); }
};
}
@@ -121,6 +122,10 @@ async function run() {
f.edit("local-after-failed-restore"); await f.tick();
assert.equal(f.local, "local-after-failed-restore"); assert.match(f.html, /Conflit détecté/);
});
+ await check("future durable journal is never overwritten or treated as synchronized", async () => {
+ const f=fixture();await f.prepare("automatic");f.corruptJournal();const before=f.journal,writes=f.writes.length;f.edit("pending-after-future-journal");await f.tick();
+ assert.equal(f.journal,before);assert.equal(f.writes.length,writes);assert.match(f.html,/incompatible/);assert.doesNotMatch(f.html,/À jour sur cet appareil et dans le cloud/);
+ });
console.log("N03 offline regression cases: " + count + "/" + (count + failures.length) + " passed; no network or real data.");
if (failures.length) throw new AggregateError(failures, failures.length + " N03 regression cases failed");
}
From dd7a090b2c6e1e6df5683aa4060ff039b0097c1a Mon Sep 17 00:00:00 2001
From: iPoower <101516206+iPoower@users.noreply.github.com>
Date: Sat, 24 Oct 2026 13:46:46 +0200
Subject: [PATCH 7/7] fix(restore): distinguish same-page reauthentication from
reopening verification
---
cloud-ui.js | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/cloud-ui.js b/cloud-ui.js
index 2e80d6d..b8e5df5 100644
--- a/cloud-ui.js
+++ b/cloud-ui.js
@@ -4,7 +4,7 @@
const RC_CLOUD_UI = (() => {
const CONFIG="reconversion-control.cloud.config.v1",SESSION="reconversion-control.cloud.session.v1";
let env,client,cfg,email="",phrase="",remember=false,user="",scope="",meta={v:1},remote=null,remoteData=null;
- let revision=null,baseline="",busy=false,applying=false,mode="setup",status="Local uniquement · cloud non configuré.",timer=null,pending=null,epoch=0,retries=0,historyRows=[];
+ let revision=null,baseline="",busy=false,applying=false,mode="setup",status="Local uniquement · cloud non configuré.",timer=null,pending=null,epoch=0,retries=0,historyRows=[],restoredHere=false;
const T=()=>RC_CLOUD_TRANSPORT,now=()=>new Date().toISOString();
function safe(s){return env.escape(String(s||""));}
function load(key){const raw=localStorage.getItem(key);if(raw===null)return null;if(raw.length>4200000)throw Error("Métadonnées trop volumineuses");return JSON.parse(raw);}
@@ -96,7 +96,7 @@ const RC_CLOUD_UI = (() => {
if(latest.revision===meta.ack.revision){
if(await T().fingerprint(there)!==meta.ack.hash)throw Error("Même révision avec contenu différent : restauration refusée");guard(ticket);
baseline=there;revision=latest.revision;mode="active";
- status=uploadStatus(meta.restore&&hash===meta.restore.hash?"Restauration vérifiée après réouverture · connecté et déverrouillé.":"Connecté et déverrouillé. Reprise de la synchronisation.");activate();delayed();return;
+ status=uploadStatus(meta.restore&&hash===meta.restore.hash&&!restoredHere?"Restauration vérifiée après réouverture · connecté et déverrouillé.":"Connecté et déverrouillé. Reprise de la synchronisation.");activate();delayed();return;
}
// Read/decrypt/hash may yield; take another snapshot before deciding to replace.
if(hash!==meta.ack.hash||local()!==here){mode="conflict";status="Conflit détecté : modifications sur deux appareils.";return;}
@@ -136,7 +136,7 @@ const RC_CLOUD_UI = (() => {
try{
if(env.preserve)env.preserve(JSON.parse(expected));
guard(ticket);if(local()!==expected)throw Error("Progression locale modifiée pendant la réception");
- env.restore(valid);
+ restoredHere=true;env.restore(valid);
const restored=local();if(restored!==T().serialize(valid))throw Error("Relecture restaurée différente");
await acknowledge(restored,next,ticket,remote&&remote.payload);guard(ticket);persist({...meta,restore:{hash:meta.ack.hash,revision:next}});
}finally{applying=false;}
@@ -212,7 +212,7 @@ const RC_CLOUD_UI = (() => {
busy=true;const ticket=epoch;
try{
const before=local(),saved=await client.historyVersion(r.revision);guard(ticket);const data=validate(await T().open(saved.payload,phrase));guard(ticket);if(local()!==before)throw Error("Progression locale modifiée");
- if(env.preserve)env.preserve(JSON.parse(before));env.restore(data);
+ if(env.preserve)env.preserve(JSON.parse(before));restoredHere=true;env.restore(data);
if(local()!==T().serialize(data))throw Error("Relecture restaurée différente");
mode="conflict";baseline="";status="Ancienne révision relue localement. Exporter puis revérifier les sources avant toute synchronisation.";
}catch(e){if(ticket===epoch)status="Récupération refusée : "+e.message;}