discovery-media-player 0.1.55 → 0.1.57

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.
@@ -176,7 +176,9 @@ async function lireDepuis(fh, stat, cible, range) {
176
176
  const buf = Buffer.alloc(fin - debut + 1);
177
177
  const { bytesRead } = await fh.read(buf, 0, buf.length, debut);
178
178
  const corps = bytesRead === buf.length ? buf : buf.subarray(0, bytesRead);
179
- const entetes = { "content-type": type };
179
+ // La taille est connue d'un `stat` sur le descripteur ouvert : l'annoncer permet au relais de
180
+ // la refuser avant d'allouer, et à la visionneuse d'afficher une progression.
181
+ const entetes = { "content-type": type, "content-length": String(corps.length) };
180
182
  if (statut === 206) entetes["content-range"] = `bytes ${debut}-${fin}/${total}`;
181
183
  return reponse(statut, entetes, corps);
182
184
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "discovery-media-player",
3
- "version": "0.1.55",
3
+ "version": "0.1.57",
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",
@@ -62,7 +62,8 @@
62
62
  "lint:fix": "eslint bin context server src build --fix",
63
63
  "typecheck": "tsc --noEmit",
64
64
  "prepublishOnly": "npm run build && npm test",
65
- "test:e2e": "vitest run --config vitest.e2e.config.mjs"
65
+ "test:e2e": "vitest run --config vitest.e2e.config.mjs",
66
+ "test:base": "vitest run --config vitest.base.config.mjs"
66
67
  },
67
68
  "engines": {
68
69
  "node": ">=22"
package/server/handler.js CHANGED
@@ -4,6 +4,8 @@
4
4
  // - GET /doc/:slug?file=1 → stream le PDF depuis le Storage (MÊME ORIGINE → pas de souci CORS pour pdf.js)
5
5
  // - POST /api/doc {slug,event…}→ journalise un événement (open / page / heartbeat) — best-effort
6
6
  const crypto = require("crypto");
7
+ const { Readable } = require("node:stream");
8
+ const { pipeline } = require("node:stream/promises");
7
9
  const { getShareBySlug, logView, upsertSession, createReshare, sendReshareEmail, upsertInternalSession,
8
10
  createShare, revokeShare, setShareAuth, overview: docOverview, listSharesForDoc, listSessionsForDoc, internalStatsForDoc } = require("./shares");
9
11
  const { SESSION_QUOTA_PER_HOUR, PRESENT_QUOTA_PER_HOUR, PRESENT_CACHE_MS } = require("./shared.generated.js");
@@ -309,6 +311,12 @@ var Live=(function(){
309
311
  // participants, d'URL — donc du dehors. typers est le cas vif : il est alimenté par 'typing',
310
312
  // le SEUL événement qui croie encore son émetteur (cf. 0.1.30). Un objet nu retire la question
311
313
  // entière au lieu de la traiter cas par cas. (audit P1-2)
314
+ // ⚠️ _ordEtat, _ordChat et _filet VIVAIENT DANS connect() : disconnect(), défini ici, ne pouvait
315
+ // pas les atteindre. Après connect → disconnect → connect, les relectures de l'ancienne session
316
+ // continuaient — un spectateur qui rouvre la page doublait le trafic, et le filet de sécurité
317
+ // battait deux fois. On les déclare au niveau du CYCLE DE VIE : ce qui doit être arrêté doit
318
+ // être visible depuis l'endroit qui arrête.
319
+ var _ordEtat=null,_ordChat=null,_filet=null;
312
320
  var sb=null,ch=null,ME=null,SLUG=null,CONTROL=null,LOCKED=false,AUTHTOK=null,PRESENT=[],PRESNAME='',seen=Object.create(null),msgEls=Object.create(null),msgData=Object.create(null),replyCtx=null,typers=Object.create(null),pdfCache=Object.create(null),_tyT=0,_tyIv=0,_atIv=0,unread=0,autoOpened=false,_histDone=false,_phWired=false,_onMap=null,_onState=null,_peekT=0,MUTED=false;
313
321
  try{ MUTED=localStorage.getItem('3dd-present-mute')==='1'; }catch(e){}
314
322
  // Couper/rétablir les notifications du chat (cloche) : coupé = plus de ticker ni de pulse (badge silencieux gardé).
@@ -419,11 +427,23 @@ var Live=(function(){
419
427
  if(y)y.onclick=function(){close();if(onOk)onOk();};if(n)n.onclick=close;m.onclick=function(e){if(e.target===m)close();};
420
428
  document.addEventListener('keydown',key);m.classList.add('open');if(y)try{y.focus();}catch(e){}}
421
429
  function history(){fetch('/api/doc?present='+encodeURIComponent(SLUG)+'&chat=1').then(function(r){return r.json();}).then(function(d){var box=document.getElementById('chatMsgs');if(d&&d.messages&&d.messages.length){d.messages.forEach(function(m){addMsg(m);});}else if(box&&!box.children.length){box.innerHTML='<div class=chat-empty>Aucun message. Lancez la discussion.</div>';}if(d&&typeof d.locked!=='undefined')applyLock(d.locked);_histDone=true;}).catch(function(){_histDone=true;});}
422
- function react(id,e){if(!ME||!id||!e)return;fetch('/api/doc',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'present-react',slug:SLUG,msgId:+id,emoji:e,reactor:MOIREF})}).then(majDiffusee).catch(function(){});}
430
+ function react(id,e){if(!ME||!id||!e)return;
431
+ // ⚠️ ON ENVOIE L'ÉTAT VOULU, PAS « INVERSE ». Basculer n'a de sens qu'une fois : un renvoi
432
+ // réseau, un double-clic, une reprise de requête, et la réaction que le participant vient
433
+ // d'ajouter disparaît — sans aucune erreur affichée. Il voit son émoji s'allumer puis
434
+ // s'éteindre, recommence, et rebascule encore. Rejouer la même intention deux fois donne le
435
+ // même résultat qu'une fois ; c'est ce que le réseau exige.
436
+ var _m=msgData[id]||{},_rs=(_m.reactions&&_m.reactions[e])||[],veut=_rs.indexOf(MOIREF)<0;
437
+ fetch('/api/doc',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'present-react',slug:SLUG,msgId:+id,emoji:e,reactor:MOIREF,etat:veut})}).then(majDiffusee).catch(function(){});}
423
438
  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();}
