discovery-media-player 0.1.166 → 0.1.167
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/context/standalone.js +27 -9
- package/docs/HOST-CONTRACT.md +31 -1
- package/package.json +1 -1
- package/server/bornes.js +5 -1
- package/server/capture.js +27 -0
- package/server/handler.js +6 -5
- package/server/presentations.js +7 -6
- package/server/retention.js +4 -3
- package/server/routes-agent.js +3 -2
- package/server/routes-direct.js +9 -8
- package/server/routes-liens.js +13 -12
- package/server/routes-visiteur.js +3 -2
- package/server/schema.js +4 -2
- package/server/shares.js +3 -2
- package/types/context.d.ts +8 -6
package/context/standalone.js
CHANGED
|
@@ -18,6 +18,20 @@
|
|
|
18
18
|
const crypto = require("node:crypto");
|
|
19
19
|
const storage = require("./storage");
|
|
20
20
|
|
|
21
|
+
// ⚠️ « JAMAIS BLOQUANT » NE TENAIT QUE POUR UNE EXCEPTION SYNCHRONE, ICI AUSSI. Le cœur a reçu
|
|
22
|
+
// `server/capture.js` à la sixième passe de l'audit ; ce contexte gardait cinq appels directs à
|
|
23
|
+
// `journal.capture` sous un `try/catch`, et un helper en ligne dans `appelHote`. Or `ctx.errors` et le
|
|
24
|
+
// journal capturé par ces capacités sont LE MÊME objet : un hôte qui pose un `capture` qui rejette
|
|
25
|
+
// tuait le processus à `mail.send` sans secret, avant tout réseau (reproduit par l'audit, septième
|
|
26
|
+
// passe, 14/09). Même règle que le cœur, reprise ici sans l'importer — le contexte ne dépend pas du
|
|
27
|
+
// serveur — et UNE seule forme : tout appel au journal passe par ici, un banc structurel le tient.
|
|
28
|
+
function capturerJournalSansBloquer(journal, erreur, meta) {
|
|
29
|
+
try {
|
|
30
|
+
const resultat = journal && typeof journal.capture === "function" ? journal.capture(erreur, meta) : undefined;
|
|
31
|
+
if (resultat && typeof resultat.then === "function") resultat.then(undefined, () => { /* un journal ne doit jamais interrompre le traitement */ });
|
|
32
|
+
} catch { /* exception synchrone : même règle */ }
|
|
33
|
+
}
|
|
34
|
+
|
|
21
35
|
/**
|
|
22
36
|
* Retire les barres finales d'une base d'URL.
|
|
23
37
|
*
|
|
@@ -228,7 +242,7 @@ async function appelHote(url, secret, corps, errors) {
|
|
|
228
242
|
// indiscernable de « le droit est refusé », et on cherche pendant une demi-journée du côté des
|
|
229
243
|
// rôles. Un hôte qui a écrit sa route sur la description du contrat plutôt que sur le code a
|
|
230
244
|
// perdu exactement ce temps-là.
|
|
231
|
-
const signaler = (quoi) =>
|
|
245
|
+
const signaler = (quoi) => capturerJournalSansBloquer(errors, new Error(`route hôte : ${quoi}`), { url });
|
|
232
246
|
try {
|
|
233
247
|
const r = await fetchBorne(url, {
|
|
234
248
|
method: "POST",
|
|
@@ -333,7 +347,7 @@ function creerLimites(db, journal, horloge = () => Date.now()) {
|
|
|
333
347
|
function prevenirUneFois(message) {
|
|
334
348
|
if (deja === message) return;
|
|
335
349
|
deja = message;
|
|
336
|
-
try { journal
|
|
350
|
+
try { capturerJournalSansBloquer(journal, new Error(message), { route: "limits" }); } catch { /* jamais bloquant */ }
|
|
337
351
|
}
|
|
338
352
|
|
|
339
353
|
async function tablePresente() {
|
|
@@ -346,7 +360,7 @@ function creerLimites(db, journal, horloge = () => Date.now()) {
|
|
|
346
360
|
if (!signale) {
|
|
347
361
|
signale = true;
|
|
348
362
|
try {
|
|
349
|
-
journal
|
|
363
|
+
capturerJournalSansBloquer(journal, new Error(
|
|
350
364
|
"compteurs de débit non partagés : appliquez supabase/migrations/0003-limites-partagees.sql. "
|
|
351
365
|
+ "Sans elle, chaque instance compte pour elle seule et les limites sont plus lâches qu'annoncé.",
|
|
352
366
|
), { route: "limits" });
|
|
@@ -546,7 +560,7 @@ function createStandaloneContext(env = process.env) {
|
|
|
546
560
|
const secret = String(env.PLAYER_HOST_MAIL_SECRET || "");
|
|
547
561
|
if (!url) return null;
|
|
548
562
|
if (!secret) {
|
|
549
|
-
try { journal
|
|
563
|
+
try { capturerJournalSansBloquer(journal, new Error("PLAYER_HOST_MAIL_URL est configurée sans PLAYER_HOST_MAIL_SECRET : aucun envoi ne partira"), {}); } catch { /* ignore */ }
|
|
550
564
|
return null;
|
|
551
565
|
}
|
|
552
566
|
const reponse = await appelHote(url, secret, message, journal);
|
|
@@ -591,7 +605,7 @@ function createStandaloneContext(env = process.env) {
|
|
|
591
605
|
if (emetteur && !cle) {
|
|
592
606
|
// Le refus silencieux est le piège de cette configuration : sans clé, chaque membre est
|
|
593
607
|
// simplement « non authentifié », ce qui ressemble à un droit manquant. On le dit.
|
|
594
|
-
try { journal
|
|
608
|
+
try { capturerJournalSansBloquer(journal, new Error("PLAYER_AUTH_URL est configurée sans PLAYER_AUTH_KEY : aucun jeton ne peut être vérifié"), {}); } catch { /* ignore */ }
|
|
595
609
|
}
|
|
596
610
|
if (!jeton || !url || !cle) return null;
|
|
597
611
|
try {
|
|
@@ -757,7 +771,7 @@ function createStandaloneContext(env = process.env) {
|
|
|
757
771
|
// parfaitement intentionnée — vaut refus, et le dit. C'est le cas le plus courant au
|
|
758
772
|
// branchement d'un nouvel hôte.
|
|
759
773
|
if (reponse && typeof reponse.allowed !== "boolean") {
|
|
760
|
-
try { journal
|
|
774
|
+
try { capturerJournalSansBloquer(journal, new Error("route d'autorisation : champ `allowed` booléen attendu"), { recu: Object.keys(reponse).join(",") }); } catch { /* ignore */ }
|
|
761
775
|
}
|
|
762
776
|
return reponse ? reponse.allowed === true : false;
|
|
763
777
|
},
|
|
@@ -847,11 +861,15 @@ function createStandaloneContext(env = process.env) {
|
|
|
847
861
|
// qui ramène un tel délai à 1 ms (audit externe, cinquième passe). Les plages vivent dans
|
|
848
862
|
// `server/bornes.js`, et le cœur les applique à `init` en DISANT une fois ce qu'il a refusé —
|
|
849
863
|
// si on bornait ici, il ne verrait qu'une valeur valide et l'exploitant ne saurait jamais.
|
|
850
|
-
|
|
864
|
+
// ⚠️ ET « TEL QUEL » VEUT DIRE LA CHAÎNE, PAS `Number(chaîne)`. La seconde écriture convertissait
|
|
865
|
+
// encore : « abc » arrivait au cœur en NaN et le diagnostic disait `relayStallMs=NaN` — l'exploitant
|
|
866
|
+
// ne retrouvait pas ce qu'il avait saisi (audit, sixième passe). La chaîne passe intacte ; absente
|
|
867
|
+
// ou vide, le cœur applique le défaut sans avertir (« non posé »).
|
|
868
|
+
maxConcurrentRelays: env.PLAYER_MAX_RELAYS,
|
|
851
869
|
// Un relais sans progression pendant relayStallMs, ou plus long que relayMaxMs, est abandonné
|
|
852
870
|
// (source et réponse détruites) : sans ça, un client qui cesse de lire garde sa place pour toujours.
|
|
853
|
-
relayStallMs: env.PLAYER_RELAY_STALL_MS
|
|
854
|
-
relayMaxMs: env.PLAYER_RELAY_MAX_MS
|
|
871
|
+
relayStallMs: env.PLAYER_RELAY_STALL_MS,
|
|
872
|
+
relayMaxMs: env.PLAYER_RELAY_MAX_MS,
|
|
855
873
|
|
|
856
874
|
/**
|
|
857
875
|
* Clé de `localStorage` sous laquelle VOTRE application range la session de ses membres.
|
package/docs/HOST-CONTRACT.md
CHANGED
|
@@ -185,7 +185,7 @@ these numbers from their side.
|
|
|
185
185
|
| `routes` | one entry per family of work — `document`, `presentation`, `action`, `fichier`, `carte`, `autre`. Families absent from the object were never exercised in this process |
|
|
186
186
|
| `base` | the same shape, for calls through the `db` capability **you** supply — measured at the seam, so it covers every call, including ones nobody has written yet |
|
|
187
187
|
| `statuts` | responses by class: `ok` (<400), `refus4xx`, `debit429`, `occupe503`, `erreur5xx` |
|
|
188
|
-
| `memoireMio` | `rss`, `heap` (heap used), `tampons` (`arrayBuffers`) in MiB, read at the moment of the request |
|
|
188
|
+
| `memoireMio` | `rss`, `heap` (heap used), `tampons` (`arrayBuffers`) in MiB, read at the moment of the request. ⚠️ **Half a number**: it is only judicable against the memory ceiling of the process, which the player does not know and no platform serves the same way — on Lambda-based functions (Vercel included) read `AWS_LAMBDA_FUNCTION_MEMORY_SIZE`; in a container, the cgroup limit. A host spent half a day finding that its project API, its logs and its `vercel.json` all left it out (14/09). Display the ceiling beside the RSS, or the RSS says nothing about the relay ceiling you can afford |
|
|
189
189
|
| `boucleMs` | event-loop **delay** — `moyen` and `p99` in ms, with `n` samples and the sampler's `resolutionMs` |
|
|
190
190
|
|
|
191
191
|
⚠️ **A percentile over buckets is a bound, not a value.** `p95sousMs: 250` reads *"95% of calls
|
|
@@ -238,6 +238,36 @@ saturate*; on a process that started four seconds ago it says *nobody has looked
|
|
|
238
238
|
same trap as `inconnu` in the two rows above, and the reason the three keys are returned as one
|
|
239
239
|
object rather than as separate fields you could read apart.
|
|
240
240
|
|
|
241
|
+
⚠️ **Read `fenetreS` first — and on serverless, expect it to stay short.** Every counter on this
|
|
242
|
+
card belongs to the process, and on a serverless platform the process is the unit that dies: a host
|
|
243
|
+
read its two domains a few minutes apart and got windows of 15 s and 19 s, then 4 s and 7 s — the
|
|
244
|
+
processes had been recycled in between (14/09). There, `total: 0` over 15 seconds means *nothing in
|
|
245
|
+
the last quarter of a minute*, which is almost no information, and these counters can structurally
|
|
246
|
+
never accumulate more than a cold start's lifetime. This is not a defect of the field — on a
|
|
247
|
+
long-lived process it says what it should — it is a limit of applicability, and `fenetreS` is the
|
|
248
|
+
key that reveals it. So the reading order is: `fenetreS`, then `total`; and a fleet of short windows
|
|
249
|
+
is a fact about your hosting, never a reassurance. ⚠️ **And sampling from outside does not repair a
|
|
250
|
+
short window — it inherits it.** A host did the arithmetic (14/09): a daily cron reading a counter
|
|
251
|
+
whose window is ~15 s observes 15 × 365 = 5 475 seconds a year out of 31 536 000, 0.017 % of the
|
|
252
|
+
time; an hourly one, 0.42 %. A `total: 0` collected 365 times a year says exactly what it says once,
|
|
253
|
+
with the added look of a time series — the credibility of a surveillance without the surveillance.
|
|
254
|
+
The only form that would work on serverless is a **push at the end of the process**, because the
|
|
255
|
+
process is the one entity that knows its own total and it dies without saying it. The player does
|
|
256
|
+
not do that today, and no serverless platform guarantees a hook to do it in; this paragraph states
|
|
257
|
+
the limit with its way out, so that nobody builds the sampler first.
|
|
258
|
+
The same applies to `relaisRefuses` below and to the **counters** under `mesures` (`statuts`,
|
|
259
|
+
`routes`, `base`).
|
|
260
|
+
|
|
261
|
+
⚠️ **Three natures share this card, and the rule above covers only the first.** An earlier version
|
|
262
|
+
of this paragraph said it applied to "everything under `mesures`" — too broad, and a host applied
|
|
263
|
+
the counter rule to a gauge because nothing told it the gauge was not covered (14/09).
|
|
264
|
+
|
|
265
|
+
| nature | examples | what qualifies it | what saves it |
|
|
266
|
+
|---|---|---|---|
|
|
267
|
+
| **counter** | `lectureSaturee`, `relaisRefuses`, `mesures.statuts` | accumulates over `fenetreS` | read `fenetreS` first — a long window makes a zero informative |
|
|
268
|
+
| **gauge** | `mesures.memoireMio` | the state **at the instant of the read** (`process.memoryUsage()`); no window enters its production | **nothing on the card**: a process idle for a week shows the same ~64 MiB as one born a minute ago, and a long window would only make it *look* trustworthy. It says nothing about what the process would weigh under load; the only way to learn that is to read it *during* a load — which an audit can provoke and a host must not on real readers |
|
|
269
|
+
| **sample** | `mesures.boucleMs` | `{ n: 0, moyen: null, p99: null }` until something was observed | itself — it refuses to answer rather than return an interpretable zero |
|
|
270
|
+
|
|
241
271
|
⚠️ **It is process-local.** Behind a load balancer this is the count of the instance that answered,
|
|
242
272
|
not of your deployment. Aggregating is your job — and letting you believe otherwise would be worse
|
|
243
273
|
than returning nothing.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "discovery-media-player",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.167",
|
|
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",
|
package/server/bornes.js
CHANGED
|
@@ -29,7 +29,11 @@ const RELAIS_MS_MIN = 1, RELAIS_MS_MAX = 86_400_000;
|
|
|
29
29
|
function entierBorne(v, { defaut, min, max }) {
|
|
30
30
|
if (v === undefined || v === null || v === "") return { valeur: defaut, valide: true, posee: false };
|
|
31
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 ».
|
|
32
|
+
// `Number(true)` transformerait en un délai de 1 ms parfaitement « valide ». ⚠️ La famille est plus
|
|
33
|
+
// large que les booléens, et un hôte l'a payée : `Number(null)` et `Number("")` valent 0, et chez
|
|
34
|
+
// lui une absence devenait une coordonnée (0, 0) valide, en six endroits dont un qui débitait un
|
|
35
|
+
// quota (ADV, 14/09). L'ORDRE des opérations est le seul remède général : l'absence est écartée
|
|
36
|
+
// ci-dessus AVANT toute conversion, et 0 tombe ensuite sous `min`. Ce n'est pas un hasard.
|
|
33
37
|
const n = typeof v === "number" ? v : typeof v === "string" ? Number(v.trim()) : NaN;
|
|
34
38
|
const valide = Number.isSafeInteger(n) && n >= min && n <= max;
|
|
35
39
|
return { valeur: valide ? n : defaut, valide, posee: true };
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright © 2026 3D Discovery
|
|
3
|
+
// « JAMAIS BLOQUANT » NE TENAIT QUE POUR UNE EXCEPTION SYNCHRONE.
|
|
4
|
+
//
|
|
5
|
+
// ⚠️ TRENTE-CINQ APPELS À `errors.capture` PORTAIENT LE COMMENTAIRE « jamais bloquant » SOUS UN
|
|
6
|
+
// `try/catch` — QUI N'ATTRAPE QU'UNE EXCEPTION SYNCHRONE. Le contrat autorise `capture` à rendre une
|
|
7
|
+
// promesse ; une promesse REJETÉE échappait au `catch`, remontait en `unhandledRejection`, et Node
|
|
8
|
+
// arrête le processus : reproduit par un audit externe sur le tag v0.1.166, à `init`, avec un
|
|
9
|
+
// `capture` qui rejette et une configuration hors plage — sortie 1 avant le premier octet servi
|
|
10
|
+
// (sixième passe, 14/09). Un journal qui échoue ne doit rien arrêter, ni de façon synchrone ni de
|
|
11
|
+
// façon asynchrone : c'est la même règle, et elle vit ici, une fois, pour tous les appelants.
|
|
12
|
+
//
|
|
13
|
+
// ⚠️ On n'ATTEND pas la promesse — on la neutralise. Attendre ferait dépendre chaque réponse de la
|
|
14
|
+
// latence du journal de l'hôte ; ici on veut juste qu'un rejet ne tue personne.
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Confie une erreur au journal de l'hôte sans que le journal puisse, lui, faire échouer quoi que
|
|
18
|
+
* ce soit — ni par une exception, ni par une promesse rejetée. `errors` peut être absent.
|
|
19
|
+
*/
|
|
20
|
+
function capturerSansBloquer(errors, erreur, meta) {
|
|
21
|
+
try {
|
|
22
|
+
const r = errors && typeof errors.capture === "function" ? errors.capture(erreur, meta) : undefined;
|
|
23
|
+
if (r && typeof r.then === "function") r.then(undefined, () => { /* un journal qui échoue ne doit rien arrêter */ });
|
|
24
|
+
} catch { /* exception synchrone : même règle */ }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
module.exports = { capturerSansBloquer };
|
package/server/handler.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// - GET /doc/:slug?file=1 → stream le PDF depuis le Storage (MÊME ORIGINE → pas de souci CORS pour pdf.js)
|
|
7
7
|
// - POST /api/doc {slug,event…}→ journalise un événement (open / page / heartbeat) — best-effort
|
|
8
8
|
const crypto = require("crypto");
|
|
9
|
+
const { capturerSansBloquer } = require("./capture");
|
|
9
10
|
const { Readable } = require("node:stream");
|
|
10
11
|
const { pipeline } = require("node:stream/promises");
|
|
11
12
|
const { getShareBySlug } = require("./shares");
|
|
@@ -54,7 +55,7 @@ function init(ctx) {
|
|
|
54
55
|
if (bornes.invalides.length) {
|
|
55
56
|
// Une fois, ici — pas à chaque relais : un réglage hors plage est une erreur de déploiement, dite
|
|
56
57
|
// à l'exploitant avec la plage, plutôt qu'un défaut appliqué en silence.
|
|
57
|
-
try { PLAYER.errors
|
|
58
|
+
try { capturerSansBloquer(PLAYER.errors, new Error(`réglages de relais hors plage, défauts appliqués : ${bornes.invalides.join(" ; ")}`), { route: "relais", benin: true }); } catch { /* jamais bloquant */ }
|
|
58
59
|
}
|
|
59
60
|
// Le domaine reçoit le même contexte : une seule construction pour tout le player.
|
|
60
61
|
require("./shares").init(ctx);
|
|
@@ -106,7 +107,7 @@ async function relayerSousAdmission(res, travail) {
|
|
|
106
107
|
// Une fois par heure, l'exploitant l'apprend : un 503 muet ressemble à une panne d'amont.
|
|
107
108
|
try {
|
|
108
109
|
if (await PLAYER.limits.allow("relais:sature-avert", 1, 3600)) {
|
|
109
|
-
PLAYER.errors
|
|
110
|
+
capturerSansBloquer(PLAYER.errors, new Error(`relais refusés : ${plafondRelais} transferts simultanés atteints dans ce processus (config.maxConcurrentRelays)`), { route: "relais", benin: true });
|
|
110
111
|
}
|
|
111
112
|
} catch { /* jamais bloquant */ }
|
|
112
113
|
res.setHeader("Retry-After", "2");
|
|
@@ -310,7 +311,7 @@ async function relayerFichier(res, r, disposition, bornes = { stallMs: relaisSta
|
|
|
310
311
|
const brute = r.headers.get("content-length");
|
|
311
312
|
const annoncee = Number(brute || 0);
|
|
312
313
|
if (annoncee > PLAFOND_RELAIS) {
|
|
313
|
-
try { PLAYER.errors
|
|
314
|
+
try { capturerSansBloquer(PLAYER.errors, new Error(`relais refusé : ${annoncee} octets au-dessus du plafond de ${PLAFOND_RELAIS}`), { route: "relais" }); } catch { /* jamais bloquant */ }
|
|
314
315
|
refuserEnTexte(res, 413, "Fichier trop volumineux");
|
|
315
316
|
// ⚠️ Renoncer ne suffit pas : un corps jamais tiré laisse la connexion amont OUVERTE, et le
|
|
316
317
|
// pool de sockets s'épuise sur les gros fichiers — exactement la ressource qu'on protège.
|
|
@@ -387,7 +388,7 @@ async function relayerFichier(res, r, disposition, bornes = { stallMs: relaisSta
|
|
|
387
388
|
// reste que la coupure. Une coupure fréquente ici est un plafond mal réglé ou un amont
|
|
388
389
|
// défaillant : l'avaler ferait passer un défaut d'exploitation pour un caprice du réseau.
|
|
389
390
|
const cause = abandon.signal.aborted && abandon.signal.reason instanceof Error ? abandon.signal.reason : erreur;
|
|
390
|
-
try { PLAYER.errors
|
|
391
|
+
try { capturerSansBloquer(PLAYER.errors, cause instanceof Error ? cause : new Error(String(cause)), { route: "relais" }); } catch { /* jamais bloquant */ }
|
|
391
392
|
try { res.destroy(); } catch { /* le socket est peut-être déjà parti */ }
|
|
392
393
|
} finally {
|
|
393
394
|
clearTimeout(stall); clearTimeout(budget);
|
|
@@ -1219,7 +1220,7 @@ async function handlerMesure(req, res) {
|
|
|
1219
1220
|
// exactement comme sans PLAYER_HOST_AUTHZ_URL personne ne peut DIFFUSER.
|
|
1220
1221
|
if (share.embed && !(PLAYER.config.extraFrameAncestors || []).length) {
|
|
1221
1222
|
try {
|
|
1222
|
-
PLAYER.errors
|
|
1223
|
+
capturerSansBloquer(PLAYER.errors,
|
|
1223
1224
|
new Error("?embed=1 demandé mais DOC_FRAME_ANCESTORS est vide : seuls une page de même origine et *.vercel.app peuvent encadrer cette instance"),
|
|
1224
1225
|
{ route: "doc", indice: "le navigateur bloquera l'iframe avant le chargement — aucun embed-denied ne partira" },
|
|
1225
1226
|
);
|
package/server/presentations.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// (cf. migration v12324) — écriture service role only. Le présentateur détient un control_token ; on en
|
|
5
5
|
// stocke le HASH (sha256) → l'audience peut lire la ligne (Realtime) sans pouvoir piloter.
|
|
6
6
|
const crypto = require("crypto");
|
|
7
|
+
const { capturerSansBloquer } = require("./capture");
|
|
7
8
|
// Base de données via le contexte injecté (cf. _player-context.js) — aucune adhérence au studio.
|
|
8
9
|
// ⚠️ Le contexte est REÇU, pas construit. Ce module ne doit pas savoir d'où il vient : c'est ce
|
|
9
10
|
// qui lui permettra de partir dans le dépôt du player sans emporter le studio avec lui.
|
|
@@ -487,7 +488,7 @@ async function createUploadUrl(slug, name, type) {
|
|
|
487
488
|
// serait pire : une pièce jointe qui ne part jamais, sans que personne sache que la capacité
|
|
488
489
|
// manque.
|
|
489
490
|
if (!PLAYER.storage || typeof PLAYER.storage.signUpload !== "function") {
|
|
490
|
-
try { PLAYER.errors
|
|
491
|
+
try { capturerSansBloquer(PLAYER.errors, new Error("storage.signUpload absent du contexte : les pièces jointes de chat sont indisponibles"), {}); } catch { /* jamais bloquant */ }
|
|
491
492
|
return { ok: false, status: 501 };
|
|
492
493
|
}
|
|
493
494
|
const signe = await PLAYER.storage.signUpload("present-attachments", path);
|
|
@@ -687,7 +688,7 @@ async function addMessage(slug, { name, email, avatar, isPresenter, isMember, bo
|
|
|
687
688
|
// attendu est journalisé une fois, en clair, parce qu'un renvoi fréquent est une information.
|
|
688
689
|
const conflit = cle && estConflit(erreur);
|
|
689
690
|
if (!conflit) throw erreur;
|
|
690
|
-
try { PLAYER.errors
|
|
691
|
+
try { capturerSansBloquer(PLAYER.errors, new Error("message déjà enregistré (renvoi) : " + String(slug)), { route: "present-chat", benin: true }); } catch { /* jamais bloquant */ }
|
|
691
692
|
const deja = await PLAYER.db.request(
|
|
692
693
|
`doc_presentation_messages?slug=eq.${enc(String(slug))}&client_key=eq.${enc(cle)}&select=*&limit=1`);
|
|
693
694
|
const ligne = Array.isArray(deja) && deja[0];
|
|
@@ -1022,7 +1023,7 @@ async function appelerBump(corps, durcissementVoulu) {
|
|
|
1022
1023
|
// bootstrap auto-déclaré s'emparer d'une présence réclamée — c'est-à-dire une fermeture qui
|
|
1023
1024
|
// rassure sans protéger. On nomme donc le fichier ET la conséquence.
|
|
1024
1025
|
try {
|
|
1025
|
-
PLAYER.errors
|
|
1026
|
+
capturerSansBloquer(PLAYER.errors, new Error(
|
|
1026
1027
|
"bootstrap de présence NON durci : appliquez supabase/migrations/0018-bootstrap-non-usurpable.sql. "
|
|
1027
1028
|
+ "Sans elle, un bootstrap auto-déclaré peut écraser une présence déjà réclamée par un porteur "
|
|
1028
1029
|
+ "de jeton — n'armez pas PLAYER_PRESENCE_STRICT avant de l'avoir appliquée.",
|
|
@@ -1162,7 +1163,7 @@ async function recordAttendance(slug, participant, { presentation = null, ipHash
|
|
|
1162
1163
|
// battements ORDINAIRES, qui continuent de se replier normalement.
|
|
1163
1164
|
if (onlyIfUnclaimed) {
|
|
1164
1165
|
try {
|
|
1165
|
-
PLAYER.errors
|
|
1166
|
+
capturerSansBloquer(PLAYER.errors, new Error(
|
|
1166
1167
|
"bootstrap de présence refusé : le contrôle anti-usurpation n'a pas pu s'exécuter — "
|
|
1167
1168
|
+ ((erreur && erreur.message) || erreur),
|
|
1168
1169
|
), { route: "present-attend" });
|
|
@@ -1181,7 +1182,7 @@ async function recordAttendance(slug, participant, { presentation = null, ipHash
|
|
|
1181
1182
|
const fichier = transitionDispo
|
|
1182
1183
|
? "supabase/migrations/0017-jeton-presence.sql (ou 0015-presence-atomique.sql)"
|
|
1183
1184
|
: "supabase/migrations/0015-presence-atomique.sql";
|
|
1184
|
-
PLAYER.errors
|
|
1185
|
+
capturerSansBloquer(PLAYER.errors, new Error(
|
|
1185
1186
|
"présence non atomique : appliquez " + fichier + ". "
|
|
1186
1187
|
+ "Sans elle, la présence est écrite par lire-modifier-réécrire (correct) mais le plafond "
|
|
1187
1188
|
+ "de création de faux participants anonymes n'est pas appliqué. "
|
|
@@ -1235,7 +1236,7 @@ async function recordAttendance(slug, participant, { presentation = null, ipHash
|
|
|
1235
1236
|
if (!estConflit(erreur)) throw erreur;
|
|
1236
1237
|
// Journalisé comme bénin : deux onglets qui arrivent ensemble sont une information, pas
|
|
1237
1238
|
// une panne — et la garde des écritures muettes exige que tout rattrapage parle.
|
|
1238
|
-
try { PLAYER.errors
|
|
1239
|
+
try { capturerSansBloquer(PLAYER.errors, new Error("présence déjà ouverte (second onglet) : " + String(slug)), { route: "present-attend", benin: true }); } catch { /* jamais bloquant */ }
|
|
1239
1240
|
continue; // le tour suivant relit : la ligne que l'autre onglet vient de créer
|
|
1240
1241
|
}
|
|
1241
1242
|
}
|
package/server/retention.js
CHANGED
|
@@ -154,7 +154,7 @@ async function retirerFichier(bucket, chemin, opts) {
|
|
|
154
154
|
if (!sansRemoveDit) {
|
|
155
155
|
sansRemoveDit = true;
|
|
156
156
|
try {
|
|
157
|
-
PLAYER.errors
|
|
157
|
+
capturerSansBloquer(PLAYER.errors, new Error("rétention : `storage.remove` n'est pas fourni — les lignes porteuses de fichiers"
|
|
158
158
|
+ " sont RETENUES (leur objet resterait sinon inatteignable, la capacité n'exposant jamais `list`)."
|
|
159
159
|
+ " Fournissez `storage.remove` pour qu'elles partent."), { route: "retention", benin: true });
|
|
160
160
|
} catch { /* jamais bloquant */ }
|
|
@@ -232,6 +232,7 @@ async function resteEncore(table, filtre, colId, curseur, dryRun) {
|
|
|
232
232
|
// avec le slug de la présentation purgée. Une validation d'écriture n'est jamais la seule barrière
|
|
233
233
|
// d'un delete : les lignes déjà en base d'avant le correctif peuvent porter une URL piégée.
|
|
234
234
|
const { cheminPieceJointe: cheminSurSlug } = require("./presentations");
|
|
235
|
+
const { capturerSansBloquer } = require("./capture");
|
|
235
236
|
|
|
236
237
|
// Purge des messages d'une présentation morte, par lots bornés qui lisent id+attachment ENSEMBLE :
|
|
237
238
|
// on retire les fichiers du bucket du lot (si l'hôte sait), puis on supprime le lot. Rend `tronque`
|
|
@@ -360,7 +361,7 @@ async function purgerRetention(now, optsBrutes = {}) {
|
|
|
360
361
|
try { f = fenetresValidees(); opts = optionsValidees(optsBrutes); }
|
|
361
362
|
catch (e) {
|
|
362
363
|
if (!e.retentionInvalide) throw e;
|
|
363
|
-
try { PLAYER.errors
|
|
364
|
+
try { capturerSansBloquer(PLAYER.errors, e, { route: "retention" }); } catch { /* jamais bloquant */ }
|
|
364
365
|
return { ok: false, error: e.message }; // config OU option douteuse → zéro DELETE
|
|
365
366
|
}
|
|
366
367
|
const base = String((PLAYER.config && PLAYER.config.supabaseUrl) || "");
|
|
@@ -480,7 +481,7 @@ function tick() {
|
|
|
480
481
|
Promise.resolve()
|
|
481
482
|
.then(() => PLAYER.limits.allow("retention:sweep", 1, 86400))
|
|
482
483
|
.then((permis) => { if (permis) return purgerRetention(Date.now()); })
|
|
483
|
-
.catch((e) => { try { PLAYER.errors
|
|
484
|
+
.catch((e) => { try { capturerSansBloquer(PLAYER.errors, e, { route: "retention", benin: true }); } catch { /* jamais bloquant */ } });
|
|
484
485
|
}
|
|
485
486
|
|
|
486
487
|
/**
|
package/server/routes-agent.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
// des bancs) n'ont que WebCrypto, et la route rendait alors 500 à la première synthèse. Le banc
|
|
10
10
|
// agentVoix.test.js échoue sans cette ligne.
|
|
11
11
|
const crypto = require("node:crypto");
|
|
12
|
+
const { capturerSansBloquer } = require("./capture");
|
|
12
13
|
const { adresseAppelant } = require("./appelant");
|
|
13
14
|
const { jsonPour, etiquetteRoute } = require("./reponses.js");
|
|
14
15
|
|
|
@@ -355,7 +356,7 @@ async function traiter(req, res, body, _slug) {
|
|
|
355
356
|
cached: extrait.cached || undefined,
|
|
356
357
|
spoken: spoken !== text ? spoken : undefined,
|
|
357
358
|
});
|
|
358
|
-
} catch (e) { try { PLAYER.errors
|
|
359
|
+
} catch (e) { try { capturerSansBloquer(PLAYER.errors, e, { route: etiquetteRoute(body.action) }); } catch { /* jamais bloquant */ } return jp(500, { ok: false }); }
|
|
359
360
|
}
|
|
360
361
|
if (body.action === "bot-start" || body.action === "bot-say" || body.action === "bot-history" || body.action === "bot-nudge" || body.action === "bot-book" || body.action === "bot-contact" || body.action === "bot-rate" || body.action === "bot-script") {
|
|
361
362
|
const jp = jsonPour(res);
|
|
@@ -470,7 +471,7 @@ async function traiter(req, res, body, _slug) {
|
|
|
470
471
|
const r = await docbot.botSay(String(body.sessionId || ""), share, text, pages, mobile, blang);
|
|
471
472
|
if (r.error) return jp(400, { ok: false, error: r.error });
|
|
472
473
|
return jp(200, { ok: true, ...r });
|
|
473
|
-
} catch (e) { try { PLAYER.errors
|
|
474
|
+
} catch (e) { try { capturerSansBloquer(PLAYER.errors, e, { route: etiquetteRoute(body.action) }); } catch { /* jamais bloquant */ } return jp(500, { ok: false }); }
|
|
474
475
|
}
|
|
475
476
|
// Assistance (heartbeat) : PUBLIC (l'audience est anonyme). Journalise qui suit / combien de temps / pages vues.
|
|
476
477
|
// Rate-limit généreux par IP (heartbeat ≈ 145/h/participant) : bloque le spam d'assistants factices sans
|
package/server/routes-direct.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// Reste à PLAT dans server/ (les gardes de forge ciblent server/*.js).
|
|
5
5
|
|
|
6
6
|
const { adresseAppelant, lcMembre, cleAnonyme, profilDuJeton } = require("./appelant");
|
|
7
|
+
const { capturerSansBloquer } = require("./capture");
|
|
7
8
|
const { jsonPour, etiquetteRoute } = require("./reponses.js");
|
|
8
9
|
|
|
9
10
|
const { createPresentation, getPresentation, setPage, endPresentation, addMessage, toggleReaction, editMessage, deleteMessage, setChatLock, createUploadUrl, reclaimPresentation, touchPresentation, listActivePresentations, handoverPresentation, endPresentationByOwner, recordAttendance, presentationStats, listPresentationsForDoc, switchPresentationDoc, setPresentationContent } = require("./presentations");
|
|
@@ -49,7 +50,7 @@ async function traiter(req, res, body, _slug) {
|
|
|
49
50
|
// hôte, chaque « Terminer » échouait en 23502 (marqueur d'archive NOT NULL) — et ce 500
|
|
50
51
|
// muet ne laissait RIEN, même pas une ligne dans le journal d'erreurs. Un journal que
|
|
51
52
|
// personne ne lit vaut peu ; aucun journal ne vaut rien du tout.
|
|
52
|
-
try { PLAYER.errors
|
|
53
|
+
try { capturerSansBloquer(PLAYER.errors, erreur instanceof Error ? erreur : new Error(String(erreur)), { route: etiquetteRoute("present-" + String(body.action || "").replace(/^present-/, "")) }); } catch { /* jamais bloquant */ }
|
|
53
54
|
return jp(500, { ok: false });
|
|
54
55
|
}
|
|
55
56
|
}
|
|
@@ -174,7 +175,7 @@ async function traiter(req, res, body, _slug) {
|
|
|
174
175
|
if (PLAYER.config && PLAYER.config.presenceStrict && !peutEmettre && !profil) {
|
|
175
176
|
try {
|
|
176
177
|
if (await PLAYER.limits.allow("presence:strict-inerte", 1, 3600)) {
|
|
177
|
-
PLAYER.errors
|
|
178
|
+
capturerSansBloquer(PLAYER.errors, new Error(
|
|
178
179
|
"PLAYER_PRESENCE_STRICT est posé mais AUCUN jeton ne peut être émis "
|
|
179
180
|
+ "(PLAYER_PRESENCE_SECRET absent) : la porte reste OUVERTE. Armé tel quel, elle "
|
|
180
181
|
+ "refuserait tous les participants anonymes.",
|
|
@@ -228,7 +229,7 @@ async function traiter(req, res, body, _slug) {
|
|
|
228
229
|
// des présences) : une présentation close refuse toute écriture, jeton valide ou non.
|
|
229
230
|
const pt = (r.ok && jetonCandidat) ? jetonCandidat : "";
|
|
230
231
|
return jp(r.ok ? 200 : (r.status || 400), pt ? { ...r, pt } : r);
|
|
231
|
-
} catch (e) { try { PLAYER.errors
|
|
232
|
+
} catch (e) { try { capturerSansBloquer(PLAYER.errors, e, { route: etiquetteRoute(body.action) }); } catch { /* jamais bloquant */ } return jp(500, { ok: false }); }
|
|
232
233
|
}
|
|
233
234
|
// Gestion des présentations (membre AUTHENTIFIÉ requis) : liste / reprise / transfert / stats / historique doc.
|
|
234
235
|
if (body.action === "present-list" || body.action === "present-reclaim" || body.action === "present-handover" || body.action === "present-owner-end" || body.action === "present-stats" || body.action === "present-doc-list" || body.action === "present-switch" || body.action === "present-content") {
|
|
@@ -281,7 +282,7 @@ async function traiter(req, res, body, _slug) {
|
|
|
281
282
|
else if (body.action === "present-content") r = await setPresentationContent(String(body.slug || ""), (u && u.email) || "", isAdmin, body.content, String(body.control || ""));
|
|
282
283
|
else r = await handoverPresentation(String(body.slug || ""), u.email, body.newOwner);
|
|
283
284
|
return jp(r.ok ? 200 : (r.status || 400), r);
|
|
284
|
-
} catch (e) { try { PLAYER.errors
|
|
285
|
+
} catch (e) { try { capturerSansBloquer(PLAYER.errors, e, { route: etiquetteRoute(body.action) }); } catch { /* jamais bloquant */ } return jp(500, { ok: false }); }
|
|
285
286
|
}
|
|
286
287
|
// Chat de présentation (historisé) : n'importe quel participant (présentateur ou audience) poste un
|
|
287
288
|
// message. Écriture via service role ; anti-spam par IP (60/h). La présentation doit exister.
|
|
@@ -313,7 +314,7 @@ async function traiter(req, res, body, _slug) {
|
|
|
313
314
|
avatar: profil ? profil.avatar : null,
|
|
314
315
|
isPresenter: validControl, isMember: !!profil, body: body.body, replyTo: body.replyTo, replyName: body.replyName, replyText: body.replyText, authorToken: body.authorToken, attachment: body.attachment , clientKey: body.clientKey });
|
|
315
316
|
return jp(r.ok ? 200 : (r.status || 400), r);
|
|
316
|
-
} catch (e) { try { PLAYER.errors
|
|
317
|
+
} catch (e) { try { capturerSansBloquer(PLAYER.errors, e, { route: etiquetteRoute(body.action) }); } catch { /* jamais bloquant */ } return jp(500, { ok: false }); }
|
|
317
318
|
}
|
|
318
319
|
// Pièce jointe : URL d'upload signée (la présentation doit exister ; rate-limit).
|
|
319
320
|
if (body.action === "present-upload-url") {
|
|
@@ -326,7 +327,7 @@ async function traiter(req, res, body, _slug) {
|
|
|
326
327
|
if (!allowed) return jp(429, { ok: false, error: "rate" });
|
|
327
328
|
const r = await createUploadUrl(String(body.slug || ""), body.name, body.type);
|
|
328
329
|
return jp(r.ok ? 200 : (r.status || 400), r);
|
|
329
|
-
} catch (e) { try { PLAYER.errors
|
|
330
|
+
} catch (e) { try { capturerSansBloquer(PLAYER.errors, e, { route: etiquetteRoute(body.action) }); } catch { /* jamais bloquant */ } return jp(500, { ok: false }); }
|
|
330
331
|
}
|
|
331
332
|
// Chat : éditer / supprimer un message, verrouiller le chat.
|
|
332
333
|
if (body.action === "present-msg-edit" || body.action === "present-msg-delete" || body.action === "present-chatlock") {
|
|
@@ -337,7 +338,7 @@ async function traiter(req, res, body, _slug) {
|
|
|
337
338
|
else if (body.action === "present-msg-delete") r = await deleteMessage(String(body.slug || ""), body.msgId, { authorToken: body.authorToken, control: body.control });
|
|
338
339
|
else r = await setChatLock(String(body.slug || ""), String(body.control || ""), !!body.locked);
|
|
339
340
|
return jp(r.ok ? 200 : (r.status || 400), r);
|
|
340
|
-
} catch (e) { try { PLAYER.errors
|
|
341
|
+
} catch (e) { try { capturerSansBloquer(PLAYER.errors, e, { route: etiquetteRoute(body.action) }); } catch { /* jamais bloquant */ } return jp(500, { ok: false }); }
|
|
341
342
|
}
|
|
342
343
|
// Réaction emoji (toggle) sur un message du chat de présentation.
|
|
343
344
|
if (body.action === "present-react") {
|
|
@@ -357,7 +358,7 @@ async function traiter(req, res, body, _slug) {
|
|
|
357
358
|
const reacteur = require("./presentations").reacteurDepuisJeton(body.authorToken);
|
|
358
359
|
const r = await toggleReaction(String(body.slug || ""), body.msgId, body.emoji, reacteur, body.etat);
|
|
359
360
|
return jp(r.ok ? 200 : (r.status || 400), r);
|
|
360
|
-
} catch (e) { try { PLAYER.errors
|
|
361
|
+
} catch (e) { try { capturerSansBloquer(PLAYER.errors, e, { route: etiquetteRoute(body.action) }); } catch { /* jamais bloquant */ } return jp(500, { ok: false }); }
|
|
361
362
|
}
|
|
362
363
|
return false;
|
|
363
364
|
}
|
package/server/routes-liens.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// Reste à PLAT dans server/ (les gardes de forge ciblent server/*.js).
|
|
5
5
|
|
|
6
6
|
const { adresseAppelant } = require("./appelant");
|
|
7
|
+
const { capturerSansBloquer } = require("./capture");
|
|
7
8
|
const { jsonPour, repondreJson, etiquetteRoute } = require("./reponses.js");
|
|
8
9
|
const { estConflit } = require("./erreurs-base.js");
|
|
9
10
|
const { createShare, createReshare, sendReshareEmail, revokeShare, setShareAuth, listSharesForDoc, listSessionsForDoc, listSessionsForRecipient, internalStatsForDoc, cleIdempotence, getShareBySlug, logView, upsertSession, upsertInternalSession, overview: docOverview } = require("./shares");
|
|
@@ -56,7 +57,7 @@ async function traiter(req, res, body, slug) {
|
|
|
56
57
|
const { action: _a, ...opts } = body;
|
|
57
58
|
const resultat = await require("./retention").purgerRetention(Date.now(), opts);
|
|
58
59
|
return jd(resultat.ok === false ? 400 : 200, resultat);
|
|
59
|
-
} catch (e) { try { PLAYER.errors
|
|
60
|
+
} catch (e) { try { capturerSansBloquer(PLAYER.errors, e, { route: "retention" }); } catch { /* jamais bloquant */ } return jd(500, { ok: false }); }
|
|
60
61
|
}
|
|
61
62
|
if (String(body.action || "").startsWith("docshare.")) {
|
|
62
63
|
const jd = jsonPour(res);
|
|
@@ -159,7 +160,7 @@ async function traiter(req, res, body, slug) {
|
|
|
159
160
|
});
|
|
160
161
|
} catch (erreur) {
|
|
161
162
|
if (!estConflit(erreur)) throw erreur;
|
|
162
|
-
try { PLAYER.errors
|
|
163
|
+
try { capturerSansBloquer(PLAYER.errors, new Error("backfill hôte : la clé était déjà posée ailleurs — " + docId), { route: "hostshare", benin: true }); } catch { /* jamais bloquant */ }
|
|
163
164
|
const gagnant = await PLAYER.db.request(`commercial_doc_shares?idem_key=eq.${encodeURIComponent(cleHote)}&select=slug&limit=1`);
|
|
164
165
|
if (!Array.isArray(gagnant) || !gagnant[0]) throw erreur;
|
|
165
166
|
return jd(200, { ok: true, slug: gagnant[0].slug, reused: true });
|
|
@@ -186,7 +187,7 @@ async function traiter(req, res, body, slug) {
|
|
|
186
187
|
return jd(200, { ok: true, slug: neuf.slug, reused: false });
|
|
187
188
|
} catch (erreur) {
|
|
188
189
|
if (!estConflit(erreur)) throw erreur;
|
|
189
|
-
try { PLAYER.errors
|
|
190
|
+
try { capturerSansBloquer(PLAYER.errors, new Error("lien hôte déjà créé par une demande simultanée : " + docId), { route: "hostshare", benin: true }); } catch { /* jamais bloquant */ }
|
|
190
191
|
const gagnant = await PLAYER.db.request(`commercial_doc_shares?idem_key=eq.${encodeURIComponent(cleHote)}&select=slug&limit=1`);
|
|
191
192
|
if (!Array.isArray(gagnant) || !gagnant[0]) throw erreur; // 409 d'autre chose : on ne l'invente pas
|
|
192
193
|
return jd(200, { ok: true, slug: gagnant[0].slug, reused: true });
|
|
@@ -266,7 +267,7 @@ async function traiter(req, res, body, slug) {
|
|
|
266
267
|
await PLAYER.db.request(`commercial_doc_shares?slug=eq.${encodeURIComponent(ex[0].slug)}`, { method: "PATCH", headers: { Prefer: "return=minimal" }, body: { doc_title: body.docTitle || null, file_url: String(body.fileUrl), file_name: body.fileName || null, bot_enabled: true, bot_guided: true, bot_profile_id: (body.profileId || "").trim() || null, revoked: false, ...(cleDispo ? { idem_key: cleTest } : {}) } });
|
|
267
268
|
} catch (erreur) {
|
|
268
269
|
if (!estConflit(erreur)) throw erreur;
|
|
269
|
-
try { PLAYER.errors
|
|
270
|
+
try { capturerSansBloquer(PLAYER.errors, new Error("backfill répétition : la clé était déjà posée ailleurs — " + docId), { route: "docshare-test", benin: true }); } catch { /* jamais bloquant */ }
|
|
270
271
|
const gagnant = await PLAYER.db.request(`commercial_doc_shares?idem_key=eq.${encodeURIComponent(cleTest)}&select=slug&limit=1`);
|
|
271
272
|
if (!Array.isArray(gagnant) || !gagnant[0]) throw erreur;
|
|
272
273
|
return jd(200, { ok: true, slug: gagnant[0].slug, reused: true });
|
|
@@ -279,7 +280,7 @@ async function traiter(req, res, body, slug) {
|
|
|
279
280
|
return jd(200, { ok: true, slug: t.slug });
|
|
280
281
|
} catch (erreur) {
|
|
281
282
|
if (!estConflit(erreur)) throw erreur;
|
|
282
|
-
try { PLAYER.errors
|
|
283
|
+
try { capturerSansBloquer(PLAYER.errors, new Error("lien de répétition déjà créé par une demande simultanée : " + docId), { route: "docshare-test", benin: true }); } catch { /* jamais bloquant */ }
|
|
283
284
|
const gagnant = await PLAYER.db.request(`commercial_doc_shares?idem_key=eq.${encodeURIComponent(cleTest)}&select=slug&limit=1`);
|
|
284
285
|
if (!Array.isArray(gagnant) || !gagnant[0]) throw erreur;
|
|
285
286
|
return jd(200, { ok: true, slug: gagnant[0].slug, reused: true });
|
|
@@ -291,7 +292,7 @@ async function traiter(req, res, body, slug) {
|
|
|
291
292
|
}
|
|
292
293
|
const { slug } = await createShare({ brandKey: body.brandKey, docId: body.docId, docTitle: body.docTitle, fileUrl: body.fileUrl, fileName: body.fileName, recipientEmail: body.recipientEmail, recipientName: body.recipientName, createdBy: u.email, bot: body.bot, botScript: body.botScript, guided: body.guided, profileId: body.profileId, allowDownload: body.allowDownload, videoLayout: body.videoLayout, logo: body.logo, logoDark: body.logoDark });
|
|
293
294
|
return jd(200, { ok: true, slug });
|
|
294
|
-
} catch (e) { try { PLAYER.errors
|
|
295
|
+
} catch (e) { try { capturerSansBloquer(PLAYER.errors, e, { route: etiquetteRoute(body.action) }); } catch { /* jamais bloquant */ } return jd(500, { ok: false }); }
|
|
295
296
|
}
|
|
296
297
|
|
|
297
298
|
// Re-partage (forward depuis la visionneuse) : crée un lien enfant tracé, et envoie l'email via 3D
|
|
@@ -369,7 +370,7 @@ async function traiter(req, res, body, slug) {
|
|
|
369
370
|
// Signalé par la seconde passe d'audit (P1-1).
|
|
370
371
|
const publique = String(PLAYER.legal.publicUrl || "").trim();
|
|
371
372
|
if (!publique) {
|
|
372
|
-
try { PLAYER.errors
|
|
373
|
+
try { capturerSansBloquer(PLAYER.errors, new Error("PLAYER_PUBLIC_URL non configurée : envoi refusé (le lien de l'email serait construit depuis l'en-tête Host, que le client choisit)"), { route: "reshare" }); } catch { /* jamais bloquant */ }
|
|
373
374
|
refusEnvoi = "public-url-unconfigured";
|
|
374
375
|
throw new Error("URL publique non configurée");
|
|
375
376
|
}
|
|
@@ -468,7 +469,7 @@ async function traiter(req, res, body, slug) {
|
|
|
468
469
|
// et le signalement passe AVANT le `return`, sans quoi il ne s'exécuterait jamais.
|
|
469
470
|
try {
|
|
470
471
|
if (await PLAYER.limits.allow("intsess:quota-avert", 1, 3600)) {
|
|
471
|
-
PLAYER.errors
|
|
472
|
+
capturerSansBloquer(PLAYER.errors, new Error(`session interne refusée : quota horaire atteint (${SESSION_QUOTA_PER_HOUR}/h par adresse) — la mesure s'arrête tant qu'il l'est`), { route: "internal-session" });
|
|
472
473
|
}
|
|
473
474
|
} catch { /* un journal ne doit jamais empêcher une lecture */ }
|
|
474
475
|
repondreJson(res, 429, { ok: false, error: "rate" });
|
|
@@ -486,7 +487,7 @@ async function traiter(req, res, body, slug) {
|
|
|
486
487
|
// Une fois par heure et par instance : assez pour être vu dans les journaux, pas assez
|
|
487
488
|
// pour les noyer — un avertissement répété à chaque battement ne se lit plus.
|
|
488
489
|
if (await PLAYER.limits.allow("intsess:avert", 1, 3600)) {
|
|
489
|
-
try { PLAYER.errors
|
|
490
|
+
try { capturerSansBloquer(PLAYER.errors, new Error("session interne écrite sans jeton : l'identité vient du navigateur. Poser PLAYER_INTERNAL_STRICT=1 une fois l'hôte à jour"), { route: "internal-session" }); } catch { /* ignore */ }
|
|
490
491
|
}
|
|
491
492
|
}
|
|
492
493
|
// Le jeton fait foi quand il est là : c'est l'hôte qui se porte garant, pas l'appelant.
|
|
@@ -517,7 +518,7 @@ async function traiter(req, res, body, slug) {
|
|
|
517
518
|
// de mesure.
|
|
518
519
|
try {
|
|
519
520
|
if (await PLAYER.limits.allow("intsess:echec", 1, 3600)) {
|
|
520
|
-
PLAYER.errors
|
|
521
|
+
capturerSansBloquer(PLAYER.errors, new Error(`écriture de session interne refusée : ${e && e.message ? e.message : "cause inconnue"} — la mesure ne s'enregistre pas`), { route: "internal-session" });
|
|
521
522
|
}
|
|
522
523
|
} catch { /* un journal ne doit jamais empêcher une lecture */ }
|
|
523
524
|
}
|
|
@@ -545,7 +546,7 @@ async function traiter(req, res, body, slug) {
|
|
|
545
546
|
// que l'exploitant peut relier à un quota, plutôt qu'une mesure qui stagne sans explication.
|
|
546
547
|
try {
|
|
547
548
|
const avert = estSession ? "sess:quota-avert" : "view:quota-avert";
|
|
548
|
-
if (await PLAYER.limits.allow(avert, 1, 3600)) PLAYER.errors
|
|
549
|
+
if (await PLAYER.limits.allow(avert, 1, 3600)) capturerSansBloquer(PLAYER.errors, new Error(`télémétrie externe abandonnée (${body.event}) : quota horaire atteint (${quotaTrack}/h par adresse)`), { route: "track", abandon: true });
|
|
549
550
|
} catch { /* jamais bloquant */ }
|
|
550
551
|
repondreJson(res, 200, { ok: true });
|
|
551
552
|
return;
|
|
@@ -565,7 +566,7 @@ async function traiter(req, res, body, slug) {
|
|
|
565
566
|
// n'aurait rien vu : c'est ce qui rend la classe dangereuse, pas l'instance.
|
|
566
567
|
try {
|
|
567
568
|
if (await PLAYER.limits.allow("sess:echec", 1, 3600)) {
|
|
568
|
-
PLAYER.errors
|
|
569
|
+
capturerSansBloquer(PLAYER.errors, new Error(`écriture de mesure refusée : ${e && e.message ? e.message : "cause inconnue"} — la lecture n'est pas comptée`), { route: "track" });
|
|
569
570
|
}
|
|
570
571
|
} catch { /* un journal ne doit jamais empêcher une lecture */ }
|
|
571
572
|
}
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// Reste à PLAT dans server/ (les gardes de forge ciblent server/*.js).
|
|
5
5
|
|
|
6
6
|
const { adresseAppelant } = require("./appelant");
|
|
7
|
+
const { capturerSansBloquer } = require("./capture");
|
|
7
8
|
const { repondreJson } = require("./reponses.js");
|
|
8
9
|
|
|
9
10
|
const { getShareBySlug } = require("./shares");
|
|
@@ -54,7 +55,7 @@ function direLeRepli(pourquoi) {
|
|
|
54
55
|
if (repliDit) return;
|
|
55
56
|
repliDit = true;
|
|
56
57
|
try {
|
|
57
|
-
PLAYER.errors
|
|
58
|
+
capturerSansBloquer(PLAYER.errors, new Error(`mur visiteur : ${pourquoi} — les compteurs par identité utilisent une EMPREINTE de l'email (SHA-256 tronqué), pseudonyme mais renversable par dictionnaire. Fournissez rateLimitKey (HMAC, secret côté hôte, séparation de domaine).`), { route: "visitor", benin: true });
|
|
58
59
|
} catch { /* jamais bloquant */ }
|
|
59
60
|
}
|
|
60
61
|
const init = (ctx) => { PLAYER = ctx; repliDit = false; };
|
|
@@ -95,7 +96,7 @@ async function traiter(req, res, body, _slug) {
|
|
|
95
96
|
// noms. Une liste ne voit que ce qu'on y a mis ; une forme voit aussi le prochain.
|
|
96
97
|
try {
|
|
97
98
|
if (await PLAYER.limits.allow("unlock:echec", 1, 3600)) {
|
|
98
|
-
PLAYER.errors
|
|
99
|
+
capturerSansBloquer(PLAYER.errors, new Error(`déverrouillage visiteur non journalisé : ${e && e.message ? e.message : "cause inconnue"}`), { route: "visitor-unlock" });
|
|
99
100
|
}
|
|
100
101
|
} catch { /* un journal ne doit jamais empêcher une lecture */ }
|
|
101
102
|
}
|
package/server/schema.js
CHANGED
|
@@ -52,6 +52,8 @@
|
|
|
52
52
|
// — il balaie le dossier et refuse un nom qui déclencherait la garde, au moment où on l'écrit.
|
|
53
53
|
// (La forme de la règle vient du STUDIO : une règle tenue par habitude a un taux de couverture que
|
|
54
54
|
// personne ne mesure.)
|
|
55
|
+
const { capturerSansBloquer } = require("./capture");
|
|
56
|
+
|
|
55
57
|
const RACINE_MIGRATIONS = "supabase/migrations/";
|
|
56
58
|
|
|
57
59
|
const ATTENDUES = {
|
|
@@ -460,7 +462,7 @@ const PORTEE_FUSION =
|
|
|
460
462
|
// plus rien.
|
|
461
463
|
async function journaliser(cle, message) {
|
|
462
464
|
try {
|
|
463
|
-
if (await PLAYER.limits.allow(cle, 1, 3600)) PLAYER.errors
|
|
465
|
+
if (await PLAYER.limits.allow(cle, 1, 3600)) capturerSansBloquer(PLAYER.errors, new Error(message), { route: "schema" });
|
|
464
466
|
} catch { /* jamais bloquant */ }
|
|
465
467
|
}
|
|
466
468
|
|
|
@@ -529,7 +531,7 @@ async function ajouterMigrationsDePresence(etat) {
|
|
|
529
531
|
// boucle, et un journal sans frein deviendrait une arme.
|
|
530
532
|
try {
|
|
531
533
|
if (await PLAYER.limits.allow("schema:durcissement-absent", 1, 3600)) {
|
|
532
|
-
PLAYER.errors
|
|
534
|
+
capturerSansBloquer(PLAYER.errors, new Error(
|
|
533
535
|
absente
|
|
534
536
|
? "migration 0018-bootstrap-non-usurpable.sql ABSENTE : les bootstraps de présence ne "
|
|
535
537
|
+ "sont pas contrôlés. N'armez pas PLAYER_PRESENCE_STRICT avant de l'appliquer — il "
|
package/server/shares.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// GED commerciale : liens de partage tracés (un par destinataire) + agrégation des consultations.
|
|
4
4
|
// Tables service-role only (cf. migration v12321) → tout passe par le service role ici.
|
|
5
5
|
const crypto = require("crypto");
|
|
6
|
+
const { capturerSansBloquer } = require("./capture");
|
|
6
7
|
const { signatureAbsente } = require("./erreurs-base.js");
|
|
7
8
|
// Tout ce qui vient de l'hôte passe par le contexte injecté — base, email, marque. C'est ce qui
|
|
8
9
|
// permettra à ce fichier de partir dans le dépôt du player sans emporter le studio avec lui.
|
|
@@ -434,7 +435,7 @@ async function overviewEnBase(since) {
|
|
|
434
435
|
lastAt: r.last_at || null,
|
|
435
436
|
}])) || [];
|
|
436
437
|
} catch (erreur) {
|
|
437
|
-
try { PLAYER.errors
|
|
438
|
+
try { capturerSansBloquer(PLAYER.errors, erreur, { route: "overview", indice: "consultations internes indisponibles — la vue d'ensemble ne montrera que les ouvertures client" }); } catch { /* jamais bloquant */ }
|
|
438
439
|
}
|
|
439
440
|
return { byDoc: new Map(vues), intByDoc: new Map(internes) };
|
|
440
441
|
}
|
|
@@ -935,7 +936,7 @@ async function upsertInternalSession(p, { ip: _ip, ua }) {
|
|
|
935
936
|
try {
|
|
936
937
|
if (await PLAYER.limits.allow("intsess:jetee", 1, 3600)) {
|
|
937
938
|
const manque = !sessionId ? "sessionId" : "docId";
|
|
938
|
-
PLAYER.errors
|
|
939
|
+
capturerSansBloquer(PLAYER.errors, new Error(`session interne jetée : ${manque} absent — rien ne sera mesuré tant qu'il manque`), { route: "internal-session" });
|
|
939
940
|
}
|
|
940
941
|
} catch { /* un journal ne doit jamais empêcher une lecture */ }
|
|
941
942
|
return;
|
package/types/context.d.ts
CHANGED
|
@@ -129,17 +129,19 @@ export interface Reglages {
|
|
|
129
129
|
mapsKey: string;
|
|
130
130
|
extraFrameAncestors: string[];
|
|
131
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`).
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
|
|
132
|
+
* sinon le défaut, dit une fois à `init`). Une chaîne — celle de l'environnement — est acceptée
|
|
133
|
+
* telle quelle et bornée par le cœur, qui cite ce qu'il a reçu s'il la refuse. Au-delà, le relais
|
|
134
|
+
* répond 503 + Retry-After AVANT l'appel amont ; la place est rendue en `finally`. 64 n'est pas sûr
|
|
135
|
+
* partout : 64 × 8 Mio avec des clients lents ont fait monter la RSS de 63 à 193–257 Mio (une
|
|
136
|
+
* croissance de 130 à 194) — 16 à 32 sur un processus à 256 Mio. */
|
|
137
|
+
maxConcurrentRelays?: number | string;
|
|
136
138
|
/** Un relais sans progression pendant ce délai est abandonné — source et réponse détruites, place
|
|
137
139
|
* rendue (défaut 30 000 ms ; entier de 1 à 86 400 000 ms, sinon le défaut, dit une fois à `init` :
|
|
138
140
|
* `setTimeout` ramène à 1 ms tout délai au-delà de 2 147 483 647 ms). `requestTimeout` ne borne
|
|
139
141
|
* pas l'émission d'une réponse. */
|
|
140
|
-
relayStallMs?: number;
|
|
142
|
+
relayStallMs?: number | string;
|
|
141
143
|
/** Durée totale maximale d'un relais (défaut 900 000 ms ; même plage que `relayStallMs`). */
|
|
142
|
-
relayMaxMs?: number;
|
|
144
|
+
relayMaxMs?: number | string;
|
|
143
145
|
/** Ce qui est POSÉ, à côté de ce que le code SAIT faire : la carte d'identité publie les deux,
|
|
144
146
|
* parce qu'une capacité disponible mais non configurée se comporte comme une absence. */
|
|
145
147
|
separateIssuer?: boolean;
|