discovery-media-player 0.1.59 → 0.1.61

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.
@@ -63,7 +63,14 @@ function creerDb(env) {
63
63
  },
64
64
  body: options.body ? JSON.stringify(options.body) : undefined,
65
65
  });
66
- if (!r.ok) throw new Error(`Supabase ${methode} ${chemin} → ${r.status}`);
66
+ if (!r.ok) {
67
+ // ⚠️ LE CORPS DIT POURQUOI, LE CODE NE DIT QUE COMBIEN. Un « 400 » nu a coûté un aller-retour
68
+ // de forge complet pour apprendre ce que PostgREST avait écrit dans sa réponse depuis le
69
+ // début. Tronqué : c'est un message d'erreur, pas un relais de données.
70
+ let detail = "";
71
+ try { detail = (await r.text()).slice(0, 300); } catch { /* le statut restera seul */ }
72
+ throw new Error(`Supabase ${methode} ${chemin} → ${r.status}${detail ? ` — ${detail}` : ""}`);
73
+ }
67
74
  const texte = await r.text();
68
75
  return texte ? JSON.parse(texte) : null;
69
76
  }
@@ -173,6 +173,15 @@ async function lireDepuis(fh, stat, cible, range) {
173
173
  statut = 206;
174
174
  }
175
175
 
176
+ // ⚠️ LE PLAFOND AVANT L'ALLOCATION — parce que c'est l'allocation qui coûte. Le contrôle du
177
+ // relais (Content-Length, dans le handler) arrivait APRÈS ce Buffer.alloc : un fichier local plus
178
+ // grand que le plafond était alloué EN ENTIER à chaque requête, puis refusé — le refus payait
179
+ // exactement le prix qu'il devait éviter. Lu à l'appel et non au chargement : le serveur autonome
180
+ // fabrique son contexte à l'import, avant que l'exploitant ait pu poser la variable.
181
+ const plafond = Number(process.env.PLAYER_MAX_RELAY_BYTES || 0) || 60 * 1024 * 1024;
182
+ if (fin - debut + 1 > plafond) {
183
+ return reponse(413, {}, Buffer.alloc(0));
184
+ }
176
185
  const buf = Buffer.alloc(fin - debut + 1);
177
186
  const { bytesRead } = await fh.read(buf, 0, buf.length, debut);
178
187
  const corps = bytesRead === buf.length ? buf : buf.subarray(0, bytesRead);
@@ -32,7 +32,7 @@ need.
32
32
  "hostShare": true,
33
33
  "hostMail": true,
34
34
  "plugins": { "bot": false, "visitors": false, "brandIntro": false, "botBrowser": false, "providerQuotas": false },
35
- "schema": { "attendues": 3, "sondees": 1, "verdict": "partiel", "manquant": [] }
35
+ "schema": { "couvre": "colonnes-conditionnelles", "attendues": 4, "sondees": 1, "verdict": "partiel", "manquant": [] }
36
36
  }
