discovery-media-player 0.1.8 → 0.1.10

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/README.md CHANGED
@@ -189,6 +189,11 @@ the player, not the people plugging into it.
189
189
  [`CONTRIBUTING.md`](CONTRIBUTING.md) — how to run the tests, what the review looks for, and the
190
190
  one rule that matters: a behaviour worth keeping is worth a test that fails without it.
191
191
 
192
+ Your first pull request asks you to sign the [CLA](CLA.md) — one reply, once, for good. You keep
193
+ the copyright in your work; you grant a licence that may be sublicensed, so that the core can stay
194
+ AGPL while a commercial licence remains possible for organisations that cannot live with the
195
+ network clause. Better said before you write the patch than after.
196
+
192
197
  The code comments are in French. The project was built in a French company and the reasoning
193
198
  behind each decision is written where the decision is; translating it would have meant either
194
199
  losing it or maintaining two versions. Everything an integrator needs is in English.
@@ -11,7 +11,7 @@
11
11
  // déléguées à des routes de l'hôte, ou refusées si elles ne sont pas configurées.
12
12
  //
13
13
  // ⚠️ Il ne remplace pas un câblage : il ne sait rien de vos rôles. Ce qu'il ne sait pas, il le
14
- // REFUSE — jamais il n'accorde par défaut. Cf. CONTRAT.md, « Le câblage d'une instance ».
14
+ // REFUSE — jamais il n'accorde par défaut. Cf. docs/CONFIGURATION.md, « Decisions that are yours ».
15
15
 
16
16
  const storage = require("./storage");
17
17
 
@@ -164,6 +164,39 @@ function createStandaloneContext(env = process.env) {
164
164
  }),
165
165
  // Écriture de fichier : hors périmètre d'une instance autonome (elle sert, elle ne range pas).
166
166
  async put() { throw new Error("storage.put n'est pas disponible sans câblage d'hôte"); },
167
+
168
+ /**
169
+ * URL d'envoi signée, pour une pièce jointe de chat. **Capacité de l'HÔTE**, pas du cœur.
170
+ *
171
+ * ⚠️ Le cœur lisait `SUPABASE_SERVICE_ROLE_KEY` directement dans l'environnement — la clé
172
+ * qui ouvre toute la base, lue en contournant le contexte injecté, c'est-à-dire en violant
173
+ * la règle que le projet affiche partout ailleurs. Un hôte qui branche SA base se faisait
174
+ * court-circuiter ici sans que rien ne le signale.
175
+ *
176
+ * ⚠️ Honnête sur ce qui reste : la page renvoyée appelle `uploadToSignedUrl` de supabase-js.
177
+ * Cette capacité retire le secret du cœur ; elle ne rend pas la fonction portable pour
178
+ * autant. Un hôte sur une autre pile devra aussi remplacer la moitié navigateur.
179
+ */
180
+ async signUpload(bucket, chemin) {
181
+ const base = sansBarreFinale(env.SUPABASE_URL);
182
+ const cle = String(env.SUPABASE_SERVICE_ROLE_KEY || "");
183
+ if (!base || !cle || !bucket || !chemin) return null;
184
+ try {
185
+ const r = await fetch(`${base}/storage/v1/object/upload/sign/${bucket}/${chemin}`, {
186
+ method: "POST",
187
+ headers: { apikey: cle, Authorization: `Bearer ${cle}`, "Content-Type": "application/json" },
188
+ body: "{}",
189
+ });
190
+ if (!r.ok) return null;
191
+ const d = await r.json().catch(() => null);
192
+ const url = d && d.url;
193
+ if (!url) return null;
194
+ return {
195
+ token: (String(url).split("token=")[1] || "").split("&")[0],
196
+ publicUrl: `${base}/storage/v1/object/public/${bucket}/${chemin}`,
197
+ };
198
+ } catch { return null; }
199
+ },
167
200
  },
168
201
 
169
202
  db: { request: db.request, selectAll: db.selectAll },
@@ -285,10 +318,17 @@ function createStandaloneContext(env = process.env) {
285
318
  },
286
319
 
