discovery-media-player 0.1.24 → 0.1.26

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "discovery-media-player",
3
- "version": "0.1.24",
3
+ "version": "0.1.26",
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, 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, presenterKey, 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.
@@ -233,7 +233,7 @@ const LIVE_PANEL = `<div class="chat hidden" id=chatPanel><div class=chat-grip i
233
233
  // JS partagé : présence + chat via Supabase Realtime. Live.connect(slug, me) / Live.disconnect().
234
234
  const LIVE_JS = `
235
235
  var Live=(function(){
236
- var sb=null,ch=null,ME=null,SLUG=null,CONTROL=null,LOCKED=false,AUTHTOK=null,PRESENT=[],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;
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
237
  try{ MUTED=localStorage.getItem('3dd-present-mute')==='1'; }catch(e){}
238
238
  // Couper/rétablir les notifications du chat (cloche) : coupé = plus de ticker ni de pulse (badge silencieux gardé).
239
239
  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(); }
@@ -276,7 +276,11 @@ var Live=(function(){
276
276
  function attKey(me){ return Player.live.attendeeKey(me,_store(),MYID); }
277
277
  // Heartbeat d'assistance → le serveur journalise qui suit, combien de temps, et les pages vues (via la page
278
278
  // courante de la présentation). Envoyé à la connexion puis toutes les 25 s. Best-effort (silencieux).
279
- function sendAttend(){ if(!SLUG||!ME)return; try{ fetch('/api/doc',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'present-attend',slug:SLUG,key:attKey(ME),name:ME.name||'',email:ME.email||'',avatar:ME.avatar||'',isMember:!!ME.member,isPresenter:ME.role==='presenter'})}); }catch(e){} }
279
+ // Le serveur ne croit plus 'isMember' ni 'isPresenter' sur parole : l'appartenance se prouve par
280
+ // le jeton d'acces de la session, le titre de presentateur par le control_token. Ce qui part d'ici
281
+ // n'est plus qu'une AFFIRMATION, et le serveur la remplace par ce qu'il a verifie.
282
+ function sendAttend(){ if(!SLUG||!ME)return; try{ var h={'Content-Type':'application/json'};var jw=accessToken();if(jw)h.Authorization='Bearer '+jw;
283
+ fetch('/api/doc',{method:'POST',headers:h,body:JSON.stringify({action:'present-attend',slug:SLUG,control:CONTROL,key:attKey(ME),name:ME.name||'',email:ME.email||'',avatar:ME.avatar||''})}); }catch(e){} }
280
284
  var EMOJIS=['👍','❤️','😂','😮','👏','🎉'];
281
285
  var RSVG='<svg viewBox="0 0 24 24" fill=none stroke=currentColor stroke-width=2 stroke-linecap=round><circle cx=12 cy=12 r=9 /><path d="M8.5 14.5s1.4 1.7 3.5 1.7 3.5-1.7 3.5-1.7"/><line x1=9 y1=9.2 x2=9.01 y2=9.2 /><line x1=15 y1=9.2 x2=15.01 y2=9.2 /></svg>';
282
286
  function esc(s){return Player.live.escapeHtml(s);}
@@ -294,7 +298,7 @@ var Live=(function(){
294
298
  function isMentioned(m){return Player.live.isMentioned(m,ME);}
295
299
  function renderPres(st){var l=flat(st),c=l.length;PRESENT=l;var e=document.getElementById('presCount');if(e)e.textContent=c;
296
300
  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('');
297
- 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.role==='presenter'?' <span class=tag>présentateur</span>':'')+'</div>'+(m.email?'<div class=e>'+esc(m.email)+'</div>':'')+'</span></div>';}).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('');}
298
302
  // Rendu d'un message → player/src/chat.ts (échappement testé sous jsdom : on y vérifie ce que
299
303
  // le NAVIGATEUR fabrique, pas seulement la chaîne produite).
