discovery-media-player 0.1.129 → 0.1.130

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/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.129",
3
+ "version": "0.1.130",
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",
@@ -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
- const texte = String((erreur && erreur.message) || "");
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
- let cur = null;
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
- if (cur === null) {
1144
- const rows = await PLAYER.db.request(`doc_presentation_attendees?slug=eq.${enc(slug)}&attendee_key=eq.${enc(String(key))}&select=*&limit=1`);
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
- cur = null;
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
- cur = null; // battu en vol : on relira l'état frais au tour suivant
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.
@@ -106,8 +106,35 @@ 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
- const note = Math.max(1, Math.min(5, Number(body.rating) || 0));
110
- if (!note) return jp(400, { ok: false, error: "rating" });
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 });