287
320
  config: {
288
- supabaseUrl: env.SUPABASE_URL || "",
321
+ // Sans barre finale : le cœur consomme cette valeur telle quelle. Un seul endroit décide.
322
+ supabaseUrl: sansBarreFinale(env.SUPABASE_URL),
289
323
  supabasePublishableKey: env.SUPABASE_PUBLISHABLE_KEY || "",
290
324
  mapsKey: env.GOOGLE_MAPS_API_KEY || "",
291
325
  extraFrameAncestors: String(env.DOC_FRAME_ANCESTORS || "").split(/\s+/).filter(Boolean),
326
+ // ⚠️ Un BOOLÉEN, jamais l'URL. La carte d'identité doit pouvoir dire « un émetteur distinct
327
+ // est configuré » sans dire lequel : c'est le dernier endroit où un hôte devait deviner.
328
+ // `host-auth` dit que le code SAIT faire la séparation ; ceci dit qu'elle est POSÉE. Sans
329
+ // les deux, une variable oubliée redonne exactement le symptôme que 0.1.8 a retiré — des
330
+ // membres « non authentifiés », ce qui ressemble à un droit manquant.
331
+ separateIssuer: !!sansBarreFinale(env.PLAYER_AUTH_URL),
292
332
  },
293
333
  };
294
334
  }
@@ -25,9 +25,10 @@ need.
25
25
  {
26
26
  "product": "discovery-media-player",
27
27
  "contract": 1,
28
- "version": "0.1.8",
28
+ "version": "0.1.9",
29
29
  "capabilities": ["docshare", "presentations", "embed-denied", "host-fetch", "brand-reference", "host-auth"],
30
30
  "frameAncestors": ["'self'", "https://*.vercel.app", "https://app.example.com"],
31
+ "separateIssuer": true,
31
32
  "plugins": { "bot": false, "visitors": false, "brandIntro": false, "botBrowser": false, "providerQuotas": false }
32
33
  }
