discovery-media-player 0.1.129 → 0.1.131
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 +14 -1
- package/docs/README.md +5 -0
- package/package.json +1 -1
- package/server/handler.js +31 -9
- package/server/presentations.js +16 -9
- package/server/routes-agent.js +49 -3
package/bin/serve.js
CHANGED
|
@@ -119,7 +119,20 @@ const serveur = http.createServer(async (req, res) => {
|
|
|
119
119
|
|
|
120
120
|
// Mode dossier : la racine du serveur montre ce qu'il y a à lire.
|
|
121
121
|
if ((url.pathname === "/" || url.pathname === "/preview" || url.pathname === "/preview/") && process.env.PLAYER_LOCAL_ROOT) {
|
|
122
|
-
res.writeHead(200, {
|
|
122
|
+
res.writeHead(200, {
|
|
123
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
124
|
+
"Cache-Control": "no-store",
|
|
125
|
+
// ⚠️ Les MÊMES protections que la visionneuse, écrites pour la page qui n'y passait pas.
|
|
126
|
+
// C'était la seule page HTML servie sans CSP ni `nosniff`, encadrable par n'importe qui —
|
|
127
|
+
// relevé par le premier scan ZAP baseline (24/08). Elle n'a ni script, ni image, ni
|
|
128
|
+
// formulaire : tout est refusé, sauf son unique bloc <style>.
|
|
129
|
+
"X-Content-Type-Options": "nosniff",
|
|
130
|
+
"Referrer-Policy": "strict-origin-when-cross-origin",
|
|
131
|
+
// La définition vit dans le player (une seule copie du fait) — voir son commentaire.
|
|
132
|
+
"Permissions-Policy": player.POLITIQUE_PERMISSIONS,
|
|
133
|
+
"Content-Security-Policy":
|
|
134
|
+
"default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
|
|
135
|
+
});
|
|
123
136
|
res.end(await pageAccueil(path.resolve(process.env.PLAYER_LOCAL_ROOT)));
|
|
124
137
|
return;
|
|
125
138
|
}
|
package/docs/README.md
CHANGED
|
@@ -17,6 +17,7 @@ no document assumes you have read the others.
|
|
|
17
17
|
|---|---|
|
|
18
18
|
| [`CONFIGURATION.md`](CONFIGURATION.md) | Every environment variable. An instance is described entirely by its environment — there is no configuration file, on purpose. |
|
|
19
19
|
| [`MIGRATIONS.md`](MIGRATIONS.md) | What happens to a database **already in service** when the player expects a newer schema. (French.) |
|
|
20
|
+
| [`VERIFYING-RELEASES.md`](VERIFYING-RELEASES.md) | how to check, yourself, that the package or image you pulled is the one this repository built — the commands, the expected output, and which signing identity to expect. |
|
|
20
21
|
| [`RETENTION.md`](RETENTION.md) | The declared perimeter of data retention: every personal-data column has a written policy, and CI enforces that the list is complete. Also an export of the package: `require.resolve("discovery-media-player/retention")`. |
|
|
21
22
|
|
|
22
23
|
## You are contributing, or publishing a version
|
|
@@ -26,6 +27,9 @@ no document assumes you have read the others.
|
|
|
26
27
|
| [`../CONTRIBUTING.md`](../CONTRIBUTING.md) | how to run the benches, what review looks for, and the one rule: a behaviour worth keeping is worth a test that fails without it. |
|
|
27
28
|
| [`../AGENTS.md`](../AGENTS.md) | the conventions that are not obvious from the file tree — which ones a guard enforces, and which ones only review does. |
|
|
28
29
|
| [`RELEASING.md`](RELEASING.md) | the release train, freezing the candidate SHA, the read-only preflight to run **before** the tag, and what to do when a tag lands on the wrong commit. |
|
|
30
|
+
| [`DEPENDENCIES.md`](DEPENDENCIES.md) | what the project depends on and why so little of it: the bar a new dependency has to clear, how updates arrive, the two upgrades deliberately held back, and the vulnerability thresholds a change has to clear to merge. |
|
|
31
|
+
| [`SECURITY-PRACTICES.md`](SECURITY-PRACTICES.md) | the two standing policies: how secrets are handled (there are no long-lived ones, on purpose) and what happens when a security tool reports something. |
|
|
32
|
+
| [`../MAINTAINERS.md`](../MAINTAINERS.md) | who can merge, who can publish, who reads a vulnerability report — and what one maintainer means for the answer. |
|
|
29
33
|
|
|
30
34
|
## You are evaluating the project
|
|
31
35
|
|
|
@@ -35,6 +39,7 @@ tracked version by version in the [CHANGELOG](../CHANGELOG.md).
|
|
|
35
39
|
|
|
36
40
|
| Document | What it is |
|
|
37
41
|
|---|---|
|
|
42
|
+
| [`THREAT-MODEL.md`](THREAT-MODEL.md) | what an attacker would go after, what stands in the way, and — the part most threat models omit — what is deliberately left standing. |
|
|
38
43
|
| [`AUDIT-2026-08-14-RAPPORT.md`](AUDIT-2026-08-14-RAPPORT.md) | First external audit, on `0.1.17`. Historical. (French.) |
|
|
39
44
|
| [`AUDIT-2026-08-14-SUIVI.md`](AUDIT-2026-08-14-SUIVI.md) | The follow-up ledger: done, decided-but-not-done, and rejected-with-reason. Historical. (French.) |
|
|
40
45
|
| [`AUDIT-2026-08-15-SECONDE-PASSE.md`](AUDIT-2026-08-15-SECONDE-PASSE.md) | Second pass, on `0.1.26` — including what the first follow-up had marked too optimistically. Historical. (French.) |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "discovery-media-player",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.131",
|
|
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/handler.js
CHANGED
|
@@ -186,18 +186,38 @@ const { adresseAppelant } = require("./appelant");
|
|
|
186
186
|
*/
|
|
187
187
|
const PLAFOND_RELAIS = Number(process.env.PLAYER_MAX_RELAY_BYTES || 0) || 60 * 1024 * 1024;
|
|
188
188
|
|
|
189
|
+
// ⚠️ UN REFUS EN TEXTE POSE SON TYPE, COMME TOUT LE RESTE. Ces réponses partaient avec un code et
|
|
190
|
+
// un corps, sans Content-Type : le seul corps de ce serveur qu'un navigateur avait le droit de
|
|
191
|
+
// deviner, alors que la règle `nosniff` est écrite partout ailleurs. Relevé par le premier scan
|
|
192
|
+
// ZAP baseline (règle 10019, 24/08).
|
|
193
|
+
function refuserEnTexte(res, statut, message) {
|
|
194
|
+
res.statusCode = statut;
|
|
195
|
+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
196
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
197
|
+
res.end(message);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ⚠️ CE QUE LES PAGES N'UTILISENT PAS EST REFUSÉ PAR ÉCRIT. Sans Permissions-Policy, un script
|
|
201
|
+
// tiers compromis (le risque exact que l'inventaire TIERS épingle) hérite de tout ce que le
|
|
202
|
+
// navigateur sait faire — caméra, micro, position. Aucune page ici ne s'en sert : on le dit.
|
|
203
|
+
// Le plein écran n'est PAS refusé — la présentation s'en sert (page-visionneuse.js). Relevé par
|
|
204
|
+
// le premier passage CI du scan ZAP (règle 10063, 24/08) ; le passage local, scanners par
|
|
205
|
+
// défaut, ne l'avait pas vu. UNE définition, exportée : bin/serve.js pose la même — deux
|
|
206
|
+
// exemplaires d'un fait divergent, c'est la formule du dépôt.
|
|
207
|
+
const POLITIQUE_PERMISSIONS = "camera=(), microphone=(), geolocation=(), payment=()";
|
|
208
|
+
|
|
189
209
|
async function relayerFichier(res, r, disposition) {
|
|
190
|
-
if (!r) { res
|
|
210
|
+
if (!r) { refuserEnTexte(res, 404, "Fichier indisponible"); return; }
|
|
191
211
|
// 413 et 416 sont des REFUS ARGUMENTÉS de l'amont local (plafond, borne absurde) : les fondre
|
|
192
212
|
// dans un 502 dirait « panne » là où l'amont a dit « demande irrecevable ».
|
|
193
213
|
if (!r.ok && r.status !== 206) {
|
|
194
|
-
res
|
|
195
|
-
|
|
214
|
+
refuserEnTexte(res, r.status === 413 || r.status === 416 ? r.status : 502,
|
|
215
|
+
r.status === 413 ? "Fichier trop volumineux" : "Fichier indisponible");
|
|
196
216
|
return;
|
|
197
217
|
}
|
|
198
218
|
|
|
199
219
|
const compresse = !!r.headers.get("content-encoding");
|
|
200
|
-
if (compresse && r.status === 206) { res
|
|
220
|
+
if (compresse && r.status === 206) { refuserEnTexte(res, 502, "Fichier indisponible"); return; }
|
|
201
221
|
|
|
202
222
|
// ⚠️ DEUX BORNES, ET LA SECONDE EST LA SEULE QUI TIENNE DEVANT UN AMONT QUI MENT.
|
|
203
223
|
//
|
|
@@ -213,8 +233,7 @@ async function relayerFichier(res, r, disposition) {
|
|
|
213
233
|
const annoncee = Number(brute || 0);
|
|
214
234
|
if (annoncee > PLAFOND_RELAIS) {
|
|
215
235
|
try { PLAYER.errors.capture(new Error(`relais refusé : ${annoncee} octets au-dessus du plafond de ${PLAFOND_RELAIS}`), { route: "relais" }); } catch { /* jamais bloquant */ }
|
|
216
|
-
res
|
|
217
|
-
res.end("Fichier trop volumineux");
|
|
236
|
+
refuserEnTexte(res, 413, "Fichier trop volumineux");
|
|
218
237
|
// ⚠️ Renoncer ne suffit pas : un corps jamais tiré laisse la connexion amont OUVERTE, et le
|
|
219
238
|
// pool de sockets s'épuise sur les gros fichiers — exactement la ressource qu'on protège.
|
|
220
239
|
try { if (r.body) r.body.cancel(); } catch { /* déjà refermé */ }
|
|
@@ -296,6 +315,7 @@ function sendHtml(res, status, html, scriptSrc, imgExtra, frameAncestors) {
|
|
|
296
315
|
res.setHeader("Cache-Control", "no-store, max-age=0");
|
|
297
316
|
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
298
317
|
res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
|
|
318
|
+
res.setHeader("Permissions-Policy", POLITIQUE_PERMISSIONS);
|
|
299
319
|
res.setHeader("Content-Security-Policy", [
|
|
300
320
|
"default-src 'none'",
|
|
301
321
|
// ⚠️ `'none'` DOIT ÊTRE SEUL ou la directive est invalide — « 'none' 'self' » a fait rejeter
|
|
@@ -339,6 +359,7 @@ function sendSoftWallHtml(res, html, nonce, imgExtra, frameAncestors) {
|
|
|
339
359
|
res.setHeader("Cache-Control", "no-store, max-age=0");
|
|
340
360
|
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
341
361
|
res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
|
|
362
|
+
res.setHeader("Permissions-Policy", POLITIQUE_PERMISSIONS);
|
|
342
363
|
res.setHeader("Content-Security-Policy", [
|
|
343
364
|
"default-src 'none'",
|
|
344
365
|
`script-src 'nonce-${nonce}' https://accounts.google.com/gsi/client`,
|
|
@@ -427,6 +448,7 @@ function sendPresentHtml(res, html, nonce, supaUrl, imgExtra, frameAncestors) {
|
|
|
427
448
|
res.setHeader("Cache-Control", "no-store, max-age=0");
|
|
428
449
|
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
429
450
|
res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
|
|
451
|
+
res.setHeader("Permissions-Policy", POLITIQUE_PERMISSIONS);
|
|
430
452
|
res.setHeader("Content-Security-Policy", [
|
|
431
453
|
"default-src 'none'",
|
|
432
454
|
`script-src 'nonce-${nonce}' 'self' https://cdn.jsdelivr.net https://unpkg.com https://maps.googleapis.com https://maps.gstatic.com`,
|
|
@@ -533,7 +555,7 @@ async function handler(req, res) {
|
|
|
533
555
|
if (!octets) {
|
|
534
556
|
try { octets = global.__actifsPdfjs[fichier] = require("node:fs").readFileSync(require.resolve(fichier)); } catch { /* dépendance absente */ }
|
|
535
557
|
}
|
|
536
|
-
if (!octets) { res
|
|
558
|
+
if (!octets) { refuserEnTexte(res, 404, "actif indisponible"); return; }
|
|
537
559
|
res.statusCode = 200;
|
|
538
560
|
res.setHeader("Content-Type", "text/javascript; charset=utf-8");
|
|
539
561
|
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
@@ -845,7 +867,7 @@ async function handler(req, res) {
|
|
|
845
867
|
const pres = await getPresentation(String(q.present));
|
|
846
868
|
if (!pres) return sendRefusal(res, "ended", embed);
|
|
847
869
|
if (String(q.file || "") === "1") {
|
|
848
|
-
if (!isAllowedStorageUrl(pres.file_url)) { res
|
|
870
|
+
if (!isAllowedStorageUrl(pres.file_url)) { refuserEnTexte(res, 404, "Fichier indisponible"); return; }
|
|
849
871
|
const range = req.headers["range"];
|
|
850
872
|
const r = await PLAYER.storage.fetchFile(pres.file_url, { range });
|
|
851
873
|
await relayerFichier(res, r, null);
|
|
@@ -1013,6 +1035,6 @@ async function handler(req, res) {
|
|
|
1013
1035
|
// tenir une seconde liste — c'est la seule façon qu'une empreinte périmée finisse par se voir.
|
|
1014
1036
|
// ⚠️ Exporté pour être ÉPROUVÉ, pas pour être appelé : le plafond du relais ne se vérifie qu en
|
|
1015
1037
|
// regardant si le corps a été lu, ce qu aucune route ne peut montrer de l extérieur.
|
|
1016
|
-
module.exports = { handler, init, TIERS, __relayerFichier: relayerFichier, __jsonPourScript: jsonPourScript };
|
|
1038
|
+
module.exports = { handler, init, TIERS, POLITIQUE_PERMISSIONS, __relayerFichier: relayerFichier, __jsonPourScript: jsonPourScript };
|
|
1017
1039
|
|
|
1018
1040
|
// redeploy: forcer le build production (Vercel a sauté la prod du merge #463 — wording re-partage).
|
package/server/presentations.js
CHANGED
|
@@ -828,7 +828,10 @@ function signatureAbsente(erreur) {
|
|
|
828
828
|
if (!erreur) return false;
|
|
829
829
|
const code = erreur.details && (erreur.details.code || (erreur.details.error && erreur.details.error.code));
|
|
830
830
|
if (code === "PGRST202") return true;
|
|
831
|
-
|
|
831
|
+
// ⚠️ PAS DE `erreur &&` ICI : la garde de la première ligne l'a déjà tranché. Le garder ne
|
|
832
|
+
// protégeait de rien et APPRENAIT AU LECTEUR QUE `erreur` PEUT ÊTRE NULLE À CET ENDROIT — ce qui
|
|
833
|
+
// est faux. Un test qui ne peut pas échouer ne coûte pas un cycle, il coûte une lecture.
|
|
834
|
+
const texte = String(erreur.message || "");
|
|
832
835
|
return texte.includes("PGRST202") || /Could not find the function/i.test(texte);
|
|
833
836
|
}
|
|
834
837
|
|
|
@@ -1138,12 +1141,17 @@ async function recordAttendance(slug, participant, { presentation = null, ipHash
|
|
|
1138
1141
|
presentateurEcrit = presentateurEcrit
|
|
1139
1142
|
|| (controlHash != null && relu.control_hash != null && controlHash === relu.control_hash);
|
|
1140
1143
|
}
|
|
1141
|
-
|
|
1144
|
+
// ⚠️ ON RELIT À CHAQUE TOUR — ET C'EST DÉJÀ CE QUI SE PASSAIT. La lecture était gardée par un
|
|
1145
|
+
// `if (cur === null)` qui ne pouvait JAMAIS être faux : les deux seuls chemins qui rebouclent
|
|
1146
|
+
// (un 409 à la création, une écriture battue en vol) remettaient `cur` à null juste avant. La
|
|
1147
|
+
// condition décrivait une intention — « ne relis que si tu n'as pas d'état frais » — que le code
|
|
1148
|
+
// ne pouvait pas contredire. Signalée par le scan (#78), et il a raison : une branche morte
|
|
1149
|
+
// laisse croire qu'il existe un cas où l'on repart avec un état déjà lu. Il n'y en a pas, et il
|
|
1150
|
+
// ne DOIT pas y en avoir : rejouer sur un état périmé est précisément ce que la serrure
|
|
1151
|
+
// `last_seen` interdit.
|
|
1142
1152
|
for (let essai = 0; essai < 4; essai += 1) {
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
cur = (Array.isArray(rows) && rows[0]) || false;
|
|
1146
|
-
}
|
|
1153
|
+
const rows = await PLAYER.db.request(`doc_presentation_attendees?slug=eq.${enc(slug)}&attendee_key=eq.${enc(String(key))}&select=*&limit=1`);
|
|
1154
|
+
let cur = (Array.isArray(rows) && rows[0]) || false;
|
|
1147
1155
|
if (!cur) {
|
|
1148
1156
|
const now = Date.now();
|
|
1149
1157
|
const row = { slug: String(slug), attendee_key: String(key).slice(0, 200), name: (name || "").slice(0, 120) || null, email: lc(email) || null, avatar: (avatar || "").slice(0, 600) || null, is_member: !!isMember, is_presenter: presentateurEcrit, first_seen: new Date(now).toISOString(), last_seen: new Date(now).toISOString(), total_ms: 0, pages: [pageEcrite] };
|
|
@@ -1159,8 +1167,7 @@ async function recordAttendance(slug, participant, { presentation = null, ipHash
|
|
|
1159
1167
|
// Journalisé comme bénin : deux onglets qui arrivent ensemble sont une information, pas
|
|
1160
1168
|
// une panne — et la garde des écritures muettes exige que tout rattrapage parle.
|
|
1161
1169
|
try { PLAYER.errors.capture(new Error("présence déjà ouverte (second onglet) : " + String(slug)), { route: "present-attend", benin: true }); } catch { /* jamais bloquant */ }
|
|
1162
|
-
|
|
1163
|
-
continue;
|
|
1170
|
+
continue; // le tour suivant relit : la ligne que l'autre onglet vient de créer
|
|
1164
1171
|
}
|
|
1165
1172
|
}
|
|
1166
1173
|
// ⚠️ UN `last_seen` ACCEPTÉ EST STRICTEMENT CROISSANT — sans ça, la serrure est aveugle dans
|
|
@@ -1184,7 +1191,7 @@ async function recordAttendance(slug, participant, { presentation = null, ipHash
|
|
|
1184
1191
|
is_member: !!isMember, is_presenter: presentateurEcrit },
|
|
1185
1192
|
);
|
|
1186
1193
|
if (ecrit) return { ok: true };
|
|
1187
|
-
|
|
1194
|
+
// battu en vol : le tour suivant relit l'état frais — c'est la lecture en tête de boucle
|
|
1188
1195
|
}
|
|
1189
1196
|
// Quatre tours perdus : un battement de présence se reperd sans conséquence — le suivant arrive
|
|
1190
1197
|
// dans quelques secondes. Refuser net plutôt qu'écraser ce que les gagnants viennent d'écrire.
|
package/server/routes-agent.js
CHANGED
|
@@ -106,13 +106,59 @@ async function traiter(req, res, body, _slug) {
|
|
|
106
106
|
if (body.action === "bot-rate") { // satisfaction (1-5 étoiles) posée depuis le bloc central du viewer
|
|
107
107
|
const sess = await docbot.getSession(String(body.sessionId || ""));
|
|
108
108
|
if (!sess || sess.share_slug !== share.slug) return jp(400, { ok: false, error: "session" });
|
|
109
|
-
|
|
110
|
-
|
|
109
|
+
// ⚠️ CE REFUS ÉTAIT INATTEIGNABLE, ET C'EST LA MESURE QUI EN PAYAIT LE PRIX.
|
|
110
|
+
//
|
|
111
|
+
// const note = Math.max(1, Math.min(5, Number(body.rating) || 0));
|
|
112
|
+
// if (!note) return jp(400, { ok: false, error: "rating" });
|
|
113
|
+
//
|
|
114
|
+
// `Math.max(1, …)` posait un PLANCHER à 1 : `note` valait toujours au moins 1, donc
|
|
115
|
+
// `!note` n'était jamais vrai et la ligne suivante ne s'exécutait jamais. Une notation
|
|
116
|
+
// SANS note — champ absent, `null`, `"abc"`, un double envoi du client — n'était pas
|
|
117
|
+
// refusée : elle enregistrait **1 étoile**, la pire du barème. La satisfaction mesurée
|
|
118
|
+
// baissait donc d'elle-même à chaque appel malformé, et rien dans le chiffre ne
|
|
119
|
+
// permettait de le voir.
|
|
120
|
+
//
|
|
121
|
+
// C'est la règle que ce dépôt applique déjà au temps de lecture, une couche plus haut :
|
|
122
|
+
// « une métrique qui dit le contraire est la première que les gens cessent de croire ».
|
|
123
|
+
// Une note absente n'est pas une mauvaise note, c'est une absence — et elle se refuse.
|
|
124
|
+
//
|
|
125
|
+
// ⚠️ TROUVÉ EN CHERCHANT À EXERCER LA BRANCHE, pas en lisant le code. Le refus était
|
|
126
|
+
// écrit, donc il avait l'air tenu ; il a fallu écrire le test qui l'atteint pour
|
|
127
|
+
// constater qu'aucune entrée n'y arrivait.
|
|
128
|
+
//
|
|
129
|
+
// ⚠️ `Math.round` PARCE QUE LA COLONNE EST `smallint`. `3.7` traversait la garde intact
|
|
130
|
+
// et partait tel quel vers PostgREST : l'arrondi se décidait en aval, hors de vue. Le
|
|
131
|
+
// plafond à 5 reste, lui — un clic sur une sixième étoile est un défaut d'interface,
|
|
132
|
+
// pas une tentative, et le ramener à 5 est ce que l'appelant voulait dire.
|
|
133
|
+
//
|
|
134
|
+
// `!(note >= 1)` plutôt que `!note` : la forme rend faux pour `NaN` sans s'en remettre
|
|
135
|
+
// au `|| 0` qui le précède, donc elle survit à un changement de la ligne d'au-dessus.
|
|
136
|
+
const note = Math.min(5, Math.round(Number(body.rating) || 0));
|
|
137
|
+
if (!(note >= 1)) return jp(400, { ok: false, error: "rating" });
|
|
111
138
|
const cmt = String(body.comment || "").trim().slice(0, 500); // mot facultatif (2e temps du bloc)
|
|
112
139
|
await PLAYER.db.request("doc_bot_sessions?id=eq." + encodeURIComponent(String(body.sessionId)), { method: "PATCH", headers: { Prefer: "return=minimal" }, body: cmt ? { rating: note, rating_comment: cmt } : { rating: note } });
|
|
113
140
|
return jp(200, { ok: true });
|
|
114
141
|
}
|
|
115
|
-
if (body.action === "bot-history")
|
|
142
|
+
if (body.action === "bot-history") {
|
|
143
|
+
// ⚠️ MÊME LIAISON QUE `bot-rate` ET `bot-script`, ET ELLE MANQUAIT ICI.
|
|
144
|
+
//
|
|
145
|
+
// Cette action rendait le transcript de N'IMPORTE QUELLE session à qui en connaissait
|
|
146
|
+
// l'identifiant — et cet identifiant voyage côté client. Il suffisait d'un slug avec
|
|
147
|
+
// assistant activé (le refus juste au-dessus) et d'un `sessionId` récupéré ailleurs
|
|
148
|
+
// pour lire la conversation tenue sur un autre document : des questions de prospect,
|
|
149
|
+
// c'est-à-dire de la donnée commerciale sur un client, pas un compteur d'usage.
|
|
150
|
+
//
|
|
151
|
+
// ⚠️ ON NE PEUT PAS DÉLÉGUER CE FILTRAGE AU GREFFON. `docbot` est `ctx.plugins.bot`,
|
|
152
|
+
// fourni par l'hôte : supposer qu'il recoupe la session et le document reviendrait à
|
|
153
|
+
// faire dépendre une propriété de sécurité du player d'un code qu'il ne contient pas.
|
|
154
|
+
// C'est exactement pourquoi les deux actions voisines le vérifient elles-mêmes.
|
|
155
|
+
//
|
|
156
|
+
// Les deux voisines avaient la garde ET leur banc ; celle-ci n'avait ni l'une ni
|
|
157
|
+
// l'autre, et la relecture qui a ajouté les bancs a couvert deux actions sur trois.
|
|
158
|
+
const sess = await docbot.getSession(String(body.sessionId || ""));
|
|
159
|
+
if (!sess || sess.share_slug !== share.slug) return jp(400, { ok: false, error: "session" });
|
|
160
|
+
return jp(200, { ok: true, messages: await docbot.listMessages(String(body.sessionId || "")) });
|
|
161
|
+
}
|
|
116
162
|
// ⚠️ Même piège : un objet littéral répond à `constructor`. Sans `Object.hasOwn`, une
|
|
117
163
|
// langue « constructor » passait la garde et finissait interpolée dans le prompt du
|
|
118
164
|
// modèle sous la forme « function Object() { [native code] } ».
|