discovery-media-player 0.1.163 → 0.1.165

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.
package/bin/serve.js CHANGED
@@ -179,7 +179,21 @@ async function servir(req, res) {
179
179
  // Le player lit `req.query` (convention des plateformes serverless) et `req.body` déjà analysé.
180
180
  req.query = q;
181
181
  if (req.method === "POST") {
182
- req.body = await lireCorpsJson(req);
182
+ // ⚠️ TROIS ISSUES DISTINCTES, TROIS RÉPONSES — un corps invalide, trop gros ou coupé rendait
183
+ // `{}` dans les trois cas, et le gestionnaire répondait « bad-event » à un client qui n'avait
184
+ // qu'envoyé un fichier trop lourd. 413 pour le dépassement (avec `Connection: close` : on ne
185
+ // draine pas un corps sans fin), 400 pour un JSON illisible, rien pour une connexion partie.
186
+ // Relevé par un audit externe le 13/09.
187
+ const lu = await lireCorpsJson(req);
188
+ if (lu.etat === "too-large") {
189
+ res.setHeader("Connection", "close");
190
+ res.once("finish", () => { try { req.destroy(); } catch { /* déjà parti */ } });
191
+ player.refuserEnTexte(res, 413, "Corps trop volumineux (1 Mo au plus)");
192
+ return;
193
+ }
194
+ if (lu.etat === "invalid-json") { player.refuserEnTexte(res, 400, "Corps JSON illisible"); return; }
195
+ if (lu.etat === "aborted") { try { res.end(); } catch { /* flux clos */ } return; }
196
+ req.body = lu.corps;
183
197
  }
184
198
 
185
199
  try {
@@ -202,22 +216,43 @@ const serveur = http.createServer((req, res) => {
202
216
  player.refuserEnTexte(res, 500, "Erreur");
203
217
  });
204
218
  });
219
+ // ⚠️ LES DÉLAIS DE NODE (300 s par requête, 60 s pour les en-têtes) SONT CEUX D'UN SERVEUR DERRIÈRE UN
220
+ // PROXY, PAS D'UN SERVEUR EXPOSÉ. Mesurés par un audit externe le 13/09 : `requestTimeout` 300 000,
221
+ // `headersTimeout` 60 000. Une connexion qui envoie ses en-têtes au goutte-à-goutte tenait donc une
222
+ // minute, un corps lent cinq — par socket. ⚠️ Ces bornes portent sur la REQUÊTE : `requestTimeout`
223
+ // ne couvre PAS l'émission d'une réponse (cette phrase affirmait que « trente secondes couvrent un
224
+ // relais de 60 Mo » — faux, relevé par le même audit) ; un relais lent est borné par ses propres
225
+ // délais de progression et de budget (`relayStallMs`, `relayMaxMs` dans le gestionnaire). Les
226
+ // en-têtes n'ont aucune raison de prendre plus de quinze secondes ; un keep-alive court rend les
227
+ // sockets. Un proxy amont peut serrer davantage, jamais l'inverse.
228
+ serveur.requestTimeout = 30_000;
229
+ serveur.headersTimeout = 15_000;
230
+ serveur.keepAliveTimeout = 5_000;
205
231
 
206
232
  /** Corps JSON, borné. Un corps sans fin est une façon peu coûteuse de faire tomber un serveur. */
233
+ /**
234
+ * Rend `{ etat, corps }` : `ok` (corps = l'objet lu, `{}` pour un corps vide), `too-large` (la lecture
235
+ * s'arrête au premier octet au-delà de `maxOctets`, sans drainer la suite), `invalid-json`, ou
236
+ * `aborted` (la connexion est partie avant la fin). L'appelant choisit le code HTTP : ici, on ne sait
237
+ * que lire.
238
+ */
207
239
  function lireCorpsJson(req, maxOctets = 1_000_000) {
208
240
  return new Promise((resolve) => {
209
- let taille = 0;
241
+ let taille = 0, regle = false;
210
242
  const morceaux = [];
243
+ const rendre = (v) => { if (!regle) { regle = true; resolve(v); } };
211
244
  req.on("data", (c) => {
245
+ if (regle) return;
212
246
  taille += c.length;
213
- if (taille > maxOctets) { req.destroy(); resolve({}); return; }
247
+ if (taille > maxOctets) { try { req.pause(); } catch { /* déjà clos */ } rendre({ etat: "too-large", corps: null }); return; }
214
248
  morceaux.push(c);
215
249
  });
216
250
  req.on("end", () => {
217
- try { resolve(JSON.parse(Buffer.concat(morceaux).toString("utf8") || "{}")); }
218
- catch { resolve({}); }
251
+ try { rendre({ etat: "ok", corps: JSON.parse(Buffer.concat(morceaux).toString("utf8") || "{}") }); }
252
+ catch { rendre({ etat: "invalid-json", corps: null }); }
219
253
  });
220
- req.on("error", () => resolve({}));
254
+ req.on("error", () => rendre({ etat: "aborted", corps: null }));
255
+ req.on("aborted", () => rendre({ etat: "aborted", corps: null }));
221
256
  });
222
257
  }
223
258
 
@@ -286,4 +321,4 @@ if (require.main === module) {
286
321
  });
287
322
  }
288
323
 
