discovery-media-player 0.1.165 → 0.1.166

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.
@@ -842,11 +842,16 @@ function createStandaloneContext(env = process.env) {
842
842
  extraFrameAncestors: String(env.DOC_FRAME_ANCESTORS || "").split(/\s+/).filter(Boolean),
843
843
  // Transferts de fichiers simultanés par processus (défaut 64) : le relais refuse en 503 au-delà,
844
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,
845
+ // ⚠️ TRANSMIS TEL QUEL, PAS NORMALISÉ ICI. La première écriture faisait `Number(x) || 64` : une
846
+ // valeur illisible devenait le défaut EN SILENCE, et 2 147 483 648 passait jusqu'à `setTimeout`,
847
+ // qui ramène un tel délai à 1 ms (audit externe, cinquième passe). Les plages vivent dans
848
+ // `server/bornes.js`, et le cœur les applique à `init` en DISANT une fois ce qu'il a refusé —
849
+ // si on bornait ici, il ne verrait qu'une valeur valide et l'exploitant ne saurait jamais.
850
+ maxConcurrentRelays: env.PLAYER_MAX_RELAYS ? Number(env.PLAYER_MAX_RELAYS) : 64,
846
851
  // Un relais sans progression pendant relayStallMs, ou plus long que relayMaxMs, est abandonné
847
852
  // (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,
853
+ relayStallMs: env.PLAYER_RELAY_STALL_MS ? Number(env.PLAYER_RELAY_STALL_MS) : 30_000,
854
+ relayMaxMs: env.PLAYER_RELAY_MAX_MS ? Number(env.PLAYER_RELAY_MAX_MS) : 900_000,
850
855
 
851
856
  /**
852
857
  * Clé de `localStorage` sous laquelle VOTRE application range la session de ses membres.
@@ -50,6 +50,7 @@ need.
50
50
  "presenceDurcissement": "inconnu",
51
51
  "presenceFusion": "inconnu",
52
52
  "lectureSaturee": { "total": 0, "fenetreS": 0, "derniereIlYaS": null },
53
+ "relaisRefuses": { "total": 0, "fenetreS": 0, "derniereIlYaS": null },
53
54
  "mesures": { "fenetreS": 0, "seauxMs": [1, 2, 5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000], "familles": ["document", "presentation", "action", "fichier", "carte", "autre"], "routes": {}, "base": { "n": 0 }, "statuts": { "ok": 0, "refus4xx": 0, "debit429": 0, "occupe503": 0, "erreur5xx": 0 }, "memoireMio": { "rss": 0, "heap": 0, "tampons": 0 }, "boucleMs": { "n": 0, "moyen": null, "p99": null, "resolutionMs": 20 } },
54
55
  "retentionSweep": false,
55
56
  "hostShare": true,
@@ -241,6 +242,18 @@ object rather than as separate fields you could read apart.
241
242
  not of your deployment. Aggregating is your job — and letting you believe otherwise would be worse
242
243
  than returning nothing.
243
244
 
245
+ ### `relaisRefuses` — what the relay admission refused
246
+
247
+ Same three keys, same reading rules, **another ceiling**: this counts the file relays refused with
248
+ `503` + `Retry-After: 2` because `config.maxConcurrentRelays` was reached (see *Relay `Range`* in the
249
+ three things a host implements). ⚠️ `lectureSaturee` does **not** cover it — it is the read cache
250
+ only. A host answered *"we never saturate relays"* from `lectureSaturee.total = 0` (13/09), which was
251
+ a reasonable reading of a card that had no relay counter; and `mesures.statuts.occupe503` mixes the
252
+ two refusals. This field exists so that the question *did this instance refuse a relay?* is answered
253
+ by the card, structured and dated, and never by a search through logs for `relais refusés` — the
254
+ log line stays for diagnosis, the card is the way to *notice*. Process-local like everything on this
255
+ card, and **never reset by `init`**, exactly like the counter of open relays.
256
+
244
257
  ⚠️ **Before you upgrade, do not read `presenceDurcissement` or `presenceFusion`.** They are *reports
245
258
  of execution*: on an instance where nothing is running they say `inconnu`, which means *nobody
246
259
  looked* — not *the migration is there*. A pre-flight check built on one of them silently passes on
@@ -771,6 +784,15 @@ Four requirements, in order of what they cost when missed:
771
784
  `config.relayStallMs` (30 s) or a total beyond `config.relayMaxMs` (15 min) aborts the pipeline,
772
785
  destroying your response and the client's. A client that stops reading no longer keeps a slot
773
786
  forever; a route of yours that stops sending does not either.
787
+ ⚠️ Three things about those numbers, all measured on 0.1.165 by an audit: **64 is not a safe
788
+ default everywhere** — 64 relays × 8 MiB with slow consumers took the process RSS from ~63 MiB to
789
+ 193–257 MiB; on a 256 MiB process set 16–32. The settings are **integers in a written range**
790
+ (`maxConcurrentRelays` 1–1024, the two delays 1–86 400 000 ms): out of range falls back to the
791
+ default and is reported once at `init` through `errors.capture`, because `setTimeout` clamps
792
+ anything above 2 147 483 647 ms to 1 ms and a "24-day" delay aborted a relay in 6 ms. And the
793
+ **counter of open relays is never reset by `init`**: calling `init` again re-reads the ceiling and
794
+ the delays for the relays admitted afterwards; one already in flight keeps its slot and its
795
+ bounds, so a re-initialisation cannot let a request past a full ceiling.
774
796
  3. **Accept a server-to-server call.** A tracked link is opened by someone with no session on your
775
797
  side. Authenticate the player with the shared secret in the `x-player-fetch-secret` **header** —
776
798
  header only, never a query string: logs keep URLs.
@@ -821,6 +843,14 @@ back on an inability to *reach*.** And "do not fall back" applies to what you **
821
843
 
822
844
  ## What will bite
823
845
 
846
+ ⚠️ **`node_modules` cannot be a symlink, and a repository inside a syncing folder will break under
847
+ you.** Reported by a host (13/09), not reproduced here: a checkout living in iCloud Drive had files
848
+ duplicated and emptied *while being worked on* — four breakages in six days, four different
849
+ signatures, one of which blocked a delivery. Their escape route, a symbolic link from `node_modules`
850
+ to a folder outside the sync, is a dead end: `npm` replaces the link with a real directory, whether
851
+ the target is empty or populated, on `install` as on `ci`. Keep the clone itself outside any
852
+ synchronised folder; there is no way to keep only its dependencies out.
853
+
824
854
  ⚠️ **Every capability you provide must settle in bounded time — `db.request` first of all.** The
825
855
  player awaits your `db.request`, `storage.fetchFile`, `mail.send` and the visitor plugin; a promise
826
856
  that never settles keeps a request in flight, and the read cache admits at most 128 in-flight reads
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "discovery-media-player",
3
- "version": "0.1.165",
3
+ "version": "0.1.166",
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,59 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright © 2026 3D Discovery
3
+ // LES RÉGLAGES DU RELAIS, BORNÉS À UN SEUL ENDROIT — parce qu'un nombre fini n'est pas un délai.
4
+ //
5
+ // ⚠️ `setTimeout` PLAFONNE À 2 147 483 647 ms ET RAMÈNE TOUT DÉPASSEMENT À 1 ms. Un délai de
6
+ // progression configuré à 2 147 483 648 ms — « environ 24,8 jours » — abandonnait le transfert en
7
+ // 6 ms, avec 65 `TimeoutOverflowWarning` pour le dire (reproduit par un audit externe, cinquième
8
+ // passe, 13/09). La première borne acceptait « tout nombre fini positif » : une décimale, l'infini
9
+ // sous la forme d'un très grand entier, tout passait jusqu'à Node. On n'accepte donc qu'un ENTIER
10
+ // SÛR dans une plage écrite, bien sous la limite native, et une valeur hors plage retombe sur le
11
+ // défaut EN LE DISANT — une fois, à l'initialisation, pas à chaque relais.
12
+ //
13
+ // Le même module sert le cœur (`handler.init`) et le contexte autonome : une seule décision.
14
+
15
+ /** Relais simultanés par processus. 1 024 est une borne de configuration, pas une recommandation :
16
+ * 64 relais × 8 Mio avec des clients lents ont fait monter la RSS de 63 à 193–257 Mio (mesuré par
17
+ * un audit externe) — sur un processus à 256 Mio, 16 à 32 est le bon ordre de grandeur. */
18
+ const RELAIS_SIMULTANES_DEFAUT = 64, RELAIS_SIMULTANES_MIN = 1, RELAIS_SIMULTANES_MAX = 1024;
19
+ /** Délais du relais, en millisecondes entières : de 1 ms à 24 h. Vingt-quatre heures est une borne
20
+ * applicative, très en dessous des 2 147 483 647 ms que `setTimeout` sait tenir. */
21
+ const RELAIS_STALL_MS_DEFAUT = 30_000, RELAIS_MAX_MS_DEFAUT = 900_000;
22
+ const RELAIS_MS_MIN = 1, RELAIS_MS_MAX = 86_400_000;
23
+
24
+ /**
25
+ * Un entier sûr dans [min, max], sinon le défaut. `undefined` et `null` sont « non posé » : le
26
+ * défaut s'applique sans être un défaut de configuration ; tout le reste hors plage l'est.
27
+ * @returns {{ valeur: number, valide: boolean, posee: boolean }}
28
+ */
29
+ function entierBorne(v, { defaut, min, max }) {
30
+ if (v === undefined || v === null || v === "") return { valeur: defaut, valide: true, posee: false };
31
+ // Un nombre, ou une chaîne (l'environnement n'en connaît pas d'autre) — jamais un booléen, que
32
+ // `Number(true)` transformerait en un délai de 1 ms parfaitement « valide ».
33
+ const n = typeof v === "number" ? v : typeof v === "string" ? Number(v.trim()) : NaN;
34
+ const valide = Number.isSafeInteger(n) && n >= min && n <= max;
35
+ return { valeur: valide ? n : defaut, valide, posee: true };
36
+ }
37
+
38
+ /**
39
+ * Les trois réglages du relais, lus dans `config`, bornés, avec la liste de ce qui a été refusé —
40
+ * pour que l'appelant le dise une fois, avec la plage, plutôt que d'appliquer un défaut en silence.
41
+ * @returns {{ plafond: number, stallMs: number, maxMs: number, invalides: string[] }}
42
+ */
43
+ function bornesRelais(config) {
44
+ const c = config || {};
45
+ const plafond = entierBorne(c.maxConcurrentRelays, { defaut: RELAIS_SIMULTANES_DEFAUT, min: RELAIS_SIMULTANES_MIN, max: RELAIS_SIMULTANES_MAX });
46
+ const stall = entierBorne(c.relayStallMs, { defaut: RELAIS_STALL_MS_DEFAUT, min: RELAIS_MS_MIN, max: RELAIS_MS_MAX });
47
+ const total = entierBorne(c.relayMaxMs, { defaut: RELAIS_MAX_MS_DEFAUT, min: RELAIS_MS_MIN, max: RELAIS_MS_MAX });
48
+ const invalides = [];
49
+ if (!plafond.valide) invalides.push(`maxConcurrentRelays=${String(c.maxConcurrentRelays)} (entier de ${RELAIS_SIMULTANES_MIN} à ${RELAIS_SIMULTANES_MAX}, défaut ${RELAIS_SIMULTANES_DEFAUT})`);
50
+ if (!stall.valide) invalides.push(`relayStallMs=${String(c.relayStallMs)} (entier de ${RELAIS_MS_MIN} à ${RELAIS_MS_MAX} ms, défaut ${RELAIS_STALL_MS_DEFAUT})`);
51
+ if (!total.valide) invalides.push(`relayMaxMs=${String(c.relayMaxMs)} (entier de ${RELAIS_MS_MIN} à ${RELAIS_MS_MAX} ms, défaut ${RELAIS_MAX_MS_DEFAUT})`);
52
+ return { plafond: plafond.valeur, stallMs: stall.valeur, maxMs: total.valeur, invalides };
53
+ }
54
+
55
+ module.exports = {
56
+ entierBorne, bornesRelais,
57
+ RELAIS_SIMULTANES_DEFAUT, RELAIS_SIMULTANES_MIN, RELAIS_SIMULTANES_MAX,
58
+ RELAIS_STALL_MS_DEFAUT, RELAIS_MAX_MS_DEFAUT, RELAIS_MS_MIN, RELAIS_MS_MAX,
59
+ };
package/server/handler.js CHANGED
@@ -40,10 +40,22 @@ function init(ctx) {
40
40
  // `init`, et un hôte a le même droit. On n'ajoute qu'une chose, on n'en fige aucune.
41
41
  if (ctx && ctx.db) { const vu = Object.create(ctx); vu.db = mesures.observerBase(ctx.db); ctx = vu; }
42
42
  PLAYER = ctx;
43
- plafondRelais = borneRelais(ctx && ctx.config && ctx.config.maxConcurrentRelays);
44
- relaisStallMs = borneMs(ctx && ctx.config && ctx.config.relayStallMs, RELAIS_STALL_MS_DEFAUT);
45
- relaisMaxMs = borneMs(ctx && ctx.config && ctx.config.relayMaxMs, RELAIS_MAX_MS_DEFAUT);
46
- relaisEnCours = 0;
43
+ // ⚠️ LA CONFIGURATION SE RELIT, L'ÉTAT VIVANT NE SE REMET PAS À ZÉRO. Cette ligne posait
44
+ // `relaisEnCours = 0` : un hôte qui rappelle `init` pendant qu'un relais est ouvert désarmait le
45
+ // plafond — la demande suivante partait vers l'amont avec l'unique place encore prise, et le
46
+ // `finally` de l'ancien relais rendait ensuite le compteur négatif (reproduit par un audit externe,
47
+ // cinquième passe, 13/09). Le compteur appartient au processus, pas au contexte : il ne se relit pas.
48
+ // Les bornes de temps, elles, sont capturées à l'ADMISSION de chaque relais (`relayerSousAdmission`) :
49
+ // un relais admis sous 30 s reste sous 30 s, quoi qu'un `init` ultérieur décide.
50
+ const bornes = bornesRelais(ctx && ctx.config);
51
+ plafondRelais = bornes.plafond;
52
+ relaisStallMs = bornes.stallMs;
53
+ relaisMaxMs = bornes.maxMs;
54
+ if (bornes.invalides.length) {
55
+ // Une fois, ici — pas à chaque relais : un réglage hors plage est une erreur de déploiement, dite
56
+ // à l'exploitant avec la plage, plutôt qu'un défaut appliqué en silence.
57
+ try { PLAYER.errors.capture(new Error(`réglages de relais hors plage, défauts appliqués : ${bornes.invalides.join(" ; ")}`), { route: "relais", benin: true }); } catch { /* jamais bloquant */ }
58
+ }
47
59
  // Le domaine reçoit le même contexte : une seule construction pour tout le player.
48
60
  require("./shares").init(ctx);
49
61
  require("./retention").init(ctx);
@@ -69,8 +81,15 @@ const isAllowedStorageUrl = (url) => PLAYER.storage.isAllowedUrl(url);
69
81
  // contexte (`config.maxConcurrentRelays`, défaut 64) : assez pour les requêtes Range parallèles de
70
82
  // pdf.js, borné pour un processus. Sur serverless la plate-forme borne déjà la concurrence globale ;
71
83
  // ceci protège le mode autonome et chaque instance chaude.
72
- const RELAIS_SIMULTANES_DEFAUT = 64;
84
+ const { bornesRelais, RELAIS_SIMULTANES_DEFAUT, RELAIS_STALL_MS_DEFAUT, RELAIS_MAX_MS_DEFAUT } = require("./bornes");
73
85
  let plafondRelais = RELAIS_SIMULTANES_DEFAUT, relaisEnCours = 0;
86
+ // ⚠️ COMPTÉS, PARCE QU'UN HÔTE A CRU LES LIRE AILLEURS. On demandait aux hôtes de chercher « relais
87
+ // refusés » dans leurs journaux ; l'un d'eux a répondu par `lectureSaturee.total = 0` de la carte —
88
+ // qui ne compte que le cache de lecture, pas les relais (13/09). Une question qu'un hôte peut
89
+ // trancher par la carte ne doit pas être posée comme une fouille de journaux : la carte est
90
+ // structurée, datée, et répond même pour l'hôte qui ne lit jamais ses journaux. État du processus,
91
+ // comme `relaisEnCours` : jamais remis à zéro par `init`.
92
+ let relaisRefusesTotal = 0, dernierRefusRelais = null;
74
93
  // ⚠️ UNE PLACE N'EST BORNÉE QUE SI LE RELAIS QUI L'OCCUPE FINIT. Un client qui cesse de lire — ou un
75
94
  // amont qui cesse d'envoyer — laissait le pipeline en attente pour toujours : `finally` jamais atteint,
76
95
  // place jamais rendue, et avec un plafond de 1, plus aucun fichier ne partait (reproduit par un audit
@@ -78,12 +97,12 @@ let plafondRelais = RELAIS_SIMULTANES_DEFAUT, relaisEnCours = 0;
78
97
  // réponse — le commentaire de `bin/serve.js` affirmait le contraire. Deux bornes, configurables par
79
98
  // le contexte : sans progression pendant `relayStallMs` (30 s), ou au-delà de `relayMaxMs` (15 min),
80
99
  // le pipeline est ABANDONNÉ par signal — source amont détruite, réponse détruite, rejet, `finally`.
81
- const RELAIS_STALL_MS_DEFAUT = 30_000, RELAIS_MAX_MS_DEFAUT = 900_000;
100
+ // ⚠️ Les valeurs viennent de `server/bornes.js` : entiers dans une plage écrite, jamais « tout nombre
101
+ // fini » — `setTimeout` ramène à 1 ms tout délai au-delà de 2 147 483 647 ms (audit, cinquième passe).
82
102
  let relaisStallMs = RELAIS_STALL_MS_DEFAUT, relaisMaxMs = RELAIS_MAX_MS_DEFAUT;
83
- const borneMs = (v, defaut) => { const n = Number(v); return Number.isFinite(n) && n > 0 ? n : defaut; };
84
- function borneRelais(v) { const n = Math.trunc(Number(v)); return Number.isFinite(n) && n >= 1 ? n : RELAIS_SIMULTANES_DEFAUT; }
85
103
  async function relayerSousAdmission(res, travail) {
86
104
  if (relaisEnCours >= plafondRelais) {
105
+ relaisRefusesTotal += 1; dernierRefusRelais = Date.now();
87
106
  // Une fois par heure, l'exploitant l'apprend : un 503 muet ressemble à une panne d'amont.
88
107
  try {
89
108
  if (await PLAYER.limits.allow("relais:sature-avert", 1, 3600)) {
@@ -94,8 +113,11 @@ async function relayerSousAdmission(res, travail) {
94
113
  refuserEnTexte(res, 503, "Trop de transferts en cours, réessayez dans un instant");
95
114
  return;
96
115
  }
116
+ // Les bornes de CE relais sont figées ici : un `init` pendant le transfert relit la configuration
117
+ // pour les suivants, jamais pour celui-ci.
118
+ const bornes = { stallMs: relaisStallMs, maxMs: relaisMaxMs };
97
119
  relaisEnCours += 1;
98
- try { await travail(); } finally { relaisEnCours -= 1; }
120
+ try { await travail(bornes); } finally { relaisEnCours -= 1; }
99
121
  }
100
122
 
101
123
  // GREFFONS de ce studio — jamais du player. `null` quand le module est absent ou coupé
@@ -262,7 +284,7 @@ const { refuserEnTexte, repondreJson, repondreJsonTexte } = require("./reponses.
262
284
  // exemplaires d'un fait divergent, c'est la formule du dépôt.
263
285
  const POLITIQUE_PERMISSIONS = "camera=(), microphone=(), geolocation=(), payment=()";
264
286
 
265
- async function relayerFichier(res, r, disposition) {
287
+ async function relayerFichier(res, r, disposition, bornes = { stallMs: relaisStallMs, maxMs: relaisMaxMs }) {
266
288
  if (!r) { refuserEnTexte(res, 404, "Fichier indisponible"); return; }
267
289
  // 413 et 416 sont des REFUS ARGUMENTÉS de l'amont local (plafond, borne absurde) : les fondre
268
290
  // dans un 502 dirait « panne » là où l'amont a dit « demande irrecevable ».
@@ -347,8 +369,8 @@ async function relayerFichier(res, r, disposition) {
347
369
  // ne se réarme jamais. L'abandon passe par le signal du pipeline, qui détruit la source ET la réponse.
348
370
  const abandon = new globalThis.AbortController();
349
371
  let stall = null;
350
- const rearmer = () => { clearTimeout(stall); stall = setTimeout(() => abandon.abort(new Error(`relais abandonné : aucune progression depuis ${relaisStallMs} ms`)), relaisStallMs); };
351
- const budget = setTimeout(() => abandon.abort(new Error(`relais abandonné : plus de ${relaisMaxMs} ms au total`)), relaisMaxMs);
372
+ const rearmer = () => { clearTimeout(stall); stall = setTimeout(() => abandon.abort(new Error(`relais abandonné : aucune progression depuis ${bornes.stallMs} ms`)), bornes.stallMs); };
373
+ const budget = setTimeout(() => abandon.abort(new Error(`relais abandonné : plus de ${bornes.maxMs} ms au total`)), bornes.maxMs);
352
374
  rearmer();
353
375
  try {
354
376
  await pipeline(Readable.fromWeb(r.body), async function* (source) {
@@ -817,6 +839,15 @@ async function handlerMesure(req, res) {
817
839
  derniereIlYaS: dernier == null ? null : Math.max(0, Math.round((Date.now() - dernier) / 1000)),
818
840
  };
819
841
  })(),
842
+ // ⚠️ MÊME FORME, AUTRE PLAFOND. `lectureSaturee` est le cache de lecture ; ceci est l'admission
843
+ // des relais de fichiers (`config.maxConcurrentRelays`). Un hôte a lu le premier pour le second
844
+ // (13/09) parce que le second n'existait pas sur la carte — et `mesures.statuts.occupe503`
845
+ // les confond. Trois clés, jamais séparées : un total sans sa fenêtre ment par omission.
846
+ relaisRefuses: {
847
+ total: relaisRefusesTotal,
848
+ fenetreS: Math.round(process.uptime()),
849
+ derniereIlYaS: dernierRefusRelais == null ? null : Math.max(0, Math.round((Date.now() - dernierRefusRelais) / 1000)),
850
+ },
820
851
  // ⚠️ CE QUE CETTE INSTANCE A VÉCU — parce que `lectureSaturee` ne répondait qu'à UNE
821
852
  // question. « La route est-elle lente ? », « lesquelles ? », « la base ou nous ? »,
822
853
  // « combien de 5xx ? », « la boucle décroche-t-elle ? » n'avaient aucune réponse
@@ -1037,9 +1068,9 @@ async function handlerMesure(req, res) {
1037
1068
  if (String(q.file || "") === "1") {
1038
1069
  if (!isAllowedStorageUrl(pres.file_url)) { refuserEnTexte(res, 404, "Fichier indisponible"); return; }
1039
1070
  const range = req.headers["range"];
1040
- await relayerSousAdmission(res, async () => {
1071
+ await relayerSousAdmission(res, async (bornes) => {
1041
1072
  const r = await PLAYER.storage.fetchFile(pres.file_url, { range });
1042
- await relayerFichier(res, r, null);
1073
+ await relayerFichier(res, r, null, bornes);
1043
1074
  });
1044
1075
  return;
1045
1076
  }
@@ -1062,9 +1093,9 @@ async function handlerMesure(req, res) {
1062
1093
  if (!isAllowedStorageUrl(url)) return sendRefusal(res, "url-not-allowed", embed);
1063
1094
  if (String(q.stream || "") === "1") {
1064
1095
  const range = req.headers["range"];
1065
- await relayerSousAdmission(res, async () => {
1096
+ await relayerSousAdmission(res, async (bornes) => {
1066
1097
  const r = await PLAYER.storage.fetchFile(url, { range });
1067
- await relayerFichier(res, r, dispositionInline(q.name));
1098
+ await relayerFichier(res, r, dispositionInline(q.name), bornes);
1068
1099
  });
1069
1100
  return;
1070
1101
  }
@@ -1116,9 +1147,9 @@ async function handlerMesure(req, res) {
1116
1147
  // Stream depuis le Storage en RELAYANT les requêtes Range → pdf.js charge progressivement (les 1res
1117
1148
  // pages s'affichent sans télécharger tout le PDF) → affichage bien plus rapide.
1118
1149
  const range = req.headers["range"];
1119
- await relayerSousAdmission(res, async () => {
1150
+ await relayerSousAdmission(res, async (bornes) => {
1120
1151
  const r = await PLAYER.storage.fetchFile(share.file_url, { range });
1121
- await relayerFichier(res, r, dispositionInline(share.file_name));
1152
+ await relayerFichier(res, r, dispositionInline(share.file_name), bornes);
1122
1153
  });
1123
1154
  return;
1124
1155
  }
@@ -1211,6 +1242,9 @@ async function handlerMesure(req, res) {
1211
1242
  // regardant si le corps a été lu, ce qu aucune route ne peut montrer de l extérieur.
1212
1243
  // ⚠️ Exporté pour être ÉPROUVÉ : « le contexte reste vivant » ne se vérifie pas de l'extérieur.
1213
1244
  module.exports = { __contexte: () => PLAYER, handler, init, TIERS, POLITIQUE_PERMISSIONS, refuserEnTexte, repondreJson, __relayerFichier: relayerFichier, __jsonPourScript: jsonPourScript,
1245
+ // ⚠️ COUTURE DE BANC : le compteur de relais en vol est un état du PROCESSUS, jamais remis à zéro par
1246
+ // `init`. Un banc doit pouvoir vérifier qu'il revient à zéro et ne passe jamais sous zéro.
1247
+ __relaisEnCours: () => relaisEnCours,
1214
1248
  // ⚠️ COUTURE DE BANC, PAS D'API : le cache de lecture est global au module, et un banc qui laisse des
1215
1249
  // lectures en vol contamine le suivant (128 promesses éternelles, 503 partout — trouvé par un audit
1216
1250
  // externe sous mélange, graine 20260913). Un banc doit pouvoir VÉRIFIER qu'il rend le cache vide.
@@ -20,9 +20,11 @@ let PLAYER = null;
20
20
  // Deux dimensions pour le code, parce qu'une seule se contourne : par ADRESSE (une adresse ne
21
21
  // recommence pas à zéro en changeant d'email) et par IDENTITÉ (plusieurs adresses ne forcent pas un
22
22
  // même email). Les compteurs sont pris À L'ADMISSION, donc réussite, échec et exception les
23
- // consomment pareil. L'identité est une EMPREINTE de l'email normalisé, jamais l'email : la table des
24
- // compteurs n'a pas à porter d'adresses en clair. (Le cœur n'a pas de secret de serveur, par
25
- // conception — voir le contexte autonome — donc une empreinte, pas un HMAC.)
23
+ // consomment pareil. L'identité n'est jamais l'email : la table des compteurs n'a pas à porter
24
+ // d'adresses en clair. La clé vient du GREFFON (un HMAC avec un secret chez l'hôte, ci-dessous) ;
25
+ // l'empreinte SHA-256 n'est que le repli, et il est dit. ⚠️ Ce paragraphe affirmait « le cœur n'a
26
+ // pas de secret de serveur, donc une empreinte, pas un HMAC » — retiré (cinquième passe de l'audit,
27
+ // 13/09) : trop absolu, et surtout la clé n'a pas besoin d'un secret du cœur, elle vient de l'hôte.
26
28
  const VERIF_PAR_ADRESSE = 100, VERIF_PAR_IDENTITE = 10, VERIF_FENETRE_IDENTITE_S = 900;
27
29
  const GOOGLE_PAR_ADRESSE = 100, DEMANDE_PAR_IDENTITE = 5;
28
30
  // ⚠️ UN SHA-256 D'EMAIL N'EST PAS UNE ANONYMISATION : il se renverse par dictionnaire — qui lit la
@@ -128,13 +128,17 @@ export interface Reglages {
128
128
  supabasePublishableKey: string;
129
129
  mapsKey: string;
130
130
  extraFrameAncestors: string[];
131
- /** Transferts de fichiers relayés simultanément par processus (défaut 64). Au-delà, le relais
132
- * répond 503 + Retry-After AVANT l'appel amont ; la place est rendue en `finally`. */
131
+ /** Transferts de fichiers relayés simultanément par processus (défaut 64 ; entier de 1 à 1024,
132
+ * sinon le défaut, dit une fois à `init`). Au-delà, le relais répond 503 + Retry-After AVANT
133
+ * l'appel amont ; la place est rendue en `finally`. 64 n'est pas sûr partout : 64 × 8 Mio avec
134
+ * des clients lents font monter la RSS de 130 à 194 Mio — 16 à 32 sur un processus à 256 Mio. */
133
135
  maxConcurrentRelays?: number;
134
136
  /** Un relais sans progression pendant ce délai est abandonné — source et réponse détruites, place
135
- * rendue (défaut 30 000 ms). `requestTimeout` ne borne pas l'émission d'une réponse. */
137
+ * rendue (défaut 30 000 ms ; entier de 1 à 86 400 000 ms, sinon le défaut, dit une fois à `init` :
138
+ * `setTimeout` ramène à 1 ms tout délai au-delà de 2 147 483 647 ms). `requestTimeout` ne borne
139
+ * pas l'émission d'une réponse. */
136
140
  relayStallMs?: number;
137
- /** Durée totale maximale d'un relais (défaut 900 000 ms). */
141
+ /** Durée totale maximale d'un relais (défaut 900 000 ms ; même plage que `relayStallMs`). */
138
142
  relayMaxMs?: number;
139
143
  /** Ce qui est POSÉ, à côté de ce que le code SAIT faire : la carte d'identité publie les deux,
140
144
  * parce qu'une capacité disponible mais non configurée se comporte comme une absence. */