discovery-media-player 0.1.139 → 0.1.142

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/serve.js CHANGED
@@ -108,8 +108,33 @@ async function pageAccueil(racine) {
108
108
  </div></body></html>`;
109
109
  }
110
110
 
111
- const serveur = http.createServer(async (req, res) => {
112
- const url = new URL(req.url, `http://${req.headers.host || "localhost"}`);
111
+ /**
112
+ * ⚠️ LE TRAITEMENT VIT DANS UNE FONCTION, ET C'EST L'ÉCOUTEUR QUI RATTRAPE. Ce corps était passé
113
+ * directement à `createServer` en fonction `async` : `http` n'attend pas la promesse rendue, donc
114
+ * TOUTE exception levée hors du `try` du bas — analyse de l'URL, page d'accueil, lecture du corps —
115
+ * devenait un rejet non géré, et Node sort du processus sur un rejet non géré. UNE SEULE REQUÊTE
116
+ * ANONYME ARRÊTAIT DONC LE SERVEUR : constaté sur la v0.1.139, code de sortie 1, port fermé, les
117
+ * requêtes suivantes refusées. Le `try` existant ne couvrait que `player.handler`.
118
+ *
119
+ * ⚠️ ET LA BASE DE L'URL EST FIXE — L'EN-TÊTE `Host` DU CLIENT N'EN EST PAS UNE. Écrire
120
+ * `new URL(req.url, "http://" + host)` fait entrer une chaîne choisie par l'appelant dans un
121
+ * analyseur qui jette : `Host: [` donne la base `http://[`, invalide, exception. Or rien ici ne lit
122
+ * l'hôte — seuls `pathname` et `searchParams` servent : il n'apportait QUE sa panne.
123
+ *
124
+ * ⚠️ LA BONNE FORME ÉTAIT DÉJÀ ÉCRITE À CÔTÉ, ET CELLE-CI ÉTAIT LA COPIE DIVERGENTE.
125
+ * `server/handler.js` analyse depuis une base interne fixe, et le lien des courriels de re-partage
126
+ * a quitté `req.headers.host` pour `PLAYER_PUBLIC_URL` (cf. `server/__tests__/originePublique.test.js`).
127
+ * Deux fois la même leçon : une origine publique se DÉCLARE, elle ne se devine pas dans un en-tête.
128
+ */
129
+ async function servir(req, res) {
130
+ let url;
131
+ try {
132
+ url = new URL(req.url || "/", "http://interne");
133
+ } catch {
134
+ // La cible de requête elle-même est malformée : c'est le client qui a tort, pas le serveur.
135
+ player.refuserEnTexte(res, 400, "Bad request");
136
+ return;
137
+ }
113
138
 
114
139
  // Point de santé : un orchestrateur doit pouvoir savoir si le processus répond sans ouvrir un
115
140
  // document ni toucher la base.
@@ -165,6 +190,17 @@ const serveur = http.createServer(async (req, res) => {
165
190
  // de l'écriture ne peut plus rien poser, et tenter de le faire jetterait dans le rattrapage.
166
191
  player.refuserEnTexte(res, 500, "Erreur");
167
192
  }
193
+ }
194
+
195
+ const serveur = http.createServer((req, res) => {
196
+ // ⚠️ L'ÉCOUTEUR N'EST PAS `async` : il RATTRAPE. `void servir(...).catch(...)` est précisément ce
197
+ // qui transforme un rejet fatal en réponse. Le `try` autour de `player.handler` reste — il
198
+ // distingue une erreur du player d'une erreur du serveur — ; celui-ci est le filet de tout le
199
+ // reste, y compris de ce que personne n'a encore ajouté au-dessus.
200
+ void servir(req, res).catch((error) => {
201
+ console.error("[player] erreur non rattrapée", error);
202
+ player.refuserEnTexte(res, 500, "Erreur");
203
+ });
168
204
  });
169
205
 
170
206
  /** Corps JSON, borné. Un corps sans fin est une façon peu coûteuse de faire tomber un serveur. */
@@ -250,4 +286,4 @@ if (require.main === module) {
250
286
  });
251
287
  }
252
288
 
253
- module.exports = { serveur, versParametres, pageAccueil, __arreterProprement: arreterProprement, DELAI_ARRET_MS };
289
+ module.exports = { serveur, servir, versParametres, pageAccueil, __arreterProprement: arreterProprement, DELAI_ARRET_MS };
@@ -41,6 +41,7 @@ need.
41
41
  "presenceDurcissement": "inconnu",
42
42
  "presenceFusion": "inconnu",
43
43
  "lectureSaturee": { "total": 0, "fenetreS": 0, "derniereIlYaS": null },