289
- module.exports = { serveur, servir, versParametres, pageAccueil, __arreterProprement: arreterProprement, DELAI_ARRET_MS };
324
+ module.exports = { serveur, servir, versParametres, pageAccueil, lireCorpsJson, __arreterProprement: arreterProprement, DELAI_ARRET_MS };
@@ -56,12 +56,43 @@ function sansBarreFinale(valeur) {
56
56
  * consultations protégées.
57
57
  *
58
58
  * ⚠️ ET UNE COURSE DE PROMESSES NE SUFFIRAIT PAS : elle rendrait la main sans ANNULER le `fetch`,
59
- * donc sans rien libérer. C'est `AbortSignal` ou rien. Un signal fourni par l'appelant a priorité —
60
- * un hôte qui borne lui-même une opération longue n'est pas écrasé.
59
+ * donc sans rien libérer. C'est `AbortSignal` ou rien. Un signal fourni par l'appelant s'AJOUTE au
60
+ * plancher — voir `composerSignaux` : le premier des deux qui parle gagne.
61
61
  */
62
+ /**
63
+ * ⚠️ ON COMPOSE LES SIGNAUX, ON NE LES REMPLACE PAS — ET LA PREMIÈRE ÉCRITURE LES REMPLAÇAIT.
64
+ *
65
+ * Elle disait `options.signal || AbortSignal.timeout(delai)` : un signal fourni par l'appelant
66
+ * SUPPRIMAIT le plancher, au lieu de s'y ajouter. Un hôte qui borne lui-même une opération longue
67
+ * croyait donc ajouter une garantie, et en retirait une. Mesuré : avec un signal qui n'expire jamais
68
+ * et `timeoutMs: 20`, la promesse est encore en attente après 150 ms.
69
+ *
70
+ * ⚠️ ET LE COMMENTAIRE BÉNISSAIT LE DÉFAUT. Il écrivait « un signal fourni par l'appelant a
71
+ * priorité — un hôte qui borne lui-même une opération longue n'est pas écrasé ». L'intention est
72
+ * juste ; « a priorité » était la mauvaise traduction. Le premier des deux qui parle gagne : c'est
73
+ * ce que « borner » veut dire. Rapporté par un audit externe le 12/09 comme défaut LATENT — aucun
74
+ * appel du produit ne transmet aujourd'hui de signal, donc personne ne l'aurait vu arriver.
75
+ */
76
+ function composerSignaux(fourni, delaiMs) {
77
+ const horloge = (typeof AbortSignal !== "undefined" && AbortSignal.timeout)
78
+ ? AbortSignal.timeout(delaiMs) : undefined;
79
+ if (!fourni) return horloge;
80
+ if (!horloge) return fourni;
81
+ if (typeof AbortSignal.any === "function") return AbortSignal.any([fourni, horloge]);
82
+ // ⚠️ REPLI SANS `AbortSignal.any` : un contrôleur qui suit les deux. Le `aborted` se teste AVANT
83
+ // de s'abonner — un signal déjà déclenché n'émettra plus jamais son événement, et l'attendre
84
+ // serait une attente infinie posée par la précaution elle-même.
85
+ const relais = new globalThis.AbortController();
86
+ const abandonner = () => { try { relais.abort(); } catch { /* déjà abandonné */ } };
87
+ for (const s of [fourni, horloge]) {
88
+ if (s.aborted) { abandonner(); break; }
89
+ try { s.addEventListener("abort", abandonner, { once: true }); } catch { /* signal exotique */ }
90
+ }
91
+ return relais.signal;
92
+ }
93
+
62
94
  function fetchBorne(cible, options = {}, delaiMs) {
63
- const signal = options.signal
64
- || (typeof AbortSignal !== "undefined" && AbortSignal.timeout ? AbortSignal.timeout(delaiMs) : undefined);
95
+ const signal = composerSignaux(options.signal, delaiMs);
65
96
  return fetch(cible, { ...options, ...(signal ? { signal } : {}) });
66
97
  }
67
98
 
@@ -243,13 +274,33 @@ async function appelHote(url, secret, corps, errors) {
243
274
  * base. Y adosser un compteur partagé ferait payer à la garde le prix qu'on venait d'épargner à ce
244
275
  * qu'elle garde. Sur ce chemin, la protection réelle est le cache, pas le compteur.
245
276
  *
246
- * ⚠️ LE COMPTE PARTAGÉ N'EST PAS ATOMIQUE. PostgREST ne sait pas exprimer « incrémente » : c'est une
247
- * lecture puis une écriture. Deux instances peuvent donc lire la même valeur et n'en écrire qu'une —
248
- * le compteur SOUS-estime sous forte concurrence. Pour une limite de débit, sous-estimer signifie
249
- * laisser passer un peu plus, jamais refuser à tort. Le dire vaut mieux que laisser croire à une
250
- * exactitude qu'on n'a pas.
277
+ * ⚠️ CE PARAGRAPHE DISAIT QUE LE COMPTE PARTAGÉ N'EST PAS ATOMIQUE. C'EST FAUX DEPUIS 0004, et il
278
+ * a survécu à ce qu'il décrivait — écrit quand PostgREST ne savait pas exprimer « incrémente »,
279
+ * donc quand compter était une lecture puis une écriture. La migration `0004-limites-atomiques.sql`
280
+ * a remplacé les deux par UNE instruction serveur : `player_rate_limit_bump`. Corrigé plutôt que
281
+ * supprimé, parce qu'un hôte qui l'a lu a pu bâtir une compensation dont il n'a pas besoin.
282
+ *
283
+ * ⚠️ ET LA DÉGRADATION RÉELLE EST L'INVERSE DE CE QU'IL LAISSAIT CROIRE. Sans 0004, l'étage partagé
284
+ * ne compte pas moins bien : IL NE COMPTE PAS DU TOUT. Le `return true` plus bas laisse passer, et
285
+ * seul le compteur LOCAL, par processus, subsiste — une limite de 120/h en autorise 120 PAR
286
+ * EXÉCUTION. « Non atomique » nommait un mode qui n'existe pas : un comptage partagé dégradé.
287
+ *
288
+ * La matrice complète est dans `docs/HOST-CONTRACT.md` ; elle est la version qui fait foi.
289
+ *
290
+ * ⚠️ CETTE PHRASE-CI EST LE JUMEAU FRANÇAIS DE CELLE CORRIGÉE DANS LE CONTRAT LE 11/09 — À 95
291
+ * LIGNES DE L'AVERTISSEMENT CORRIGÉ LE MÊME JOUR, DANS CE MÊME FICHIER. Corriger un exemplaire
292
+ * d'une affirmation et pas l'autre est le mode de panne que ce dépôt traque partout ailleurs : deux
293
+ * copies d'une règle divergent, et personne ne les confronte. Cherchez le MÉCANISME que vous venez
294
+ * de changer, pas les mots dont vous vous souvenez.
295
+ */
296
+ /**
297
+ * @param horloge lecture du temps, injectable. ⚠️ ELLE EXISTE POUR QU'UN BANC N'AIT PAS À REMPLACER
298
+ * `Date.now` GLOBALEMENT. Une simulation d'une heure d'audience doit faire avancer le temps ; le
299
+ * seul moyen était de rustiner un global, ce qui laisse l'instrument dépendre d'un `finally` posé au
300
+ * bon endroit — et la première écriture de cette simulation l'avait posé au mauvais, mesurant en
301
+ * partie le temps RÉEL sans le dire. Une horloge passée en argument ne peut pas fuir.
251
302
  */
252
- function creerLimites(db, journal) {
303
+ function creerLimites(db, journal, horloge = () => Date.now()) {
253
304
  const seaux = new Map();
254
305
  const PREFIXES_LOCAUX = ["pread:"];
255
306
  let partageDisponible = null; // null = pas encore demandé
@@ -263,7 +314,7 @@ function creerLimites(db, journal) {
263
314
  // anti-inondation, et c'est le compromis explicitement recommandé.
264
315
  const PLAFOND_CLES = 5000;
265
316
  function localAutorise(cle, max, fenetreSecondes) {
266
- const maintenant = Date.now();
317
+ const maintenant = horloge();
267
318
  const fenetreMs = fenetreSecondes * 1000;
268
319
  let e = seaux.get(cle);
269
320
  if (!e || maintenant - e.debut >= fenetreMs) e = { debut: maintenant, compte: 0 };
@@ -390,7 +441,23 @@ function createStandaloneContext(env = process.env) {
390
441
  // ⚠️ DERNIÈRE BARRIÈRE avant un DELETE à la clé service_role (P1 huitième audit). Bucket en
391
442
  // liste blanche, et refus de toute traversée — chaque segment sur l'alphabet des chemins
392
443
  // signés. `fetch` normalise `..` : un chemin non validé sortirait du bucket visé.
393
- if (bucket !== "present-attachments") return false;
444
+ //
445
+ // ⚠️ LA LISTE NE PORTAIT QU'UN BUCKET SUR LES DEUX, ET LA PURGE DU CACHE DE VOIX N'A DONC
446
+ // JAMAIS RIEN RETIRÉ. `tts-cache` était refusé ICI, avant tout appel réseau : chaque retrait
447
+ // rendait `false`, la trace partait quand même, et l'objet restait dans un bucket PUBLIC
448
+ // sans plus aucun chemin vers lui — puisque cette capacité expose `put` et `remove`, jamais
449
+ // `list`. C'est très exactement le mal que la migration 0021 avait été écrite pour rendre
450
+ // réparable, à 100 %, en silence.
451
+ //
452
+ // ⚠️ ET CE SILENCE ÉTAIT DOCUMENTÉ. Le rapport comptait ces refus dans `fichiersErreur`, que
453
+ // `docs/RETENTION.md` explique par un fait vrai — un tiers des empreintes n'a pas de `.json`
454
+ // d'alignement (552 mp3 pour 356 json, mesuré par un hôte). Une explication JUSTE rendait
455
+ // donc un échec TOTAL indiscernable d'un fonctionnement normal. Trouvé le 12/09 en écrivant
456
+ // la documentation du correctif d'un AUTRE défaut du même chemin.
457
+ //
458
+ // La liste énumère maintenant les deux buckets que la rétention doit atteindre, et rien
459
+ // d'autre : la barrière garde son objet, elle cesse d'interdire le travail qu'on lui demande.
460
+ if (bucket !== "present-attachments" && bucket !== "tts-cache") return false;
394
461
  const segs = String(chemin).split("/");
395
462
  for (const seg of segs) {
396
463
  if (seg === "" || seg === "." || seg === ".." || !/^[A-Za-z0-9._-]+$/.test(seg)) return false;
@@ -399,7 +466,23 @@ function createStandaloneContext(env = process.env) {
399
466
  const r = await fetchBorne(`${base}/storage/v1/object/${encodeURIComponent(bucket)}/${segs.map(encodeURIComponent).join("/")}`, {
400
467
  method: "DELETE", headers: { apikey: cle, Authorization: `Bearer ${cle}` },
401
468
  }, DELAI_STOCKAGE_MS);
402
- return r.ok;
469
+ if (r.ok) return true;
470
+ // ⚠️ UN OBJET DÉJÀ ABSENT EST UN SUCCÈS POUR CE QU'ON DEMANDE ICI, ET CE N'EST PLUS UNE
471
+ // NUANCE DE COMPTAGE. La purge RETIENT désormais la ligne quand ce
472
+ // retrait rend `false`, parce que la ligne est le seul chemin vers l'objet (`storage`
473
+ // expose `put` et `remove`, jamais `list`). Rendre `false` sur un objet qui n'est plus là
474
+ // retiendrait donc la ligne POUR TOUJOURS, en attendant un fichier qui n'existe pas —
475
+ // exactement la sur-rétention que le correctif de la sous-rétention ne doit pas créer.
476
+ // Ce qu'on demande est « l'objet n'est plus là », et il n'y est plus.
477
+ //
478
+ // ⚠️ ON LIT LE CORPS PARCE QUE LE CODE NE SUFFIT PAS. Le Storage de Supabase répond 400
479
+ // sur un objet manquant, pas seulement 404 : se fier au seul statut raterait le cas le
480
+ // plus fréquent. NON VÉRIFIÉ CONTRE UN SUPABASE VIVANT DEPUIS CE DÉPÔT — ce qui est
481
+ // éprouvé ici est la CORRESPONDANCE (statut et corps vers verdict), pas la forme exacte
482
+ // que le fournisseur émet. Un hôte qui observerait une autre formulation doit la dire.
483
+ if (r.status === 404) return true;
484
+ const corps = await r.text().catch(() => "");
485
+ return /not[_ ]?found|no such key|does not exist/i.test(corps);
403
486
  } catch { return false; }
404
487
  },
405
488
 
@@ -757,6 +840,13 @@ function createStandaloneContext(env = process.env) {
757
840
  supabasePublishableKey: env.SUPABASE_PUBLISHABLE_KEY || "",
758
841
  mapsKey: env.GOOGLE_MAPS_API_KEY || "",
759
842
  extraFrameAncestors: String(env.DOC_FRAME_ANCESTORS || "").split(/\s+/).filter(Boolean),
843
+ // Transferts de fichiers simultanés par processus (défaut 64) : le relais refuse en 503 au-delà,
844
+ // avant tout appel amont. Lu ICI, pas dans le cœur — la configuration entre par le contexte.
845
+ maxConcurrentRelays: Number(env.PLAYER_MAX_RELAYS || 0) || 64,
846
+ // Un relais sans progression pendant relayStallMs, ou plus long que relayMaxMs, est abandonné
847
+ // (source et réponse détruites) : sans ça, un client qui cesse de lire garde sa place pour toujours.
848
+ relayStallMs: Number(env.PLAYER_RELAY_STALL_MS || 0) || 30_000,
849
+ relayMaxMs: Number(env.PLAYER_RELAY_MAX_MS || 0) || 900_000,
760
850
 
761
851
  /**
762
852
  * Clé de `localStorage` sous laquelle VOTRE application range la session de ses membres.
@@ -327,6 +327,22 @@ function isAllowedStorageUrl(candidate, origins, hostBase, root) {
327
327
  // 3. le nombre de sauts est borné, et le protocole ne peut pas changer de nature : une
328
328
  // redirection vers `file:` transformerait un amont distant en lecture de disque local.
329
329
  const MAX_REDIRECTIONS = 5;
330
+ /**
331
+ * ⚠️ LE BUDGET EST GLOBAL À L'OPÉRATION, PAS PAR SAUT — ET IL ÉTAIT PAR SAUT.
332
+ *
333
+ * Chaque tour de boucle créait son propre `AbortSignal.timeout(60 s)`. Avec six tours (saut 0 à 5),
334
+ * une chaîne de redirections lente pouvait donc immobiliser la requête, sa socket et sa place
335
+ * d'admission pendant SIX MINUTES — alors que le commentaire juste en dessous affirmait « le délai
336
+ * est large mais il est borné ». Il bornait un saut ; personne ne bornait l'opération. Relevé par
337
+ * un audit externe le 12/09.
338
+ *
339
+ * ⚠️ CE N'EST PAS UN TROU DE SÉCURITÉ, ET LE DIRE COMPTE : chaque saut repasse la garde complète
340
+ * d'origine et recalcule le secret. Le risque est de DISPONIBILITÉ — c'est la même leçon que les
341
+ * appels non bornés du contexte autonome, au même endroit du raisonnement.
342
+ *
343
+ * Un seul signal, créé avant la boucle et partagé par tous les sauts : le total ne peut pas dépasser
344
+ * ce chiffre, quel que soit le nombre de redirections.
345
+ */
330
346
  const DELAI_MAX_MS = 60_000;
331
347
 
332
348
  async function fetchAllowedFile(url, { range } = {}, { origins, hostBase, root, secret } = {}) {
@@ -335,6 +351,10 @@ async function fetchAllowedFile(url, { range } = {}, { origins, hostBase, root,
335
351
  if (local) return readLocal(local, range);
336
352
 
337
353
  let cible = String(url);
354
+ // ⚠️ CRÉÉ ICI, PAS DANS LA BOUCLE : `AbortSignal.timeout` compte à partir de sa création, donc un
355
+ // signal fabriqué avant le premier saut EST le budget de toute l'opération.
356
+ const budget = (typeof AbortSignal !== "undefined" && AbortSignal.timeout)
357
+ ? AbortSignal.timeout(DELAI_MAX_MS) : undefined;
338
358
  for (let saut = 0; saut <= MAX_REDIRECTIONS; saut++) {
339
359
  const headers = { "accept-encoding": "identity" };
340
360
  if (range) headers.range = range;
@@ -342,8 +362,9 @@ async function fetchAllowedFile(url, { range } = {}, { origins, hostBase, root,
342
362
  if (isHostFetchUrl(cible, hostBase) && secret) headers["x-player-fetch-secret"] = secret;
343
363
 
344
364
  // Un amont qui ne répond jamais immobiliserait la requête et ses ressources indéfiniment.
345
- // Le délai est large — un gros document met du temps — mais il est borné.
346
- const r = await fetch(cible, { headers, redirect: "manual", signal: AbortSignal.timeout(DELAI_MAX_MS) });
365
+ // Le délai est large — un gros document met du temps — mais il borne l'OPÉRATION ENTIÈRE, pas
366
+ // chaque saut : le même signal sert à tous, donc le temps déjà consommé ne se reconstitue pas.
367
+ const r = await fetch(cible, { headers, redirect: "manual", ...(budget ? { signal: budget } : {}) });
347
368
  if (r.status < 300 || r.status > 399) return r;
348
369
 
349
370
  const suivante = r.headers.get("location");
@@ -410,6 +410,56 @@ inheritance both refuse — without either of them knowing why.
410
410
  Requires `supabase/migrations/0001-destinataire-atteste.sql`. Until it is applied the player refuses
411
411
  the attested creation and names the file; it never falls back to the other column.
412
412
 
413
+ ## The visitor wall (`plugins.visitors`): what the player counts, and what your plugin must do
414
+
415
+ A host can gate documents behind a soft wall — an e-mail code, or a Google credential — by providing
416
+ `plugins.visitors` with `requestCode(email, { title })`, `verifyCode(email, code, name)` and
417
+ `verifyGoogle(credential)`. The player exposes them as `visitor-request`, `visitor-verify` and
418
+ `visitor-google`.
419
+
420
+ ⚠️ **Until this train, only the request was rate-limited; verification called your plugin directly.**
421
+ An external audit reproduced 1 000 code attempts and 1 000 Google verifications from one address with
422
+ zero limiter calls (13/09). A short code with no counter in the plugin was brute-forceable, and a
423
+ Google verification per anonymous request was a network amplifier. The player no longer assumes your
424
+ plugin counts — the same rule as the assistant's session↔document binding: a security property must
425
+ not depend on code the player does not contain.
426
+
427
+ | action | per address | per identity (fingerprint of the normalised e-mail, never the address) |
428
+ |---|---|---|
429
+ | `visitor-request` | 20 / hour | 5 / hour |
430
+ | `visitor-verify` | 100 / hour | 10 / 15 minutes |
431
+ | `visitor-google` | 100 / hour | — |
432
+
433
+ Counters are taken **at admission**: success, failure and an exception in your plugin consume them
434
+ alike. Beyond a limit the answer is `429 { error: "rate" }` and **your plugin is not called**.
435
+
436
+ ⚠️ **Provide `rateLimitKey(email): Promise<string>` on the plugin — the identity key should be
437
+ yours.** Without it the player keys the per-identity counters on a truncated SHA-256 of the
438
+ normalised e-mail: `player_rate_limits` never carries an address in clear, but a fingerprint is a
439
+ **pseudonym, not a secret** — anyone reading that table, a backup or an admin tool can precompute the
440
+ fingerprints of likely addresses (an audit showed it on 13/09). Your implementation should be a
441
+ stable, opaque HMAC with a host-side secret and **domain separation**:
442
+ `HMAC(secret, "visitor-email\0" + emailNormalised)`. The player calls it with the e-mail already
443
+ trimmed and lower-cased, prefixes your key with `h:` (a fallback fingerprint gets `e:`, so the two
444
+ never collide), and truncates it to 64 characters. If the capability is absent, throws, or returns
445
+ anything but a non-empty string, the player **falls back to the fingerprint and reports it once per
446
+ process** through `errors.capture` (`benin: true`): refusing to limit would be worse than limiting
447
+ under a weak pseudonym, and silence would be worse than both. (An earlier version of this paragraph
448
+ said the player holds no server secret at all — too absolute: the standalone context already carries
449
+ `ipHashSecret` for another purpose. The key still belongs with you, not with that secret.)
450
+
451
+ ⚠️ **What your plugin must still guarantee — the player cannot do it for you:**
452
+
453
+ - the code is **short-lived** (minutes, not hours) and **single-use**: a code that stays valid after a
454
+ successful verification can be replayed from a shoulder-surfed screen;
455
+ - the code has enough entropy for 10 attempts per quarter-hour not to be a lottery — six digits give
456
+ one chance in 100 000 per attempt at that pace, which is acceptable; four digits are not;
457
+ - `verifyGoogle` validates the credential's audience and issuer server-side, and does not accept an
458
+ expired token.
459
+
460
+ The player's counters bound the *rate*; your plugin bounds the *code*. Both are needed, and neither
461
+ replaces the other.
462
+
413
463
  ## ⚠️ What `limits.allow` promises changed
414
464
 
415
465
  It used to promise *best effort, per process*. The standalone context now counts in a **shared
@@ -712,6 +762,15 @@ Four requirements, in order of what they cost when missed:
712
762
  anywhere. Announce the length of what you send, request `Accept-Encoding: identity`, and refuse
713
763
  a compressed `206` — range bounds refer to compressed bytes.
714
764
  2. **Relay `Range`** (`206` + `Accept-Ranges: bytes`). Progressive loading depends on it.
765
+ ⚠️ And expect the player to hold **at most `config.maxConcurrentRelays` relays open per process**
766
+ (default 64; `PLAYER_MAX_RELAYS` in the standalone context): above it the player answers **503
767
+ with `Retry-After: 2` before calling you**, with no queue. The stream bounded bytes; nothing
768
+ bounded how many streams were open — an audit opened 200 slow transfers and got 200 upstream
769
+ connections (13/09). The slot is released in a `finally`, so your errors and a client leaving
770
+ mid-stream give it back — and so does a relay that **stops progressing**: no chunk for
771
+ `config.relayStallMs` (30 s) or a total beyond `config.relayMaxMs` (15 min) aborts the pipeline,
772
+ destroying your response and the client's. A client that stops reading no longer keeps a slot
773
+ forever; a route of yours that stops sending does not either.
715
774
  3. **Accept a server-to-server call.** A tracked link is opened by someone with no session on your
716
775
  side. Authenticate the player with the shared secret in the `x-player-fetch-secret` **header** —
717
776
  header only, never a query string: logs keep URLs.
@@ -762,6 +821,15 @@ back on an inability to *reach*.** And "do not fall back" applies to what you **
762
821
 
763
822
  ## What will bite
764
823
 
824
+ ⚠️ **Every capability you provide must settle in bounded time — `db.request` first of all.** The
825
+ player awaits your `db.request`, `storage.fetchFile`, `mail.send` and the visitor plugin; a promise
826
+ that never settles keeps a request in flight, and the read cache admits at most 128 in-flight reads
827
+ per process before answering **503 busy** to everyone. A database call that hangs is therefore not
828
+ "slow", it is an availability incident for the whole instance — the exact mechanism an audit
829
+ reproduced inside the test suite with a never-settling promise (13/09). Time out your own calls
830
+ (the standalone context bounds its own with `AbortSignal`), and never return a promise you cannot
831
+ guarantee will settle.
832
+
765
833
  **Your document-opening doors reappear.** A host has more than one place that opens a file, and new
766
834
  ones get written. Keep the list and hunt it periodically — and note that **your search criteria
767
835
  decide what you find**: search by what the user *obtains* (a document opens), not by the technique
@@ -825,6 +893,94 @@ was a correct `bot`, and the reader would have returned an empty string for ever
825
893
  set, so every request refused, on a perfectly correct integration. If your field is none of those
826
894
  three, tell us and we widen the list. The field name carries no security; the **role** filter does.
827
895
 
896
+ ⚠️ **`reshare` now answers with a three-state `delivery`, and the state you must handle is
897
+ `"unknown"`.** The response used to carry a single `sent` boolean, which collapsed three different
898
+ outcomes: your mail path declined, *we* declined, or **the call failed without us learning what your
899
+ side did**. Only the third is dangerous — if your host really sent the message and then answered too
900
+ late, a caller reading `sent: false` retries, creating a **second child link and a second email**.
901
+
902
+ | `delivery` | what happened | what to do |
903
+ |---|---|---|
904
+ | `"sent"` | your mail path reported success | nothing |
905
+ | `"refused"` | a decision was made — yours or ours; `sendRefused` names it, and when your mail hook answered `{ sent: false, reason }` (or `motif`) that word comes back as `hostReason`, trimmed to 80 characters | surface the reason; retrying will refuse again |
906
+ | `"unknown"` | the call failed (timeout, network). **We do not know whether the mail went out** | surface it to a human. **Do not retry automatically** — a retry may duplicate the email |
907
+ | `"not-requested"` | `send` was falsy | nothing |
908
+
909
+ `sent` is unchanged for integrations already reading it.
910
+
911
+ ⚠️ **Your refusal reason was being thrown away.** One host answers every refusal with
912
+ `{ sent: false, motif }` — eight distinct reasons — precisely so that *refused* never reads as *down*;
913
+ the route read only `sent`. From this train on, a string `reason` or `motif` on your hook's answer travels
914
+ back to the caller as `hostReason` (a string, at most 80 characters; an object is ignored). It is
915
+ your word to your own caller, not a channel: keep it short and non-sensitive.
916
+
917
+ ⚠️ **And you can now make the retry safe: pass a `clientKey`.** Two `reshare` calls with the same
918
+ parent, the same recipient and the same `clientKey` return the **same child link** and send **one**
919
+ email — the second answers `delivery: "idempotent"`, which means *"this was already done"*, not
920
+ *"this failed"*. Generate the key before the first call and reuse it on every retry.
921
+
922
+ - The key you send is **fingerprinted on our side**, together with the parent and the recipient. It
923
+ is never stored as you wrote it, and it cannot collide with the system links the host-to-host path
924
+ creates.
925
+ - ⚠️ **This rides on migration `0011`, which you may already have.** No new column was added: the
926
+ table has carried a unique idempotency key since then, and the reshare route had simply never been
927
+ offered it. If `0011` is not applied, `clientKey` is ignored and you get the old behaviour — a
928
+ retry creates a second link. Nothing breaks; the guarantee is what degrades, and `delivery` still
929
+ tells you when you are in doubt.
930
+ - Without a `clientKey`, nothing changes: several links to the same recipient remain possible, which
931
+ is a legitimate thing to want.
932
+
933
+ ⚠️ **Avatars are only loaded from origins the page already serves content from, and everything else
934
+ degrades to initials.** An avatar URL is an `<img>` in the browser of **every other viewer**: an
935
+ arbitrary URL therefore sends each of them — their IP, user agent, the time, the page origin — to
936
+ whoever wrote it. That is not an XSS (the markup is escaped); it is a privacy leak aimed at your
937
+ audience, and an external audit reproduced it on 2026-09-12 against the real chat renderer.
938
+
939
+ Three things changed, and the second one is the one you may notice:
940
+
941
+ - **A participant who is not authenticated no longer supplies an avatar at all.** A proven identity
942
+ replaces what is asserted; an anonymous visitor proves nothing, so the field is dropped and the
943
+ audience sees initials.
944
+ - **A member's avatar must come from your Supabase origin (or be a relative URL).** It arrives from
945
+ your identity provider's metadata, and a provider that lets a user edit that field would hand us
946
+ an arbitrary URL under a proven name. ⚠️ **If your members' avatars live elsewhere — Gravatar, a
947
+ CDN, Google — they will now render as initials.** Serve them from your own storage to get the
948
+ images back. The degradation is visible and reversible; the leak was neither.
949
+ - **The renderer refuses the same URLs again**, because one path never reaches this server: a
950
+ participant can broadcast presence over Realtime straight to the other viewers. No server-side
951
+ barrier can see that, so the check also lives where every path converges — at render time.
952
+ - ⚠️ **`data:` and `blob:` URLs are refused too, deliberately.** They reach no one, but a data URL
953
+ travels inside every message row and every presence broadcast, and one more "harmless" form is
954
+ one more form to reason about at the next audit. A host that stores avatars as data URLs — as an
955
+ offline fallback, say — will see initials, and nothing will say why except this line. (Asked for by
956
+ a host, 13/09: three of its members carry one.)
957
+
958
+ ⚠️ **What your `storage.remove` returns now decides whether a row survives.** It returns a boolean:
959
+ `true` means the object is gone, `false` means it is still there. Until 0.1.163 the retention sweep
960
+ erased the row either way — and since this capability exposes `put` and `remove` but **never
961
+ `list`**, the row is the only path to the object: erasing it stranded the file in the bucket
962
+ permanently. The sweep now **keeps the row** when `remove` returns `false`, and reports it as
963
+ `retenues`. Two consequences for you:
964
+
965
+ - **Do not return `false` for an object that was already absent.** An already-gone object is a
966
+ success for this purpose — returning `false` makes the sweep retain a row forever, waiting for a
967
+ file that does not exist. ⚠️ **The player's own standalone context used to have exactly this bug**,
968
+ found while writing this paragraph: it returned `r.ok`, and Supabase Storage answers an error for a
969
+ missing object, so the fix for lost files would have created permanent retention instead. It now
970
+ treats 404 — and a body naming "not found" — as removed, because what is being asked is *"the
971
+ object is no longer there"*, and it is not there. If you wrap a different provider, do the same.
972
+ - **`retenues > 0` in a retention report means your provider refused a removal**, not that the purge
973
+ is broken. The next pass retries. A row that lingers is recoverable; a file whose only pointer was
974
+ erased is not.
975
+ - ⚠️ **If you provide no `storage.remove` at all, file-bearing rows are retained too — and the report
976
+ says so.** There were three states, not two: `true`, `false`, and *not attempted*. Until 0.1.164 the
977
+ third one let the row go "as before" — so a host providing `put` without `remove` manufactured
978
+ permanently unreachable objects at every sweep, with no counter moving. A host found it by reading
979
+ `retention.js`, not this paragraph, which assumed you provide one. Now: the row stays, `retenues`
980
+ counts it, the report carries `sansRemove: true` (in `dryRun` too, so you can read it before arming
981
+ the sweep), and the missing capability is reported once per process through `errors.capture` with
982
+ `benin: true`. Provide `storage.remove` and the next pass lets them go.
983
+
828
984
  **If you write to the `tts-cache` bucket yourself, write the trace too.** Retention removes an object
829
985
  only when its fingerprint has a row in `doc_tts_objects`, and only the player's own route writes that
830
986
  row. Anything your code puts in that bucket is therefore invisible to the sweep — **permanently**,
package/docs/RETENTION.md CHANGED
@@ -287,8 +287,54 @@ numbers, say so; do not round the sentence.
287
287
  it defers nothing. A host with no PITR and eight daily snapshots has **one** deadline, not two: the
288
288
  age of its oldest snapshot. Take the later of the deadlines that *exist*.
289
289
 
290
+ ## Cleaning up the objects the broken sweep stranded
291
+
292
+ The voice-cache sweep removed nothing until it was fixed (see above), so every voice object it
293
+ "purged" is still in the bucket with its row deleted — unreachable by the product, by construction.
294
+ `tools/orphelins-tts.mjs` exists for exactly that backlog, and for nothing else.
295
+
296
+ ```
297
+ node tools/orphelins-tts.mjs --inspecter [--age-jours=N] [--limite=N]
298
+ node tools/orphelins-tts.mjs --inspecter --supprimer --confirme=<the count from the report>
299
+ ```
300
+
301
+ It reads `SUPABASE_URL` and `SUPABASE_SERVICE_ROLE_KEY` from the environment, and it deliberately
302
+ **steps outside the host contract**: it talks to the Storage API directly to do the one thing the
303
+ contract does not expose — `list`. That is why it is not a guard, runs in no workflow, and contacts
304
+ nothing at all until you pass `--inspecter`.
305
+
306
+ ⚠️ **It cannot tell our orphans from yours, and no measurement can.** An object with no row is one of
307
+ three things: stranded by the broken sweep, a relic from before migration 0021, or **a file your own
308
+ code wrote under the player's naming** — one integrating host reported 908 of those. The tool
309
+ therefore:
310
+
311
+ - **reports by default** and deletes nothing;
312
+ - treats only objects **older than the retention window** as candidates — a recent object with no row
313
+ may be a synthesis whose trace write just failed, and deleting it would erase a file the product is
314
+ about to serve;
315
+ - refuses to act unless you **type the candidate count back** from a report it produced on the
316
+ current state. If the number has changed since you looked, the bucket moved, and that is precisely
317
+ what the barrier is there to tell you;
318
+ - never touches a name that is not `<fingerprint>.mp3` / `.json`, nor an object whose date it cannot
319
+ read — no date means *we do not know*, and we do not delete what we do not know.
320
+
321
+ Read the report before passing `--supprimer`. If your own code writes to this bucket, write the trace
322
+ too (see *Voice* in `docs/HOST-CONTRACT.md`) — that is what makes your objects distinguishable, and
323
+ what keeps them out of this tool's candidate list.
324
+
290
325
  ## Limits stated rather than left unsaid
291
326
 
327
+ - ⚠️ **Until the next release the voice-cache sweep removed nothing at all, in the reference host context,
328
+ and the paragraph below is what hid it.** `storage.remove` carries an allow-list — a last barrier
329
+ before a DELETE with the service-role key — and it named only `present-attachments`. `tts-cache`
330
+ was refused **before any network call**: every removal returned `false`, the trace row was erased
331
+ anyway, and the object stayed in a public bucket with no path left to it. That is precisely the
332
+ harm migration 0021 was written to make repairable, realised at 100%.
333
+ ⚠️ **What concealed it is a true explanation.** Those refusals were counted in `fichiersErreur`,
334
+ which the next bullet attributes — correctly — to alignment files that legitimately do not exist.
335
+ A correct account of the noise is the best place to hide a signal. Found on 2026-09-12 while
336
+ writing the documentation for a *different* fix on the same path; the allow-list now names both
337
+ buckets the sweep must reach, and nothing else.
292
338
  - ⚠️ **`fichiersErreur` can be high without any removal having failed.** Each fingerprint has two
293
339
  objects, and the alignment `.json` is not always there — the provider does not always return one.
294
340
  Measured on an integrating host's bucket on 27/08: **552 `.mp3` for 356 `.json`**, so 196 audio
@@ -312,6 +358,34 @@ age of its oldest snapshot. Take the later of the deadlines that *exist*.
312
358
  - **The dryRun report is complete for presentations**: `messagesExaminees`, `presencesExaminees`
313
359
  and `fichiersCandidats` say what the REAL purge would do — same selection walk, no-op deletion,
314
360
  `efface.* = 0`.
361
+ - ⚠️ **A row is never erased above a file that resisted, and `retenues` counts the rows kept.**
362
+ Until 0.1.163 the deletion was unconditional: a failed `storage.remove` still erased the row — and
363
+ with it the only path to the object. The `storage` capability exposes `put` and `remove`, **never
364
+ `list`**, which is the very argument that justified migration 0021: with no row there is nothing
365
+ to walk, so the object stays in the bucket **permanently**, beyond the reach of any sweep. Found
366
+ by an external audit on 2026-09-12, reproduced before being fixed. A retained row is recoverable —
367
+ the next pass retries it; a lost file is not. Read `retenues > 0` as *"the storage provider refused
368
+ a removal; look at it"*, not as a purge failure.
369
+ - ⚠️ **And "not attempted" is the third state, found by a host on 0.1.164.** A host providing `put`
370
+ without `remove` had no capability to refuse with: `retirerFichier` answered `null` and the row
371
+ went "as before" — the irreversible loss above, through the other door. Now a missing
372
+ `storage.remove` retains every file-bearing row, counts it in `retenues`, sets `sansRemove: true` on
373
+ the result (also in `dryRun`), and is reported once per process (`errors.capture`, `benin: true`).
374
+ A row without a file still goes.
375
+ - ⚠️ **The alignment `.json` never retains anything — only the audio does.** A third of fingerprints
376
+ legitimately have no companion (see the 552/356 measurement above); gating the row on both objects
377
+ would hold a third of the cache forever to protect files that do not exist. So the `.mp3` alone
378
+ decides, and a missing `.json` is still **counted** in `fichiersErreur` rather than masked.
379
+ - ⚠️ **A presentation is not deleted above a retained message.** The condition used to require only
380
+ that nothing was `tronque`; "retained" is a second way of not having gone, and without it the fix
381
+ above would have reopened the parent/child orphan an earlier audit had closed.
382
+ - ⚠️ **The delete carries the purge predicate, not just the identifiers.** The sweep selects by date
383
+ and used to delete by identifier alone: a heartbeat landing between the two requests refreshed a
384
+ row that was then erased anyway, judged on a date that was no longer its own. Same audit, same
385
+ day, also reproduced. PostgREST applies every predicate in the URL at delete time, so replaying
386
+ the original filter makes the row be judged on its state **at that instant**. The race window does
387
+ not disappear — it stops being destructive. `select=` returns only what actually went, so the
388
+ counts stay honest when the database spares a row at the last moment.
315
389
  - **The purge advances in BOUNDED BATCHES** (200 rows, a ceiling of 5000 per table and 500
316
390
  presentations per run): it selects a batch of identifiers, deletes them with `id=in.(…)`, and
317
391
  starts again. The report (`r.rapport`) carries, per table: `examinees`, `supprimees`, `tronque`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "discovery-media-player",
3
- "version": "0.1.163",
3
+ "version": "0.1.165",
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",