300
304
  function renderRe(m){return Player.chat.renderReactions(m,ME);}
@@ -333,10 +337,11 @@ var Live=(function(){
333
337
  function setReply(id){var m=msgData[id];if(!m||m.deleted)return;var nm=m.author_name||'Invité';replyCtx={id:+id,name:nm,text:(m.body||'').slice(0,120)};var el=document.getElementById('chatReply');if(el){el.style.display='flex';el.innerHTML='<span class=cq><b>'+esc(nm)+'</b> '+esc((m.body||'').slice(0,80))+'</span><button id=chatReplyX title=Annuler>×</button>';var x=document.getElementById('chatReplyX');if(x)x.addEventListener('click',clearReply);}var t=document.getElementById('chatText');if(t)t.focus();}
334
338
  function clearReply(){replyCtx=null;var el=document.getElementById('chatReply');if(el){el.style.display='none';el.innerHTML='';}}
335
339
  function send(){var i=document.getElementById('chatText');var t=(i.value||'').trim();if(!t||!ME)return;if(LOCKED&&!canMod())return;i.value='';toggleSend();
336
- var o={action:'present-chat',slug:SLUG,name:ME.name,email:ME.email,avatar:ME.avatar,isPresenter:ME.role==='presenter',isMember:!!ME.member,body:t,authorToken:authToken()};
340
+ var o={action:'present-chat',slug:SLUG,name:ME.name,email:ME.email,avatar:ME.avatar,body:t,authorToken:authToken()};
337
341
  if(CONTROL)o.control=CONTROL;
338
342
  if(replyCtx){o.replyTo=replyCtx.id;o.replyName=replyCtx.name;o.replyText=replyCtx.text;clearReply();}
339
- fetch('/api/doc',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(o)})
343
+ var h1={'Content-Type':'application/json'};var j1=accessToken();if(j1)h1.Authorization='Bearer '+j1;
344
+ fetch('/api/doc',{method:'POST',headers:h1,body:JSON.stringify(o)})
340
345
  .then(function(r){return r.json();})
341
346
  .then(function(d){if(d&&d.ok&&d.message){addMsg(d.message);sendMsg(d.message);}})
342
347
  .catch(function(){});}
@@ -349,9 +354,10 @@ var Live=(function(){
349
354
  return sb.storage.from('present-attachments').uploadToSignedUrl(d.path,d.token,file).then(function(u){
350
355
  if(u&&u.error)throw 0;
351
356
  var i=document.getElementById('chatText'),cap=(i&&i.value||'').trim();if(i)i.value='';
352
- var o={action:'present-chat',slug:SLUG,name:ME.name,email:ME.email,avatar:ME.avatar,isPresenter:ME.role==='presenter',isMember:!!ME.member,body:cap,authorToken:authToken(),attachment:{url:d.publicUrl,name:file.name,type:file.type,kind:d.kind}};
357
+ var o={action:'present-chat',slug:SLUG,name:ME.name,email:ME.email,avatar:ME.avatar,body:cap,authorToken:authToken(),attachment:{url:d.publicUrl,name:file.name,type:file.type,kind:d.kind}};
353
358
  if(CONTROL)o.control=CONTROL;
354
- return fetch('/api/doc',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(o)});
359
+ var h2={'Content-Type':'application/json'};var j2=accessToken();if(j2)h2.Authorization='Bearer '+j2;
360
+ return fetch('/api/doc',{method:'POST',headers:h2,body:JSON.stringify(o)});
355
361
  });
356
362
  }).then(done).catch(function(){done();});}
357
363
  function applyLock(v){LOCKED=!!v;var t=document.getElementById('chatText'),s=document.getElementById('chatSend');var can=!LOCKED||canMod();if(t){t.disabled=!can;t.placeholder=can?'Écrire un message…':'Chat en lecture seule';}if(s)s.disabled=!can;var lk=document.getElementById('chatLockBtn');if(lk)lk.classList.toggle('on',LOCKED);var no=document.getElementById('chatLocked');if(no)no.style.display=(LOCKED&&!canMod())?'block':'none';toggleSend();}
