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 +42 -7
- package/context/standalone.js +103 -13
- package/context/storage.js +23 -2
- package/docs/HOST-CONTRACT.md +156 -0
- package/docs/RETENTION.md +74 -0
- package/package.json +1 -1
- package/server/browser.generated.js +1 -1
- package/server/gabarit-agent.js +1 -0
- package/server/gabarit-live.js +34 -15
- package/server/handler.js +84 -13
- package/server/page-visionneuse.js +160 -21
- package/server/presentations.js +45 -2
- package/server/retention.js +96 -15
- package/server/routes-direct.js +14 -2
- package/server/routes-liens.js +52 -4
- package/server/routes-visiteur.js +53 -1
- package/server/shares.js +58 -3
- package/supabase/migrations/0004-limites-atomiques.sql +7 -3
- package/types/context.d.ts +8 -0
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
|
-
|
|
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.
|
|
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 {
|
|
218
|
-
catch {
|
|
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", () =>
|
|
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 };
|
package/context/standalone.js
CHANGED
|
@@ -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
|
|
60
|
-
*
|
|
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.
|
|
247
|
-
*
|
|
248
|
-
*
|
|
249
|
-
*
|
|
250
|
-
*
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
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
|
@@ -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.
|
|
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",
|