discovery-media-player 0.1.48 → 0.1.49

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.
@@ -121,18 +121,100 @@ async function appelHote(url, secret, corps, errors) {
121
121
  * limite réelle est donc N fois celle annoncée. C'est suffisant pour freiner une boucle, pas pour
122
122
  * contenir un attaquant déterminé. Un hôte sérieux branche un compteur partagé dans son câblage.
123
123
  */
124
- function creerLimites() {
124
+ /**
125
+ * Les limites de débit, comptées pour l'INSTANCE et non pour le processus.
126
+ *
127
+ * ⚠️ UN COMPTEUR EN MÉMOIRE EST DIVISÉ PAR LE NOMBRE D'INSTANCES, ET RIEN NE LE DIT. En serverless,
128
+ * plusieurs exécutions servent en parallèle et démarrent à froid : une limite de 120 par heure en
129
+ * autorise 120 PAR INSTANCE. Elle existe, elle rassure, et elle ne limite qu'une fraction de ce
130
+ * qu'elle annonce. C'est le second hôte qui l'a relevé chez lui — il tourne sur ce contexte, et ne
131
+ * s'en était pas aperçu avant qu'on lui pose la question.
132
+ *
133
+ * ⚠️ DEUX ÉTAGES, ET LE PREMIER NE PEUT PAS SE TROMPER. Le compteur local ne voit que ce que CE
134
+ * processus a servi : il sous-compte toujours par rapport au partagé. Donc s'il dépasse déjà le
135
+ * plafond, le partagé le dépasse aussi — un refus local est toujours juste, et il ne coûte aucun
136
+ * aller-retour. C'est l'abus qui se refuse gratuitement, pas le trafic légitime.
137
+ *
138
+ * ⚠️ LE CHEMIN DE LECTURE RESTE LOCAL, ET C'EST UN ARBITRAGE ASSUMÉ. Les relectures publiques
139
+ * (`pread:`) sont servies depuis un cache mémoire par slug, précisément pour ne rien coûter à la
140
+ * base. Y adosser un compteur partagé ferait payer à la garde le prix qu'on venait d'épargner à ce
141
+ * qu'elle garde. Sur ce chemin, la protection réelle est le cache, pas le compteur.
142
+ *
143
+ * ⚠️ LE COMPTE PARTAGÉ N'EST PAS ATOMIQUE. PostgREST ne sait pas exprimer « incrémente » : c'est une
144
+ * lecture puis une écriture. Deux instances peuvent donc lire la même valeur et n'en écrire qu'une —
145
+ * le compteur SOUS-estime sous forte concurrence. Pour une limite de débit, sous-estimer signifie
146
+ * laisser passer un peu plus, jamais refuser à tort. Le dire vaut mieux que laisser croire à une
147
+ * exactitude qu'on n'a pas.
148
+ */
149
+ function creerLimites(db, journal) {
125
150
  const seaux = new Map();
151
+ const PREFIXES_LOCAUX = ["pread:"];
152
+ let partageDisponible = null; // null = pas encore demandé
153
+ let signale = false;
154
+
155
+ /** Le compteur local : sert de refus rapide, et de repli complet quand la table manque. */
156
+ function localAutorise(cle, max, fenetreSecondes) {
157
+ const maintenant = Date.now();
158
+ const debut = maintenant - fenetreSecondes * 1000;
159
+ const vus = (seaux.get(cle) || []).filter((t) => t > debut);
160
+ if (vus.length >= max) { seaux.set(cle, vus); return false; }
161
+ vus.push(maintenant);
162
+ seaux.set(cle, vus);
163
+ if (seaux.size > 5000) for (const [k, v] of seaux) if (!v.some((t) => t > debut)) seaux.delete(k);
164
+ return true;
165
+ }
166
+
167
+ async function tablePresente() {
168
+ if (partageDisponible !== null) return partageDisponible;
169
+ try {
170
+ await db.request("player_rate_limits?select=key&limit=0");
171
+ partageDisponible = true;
172
+ } catch {
173
+ partageDisponible = false;
174
+ if (!signale) {
175
+ signale = true;
176
+ try {
177
+ journal.capture(new Error(
178
+ "compteurs de débit non partagés : appliquez supabase/migrations/0003-limites-partagees.sql. "
179
+ + "Sans elle, chaque instance compte pour elle seule et les limites sont plus lâches qu'annoncé.",
180
+ ), { route: "limits" });
181
+ } catch { /* jamais bloquant */ }
182
+ }
183
+ }
184
+ return partageDisponible;
185
+ }
186
+
126
187
  return {
127
188
  async allow(cle, max, fenetreSecondes) {
128
- const maintenant = Date.now();
129
- const debut = maintenant - fenetreSecondes * 1000;
130
- const vus = (seaux.get(cle) || []).filter((t) => t > debut);
131
- if (vus.length >= max) { seaux.set(cle, vus); return false; }
132
- vus.push(maintenant);
133
- seaux.set(cle, vus);
134
- if (seaux.size > 5000) for (const [k, v] of seaux) if (!v.some((t) => t > debut)) seaux.delete(k);
135
- return true;
189
+ // Étage 1 : le refus local est toujours juste, et gratuit.
190
+ if (!localAutorise(cle, max, fenetreSecondes)) return false;
191
+ if (PREFIXES_LOCAUX.some((p) => String(cle).startsWith(p))) return true;
192
+ if (!(await tablePresente())) return true;
193
+
194
+ // Étage 2 : le compte partagé. ⚠️ Échec ouvert — une limite injoignable ne doit pas empêcher
195
+ // de lire un document. Un 429 raté coûte moins cher qu'une visionneuse morte.
196
+ try {
197
+ const fin = new Date(Date.now() + fenetreSecondes * 1000).toISOString();
198
+ const lignes = await db.request(`player_rate_limits?key=eq.${encodeURIComponent(cle)}&select=count,expires_at&limit=1`);
199
+ const ligne = Array.isArray(lignes) && lignes[0];
200
+ const vivante = ligne && new Date(ligne.expires_at).getTime() > Date.now();
201
+ if (!vivante) {
202
+ await db.request("player_rate_limits", {
203
+ method: "POST",
204
+ headers: { Prefer: "resolution=merge-duplicates,return=minimal" },
205
+ body: [{ key: String(cle), count: 1, expires_at: fin }],
206
+ });
207
+ return true;
208
+ }
209
+ if (Number(ligne.count || 0) >= max) return false;
210
+ await db.request(`player_rate_limits?key=eq.${encodeURIComponent(cle)}`, {
211
+ method: "PATCH", headers: { Prefer: "return=minimal" },
212
+ body: { count: Number(ligne.count || 0) + 1 },
213
+ });
214
+ return true;
215
+ } catch {
216
+ return true;
217
+ }
136
218
  },
137
219
  };
138
220
  }
@@ -401,7 +483,7 @@ function createStandaloneContext(env = process.env) {
401
483
  },
402
484
  },
403
485
 
404
- limits: creerLimites(),
486
+ limits: creerLimites(db, journal),
405
487
 
406
488
  branding: {
407
489
  async logo() { return String(env.PLAYER_BRAND_LOGO || "").trim(); },
@@ -44,6 +44,56 @@ viewer: the browser blocks the iframe **before any script runs**, so no message
44
44
  the host sees a silence indistinguishable from an unreachable instance. Check that your domain is
45
45
  there before you open a document.
46
46
 
47
+ ## Counting the reads of a visitor you vouch for
48
+
49
+ A host that identifies its own visitors — one-time code, project area — can have their reads counted,
50
+ attributed and revocable, without either an anonymous link or a member's token. Pass
51
+ `recipientEmail` on the server-to-server `docshare.create`: **the host vouches, the player stops
52
+ believing the caller.**
53
+
54
+ ⚠️ **The address must never come from a browser request.** Read it server-side, from the visitor's
55
+ session, at the same place that already decides whether they may see the document. A path that
56
+ cannot phrase the request will never phrase it by accident.
57
+
58
+ ⚠️ **An attested link is named, not closed.** It remains forwardable: the reader is attributed, not
59
+ verified. A host whose documents are confidential must not rely on it — that closes with an attested
60
+ *reader*, which does not exist yet.
61
+
62
+ The address is stored apart from `recipient_email`, and that separation is the whole point:
63
+ `recipient_email` says *who may send in the link's name* when a recipient forwards it, and a vouched
64
+ visitor never gained that right. Leaving it empty is what makes the send guard and the re-share
65
+ inheritance both refuse — without either of them knowing why.
66
+
67
+ Requires `supabase/migrations/0001-destinataire-atteste.sql`. Until it is applied the player refuses
68
+ the attested creation and names the file; it never falls back to the other column.
69
+
70
+ ## ⚠️ What `limits.allow` promises changed
71
+
72
+ It used to promise *best effort, per process*. The standalone context now counts in a **shared
73
+ table**, so a limit means what it says for the **instance** rather than for one execution.
74
+
75
+ This matters because nothing announced the difference: on serverless, several executions serve in
76
+ parallel and start cold, so a limit of 120/hour allowed 120 **per instance**. It existed, it
77
+ reassured, and it bounded a fraction of what it claimed.
78
+
79
+ Requires `supabase/migrations/0003-limites-partagees.sql`. **Until it is applied, nothing breaks**:
80
+ counting falls back to memory — the previous behaviour — and the host is told once, by name.
81
+
82
+ Two deliberate exceptions, both written next to the code:
83
+
84
+ - **The local counter stays in front, as a fast refusal.** It only ever sees what one process served,
85
+ so it under-counts: if *it* is already over the ceiling, the shared one is too. A local refusal is
86
+ therefore always right, and costs no round trip. Abuse is refused for free; legitimate traffic pays.
87
+ - **The public read path (`pread:`) is counted locally only.** Those responses already come from a
88
+ per-slug memory cache, put there precisely so they cost the database nothing. Backing their guard
89
+ with a shared counter would make the guard pay the price we had just spared the thing it guards.
90
+ On that path the real protection is the cache, not the counter.
91
+
92
+ ⚠️ **The shared count is not atomic.** PostgREST cannot express "increment": it is a read then a
93
+ write. Two instances can read the same value and write one. The counter therefore **under**-estimates
94
+ under heavy concurrency — it lets a little more through, never refuses wrongly. Said plainly rather
95
+ than implying a precision we do not have.
96
+
47
97
  ## The three things a host implements
48
98
 
49
99
  Everything the player borrows arrives through one injected object. Two of its entries carry
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "discovery-media-player",
3
- "version": "0.1.48",
3
+ "version": "0.1.49",
4
4
  "description": "Self-hosted document viewer: per-recipient tracked links, reading analytics, live presentation. The core knows nothing about the application hosting it — everything it borrows arrives through an injected context.",
5
5
  "keywords": [
6
6
  "pdf-viewer",
package/server/handler.js CHANGED
@@ -2106,6 +2106,15 @@ ${LEGAL_CSS}
2106
2106
  //
2107
2107
  // Le rythme minimum remplace les deux ordonnanceurs qu'elle absorbe — un déplacement de carte à
2108
2108
  // la souris produirait sinon une écriture par image.
2109
+ // ⚠️ LE RANG D'ÉCRITURE, ET POURQUOI IL VIT ICI. La file garantit une seule écriture en vol,
2110
+ // donc l'ordre des DÉPARTS. Elle ne peut rien sur une requête abandonnée par le délai maximal :
2111
+ // le navigateur cesse de l'attendre, il ne l'annule pas chez le serveur, et elle peut atterrir
2112
+ // après celle qui l'a remplacée. Le rang ferme ce dernier cas.
2113
+ //
2114
+ // Remis à zéro à chaque prise de pilotage — démarrage ou reprise — parce qu'un jeton de contrôle
2115
+ // neuf ouvre un nouveau domaine d'ordre côté serveur.
2116
+ var _seq=0;
2117
+ function prochainRang(){ return ++_seq; }
2109
2118
  var _file=null;
2110
2119
  function fileEcritures(){
2111
2120
  if(!_file&&window.Player&&Player.live&&Player.live.createFileEcritures){
@@ -2121,7 +2130,7 @@ ${LEGAL_CSS}
2121
2130
  if(!f) return envoyerPage(page);
2122
2131
  return f.poser('page',function(){ return envoyerPage(cur||page); }); }
2123
2132
  function envoyerPage(page){ if(!PRES)return Promise.resolve();
2124
- 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})})
2133
+ 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,seq:prochainRang()})})
2125
2134
  .then(function(r){ if(r&&r.ok)diffuserEtat(); })
