☁️ 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.
'+msg;
+ if(mode==="setup")return intro+'
Connexion réservée à tes appareils personnels. Sur un PC professionnel, utilise les cours publics sans connexion.
Google identifie le compte. Ta phrase de chiffrement sera demandée ensuite ; elle ne sera pas transmise à Google ou Supabase.
Connexion email existante
'+msg;
+ if(mode==="oauth")return intro+'
Compte Google confirmé : '+safe(googleIdentity&&googleIdentity.email)+'
Session chiffrée mémorisée. Déverrouille-la pour vérifier sa validité auprès de Supabase.
')+'
'+(mode==="otp"?'':'')+''+disconnect+'
'+msg;
let controls='
'+disconnect+'
';
if(mode==="initial")controls='
Le cloud est vide. Ta progression locale sera la source initiale. Vérifie d’abord ton export indépendant.
'+disconnect+'
';
@@ -44,12 +45,49 @@ const RC_CLOUD_UI = (() => {
const encrypted=await T().sealSession(session,phrase);guard(ticket);save(SESSION,{v:1,project:cfg.url,payload:encrypted});
}});
}
+ function readConfig(){
+ const next=T().config(document.querySelector("#rc-sync-url").value,document.querySelector("#rc-sync-key").value);
+ if(cfg&&next.url===cfg.url&&cfg.owner)next.owner=cfg.owner;return next;
+ }
+ async function startGoogle(){
+ if(busy)return;busy=true;const ticket=++epoch;
+ try{
+ local();cfg=readConfig();save(CONFIG,cfg);remember=false;phrase="";client=makeClient(ticket);
+ const callback=new URL("./",location.href);callback.search="";callback.hash="";
+ const target=await client.beginOAuth(callback.href);guard(ticket);busy=false;location.assign(target);
+ }catch(e){if(ticket===epoch){status="Connexion Google impossible : "+e.message;busy=false;repaint();}}
+ }
+ async function receiveGoogle(result){
+ if(!cfg){status="Retour Google sans configuration : aucune donnée modifiée.";repaint();return;}
+ const ticket=++epoch;remember=false;phrase="";busy=true;
+ try{
+ client=makeClient(ticket);
+ if(result.error){client.cancelOAuth();throw Error(result.error);}
+ const callback=new URL("./",location.href);callback.search="";callback.hash="";
+ googleIdentity=await client.completeOAuth(result.code,callback.href);guard(ticket);user=googleIdentity.id;
+ mode="oauth";status="Compte vérifié. Aucun instantané téléchargé ou envoyé avant déverrouillage et choix de source.";
+ }catch(e){if(ticket===epoch){user="";googleIdentity=null;client=null;status="Connexion Google refusée : "+e.message;}}
+ finally{if(ticket===epoch){busy=false;repaint();}}
+ }
+ async function unlockGoogle(){
+ if(busy||!client||!googleIdentity)return;busy=true;const ticket=epoch;
+ try{
+ if(!document.querySelector("#rc-sync-confirm-account").checked)throw Error("Confirme le compte affiché avant de continuer.");
+ local();phrase=document.querySelector("#rc-sync-phrase").value;
+ if(phrase.length<12||phrase.length>1024)throw Error("Phrase secrète : 12 à 1024 caractères.");
+ if(cfg.owner&&cfg.owner!==user)throw Error("Compte différent du compte attendu : aucune progression transférée.");
+ cfg={...cfg,owner:user};save(CONFIG,cfg);remember=!!document.querySelector("#rc-sync-remember").checked;
+ if(remember)await client.rememberSession();else if(load(SESSION))remove(SESSION);
+ guard(ticket);loadMeta();await inspect(ticket);
+ }catch(e){if(ticket===epoch){status="Déverrouillage refusé : "+e.message;if(!["oauth","initial","choose"].includes(mode))mode="error";}}
+ finally{if(ticket===epoch){busy=false;repaint();}}
+ }
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);
+ cfg=readConfig();
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;}
@@ -233,19 +271,19 @@ const RC_CLOUD_UI = (() => {
}
async function disconnect(){
const ticket=++epoch,previous=client;if(timer)clearInterval(timer);if(pending)clearTimeout(pending);timer=pending=null;
- client=null;phrase="";email="";user="";remote=remoteData=null;revision=null;baseline="";meta={v:1};historyRows=[];busy=false;mode="setup";
+ client=null;phrase="";email="";user="";googleIdentity=null;remote=remoteData=null;revision=null;baseline="";meta={v:1};historyRows=[];busy=false;mode="setup";
try{remove(SESSION);status="Déconnecté localement. Données et journal de récupération conservés.";}
catch(e){status="Session en mémoire fermée ; suppression durable NON confirmée. Efface la session mémorisée avant de quitter cet appareil.";}
repaint();if(previous){const revoked=await previous.logout();if(ticket===epoch&&!revoked){status+=" Révocation serveur non confirmée : reconnecte-toi au fournisseur pour révoquer la session.";repaint();}}
}
function bind(){
const on=(id,fn)=>{const el=document.querySelector("#"+id);if(el)el.onclick=fn;};
- 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);
+ on("rc-sync-google",startGoogle);on("rc-sync-google-unlock",unlockGoogle);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.";}}
+ try{const saved=load(CONFIG);if(saved){cfg=T().config(saved.url,saved.key);if(saved.owner!==undefined){if(!/^[0-9a-f-]{36}$/i.test(saved.owner))throw Error("Compte mémorisé invalide");cfg.owner=saved.owner;}}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);
@@ -253,6 +291,8 @@ const RC_CLOUD_UI = (() => {
window.addEventListener("pageshow",()=>{if(mode==="active")tick();});
}
if(document.addEventListener)document.addEventListener("visibilitychange",()=>{if(!document.hidden&&mode==="active")tick();});
+ const returned=typeof RC_ACCESS_MODE!=="undefined"?RC_ACCESS_MODE.takeOAuthReturn():null;
+ if(returned)Promise.resolve().then(()=>receiveGoogle(returned));
return {render:view,bind,changed};
}
return {attach};
diff --git a/courses-public.html b/courses-public.html
new file mode 100644
index 0000000..e749bea
--- /dev/null
+++ b/courses-public.html
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+ Reconversion Control · cours publics
+
+
+
+
Reconversion Control · cours publics
+
Lecture sans compte et sans progression personnelle. Aucun déverrouillage, aucune synchronisation et aucun enregistrement de réponses sur cet appareil.
+
Un ordinateur professionnel peut être supervisé. Ce lecteur ne rend pas ta navigation confidentielle vis-à-vis de son administrateur.
+
+
+
+
+
+
diff --git a/courses-public.js b/courses-public.js
new file mode 100644
index 0000000..c411c52
--- /dev/null
+++ b/courses-public.js
@@ -0,0 +1,20 @@
+"use strict";
+/* Public teaching catalog only. No storage, Auth, analytics, imports or service worker. */
+(()=>{
+ const esc=s=>String(s==null?"":s).replace(/[&<>"']/g,c=>({"&":"&","<":"<",">":">",'"':""","'":"'"}[c]));
+ const list=document.querySelector("#public-courses"),article=document.querySelector("#public-lesson"),search=document.querySelector("#public-search");
+ const title=l=>{const t=RC_TOPICS.find(t=>t.id===l.id);return t?t.title:l.title||l.id;};
+ const answer=(name,text)=>''+esc(name)+'
'+esc(text)+'
';
+ function sourceHTML(s){
+ const id=typeof s==="string"?s:s.id,source=RC_SUPPORTS.find(p=>p.id===id)||(typeof s==="object"?s:{}),text=(source.title||id||"Source")+(s.pages?" · p. "+s.pages:""),href=source.url;
+ return '
'+esc(l.provenance||"Support historique · adapté pour apprendre")+'
Objectifs
'+l.goals.map(g=>'
'+esc(g)+'
').join("")+'
'+l.sections.map(p=>'
'+esc(p.title)+'
'+p.body.map(t=>'
'+esc(t)+'
').join("")+(p.code?'
'+esc(p.code)+'
':"")+'').join("")+'
Pièges
'+l.pitfalls.map(t=>'
'+esc(t)+'
').join("")+'
Entretien
'+esc(l.interview.question)+'
'+answer("Voir la réponse",l.interview.answer)+'
Exercice
'+esc(l.exercise.prompt)+'
'+answer("Voir le corrigé",l.exercise.answer)+(l.quiz||[]).map(q=>'
'+esc(q.question)+'
'+answer("Réponse",q.answer)).join("")+'
Glossaire
'+(l.glossary||[]).map(g=>'
'+esc(g.term)+'
'+esc(g.definition)+'
').join("")+'
Sources
'+l.sources.map(sourceHTML).join("")+'
';
+ article.querySelector("h2").focus();
+ }
+ function render(){const q=search.value.toLocaleLowerCase("fr");list.innerHTML=RC_LESSONS.filter(l=>(title(l)+" "+l.sections.map(p=>p.title+" "+p.body.join(" ")).join(" ")).toLocaleLowerCase("fr").includes(q)).map(l=>'').join("");list.querySelectorAll("[data-public-lesson]").forEach(b=>b.onclick=()=>show(b.dataset.publicLesson));}
+ search.oninput=render;render();
+})();
diff --git a/docs/google-auth.md b/docs/google-auth.md
new file mode 100644
index 0000000..89219f1
--- /dev/null
+++ b/docs/google-auth.md
@@ -0,0 +1,93 @@
+# Google OAuth sans domaine — Reconversion Control
+
+Auteur : Bryan Fiarda. 25 octobre 2026.
+
+Ce changement prolonge le candidat #33 (4570c111…). Il ne fusionne ni ne publie le code, ne crée pas d'utilisateur personnel et ne transmet pas de progression personnelle. Aucune migration SQL n'est nécessaire. AES-256-GCM/PBKDF2, DTO cloud V2 issu du snapshot atomique V3, outbox, révisions, RLS et rétention 20 restent inchangés.
+
+## Deux usages
+
+- **Cours publics** : `courses-public.html` ne charge ni app.js, ni cloud-ui, ni transport Auth. Il ne lit ni localStorage/IndexedDB, ni session personnelle, et ne conserve pas de réponses. CSP connect-src none ; pas d'analytics ni de service worker propre. Le démarrage normal propose cet accès avant tout chargement de progression.
+- **Appareil personnel** : choix explicite avant de charger l'application et son stockage. La déclaration est un choix d'usage, pas une détection fiable d'un PC administré. Google identifie le compte ; la phrase indépendante déverrouille ensuite le chiffrement. Une session mémorisée demeure chiffrée et facultative. La fermeture sans mémorisation perd la session en mémoire, pas le snapshot local.
+
+Un PC professionnel peut enregistrer écran, frappes, fichiers et trafic. Ne jamais y ouvrir le mode personnel ou saisir une phrase. Une déconnexion ne supprime pas la progression locale en clair. Aucune purge de stockage n'est effectuée. Une URL de cours sous la même origine ne constitue pas une isolation : les chemins GitHub Pages d'un même hôte partagent localStorage. Le lecteur public peut être distribué séparément sur une **origine gratuite distincte**, sans déplacer les données personnelles. Le build public décrit plus bas est préparé ; aucun nouvel hébergement n'est créé ou publié.
+
+## Contrat de connexion
+
+1. Depuis un appareil personnel, préparer un vérificateur PKCE aléatoire 32 octets et son SHA-256 S256. Une tentative unique, liée au projet et au callback exact, est conservée dans sessionStorage pour cinq minutes maximum. Aucun access/refresh token ni phrase n'y est conservé.
+2. Navigation de premier niveau vers Supabase `/auth/v1/authorize`, provider google, scopes **openid email profile** uniquement. Supabase conserve le contrôle de son state signé ; l'application ne le remplace pas. Pas de One Tap, iframe, script Google externe, Google Drive, Gmail ou accès offline aux API Google.
+3. Le bootstrap retire immédiatement code et erreurs de l'URL avant de charger le code personnel. Le code OAuth temporaire apparaît nécessairement dans le GET de callback ; le nettoyage d'historique ne peut effacer les journaux du fournisseur d'hébergement. PKCE lie ce code à son vérificateur local ; aucun Bearer/refresh token n'est accepté depuis une URL.
+4. Échange unique POST `/auth/v1/token?grant_type=pkce`, puis GET `/auth/v1/user` avec le Bearer Supabase reçu. Vérifier UUID, non-anonymat et identité Google. Un UUID différent du propriétaire déjà confirmé est refusé avant tout accès REST de progression. Le décodage local d'un JWT n'est pas utilisé comme preuve d'identité.
+5. L'utilisateur confirme le compte affiché et saisit sa phrase. L'UUID du propriétaire est mémorisé comme métadonnée de configuration, jamais remplacé silencieusement. Les sessions héritées et les anciens formats cryptographiques restent lisibles ; ne pas déplacer un ancien journal vers un nouvel UUID.
+6. Seulement ensuite : lecture/déchiffrement/validation, choix explicite de source initiale et envoi conditionnel. Aucun premier upload provoqué par Google seul. Les jetons de fournisseur Google sont ignorés ; seules les données de session Supabase nécessaires passent à la mémorisation chiffrée existante.
+
+Un retour sans tentative, avec mauvais projet, callback, vérificateur, date future ou identité inattendue échoue sans restaurer ni envoyer de progression. Une réponse Auth perdue exige de recommencer la connexion ; elle n'autorise pas une réinitialisation du stockage. Les réponses de sauvegarde perdues suivent toujours la réconciliation idempotente existante.
+
+## Paramètres administratifs nécessaires — ne pas confondre préparation et exécution
+
+| Paramètre | Valeur |
+| --- | --- |
+| Projet Supabase unique | jsxbpxlipjmkszfektis |
+| Projet ancien exclu | zjydqnkyqshpnteabpat |
+| Client Google | Application Web, dans un projet Google Cloud personnel consacré à cette app |
+| Origine JavaScript | https://ipoower.github.io (sans chemin) |
+| URI de callback Google | **https://jsxbpxlipjmkszfektis.supabase.co/auth/v1/callback** |
+| Site URL et redirect Supabase | **https://ipoower.github.io/Reconversion-Control/**, entrée exacte, aucune wildcard |
+| Scopes | openid, userinfo.email, userinfo.profile, sans autre permission |
+| Secret client | Uniquement dans la configuration serveur Supabase, jamais dans le dépôt, l'app, un export ou une conversation |
+| Skip nonce checks | Désactivé |
+| Utilisateurs sans email / anonymes | Refusés |
+
+Le domaine Supabase fourni peut servir de callback ; aucun achat de domaine, SMTP ou abonnement Google Identity Platform n'est nécessaire à ce parcours. Le branding personnalisé et un domaine personnalisé sont des sujets distincts. Les exceptions Google aux restrictions Testing pour les trois scopes d'identité signifient que **la liste des test users n'est pas une barrière d'accès privée**. Les inscriptions Supabase doivent être fermées avant l'exploitation personnelle ; ne pas compter sur un bouton masqué comme contrôle d'autorisation.
+
+Préparer, sous GO distinct de création d'utilisateur personnel, un unique utilisateur confirmé par le mécanisme **Auth Admin légitime**, puis vérifier en environnement de recette l'association automatique Google à son UUID avec inscriptions fermées. L'Admin createUser ne requiert pas de SMTP ; il ne faut pas écrire directement dans auth.users ou désactiver globalement la confirmation email. Le choix exact de l'identité et son contrôle appartiennent à l'utilisateur. Tout échec d'association bloque l'activation ; aucun contournement RLS ni nouvelle fenêtre d'inscription publique ne doit être improvisé.
+
+**État constaté le 25/10 :** projet Healthy ; catalogue identique au contrôle précédent, zéro utilisateur et zéro ligne des deux tables. Google et GitHub désactivés, email activé, anonymes refusés, nonce checks conservés, Au départ, Site URL localhost:3000 et redirects vides. Sous le GO Google du 25/10, Site URL et une seule redirection exacte ont été enregistrées vers https://ipoower.github.io/Reconversion-Control/ ; les inscriptions publiques ont été fermées. Aucun utilisateur créé, aucune migration ni permission SQL modifiée. La console Google Cloud affiche « Site Unavailable » dans le navigateur de travail après une seule relance ; le client OAuth n'a donc pas été créé et le provider n'a pas été activé. L'accès administrateur Supabase seul ne suffit pas à inventer les identifiants Google. Aucun plugin disponible identifié ne fournit l'administration OAuth Google Cloud.
+
+## Sessions, iPhone et récupération
+
+Le moteur Supabase existant renouvelle ses propres sessions. La révocation Google, la session Supabase et la clé de chiffrement sont distinctes. Déconnecter l'app ne déconnecte pas le compte Google du navigateur ; un access token Supabase déjà émis peut rester valide jusqu'à expiration malgré la révocation de son refresh token. La phrase reste en mémoire ; le chiffrement de session ne protège pas un appareil compromis pendant son déverrouillage.
+
+Safari et la PWA installée peuvent avoir des stockages distincts. PKCE exige de retrouver le même vérificateur dans le contexte qui a lancé la connexion. Une bascule de contexte doit produire un refus clair et une nouvelle connexion depuis le bon contexte, jamais un transfert de tokens par URL, copier-coller ou stockage en clair. Le comportement effectif des redirections doit être vérifié sur l'iPhone physique. Une PWA fermée/suspendue ne synchronise pas en permanence ; reprise après ouverture, déverrouillage et réseau.
+
+Avant toute recette personnelle : exports PC et iPhone séparés, copie indépendante, réimport vérifié dans un profil isolé, choix de source. La perte du compte Google exige sa récupération ou une intervention Auth contrôlée conservant l'UUID ; ne pas recréer un utilisateur en prétendant qu'il retrouve automatiquement les sauvegardes de l'ancien. La perte de la phrase ne peut pas être réparée par Google ou Supabase. Conserver toute copie locale lisible et ses exports.
+
+## Validation
+
+Distribution publique isolée, préparée à partir d'un commit exact, sans publication :
+
+```sh
+python3 .github/scripts/stage-public-courses.py HEAD /tmp/reconversion-cours-publics
+```
+
+La destination doit être nouvelle. Elle contient uniquement les huit fichiers pédagogiques autorisés et leur manifeste SHA-256 ; ni application personnelle, ni Auth, ni service worker, ni secrets. Un hébergement statique gratuit sur une origine distincte reste à choisir et autoriser. Aucun service supplémentaire n'est nécessaire à la synchronisation personnelle.
+
+```sh
+node tests/cloud-oauth.cjs
+BROWSER=chromium node tests/cloud-oauth-browser.cjs
+BROWSER=webkit node tests/cloud-oauth-browser.cjs
+```
+
+La nouvelle suite navigateur vérifie accès public sans données privées, retour PKCE, confirmation du compte et de la phrase, deux clients, sauvegarde chiffrée relue, restauration après fermeture/réouverture, mauvaise phrase, hors ligne, réponse perdue, mauvais compte et logs canaris. **Tout HTTP fournisseur est intercepté.** Les tests SQL/cryptographiques/concurrence et de publication existants restent exécutés. Leur amorçage choisit explicitement l'appareil personnel ; aucune assertion métier n'est supprimée pour contourner le nouvel écran d'accès.
+
+Cette CI ne valide ni Google réel, ni une nouvelle session Auth hébergée, ni un iPhone physique. La recette hébergée doit utiliser deux identités de test légitimes et des données synthétiques dans un périmètre isolé explicitement autorisé, sans conserver tokens/HAR/logs Auth bruts. Les comptes fictifs SQL dans une transaction annulée ne remplacent pas cette preuve. Les comptes et fixtures hébergés persistants ne sont pas supprimés sans autorisation.
+
+## Sécurité GitHub et livraison
+
+Main e8b5dd49… est non protégée et aucun ruleset n'est déclaré au contrôle du 25/10. Le dépôt public permet une protection gratuite : PR obligatoire, provenance + mobile (chromium) + mobile (webkit) requis sur SHA exact et base à jour, force push/suppression de main interdits, pas de bypass administrateur. Une revue GitHub obligatoire par un autre auteur nécessite un second reviewer qualifié : l'auteur iPoower ne peut pas approuver sa propre PR. Ne pas activer une règle impossible à satisfaire pour ensuite ajouter un contournement.
+
+Conserver séparément github-pages : iPoower approbateur, aucun bypass, self-review possible, branche main seule. Les paramètres Pages étaient vérifiés dans les archives du 24/10 ; leur configuration administrative n'a pas été changée ici. Le workflow et les contrôles de provenance conservent leurs conditions fail-closed. Aucun déploiement de cette branche n'est autorisé.
+
+Sources officielles consultées le 25/10 :
+
+- https://supabase.com/docs/guides/auth/social-sign-in/auth-google
+- https://supabase.com/docs/guides/auth/sessions/pkce-flow
+- https://github.com/supabase/auth/blob/master/openapi.yaml
+- https://support.google.com/cloud/answer/15549945?hl=en
+- https://developers.google.com/identity/protocols/oauth2/production-readiness/brand-verification
+- https://supabase.com/docs/guides/auth/general-configuration
+- https://supabase.com/docs/reference/javascript/auth-admin-createuser
+- https://supabase.com/docs/guides/auth/auth-identity-linking
+- https://webkit.org/blog/14787/webkit-features-in-safari-17-2/
+- https://developer.apple.com/videos/play/wwdc2023/10120/
+- https://supabase.com/pricing
+- https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches
diff --git a/index.html b/index.html
index 5cd9cd0..5000ceb 100644
--- a/index.html
+++ b/index.html
@@ -10,6 +10,7 @@
+
Reconversion Control
@@ -17,6 +18,8 @@
+
+