discovery-media-player 0.1.47 → 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.47",
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
@@ -29,6 +29,9 @@ function init(ctx) {
29
29
  require("./shares").init(ctx);
30
30
  require("./presentations").init(ctx);
31
31
  require("./brands").init(ctx);
32
+ // ⚠️ Réinitialisé avec le contexte : les réponses de la sonde valent pour UNE base. Un hôte qui
33
+ // rebranche son contexte sur un autre projet doit reposer la question, pas hériter des réponses.
34
+ require("./schema").init(ctx);
32
35
  docbot = ctx.plugins.bot;
33
36
  brandIntroRuntime = ctx.plugins.brandIntro && ctx.plugins.brandIntro.brandIntroRuntime;
34
37
  botBrowser = ctx.plugins.botBrowser;
@@ -2103,6 +2106,15 @@ ${LEGAL_CSS}
2103
2106
  //
2104
2107
  // Le rythme minimum remplace les deux ordonnanceurs qu'elle absorbe — un déplacement de carte à
2105
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; }
2106
2118
  var _file=null;
2107
2119
  function fileEcritures(){
2108
2120
  if(!_file&&window.Player&&Player.live&&Player.live.createFileEcritures){
@@ -2118,7 +2130,7 @@ ${LEGAL_CSS}
2118
2130
  if(!f) return envoyerPage(page);
2119
2131
  return f.poser('page',function(){ return envoyerPage(cur||page); }); }
2120
2132
  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})})
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()})})
2122
2134
  .then(function(r){ if(r&&r.ok)diffuserEtat(); })
2123
2135
  .catch(function(){}); }
2124
2136
  // ⚠️ TROIS GESTES, ET L'ORDRE ÉTAIT LE PIRE DES SIX. On signalait la fin, puis on COUPAIT LE
@@ -2186,7 +2198,7 @@ ${LEGAL_CSS}
2186
2198
  .then(function(r){return r.json();}).then(function(d){
2187
2199
  if(btn){ btn.disabled=false; btn.textContent='Présenter'; }
2188
2200
  if(!d||!d.ok||!d.slug) return;
2189
- 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;
2190
2202
  showBar(d.slug); liveConnect(d.slug,d.control); startHb(); pushPage();
2191
2203
  }).catch(function(){ if(btn){ btn.disabled=false; btn.textContent='Présenter'; } });
2192
2204
  }
@@ -2198,7 +2210,7 @@ ${LEGAL_CSS}
2198
2210
  fetch('/api/doc',{method:'POST',headers:h,body:JSON.stringify({action:'present-reclaim',slug:slug})})
2199
2211
  .then(function(r){return r.json();}).then(function(d){
2200
2212
  if(!d||!d.ok||!d.control){ if(d&&d.status===403){ Player.bridge.sendToHost({type:'present-denied'}); } return; }
2201
- PRES={slug:slug,control:d.control}; saveCtl(slug,d.control);
2213
+ PRES={slug:slug,control:d.control}; saveCtl(slug,d.control); _seq=0;
2202
2214
  showBar(slug); liveConnect(slug,d.control); startHb();
2203
2215
  var target=Math.max(1,d.page||1);
2204
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); } })();
@@ -2409,6 +2421,53 @@ ${botOn && botBrowser ? botBrowser.botViewerJs(ICONS) : ""}
2409
2421
 
2410
2422
  // CSP de la page audience (Présenter) : autorise pdf.js (cdnjs), supabase-js (jsdelivr) et la connexion
2411
2423
  // Realtime (https + wss vers le projet Supabase). Plus permissive que la visionneuse, limitée à cette page.
