discovery-media-player 0.1.10 → 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
  /**
@@ -252,6 +253,29 @@ function createStandaloneContext(env = process.env) {
252
253
  return r.ok ? await r.json() : null;
253
254
  } catch { return null; }
254
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
+
255
279
  // ⚠️ Le rôle vient d'`app_metadata`, JAMAIS d'`user_metadata` : ce dernier est modifiable par
256
280
  // l'utilisateur lui-même. S'y fier revient à laisser chacun choisir ses droits.
257
281
  roleOf: (user) => String(((user || {}).app_metadata || {}).role || "").trim().toLowerCase(),
@@ -329,6 +353,9 @@ function createStandaloneContext(env = process.env) {
329
353
  // les deux, une variable oubliée redonne exactement le symptôme que 0.1.8 a retiré — des
330
354
  // membres « non authentifiés », ce qui ressemble à un droit manquant.
331
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 || ""),
332
359
  },
333
360
  };
334
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.10",
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",
package/server/handler.js CHANGED
@@ -2461,6 +2461,67 @@ async function handler(req, res) {
2461
2461
  if (String(body.action || "").startsWith("docshare.")) {
2462
2462
  const jd = (status, obj) => { res.statusCode = status; res.setHeader("Content-Type", "application/json"); res.end(JSON.stringify(obj)); };
2463
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
+
2464
2525
  const u = await PLAYER.identity.verifyToken(req.headers.authorization);
2465
2526
  if (!u || !u.email) return jd(401, { ok: false, error: "auth" });
2466
2527
  // L'action est transmise telle quelle (`create`, `revoke`…) : l'hôte peut séparer
@@ -2595,6 +2656,7 @@ async function handler(req, res) {
2595
2656
  // muette sur les URL.
2596
2657
  capabilities: [
2597
2658
  "docshare", "presentations", "embed-denied", "host-fetch", "brand-reference", "host-auth",
2659
+ "host-share",
2598
2660
  ],
2599
2661
  // ⚠️ POUR QUELLES ORIGINES cette instance accepte d'être encadrée. Un booléen ne
2600
2662
  // suffisait pas : un hôte a besoin de voir que SON domaine manque, pas seulement que
@@ -2607,6 +2669,10 @@ async function handler(req, res) {
2607
2669
  // l'hôte connaît déjà son émetteur — il veut seulement savoir si l'instance le regarde.
2608
2670
  // Dire lequel n'aiderait personne et renseignerait qui sonde.
2609
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),
2610
2676
  // Greffons de l'hôte : présents ou coupés (PLAYER_PLUGINS_OFF). Booléens uniquement.
2611
2677
  plugins: {
2612
2678
  bot: !!p.bot, visitors: !!p.visitors, brandIntro: !!p.brandIntro,