discovery-media-player 0.1.9 → 0.1.11

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.
@@ -13,6 +13,7 @@
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
14
  // REFUSE — jamais il n'accorde par défaut. Cf. docs/CONFIGURATION.md, « Decisions that are yours ».
15
15
 
16
+ const crypto = require("node:crypto");
16
17
  const storage = require("./storage");
17
18
 
18
19
  /**
@@ -164,6 +165,39 @@ function createStandaloneContext(env = process.env) {
164
165
  }),
165
166
  // Écriture de fichier : hors périmètre d'une instance autonome (elle sert, elle ne range pas).
166
167
  async put() { throw new Error("storage.put n'est pas disponible sans câblage d'hôte"); },
168
+
169
+ /**
170
+ * URL d'envoi signée, pour une pièce jointe de chat. **Capacité de l'HÔTE**, pas du cœur.
171
+ *
172
+ * ⚠️ Le cœur lisait `SUPABASE_SERVICE_ROLE_KEY` directement dans l'environnement — la clé
173
+ * qui ouvre toute la base, lue en contournant le contexte injecté, c'est-à-dire en violant
174
+ * la règle que le projet affiche partout ailleurs. Un hôte qui branche SA base se faisait
175
+ * court-circuiter ici sans que rien ne le signale.
176
+ *
177
+ * ⚠️ Honnête sur ce qui reste : la page renvoyée appelle `uploadToSignedUrl` de supabase-js.
178
+ * Cette capacité retire le secret du cœur ; elle ne rend pas la fonction portable pour
179
+ * autant. Un hôte sur une autre pile devra aussi remplacer la moitié navigateur.
180
+ */
181
+ async signUpload(bucket, chemin) {
182
+ const base = sansBarreFinale(env.SUPABASE_URL);
183
+ const cle = String(env.SUPABASE_SERVICE_ROLE_KEY || "");
184
+ if (!base || !cle || !bucket || !chemin) return null;
185
+ try {
186
+ const r = await fetch(`${base}/storage/v1/object/upload/sign/${bucket}/${chemin}`, {
187
+ method: "POST",
188
+ headers: { apikey: cle, Authorization: `Bearer ${cle}`, "Content-Type": "application/json" },
189
+ body: "{}",
190
+ });
191
+ if (!r.ok) return null;
192
+ const d = await r.json().catch(() => null);
193
+ const url = d && d.url;
194
+ if (!url) return null;
195
+ return {
196
+ token: (String(url).split("token=")[1] || "").split("&")[0],
197
+ publicUrl: `${base}/storage/v1/object/public/${bucket}/${chemin}`,
198
+ };
199
+ } catch { return null; }
200
+ },
167
201
  },
168
202
 
169
203
  db: { request: db.request, selectAll: db.selectAll },
@@ -219,6 +253,29 @@ function createStandaloneContext(env = process.env) {
219
253
  return r.ok ? await r.json() : null;
220
254
  } catch { return null; }
221
255
  },
256
+ /**
257
+ * Cet appel vient-il de l'HÔTE lui-même, et non d'un de ses membres ?
258
+ *
259
+ * ⚠️ UN SECRET DISTINCT DE `PLAYER_HOST_FETCH_SECRET`, ET C'EST LE POINT. Celui-là ne
260
+ * circule QUE dans un sens — le player l'envoie à l'hôte, à chaque fichier. Il traîne donc
261
+ * dans les journaux d'accès de l'hôte, ses proxys, son traceur d'erreurs. Aujourd'hui, qui
262
+ * l'obtient peut se faire passer pour le player AUPRÈS de l'hôte. L'accepter en entrée lui
263
+ * ajouterait le droit d'écrire ICI — créer des liens tracés qui collecteront des lectures.
264
+ * Une variable de plus contre un rayon d'explosion qui ne grandit pas : un secret ne suit
265
+ * ni un changement de destinataire, ni un changement de direction.
266
+ *
267
+ * ⚠️ Le cœur ne voit jamais le secret : il pose une QUESTION, l'adaptateur répond oui ou
268
+ * non. Non configuré ⇒ non — un pouvoir qu'on ne sait pas accorder ne s'accorde pas.
269
+ */
270
+ isTrustedHostCall(headers) {
271
+ const attendu = String(env.PLAYER_HOST_SHARE_SECRET || "");
272
+ const recu = String((headers && (headers["x-player-share-secret"] || headers["X-Player-Share-Secret"])) || "");
273
+ if (!attendu || !recu) return false;
274
+ // Comparaison à temps constant : une comparaison naïve fuit la longueur du préfixe commun.
275
+ const a = Buffer.from(attendu, "utf8"); const b = Buffer.from(recu, "utf8");
276
+ return a.length === b.length && crypto.timingSafeEqual(a, b);
277
+ },
278
+
222
279
  // ⚠️ Le rôle vient d'`app_metadata`, JAMAIS d'`user_metadata` : ce dernier est modifiable par
