discovery-media-player 0.1.44 → 0.1.46

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.
@@ -53,10 +53,38 @@ therefore write **no wiring code at all**, only environment variables.
53
53
  ### 1. Who may manage tracked links
54
54
 
55
55
  ```
56
- POST → { "email": "…", "role": "…", "action": "create|list|list.all|revoke|setauth|overview|sessions|test" }
56
+ POST → { "email": "…", "role": "…", "action": "<one of the names below>" }
57
57
  ← { "allowed": true }
58
58
  ```
59
59
 
60
+ | action | what it grants |
61
+ |---|---|
62
+ | `create` | create a tracked link |
63
+ | `list` | list one's own links |
64
+ | `list.all` | list everyone's links |
65
+ | `revoke` | revoke a link |
66
+ | `setauth` | change a link's access wall |
67
+ | `overview` | read a document's aggregate figures |
68
+ | `sessions` | read individual reading sessions |
69
+ | `test` | create a rehearsal link |
70
+ | `presentations.list.all` | list presentations **one does not own** (slugs, presenter names, counts) |
71
+ | `presentations.stats` | read the **attendees** of a presentation one does not own — names, addresses, dwell time, pages |
72
+
73
+ ⚠️ **This list grows, and a closed table refuses what it has not heard of.** Both
74
+ `presentations.*` names arrived in `0.1.46`; a host whose table predates them answers *no* to
75
+ everyone, administrators included — not because the right is missing, but because the table is older
76
+ than the player. That failure reads exactly like a permission problem, which is what makes it
77
+ expensive. **Compare this table against your own at each upgrade**, and prefer a refusal that names
78
+ the unknown action over one that looks like a role issue.
79
+
80
+ ⚠️ **`presentations.list.all` and `presentations.stats` are deliberately separate.** Seeing *that* a
81
+ presentation happened and seeing *who attended it* are different sensitivities: the first returns
82
+ metadata, the second returns people — often prospects. Merging them would take from you the choice of
83
+ opening one to the whole team while reserving the other.
84
+
85
+ Neither is needed to read one's **own** presentations: an owner, and an administrator, are always
86
+ served without the player asking you anything.
87
+
60
88
  - **The player verifies the token; you decide the rights.** No answer, or a failing rule, means
61
89
  refusal. A right that cannot be granted is not granted.