37
37
  ```
38
38
 
@@ -65,11 +65,37 @@ it. `verdict` is then one of:
65
65
  | `partiel` | some expectations checked, none of them missing |
66
66
  | `complet` | all checked, all present |
67
67
  | `incomplet` | at least one is missing — `manquant` names the file and the sleeping feature |
68
+
69
+ Each `manquant` entry has **exactly this shape** — pin your parser to it, not to what a schema
70
+ probe "should" return:
71
+
72
+ ```json
73
+ { "migration": "supabase/migrations/0006-reactions-ordonnees.sql",
74
+ "fonction": "empêcher deux réactions simultanées de s'écraser" }
75
+ ```
76
+
77
+ ⚠️ This shape went **undocumented** for two releases, and the second host typed it from memory as
78
+ `{table, colonne, migration}` — their monitoring filter then discarded every real entry and would
79
+ have shown an empty table on a database that was actually missing something. **No mutation on
80
+ their side could catch it: their test built the card the same wrong way, so the bench validated
81
+ the assumption instead of the behaviour.** The only cure was reading data they had not fabricated.
82
+ If you consume this card, test your parser against the JSON above, not against a fixture you wrote.
83
+
84
+ ⚠️ **A card without a `schema` field is an alert, not a success**: it signals an instance older
85
+ than 0.1.58 — a version that cannot answer the question. (Rule contributed by the second host, for
86
+ exactly the monitoring case where "no data" would otherwise read as "all clear".)
68
87
  | `indetermine` | the database did not answer; this measurement did not happen |
69
88
 
70
89
  ⚠️ **`incomplet` wins over `partiel`**: a missing column is a positive fact and settles the verdict
71
90
  on its own, even when the rest has not been checked.
72
91
 
92
+ ⚠️ **`couvre` states the scope, because `complet` without a scope overpromises.** The card checks
93
+ the **conditional columns** — the ones a feature probes before writing. The rate-limit migrations
94
+ (`0003`, `0004`) are deliberately **not** in it: a host may provide its own `limits` capability, and
95
+ on such a host their absence is normal, not a defect. `complet` therefore never means *every file
96
+ under `supabase/` has been applied* — read it as *every column this code conditions its writes on
97
+ is present*.
98
+
73
99
  ⚠️ **`indetermine` is not a failure of the card.** A control column — the primary key of the oldest
74
100
  table — is queried first. If *it* does not answer, nothing is missing: the database is. Without that
75
101
  control, an unreachable database would make all three probes fail and the card would announce three
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "discovery-media-player",
3
- "version": "0.1.59",
3
+ "version": "0.1.61",
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",
@@ -4,4 +4,4 @@
4
4
  // Injecté par api/doc.js dans un <script nonce=…> de la visionneuse. Le code navigateur du
5
5
  // player vit en modules TypeScript testés sous player/src/ — c'est ici qu'il atterrit.
6
6
  "use strict";
7
- module.exports = { PLAYER_BROWSER_JS: "\"use strict\";var Player=(()=>{var C=Object.defineProperty,He=Object.defineProperties,De=Object.getOwnPropertyDescriptor,We=Object.getOwnPropertyDescriptors,Be=Object.getOwnPropertyNames,he=Object.getOwnPropertySymbols;var we=Object.prototype.hasOwnProperty,Ve=Object.prototype.propertyIsEnumerable;var ve=(e,n,t)=>n in e?C(e,n,{enumerable:!0,configurable:!0,writable:!0,value:t}):e[n]=t,G=(e,n)=>{for(var t in n||(n={}))we.call(n,t)&&ve(e,t,n[t]);if(he)for(var t of he(n))Ve.call(n,t)&&ve(e,t,n[t]);return e},Me=(e,n)=>He(e,We(n));var v=(e,n)=>{for(var t in n)C(e,t,{get:n[t],enumerable:!0})},ze=(e,n,t,r)=>{if(n&&typeof n==\"object\"||typeof n==\"function\")for(let o of Be(n))!we.call(e,o)&&o!==t&&C(e,o,{get:()=>n[o],enumerable:!(r=De(n,o))||r.enumerable});return e};var Fe=e=>ze(C({},\"__esModule\",{value:!0}),e);var Gn={};v(Gn,{bridge:()=>L,cadence:()=>N,chat:()=>$,live:()=>U,presentation:()=>H,presentationState:()=>W,tracking:()=>O,viewer:()=>D});var L={};v(L,{onHostMessage:()=>en,onPlayerMessage:()=>Je,parseHostMessage:()=>_e,parsePlayerMessage:()=>Te,sendToHost:()=>qe,sendToPlayer:()=>Qe,toWire:()=>Y});var K=\"3dd-doc-\",je=/^[A-Za-z0-9_-]{1,64}$/,Ge=[\"close\",\"share\",\"embed-ready\",\"present-left\",\"present-denied\"],Ke=[\"embed-denied\"],Ye=[\"present-invite\",\"present-handover\",\"present-switch\"],Ze=[\"handover-done\"];function Y(e){let n={type:K+e.type};return\"slug\"in e&&e.slug&&(n.slug=e.slug),\"reason\"in e&&e.reason&&(n.reason=String(e.reason).slice(0,40)),n}function xe(e,n,t){if(!e||typeof e!=\"object\")return null;let r=e.type;if(typeof r!=\"string\"||!r.startsWith(K))return null;let o=r.slice(K.length);if(n.includes(o))return{type:o};if(Ke.includes(o)){let a=e.reason;return{type:o,reason:/^[a-z-]{1,40}$/.test(String(a||\"\"))?String(a):\"unknown\"}}if(!t.includes(o))return null;let s=e.slug;return typeof s!=\"string\"||!je.test(s)?null:{type:o,slug:s}}function Te(e){return xe(e,Ge,Ye)}function _e(e){return xe(e,Ze,[])}function qe(e,n){try{let t=n||(typeof window!=\"undefined\"?window.parent:null);t&&t.postMessage(Y(e),\"*\")}catch(t){}}function Qe(e,n){var t;try{(t=e==null?void 0:e.contentWindow)==null||t.postMessage(Y(n),\"*\")}catch(r){}}function Xe(e,n){var r;if(!e)return!0;let t=(r=e.contentWindow)!=null?r:e;return n===t}function Je(e,n,t){let r=n||(typeof window!=\"undefined\"?window:null);if(!r)return()=>{};let o=s=>{if(!Xe(t,s.source))return;let a=Te(s.data);a&&e(a)};return r.addEventListener(\"message\",o),()=>r.removeEventListener(\"message\",o)}function en(e,n){var s;let t=n||(typeof window!=\"undefined\"?window:null);if(!t)return()=>{};let r=(s=t.parent)!=null?s:null,o=a=>{if(!r||r===t||a.source!==r)return;let u=_e(a.data);u&&e(u)};return t.addEventListener(\"message\",o),()=>t.removeEventListener(\"message\",o)}var O={};v(O,{createTracker:()=>ln});var N={};v(N,{PRESENTER_ACTIONS_PER_HOUR:()=>X,PRESENT_CACHE_MS:()=>rn,PRESENT_QUOTA_MARGIN:()=>Ie,PRESENT_QUOTA_PER_HOUR:()=>on,PRESENT_READS_PER_HOUR:()=>Re,PRESENT_READ_BURST:()=>Pe,PRESENT_READ_COALESCE_MS:()=>Ae,PRESENT_RESYNC_MS:()=>Se,PRESENT_SIGNAL_BUDGET_PER_HOUR:()=>tn,PRESENT_WRITE_TIMEOUT_MS:()=>J,READERS_PER_EGRESS:()=>q,RESYNC_READS_PER_HOUR:()=>ke,SESSION_IDLE_MS:()=>Q,SESSION_INTERVAL_MS:()=>Z,SESSION_QUOTA_PER_HOUR:()=>nn,SESSION_WRITES_PER_HOUR:()=>Ee});var Z=12e3,Ee=Math.ceil(300),q=25,nn=Ee*q,Q=18e4,Se=25e3,ke=Math.ceil(36e5/Se),X=720,Re=ke+X,tn=X,Pe=20,J=1e4,Ae=400,rn=Ae,Ie=4,on=(Re+Pe)*q*Ie;var sn=[\"mousemove\",\"mousedown\",\"keydown\",\"wheel\",\"touchstart\",\"pointerdown\"];function an(){let e=typeof globalThis!=\"undefined\"?globalThis.crypto:void 0;if(e&&typeof e.randomUUID==\"function\")return e.randomUUID().replace(/-/g,\"\");if(e&&typeof e.getRandomValues==\"function\"){let n=e.getRandomValues(new Uint8Array(16));return Array.from(n,t=>t.toString(16).padStart(2,\"0\")).join(\"\")}return Math.random().toString(36).slice(2)+Date.now().toString(36)}function un(e,n){return t=>{try{let r=JSON.stringify(t),o=n.navigator;o&&typeof o.sendBeacon==\"function\"?o.sendBeacon(e,new Blob([r],{type:\"application/json\"})):n.fetch(e,{method:\"POST\",headers:{\"Content-Type\":\"application/json\"},body:r,keepalive:!0})}catch(r){}}}function ln(e={}){var ge,be;let n=e.win||(typeof window!=\"undefined\"?window:null),t=e.doc||n&&n.document,r=e.now||(()=>Date.now()),o=e.endpoint||\"/api/doc\",s=(ge=e.idleMs)!=null?ge:Q,a=(be=e.sessionEveryMs)!=null?be:12e3,u=e.slug||\"\",d=e.internal||null,m=e.sessionId||an(),p=e.send||un(o,n),c={},l=0,g=0,f=0,w=0,h=null,E=r(),S=!1,B=!1,A=[],$e=()=>!!u||!!d,ce=i=>{if($e())try{p(i)}catch(b){}},V=()=>!!t&&t.visibilityState===\"visible\"&&!S,de=i=>{let b=r()-i,x=Math.max(0,E-i)+s;return Math.max(0,Math.min(b,x))},z=()=>{h!=null&&l>0&&(c[l]=(c[l]||0)+de(h)/1e3),h=V()?r():null},pe=()=>{z(),h=null},F=()=>{h==null&&V()&&(h=r())},j=()=>{let i=0;for(let b in c)i+=c[b];return h!=null&&(i+=de(h)/1e3),Math.round(i)},fe=()=>{let i={};for(let b in c)i[b]=Math.round(c[b]);return i},k=()=>{E=r(),S&&(S=!1,F())},me=(i,b)=>{d||ce({slug:u,event:i,page:b||g,maxPage:f,seconds:j(),sessionId:m})},I=()=>{z();let i={event:\"session\",sessionId:m,numPages:w,maxPage:f,totalSeconds:j(),pagesTime:fe()};d?(i.internal=!0,i.docId=d.docId,i.email=d.email,i.name=d.name,d.it&&(i.it=d.it)):i.slug=u,ce(i)},R=(i,b,x,ye)=>{!i||typeof i.addEventListener!=\"function\"||(i.addEventListener(b,x,ye),A.push(()=>{i.removeEventListener(b,x,ye)}))};return{sessionId:m,totalSeconds:j,maxPage:()=>f,pageTimes:fe,start(){if(B)return;B=!0,me(\"open\",1),l=1,E=r(),h=V()?r():null,R(t,\"visibilitychange\",()=>{t.visibilityState===\"hidden\"?(pe(),I()):(k(),F())}),R(n,\"focus\",()=>{k(),F()});for(let x of sn)R(n,x,k,{passive:!0});R(e.scrollElement,\"scroll\",k,{passive:!0}),R(n,\"beforeunload\",I);let i=n.setInterval(()=>{!S&&r()-E>s&&(S=!0,pe())},5e3),b=n.setInterval(I,a);A.push(()=>{n.clearInterval(i),n.clearInterval(b)})},setPage(i){!i||i===g||(E=r(),z(),l=i,g=i,i>f&&(f=i,me(\"page\",i)))},setPageCount(i){w=i||0},noteActivity:k,flush:I,stop(){for(;A.length;){let i=A.pop();try{i==null||i()}catch(b){}}B=!1}}}var U={};v(U,{STORAGE_KEYS:()=>M,attendeeKey:()=>fn,authorToken:()=>mn,avatarHtml:()=>ee,canModerate:()=>oe,createBudget:()=>Mn,createFileEcritures:()=>xn,createScheduler:()=>vn,escapeHtml:()=>y,fetchBorne:()=>wn,flattenPresence:()=>cn,formatMessageBody:()=>ne,initials:()=>Ce,isMentioned:()=>bn,isMine:()=>T,presenceId:()=>Tn,reactorId:()=>re,referenceAuteur:()=>gn,shouldNotify:()=>yn,unreadLabel:()=>hn});var M={attendeeKey:\"3dd-present-attkey\",authorToken:\"3dd-present-authtoken\",chatMuted:\"3dd-present-mute\",presenceId:\"3dd-present-uid\"};function y(e){return String(e==null?\"\":e).replace(/&/g,\"&amp;\").replace(/</g,\"&lt;\").replace(/>/g,\"&gt;\").replace(/\"/g,\"&quot;\")}function Ce(e){let n=(e||\"?\").trim().split(/\\s+/);return(((n[0]||\"\")[0]||\"?\")+((n[1]||\"\")[0]||\"\")).toUpperCase()}function ee(e,n){return e?`<img src=\"${y(e)}\" alt=\"\">`:y(Ce(n))}function ne(e){let n=y(e);return n=n.replace(/(https?:\\/\\/[^\\s<]+)/g,t=>`<a href=\"${t}\" target=_blank rel=noreferrer class=cm-link>${t}</a>`),n=n.replace(/(^|\\s)@([\\p{L}0-9_'.-]+)/gu,(t,r,o)=>`${r}<span class=cm-mention>@${o}</span>`),n}function cn(e){let n=new Map;for(let t in e||{})for(let r of(e||{})[t]||[]){let o=String(r.uid||r.email||r.name||\"\").toLowerCase()||`_${t}_${n.size}`;(!n.get(o)||r.role===\"presenter\")&&n.set(o,r)}return[...n.values()]}function dn(e,n){try{return e?e.getItem(n):null}catch(t){return null}}function pn(e,n,t){try{e==null||e.setItem(n,t)}catch(r){}}function fn(e,n=\"anon\",t=te){try{if(!e)return`anon-${n}`;let r=e.getItem(M.attendeeKey);if(r)return r;let o=`anon-${t()}`;return e.setItem(M.attendeeKey,o),o}catch(r){return`anon-${n}`}}function te(){let e=typeof globalThis!=\"undefined\"?globalThis.crypto:void 0;if(e&&typeof e.randomUUID==\"function\")return e.randomUUID().replace(/-/g,\"\");if(e&&typeof e.getRandomValues==\"function\"){let n=e.getRandomValues(new Uint8Array(24));return Array.from(n,t=>t.toString(16).padStart(2,\"0\")).join(\"\")}try{console.warn(\"[player] crypto indisponible : jeton d'auteur affaibli\")}catch(n){}return Math.random().toString(36).slice(2)+Math.random().toString(36).slice(2)}function mn(e,n=te){let t=dn(e,M.authorToken);if(t)return t;let r=n();return pn(e,M.authorToken,r),r}async function gn(e,n){let t=n!==void 0?n:typeof globalThis!=\"undefined\"&&globalThis.crypto?globalThis.crypto.subtle:null;if(!t||!e)return\"\";try{let r=async o=>{let s=await t.digest(\"SHA-256\",new TextEncoder().encode(o));return Array.from(new Uint8Array(s),a=>a.toString(16).padStart(2,\"0\")).join(\"\")};return(await r(\"ref:\"+await r(e))).slice(0,16)}catch(r){return\"\"}}function re(e){return String(e&&e.ref||\"\")}function T(e,n){return!e||!n||!e.author_ref||!n.ref?!1:e.author_ref===n.ref}function oe(e){return!!(e&&e.role===\"presenter\")}function bn(e,n){if(!n||!n.name)return!1;let t=(n.name.split(/\\s+/)[0]||\"\").toLowerCase();return t?String(e&&e.body||\"\").toLowerCase().includes(`@${t}`)&&!T(e,n):!1}function yn(e){let{msg:n,me:t,historyLoaded:r,chatHidden:o}=e;return!r||!n||n.deleted||T(n,t)?!1:o}function hn(e){return!e||e<=0?\"\":e>9?\"9+\":String(e)}function vn(e,n={}){var g;let t=(g=n.minMs)!=null?g:300,r=n.now||(()=>Date.now()),o=n.setTimer||((f,w)=>setTimeout(f,w)),s=n.clearTimer||(f=>clearTimeout(f)),a=!1,u=!1,d=-1/0,m=null,p=!1;function c(){if(p||a){u=!0;return}let f=t-(r()-d);if(f>0){l(f);return}a=!0,u=!1,d=r();let w=!1;e(()=>{w||(w=!0,a=!1,u&&!p&&c())})}function l(f){p||m!==null||(m=o(()=>{m=null,c()},Math.max(0,f)))}return{signaler(){p||c()},maintenant(){p||(d=-1/0,c())},arreter(){p=!0,m!==null&&(s(m),m=null)}}}function wn(e,n,t=J,r={}){let o=r.fetch||(typeof fetch==\"function\"?fetch:void 0);if(!o)return Promise.reject(new Error(\"fetch indisponible\"));let s=r.setTimer||((l,g)=>setTimeout(l,g)),a=r.clearTimer||(l=>clearTimeout(l)),u=null;try{u=new AbortController}catch(l){}let d=u?Me(G({},n||{}),{signal:u.signal}):G({},n||{}),m=null,p=new Promise((l,g)=>{m=s(()=>{try{u==null||u.abort()}catch(f){}g(new Error(\"delai\"))},t)}),c=()=>{try{a(m)}catch(l){}};return Promise.race([o(e,d),p]).then(l=>(c(),l),l=>{throw c(),l})}function Mn(e){let n=e.now||(()=>Date.now()),t=Math.max(1,e.rafale),r=t,o=n();return{prendre(){let s=n();return r=Math.min(t,r+(s-o)*e.parHeure/36e5),o=s,r<1?!1:(r-=1,!0)},restants:()=>r}}function xn(e={}){var m;let n=(m=e.minMs)!=null?m:0,t=e.now||(()=>Date.now()),r=e.setTimer||((p,c)=>setTimeout(p,c)),o=new Map,s=!1,a=-1/0,u=!1;function d(){if(s||o.size===0)return;let p=n-(t()-a);if(p>0){if(u)return;u=!0,r(()=>{u=!1,d()},p);return}let c=o.keys().next().value,l=o.get(c);o.delete(c),s=!0,a=t(),Promise.resolve().then(l.tache).then(g=>l.regler(g),()=>l.regler(null)).then(()=>{s=!1,d()})}return{poser(p,c){return new Promise(l=>{let g=o.get(p);o.set(p,{tache:c,regler:f=>{g&&g.regler(f),l(f)}}),d()})},enAttente:()=>o.size}}function Tn(e,n=te){try{if(e){let t=e.getItem(M.presenceId);if(t)return t;let r=`p-${n()}`;return e.setItem(M.presenceId,r),r}}catch(t){}return`p-${n()}`}var $={};v($,{messageClassName:()=>En,renderActions:()=>Oe,renderAttachment:()=>Ne,renderMessage:()=>_n,renderReactions:()=>Le});function Le(e,n){let t=e&&e.reactions||{},r=re(n),o=\"\";for(let s in t){let a=t[s]||[];if(!a.length)continue;let u=a.indexOf(r)>=0;o+=`<button class=\"re-chip${u?\" on\":\"\"}\" data-e=\"${y(s)}\">${y(s)} ${a.length}</button>`}return o}function Ne(e){if(!e||!e.url)return\"\";let n=y(e.url);if(e.kind===\"image\")return`<a class=cm-att href=\"${n}\" target=_blank rel=noreferrer><img src=\"${n}\" alt=\"\" loading=lazy></a>`;let t=y(e.name||\"Document.pdf\");return`<a class=cm-att-pdf href=\"${n}\" target=_blank rel=noreferrer data-pdf=\"${n}\"><span class=cm-att-ph></span><span class=cm-pdflabel>${t}</span></a>`}function Oe(e,n){let t=T(e,n.me),r=`<button class=cm-react title=\"R\\xE9agir\">${n.reactIcon||\"\"}</button><button class=cm-reply title=\"R\\xE9pondre\">\\u21A9</button>`;return t&&(r+='<button class=cm-edit title=\"Modifier\">\\u270E</button>'),(t||oe(n.me))&&(r+='<button class=cm-del-btn title=\"Supprimer\">\\u{1F5D1}</button>'),r}function _n(e,n){if(e.deleted)return'<span class=a></span><span class=b><div class=\"txt cm-del\">Message supprim\\xE9</div></span>';let t=e.reply_to?`<div class=cm-q><b>${y(e.reply_name||\"\")}</b> ${y(e.reply_text||\"\")}</div>`:\"\",r=e.edited?\" <span class=cm-ed>(modifi\\xE9)</span>\":\"\",o=e.body?`<div class=txt>${ne(e.body)}${r}</div>`:\"\",s=e.is_presenter?\" <span class=tag>pr\\xE9sentateur</span>\":\"\";return`<span class=a>${ee(e.author_avatar,e.author_name)}</span><span class=b><div class=who><b>${y(e.author_name||\"Invit\\xE9\")}</b>${s}</div>`+t+o+Ne(e.attachment)+`<div class=cm-re>${Le(e,n.me)}</div></span><span class=cm-act>${Oe(e,n)}</span>`}function En(e,n,t){return\"cm\"+(T(e,n)?\" me\":\"\")+(e.deleted?\" isdel\":\"\")+(t?\" mentioned\":\"\")}var H={};v(H,{DEFAULT_CENTER:()=>ae,DEFAULT_ZOOM:()=>ue,MAP_TYPES:()=>P,cycleMapType:()=>Sn,initialMapContent:()=>Rn,mapTypeLabel:()=>kn,sanitizeContent:()=>le});var P=[\"roadmap\",\"satellite\",\"hybrid\"],ae=[46.6,2.5],ue=6;function _(e){let n=Number(e);return Number.isFinite(n)?n:null}function se(e){if(!Array.isArray(e))return null;let n=_(e[0]),t=_(e[1]);return n!=null&&t!=null?[n,t]:null}var ie=(e,n,t)=>Math.max(n,Math.min(t,e));function le(e){if(!e||typeof e!=\"object\")return null;let n=e;if(n.kind===\"pdf\"||n.kind==null)return null;if(n.kind===\"streetview\"){let t=se(n.position);if(!t)return null;let r=n.pov&&typeof n.pov==\"object\"?n.pov:null,o=r?{heading:_(r.heading)||0,pitch:ie(_(r.pitch)||0,-90,90)}:{heading:0,pitch:0};return{kind:\"streetview\",position:t,pov:o,zoom:ie(_(n.zoom)||1,0,5)}}return n.kind!==\"map\"?null:{kind:\"map\",center:se(n.center)||[...ae],zoom:ie(Math.trunc(_(n.zoom)||ue),1,21),marker:se(n.marker),mapType:P.includes(n.mapType)?n.mapType:null,label:String(n.label||\"\").slice(0,160)||null}}function Sn(e){let n=P.indexOf(e);return P[(n+1)%P.length]}function kn(e){return e===\"roadmap\"?\"\\u{1F6F0} Satellite\":e===\"satellite\"?\"\\u{1F5FA} Hybride\":\"\\u{1F5FA} Plan\"}function Rn(){return{kind:\"map\",center:[...ae],zoom:ue,marker:null,mapType:null,label:null}}var D={};v(D,{CURRENT_PAGE_MARGIN:()=>Nn,DEFAULT_ASPECT:()=>In,MAX_ZOOM:()=>An,MIN_PAGE_WIDTH:()=>Cn,MIN_ZOOM:()=>Pn,PRERENDER_MARGIN:()=>Ln,arrowState:()=>$n,averageColor:()=>Wn,clampPage:()=>Ue,clampZoom:()=>On,fitWidth:()=>Dn,isImageDocument:()=>Bn,progressPercent:()=>Un,workerBlobUrl:()=>Vn});var Pn=.5,An=3,In=1.35,Cn=280,Ln=\"500px 0px\",Nn=\"-48% 0px -48% 0px\";function On(e){let n=Math.round((Number(e)||0)*10)/10;return Math.max(.5,Math.min(3,n))}function Ue(e,n){return Math.max(1,Math.min(n||1,Number(e)||1))}function Un(e,n){return n?Math.round(Ue(e,n)/n*100):0}function $n(e,n){return{prevDisabled:e<=1,nextDisabled:!!n&&e>=n}}var Hn=28;function Dn(e){var a;let n=(a=e.zoom)!=null?a:1,t=Math.max(280,e.containerWidth||900)*n;if(!e.onePage)return t;let r=e.aspect||1.35,s=((e.containerHeight||600)-Hn-(e.overlap||0)-(e.reserve||0))/r;return s>120?Math.min(t,s):t}function Wn(e){let n=e.length/4;if(!n)return\"rgb(0,0,0)\";let t=0,r=0,o=0;for(let s=0;s<e.length;s+=4)t+=e[s],r+=e[s+1],o+=e[s+2];return`rgb(${Math.round(t/n)},${Math.round(r/n)},${Math.round(o/n)})`}function Bn(e,n){return/[.](png|jpe?g|webp|gif|avif)($|[?])/i.test(String(e||n||\"\"))}async function Vn(e,n,t={}){let r=t.fetch||(typeof fetch==\"function\"?(a=>fetch(a)):null),o=t.subtle!==void 0?t.subtle:typeof globalThis!=\"undefined\"&&globalThis.crypto?globalThis.crypto.subtle:null,s=t.creerUrl||(a=>URL.createObjectURL(a));if(!r||!o||!n)return null;try{let a=await r(e);if(!a.ok)return null;let u=await a.arrayBuffer(),d=await o.digest(\"SHA-384\",u);return`sha384-${zn(new Uint8Array(d))}`!==n?null:s(new Blob([u],{type:\"application/javascript\"}))}catch(a){return null}}function zn(e){let n=\"\";for(let t of e)n+=String.fromCharCode(t);return btoa(n)}var W={};v(W,{presentationTransition:()=>Fn,switchDocUrl:()=>jn});function Fn(e,n={}){if(!e)return[{kind:\"nothing\"}];if(e.active===!1)return[{kind:\"ended\"}];let t=le(e.content);if(t)return[{kind:\"show-map\",content:t}];let r=[{kind:\"leave-map\"}];return e.file_url&&e.file_url!==n.docUrl?(r.push({kind:\"switch-doc\",url:e.file_url,name:e.file_name||void 0,title:e.doc_title||void 0,updatedAt:e.updated_at||void 0}),r):(e.current_page&&r.push({kind:\"show-page\",page:e.current_page}),r)}function jn(e,n){let t=encodeURIComponent(n||String(Date.now()));return`${e}${e.includes(\"?\")?\"&\":\"?\"}v=${t}`}return Fe(Gn);})();", SOURCE_HASH: "5bacfcb0f5b69825" };
7
+ module.exports = { PLAYER_BROWSER_JS: "\"use strict\";var Player=(()=>{var C=Object.defineProperty,He=Object.defineProperties,De=Object.getOwnPropertyDescriptor,We=Object.getOwnPropertyDescriptors,Be=Object.getOwnPropertyNames,he=Object.getOwnPropertySymbols;var we=Object.prototype.hasOwnProperty,Ve=Object.prototype.propertyIsEnumerable;var ve=(e,n,t)=>n in e?C(e,n,{enumerable:!0,configurable:!0,writable:!0,value:t}):e[n]=t,G=(e,n)=>{for(var t in n||(n={}))we.call(n,t)&&ve(e,t,n[t]);if(he)for(var t of he(n))Ve.call(n,t)&&ve(e,t,n[t]);return e},Me=(e,n)=>He(e,We(n));var v=(e,n)=>{for(var t in n)C(e,t,{get:n[t],enumerable:!0})},ze=(e,n,t,r)=>{if(n&&typeof n==\"object\"||typeof n==\"function\")for(let o of Be(n))!we.call(e,o)&&o!==t&&C(e,o,{get:()=>n[o],enumerable:!(r=De(n,o))||r.enumerable});return e};var Fe=e=>ze(C({},\"__esModule\",{value:!0}),e);var Gn={};v(Gn,{bridge:()=>L,cadence:()=>N,chat:()=>$,live:()=>U,presentation:()=>H,presentationState:()=>W,tracking:()=>O,viewer:()=>D});var L={};v(L,{onHostMessage:()=>en,onPlayerMessage:()=>Je,parseHostMessage:()=>_e,parsePlayerMessage:()=>Te,sendToHost:()=>qe,sendToPlayer:()=>Qe,toWire:()=>Y});var K=\"3dd-doc-\",je=/^[A-Za-z0-9_-]{1,64}$/,Ge=[\"close\",\"share\",\"embed-ready\",\"present-left\",\"present-denied\"],Ke=[\"embed-denied\"],Ye=[\"present-invite\",\"present-handover\",\"present-switch\"],Ze=[\"handover-done\"];function Y(e){let n={type:K+e.type};return\"slug\"in e&&e.slug&&(n.slug=e.slug),\"reason\"in e&&e.reason&&(n.reason=String(e.reason).slice(0,40)),n}function xe(e,n,t){if(!e||typeof e!=\"object\")return null;let r=e.type;if(typeof r!=\"string\"||!r.startsWith(K))return null;let o=r.slice(K.length);if(n.includes(o))return{type:o};if(Ke.includes(o)){let a=e.reason;return{type:o,reason:/^[a-z-]{1,40}$/.test(String(a||\"\"))?String(a):\"unknown\"}}if(!t.includes(o))return null;let s=e.slug;return typeof s!=\"string\"||!je.test(s)?null:{type:o,slug:s}}function Te(e){return xe(e,Ge,Ye)}function _e(e){return xe(e,Ze,[])}function qe(e,n){try{let t=n||(typeof window!=\"undefined\"?window.parent:null);t&&t.postMessage(Y(e),\"*\")}catch(t){}}function Qe(e,n){var t;try{(t=e==null?void 0:e.contentWindow)==null||t.postMessage(Y(n),\"*\")}catch(r){}}function Xe(e,n){var r;if(!e)return!0;let t=(r=e.contentWindow)!=null?r:e;return n===t}function Je(e,n,t){let r=n||(typeof window!=\"undefined\"?window:null);if(!r)return()=>{};let o=s=>{if(!Xe(t,s.source))return;let a=Te(s.data);a&&e(a)};return r.addEventListener(\"message\",o),()=>r.removeEventListener(\"message\",o)}function en(e,n){var s;let t=n||(typeof window!=\"undefined\"?window:null);if(!t)return()=>{};let r=(s=t.parent)!=null?s:null,o=a=>{if(!r||r===t||a.source!==r)return;let u=_e(a.data);u&&e(u)};return t.addEventListener(\"message\",o),()=>t.removeEventListener(\"message\",o)}var O={};v(O,{createTracker:()=>ln});var N={};v(N,{PRESENTER_ACTIONS_PER_HOUR:()=>X,PRESENT_CACHE_MS:()=>rn,PRESENT_QUOTA_MARGIN:()=>Ie,PRESENT_QUOTA_PER_HOUR:()=>on,PRESENT_READS_PER_HOUR:()=>Re,PRESENT_READ_BURST:()=>Pe,PRESENT_READ_COALESCE_MS:()=>Ae,PRESENT_RESYNC_MS:()=>Se,PRESENT_SIGNAL_BUDGET_PER_HOUR:()=>tn,PRESENT_WRITE_TIMEOUT_MS:()=>J,READERS_PER_EGRESS:()=>q,RESYNC_READS_PER_HOUR:()=>ke,SESSION_IDLE_MS:()=>Q,SESSION_INTERVAL_MS:()=>Z,SESSION_QUOTA_PER_HOUR:()=>nn,SESSION_WRITES_PER_HOUR:()=>Ee});var Z=12e3,Ee=Math.ceil(300),q=25,nn=Ee*q,Q=18e4,Se=25e3,ke=Math.ceil(36e5/Se),X=720,Re=ke+X,tn=X,Pe=20,J=1e4,Ae=400,rn=Ae,Ie=4,on=(Re+Pe)*q*Ie;var sn=[\"mousemove\",\"mousedown\",\"keydown\",\"wheel\",\"touchstart\",\"pointerdown\"];function an(){let e=typeof globalThis!=\"undefined\"?globalThis.crypto:void 0;if(e&&typeof e.randomUUID==\"function\")return e.randomUUID().replace(/-/g,\"\");if(e&&typeof e.getRandomValues==\"function\"){let n=e.getRandomValues(new Uint8Array(16));return Array.from(n,t=>t.toString(16).padStart(2,\"0\")).join(\"\")}return Math.random().toString(36).slice(2)+Date.now().toString(36)}function un(e,n){return t=>{try{let r=JSON.stringify(t),o=n.navigator;o&&typeof o.sendBeacon==\"function\"?o.sendBeacon(e,new Blob([r],{type:\"application/json\"})):n.fetch(e,{method:\"POST\",headers:{\"Content-Type\":\"application/json\"},body:r,keepalive:!0})}catch(r){}}}function ln(e={}){var ge,be;let n=e.win||(typeof window!=\"undefined\"?window:null),t=e.doc||n&&n.document,r=e.now||(()=>Date.now()),o=e.endpoint||\"/api/doc\",s=(ge=e.idleMs)!=null?ge:Q,a=(be=e.sessionEveryMs)!=null?be:12e3,u=e.slug||\"\",d=e.internal||null,m=e.sessionId||an(),p=e.send||un(o,n),c={},l=0,g=0,f=0,w=0,h=null,E=r(),S=!1,B=!1,A=[],$e=()=>!!u||!!d,ce=i=>{if($e())try{p(i)}catch(b){}},V=()=>!!t&&t.visibilityState===\"visible\"&&!S,de=i=>{let b=r()-i,x=Math.max(0,E-i)+s;return Math.max(0,Math.min(b,x))},z=()=>{h!=null&&l>0&&(c[l]=(c[l]||0)+de(h)/1e3),h=V()?r():null},pe=()=>{z(),h=null},F=()=>{h==null&&V()&&(h=r())},j=()=>{let i=0;for(let b in c)i+=c[b];return h!=null&&(i+=de(h)/1e3),Math.round(i)},fe=()=>{let i={};for(let b in c)i[b]=Math.round(c[b]);return i},k=()=>{E=r(),S&&(S=!1,F())},me=(i,b)=>{d||ce({slug:u,event:i,page:b||g,maxPage:f,seconds:j(),sessionId:m})},I=()=>{z();let i={event:\"session\",sessionId:m,numPages:w,maxPage:f,totalSeconds:j(),pagesTime:fe()};d?(i.internal=!0,i.docId=d.docId,i.email=d.email,i.name=d.name,d.it&&(i.it=d.it)):i.slug=u,ce(i)},R=(i,b,x,ye)=>{!i||typeof i.addEventListener!=\"function\"||(i.addEventListener(b,x,ye),A.push(()=>{i.removeEventListener(b,x,ye)}))};return{sessionId:m,totalSeconds:j,maxPage:()=>f,pageTimes:fe,start(){if(B)return;B=!0,me(\"open\",1),l=1,E=r(),h=V()?r():null,R(t,\"visibilitychange\",()=>{t.visibilityState===\"hidden\"?(pe(),I()):(k(),F())}),R(n,\"focus\",()=>{k(),F()});for(let x of sn)R(n,x,k,{passive:!0});R(e.scrollElement,\"scroll\",k,{passive:!0}),R(n,\"beforeunload\",I);let i=n.setInterval(()=>{!S&&r()-E>s&&(S=!0,pe())},5e3),b=n.setInterval(I,a);A.push(()=>{n.clearInterval(i),n.clearInterval(b)})},setPage(i){!i||i===g||(E=r(),z(),l=i,g=i,i>f&&(f=i,me(\"page\",i)))},setPageCount(i){w=i||0},noteActivity:k,flush:I,stop(){for(;A.length;){let i=A.pop();try{i==null||i()}catch(b){}}B=!1}}}var U={};v(U,{STORAGE_KEYS:()=>M,attendeeKey:()=>fn,authorToken:()=>mn,avatarHtml:()=>ee,canModerate:()=>oe,createBudget:()=>Mn,createFileEcritures:()=>xn,createScheduler:()=>vn,escapeHtml:()=>y,fetchBorne:()=>wn,flattenPresence:()=>cn,formatMessageBody:()=>ne,initials:()=>Ce,isMentioned:()=>bn,isMine:()=>T,presenceId:()=>Tn,reactorId:()=>re,referenceAuteur:()=>gn,shouldNotify:()=>yn,unreadLabel:()=>hn});var M={attendeeKey:\"3dd-present-attkey\",authorToken:\"3dd-present-authtoken\",chatMuted:\"3dd-present-mute\",presenceId:\"3dd-present-uid\"};function y(e){return String(e==null?\"\":e).replace(/&/g,\"&amp;\").replace(/</g,\"&lt;\").replace(/>/g,\"&gt;\").replace(/\"/g,\"&quot;\")}function Ce(e){let n=(e||\"?\").trim().split(/\\s+/);return(((n[0]||\"\")[0]||\"?\")+((n[1]||\"\")[0]||\"\")).toUpperCase()}function ee(e,n){return e?`<img src=\"${y(e)}\" alt=\"\">`:y(Ce(n))}function ne(e){let n=y(e);return n=n.replace(/(https?:\\/\\/[^\\s<]+)/g,t=>`<a href=\"${t}\" target=_blank rel=noreferrer class=cm-link>${t}</a>`),n=n.replace(/(^|\\s)@([\\p{L}0-9_'.-]+)/gu,(t,r,o)=>`${r}<span class=cm-mention>@${o}</span>`),n}function cn(e){let n=new Map;for(let t in e||{})for(let r of(e||{})[t]||[]){let o=String(r.uid||r.email||r.name||\"\").toLowerCase()||`_${t}_${n.size}`;(!n.get(o)||r.role===\"presenter\")&&n.set(o,r)}return[...n.values()]}function dn(e,n){try{return e?e.getItem(n):null}catch(t){return null}}function pn(e,n,t){try{e==null||e.setItem(n,t)}catch(r){}}function fn(e,n=\"anon\",t=te){try{if(!e)return`anon-${n}`;let r=e.getItem(M.attendeeKey);if(r)return r;let o=`anon-${t()}`;return e.setItem(M.attendeeKey,o),o}catch(r){return`anon-${n}`}}function te(){let e=typeof globalThis!=\"undefined\"?globalThis.crypto:void 0;if(e&&typeof e.randomUUID==\"function\")return e.randomUUID().replace(/-/g,\"\");if(e&&typeof e.getRandomValues==\"function\"){let n=e.getRandomValues(new Uint8Array(24));return Array.from(n,t=>t.toString(16).padStart(2,\"0\")).join(\"\")}try{console.warn(\"[player] crypto indisponible : jeton d'auteur affaibli\")}catch(n){}return Math.random().toString(36).slice(2)+Math.random().toString(36).slice(2)}function mn(e,n=te){let t=dn(e,M.authorToken);if(t)return t;let r=n();return pn(e,M.authorToken,r),r}async function gn(e,n){let t=n!==void 0?n:typeof globalThis!=\"undefined\"&&globalThis.crypto?globalThis.crypto.subtle:null;if(!t||!e)return\"\";try{let r=async o=>{let s=await t.digest(\"SHA-256\",new TextEncoder().encode(o));return Array.from(new Uint8Array(s),a=>a.toString(16).padStart(2,\"0\")).join(\"\")};return(await r(\"ref:\"+await r(e))).slice(0,16)}catch(r){return\"\"}}function re(e){return String(e&&e.ref||\"\")}function T(e,n){return!e||!n||!e.author_ref||!n.ref?!1:e.author_ref===n.ref}function oe(e){return!!(e&&e.role===\"presenter\")}function bn(e,n){if(!n||!n.name)return!1;let t=(n.name.split(/\\s+/)[0]||\"\").toLowerCase();return t?String(e&&e.body||\"\").toLowerCase().includes(`@${t}`)&&!T(e,n):!1}function yn(e){let{msg:n,me:t,historyLoaded:r,chatHidden:o}=e;return!r||!n||n.deleted||T(n,t)?!1:o}function hn(e){return!e||e<=0?\"\":e>9?\"9+\":String(e)}function vn(e,n={}){var g;let t=(g=n.minMs)!=null?g:300,r=n.now||(()=>Date.now()),o=n.setTimer||((f,w)=>setTimeout(f,w)),s=n.clearTimer||(f=>clearTimeout(f)),a=!1,u=!1,d=-1/0,m=null,p=!1;function c(){if(p||a){u=!0;return}let f=t-(r()-d);if(f>0){l(f);return}a=!0,u=!1,d=r();let w=!1;e(()=>{w||(w=!0,a=!1,u&&!p&&c())})}function l(f){p||m!==null||(m=o(()=>{m=null,c()},Math.max(0,f)))}return{signaler(){p||c()},maintenant(){p||(d=-1/0,c())},arreter(){p=!0,m!==null&&(s(m),m=null)}}}function wn(e,n,t=J,r={}){let o=r.fetch||(typeof fetch==\"function\"?fetch:void 0);if(!o)return Promise.reject(new Error(\"fetch indisponible\"));let s=r.setTimer||((l,g)=>setTimeout(l,g)),a=r.clearTimer||(l=>clearTimeout(l)),u=null;try{u=new AbortController}catch(l){}let d=u?Me(G({},n||{}),{signal:u.signal}):G({},n||{}),m=null,p=new Promise((l,g)=>{m=s(()=>{try{u==null||u.abort()}catch(f){}g(new Error(\"delai\"))},t)}),c=()=>{try{a(m)}catch(l){}};return Promise.race([o(e,d),p]).then(l=>(c(),l),l=>{throw c(),l})}function Mn(e){let n=e.now||(()=>Date.now()),t=Math.max(1,e.rafale),r=t,o=n();return{prendre(){let s=n();return r=Math.min(t,r+(s-o)*e.parHeure/36e5),o=s,r<1?!1:(r-=1,!0)},restants:()=>r}}function xn(e={}){var m;let n=(m=e.minMs)!=null?m:0,t=e.now||(()=>Date.now()),r=e.setTimer||((p,c)=>setTimeout(p,c)),o=new Map,s=!1,a=-1/0,u=!1;function d(){if(s||o.size===0)return;let p=n-(t()-a);if(p>0){if(u)return;u=!0,r(()=>{u=!1,d()},p);return}let c=o.keys().next().value,l=o.get(c);o.delete(c),s=!0,a=t(),Promise.resolve().then(l.tache).then(g=>l.regler(g),()=>l.regler(null)).then(()=>{s=!1,d()})}return{poser(p,c){return new Promise(l=>{let g=o.get(p);o.set(p,{tache:c,regler:f=>{g&&g.regler(f),l(f)}}),d()})},enAttente:()=>o.size}}function Tn(e,n=te){try{if(e){let t=e.getItem(M.presenceId);if(t)return t;let r=`p-${n()}`;return e.setItem(M.presenceId,r),r}}catch(t){}return`p-${n()}`}var $={};v($,{messageClassName:()=>En,renderActions:()=>Oe,renderAttachment:()=>Ne,renderMessage:()=>_n,renderReactions:()=>Le});function Le(e,n){let t=e&&e.reactions||{},r=re(n),o=\"\";for(let s in t){let a=t[s]||[];if(!a.length)continue;let u=a.indexOf(r)>=0;o+=`<button class=\"re-chip${u?\" on\":\"\"}\" data-e=\"${y(s)}\">${y(s)} ${a.length}</button>`}return o}function Ne(e){if(!e||!e.url)return\"\";let n=y(e.url);if(e.kind===\"image\")return`<a class=cm-att href=\"${n}\" target=_blank rel=noreferrer><img src=\"${n}\" alt=\"\" loading=lazy></a>`;let t=y(e.name||\"Document.pdf\");return`<a class=cm-att-pdf href=\"${n}\" target=_blank rel=noreferrer data-pdf=\"${n}\"><span class=cm-att-ph></span><span class=cm-pdflabel>${t}</span></a>`}function Oe(e,n){let t=T(e,n.me),r=`<button class=cm-react title=\"R\\xE9agir\">${n.reactIcon||\"\"}</button><button class=cm-reply title=\"R\\xE9pondre\">\\u21A9</button>`;return t&&(r+='<button class=cm-edit title=\"Modifier\">\\u270E</button>'),(t||oe(n.me))&&(r+='<button class=cm-del-btn title=\"Supprimer\">\\u{1F5D1}</button>'),r}function _n(e,n){if(e.deleted)return'<span class=a></span><span class=b><div class=\"txt cm-del\">Message supprim\\xE9</div></span>';let t=e.reply_to?`<div class=cm-q><b>${y(e.reply_name||\"\")}</b> ${y(e.reply_text||\"\")}</div>`:\"\",r=e.edited?\" <span class=cm-ed>(modifi\\xE9)</span>\":\"\",o=e.body?`<div class=txt>${ne(e.body)}${r}</div>`:\"\",s=e.is_presenter?\" <span class=tag>pr\\xE9sentateur</span>\":\"\";return`<span class=a>${ee(e.author_avatar,e.author_name)}</span><span class=b><div class=who><b>${y(e.author_name||\"Invit\\xE9\")}</b>${s}</div>`+t+o+Ne(e.attachment)+`<div class=cm-re>${Le(e,n.me)}</div></span><span class=cm-act>${Oe(e,n)}</span>`}function En(e,n,t){return\"cm\"+(T(e,n)?\" me\":\"\")+(e.deleted?\" isdel\":\"\")+(t?\" mentioned\":\"\")}var H={};v(H,{DEFAULT_CENTER:()=>ae,DEFAULT_ZOOM:()=>ue,MAP_TYPES:()=>P,cycleMapType:()=>Sn,initialMapContent:()=>Rn,mapTypeLabel:()=>kn,sanitizeContent:()=>le});var P=[\"roadmap\",\"satellite\",\"hybrid\"],ae=[46.6,2.5],ue=6;function _(e){let n=Number(e);return Number.isFinite(n)?n:null}function se(e){if(!Array.isArray(e))return null;let n=_(e[0]),t=_(e[1]);return n!=null&&t!=null?[n,t]:null}var ie=(e,n,t)=>Math.max(n,Math.min(t,e));function le(e){if(!e||typeof e!=\"object\")return null;let n=e;if(n.kind===\"pdf\"||n.kind==null)return null;if(n.kind===\"streetview\"){let t=se(n.position);if(!t)return null;let r=n.pov&&typeof n.pov==\"object\"?n.pov:null,o=r?{heading:_(r.heading)||0,pitch:ie(_(r.pitch)||0,-90,90)}:{heading:0,pitch:0};return{kind:\"streetview\",position:t,pov:o,zoom:ie(_(n.zoom)||1,0,5)}}return n.kind!==\"map\"?null:{kind:\"map\",center:se(n.center)||[...ae],zoom:ie(Math.trunc(_(n.zoom)||ue),1,21),marker:se(n.marker),mapType:P.includes(n.mapType)?n.mapType:null,label:String(n.label||\"\").slice(0,160)||null}}function Sn(e){let n=P.indexOf(e);return P[(n+1)%P.length]}function kn(e){return e===\"roadmap\"?\"\\u{1F6F0} Satellite\":e===\"satellite\"?\"\\u{1F5FA} Hybride\":\"\\u{1F5FA} Plan\"}function Rn(){return{kind:\"map\",center:[...ae],zoom:ue,marker:null,mapType:null,label:null}}var D={};v(D,{CURRENT_PAGE_MARGIN:()=>Nn,DEFAULT_ASPECT:()=>In,MAX_ZOOM:()=>An,MIN_PAGE_WIDTH:()=>Cn,MIN_ZOOM:()=>Pn,PRERENDER_MARGIN:()=>Ln,arrowState:()=>$n,averageColor:()=>Wn,clampPage:()=>Ue,clampZoom:()=>On,fitWidth:()=>Dn,isImageDocument:()=>Bn,progressPercent:()=>Un,workerBlobUrl:()=>Vn});var Pn=.5,An=3,In=1.35,Cn=280,Ln=\"500px 0px\",Nn=\"-48% 0px -48% 0px\";function On(e){let n=Math.round((Number(e)||0)*10)/10;return Math.max(.5,Math.min(3,n))}function Ue(e,n){return Math.max(1,Math.min(n||1,Number(e)||1))}function Un(e,n){return n?Math.round(Ue(e,n)/n*100):0}function $n(e,n){return{prevDisabled:e<=1,nextDisabled:!!n&&e>=n}}var Hn=28;function Dn(e){var a;let n=(a=e.zoom)!=null?a:1,t=Math.max(280,e.containerWidth||900)*n;if(!e.onePage)return t;let r=e.aspect||1.35,s=((e.containerHeight||600)-Hn-(e.overlap||0)-(e.reserve||0))/r;return s>120?Math.min(t,s):t}function Wn(e){let n=e.length/4;if(!n)return\"rgb(0,0,0)\";let t=0,r=0,o=0;for(let s=0;s<e.length;s+=4)t+=e[s],r+=e[s+1],o+=e[s+2];return`rgb(${Math.round(t/n)},${Math.round(r/n)},${Math.round(o/n)})`}function Bn(e,n){return/[.](png|jpe?g|webp|gif|avif)($|[?])/i.test(String(e||n||\"\"))}async function Vn(e,n,t={}){let r=t.fetch||(typeof fetch==\"function\"?(a=>fetch(a)):null),o=t.subtle!==void 0?t.subtle:typeof globalThis!=\"undefined\"&&globalThis.crypto?globalThis.crypto.subtle:null,s=t.creerUrl||(a=>URL.createObjectURL(a));if(!r||!o||!n)return null;try{let a=await r(e);if(!a.ok)return null;let u=await a.arrayBuffer(),d=await o.digest(\"SHA-384\",u);return`sha384-${zn(new Uint8Array(d))}`!==n?null:s(new Blob([u],{type:\"application/javascript\"}))}catch(a){return null}}function zn(e){let n=\"\";for(let t of e)n+=String.fromCharCode(t);return btoa(n)}var W={};v(W,{presentationTransition:()=>Fn,switchDocUrl:()=>jn});function Fn(e,n={}){if(!e)return[{kind:\"nothing\"}];if(e.active===!1)return[{kind:\"ended\"}];let t=le(e.content);if(t)return[{kind:\"show-map\",content:t}];let r=[{kind:\"leave-map\"}];return e.file_url&&e.file_url!==n.docUrl?(r.push({kind:\"switch-doc\",url:e.file_url,name:e.file_name||void 0,title:e.doc_title||void 0,updatedAt:e.updated_at||void 0}),r):(e.current_page&&r.push({kind:\"show-page\",page:e.current_page}),r)}function jn(e,n){let t=encodeURIComponent(n||String(Date.now()));return`${e}${e.includes(\"?\")?\"&\":\"?\"}v=${t}`}return Fe(Gn);})();", SOURCE_HASH: "0e1b2ed50563dde4" };
package/server/handler.js CHANGED
@@ -434,7 +434,7 @@ var Live=(function(){
434
434
  // s'éteindre, recommence, et rebascule encore. Rejouer la même intention deux fois donne le
435
435
  // même résultat qu'une fois ; c'est ce que le réseau exige.
436
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(){});}
437
+ fetch('/api/doc',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'present-react',slug:SLUG,msgId:+id,emoji:e,authorToken:authToken(),etat:veut})}).then(majDiffusee).catch(function(){});}
438
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();}
439
439
  function clearReply(){replyCtx=null;var el=document.getElementById('chatReply');if(el){el.style.display='none';el.innerHTML='';}}
