discovery-media-player 0.1.26 → 0.1.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -457,6 +457,20 @@ function createStandaloneContext(env = process.env) {
457
457
  supabasePublishableKey: env.SUPABASE_PUBLISHABLE_KEY || "",
458
458
  mapsKey: env.GOOGLE_MAPS_API_KEY || "",
459
459
  extraFrameAncestors: String(env.DOC_FRAME_ANCESTORS || "").split(/\s+/).filter(Boolean),
460
+
461
+ /**
462
+ * Clé de `localStorage` sous laquelle VOTRE application range la session de ses membres.
463
+ *
464
+ * ⚠️ Vide par défaut, donc la détection des membres est inactive : aucun membre reconnu,
465
+ * aucune usurpation possible. Ce paquet portait `3dd-supabase-auth` en dur — la clé d'un seul
466
+ * hôte, qui ne trouvait rien chez tous les autres, et qui depuis 0.1.25 porte une propriété
467
+ * de sécurité : c'est par elle que l'appartenance se prouve.
468
+ *
469
+ * ⚠️ Et ça ne marche QUE si votre application et le player partagent la même origine. Deux
470
+ * sous-domaines, ce sont deux `localStorage` : aucune valeur ici n'y changera rien. C'est une
471
+ * transition — le bon mécanisme est que l'hôte injecte son membre au rendu de la page.
472
+ */
473
+ hostAuthStorageKey: String(env.PLAYER_HOST_AUTH_STORAGE_KEY || "").trim(),
460
474
  // ⚠️ Un BOOLÉEN, jamais l'URL. La carte d'identité doit pouvoir dire « un émetteur distinct
461
475
  // est configuré » sans dire lequel : c'est le dernier endroit où un hôte devait deviner.
462
476
  // `host-auth` dit que le code SAIT faire la séparation ; ceci dit qu'elle est POSÉE. Sans
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "discovery-media-player",
3
- "version": "0.1.26",
3
+ "version": "0.1.28",
4
4
  "description": "Self-hosted document viewer: per-recipient tracked links, reading analytics, live presentation. The core knows nothing about the application hosting it — everything it borrows arrives through an injected context.",
5
5
  "keywords": [
6
6
  "pdf-viewer",
package/server/handler.js CHANGED
@@ -6,7 +6,7 @@
6
6
  const crypto = require("crypto");
7
7
  const { getShareBySlug, logView, upsertSession, createReshare, sendReshareEmail, upsertInternalSession,
8
8
  createShare, revokeShare, setShareAuth, overview: docOverview, listSharesForDoc, listSessionsForDoc, internalStatsForDoc } = require("./shares");
9
- const { createPresentation, getPresentation, setPage, endPresentation, addMessage, listMessages, toggleReaction, editMessage, deleteMessage, setChatLock, createUploadUrl, reclaimPresentation, touchPresentation, listActivePresentations, handoverPresentation, endPresentationByOwner, recordAttendance, presenterKey, presentationStats, listPresentationsForDoc, switchPresentationDoc, setPresentationContent } = require("./presentations");
9
+ const { createPresentation, getPresentation, setPage, endPresentation, addMessage, listMessages, toggleReaction, editMessage, deleteMessage, setChatLock, createUploadUrl, reclaimPresentation, touchPresentation, listActivePresentations, handoverPresentation, endPresentationByOwner, recordAttendance, presentationStats, listPresentationsForDoc, switchPresentationDoc, setPresentationContent } = require("./presentations");
10
10
  // CONTEXTE INJECTÉ : tout ce que le player emprunte à l'application hôte passe par ici — stockage,
11
11
  // base, identité, limites, marque, journalisation — et rien d'autre. C'est la frontière qui permettra
12
12
  // de brancher un second projet, puis d'ouvrir le cœur. Cf. api/_player-context.js.
@@ -119,6 +119,7 @@ const LIVE_CSS = `
119
119
  .pres-item{display:flex;align-items:center;gap:9px;padding:6px 8px;border-radius:8px}
120
120
  .pres-item .a,.cm .a{width:28px;height:28px;border-radius:50%;flex:none;background:#e6e2db;color:#555;font-size:11px;font-weight:700;display:inline-flex;align-items:center;justify-content:center;overflow:hidden}
121
121
  .pres-item .a img,.cm .a img{width:100%;height:100%;object-fit:cover}
122
+ .pres-by{font-size:12px;opacity:.72;margin:0 0 8px;padding:0 2px}
122
123
  .pres-item .n{font-size:13px;font-weight:600}
123
124
  .pres-item .e{font-size:11px;color:#888}
124
125
  .tag{font-size:9.5px;font-weight:800;color:#e5384d;text-transform:uppercase;letter-spacing:.02em}
@@ -233,7 +234,7 @@ const LIVE_PANEL = `<div class="chat hidden" id=chatPanel><div class=chat-grip i
233
234
  // JS partagé : présence + chat via Supabase Realtime. Live.connect(slug, me) / Live.disconnect().
234
235
  const LIVE_JS = `
235
236
  var Live=(function(){
236
- var sb=null,ch=null,ME=null,SLUG=null,CONTROL=null,LOCKED=false,AUTHTOK=null,PRESENT=[],PRESKEY='',seen={},msgEls={},msgData={},replyCtx=null,typers={},pdfCache={},_tyT=0,_tyIv=0,_atIv=0,unread=0,autoOpened=false,_histDone=false,_phWired=false,_onMap=null,_onState=null,_peekT=0,MUTED=false;
237
+ var sb=null,ch=null,ME=null,SLUG=null,CONTROL=null,LOCKED=false,AUTHTOK=null,PRESENT=[],PRESNAME='',seen={},msgEls={},msgData={},replyCtx=null,typers={},pdfCache={},_tyT=0,_tyIv=0,_atIv=0,unread=0,autoOpened=false,_histDone=false,_phWired=false,_onMap=null,_onState=null,_peekT=0,MUTED=false;
237
238
  try{ MUTED=localStorage.getItem('3dd-present-mute')==='1'; }catch(e){}
238
239
  // Couper/rétablir les notifications du chat (cloche) : coupé = plus de ticker ni de pulse (badge silencieux gardé).
239
240
  function applyMute(){ var b=document.getElementById('chatMute'); if(b){b.classList.toggle('muted',MUTED);b.title=MUTED?'Réactiver les notifications du chat':'Couper les notifications du chat';} setBadge(); }
@@ -298,7 +299,7 @@ var Live=(function(){
298
299
  function isMentioned(m){return Player.live.isMentioned(m,ME);}
299
300
  function renderPres(st){var l=flat(st),c=l.length;PRESENT=l;var e=document.getElementById('presCount');if(e)e.textContent=c;
300
301
  var a=document.getElementById('presAvs');if(a)a.innerHTML=l.slice(0,4).map(function(m){return '<span class=pres-av>'+av(m.avatar,m.name)+'</span>';}).join('');
301
- var p=document.getElementById('presList');if(p)p.innerHTML='<h5>'+c+' en ligne</h5>'+l.map(function(m){return '<div class=pres-item><span class=a>'+av(m.avatar,m.name)+'</span><span class=b><div class=n>'+esc(m.name||'Invité')+(m.uid&&m.uid===PRESKEY?' <span class=tag>présentateur</span>':'')+'</div>'+(m.email?'<div class=e>'+esc(m.email)+'</div>':'')+'</span></div>';}).join('');}
302
+ var p=document.getElementById('presList');if(p)p.innerHTML='<h5>'+c+' en ligne</h5>'+(PRESNAME?'<div class=pres-by>Présenté par '+esc(PRESNAME)+'</div>':'')+l.map(function(m){return '<div class=pres-item><span class=a>'+av(m.avatar,m.name)+'</span><span class=b><div class=n>'+esc(m.name||'Invité')+'</div>'+(m.email?'<div class=e>'+esc(m.email)+'</div>':'')+'</span></div>';}).join('');}
302
303
  // Rendu d'un message → player/src/chat.ts (échappement testé sous jsdom : on y vérifie ce que
303
304
  // le NAVIGATEUR fabrique, pas seulement la chaîne produite).
304
305
  function renderRe(m){return Player.chat.renderReactions(m,ME);}
@@ -415,7 +416,13 @@ var Live=(function(){
415
416
  var pb=document.getElementById('presBtn');if(pb)pb.style.display='inline-flex';
416
417
  var cb=document.getElementById('chatBtn');if(cb)cb.style.display='inline-flex';var _fb=document.getElementById('chatFab');if(_fb)_fb.classList.add('on');
417
418
  wire();history();
418
- try{sb=window.supabase.createClient(LIVECFG.supaUrl,LIVECFG.supaKey,{realtime:{params:{eventsPerSecond:10}}});
419
+ try{sb=window.supabase.createClient(LIVECFG.supaUrl,LIVECFG.supaKey,{realtime:{params:{eventsPerSecond:10}},
420
+ // ⚠️ UNE CLÉ DÉCLARÉE, PAS CELLE PAR DÉFAUT. Ce client vivra un jour une session anonyme
421
+ // (canal Realtime prive). S'il ecrit sous la cle par defaut et que l'application de l'hote
422
+ // l'utilise aussi sur la meme origine, la session anonyme ECRASE celle du membre connecte.
423
+ // Chez nous les deux cles different deja, mais par heureux hasard : le declarer rend
424
+ // intentionnel ce qui n'etait qu'une consequence, et une topologie peut changer.
425
+ auth:{storageKey:LIVECFG.liveAuthKey||'dmp-live-auth',persistSession:true,autoRefreshToken:true}});
419
426
  ch=sb.channel('plive-'+slug,{config:{presence:{key:(me.email||me.name||'x')+':'+MYID}}});
420
427
  ch.on('presence',{event:'sync'},function(){renderPres(ch.presenceState());});
421
428
  // Plus d'abonnement à la table des messages : elle n'est plus publiée ni lisible
@@ -478,8 +485,8 @@ var Live=(function(){
478
485
  // Le serveur renvoie la CLÉ de celui qui a prouvé le control_token ; l'audience compare. Pas
479
486
  // de clé, pas de titre : mieux vaut aucun titre qu'un titre usurpé.
480
487
  function etatDuServeur(st){
481
- if(typeof st.presenter_key!=='undefined'){var k=st.presenter_key||'';
482
- if(k!==PRESKEY){PRESKEY=k;try{if(ch)renderPres(ch.presenceState());}catch(e){}}}
488
+ if(typeof st.presenter_name!=='undefined'){var n2=st.presenter_name||'';
489
+ if(n2!==PRESNAME){PRESNAME=n2;try{if(ch)renderPres(ch.presenceState());}catch(e){}}}
483
490
  if(_onState)_onState(st);
484
491
  }
485
492
  relire('&state=1',function(d){if(d.state)etatDuServeur(d.state);});
@@ -503,9 +510,9 @@ var Live=(function(){
503
510
  // Membre de l'équipe reconnu via la session app (MÊME ORIGINE, localStorage) → avatar + nom auto.
504
511
  // Le jeton d'acces de la session locale, quand il y en a une. C'est la SEULE chose qui prouve au
505
512
  // serveur qu'on est un membre ; 'member:true' dans la page ne prouve rien, il ne sert qu'a l'affichage.
506
- function accessToken(){try{var raw=localStorage.getItem('3dd-supabase-auth');if(!raw)return '';var s=JSON.parse(raw);
513
+ function accessToken(){try{var raw=localStorage.getItem(LIVECFG.hostAuthKey||'');if(!raw)return '';var s=JSON.parse(raw);
507
514
  return String((s&&(s.access_token||(s.currentSession&&s.currentSession.access_token)||(s.session&&s.session.access_token)))||'');}catch(e){return '';}}
508
- function detectMember(){try{var raw=localStorage.getItem('3dd-supabase-auth');if(!raw)return null;var s=JSON.parse(raw);var u=s&&(s.user||(s.currentSession&&s.currentSession.user)||(s.session&&s.session.user));if(u&&u.email){var m=u.user_metadata||{};return{name:m.name||u.email,email:u.email,avatar:m.avatarUrl||'',member:true,role:'viewer'};}}catch(e){}return null;}
515
+ function detectMember(){try{var raw=localStorage.getItem(LIVECFG.hostAuthKey||'');if(!raw)return null;var s=JSON.parse(raw);var u=s&&(s.user||(s.currentSession&&s.currentSession.user)||(s.session&&s.session.user));if(u&&u.email){var m=u.user_metadata||{};return{name:m.name||u.email,email:u.email,avatar:m.avatarUrl||'',member:true,role:'viewer'};}}catch(e){}return null;}
509
516
  return {connect:connect,disconnect:disconnect,detectMember:detectMember,sendMap:sendMap,onMap:onMap,sendState:sendState,onState:onState};
510
517
  })();`;
511
518
 
@@ -1243,6 +1250,34 @@ function adresseAppelant(req) {
1243
1250
  * Le jeton d'accès, lui, est vérifié par l'hôte. Absent ou invalide : pas membre. Jamais de repli
1244
1251
  * sur ce que l'appelant affirme, sinon la vérification ne sert qu'aux honnêtes.
1245
1252
  */
1253
+ // ── Clés de `localStorage`, et pourquoi ce ne sont plus des constantes ─────────────────────────
1254
+ //
1255
+ // ⚠️ `3dd-supabase-auth` — la clé de session du studio 3D Discovery — était écrite EN DUR à cinq
1256
+ // endroits de ce paquet open source. Conséquence pour tout autre hôte : `detectMember()` et
1257
+ // `accessToken()` ne trouvent jamais rien, donc AUCUN de ses membres n'est reconnu comme tel. La
1258
+ // séparation des populations interne/externe, que ce produit vend, ne fonctionnait que chez nous.
1259
+ //
1260
+ // ⚠️ Et depuis 0.1.25 cette clé porte une propriété de sécurité : c'est par elle que l'appartenance
1261
+ // se prouve. Une constante d'un hôte devenue porteuse pour tous les autres.
1262
+ //
1263
+ // Défaut VIDE, et c'est délibéré : sans clé déclarée, la détection est simplement inactive — aucun
1264
+ // membre, donc aucune usurpation. Un défaut à `3dd-supabase-auth` aurait gardé notre instance en
1265
+ // marche en laissant le défaut de conception intact, et le prochain hôte l'aurait découvert comme
1266
+ // ADV : en constatant que ses statistiques ne séparent rien.
1267
+ //
1268
+ // ⚠️ CE N'EST QU'UNE TRANSITION. Lire le `localStorage` d'une autre application ne peut PAS marcher
1269
+ // quand les origines diffèrent — l'instance ADV est sur `doc.adnfamily.com` et son application sur
1270
+ // `app.adnfamily.com` : deux `localStorage`, aucune configuration n'y changera rien. Le bon
1271
+ // mécanisme est que l'hôte INJECTE son membre au rendu de la page, comme il injecte déjà sa marque.
1272
+ // Tracé dans docs/AUDIT-2026-08-14-SUIVI.md.
1273
+ function cleSessionHote() {
1274
+ return String((PLAYER.config && PLAYER.config.hostAuthStorageKey) || "");
1275
+ }
1276
+ // La session du client Realtime du player, et l'identité d'un invité : à lui, sous son nom. Elles
1277
+ // ne dépendent d'aucun hôte, donc elles restent des constantes — mais plus des constantes d'AUTRUI.
1278
+ const CLE_SESSION_PLAYER = "dmp-live-auth";
1279
+ const CLE_INVITE = "dmp-present-me";
1280
+
1246
1281
  async function membreVerifie(req) {
1247
1282
  try {
1248
1283
  const u = await PLAYER.identity.verifyToken((req.headers && req.headers.authorization) || "");
@@ -1796,7 +1831,7 @@ ${LEGAL_CSS}
1796
1831
  <script nonce="${nonce}">${PLAYER_BROWSER_JS}</script>
1797
1832
  <script nonce="${nonce}" src="${PDFJS}/pdf.min.js"></script>
1798
1833
  ${preview ? `<script nonce="${nonce}" src="${SUPAJS}"></script>
1799
- <script nonce="${nonce}">var LIVECFG={supaUrl:${JSON.stringify(share.supa_url || "")},supaKey:${JSON.stringify(share.supa_key || "")}};var GMAPS_KEY=${JSON.stringify((PLAYER.config && PLAYER.config.mapsKey) || "" || "")};${LIVE_JS}
1834
+ <script nonce="${nonce}">var LIVECFG={supaUrl:${JSON.stringify(share.supa_url || "")},supaKey:${JSON.stringify(share.supa_key || "")},hostAuthKey:${JSON.stringify(cleSessionHote())},liveAuthKey:${JSON.stringify(CLE_SESSION_PLAYER)},guestKey:${JSON.stringify(CLE_INVITE)}};var GMAPS_KEY=${JSON.stringify((PLAYER.config && PLAYER.config.mapsKey) || "" || "")};${LIVE_JS}
1800
1835
  ${MAP_JS}</script>` : ""}
1801
1836
  <script nonce="${nonce}">
1802
1837
  (function(){
@@ -1871,7 +1906,7 @@ ${LEGAL_CSS}
1871
1906
  var PRES=null, _pushT=null, _hbIv=0;
1872
1907
  // JWT de la session app (MÊME ORIGINE, localStorage) → autorise le rattachement de la présentation au membre
1873
1908
  // (reprise / liste / transfert) et les actions authentifiées (reclaim).
1874
- function appToken(){ try{ var raw=localStorage.getItem('3dd-supabase-auth'); if(!raw)return''; var s=JSON.parse(raw); var t=(s&&(s.access_token||(s.currentSession&&s.currentSession.access_token)||(s.session&&s.session.access_token)))||''; return t; }catch(e){ return ''; } }
1909
+ function appToken(){ try{ var raw=localStorage.getItem(LIVECFG.hostAuthKey||''); if(!raw)return''; var s=JSON.parse(raw); var t=(s&&(s.access_token||(s.currentSession&&s.currentSession.access_token)||(s.session&&s.session.access_token)))||''; return t; }catch(e){ return ''; } }
1875
1910
  function ctlKey(slug){ return '3dd-pres-ctl-'+slug; }
1876
1911
  function saveCtl(slug,control){ try{ localStorage.setItem(ctlKey(slug),control); }catch(e){} }
1877
1912
  function clearCtl(slug){ try{ localStorage.removeItem(ctlKey(slug)); }catch(e){} }
@@ -2314,7 +2349,7 @@ function presentHtml(pres, nonce, logoUrl, supaUrl, supaKey) {
2314
2349
  })();
2315
2350
  </script>
2316
2351
  <script nonce="${nonce}">
2317
- var LIVECFG={supaUrl:${JSON.stringify(supaUrl || "")},supaKey:${JSON.stringify(supaKey || "")}};var GMAPS_KEY=${JSON.stringify((PLAYER.config && PLAYER.config.mapsKey) || "" || "")};
2352
+ var LIVECFG={supaUrl:${JSON.stringify(supaUrl || "")},supaKey:${JSON.stringify(supaKey || "")},hostAuthKey:${JSON.stringify(cleSessionHote())},liveAuthKey:${JSON.stringify(CLE_SESSION_PLAYER)},guestKey:${JSON.stringify(CLE_INVITE)}};var GMAPS_KEY=${JSON.stringify((PLAYER.config && PLAYER.config.mapsKey) || "" || "")};
2318
2353
  ${LIVE_JS}
2319
2354
  ${MAP_JS}
2320
2355
  (function(){
@@ -2337,19 +2372,19 @@ function presentHtml(pres, nonce, logoUrl, supaUrl, supaKey) {
2337
2372
  // « Reprendre la main » : si le membre connecté (même origine) devient propriétaire de CETTE présentation
2338
2373
  // (après un transfert), on affiche un bouton pour ouvrir la visionneuse en pilotage — sinon on ne peut pas
2339
2374
  // piloter depuis la page audience.
2340
- function appTok(){ try{ var raw=localStorage.getItem('3dd-supabase-auth'); if(!raw)return''; var s=JSON.parse(raw); return (s&&(s.access_token||(s.currentSession&&s.currentSession.access_token)||(s.session&&s.session.access_token)))||''; }catch(e){return'';} }
2375
+ function appTok(){ try{ var raw=localStorage.getItem(LIVECFG.hostAuthKey||''); if(!raw)return''; var s=JSON.parse(raw); return (s&&(s.access_token||(s.currentSession&&s.currentSession.access_token)||(s.session&&s.session.access_token)))||''; }catch(e){return'';} }
2341
2376
  function checkOwner(){ var tk=appTok(); if(!tk)return; fetch('/api/doc',{method:'POST',headers:{'Content-Type':'application/json','Authorization':'Bearer '+tk},body:JSON.stringify({action:'present-list'})}).then(function(r){return r.json();}).then(function(d){ var mine=false; if(d&&d.presentations){ for(var i=0;i<d.presentations.length;i++){ if(d.presentations[i].slug===slug&&d.presentations[i].mine){mine=true;break;} } } var b=document.getElementById('takeOver'); if(b)b.style.display=mine?'inline-flex':'none'; }).catch(function(){}); }
2342
2377
  function startOwnerWatch(){ var b=document.getElementById('takeOver'); if(b&&!b._w){b._w=1;b.addEventListener('click',function(){ location.href='/app/documents?resume='+encodeURIComponent(slug); });} checkOwner(); setInterval(checkOwner,12000); }
2343
2378
  var me=Live.detectMember();
2344
2379
  if(me){ Live.connect(slug, me); startOwnerWatch(); return; }
2345
- var saved=null; try{ saved=JSON.parse(localStorage.getItem('3dd-present-me')||'null'); }catch(e){}
2380
+ var saved=null; try{ saved=JSON.parse(localStorage.getItem(LIVECFG.guestKey||'dmp-present-me')||'null'); }catch(e){}
2346
2381
  if(saved&&saved.name){ Live.connect(slug, saved); return; }
2347
2382
  // Externe : on demande le nom pour participer.
2348
2383
  var o=document.createElement('div'); o.className='join';
2349
2384
  o.innerHTML='<div class=join-card><h4>Rejoindre la présentation</h4><p>Votre nom pour participer à la discussion.</p><input id=jName placeholder="Votre nom" maxlength=60 autocomplete=name><input id=jMail placeholder="Email (facultatif)" maxlength=120 autocomplete=email><button id=jGo>Rejoindre</button></div>';
2350
2385
  document.body.appendChild(o);
2351
2386
  var n=o.querySelector('#jName'); try{ n.focus(); }catch(e){}
2352
- function go(){ var name=(n.value||'').trim()||'Invité'; var email=(o.querySelector('#jMail').value||'').trim(); var me2={name:name,email:email,avatar:'',member:false,role:'viewer'}; try{ localStorage.setItem('3dd-present-me',JSON.stringify(me2)); }catch(e){} o.parentNode&&o.parentNode.removeChild(o); Live.connect(slug, me2); }
2387
+ function go(){ var name=(n.value||'').trim()||'Invité'; var email=(o.querySelector('#jMail').value||'').trim(); var me2={name:name,email:email,avatar:'',member:false,role:'viewer'}; try{ localStorage.setItem(LIVECFG.guestKey||'dmp-present-me',JSON.stringify(me2)); }catch(e){} o.parentNode&&o.parentNode.removeChild(o); Live.connect(slug, me2); }
2353
2388
  o.querySelector('#jGo').addEventListener('click',go);
2354
2389
  n.addEventListener('keydown',function(e){ if(e.key==='Enter') go(); });
2355
2390
  })();
@@ -3039,10 +3074,26 @@ async function handler(req, res) {
3039
3074
  file_name: pres.file_name || null,
3040
3075
  doc_title: pres.doc_title || null,
3041
3076
  updated_at: pres.updated_at || null,
3042
- // Qui porte le titre, d'après le serveur. La liste des participants le tirait de la charge
3043
- // de présence, que chacun compose : il suffisait de s'annoncer présentateur pour l'être aux
3044
- // yeux de tous. Une clé, pas un booléen l'audience compare, elle ne croit pas.
3045
- presenter_key: await presenterKey(String(q.present || "")),
3077
+ // ⚠️ CE CHAMP A PORTÉ `presenter_key` PENDANT UNE JOURNÉE, ET C'ÉTAIT DEUX FAUTES.
3078
+ //
3079
+ // 1. FUITE. `attendeeKey()` renvoie l'ADRESSE E-MAIL quand le participant en a une. La clé
3080
+ // du présentateur était donc son e-mail, servi à tout visiteur anonyme du lien — sur la
3081
+ // route même dont le commentaire ci-dessus promet « rien que ce que l'audience doit
3082
+ // savoir ». Le champ portait un nom technique, et personne (moi compris) n'est allé
3083
+ // voir ce qu'il contenait.
3084
+ //
3085
+ // 2. CE N'ÉTAIT PAS UNE PREUVE. Le badge comparait cette clé à l'`uid` de la charge de
3086
+ // présence — que le client COMPOSE. Lire la clé publique puis s'annoncer avec elle
3087
+ // suffisait à porter le titre. On avait remplacé « le client déclare son rôle » par
3088
+ // « le client déclare une valeur que le serveur lui a donnée » : plus laborieux à
3089
+ // exploiter, pas plus vrai.
3090
+ //
3091
+ // ⚠️ Comparer deux valeurs que le client choisit ou connaît n'est pas une preuve. Le nom
3092
+ // du présentateur vient de l'hôte, il ne se compare à rien, et il s'affiche à part de la
3093
+ // liste des participants — laquelle ne porte plus aucun titre.
3094
+ //
3095
+ // Signalé par la seconde passe d'audit (P0-4) ; la fuite n'y était pas.
3096
+ presenter_name: String(pres.presenter_name || "") || null,
3046
3097
  } }));
3047
3098
  return;
3048
3099
  }
@@ -281,29 +281,6 @@ async function toggleReaction(slug, msgId, emoji, reactor) {
281
281
  return { ok: true, message: premierPublic(maj) };
282
282
  }
283
283
 
284
- /**
285
- * La clé du participant qui porte le titre de présentateur, telle que le SERVEUR la connaît.
286
- *
287
- * ⚠️ Elle existe parce que la liste des participants affichait le titre d'après la charge de
288
- * PRÉSENCE, que chacun compose lui-même : `track({role:'presenter'})` suffisait à apparaître comme
289
- * présentateur devant toute l'audience, avec le nom et l'avatar de son choix. Le canal ne peut pas
290
- * arbitrer ça — un participant légitime a le droit d'y écrire sa présence.
291
- *
292
- * Le titre est désormais accordé ici, à partir de `is_presenter`, lui-même dérivé du `control_token`.
293
- * Null quand personne ne l'a prouvé : mieux vaut aucun titre qu'un titre usurpé.
294
- */
295
- async function presenterKey(slug) {
296
- if (!slug) return null;
297
- // ⚠️ NE JAMAIS FAIRE ÉCHOUER LA RELECTURE D'ÉTAT. Cette recherche est un ajout à une route dont
298
- // l'audience dépend pour savoir quelle page est affichée. Une requête de plus, c'est une raison
299
- // de plus de renvoyer 500 — et perdre TOUT l'état parce qu'on n'a pas su dire qui porte un badge
300
- // serait un très mauvais échange. Sans réponse : pas de clé, donc pas de titre, et le reste passe.
301
- try {
302
- const rows = await PLAYER.db.request(`doc_presentation_attendees?slug=eq.${enc(slug)}&is_presenter=is.true&select=attendee_key&order=last_seen.desc&limit=1`);
303
- return (Array.isArray(rows) && rows[0] && rows[0].attendee_key) || null;
304
- } catch { return null; }
305
- }
306
-
307
284
  // ── Statistiques de présentation (assistance) ────────────────────────────────────────────────────────────
308
285
  // Heartbeat d'un participant : upsert de sa ligne d'assistance. On accumule le temps de présence (intervalles
309
286
  // < 60 s → un aller-retour ne gonfle pas total_ms) et l'ensemble des pages vues (page courante de la présentation).
@@ -410,4 +387,4 @@ async function listPresentationsForDoc(docId) {
410
387
  }
411
388
 
412
389
  module.exports = {
413
- messagePublic, CHAMPS_PUBLICS, init, createPresentation, getPresentation, setPage, endPresentation, addMessage, listMessages, toggleReaction, editMessage, deleteMessage, setChatLock, createUploadUrl, reclaimPresentation, touchPresentation, listActivePresentations, handoverPresentation, endPresentationByOwner, recordAttendance, presenterKey, presentationStats, listPresentationsForDoc, switchPresentationDoc, setPresentationContent };
390
+ messagePublic, CHAMPS_PUBLICS, init, createPresentation, getPresentation, setPage, endPresentation, addMessage, listMessages, toggleReaction, editMessage, deleteMessage, setChatLock, createUploadUrl, reclaimPresentation, touchPresentation, listActivePresentations, handoverPresentation, endPresentationByOwner, recordAttendance, presentationStats, listPresentationsForDoc, switchPresentationDoc, setPresentationContent };