@@ -449,7 +455,7 @@ var Live=(function(){
449
455
  var _relEtat=null,_relChat=null;
450
456
  // Groupé : dix diffusions d'affilée ne doivent pas produire dix requêtes.
451
457
  function relireEtat(){clearTimeout(_relEtat);_relEtat=setTimeout(function(){
452
- relire('&state=1',function(d){if(d.state&&_onState)_onState(d.state);});
458
+ relire('&state=1',function(d){if(d.state)etatDuServeur(d.state);});
453
459
  },120);}
454
460
  function relireChat(){clearTimeout(_relChat);_relChat=setTimeout(function(){
455
461
  relire('&chat=1',function(d){
@@ -463,6 +469,21 @@ var Live=(function(){
463
469
  });
464
470
  },120);}
465
471
 
472
+ // ⚠️ LE TITRE VIENT D'ICI, PAS DE LA PRÉSENCE. La liste des participants tirait
473
+ // « présentateur » de la charge de présence, que chacun compose lui-même : un
474
+ // 'track({role:"presenter"})' suffisait à apparaître comme le présentateur devant toute
475
+ // l'audience, avec le nom et l'avatar de son choix. Le canal ne peut pas arbitrer ça — un
476
+ // participant légitime a le droit d'y écrire SA présence.
477
+ //
478
+ // Le serveur renvoie la CLÉ de celui qui a prouvé le control_token ; l'audience compare. Pas
479
+ // de clé, pas de titre : mieux vaut aucun titre qu'un titre usurpé.
480
+ 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){}}}
483
+ if(_onState)_onState(st);
484
+ }
485
+ relire('&state=1',function(d){if(d.state)etatDuServeur(d.state);});
486
+
466
487
  // Le message reçu sert à savoir QU'IL SE PASSE quelque chose, et à notifier ; son CONTENU
467
488
  // vient de la relecture. Sans ça, une notification pourrait afficher un texte forgé.
468
489
  ch.on('broadcast',{event:'msg'},function(){relireChat();});
@@ -480,6 +501,10 @@ var Live=(function(){
480
501
  }catch(e){}}
481
502
  function disconnect(){try{clearInterval(_tyIv);}catch(e){}try{clearInterval(_atIv);}catch(e){}try{sendAttend();}catch(e){}try{if(ch){ch.untrack();ch.unsubscribe();ch=null;}}catch(e){}var pb=document.getElementById('presBtn');if(pb)pb.style.display='none';var cb=document.getElementById('chatBtn');if(cb)cb.style.display='none';var _fb=document.getElementById('chatFab');if(_fb)_fb.classList.remove('on');var pn=document.getElementById('chatPanel');if(pn)pn.classList.add('hidden');}
482
503
  // Membre de l'équipe reconnu via la session app (MÊME ORIGINE, localStorage) → avatar + nom auto.
504
+ // Le jeton d'acces de la session locale, quand il y en a une. C'est la SEULE chose qui prouve au
505
+ // 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);
507
+ return String((s&&(s.access_token||(s.currentSession&&s.currentSession.access_token)||(s.session&&s.session.access_token)))||'');}catch(e){return '';}}
483
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;}
484
509
  return {connect:connect,disconnect:disconnect,detectMember:detectMember,sendMap:sendMap,onMap:onMap,sendState:sendState,onState:onState};