440
440
  function send(){var i=document.getElementById('chatText');var t=(i.value||'').trim();if(!t||!ME)return;if(LOCKED&&!canMod())return;i.value='';toggleSend();
@@ -1560,7 +1560,13 @@ const PLAFOND_RELAIS = Number(process.env.PLAYER_MAX_RELAY_BYTES || 0) || 60 * 1
1560
1560
 
1561
1561
  async function relayerFichier(res, r, disposition) {
1562
1562
  if (!r) { res.statusCode = 404; res.end("Fichier indisponible"); return; }
1563
- if (!r.ok && r.status !== 206) { res.statusCode = 502; res.end("Fichier indisponible"); return; }
1563
+ // 413 et 416 sont des REFUS ARGUMENTÉS de l'amont local (plafond, borne absurde) : les fondre
1564
+ // dans un 502 dirait « panne » là où l'amont a dit « demande irrecevable ».
1565
+ if (!r.ok && r.status !== 206) {
1566
+ res.statusCode = r.status === 413 || r.status === 416 ? r.status : 502;
1567
+ res.end(r.status === 413 ? "Fichier trop volumineux" : "Fichier indisponible");
1568
+ return;
1569
+ }
1564
1570
 
1565
1571
  const compresse = !!r.headers.get("content-encoding");
1566
1572
  if (compresse && r.status === 206) { res.statusCode = 502; res.end("Fichier indisponible"); return; }
@@ -2096,6 +2102,8 @@ ${LEGAL_CSS}
2096
2102
  .pbar-link{border:1px solid #e4e0d9;border-radius:8px;padding:6px 9px;font:inherit;font-size:12px;color:#555;width:240px;max-width:32vw;background:#f8f6f2}
2097
2103
  .pbar-btn{border:1px solid #e4e0d9;background:#f6f4ef;color:#1c1c1c;border-radius:8px;padding:7px 11px;font:inherit;font-size:12.5px;font-weight:600;cursor:pointer;white-space:nowrap}
2098
2104
  .pbar-btn:hover{background:#eceae5}
2105
+ .pbar-err{display:none;color:#b3261e;font-size:12px;font-weight:600;max-width:260px;line-height:1.25}
2106
+ .pbar-err.on{display:inline-block}
2099
2107
  #pbarEnd{color:#c0392b}
2100
2108
  /* Barre d'outils responsive : sous 860px, zoom/plein écran/Présenter/Partager/Télécharger passent dans le
2101
2109
  menu « ⋯ » ; le titre se tronque puis disparaît. */
@@ -2143,6 +2151,7 @@ ${LEGAL_CSS}
2143
2151
  <button class=pbar-btn id=pbarInvite>Inviter l'équipe</button>
2144
2152
  <button class=pbar-btn id=pbarHandover>Passer la main</button>
2145
2153
  <button class=pbar-btn id=pbarEnd>Terminer</button>
2154
+ <span class=pbar-err id=pbarErr role=alert></span>
2146
2155
  </div>` : ""}
2147
2156
  <div class=pop id=pop>
2148
2157
  <h4>Transmettre le document</h4>
@@ -2342,15 +2351,39 @@ ${LEGAL_CSS}
2342
2351
  if(!PRES) return Promise.resolve();
2343
2352
  var p=PRES, btn=document.getElementById('pbarEnd'), libelle=btn?btn.textContent:'';
2344
2353
  if(btn){ btn.disabled=true; btn.textContent='…'; }
2354
+ var errAvant=document.getElementById('pbarErr'); if(errAvant){ errAvant.classList.remove('on'); }
2345
2355
  return Player.live.fetchBorne('/api/doc',{method:'POST',headers:{'Content-Type':'application/json'},keepalive:true,
2346
2356
  body:JSON.stringify({action:'present-end',slug:p.slug,control:p.control})})
2347
2357
  .then(function(r){ if(!r||!r.ok) throw new Error('present-end'); })
2348
2358
  .then(function(){
2359
+ // RELIRE, PAS REUTILISER : un second aller-retour, indépendant de l'écriture. Réserve
2360
+ // posée par le second hôte, et elle est juste — relire la réponse du PATCH ferait ce que
2361
+ // faisait le cache négatif : la mesure confirmerait ce que l'écriture CROIT avoir fait.
2362
+ // C'est le seul moyen de rendre observable la classe entière des succès silencieux, y
2363
+ // compris contre un futur serveur qui répondrait ok sans avoir écrit.
2364
+ return Player.live.fetchBorne('/api/doc?present='+encodeURIComponent(p.slug)+'&state=1')
2365
+ .then(function(r){ return r&&r.ok?r.json():null; })
2366
+ .catch(function(){ return null; }); // relecture INDISPONIBLE n'est pas « encore active »
2367
+ })
2368
+ .then(function(d){
2369
+ if(d&&d.state&&d.state.active){
2370
+ // Le serveur a dit oui, la base dit encore active : fin non prise, ou reprise ailleurs.
2371
+ // Dans les deux cas, fermer l'interface mentirait au présentateur.
2372
+ if(btn){ btn.disabled=false; btn.textContent=libelle||'Terminer'; }
2373
+ var err2=document.getElementById('pbarErr');
2374
+ if(err2){ err2.textContent='La présentation est ENCORE ACTIVE après la fin — reprise ailleurs, ou fin non prise. Réessayez.'; err2.classList.add('on'); }
2375
+ return;
2376
+ }
2349
2377
  PRES=null; clearInterval(_hbIv); clearCtl(p.slug);
2350
2378
  var pb=document.getElementById('pbar'); if(pb)pb.style.display='none';
2351
2379
  try{ if(window.Live){ Live.sendState(); Live.disconnect(); } }catch(e){}
2352
2380
  }, function(){
2353
- if(btn){ btn.disabled=false; btn.textContent=libelle||'Terminer'; btn.title='La fin n’a pas été enregistrée — réessayez.'; }
2381
+ // L'échec d'une clôture ne prive de rien qu'on regarde : son succès ne produit rien,
2382
+ // donc son échec non plus. La première version le disait dans un TITLE — un tooltip que
2383
+ // personne ne survole. Le second hôte a eu des présentations « actives » trois jours.
2384
+ if(btn){ btn.disabled=false; btn.textContent=libelle||'Terminer'; }
2385
+ var err=document.getElementById('pbarErr');
2386
+ if(err){ err.textContent='La présentation est TOUJOURS ACTIVE — la fin n’a pas été enregistrée. Réessayez.'; err.classList.add('on'); }
2354
2387
  });
2355
2388
  }
2356
2389
  // ⚠️ LE SEUL ENDROIT OÙ « sendBeacon » A UN SENS : la page s'en va, aucune réponse ne pourra être
@@ -3101,7 +3134,14 @@ async function handler(req, res) {
3101
3134
  ? await touchPresentation(String(body.slug || ""), String(body.control || ""))
3102
3135
  : await endPresentation(String(body.slug || ""), String(body.control || ""));
3103
3136
  return jp(r.ok ? 200 : (r.status || 400), r);
3104
- } catch { return jp(500, { ok: false }); }
3137
+ } catch (erreur) {
3138
+ // ⚠️ CE CATCH A AVALÉ TROIS JOURS DE CLÔTURES IMPOSSIBLES SANS UNE TRACE. Chez le second
3139
+ // hôte, chaque « Terminer » échouait en 23502 (marqueur d'archive NOT NULL) — et ce 500
3140
+ // muet ne laissait RIEN, même pas une ligne dans le journal d'erreurs. Un journal que
3141
+ // personne ne lit vaut peu ; aucun journal ne vaut rien du tout.
3142
+ try { PLAYER.errors.capture(erreur instanceof Error ? erreur : new Error(String(erreur)), { route: "present-" + String(body.action || "").replace(/^present-/, "") }); } catch { /* jamais bloquant */ }
3143
+ return jp(500, { ok: false });
3144
+ }
3105
3145
  }
3106
3146
  // Assistant IA « présentateur » (bot) sur un lien tracé bot_enabled. PUBLIC (prospect anonyme) → rate-limit IP.
3107
3147
  // Synthèse vocale (ElevenLabs) : Léa lit ses messages. PUBLIC (audience anonyme), mais gated : la clé
@@ -3398,7 +3438,16 @@ async function handler(req, res) {
3398
3438
  const ip = adresseAppelant(req) || "anon";
3399
3439
  const allowed = await PLAYER.limits.allow(`preact:${ip}`, 200, 3600);
3400
3440
  if (!allowed) return jp(429, { ok: false, error: "rate" });
3401
- const r = await toggleReaction(String(body.slug || ""), body.msgId, body.emoji, body.reactor);
3441
+ // ⚠️ LE CINQUIÈME ARGUMENT A MANQUÉ ICI PENDANT TROIS VERSIONS. `toggleReaction` sait
3442
+ // poser un état depuis 0.1.56, le client l'envoie depuis 0.1.56 — et cette ligne le
3443
+ // jetait. Les essais éprouvaient la FONCTION, jamais la route : le correctif était vu
3444
+ // refuser en mutation et inactif en production. Trouvé par le troisième audit.
3445
+ // ⚠️ `body.reactor` N'EST PLUS LU. C'est un champ que le client CHOISIT, et les refs des
3446
+ // autres participants sont publics (le tableau des réactions les porte) : s'y fier
3447
+ // laissait n'importe qui poser ou retirer les réactions d'un autre. L'identité se
3448
+ // dérive du jeton d'auteur — le secret qui ne quitte pas le navigateur qui l'a tiré.
3449
+ const reacteur = require("./presentations").reacteurDepuisJeton(body.authorToken);
3450
+ const r = await toggleReaction(String(body.slug || ""), body.msgId, body.emoji, reacteur, body.etat);
3402
3451
  return jp(r.ok ? 200 : (r.status || 400), r);
3403
3452
  } catch { return jp(500, { ok: false }); }
3404
3453
  }
@@ -3832,9 +3881,14 @@ async function handler(req, res) {
3832
3881
  // carte est ce qu'un hôte interroge déjà pour épingler sa version ; c'est donc ici.
3833
3882
  // Elle RAPPORTE ce qui est connu, elle ne sonde pas : un diagnostic ne doit pas tomber en
3834
3883
  // même temps que ce qu'il diagnostique.
3835
- schema: String(q.schema || "") === "1"
3836
- ? await require("./schema").sonderTout()
3837
- : require("./schema").etatDuSchema(),
3884
+ // ⚠️ `couvre` DIT LA PORTÉE, parce que « complet » sans portée surpromet : les migrations de
3885
+ // limites (0003/0004) n'y sont PAS — un hôte peut fournir sa propre capacité `limits`, et
3886
+ // chez lui leur absence est normale. Ce champ empêche de lire « complet » comme « tout le
3887
+ // dossier supabase/ est appliqué ». Relevé par le troisième audit.
3888
+ schema: { couvre: "colonnes-conditionnelles",
3889
+ ...(String(q.schema || "") === "1"
3890
+ ? await require("./schema").sonderTout()
3891
+ : require("./schema").etatDuSchema()) },
3838
3892
  // « L'hôte peut-il créer un lien en son nom propre ? » — configuré, pas seulement
3839
3893
  // possible. Un hôte qui oublie le secret reçoit un 401 qui ressemble à un droit
3840
3894
  // manquant ; ce booléen le lui dit sans qu'il ait à essayer.
@@ -24,8 +24,29 @@ const tokenMatches = (token, storedHash) => !!(token && storedHash && sameHash(s
24
24
  // owner = { id, email, name, avatar } (membre authentifié). La CLÉ de propriété est l'EMAIL (issu du JWT
25
25
  // vérifié) → permet reprise / liste / transfert, y compris vers un membre choisi par email.
26
26
  const lc = (s) => String(s || "").trim().toLowerCase();
27
+ /**
28
+ * ⚠️ LA PURGE NE DOIT PAS DÉPENDRE DE L'OUVERTURE D'UN PANNEAU. Elle ne vivait que dans
29
+ * `listActivePresentations` — c'est-à-dire qu'elle ne tournait que si quelqu'un ouvrait la liste.
30
+ * Le second hôte a eu QUATRE présentations bloquées « actives » pendant TROIS JOURS : personne
31
+ * n'avait ouvert le panneau, donc personne n'avait déclenché le seul mécanisme qui les fermait.
32
+ * Un échec de clôture ne prive de rien qu'on regarde — la purge doit donc s'accrocher à un geste
33
+ * qui arrive de lui-même. Démarrer une présentation en est un : ce sont les présentateurs qui
34
+ * créent les orphelines, et le suivant nettoie celles d'avant.
35
+ *
36
+ * Conditionnée à `active=eq.true&last_seen=lte.seuil` : une session qui vient de battre n'est pas
37
+ * touchée (même garde que la purge du panneau, chantier des écritures conditionnées).
38
+ */
39
+ async function purgerPerimees(now) {
40
+ const seuil = new Date(now - STALE_MS).toISOString();
41
+ await PLAYER.db.request(
42
+ `doc_presentations?active=eq.true&last_seen=lte.${enc(seuil)}`,
43
+ { method: "PATCH", headers: { Prefer: "return=minimal" }, body: { active: false, updated_at: new Date(now).toISOString() } },
44
+ ).catch(() => { /* la purge est un service, jamais un préalable */ });
45
+ }
46
+
27
47
  async function createPresentation({ docId, fileUrl, fileName, docTitle, presenterName, owner }) {
28
48
  if (!fileUrl) throw Object.assign(new Error("doc invalide"), { statusCode: 400 });
49
+ await purgerPerimees(Date.now());
29
50
  const slug = newToken(9); // ~12 chars URL-safe
30
51
  const control = newToken(18); // secret pilotage
31
52
  const o = owner && typeof owner === "object" ? owner : {};
@@ -55,7 +76,15 @@ async function reclaimPresentation(slug, email) {
55
76
  // docs/MIGRATIONS.md décrit : PostgREST rejette le PATCH ENTIER si la colonne manque. La reprise
56
77
  // aurait cessé de fonctionner chez tout hôte non migré — pas la nouvelle garantie, la reprise.
57
78
  const rangDispo = await require("./schema").attendue("rangEcriture");
58
- await PLAYER.db.request(`doc_presentations?slug=eq.${enc(slug)}`, { method: "PATCH", headers: { Prefer: "return=minimal" }, body: { control_hash: sha(control), active: true, ...(rangDispo ? { write_seq: 0 } : {}), last_seen: new Date().toISOString(), updated_at: new Date().toISOString() } });
79
+ // ⚠️ LE PROPRIÉTAIRE DANS LA CONDITION, pas seulement dans la vérification. Entre la lecture et
80
+ // l'écriture, un transfert a pu donner la présentation à quelqu'un d'autre : la reprise de
81
+ // l'ANCIEN propriétaire arrivait quand même, régénérait le jeton, et volait la session au
82
+ // nouveau — précisément ce que le transfert venait d'accorder. Constat du troisième audit.
83
+ const ecrit = await ecrireSiEncoreVrai(
84
+ `doc_presentations?slug=eq.${enc(slug)}&owner_email=eq.${enc(row.owner_email)}`,
85
+ { control_hash: sha(control), active: true, ...(rangDispo ? { write_seq: 0 } : {}), last_seen: new Date().toISOString(), updated_at: new Date().toISOString() },
86
+ );
87
+ if (!ecrit) return { ok: false, status: 409 };
59
88
  return { ok: true, slug, control, page: row.current_page || 1, fileUrl: row.file_url, fileName: row.file_name, docTitle: row.doc_title, docId: row.doc_id };
60
89
  }
61
90
 
@@ -65,8 +94,15 @@ async function touchPresentation(slug, control) {
65
94
  const row = await getPresentation(slug);
66
95
  if (!row) return { ok: false, status: 404 };
67
96
  if (!tokenMatches(control, row.control_hash)) return { ok: false, status: 403 };
68
- await PLAYER.db.request(`doc_presentations?slug=eq.${enc(slug)}`, { method: "PATCH", headers: { Prefer: "return=minimal" }, body: { last_seen: new Date().toISOString() } });
69
- return { ok: true };
97
+ // ⚠️ LE JETON DANS LA CONDITION : une reprise ailleurs le régénère, une clôture l'annule. Sans
98
+ // ça, le heartbeat d'un ANCIEN onglet maintenait « vivante » une session qui ne lui appartient
99
+ // plus — et l'auto-purge, qui se fie à `last_seen`, ne fermait jamais l'orpheline.
100
+ const ecrit = await ecrireSiEncoreVrai(
101
+ `doc_presentations?slug=eq.${enc(slug)}&control_hash=eq.${sha(control)}`,
102
+ { last_seen: new Date().toISOString() },
103
+ );
104
+ // Un heartbeat refusé dit à l'onglet périmé de s'arrêter — c'est une information, pas une panne.
105
+ return ecrit ? { ok: true } : { ok: false, status: 409 };
70
106
  }
71
107
 
72
108
  // Liste des présentations en cours (membre authentifié). Auto-purge : une présentation active dont le
@@ -108,7 +144,12 @@ async function listActivePresentations(email) {
108
144
  const now = Date.now();
109
145
  const stale = list.filter((r) => now - new Date(r.last_seen || r.updated_at || 0).getTime() > STALE_MS).map((r) => r.slug);
110
146
  if (stale.length) {
111
- await PLAYER.db.request(`doc_presentations?slug=in.(${stale.map((s) => enc(s)).join(",")})`, { method: "PATCH", headers: { Prefer: "return=minimal" }, body: { active: false, updated_at: new Date().toISOString() } }).catch(() => {});
147
+ // ⚠️ L'INACTIVITÉ DANS LA CONDITION, pas seulement dans le calcul. Entre la lecture de la liste
148
+ // et cette écriture, une présentation « orpheline » a pu envoyer son heartbeat : la purger
149
+ // quand même désactivait une session redevenue vivante, sous les pieds de son présentateur.
150
+ // `lte` sur le seuil : seule une ligne dont last_seen n'a PAS bougé est purgée.
151
+ const seuil = new Date(now - STALE_MS).toISOString();
152
+ await PLAYER.db.request(`doc_presentations?slug=in.(${stale.map((s) => enc(s)).join(",")})&active=eq.true&last_seen=lte.${enc(seuil)}`, { method: "PATCH", headers: { Prefer: "return=minimal" }, body: { active: false, updated_at: new Date().toISOString() } }).catch(() => {});
112
153
  }
113
154
  const live = list.filter((r) => !stale.includes(r.slug));
114
155
  return live.map((r) => ({ slug: r.slug, docId: r.doc_id, fileUrl: r.file_url, fileName: r.file_name, docTitle: r.doc_title, presenterName: r.presenter_name, ownerName: r.owner_name, ownerAvatar: r.owner_avatar, currentPage: r.current_page || 1, mine: !!(me && r.owner_email && r.owner_email === me), updatedAt: r.updated_at }));
@@ -148,8 +189,18 @@ async function endPresentationByOwner(slug, email, isAdmin) {
148
189
  const row = await getPresentation(slug);
149
190
  if (!row) return { ok: false, status: 404 };
150
191
  if (!isAdmin && (!row.owner_email || row.owner_email !== lc(email))) return { ok: false, status: 403 };
151
- await PLAYER.db.request(`doc_presentations?slug=eq.${enc(slug)}`, { method: "PATCH", headers: { Prefer: "return=minimal" }, body: { ...CLOTURE, updated_at: new Date().toISOString() } });
152
- return { ok: true };
192
+ // ⚠️ LE PROPRIÉTAIRE DANS LA CONDITION (l'admin, lui, ferme ce qu'il veut : modération). Une
193
+ // clôture retardée émise par l'ANCIEN propriétaire après un transfert fermait la session du
194
+ // nouveau — le contraire de ce que le transfert venait d'accorder.
195
+ if (isAdmin) {
196
+ await PLAYER.db.request(`doc_presentations?slug=eq.${enc(slug)}`, { method: "PATCH", headers: { Prefer: "return=minimal" }, body: { ...CLOTURE, updated_at: new Date().toISOString() } });
197
+ return { ok: true };
198
+ }
199
+ const ecrit = await ecrireSiEncoreVrai(
200
+ `doc_presentations?slug=eq.${enc(slug)}&owner_email=eq.${enc(row.owner_email)}`,
201
+ { ...CLOTURE, updated_at: new Date().toISOString() },
202
+ );
203
+ return ecrit ? { ok: true } : { ok: false, status: 409 };
153
204
  }
154
205
 
155
206
  // Transfert de contrôle : le propriétaire actuel (JWT) désigne un nouveau membre propriétaire (par email).
@@ -161,8 +212,14 @@ async function handoverPresentation(slug, currentEmail, newOwner) {
161
212
  const row = await getPresentation(slug);
162
213
  if (!row) return { ok: false, status: 404 };
163
214
  if (!row.owner_email || row.owner_email !== lc(currentEmail)) return { ok: false, status: 403 };
164
- await PLAYER.db.request(`doc_presentations?slug=eq.${enc(slug)}`, { method: "PATCH", headers: { Prefer: "return=minimal" }, body: { owner_user_id: o.id ? String(o.id) : null, owner_email: lc(o.email), owner_name: (o.name || "").slice(0, 120) || null, owner_avatar: (o.avatar || "").slice(0, 600) || null, updated_at: new Date().toISOString() } });
165
- return { ok: true, slug };
215
+ // ⚠️ Deux transferts concurrents depuis le même propriétaire : le premier écrit gagne, le second
216
+ // ne trouve plus sa ligne — sans condition, il ÉCRASAIT le premier et le document changeait de
217
+ // mains deux fois, dans un ordre que personne n'avait choisi.
218
+ const ecrit = await ecrireSiEncoreVrai(
219
+ `doc_presentations?slug=eq.${enc(slug)}&owner_email=eq.${enc(lc(currentEmail))}`,
220
+ { owner_user_id: o.id ? String(o.id) : null, owner_email: lc(o.email), owner_name: (o.name || "").slice(0, 120) || null, owner_avatar: (o.avatar || "").slice(0, 600) || null, updated_at: new Date().toISOString() },
221
+ );
222
+ return ecrit ? { ok: true, slug } : { ok: false, status: 409 };
166
223
  }
167
224
 
168
225
  async function getPresentation(slug) {
@@ -350,6 +407,21 @@ const CHAMPS_PUBLICS = [
350
407
  */
351
408
  const refAuteur = (hash) => (hash ? sha("ref:" + hash).slice(0, 16) : null);
352
409
 
410
+ /**
411
+ * ⚠️ L'IDENTITÉ D'UN RÉACTEUR SE DÉRIVE D'UN SECRET, ELLE NE SE DÉCLARE PAS.
412
+ *
413
+ * Le client envoyait `reactor: MOIREF` — or MOIREF est PUBLIC : chaque participant reçoit les refs
414
+ * de tous les autres dans le tableau des réactions (il en a besoin pour dessiner « j'ai réagi »).
415
+ * N'importe qui pouvait donc copier le ref d'un autre et poser ou retirer SES réactions. Constat
416
+ * du troisième audit, vérifié : les refs transitent en clair dans `reactionsPubliques`.
417
+ *
418
+ * Le jeton d'auteur, lui, ne quitte jamais le navigateur qui l'a tiré. La dérivation est la même
419
+ * que côté client (`referenceAuteur` dans src/live.ts) : sha("ref:" + sha(jeton)), tronqué à 16 —
420
+ * si les deux divergent, « mes » réactions cessent de s'afficher comme miennes, et l'essai qui
421
+ * confronte les deux moteurs le dira.
422
+ */
423
+ const reacteurDepuisJeton = (jeton) => (jeton ? refAuteur(sha(String(jeton))) : null);
424
+
353
425
  /**
354
426
  * ⚠️ LES ANCIENNES RÉACTIONS PORTENT DES ADRESSES, et aucune migration ne les réécrira : le
355
427
  * client stockait `email || nom` comme identité de réacteur. On garde le COMPTE — la pastille
@@ -504,8 +576,13 @@ async function setChatLock(slug, control, locked) {
504
576
  const pres = await getPresentation(slug);
505
577
  if (!pres) return { ok: false, status: 404 };
506
578
  if (!tokenMatches(control, pres.control_hash)) return { ok: false, status: 403 };
507
- await PLAYER.db.request(`doc_presentations?slug=eq.${enc(String(slug || ""))}`, { method: "PATCH", headers: { Prefer: "return=minimal" }, body: { chat_locked: !!locked } });
508
- return { ok: true };
579
+ // Le jeton dans la condition : un verrou posé APRÈS une clôture ou une reprise ailleurs ne
580
+ // trouve plus sa ligne — il ne s'applique pas à une session qui n'est plus la sienne.
581
+ const ecrit = await ecrireSiEncoreVrai(
582
+ `doc_presentations?slug=eq.${enc(String(slug || ""))}&control_hash=eq.${sha(control)}`,
583
+ { chat_locked: !!locked },
584
+ );
585
+ return ecrit ? { ok: true } : { ok: false, status: 409 };
509
586
  }
510
587
 
511
588
  // Réaction emoji (toggle) : le participant (identifié par email ou nom) ajoute/retire un emoji sur un message.
@@ -532,7 +609,10 @@ async function toggleReaction(slug, msgId, emoji, reactor, etat) {
532
609
  // participants comme une réaction, ce qui est absurde même sans être dangereux. Deux barrières,
533
610
  // parce que la seconde protège le jour où quelqu'un lève le plafond de longueur.
534
611
  if (/[A-Za-z_$]/.test(e)) return { ok: false, status: 400 };
535
- const rows = await PLAYER.db.request(`doc_presentation_messages?id=eq.${id}&slug=eq.${enc(String(slug || ""))}&select=reactions&limit=1`);
612
+ // ⚠️ LE RANG N'EST DEMANDÉ QUE LÀ OÙ IL EXISTE — sélectionner une colonne absente fait échouer
613
+ // la LECTURE entière chez un hôte non migré, et ce serait les réactions qu'on casserait.
614
+ const ordonne = await require("./schema").attendue("reactionsOrdonnees");
615
+ const rows = await PLAYER.db.request(`doc_presentation_messages?id=eq.${id}&slug=eq.${enc(String(slug || ""))}&select=${ordonne ? "reactions,reactions_seq" : "reactions"}&limit=1`);
536
616
  // ⚠️ LE NOM DE PROPRIÉTÉ ÉCRIT ICI VIENT DU CLIENT — et la garde posée en 0.1.2 ne couvrait que
537
617
  // les LECTURES. `Object.hasOwn` empêchait de LIRE `constructor` ; rien n'empêchait de l'ÉCRIRE.
538
618
  //
@@ -547,16 +627,35 @@ async function toggleReaction(slug, msgId, emoji, reactor, etat) {
547
627
  //
548
628
  // Un objet SANS prototype retire la classe entière : il n'y a plus rien à masquer ni à
549
629
  // atteindre, quelle que soit la clé et quel que soit le plafond.
550
- const brut = (Array.isArray(rows) && rows[0] && rows[0].reactions && typeof rows[0].reactions === "object" && !Array.isArray(rows[0].reactions)) ? rows[0].reactions : {};
551
- const cur = Object.assign(Object.create(null), brut);
552
- const arr = Array.isArray(cur[e]) ? cur[e] : [];
553
- const i = arr.indexOf(who);
554
- const veut = etat === undefined || etat === null ? (i < 0) : !!etat;
555
- if (veut && i < 0) arr.push(who);
556
- if (!veut && i >= 0) arr.splice(i, 1);
557
- if (arr.length) cur[e] = arr; else delete cur[e];
558
- const maj = await PLAYER.db.request(`doc_presentation_messages?id=eq.${id}&select=*`, { method: "PATCH", headers: { Prefer: "return=representation" }, body: { reactions: cur } });
559
- return { ok: true, message: premierPublic(maj) };
630
+ // ⚠️ LIRE-MODIFIER-RÉÉCRIRE PERD DES ÉCRITURES : deux participants qui réagissent dans la même
631
+ // seconde lisent le même JSON, et la seconde réécriture emporte la première — une réaction
632
+ // disparaît, sans erreur nulle part. Même remède que le pilotage (0002) : l'écriture porte le
633
+ // rang qu'elle a LU, la base ne l'accepte que s'il n'a pas bougé, sinon on relit et on rejoue.
634
+ // Trois rejeux suffisent : chaque tour, au moins UN écrivain gagne — l'attente est bornée par le
635
+ // nombre de concurrents réels, pas par la malchance.
636
+ let lu = rows;
637
+ for (let essai = 0; essai < 4; essai += 1) {
638
+ const brut = (Array.isArray(lu) && lu[0] && lu[0].reactions && typeof lu[0].reactions === "object" && !Array.isArray(lu[0].reactions)) ? lu[0].reactions : {};
639
+ const rang = ordonne ? Math.trunc(Number((Array.isArray(lu) && lu[0] && lu[0].reactions_seq) || 0)) : 0;
640
+ // Objet NU déclaré tel quel : la garde statique des écritures indexées reconnaît la
641
+ // déclaration, pas un voisinage — et c'est elle qui a refusé la première forme de cette boucle.
642
+ const cur = Object.create(null);
643
+ Object.assign(cur, brut);
644
+ const arr = Array.isArray(cur[e]) ? cur[e] : [];
645
+ const i = arr.indexOf(who);
646
+ const veut = etat === undefined || etat === null ? (i < 0) : !!etat;
647
+ if (veut && i < 0) arr.push(who);
648
+ if (!veut && i >= 0) arr.splice(i, 1);
649
+ if (arr.length) cur[e] = arr; else delete cur[e];
650
+ const condition = ordonne ? `&reactions_seq=eq.${rang}` : "";
651
+ const maj = await PLAYER.db.request(`doc_presentation_messages?id=eq.${id}${condition}&select=*`, { method: "PATCH", headers: { Prefer: "return=representation" }, body: { reactions: cur, ...(ordonne ? { reactions_seq: rang + 1 } : {}) } });
652
+ if (!ordonne || (Array.isArray(maj) && maj.length)) return { ok: true, message: premierPublic(maj) };
653
+ // Rang dépassé : quelqu'un a écrit entre notre lecture et notre écriture. On relit, on rejoue.
654
+ lu = await PLAYER.db.request(`doc_presentation_messages?id=eq.${id}&slug=eq.${enc(String(slug || ""))}&select=reactions,reactions_seq&limit=1`);
655
+ }
656
+ // Quatre tours perdus : plus de concurrents que de raisons d'attendre. Un refus net vaut mieux
657
+ // qu'une écriture qui écraserait ce que les gagnants viennent de poser.
658
+ return { ok: false, status: 409 };
560
659
  }
561
660
 
562
661
  // ── Statistiques de présentation (assistance) ────────────────────────────────────────────────────────────
@@ -767,4 +866,6 @@ async function listPresentationsForDoc(docId, email, isAdmin, autoriseLarge) {
767
866
  }
768
867
 
769
868
  module.exports = {
869
+ reacteurDepuisJeton,
870
+ purgerPerimees,
770
871
  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 };
package/server/schema.js CHANGED
@@ -42,6 +42,11 @@ const ATTENDUES = {
42
42
  migration: "supabase/migrations/0005-envoi-unique.sql",
43
43
  fonction: "empêcher qu'un renvoi crée un second message",
44
44
  },
45
+ reactionsOrdonnees: {
46
+ table: "doc_presentation_messages", colonne: "reactions_seq",
47
+ migration: "supabase/migrations/0006-reactions-ordonnees.sql",
48
+ fonction: "empêcher deux réactions simultanées de s'écraser",
49
+ },
45
50
  };
46
51
 
47
52
  /**
@@ -71,6 +76,25 @@ let PLAYER = null;
71
76
  */
72
77
  const connues = new Map();
73
78
 
79
+ /**
80
+ * ⚠️ UN « NON » N'A PAS LA MÊME DURÉE DE VIE QU'UN « OUI » — et les confondre a fait qu'une panne
81
+ * passagère ÉTEIGNAIT des fonctions pour la vie du processus.
82
+ *
83
+ * Un « oui » est stable : une colonne présente ne disparaît pas (les migrations sont additives, et
84
+ * personne ne supprime une colonne sous un processus qui tourne). Un « non », lui, recouvre deux
85
+ * réalités que la sonde ne distingue pas — colonne absente, base injoignable — dont la seconde se
86
+ * répare TOUTE SEULE. Retenir ce « non » pour toujours transformait un hoquet de base pendant un
87
+ * envoi de message en idempotence morte jusqu'au redémarrage, sans un mot.
88
+ *
89
+ * D'où : un « oui » est retenu pour le processus ; un « non » expire. Le délai borne le coût chez
90
+ * un hôte réellement non migré (une sonde ratée par minute et par attente, pas une par écriture)
91
+ * tout en rendant la guérison automatique — la seule direction où l'erreur se répare toute seule.
92
+ * Trouvé par le troisième audit, qui a aussi montré que notre essai de récupération appelait
93
+ * `init()` — lequel vide précisément ce cache : il prouvait une guérison qui n'existait pas.
94
+ */
95
+ const TTL_NEGATIF_MS = 60 * 1000;
96
+ const quandNegatif = new Map(); // cle → instant du « non », pour le faire expirer
97
+
74
98
  /**
75
99
  * Les réponses DÉJÀ OBTENUES, pour qui veut les lire — la carte d'identité, essentiellement.
76
100
  *
@@ -84,6 +108,9 @@ function init(ctx) {
84
108
  PLAYER = ctx;
85
109
  connues.clear();
86
110
  reponses.clear();
111
+ quandNegatif.clear();
112
+ sondeEnCours = null;
113
+ sondePrise = 0;
87
114
  }
88
115
 
89
116
  /** La sonde d'une attente déclarée. C'est la seule forme d'appel que les appelants utilisent. */
@@ -146,6 +173,12 @@ function etatDuSchema() {
146
173
  */
147
174
  function aLaColonne(table, colonne, migration) {
148
175
  const cle = `${table}.${colonne}`;
176
+ // Un « non » périmé n'est plus une réponse : on repose la question. Un « oui » ne périme pas.
177
+ const depuis = quandNegatif.get(cle);
178
+ if (depuis !== undefined && Date.now() - depuis > TTL_NEGATIF_MS) {
179
+ connues.delete(cle);
180
+ quandNegatif.delete(cle);
181
+ }
149
182
  if (!connues.has(cle)) connues.set(cle, sonder(table, colonne, migration, cle));
150
183
  return connues.get(cle);
151
184
  }
@@ -166,6 +199,7 @@ async function sonder(table, colonne, migration, cle) {
166
199
  // portage a un fichier à réécrire, pas une habitude à retrouver partout.
167
200
  const champ = encodeURIComponent(colonne);
168
201
  await PLAYER.db.request(`${table}?select=${champ}&limit=0`);
202
+ quandNegatif.delete(cle);
169
203
  noter(cle, true, migration);
170
204
  return true;
171
205
  } catch {
@@ -174,6 +208,7 @@ async function sonder(table, colonne, migration, cle) {
174
208
  // message d'erreur, c'est-à-dire de dépendre du texte d'un service tiers. Ce qui change entre
175
209
  // les deux, c'est la durée : une base injoignable le redevient, et le processus suivant reposera
176
210
  // la question.
211
+ quandNegatif.set(cle, Date.now());
177
212
  noter(cle, false, migration);
178
213
  signaler(cle, migration);
179
214
  return false;
@@ -201,7 +236,24 @@ function verdict(manque, sondees, attendues) {
201
236
  * messages jusqu'au prochain démarrage. Une route de contrôle qui casse la production. Si le
202
237
  * témoin ne répond pas, on ne sonde RIEN et on ne retient RIEN.
203
238
  */
204
- async function sonderTout() {
239
+ /**
240
+ * ⚠️ LA SONDE EST PUBLIQUE : chaque appel coûtait des requêtes base, autant de fois qu'on veut.
241
+ * Une boucle sur `?contract=1&schema=1` faisait de la carte un petit amplificateur — la ressource
242
+ * PARTAGÉE paie, pas l'appelant. Deux bornes : les appels simultanés partagent UNE sonde, et le
243
+ * résultat sert pendant 30 s — un état de schéma ne change pas plus vite qu'une migration.
244
+ */
245
+ const CACHE_SONDE_MS = 30 * 1000;
246
+ let sondeEnCours = null;
247
+ let sondePrise = 0;
248
+
249
+ function sonderTout() {
250
+ if (sondeEnCours && Date.now() - sondePrise < CACHE_SONDE_MS) return sondeEnCours;
251
+ sondePrise = Date.now();
252
+ sondeEnCours = vraimentSonderTout();
253
+ return sondeEnCours;
254
+ }
255
+
256
+ async function vraimentSonderTout() {
205
257
  // ⚠️ LA PART ENCODÉE EST CALCULÉE À PART, comme dans `sonder()` dix lignes plus haut, et pour la
206
258
  // même raison : la garde de portabilité traque une parenthèse après « select= », et un appel de
207
259
  // fonction écrit dans le gabarit en produit une. J'ai reproduit ici le défaut dont le correctif
@@ -215,6 +267,11 @@ async function sonderTout() {
215
267
  // dit que cette mesure-ci n'a pas eu lieu. Taire l'un ou l'autre serait mentir d'un côté.
216
268
  return { ...etatDuSchema(), verdict: "indetermine" };
217
269
  }
270
+ // ⚠️ LE TÉMOIN VIENT DE RÉPONDRE : tout « non » encore en cache est SUSPECT — il peut dater
271
+ // d'une panne guérie. On le jette et on repose la question, sinon ce diagnostic rendrait la
272
+ // valeur d'un incident passé en la datant d'aujourd'hui. Les « oui » restent : ils sont stables.
273
+ for (const [cle] of quandNegatif) { connues.delete(cle); reponses.delete(cle); }
274
+ quandNegatif.clear();
218
275
  for (const nom of Object.keys(ATTENDUES)) await attendue(nom);
219
276
  return etatDuSchema();
220
277
  }
@@ -238,6 +295,6 @@ function signaler(cle, migration) {
238
295
  }
239
296
 
240
297
  /** Pour les tests et l'exploitation : reposer la question. */
241
- function oublier() { connues.clear(); reponses.clear(); }
298
+ function oublier() { connues.clear(); reponses.clear(); quandNegatif.clear(); sondeEnCours = null; sondePrise = 0; }
242
299
 
243
300
  module.exports = { init, aLaColonne, attendue, etatDuSchema, sonderTout, oublier, ATTENDUES, TEMOIN };
package/supabase/init.sql CHANGED
@@ -126,7 +126,10 @@ create index if not exists commercial_doc_internal_sessions_last_at_idx
126
126
  -- que le droit de suivre.
127
127
  create table if not exists public.doc_presentations (
128
128
  slug text primary key,
129
- control_hash text not null,
129
+ -- ⚠️ NULLABLE, ET C'EST LE CŒUR DU MODÈLE : null = archivée (plus de pilote). Déclarée NOT NULL
130
+ -- jusqu'à la 0008, ce qui rendait la CLÔTURE impossible sur une base neuve — trouvé par le banc
131
+ -- vraie base, seul endroit où une contrainte peut refuser quelque chose.
132
+ control_hash text,
130
133
  doc_id text,
131
134
  file_url text not null,
132
135
  file_name text,
@@ -173,7 +176,10 @@ create table if not exists public.doc_presentation_messages (
173
176
  attachment jsonb,
174
177
  -- Clé d'idempotence fabriquée par le client AVANT le premier envoi et réutilisée au renvoi :
175
178
  -- un renvoi réseau ne crée pas un second message. Nulle si le client ne la fournit pas.
176
- client_key text
179
+ client_key text,
180
+ -- Rang de la dernière écriture de réactions acceptée : deux réactions simultanées ne
181
+ -- s'écrasent plus — l'écriture conditionnée à un rang dépassé ne modifie rien, le serveur rejoue.
182
+ reactions_seq bigint not null default 0
177
183
  );
178
184
  create index if not exists dpm_slug_idx on public.doc_presentation_messages (slug, created_at);
179
185
  -- ⚠️ Portée sur (slug, client_key), pas sur la clé seule : deux présentations n'ont aucune raison
@@ -310,15 +316,18 @@ alter table public.doc_bot_sessions enable row level security;
310
316
  -- le présentateur sans qu'aucune table ne soit lisible publiquement. C'est ce qui permet de ne
311
317
  -- créer aucune politique de lecture au-dessus.
312
318
  --
313
- -- ⚠️ LE CHAT N'EST PAS ENCORE SUR CE CHEMIN. Ses messages sont livrés par `postgres_changes`, qui
314
- -- exige un SELECT au niveau de la TABLE — donc ouvert à quiconque détient la clé publiable, et
315
- -- pour TOUTES les présentations, pas seulement la sienne. Ce fichier refuse ce compromis : la
316
- -- table est publiée, mais AUCUNE politique de lecture n'est créée.
319
+ -- ⚠️ LE CHAT AUSSI EST SUR CE CHEMIN, DÉSORMAIS. Un message émet un signal broadcast (`msg`,
320
+ -- `msg-upd`) et chaque participant RELIT l'historique par la route du player — le signal dit
321
+ -- « quelque chose a changé », jamais le contenu. Aucune politique de lecture n'est donc
322
+ -- nécessaire, et le chat est en direct sur une installation neuve.
317
323
  --
318
- -- Conséquence assumée sur une installation neuve : un participant voit l'historique du chat en
319
- -- arrivant, et les messages suivants à l'actualisation — pas en direct. Le chat n'est pas cassé,
320
- -- il est différé. Le portage du chat en broadcast lèvera cette limite ; d'ici là, mieux vaut une
321
- -- fonctionnalité partiellement en retard qu'une base où chacun lit les conversations des autres.
324
+ -- (Ce commentaire a affirmé le contraire pendant plusieurs versions après le portage du chat en
325
+ -- broadcast — le troisième audit l'a relevé. Un commentaire périmé dans un fichier d'installation
326
+ -- n'est pas un détail : il pousse un mainteneur à « réparer » un différé qui n'existe plus, en
327
+ -- ajoutant précisément la politique de lecture que ce fichier refuse.)
328
+ --
329
+ -- La publication de la table reste utile aux INSTANCES HISTORIQUES encore sur `postgres_changes`,
330
+ -- et inoffensive ici : publiée sans politique de lecture, elle ne livre rien à personne.
322
331
  --
323
332
  -- ⚠️ Ne mettez de toute façon jamais de donnée confidentielle dans un message de chat ni dans un
324
333
  -- nom d'auteur.
@@ -336,6 +345,43 @@ begin
336
345
  end $$;
337
346
  alter table public.doc_presentation_messages replica identity full;
338
347
 
348
+ -- ── Le scellé de l'archive ─────────────────────────────────────────────────────────────────────
349
+ -- Sept chemins d'écriture vérifient l'archive PUIS écrivent — dans une AUTRE table que celle qui
350
+ -- porte l'état. Aucun filtre PostgREST ne peut fermer cette fenêtre : l'arbitre est la base.
351
+ -- `FOR KEY SHARE` bloque une clôture concurrente jusqu'au commit de l'écriture — c'est lui qui
352
+ -- rend le refus atomique, pas le simple test.
353
+ create or replace function public.player_archive_scellee()
354
+ returns trigger
355
+ language plpgsql
356
+ as $$
357
+ declare
358
+ v_active boolean;
359
+ v_control text;
360
+ begin
361
+ select p.active, p.control_hash into v_active, v_control
362
+ from public.doc_presentations p
363
+ where p.slug = new.slug
364
+ for key share;
365
+ -- Pas de présentation : rien à sceller (chat d'un lien sans session live, données historiques).
366
+ if not found then return new; end if;
367
+ if v_active = false and v_control is null then
368
+ raise exception 'presentation archivee : ecriture refusee'
369
+ using errcode = 'P0001', hint = 'l''archive est en lecture seule';
370
+ end if;
371
+ return new;
372
+ end
373
+ $$;
374
+
375
+ drop trigger if exists dpm_archive_scellee on public.doc_presentation_messages;
376
+ create trigger dpm_archive_scellee
377
+ before insert or update on public.doc_presentation_messages
378
+ for each row execute function public.player_archive_scellee();
379
+
380
+ drop trigger if exists dpa_archive_scellee on public.doc_presentation_attendees;
381
+ create trigger dpa_archive_scellee
382
+ before insert or update on public.doc_presentation_attendees
383
+ for each row execute function public.player_archive_scellee();
384
+
339
385
  -- ── Rattrapage : bases installées depuis un init.sql plus ancien ───────────────────────────────
340
386
  --
341
387
  -- ⚠️ CE FICHIER A ÉTÉ INCOMPLET, ET RIEN NE LE DISAIT. Il annonçait « un seul fichier, sans rien à
@@ -355,3 +401,8 @@ alter table public.doc_presentations
355
401
  add column if not exists write_seq bigint not null default 0;
356
402
  alter table public.doc_presentation_messages
357
403
  add column if not exists client_key text;
404
+ -- Une base née d'un init.sql d'avant la 0008 porte un NOT NULL qui rend la clôture impossible.
405
+ alter table public.doc_presentations
406
+ alter column control_hash drop not null;
407
+ alter table public.doc_presentation_messages
408
+ add column if not exists reactions_seq bigint not null default 0;
@@ -0,0 +1,17 @@
1
+ -- DEUX RÉACTIONS SIMULTANÉES S'ÉCRASAIENT.
2
+ --
3
+ -- Le serveur lit le JSON des réactions, le modifie en mémoire, le réécrit en entier : deux
4
+ -- participants qui réagissent au même message dans la même seconde lisent le même état, et la
5
+ -- seconde écriture emporte la première — une réaction disparaît, sans erreur nulle part.
6
+ --
7
+ -- Même remède que pour les écritures de pilotage (0002) : un rang. L'écriture porte le rang
8
+ -- qu'elle a lu et n'est acceptée que s'il n'a pas bougé ; sinon le serveur relit et rejoue.
9
+ -- La comparaison et l'écriture deviennent le même geste — c'est la base qui arbitre, pas la
10
+ -- mémoire d'un processus.
11
+ alter table public.doc_presentation_messages
12
+ add column if not exists reactions_seq bigint not null default 0;
13
+
14
+ comment on column public.doc_presentation_messages.reactions_seq is
15
+ 'Rang de la dernière écriture de réactions acceptée. Une écriture conditionnée à un rang déjà '
16
+ 'dépassé ne modifie aucune ligne : le serveur relit et rejoue. Sans lui : rien ne casse, mais '
17
+ 'deux réactions simultanées peuvent s''écraser (dernier écrivain gagne), comme avant la 0006.';
@@ -0,0 +1,49 @@
1
+ -- L'ARCHIVE ÉTAIT VÉRIFIÉE, PUIS L'ÉCRITURE PARTAIT SANS ELLE.
2
+ --
3
+ -- Sept chemins d'écriture (message, édition, suppression, réaction, présence, verrou, pièce
4
+ -- jointe) demandent d'abord si la présentation est archivée, puis écrivent. Entre les deux, une
5
+ -- clôture peut passer : un message atterrit dans une archive « en lecture seule ». La fenêtre est
6
+ -- étroite, mais la promesse — une archive ne bouge plus — était tenue par du code, pas par la base.
7
+ --
8
+ -- ⚠️ AUCUN FILTRE POSTGREST NE PEUT FERMER ÇA : les écritures visent doc_presentation_messages et
9
+ -- doc_presentation_attendees, la condition vit dans doc_presentations — une AUTRE table. Sans
10
+ -- transaction ni RPC côté player, l'arbitre ne peut être que la base elle-même. D'où ce trigger.
11
+ --
12
+ -- ⚠️ `FOR KEY SHARE` EST CE QUI REND LE REFUS ATOMIQUE, pas le simple test. Sans lui, une clôture
13
+ -- peut se COMMITTER entre la vérification du trigger et le commit de l'écriture — la fenêtre
14
+ -- rétrécit mais survit. Le verrou de ligne bloque la clôture concurrente jusqu'au commit de
15
+ -- l'écriture : l'ordre devient réel, plus probable.
16
+ --
17
+ -- Sans lui : rien ne casse — les sept chemins gardent leur vérification préalable, et la fenêtre
18
+ -- de course reste ce qu'elle était avant cette migration. Le trigger est le scellé, pas la porte.
19
+ create or replace function public.player_archive_scellee()
20
+ returns trigger
21
+ language plpgsql
22
+ as $$
23
+ declare
24
+ v_active boolean;
25
+ v_control text;
26
+ begin
27
+ select p.active, p.control_hash into v_active, v_control
28
+ from public.doc_presentations p
29
+ where p.slug = new.slug
30
+ for key share;
31
+ -- Pas de présentation : rien à sceller (chat d'un lien sans session live, données historiques).
32
+ if not found then return new; end if;
33
+ if v_active = false and v_control is null then
34
+ raise exception 'presentation archivee : ecriture refusee'
35
+ using errcode = 'P0001', hint = 'l''archive est en lecture seule';
36
+ end if;
37
+ return new;
38
+ end
39
+ $$;
40
+
41
+ drop trigger if exists dpm_archive_scellee on public.doc_presentation_messages;
42
+ create trigger dpm_archive_scellee
43
+ before insert or update on public.doc_presentation_messages
44
+ for each row execute function public.player_archive_scellee();
45
+
46
+ drop trigger if exists dpa_archive_scellee on public.doc_presentation_attendees;
47
+ create trigger dpa_archive_scellee
48
+ before insert or update on public.doc_presentation_attendees
49
+ for each row execute function public.player_archive_scellee();
@@ -0,0 +1,19 @@
1
+ -- CLÔTURER ÉTAIT IMPOSSIBLE SUR UNE BASE NEUVE.
2
+ --
3
+ -- La clôture d'une présentation pose `control_hash = null` : c'est LE marqueur d'archive — le slug
4
+ -- seul donne le droit de suivre, le hash celui de piloter, et une archive n'a plus de pilote.
5
+ -- Or init.sql déclarait la colonne NOT NULL : chaque clôture violait la contrainte (23502) et
6
+ -- toute présentation restait « active » pour toujours chez un hôte installé depuis ce fichier.
7
+ --
8
+ -- ⚠️ TROUVÉ PAR LE BANC « VRAIE BASE », PAS PAR UN LECTEUR : 793 essais passaient — le double en
9
+ -- mémoire n'a pas de contraintes, c'est écrit dans son en-tête. Les bases HISTORIQUES, nées de
10
+ -- migrations plus anciennes, sont nullables : la production ne pouvait pas le montrer non plus.
11
+ --
12
+ -- Sans lui : sur une base née d'un init.sql d'avant cette correction, `endPresentation` échoue en
13
+ -- erreur serveur et l'archive en lecture seule n'existe pas — les présentations ne se ferment pas.
14
+ alter table public.doc_presentations
15
+ alter column control_hash drop not null;
16
+
17
+ comment on column public.doc_presentations.control_hash is
18
+ 'Hash du jeton de pilotage. NULL = présentation archivée : plus personne ne pilote, et le '
19
+ 'scellé (0007) refuse toute écriture. Le slug seul ne donne que le droit de suivre.';