223
280
  // l'utilisateur lui-même. S'y fier revient à laisser chacun choisir ses droits.
224
281
  roleOf: (user) => String(((user || {}).app_metadata || {}).role || "").trim().toLowerCase(),
@@ -285,7 +342,8 @@ function createStandaloneContext(env = process.env) {
285
342
  },
286
343
 
287
344
  config: {
288
- supabaseUrl: env.SUPABASE_URL || "",
345
+ // Sans barre finale : le cœur consomme cette valeur telle quelle. Un seul endroit décide.
346
+ supabaseUrl: sansBarreFinale(env.SUPABASE_URL),
289
347
  supabasePublishableKey: env.SUPABASE_PUBLISHABLE_KEY || "",
290
348
  mapsKey: env.GOOGLE_MAPS_API_KEY || "",
291
349
  extraFrameAncestors: String(env.DOC_FRAME_ANCESTORS || "").split(/\s+/).filter(Boolean),
@@ -295,6 +353,9 @@ function createStandaloneContext(env = process.env) {
295
353
  // les deux, une variable oubliée redonne exactement le symptôme que 0.1.8 a retiré — des
296
354
  // membres « non authentifiés », ce qui ressemble à un droit manquant.
297
355
  separateIssuer: !!sansBarreFinale(env.PLAYER_AUTH_URL),
356
+ // Même raison que ci-dessus : « la capacité existe » et « elle est configurée »
357
+ // sont deux questions, et seule la seconde explique un refus.
358
+ hostShare: !!String(env.PLAYER_HOST_SHARE_SECRET || ""),
298
359
  },
299
360
  };
300
361
  }
@@ -26,9 +26,10 @@ need.
26
26
  "product": "discovery-media-player",
27
27
  "contract": 1,
28
28
  "version": "0.1.9",
29
- "capabilities": ["docshare", "presentations", "embed-denied", "host-fetch", "brand-reference", "host-auth"],
29
+ "capabilities": ["docshare", "presentations", "embed-denied", "host-fetch", "brand-reference", "host-auth", "host-share"],
30
30
  "frameAncestors": ["'self'", "https://*.vercel.app", "https://app.example.com"],
31
31
  "separateIssuer": true,
32
+ "hostShare": true,
32
33
  "plugins": { "bot": false, "visitors": false, "brandIntro": false, "botBrowser": false, "providerQuotas": false }
33
34
  }
34
35
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "discovery-media-player",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
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",
@@ -69,7 +69,7 @@
69
69
  "@eslint/js": "^10.0.1",
70
70
  "esbuild": "^0.28.2",
71
71
  "eslint": "^10.8.1",
72
- "jsdom": "^25.0.1",
72
+ "jsdom": "^30.0.1",
73
73
  "typescript": "^5.5.4",
74
74
  "typescript-eslint": "^8.46.4",
