Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions .github/scripts/stage-public-courses.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"""Stage a public-only reader from committed blobs. Never uploads or publishes."""
import argparse
import hashlib
import json
from pathlib import Path
import subprocess

FILES = ("courses-public.html", "courses-public.js", "data.js", "course-foundations.js",
"course-race.js", "course-operations.js", "learning.js", "style.css")


def stage(sha, destination):
# Resolve refs once, then read only this exact immutable revision.
sha = subprocess.check_output(["git", "rev-parse", "--verify", sha + "^{commit}"], text=True).strip()
destination = Path(destination)
if destination.exists():
raise ValueError("Destination must not exist; no existing files are replaced")
content = {}
for name in FILES:
entry = subprocess.check_output(["git", "ls-tree", sha, "--", name], text=True).split()
if len(entry) != 4 or entry[:2] != ["100644", "blob"]:
raise ValueError("Missing regular public source: " + name)
content["index.html" if name == "courses-public.html" else name] = subprocess.check_output(["git", "show", sha + ":" + name])
destination.mkdir(parents=True)
manifest = {"sha": sha, "kind": "public-courses-only", "files": {}}
for name, data in content.items():
(destination / name).write_bytes(data)
manifest["files"][name] = hashlib.sha256(data).hexdigest()
(destination / "public-provenance.json").write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
return manifest


