diff --git a/internal/ajean/migrate_07.go b/internal/ajean/migrate_07.go index 777fa9e..b4d28ab 100644 --- a/internal/ajean/migrate_07.go +++ b/internal/ajean/migrate_07.go @@ -26,6 +26,7 @@ import ( "os/exec" "path/filepath" "runtime" + "sort" "strings" ) @@ -263,6 +264,27 @@ func importLegacyState(home string) error { } } } + // .authorized_users : les identités appairées pour le chat chiffré de bout en + // bout, une clé publique par ligne. L'oublier coûte cher et de façon peu + // lisible : le tunnel s'établit, la machine s'affiche en ligne, mais le + // portail refuse toute conversation chiffrée — il faut ré-appairer sans + // comprendre pourquoi. Vécu sur le serveur de test. + if v := read(".authorized_users"); v != "" { + var list []string + for _, line := range strings.Split(v, "\n") { + if h := strings.ToLower(strings.TrimSpace(line)); h != "" { + list = append(list, h) + } + } + if len(list) > 0 { + sort.Strings(list) + _ = putJSON(bkState, "authorized_users", list) + fmt.Printf(" %s %d identité(s) appairée(s)\n", green("✓"), len(list)) + } + } + // .pair_codes n'est PAS repris : ces codes expirent au bout de 10 minutes, + // les reprendre n'aurait aucun sens. + // mcp.json : on ne garde que la map interne, la clé « mcpServers » disparaît. if v := read("mcp.json"); v != "" { var f struct { diff --git a/internal/ajean/migrate_07_test.go b/internal/ajean/migrate_07_test.go index f36a115..c05d13f 100644 --- a/internal/ajean/migrate_07_test.go +++ b/internal/ajean/migrate_07_test.go @@ -4,6 +4,7 @@ import ( "os" "path/filepath" "strings" + "sync" "testing" ) @@ -40,6 +41,7 @@ func fake07Home(t *testing.T) string { write("sysprompt.txt", "Tu es AJEAN.\n") write("conversation.json", `{"seq":42,"messages":[{"role":"user","content":"salut"}]}`) write("SKILLS/vieux-skill/SKILL.md", "# un skill d'avant\n") + write(".authorized_users", "AABBCCDDEEFF00112233445566778899AABBCCDDEEFF001122334455667788AA\n") return home } @@ -101,6 +103,14 @@ func TestMigration07(t *testing.T) { if conv.Seq != 42 { t.Errorf("conversation perdue (seq %d)", conv.Seq) } + // Les identités appairées : les perdre casse le chat chiffré du portail + // sans que rien ne le dise (le tunnel, lui, s'établit normalement). + // La liste est mise en cache une fois par process (authOnce) : en production + // c'est un process neuf qui sert après l'install, ici il faut le dire. + authOnce, authSet = sync.Once{}, map[string]bool{} + if !isAuthorizedUser("aabbccddeeff00112233445566778899aabbccddeeff001122334455667788aa") { + t.Error("identité appairée perdue : le chat E2E du portail serait refusé") + } // Les fichiers consommés sont rangés, pas détruits. for _, archive := range []string{"config.env", "SKILLS"} { diff --git a/internal/ajean/ui/index.html b/internal/ajean/ui/index.html index 634cd6f..79ef7aa 100644 --- a/internal/ajean/ui/index.html +++ b/internal/ajean/ui/index.html @@ -1912,6 +1912,10 @@ function _openAsk(kind, message, opts){ // où cette fonction remplace le window.askConfirm du bootstrap boîte noire). if(message && typeof message==='object'){ const o=message; opts={title:o.title, okText:o.yes, cancelText:o.no, placeholder:o.placeholder, default:o.default, danger:o.danger}; message=o.msg||''; } opts=opts||{}; + // Une modale déjà ouverte ne doit pas être écrasée en silence : son appelant + // attend une réponse, et l'écraser laissait sa promesse pendante POUR + // TOUJOURS (requête suspendue, bouton figé). On l'annule proprement d'abord. + if(_askResolver){ const prev=_askResolver; _askResolver=null; prev(_askKind==='prompt' ? null : false); } _askKind=kind; document.getElementById('ask-title').textContent = opts.title || (kind==='alert'?'Info':kind==='prompt'?'Saisie':'Confirmation'); document.getElementById('ask-msg').textContent = message||''; @@ -1969,11 +1973,31 @@ async function jfetch(u, opts){ } finally{ if(timer) clearTimeout(timer); } if(r.status === 401){ - const k = await askPrompt('Clé de pilotage ajean requise :', {title:'Authentification', placeholder:'clé…'}); - if(k){ TOKEN = k.trim(); localStorage.setItem('ajean.key', TOKEN); opts.headers = authHeaders(opts.headers); r = await fetch(u, opts); } + const k = await askKeyOnce(); + if(k){ opts.headers = authHeaders(opts.headers); r = await fetch(u, opts); } } return r; } +// askKeyOnce : UNE seule demande de clé à la fois, partagée par tous les appels. +// +// Le chargement de l'UI lance une dizaine d'appels /api/* en parallèle, et le +// rafraîchissement périodique en relance autant. Sans ce partage, CHAQUE 401 +// rouvrait la modale, laquelle vide son champ à l'ouverture : impossible de +// taper la clé, les caractères disparaissaient au fur et à mesure. Les appels +// concurrents attendent désormais la même promesse, et repartent tous avec la +// clé saisie une seule fois. +let _keyAsk = null; +function askKeyOnce(){ + if(!_keyAsk){ + _keyAsk = askPrompt('Clé de pilotage AJEAN requise :', {title:'Authentification', placeholder:'clé…'}) + .then(k => { + _keyAsk = null; + if(k){ TOKEN = k.trim(); localStorage.setItem('ajean.key', TOKEN); } + return k; + }, e => { _keyAsk = null; throw e; }); + } + return _keyAsk; +} async function jget(u){ const r=await jfetch(u); return r.json(); } async function jpost(u,b){ const r=await jfetch(u,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(b||{})}); return r.json(); } async function loadStatus(){ diff --git a/internal/ajean/ui/src/js/03-ui-kit.js b/internal/ajean/ui/src/js/03-ui-kit.js index 6c3560a..d17cc73 100644 --- a/internal/ajean/ui/src/js/03-ui-kit.js +++ b/internal/ajean/ui/src/js/03-ui-kit.js @@ -54,6 +54,10 @@ function _openAsk(kind, message, opts){ // où cette fonction remplace le window.askConfirm du bootstrap boîte noire). if(message && typeof message==='object'){ const o=message; opts={title:o.title, okText:o.yes, cancelText:o.no, placeholder:o.placeholder, default:o.default, danger:o.danger}; message=o.msg||''; } opts=opts||{}; + // Une modale déjà ouverte ne doit pas être écrasée en silence : son appelant + // attend une réponse, et l'écraser laissait sa promesse pendante POUR + // TOUJOURS (requête suspendue, bouton figé). On l'annule proprement d'abord. + if(_askResolver){ const prev=_askResolver; _askResolver=null; prev(_askKind==='prompt' ? null : false); } _askKind=kind; document.getElementById('ask-title').textContent = opts.title || (kind==='alert'?'Info':kind==='prompt'?'Saisie':'Confirmation'); document.getElementById('ask-msg').textContent = message||''; diff --git a/internal/ajean/ui/src/js/04-api.js b/internal/ajean/ui/src/js/04-api.js index 6b3dcbb..5afc573 100644 --- a/internal/ajean/ui/src/js/04-api.js +++ b/internal/ajean/ui/src/js/04-api.js @@ -30,10 +30,30 @@ async function jfetch(u, opts){ } finally{ if(timer) clearTimeout(timer); } if(r.status === 401){ - const k = await askPrompt('Clé de pilotage ajean requise :', {title:'Authentification', placeholder:'clé…'}); - if(k){ TOKEN = k.trim(); localStorage.setItem('ajean.key', TOKEN); opts.headers = authHeaders(opts.headers); r = await fetch(u, opts); } + const k = await askKeyOnce(); + if(k){ opts.headers = authHeaders(opts.headers); r = await fetch(u, opts); } } return r; } +// askKeyOnce : UNE seule demande de clé à la fois, partagée par tous les appels. +// +// Le chargement de l'UI lance une dizaine d'appels /api/* en parallèle, et le +// rafraîchissement périodique en relance autant. Sans ce partage, CHAQUE 401 +// rouvrait la modale, laquelle vide son champ à l'ouverture : impossible de +// taper la clé, les caractères disparaissaient au fur et à mesure. Les appels +// concurrents attendent désormais la même promesse, et repartent tous avec la +// clé saisie une seule fois. +let _keyAsk = null; +function askKeyOnce(){ + if(!_keyAsk){ + _keyAsk = askPrompt('Clé de pilotage AJEAN requise :', {title:'Authentification', placeholder:'clé…'}) + .then(k => { + _keyAsk = null; + if(k){ TOKEN = k.trim(); localStorage.setItem('ajean.key', TOKEN); } + return k; + }, e => { _keyAsk = null; throw e; }); + } + return _keyAsk; +} async function jget(u){ const r=await jfetch(u); return r.json(); } async function jpost(u,b){ const r=await jfetch(u,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(b||{})}); return r.json(); }