485
510
  })();`;
@@ -1207,6 +1232,24 @@ function adresseAppelant(req) {
1207
1232
  return String((req.socket && req.socket.remoteAddress) || "").slice(0, 60);
1208
1233
  }
1209
1234
 
1235
+ /**
1236
+ * Cet appelant est-il un MEMBRE de l'hôte, prouvé — pas déclaré ?
1237
+ *
1238
+ * ⚠️ L'appartenance décide de quelle POPULATION une lecture rejoint. « Ce client a lu douze
1239
+ * minutes » ne vaut que si un collègue relisant le document n'entre pas dans le même compte : c'est
1240
+ * la promesse que ce produit vend. Un booléen envoyé par le navigateur ne prouve rien — il est
1241
+ * choisi par qui l'envoie.
1242
+ *
1243
+ * Le jeton d'accès, lui, est vérifié par l'hôte. Absent ou invalide : pas membre. Jamais de repli
1244
+ * sur ce que l'appelant affirme, sinon la vérification ne sert qu'aux honnêtes.
1245
+ */
1246
+ async function membreVerifie(req) {
1247
+ try {
1248
+ const u = await PLAYER.identity.verifyToken((req.headers && req.headers.authorization) || "");
1249
+ return !!(u && u.email);
1250
+ } catch { return false; }
1251
+ }
1252
+
1210
1253
  async function relayerFichier(res, r, disposition) {
1211
1254
  if (!r) { res.statusCode = 404; res.end("Fichier indisponible"); return; }
1212
1255
  if (!r.ok && r.status !== 206) { res.statusCode = 502; res.end("Fichier indisponible"); return; }
@@ -2548,7 +2591,21 @@ async function handler(req, res) {
2548
2591
  const ip = adresseAppelant(req) || "anon";
2549
2592
  const allowed = await PLAYER.limits.allow(`patt:${ip}`, 1000, 3600);
2550
2593
  if (!allowed) return jp(429, { ok: false, error: "rate" });
2551
- const r = await recordAttendance(String(body.slug || ""), { key: body.key, name: body.name, email: body.email, avatar: body.avatar, isMember: !!body.isMember, isPresenter: !!body.isPresenter });
2594
+ // ⚠️ CES DEUX BOOLÉENS ÉTAIENT CEUX DE L'APPELANT. `isMember` sépare la population interne
2595
+ // de celle des prospects — c'est la promesse même du produit — et `isPresenter` accorde le
2596
+ // titre dans la liste des participants. Les deux venaient du corps de la requête : un
2597
+ // prospect pouvait se compter comme collègue, et se donner le titre de présentateur.
2598
+ //
2599
+ // Ce qui distingue un présentateur d'un participant n'est pas ce qu'il affirme, c'est le
2600
+ // `control_token` — `present-chat` le vérifiait déjà pour le badge des messages, cette
2601
+ // route ne le faisait pas. Et l'appartenance se prouve par le jeton d'accès de la session :
2602
+ // cette route est un `fetch`, elle peut porter un en-tête (contrairement au suivi de
2603
+ // lecture, qui part par `sendBeacon` et signe donc dans le corps).
2604
+ const pres = await getPresentation(String(body.slug || ""));
2605
+ if (!pres) return jp(404, { ok: false });
2606
+ const estPresentateur = !!(body.control && require("crypto").createHash("sha256").update(String(body.control)).digest("hex") === pres.control_hash);
2607
+ const estMembre = await membreVerifie(req);
2608
+ const r = await recordAttendance(String(body.slug || ""), { key: body.key, name: body.name, email: body.email, avatar: body.avatar, isMember: estMembre, isPresenter: estPresentateur });
2552
2609
  return jp(r.ok ? 200 : (r.status || 400), r);
2553
2610
  } catch { return jp(500, { ok: false }); }
2554
2611
  }
@@ -2588,7 +2645,10 @@ async function handler(req, res) {
2588
2645
  // participant pourrait poster un message usurpant le présentateur). Sert aussi au chat verrouillé.
2589
2646
  const validControl = !!(body.control && require("crypto").createHash("sha256").update(String(body.control)).digest("hex") === pres.control_hash);
2590
2647
  if (pres.chat_locked && !validControl) return jp(423, { ok: false, error: "locked" });
2591
- const r = await addMessage(String(body.slug || ""), { name: body.name, email: body.email, avatar: body.avatar, isPresenter: validControl, isMember: !!body.isMember, body: body.body, replyTo: body.replyTo, replyName: body.replyName, replyText: body.replyText, authorToken: body.authorToken, attachment: body.attachment });
2648
+ // `isMember` restait l'affirmation du client, alors que `isPresenter` était vérifié juste
2649
+ // au-dessus. Deux poids sur la même ligne : le badge « présentateur » se méritait, celui
2650
+ // de collègue se réclamait.
2651
+ const r = await addMessage(String(body.slug || ""), { name: body.name, email: body.email, avatar: body.avatar, isPresenter: validControl, isMember: await membreVerifie(req), body: body.body, replyTo: body.replyTo, replyName: body.replyName, replyText: body.replyText, authorToken: body.authorToken, attachment: body.attachment });
2592
2652
  return jp(r.ok ? 200 : (r.status || 400), r);
2593
2653
  } catch { return jp(500, { ok: false }); }
2594
2654
  }
@@ -2979,6 +3039,10 @@ async function handler(req, res) {
2979
3039
  file_name: pres.file_name || null,
2980
3040
  doc_title: pres.doc_title || null,
2981
3041
  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 || "")),
2982
3046
  } }));
2983
3047
  return;
2984
3048
  }
@@ -281,6 +281,29 @@ 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
+
284
307
  // ── Statistiques de présentation (assistance) ────────────────────────────────────────────────────────────
285
308
  // Heartbeat d'un participant : upsert de sa ligne d'assistance. On accumule le temps de présence (intervalles
286
309
  // < 60 s → un aller-retour ne gonfle pas total_ms) et l'ensemble des pages vues (page courante de la présentation).
@@ -302,7 +325,12 @@ async function recordAttendance(slug, { key, name, email, avatar, isMember, isPr
302
325
  const addMs = gap > 0 && gap <= ATTEND_MAX_GAP_MS ? gap : 0;
303
326
  const pages = Array.isArray(cur.pages) ? cur.pages.slice() : [];
304
327
  if (!pages.includes(page)) pages.push(page);
305
- await PLAYER.db.request(`doc_presentation_attendees?slug=eq.${enc(slug)}&attendee_key=eq.${enc(String(key))}`, { method: "PATCH", headers: { Prefer: "return=minimal" }, body: { last_seen: new Date(now).toISOString(), total_ms: Number(cur.total_ms || 0) + addMs, pages, name: (name || cur.name || "").slice(0, 120) || null, avatar: (avatar || cur.avatar || "").slice(0, 600) || null } });
328
+ await PLAYER.db.request(`doc_presentation_attendees?slug=eq.${enc(slug)}&attendee_key=eq.${enc(String(key))}`, { method: "PATCH", headers: { Prefer: "return=minimal" }, body: { last_seen: new Date(now).toISOString(), total_ms: Number(cur.total_ms || 0) + addMs, pages, name: (name || cur.name || "").slice(0, 120) || null, avatar: (avatar || cur.avatar || "").slice(0, 600) || null,
329
+ // ⚠️ Les deux drapeaux se remettent à jour à chaque battement, ils ne sont plus figés à la
330
+ // première ligne : un transfert de présentation change qui porte le titre, et une session qui
331
+ // s'authentifie en cours de route devient un membre. Figés, ils décriraient l'instant de
332
+ // l'arrivée et non la réalité — et le premier arrivé aurait raison pour toujours.
333
+ is_member: !!isMember, is_presenter: !!isPresenter } });
306
334
  return { ok: true };
307
335
  }
308
336
 
@@ -382,4 +410,4 @@ async function listPresentationsForDoc(docId) {
382
410
  }
383
411
 
384
412
  module.exports = {
385
- 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 };
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 };