if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("sha")
parser.add_argument("destination")
args = parser.parse_args()
print(json.dumps(stage(args.sha, args.destination)))
2 changes: 1 addition & 1 deletion .github/scripts/verify-pages.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
WORKFLOW_NAME = "Reconversion Control · tests mobiles"
REQUIRED_JOBS = {"provenance", "mobile (chromium)", "mobile (webkit)"}
PUBLIC_FILES = (
"index.html", "project-plan.js", "app.js", "data.js", "course-foundations.js",
"index.html", "access-mode.js", "courses-public.html", "courses-public.js", "project-plan.js", "app.js", "data.js", "course-foundations.js",
"course-race.js", "course-operations.js", "learning.js", "english.js",
"drive-learning.js", "daily-five.js", "deep-dives.js", "evidence-checks.js",
"cloud-transport.js", "cloud-ui.js", "style.css", "sw.js", "manifest.webmanifest",
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -91,12 +91,16 @@ jobs:
node --check evidence-checks.js
node --check cloud-transport.js
node --check cloud-ui.js
node --check access-mode.js
node --check courses-public.js
node --check sw.js
- name: Exercices probants et modules IT sourcés
run: |
node tests/deep-checks.cjs
node tests/project-checks.cjs
node tests/cloud-sync.spec.cjs
node tests/cloud-oauth.cjs
BROWSER=${{ matrix.browser }} node tests/cloud-oauth-browser.cjs
node tests/cloud-final-mile.cjs
node tests/cloud-history.cjs
BROWSER=${{ matrix.browser }} node tests/cloud-final-mile-browser.cjs
Expand Down
6 changes: 6 additions & 0 deletions TESTING.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Vérification mobile

## Google OAuth et accès public — 25/10/2026

`node tests/cloud-oauth.cjs` vérifie PKCE S256, le callback exact, l'identité vérifiée, les annulations concurrentes et le renouvellement/révocation de session. `BROWSER=chromium node tests/cloud-oauth-browser.cjs` et la variante WebKit exercent deux clients fictifs, restauration, fermeture/réouverture et réponse perdue. Le fournisseur Auth et la Data API sont simulés ; aucun compte Google réel, utilisateur personnel ou iPhone physique n'est validé par ces tests. Le protocole réel et les prérequis sont décrits dans [docs/google-auth.md](docs/google-auth.md).

Les suites historiques choisissent explicitement un appareil personnel fictif avant leur scénario. Le lecteur public est testé séparément, avec canari de progression préexistante : aucun chargement du stockage personnel. `python3 -m unittest discover -s tests -p 'test_pages*.py'` valide aussi le build public à liste fermée, depuis les blobs du commit courant.

La CI teste la navigation tactile et la recherche dans Chromium et WebKit avec le profil iPhone 11 Pro Max.
Elle couvre Safari (414 × 715), l’application en plein écran (414 × 896), le paysage (896 × 414),
un petit écran (320 × 568) et le bureau (1280 × 900).
Expand Down
33 changes: 33 additions & 0 deletions access-mode.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
"use strict";
/* Runs before application scripts: public access never reads personal storage.
The device choice is an explicit user statement, not device attestation. */
const RC_ACCESS_MODE=(()=>{
const KEY="reconversion-control.device.mode.v1";let returned=null,started=false;
const u=new URL(location.href),codes=u.searchParams.getAll("code");
const tokenKeys=["access_token","refresh_token","provider_token","provider_refresh_token"];
const implicit=tokenKeys.some(k=>u.searchParams.has(k)||new URLSearchParams(u.hash.slice(1)).has(k));
if(codes.length||u.searchParams.has("error")||implicit){
returned=implicit||codes.length>1?{error:"Retour de connexion refusé"}:u.searchParams.has("error")?{error:"Connexion Google annulée ou refusée"}:{code:codes[0]};
["code","error","error_description","error_code",...tokenKeys].forEach(k=>u.searchParams.delete(k));if(implicit)u.hash="";
history.replaceState(null,"",u.pathname+u.search+u.hash);
}
function trusted(){try{return sessionStorage.getItem(KEY)==="personal";}catch(e){return false;}}
async function enter(){
if(started)return;started=true;
try{sessionStorage.setItem(KEY,"personal");}catch(e){} // Local-only use may still run with storage unavailable.
document.querySelector("#rc-access-screen").hidden=true;document.querySelector("#rc-private-shell").hidden=false;
try{
for(const source of document.querySelectorAll("script[data-rc-private-src]"))await new Promise((resolve,reject)=>{const script=document.createElement("script");script.src=source.getAttribute("data-rc-private-src");script.onload=resolve;script.onerror=()=>reject(Error("Chargement incomplet"));document.body.appendChild(script);});
document.documentElement.dataset.rcPrivateReady="yes";
}catch(e){const screen=document.querySelector("#rc-access-screen");screen.hidden=false;screen.textContent="Application incomplète : recharge avec le réseau. Aucun stockage existant effacé.";}
}
function ready(){
if(trusted()){enter();return;}
const screen=document.querySelector("#rc-access-screen");
screen.innerHTML='<section class="mod"><h2>Choisir l’accès</h2><p>Sur un ordinateur professionnel ou partagé, consulte les cours publics sans connexion, sans progression personnelle et sans phrase secrète.</p><p><a class="btn pri" href="./courses-public.html">Consulter les cours sans connexion</a></p><label><input type="checkbox" id="rc-private-confirm"> Cet appareil est personnel et sous mon contrôle.</label><p><button class="btn" id="rc-private-enter">Ouvrir ma progression personnelle</button></p><p id="rc-access-message" role="status"></p></section>';
if(returned)document.querySelector("#rc-access-message").textContent="Retour de connexion reçu sans contexte personnel autorisé. Recommence depuis ton appareil personnel.";
document.querySelector("#rc-private-enter").onclick=()=>{if(!document.querySelector("#rc-private-confirm").checked){document.querySelector("#rc-access-message").textContent="Confirme que cet appareil est personnel, ou utilise les cours publics.";return;}enter();};
}
document.addEventListener("DOMContentLoaded",ready);
return Object.freeze({takeOAuthReturn:()=>{const value=returned;returned=null;return value;},publicURL:()=>new URL("./courses-public.html",location.href).href});
})();
34 changes: 30 additions & 4 deletions cloud-transport.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,31 @@ const RC_CLOUD_TRANSPORT = (() => {
function row(value){if(!value||!validRevision(value.revision)||!value.payload||typeof value.payload!=="object")throw Error("Réponse cloud invalide");bounded(value.payload);return value;}
function create(cfg,options={}){
const base=config(cfg.url,cfg.key);let session=null,refreshing=null,generation=0,sessionNeedsSave=false,saving=null;
const expectedUser=options.expectedUser||cfg.owner||null,oauthKey="reconversion-control.oauth.pending.v1:"+base.url;let oauthGeneration=0;
if(expectedUser!==null&&!/^[0-9a-f-]{36}$/i.test(expectedUser))throw Error("Compte attendu invalide");
function oauthStore(){const s=options.oauthStorage||(typeof sessionStorage!=="undefined"?sessionStorage:null);if(!s)throw Error("Stockage temporaire indisponible : connexion Google refusée");return s;}
function callbackURL(value){const u=new URL(value);if(u.search||u.hash||u.username||u.password)throw Error("Retour OAuth invalide");if(u.href!=="https://ipoower.github.io/Reconversion-Control/"&&!(u.protocol==="http:"&&["127.0.0.1","localhost"].includes(u.hostname)&&!u.username&&!u.password))throw Error("Retour OAuth non autorisé");return u.href;}
async function beginOAuth(callback){
const ticket=generation,attempt=++oauthGeneration;
const target=callbackURL(callback),s=oauthStore(),verifier=b64(crypto.getRandomValues(new Uint8Array(32))).replace(/\+/g,"-").replace(/\//g,"_").replace(/=+$/,"");
const challenge=b64(new Uint8Array(await crypto.subtle.digest("SHA-256",te.encode(verifier)))).replace(/\+/g,"-").replace(/\//g,"_").replace(/=+$/,"");
if(ticket!==generation||attempt!==oauthGeneration)throw Error("Tentative Google annulée");
const raw=JSON.stringify({v:1,project:base.url,provider:"google",callback:target,created:Date.now(),verifier});s.setItem(oauthKey,raw);if(s.getItem(oauthKey)!==raw)throw Error("Préparation OAuth non confirmée");
const u=new URL(base.url+"/auth/v1/authorize");u.searchParams.set("provider","google");u.searchParams.set("redirect_to",target);u.searchParams.set("scopes","openid email profile");u.searchParams.set("code_challenge",challenge);u.searchParams.set("code_challenge_method","s256");u.searchParams.set("prompt","select_account");return u.href;
}
function cancelOAuth(){oauthGeneration++;try{const s=oauthStore();s.removeItem(oauthKey);if(s.getItem(oauthKey)!==null)throw Error("Suppression OAuth non confirmée");}catch(e){throw Error("Suppression de la tentative Google non confirmée");}}
async function completeOAuth(code,callback){
const ticket=generation,s=oauthStore(),raw=s.getItem(oauthKey);cancelOAuth();
if(!raw||raw.length>4096||typeof code!=="string"||!/^[-a-zA-Z0-9_]{16,1024}$/.test(code))throw Error("Retour Google non sollicité ou invalide");
let p;try{p=JSON.parse(raw);}catch(e){throw Error("Tentative Google illisible");}
if(!p||p.v!==1||p.project!==base.url||p.provider!=="google"||p.callback!==callbackURL(callback)||!Number.isFinite(p.created)||Date.now()<p.created||Date.now()-p.created>300000||typeof p.verifier!=="string"||!/^[-A-Za-z0-9_]{43}$/.test(p.verifier))throw Error("Tentative Google absente, expirée ou incompatible");
const result=await request("/auth/v1/token?grant_type=pkce",{method:"POST",body:{auth_code:code,code_verifier:p.verifier}});
if(!result||typeof result.access_token!=="string"||result.access_token.length>16384||!result.user)throw Error("Session Google invalide");
const verified=await request("/auth/v1/user",{headers:{Authorization:"Bearer "+result.access_token}});
if(ticket!==generation||!verified||verified.id!==result.user.id||verified.is_anonymous!==false||!Array.isArray(verified.identities)||!verified.identities.some(i=>i&&i.provider==="google"))throw Error("Identité Google non confirmée");
const id=await accept({...result,user:verified},ticket);
return {id,email:typeof verified.email==="string"?verified.email:"",provider:"google"};
}
async function request(path,init={}){
const headers={apikey:base.key,"Content-Type":"application/json",...(init.headers||{})};
if(init.secure){const ticket=generation,token=await access();if(ticket!==generation)throw Error("Session annulée");headers.Authorization="Bearer "+token;}
Expand All @@ -46,7 +71,7 @@ const RC_CLOUD_TRANSPORT = (() => {
if(ticket!==generation)throw Error("Session annulée");
if(!j||!j.user||j.user.is_anonymous===true)throw Error("Authentification non confirmée");
const next={project:base.url,access_token:j.access_token,refresh_token:j.refresh_token,expires_at:Date.now()+Number(j.expires_in||3600)*1000,user:j.user.id};
if(!validSession(next)||session&&session.user!==next.user)throw Error("Authentification non confirmée");
if(!validSession(next)||expectedUser&&next.user!==expectedUser||session&&session.user!==next.user)throw Error("Authentification non confirmée : compte inattendu");
session=next;sessionNeedsSave=true;await persistSession(ticket);
if(ticket!==generation)throw Error("Session annulée");
return session.user;
Expand All @@ -66,7 +91,7 @@ const RC_CLOUD_TRANSPORT = (() => {
return refreshing;
}
async function resume(saved){
if(!validSession(saved))throw Error("Session mémorisée incompatible");session={...saved};const ticket=generation;
if(!validSession(saved)||expectedUser&&saved.user!==expectedUser)throw Error("Session mémorisée incompatible");session={...saved};const ticket=generation;
const user=await request("/auth/v1/user",{secure:true});
if(ticket!==generation||!session||!user||user.id!==session.user||user.is_anonymous===true){session=null;throw Error("Session non confirmée");}return session.user;
}
Expand Down Expand Up @@ -107,8 +132,9 @@ const RC_CLOUD_TRANSPORT = (() => {
const rows=await request("/rest/v1/reconversion_progress_history?user_id=eq."+encodeURIComponent(session.user)+"&revision=eq."+revision+"&select=revision,payload&limit=1",{secure:true});
if(!Array.isArray(rows)||rows.length!==1||rows[0].revision!==revision)throw Error("Version historique absente");return row(rows[0]);
}
async function logout(){const previous=session;generation++;session=null;refreshing=null;if(previous){try{await request("/auth/v1/logout?scope=local",{method:"POST",headers:{Authorization:"Bearer "+previous.access_token}});return true;}catch(e){return false;}}return true;}
return {sendCode,verifyCode,resume,read,readRevision,write,history,historyVersion,logout};
async function rememberSession(){if(!session)throw Error("Non connecté");sessionNeedsSave=true;await persistSession(generation);}
async function logout(){const previous=session;generation++;session=null;refreshing=null;try{cancelOAuth();}catch(e){}if(previous){try{await request("/auth/v1/logout?scope=local",{method:"POST",headers:{Authorization:"Bearer "+previous.access_token}});return true;}catch(e){return false;}}return true;}
return {sendCode,verifyCode,beginOAuth,completeOAuth,cancelOAuth,rememberSession,resume,read,readRevision,write,history,historyVersion,logout};
}
async function encrypt(snapshot,phrase,context){
if(typeof phrase!=="string"||phrase.length<12||phrase.length>1024)throw Error("Phrase de chiffrement : 12 à 1024 caractères");
Expand Down
Loading
Loading