2424
+ /**
2425
+ * Les origines d'images qu'une page a le droit de charger.
2426
+ *
2427
+ * ⚠️ FOURNIR UNE URL SANS AUTORISER SON ORIGINE REVIENT À NE PAS LA FOURNIR — avec l'apparence du
2428
+ * contraire. Le HTML est parfait, le fichier répond 200, et le navigateur refuse quand même. C'est
2429
+ * arrivé en 0.1.47 : la marque du client était résolue, écrite dans la page, et bloquée. Le chemin
2430
+ * du lien tracé ajoutait bien son origine ; celui de l'aperçu, non. Deux politiques sur la même
2431
+ * instance, à la même minute.
2432
+ *
2433
+ * ⚠️ ET AUCUNE SONDE SERVEUR NE PEUT LE VOIR. Le HTML rendu est correct, le script se compile, le
2434
+ * paquet est conforme. Ni notre étape de fumée, ni la garde d'artefact, ni un test qui exécute la
2435
+ * page ne mordent : seul un navigateur le montre. C'est le second hôte qui l'a vu, à l'œil, chez son
2436
+ * client.
2437
+ *
2438
+ * D'où cette fonction : une seule liste, pour toutes les routes qui rendent la visionneuse. La
2439
+ * remplir est une décision ; l'oublier n'est plus possible, parce qu'il n'y a plus qu'un endroit.
2440
+ *
2441
+ * ⚠️ CE QU'ELLE NE COUVRE PAS, ET QUI EST UN AUTRE PROBLÈME. La page d'AUDIENCE affiche les avatars
2442
+ * des participants, qui arrivent par la présence — donc à l'exécution, et depuis autant d'origines
2443
+ * qu'il y a de membres chez l'hôte. Aucune liste posée au rendu ne peut les prévoir. Les
2444
+ * pré-autoriser demanderait d'élargir la politique à une origine d'hôte entière, ce qui est une
2445
+ * décision à prendre séparément, pas un oubli à corriger ici.
2446
+ *
2447
+ * ⚠️ ON NE DÉRIVE PAS CETTE LISTE DU HTML RENDU, et c'est délibéré. Ce serait plus général et ça
2448
+ * viderait la politique de son sens : autoriser tout ce que la page référence, c'est autoriser aussi
2449
+ * ce qu'une valeur mal filtrée y aurait glissé. La liste porte des CHAMPS connus, pas des URL
2450
+ * trouvées.
2451
+ */
2452
+ function originesImages(logoInstance, share) {
2453
+ const s = share || {};
2454
+ return [
2455
+ originOf(logoInstance),
2456
+ originOf(s.brand_logo),
2457
+ originOf(s.bot_avatar),
2458
+ // ⚠️ Celui-ci ne cassait pas, et c'est pire qu'un défaut visible : il marchait PAR ACCIDENT,
2459
+ // parce que la photo du présentateur et l'avatar de l'assistant sortent en général du même
2460
+ // stockage, donc de la même origine. Le jour où un hôte range l'une ailleurs, elle disparaît
2461
+ // sans que rien n'ait changé chez lui.
2462
+ originOf(s.bot_vphoto),
2463
+ // ⚠️ Trouvé par la garde à son premier passage, et il ne se voyait pas : cette adresse ne part
2464
+ // pas dans le HTML mais dans la configuration, et c'est la couche live qui en fait une image à
2465
+ // l'exécution — dans la liste des participants. Un défaut de politique sur une image construite
2466
+ // par du script se lit encore moins qu'un autre : la page est déjà chargée quand il se produit.
2467
+ originOf(s.presenter_avatar),
2468
+ ].filter(Boolean).join(" ");
2469
+ }
2470
+
2412
2471
  function sendPresentHtml(res, html, nonce, supaUrl, imgExtra, frameAncestors) {
2413
2472
  const wss = String(supaUrl || "").replace(/^https:/, "wss:");
2414
2473
  res.statusCode = 200;
@@ -2769,7 +2828,7 @@ async function handler(req, res) {
2769
2828
  return jp(200, { ok: true, slug: out.slug, control: out.control });
2770
2829
  }
2771
2830
  const r = body.action === "present-page"
2772
- ? await setPage(String(body.slug || ""), String(body.control || ""), body.page)
2831
+ ? await setPage(String(body.slug || ""), String(body.control || ""), body.page, body.seq)
2773
2832
  : body.action === "present-touch"
2774
2833
  ? await touchPresentation(String(body.slug || ""), String(body.control || ""))
2775
2834
  : await endPresentation(String(body.slug || ""), String(body.control || ""));
@@ -3112,21 +3171,62 @@ async function handler(req, res) {
3112
3171
  if (body.action !== "docshare.create") {
3113
3172
  return jd(403, { ok: false, error: "L'appel serveur à serveur ne crée que des liens sans destinataire." });
3114
3173
  }
3115
- if (body.recipientEmail) {
3116
- return jd(400, { ok: false, error: "Un lien avec destinataire appartient à un membre : il exige son jeton." });
3117
- }
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();
3118
3200
  const docId = String(body.docId || "").trim();
3119
3201
  if (!docId || !body.fileUrl) return jd(400, { ok: false, error: "docId/fileUrl requis" });
3120
3202
 
3121
3203
  const ipH = adresseAppelant(req) || "hote";
3122
3204
  if (!(await PLAYER.limits.allow(`hshare:${ipH}`, 120, 3600))) return jd(429, { ok: false, error: "rate" });
3123
3205
 
3124
- // ⚠️ La clé d'idempotence n'a PAS demandé de colonne : « le lien de l'hôte pour ce
3125
- // document » est exactement la ligne sans créateur ET sans destinataire. Seul ce
3126
- // chemin en produit, donc elle est sans ambiguïté — et une instance déjà en service
3127
- // 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";
3128
3228
  const dejaLa = await PLAYER.db.request(
3129
- `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`,
3130
3230
  );
3131
3231
  if (Array.isArray(dejaLa) && dejaLa[0]) {
3132
3232
  await PLAYER.db.request(`commercial_doc_shares?slug=eq.${encodeURIComponent(dejaLa[0].slug)}`, {
@@ -3140,7 +3240,8 @@ async function handler(req, res) {
3140
3240
  // de personne, et reste visible en administration (`list.all`, qui ne filtre pas).
3141
3241
  const neuf = await createShare({
3142
3242
  brandKey: body.brandKey, docId, docTitle: body.docTitle, fileUrl: body.fileUrl,
3143
- 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,
3144
3245
  guided: body.guided, profileId: body.profileId, allowDownload: body.allowDownload,
3145
3246
  videoLayout: body.videoLayout, logo: body.logo, logoDark: body.logoDark,
3146
3247
  });
@@ -3640,7 +3741,7 @@ async function handler(req, res) {
3640
3741
  // Conséquence absurde relevée par cet hôte : la page de REFUS, corrigée la veille, était
3641
3742
  // encadrable chez lui — pas la page de SUCCÈS. Le chemin d'erreur était plus portable que
3642
3743
  // le chemin nominal.
3643
- return sendPresentHtml(res, viewerHtml(pseudo, pnonce, plogo), pnonce, supaUrl, originOf(plogo),
3744
+ return sendPresentHtml(res, viewerHtml(pseudo, pnonce, plogo), pnonce, supaUrl, originesImages(plogo, pseudo),
3644
3745
  embed ? embedFrameAncestors() : "'self'");
3645
3746
  }
3646
3747
 
@@ -3739,7 +3840,7 @@ async function handler(req, res) {
3739
3840
  const frameAncestors = share.embed
3740
3841
  ? embedFrameAncestors()
3741
3842
  : "'self'";
3742
- return sendHtml(res, 200, viewerHtml(share, nonce, logoUrl, pitch), `'nonce-${nonce}'`, [originOf(logoUrl), originOf(share.bot_avatar), originOf(share.brand_logo)].filter(Boolean).join(" "), frameAncestors);
3843
+ return sendHtml(res, 200, viewerHtml(share, nonce, logoUrl, pitch), `'nonce-${nonce}'`, originesImages(logoUrl, share), frameAncestors);
3743
3844
  } catch (error) {
3744
3845
  try { await PLAYER.errors.capture(error, { route: "doc", method: req.method }); } catch { /* ignore */ }
3745
3846
  res.statusCode = 500; res.end("Erreur");
@@ -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
 
@@ -0,0 +1,97 @@
1
+ // CE QUE LA BASE PORTE VRAIMENT, ET NON CE QU'ON CROIT LUI AVOIR APPLIQUÉ.
2
+ //
3
+ // ⚠️ LE PLAYER N'APPLIQUE PAS LES MIGRATIONS, ET NE LE POURRA JAMAIS. Il parle à la base uniquement
4
+ // par PostgREST, qui n'exécute pas de DDL. Lui donner ce pouvoir supposerait d'exposer une fonction
5
+ // capable d'exécuter du SQL arbitraire — dans un service qui sert des liens publics. C'est l'hôte qui
6
+ // applique ; le player doit seulement SAVOIR.
7
+ //
8
+ // ⚠️ ET IL DEMANDE PLUTÔT QU'IL NE RETIENT. Une table de suivi des migrations aurait dû être créée
9
+ // par une migration : le premier pas serait retombé sur le problème qu'elle résout. Et un registre
10
+ // dit ce qu'on CROIT avoir appliqué ; une sonde dit ce qui EST. Les deux divergent le jour où
11
+ // quelqu'un applique à la main — c'est-à-dire le jour où ça compte.
12
+ //
13
+ // ⚠️ POURQUOI CE FICHIER EXISTE. PostgREST rejette un `PATCH` portant une colonne inconnue. Un hôte
14
+ // qui déploie le code avant la migration voit donc TOUTES ses écritures échouer sur ce chemin, pas
15
+ // seulement la fonction nouvelle — et le message parle d'une colonne, pas d'une version. Deux
16
+ // chantiers ont été repoussés pour cette seule raison. Avec cette sonde, l'ordre de déploiement
17
+ // cesse d'être un piège.
18
+
19
+ let PLAYER = null;
20
+ /**
21
+ * Une question posée une fois, retenue pour le processus.
22
+ *
23
+ * ⚠️ C'EST AUSSI CE QUI DÉDOUBLONNE LE JOURNAL, et il n'y a donc rien d'autre à écrire pour ça. La
24
+ * première version portait un second ensemble « déjà signalé » : vidé exactement quand celui-ci
25
+ * l'est, donc inatteignable. Une mutation qui le retirait ne faisait échouer aucun test — la bonne
26
+ * réponse n'était pas d'ajouter un test pour le justifier, c'était de constater qu'il ne servait à
27
+ * rien. Une garde qu'on ne peut pas voir refuser n'est pas une garde.
28
+ */
29
+ const connues = new Map();
30
+
31
+ function init(ctx) {
32
+ PLAYER = ctx;
33
+ connues.clear();
34
+ }
35
+
36
+ /**
37
+ * La base porte-t-elle cette colonne ?
38
+ *
39
+ * ⚠️ EN CAS DE DOUTE, ABSENTE. Supposer présente ferait échouer l'écriture ENTIÈRE — la nouvelle
40
+ * fonction et tout ce qui l'accompagne. Supposer absente fait attendre la fonction seule. Une
41
+ * fonction qui attend vaut mieux qu'une écriture perdue, et c'est la seule direction où l'erreur se
42
+ * répare toute seule quand la migration arrive.
43
+ *
44
+ * @param {string} table
45
+ * @param {string} colonne
46
+ * @param {string} migration le fichier à appliquer — c'est LUI qu'on nomme dans le journal
47
+ */
48
+ function aLaColonne(table, colonne, migration) {
49
+ const cle = `${table}.${colonne}`;
50
+ if (!connues.has(cle)) connues.set(cle, sonder(table, colonne, migration, cle));
51
+ return connues.get(cle);
52
+ }
53
+
54
+ async function sonder(table, colonne, migration, cle) {
55
+ try {
56
+ // `limit=0` : on ne veut aucune ligne, seulement savoir si la colonne se sélectionne. PostgREST
57
+ // répond 400 « column … does not exist » quand elle manque — donc l'échec EST la réponse.
58
+ //
59
+ // ⚠️ LA PART ENCODÉE EST CALCULÉE À PART, ET PAS POUR LA LISIBILITÉ. La garde de portabilité de
60
+ // la CI traque la syntaxe propre à PostgREST — les ressources imbriquées « select=a(b) », les
61
+ // arbres booléens — en cherchant une parenthèse après « select= ». Écrite dans le gabarit,
62
+ // l'appel à encodeURIComponent en produisait une : la garde accusait une requête parfaitement
63
+ // portable. On lève l'ambiguïté du côté du code, pas du côté de la garde.
64
+ //
65
+ // ⚠️ ET CETTE SONDE EST LE SEUL ENDROIT QUI DÉPEND DU COMPORTEMENT D'ERREUR DE PostgREST. Sur
66
+ // une autre base, « la colonne manque » se demanderait autrement. C'est isolé ici exprès : un
67
+ // portage a un fichier à réécrire, pas une habitude à retrouver partout.
68
+ const champ = encodeURIComponent(colonne);
69
+ await PLAYER.db.request(`${table}?select=${champ}&limit=0`);
70
+ return true;
71
+ } catch {
72
+ // ⚠️ ON NE DISTINGUE PAS « COLONNE ABSENTE » DE « BASE INJOIGNABLE », ET C'EST VOULU. Les deux
73
+ // mènent à la même décision — ne pas écrire ce champ — et distinguer supposerait de lire un
74
+ // message d'erreur, c'est-à-dire de dépendre du texte d'un service tiers. Ce qui change entre
75
+ // les deux, c'est la durée : une base injoignable le redevient, et le processus suivant reposera
76
+ // la question.
77
+ signaler(cle, migration);
78
+ return false;
79
+ }
80
+ }
81
+
82
+ /**
83
+ * ⚠️ ON NOMME LE FICHIER, PAS L'ERREUR. « column does not exist » envoie l'exploitant lire du
84
+ * PostgREST ; « appliquez supabase/migrations/0001-…sql » lui dit quoi faire. La différence entre
85
+ * les deux se compte en heures.
86
+ */
87
+ function signaler(cle, migration) {
88
+ const quoi = migration ? `Appliquez ${migration}.` : "Une migration est en attente.";
89
+ try {
90
+ console.warn(`[player] la colonne « ${cle} » manque : la fonction qui en dépend reste inactive. ${quoi}`);
91
+ } catch { /* sans console */ }
92
+ }
93
+
94
+ /** Pour les tests et l'exploitation : reposer la question. */
95
+ function oublier() { connues.clear(); }
96
+
97
+ module.exports = { init, aLaColonne, oublier };
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,30 @@
1
+ -- 0001 — le destinataire ATTESTÉ par l'hôte, séparé de qui peut expédier en son nom
2
+ --
3
+ -- Pour : un hôte qui identifie lui-même son visiteur (code à usage unique, espace projet) et veut
4
+ -- que ses lectures soient comptées, attribuées et révocables — sans lui donner de lien
5
+ -- anonyme, et sans exiger le jeton d'un membre qu'il n'a pas.
6
+ --
7
+ -- Sans lui : rien ne change. Les liens attestés ne peuvent pas être créés, et le player refuse la
8
+ -- demande en le disant. Aucune fonction existante n'est affectée.
9
+ --
10
+ -- Sûre pendant que la version précédente tourne : oui — additive, et personne ne lit ni n'écrit
11
+ -- cette colonne tant que le code qui la connaît n'est pas déployé.
12
+ --
13
+ -- ⚠️ POURQUOI UNE COLONNE PLUTÔT QU'UN DRAPEAU. `recipient_email` porte aujourd'hui DEUX faits :
14
+ -- « à qui ce lien est destiné » et « qui a le droit d'expédier en son nom au repartage ». Un lien
15
+ -- attesté par l'hôte doit avoir le premier sans le second — son visiteur n'a jamais engagé sa
16
+ -- responsabilité chez nous.
17
+ --
18
+ -- Un drapeau `atteste_par_hote` dirait « ne fais pas la chose » : il décrirait le correctif au lieu
19
+ -- du fait, et quelqu'un l'ignorerait dans six mois pour un cas qui semblerait différent. Avec deux
20
+ -- colonnes, la garde d'envoi (qui exige `recipient_email`) et l'héritage du repartage refusent tous
21
+ -- deux SANS SAVOIR POURQUOI on les protège. La règle devient une conséquence de la donnée.
22
+ --
23
+ -- Formulé par le second hôte, dont la demande a produit la séparation.
24
+
25
+ alter table public.commercial_doc_shares
26
+ add column if not exists attested_recipient_email text;
27
+
28
+ comment on column public.commercial_doc_shares.attested_recipient_email is
29
+ 'Destinataire attesté par l''hôte : sert à ATTRIBUER une lecture, jamais à expédier en son nom. '
30
+ 'La colonne recipient_email, elle, dit qui peut expédier — vide quand personne ne le peut.';
@@ -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.';