33
34
  ```
@@ -67,8 +68,14 @@ things. They are the same project while the player and your application share a
67
68
  different by construction once the instance is separate. Point `PLAYER_AUTH_URL` at the project
68
69
  that issues your members' tokens (with its own publishable key in `PLAYER_AUTH_KEY`), or every
69
70
  member action is refused in a way that reads like a missing permission. Unset, it falls back to
70
- the player's own project, so a shared deployment changes by not one character. The `host-auth`
71
- capability tells you an instance supports the split.
71
+ the player's own project, so a shared deployment changes by not one character.
72
+
73
+ Two signals, and you need both: **`host-auth` in `capabilities`** says this instance *can* target
74
+ a separate issuer; **`separateIssuer: true`** says one *is configured*. A version that supports
75
+ the split with the variable left unset fails exactly like the version before it — members come
76
+ back unauthenticated, which reads like a missing permission — and you would conclude the upgrade
77
+ changed nothing. The card answers with a boolean and never the issuer itself: you already know
78
+ which one is yours.
72
79
 
73
80
  ### 2. What a client's brand is
74
81
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "discovery-media-player",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
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",
@@ -66,12 +66,12 @@
66
66
  "node": ">=22"
67
67
  },
68
68
  "devDependencies": {
69
- "@eslint/js": "^9.39.4",
70
- "esbuild": "^0.25.12",
71
- "eslint": "^9.39.4",
72
- "jsdom": "^25.0.1",
69
+ "@eslint/js": "^10.0.1",
70
+ "esbuild": "^0.28.2",
71
+ "eslint": "^10.8.1",
72
+ "jsdom": "^30.0.1",
73
73
  "typescript": "^5.5.4",
74
74
  "typescript-eslint": "^8.46.4",
75
- "vitest": "^3.2.7"
75
+ "vitest": "^4.1.10"
76
76
  }
77
77
  }
package/server/handler.js CHANGED
@@ -1172,7 +1172,7 @@ const LEGAL_CSS = `
1172
1172
  function sendHtml(res, status, html, scriptSrc, imgExtra, frameAncestors) {
1173
1173
  res.statusCode = status;
1174
1174
  // Origine Supabase Storage (voix ElevenLabs mise en cache dans le bucket public tts-cache) → autorisée en media-src.
1175
- let supaOrigin = ""; try { supaOrigin = new URL(process.env.SUPABASE_URL || "").origin; } catch { supaOrigin = ""; }
1175
+ let supaOrigin; try { supaOrigin = new URL((PLAYER.config && PLAYER.config.supabaseUrl) || "").origin; } catch { supaOrigin = ""; }
1176
1176
  res.setHeader("Content-Type", "text/html; charset=utf-8");
1177
1177
  res.setHeader("Cache-Control", "no-store, max-age=0");
1178
1178
  res.setHeader("X-Content-Type-Options", "nosniff");
@@ -1217,9 +1217,16 @@ function sendSoftWallHtml(res, html, nonce, imgExtra, frameAncestors) {
1217
1217
  * doit pouvoir être encadrée exactement comme la visionneuse qu'elle remplace — sinon le
1218
1218
  * navigateur bloque le rendu et le message de refus ci-dessous n'est jamais émis.
1219
1219
  */
1220
+ // ⚠️ LA MÊME SOURCE QUE LA CARTE D'IDENTITÉ, ET C'EST TOUT LE SUJET. Cette fonction lisait
1221
+ // `process.env.DOC_FRAME_ANCESTORS` pendant que `?contract=1` annonçait
1222
+ // `PLAYER.config.extraFrameAncestors`. Les deux coïncident tant qu'un hôte remplit sa
1223
+ // configuration depuis cette variable — c'est le cas des deux hôtes actuels, donc rien n'était
1224
+ // cassé. Mais un hôte qui la calcule autrement (base, fichier, autre nom de variable) verrait la
1225
+ // carte annoncer une liste et l'en-tête CSP en servir une autre : « configuré » et « servi »
1226
+ // divergents à l'intérieur même du mécanisme construit pour détecter cette divergence.
1220
1227
  function embedFrameAncestors() {
1221
1228
  return ["'self'", "https://*.vercel.app"]
1222
- .concat(String(process.env.DOC_FRAME_ANCESTORS || "").split(/\s+/).filter(Boolean))
1229
+ .concat((PLAYER.config && PLAYER.config.extraFrameAncestors) || [])
1223
1230
  .join(" ");
1224
1231
  }
1225
1232
 
@@ -1580,7 +1587,7 @@ ${LEGAL_CSS}
1580
1587
  <script nonce="${nonce}">${PLAYER_BROWSER_JS}</script>
1581
1588
  <script nonce="${nonce}" src="${PDFJS}/pdf.min.js"></script>
1582
1589
  ${preview ? `<script nonce="${nonce}" src="${SUPAJS}"></script>
1583
- <script nonce="${nonce}">var LIVECFG={supaUrl:${JSON.stringify(share.supa_url || "")},supaKey:${JSON.stringify(share.supa_key || "")}};var GMAPS_KEY=${JSON.stringify(process.env.GOOGLE_MAPS_API_KEY || "")};${LIVE_JS}
1590
+ <script nonce="${nonce}">var LIVECFG={supaUrl:${JSON.stringify(share.supa_url || "")},supaKey:${JSON.stringify(share.supa_key || "")}};var GMAPS_KEY=${JSON.stringify((PLAYER.config && PLAYER.config.mapsKey) || "" || "")};${LIVE_JS}
1584
1591
  ${MAP_JS}</script>` : ""}
1585
1592
  <script nonce="${nonce}">
1586
1593
  (function(){
@@ -2087,7 +2094,7 @@ function presentHtml(pres, nonce, logoUrl, supaUrl, supaKey) {
2087
2094
  })();
2088
2095
  </script>
2089
2096
  <script nonce="${nonce}">
2090
- var LIVECFG={supaUrl:${JSON.stringify(supaUrl || "")},supaKey:${JSON.stringify(supaKey || "")}};var GMAPS_KEY=${JSON.stringify(process.env.GOOGLE_MAPS_API_KEY || "")};
2097
+ var LIVECFG={supaUrl:${JSON.stringify(supaUrl || "")},supaKey:${JSON.stringify(supaKey || "")}};var GMAPS_KEY=${JSON.stringify((PLAYER.config && PLAYER.config.mapsKey) || "" || "")};
2091
2098
  ${LIVE_JS}
2092
2099
  ${MAP_JS}
2093
2100
  (function(){
@@ -2252,7 +2259,7 @@ async function handler(req, res) {
2252
2259
  // `spoken` est renvoyé quand il diffère → le viewer aligne le karaoké dessus (mapping mot à mot).
2253
2260
  const spoken = (() => { if (!pron) return text; try { const s = pron(text).replace(/\s+/g, " ").trim().slice(0, 700); return s || text; } catch { return text; } })();
2254
2261
  const modelId = process.env.ELEVENLABS_MODEL || "eleven_multilingual_v2";
2255
- const base = process.env.SUPABASE_URL || "";
2262
+ const base = (PLAYER.config && PLAYER.config.supabaseUrl) || "";
2256
2263
  // « v2 » = version du format de cache : les extraits v1 (sans alignement timestamps) sont ignorés
2257
2264
  // d'office et tout se régénère AVEC l'horodatage par caractère (karaoké exact). Anciens fichiers = poids mort minime.
2258
2265
  const keyFor = (vid) => crypto.createHash("sha256").update(vid + "|" + modelId + "|v2|" + spoken).digest("hex");
@@ -2595,6 +2602,11 @@ async function handler(req, res) {
2595
2602
  // autrement — le navigateur bloque avant tout script, et rien ne peut lui être émis.
2596
2603
  // Ce n'est pas un secret : ces mêmes valeurs partent dans chaque en-tête CSP servi.
2597
2604
  frameAncestors: ["'self'", "https://*.vercel.app"].concat(PLAYER.config.extraFrameAncestors || []),
2605
+ // Même besoin que `frameAncestors`, l'inverse de la réponse : là on nomme les origines
2606
+ // parce qu'un hôte doit voir que LA SIENNE manque ; ici un booléen suffit, parce que
2607
+ // l'hôte connaît déjà son émetteur — il veut seulement savoir si l'instance le regarde.
2608
+ // Dire lequel n'aiderait personne et renseignerait qui sonde.
2609
+ separateIssuer: !!(PLAYER.config && PLAYER.config.separateIssuer),
2598
2610
  // Greffons de l'hôte : présents ou coupés (PLAYER_PLUGINS_OFF). Booléens uniquement.
2599
2611
  plugins: {
2600
2612
  bot: !!p.bot, visitors: !!p.visitors, brandIntro: !!p.brandIntro,
@@ -2646,8 +2658,8 @@ async function handler(req, res) {
2646
2658
  await relayerFichier(res, r, null);
2647
2659
  return;
2648
2660
  }
2649
- const supaUrl = process.env.SUPABASE_URL || "";
2650
- const supaKey = process.env.SUPABASE_PUBLISHABLE_KEY || "";
2661
+ const supaUrl = (PLAYER.config && PLAYER.config.supabaseUrl) || "";
2662
+ const supaKey = (PLAYER.config && PLAYER.config.supabasePublishableKey) || "";
2651
2663
  let alogo = ""; try { alogo = await PLAYER.branding.logo(); } catch { /* sans logo */ }
2652
2664
  const anonce = crypto.randomBytes(16).toString("base64");
2653
2665
  // Même sujet, trouvé en vérifiant le précédent : cette page ne passait AUCUN paramètre,
@@ -2669,8 +2681,8 @@ async function handler(req, res) {
2669
2681
  await relayerFichier(res, r, dispositionInline(q.name));
2670
2682
  return;
2671
2683
  }
2672
- const supaUrl = process.env.SUPABASE_URL || "";
2673
- const supaKey = process.env.SUPABASE_PUBLISHABLE_KEY || "";
2684
+ const supaUrl = (PLAYER.config && PLAYER.config.supabaseUrl) || "";
2685
+ const supaKey = (PLAYER.config && PLAYER.config.supabasePublishableKey) || "";
2674
2686
  const pseudo = { preview: true, slug: "", file_name: String(q.name || "document.pdf"), doc_title: String(q.title || q.name || "Document"), raw_url: url, doc_id: String(q.docId || ""), presenter_name: String(q.by || ""), presenter_avatar: String(q.av || ""), internal_email: String(q.uemail || ""), supa_url: supaUrl, supa_key: supaKey, auto_present: String(q.autopresent || "") === "1", resume_slug: String(q.resume || ""), stream_url: `/api/doc?preview=1&stream=1&url=${encodeURIComponent(url)}&name=${encodeURIComponent(String(q.name || ""))}` };
2675
2687
  let plogo = ""; try { plogo = await PLAYER.branding.logo(); } catch { /* sans logo */ }
2676
2688
  const pnonce = crypto.randomBytes(16).toString("base64");
@@ -130,7 +130,7 @@ async function endPresentation(slug, control) {
130
130
  // validés par le bucket (image/*+pdf, ≤10 Mo). L'URL publique finale est renvoyée pour attacher au message.
131
131
  const ATT_KINDS = { "image/png": "image", "image/jpeg": "image", "image/webp": "image", "image/gif": "image", "application/pdf": "pdf" };
132
132
  async function createUploadUrl(slug, name, type) {
133
- const base = (process.env.SUPABASE_URL || "").replace(/\/+$/, ""); const KEY = process.env.SUPABASE_SERVICE_ROLE_KEY || "";
133
+
134
134
  // ⚠️ `Object.hasOwn` et pas une simple lecture : un objet littéral hérite de `constructor`,
135
135
  // `toString`, `valueOf`, `__proto__`… `ATT_KINDS["constructor"]` rend une FONCTION, donc une
136
136
  // valeur vraie — la garde juste en dessous laissait alors passer un type qui n'a jamais été
@@ -138,14 +138,21 @@ async function createUploadUrl(slug, name, type) {
138
138
  // action publique. Signalé par un hôte tiers qui venait de trouver la même forme chez lui.
139
139
  const demande = String(type || "").toLowerCase();
140
140
  const kind = Object.hasOwn(ATT_KINDS, demande) ? ATT_KINDS[demande] : null;
141
- if (!kind || !slug || !base || !KEY) return { ok: false, status: 400 };
141
+ if (!kind || !slug) return { ok: false, status: 400 };
142
142
  const safe = (String(name || "fichier").replace(/[^a-zA-Z0-9._-]/g, "_").slice(-60)) || "fichier";
143
143
  const path = `${String(slug).replace(/[^a-zA-Z0-9._-]/g, "")}/${Date.now()}-${crypto.randomBytes(4).toString("hex")}-${safe}`;
144
- const r = await fetch(`${base}/storage/v1/object/upload/sign/present-attachments/${path}`, { method: "POST", headers: { apikey: KEY, Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" }, body: "{}" });
145
- if (!r.ok) return { ok: false, status: 502 };
146
- const d = await r.json().catch(() => null); const url = d && d.url; if (!url) return { ok: false, status: 502 };
147
- const token = (String(url).split("token=")[1] || "").split("&")[0];
148
- return { ok: true, path, token, kind, publicUrl: `${base}/storage/v1/object/public/present-attachments/${path}` };
144
+
145
+ // ⚠️ La signature appartient à l'HÔTE : c'est lui qui détient la clé, pas le cœur. Un hôte qui
146
+ // ne fournit pas cette capacité voit la pièce jointe refusée — et prévenue. Le refus silencieux
147
+ // serait pire : une pièce jointe qui ne part jamais, sans que personne sache que la capacité
148
+ // manque.
149
+ if (!PLAYER.storage || typeof PLAYER.storage.signUpload !== "function") {
150
+ try { PLAYER.errors.capture(new Error("storage.signUpload absent du contexte : les pièces jointes de chat sont indisponibles"), {}); } catch { /* jamais bloquant */ }
151
+ return { ok: false, status: 501 };
152
+ }
153
+ const signe = await PLAYER.storage.signUpload("present-attachments", path);
154
+ if (!signe || !signe.token) return { ok: false, status: 502 };
155
+ return { ok: true, path, token: signe.token, kind, publicUrl: signe.publicUrl };
149
156
  }
150
157
 
151
158
  /**
@@ -177,7 +184,10 @@ function premierPublic(reponse) {
177
184
 
178
185
  async function addMessage(slug, { name, email, avatar, isPresenter, isMember, body, replyTo, replyName, replyText, authorToken, attachment }) {
179
186
  const b = String(body || "").trim().slice(0, 2000);
180
- const base = (process.env.SUPABASE_URL || "").replace(/\/+$/, "");
187
+ // ⚠️ Pas de normalisation ici : `config.supabaseUrl` arrive SANS barre finale, c'est le
188
+ // contrat de l'adaptateur. En rajouter une deuxième couche, c'est deux endroits qui décident —
189
+ // et c'est comme ça que `.replace(/\/+$/, "")` s'est retrouvé à cinq exemplaires dans ce dépôt.
190
+ const base = String((PLAYER.config && PLAYER.config.supabaseUrl) || "");
181
191
  let att = null;
182
192
  if (attachment && typeof attachment === "object" && attachment.url && String(attachment.url).startsWith(base + "/storage/v1/object/public/present-attachments/")) {
183
193
  att = { url: String(attachment.url).slice(0, 600), name: String(attachment.name || "").slice(0, 120), type: String(attachment.type || "").slice(0, 60), kind: attachment.kind === "pdf" ? "pdf" : "image" };