62
90
  - **`email` is the authoritative identity.** `role` is what the session token carried; a host whose
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "discovery-media-player",
3
- "version": "0.1.44",
3
+ "version": "0.1.46",
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,Ne=Object.defineProperties,$e=Object.getOwnPropertyDescriptor,He=Object.getOwnPropertyDescriptors,Ue=Object.getOwnPropertyNames,be=Object.getOwnPropertySymbols;var he=Object.prototype.hasOwnProperty,De=Object.prototype.propertyIsEnumerable;var ye=(e,n,t)=>n in e?C(e,n,{enumerable:!0,configurable:!0,writable:!0,value:t}):e[n]=t,K=(e,n)=>{for(var t in n||(n={}))he.call(n,t)&&ye(e,t,n[t]);if(be)for(var t of be(n))De.call(n,t)&&ye(e,t,n[t]);return e},ve=(e,n)=>Ne(e,He(n));var v=(e,n)=>{for(var t in n)C(e,t,{get:n[t],enumerable:!0})},We=(e,n,t,r)=>{if(n&&typeof n==\"object\"||typeof n==\"function\")for(let o of Ue(n))!he.call(e,o)&&o!==t&&C(e,o,{get:()=>n[o],enumerable:!(r=$e(n,o))||r.enumerable});return e};var Ve=e=>We(C({},\"__esModule\",{value:!0}),e);var Nn={};v(Nn,{bridge:()=>L,cadence:()=>O,chat:()=>H,live:()=>$,presentation:()=>U,presentationState:()=>W,tracking:()=>N,viewer:()=>D});var L={};v(L,{onHostMessage:()=>Xe,onPlayerMessage:()=>qe,parseHostMessage:()=>_e,parsePlayerMessage:()=>xe,sendToHost:()=>Be,sendToPlayer:()=>Ye,toWire:()=>Y});var B=\"3dd-doc-\",Fe=/^[A-Za-z0-9_-]{1,64}$/,ze=[\"close\",\"share\",\"embed-ready\",\"present-left\",\"present-denied\"],je=[\"embed-denied\"],Ge=[\"present-invite\",\"present-handover\",\"present-switch\"],Ke=[\"handover-done\"];function Y(e){let n={type:B+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 Me(e,n,t){if(!e||typeof e!=\"object\")return null;let r=e.type;if(typeof r!=\"string\"||!r.startsWith(B))return null;let o=r.slice(B.length);if(n.includes(o))return{type:o};if(je.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\"||!Fe.test(s)?null:{type:o,slug:s}}function xe(e){return Me(e,ze,Ge)}function _e(e){return Me(e,Ke,[])}function Be(e,n){try{let t=n||(typeof window!=\"undefined\"?window.parent:null);t&&t.postMessage(Y(e),\"*\")}catch(t){}}function Ye(e,n){var t;try{(t=e==null?void 0:e.contentWindow)==null||t.postMessage(Y(n),\"*\")}catch(r){}}function Ze(e,n){var r;if(!e)return!0;let t=(r=e.contentWindow)!=null?r:e;return n===t}function qe(e,n,t){let r=n||(typeof window!=\"undefined\"?window:null);if(!r)return()=>{};let o=s=>{if(!Ze(t,s.source))return;let a=xe(s.data);a&&e(a)};return r.addEventListener(\"message\",o),()=>r.removeEventListener(\"message\",o)}function Xe(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 l=_e(a.data);l&&e(l)};return t.addEventListener(\"message\",o),()=>t.removeEventListener(\"message\",o)}var N={};v(N,{createTracker:()=>rn});var O={};v(O,{PRESENTER_ACTIONS_PER_HOUR:()=>Se,PRESENT_QUOTA_PER_HOUR:()=>Je,PRESENT_READS_PER_HOUR:()=>ke,PRESENT_RESYNC_MS:()=>we,PRESENT_WRITE_TIMEOUT_MS:()=>Q,READERS_PER_EGRESS:()=>q,RESYNC_READS_PER_HOUR:()=>Ee,SESSION_IDLE_MS:()=>X,SESSION_INTERVAL_MS:()=>Z,SESSION_QUOTA_PER_HOUR:()=>Qe,SESSION_WRITES_PER_HOUR:()=>Te});var Z=12e3,Te=Math.ceil(300),q=25,Qe=Te*q,X=18e4,we=25e3,Ee=Math.ceil(36e5/we),Se=720,ke=Ee+Se,Je=ke*q,Q=1e4;var en=[\"mousemove\",\"mousedown\",\"keydown\",\"wheel\",\"touchstart\",\"pointerdown\"];function nn(){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 tn(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 rn(e={}){var fe,me;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=(fe=e.idleMs)!=null?fe:X,a=(me=e.sessionEveryMs)!=null?me:12e3,l=e.slug||\"\",p=e.internal||null,m=e.sessionId||nn(),b=e.send||tn(o,n),f={},u=0,y=0,c=0,M=0,h=null,w=r(),E=!1,V=!1,P=[],Oe=()=>!!l||!!p,le=i=>{if(Oe())try{b(i)}catch(d){}},F=()=>!!t&&t.visibilityState===\"visible\"&&!E,ue=i=>{let d=r()-i,x=Math.max(0,w-i)+s;return Math.max(0,Math.min(d,x))},z=()=>{h!=null&&u>0&&(f[u]=(f[u]||0)+ue(h)/1e3),h=F()?r():null},ce=()=>{z(),h=null},j=()=>{h==null&&F()&&(h=r())},G=()=>{let i=0;for(let d in f)i+=f[d];return h!=null&&(i+=ue(h)/1e3),Math.round(i)},de=()=>{let i={};for(let d in f)i[d]=Math.round(f[d]);return i},S=()=>{w=r(),E&&(E=!1,j())},pe=(i,d)=>{p||le({slug:l,event:i,page:d||y,maxPage:c,seconds:G(),sessionId:m})},A=()=>{z();let i={event:\"session\",sessionId:m,numPages:M,maxPage:c,totalSeconds:G(),pagesTime:de()};p?(i.internal=!0,i.docId=p.docId,i.email=p.email,i.name=p.name,p.it&&(i.it=p.it)):i.slug=l,le(i)},k=(i,d,x,ge)=>{!i||typeof i.addEventListener!=\"function\"||(i.addEventListener(d,x,ge),P.push(()=>{i.removeEventListener(d,x,ge)}))};return{sessionId:m,totalSeconds:G,maxPage:()=>c,pageTimes:de,start(){if(V)return;V=!0,pe(\"open\",1),u=1,w=r(),h=F()?r():null,k(t,\"visibilitychange\",()=>{t.visibilityState===\"hidden\"?(ce(),A()):(S(),j())}),k(n,\"focus\",()=>{S(),j()});for(let x of en)k(n,x,S,{passive:!0});k(e.scrollElement,\"scroll\",S,{passive:!0}),k(n,\"beforeunload\",A);let i=n.setInterval(()=>{!E&&r()-w>s&&(E=!0,ce())},5e3),d=n.setInterval(A,a);P.push(()=>{n.clearInterval(i),n.clearInterval(d)})},setPage(i){!i||i===y||(w=r(),z(),u=i,y=i,i>c&&(c=i,pe(\"page\",i)))},setPageCount(i){M=i||0},noteActivity:S,flush:A,stop(){for(;P.length;){let i=P.pop();try{i==null||i()}catch(d){}}V=!1}}}var $={};v($,{STORAGE_KEYS:()=>R,attendeeKey:()=>ln,authorToken:()=>un,avatarHtml:()=>J,canModerate:()=>te,createScheduler:()=>fn,escapeHtml:()=>g,fetchBorne:()=>mn,flattenPresence:()=>on,formatMessageBody:()=>ee,initials:()=>Re,isMentioned:()=>cn,isMine:()=>_,reactorId:()=>ne,shouldNotify:()=>dn,unreadLabel:()=>pn});var R={attendeeKey:\"3dd-present-attkey\",authorToken:\"3dd-present-authtoken\",chatMuted:\"3dd-present-mute\"};function g(e){return String(e==null?\"\":e).replace(/&/g,\"&amp;\").replace(/</g,\"&lt;\").replace(/>/g,\"&gt;\").replace(/\"/g,\"&quot;\")}function Re(e){let n=(e||\"?\").trim().split(/\\s+/);return(((n[0]||\"\")[0]||\"?\")+((n[1]||\"\")[0]||\"\")).toUpperCase()}function J(e,n){return e?`<img src=\"${g(e)}\" alt=\"\">`:g(Re(n))}function ee(e){let n=g(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 on(e){let n={},t=[];for(let r in e||{})for(let o of(e||{})[r]||[]){let s=String(o.uid||o.email||o.name||\"\").toLowerCase()||`_${r}_${t.length}`;n[s]?o.role===\"presenter\"&&(n[s]=o):(n[s]=o,t.push(s))}return t.map(r=>n[r])}function sn(e,n){try{return e?e.getItem(n):null}catch(t){return null}}function an(e,n,t){try{e==null||e.setItem(n,t)}catch(r){}}function ln(e,n=\"anon\",t=Ie){try{if(!e)return`anon-${n}`;let r=e.getItem(R.attendeeKey);if(r)return r;let o=`anon-${t()}`;return e.setItem(R.attendeeKey,o),o}catch(r){return`anon-${n}`}}function Ie(){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 un(e,n=Ie){let t=sn(e,R.authorToken);if(t)return t;let r=n();return an(e,R.authorToken,r),r}function ne(e){return String(e&&(e.email||e.name)||\"\").toLowerCase()}function _(e,n){return!e||!n?!1:e.author_email&&n.email?e.author_email===n.email:!e.author_email&&!n.email?e.author_name===n.name:!1}function te(e){return!!(e&&e.role===\"presenter\")}function cn(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}`)&&!_(e,n):!1}function dn(e){let{msg:n,me:t,historyLoaded:r,chatHidden:o}=e;return!r||!n||n.deleted||_(n,t)?!1:o}function pn(e){return!e||e<=0?\"\":e>9?\"9+\":String(e)}function fn(e,n={}){var y;let t=(y=n.minMs)!=null?y:300,r=n.now||(()=>Date.now()),o=n.setTimer||((c,M)=>setTimeout(c,M)),s=n.clearTimer||(c=>clearTimeout(c)),a=!1,l=!1,p=-1/0,m=null,b=!1;function f(){if(b||a){l=!0;return}let c=t-(r()-p);if(c>0){u(c);return}a=!0,l=!1,p=r();let M=!1;e(()=>{M||(M=!0,a=!1,l&&!b&&f())})}function u(c){b||m!==null||(m=o(()=>{m=null,f()},Math.max(0,c)))}return{signaler(){b||f()},maintenant(){b||(p=-1/0,f())},arreter(){b=!0,m!==null&&(s(m),m=null)}}}function mn(e,n,t=Q,r={}){let o=r.fetch||(typeof fetch==\"function\"?fetch:void 0);if(!o)return Promise.reject(new Error(\"fetch indisponible\"));let s=r.setTimer||((u,y)=>setTimeout(u,y)),a=r.clearTimer||(u=>clearTimeout(u)),l=null;try{l=new AbortController}catch(u){}let p=l?ve(K({},n||{}),{signal:l.signal}):K({},n||{}),m=null,b=new Promise((u,y)=>{m=s(()=>{try{l==null||l.abort()}catch(c){}y(new Error(\"delai\"))},t)}),f=()=>{try{a(m)}catch(u){}};return Promise.race([o(e,p),b]).then(u=>(f(),u),u=>{throw f(),u})}var H={};v(H,{messageClassName:()=>bn,renderActions:()=>Ce,renderAttachment:()=>Ae,renderMessage:()=>gn,renderReactions:()=>Pe});function Pe(e,n){let t=e&&e.reactions||{},r=ne(n),o=\"\";for(let s in t){let a=t[s]||[];if(!a.length)continue;let l=a.indexOf(r)>=0;o+=`<button class=\"re-chip${l?\" on\":\"\"}\" data-e=\"${g(s)}\">${g(s)} ${a.length}</button>`}return o}function Ae(e){if(!e||!e.url)return\"\";let n=g(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=g(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 Ce(e,n){let 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||te(n.me))&&(r+='<button class=cm-del-btn title=\"Supprimer\">\\u{1F5D1}</button>'),r}function gn(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>${g(e.reply_name||\"\")}</b> ${g(e.reply_text||\"\")}</div>`:\"\",r=e.edited?\" <span class=cm-ed>(modifi\\xE9)</span>\":\"\",o=e.body?`<div class=txt>${ee(e.body)}${r}</div>`:\"\",s=e.is_presenter?\" <span class=tag>pr\\xE9sentateur</span>\":\"\";return`<span class=a>${J(e.author_avatar,e.author_name)}</span><span class=b><div class=who><b>${g(e.author_name||\"Invit\\xE9\")}</b>${s}</div>`+t+o+Ae(e.attachment)+`<div class=cm-re>${Pe(e,n.me)}</div></span><span class=cm-act>${Ce(e,n)}</span>`}function bn(e,n,t){return\"cm\"+(_(e,n)?\" me\":\"\")+(e.deleted?\" isdel\":\"\")+(t?\" mentioned\":\"\")}var U={};v(U,{DEFAULT_CENTER:()=>ie,DEFAULT_ZOOM:()=>se,MAP_TYPES:()=>I,cycleMapType:()=>yn,initialMapContent:()=>vn,mapTypeLabel:()=>hn,sanitizeContent:()=>ae});var I=[\"roadmap\",\"satellite\",\"hybrid\"],ie=[46.6,2.5],se=6;function T(e){let n=Number(e);return Number.isFinite(n)?n:null}function re(e){if(!Array.isArray(e))return null;let n=T(e[0]),t=T(e[1]);return n!=null&&t!=null?[n,t]:null}var oe=(e,n,t)=>Math.max(n,Math.min(t,e));function ae(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=re(n.position);if(!t)return null;let r=n.pov&&typeof n.pov==\"object\"?n.pov:null,o=r?{heading:T(r.heading)||0,pitch:oe(T(r.pitch)||0,-90,90)}:{heading:0,pitch:0};return{kind:\"streetview\",position:t,pov:o,zoom:oe(T(n.zoom)||1,0,5)}}return n.kind!==\"map\"?null:{kind:\"map\",center:re(n.center)||[...ie],zoom:oe(Math.trunc(T(n.zoom)||se),1,21),marker:re(n.marker),mapType:I.includes(n.mapType)?n.mapType:null,label:String(n.label||\"\").slice(0,160)||null}}function yn(e){let n=I.indexOf(e);return I[(n+1)%I.length]}function hn(e){return e===\"roadmap\"?\"\\u{1F6F0} Satellite\":e===\"satellite\"?\"\\u{1F5FA} Hybride\":\"\\u{1F5FA} Plan\"}function vn(){return{kind:\"map\",center:[...ie],zoom:se,marker:null,mapType:null,label:null}}var D={};v(D,{CURRENT_PAGE_MARGIN:()=>En,DEFAULT_ASPECT:()=>_n,MAX_ZOOM:()=>xn,MIN_PAGE_WIDTH:()=>Tn,MIN_ZOOM:()=>Mn,PRERENDER_MARGIN:()=>wn,arrowState:()=>Rn,averageColor:()=>An,clampPage:()=>Le,clampZoom:()=>Sn,fitWidth:()=>Pn,isImageDocument:()=>Cn,progressPercent:()=>kn});var Mn=.5,xn=3,_n=1.35,Tn=280,wn=\"500px 0px\",En=\"-48% 0px -48% 0px\";function Sn(e){let n=Math.round((Number(e)||0)*10)/10;return Math.max(.5,Math.min(3,n))}function Le(e,n){return Math.max(1,Math.min(n||1,Number(e)||1))}function kn(e,n){return n?Math.round(Le(e,n)/n*100):0}function Rn(e,n){return{prevDisabled:e<=1,nextDisabled:!!n&&e>=n}}var In=28;function Pn(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)-In-(e.overlap||0)-(e.reserve||0))/r;return s>120?Math.min(t,s):t}function An(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 Cn(e,n){return/[.](png|jpe?g|webp|gif|avif)($|[?])/i.test(String(e||n||\"\"))}var W={};v(W,{presentationTransition:()=>Ln,switchDocUrl:()=>On});function Ln(e,n={}){if(!e)return[{kind:\"nothing\"}];if(e.active===!1)return[{kind:\"ended\"}];let t=ae(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 On(e,n){let t=encodeURIComponent(n||String(Date.now()));return`${e}${e.includes(\"?\")?\"&\":\"?\"}v=${t}`}return Ve(Nn);})();", SOURCE_HASH: "23da6b6df4f91661" };
7
+ module.exports = { PLAYER_BROWSER_JS: "\"use strict\";var Player=(()=>{var C=Object.defineProperty,Ue=Object.defineProperties,De=Object.getOwnPropertyDescriptor,We=Object.getOwnPropertyDescriptors,Ve=Object.getOwnPropertyNames,ye=Object.getOwnPropertySymbols;var Me=Object.prototype.hasOwnProperty,Fe=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,B=(e,n)=>{for(var t in n||(n={}))Me.call(n,t)&&ve(e,t,n[t]);if(ye)for(var t of ye(n))Fe.call(n,t)&&ve(e,t,n[t]);return e},xe=(e,n)=>Ue(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 Ve(n))!Me.call(e,o)&&o!==t&&C(e,o,{get:()=>n[o],enumerable:!(r=De(n,o))||r.enumerable});return e};var je=e=>ze(C({},\"__esModule\",{value:!0}),e);var zn={};v(zn,{bridge:()=>L,cadence:()=>N,chat:()=>H,live:()=>$,presentation:()=>U,presentationState:()=>W,tracking:()=>O,viewer:()=>D});var L={};v(L,{onHostMessage:()=>en,onPlayerMessage:()=>Je,parseHostMessage:()=>Te,parsePlayerMessage:()=>we,sendToHost:()=>qe,sendToPlayer:()=>Qe,toWire:()=>Y});var K=\"3dd-doc-\",Ge=/^[A-Za-z0-9_-]{1,64}$/,Be=[\"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 _e(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 i=e.slug;return typeof i!=\"string\"||!Ge.test(i)?null:{type:o,slug:i}}function we(e){return _e(e,Be,Ye)}function Te(e){return _e(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=i=>{if(!Xe(t,i.source))return;let a=we(i.data);a&&e(a)};return r.addEventListener(\"message\",o),()=>r.removeEventListener(\"message\",o)}function en(e,n){var i;let t=n||(typeof window!=\"undefined\"?window:null);if(!t)return()=>{};let r=(i=t.parent)!=null?i:null,o=a=>{if(!r||r===t||a.source!==r)return;let l=Te(a.data);l&&e(l)};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:()=>Ae,PRESENT_QUOTA_PER_HOUR:()=>on,PRESENT_READS_PER_HOUR:()=>Re,PRESENT_READ_BURST:()=>Ie,PRESENT_READ_COALESCE_MS:()=>Pe,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,Ie=20,J=1e4,Pe=400,rn=Pe,Ae=4,on=(Re+Ie)*q*Ae;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\",i=(ge=e.idleMs)!=null?ge:Q,a=(be=e.sessionEveryMs)!=null?be:12e3,l=e.slug||\"\",d=e.internal||null,m=e.sessionId||an(),p=e.send||un(o,n),c={},u=0,g=0,f=0,M=0,y=null,E=r(),S=!1,V=!1,P=[],He=()=>!!l||!!d,ce=s=>{if(He())try{p(s)}catch(b){}},F=()=>!!t&&t.visibilityState===\"visible\"&&!S,de=s=>{let b=r()-s,_=Math.max(0,E-s)+i;return Math.max(0,Math.min(b,_))},z=()=>{y!=null&&u>0&&(c[u]=(c[u]||0)+de(y)/1e3),y=F()?r():null},pe=()=>{z(),y=null},j=()=>{y==null&&F()&&(y=r())},G=()=>{let s=0;for(let b in c)s+=c[b];return y!=null&&(s+=de(y)/1e3),Math.round(s)},fe=()=>{let s={};for(let b in c)s[b]=Math.round(c[b]);return s},k=()=>{E=r(),S&&(S=!1,j())},me=(s,b)=>{d||ce({slug:l,event:s,page:b||g,maxPage:f,seconds:G(),sessionId:m})},A=()=>{z();let s={event:\"session\",sessionId:m,numPages:M,maxPage:f,totalSeconds:G(),pagesTime:fe()};d?(s.internal=!0,s.docId=d.docId,s.email=d.email,s.name=d.name,d.it&&(s.it=d.it)):s.slug=l,ce(s)},R=(s,b,_,he)=>{!s||typeof s.addEventListener!=\"function\"||(s.addEventListener(b,_,he),P.push(()=>{s.removeEventListener(b,_,he)}))};return{sessionId:m,totalSeconds:G,maxPage:()=>f,pageTimes:fe,start(){if(V)return;V=!0,me(\"open\",1),u=1,E=r(),y=F()?r():null,R(t,\"visibilitychange\",()=>{t.visibilityState===\"hidden\"?(pe(),A()):(k(),j())}),R(n,\"focus\",()=>{k(),j()});for(let _ of sn)R(n,_,k,{passive:!0});R(e.scrollElement,\"scroll\",k,{passive:!0}),R(n,\"beforeunload\",A);let s=n.setInterval(()=>{!S&&r()-E>i&&(S=!0,pe())},5e3),b=n.setInterval(A,a);P.push(()=>{n.clearInterval(s),n.clearInterval(b)})},setPage(s){!s||s===g||(E=r(),z(),u=s,g=s,s>f&&(f=s,me(\"page\",s)))},setPageCount(s){M=s||0},noteActivity:k,flush:A,stop(){for(;P.length;){let s=P.pop();try{s==null||s()}catch(b){}}V=!1}}}var $={};v($,{STORAGE_KEYS:()=>x,attendeeKey:()=>fn,authorToken:()=>mn,avatarHtml:()=>ee,canModerate:()=>oe,createBudget:()=>Mn,createFileEcritures:()=>xn,createScheduler:()=>yn,escapeHtml:()=>h,fetchBorne:()=>vn,flattenPresence:()=>cn,formatMessageBody:()=>ne,initials:()=>Ce,isMentioned:()=>gn,isMine:()=>w,presenceId:()=>_n,reactorId:()=>re,shouldNotify:()=>bn,unreadLabel:()=>hn});var x={attendeeKey:\"3dd-present-attkey\",authorToken:\"3dd-present-authtoken\",chatMuted:\"3dd-present-mute\",presenceId:\"3dd-present-uid\"};function h(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=\"${h(e)}\" alt=\"\">`:h(Ce(n))}function ne(e){let n=h(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={},t=[];for(let r in e||{})for(let o of(e||{})[r]||[]){let i=String(o.uid||o.email||o.name||\"\").toLowerCase()||`_${r}_${t.length}`;n[i]?o.role===\"presenter\"&&(n[i]=o):(n[i]=o,t.push(i))}return t.map(r=>n[r])}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(x.attendeeKey);if(r)return r;let o=`anon-${t()}`;return e.setItem(x.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,x.authorToken);if(t)return t;let r=n();return pn(e,x.authorToken,r),r}function re(e){return String(e&&(e.email||e.name)||\"\").toLowerCase()}function w(e,n){return!e||!n?!1:e.author_email&&n.email?e.author_email===n.email:!e.author_email&&!n.email?e.author_name===n.name:!1}function oe(e){return!!(e&&e.role===\"presenter\")}function gn(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}`)&&!w(e,n):!1}function bn(e){let{msg:n,me:t,historyLoaded:r,chatHidden:o}=e;return!r||!n||n.deleted||w(n,t)?!1:o}function hn(e){return!e||e<=0?\"\":e>9?\"9+\":String(e)}function yn(e,n={}){var g;let t=(g=n.minMs)!=null?g:300,r=n.now||(()=>Date.now()),o=n.setTimer||((f,M)=>setTimeout(f,M)),i=n.clearTimer||(f=>clearTimeout(f)),a=!1,l=!1,d=-1/0,m=null,p=!1;function c(){if(p||a){l=!0;return}let f=t-(r()-d);if(f>0){u(f);return}a=!0,l=!1,d=r();let M=!1;e(()=>{M||(M=!0,a=!1,l&&!p&&c())})}function u(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&&(i(m),m=null)}}}function vn(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 i=r.setTimer||((u,g)=>setTimeout(u,g)),a=r.clearTimer||(u=>clearTimeout(u)),l=null;try{l=new AbortController}catch(u){}let d=l?xe(B({},n||{}),{signal:l.signal}):B({},n||{}),m=null,p=new Promise((u,g)=>{m=i(()=>{try{l==null||l.abort()}catch(f){}g(new Error(\"delai\"))},t)}),c=()=>{try{a(m)}catch(u){}};return Promise.race([o(e,d),p]).then(u=>(c(),u),u=>{throw c(),u})}function Mn(e){let n=e.now||(()=>Date.now()),t=Math.max(1,e.rafale),r=t,o=n();return{prendre(){let i=n();return r=Math.min(t,r+(i-o)*e.parHeure/36e5),o=i,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,i=!1,a=-1/0,l=!1;function d(){if(i||o.size===0)return;let p=n-(t()-a);if(p>0){if(l)return;l=!0,r(()=>{l=!1,d()},p);return}let c=o.keys().next().value,u=o.get(c);o.delete(c),i=!0,a=t(),Promise.resolve().then(u.tache).then(g=>u.regler(g),()=>u.regler(null)).then(()=>{i=!1,d()})}return{poser(p,c){return new Promise(u=>{let g=o.get(p);o.set(p,{tache:c,regler:f=>{g&&g.regler(f),u(f)}}),d()})},enAttente:()=>o.size}}function _n(e,n=te){try{if(e){let t=e.getItem(x.presenceId);if(t)return t;let r=`p-${n()}`;return e.setItem(x.presenceId,r),r}}catch(t){}return`p-${n()}`}var H={};v(H,{messageClassName:()=>Tn,renderActions:()=>Oe,renderAttachment:()=>Ne,renderMessage:()=>wn,renderReactions:()=>Le});function Le(e,n){let t=e&&e.reactions||{},r=re(n),o=\"\";for(let i in t){let a=t[i]||[];if(!a.length)continue;let l=a.indexOf(r)>=0;o+=`<button class=\"re-chip${l?\" on\":\"\"}\" data-e=\"${h(i)}\">${h(i)} ${a.length}</button>`}return o}function Ne(e){if(!e||!e.url)return\"\";let n=h(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=h(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=w(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 wn(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>${h(e.reply_name||\"\")}</b> ${h(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>`:\"\",i=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>${h(e.author_name||\"Invit\\xE9\")}</b>${i}</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 Tn(e,n,t){return\"cm\"+(w(e,n)?\" me\":\"\")+(e.deleted?\" isdel\":\"\")+(t?\" mentioned\":\"\")}var U={};v(U,{DEFAULT_CENTER:()=>ae,DEFAULT_ZOOM:()=>ue,MAP_TYPES:()=>I,cycleMapType:()=>En,initialMapContent:()=>kn,mapTypeLabel:()=>Sn,sanitizeContent:()=>le});var I=[\"roadmap\",\"satellite\",\"hybrid\"],ae=[46.6,2.5],ue=6;function T(e){let n=Number(e);return Number.isFinite(n)?n:null}function ie(e){if(!Array.isArray(e))return null;let n=T(e[0]),t=T(e[1]);return n!=null&&t!=null?[n,t]:null}var se=(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=ie(n.position);if(!t)return null;let r=n.pov&&typeof n.pov==\"object\"?n.pov:null,o=r?{heading:T(r.heading)||0,pitch:se(T(r.pitch)||0,-90,90)}:{heading:0,pitch:0};return{kind:\"streetview\",position:t,pov:o,zoom:se(T(n.zoom)||1,0,5)}}return n.kind!==\"map\"?null:{kind:\"map\",center:ie(n.center)||[...ae],zoom:se(Math.trunc(T(n.zoom)||ue),1,21),marker:ie(n.marker),mapType:I.includes(n.mapType)?n.mapType:null,label:String(n.label||\"\").slice(0,160)||null}}function En(e){let n=I.indexOf(e);return I[(n+1)%I.length]}function Sn(e){return e===\"roadmap\"?\"\\u{1F6F0} Satellite\":e===\"satellite\"?\"\\u{1F5FA} Hybride\":\"\\u{1F5FA} Plan\"}function kn(){return{kind:\"map\",center:[...ae],zoom:ue,marker:null,mapType:null,label:null}}var D={};v(D,{CURRENT_PAGE_MARGIN:()=>Ln,DEFAULT_ASPECT:()=>Pn,MAX_ZOOM:()=>In,MIN_PAGE_WIDTH:()=>An,MIN_ZOOM:()=>Rn,PRERENDER_MARGIN:()=>Cn,arrowState:()=>$n,averageColor:()=>Dn,clampPage:()=>$e,clampZoom:()=>Nn,fitWidth:()=>Un,isImageDocument:()=>Wn,progressPercent:()=>On});var Rn=.5,In=3,Pn=1.35,An=280,Cn=\"500px 0px\",Ln=\"-48% 0px -48% 0px\";function Nn(e){let n=Math.round((Number(e)||0)*10)/10;return Math.max(.5,Math.min(3,n))}function $e(e,n){return Math.max(1,Math.min(n||1,Number(e)||1))}function On(e,n){return n?Math.round($e(e,n)/n*100):0}function $n(e,n){return{prevDisabled:e<=1,nextDisabled:!!n&&e>=n}}var Hn=28;function Un(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,i=((e.containerHeight||600)-Hn-(e.overlap||0)-(e.reserve||0))/r;return i>120?Math.min(t,i):t}function Dn(e){let n=e.length/4;if(!n)return\"rgb(0,0,0)\";let t=0,r=0,o=0;for(let i=0;i<e.length;i+=4)t+=e[i],r+=e[i+1],o+=e[i+2];return`rgb(${Math.round(t/n)},${Math.round(r/n)},${Math.round(o/n)})`}function Wn(e,n){return/[.](png|jpe?g|webp|gif|avif)($|[?])/i.test(String(e||n||\"\"))}var W={};v(W,{presentationTransition:()=>Vn,switchDocUrl:()=>Fn});function Vn(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 Fn(e,n){let t=encodeURIComponent(n||String(Date.now()));return`${e}${e.includes(\"?\")?\"&\":\"?\"}v=${t}`}return je(zn);})();", SOURCE_HASH: "0308434f00e5e31f" };
@@ -0,0 +1,82 @@
1
+ // UN CACHE COURT SUR LE CHEMIN DE LECTURE PUBLIC.
2
+ //
3
+ // ⚠️ IL NE REMPLACE PAS UNE LIMITE, IL SUPPRIME CE QU'ELLE GARDAIT. `state=1` et `chat=1` lisent une
4
+ // ligne identique pour tous les spectateurs d'une même présentation à un instant donné. Les relire
5
+ // une fois par spectateur n'apporte rien ; les relire une fois par FENÊTRE fait s'effondrer
6
+ // n'importe quelle cadence — légitime ou hostile — à un accès base, quel que soit le nombre
7
+ // d'appelants. Il n'y a plus de ressource à saturer, donc plus de victime.
8
+ //
9
+ // L'idée vient du second hôte, qui a vu plus loin que notre quota : « la cadence de relecture est
10
+ // imposée par qui diffuse, donc tout quota par appelant est une arme retournée ».
11
+ //
12
+ // ⚠️ TROIS PIÈGES, ET ILS SONT TOUS DANS CE FICHIER.
13
+ //
14
+ // 1. LA RAFALE FROIDE. Vingt-cinq spectateurs qui arrivent ensemble sur une clé absente
15
+ // produiraient vingt-cinq lectures si l'on ne mémorisait que le RÉSULTAT. On mémorise donc la
16
+ // PROMESSE : le premier déclenche, les autres attendent la même. C'est exactement le cas que le
17
+ // cache existe pour couvrir — le rater le viderait de son sens dans la seule situation qui
18
+ // compte.
19
+ //
20
+ // 2. LA CLÉ VIENT DE L'APPELANT. Le slug est dans l'URL : n'importe qui peut en demander un million
21
+ // de différents. Une table sans borne deviendrait une fuite mémoire commandée depuis l'extérieur.
22
+ // On plafonne, et on évince le plus ancien.
23
+ //
24
+ // 3. UN ÉCHEC NE SE MÉMORISE PAS. Servir une erreur pendant toute la fenêtre transformerait un
25
+ // hoquet en panne visible, et retarderait le rétablissement. On oublie la promesse rompue.
26
+ //
27
+ // ⚠️ CE QUE CE CACHE NE FAIT PAS : il vit dans la mémoire du PROCESSUS. En serverless, plusieurs
28
+ // instances servent en parallèle et démarrent à froid — l'effondrement est donc « une lecture par
29
+ // fenêtre ET PAR INSTANCE », pas « une lecture ». C'est la même limite que celle d'un compteur de
30
+ // débit en mémoire, et elle est écrite ici plutôt que découverte plus tard. Elle reste un
31
+ // effondrement de plusieurs ordres de grandeur.
32
+
33
+ /**
34
+ * Un cache à durée de vie courte, avec regroupement des demandes concurrentes.
35
+ *
36
+ * @param {{ ttlMs: number, max?: number, now?: () => number }} options
37
+ */
38
+ function creerCache(options) {
39
+ const ttl = Math.max(0, Number(options && options.ttlMs) || 0);
40
+ const max = Math.max(1, Number(options && options.max) || 500);
41
+ const now = (options && options.now) || (() => Date.now());
42
+ /** @type {Map<string, { echeance: number, promesse: Promise<unknown> }>} */
43
+ const entrees = new Map();
44
+
45
+ return {
46
+ /**
47
+ * Rend la valeur en cache si elle est fraîche, sinon la produit — une seule fois pour tous les
48
+ * appelants concurrents.
49
+ */
50
+ async lire(cle, produire) {
51
+ const k = String(cle);
52
+ const t = now();
53
+ const vue = entrees.get(k);
54
+ if (vue && vue.echeance > t) return vue.promesse;
55
+
56
+ const promesse = Promise.resolve().then(produire);
57
+ entrees.set(k, { echeance: t + ttl, promesse });
58
+
59
+ // ⚠️ On oublie une promesse ROMPUE, jamais une promesse tenue : sinon un hoquet de base
60
+ // resterait servi pendant toute la fenêtre, et le rétablissement attendrait pour rien.
61
+ promesse.catch(() => {
62
+ const courante = entrees.get(k);
63
+ if (courante && courante.promesse === promesse) entrees.delete(k);
64
+ });
65
+
66
+ // Éviction : la plus ancienne d'abord (Map conserve l'ordre d'insertion). La clé venant de
67
+ // l'appelant, une table sans borne serait une fuite mémoire commandée depuis l'extérieur.
68
+ if (entrees.size > max) {
69
+ for (const vieille of entrees.keys()) {
70
+ entrees.delete(vieille);
71
+ if (entrees.size <= max) break;
72
+ }
73
+ }
74
+ return promesse;
75
+ },
76
+
77
+ /** Pour les tests et l'exploitation : ce que la table contient réellement. */
78
+ taille: () => entrees.size,
79
+ };
80
+ }
81
+
82
+ module.exports = { creerCache };
package/server/handler.js CHANGED
@@ -6,7 +6,15 @@
6
6
  const crypto = require("crypto");
7
7
  const { getShareBySlug, logView, upsertSession, createReshare, sendReshareEmail, upsertInternalSession,
8
8
  createShare, revokeShare, setShareAuth, overview: docOverview, listSharesForDoc, listSessionsForDoc, internalStatsForDoc } = require("./shares");
9
- const { SESSION_QUOTA_PER_HOUR, PRESENT_QUOTA_PER_HOUR } = require("./shared.generated.js");
9
+ const { SESSION_QUOTA_PER_HOUR, PRESENT_QUOTA_PER_HOUR, PRESENT_CACHE_MS } = require("./shared.generated.js");
10
+ const { creerCache } = require("./cache.js");
11
+
12
+ // ⚠️ UN SEUL CACHE POUR TOUT LE PROCESSUS, ET C'EST LE POINT. Le créer par requête reviendrait à
13
+ // n'en avoir aucun : chaque appelant repartirait d'une table vide, et l'effondrement qu'on
14
+ // cherche — N spectateurs, une lecture — n'aurait jamais lieu. C'est aussi pourquoi il vit ici,
15
+ // au chargement du module, et non dans `init()` : un hôte qui réinitialise son contexte ne doit
16
+ // pas vider ce qui protège sa base.
17
+ const cacheLecture = creerCache({ ttlMs: PRESENT_CACHE_MS });
10
18
  const { createPresentation, getPresentation, setPage, endPresentation, addMessage, listMessages, toggleReaction, editMessage, deleteMessage, setChatLock, createUploadUrl, reclaimPresentation, touchPresentation, listActivePresentations, handoverPresentation, endPresentationByOwner, recordAttendance, presentationStats, listPresentationsForDoc, switchPresentationDoc, setPresentationContent } = require("./presentations");
11
19
  // CONTEXTE INJECTÉ : tout ce que le player emprunte à l'application hôte passe par ici — stockage,
12
20
  // base, identité, limites, marque, journalisation — et rien d'autre. C'est la frontière qui permettra
@@ -283,6 +291,13 @@ var Live=(function(){
283
291
  function _store(){try{return window.localStorage;}catch(e){return null;}}
284
292
  // Clé d'assistance STABLE (analytics de présentation) : email si connu, sinon id persistant par navigateur.
285
293
  function attKey(){ return Player.live.attendeeKey(_store(),MYID); }
294
+ // ⚠️ CE QUI PART DANS LA PRÉSENCE N'EST PAS CE QUI IDENTIFIE UNE LIGNE DE MESURE. La présence est
295
+ // diffusée à toute l'audience ; y mettre la clé de participation revenait à donner à chacun de
296
+ // quoi écraser la ligne de son voisin. Deux besoins, deux valeurs.
297
+ // Mémorisé pour la durée de la page : sans stockage, la fonction rend une valeur neuve à chaque
298
+ // appel — c'est ce qui garantit qu'elle ne partage aucune graine avec la clé de mesure.
299
+ var _presId=null;
300
+ function presId(){ if(!_presId)_presId=Player.live.presenceId(_store()); return _presId; }
286
301
  // Heartbeat d'assistance → le serveur journalise qui suit, combien de temps, et les pages vues (via la page
287
302
  // courante de la présentation). Envoyé à la connexion puis toutes les 25 s. Best-effort (silencieux).
288
303
  // Le serveur ne croit plus 'isMember' ni 'isPresenter' sur parole : l'appartenance se prouve par
@@ -474,41 +489,39 @@ var Live=(function(){
474
489
  // Et à l'inverse, des signaux un peu plus espacés produisaient une requête chacun, POUR
475
490
  // CHAQUE SPECTATEUR : le canal public devenait un amplificateur vers l'API.
476
491
  // Détail et propriétés : src/live.ts + src/__tests__/ordonnanceur.test.ts. (audit P0-2)
477
- // UNE REQUÊTE QUI NE REVIENT PAS BLOQUE TOUT CE QUI L'ATTEND.
492
+ // ⚠️ LE BUDGET GATE LE SIGNAL, JAMAIS LE FILET ET C'EST TOUTE LA DIFFÉRENCE.
478
493
  //
479
- // ⚠️ Ce risque est d'un correctif. Avant 0.1.41 l'ordonnanceur appelait « fini() » tout de
480
- // suite : le désordre était possible, le blocage non. En rendant les écritures séquentielles on a
481
- // échangé un défaut de correction contre un risque de disponibilité — une requête suspendue ne
482
- // règle jamais sa promesse, la file ne repart pas, et le présentateur pilote dans le vide sans
483
- // qu'aucun message ne le dise. Un navigateur ne garantit aucun délai de son côté.
494
+ // « signaler() » est déclenché par le canal, donc par n'importe quel participant : c'est la
495
+ // porte par laquelle un diffuseur hostile faisait relire toute une salle jusqu'au quota, et
496
+ // au-delà. On la rationne.
484
497
  //
485
- // ⚠️ Ce que ça restaure : la VIVACITÉ. Pas l'ORDRE une requête abandonnée peut être arrivée au
486
- // serveur et atterrir après celle qui l'a remplacée. L'ordre malgré un abandon demande un numéro
487
- // de version porté par l'écriture, et c'est le chantier de la file unique.
498
+ // « maintenant() » est le filet périodique, déclenché par NOUS toutes les 25 s. Le rationner
499
+ // rendrait une audience à budget épuisé définitivement muette on aurait remplacé un déni de
500
+ // service venu du dehors par un déni de service maison. C'est le plancher : quoi qu'il arrive,
501
+ // l'audience finit toujours par se resynchroniser.
488
502
  //
489
- // L'abandon est BEST-EFFORT : sans AbortController on laisse la requête vivre, on cesse seulement
490
- // de l'attendre. Rendre la main compte plus que couper le fil.
491
- function fetchBorne(url,opts,ms){
492
- var limite=ms||(window.Player&&Player.cadence&&Player.cadence.PRESENT_WRITE_TIMEOUT_MS)||10000;
493
- var ctl=null; try{ ctl=new AbortController(); }catch(e){}
494
- var o=opts||{};
495
- if(ctl){ o=Object.assign({},o); o.signal=ctl.signal; }
496
- var minuteur=null;
497
- var expiration=new Promise(function(_,ko){
498
- minuteur=setTimeout(function(){ try{ if(ctl)ctl.abort(); }catch(e){} ko(new Error('delai')); },limite);
499
- });
500
- function rendreLaMain(v,echec){ try{ clearTimeout(minuteur); }catch(e){} if(echec)throw v; return v; }
501
- return Promise.race([fetch(url,o),expiration])
502
- .then(function(r){return rendreLaMain(r,false);},function(e){return rendreLaMain(e,true);});
503
- }
503
+ // Refuser un signal ne perd donc rien : la relecture suivante lira l'état le plus récent. On
504
+ // arrive en retard, jamais à côté.
504
505
  function relireAvec(url,applique){
505
- return Player.live.createScheduler(function(fini){
506
- fetchBorne('/api/doc?present='+encodeURIComponent(SLUG)+url)
506
+ var ord=Player.live.createScheduler(function(fini){
507
+ Player.live.fetchBorne('/api/doc?present='+encodeURIComponent(SLUG)+url)
507
508
  .then(function(r){return r.json();})
508
509
  .then(function(d){if(d&&d.ok)applique(d);})
509
510
  .catch(function(){})
510
511
  .then(fini,fini);
511
- },{minMs:400});
512
+ // ⚠️ La fenêtre vient de la constante partagée, pas d'un nombre écrit ici : le cache serveur
513
+ // se déduit d'elle, et deux nombres séparés finiraient par diverger — un cache plus long que
514
+ // le regroupement ajouterait une attente que le spectateur n'a pas consentie.
515
+ },{minMs:(window.Player&&Player.cadence&&Player.cadence.PRESENT_READ_COALESCE_MS)||400});
516
+ var budget=Player.live.createBudget({
517
+ parHeure:Player.cadence.PRESENT_SIGNAL_BUDGET_PER_HOUR,
518
+ rafale:Player.cadence.PRESENT_READ_BURST,
519
+ });
520
+ return {
521
+ signaler:function(){ if(budget.prendre()) ord.signaler(); },
522
+ maintenant:function(){ ord.maintenant(); },
523
+ arreter:function(){ ord.arreter(); },
524
+ };
512
525
  }
513
526
  var _ordEtat=relireAvec('&state=1',function(d){if(d.state)etatDuServeur(d.state);});
514
527
  // ⚠️ Exposé hors de cette fermeture : la carte vit dans un AUTRE bloc de script et doit
@@ -560,7 +573,7 @@ var Live=(function(){
560
573
  // par accident : le jour où quelqu'un rebranche un paramètre, la charge d'un canal public
561
574
  // redeviendrait crue sans que rien ne le signale. On coupe le chemin, pas seulement l'usage.
562
575
  ch.on('broadcast',{event:'map'},function(){if(_onMap)_onMap();});
563
- ch.subscribe(function(st){if(st==='SUBSCRIBED'){ch.track({name:me.name,email:me.email,avatar:me.avatar,role:me.role,member:!!me.member,uid:attKey()});sendAttend();}});
576
+ ch.subscribe(function(st){if(st==='SUBSCRIBED'){ch.track({name:me.name,email:me.email,avatar:me.avatar,role:me.role,member:!!me.member,uid:presId()});sendAttend();}});
564
577
  _tyIv=setInterval(renderTyping,1500);
565
578
  _atIv=setInterval(sendAttend,25000);
566
579
  // Filet de sécurité : au déchargement de la page/iframe (fermeture, reload, switch), on retire la présence
@@ -654,16 +667,18 @@ var Map3DD=(function(){
654
667
  //
655
668
  // Le suivi devient PAR PALIERS au lieu d'être continu (environ deux fois par seconde). C'est le
656
669
  // prix pour que personne d'autre que le présentateur ne pilote l'écran de l'audience.
657
- var _ordPersist=null;
658
- function persistOrd(){ if(!_ordPersist&&window.Player&&Player.live&&Player.live.createScheduler){
659
- _ordPersist=Player.live.createScheduler(function(fini){
660
- // ⚠️ ON ATTEND L'ÉCRITURE. Appeler fini() tout de suite disait à l'ordonnanceur que le
661
- // travail était fait alors qu'il commençait : sa garantie du « dernier état gagne »
662
- // portait sur l'ordre des appels et non sur celui des arrivées.
663
- var p=null; try{ if(map&&persist)p=persist(state()); }catch(e){}
664
- if(p&&p.then)p.then(fini,fini); else fini();
665
- },{minMs:500}); }
666
- return _ordPersist; }
670
+ // ⚠️ CET ORDONNANCEUR N'EN EST PLUS UN, ET C'EST LE CORRECTIF. Il en existait DEUX ici — un pour la
671
+ // carte, un pour Street View — pendant que quatre autres chemins écrivaient sans passer par eux.
672
+ // Deux mécaniques pour un même rôle, et une couverture d'un chemin sur trois.
673
+ //
674
+ // L'ordonnancement vit désormais dans « presentContent » elle-même : ce qu'on appelle ici finit dans
675
+ // la file unique, avec son regroupement par genre, son écriture unique en vol et son rythme
676
+ // minimum. Il ne reste de cette fonction qu'un adaptateur, pour ne pas réécrire ses appelants.
677
+ //
678
+ // Ce qu'elle garantissait — lire l'état le plus frais — reste vrai autrement : la file conserve la
679
+ // DERNIÈRE demande de ce genre, donc l'état du dernier mouvement.
680
+ function persistOrd(){
681
+ return { signaler: function(){ try{ if(map&&persist)persist(state()); }catch(e){} } }; }
667
682
  function broadcast(){ if(!map)return; var n=Date.now(); if(n-_bcT<200)return; _bcT=n;
668
683
  var o=persistOrd(); if(o)o.signaler();
669
684
  try{ if(window.Live) Live.sendMap(); }catch(e){} }
@@ -707,13 +722,9 @@ var Map3DD=(function(){
707
722
  loadGoogle(function(){ try{ new google.maps.StreetViewService().getPanorama({location:{lat:center[0],lng:center[1]},radius:80},function(data,status){ if(status==='OK'&&data&&data.location){ var ll=data.location.latLng; var content={kind:'streetview',position:[ll.lat(),ll.lng()],pov:{heading:0,pitch:0},zoom:1}; if(persist)persist(content); enterSV(content,true,persist); } else { tempHint('Pas de Street View à cet endroit.'); } }); }catch(e){ tempHint('Street View indisponible.'); } }); }
708
723
  function svState(){ if(!pano)return null; var p=pano.getPosition(),v=pano.getPov(); if(!p)return null; return {kind:'streetview',position:[p.lat(),p.lng()],pov:{heading:v.heading,pitch:v.pitch},zoom:pano.getZoom()}; }
709
724
  // Même règle qu'au-dessus : on persiste, on signale, on ne transporte pas la position.
710
- var _ordSv=null;
711
- function svOrd(){ if(!_ordSv&&window.Player&&Player.live&&Player.live.createScheduler){
712
- _ordSv=Player.live.createScheduler(function(fini){
713
- var p=null; try{ if(persist&&pano)p=persist(svState()); }catch(e){}
714
- if(p&&p.then)p.then(fini,fini); else fini();
715
- },{minMs:500}); }
716
- return _ordSv; }
725
+ // Même chose pour Street View : la file s'en charge, il ne reste qu'un adaptateur.
726
+ function svOrd(){
727
+ return { signaler: function(){ try{ if(persist&&pano)persist(svState()); }catch(e){} } }; }
717
728
  function svBcast(){ var n=Date.now(); if(n-_svBcT<200)return; _svBcT=n; if(!svState())return;
718
729
  var o=svOrd(); if(o)o.signaler();
719
730
  try{ if(window.Live)Live.sendMap(); }catch(e){} }
@@ -2057,7 +2068,7 @@ ${LEGAL_CSS}
2057
2068
  // Présentation live — GÉRÉE DANS L'IFRAME (toujours servie fraîche, en-tête no-store) → robuste même si le
2058
2069
  // bundle React de l'app est en cache. Bandeau fixe persistant en bas : lien + copier + inviter + terminer.
2059
2070
  // Seul l'envoi par chat (Inviter) est délégué à l'app (postMessage) car il exige le JWT.
2060
- var PRES=null, _pushT=null, _hbIv=0;
2071
+ var PRES=null, _hbIv=0;
2061
2072
  // JWT de la session app (MÊME ORIGINE, localStorage) → autorise le rattachement de la présentation au membre
2062
2073
  // (reprise / liste / transfert) et les actions authentifiées (reclaim).
2063
2074
  function appToken(){ try{ var raw=localStorage.getItem(LIVECFG.hostAuthKey||''); if(!raw)return''; var s=JSON.parse(raw); var t=(s&&(s.access_token||(s.currentSession&&s.currentSession.access_token)||(s.session&&s.session.access_token)))||''; return t; }catch(e){ return ''; } }
@@ -2080,11 +2091,36 @@ ${LEGAL_CSS}
2080
2091
  // rien, alors que l'émettre trop tôt fait relire pour rien ET perdre le changement.
2081
2092
  // (audit P0-3)
2082
2093
  function diffuserEtat(){ if(!PRES||!window.Live)return; try{ Live.sendState(); }catch(e){} }
2083
- function pushPage(){ if(!PRES)return; clearTimeout(_pushT); _pushT=setTimeout(function(){ if(!PRES)return;
2084
- Player.live.fetchBorne('/api/doc',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'present-page',slug:PRES.slug,control:PRES.control,page:cur||1})})
2094
+ // ⚠️ UNE SEULE FILE, ET CE SONT LES FONCTIONS D'ÉCRITURE QUI L'EMPRUNTENT — PAS LEURS APPELANTS.
2095
+ //
2096
+ // Six chemins écrivaient : « pushPage », « showMap », « hideMap », « toMap », la recherche Street
2097
+ // View, et les deux ordonnanceurs de carte. Deux étaient protégés. Le correctif de 0.1.41, qui
2098
+ // annonçait des écritures de carte séquentielles, n'en couvrait qu'un chemin sur trois.
2099
+ //
2100
+ // Demander à chaque appelant de passer par la bonne porte serait une LISTE : le prochain
2101
+ // l'oubliera, et rien ne le dira. On range donc la file DANS « pushPage » et « presentContent » :
2102
+ // il n'existe plus de chemin direct, donc plus rien à oublier.
2103
+ //
2104
+ // Le rythme minimum remplace les deux ordonnanceurs qu'elle absorbe — un déplacement de carte à
2105
+ // la souris produirait sinon une écriture par image.
2106
+ var _file=null;
2107
+ function fileEcritures(){
2108
+ if(!_file&&window.Player&&Player.live&&Player.live.createFileEcritures){
2109
+ _file=Player.live.createFileEcritures({minMs:500});
2110
+ }
2111
+ return _file;
2112
+ }
2113
+ // ⚠️ L'ANTI-REBOND DE 250 ms A DISPARU, ET C'EST VOULU. Il repoussait l'échéance à chaque appel —
2114
+ // un défilement continu pouvait donc ne JAMAIS écrire. La file regroupe par genre sans jamais
2115
+ // repousser : la dernière page demandée part au prochain tour, et elle part.
2116
+ function pushPage(){ if(!PRES)return;
2117
+ var f=fileEcritures(); var page=cur||1;
2118
+ if(!f) return envoyerPage(page);
2119
+ return f.poser('page',function(){ return envoyerPage(cur||page); }); }
2120
+ function envoyerPage(page){ if(!PRES)return Promise.resolve();
2121
+ return Player.live.fetchBorne('/api/doc',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'present-page',slug:PRES.slug,control:PRES.control,page:page})})
2085
2122
  .then(function(r){ if(r&&r.ok)diffuserEtat(); })
2086
- .catch(function(){});
2087
- },250); }
2123
+ .catch(function(){}); }
2088
2124
  // ⚠️ TROIS GESTES, ET L'ORDRE ÉTAIT LE PIRE DES SIX. On signalait la fin, puis on COUPAIT LE
2089
2125
  // CANAL, puis on envoyait l'avis de fin. Le signal partait donc avant l'écriture (relecture sur
2090
2126
  // un état périmé), et la coupure avant l'envoi. L'audience pouvait ne jamais apprendre que la
@@ -2177,7 +2213,10 @@ ${LEGAL_CSS}
2177
2213
  // alors qu'elle venait de PARTIR. Sa garantie « une seule en vol, la dernière gagne » ne
2178
2214
  // s'appliquait qu'à l'ordre des APPELS, pas à celui des écritures — plusieurs PATCH pouvaient
2179
2215
  // voler ensemble, et une position ancienne atterrir après une récente.
2180
- function presentContent(content){ if(!PRES)return Promise.resolve(); var h={'Content-Type':'application/json'}; var tk=appToken(); if(tk) h['Authorization']='Bearer '+tk;
2216
+ function presentContent(content){ if(!PRES)return Promise.resolve();
2217
+ var f=fileEcritures(); if(!f) return envoyerContenu(content);
2218
+ return f.poser('content',function(){ return envoyerContenu(content); }); }
2219
+ function envoyerContenu(content){ if(!PRES)return Promise.resolve(); var h={'Content-Type':'application/json'}; var tk=appToken(); if(tk) h['Authorization']='Bearer '+tk;
2181
2220
  return Player.live.fetchBorne('/api/doc',{method:'POST',headers:h,body:JSON.stringify({action:'present-content',slug:PRES.slug,control:PRES.control,content:content})})
2182
2221
  .then(function(r){ if(r&&r.ok)diffuserEtat(); })
2183
2222
  .catch(function(){}); }
@@ -2943,8 +2982,29 @@ async function handler(req, res) {
2943
2982
  if (body.action === "present-list") r = { ok: true, presentations: await listActivePresentations(u.email) };
2944
2983
  else if (body.action === "present-reclaim") r = await reclaimPresentation(String(body.slug || ""), u.email);
2945
2984
  else if (body.action === "present-owner-end") r = await endPresentationByOwner(String(body.slug || ""), u.email, isAdmin);
2946
- else if (body.action === "present-stats") r = await presentationStats(String(body.slug || ""));
2947
- else if (body.action === "present-doc-list") r = { ok: true, presentations: await listPresentationsForDoc(String(body.docId || "")) };
2985
+ // ⚠️ DEUX DROITS, PAS UN ET LEUR NOM EST UN ÉLÉMENT DE CONTRAT.
2986
+ //
2987
+ // Voir qu'une présentation a eu lieu et voir QUI y était sont deux sensibilités
2988
+ // différentes : l'historique rend des slugs, des noms de présentateurs et des compteurs ;
2989
+ // les statistiques rendent les NOMS, les ADRESSES et le temps de présence de participants
2990
+ // qui sont souvent des prospects. Un hôte peut vouloir ouvrir le premier à toute l'équipe
2991
+ // et réserver le second — les fondre en un seul droit lui retirerait ce choix.
2992
+ //
2993
+ // ⚠️ Les noms suivent la convention existante : `list` / `list.all`, où le suffixe dit
2994
+ // « au-delà des miennes ». Ils sont documentés dans docs/HOST-CONTRACT.md, et une garde
2995
+ // vérifie que tout nom demandé ici y figure — une action inconnue vaut refus chez un hôte
2996
+ // prudent, donc l'introduire en silence casserait son instance sans rien dire.
2997
+ //
2998
+ // Le droit est demandé PARESSEUSEMENT : un propriétaire n'a pas à payer un aller-retour
2999
+ // pour lire ce qui lui appartient déjà.
3000
+ else if (body.action === "present-stats") {
3001
+ r = await presentationStats(String(body.slug || ""), u.email, isAdmin,
3002
+ () => PLAYER.identity.canManageShares(u, "presentations.stats"));
3003
+ }
3004
+ else if (body.action === "present-doc-list") {
3005
+ r = { ok: true, presentations: await listPresentationsForDoc(String(body.docId || ""), u.email, isAdmin,
3006
+ () => PLAYER.identity.canManageShares(u, "presentations.list.all")) };
3007
+ }
2948
3008
  else if (body.action === "present-switch") {
2949
3009
  if (!isAllowedStorageUrl(String(body.fileUrl || ""))) return jp(400, { ok: false, error: "url" });
2950
3010
  r = await switchPresentationDoc(String(body.slug || ""), u.email, isAdmin, { fileUrl: body.fileUrl, fileName: body.fileName, docTitle: body.docTitle, docId: body.docId });
@@ -3449,23 +3509,38 @@ async function handler(req, res) {
3449
3509
  return;
3450
3510
  }
3451
3511
  }
3452
- const pres = await getPresentation(String(q.present));
3453
- if (!pres) return sendRefusal(res, "ended", embed);
3454
3512
  // Historique du chat (chargé au join par présentateur ET audience).
3455
3513
  // ÉTAT de la présentation, relu par l'audience à la reconnexion ou au retour d'onglet.
3456
3514
  // C'est la porte qui permettra de retirer la lecture anonyme des tables : l'audience n'a
3457
3515
  // plus besoin de lire `doc_presentations` pour connaître la page courante. On ne renvoie
3458
3516
  // QUE ce que l'audience doit savoir — ni propriétaire, ni jeton, ni horodatages internes.
3459
- if (String(q.state || "") === "1") {
3460
- res.statusCode = 200; res.setHeader("Content-Type", "application/json"); res.setHeader("Cache-Control", "no-store");
3461
- res.end(JSON.stringify({ ok: true, state: {
3462
- active: pres.active !== false,
3463
- current_page: pres.current_page || 1,
3464
- content: pres.content || null,
3465
- file_url: pres.file_url || null,
3466
- file_name: pres.file_name || null,
3467
- doc_title: pres.doc_title || null,
3468
- updated_at: pres.updated_at || null,
3517
+ // ⚠️ LA RÈGLE, PAS LA LISTE — ET C'EST LE SECOND HÔTE QUI A EU RAISON.
3518
+ //
3519
+ // La première version mettait en cache DEUX ROUTES ÉNUMÉRÉES. Leur remarque : « la troisième
3520
+ // route de lecture arrivera sans cache, et ça ne se verra pas ». C'est le motif croisé six
3521
+ // fois cette semaine — une liste se périme, une forme non.
3522
+ //
3523
+ // La règle est donc écrite ici, et le code la fait respecter par construction : TOUTE RÉPONSE
3524
+ // IDENTIQUE POUR TOUS LES SPECTATEURS D'UN MÊME SLUG SE CACHE. Ajouter une lecture publique,
3525
+ // c'est ajouter une entrée à cette table — elle sera mise en cache sans qu'on y pense, parce
3526
+ // qu'il n'existe pas d'autre chemin pour la servir.
3527
+ //
3528
+ // ⚠️ LA CONDITION D'ADMISSION EST « IDENTIQUE POUR TOUS », PAS « EN LECTURE SEULE ». Une
3529
+ // réponse qui dépendrait de l'appelant servirait l'état d'un visiteur à un autre — défaut
3530
+ // d'une tout autre gravité que celui qu'on ferme. Elle se revérifie à chaque champ ajouté.
3531
+ //
3532
+ // Le cache englobe la LECTURE ET la sérialisation : sous rafale, une seule des deux se paie.
3533
+ // `file=1` ne figure pas dans cette table, et c'est délibéré : il relaie des octets avec des
3534
+ // en-têtes de plage propres à l'appelant — ce n'est pas une charge partagée.
3535
+ const LECTURES_PARTAGEES = [
3536
+ { param: "state", produire: async (p) => ({ ok: true, state: {
3537
+ active: p.active !== false,
3538
+ current_page: p.current_page || 1,
3539
+ content: p.content || null,
3540
+ file_url: p.file_url || null,
3541
+ file_name: p.file_name || null,
3542
+ doc_title: p.doc_title || null,
3543
+ updated_at: p.updated_at || null,
3469
3544
  // ⚠️ CE CHAMP A PORTÉ `presenter_key` PENDANT UNE JOURNÉE, ET C'ÉTAIT DEUX FAUTES.
3470
3545
  //
3471
3546
  // 1. FUITE. `attendeeKey()` renvoie l'ADRESSE E-MAIL quand le participant en a une. La clé
@@ -3485,15 +3560,28 @@ async function handler(req, res) {
3485
3560
  // liste des participants — laquelle ne porte plus aucun titre.
3486
3561
  //
3487
3562
  // Signalé par la seconde passe d'audit (P0-4) ; la fuite n'y était pas.
3488
- presenter_name: String(pres.presenter_name || "") || null,
3489
- } }));
3490
- return;
3491
- }
3492
- if (String(q.chat || "") === "1") {
3563
+ presenter_name: String(p.presenter_name || "") || null,
3564
+ } }) },
3565
+ { param: "chat", produire: async (p, slug) => ({ ok: true, messages: await listMessages(slug), locked: !!p.chat_locked }) },
3566
+ ];
3567
+ for (const lecture of LECTURES_PARTAGEES) {
3568
+ if (String(q[lecture.param] || "") !== "1") continue;
3569
+ const slugLu = String(q.present);
3570
+ const corps = await cacheLecture.lire(`${lecture.param}:${slugLu}`, async () => {
3571
+ const p = await getPresentation(slugLu);
3572
+ return p ? JSON.stringify(await lecture.produire(p, slugLu)) : null;
3573
+ });
3574
+ if (!corps) return sendRefusal(res, "ended", embed);
3493
3575
  res.statusCode = 200; res.setHeader("Content-Type", "application/json"); res.setHeader("Cache-Control", "no-store");
3494
- res.end(JSON.stringify({ ok: true, messages: await listMessages(String(q.present)), locked: !!pres.chat_locked }));
3576
+ res.end(corps);
3495
3577
  return;
3496
3578
  }
3579
+ // ⚠️ LA LIGNE N'EST LUE QU'ICI, ET C'EST CE QUI DONNE SON SENS AU CACHE. Elle était lue
3580
+ // PLUS HAUT, avant toutes les branches : les deux chemins mis en cache la relisaient donc
3581
+ // quand même — le cache ne retirait rien, et il ajoutait une seconde interrogation. Une
3582
+ // mémoire posée derrière une dépense ne l'épargne pas.
3583
+ const pres = await getPresentation(String(q.present));
3584
+ if (!pres) return sendRefusal(res, "ended", embed);
3497
3585
  if (String(q.file || "") === "1") {
3498
3586
  if (!isAllowedStorageUrl(pres.file_url)) { res.statusCode = 404; res.end("Fichier indisponible"); return; }
3499
3587
  const range = req.headers["range"];
@@ -341,10 +341,37 @@ async function recordAttendance(slug, { key, name, email, avatar, isMember, isPr
341
341
  }
342
342
 
343
343
  // Détail d'une présentation : entête + participants + nombre de messages par participant.
344
- async function presentationStats(slug) {
344
+ /**
345
+ * Les statistiques d'une présentation : qui a suivi, combien de temps, quelles pages.
346
+ *
347
+ * ⚠️ UNE SESSION NE SUFFIT PAS, ET C'EST LE CORRECTIF. Cette route exigeait un jeton et rien de plus :
348
+ * tout membre connaissant un slug lisait donc les participants d'une présentation qui n'était pas la
349
+ * sienne — leurs NOMS, leurs ADRESSES, leur temps de présence et les pages qu'ils ont vues. Ces
350
+ * participants sont souvent des prospects : ce sont des données commerciales sur des clients, pas un
351
+ * compteur d'usage.
352
+ *
353
+ * ⚠️ QUI Y A DROIT AU-DELÀ DU PROPRIÉTAIRE EST UNE RÈGLE DE L'HÔTE, PAS DU PLAYER. Une petite équipe
354
+ * où chacun voit tout est un choix parfaitement défendable ; une instance à plusieurs populations ne
355
+ * peut pas se le permettre. Le player ne tranche donc pas : il accorde ce qui est manifeste — le
356
+ * propriétaire, l'administrateur — et demande le reste à l'hôte, par le même crochet qui décide déjà
357
+ * qui peut diffuser un document.
358
+ *
359
+ * `autoriseLarge` est une FONCTION, pas un booléen : sans elle on paierait un aller-retour
360
+ * d'autorisation pour un propriétaire qui n'en a pas besoin.
361
+ *
362
+ * Signalé par deux audits (C-8, V-6).
363
+ */
364
+ async function presentationStats(slug, email, isAdmin, autoriseLarge) {
345
365
  if (!slug) return { ok: false, status: 400 };
346
366
  const pres = await getPresentation(slug);
347
367
  if (!pres) return { ok: false, status: 404 };
368
+ const proprietaire = !!isAdmin || !!(pres.owner_email && pres.owner_email === lc(email));
369
+ if (!proprietaire) {
370
+ let large = false;
371
+ // Une autorisation qui LÈVE vaut refus : « large » garde sa valeur initiale, et c'est la bonne.
372
+ try { large = typeof autoriseLarge === "function" ? !!(await autoriseLarge()) : !!autoriseLarge; } catch { /* hôte injoignable : refus */ }
373
+ if (!large) return { ok: false, status: 403 };
374
+ }
348
375
  const [attRows, msgRows] = await Promise.all([
349
376
  PLAYER.db.request(`doc_presentation_attendees?slug=eq.${enc(slug)}&select=*&order=first_seen.asc&limit=500`),
350
377
  PLAYER.db.request(`doc_presentation_messages?slug=eq.${enc(slug)}&deleted=eq.false&select=author_email,author_name&limit=1000`),
@@ -441,10 +468,26 @@ async function setPresentationContent(slug, email, isAdmin, content, control) {
441
468
  }
442
469
 
443
470
  // Historique des présentations d'un document (pour l'onglet Suivi) : la plus récente d'abord, avec le nb de participants.
444
- async function listPresentationsForDoc(docId) {
471
+ /**
472
+ * L'historique des présentations d'un document.
473
+ *
474
+ * ⚠️ ON FILTRE, ON NE REFUSE PAS — et la nuance est un choix. Refuser en bloc priverait un membre de
475
+ * SA propre liste dès qu'il n'a pas le droit élargi ; filtrer lui rend exactement ce qui lui revient.
476
+ * Une liste qui montre moins n'est pas une panne, une liste qui refuse tout en est une.
477
+ *
478
+ * Même règle que pour les statistiques : le propriétaire et l'administrateur toujours, le reste sur
479
+ * décision de l'hôte.
480
+ */
481
+ async function listPresentationsForDoc(docId, email, isAdmin, autoriseLarge) {
445
482
  if (!docId) return [];
446
- const rows = await PLAYER.db.request(`doc_presentations?doc_id=eq.${enc(String(docId))}&select=slug,presenter_name,owner_name,current_page,active,created_at,updated_at&order=created_at.desc&limit=50`);
447
- const list = Array.isArray(rows) ? rows : [];
483
+ const rows = await PLAYER.db.request(`doc_presentations?doc_id=eq.${enc(String(docId))}&select=slug,presenter_name,owner_name,owner_email,current_page,active,created_at,updated_at&order=created_at.desc&limit=50`);
484
+ let list = Array.isArray(rows) ? rows : [];
485
+ if (!isAdmin) {
486
+ let large = false;
487
+ // Une autorisation qui LÈVE vaut refus : « large » garde sa valeur initiale, et c'est la bonne.
488
+ try { large = typeof autoriseLarge === "function" ? !!(await autoriseLarge()) : !!autoriseLarge; } catch { /* hôte injoignable : refus */ }
489
+ if (!large) list = list.filter((p) => p.owner_email && p.owner_email === lc(email));
490
+ }
448
491
  // UNE requête groupée (in.(…)) au lieu d'une par présentation (N+1, jusqu'à 50) ; agrégation en mémoire.
449
492
  const counts = new Map();
450
493
  if (list.length) {
@@ -29,9 +29,14 @@ __export(shared_exports, {
29
29
  DEFAULT_ZOOM: () => DEFAULT_ZOOM,
30
30
  MAP_TYPES: () => MAP_TYPES,
31
31
  PRESENTER_ACTIONS_PER_HOUR: () => PRESENTER_ACTIONS_PER_HOUR,
32
+ PRESENT_CACHE_MS: () => PRESENT_CACHE_MS,
33
+ PRESENT_QUOTA_MARGIN: () => PRESENT_QUOTA_MARGIN,
32
34
  PRESENT_QUOTA_PER_HOUR: () => PRESENT_QUOTA_PER_HOUR,
33
35
  PRESENT_READS_PER_HOUR: () => PRESENT_READS_PER_HOUR,
36
+ PRESENT_READ_BURST: () => PRESENT_READ_BURST,
37
+ PRESENT_READ_COALESCE_MS: () => PRESENT_READ_COALESCE_MS,
34
38
  PRESENT_RESYNC_MS: () => PRESENT_RESYNC_MS,
39
+ PRESENT_SIGNAL_BUDGET_PER_HOUR: () => PRESENT_SIGNAL_BUDGET_PER_HOUR,
35
40
  PRESENT_WRITE_TIMEOUT_MS: () => PRESENT_WRITE_TIMEOUT_MS,
36
41
  READERS_PER_EGRESS: () => READERS_PER_EGRESS,
37
42
  RESYNC_READS_PER_HOUR: () => RESYNC_READS_PER_HOUR,
@@ -105,17 +110,27 @@ var PRESENT_RESYNC_MS = 25e3;
105
110
  var RESYNC_READS_PER_HOUR = Math.ceil(36e5 / PRESENT_RESYNC_MS);
106
111
  var PRESENTER_ACTIONS_PER_HOUR = 720;
107
112
  var PRESENT_READS_PER_HOUR = RESYNC_READS_PER_HOUR + PRESENTER_ACTIONS_PER_HOUR;
108
- var PRESENT_QUOTA_PER_HOUR = PRESENT_READS_PER_HOUR * READERS_PER_EGRESS;
113
+ var PRESENT_SIGNAL_BUDGET_PER_HOUR = PRESENTER_ACTIONS_PER_HOUR;
114
+ var PRESENT_READ_BURST = 20;
109
115
  var PRESENT_WRITE_TIMEOUT_MS = 1e4;
116
+ var PRESENT_READ_COALESCE_MS = 400;
117
+ var PRESENT_CACHE_MS = PRESENT_READ_COALESCE_MS;
118
+ var PRESENT_QUOTA_MARGIN = 4;
119
+ var PRESENT_QUOTA_PER_HOUR = (PRESENT_READS_PER_HOUR + PRESENT_READ_BURST) * READERS_PER_EGRESS * PRESENT_QUOTA_MARGIN;
110
120
  // Annotate the CommonJS export names for ESM import in node:
111
121
  0 && (module.exports = {
112
122
  DEFAULT_CENTER,
113
123
  DEFAULT_ZOOM,
114
124
  MAP_TYPES,
115
125
  PRESENTER_ACTIONS_PER_HOUR,
126
+ PRESENT_CACHE_MS,
127
+ PRESENT_QUOTA_MARGIN,
116
128
  PRESENT_QUOTA_PER_HOUR,
117
129
  PRESENT_READS_PER_HOUR,
130
+ PRESENT_READ_BURST,
131
+ PRESENT_READ_COALESCE_MS,
118
132
  PRESENT_RESYNC_MS,
133
+ PRESENT_SIGNAL_BUDGET_PER_HOUR,
119
134
  PRESENT_WRITE_TIMEOUT_MS,
120
135
  READERS_PER_EGRESS,
121
136
  RESYNC_READS_PER_HOUR,