discovery-media-player 0.1.162 → 0.1.164

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.
@@ -41,6 +41,72 @@ function sansBarreFinale(valeur) {
41
41
  }
42
42
 
43
43
  /** Client REST minimal (PostgREST). Absent de configuration ⇒ chaque appel échoue franchement. */
44
+ /**
45
+ * ⚠️ UN SEUL ENDROIT QUI BORNE, PARCE QU'IL Y EN AVAIT UN SUR QUATRE.
46
+ *
47
+ * `db.request` abandonnait déjà après un délai, avec le raisonnement écrit à côté : sans signal, un
48
+ * service qui accepte la connexion et ne répond plus immobilise la requête, sa socket ET la place
49
+ * d'admission jusqu'à ce que la plateforme tue la fonction. Trois autres appels — suppression
50
+ * Storage, signature d'envoi, vérification de jeton — partaient nus. Un audit externe l'a mesuré le
51
+ * 11/09 en remplaçant `fetch` : `REST hasSignal true`, les trois autres `false`.
52
+ *
53
+ * ⚠️ CE N'EST PAS UN CONTOURNEMENT D'AUTORISATION : ces chemins refusent en cas d'échec, ils rendent
54
+ * `null` ou `false`. Le risque est de DISPONIBILITÉ — sous concurrence, des sockets, de la mémoire
55
+ * et des exécutions serverless retenues par un tiers lent, y compris pour les purges et les
56
+ * consultations protégées.
57
+ *
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 s'AJOUTE au
60
+ * plancher — voir `composerSignaux` : le premier des deux qui parle gagne.
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
+
94
+ function fetchBorne(cible, options = {}, delaiMs) {
95
+ const signal = composerSignaux(options.signal, delaiMs);
96
+ return fetch(cible, { ...options, ...(signal ? { signal } : {}) });
97
+ }
98
+
99
+ /**
100
+ * Les délais, nommés plutôt qu'écrits en clair sur l'appel. ⚠️ ILS NE SONT PAS ÉGAUX, ET C'EST LE
101
+ * SUJET : une vérification de jeton est sur le chemin d'une réponse qu'un visiteur attend, un
102
+ * transfert vers Storage ne l'est pas. Un délai unique ferait patienter le visiteur au rythme du
103
+ * service le plus lent.
104
+ */
105
+ const DELAI_AUTH_MS = 5000;
106
+ const DELAI_ROUTE_HOTE_MS = 4000;
107
+ const DELAI_STOCKAGE_MS = 15000;
108
+ const DELAI_BASE_MS = 15000;
109
+
44
110
  function creerDb(env) {
45
111
  const url = sansBarreFinale(env.SUPABASE_URL);
46
112
  const cle = String(env.SUPABASE_SERVICE_ROLE_KEY || "");
@@ -67,12 +133,10 @@ function creerDb(env) {
67
133
  // transforme un ralentissement en refus général. Une course `Promise.race` ne suffirait pas —
68
134
  // elle rendrait la main sans ANNULER le fetch, donc sans libérer quoi que ce soit. L'hôte peut
69
135
  // fournir son propre `signal` (opérations longues : purges, transferts). (Audit externe.)
70
- const delai = Number(options.timeoutMs) > 0 ? Number(options.timeoutMs) : 15000;
71
- const signal = options.signal || (typeof AbortSignal !== "undefined" && AbortSignal.timeout
72
- ? AbortSignal.timeout(delai) : undefined);
73
- const r = await fetch(`${url}/rest/v1/${chemin}`, {
136
+ const delai = Number(options.timeoutMs) > 0 ? Number(options.timeoutMs) : DELAI_BASE_MS;
137
+ const r = await fetchBorne(`${url}/rest/v1/${chemin}`, {
74
138
  method: methode,
75
- ...(signal ? { signal } : {}),
139
+ ...(options.signal ? { signal: options.signal } : {}),
76
140
  headers: {
77
141
  apikey: cle,
78
142
  Authorization: `Bearer ${cle}`,
@@ -80,7 +144,7 @@ function creerDb(env) {
80
144
  ...(options.headers || {}),
81
145
  },
82
146
  body: options.body ? JSON.stringify(options.body) : undefined,
83
- });
147
+ }, delai);
84
148
  if (!r.ok) {
85
149
  // ⚠️ LE CORPS DIT POURQUOI, LE CODE NE DIT QUE COMBIEN. Un « 400 » nu a coûté un aller-retour
86
150
  // de forge complet pour apprendre ce que PostgREST avait écrit dans sa réponse depuis le
@@ -166,15 +230,14 @@ async function appelHote(url, secret, corps, errors) {
166
230
  // perdu exactement ce temps-là.
167
231
  const signaler = (quoi) => { try { errors && errors.capture(new Error(`route hôte : ${quoi}`), { url }); } catch { /* jamais bloquant */ } };
168
232
  try {
169
- const r = await fetch(url, {
233
+ const r = await fetchBorne(url, {
170
234
  method: "POST",
171
235
  headers: {
172
236
  "Content-Type": "application/json",
173
237
  ...(secret ? { "x-player-fetch-secret": secret } : {}),
174
238
  },
175
239
  body: JSON.stringify(corps),
176
- signal: AbortSignal.timeout(4000), // une décision qui tarde est une décision absente
177
- });
240
+ }, DELAI_ROUTE_HOTE_MS); // une décision qui tarde est une décision absente
178
241
  if (!r.ok) { signaler(`réponse ${r.status}`); return null; }
179
242
  const d = await r.json().catch(() => null);
180
243
  if (!d || typeof d !== "object") { signaler("réponse illisible (JSON attendu)"); return null; }
@@ -211,13 +274,33 @@ async function appelHote(url, secret, corps, errors) {
211
274
  * base. Y adosser un compteur partagé ferait payer à la garde le prix qu'on venait d'épargner à ce
212
275
  * qu'elle garde. Sur ce chemin, la protection réelle est le cache, pas le compteur.
213
276
  *
214
- * ⚠️ LE COMPTE PARTAGÉ N'EST PAS ATOMIQUE. PostgREST ne sait pas exprimer « incrémente » : c'est une
215
- * lecture puis une écriture. Deux instances peuvent donc lire la même valeur et n'en écrire qu'une —
216
- * le compteur SOUS-estime sous forte concurrence. Pour une limite de débit, sous-estimer signifie
217
- * laisser passer un peu plus, jamais refuser à tort. Le dire vaut mieux que laisser croire à une
218
- * 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.
219
302
  */
220
- function creerLimites(db, journal) {
303
+ function creerLimites(db, journal, horloge = () => Date.now()) {
221
304
  const seaux = new Map();
222
305
  const PREFIXES_LOCAUX = ["pread:"];
223
306
  let partageDisponible = null; // null = pas encore demandé
@@ -231,7 +314,7 @@ function creerLimites(db, journal) {
231
314
  // anti-inondation, et c'est le compromis explicitement recommandé.
232
315
  const PLAFOND_CLES = 5000;
233
316
  function localAutorise(cle, max, fenetreSecondes) {
234
- const maintenant = Date.now();
317
+ const maintenant = horloge();
235
318
  const fenetreMs = fenetreSecondes * 1000;
236
319
  let e = seaux.get(cle);
237
320
  if (!e || maintenant - e.debut >= fenetreMs) e = { debut: maintenant, compte: 0 };
@@ -306,8 +389,14 @@ function creerLimites(db, journal) {
306
389
  // passer ; dans les deux cas on le dit, en NOMMANT le fichier à appliquer. C'est la règle
307
390
  // du chemin de migration : dégrader, jamais casser, et ne jamais dégrader en silence.
308
391
  prevenirUneFois(
309
- "compteurs de débit non atomiques : appliquez supabase/migrations/0004-limites-atomiques.sql. "
310
- + "Sans elle, plusieurs requêtes simultanées peuvent dépasser la limite ensemble. "
392
+ // ⚠️ CE MESSAGE NOMMAIT UN MODE QUI N'EXISTE PAS. Il disait « compteurs non atomiques »,
393
+ // ce qui décrit un comptage partagé plus faible. Or ici il n'y a PLUS de comptage partagé
394
+ // du tout : le `return true` ci-dessous laisse passer, et seul l'étage local subsiste.
395
+ // Dire « non atomique » laisse croire qu'un plafond d'instance tient encore, en moins
396
+ // précis. Trouvé par un audit externe le 11/09, avec la contradiction jumelle du contrat.
397
+ "compteur de débit PARTAGÉ INDISPONIBLE : appliquez supabase/migrations/0004-limites-atomiques.sql. "
398
+ + "Sans elle il ne reste que le compteur LOCAL, par processus — une limite de 120/h en "
399
+ + "autorise 120 PAR EXÉCUTION. Ce n'est pas un comptage partagé dégradé, c'est aucun. "
311
400
  + "(" + ((erreur && erreur.message) || erreur) + ")",
312
401
  );
313
402
  return true;
@@ -352,16 +441,48 @@ function createStandaloneContext(env = process.env) {
352
441
  // ⚠️ DERNIÈRE BARRIÈRE avant un DELETE à la clé service_role (P1 huitième audit). Bucket en
353
442
  // liste blanche, et refus de toute traversée — chaque segment sur l'alphabet des chemins
354
443
  // signés. `fetch` normalise `..` : un chemin non validé sortirait du bucket visé.
355
- 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;
356
461
  const segs = String(chemin).split("/");
357
462
  for (const seg of segs) {
358
463
  if (seg === "" || seg === "." || seg === ".." || !/^[A-Za-z0-9._-]+$/.test(seg)) return false;
359
464
  }
360
465
  try {
361
- const r = await fetch(`${base}/storage/v1/object/${encodeURIComponent(bucket)}/${segs.map(encodeURIComponent).join("/")}`, {
466
+ const r = await fetchBorne(`${base}/storage/v1/object/${encodeURIComponent(bucket)}/${segs.map(encodeURIComponent).join("/")}`, {
362
467
  method: "DELETE", headers: { apikey: cle, Authorization: `Bearer ${cle}` },
363
- });
364
- return r.ok;
468
+ }, DELAI_STOCKAGE_MS);
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);
365
486
  } catch { return false; }
366
487
  },
367
488
 
@@ -382,11 +503,11 @@ function createStandaloneContext(env = process.env) {
382
503
  const cle = String(env.SUPABASE_SERVICE_ROLE_KEY || "");
383
504
  if (!base || !cle || !bucket || !chemin) return null;
384
505
  try {
385
- const r = await fetch(`${base}/storage/v1/object/upload/sign/${bucket}/${chemin}`, {
506
+ const r = await fetchBorne(`${base}/storage/v1/object/upload/sign/${bucket}/${chemin}`, {
386
507
  method: "POST",
387
508
  headers: { apikey: cle, Authorization: `Bearer ${cle}`, "Content-Type": "application/json" },
388
509
  body: "{}",
389
- });
510
+ }, DELAI_STOCKAGE_MS);
390
511
  if (!r.ok) return null;
391
512
  const d = await r.json().catch(() => null);
392
513
  const url = d && d.url;
@@ -474,9 +595,9 @@ function createStandaloneContext(env = process.env) {
474
595
  }
475
596
  if (!jeton || !url || !cle) return null;
476
597
  try {
477
- const r = await fetch(`${url}/auth/v1/user`, {
598
+ const r = await fetchBorne(`${url}/auth/v1/user`, {
478
599
  headers: { apikey: cle, Authorization: `Bearer ${jeton}` },
479
- });
600
+ }, DELAI_AUTH_MS);
480
601
  return r.ok ? await r.json() : null;
481
602
  } catch { return null; }
482
603
  },
@@ -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");
@@ -441,10 +441,25 @@ Two deliberate exceptions, both written next to the code:
441
441
  with a shared counter would make the guard pay the price we had just spared the thing it guards.
442
442
  On that path the real protection is the cache, not the counter.
443
443
 
444
- ⚠️ **The shared count is not atomic.** PostgREST cannot express "increment": it is a read then a
445
- write. Two instances can read the same value and write one. The counter therefore **under**-estimates
446
- under heavy concurrency — it lets a little more through, never refuses wrongly. Said plainly rather
447
- than implying a precision we do not have.
444
+ ⚠️ **The paragraph that used to stand here said the shared count was not atomic. That has been
445
+ false since `0004`, and it contradicted the paragraph above it in this same document.** It was
446
+ written before the database function existed and outlived what it described. An external audit found
447
+ it on 2026-09-11; it is corrected rather than quietly deleted, because a host who read it may have
448
+ built a compensating control they do not need.
449
+
450
+ Here is the matrix, stated once, so nothing has to be inferred:
451
+
452
+ | Installed | What actually protects you |
453
+ |---|---|
454
+ | `0003` **and** `0004` | fast local refusal **+ shared atomic counter** — the limit means what it says for the instance |
455
+ | `0003` without `0004` | **local only.** The function is absent, PostgREST answers 404, and the shared stage lets through after warning by name |
456
+ | neither | **local only**, in memory, warned by name |
457
+ | keys prefixed `pread:` | local only, **deliberately** — see the exception above |
458
+
459
+ ⚠️ **Read the second row carefully: without `0004` the shared stage does not count less well, it
460
+ does not count at all.** The degradation is to the local counter, not to a weaker shared one. The
461
+ warning the player emits used to say *"non-atomic rate counters"*, which named a mode that does not
462
+ exist; it now says the shared counter is unavailable.
448
463
 
449
464
  ## The three things a host implements
450
465
 
@@ -793,7 +808,10 @@ claimed the opposite. Two consequences you must act on:
793
808
  - **`bot-tts` now requires a `sessionId`**, bound to the requested `slug`, and the text must match
794
809
  something the assistant said in that session. The player reads `listMessages(sessionId)` and
795
810
  treats a message as the assistant's when its `role` is `bot`, `assistant` or `ai`, taking the text
796
- from `text` or `content`. **Anything it cannot read counts as "not said"** — an unrecognised shape
811
+ from `text`, `content` or `body` — the same three fields, in the same order, as everywhere else in
812
+ this document. ⚠️ **This sentence named only the first two**, and an external audit found the
813
+ mismatch on 2026-09-11: a host whose messages carry `body` and nothing else would have read here
814
+ that its assistant never speaks, while the code reads it perfectly well. **Anything it cannot read counts as "not said"** — an unrecognised shape
797
815
  yields an empty set and every request is refused. On the one route that spends money, *"I could
798
816
  not verify"* must read as **no**, never as *go ahead*.
799
817
 
@@ -807,6 +825,75 @@ was a correct `bot`, and the reader would have returned an empty string for ever
807
825
  set, so every request refused, on a perfectly correct integration. If your field is none of those
808
826
  three, tell us and we widen the list. The field name carries no security; the **role** filter does.
809
827
 
828
+ ⚠️ **`reshare` now answers with a three-state `delivery`, and the state you must handle is
829
+ `"unknown"`.** The response used to carry a single `sent` boolean, which collapsed three different
830
+ outcomes: your mail path declined, *we* declined, or **the call failed without us learning what your
831
+ side did**. Only the third is dangerous — if your host really sent the message and then answered too
832
+ late, a caller reading `sent: false` retries, creating a **second child link and a second email**.
833
+
834
+ | `delivery` | what happened | what to do |
835
+ |---|---|---|
836
+ | `"sent"` | your mail path reported success | nothing |
837
+ | `"refused"` | a decision was made — yours or ours; `sendRefused` names it | surface the reason; retrying will refuse again |
838
+ | `"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 |
839
+ | `"not-requested"` | `send` was falsy | nothing |
840
+
841
+ `sent` is unchanged for integrations already reading it.
842
+
843
+ ⚠️ **And you can now make the retry safe: pass a `clientKey`.** Two `reshare` calls with the same
844
+ parent, the same recipient and the same `clientKey` return the **same child link** and send **one**
845
+ email — the second answers `delivery: "idempotent"`, which means *"this was already done"*, not
846
+ *"this failed"*. Generate the key before the first call and reuse it on every retry.
847
+
848
+ - The key you send is **fingerprinted on our side**, together with the parent and the recipient. It
849
+ is never stored as you wrote it, and it cannot collide with the system links the host-to-host path
850
+ creates.
851
+ - ⚠️ **This rides on migration `0011`, which you may already have.** No new column was added: the
852
+ table has carried a unique idempotency key since then, and the reshare route had simply never been
853
+ offered it. If `0011` is not applied, `clientKey` is ignored and you get the old behaviour — a
854
+ retry creates a second link. Nothing breaks; the guarantee is what degrades, and `delivery` still
855
+ tells you when you are in doubt.
856
+ - Without a `clientKey`, nothing changes: several links to the same recipient remain possible, which
857
+ is a legitimate thing to want.
858
+
859
+ ⚠️ **Avatars are only loaded from origins the page already serves content from, and everything else
860
+ degrades to initials.** An avatar URL is an `<img>` in the browser of **every other viewer**: an
861
+ arbitrary URL therefore sends each of them — their IP, user agent, the time, the page origin — to
862
+ whoever wrote it. That is not an XSS (the markup is escaped); it is a privacy leak aimed at your
863
+ audience, and an external audit reproduced it on 2026-09-12 against the real chat renderer.
864
+
865
+ Three things changed, and the second one is the one you may notice:
866
+
867
+ - **A participant who is not authenticated no longer supplies an avatar at all.** A proven identity
868
+ replaces what is asserted; an anonymous visitor proves nothing, so the field is dropped and the
869
+ audience sees initials.
870
+ - **A member's avatar must come from your Supabase origin (or be a relative URL).** It arrives from
871
+ your identity provider's metadata, and a provider that lets a user edit that field would hand us
872
+ an arbitrary URL under a proven name. ⚠️ **If your members' avatars live elsewhere — Gravatar, a
873
+ CDN, Google — they will now render as initials.** Serve them from your own storage to get the
874
+ images back. The degradation is visible and reversible; the leak was neither.
875
+ - **The renderer refuses the same URLs again**, because one path never reaches this server: a
876
+ participant can broadcast presence over Realtime straight to the other viewers. No server-side
877
+ barrier can see that, so the check also lives where every path converges — at render time.
878
+
879
+ ⚠️ **What your `storage.remove` returns now decides whether a row survives.** It returns a boolean:
880
+ `true` means the object is gone, `false` means it is still there. Until 0.1.163 the retention sweep
881
+ erased the row either way — and since this capability exposes `put` and `remove` but **never
882
+ `list`**, the row is the only path to the object: erasing it stranded the file in the bucket
883
+ permanently. The sweep now **keeps the row** when `remove` returns `false`, and reports it as
884
+ `retenues`. Two consequences for you:
885
+
886
+ - **Do not return `false` for an object that was already absent.** An already-gone object is a
887
+ success for this purpose — returning `false` makes the sweep retain a row forever, waiting for a
888
+ file that does not exist. ⚠️ **The player's own standalone context used to have exactly this bug**,
889
+ found while writing this paragraph: it returned `r.ok`, and Supabase Storage answers an error for a
890
+ missing object, so the fix for lost files would have created permanent retention instead. It now
891
+ treats 404 — and a body naming "not found" — as removed, because what is being asked is *"the
892
+ object is no longer there"*, and it is not there. If you wrap a different provider, do the same.
893
+ - **`retenues > 0` in a retention report means your provider refused a removal**, not that the purge
894
+ is broken. The next pass retries. A row that lingers is recoverable; a file whose only pointer was
895
+ erased is not.
896
+
810
897
  **If you write to the `tts-cache` bucket yourself, write the trace too.** Retention removes an object
811
898
  only when its fingerprint has a row in `doc_tts_objects`, and only the player's own route writes that
812
899
  row. Anything your code puts in that bucket is therefore invisible to the sweep — **permanently**,
package/docs/RETENTION.md CHANGED
@@ -131,8 +131,11 @@ it is the trace. The row records a fingerprint and a date and nothing else: writ
131
131
  would recreate, inside the database, whatever personal data the bucket may already hold — and make
132
132
  it queryable, which is strictly worse than not having it.
133
133
 
134
- ⚠️ **A visitor chooses what goes in.** `bot-tts` accepts the caller's text, so a unique text leaves
135
- an MP3 and a JSON in a public bucket. The grouping and ceilings added in 0.1.140 bound the cost per
134
+ ⚠️ **This paragraph used to say a visitor chooses what goes in. That stopped being true** when
135
+ `bot-tts` began confronting the text with what the assistant actually said in that session — an
136
+ external audit found the stale claim on 2026-09-11. The caller **proposes** a text; only a text the
137
+ assistant already spoke is accepted. What still holds is the consequence: each *distinct accepted*
138
+ text leaves an MP3 and a JSON in a public bucket. The grouping and ceilings added in 0.1.140 bound the cost per
136
139
  hour; only this window bounds the **duration**.
137
140
 
138
141
  ## Purging the reader IP and User-Agent (migrations 0026 and 0027)
@@ -284,8 +287,54 @@ numbers, say so; do not round the sentence.
284
287
  it defers nothing. A host with no PITR and eight daily snapshots has **one** deadline, not two: the
285
288
  age of its oldest snapshot. Take the later of the deadlines that *exist*.
286
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
+
287
325
  ## Limits stated rather than left unsaid
288
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.
289
338
  - ⚠️ **`fichiersErreur` can be high without any removal having failed.** Each fingerprint has two
290
339
  objects, and the alignment `.json` is not always there — the provider does not always return one.
291
340
  Measured on an integrating host's bucket on 27/08: **552 `.mp3` for 356 `.json`**, so 196 audio
@@ -309,6 +358,28 @@ age of its oldest snapshot. Take the later of the deadlines that *exist*.
309
358
  - **The dryRun report is complete for presentations**: `messagesExaminees`, `presencesExaminees`
310
359
  and `fichiersCandidats` say what the REAL purge would do — same selection walk, no-op deletion,
311
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
+ - ⚠️ **The alignment `.json` never retains anything — only the audio does.** A third of fingerprints
370
+ legitimately have no companion (see the 552/356 measurement above); gating the row on both objects
371
+ would hold a third of the cache forever to protect files that do not exist. So the `.mp3` alone
372
+ decides, and a missing `.json` is still **counted** in `fichiersErreur` rather than masked.
373
+ - ⚠️ **A presentation is not deleted above a retained message.** The condition used to require only
374
+ that nothing was `tronque`; "retained" is a second way of not having gone, and without it the fix
375
+ above would have reopened the parent/child orphan an earlier audit had closed.
376
+ - ⚠️ **The delete carries the purge predicate, not just the identifiers.** The sweep selects by date
377
+ and used to delete by identifier alone: a heartbeat landing between the two requests refreshed a
378
+ row that was then erased anyway, judged on a date that was no longer its own. Same audit, same
379
+ day, also reproduced. PostgREST applies every predicate in the URL at delete time, so replaying
380
+ the original filter makes the row be judged on its state **at that instant**. The race window does
381
+ not disappear — it stops being destructive. `select=` returns only what actually went, so the
382
+ counts stay honest when the database spares a row at the last moment.
312
383
  - **The purge advances in BOUNDED BATCHES** (200 rows, a ceiling of 5000 per table and 500
313
384
  presentations per run): it selects a batch of identifiers, deletes them with `id=in.(…)`, and
314
385
  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.162",
3
+ "version": "0.1.164",
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",