discovery-media-player 0.1.162 → 0.1.163

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,41 @@ 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 a priorité —
60
+ * un hôte qui borne lui-même une opération longue n'est pas écrasé.
61
+ */
62
+ function fetchBorne(cible, options = {}, delaiMs) {
63
+ const signal = options.signal
64
+ || (typeof AbortSignal !== "undefined" && AbortSignal.timeout ? AbortSignal.timeout(delaiMs) : undefined);
65
+ return fetch(cible, { ...options, ...(signal ? { signal } : {}) });
66
+ }
67
+
68
+ /**
69
+ * Les délais, nommés plutôt qu'écrits en clair sur l'appel. ⚠️ ILS NE SONT PAS ÉGAUX, ET C'EST LE
70
+ * SUJET : une vérification de jeton est sur le chemin d'une réponse qu'un visiteur attend, un
71
+ * transfert vers Storage ne l'est pas. Un délai unique ferait patienter le visiteur au rythme du
72
+ * service le plus lent.
73
+ */
74
+ const DELAI_AUTH_MS = 5000;
75
+ const DELAI_ROUTE_HOTE_MS = 4000;
76
+ const DELAI_STOCKAGE_MS = 15000;
77
+ const DELAI_BASE_MS = 15000;
78
+
44
79
  function creerDb(env) {
45
80
  const url = sansBarreFinale(env.SUPABASE_URL);
46
81
  const cle = String(env.SUPABASE_SERVICE_ROLE_KEY || "");
@@ -67,12 +102,10 @@ function creerDb(env) {
67
102
  // transforme un ralentissement en refus général. Une course `Promise.race` ne suffirait pas —
68
103
  // elle rendrait la main sans ANNULER le fetch, donc sans libérer quoi que ce soit. L'hôte peut
69
104
  // 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}`, {
105
+ const delai = Number(options.timeoutMs) > 0 ? Number(options.timeoutMs) : DELAI_BASE_MS;
106
+ const r = await fetchBorne(`${url}/rest/v1/${chemin}`, {
74
107
  method: methode,
75
- ...(signal ? { signal } : {}),
108
+ ...(options.signal ? { signal: options.signal } : {}),
76
109
  headers: {
77
110
  apikey: cle,
78
111
  Authorization: `Bearer ${cle}`,
@@ -80,7 +113,7 @@ function creerDb(env) {
80
113
  ...(options.headers || {}),
81
114
  },
82
115
  body: options.body ? JSON.stringify(options.body) : undefined,
83
- });
116
+ }, delai);
84
117
  if (!r.ok) {
85
118
  // ⚠️ LE CORPS DIT POURQUOI, LE CODE NE DIT QUE COMBIEN. Un « 400 » nu a coûté un aller-retour
86
119
  // de forge complet pour apprendre ce que PostgREST avait écrit dans sa réponse depuis le
@@ -166,15 +199,14 @@ async function appelHote(url, secret, corps, errors) {
166
199
  // perdu exactement ce temps-là.
167
200
  const signaler = (quoi) => { try { errors && errors.capture(new Error(`route hôte : ${quoi}`), { url }); } catch { /* jamais bloquant */ } };
168
201
  try {
169
- const r = await fetch(url, {
202
+ const r = await fetchBorne(url, {
170
203
  method: "POST",
171
204
  headers: {
172
205
  "Content-Type": "application/json",
173
206
  ...(secret ? { "x-player-fetch-secret": secret } : {}),
174
207
  },
175
208
  body: JSON.stringify(corps),
176
- signal: AbortSignal.timeout(4000), // une décision qui tarde est une décision absente
177
- });
209
+ }, DELAI_ROUTE_HOTE_MS); // une décision qui tarde est une décision absente
178
210
  if (!r.ok) { signaler(`réponse ${r.status}`); return null; }
179
211
  const d = await r.json().catch(() => null);
180
212
  if (!d || typeof d !== "object") { signaler("réponse illisible (JSON attendu)"); return null; }
@@ -306,8 +338,14 @@ function creerLimites(db, journal) {
306
338
  // passer ; dans les deux cas on le dit, en NOMMANT le fichier à appliquer. C'est la règle
307
339
  // du chemin de migration : dégrader, jamais casser, et ne jamais dégrader en silence.
308
340
  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. "
341
+ // ⚠️ CE MESSAGE NOMMAIT UN MODE QUI N'EXISTE PAS. Il disait « compteurs non atomiques »,
342
+ // ce qui décrit un comptage partagé plus faible. Or ici il n'y a PLUS de comptage partagé
343
+ // du tout : le `return true` ci-dessous laisse passer, et seul l'étage local subsiste.
344
+ // Dire « non atomique » laisse croire qu'un plafond d'instance tient encore, en moins
345
+ // précis. Trouvé par un audit externe le 11/09, avec la contradiction jumelle du contrat.
346
+ "compteur de débit PARTAGÉ INDISPONIBLE : appliquez supabase/migrations/0004-limites-atomiques.sql. "
347
+ + "Sans elle il ne reste que le compteur LOCAL, par processus — une limite de 120/h en "
348
+ + "autorise 120 PAR EXÉCUTION. Ce n'est pas un comptage partagé dégradé, c'est aucun. "
311
349
  + "(" + ((erreur && erreur.message) || erreur) + ")",
312
350
  );
313
351
  return true;
@@ -358,9 +396,9 @@ function createStandaloneContext(env = process.env) {
358
396
  if (seg === "" || seg === "." || seg === ".." || !/^[A-Za-z0-9._-]+$/.test(seg)) return false;
359
397
  }
360
398
  try {
361
- const r = await fetch(`${base}/storage/v1/object/${encodeURIComponent(bucket)}/${segs.map(encodeURIComponent).join("/")}`, {
399
+ const r = await fetchBorne(`${base}/storage/v1/object/${encodeURIComponent(bucket)}/${segs.map(encodeURIComponent).join("/")}`, {
362
400
  method: "DELETE", headers: { apikey: cle, Authorization: `Bearer ${cle}` },
363
- });
401
+ }, DELAI_STOCKAGE_MS);
364
402
  return r.ok;
365
403
  } catch { return false; }
366
404
  },
@@ -382,11 +420,11 @@ function createStandaloneContext(env = process.env) {
382
420
  const cle = String(env.SUPABASE_SERVICE_ROLE_KEY || "");
383
421
  if (!base || !cle || !bucket || !chemin) return null;
384
422
  try {
385
- const r = await fetch(`${base}/storage/v1/object/upload/sign/${bucket}/${chemin}`, {
423
+ const r = await fetchBorne(`${base}/storage/v1/object/upload/sign/${bucket}/${chemin}`, {
386
424
  method: "POST",
387
425
  headers: { apikey: cle, Authorization: `Bearer ${cle}`, "Content-Type": "application/json" },
388
426
  body: "{}",
389
- });
427
+ }, DELAI_STOCKAGE_MS);
390
428
  if (!r.ok) return null;
391
429
  const d = await r.json().catch(() => null);
392
430
  const url = d && d.url;
@@ -474,9 +512,9 @@ function createStandaloneContext(env = process.env) {
474
512
  }
475
513
  if (!jeton || !url || !cle) return null;
476
514
  try {
477
- const r = await fetch(`${url}/auth/v1/user`, {
515
+ const r = await fetchBorne(`${url}/auth/v1/user`, {
478
516
  headers: { apikey: cle, Authorization: `Bearer ${jeton}` },
479
- });
517
+ }, DELAI_AUTH_MS);
480
518
  return r.ok ? await r.json() : null;
481
519
  } catch { return null; }
482
520
  },
@@ -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
 
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)
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.163",
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",
@@ -0,0 +1,27 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright © 2026 3D Discovery
3
+ // UNE FEUILLE, ET C'EST TOUT CE QU'ELLE EST — ELLE N'IMPORTE RIEN, DONC ELLE NE PEUT FERMER AUCUN CYCLE.
4
+ //
5
+ // ⚠️ CE FICHIER NAÎT D'UN CYCLE MESURÉ, PAS D'UN GOÛT POUR LE RANGEMENT. Un audit externe a relevé
6
+ // le 11/09 le seul cycle du graphe serveur : `schema.js` ↔ `presentations.js`, par cinq `require()`
7
+ // DYNAMIQUES croisés — écrits dans le corps des fonctions précisément parce qu'un `require` en tête
8
+ // aurait rendu le cycle visible à l'initialisation. Un cycle qu'on contourne par le placement de
9
+ // l'import est un cycle qu'on a caché, pas retiré.
10
+ //
11
+ // ⚠️ ET LA MOITIÉ DU CYCLE ÉTAIT UN PUR DÉTOUR. `schema.js` allait chercher `signatureAbsente` DANS
12
+ // `presentations.js`, qui l'importe lui-même de `erreurs-base.js` : trois modules pour une fonction
13
+ // qui en habite un. Corrigé en important à la source. L'autre moitié est ce fichier : un seuil de
14
+ // domaine que deux modules lisent, et qui n'appartenait qu'à l'un d'eux par accident d'écriture.
15
+
16
+ /**
17
+ * Au-delà de ce silence, une présentation est ABANDONNÉE — pas muette.
18
+ *
19
+ * ⚠️ UN SEUL NOMBRE, PARCE QUE DEUX AURAIENT DIVERGÉ. Le présentateur bat toutes les 30 s
20
+ * (`present-touch`). Ce seuil définit « vivante » pour le balayage des présentations orphelines ET
21
+ * pour le comptage des présentations actives de la carte d'identité. Les deux doivent répondre la
22
+ * même chose : une carte qui compte « actives » selon un autre seuil que celui qui les clôt
23
+ * afficherait des présentations que le balayage vient de retirer.
24
+ */
25
+ const STALE_MS = 3 * 60 * 1000;
26
+
27
+ module.exports = { STALE_MS };
package/server/mesures.js CHANGED
@@ -58,6 +58,16 @@ function creerHistogramme() {
58
58
  },
59
59
  compte: () => n,
60
60
  max: () => Math.round(maxMs),
61
+ /**
62
+ * ⚠️ REMET À ZÉRO SANS CHANGER D'IDENTITÉ, ET C'EST LA RAISON DE CETTE MÉTHODE. `histoBase` est
63
+ * un `const` exporté par identité (`__histoBase`) : le réassigner périmerait l'export et les
64
+ * bancs mesureraient un objet que le module n'utilise plus. On vide l'état en place.
65
+ */
66
+ reset() {
67
+ seaux.fill(0);
68
+ n = 0;
69
+ maxMs = 0;
70
+ },
61
71
  };
62
72
  }
63
73
 
@@ -218,8 +228,24 @@ function relever() {
218
228
  }
219
229
 
220
230
  /** Pour les bancs : repartir d'une instance vierge sans recharger le module. */
231
+ /**
232
+ * ⚠️ CETTE FONCTION PROMETTAIT PLUS QUE CE QU'ELLE FAISAIT, ET C'EST UN INSTRUMENT QUI MENTAIT.
233
+ *
234
+ * Elle annonçait « repartir d'une instance vierge sans recharger le module » et ne remettait à zéro
235
+ * que les histogrammes de routes et les compteurs de statut. `histoBase` et le retard de boucle
236
+ * survivaient. La télémétrie de production n'en souffrait pas — `vider()` n'y est jamais appelée —
237
+ * mais les BANCS D'ENDURANCE l'appellent entre l'échauffement et la mesure, puis entre scénarios.
238
+ * Ils pouvaient donc attribuer au scénario courant les appels base de l'échauffement et les
239
+ * ralentissements de boucle du scénario précédent.
240
+ *
241
+ * ⚠️ CE N'EST PAS UN DÉFAUT DE PRODUIT, C'EST PIRE POUR CE DÉPÔT : un instrument affaibli mesure
242
+ * moins bien le code qu'il surveille, et rien ne le dit. Trouvé par un audit externe le 11/09, qui
243
+ * l'a REPRODUIT plutôt que lu — `avant base n=1 boucle n=0` puis `apresVider base n=1 boucle n=2`.
244
+ */
221
245
  function vider() {
222
246
  for (const nom of FAMILLES) histos.set(nom, creerHistogramme());
247
+ histoBase.reset();
248
+ boucle.reset();
223
249
  // Écrits un par un, pour la même raison que les deux enveloppes de `observerBase` : une clé
224
250
  // calculée sur un objet ordinaire est la forme que `proprieteEcrite.test.js` refuse.
225
251
  statuts.ok = 0; statuts.refus4xx = 0; statuts.debit429 = 0; statuts.occupe503 = 0; statuts.erreur5xx = 0;
@@ -171,7 +171,10 @@ async function touchPresentation(slug, control) {
171
171
 
172
172
  // Liste des présentations en cours (membre authentifié). Auto-purge : une présentation active dont le
173
173
  // dernier heartbeat remonte à > STALE_MS (présentateur parti sans clôturer) est marquée inactive.
174
- const STALE_MS = 3 * 60 * 1000;
174
+ // ⚠️ LE SEUIL VIT DÉSORMAIS DANS UNE FEUILLE, et `schema.js` le lit de là plutôt que d'ici. Il
175
+ // était défini ici et emprunté par un `require()` dynamique croisé, ce qui fermait le seul cycle du
176
+ // graphe serveur. Le commentaire ci-dessus reste : il explique POURQUOI trois minutes.
177
+ const { STALE_MS } = require("./constantes-presentation.js");
175
178
 
176
179
  /**
177
180
  * ÉCRIRE SEULEMENT SI LA CONDITION TIENT ENCORE — au moment de l'écriture, pas au moment du contrôle.
@@ -231,8 +231,11 @@ async function purgerMessagesPresentation(slug, opts, base, plafond) {
231
231
  * `list` : il n'y avait littéralement rien à parcourir. `doc_tts_objects` (migration 0021) est la
232
232
  * trace, et c'est elle qui rend cette purge possible.
233
233
  *
234
- * ⚠️ ET C'EST UN VISITEUR QUI DÉCIDE DE CE QUI Y ENTRE. `bot-tts` accepte le texte de l'appelant :
235
- * un texte unique laisse un MP3 et un JSON dans un bucket PUBLIC. Les plafonds de la 0.1.140
234
+ * ⚠️ CE COMMENTAIRE DISAIT « C'EST UN VISITEUR QUI DÉCIDE DE CE QUI Y ENTRE ». CE N'EST PLUS VRAI
235
+ * depuis que `bot-tts` confronte le texte à ce que l'assistant a réellement dit dans cette session :
236
+ * l'appelant PROPOSE, il ne choisit pas. Trouvé par un audit externe le 11/09, en même temps que la
237
+ * phrase jumelle de `docs/RETENTION.md`. Ce qui reste vrai est la conséquence : chaque texte DISTINCT
238
+ * ACCEPTÉ laisse un MP3 et un JSON dans un bucket PUBLIC. Les plafonds de la 0.1.140
236
239
  * bornent le coût par heure ; seule cette fenêtre borne la DURÉE.
237
240
  */
238
241
  async function purgerCacheDeVoix(opts, borneDate) {
package/server/schema.js CHANGED
@@ -471,7 +471,10 @@ async function ajouterMigrationsDePresence(etat) {
471
471
  p_max_gap_ms: 0, p_anon_cap: 0, p_has_token: null, p_only_if_unclaimed: true,
472
472
  };
473
473
  const estSignatureAbsente = (erreur) => {
474
- try { return require("./presentations.js").signatureAbsente(erreur); } catch { return false; }
474
+ // ⚠️ À LA SOURCE, PAS PAR `presentations.js`. Ce détour fermait le seul cycle du graphe serveur
475
+ // (audit externe du 11/09) alors que `presentations.js` importe lui-même cette fonction de
476
+ // `erreurs-base.js` : trois modules pour une fonction qui en habite un.
477
+ try { return require("./erreurs-base.js").signatureAbsente(erreur); } catch { return false; }
475
478
  };
476
479
 
477
480
  etat.fusionBaseCouvre = PORTEE_FUSION;
@@ -612,7 +615,7 @@ async function ajouterPresence(etat) {
612
615
  // même que celui du balayage des présentations orphelines — plutôt que d'inventer un second
613
616
  // nombre qui divergerait. Le présentateur bat toutes les 30 s (`present-touch`) : une présentation
614
617
  // sans battement depuis trois minutes est abandonnée, pas silencieuse. (Relevé du second hôte.)
615
- const vivantDepuis = new Date(Date.now() - require("./presentations").STALE_MS).toISOString();
618
+ const vivantDepuis = new Date(Date.now() - require("./constantes-presentation.js").STALE_MS).toISOString();
616
619
  const actives = await PLAYER.db.request(`doc_presentations?active=eq.true&last_seen=gt.${encodeURIComponent(vivantDepuis)}&select=slug${bornee}`);
617
620
  const nActives = Array.isArray(actives) ? actives.length : 0;
618
621
  // ⚠️ `couvre` VOYAGE AVEC LES NOMBRES, ET C'EST LE POINT. Le commentaire ci-dessus protège celui