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.
- package/context/standalone.js +147 -26
- package/context/storage.js +23 -2
- package/docs/HOST-CONTRACT.md +92 -5
- package/docs/RETENTION.md +73 -2
- package/package.json +1 -1
- package/server/browser.generated.js +1 -1
- package/server/constantes-presentation.js +27 -0
- package/server/gabarit-live.js +11 -1
- package/server/mesures.js +26 -0
- package/server/page-visionneuse.js +109 -21
- package/server/presentations.js +49 -3
- package/server/retention.js +73 -13
- package/server/routes-direct.js +14 -2
- package/server/routes-liens.js +31 -3
- package/server/schema.js +5 -2
- package/server/shares.js +58 -3
- package/supabase/migrations/0004-limites-atomiques.sql +7 -3
package/context/standalone.js
CHANGED
|
@@ -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) :
|
|
71
|
-
const
|
|
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
|
|
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
|
-
|
|
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.
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
*
|
|
218
|
-
*
|
|
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 =
|
|
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
|
-
|
|
310
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
},
|
package/context/storage.js
CHANGED
|
@@ -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
|
|
346
|
-
|
|
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");
|
package/docs/HOST-CONTRACT.md
CHANGED
|
@@ -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
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
than
|
|
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 `
|
|
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
|
-
⚠️ **
|
|
135
|
-
|
|
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.
|
|
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",
|