2126
2135
  .catch(function(){}); }
2127
2136
  // ⚠️ TROIS GESTES, ET L'ORDRE ÉTAIT LE PIRE DES SIX. On signalait la fin, puis on COUPAIT LE
@@ -2189,7 +2198,7 @@ ${LEGAL_CSS}
2189
2198
  .then(function(r){return r.json();}).then(function(d){
2190
2199
  if(btn){ btn.disabled=false; btn.textContent='Présenter'; }
2191
2200
  if(!d||!d.ok||!d.slug) return;
2192
- PRES={slug:d.slug,control:d.control}; saveCtl(d.slug,d.control);
2201
+ PRES={slug:d.slug,control:d.control}; saveCtl(d.slug,d.control); _seq=0;
2193
2202
  showBar(d.slug); liveConnect(d.slug,d.control); startHb(); pushPage();
2194
2203
  }).catch(function(){ if(btn){ btn.disabled=false; btn.textContent='Présenter'; } });
2195
2204
  }
@@ -2201,7 +2210,7 @@ ${LEGAL_CSS}
2201
2210
  fetch('/api/doc',{method:'POST',headers:h,body:JSON.stringify({action:'present-reclaim',slug:slug})})
2202
2211
  .then(function(r){return r.json();}).then(function(d){
2203
2212
  if(!d||!d.ok||!d.control){ if(d&&d.status===403){ Player.bridge.sendToHost({type:'present-denied'}); } return; }
2204
- PRES={slug:slug,control:d.control}; saveCtl(slug,d.control);
2213
+ PRES={slug:slug,control:d.control}; saveCtl(slug,d.control); _seq=0;
2205
2214
  showBar(slug); liveConnect(slug,d.control); startHb();
2206
2215
  var target=Math.max(1,d.page||1);
2207
2216
  var tries=0; (function jump(){ var el=(document.getElementById('pages')||document).querySelector('.page[data-p="'+target+'"]'); if(el){ el.scrollIntoView({block:'start'}); } else if(tries++<40){ setTimeout(jump,150); } })();
@@ -2819,7 +2828,7 @@ async function handler(req, res) {
2819
2828
  return jp(200, { ok: true, slug: out.slug, control: out.control });
2820
2829
  }
2821
2830
  const r = body.action === "present-page"
2822
- ? await setPage(String(body.slug || ""), String(body.control || ""), body.page)
2831
+ ? await setPage(String(body.slug || ""), String(body.control || ""), body.page, body.seq)
2823
2832
  : body.action === "present-touch"
2824
2833
  ? await touchPresentation(String(body.slug || ""), String(body.control || ""))
2825
2834
  : await endPresentation(String(body.slug || ""), String(body.control || ""));
@@ -3162,21 +3171,62 @@ async function handler(req, res) {
3162
3171
  if (body.action !== "docshare.create") {
3163
3172
  return jd(403, { ok: false, error: "L'appel serveur à serveur ne crée que des liens sans destinataire." });
3164
3173
  }
3165
- if (body.recipientEmail) {
3166
- return jd(400, { ok: false, error: "Un lien avec destinataire appartient à un membre : il exige son jeton." });
3167
- }
3174
+ // ⚠️ LE DESTINATAIRE ATTESTÉ : L'HÔTE SE PORTE GARANT, IL N'AFFIRME PAS.
3175
+ //
3176
+ // Cette route refusait tout destinataire, au motif qu'un lien nommé appartient à un
3177
+ // membre. Le second hôte a montré la limite : il IDENTIFIE lui-même son visiteur — code
3178
+ // à usage unique, espace projet — et veut ses lectures comptées, attribuées, révocables.
3179
+ // Le lien anonyme est exclu (il serait transmissible à qui n'y a pas droit) et le lien
3180
+ // nominatif exige un jeton de membre que ce visiteur n'aura jamais.
3181
+ //
3182
+ // Ce qui change tout est QUI FOURNIT L'ADRESSE. Dans le cas refusé, elle venait d'un
3183
+ // formulaire rempli par un inconnu. Ici elle vient de la base de l'hôte, après
3184
+ // vérification, et le visiteur ne la saisit jamais. C'est la forme du jeton interne :
3185
+ // l'hôte atteste, le player n'a plus à croire l'appelant.
3186
+ //
3187
+ // ⚠️ ET ELLE NE VA PAS DANS `recipient_email`, PARCE QUE CE CHAMP PORTE DEUX FAITS.
3188
+ // « À qui ce lien est destiné » et « qui a le droit d'expédier en son nom au repartage »
3189
+ // y vivaient ensemble. Un visiteur attesté doit avoir le premier sans le second — il n'a
3190
+ // jamais engagé sa responsabilité chez nous, et `sendReshareEmail` fait du destinataire
3191
+ // du parent l'EXPÉDITEUR (`from`, `replyTo`) d'un message vers une adresse choisie par
3192
+ // qui détient le lien. Lui donner ce champ ferait de nos serveurs un relais de courrier
3193
+ // signé d'un visiteur.
3194
+ //
3195
+ // En le rangeant ailleurs, `recipient_email` reste vide sur toute la chaîne : la garde
3196
+ // d'envoi refuse, et l'héritage du repartage (`created_by: parent.recipient_email || …`)
3197
+ // ne transmet rien. Les deux refusent SANS SAVOIR POURQUOI on les protège — la règle est
3198
+ // devenue une conséquence de la donnée, pas une consigne à retenir en deux endroits.
3199
+ const atteste = String(body.recipientEmail || "").trim().toLowerCase();
3168
3200
  const docId = String(body.docId || "").trim();
3169
3201
  if (!docId || !body.fileUrl) return jd(400, { ok: false, error: "docId/fileUrl requis" });
3170
3202
 
3171
3203
  const ipH = adresseAppelant(req) || "hote";
3172
3204
  if (!(await PLAYER.limits.allow(`hshare:${ipH}`, 120, 3600))) return jd(429, { ok: false, error: "rate" });
3173
3205
 
3174
- // ⚠️ La clé d'idempotence n'a PAS demandé de colonne : « le lien de l'hôte pour ce
3175
- // document » est exactement la ligne sans créateur ET sans destinataire. Seul ce
3176
- // chemin en produit, donc elle est sans ambiguïté et une instance déjà en service
3177
- // n'a aucune migration à passer.
3206
+ // ⚠️ SANS LA COLONNE, ON REFUSE ON N'ÉCRIT PAS AILLEURS. Un hôte qui n'a pas appliqué
3207
+ // la migration verrait sinon son visiteur rangé dans `recipient_email` par défaut, donc
3208
+ // capable d'expédier en son nom : le repli silencieux ouvrirait exactement la porte que
3209
+ // la séparation ferme. On nomme le fichier à appliquer et on s'arrête.
3210
+ if (atteste) {
3211
+ const pret = await require("./schema").aLaColonne(
3212
+ "commercial_doc_shares", "attested_recipient_email",
3213
+ "supabase/migrations/0001-destinataire-atteste.sql",
3214
+ );
3215
+ if (!pret) {
3216
+ return jd(409, { ok: false, error: "migration", message: "Destinataire attesté indisponible : appliquez supabase/migrations/0001-destinataire-atteste.sql." });
3217
+ }
3218
+ }
3219
+
3220
+ // ⚠️ LA CLÉ D'IDEMPOTENCE COMPTE MAINTENANT LE DESTINATAIRE ATTESTÉ. « Le lien de l'hôte
3221
+ // pour ce document » ne suffit plus : un lien anonyme et un lien attesté ont tous deux
3222
+ // ni créateur ni destinataire au sens de `recipient_email`. Sans cette distinction, le
3223
+ // premier visiteur attesté récupérerait le lien anonyme du document — et tous les
3224
+ // suivants le même, donc des lectures attribuées à la mauvaise personne.
3225
+ const filtreAtteste = atteste
3226
+ ? `&attested_recipient_email=eq.${encodeURIComponent(atteste)}`
3227
+ : "&attested_recipient_email=is.null";
3178
3228
  const dejaLa = await PLAYER.db.request(
3179
- `commercial_doc_shares?doc_id=eq.${encodeURIComponent(docId)}&created_by=is.null&recipient_email=is.null&select=slug&limit=1`,
3229
+ `commercial_doc_shares?doc_id=eq.${encodeURIComponent(docId)}&created_by=is.null&recipient_email=is.null${filtreAtteste}&select=slug&limit=1`,
3180
3230
  );
3181
3231
  if (Array.isArray(dejaLa) && dejaLa[0]) {
3182
3232
  await PLAYER.db.request(`commercial_doc_shares?slug=eq.${encodeURIComponent(dejaLa[0].slug)}`, {
@@ -3190,7 +3240,8 @@ async function handler(req, res) {
3190
3240
  // de personne, et reste visible en administration (`list.all`, qui ne filtre pas).
3191
3241
  const neuf = await createShare({
3192
3242
  brandKey: body.brandKey, docId, docTitle: body.docTitle, fileUrl: body.fileUrl,
3193
- fileName: body.fileName, createdBy: null, bot: body.bot, botScript: body.botScript,
3243
+ fileName: body.fileName, createdBy: null, attestedRecipientEmail: atteste || null,
3244
+ bot: body.bot, botScript: body.botScript,
3194
3245
  guided: body.guided, profileId: body.profileId, allowDownload: body.allowDownload,
3195
3246
  videoLayout: body.videoLayout, logo: body.logo, logoDark: body.logoDark,
3196
3247
  });
@@ -47,7 +47,17 @@ async function reclaimPresentation(slug, email) {
47
47
  if (!row) return { ok: false, status: 404 };
48
48
  if (!row.owner_email || row.owner_email !== lc(email)) return { ok: false, status: 403 };
49
49
  const control = newToken(18);
50
- await PLAYER.db.request(`doc_presentations?slug=eq.${enc(slug)}`, { method: "PATCH", headers: { Prefer: "return=minimal" }, body: { control_hash: sha(control), active: true, last_seen: new Date().toISOString(), updated_at: new Date().toISOString() } });
50
+ // ⚠️ REMETTRE LE RANG À ZÉRO MAIS SEULEMENT SI LA COLONNE EXISTE. Un jeton de contrôle neuf ouvre
51
+ // un nouveau domaine d'ordre : le compteur du navigateur repart de 1, et sans remise à zéro toutes
52
+ // ses écritures seraient réputées périmées.
53
+ //
54
+ // ⚠️ Et j'ai écrit ce champ sans condition en premier jet, ce qui est exactement le piège que
55
+ // docs/MIGRATIONS.md décrit : PostgREST rejette le PATCH ENTIER si la colonne manque. La reprise
56
+ // aurait cessé de fonctionner chez tout hôte non migré — pas la nouvelle garantie, la reprise.
57
+ const rangDispo = await require("./schema").aLaColonne(
58
+ "doc_presentations", "write_seq", "supabase/migrations/0002-ordre-des-ecritures.sql",
59
+ );
60
+ 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() } });
51
61
  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 };
52
62
  }
53
63
 
@@ -134,12 +144,56 @@ async function getPresentation(slug) {
134
144
  }
135
145
 
136
146
  // Pilotage : change la page courante (présentateur uniquement, via control_token).
137
- async function setPage(slug, control, page) {
147
+ /**
148
+ * Le rang d'écriture : ce qui fait qu'une écriture périmée n'écrase pas une plus récente.
149
+ *
150
+ * ⚠️ LA FILE DU NAVIGATEUR NE SUFFIT PAS, ET ON L'A ÉCRIT EN LA LIVRANT. Elle garantit UNE seule
151
+ * écriture en vol : elle supprime le désordre qu'on CAUSE. Mais une requête abandonnée par le délai
152
+ * maximal peut très bien être arrivée au serveur — le navigateur cesse de l'attendre, il ne
153
+ * l'annule pas chez nous — et y atterrir APRÈS celle qui l'a remplacée. Ce désordre-là, on le SUBIT.
154
+ *
155
+ * Le rang le ferme : chaque écriture porte le sien, et le serveur refuse un rang qu'il a déjà
156
+ * dépassé. L'ordre ne dépend plus de l'ordre d'arrivée.
157
+ *
158
+ * ⚠️ UN RANG, PAS UN HORODATAGE. Une heure vient d'une horloge, et deux onglets n'ont pas la même.
159
+ * Un rang vient d'un compteur : il ne dit pas QUAND, il dit APRÈS QUOI — la question réellement
160
+ * posée.
161
+ *
162
+ * ⚠️ CE QU'IL COÛTE, ET QUI EST ASSUMÉ. Deux onglets du même présentateur partagent le jeton de
163
+ * contrôle (il est persisté) mais pas le compteur : celui qui a le rang le plus haut gagne, l'autre
164
+ * est refusé en silence. Deux onglets pilotant la même présentation sont déjà incohérents — ils se
165
+ * disputeraient la page de toute façon — mais le refus est désormais net plutôt qu'aléatoire.
166
+ *
167
+ * ⚠️ SANS LA COLONNE, ON NE CONTRÔLE PLUS RIEN — ET C'EST LE BON REPLI. Refuser toutes les écritures
168
+ * ferait d'une migration non appliquée une panne totale de pilotage. On revient au comportement
169
+ * d'avant (dernier arrivé gagne), en le signalant une fois.
170
+ */
171
+ async function rangAccepte(row, seq) {
172
+ const rang = Number(seq);
173
+ if (!Number.isFinite(rang) || rang <= 0) return { controle: false }; // client plus ancien : pas de rang
174
+ const dispo = await require("./schema").aLaColonne(
175
+ "doc_presentations", "write_seq", "supabase/migrations/0002-ordre-des-ecritures.sql",
176
+ );
177
+ if (!dispo) return { controle: false };
178
+ if (rang <= Number(row.write_seq || 0)) return { controle: true, perime: true };
179
+ // ⚠️ ON REND LE RANG, PAS UN OBJET TOUT FAIT. La première version rendait « { write_seq: rang } »,
180
+ // répandu plus loin par « ...(rang.champ || {}) » : c'était sûr, et illisible — le lecteur du PATCH
181
+ // ne voyait pas que ce champ est conditionnel. La garde des colonnes migrées l'a signalé, et elle
182
+ // avait raison sur le fond même si le code était correct : une condition qui ne se voit pas au
183
+ // point d'écriture finit par être recopiée sans elle.
184
+ return { controle: true, perime: false, rang };
185
+ }
186
+
187
+ async function setPage(slug, control, page, seq) {
138
188
  const row = await getPresentation(slug);
139
189
  if (!row) return { ok: false, status: 404 };
140
190
  if (!tokenMatches(control, row.control_hash)) return { ok: false, status: 403 };
191
+ const rang = await rangAccepte(row, seq);
192
+ // ⚠️ « Périmé » n'est pas une erreur : c'est le système qui fonctionne. On répond ok pour que le
193
+ // navigateur n'affiche rien — la page qu'il voulait écrire est déjà dépassée par une plus récente.
194
+ if (rang.perime) return { ok: true, perime: true };
141
195
  const p = Math.max(1, Math.trunc(Number(page) || 1));
142
- await PLAYER.db.request(`doc_presentations?slug=eq.${enc(slug)}`, { method: "PATCH", headers: { Prefer: "return=minimal" }, body: { current_page: p, active: true, last_seen: new Date().toISOString(), updated_at: new Date().toISOString() } });
196
+ await PLAYER.db.request(`doc_presentations?slug=eq.${enc(slug)}`, { method: "PATCH", headers: { Prefer: "return=minimal" }, body: { current_page: p, active: true, last_seen: new Date().toISOString(), updated_at: new Date().toISOString(), ...(rang.controle ? { write_seq: rang.rang } : {}) } });
143
197
  return { ok: true };
144
198
  }
145
199
 
package/server/shares.js CHANGED
@@ -15,12 +15,21 @@ function newSlug() { return crypto.randomBytes(9).toString("base64url"); } // ~1
15
15
 
16
16
  // Crée un lien de partage (un par destinataire). Dénormalise titre/URL/nom pour résilience (le doc vit dans
17
17
  // un snapshot). Renvoie le slug.
18
- async function createShare({ docId, docTitle, fileUrl, fileName, recipientEmail, recipientName, createdBy, bot, botScript, guided, profileId, allowDownload, isTest, videoLayout, logo, logoDark, brandKey}) {
18
+ async function createShare({ docId, docTitle, fileUrl, fileName, recipientEmail, recipientName, attestedRecipientEmail, createdBy, bot, botScript, guided, profileId, allowDownload, isTest, videoLayout, logo, logoDark, brandKey}) {
19
19
  if (!docId || !fileUrl) throw Object.assign(new Error("doc invalide"), { statusCode: 400 });
20
20
  const slug = newSlug();
21
21
  const row = {
22
22
  slug, doc_id: String(docId), doc_title: docTitle || null, file_url: String(fileUrl), file_name: fileName || null,
23
23
  recipient_email: low(recipientEmail) || null, recipient_name: (recipientName || "").trim() || null, created_by: low(createdBy) || null,
24
+ // ⚠️ DEUX CHAMPS, DEUX FAITS. `recipient_email` dit qui a le droit d'EXPÉDIER en son nom au
25
+ // repartage — vide quand personne ne l'a. Celui-ci dit seulement à qui le lien est destiné,
26
+ // pour attribuer une lecture et pouvoir la révoquer.
27
+ //
28
+ // Les confondre revenait à donner à un visiteur attesté par un hôte le pouvoir de faire partir
29
+ // un courrier de nos serveurs, signé de son adresse, vers une adresse choisie par quiconque
30
+ // détient le lien. La garde d'envoi et l'héritage du repartage lisent tous deux
31
+ // `recipient_email` : en le laissant vide, ils refusent sans avoir à connaître cette histoire.
32
+ ...(attestedRecipientEmail ? { attested_recipient_email: low(attestedRecipientEmail) } : {}),
24
33
  bot_enabled: !!bot, bot_script: (botScript || "").trim() ? String(botScript).slice(0, 2000) : null, bot_guided: guided !== false,
25
34
  bot_profile_id: (profileId || "").trim() ? String(profileId).slice(0, 40) : null,
26
35
  allow_download: allowDownload !== false, // défaut : autorisé (rétro-compatible)
@@ -0,0 +1,25 @@
1
+ -- 0002 — le rang d'écriture, pour que l'ordre survive à un abandon
2
+ --
3
+ -- Pour : garantir qu'une écriture PÉRIMÉE n'écrase pas une plus récente. La file unique du
4
+ -- navigateur supprime le désordre qu'on CAUSE (une seule écriture en vol) ; elle ne peut
5
+ -- rien contre celui qu'on SUBIT — une requête abandonnée par le délai maximal peut être
6
+ -- arrivée au serveur et y atterrir après celle qui l'a remplacée.
7
+ --
8
+ -- Sans lui : rien ne change. Le player détecte l'absence de la colonne, cesse de contrôler l'ordre,
9
+ -- et le signale une fois en nommant ce fichier. On revient exactement au comportement
10
+ -- d'avant : dernier arrivé gagne.
11
+ --
12
+ -- Sûre pendant que la version précédente tourne : oui — additive, avec une valeur par défaut, donc
13
+ -- aucune ligne existante n'est réécrite et aucun code plus ancien ne la lit.
14
+ --
15
+ -- ⚠️ POURQUOI UN RANG PLUTÔT QU'UN HORODATAGE. Une heure vient d'une horloge, et deux onglets d'un
16
+ -- même présentateur n'ont pas la même. Un rang vient d'un compteur : il ne dit pas QUAND, il dit
17
+ -- APRÈS QUOI — ce qui est exactement la question posée.
18
+
19
+ alter table public.doc_presentations
20
+ add column if not exists write_seq bigint not null default 0;
21
+
22
+ comment on column public.doc_presentations.write_seq is
23
+ 'Rang de la dernière écriture de pilotage acceptée. Une écriture de rang inférieur ou égal est '
24
+ 'refusée : elle a été doublée en vol. Remis à zéro par toute émission d''un jeton de contrôle '
25
+ '(démarrage, reprise), qui ouvre un nouveau domaine d''ordre.';
@@ -0,0 +1,30 @@
1
+ -- 0003 — un compteur de débit partagé entre les instances
2
+ --
3
+ -- Pour : que les limites du player comptent pour l'INSTANCE et non pour le processus. En
4
+ -- serverless, plusieurs exécutions servent en parallèle et démarrent à froid : un compteur
5
+ -- en mémoire est divisé par le nombre d'instances, sans que rien ne le dise.
6
+ --
7
+ -- Sans lui : le player retombe sur le compteur en mémoire — le comportement d'avant — et le signale
8
+ -- une fois en nommant ce fichier. Aucune limite ne disparaît ; elles comptent seulement
9
+ -- par processus, ce qui les rend plus lâches qu'annoncé.
10
+ --
11
+ -- Sûre pendant que la version précédente tourne : oui — nouvelle table, que personne ne lit encore.
12
+ --
13
+ -- ⚠️ ACCÈS RÉSERVÉ AU SERVICE. Un compteur de débit lisible par le public dirait à un attaquant
14
+ -- exactement où il en est ; modifiable, il ne limiterait rien. Aucune politique n'est ouverte : seule
15
+ -- la clé de service y accède, comme pour toutes les tables que le navigateur ne touche jamais.
16
+
17
+ create table if not exists public.player_rate_limits (
18
+ key text primary key,
19
+ count integer not null default 0,
20
+ expires_at timestamptz not null
21
+ );
22
+
23
+ create index if not exists player_rate_limits_expires_idx
24
+ on public.player_rate_limits (expires_at);
25
+
26
+ alter table public.player_rate_limits enable row level security;
27
+
28
+ comment on table public.player_rate_limits is
29
+ 'Compteurs de débit partagés entre instances. Une ligne par clé et par fenêtre ; les lignes '
30
+ 'périmées sont écrasées à la première demande suivante, il n''y a rien à purger.';