44
+ "mesures": { "fenetreS": 0, "seauxMs": [1, 2, 5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000], "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 } },
44
45
  "retentionSweep": false,
45
46
  "hostShare": true,
46
47
  "hostMail": true,
@@ -53,6 +54,21 @@ need.
53
54
  never by order. `plugins` lets you refuse to start when you depend on an optional module this
54
55
  instance does not have.
55
56
 
57
+ ⚠️ **`ctx.has(name)` is that same question in code — and it is documented here because it was, until
58
+ 27/08, an accident.** The injected context carries it: `has(name)` answers whether the named plugin
59
+ is present, the same truth the `plugins` field above reports over HTTP. Supply it as
60
+ `has: (name) => !!plugins[name]`; the standalone context returns `false` for everything, having no
61
+ plugins at all.
62
+
63
+ **As measured on 27/08, nothing in `server/` called it** — that is a reading, not an omission of
64
+ this paragraph, and it is why implementing it buys you nothing immediately and skipping it costs
65
+ you nothing. It is written down for the opposite reason: on that day one host was found to
66
+ implement it *correctly and without knowing*, because the type declared it, while the shape lived
67
+ in 57 test fixtures and, **until this paragraph existed**, in no document at all. A seam that
68
+ exists, works, and is written nowhere is one rename away from being deleted as dead — and it would
69
+ not have been dead. If a future feature needs to ask *"does this host have that plugin?"*, this is
70
+ the spelling, and there should not be a second one.
71
+
56
72
  ⚠️ **`runtime` is the only way to see what the player is actually running on.** `nodeRequired` is
57
73
  the floor the package declares, `node` is what the process reports — two numbers, no verdict:
58
74
  compare them with your own semver rather than trusting a field we compute for you.
@@ -92,6 +108,43 @@ The three `presence*` fields report what the host has **observed**, not what it
92
108
  | `presenceDurcissement` | `actif` (a hardened call came back), `degrade` (migration 0018 is missing), `inconnu` (nothing attempted in this process — **not** a green light, and process-local: another instance may have seen otherwise) |
93
109
  | `presenceFusion` | `actif` (a heartbeat used the fused contract — one round trip instead of two), `degrade` (migration 0019 is missing: heartbeats cost 3 round trips instead of 2, nothing breaks), `inconnu` (no heartbeat served in this process). Same three states, same trap, same reading rule as the row above |
94
110
 
111
+ ### `mesures` — what this instance has actually lived through
112
+
113
+ `lectureSaturee` (below) answers exactly one question. *Is a route slow? which ones? us or the
114
+ database? how many 5xx? is the event loop slipping?* had **no observable answer at all** — and
115
+ deciding to optimise without them is guessing. Both integrating hosts confirmed they cannot produce
116
+ these numbers from their side.
117
+
118
+ | key | meaning |
119
+ |---|---|
120
+ | `fenetreS` | seconds this process has been running — **the window every total below was counted over** |
121
+ | `seauxMs` | the bucket ladder the percentiles are read off, published **with** the numbers |
122
+ | `routes` | one entry per family of work — `document`, `presentation`, `action`, `fichier`, `carte`, `autre`. Families absent from the object were never exercised in this process |
123
+ | `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 |
124
+ | `statuts` | responses by class: `ok` (<400), `refus4xx`, `debit429`, `occupe503`, `erreur5xx` |
125
+ | `memoireMio` | `rss`, `heap` (heap used), `tampons` (`arrayBuffers`) in MiB, read at the moment of the request |
126
+ | `boucleMs` | event-loop **delay** — `moyen` and `p99` in ms, with `n` samples and the sampler's `resolutionMs` |
127
+
128
+ ⚠️ **A percentile over buckets is a bound, not a value.** `p95sousMs: 250` reads *"95% of calls
129
+ under 250 ms"* — never *"the 95th is 250 ms"*. That is why the key is named `sousMs`, and why
130
+ `seauxMs` ships alongside: without the ladder you cannot judge how precise the number you are
131
+ reading is. `null` means *past the top of the ladder* (over 10 s), which is itself the answer.
132
+
133
+ ⚠️ **`n: 0` is not `0 ms`.** A family that was never exercised reports `{ "n": 0 }` and nothing
134
+ else, and `boucleMs` with no samples reports `moyen: null` — not a zero that would read as *healthy*.
135
+
136
+ ⚠️ **`boucleMs` is the delay, not the interval.** The sampler observes how long its own timer
137
+ actually took, which at rest equals its resolution; the resolution is subtracted, so an idle
138
+ instance reports about `0` rather than a permanent `20` that would send you hunting a fault that
139
+ does not exist.
140
+
141
+ ⚠️ **No slug, no address, no text.** These are counters and durations. Nothing here names a visitor,
142
+ a document or a presentation — which is what makes it publishable on a card you read without
143
+ ceremony.
144
+
145
+ ⚠️ **Process-local, and reset by every deployment**, exactly like `lectureSaturee` below. Behind a
146
+ load balancer this is the instance that answered, not your deployment. Aggregating is your job.
147
+
95
148
  ### `lectureSaturee` — what this instance actually refused
96
149
 
97
150
  The read cache groups concurrent requests for the same presentation state and admits a bounded
@@ -179,10 +232,20 @@ it. `verdict` is then one of:
179
232
 
180
233
  | verdict | meaning |
181
234
  |---|---|
182
- | `non-sonde` | nothing asked yet — **not** *nothing missing* |
183
- | `partiel` | some expectations checked, none of them missing |
235
+ | `non-sonde` | nothing asked yet — **not** *nothing missing*, and **process-local**: this process has looked at nothing; another instance may have looked at everything |
236
+ | `partiel` | some checked, none of them missing — the probe is **lazy**: a column is inspected only once something touches it, so this is *how much has been exercised*, never *how much exists* |
184
237
  | `complet` | all checked, all present |
185
238
  | `incomplet` | at least one is missing — `manquant` names the file and the sleeping feature |
239
+ | `indetermine` | the database did not answer; this measurement did not happen |
240
+
241
+ ⚠️ **The first two rows are one trap, and it fires on every deploy.** Because the probe is lazy and
242
+ lives in the process, a fresh instance answers `non-sonde`, then `partiel`, then `complet` as
243
+ traffic exercises columns — on a database that never changed. Measured at an integrating host on
244
+ 27/08, on one unchanged base: `sondees=9 complet` on the old instance, `sondees=0 non-sonde` right
245
+ after the deploy, `sondees=1 partiel` sixty-seven minutes later. They looked three times before
246
+ concluding, because a neighbouring field had already taught them to distrust that zero — and they
247
+ were about to report a regression that did not exist. Read these two verdicts as *what this
248
+ process has asked so far*, never as a statement about the schema.
186
249
 
187
250
  Each `manquant` entry has **exactly this shape** — pin your parser to it, not to what a schema
188
251
  probe "should" return:
@@ -202,7 +265,6 @@ If you consume this card, test your parser against the JSON above, not against a
202
265
  ⚠️ **A card without a `schema` field is an alert, not a success**: it signals an instance older
203
266
  than 0.1.58 — a version that cannot answer the question. (Rule contributed by the second host, for
204
267
  exactly the monitoring case where "no data" would otherwise read as "all clear".)
205
- | `indetermine` | the database did not answer; this measurement did not happen |
206
268
 
207
269
  ⚠️ **`incomplet` wins over `partiel`**: a missing column is a positive fact and settles the verdict
208
270
  on its own, even when the rest has not been checked.
@@ -465,6 +527,93 @@ not from a list of column names borrowed from someone else's — a coarse check
465
527
  positives to excuse, it has a pattern to derive. The question each row must be able to answer is
466
528
  *which document was this written for*; the key that answers it is yours to name.
467
529
 
530
+ **Your `bot` plugin owns the assistant's behaviour — all of it.** The player ships the assistant's
531
+ markup and wires **none** of its sixty-four controls: no browser bundle, no inline script. That has
532
+ been true since the first commit, and until 26/08 it was undocumented while `docs/CONFIGURATION.md`
533
+ claimed the opposite. Two consequences you must act on:
534
+
535
+ - **Declare `wiresVoice: true`** on the object you pass as `ctx.plugins.bot` if you wire the voice
536
+ controls. Without it — or with a merely truthy value rather than exactly `true` — the three voice
537
+ buttons and the audio-consent step are not rendered at all. `ELEVENLABS_API_KEY` alone no longer
538
+ shows them: the key proves the *server* can synthesise, never that a click leads anywhere, and a
539
+ button that leads to silence is a broken promise made in your name.
540
+ - **`bot-tts` now requires a `sessionId`**, bound to the requested `slug`, and the text must match
541
+ something the assistant said in that session. The player reads `listMessages(sessionId)` and
542
+ treats a message as the assistant's when its `role` is `bot`, `assistant` or `ai`, taking the text
543
+ from `text` or `content`. **Anything it cannot read counts as "not said"** — an unrecognised shape
544
+ yields an empty set and every request is refused. On the one route that spends money, *"I could
545
+ not verify"* must read as **no**, never as *go ahead*.
546
+
547
+ ⚠️ **The player does not delegate that check to your plugin**, for the reason already stated above
548
+ about session binding: a security property of the player cannot depend on code the player does not
549
+ contain. It reads the messages and decides itself.
550
+
551
+ The message text is read from `text`, `content` or `body`, first non-empty wins. `body` is there
552
+ because a host said so **before** hitting it: its messages carry `body` and nothing else, its `role`
553
+ was a correct `bot`, and the reader would have returned an empty string for every message — an empty
554
+ set, so every request refused, on a perfectly correct integration. If your field is none of those
555
+ three, tell us and we widen the list. The field name carries no security; the **role** filter does.
556
+
557
+ **If you write to the `tts-cache` bucket yourself, write the trace too.** Retention removes an object
558
+ only when its fingerprint has a row in `doc_tts_objects`, and only the player's own route writes that
559
+ row. Anything your code puts in that bucket is therefore invisible to the sweep — **permanently**,
560
+ not just for the objects already there.
561
+
562
+ This is not hypothetical: an integrating host reported 908 objects it had written itself, under
563
+ **exactly** the player's naming — same digest, same two files, same bucket root. Its own comment says
564
+ the parity was deliberate, so that one clip serves both surfaces. Nothing about the name distinguishes
565
+ its objects from the player's; only the missing row does.
566
+
567
+ So `doc_tts_objects` is a **host write point**, not an internal table. What the sweep needs from
568
+ you is **one property, and only this one**:
569
+
570
+ > the `hash` you write in the row **is** the object's base name — the file is `<hash>.mp3`, its
571
+ > alignment is `<hash>.json`, and nothing else has to be true.
572
+
573
+ ⚠️ **How you compute that digest is yours, and this page used to say otherwise.** It read *"write
574
+ it with the same fingerprint the player computes, and nothing else"*, which made a perfectly safe
575
+ host non-compliant on paper — and the obvious fix, realigning the formula, is the one thing that
576
+ would break: the objects already in the bucket carry the **old** digest in their names, so a row
577
+ written with a new one points at nothing, and the real name loses its only row. Reported on 27/08
578
+ by a host whose third writer uses `preview-fr-v2` where the player uses `v2`. Their five write
579
+ sites are correct as they stand.
580
+
581
+ The sweep never recomputes anything: it reads `hash` from the row and removes `hash + ".mp3"` and
582
+ `hash + ".json"` (`server/retention.js`). A bench holds that property rather than a comment —
583
+ `retentionCacheDeVoix.test.js` builds its rows with `hash: "aaa"`, which is the sha256 of nothing,
584
+ and requires `tts-cache/aaa.mp3` to be the file removed.
585
+
586
+ **The player's own formula matters for one thing only, and it is not retention** — sharing. Its
587
+ route recomputes this digest to find a clip it already paid for, so match it *if* you want one clip
588
+ to serve both surfaces (as one host deliberately does, for 908 objects). If you don't, the player
589
+ simply synthesises its own, and nothing else changes:
590
+
591
+ ```
592
+ hash = sha256(voiceId + "|" + modelId + "|v2|" + spokenText) -- hex, lowercase
593
+ ```
594
+
595
+ | requirement | mandatory? | if you don't |
596
+ |---|---|---|
597
+ | the row's `hash` is the object's base name | **yes, always** | the object is invisible to the sweep, permanently |
598
+ | the digest matches the player's formula | no — only to share a clip | the player synthesises its own, and pays for it |
599
+
600
+ ```sql
601
+ insert into public.doc_tts_objects (hash) values ($1)
602
+ on conflict (hash) do nothing;
603
+ ```
604
+
605
+ ⚠️ **Never write the text**, in any column. The table holds a fingerprint and a date on purpose: the
606
+ bucket may already hold personal data, and writing the text would recreate it in the database — this
607
+ time queryable. `hash` is the primary key, so the insert is idempotent; a clip regenerated under a new
608
+ voice yields a new fingerprint and a new row, which is correct.
609
+
610
+ RLS is on with **no policy**, so nothing reaches it except a role that bypasses RLS — the
611
+ `service_role` key the `db` capability already uses. No grant, no schema change, no new migration:
612
+ apply `0021` and write.
613
+
614
+ Objects written before you start writing the trace stay untraceable for good. The sweep counts rows,
615
+ so it can say *"no trace outside the window survives"* — never *"the bucket is clean"*.
616
+
468
617
  **A database error carries its status as a number, not inside its message.** Set `statusCode` (or
469
618
  `status`) on whatever `db.request` throws. Both contexts shipped here already do; a host that
470
619
  implements the seam itself may not, and the player then has to guess from the text.
package/docs/RETENTION.md CHANGED
@@ -111,8 +111,45 @@ Agent-guided walkthrough: **purged 13 months** after `last_at`.
111
111
  |---|---|---|
112
112
  | `player_rate_limits.key` | may contain an **IP in the clear** (`hshare:<ip>`) or an email | row purged as soon as `expires_at` has passed (opportunistically, on every pass) |
113
113
 
114
+ ## Voice cache (`doc_tts_objects` + the `tts-cache` bucket)
115
+
116
+ Every synthesis writes two objects to the **public** `tts-cache` bucket — `<fingerprint>.mp3` and
117
+ `<fingerprint>.json` (per-character alignment). The fingerprint is a digest of voice + model +
118
+ **spoken** text. They are **purged 13 months** after `created_at`: the two objects first, then the
119
+ row — never the other way round, because erasing the trace first would leave the objects
120
+ permanently unreachable.
121
+
122
+ | column | contents | fate |
123
+ |---|---|---|
124
+ | `doc_tts_objects.hash` | the fingerprint — **never the text** | purged with the row, after both bucket objects |
125
+ | `doc_tts_objects.created_at` | when the object was written | the window is measured on it |
126
+
127
+ ⚠️ **Why a table exists at all.** The objects are named by a digest that ties back to no row, and
128
+ the host `storage` capability exposes `put` and `remove` — never `list`. Before this table the
129
+ bucket could not be swept at all: there was nothing to walk. This is not a policy that was missing,
130
+ it is the trace. The row records a fingerprint and a date and nothing else: writing the text here
131
+ would recreate, inside the database, whatever personal data the bucket may already hold — and make
132
+ it queryable, which is strictly worse than not having it.
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
136
+ hour; only this window bounds the **duration**.
137
+
114
138
  ## Limits stated rather than left unsaid
115
139
 
140
+ - ⚠️ **`fichiersErreur` can be high without any removal having failed.** Each fingerprint has two
141
+ objects, and the alignment `.json` is not always there — the provider does not always return one.
142
+ Measured on an integrating host's bucket on 27/08: **552 `.mp3` for 356 `.json`**, so 196 audio
143
+ files legitimately have no companion to remove. The count is deliberately not masked, but read it
144
+ with that in mind: a first sweep reporting two hundred "errors" may have failed at nothing.
145
+ - **If you write to the `tts-cache` bucket yourself, write the trace too**, or your objects are
146
+ invisible to the sweep permanently — see *Voice* in `docs/HOST-CONTRACT.md` for the exact
147
+ fingerprint and the insert. The same host had written **908 objects under the player's exact
148
+ naming**; nothing but the missing row distinguished them.
149
+ - **Voice-cache objects written before migration 0021 have no trace, and never will.** The sweep
150
+ can only reach what a row points at, and no row was ever written for them. They stay in the
151
+ bucket until an operator removes them by hand. The census counts rows, so it cannot see them
152
+ either — it can say "no trace past the window survives", never "the bucket is clean".
116
153
  - **Orphaned attachments**: purging the rows erases the bucket file only if the host context
117
154
  provides `storage.remove` (an optional capability). Without it, the URL becomes unreachable from
118
155
  the product but the object survives in the bucket — said here rather than simulated.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "discovery-media-player",
3
- "version": "0.1.139",
3
+ "version": "0.1.142",
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",
@@ -74,7 +74,9 @@
74
74
  "test:e2e": "node -e \"require('fs').existsSync('vitest.e2e.config.mjs')||(console.error('Ce banc ne vit pas dans le paquet publié - le champ scripts annonce plus que le tarball ne contient. Clonez le depot puis npm ci : https://github.com/Juli1artha/discovery-media-player'),process.exit(1))\" && vitest run --config vitest.e2e.config.mjs",
75
75
  "test:base": "node -e \"require('fs').existsSync('vitest.base.config.mjs')||(console.error('Ce banc ne vit pas dans le paquet publié - le champ scripts annonce plus que le tarball ne contient. Clonez le depot puis npm ci : https://github.com/Juli1artha/discovery-media-player'),process.exit(1))\" && vitest run --config vitest.base.config.mjs",
76
76
  "test:charge": "node -e \"require('fs').existsSync('vitest.charge.config.mjs')||(console.error('Ce banc ne vit pas dans le paquet publié - le champ scripts annonce plus que le tarball ne contient. Clonez le depot puis npm ci : https://github.com/Juli1artha/discovery-media-player'),process.exit(1))\" && vitest run --config vitest.charge.config.mjs",
77
- "test:campagne": "node -e \"require('fs').existsSync('vitest.campagne.config.mjs')||(console.error('Ce banc ne vit pas dans le paquet publié - le champ scripts annonce plus que le tarball ne contient. Clonez le depot puis npm ci : https://github.com/Juli1artha/discovery-media-player'),process.exit(1))\" && vitest run --config vitest.campagne.config.mjs"
77
+ "test:campagne": "node -e \"require('fs').existsSync('vitest.campagne.config.mjs')||(console.error('Ce banc ne vit pas dans le paquet publié - le champ scripts annonce plus que le tarball ne contient. Clonez le depot puis npm ci : https://github.com/Juli1artha/discovery-media-player'),process.exit(1))\" && vitest run --config vitest.campagne.config.mjs",
78
+ "test:endurance": "node -e \"require('fs').existsSync('vitest.endurance.config.mjs')||(console.error('Ce banc ne vit pas dans le paquet publié - le champ scripts annonce plus que le tarball ne contient. Clonez le depot puis npm ci : https://github.com/Juli1artha/discovery-media-player'),process.exit(1))\" && vitest run --config vitest.endurance.config.mjs",
79
+ "test:stats": "node -e \"require('fs').existsSync('vitest.stats.config.mjs')||(console.error('Ce banc ne vit pas dans le paquet publié - le champ scripts annonce plus que le tarball ne contient. Clonez le depot puis npm ci : https://github.com/Juli1artha/discovery-media-player'),process.exit(1))\" && vitest run --config vitest.stats.config.mjs"
78
80
  },
79
81
  "engines": {
80
82
  "node": ">=22.13.0"
package/server/cache.js CHANGED
@@ -176,6 +176,17 @@ function creerCache(options) {
176
176
  return promesse;
177
177
  },
178
178
 
179
+ /**
180
+ * Oublie ce qui est RETENU, jamais ce qui est en vol — la même règle que l'éviction : retirer
181
+ * une demande en cours ne libère rien et casserait le regroupement pour ses appelants.
182
+ *
183
+ * ⚠️ EXISTE PARCE QU'UN CACHE DE MODULE SURVIT À UN BANC. Deux bancs qui demandent la même clé
184
+ * dans le même processus : le second est servi par la mémoire du premier et n'observe donc PAS
185
+ * ce qu'il croit observer — il a compté zéro appel réseau et conclu que la route n'appelait
186
+ * pas. Un banc qui ne remet pas cet état à zéro mesure le banc d'avant.
187
+ */
188
+ vider() { for (const [k, e] of [...entrees]) if (!e.enVol) oublier(k); },
189
+
179
190
  /** Pour les tests et l'exploitation : ce que la table contient réellement. */
180
191
  taille: () => entrees.size,
181
192
  /** Poids cumulé des résultats retenus, en OCTETS UTF-8. */
@@ -57,4 +57,37 @@ function estConflit(erreur) {
57
57
  return /→\s*409\b/.test(String((erreur && erreur.message) || ""));
58
58
  }
59
59
 
60
- module.exports = { estConflit };
60
+ // ⚠️ « CETTE FONCTION N'EXISTE PAS ICI » VIT AVEC « CE CONFLIT EST UN CONFLIT », ET PAS AILLEURS.
61
+ // Elle habitait `presentations.js`, où elle était née ; deux autres modules en ont désormais besoin
62
+ // pour replier sur un chemin en mémoire quand une migration n'a pas été appliquée. La recopier
63
+ // aurait donné deux définitions d'un même fait — exactement ce que ce fichier existe pour empêcher.
64
+ // `presentations.js` la ré-exporte : sa surface publique ne bouge pas.
65
+ /**
66
+ * Cette erreur dit-elle « CETTE SIGNATURE N'EXISTE PAS », et rien d'autre ?
67
+ *
68
+ * ⚠️ C'EST LA QUESTION QUI MANQUAIT, ET SON ABSENCE RETIRAIT UNE PROTECTION. Le repli vers l'ancien
69
+ * contrat se déclenchait sur N'IMPORTE QUELLE exception : un `ECONNRESET`, un 500, un délai dépassé
70
+ * valaient « migration 0018 absente », et le processus restait dégradé — sans contrôle anti-usurpation
71
+ * — jusqu'à son redémarrage. Une panne réseau d'une seconde désarmait une garde de sécurité sur une
72
+ * base pourtant entièrement migrée.
73
+ *
74
+ * C'est la règle du jour appliquée au code de production : **un mécanisme qui ne peut pas mesurer doit
75
+ * refuser de conclure, pas conclure par défaut.** Ici, ne pas savoir distinguer PGRST202 d'un timeout
76
+ * ne rendait pas le repli prudent — il le rendait automatique.
77
+ *
78
+ * PostgREST rend `PGRST202` quand aucune fonction ne correspond au jeu d'arguments nommés. On accepte
79
+ * les DEUX formes que nos contextes produisent (code analysé, ou message contenant le code / la phrase
80
+ * de PostgREST) — et RIEN d'autre : un statut 404 seul ne suffit pas, il peut venir d'ailleurs.
81
+ */
82
+ function signatureAbsente(erreur) {
83
+ if (!erreur) return false;
84
+ const code = erreur.details && (erreur.details.code || (erreur.details.error && erreur.details.error.code));
85
+ if (code === "PGRST202") return true;
86
+ // ⚠️ PAS DE `erreur &&` ICI : la garde de la première ligne l'a déjà tranché. Le garder ne
87
+ // protégeait de rien et APPRENAIT AU LECTEUR QUE `erreur` PEUT ÊTRE NULLE À CET ENDROIT — ce qui
88
+ // est faux. Un test qui ne peut pas échouer ne coûte pas un cycle, il coûte une lecture.
89
+ const texte = String(erreur.message || "");
90
+ return texte.includes("PGRST202") || /Could not find the function/i.test(texte);
91
+ }
92
+
93
+ module.exports = { estConflit, signatureAbsente };
@@ -10,6 +10,31 @@ const { esc } = require("./texte");
10
10
  let PLAYER = null;
11
11
  const init = (ctx) => { PLAYER = ctx; };
12
12
 
13
+ /**
14
+ * La voix se propose-t-elle au visiteur ? DEUX conditions, et la seconde est arrivée le 26/08.
15
+ *
16
+ * ⚠️ LA CLÉ SEULE NE PROUVE RIEN, ET C'EST POURTANT CE QU'ELLE PRÉTENDAIT. Elle dit que le SERVEUR
17
+ * sait synthétiser ; elle ne dit rien de ce qui arrive quand on clique. Or ce paquet ne câble AUCUN
18
+ * des soixante-quatre contrôles de cet assistant — il en livre le balisage, l'hôte livre le
19
+ * comportement. Les trois boutons de voix et l'étape de consentement audio étaient les SEULS dont
20
+ * l'apparition dépendait d'un secret serveur : les soixante autres s'affichent toujours, donc un
21
+ * hôte qui embarque cet assistant sait qu'il doit les brancher. Ceux-là, non — une clé posée les
22
+ * faisait apparaître, et un visiteur qui cliquait obtenait le silence.
23
+ *
24
+ * Signalé le 26/08 par un hôte intégrateur qui est allé chercher qui appelait `bot-tts` et n'a
25
+ * trouvé personne. Ce fichier portait DÉJÀ la règle, dans son banc : « une porte "écouter la
26
+ * présentation" qui mène au silence est une promesse cassée ». Elle était vérifiée SANS clé,
27
+ * jamais AVEC — le seul cas où la porte pouvait exister.
28
+ *
29
+ * ⚠️ `=== true` PLUTÔT QUE VÉRIDIQUE. Une fonction, une chaîne ou un objet posé là par accident
30
+ * ouvrirait la porte : on exige une DÉCLARATION, pas une présence.
31
+ */
32
+ function voixProposable() {
33
+ if (!process.env.ELEVENLABS_API_KEY) return false;
34
+ const bot = PLAYER && PLAYER.plugins && PLAYER.plugins.bot;
35
+ return !!(bot && bot.wiresVoice === true);
36
+ }
37
+
13
38
  const BOT_CSS = `
14
39
  /* Doc + chat côte à côte (ces styles ne vivent sinon que dans LIVE_CSS, réservé au mode présentation). */
15
40
  .lrow{flex:1;display:flex;min-height:0;position:relative}
@@ -539,15 +564,15 @@ function botMarkup(share, pitch) {
539
564
  const acc = esc(share.bot_accent || "#15130f");
540
565
  const avatar = share.bot_avatar ? `<img src="${esc(share.bot_avatar)}" alt="">` : (share.bot_name ? esc(String(share.bot_name).trim().charAt(0).toUpperCase()) : "◆");
541
566
  // Voix (ElevenLabs) : les boutons 🔊 ne sont proposés que si une clé est configurée côté serveur.
542
- const voiceBtn = process.env.ELEVENLABS_API_KEY ? `<button class=botc-voice id=botcVoice title="Écouter la présentation" aria-pressed=false>${ICONS.mute}</button>` : "";
543
- const pVoiceBtn = process.env.ELEVENLABS_API_KEY ? `<button id=botpVoice title="Écouter">${ICONS.mute}</button>` : "";
567
+ const voiceBtn = voixProposable() ? `<button class=botc-voice id=botcVoice title="Écouter la présentation" aria-pressed=false>${ICONS.mute}</button>` : "";
568
+ const pVoiceBtn = voixProposable() ? `<button id=botpVoice title="Écouter">${ICONS.mute}</button>` : "";
544
569
  // Étape 2 du sélecteur de départ (uniquement si la voix est disponible) : le CONSENTEMENT audio se donne
545
570
  // ICI, clairement, avant de lancer la présentation — audio interactif (voix + chat) ou par écrit.
546
571
  const hasVClips = !!share.bot_vclips;
547
572
  const videoDoor = hasVClips ? `<button class=botw-door id=doorVideo><i>${ICONS.play}</i><span>En vidéo avec ${name}<small>${name} vous présente face caméra — le format le plus vivant</small></span><em class=botw-tag>Populaire</em></button>` : "";
548
573
  const vNote = !hasVClips && share.video_layout ? `<p class=botw-note>🎬 La présentation vidéo arrive bientôt sur ce document.</p>` : "";
549
- const s2 = process.env.ELEVENLABS_API_KEY ? `<div class="botw-card botw-s2"><div class=botw-head><span class=botw-av>${avatar}</span><div><b>${name}</b><span>${sub}</span></div></div><p class=botw-q>Parfait ! Comment préférez-vous suivre la présentation ?</p>${videoDoor}<button class=botw-door id=doorVoice><i>${ICONS.sound}</i><span>${hasVClips ? "En audio" : `Avec la voix de ${name}`}<small>Audio interactif — écoutez la présentation et posez vos questions dans le chat à tout moment</small></span>${hasVClips ? "" : `<em class=botw-tag>Populaire</em>`}</button><button class=botw-door id=doorSilent><i>${ICONS.chat}</i><span>Par écrit, dans le chat<small>${name} écrit page après page, en silence — à votre rythme</small></span></button>${vNote}<button class=botw-back id=botwBack>← Revenir aux options</button></div>` : "";
550
- return `<div class="botc min" id=botc style="--bacc:${acc}"><div class=botc-grab id=botcGrab><i></i></div><div class=botc-h><span class=botc-av>${avatar}</span><div><b>${name}</b><span class=botc-sub>${sub}</span></div>${voiceBtn}<button class=botc-gearbtn id=botcGearBtn title="Réglages d'affichage">${ICONS.gear}</button><button class=botc-min id=botcMin title=Réduire>${ICONS.min}</button></div><button class=botc-back id=botcBack>${ICONS.prev}<span>Revenir à la présentation</span></button><div class=botc-msgs id=botcMsgs><div class=botc-choices id=botcChoices></div></div><button class=botc-resume id=botcResume>Reprendre la présentation</button><div class=botc-in><input id=botcText placeholder="Écrivez votre message…" autocomplete=off maxlength=1000><button id=botcSend title=Envoyer>${ICONS.send}</button></div></div><button class=botc-fab id=botcFab style="--bacc:${acc}" title="Assistant & réglages">${share.bot_avatar ? `<img src="${esc(share.bot_avatar)}" alt="">` : ICONS.chat}<span class=botc-badge id=botcBadge></span><span class=botc-gear>${ICONS.gear}</span><span class=fab-gear>${ICONS.gear}</span></button><button class=botc-fab2 id=botcFab2 title="Parler à ${esc(String(share.bot_name || "l'assistant"))}">${ICONS.chat}<span class=botc-badge id=botcBadge2></span></button>${process.env.ELEVENLABS_API_KEY ? `<button class="botc-fab2 botc-fab3" id=botcVoice2 title="Couper la voix"></button>` : ""}<button class="botc-fab2 botc-fab4" id=botcPlay2 title="Relancer une visite">${ICONS.play}</button><button class=botc-peek id=botcPeek></button><div class=fabmenu id=fabMenu style="--bacc:${acc}"><div class=fm-l0 id=fmL0><button class=fm-row data-p=pDisp><span class=fm-rl>Affichage</span><em id=fmVDisp></em>${ICONS.next}</button><button class=fm-row data-p=pLang><span class=fm-rl>Langue</span><em id=fmVLang></em>${ICONS.next}</button><button class=fm-row data-p=pLook><span class=fm-rl>Apparence</span><em id=fmVLook></em>${ICONS.next}</button></div><div class=fm-p id=pDisp><button class=fm-back>${ICONS.prev}<span class=fm-rl>Affichage</span></button><div class=fm-seg id=fmDisp><button data-v=panel>Panneau</button><button data-v=bubble>Bulle</button><button data-v=cap>Barre</button><button data-v=audio>Audio seul</button></div></div><div class=fm-p id=pLang><button class=fm-back>${ICONS.prev}<span class=fm-rl>Langue</span></button><div class=fm-seg id=fmLang><button data-v=fr>FR</button><button data-v=en>EN</button><button data-v=es>ES</button></div></div><div class=fm-p id=pLook><button class=fm-back>${ICONS.prev}<span class=fm-rl>Apparence</span></button><div class=fm-sec>Thème</div><div class=fm-seg id=fmTheme><button data-v=dark>Sombre</button><button data-v=light>Clair</button></div><div class=fm-sec>Style du texte</div><div class=fm-seg id=fmStyle><button data-v=classic>Classique</button><button data-v=focus>Focus</button><button data-v=fill>Encre</button><button data-v=underline>Souligné</button></div></div></div><div class=botw id=botw style="--bacc:${acc}"><button class=botw-x id=botwX aria-label=Fermer>${ICONS.close}</button><div class=botw-card><div class=botw-head><span class=botw-av>${avatar}</span><div><b>${name}</b><span>${sub}</span></div></div><div class=botw-lang id=botwLang><button data-v=fr>FR</button><button data-v=en>EN</button><button data-v=es>ES</button></div>${pitch ? `<p class=botw-pitch>${esc(pitch)}</p>` : ""}<p class=botw-q>Comment souhaitez-vous découvrir ce document ?</p><button class=botw-door id=doorPresent><i>${ICONS.play}</i><span>Je me laisse guider<small>${name} vous présente le document, à votre rythme</small></span><em class=botw-tag>Recommandé</em></button><button class=botw-door id=doorRead><i>${ICONS.book}</i><span>Je le parcours seul<small>Lecture libre — l'assistant reste disponible</small></span></button><button class=botw-door id=doorChat><i>${ICONS.chat}</i><span>J'ai des questions<small>Échangez directement avec ${name}</small></span></button></div>${s2}</div><div class=botp id=botp style="--bacc:${acc}"><div class=botp-prog id=botpProg><i id=botpFill></i></div><div class=botp-cap id=botpCap></div><div class=botp-chips id=botpChips></div><div class=botp-ctl><button class=pp id=botpPP aria-label="Lecture / pause">${ICONS.pause}${ICONS.play}</button>${pVoiceBtn}<button id=botpFs title="Plein écran">${ICONS.fs}</button><button id=botpChat title="Parler à ${esc(String(share.bot_name || "l'assistant"))}">${share.bot_avatar ? `<img src="${esc(share.bot_avatar)}" alt="">` : ICONS.chat}</button><button id=botpMore title=Options>${ICONS.more}</button></div><button class=botp-big id=botpBig aria-label=Reprendre>${ICONS.play}</button><div class=rot-hint id=rotHint><i class=rh-ph><svg viewBox="0 0 24 24" width="26" height="26" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="7" y="3" width="10" height="18" rx="2.5"/><path d="M11 18.2h2"/></svg></i><div class=rh-t><b>Plein écran</b><span>Tournez votre téléphone</span></div></div><div class=botp-menu id=botpMenu><button id=bmChat>${ICONS.chat}<span>Poser une question</span></button><button id=bmCall>${ICONS.cal}<span>Être rappelé / prendre RDV</span></button><button id=bmDl>${ICONS.dl}<span>Télécharger le document</span></button><button id=bmRestart>${ICONS.restart}<span>Recommencer la présentation</span></button><button id=bmRead>${ICONS.book}<span>Consulter tranquillement</span></button><button id=bmSet class=bm-set>${ICONS.gear}<span>Réglages</span></button></div><div class="botp-menu botp-set" id=botpSet><button class=bs-back id=bsBack>${ICONS.prev}<span>Réglages</span></button><div class=fm-sec>Vitesse de lecture</div><div class=fm-seg id=msSpd><button data-v=1>1×</button><button data-v=1.5>1,5×</button><button data-v=2>2×</button></div><div class=fm-sec>Thème</div><div class=fm-seg id=msTheme><button data-v=dark>Sombre</button><button data-v=light>Clair</button></div><div class=fm-sec>Style du texte</div><div class=fm-seg id=msStyle><button data-v=classic>Classique</button><button data-v=focus>Focus</button><button data-v=fill>Encre</button><button data-v=underline>Souligné</button></div></div></div>`;
574
+ const s2 = voixProposable() ? `<div class="botw-card botw-s2"><div class=botw-head><span class=botw-av>${avatar}</span><div><b>${name}</b><span>${sub}</span></div></div><p class=botw-q>Parfait ! Comment préférez-vous suivre la présentation ?</p>${videoDoor}<button class=botw-door id=doorVoice><i>${ICONS.sound}</i><span>${hasVClips ? "En audio" : `Avec la voix de ${name}`}<small>Audio interactif — écoutez la présentation et posez vos questions dans le chat à tout moment</small></span>${hasVClips ? "" : `<em class=botw-tag>Populaire</em>`}</button><button class=botw-door id=doorSilent><i>${ICONS.chat}</i><span>Par écrit, dans le chat<small>${name} écrit page après page, en silence — à votre rythme</small></span></button>${vNote}<button class=botw-back id=botwBack>← Revenir aux options</button></div>` : "";
575
+ return `<div class="botc min" id=botc style="--bacc:${acc}"><div class=botc-grab id=botcGrab><i></i></div><div class=botc-h><span class=botc-av>${avatar}</span><div><b>${name}</b><span class=botc-sub>${sub}</span></div>${voiceBtn}<button class=botc-gearbtn id=botcGearBtn title="Réglages d'affichage">${ICONS.gear}</button><button class=botc-min id=botcMin title=Réduire>${ICONS.min}</button></div><button class=botc-back id=botcBack>${ICONS.prev}<span>Revenir à la présentation</span></button><div class=botc-msgs id=botcMsgs><div class=botc-choices id=botcChoices></div></div><button class=botc-resume id=botcResume>Reprendre la présentation</button><div class=botc-in><input id=botcText placeholder="Écrivez votre message…" autocomplete=off maxlength=1000><button id=botcSend title=Envoyer>${ICONS.send}</button></div></div><button class=botc-fab id=botcFab style="--bacc:${acc}" title="Assistant & réglages">${share.bot_avatar ? `<img src="${esc(share.bot_avatar)}" alt="">` : ICONS.chat}<span class=botc-badge id=botcBadge></span><span class=botc-gear>${ICONS.gear}</span><span class=fab-gear>${ICONS.gear}</span></button><button class=botc-fab2 id=botcFab2 title="Parler à ${esc(String(share.bot_name || "l'assistant"))}">${ICONS.chat}<span class=botc-badge id=botcBadge2></span></button>${voixProposable() ? `<button class="botc-fab2 botc-fab3" id=botcVoice2 title="Couper la voix"></button>` : ""}<button class="botc-fab2 botc-fab4" id=botcPlay2 title="Relancer une visite">${ICONS.play}</button><button class=botc-peek id=botcPeek></button><div class=fabmenu id=fabMenu style="--bacc:${acc}"><div class=fm-l0 id=fmL0><button class=fm-row data-p=pDisp><span class=fm-rl>Affichage</span><em id=fmVDisp></em>${ICONS.next}</button><button class=fm-row data-p=pLang><span class=fm-rl>Langue</span><em id=fmVLang></em>${ICONS.next}</button><button class=fm-row data-p=pLook><span class=fm-rl>Apparence</span><em id=fmVLook></em>${ICONS.next}</button></div><div class=fm-p id=pDisp><button class=fm-back>${ICONS.prev}<span class=fm-rl>Affichage</span></button><div class=fm-seg id=fmDisp><button data-v=panel>Panneau</button><button data-v=bubble>Bulle</button><button data-v=cap>Barre</button><button data-v=audio>Audio seul</button></div></div><div class=fm-p id=pLang><button class=fm-back>${ICONS.prev}<span class=fm-rl>Langue</span></button><div class=fm-seg id=fmLang><button data-v=fr>FR</button><button data-v=en>EN</button><button data-v=es>ES</button></div></div><div class=fm-p id=pLook><button class=fm-back>${ICONS.prev}<span class=fm-rl>Apparence</span></button><div class=fm-sec>Thème</div><div class=fm-seg id=fmTheme><button data-v=dark>Sombre</button><button data-v=light>Clair</button></div><div class=fm-sec>Style du texte</div><div class=fm-seg id=fmStyle><button data-v=classic>Classique</button><button data-v=focus>Focus</button><button data-v=fill>Encre</button><button data-v=underline>Souligné</button></div></div></div><div class=botw id=botw style="--bacc:${acc}"><button class=botw-x id=botwX aria-label=Fermer>${ICONS.close}</button><div class=botw-card><div class=botw-head><span class=botw-av>${avatar}</span><div><b>${name}</b><span>${sub}</span></div></div><div class=botw-lang id=botwLang><button data-v=fr>FR</button><button data-v=en>EN</button><button data-v=es>ES</button></div>${pitch ? `<p class=botw-pitch>${esc(pitch)}</p>` : ""}<p class=botw-q>Comment souhaitez-vous découvrir ce document ?</p><button class=botw-door id=doorPresent><i>${ICONS.play}</i><span>Je me laisse guider<small>${name} vous présente le document, à votre rythme</small></span><em class=botw-tag>Recommandé</em></button><button class=botw-door id=doorRead><i>${ICONS.book}</i><span>Je le parcours seul<small>Lecture libre — l'assistant reste disponible</small></span></button><button class=botw-door id=doorChat><i>${ICONS.chat}</i><span>J'ai des questions<small>Échangez directement avec ${name}</small></span></button></div>${s2}</div><div class=botp id=botp style="--bacc:${acc}"><div class=botp-prog id=botpProg><i id=botpFill></i></div><div class=botp-cap id=botpCap></div><div class=botp-chips id=botpChips></div><div class=botp-ctl><button class=pp id=botpPP aria-label="Lecture / pause">${ICONS.pause}${ICONS.play}</button>${pVoiceBtn}<button id=botpFs title="Plein écran">${ICONS.fs}</button><button id=botpChat title="Parler à ${esc(String(share.bot_name || "l'assistant"))}">${share.bot_avatar ? `<img src="${esc(share.bot_avatar)}" alt="">` : ICONS.chat}</button><button id=botpMore title=Options>${ICONS.more}</button></div><button class=botp-big id=botpBig aria-label=Reprendre>${ICONS.play}</button><div class=rot-hint id=rotHint><i class=rh-ph><svg viewBox="0 0 24 24" width="26" height="26" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="7" y="3" width="10" height="18" rx="2.5"/><path d="M11 18.2h2"/></svg></i><div class=rh-t><b>Plein écran</b><span>Tournez votre téléphone</span></div></div><div class=botp-menu id=botpMenu><button id=bmChat>${ICONS.chat}<span>Poser une question</span></button><button id=bmCall>${ICONS.cal}<span>Être rappelé / prendre RDV</span></button><button id=bmDl>${ICONS.dl}<span>Télécharger le document</span></button><button id=bmRestart>${ICONS.restart}<span>Recommencer la présentation</span></button><button id=bmRead>${ICONS.book}<span>Consulter tranquillement</span></button><button id=bmSet class=bm-set>${ICONS.gear}<span>Réglages</span></button></div><div class="botp-menu botp-set" id=botpSet><button class=bs-back id=bsBack>${ICONS.prev}<span>Réglages</span></button><div class=fm-sec>Vitesse de lecture</div><div class=fm-seg id=msSpd><button data-v=1>1×</button><button data-v=1.5>1,5×</button><button data-v=2>2×</button></div><div class=fm-sec>Thème</div><div class=fm-seg id=msTheme><button data-v=dark>Sombre</button><button data-v=light>Clair</button></div><div class=fm-sec>Style du texte</div><div class=fm-seg id=msStyle><button data-v=classic>Classique</button><button data-v=focus>Focus</button><button data-v=fill>Encre</button><button data-v=underline>Souligné</button></div></div></div>`;
551
576
  }
552
577
 
553
578
  module.exports = { init, BOT_CSS, ICO, ICONS, botMarkup };
package/server/handler.js CHANGED
@@ -11,6 +11,7 @@ const { pipeline } = require("node:stream/promises");
11
11
  const { getShareBySlug } = require("./shares");
12
12
  const { PRESENT_QUOTA_PER_HOUR, PRESENT_CACHE_MS, estSlug } = require("./shared.generated.js");
13
13
  const { creerCache, CODE_SATURATION } = require("./cache.js");
14
+ const mesures = require("./mesures.js");
14
15
 
15
16
  // ⚠️ UN SEUL CACHE POUR TOUT LE PROCESSUS, ET C'EST LE POINT. Le créer par requête reviendrait à
16
17
  // n'en avoir aucun : chaque appelant repartirait d'une table vide, et l'effondrement qu'on
@@ -27,6 +28,17 @@ const { getPresentation, listMessages } = require("./presentations");
27
28
  // Vercel (api/doc.js) est le seul à connaître le studio.
28
29
  let PLAYER = null;
29
30
  function init(ctx) {
31
+ // ⚠️ LA LATENCE DE LA BASE SE MESURE AU SEAM, PAS À SOIXANTE-SEPT SITES D'APPEL. La capacité est
32
+ // fournie par l'HÔTE : se souvenir de chronométrer à chaque appel demanderait de ne jamais
33
+ // oublier, et le premier oubli passerait inaperçu. Enveloppée ici, la mesure couvre AUSSI ce que
34
+ // personne n'a encore écrit. Le décorateur rend la même forme, les mêmes valeurs et les mêmes
35
+ // rejets : un décorateur qui change le contrat mesurerait autre chose que la production.
36
+ //
37
+ // ⚠️ `Object.create`, PAS UNE COPIE — le contexte reste VIVANT. Un `{ ...ctx }` figerait tout ce
38
+ // que l'hôte pourrait poser ou remplacer APRÈS `init`, et le player continuerait d'utiliser la
39
+ // photo. C'est exactement ce qui a rougi la forge : un banc pose sa sonde sur `db.request` après
40
+ // `init`, et un hôte a le même droit. On n'ajoute qu'une chose, on n'en fige aucune.
41
+ if (ctx && ctx.db) { const vu = Object.create(ctx); vu.db = mesures.observerBase(ctx.db); ctx = vu; }
30
42
  PLAYER = ctx;
31
43
  // Le domaine reçoit le même contexte : une seule construction pour tout le player.
32
44
  require("./shares").init(ctx);
@@ -512,7 +524,39 @@ function parametres(req) {
512
524
  }
513
525
  }
514
526
 
527
+ /**
528
+ * ⚠️ UNE FAMILLE PAR NATURE DE TRAVAIL, PAS PAR ACTION. Une clé par action rendrait le relevé aussi
529
+ * long que la liste des routes et aussi instable qu'elle — et il faudrait la tenir à jour à la
530
+ * main, ce que ce dépôt a déjà payé trois fois. Six familles suffisent à répondre à « qu'est-ce
531
+ * qui est lent ici ? » ; le détail se cherche ensuite, avec la question déjà posée.
532
+ */
533
+ function familleDe(req, q) {
534
+ if (req.method === "POST") return "action";
535
+ if (q.contract) return "carte";
536
+ if (q.asset || q.stream || q.file) return "fichier";
537
+ if (q.present) return "presentation";
538
+ if (q.slug || q.preview) return "document";
539
+ return "autre";
540
+ }
541
+
542
+ /**
543
+ * ⚠️ LE CHRONOMÈTRE ENVELOPPE, IL NE S'INSÈRE PAS. Le gestionnaire sort par des dizaines de
544
+ * `return` ; poser une mesure à chacun serait une liste à tenir, donc une liste qui divergera. Le
545
+ * statut se lit sur `res` APRÈS coup — c'est la porte de réponse qui l'a posé, et elle est unique
546
+ * (`server/reponses.js`). Le `finally` couvre aussi le chemin d'exception : une requête qui casse
547
+ * est précisément celle qu'on veut voir comptée.
548
+ */
515
549
  async function handler(req, res) {
550
+ let fin = () => {};
551
+ try { fin = mesures.chrono(familleDe(req, parametres(req))); } catch { /* jamais bloquant */ }
552
+ try {
553
+ return await handlerMesure(req, res);
554
+ } finally {
555
+ try { fin(res && res.statusCode); } catch { /* jamais bloquant */ }
556
+ }
557
+ }
558
+
559
+ async function handlerMesure(req, res) {
516
560
  try {
517
561
  const q = parametres(req);
518
562
  const slug = String(q.slug || "").trim();
@@ -718,6 +762,24 @@ async function handler(req, res) {
718
762
  derniereIlYaS: dernier == null ? null : Math.max(0, Math.round((Date.now() - dernier) / 1000)),
719
763
  };
720
764
  })(),
765
+ // ⚠️ CE QUE CETTE INSTANCE A VÉCU — parce que `lectureSaturee` ne répondait qu'à UNE
766
+ // question. « La route est-elle lente ? », « lesquelles ? », « la base ou nous ? »,
767
+ // « combien de 5xx ? », « la boucle décroche-t-elle ? » n'avaient aucune réponse
768
+ // observable, et les deux hôtes intégrateurs ont confirmé ne pas pouvoir la produire
769
+ // depuis chez eux. Décider d'optimiser sans ça, c'est deviner (audit CODEX 5.6, §2).
770
+ //
771
+ // ⚠️ DES BORNES, PAS DES VALEURS : `p95sousMs: 250` se lit « 95 % des appels sous
772
+ // 250 ms ». L'échelle des seaux est publiée AVEC les chiffres — sans elle, un lecteur ne
773
+ // peut pas juger de la précision de ce qu'il lit. Voir `server/mesures.js`.
774
+ //
775
+ // ⚠️ AUCUN SLUG, AUCUNE ADRESSE, AUCUN TEXTE : des compteurs et des durées. Rien ici ne
776
+ // désigne un visiteur, un document ou une présentation — c'est ce qui permet de le
777
+ // publier sur une carte qu'un hôte lit sans cérémonie.
778
+ //
779
+ // ⚠️ PROCESSUS-LOCAL, comme `lectureSaturee` et les champs de présence : une instance qui
780
+ // répond n'est pas toutes les instances, et tout repart à zéro au déploiement. Agréger
781
+ // est le travail de l'hôte ; `fenetreS` est là pour qu'il sache sur quoi il agrège.
782
+ mesures: mesures.relever(),
721
783
  // ⚠️ LE BALAYAGE DE RÉTENTION EST-IL ARMÉ ? La capacité `retention` dit que l'instance PEUT
722
784
  // purger ; ce booléen dit si le balayage automatique TOURNE (`config.retention.balayage`).
723
785
  // Sans lui, une instance armée est indiscernable d'une instance éteinte — et une purge qui
@@ -1068,6 +1130,7 @@ async function handler(req, res) {
1068
1130
  // tenir une seconde liste — c'est la seule façon qu'une empreinte périmée finisse par se voir.
1069
1131
  // ⚠️ Exporté pour être ÉPROUVÉ, pas pour être appelé : le plafond du relais ne se vérifie qu en
1070
1132
  // regardant si le corps a été lu, ce qu aucune route ne peut montrer de l extérieur.
1071
- module.exports = { handler, init, TIERS, POLITIQUE_PERMISSIONS, refuserEnTexte, repondreJson, __relayerFichier: relayerFichier, __jsonPourScript: jsonPourScript };
1133
+ // ⚠️ Exporté pour être ÉPROUVÉ : « le contexte reste vivant » ne se vérifie pas de l'extérieur.
1134
+ module.exports = { __contexte: () => PLAYER, handler, init, TIERS, POLITIQUE_PERMISSIONS, refuserEnTexte, repondreJson, __relayerFichier: relayerFichier, __jsonPourScript: jsonPourScript };
1072
1135
 
1073
1136
  // redeploy: forcer le build production (Vercel a sauté la prod du merge #463 — wording re-partage).