From f512d7075d7d58f33958ec28d4284c8a5b1e3cf9 Mon Sep 17 00:00:00 2001 From: iPoower <101516206+iPoower@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:10:21 +0200 Subject: [PATCH 01/13] fix(storage): commit complete restored state atomically with readback --- app.js | 57 ++++++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 46 insertions(+), 11 deletions(-) diff --git a/app.js b/app.js index 5a7810e..6f47840 100644 --- a/app.js +++ b/app.js @@ -1,6 +1,6 @@ (function(){ "use strict"; -var KEY="reconversion-control.state.v1", THEME="reconversion-control.theme", DAILY_KEY="reconversion-control.daily-five.v1"; +var KEY="reconversion-control.state.v1", THEME="reconversion-control.theme", DAILY_KEY="reconversion-control.daily-five.v1", ATOMIC_KEY="reconversion-control.snapshot.v3"; var $=function(s){return document.querySelector(s);}; var $$=function(s){return Array.from(document.querySelectorAll(s));}; var esc=function(v){return String(v==null?"":v).replace(/[&<>"']/g,function(c){return{"&":"&","<":"<",">":">",'"':""","'":"'"}[c];});}; @@ -157,14 +157,48 @@ var RC_CAREER_STAGES=[ var defaults={view:"cockpit",section:"all",search:"",status:"all",progress:{},review:{},labs:{},proofs:{},labChecks:{},career:RC_PROJECT_PLAN.empty()}; var labCheckMessage=Object.create(null); var storageUnavailable=false, storageFailures=Object.create(null), progressUnread=false, dailyUnread=false, detailOpener=null, detailId=null, libraryTabsScroll=0, detailRoutePushed=false, pendingRouteFocus=null; +var atomicUnread=false, atomicSnapshot=atomicRead(); var state=load(); var dailyState=dailyLoad(); var syncBridge=null; function storageStatus(key,failed){if(failed)storageFailures[key]=true;else delete storageFailures[key];storageUnavailable=Object.keys(storageFailures).length>0;} function storageGet(key){try{var value=localStorage.getItem(key);storageStatus(key,false);return value;}catch(e){storageStatus(key,true);if(key===KEY)progressUnread=true;if(key===DAILY_KEY)dailyUnread=true;return null;}} +/* NOESIS_ATOMIC_BEGIN: complete authoritative state under a single localStorage key. */ +function atomicRead(){ + try{ + var raw=localStorage.getItem(ATOMIC_KEY); + if(raw===null){storageStatus(ATOMIC_KEY,false);return null;} + var parsed=JSON.parse(raw); + if(!plainObject(parsed)||parsed.version!==3||!plainObject(parsed.main)||!plainObject(parsed.daily)||!plainObject(parsed.daily.days))throw Error("Invalid atomic snapshot"); + storageStatus(ATOMIC_KEY,false);return parsed; + }catch(e){atomicUnread=true;storageStatus(ATOMIC_KEY,true);return null;} +} +function atomicCommit(nextMain,nextDaily){ + if(atomicUnread)throw Error("Instantané existant inaccessible : écriture refusée"); + if(!plainObject(nextMain)||!plainObject(nextDaily)||!plainObject(nextDaily.days))throw Error("Instantané incomplet"); + var raw=JSON.stringify({version:3,main:nextMain,daily:nextDaily}); + try{ + localStorage.setItem(ATOMIC_KEY,raw); + if(localStorage.getItem(ATOMIC_KEY)!==raw)throw Error("Relecture différente"); + atomicSnapshot=JSON.parse(raw); + storageStatus(ATOMIC_KEY,false);return true; + }catch(e){storageStatus(ATOMIC_KEY,true);storageNotice();throw Error("Instantané non confirmé : "+String(e&&e.message||"stockage"));} +} +function mirrorLegacy(nextMain,nextDaily){ + // Backwards-compatibility only; never authoritative if the atomic key exists. + try{localStorage.setItem(KEY,JSON.stringify(nextMain));storageStatus(KEY,false);}catch(e){storageStatus(KEY,true);} + try{localStorage.setItem(DAILY_KEY,JSON.stringify(nextDaily));storageStatus(DAILY_KEY,false);}catch(e){storageStatus(DAILY_KEY,true);} + storageNotice(); +} +/* NOESIS_ATOMIC_END */ function storageSet(key,value,replace){ - if(!replace&&((key===KEY&&progressUnread)||(key===DAILY_KEY&&dailyUnread))){storageNotice();return;} - try{localStorage.setItem(key,value);if(key===KEY)progressUnread=false;if(key===DAILY_KEY)dailyUnread=false;storageStatus(key,false);}catch(e){storageStatus(key,true);}storageNotice(); + if(key===KEY||key===DAILY_KEY){ + if(!replace&&((key===KEY&&progressUnread)||(key===DAILY_KEY&&dailyUnread))){storageNotice();return false;} + try{atomicCommit(state,dailyState);}catch(e){storageNotice();return false;} + try{localStorage.setItem(key,value);storageStatus(key,false);}catch(e){storageStatus(key,true);} + progressUnread=dailyUnread=false;storageNotice();return true; + } + try{localStorage.setItem(key,value);storageStatus(key,false);return true;}catch(e){storageStatus(key,true);storageNotice();return false;} } function storageRemove(key){try{localStorage.removeItem(key);if(key===KEY)progressUnread=false;if(key===DAILY_KEY)dailyUnread=false;storageStatus(key,false);}catch(e){storageStatus(key,true);}storageNotice();} function storageNotice(){ @@ -233,8 +267,8 @@ function dailySanitize(value,strict){ }); return out; } -function dailyLoad(){try{return dailySanitize(JSON.parse(storageGet(DAILY_KEY)||"null"),false);}catch(e){return{days:{}};}} -function dailySave(replace){storageSet(DAILY_KEY,JSON.stringify(dailyState),replace);if(syncBridge)syncBridge.changed();} +function dailyLoad(){try{return dailySanitize(atomicSnapshot?atomicSnapshot.daily:JSON.parse(storageGet(DAILY_KEY)||"null"),false);}catch(e){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){ if(!dailyState.days[date])dailyState.days[date]={}; @@ -284,7 +318,7 @@ async function checkLabEvidence(id,file){ function load(){ var initial=JSON.parse(JSON.stringify(defaults)); - try{var j=JSON.parse(storageGet(KEY)||"null");if(!plainObject(j))return initial; + try{var j=atomicSnapshot?atomicSnapshot.main:JSON.parse(storageGet(KEY)||"null");if(!plainObject(j))return initial; var saved=progression({progress:plainObject(j.progress)?j.progress:{},review:plainObject(j.review)?j.review:{}},false);initial.progress=saved.progress;initial.review=saved.review; if(views.indexOf(j.view)>=0)initial.view=j.view; if(j.section==="all"||section(j.section))initial.section=j.section; @@ -293,7 +327,7 @@ function load(){ initial.labs=labStateSanitize(j.labs,false);initial.proofs=proofStateSanitize(j.proofs,false);initial.labChecks=labChecksSanitize(j.labChecks,false);initial.career=RC_PROJECT_PLAN.sanitize(j.career,false); }catch(e){}return initial; } -function save(replace){storageSet(KEY,JSON.stringify(state),replace);if(syncBridge)syncBridge.changed();} +function save(replace){if(storageSet(KEY,JSON.stringify(state),replace)&&syncBridge)syncBridge.changed();} function today(){var now=new Date();return now.getFullYear()+"-"+String(now.getMonth()+1).padStart(2,"0")+"-"+String(now.getDate()).padStart(2,"0");} function addDays(d,n){var x=new Date(d+"T12:00:00Z");x.setUTCDate(x.getUTCDate()+n);return x.toISOString().slice(0,10);} function daysBetween(a,b){return Math.round((Date.parse(a+"T12:00:00Z")-Date.parse(b+"T12:00:00Z"))/86400000);} @@ -599,8 +633,9 @@ function cloudSnapshot(){ 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); - state.progress=v.progress;state.review=v.review;state.labs=labs;state.proofs=proofs;state.labChecks=checks; - state.career=career;dailyState=daily;dailySave(true);save(true);render(); + 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(); } function privacy(){ @@ -692,14 +727,14 @@ function bind(){ var st=$("#status"); if(st)st.onchange=function(){state.status=st.value;save();updateLibrary();}; if($("#export"))$("#export").onclick=exportState; if($("#import"))$("#import").onclick=function(){$("#importer").click();}; - if($("#reset"))$("#reset").onclick=function(){if(confirm("Réinitialiser toute la progression locale ?")){storageRemove(KEY);storageRemove(DAILY_KEY);state=JSON.parse(JSON.stringify(defaults));dailyState={days:{}};render();}}; + 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.");}}}; } function exportState(){ var blob=new Blob([JSON.stringify({version:2,exportedAt:new Date().toISOString(),progress:state.progress,review:state.review,labs:state.labs,proofs:state.proofs,labChecks:state.labChecks,dailyFive:dailyState,career:state.career},null,2)],{type:"application/json"}); var a=document.createElement("a"), url=URL.createObjectURL(blob);a.href=url;a.download="reconversion-control-progression.json";a.hidden=true;document.body.appendChild(a);a.click();a.remove();setTimeout(function(){URL.revokeObjectURL(url);},60000); } function readFile(file){return typeof file.text==="function"?file.text():new Promise(function(resolve,reject){var reader=new FileReader();reader.onload=function(){resolve(reader.result);};reader.onerror=function(){reject(reader.error);};reader.readAsText(file);});} -$("#importer").onchange=async function(e){var f=e.target.files&&e.target.files[0];if(!f)return;try{if(f.size>1024*1024)throw new Error("Too large");var j=JSON.parse(await readFile(f));if(!plainObject(j)||(j.version!==undefined&&j.version!==1&&j.version!==2))throw new Error("Invalid version");var imported=progression(j,true),importedLabs=labStateSanitize(j.labs,true),importedProofs=proofStateSanitize(j.proofs,true),importedChecks=labChecksSanitize(j.labChecks,true),importedDaily=j.dailyFive===undefined?null:dailySanitize(j.dailyFive,true);var importedCareer=j.career===undefined?state.career:RC_PROJECT_PLAN.sanitize(j.career,true);state.career=importedCareer;state.progress=imported.progress;state.review=imported.review;state.labs=importedLabs;state.proofs=importedProofs;state.labChecks=importedChecks;if(importedDaily){dailyState=importedDaily;dailySave(true);}save(true);render();alert(storageUnavailable?"Progression importée en mémoire. Le stockage local reste indisponible.":"Progression importée.");}catch(err){alert("Fichier invalide. La progression actuelle est conservée.");}e.target.value="";}; +$("#importer").onchange=async function(e){var f=e.target.files&&e.target.files[0];if(!f)return;try{if(f.size>1024*1024)throw new Error("Too large");var j=JSON.parse(await readFile(f));if(!plainObject(j)||(j.version!==undefined&&j.version!==1&&j.version!==2))throw new Error("Invalid version");var imported=progression(j,true),importedLabs=labStateSanitize(j.labs,true),importedProofs=proofStateSanitize(j.proofs,true),importedChecks=labChecksSanitize(j.labChecks,true),importedDaily=j.dailyFive===undefined?null:dailySanitize(j.dailyFive,true);var importedCareer=j.career===undefined?state.career:RC_PROJECT_PLAN.sanitize(j.career,true);var nextState={...state,career:importedCareer,progress:imported.progress,review:imported.review,labs:importedLabs,proofs:importedProofs,labChecks:importedChecks},nextDaily=importedDaily||dailyState;atomicCommit(nextState,nextDaily);state=nextState;dailyState=nextDaily;mirrorLegacy(state,dailyState);render();alert("Progression importée et instantané complet vérifié.");}catch(err){alert("Import refusé : fichier invalide ou stockage non confirmé. Vérifie la progression et la bannière de stockage.");}e.target.value="";}; function afterDetailClose(){document.body.classList.remove("dialog-open");$("#dlg").classList.remove("dialog-fallback");var focus=detailOpener&&detailOpener.isConnected?detailOpener:$("#course-chapters [data-open=\""+detailId+"\"]")||$("[data-open=\""+detailId+"\"]")||$("#nav [aria-current]");if(focus)focus.focus({preventScroll:true});detailOpener=null;detailRoutePushed=false;} function physicalCloseDetail(){var dlg=$("#dlg");if(typeof dlg.close==="function")dlg.close();else{dlg.removeAttribute("open");afterDetailClose();}} function closeDetail(fromRoute){ From 83960fb8990cb28bf79b2ca4ffbeb6fc26c960df Mon Sep 17 00:00:00 2001 From: iPoower <101516206+iPoower@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:10:55 +0200 Subject: [PATCH 02/13] test(storage): cover atomic restore faults, reload and legacy migration --- tests/atomic-restoration.cjs | 118 +++++++++++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 tests/atomic-restoration.cjs diff --git a/tests/atomic-restoration.cjs b/tests/atomic-restoration.cjs new file mode 100644 index 0000000..49cf7f7 --- /dev/null +++ b/tests/atomic-restoration.cjs @@ -0,0 +1,118 @@ +"use strict"; +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const vm = require("node:vm"); + +const source = fs.readFileSync(path.join(__dirname, "../app.js"), "utf8"); +const segment = source.match(/\/\* NOESIS_ATOMIC_BEGIN[\s\S]*?\/\* NOESIS_ATOMIC_END \*\//); +assert(segment, "The tests must exercise the exact atomic implementation from app.js, not a fictional restore adapter"); +const A = "reconversion-control.snapshot.v3", M = "reconversion-control.state.v1", D = "reconversion-control.daily-five.v1"; +const oldMain = {progress:{dns:"progress"},review:{},labs:{},proofs:{},labChecks:{},career:{marker:"old"}}; +const oldDaily = {days:{"2026-10-22":{completed:true}}}; +const nextMain = {...oldMain, progress:{dns:"mastered"},career:{marker:"new"}}; +const nextDaily = {days:{"2026-10-23":{completed:true}}}; +const serialize = v => JSON.stringify(v); +const document = (main,daily) => serialize({version:3,main,daily}); + +function harness(initial) { + const values = new Map(Object.entries(initial||{})); + const errors = {set:null,get:null}; + const storage = { + getItem(key) { + if(errors.get===key){errors.get=null;throw Error("synthetic get failure");} + return values.has(key)?values.get(key):null; + }, + setItem(key,value) { + if(errors.set===key){errors.set=null;throw Object.assign(Error("QuotaExceededError"),{name:"QuotaExceededError"});} + values.set(key,String(value)); + }, + removeItem(key){values.delete(key);} + }; + const faults = []; + const sandbox = {localStorage:storage,ATOMIC_KEY:A,KEY:M,DAILY_KEY:D, + atomicUnread:false,atomicSnapshot:null, + plainObject:v=>v!==null&&typeof v==="object"&&!Array.isArray(v), + storageStatus:(key,failed)=>{if(failed)faults.push(key);},storageNotice:()=>{}}; + vm.createContext(sandbox); + vm.runInContext(segment[0]+"\nglobalThis.API = {atomicRead,atomicCommit,mirrorLegacy};",sandbox); + return {values,errors,storage,sandbox,faults,...sandbox.API}; +} +let checks=0; +function test(title,fn){fn();checks++;console.log("PASS "+title);} +test("baseline: independent historical writes can create a split during quota error",()=>{ + const h=harness({[M]:serialize(oldMain),[D]:serialize(oldDaily)}); + h.storage.setItem(D,serialize(nextDaily)); + h.errors.set=M;assert.throws(()=>h.storage.setItem(M,serialize(nextMain)),/QuotaExceededError/); + assert.equal(JSON.parse(h.values.get(M)).progress.dns,"progress"); + assert.equal(JSON.parse(h.values.get(D)).days["2026-10-23"].completed,true); +}); +test("atomic commit: quota before write preserves the prior complete state",()=>{ + const h=harness({[A]:document(oldMain,oldDaily)}); + h.errors.set=A;assert.throws(()=>h.atomicCommit(nextMain,nextDaily),/Instantané non confirmé/); + const read=JSON.parse(h.values.get(A)); + assert.equal(read.main.progress.dns,"progress"); + assert.ok(read.daily.days["2026-10-22"]); +}); +test("atomic commit: a lost readback rejects the success even if bytes committed",()=>{ + const h=harness({[A]:document(oldMain,oldDaily)}); + h.errors.get=A;assert.throws(()=>h.atomicCommit(nextMain,nextDaily),/Instantané non confirmé/); + const recovered=JSON.parse(h.values.get(A)); + assert.equal(recovered.main.progress.dns,"mastered"); + assert.ok(recovered.daily.days["2026-10-23"]); +}); +test("first migration: quota preserves both original legacy keys",()=>{ + const h=harness({[M]:serialize(oldMain),[D]:serialize(oldDaily)}); + h.errors.set=A;assert.throws(()=>h.atomicCommit(nextMain,nextDaily)); + assert.equal(h.values.get(A),undefined); + assert.equal(JSON.parse(h.values.get(M)).progress.dns,"progress"); + assert.ok(JSON.parse(h.values.get(D)).days["2026-10-22"]); +}); +test("complete commit and read-after-reload yield one coherent revision",()=>{ + const h=harness({[M]:serialize(oldMain),[D]:serialize(oldDaily)}); + assert.equal(h.atomicRead(),null); + h.atomicCommit(nextMain,nextDaily); + const reboot=harness(Object.fromEntries(h.values)); + const recovered=reboot.atomicRead(); + assert.equal(recovered.version,3); + assert.equal(recovered.main.progress.dns,"mastered"); + assert.equal(recovered.main.career.marker,"new"); + assert.ok(recovered.daily.days["2026-10-23"]); +}); +test("legacy mirrors may partially fail but are never authoritative",()=>{ + const h=harness({[A]:document(oldMain,oldDaily),[M]:serialize(oldMain),[D]:serialize(oldDaily)}); + h.atomicCommit(nextMain,nextDaily); + h.errors.set=D;h.mirrorLegacy(nextMain,nextDaily); + assert.equal(JSON.parse(h.values.get(M)).progress.dns,"mastered"); + assert.ok(JSON.parse(h.values.get(D)).days["2026-10-22"]); + const reboot=harness(Object.fromEntries(h.values)); + assert.equal(reboot.atomicRead().main.progress.dns,"mastered"); + assert.ok(reboot.atomicRead().daily.days["2026-10-23"]); +}); +test("malformed authoritative snapshot blocks overwriting unknown data",()=>{ + const h=harness({[A]:'{"version":3,"main":{}}',[M]:serialize(oldMain)}); + assert.equal(h.atomicRead(),null);assert.equal(h.sandbox.atomicUnread,true); + assert.throws(()=>h.atomicCommit(nextMain,nextDaily),/inaccessible/); + assert.equal(h.values.get(A),'{"version":3,"main":{}}'); +}); +test("both primary payloads are required",()=>{ + const h=harness({}); + assert.throws(()=>h.atomicCommit(nextMain,{}),/incomplet/); + assert.equal(h.values.size,0); +}); +test("idempotent retry preserves one complete revision and no extra key",()=>{ + const h=harness({[A]:document(oldMain,oldDaily)}); + h.errors.set=A;assert.throws(()=>h.atomicCommit(nextMain,nextDaily)); + h.atomicCommit(nextMain,nextDaily);const a=h.values.get(A); + h.atomicCommit(nextMain,nextDaily); + assert.equal(h.values.get(A),a);assert.equal(h.values.size,1); +}); +test("cloud restore and manual import commit before mutating live state",()=>{ + const cloud=source.match(/function cloudRestore\(o\)\{[\s\S]*?\n\}/); + assert(cloud); + assert(cloud[0].indexOf("atomicCommit(nextState,daily)") Date: Fri, 23 Oct 2026 23:11:04 +0200 Subject: [PATCH 03/13] ci(test): require atomic restore quota and reload regression checks --- .github/workflows/tests.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 038051c..703f584 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -92,6 +92,7 @@ jobs: node tests/deep-checks.cjs node tests/project-checks.cjs node tests/cloud-sync.spec.cjs + node tests/atomic-restoration.cjs - name: Dossier reconversion, écoles, budget et compatibilité env: BROWSER: ${{ matrix.browser }} From f3f0c04a3314380930f97869c552319dcae26511 Mon Sep 17 00:00:00 2001 From: iPoower <101516206+iPoower@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:11:28 +0200 Subject: [PATCH 04/13] docs(storage): document atomic migration, limitations and safe rollback --- docs/NOESIS_RCCR_ATOMIC_RESTORE_20261023.md | 23 +++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 docs/NOESIS_RCCR_ATOMIC_RESTORE_20261023.md diff --git a/docs/NOESIS_RCCR_ATOMIC_RESTORE_20261023.md b/docs/NOESIS_RCCR_ATOMIC_RESTORE_20261023.md new file mode 100644 index 0000000..f1e7cc7 --- /dev/null +++ b/docs/NOESIS_RCCR_ATOMIC_RESTORE_20261023.md @@ -0,0 +1,23 @@ +# NOESIS-RCC-001 — Restauration atomique (proposition technique) +Date : 23 octobre 2026 · Base `f3dddaf591b515eeee103538b33f199a1db955c5` · Aucune fusion ni publication autorisée. + +## Défaillance historique +La restauration cloud et l'import écrivaient `reconversion-control.daily-five.v1` et `reconversion-control.state.v1` séparément. Les exceptions de `storageSet()` étaient masquées. Une erreur de quota lors de la seconde écriture permettait un état mêlé au rechargement, avec acquittement possible par `cloud-ui.js`. + +## Changement proposé +- Le nouvel instantané local `reconversion-control.snapshot.v3` est un objet `{version:3,main,daily}`, écrit par **un seul** `localStorage.setItem`, puis relu intégralement avant accusé. +- Toutes les sauvegardes ordinaires migrent d'abord vers cet instantané atomique. Dès son existence, `load()` et `dailyLoad()` l'utilisent tous les deux, quel que soit l'état des anciennes clés. +- L'import JSON et `cloudRestore()` valident les données puis confirment cet instantané **avant** de remplacer l'état en mémoire ; si la lecture/écriture échoue, ils refusent l'accusé de succès. +- Les anciennes clés ne sont pas purgées pendant migration/restauration. Elles restent des miroirs de compatibilité **non autoritaires** ; leur écriture peut échouer sans invalider l'instantané déjà confirmé. Une application ancienne peut lire ces miroirs sans connaître leur statut : ne pas déployer deux versions concurrentes sur la même origine. +- Si un instantané V3 existe mais n'est pas lisible ou valide, aucune écriture ne le remplace ; une bannière indique le blocage pour éviter un effacement silencieux. +- La réinitialisation volontaire en interface n'efface les clés historiques qu'**après** validation du nouvel instantané vide. + +## Recette et réserves +`node tests/atomic-restoration.cjs` (appelé depuis la CI Chromium/WebKit) : erreurs de quota, relecture perdue, migration, état historique, reprise et idempotence. Les vérifications atomiques fonctionnent avec la **fonction réelle extraite de app.js** et `localStorage` synthétique à fautes injectées ; elles ne prouvent pas le comportement d'un iPhone physique. + +Autres contrôles : exécuter `node tests/cloud-ui-races.cjs`, `node tests/cloud-sync.spec.cjs`, l'import JSON et les tests du navigateur. Les résultats CI du SHA de la PR doivent être lus avant revue. Les erreurs de stockage multi-onglets et les conflits simultanés entre onglets ne sont **pas** résolus par cette PR : localStorage ne fournit aucun CAS inter-onglets. Prévoir une phase distincte avec verrou exclusif et retour utilisateur explicite plutôt qu'un dernier écrivain silencieux. + +Une restauration physique, une migration d'origine, l'activation Supabase et une publication nécessitent toujours des autorisations distinctes. Aucun jeton, donnée personnelle ou backend réel n'est touché par cette PR. + +## Repli sûr +Ne pas supprimer la clé V3. En cas de régression, conserver **toutes** les valeurs exportées avec une copie chiffrée hors de l'appareil et préparer un lecteur/migrateur de récupération avant rollback. Un simple retour de version qui ignorerait V3 peut présenter des clés historiques plus anciennes : il **ne constitue pas** un rollback des données. From c805708e25d896ef0c3676c28776e4965b09d07e Mon Sep 17 00:00:00 2001 From: iPoower <101516206+iPoower@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:14:34 +0200 Subject: [PATCH 05/13] test(mobile): inject hostile Daily Five into authoritative V3 snapshot --- tests/mobile.spec.cjs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/mobile.spec.cjs b/tests/mobile.spec.cjs index 9032b1a..928a26e 100644 --- a/tests/mobile.spec.cjs +++ b/tests/mobile.spec.cjs @@ -5,6 +5,7 @@ const {chromium, webkit, devices} = require("playwright"); const {startServer} = require("./server.cjs"); const KEY = "reconversion-control.state.v1"; const DAILY_KEY = "reconversion-control.daily-five.v1"; +const ATOMIC_KEY = "reconversion-control.snapshot.v3"; const browserName = process.env.BROWSER || "chromium"; const browserType = {chromium, webkit}[browserName]; assert(browserType, "BROWSER must be chromium or webkit"); @@ -120,7 +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.evaluate(({key,value})=>localStorage.setItem(key,JSON.stringify(value)),{key:DAILY_KEY,value:{days:{"2026-10-04":{completed:true,xp:200}}}}); + 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"); assert.equal(await tamperedDaily.page.locator(".daily-finish.won").count(),0,"impossible local Daily Five completion must be discarded"); @@ -136,7 +137,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.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");localStorage.setItem(key,JSON.stringify({days:{[date]:{sessionId:"d03",reason:"Rotation",quizChoice:0,quizCorrect:false}}}));},DAILY_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 ?"); assert.match(await adaptiveError.page.locator(".daily-game-head .src").textContent(),/ERREUR RÉCENTE/); From d34cb473cad5b8251a7471050981e3dc8f4dc092 Mon Sep 17 00:00:00 2001 From: iPoower <101516206+iPoower@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:17:02 +0200 Subject: [PATCH 06/13] fix(storage): block migration when historical state or Daily Five is unreadable --- app.js | 1 + 1 file changed, 1 insertion(+) diff --git a/app.js b/app.js index 6f47840..eb39134 100644 --- a/app.js +++ b/app.js @@ -175,6 +175,7 @@ function atomicRead(){ } function atomicCommit(nextMain,nextDaily){ if(atomicUnread)throw Error("Instantané existant inaccessible : écriture refusée"); + if(!atomicSnapshot&&(progressUnread||dailyUnread))throw Error("Ancienne progression non lisible : migration suspendue"); if(!plainObject(nextMain)||!plainObject(nextDaily)||!plainObject(nextDaily.days))throw Error("Instantané incomplet"); var raw=JSON.stringify({version:3,main:nextMain,daily:nextDaily}); try{ From 997edac56200ab7562112521291a2b3994f07d3c Mon Sep 17 00:00:00 2001 From: iPoower <101516206+iPoower@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:17:46 +0200 Subject: [PATCH 07/13] test(browser): verify quota, restart and historical mirror with real localStorage --- tests/atomic-restoration-browser.cjs | 55 ++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 tests/atomic-restoration-browser.cjs diff --git a/tests/atomic-restoration-browser.cjs b/tests/atomic-restoration-browser.cjs new file mode 100644 index 0000000..6cc7e12 --- /dev/null +++ b/tests/atomic-restoration-browser.cjs @@ -0,0 +1,55 @@ +"use strict"; +const assert = require("node:assert/strict"); +const {chromium,webkit} = require("playwright"); +const {startServer} = require("./server.cjs"); +const B = {chromium,webkit}[process.env.BROWSER || "chromium"]; +if(!B)throw Error("Set BROWSER to chromium or webkit"); +const CANON = "reconversion-control.snapshot.v3", LEGACY = "reconversion-control.state.v1"; +const fixture = {version:2,progress:{dns:"mastered"},review:{},labs:{},proofs:{},labChecks:{},dailyFive:{days:{}}}; +const buffer = {name:"synthetic-atomic-restore.json",mimeType:"application/json",buffer:Buffer.from(JSON.stringify(fixture))}; +async function run(){ + const server=await startServer(),browser=await B.launch({headless:true}); + const page=await browser.newPage(); + const dialogs=[]; page.on("dialog",async d=>{dialogs.push(d.message());await d.accept();}); + try { + await page.goto(server.url + "/?noesis=fixture"); + await page.locator('#nav [data-view="library"]').click(); + await page.locator('[data-card="dns"] [data-prog="progress"]').click(); + await page.locator('#nav [data-view="privacy"]').click(); + const before=await page.evaluate(key=>localStorage.getItem(key),CANON); + assert(before && JSON.parse(before).main.progress.dns==="progress","seeded V3 must exist"); + await page.evaluate(key=>{ + const original=Storage.prototype.setItem; + Storage.prototype.setItem=function(k,v){if(k===key)throw new DOMException("synthetic quota","QuotaExceededError");return original.call(this,k,v);}; + window.noesisRestore=()=>{Storage.prototype.setItem=original;}; + },CANON); + await page.locator("#importer").setInputFiles(buffer); + await page.waitForFunction(()=>document.querySelector("#importer").value===""); + assert(dialogs.some(x=>/Import refusé/.test(x)),"quota must visibly refuse import"); + assert.equal(await page.evaluate(k=>localStorage.getItem(k),CANON),before,"failed commit cannot replace V3"); + await page.reload(); + assert.equal(await page.evaluate(k=>JSON.parse(localStorage.getItem(k)).main.progress.dns,CANON),"progress","old state must survive restart"); + assert.equal(await page.evaluate(k=>JSON.parse(localStorage.getItem(k)).daily.days&&typeof JSON.parse(localStorage.getItem(k)).daily.days,CANON),"object"); + console.log("PASS NOESIS-RCC-001 "+(process.env.BROWSER||"chromium")+" failed cloud-equivalent import does not acknowledge/quota erase V3, including reload"); + await page.locator('#nav [data-view="privacy"]').click(); + await page.locator("#importer").setInputFiles(buffer); + await page.waitForFunction(()=>document.querySelector("#importer").value===""); + assert(dialogs.some(x=>/instantané complet vérifié/.test(x)),"successful import must be acknowledged"); + await page.reload(); + assert.equal(await page.evaluate(k=>JSON.parse(localStorage.getItem(k)).main.progress.dns,CANON),"mastered"); + console.log("PASS NOESIS-RCC-001 "+(process.env.BROWSER||"chromium")+" successful import survives restart"); + await page.locator('#nav [data-view="privacy"]').click(); + await page.evaluate(key=>{ + const original=Storage.prototype.setItem; + Storage.prototype.setItem=function(k,v){if(k===key)throw new DOMException("synthetic legacy quota","QuotaExceededError");return original.call(this,k,v);}; + },LEGACY); + const fixture2={...fixture,progress:{dns:"learn"}}; + await page.locator("#importer").setInputFiles({...buffer,buffer:Buffer.from(JSON.stringify(fixture2))}); + await page.waitForFunction(()=>document.querySelector("#importer").value===""); + assert(dialogs.some(x=>/instantané complet vérifié/.test(x)),"legacy mirror failure must not undermine canonical write"); + await page.reload(); + assert.equal(await page.evaluate(k=>JSON.parse(localStorage.getItem(k)).main.progress.dns,CANON),"learn"); + console.log("PASS NOESIS-RCC-001 "+(process.env.BROWSER||"chromium")+" legacy mirror failure cannot split authoritative state"); + }finally{await browser.close();await server.close();} +} +run().catch(e=>{console.error(e);process.exitCode=1;}); From a8cc90678fca85a613e3dd8c9135c42b72b02c7e Mon Sep 17 00:00:00 2001 From: iPoower <101516206+iPoower@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:17:52 +0200 Subject: [PATCH 08/13] ci(test): require browser-localStorage quota restore on Chromium and WebKit --- .github/workflows/tests.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 703f584..fe65331 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -93,6 +93,7 @@ jobs: node tests/project-checks.cjs node tests/cloud-sync.spec.cjs node tests/atomic-restoration.cjs + BROWSER=${{ matrix.browser }} node tests/atomic-restoration-browser.cjs - name: Dossier reconversion, écoles, budget et compatibilité env: BROWSER: ${{ matrix.browser }} From 1da84ffcd1e6813fe8abf885ae3e3cadac9ad2ad Mon Sep 17 00:00:00 2001 From: iPoower <101516206+iPoower@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:18:45 +0200 Subject: [PATCH 09/13] test(browser): attribute each import acknowledgement to its own attempt --- tests/atomic-restoration-browser.cjs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/atomic-restoration-browser.cjs b/tests/atomic-restoration-browser.cjs index 6cc7e12..4ac55c7 100644 --- a/tests/atomic-restoration-browser.cjs +++ b/tests/atomic-restoration-browser.cjs @@ -30,7 +30,7 @@ async function run(){ await page.reload(); assert.equal(await page.evaluate(k=>JSON.parse(localStorage.getItem(k)).main.progress.dns,CANON),"progress","old state must survive restart"); assert.equal(await page.evaluate(k=>JSON.parse(localStorage.getItem(k)).daily.days&&typeof JSON.parse(localStorage.getItem(k)).daily.days,CANON),"object"); - console.log("PASS NOESIS-RCC-001 "+(process.env.BROWSER||"chromium")+" failed cloud-equivalent import does not acknowledge/quota erase V3, including reload"); + console.log("PASS NOESIS-RCC-001 "+(process.env.BROWSER||"chromium")+" failed manual import does not acknowledge/quota erase V3, including reload"); await page.locator('#nav [data-view="privacy"]').click(); await page.locator("#importer").setInputFiles(buffer); await page.waitForFunction(()=>document.querySelector("#importer").value===""); @@ -43,10 +43,11 @@ async function run(){ const original=Storage.prototype.setItem; Storage.prototype.setItem=function(k,v){if(k===key)throw new DOMException("synthetic legacy quota","QuotaExceededError");return original.call(this,k,v);}; },LEGACY); + const beforeLegacyDialogs=dialogs.length; const fixture2={...fixture,progress:{dns:"learn"}}; await page.locator("#importer").setInputFiles({...buffer,buffer:Buffer.from(JSON.stringify(fixture2))}); await page.waitForFunction(()=>document.querySelector("#importer").value===""); - assert(dialogs.some(x=>/instantané complet vérifié/.test(x)),"legacy mirror failure must not undermine canonical write"); + assert(dialogs.slice(beforeLegacyDialogs).some(x=>/instantané complet vérifié/.test(x)),"legacy mirror failure must not undermine canonical write"); await page.reload(); assert.equal(await page.evaluate(k=>JSON.parse(localStorage.getItem(k)).main.progress.dns,CANON),"learn"); console.log("PASS NOESIS-RCC-001 "+(process.env.BROWSER||"chromium")+" legacy mirror failure cannot split authoritative state"); From 300f11f86566b4bd353da0d791fbfbc932a03216 Mon Sep 17 00:00:00 2001 From: iPoower <101516206+iPoower@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:21:36 +0200 Subject: [PATCH 10/13] test(storage): preserve both unread legacy stores and model migration guards --- tests/atomic-restoration.cjs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/tests/atomic-restoration.cjs b/tests/atomic-restoration.cjs index 49cf7f7..9e927ee 100644 --- a/tests/atomic-restoration.cjs +++ b/tests/atomic-restoration.cjs @@ -31,7 +31,7 @@ function harness(initial) { }; const faults = []; const sandbox = {localStorage:storage,ATOMIC_KEY:A,KEY:M,DAILY_KEY:D, - atomicUnread:false,atomicSnapshot:null, + atomicUnread:false,atomicSnapshot:null,progressUnread:false,dailyUnread:false, plainObject:v=>v!==null&&typeof v==="object"&&!Array.isArray(v), storageStatus:(key,failed)=>{if(failed)faults.push(key);},storageNotice:()=>{}}; vm.createContext(sandbox); @@ -89,6 +89,19 @@ test("legacy mirrors may partially fail but are never authoritative",()=>{ assert.equal(reboot.atomicRead().main.progress.dns,"mastered"); assert.ok(reboot.atomicRead().daily.days["2026-10-23"]); }); +test("unread legacy Daily Five blocks first migration without erasing history",()=>{ + const h=harness({[M]:serialize(oldMain),[D]:serialize(oldDaily)}); + h.sandbox.dailyUnread=true; + assert.throws(()=>h.atomicCommit(nextMain,nextDaily),/Ancienne progression non lisible/); + assert.equal(h.values.get(A),undefined); + assert.equal(h.values.get(D),serialize(oldDaily)); +}); +test("unread legacy progression blocks first migration",()=>{ + const h=harness({[M]:serialize(oldMain),[D]:serialize(oldDaily)}); + h.sandbox.progressUnread=true; + assert.throws(()=>h.atomicCommit(nextMain,nextDaily),/Ancienne progression non lisible/); + assert.equal(h.values.get(M),serialize(oldMain)); +}); test("malformed authoritative snapshot blocks overwriting unknown data",()=>{ const h=harness({[A]:'{"version":3,"main":{}}',[M]:serialize(oldMain)}); assert.equal(h.atomicRead(),null);assert.equal(h.sandbox.atomicUnread,true); From bf8b16a05d78a95ad5a70498d1601a92edba19d9 Mon Sep 17 00:00:00 2001 From: iPoower <101516206+iPoower@users.noreply.github.com> Date: Sat, 24 Oct 2026 05:53:28 +0200 Subject: [PATCH 11/13] fix(storage): reject stale tabs and roll back failed restoration readback --- .github/workflows/tests.yml | 1 + NOESIS_FINAL_MILE_STORAGE_20261024.md | 41 +++++++++++++++++ app.js | 60 ++++++++++++++++++++----- tests/atomic-restoration-browser.cjs | 15 +++++++ tests/atomic-restoration.cjs | 33 +++++++++++--- tests/storage-multitab.cjs | 65 +++++++++++++++++++++++++++ 6 files changed, 197 insertions(+), 18 deletions(-) create mode 100644 NOESIS_FINAL_MILE_STORAGE_20261024.md create mode 100644 tests/storage-multitab.cjs diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index fe65331..c5321f9 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -94,6 +94,7 @@ jobs: node tests/cloud-sync.spec.cjs node tests/atomic-restoration.cjs BROWSER=${{ matrix.browser }} node tests/atomic-restoration-browser.cjs + BROWSER=${{ matrix.browser }} node tests/storage-multitab.cjs - name: Dossier reconversion, écoles, budget et compatibilité env: BROWSER: ${{ matrix.browser }} diff --git a/NOESIS_FINAL_MILE_STORAGE_20261024.md b/NOESIS_FINAL_MILE_STORAGE_20261024.md new file mode 100644 index 0000000..2674604 --- /dev/null +++ b/NOESIS_FINAL_MILE_STORAGE_20261024.md @@ -0,0 +1,41 @@ +# NOESIS — multi-tab writes and failed restoration verification + +This branch is stacked on #26 and does not modify its source branch. + +Confirmed before correction: two instances of the actual atomic functions loaded the +same V3 baseline; the second overwrote the first without refusal. A second red test +showed that a readback failure after writing a replacement left the replacement in +persistent storage despite reporting failure. + +Corrections are confined to existing persistence and its safety notice: +- One native exclusive Web Lock grants a writer lease for a document lifetime. Other + tabs retain memory and export capability, but cannot commit imports, resets or edits. + A failed or unsupported lock never falls back to unsafe concurrent writes. +- Before writes, compare the stored canonical bytes with the loaded/confirmed baseline. + An external change blocks further writes rather than being adopted or overwritten. +- Manual/cloud restore establishes a confirmed source first. If replacement readback + fails, restore the previous exact bytes only when the exclusive writer still sees its + own replacement. If this rollback also fails or another value appears, block writes + and preserve canonical/legacy keys for diagnosis. No key is deleted. +- Invalid canonical progression/Daily Five and unparseable historical storage cannot + be overwritten by empty defaults. Canonical business sanitizers run strictly. +- Cloud snapshots are refused for unconfirmed or secondary-tab memory. + +Regression evidence: exact production functions in the VM; native Web Locks, real +localStorage, two pages, simultaneous edits, rejected import, exported unsaved work, +complete business/Daily Five comparison after closure, corrupted canonical bytes, quota +and readback failure in both required browser jobs. No Google or Supabase connection. +The local browser installation failed; GitHub CI on the exact head is required before +claiming browser success. Independent device and production states remain unverified. + +Limits: this is a cooperative lock, not protection from an XSS/hostile same-origin script +or an old version that ignores the protocol. Close old tabs before a reviewed upgrade. +A secondary tab requires export and reload after the writer closes; it never silently +promotes stale memory. Pagehide releases the lease; BFCache returns remain read-only. +A persistent storage failure cannot promise automatic rollback; the visible blocked +state is a recovery incident, not a success. Preserve all keys and independent exports. + +Rollback: retain V3 canonical bytes, both legacy mirrors and full export before any +approved code downgrade. Do not assume old code reads V3 or that mirrors are current +when a storage warning occurred. Recover in a separate origin/profile, compare every +business field, then decide. No migration, data reset or personal activation here. diff --git a/app.js b/app.js index eb39134..e453bf2 100644 --- a/app.js +++ b/app.js @@ -157,33 +157,50 @@ var RC_CAREER_STAGES=[ var defaults={view:"cockpit",section:"all",search:"",status:"all",progress:{},review:{},labs:{},proofs:{},labChecks:{},career:RC_PROJECT_PLAN.empty()}; var labCheckMessage=Object.create(null); var storageUnavailable=false, storageFailures=Object.create(null), progressUnread=false, dailyUnread=false, detailOpener=null, detailId=null, libraryTabsScroll=0, detailRoutePushed=false, pendingRouteFocus=null; -var atomicUnread=false, atomicSnapshot=atomicRead(); +var atomicUnread=false, storageWriter=false, storageWriterPending=true, storageConflict=false, atomicSnapshot=atomicRead(); var state=load(); var dailyState=dailyLoad(); var syncBridge=null; function storageStatus(key,failed){if(failed)storageFailures[key]=true;else delete storageFailures[key];storageUnavailable=Object.keys(storageFailures).length>0;} function storageGet(key){try{var value=localStorage.getItem(key);storageStatus(key,false);return value;}catch(e){storageStatus(key,true);if(key===KEY)progressUnread=true;if(key===DAILY_KEY)dailyUnread=true;return null;}} /* NOESIS_ATOMIC_BEGIN: complete authoritative state under a single localStorage key. */ +var atomicBaseline; function atomicRead(){ try{ var raw=localStorage.getItem(ATOMIC_KEY); + atomicBaseline=raw; if(raw===null){storageStatus(ATOMIC_KEY,false);return null;} var parsed=JSON.parse(raw); - if(!plainObject(parsed)||parsed.version!==3||!plainObject(parsed.main)||!plainObject(parsed.daily)||!plainObject(parsed.daily.days))throw Error("Invalid atomic snapshot"); + if(!plainObject(parsed)||parsed.version!==3||!plainObject(parsed.main)||!plainObject(parsed.main.progress)||!plainObject(parsed.main.review)||!plainObject(parsed.daily)||!plainObject(parsed.daily.days))throw Error("Invalid atomic snapshot"); storageStatus(ATOMIC_KEY,false);return parsed; }catch(e){atomicUnread=true;storageStatus(ATOMIC_KEY,true);return null;} } function atomicCommit(nextMain,nextDaily){ + if(!storageWriter||storageConflict){storageStatus("writer",true);storageNotice();throw Error("Autre onglet ou écriture concurrente : enregistrement refusé");} if(atomicUnread)throw Error("Instantané existant inaccessible : écriture refusée"); - if(!atomicSnapshot&&(progressUnread||dailyUnread))throw Error("Ancienne progression non lisible : migration suspendue"); + if(progressUnread||dailyUnread)throw Error("Ancienne progression non lisible : migration suspendue"); if(!plainObject(nextMain)||!plainObject(nextDaily)||!plainObject(nextDaily.days))throw Error("Instantané incomplet"); - var raw=JSON.stringify({version:3,main:nextMain,daily:nextDaily}); + var raw=JSON.stringify({version:3,main:nextMain,daily:nextDaily}),previous=atomicBaseline,wrote=false; try{ + if(localStorage.getItem(ATOMIC_KEY)!==atomicBaseline){storageConflict=true;throw Error("Instantané modifié dans un autre contexte");} localStorage.setItem(ATOMIC_KEY,raw); + wrote=true; if(localStorage.getItem(ATOMIC_KEY)!==raw)throw Error("Relecture différente"); + atomicBaseline=raw; atomicSnapshot=JSON.parse(raw); storageStatus(ATOMIC_KEY,false);return true; - }catch(e){storageStatus(ATOMIC_KEY,true);storageNotice();throw Error("Instantané non confirmé : "+String(e&&e.message||"stockage"));} + }catch(e){ + if(wrote){ + try{ + var current=localStorage.getItem(ATOMIC_KEY); + if(current===raw&&previous!==null){ + localStorage.setItem(ATOMIC_KEY,previous); + if(localStorage.getItem(ATOMIC_KEY)!==previous)throw Error("Retour non confirmé"); + }else if(current!==previous)storageConflict=true; + }catch(rollbackError){storageConflict=true;} + } + storageStatus(ATOMIC_KEY,true);storageNotice();throw Error("Instantané non confirmé : "+String(e&&e.message||"stockage")); + } } function mirrorLegacy(nextMain,nextDaily){ // Backwards-compatibility only; never authoritative if the atomic key exists. @@ -192,6 +209,22 @@ function mirrorLegacy(nextMain,nextDaily){ storageNotice(); } /* NOESIS_ATOMIC_END */ +// One writer for this document lifetime; stale tabs never acquire a write lease silently. +function acquireStorageWriter(){ + if(!navigator.locks||typeof navigator.locks.request!=="function"){ + storageWriterPending=false;storageStatus("writer",true);storageNotice();return; + } + navigator.locks.request("reconversion-control-writer-v3",{ifAvailable:true},function(lock){ + storageWriterPending=false;storageWriter=!!lock;storageStatus("writer",!lock);storageNotice(); + if(lock)return new Promise(function(release){ + try{atomicCommit(state,dailyState);mirrorLegacy(state,dailyState);}catch(e){storageNotice();} + window.addEventListener("pagehide",function(){storageWriter=false;storageStatus("writer",true);release();},{once:true}); + }); + }).catch(function(){storageWriterPending=false;storageWriter=false;storageStatus("writer",true);storageNotice();}); +} +window.addEventListener("storage",function(e){ + if(e.key===ATOMIC_KEY&&e.newValue!==atomicBaseline){storageConflict=true;storageStatus("writer",true);storageNotice();} +}); function storageSet(key,value,replace){ if(key===KEY||key===DAILY_KEY){ if(!replace&&((key===KEY&&progressUnread)||(key===DAILY_KEY&&dailyUnread))){storageNotice();return false;} @@ -206,7 +239,7 @@ function storageNotice(){ var app=$("#app"), notice=$("#storage-notice");if(!app)return; if(!notice){notice=document.createElement("p");notice.id="storage-notice";notice.className="privacy";notice.setAttribute("role","status");app.parentNode.insertBefore(notice,app);} notice.hidden=!storageUnavailable; - notice.textContent=storageUnavailable?"Le stockage local est indisponible. Tes changements restent en mémoire jusqu’à la fermeture de cette page. Exporte ta progression dans Sources & privacy pour la conserver.":""; + notice.textContent=storageUnavailable?(storageConflict||(!storageWriter&&!storageWriterPending)?"Enregistrement suspendu : un autre onglet utilise la progression, le stockage a changé ou le verrou navigateur est indisponible. Tes changements restent en mémoire. Exporte-les avant fermeture, puis ferme les autres onglets et recharge.":"Le stockage local est indisponible. Tes changements restent en mémoire jusqu’à la fermeture de cette page. Exporte ta progression dans Sources & privacy pour la conserver."):""; } function plainObject(value){return !!value&&Object.prototype.toString.call(value)==="[object Object]";} function validDate(value){return typeof value==="string"&&/^\d{4}-\d{2}-\d{2}$/.test(value)&&!isNaN(Date.parse(value+"T12:00:00Z"))&&new Date(value+"T12:00:00Z").toISOString().slice(0,10)===value;} @@ -268,7 +301,7 @@ function dailySanitize(value,strict){ }); return out; } -function dailyLoad(){try{return dailySanitize(atomicSnapshot?atomicSnapshot.daily:JSON.parse(storageGet(DAILY_KEY)||"null"),false);}catch(e){return{days:{}};}} +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 dailySave(replace){if(storageSet(DAILY_KEY,JSON.stringify(dailyState),replace)&&syncBridge)syncBridge.changed();} function dailyRecord(date){return dailyState.days[date]||{};} function ensureDailyRecord(date,selection){ @@ -319,14 +352,14 @@ async function checkLabEvidence(id,file){ function load(){ var initial=JSON.parse(JSON.stringify(defaults)); - try{var j=atomicSnapshot?atomicSnapshot.main:JSON.parse(storageGet(KEY)||"null");if(!plainObject(j))return initial; - var saved=progression({progress:plainObject(j.progress)?j.progress:{},review:plainObject(j.review)?j.review:{}},false);initial.progress=saved.progress;initial.review=saved.review; + try{var j=atomicSnapshot?atomicSnapshot.main:JSON.parse(storageGet(KEY)||"null");if(j===null)return initial;if(!plainObject(j)||(j.progress!==undefined&&!plainObject(j.progress))||(j.review!==undefined&&!plainObject(j.review)))throw Error("Invalid stored progression"); + var saved=progression({progress:plainObject(j.progress)?j.progress:{},review:plainObject(j.review)?j.review:{}},!!atomicSnapshot);initial.progress=saved.progress;initial.review=saved.review; if(views.indexOf(j.view)>=0)initial.view=j.view; if(j.section==="all"||section(j.section))initial.section=j.section; if(typeof j.search==="string")initial.search=j.search; if(j.status==="all"||statuses.indexOf(j.status)>=0)initial.status=j.status; - initial.labs=labStateSanitize(j.labs,false);initial.proofs=proofStateSanitize(j.proofs,false);initial.labChecks=labChecksSanitize(j.labChecks,false);initial.career=RC_PROJECT_PLAN.sanitize(j.career,false); - }catch(e){}return initial; + initial.labs=labStateSanitize(j.labs,!!atomicSnapshot);initial.proofs=proofStateSanitize(j.proofs,!!atomicSnapshot);initial.labChecks=labChecksSanitize(j.labChecks,!!atomicSnapshot);initial.career=RC_PROJECT_PLAN.sanitize(j.career,!!atomicSnapshot); + }catch(e){progressUnread=true;storageStatus(KEY,true);}return initial; } function save(replace){if(storageSet(KEY,JSON.stringify(state),replace)&&syncBridge)syncBridge.changed();} function today(){var now=new Date();return now.getFullYear()+"-"+String(now.getMonth()+1).padStart(2,"0")+"-"+String(now.getDate()).padStart(2,"0");} @@ -629,11 +662,13 @@ 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()}; } 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(); @@ -735,7 +770,7 @@ function exportState(){ var a=document.createElement("a"), url=URL.createObjectURL(blob);a.href=url;a.download="reconversion-control-progression.json";a.hidden=true;document.body.appendChild(a);a.click();a.remove();setTimeout(function(){URL.revokeObjectURL(url);},60000); } function readFile(file){return typeof file.text==="function"?file.text():new Promise(function(resolve,reject){var reader=new FileReader();reader.onload=function(){resolve(reader.result);};reader.onerror=function(){reject(reader.error);};reader.readAsText(file);});} -$("#importer").onchange=async function(e){var f=e.target.files&&e.target.files[0];if(!f)return;try{if(f.size>1024*1024)throw new Error("Too large");var j=JSON.parse(await readFile(f));if(!plainObject(j)||(j.version!==undefined&&j.version!==1&&j.version!==2))throw new Error("Invalid version");var imported=progression(j,true),importedLabs=labStateSanitize(j.labs,true),importedProofs=proofStateSanitize(j.proofs,true),importedChecks=labChecksSanitize(j.labChecks,true),importedDaily=j.dailyFive===undefined?null:dailySanitize(j.dailyFive,true);var importedCareer=j.career===undefined?state.career:RC_PROJECT_PLAN.sanitize(j.career,true);var nextState={...state,career:importedCareer,progress:imported.progress,review:imported.review,labs:importedLabs,proofs:importedProofs,labChecks:importedChecks},nextDaily=importedDaily||dailyState;atomicCommit(nextState,nextDaily);state=nextState;dailyState=nextDaily;mirrorLegacy(state,dailyState);render();alert("Progression importée et instantané complet vérifié.");}catch(err){alert("Import refusé : fichier invalide ou stockage non confirmé. Vérifie la progression et la bannière de stockage.");}e.target.value="";}; +$("#importer").onchange=async function(e){var f=e.target.files&&e.target.files[0];if(!f)return;try{if(f.size>1024*1024)throw new Error("Too large");var j=JSON.parse(await readFile(f));if(!plainObject(j)||(j.version!==undefined&&j.version!==1&&j.version!==2))throw new Error("Invalid version");var imported=progression(j,true),importedLabs=labStateSanitize(j.labs,true),importedProofs=proofStateSanitize(j.proofs,true),importedChecks=labChecksSanitize(j.labChecks,true),importedDaily=j.dailyFive===undefined?null:dailySanitize(j.dailyFive,true);var importedCareer=j.career===undefined?state.career:RC_PROJECT_PLAN.sanitize(j.career,true);atomicCommit(state,dailyState);var nextState={...state,career:importedCareer,progress:imported.progress,review:imported.review,labs:importedLabs,proofs:importedProofs,labChecks:importedChecks},nextDaily=importedDaily||dailyState;atomicCommit(nextState,nextDaily);state=nextState;dailyState=nextDaily;mirrorLegacy(state,dailyState);render();alert("Progression importée et instantané complet vérifié.");}catch(err){alert("Import refusé : fichier invalide ou stockage non confirmé. Vérifie la progression et la bannière de stockage.");}e.target.value="";}; function afterDetailClose(){document.body.classList.remove("dialog-open");$("#dlg").classList.remove("dialog-fallback");var focus=detailOpener&&detailOpener.isConnected?detailOpener:$("#course-chapters [data-open=\""+detailId+"\"]")||$("[data-open=\""+detailId+"\"]")||$("#nav [aria-current]");if(focus)focus.focus({preventScroll:true});detailOpener=null;detailRoutePushed=false;} function physicalCloseDetail(){var dlg=$("#dlg");if(typeof dlg.close==="function")dlg.close();else{dlg.removeAttribute("open");afterDetailClose();}} function closeDetail(fromRoute){ @@ -759,6 +794,7 @@ if("serviceWorker" in navigator){ navigator.serviceWorker.addEventListener("controllerchange",function(){if(hadController)showUpdateNotice();hadController=true;}); 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}); applyRoute(); })(); diff --git a/tests/atomic-restoration-browser.cjs b/tests/atomic-restoration-browser.cjs index 4ac55c7..6971994 100644 --- a/tests/atomic-restoration-browser.cjs +++ b/tests/atomic-restoration-browser.cjs @@ -39,6 +39,21 @@ async function run(){ assert.equal(await page.evaluate(k=>JSON.parse(localStorage.getItem(k)).main.progress.dns,CANON),"mastered"); console.log("PASS NOESIS-RCC-001 "+(process.env.BROWSER||"chromium")+" successful import survives restart"); await page.locator('#nav [data-view="privacy"]').click(); + const rollbackBefore=await page.evaluate(k=>localStorage.getItem(k),CANON); + await page.evaluate(key=>{ + const set=Storage.prototype.setItem,get=Storage.prototype.getItem;let writes=0,fail=false; + Storage.prototype.setItem=function(k,v){const r=set.call(this,k,v);if(k===key&&++writes===2)fail=true;return r;}; + Storage.prototype.getItem=function(k){if(k===key&&fail){fail=false;throw Error("synthetic lost readback");}return get.call(this,k);}; + },CANON); + const rollbackDialogs=dialogs.length; + await page.locator("#importer").setInputFiles({...buffer,buffer:Buffer.from(JSON.stringify({...fixture,progress:{dns:"learn"}}))}); + await page.waitForFunction(()=>document.querySelector("#importer").value===""); + assert(dialogs.slice(rollbackDialogs).some(x=>/Import refusé/.test(x)),"lost readback must refuse success"); + assert.equal(await page.evaluate(k=>localStorage.getItem(k),CANON),rollbackBefore,"rollback restores every previous byte"); + await page.reload(); + assert.equal(await page.evaluate(k=>localStorage.getItem(k),CANON),rollbackBefore,"confirmed rollback survives reload"); + console.log("PASS NOESIS-RCC-001 "+(process.env.BROWSER||"chromium")+" lost readback restores complete prior state and rejects success"); + await page.locator('#nav [data-view="privacy"]').click(); await page.evaluate(key=>{ const original=Storage.prototype.setItem; Storage.prototype.setItem=function(k,v){if(k===key)throw new DOMException("synthetic legacy quota","QuotaExceededError");return original.call(this,k,v);}; diff --git a/tests/atomic-restoration.cjs b/tests/atomic-restoration.cjs index 9e927ee..90f7fd2 100644 --- a/tests/atomic-restoration.cjs +++ b/tests/atomic-restoration.cjs @@ -17,7 +17,7 @@ const document = (main,daily) => serialize({version:3,main,daily}); function harness(initial) { const values = new Map(Object.entries(initial||{})); - const errors = {set:null,get:null}; + const errors = {set:null,get:null,getAfterSet:null}; const storage = { getItem(key) { if(errors.get===key){errors.get=null;throw Error("synthetic get failure");} @@ -26,16 +26,18 @@ function harness(initial) { setItem(key,value) { if(errors.set===key){errors.set=null;throw Object.assign(Error("QuotaExceededError"),{name:"QuotaExceededError"});} values.set(key,String(value)); + if(errors.getAfterSet===key){errors.getAfterSet=null;errors.get=key;} }, removeItem(key){values.delete(key);} }; const faults = []; const sandbox = {localStorage:storage,ATOMIC_KEY:A,KEY:M,DAILY_KEY:D, - atomicUnread:false,atomicSnapshot:null,progressUnread:false,dailyUnread:false, + atomicUnread:false,atomicSnapshot:null,progressUnread:false,dailyUnread:false,storageWriter:true,storageConflict:false, plainObject:v=>v!==null&&typeof v==="object"&&!Array.isArray(v), storageStatus:(key,failed)=>{if(failed)faults.push(key);},storageNotice:()=>{}}; vm.createContext(sandbox); vm.runInContext(segment[0]+"\nglobalThis.API = {atomicRead,atomicCommit,mirrorLegacy};",sandbox); + sandbox.atomicSnapshot=sandbox.API.atomicRead(); return {values,errors,storage,sandbox,faults,...sandbox.API}; } let checks=0; @@ -54,12 +56,12 @@ test("atomic commit: quota before write preserves the prior complete state",()=> assert.equal(read.main.progress.dns,"progress"); assert.ok(read.daily.days["2026-10-22"]); }); -test("atomic commit: a lost readback rejects the success even if bytes committed",()=>{ +test("atomic commit: failed readback restores the entire prior confirmed snapshot",()=>{ const h=harness({[A]:document(oldMain,oldDaily)}); - h.errors.get=A;assert.throws(()=>h.atomicCommit(nextMain,nextDaily),/Instantané non confirmé/); + h.errors.getAfterSet=A;assert.throws(()=>h.atomicCommit(nextMain,nextDaily),/Instantané non confirmé/); const recovered=JSON.parse(h.values.get(A)); - assert.equal(recovered.main.progress.dns,"mastered"); - assert.ok(recovered.daily.days["2026-10-23"]); + assert.equal(recovered.main.progress.dns,"progress"); + assert.deepEqual(recovered.daily,oldDaily); }); test("first migration: quota preserves both original legacy keys",()=>{ const h=harness({[M]:serialize(oldMain),[D]:serialize(oldDaily)}); @@ -113,6 +115,25 @@ test("both primary payloads are required",()=>{ assert.throws(()=>h.atomicCommit(nextMain,{}),/incomplet/); assert.equal(h.values.size,0); }); +test("stale tab refuses a sequential lost update without overwriting either value",()=>{ + const h=harness({[A]:document(oldMain,oldDaily)}); + h.values.set(A,document(nextMain,nextDaily)); + assert.throws(()=>h.atomicCommit({...oldMain,progress:{linux:"progress"}},oldDaily),/Instantané non confirmé/); + assert.equal(h.values.get(A),document(nextMain,nextDaily)); + assert.equal(h.sandbox.storageConflict,true); +}); +test("read-only tab cannot commit, including first migration and restoration",()=>{ + for(const initial of [{},{[A]:document(oldMain,oldDaily)}]){ + const h=harness(initial);h.sandbox.storageWriter=false; + assert.throws(()=>h.atomicCommit(nextMain,nextDaily),/Autre onglet/); + assert.deepEqual(Object.fromEntries(h.values),initial); + } +}); +test("preflight read failure preserves the previous authoritative bytes",()=>{ + const h=harness({[A]:document(oldMain,oldDaily)}); + h.errors.get=A;assert.throws(()=>h.atomicCommit(nextMain,nextDaily),/Instantané non confirmé/); + assert.equal(h.values.get(A),document(oldMain,oldDaily)); +}); test("idempotent retry preserves one complete revision and no extra key",()=>{ const h=harness({[A]:document(oldMain,oldDaily)}); h.errors.set=A;assert.throws(()=>h.atomicCommit(nextMain,nextDaily)); diff --git a/tests/storage-multitab.cjs b/tests/storage-multitab.cjs new file mode 100644 index 0000000..f926dbe --- /dev/null +++ b/tests/storage-multitab.cjs @@ -0,0 +1,65 @@ +"use strict"; +const assert = require("node:assert/strict"); +const {chromium,webkit} = require("playwright"); +const {startServer} = require("./server.cjs"); +const B = {chromium,webkit}[process.env.BROWSER || "chromium"]; +const A="reconversion-control.snapshot.v3"; +const fixture={version:2,progress:{dns:"progress"},review:{},labs:{},proofs:{},labChecks:{},dailyFive:{days:{"2026-10-23":{missionDone:true}}}}; +const file={name:"synthetic-multitab.json",mimeType:"application/json",buffer:Buffer.from(JSON.stringify(fixture))}; +const snapshot=page=>page.evaluate(k=>localStorage.getItem(k),A); +async function nav(page,view){await page.locator('#nav [data-view="'+view+'"]').click();} +async function writer(page){await page.waitForFunction(async()=>{const q=await navigator.locks.query();return q.held.some(l=>l.name==="reconversion-control-writer-v3");});} +async function run(){ + const server=await startServer(),browser=await B.launch({headless:true}); + const context=await browser.newContext({serviceWorkers:"block",acceptDownloads:true}); + const first=await context.newPage(),second=await context.newPage(),dialogs=[]; + second.on("dialog",async d=>{dialogs.push(d.message());await d.accept();}); + first.on("dialog",async d=>{await d.accept();}); + try{ + await first.goto(server.url);await writer(first);await nav(first,"privacy"); + await first.locator("#importer").setInputFiles(file); + await first.waitForFunction(()=>document.querySelector("#importer").value===""); + const before=await snapshot(first);assert.equal(JSON.parse(before).main.progress.dns,"progress"); + assert.deepEqual(JSON.parse(before).daily,fixture.dailyFive); + await second.goto(server.url);await nav(second,"privacy"); + await second.locator("#storage-notice").waitFor({state:"visible"}); + assert.equal(await snapshot(second),before,"opening a second tab must not rewrite the source"); + const replacement={...fixture,progress:{dns:"mastered"},dailyFive:{days:{}}}; + await second.locator("#importer").setInputFiles({...file,buffer:Buffer.from(JSON.stringify(replacement))}); + await second.waitForFunction(()=>document.querySelector("#importer").value===""); + assert(dialogs.some(x=>/Import refusé/.test(x)),"secondary-tab restoration must refuse before commit"); + assert.equal(await snapshot(second),before,"entire main and Daily Five remain unchanged"); + await nav(first,"library");await nav(second,"library"); + await Promise.all([ + first.locator('[data-card="dns"] [data-prog="mastered"]').click(), + second.locator('[data-card="dns"] [data-prog="learn"]').click() + ]); + const committed=JSON.parse(await snapshot(first));assert.equal(committed.main.progress.dns,"mastered"); + assert.deepEqual(committed.daily,fixture.dailyFive); + const downloadPromise=second.waitForEvent("download");await nav(second,"privacy");await second.locator("#export").click(); + const download=await downloadPromise;const stream=await download.createReadStream(),chunks=[];for await(const c of stream)chunks.push(c); + const memory=JSON.parse(Buffer.concat(chunks).toString());assert.equal(memory.progress.dns,"learn","refused memory changes remain exportable"); + assert.equal(JSON.parse(await snapshot(first)).main.progress.dns,"mastered"); + await first.close();await second.close(); + const reopened=await context.newPage();await reopened.goto(server.url);await writer(reopened); + const after=JSON.parse(await snapshot(reopened)); + // Navigation can differ; every durable business field must be structurally identical. + for(const key of ["progress","review","labs","proofs","labChecks","career"])assert.deepEqual(after.main[key],committed.main[key],key); + assert.deepEqual(after.daily,committed.daily);await reopened.close(); + console.log("PASS multi-tab: exclusive native Web Lock, rejected restore, concurrent actions, exportable unsaved work and full business/Daily Five persistence after closure"); + for(const corrupt of [ + {version:3,main:{progress:"corrupt",review:{}},daily:{days:{}}}, + {version:3,main:{progress:{dns:"invalid"},review:{}},daily:{days:{}}}, + {version:3,main:{progress:{},review:{}},daily:{days:{"2026-10-23":{completed:true}}}} + ]){ + const c=await browser.newContext({serviceWorkers:"block"}),p=await c.newPage(); + const raw=JSON.stringify(corrupt);await c.addInitScript(({key,raw})=>localStorage.setItem(key,raw),{key:A,raw}); + await p.goto(server.url);await writer(p);await nav(p,"library"); + await p.locator('[data-card="dns"] [data-prog="mastered"]').click(); + assert.equal(await snapshot(p),raw,"corrupt authoritative bytes must never be overwritten by defaults"); + await p.locator("#storage-notice").waitFor({state:"visible"});await c.close(); + } + console.log("PASS corrupted canonical data: malformed progression, invalid status and incomplete Daily Five preserve every original byte"); + }finally{await context.close();await browser.close();await server.close();} +} +run().catch(e=>{console.error(e);process.exitCode=1;}); From 1f582e1638bd202f331c15cef34f13f867279729 Mon Sep 17 00:00:00 2001 From: iPoower <101516206+iPoower@users.noreply.github.com> Date: Sat, 24 Oct 2026 06:12:18 +0200 Subject: [PATCH 12/13] fix(storage): retain unsupported historical progression instead of migrating defaults --- NOESIS_FINAL_MILE_STORAGE_20261024.md | 4 ++++ app.js | 4 ++-- tests/atomic-restoration.cjs | 11 +++++++++++ tests/storage-multitab.cjs | 10 ++++++++++ 4 files changed, 27 insertions(+), 2 deletions(-) diff --git a/NOESIS_FINAL_MILE_STORAGE_20261024.md b/NOESIS_FINAL_MILE_STORAGE_20261024.md index 2674604..705975f 100644 --- a/NOESIS_FINAL_MILE_STORAGE_20261024.md +++ b/NOESIS_FINAL_MILE_STORAGE_20261024.md @@ -39,3 +39,7 @@ Rollback: retain V3 canonical bytes, both legacy mirrors and full export before approved code downgrade. Do not assume old code reads V3 or that mirrors are current when a storage warning occurred. Recover in a separate origin/profile, compare every business field, then decide. No migration, data reset or personal activation here. + +An additional red regression proves unsupported legacy topic IDs were silently dropped +before migration. Legacy business readers now refuse invalid/unknown data, retain original +bytes and block migration. Both browsers cover this historical preservation case. diff --git a/app.js b/app.js index e453bf2..0630ff6 100644 --- a/app.js +++ b/app.js @@ -353,12 +353,12 @@ async function checkLabEvidence(id,file){ function load(){ var initial=JSON.parse(JSON.stringify(defaults)); try{var j=atomicSnapshot?atomicSnapshot.main:JSON.parse(storageGet(KEY)||"null");if(j===null)return initial;if(!plainObject(j)||(j.progress!==undefined&&!plainObject(j.progress))||(j.review!==undefined&&!plainObject(j.review)))throw Error("Invalid stored progression"); - var saved=progression({progress:plainObject(j.progress)?j.progress:{},review:plainObject(j.review)?j.review:{}},!!atomicSnapshot);initial.progress=saved.progress;initial.review=saved.review; + var saved=progression({progress:plainObject(j.progress)?j.progress:{},review:plainObject(j.review)?j.review:{}},true);initial.progress=saved.progress;initial.review=saved.review; if(views.indexOf(j.view)>=0)initial.view=j.view; if(j.section==="all"||section(j.section))initial.section=j.section; if(typeof j.search==="string")initial.search=j.search; if(j.status==="all"||statuses.indexOf(j.status)>=0)initial.status=j.status; - initial.labs=labStateSanitize(j.labs,!!atomicSnapshot);initial.proofs=proofStateSanitize(j.proofs,!!atomicSnapshot);initial.labChecks=labChecksSanitize(j.labChecks,!!atomicSnapshot);initial.career=RC_PROJECT_PLAN.sanitize(j.career,!!atomicSnapshot); + 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); }catch(e){progressUnread=true;storageStatus(KEY,true);}return initial; } function save(replace){if(storageSet(KEY,JSON.stringify(state),replace)&&syncBridge)syncBridge.changed();} diff --git a/tests/atomic-restoration.cjs b/tests/atomic-restoration.cjs index 90f7fd2..8dabc47 100644 --- a/tests/atomic-restoration.cjs +++ b/tests/atomic-restoration.cjs @@ -134,6 +134,17 @@ test("preflight read failure preserves the previous authoritative bytes",()=>{ h.errors.get=A;assert.throws(()=>h.atomicCommit(nextMain,nextDaily),/Instantané non confirmé/); assert.equal(h.values.get(A),document(oldMain,oldDaily)); }); +test("historical unknown progression blocks migration instead of dropping its bytes",()=>{ + const loadFunction=source.match(/function load\(\)\{[\s\S]*?\n\}/)[0]; + const progressFunction=source.match(/function progression\(data,strict\)\{[\s\S]*?\n\}/)[0]; + const context=vm.createContext({defaults:{progress:{},review:{}},atomicSnapshot:null, + KEY:M,progressUnread:false,storageGet:()=>serialize({progress:{retiredTopic:"mastered"},review:{}}), + storageStatus(){},plainObject:v=>v&&typeof v==="object"&&!Array.isArray(v), + topic:()=>null,statuses:["learn","progress","mastered"],views:[],section:()=>null, + labStateSanitize:()=>({}),proofStateSanitize:()=>({}),labChecksSanitize:()=>({}),RC_PROJECT_PLAN:{sanitize:()=>({})}}); + vm.runInContext(progressFunction+loadFunction+';load();',context); + assert.equal(context.progressUnread,true,"unsupported historical progression must stay unread and non-migratable"); +}); test("idempotent retry preserves one complete revision and no extra key",()=>{ const h=harness({[A]:document(oldMain,oldDaily)}); h.errors.set=A;assert.throws(()=>h.atomicCommit(nextMain,nextDaily)); diff --git a/tests/storage-multitab.cjs b/tests/storage-multitab.cjs index f926dbe..4ba4817 100644 --- a/tests/storage-multitab.cjs +++ b/tests/storage-multitab.cjs @@ -60,6 +60,16 @@ async function run(){ await p.locator("#storage-notice").waitFor({state:"visible"});await c.close(); } console.log("PASS corrupted canonical data: malformed progression, invalid status and incomplete Daily Five preserve every original byte"); + + const legacyContext=await browser.newContext({serviceWorkers:"block"}),legacyPage=await legacyContext.newPage(); + const legacyKey="reconversion-control.state.v1",legacyRaw=JSON.stringify({progress:{retiredTopic:"mastered"},review:{}}); + await legacyContext.addInitScript(({key,raw})=>localStorage.setItem(key,raw),{key:legacyKey,raw:legacyRaw}); + await legacyPage.goto(server.url);await writer(legacyPage);await nav(legacyPage,"library"); + await legacyPage.locator('[data-card="dns"] [data-prog="mastered"]').click(); + assert.equal(await legacyPage.evaluate(k=>localStorage.getItem(k),legacyKey),legacyRaw,"unsupported historical bytes remain untouched"); + assert.equal(await snapshot(legacyPage),null,"no authoritative replacement may hide unsupported history"); + await legacyPage.locator("#storage-notice").waitFor({state:"visible"});await legacyContext.close(); + console.log("PASS historical unsupported progression: original bytes preserved, no default-valued migration"); }finally{await context.close();await browser.close();await server.close();} } run().catch(e=>{console.error(e);process.exitCode=1;}); From 706e204040676f9022eadb0db175e34d68961966 Mon Sep 17 00:00:00 2001 From: iPoower <101516206+iPoower@users.noreply.github.com> Date: Sat, 24 Oct 2026 06:16:43 +0200 Subject: [PATCH 13/13] fix(storage): preserve safe view metadata when legacy business data is rejected --- app.js | 2 +- tests/atomic-restoration.cjs | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/app.js b/app.js index 0630ff6..f2a7082 100644 --- a/app.js +++ b/app.js @@ -353,11 +353,11 @@ async function checkLabEvidence(id,file){ function load(){ var initial=JSON.parse(JSON.stringify(defaults)); try{var j=atomicSnapshot?atomicSnapshot.main:JSON.parse(storageGet(KEY)||"null");if(j===null)return initial;if(!plainObject(j)||(j.progress!==undefined&&!plainObject(j.progress))||(j.review!==undefined&&!plainObject(j.review)))throw Error("Invalid stored progression"); - var saved=progression({progress:plainObject(j.progress)?j.progress:{},review:plainObject(j.review)?j.review:{}},true);initial.progress=saved.progress;initial.review=saved.review; if(views.indexOf(j.view)>=0)initial.view=j.view; if(j.section==="all"||section(j.section))initial.section=j.section; if(typeof j.search==="string")initial.search=j.search; 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); }catch(e){progressUnread=true;storageStatus(KEY,true);}return initial; } diff --git a/tests/atomic-restoration.cjs b/tests/atomic-restoration.cjs index 8dabc47..6c19167 100644 --- a/tests/atomic-restoration.cjs +++ b/tests/atomic-restoration.cjs @@ -138,11 +138,12 @@ test("historical unknown progression blocks migration instead of dropping its by const loadFunction=source.match(/function load\(\)\{[\s\S]*?\n\}/)[0]; const progressFunction=source.match(/function progression\(data,strict\)\{[\s\S]*?\n\}/)[0]; const context=vm.createContext({defaults:{progress:{},review:{}},atomicSnapshot:null, - KEY:M,progressUnread:false,storageGet:()=>serialize({progress:{retiredTopic:"mastered"},review:{}}), + KEY:M,progressUnread:false,storageGet:()=>serialize({view:"review",progress:{retiredTopic:"mastered"},review:{}}), storageStatus(){},plainObject:v=>v&&typeof v==="object"&&!Array.isArray(v), - topic:()=>null,statuses:["learn","progress","mastered"],views:[],section:()=>null, + topic:()=>null,statuses:["learn","progress","mastered"],views:["review"],section:()=>null, labStateSanitize:()=>({}),proofStateSanitize:()=>({}),labChecksSanitize:()=>({}),RC_PROJECT_PLAN:{sanitize:()=>({})}}); - vm.runInContext(progressFunction+loadFunction+';load();',context); + vm.runInContext(progressFunction+loadFunction+';globalThis.loaded=load();',context); + assert.equal(context.loaded.view,'review','safe navigation metadata must remain usable while business migration is blocked'); assert.equal(context.progressUnread,true,"unsupported historical progression must stay unread and non-migratable"); }); test("idempotent retry preserves one complete revision and no extra key",()=>{