424
439
  function clearReply(){replyCtx=null;var el=document.getElementById('chatReply');if(el){el.style.display='none';el.innerHTML='';}}
425
440
  function send(){var i=document.getElementById('chatText');var t=(i.value||'').trim();if(!t||!ME)return;if(LOCKED&&!canMod())return;i.value='';toggleSend();
426
- var o={action:'present-chat',slug:SLUG,name:ME.name,email:ME.email,avatar:ME.avatar,body:t,authorToken:authToken()};
441
+
442
+ // ⚠️ LA CLÉ EST FABRIQUÉE ICI, UNE FOIS, AVANT LE PREMIER ENVOI. Une clé tirée à chaque
443
+ // tentative ne servirait à rien : deux envois porteraient deux clés et passeraient tous les
444
+ // deux. C'est sa RÉUTILISATION au renvoi qui rend l'opération idempotente.
445
+ var _cle=(function(){ try{ var r=crypto.getRandomValues(new Uint8Array(12)); return Array.from(r,function(x){return x.toString(16).padStart(2,'0');}).join(''); }catch(e){ return ''; } })();
446
+ var o={action:'present-chat',clientKey:_cle,slug:SLUG,name:ME.name,email:ME.email,avatar:ME.avatar,body:t,authorToken:authToken()};
427
447
  if(CONTROL)o.control=CONTROL;
428
448
  if(replyCtx){o.replyTo=replyCtx.id;o.replyName=replyCtx.name;o.replyText=replyCtx.text;clearReply();}
429
449
  var h1={'Content-Type':'application/json'};var j1=accessToken();if(j1)h1.Authorization='Bearer '+j1;
@@ -605,14 +625,14 @@ var Live=(function(){
605
625
  arreter:function(){ ord.arreter(); },
606
626
  };
607
627
  }
608
- var _ordEtat=relireAvec('&state=1',function(d){if(d.state)etatDuServeur(d.state);});
628
+ _ordEtat=relireAvec('&state=1',function(d){if(d.state)etatDuServeur(d.state);});
609
629
  // ⚠️ Exposé hors de cette fermeture : la carte vit dans un AUTRE bloc de script et doit
610
630
  // pouvoir déclencher la relecture. Même raison que window.__presAppliquerEtat — un nom
611
631
  // référencé depuis la mauvaise portée part dans un catch muet, et l'audience se fige.
612
632
  window.__presRelireEtat=function(){_ordEtat.signaler();};
613
633
  function relireEtat(){_ordEtat.signaler();}
614
634
  function relireChat(){_ordChat.signaler();}
615
- var _ordChat=relireAvec('&chat=1',function(d){
635
+ _ordChat=relireAvec('&chat=1',function(d){
616
636
  // ⚠️ NOTIFIER CE QUI VIENT D'ARRIVER, ET SEULEMENT ÇA. 'addMsg' rend faux pour un
617
637
  // message déjà connu — la relecture ramène tout l'historique, donc sans cette condition
618
638
  // la pastille de non-lus compterait chaque message à chaque relecture. Et sans l'appel,
@@ -627,7 +647,7 @@ var Live=(function(){
627
647
  // rattrape ce cas : elle ne coûte presque rien et évite qu'une audience reste figée sur un
628
648
  // état périmé en croyant être à jour. C'est l'inverse d'une optimisation : c'est le prix de
629
649
  // la borne.
630
- var _filet=setInterval(function(){_ordEtat.maintenant();_ordChat.maintenant();},25000);
650
+ _filet=setInterval(function(){_ordEtat.maintenant();_ordChat.maintenant();},25000);
631
651
 
632
652
  // ⚠️ LE TITRE VIENT D'ICI, PAS DE LA PRÉSENCE. La liste des participants tirait
633
653
  // « présentateur » de la charge de présence, que chacun compose lui-même : un
@@ -662,7 +682,7 @@ var Live=(function(){
662
682
  // → évite les fantômes (« je me vois deux fois » au retour). Une seule fois.
663
683
  if(!_phWired){_phWired=true;window.addEventListener('pagehide',function(){try{clearInterval(_filet);_ordEtat.arreter();_ordChat.arreter();}catch(e){}try{if(ch){ch.untrack();ch.unsubscribe();ch=null;}}catch(e){}});}
664
684
  }catch(e){}}
665
- 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');}
685
+ function disconnect(){try{clearInterval(_tyIv);}catch(e){}try{clearInterval(_atIv);}catch(e){}try{clearInterval(_filet);_filet=null;}catch(e){}try{if(_ordEtat)_ordEtat.arreter();_ordEtat=null;}catch(e){}try{if(_ordChat)_ordChat.arreter();_ordChat=null;}catch(e){}try{delete window.__presRelireEtat;}catch(e){window.__presRelireEtat=null;}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');}
666
686
  // Membre de l'équipe reconnu via la session app (MÊME ORIGINE, localStorage) → avatar + nom auto.
667
687
  // Le jeton d'acces de la session locale, quand il y en a une. C'est la SEULE chose qui prouve au
668
688
  // serveur qu'on est un membre ; 'member:true' dans la page ne prouve rien, il ne sert qu'a l'affichage.
@@ -1532,6 +1552,12 @@ async function profilDuJeton(req) {
1532
1552
  } catch { return null; }
1533
1553
  }
1534
1554
 
1555
+ /**
1556
+ * Plafond du relais, en octets. Réglable par l'exploitant : un hôte qui sert des plans
1557
+ * d'architecte n'a pas les mêmes documents qu'un hôte qui sert des notices.
1558
+ */
1559
+ const PLAFOND_RELAIS = Number(process.env.PLAYER_MAX_RELAY_BYTES || 0) || 60 * 1024 * 1024;
1560
+
1535
1561
  async function relayerFichier(res, r, disposition) {
1536
1562
  if (!r) { res.statusCode = 404; res.end("Fichier indisponible"); return; }
1537
1563
  if (!r.ok && r.status !== 206) { res.statusCode = 502; res.end("Fichier indisponible"); return; }
@@ -1539,7 +1565,28 @@ async function relayerFichier(res, r, disposition) {
1539
1565
  const compresse = !!r.headers.get("content-encoding");
1540
1566
  if (compresse && r.status === 206) { res.statusCode = 502; res.end("Fichier indisponible"); return; }
1541
1567
 
1542
- const buf = Buffer.from(await r.arrayBuffer());
1568
+ // ⚠️ DEUX BORNES, ET LA SECONDE EST LA SEULE QUI TIENNE DEVANT UN AMONT QUI MENT.
1569
+ //
1570
+ // La première regarde `Content-Length` et renonce AVANT d'ouvrir le corps. C'est la seule qui
1571
+ // puisse encore répondre 413, puisque rien n'est parti — mais elle croit l'amont sur parole : un
1572
+ // stockage qui n'annonce rien, ou qui annonce 1 Ko et en envoie 500, passait sans être inquiété.
1573
+ //
1574
+ // La seconde COMPTE LES OCTETS QUI PASSENT et rompt au dépassement. Elle ne peut plus répondre
1575
+ // 413 : les en-têtes sont partis avec le premier octet, et on ne dédit pas un en-tête déjà
1576
+ // envoyé. Elle coupe. Le client voit un transfert interrompu — désagréable et honnête, là où
1577
+ // l'épuisement de la mémoire emportait la fonction ENTIÈRE, donc aussi les requêtes des autres.
1578
+ const brute = r.headers.get("content-length");
1579
+ const annoncee = Number(brute || 0);
1580
+ if (annoncee > PLAFOND_RELAIS) {
1581
+ try { PLAYER.errors.capture(new Error(`relais refusé : ${annoncee} octets au-dessus du plafond de ${PLAFOND_RELAIS}`), { route: "relais" }); } catch { /* jamais bloquant */ }
1582
+ res.statusCode = 413;
1583
+ res.end("Fichier trop volumineux");
1584
+ // ⚠️ Renoncer ne suffit pas : un corps jamais tiré laisse la connexion amont OUVERTE, et le
1585
+ // pool de sockets s'épuise sur les gros fichiers — exactement la ressource qu'on protège.
1586
+ try { if (r.body) r.body.cancel(); } catch { /* déjà refermé */ }
1587
+ return;
1588
+ }
1589
+
1543
1590
  res.statusCode = r.status;
1544
1591
  const typeAmont = r.headers.get("content-type") || "application/pdf";
1545
1592
  const executable = TYPES_EXECUTABLES.test(typeAmont);
@@ -1555,10 +1602,53 @@ async function relayerFichier(res, r, disposition) {
1555
1602
  // Les bornes d'un `Content-Range` ne valent que si l'amont n'a pas compressé.
1556
1603
  const cr = !compresse && r.headers.get("content-range");
1557
1604
  if (cr) res.setHeader("Content-Range", cr);
1558
- res.setHeader("Content-Length", String(buf.length)); // ce qu'on envoie, jamais ce qu'on a reçu
1605
+ // ⚠️ `fetch` DÉCOMPRESSE DE LUI-MÊME, et c'est le piège du flux. Sur un amont gzip,
1606
+ // `Content-Length` compte les octets COMPRIMÉS alors que nous relayons les octets déployés :
1607
+ // le recopier ferait attendre au client des octets qui ne viendront jamais, ou lui ferait couper
1608
+ // le document au milieu. La bufferisation nous rendait ce service sans qu'on le demande —
1609
+ // `buf.length` était toujours juste. En flux, il faut savoir se taire ; voir plus bas.
1559
1610
  if (disposition) res.setHeader("Content-Disposition", disposition);
1560
1611
  res.setHeader("Cache-Control", "private, max-age=600");
1561
- res.end(buf);
1612
+
1613
+ // ⚠️ UN AMONT SANS CORPS LISIBLE N'EST PAS UNE ANOMALIE, C'EST LE CONTRAT. `storage.fetchFile`
1614
+ // est une capacité de l'HÔTE : il rend ce qu'il veut, du moment qu'il sait dire `arrayBuffer()`.
1615
+ // Le chemin fichier local du mode autonome, lui, ne rend rien d'autre. Traiter cette absence
1616
+ // comme « rien à envoyer » servait des fichiers VIDES, sans une erreur pour le dire — un défaut
1617
+ // pire que celui qu'on ferme ici, et que seuls deux essais existants ont vu tomber.
1618
+ //
1619
+ // Ici la borne du flux ne peut rien : `arrayBuffer()` a déjà tout alloué quand on pourrait
1620
+ // compter. Seule la taille annoncée protège — ce qui suffit, parce qu'un hôte qui rend un corps
1621
+ // en un bloc l'a lu depuis quelque chose dont il connaît la taille.
1622
+ if (!r.body) {
1623
+ const buf = Buffer.from(await r.arrayBuffer());
1624
+ res.setHeader("Content-Length", String(buf.length)); // connue, donc annoncée
1625
+ res.end(buf);
1626
+ return;
1627
+ }
1628
+
1629
+ // ⚠️ ON N'ANNONCE UNE LONGUEUR QUE QUAND ON SAIT QU'ELLE DÉCRIT CE QU'ON ENVOIE — et en flux,
1630
+ // la seule qu'on ait est celle de l'amont. Sans encodage elle est exacte : on la garde, elle
1631
+ // vaut une barre de progression. Avec encodage on se tait (cf. plus haut), et la fin du corps
1632
+ // fait foi. Sans annonce amont, on se tait aussi : c'est le prix du flux, payé en connaissance.
1633
+ if (!compresse && brute) res.setHeader("Content-Length", brute);
1634
+
1635
+ const plafond = PLAFOND_RELAIS;
1636
+ try {
1637
+ await pipeline(Readable.fromWeb(r.body), async function* (source) {
1638
+ let vus = 0;
1639
+ for await (const morceau of source) {
1640
+ vus += morceau.length;
1641
+ if (vus > plafond) throw new Error(`relais interrompu : ${vus} octets reçus, plafond ${plafond}`);
1642
+ yield morceau;
1643
+ }
1644
+ }, res);
1645
+ } catch (erreur) {
1646
+ // ⚠️ ROMPRE, PAS RÉPONDRE — et le DIRE. Aucun code de retour n'est plus disponible ; ne
1647
+ // reste que la coupure. Une coupure fréquente ici est un plafond mal réglé ou un amont
1648
+ // défaillant : l'avaler ferait passer un défaut d'exploitation pour un caprice du réseau.
1649
+ try { PLAYER.errors.capture(erreur instanceof Error ? erreur : new Error(String(erreur)), { route: "relais" }); } catch { /* jamais bloquant */ }
1650
+ try { res.destroy(); } catch { /* le socket est peut-être déjà parti */ }
1651
+ }
1562
1652
  }
1563
1653
 
1564
1654
  // ⚠️ UNE MENTION DONT L'OBJET EST D'ÊTRE EXACTE NE DOIT PAS INVENTER UN EXPÉDITEUR.
@@ -3273,7 +3363,7 @@ async function handler(req, res) {
3273
3363
  name: (profil && profil.name) || body.name,
3274
3364
  email: profil ? profil.email : body.email,
3275
3365
  avatar: (profil && profil.avatar) || body.avatar,
3276
- isPresenter: validControl, isMember: !!profil, body: body.body, replyTo: body.replyTo, replyName: body.replyName, replyText: body.replyText, authorToken: body.authorToken, attachment: body.attachment });
3366
+ isPresenter: validControl, isMember: !!profil, body: body.body, replyTo: body.replyTo, replyName: body.replyName, replyText: body.replyText, authorToken: body.authorToken, attachment: body.attachment , clientKey: body.clientKey });
3277
3367
  return jp(r.ok ? 200 : (r.status || 400), r);
3278
3368
  } catch { return jp(500, { ok: false }); }
3279
3369
  }
@@ -4028,6 +4118,8 @@ async function handler(req, res) {
4028
4118
  // ⚠️ `TIERS` est exporté pour être CONFRONTÉ, pas pour être utilisé. Le banc navigateur et la
4029
4119
  // forge doivent pouvoir demander « quelles dépendances tierces, à quelles empreintes » sans en
4030
4120
  // tenir une seconde liste — c'est la seule façon qu'une empreinte périmée finisse par se voir.
4031
- module.exports = { handler, init, TIERS };
4121
+ // ⚠️ Exporté pour être ÉPROUVÉ, pas pour être appelé : le plafond du relais ne se vérifie qu en
4122
+ // regardant si le corps a été lu, ce qu aucune route ne peut montrer de l extérieur.
4123
+ module.exports = { handler, init, TIERS, __relayerFichier: relayerFichier };
4032
4124
 
4033
4125
  // redeploy: forcer le build production (Vercel a sauté la prod du merge #463 — wording re-partage).
@@ -381,6 +381,19 @@ function messagePublic(row) {
381
381
  if ("reactions" in out) out.reactions = reactionsPubliques(out.reactions);
382
382
  // Dérivé, jamais recopié : `author_hash` est lu pour ça et ne sort jamais tel quel.
383
383
  if ("author_hash" in row) out.author_ref = refAuteur(row.author_hash);
384
+ // ⚠️ LA GARDE CATÉGORIELLE N'EST PAS ICI, ET LA RAISON EST STRUCTURELLE.
385
+ //
386
+ // La liste blanche s'exécute AVANT : elle a déjà retiré tout champ inconnu. `publier()` n'y
387
+ // verrait donc qu'un jeu de champs connus, et aucune mutation ne peut la faire tomber à cet
388
+ // endroit — la redondance n'est pas une ceinture, c'est une garde qu'on ne peut pas éprouver.
389
+ //
390
+ // ⚠️ Et le cas qu'on écrirait pour forcer le rouge serait faux : `author_name` est CHOISI par le
391
+ // participant. Quelqu'un qui se nomme « lea@exemple.fr » verrait le chat tomber — le piège de
392
+ // `body`, rencontré une seconde fois au même endroit.
393
+ //
394
+ // Sa place est aux sorties SANS liste blanche : charge de présence, statistiques, aperçu d'un
395
+ // partage — là où un champ ajouté demain sort sans que personne ne l'ait décidé. C'est là qu'une
396
+ // mutation pourra rougir, et donc là qu'elle sera branchée.
384
397
  return out;
385
398
  }
386
399
 
@@ -390,7 +403,7 @@ function premierPublic(reponse) {
390
403
  return messagePublic(row);
391
404
  }
392
405
 
393
- async function addMessage(slug, { name, email, avatar, isPresenter, isMember, body, replyTo, replyName, replyText, authorToken, attachment }) {
406
+ async function addMessage(slug, { name, email, avatar, isPresenter, isMember, body, replyTo, replyName, replyText, authorToken, attachment, clientKey }) {
394
407
  if (await estArchive(slug)) return REFUS_ARCHIVE;
395
408
  const b = String(body || "").trim().slice(0, 2000);
396
409
  // ⚠️ Pas de normalisation ici : `config.supabaseUrl` arrive SANS barre finale, c'est le
@@ -411,10 +424,41 @@ async function addMessage(slug, { name, email, avatar, isPresenter, isMember, bo
411
424
  author_hash: authorToken ? sha(authorToken) : null,
412
425
  reply_to: rt, reply_name: rt ? ((replyName || "").slice(0, 80) || null) : null, reply_text: rt ? ((replyText || "").slice(0, 140) || null) : null,
413
426
  };
427
+ // ⚠️ LA CLÉ N'EST ÉCRITE QUE SI LA COLONNE EXISTE. PostgREST rejette le POST ENTIER sur une
428
+ // colonne inconnue : chez un hôte non migré, ce n'est pas l'idempotence qu'on perdrait, c'est
429
+ // l'envoi de messages. Même piège que le rang d'écriture, même sonde.
430
+ const cle = String(clientKey || "").slice(0, 80);
431
+ if (cle && await require("./schema").aLaColonne(
432
+ "doc_presentation_messages", "client_key", "supabase/migrations/0005-envoi-unique.sql")) {
433
+ row.client_key = cle;
434
+ }
435
+
414
436
  // `return=representation` : c'est la ligne écrite qui part ensuite en diffusion vers l'audience.
415
437
  // Sans elle, l'émetteur devrait deviner l'`id` et la date attribués par la base.
416
- const cree = await PLAYER.db.request("doc_presentation_messages?select=*", { method: "POST", headers: { Prefer: "return=representation" }, body: [row] });
417
- return { ok: true, message: premierPublic(cree) };
438
+ // ⚠️ UN REFUS D'UNICITÉ N'EST PAS UNE ERREUR, C'EST UNE CONFIRMATION. La contrainte dit « ce
439
+ // message est déjà là » : le remonter au participant remplacerait « deux messages » par « une
440
+ // erreur », ce qui n'est pas mieux. On relit donc la ligne déjà écrite et on la rend comme si
441
+ // l'envoi venait de réussir — ce qu'il a fait, la première fois.
442
+ try {
443
+ const cree = await PLAYER.db.request("doc_presentation_messages?select=*", { method: "POST", headers: { Prefer: "return=representation" }, body: [row] });
444
+ return { ok: true, message: premierPublic(cree) };
445
+ } catch (erreur) {
446
+ // ⚠️ LA GARDE DES ÉCRITURES MUETTES A REFUSÉ CE BLOC, et elle avait raison de le faire : un
447
+ // `try` autour d'une écriture doit dire quelque chose. Ici le silence était volontaire — un 409
448
+ // attendu n'est pas un incident — mais rien ne distinguait « je sais ce que je rattrape » de
449
+ // « j'avale tout ». On le dit donc : ce qui n'est pas le conflit attendu remonte, et le conflit
450
+ // attendu est journalisé une fois, en clair, parce qu'un renvoi fréquent est une information.
451
+ const conflit = cle && String((erreur && erreur.message) || "").includes("409");
452
+ if (!conflit) throw erreur;
453
+ try { PLAYER.errors.capture(new Error("message déjà enregistré (renvoi) : " + String(slug)), { route: "present-chat", benin: true }); } catch { /* jamais bloquant */ }
454
+ const deja = await PLAYER.db.request(
455
+ `doc_presentation_messages?slug=eq.${enc(String(slug))}&client_key=eq.${enc(cle)}&select=*&limit=1`);
456
+ const ligne = Array.isArray(deja) && deja[0];
457
+ // ⚠️ Si la relecture ne trouve rien, on ne prétend pas : le 409 venait d'autre chose, et le
458
+ // taire ferait croire à un envoi réussi qui n'a pas eu lieu.
459
+ if (!ligne) throw erreur;
460
+ return { ok: true, message: messagePublic(ligne), deja: true };
461
+ }
418
462
  }
419
463
 
420
464
  /**
@@ -470,7 +514,21 @@ async function setChatLock(slug, control, locked) {
470
514
  }
471
515
 
472
516
  // Réaction emoji (toggle) : le participant (identifié par email ou nom) ajoute/retire un emoji sur un message.
473
- async function toggleReaction(slug, msgId, emoji, reactor) {
517
+ /**
518
+ * ⚠️ ON POSE UN ÉTAT, ON NE BASCULE PLUS — et la différence est ce qui rend l'opération IDEMPOTENTE.
519
+ *
520
+ * « Basculer » n'a de sens qu'une fois : un renvoi réseau, un double-clic, une reprise de requête,
521
+ * et la réaction que le participant vient d'ajouter disparaît. Il ne voit aucune erreur — il voit
522
+ * son émoji s'allumer puis s'éteindre, et il recommence, ce qui rebascule encore.
523
+ *
524
+ * L'appelant sait ce qu'il VEUT (`etat`), pas ce qu'il faut inverser. Rejouer la même intention
525
+ * deux fois donne le même résultat qu'une fois, ce qui est exactement la propriété qu'un réseau
526
+ * peu fiable exige. Constat P10 de l'audit.
527
+ *
528
+ * ⚠️ Compatibilité : `etat` absent = ancien client, on bascule comme avant. Un client à jour ne
529
+ * repasse jamais par là ; un ancien garde son comportement plutôt que de perdre la fonction.
530
+ */
531
+ async function toggleReaction(slug, msgId, emoji, reactor, etat) {
474
532
  if (await estArchive(slug)) return REFUS_ARCHIVE;
475
533
  const id = Math.trunc(+msgId); const e = String(emoji || "").slice(0, 8); const who = String(reactor || "").slice(0, 160).toLowerCase();
476
534
  if (!id || !e || !who) return { ok: false, status: 400 };
@@ -498,7 +556,9 @@ async function toggleReaction(slug, msgId, emoji, reactor) {
498
556
  const cur = Object.assign(Object.create(null), brut);
499
557
  const arr = Array.isArray(cur[e]) ? cur[e] : [];
500
558
  const i = arr.indexOf(who);
501
- if (i >= 0) arr.splice(i, 1); else arr.push(who);
559
+ const veut = etat === undefined || etat === null ? (i < 0) : !!etat;
560
+ if (veut && i < 0) arr.push(who);
561
+ if (!veut && i >= 0) arr.splice(i, 1);
502
562
  if (arr.length) cur[e] = arr; else delete cur[e];
503
563
  const maj = await PLAYER.db.request(`doc_presentation_messages?id=eq.${id}&select=*`, { method: "PATCH", headers: { Prefer: "return=representation" }, body: { reactions: cur } });
504
564
  return { ok: true, message: premierPublic(maj) };
@@ -0,0 +1,72 @@
1
+ // AUCUNE ADRESSE NE SORT — LA CATÉGORIE, PAS LES QUATRE CHEMINS CONNUS.
2
+ //
3
+ // Nous avions corrigé quatre chemins qui portaient l'adresse d'un membre : les champs publics du
4
+ // chat, la carte des réactions, la charge de présence, la clé du canal. Le second hôte a dit ce
5
+ // qui manquait, et il avait raison : « mon fermé vaut pour les chemins que j'ai su nommer, pas
6
+ // pour la catégorie ». Rien ne garantissait qu'il n'y en avait pas un cinquième.
7
+ //
8
+ // ⚠️ CE N'EST PAS UNE SONDE QUI PRODUIT UNE LISTE. Une garde qui signale « ces vingt endroits
9
+ // ressemblent à des adresses » transfère le travail : quelqu'un doit juger vingt cas, et le
10
+ // jugement est précisément ce qui ne passe pas à l'échelle. Celle-ci REFUSE — le champ ne sort
11
+ // pas, et il faut écrire une décision pour qu'il sorte.
12
+ //
13
+ // ⚠️ ET ELLE NE NETTOIE PAS EN SILENCE. Effacer l'adresse au passage serait pire que la laisser :
14
+ // on croirait la donnée partie alors qu'elle n'a jamais été prévue. Une valeur inattendue à la
15
+ // sortie est un défaut de conception, pas une impureté à filtrer — donc on lève.
16
+
17
+ // Reconnaît une adresse par sa FORME, pas par le nom du champ. `author_email` était nommé ; les
18
+ // réactions ne l'étaient pas, et c'est là que l'adresse se cachait.
19
+ const ADRESSE = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/;
20
+
21
+ /**
22
+ * Rend l'objet tel quel s'il ne porte aucune adresse ; lève sinon, en NOMMANT le chemin.
23
+ *
24
+ * Le chemin compte plus que la valeur : « reactions.👍[0] » dit où regarder, « une adresse a été
25
+ * trouvée » envoie chercher.
26
+ */
27
+ /**
28
+ * ⚠️ LES CHAMPS QUE LES GENS RÉDIGENT, ET CEUX QUI DÉSIGNENT QUELQU'UN — la distinction que le
29
+ * premier branchement n'avait pas.
30
+ *
31
+ * Branchée sur tout, la garde levait sur `body` : un participant qui écrit « écris-moi à
32
+ * lea@exemple.fr » dans le chat voyait son message provoquer une exception. Elle transformait un
33
+ * usage normal en panne, et je l'ai mesuré plutôt que supposé.
34
+ *
35
+ * Une adresse DANS UN MESSAGE est du contenu : son auteur l'a écrite exprès, pour un destinataire
36
+ * qu'il a choisi. Une adresse dans un champ d'IDENTITÉ n'a été choisie par personne : elle vient
37
+ * de la base, et elle sort vers toute l'audience. Ce n'est pas la même donnée, et la garde ne doit
38
+ * pas les traiter pareil.
39
+ *
40
+ * ⚠️ LISTE DES CHAMPS DE CONTENU, PAS DES CHAMPS D'IDENTITÉ — et le sens de l'inversion est la
41
+ * garde elle-même. Énumérer ce qu'on protège laisse passer la colonne ajoutée demain ; énumérer
42
+ * ce qu'on EXEMPTE oblige à écrire une décision pour chaque exemption. Un champ inconnu est
43
+ * protégé par défaut.
44
+ */
45
+ const CONTENU = new Set(["body", "reply_text", "text", "message", "content", "caption"]);
46
+
47
+ function publier(valeur, ou = "") {
48
+ if (typeof valeur === "string") {
49
+ if (ADRESSE.test(valeur)) {
50
+ throw Object.assign(
51
+ new Error(`adresse refusée à la sortie publique : ${ou || "(racine)"}`),
52
+ { statusCode: 500, champ: ou },
53
+ );
54
+ }
55
+ return valeur;
56
+ }
57
+ if (Array.isArray(valeur)) { valeur.forEach((v, i) => publier(v, `${ou}[${i}]`)); return valeur; }
58
+ if (valeur && typeof valeur === "object") {
59
+ // ⚠️ Les CLÉS aussi : la carte des réactions portait les identités en clé chez d'autres hôtes,
60
+ // et une garde qui ne regarde que les valeurs laisserait passer exactement ce cas.
61
+ for (const [k, v] of Object.entries(valeur)) {
62
+ publier(k, ou ? `${ou}.<clé>` : "<clé>");
63
+ // Le champ que quelqu'un a rédigé traverse ; tout le reste est inspecté.
64
+ if (CONTENU.has(k)) continue;
65
+ publier(v, ou ? `${ou}.${k}` : k);
66
+ }
67
+ return valeur;
68
+ }
69
+ return valeur;
70
+ }
71
+
72
+ module.exports = { publier, ADRESSE, CONTENU };
package/supabase/init.sql CHANGED
@@ -50,7 +50,10 @@ create table if not exists public.commercial_doc_shares (
50
50
  brand_logo text, -- logo RECOPIÉ (flux historiques, toujours accepté)
51
51
  brand_dark boolean not null default false,
52
52
  require_auth boolean not null default false,
53
- brand_key text -- ⚠️ RÉFÉRENCE résolue à l'affichage (branding.forKey)
53
+ brand_key text, -- ⚠️ RÉFÉRENCE résolue à l'affichage (branding.forKey)
54
+ -- Destinataire attesté par l'hôte : sert à ATTRIBUER une lecture, jamais à expédier en son nom.
55
+ -- `recipient_email`, elle, dit qui peut expédier — vide quand personne ne le peut.
56
+ attested_recipient_email text
54
57
  );
55
58
  create index if not exists cds_doc_id_idx on public.commercial_doc_shares (doc_id);
56
59
  create index if not exists cds_parent_idx on public.commercial_doc_shares (parent_slug);
@@ -139,7 +142,11 @@ create table if not exists public.doc_presentations (
139
142
  owner_avatar text,
140
143
  owner_email text,
141
144
  last_seen timestamptz not null default now(),
142
- content jsonb -- carte / Street View (revalidé à la réception)
145
+ content jsonb, -- carte / Street View (revalidé à la réception)
146
+ -- Rang de la dernière écriture de pilotage acceptée. Une écriture de rang inférieur ou égal est
147
+ -- refusée : elle a été doublée en vol. Remis à zéro par toute émission d'un jeton de contrôle
148
+ -- (démarrage, reprise), qui ouvre un nouveau domaine d'ordre.
149
+ write_seq bigint not null default 0
143
150
  );
144
151
  create index if not exists doc_presentations_active_idx on public.doc_presentations (active, updated_at);
145
152
  create index if not exists doc_presentations_last_seen_idx on public.doc_presentations (active, last_seen);
@@ -163,9 +170,17 @@ create table if not exists public.doc_presentation_messages (
163
170
  reply_text text,
164
171
  deleted boolean not null default false,
165
172
  edited boolean not null default false,
166
- attachment jsonb
173
+ attachment jsonb,
174
+ -- Clé d'idempotence fabriquée par le client AVANT le premier envoi et réutilisée au renvoi :
175
+ -- un renvoi réseau ne crée pas un second message. Nulle si le client ne la fournit pas.
176
+ client_key text
167
177
  );
168
178
  create index if not exists dpm_slug_idx on public.doc_presentation_messages (slug, created_at);
179
+ -- ⚠️ Portée sur (slug, client_key), pas sur la clé seule : deux présentations n'ont aucune raison
180
+ -- de partager un espace de clés. Partiel, pour que les lignes sans clé ne se gênent pas entre elles.
181
+ create unique index if not exists dpm_client_key_uniq
182
+ on public.doc_presentation_messages (slug, client_key)
183
+ where client_key is not null;
169
184
 
170
185
  create table if not exists public.doc_presentation_attendees (
171
186
  slug text not null,
@@ -209,6 +224,74 @@ create table if not exists public.doc_bot_sessions (
209
224
  );
210
225
  create index if not exists doc_bot_sessions_share_idx on public.doc_bot_sessions (share_slug);
211
226
 
227
+ -- ── Limites de débit partagées ─────────────────────────────────────────────────────────────────
228
+ -- ⚠️ EN MÉMOIRE, UNE LIMITE NE LIMITE RIEN. Chaque instance serverless a la sienne : N instances
229
+ -- accordent N fois le quota, et l'hôte croit être protégé. Le compteur vit donc en base.
230
+ create table if not exists public.player_rate_limits (
231
+ key text primary key,
232
+ count integer not null default 0,
233
+ expires_at timestamptz not null
234
+ );
235
+ create index if not exists player_rate_limits_expires_idx
236
+ on public.player_rate_limits (expires_at);
237
+ alter table public.player_rate_limits enable row level security;
238
+ comment on table public.player_rate_limits is
239
+ 'Compteurs de débit partagés entre instances. Une ligne par clé et par fenêtre ; les lignes '
240
+ 'périmées sont écrasées à la première demande suivante, il n''y a rien à purger.';
241
+
242
+ -- ⚠️ ET LIRE PUIS ÉCRIRE N'EST PAS ATOMIQUE. Deux appels simultanés lisent le même compte et
243
+ -- écrivent la même valeur : la limite laisse passer le double. L'incrément se fait donc en UNE
244
+ -- instruction, côté serveur.
245
+ create or replace function public.player_rate_limit_bump(
246
+ p_key text,
247
+ p_max integer,
248
+ p_window_seconds integer
249
+ )
250
+ returns table (autorise boolean, compte integer)
251
+ language plpgsql
252
+ security definer
253
+ set search_path = public
254
+ as $$
255
+ declare
256
+ v_count integer;
257
+ begin
258
+ insert into public.player_rate_limits as l (key, count, expires_at)
259
+ values (p_key, 1, now() + make_interval(secs => p_window_seconds))
260
+ on conflict (key) do update
261
+ set count = case when l.expires_at <= now() then 1 else l.count + 1 end,
262
+ expires_at = case when l.expires_at <= now()
263
+ then now() + make_interval(secs => p_window_seconds)
264
+ else l.expires_at end
265
+ returning l.count into v_count;
266
+ return query select (v_count <= p_max), v_count;
267
+ end;
268
+ $$;
269
+ -- ⚠️ `anon` ET `authenticated` SONT DES RÔLES SUPABASE, PAS DES RÔLES POSTGRES. Les nommer en dur
270
+ -- faisait ÉCHOUER ce fichier sur un Postgres nu — donc chez tout hôte auto-hébergé, c'est-à-dire le
271
+ -- public que ce dépôt vise en s'ouvrant. Le `grant` ci-dessous était déjà gardé par un `if exists` ;
272
+ -- ce `revoke` ne l'était pas : la prudence s'arrêtait à mi-chemin. Trouvé par la garde de schéma.
273
+ --
274
+ -- `public` n'est pas un rôle mais un mot-clé : celui-là passe partout, et c'est le seul qui compte.
275
+ revoke all on function public.player_rate_limit_bump(text, integer, integer) from public;
276
+ do $$
277
+ declare
278
+ r text;
279
+ begin
280
+ foreach r in array array['anon', 'authenticated'] loop
281
+ if exists (select 1 from pg_roles where rolname = r) then
282
+ execute format('revoke all on function public.player_rate_limit_bump(text, integer, integer) from %I', r);
283
+ end if;
284
+ end loop;
285
+ end
286
+ $$;
287
+ do $$
288
+ begin
289
+ if exists (select 1 from pg_roles where rolname = 'service_role') then
290
+ grant execute on function public.player_rate_limit_bump(text, integer, integer) to service_role;
291
+ end if;
292
+ end
293
+ $$;
294
+
212
295
  -- ── Accès ──────────────────────────────────────────────────────────────────────────────────────
213
296
  -- RLS activé SANS politique permissive : seul `service_role` (donc la route du player) passe.
214
297
  -- ⚠️ N'ajoutez pas de politique de lecture publique « pour que le direct fonctionne ». C'est
@@ -252,3 +335,23 @@ begin
252
335
  end if;
253
336
  end $$;
254
337
  alter table public.doc_presentation_messages replica identity full;
338
+
339
+ -- ── Rattrapage : bases installées depuis un init.sql plus ancien ───────────────────────────────
340
+ --
341
+ -- ⚠️ CE FICHIER A ÉTÉ INCOMPLET, ET RIEN NE LE DISAIT. Il annonçait « un seul fichier, sans rien à
342
+ -- lire ailleurs » alors qu'aucune des cinq migrations de `supabase/migrations/` n'y figurait : un
343
+ -- hôte neuf installait une base sans rang d'écriture, sans limites partagées et sans clé
344
+ -- d'idempotence. Les sondes de schéma dégradent en silence — par conception, pour ne pas casser un
345
+ -- hôte en cours de migration — donc cet hôte-là ne l'apprenait JAMAIS. Un état anormal que rien ne
346
+ -- dit devient l'état normal.
347
+ --
348
+ -- Les colonnes sont désormais dans le corps des tables ci-dessus, ce qui règle le cas d'une base
349
+ -- VIERGE. Mais `create table if not exists` ne touche pas une table déjà là : une base créée
350
+ -- depuis l'ancien init.sql resterait incomplète en rejouant celui-ci. D'où ce rattrapage, qui ne
351
+ -- coûte rien sur une base neuve et rend le fichier vrai dans les deux cas.
352
+ alter table public.commercial_doc_shares
353
+ add column if not exists attested_recipient_email text;
354
+ alter table public.doc_presentations
355
+ add column if not exists write_seq bigint not null default 0;
356
+ alter table public.doc_presentation_messages
357
+ add column if not exists client_key text;
@@ -52,7 +52,24 @@ end;
52
52
  $$;
53
53
 
54
54
  -- Le player parle à la base avec la clé de service ; personne d'autre n'a à appeler ceci.
55
- revoke all on function public.player_rate_limit_bump(text, integer, integer) from public, anon, authenticated;
55
+ -- ⚠️ `anon` ET `authenticated` SONT DES RÔLES SUPABASE, PAS DES RÔLES POSTGRES. Les nommer en dur
56
+ -- faisait ÉCHOUER ce fichier sur un Postgres nu — donc chez tout hôte auto-hébergé, c'est-à-dire le
57
+ -- public que ce dépôt vise en s'ouvrant. Le `grant` ci-dessous était déjà gardé par un `if exists` ;
58
+ -- ce `revoke` ne l'était pas : la prudence s'arrêtait à mi-chemin. Trouvé par la garde de schéma.
59
+ --
60
+ -- `public` n'est pas un rôle mais un mot-clé : celui-là passe partout, et c'est le seul qui compte.
61
+ revoke all on function public.player_rate_limit_bump(text, integer, integer) from public;
62
+ do $$
63
+ declare
64
+ r text;
65
+ begin
66
+ foreach r in array array['anon', 'authenticated'] loop
67
+ if exists (select 1 from pg_roles where rolname = r) then
68
+ execute format('revoke all on function public.player_rate_limit_bump(text, integer, integer) from %I', r);
69
+ end if;
70
+ end loop;
71
+ end
72
+ $$;
56
73
 
57
74
  -- ⚠️ LE `revoke` CI-DESSUS NE SUFFIT PAS À RENDRE CE FICHIER VRAI TOUT SEUL — signalé par le
58
75
  -- second hôte, qui a interrogé `has_function_privilege` au lieu de relire la migration. Chez
@@ -0,0 +1,44 @@
1
+ -- DEUX ENVOIS DU MÊME MESSAGE NE DOIVENT PAS FAIRE DEUX MESSAGES.
2
+ --
3
+ -- Un renvoi réseau, un double-clic, une reprise après délai : la requête part deux fois et la base
4
+ -- enregistre deux lignes. Le participant voit son message en double, et rien ne le lui explique —
5
+ -- il n'y a eu aucune erreur, seulement un succès de trop.
6
+ --
7
+ -- ⚠️ LA CLÉ EST FABRIQUÉE PAR LE CLIENT, UNE FOIS, AVANT LE PREMIER ENVOI — et c'est toute la
8
+ -- subtilité. Une clé tirée à chaque tentative ne servirait à rien : les deux envois porteraient des
9
+ -- clés différentes et passeraient tous les deux. C'est la RÉUTILISATION de la clé au renvoi qui
10
+ -- rend l'opération idempotente, pas la clé elle-même.
11
+ --
12
+ -- ⚠️ INDEX PARTIEL, et il faut comprendre pourquoi. Les lignes déjà en base n'ont pas de clé ; un
13
+ -- index unique ordinaire les traiterait comme des doublons entre elles (plusieurs NULL sont
14
+ -- distincts en SQL, donc en réalité ça passerait — mais la condition rend l'intention explicite et
15
+ -- protège d'un futur `not null default ''`, où toutes les anciennes lignes deviendraient soudain
16
+ -- identiques). L'index ne contraint que ce que le nouveau client écrit.
17
+ --
18
+ -- ⚠️ CE QUE LA BASE FAIT, ET CE QUE LE CODE DOIT FAIRE. La contrainte REFUSE le second envoi ;
19
+ -- PostgREST répond alors 409. Ce refus n'est pas une erreur à remonter au participant : c'est la
20
+ -- preuve que son message est déjà là. Le serveur doit donc relire la ligne existante et la rendre,
21
+ -- comme si l'envoi avait réussi — sinon on remplace « deux messages » par « une erreur », ce qui
22
+ -- n'est pas mieux. Cette partie-là est du code, elle n'est pas dans ce fichier.
23
+ --
24
+ -- Sans lui : rien ne change, deux envois du même message créent toujours deux lignes. Le client
25
+ -- peut envoyer la clé, la colonne l'ignore poliment — aucune écriture ne casse, et le player ne
26
+ -- signale rien puisque l'ancien comportement EST le comportement d'aujourd'hui.
27
+ --
28
+ -- Applicable pendant que la version précédente tourne : tant que personne n'écrit la colonne, elle
29
+ -- reste vide et l'index ne contraint rien.
30
+
31
+ alter table public.doc_presentation_messages
32
+ add column if not exists client_key text;
33
+
34
+ comment on column public.doc_presentation_messages.client_key is
35
+ 'Clé d''idempotence fabriquée par le client AVANT le premier envoi et réutilisée au renvoi. '
36
+ 'Unique par présentation (index partiel dpm_client_key_uniq). Nulle sur les lignes antérieures '
37
+ 'à la migration 0005, et sur celles écrites par un client qui ne la fournit pas.';
38
+
39
+ -- ⚠️ Portée sur (slug, client_key) et pas sur la clé seule : deux présentations différentes n'ont
40
+ -- aucune raison de partager un espace de clés, et un client qui réutiliserait par accident la même
41
+ -- valeur d'une session à l'autre verrait son message refusé sans comprendre.
42
+ create unique index if not exists dpm_client_key_uniq
43
+ on public.doc_presentation_messages (slug, client_key)
44
+ where client_key is not null;