75
75
  "vitest": "^4.1.10"
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");
@@ -2454,6 +2461,67 @@ async function handler(req, res) {
2454
2461
  if (String(body.action || "").startsWith("docshare.")) {
2455
2462
  const jd = (status, obj) => { res.statusCode = status; res.setHeader("Content-Type", "application/json"); res.end(JSON.stringify(obj)); };
2456
2463
  try {
2464
+ // ── L'HÔTE PARLE EN SON NOM PROPRE ────────────────────────────────────────────────
2465
+ //
2466
+ // Un lien ANONYME — la plaquette publique d'un programme, lue par un prospect sans
2467
+ // compte — n'a pas de membre derrière lui. Exiger un JWT forcerait l'hôte à inventer
2468
+ // une identité qui n'existe pas : un compte de service dont le mot de passe ouvre bien
2469
+ // plus que la création d'un lien, ou pire, l'aperçu interne détourné — ce qui rangerait
2470
+ // un prospect dans la population INTERNE et ferait mentir la seule phrase qui compte
2471
+ // ici : « ce client a lu pendant douze minutes ».
2472
+ //
2473
+ // C'est la même nature que `/authz` et `/branding`, que l'hôte sert déjà en serveur à
2474
+ // serveur. Demandé par le second hôte, qui avait écarté les trois contournements
2475
+ // lui-même avant d'écrire.
2476
+ //
2477
+ // ⚠️ TROIS VERROUS, et chacun ferme une porte différente :
2478
+ // 1. `create` UNIQUEMENT — révoquer, lister, lire des statistiques restent des actes
2479
+ // de membre. Un secret de serveur ne doit pas donner à voir qui a lu quoi.
2480
+ // 2. AUCUN destinataire — le nominatif a un membre. L'admettre ici rouvrirait par la
2481
+ // bande ce que le JWT protège.
2482
+ // 3. IDEMPOTENT par `docId` — sans ça, un redéploiement, une reprise sur erreur ou un
2483
+ // double clic donnent trois liens pour la même plaquette, donc des statistiques
2484
+ // fragmentées en trois, découvertes en les lisant six mois plus tard.
2485
+ if (typeof PLAYER.identity.isTrustedHostCall === "function"
2486
+ && PLAYER.identity.isTrustedHostCall(req.headers)) {
2487
+ if (body.action !== "docshare.create") {
2488
+ return jd(403, { ok: false, error: "L'appel serveur à serveur ne crée que des liens sans destinataire." });
2489
+ }
2490
+ if (body.recipientEmail) {
2491
+ return jd(400, { ok: false, error: "Un lien avec destinataire appartient à un membre : il exige son jeton." });
2492
+ }
2493
+ const docId = String(body.docId || "").trim();
2494
+ if (!docId || !body.fileUrl) return jd(400, { ok: false, error: "docId/fileUrl requis" });
2495
+
2496
+ const ipH = String(req.headers["x-forwarded-for"] || "").split(",")[0].trim() || req.socket?.remoteAddress || "hote";
2497
+ if (!(await PLAYER.limits.allow(`hshare:${ipH}`, 120, 3600))) return jd(429, { ok: false, error: "rate" });
2498
+
2499
+ // ⚠️ La clé d'idempotence n'a PAS demandé de colonne : « le lien de l'hôte pour ce
2500
+ // document » est exactement la ligne sans créateur ET sans destinataire. Seul ce
2501
+ // chemin en produit, donc elle est sans ambiguïté — et une instance déjà en service
2502
+ // n'a aucune migration à passer.
2503
+ const dejaLa = await PLAYER.db.request(
2504
+ `commercial_doc_shares?doc_id=eq.${encodeURIComponent(docId)}&created_by=is.null&recipient_email=is.null&select=slug&limit=1`,
2505
+ );
2506
+ if (Array.isArray(dejaLa) && dejaLa[0]) {
2507
+ await PLAYER.db.request(`commercial_doc_shares?slug=eq.${encodeURIComponent(dejaLa[0].slug)}`, {
2508
+ method: "PATCH", headers: { Prefer: "return=minimal" },
2509
+ body: { doc_title: body.docTitle || null, file_url: String(body.fileUrl), file_name: body.fileName || null, revoked: false },
2510
+ });
2511
+ return jd(200, { ok: true, slug: dejaLa[0].slug, reused: true });
2512
+ }
2513
+ // `createdBy` reste NUL : personne ne l'a créé. Le filtre « mes liens » compare
2514
+ // `created_by=eq.<email>`, qui exclut les NUL — ce lien n'apparaît donc dans la liste
2515
+ // de personne, et reste visible en administration (`list.all`, qui ne filtre pas).
2516
+ const neuf = await createShare({
2517
+ brandKey: body.brandKey, docId, docTitle: body.docTitle, fileUrl: body.fileUrl,
2518
+ fileName: body.fileName, createdBy: null, bot: body.bot, botScript: body.botScript,
2519
+ guided: body.guided, profileId: body.profileId, allowDownload: body.allowDownload,
2520
+ videoLayout: body.videoLayout, logo: body.logo, logoDark: body.logoDark,
2521
+ });
2522
+ return jd(200, { ok: true, slug: neuf.slug, reused: false });
2523
+ }
2524
+
2457
2525
  const u = await PLAYER.identity.verifyToken(req.headers.authorization);
2458
2526
  if (!u || !u.email) return jd(401, { ok: false, error: "auth" });
2459
2527
  // L'action est transmise telle quelle (`create`, `revoke`…) : l'hôte peut séparer
@@ -2588,6 +2656,7 @@ async function handler(req, res) {
2588
2656
  // muette sur les URL.
2589
2657
  capabilities: [
2590
2658
  "docshare", "presentations", "embed-denied", "host-fetch", "brand-reference", "host-auth",
2659
+ "host-share",
2591
2660
  ],
2592
2661
  // ⚠️ POUR QUELLES ORIGINES cette instance accepte d'être encadrée. Un booléen ne
2593
2662
  // suffisait pas : un hôte a besoin de voir que SON domaine manque, pas seulement que
@@ -2600,6 +2669,10 @@ async function handler(req, res) {
2600
2669
  // l'hôte connaît déjà son émetteur — il veut seulement savoir si l'instance le regarde.
2601
2670
  // Dire lequel n'aiderait personne et renseignerait qui sonde.
2602
2671
  separateIssuer: !!(PLAYER.config && PLAYER.config.separateIssuer),
2672
+ // « L'hôte peut-il créer un lien en son nom propre ? » — configuré, pas seulement
2673
+ // possible. Un hôte qui oublie le secret reçoit un 401 qui ressemble à un droit
2674
+ // manquant ; ce booléen le lui dit sans qu'il ait à essayer.
2675
+ hostShare: !!(PLAYER.config && PLAYER.config.hostShare),
2603
2676
  // Greffons de l'hôte : présents ou coupés (PLAYER_PLUGINS_OFF). Booléens uniquement.
2604
2677
  plugins: {
2605
2678
  bot: !!p.bot, visitors: !!p.visitors, brandIntro: !!p.brandIntro,
@@ -2651,8 +2724,8 @@ async function handler(req, res) {
2651
2724
  await relayerFichier(res, r, null);
2652
2725
  return;
2653
2726
  }
2654
- const supaUrl = process.env.SUPABASE_URL || "";
2655
- const supaKey = process.env.SUPABASE_PUBLISHABLE_KEY || "";
2727
+ const supaUrl = (PLAYER.config && PLAYER.config.supabaseUrl) || "";
2728
+ const supaKey = (PLAYER.config && PLAYER.config.supabasePublishableKey) || "";
2656
2729
  let alogo = ""; try { alogo = await PLAYER.branding.logo(); } catch { /* sans logo */ }
2657
2730
  const anonce = crypto.randomBytes(16).toString("base64");
2658
2731
  // Même sujet, trouvé en vérifiant le précédent : cette page ne passait AUCUN paramètre,
@@ -2674,8 +2747,8 @@ async function handler(req, res) {
2674
2747
  await relayerFichier(res, r, dispositionInline(q.name));
2675
2748
  return;
2676
2749
  }
2677
- const supaUrl = process.env.SUPABASE_URL || "";
2678
- const supaKey = process.env.SUPABASE_PUBLISHABLE_KEY || "";
2750
+ const supaUrl = (PLAYER.config && PLAYER.config.supabaseUrl) || "";
2751
+ const supaKey = (PLAYER.config && PLAYER.config.supabasePublishableKey) || "";
2679
2752
  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 || ""))}` };
2680
2753
  let plogo = ""; try { plogo = await PLAYER.branding.logo(); } catch { /* sans logo */ }
2681
2754
  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" };