discovery-media-player 0.1.173 → 0.1.175

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.
@@ -118,6 +118,22 @@ function fetchBorne(cible, options = {}, delaiMs) {
118
118
  */
119
119
  const DELAI_AUTH_MS = 5000;
120
120
  const DELAI_ROUTE_HOTE_MS = 4000;
121
+
122
+ /**
123
+ * La marque d'un client, GARDÉE EN MÉMOIRE (#570). Chaque ouverture d'un lien tracé attendait un
124
+ * POST à l'hôte avant d'envoyer la page ; hôte lent ou en panne, chaque ouverture payait jusqu'à
125
+ * `DELAI_ROUTE_HOTE_MS`. Une marque change rarement, un lien s'ouvre souvent.
126
+ *
127
+ * ⚠️ LES DURÉES DISENT CE QUI RESTE VRAI DE `brands.js` : la marque est toujours résolue par
128
+ * l'hôte, jamais recopiée dans le lien — une charte corrigée se voit en 5 minutes au plus. Une
129
+ * absence (clé inconnue, hôte en panne) n'est gardée qu'une minute : assez pour ne plus payer le
130
+ * délai à chaque ouverture, assez peu pour qu'une marque créée entre-temps apparaisse vite.
131
+ * ⚠️ BORNÉE : les clés viennent des liens, donc de la base ; sans plafond, une instance qui sert
132
+ * beaucoup de clients garderait tout pour toujours. La plus ancienne entrée sort la première.
133
+ */
134
+ const MARQUE_GARDEE_MS = 5 * 60_000;
135
+ const MARQUE_ABSENTE_MS = 60_000;
136
+ const MARQUES_MAX = 500;
121
137
  const DELAI_STOCKAGE_MS = 15000;
122
138
  const DELAI_BASE_MS = 15000;
123
139
 
@@ -431,6 +447,8 @@ function createStandaloneContext(env = process.env) {
431
447
  const origins = () => storage.storageOrigins(env);
432
448
  const hostBase = () => storage.hostFetchBase(env);
433
449
  const root = () => storage.localRoot(env);
450
+ // Les marques des clients, propres à CE contexte (#570) : deux contextes ne partagent rien.
451
+ const marques = new Map();
434
452
 
435
453
  return {
436
454
  // Aucun greffon : l'assistant IA, l'intro de marque et les comptes visiteurs sont des produits
@@ -794,8 +812,22 @@ function createStandaloneContext(env = process.env) {
794
812
  */
795
813
  async forKey(key) {
796
814
  if (!key) return null;
797
- const b = await appelHote(env.PLAYER_HOST_BRAND_URL, secret, { key: String(key) }, journal);
798
- return b && b.logo ? { logo: String(b.logo), name: String(b.name || ""), dark: !!b.dark } : null;
815
+ const cle = String(key);
816
+ const gardee = marques.get(cle);
817
+ // Une promesse, pas une valeur : dix lecteurs qui ouvrent le même lien au même instant
818
+ // attendent le MÊME appel au lieu d'en lancer dix.
819
+ if (gardee && Date.now() - gardee.depuis < gardee.duree) return gardee.marque;
820
+ const entree = { depuis: Date.now(), duree: MARQUE_GARDEE_MS, marque: null };
821
+ entree.marque = appelHote(env.PLAYER_HOST_BRAND_URL, secret, { key: cle }, journal).then((b) => {
822
+ const m = b && b.logo ? { logo: String(b.logo), name: String(b.name || ""), dark: !!b.dark } : null;
823
+ if (!m) entree.duree = MARQUE_ABSENTE_MS;
824
+ return m;
825
+ });
826
+ // Réinsérée en dernier : l'ordre d'insertion de la Map est l'ordre de sortie.
827
+ marques.delete(cle);
828
+ marques.set(cle, entree);
829
+ if (marques.size > MARQUES_MAX) marques.delete(marques.keys().next().value);
830
+ return entree.marque;
799
831
  },
800
832
 
801
833
  title(base, qualificatif) {
@@ -462,6 +462,32 @@ inheritance both refuse — without either of them knowing why.
462
462
  Requires `supabase/migrations/0001-destinataire-atteste.sql`. Until it is applied the player refuses
463
463
  the attested creation and names the file; it never falls back to the other column.
464
464
 
465
+ ### Reading back the reads you vouched for
466
+
467
+ Every session now carries `attested_recipient_email`, read from its link — on both session readings,
468
+ `docshare.sessions` and `docshare.sessionsByRecipient`, for members and for you alike.
469
+ `recipient_email` stays `null` on an attested link, for the reason above. Nothing is copied into the
470
+ sessions table: no migration beyond `0001`.
471
+
472
+ You can also read those sessions yourself, server to server: `docshare.sessions` with
473
+ `x-player-share-secret` and a `docId` answers `{ ok: true, sessions, scope: "host" }`, holding
474
+ **only** the sessions of links that have no author **and** an attested address — the links you
475
+ created for a visitor you vouched for. You learn how long your visitor read, never who else did:
476
+
477
+ - an **anonymous** link (no attested address) is not readable with the secret;
478
+ - a **member's** link (`created_by` set) stays behind the member's token and `list.all`;
479
+ - a **forwarded** link born from an attested one is not readable either — its reader was chosen by
480
+ your visitor, and you never vouched for them;
481
+ - every other action with the secret — `list`, `revoke`, `overview`, `setauth`,
482
+ `sessionsByRecipient` — is still refused with `403`.
483
+
484
+ ⚠️ **The sort by client is yours, and it belongs before any display.** A `docId` is shared across
485
+ clients, so this answer covers every visitor you attested on that document. Keep only the sessions
486
+ whose `attested_recipient_email` belongs to the client being shown, on your server, after your own
487
+ check that the person asking may see that client. Without `0001`, the call answers `409` and names the
488
+ file — never an empty list, which would read as *"your client read nothing"*. Rate-limited per
489
+ calling address, separately from creation.
490
+
465
491
  ## The visitor wall (`plugins.visitors`): what the player counts, and what your plugin must do
466
492
 
467
493
  A host can gate documents behind a soft wall — an e-mail code, or a Google credential — by providing
@@ -842,6 +868,12 @@ The link carries a **reference**, never a copy of the logo: a tracked link lives
842
868
  inbox, and a logo frozen at send time would not follow a corrected brand. `name` is not decorative
843
869
  — it is what shows when the logo fails to load.
844
870
 
871
+ The standalone context **keeps each answer in memory, per process**, so that opening a link does not
872
+ wait on your route every time: a brand for **5 minutes**, an absence (unknown key, error, timeout)
873
+ for **one minute**, at most 500 keys, oldest out first. Concurrent openings of the same link share
874
+ one call. So a corrected brand shows within 5 minutes, and a brand created for a key that was
875
+ unknown shows within a minute. The link still carries only the key — nothing is frozen in it.
876
+
845
877
  ### 3. Serving a file the player cannot reach
846
878
 
847
879
  If your documents sit behind an API key, the player must **never** hold it. Expose one route
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "discovery-media-player",
3
- "version": "0.1.173",
3
+ "version": "0.1.175",
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",
@@ -78,17 +78,60 @@ async function traiter(req, res, body, slug) {
78
78
  // lui-même avant d'écrire.
79
79
  //
80
80
  // ⚠️ TROIS VERROUS, et chacun ferme une porte différente :
81
- // 1. `create` UNIQUEMENT — révoquer, lister, lire des statistiques restent des actes
82
- // de membre. Un secret de serveur ne doit pas donner à voir qui a lu quoi.
83
- // 2. AUCUN destinataire — le nominatif a un membre. L'admettre ici rouvrirait par la
84
- // bande ce que le JWT protège.
81
+ // 1. `create`, ET LA LECTURE DES LIENS QUE L'HÔTE A LUI-MÊME ATTESTÉS — révoquer,
82
+ // lister, l'aperçu, la protection et la fiche d'une personne restent des actes de
83
+ // membre. Un secret de serveur ne doit pas donner à voir qui a lu quoi. (Ce verrou
84
+ // disait « `create` UNIQUEMENT » jusqu'au #574 ; ce qui a changé est dit juste
85
+ // dessous, à l'endroit où la porte s'ouvre.)
86
+ // 2. AUCUN destinataire NOMINATIF — le nominatif a un membre. L'admettre ici rouvrirait
87
+ // par la bande ce que le JWT protège. (Le destinataire ATTESTÉ n'en est pas un : il
88
+ // va dans sa propre colonne, voir plus bas.)
85
89
  // 3. IDEMPOTENT par `docId` — sans ça, un redéploiement, une reprise sur erreur ou un
86
90
  // double clic donnent trois liens pour la même plaquette, donc des statistiques
87
91
  // fragmentées en trois, découvertes en les lisant six mois plus tard.
88
92
  if (typeof PLAYER.identity.isTrustedHostCall === "function"
89
93
  && PLAYER.identity.isTrustedHostCall(req.headers)) {
94
+ // ⚠️ L'HÔTE LIT LES SESSIONS DES LIENS QU'IL A CRÉÉS ET ATTESTÉS — ET AUCUNE AUTRE.
95
+ //
96
+ // Ce que le verrou 1 refusait, c'est qu'un secret de serveur donne à voir QUI a lu. Ici
97
+ // l'hôte ne découvre personne : chaque adresse de cette réponse, il l'a fournie lui-même
98
+ // à la création, depuis sa base, après vérification. Il apprend COMBIEN DE TEMPS son
99
+ // client a lu, pas qui. Sans cette porte, un hôte de production mesurait 108 sessions
100
+ // complètes, temps par page compris, et affichait « 0 de lecture » à son conseiller : le
101
+ // lien n'a pas d'auteur, donc aucun membre ne le voit sous « mes liens », et `list.all`
102
+ // le voyait sans adresse (#574).
103
+ //
104
+ // La borne est double, et chaque moitié ferme une porte :
105
+ // • `created_by` NUL — les liens des membres restent derrière le JWT et `list.all` ;
106
+ // • adresse attestée NON NULLE — un lien anonyme (la plaquette publique lue par un
107
+ // prospect sans compte) ne se lit pas par le secret.
108
+ // C'est le pendant exact du « mes liens » d'un membre : l'hôte lit les liens qu'il a
109
+ // créés. La réponse dit `scope: "host"` pour que personne ne la confonde avec `all`.
110
+ //
111
+ // ⚠️ ON A ÉCARTÉ UNE QUESTION `list.host` POSÉE À `canManageShares`. Elle passerait par
112
+ // le JWT d'un membre, et le `docId` se partage entre clients : un membre verrait, dans
113
+ // tout écran qui lit des sessions, les clients attestés de ses collègues. Lu par le
114
+ // secret, le tri par client se fait sur le serveur de l'hôte, avant tout affichage.
115
+ if (body.action === "docshare.sessions") {
116
+ const docId = String(body.docId || "").trim();
117
+ if (!docId) return jd(400, { ok: false, error: "docId requis" });
118
+ // Même esprit que `hshare:` — une clé à part, parce que lire et créer n'ont pas le
119
+ // même rythme : la création est idempotente (une fois par document et par visiteur),
120
+ // la lecture revient à chaque ouverture d'un espace client. Le plafond borne un secret
121
+ // fuité qui balaierait des `docId`, pas l'usage d'un hôte qui affiche ses lectures.
122
+ const ipL = adresseAppelant(req) || "hote";
123
+ if (!(await PLAYER.limits.allow(`hsessions:${ipL}`, 600, 3600))) return jd(429, { ok: false, error: "rate" });
124
+ // ⚠️ SANS LA COLONNE, UN 409 QUI NOMME LE FICHIER — PAS UN `[]`. La sonde ne distingue
125
+ // pas « colonne absente » de « base injoignable », et un tableau vide se lirait
126
+ // « votre client n'a rien lu ». Même réponse que la création attestée, plus bas.
127
+ if (!(await require("./schema").attendue("destinataireAtteste"))) {
128
+ return jd(409, { ok: false, error: "migration", message: "Destinataire attesté indisponible : appliquez supabase/migrations/0001-destinataire-atteste.sql." });
129
+ }
130
+ const sessions = await listSessionsForDoc(docId, null, { hoteAtteste: true });
131
+ return jd(200, { ok: true, sessions, scope: "host" });
132
+ }
90
133
  if (body.action !== "docshare.create") {
91
- return jd(403, { ok: false, error: "L'appel serveur à serveur ne crée que des liens sans destinataire." });
134
+ return jd(403, { ok: false, error: "L'appel serveur à serveur ne crée que des liens, et ne lit que les sessions des liens attestés qu'il a créés." });
92
135
  }
93
136
  // ⚠️ LE DESTINATAIRE ATTESTÉ : L'HÔTE SE PORTE GARANT, IL N'AFFIRME PAS.
94
137
  //
package/server/shares.js CHANGED
@@ -672,17 +672,85 @@ function racineDuLien(slug, parParent, profondeurMax = 64) {
672
672
  *
673
673
  * `null` = toutes les sessions (le rôle qui a `list.all`), une adresse = celles dont la chaîne
674
674
  * d'origine part d'un lien que cette personne a créé.
675
+ *
676
+ * `hoteAtteste` = la portée de l'HÔTE qui lit par son secret (`scope: "host"`) : seulement les
677
+ * sessions des liens qu'il a créés ET attestés — voir `sessionsDesLiensAttestes`.
675
678
  */
676
- async function listSessionsForDoc(docId, owner = null) {
679
+ async function listSessionsForDoc(docId, owner = null, { hoteAtteste = false } = {}) {
677
680
  const id = enc(String(docId || ""));
681
+ const champsLien = await champsDuLien();
682
+ if (hoteAtteste) return sessionsDesLiensAttestes(id, champsLien);
678
683
  const [sessions, shares] = await Promise.all([
679
684
  PLAYER.db.request(`commercial_doc_sessions?doc_id=eq.${id}&select=${SELECT_SESSION}&order=last_at.desc&limit=500`),
680
685
  // ⚠️ TOUS LES LIENS, RÉPÉTITIONS COMPRISES. La chaîne se remonte par `parent_slug` : un maillon
681
686
  // absent de cette lecture casse la remontée et fait échouer fermé une session légitime. Le
682
687
  // filtre `is_test` d'avant ne servait qu'à nommer le destinataire ; il ne peut pas servir à
683
688
  // reconstruire une filiation.
684
- PLAYER.db.request(`commercial_doc_shares?doc_id=eq.${id}&select=slug,parent_slug,created_by,recipient_email,recipient_name`),
689
+ PLAYER.db.request(`commercial_doc_shares?doc_id=eq.${id}&select=${champsLien}`),
685
690
  ]);
691
+ return sessionsAvecLeurLien(sessions, shares, owner);
692
+ }
693
+
694
+ /**
695
+ * Les colonnes d'un LIEN qui accompagnent ses sessions — et l'adresse attestée seulement si la base
696
+ * la porte.
697
+ *
698
+ * ⚠️ PostgREST REJETTE UN `select=` QUI NOMME UNE COLONNE INCONNUE, et pas seulement l'écriture : un
699
+ * hôte qui n'a pas appliqué la 0001 verrait TOUTES ses lectures de sessions tomber en 500 le jour où
700
+ * il déploie ce code — pour une fonction dont il ne se sert pas. On demande donc à la sonde, comme
701
+ * le chemin de création le fait déjà ; un « oui » est retenu pour le processus, la question ne
702
+ * coûte rien après la première fois.
703
+ *
704
+ * Sans la colonne, aucun lien ne peut être attesté (la création refuse en 409 et nomme le fichier) :
705
+ * le `null` que portera alors chaque session est VRAI, pas une valeur par défaut.
706
+ */
707
+ const CHAMPS_LIEN = "slug,parent_slug,created_by,recipient_email,recipient_name";
708
+ async function champsDuLien(supplement = "") {
709
+ const atteste = await require("./schema").attendue("destinataireAtteste");
710
+ return CHAMPS_LIEN + supplement + (atteste ? ",attested_recipient_email" : "");
711
+ }
712
+
713
+ /**
714
+ * Les sessions des liens que l'HÔTE a créés et attestés : sans auteur (`created_by` nul) ET portant
715
+ * une adresse attestée. Rien d'autre.
716
+ *
717
+ * ⚠️ LA BORNE EST DANS LA REQUÊTE, PAS APRÈS ELLE. La lecture des membres prend les 500 sessions les
718
+ * plus récentes du document puis filtre par chaîne d'origine — elle ne peut pas faire autrement, la
719
+ * chaîne est récursive. Ici le critère est une propriété du lien lui-même, donc se pousse en base :
720
+ * on lit d'abord les liens attestés, puis leurs seules sessions (`slug=in.(…)`). Sans ça, une
721
+ * plaquette partagée — le `docId` se partage entre clients — lue par cinq cents prospects anonymes
722
+ * repousserait hors de la fenêtre les lectures du client attesté, et l'hôte lirait « 0 de lecture »
723
+ * là où il y en avait : exactement le constat du #574, reproduit par la correction.
724
+ *
725
+ * ⚠️ LA SESSION D'UN LIEN ENFANT N'EN EST PAS. Un repartage depuis un lien attesté naît avec
726
+ * `created_by: parent.recipient_email || parent.created_by` — nul ici — mais sans adresse attestée :
727
+ * son lecteur est celui que le visiteur a choisi, que l'hôte n'a jamais vérifié. Le filtre porte
728
+ * donc sur le lien DE LA SESSION, pas sur sa racine — l'inverse de la portée des membres, et pour
729
+ * la raison inverse : un membre a causé les lectures de sa descendance, l'hôte n'atteste que les
730
+ * adresses qu'il a fournies.
731
+ *
732
+ * ⚠️ ET SANS LA COLONNE, ON REFUSE PLUTÔT QUE DE RENDRE `[]`. La sonde ne distingue pas « colonne
733
+ * absente » de « base injoignable » ; un tableau vide dirait « votre client n'a rien lu » sur une
734
+ * base qui n'a pas répondu. La route vérifie avant d'appeler et rend le 409 de la création ; ce
735
+ * refus-ci ferme la porte à tout autre appelant.
736
+ */
737
+ async function sessionsDesLiensAttestes(id, champsLien) {
738
+ if (!champsLien.includes("attested_recipient_email")) {
739
+ throw Object.assign(new Error("Destinataire attesté indisponible : appliquez la migration 0001-destinataire-atteste.sql."),
740
+ { statusCode: 409, publique: true });
741
+ }
742
+ const liens = await PLAYER.db.request(
743
+ `commercial_doc_shares?doc_id=eq.${id}&created_by=is.null&attested_recipient_email=not.is.null&select=${champsLien}&limit=500`);
744
+ const lus = Array.isArray(liens) ? liens : [];
745
+ if (!lus.length) return [];
746
+ const liste = lus.map((sh) => `"${String(sh.slug).replace(/[",()]/g, "")}"`).join(",");
747
+ const sessions = await PLAYER.db.request(
748
+ `commercial_doc_sessions?doc_id=eq.${id}&slug=in.(${enc(liste)})&select=${SELECT_SESSION}&order=last_at.desc&limit=500`);
749
+ return sessionsAvecLeurLien(sessions, lus, null);
750
+ }
751
+
752
+ /** Chaque session, projetée, avec ce que son lien dit d'elle — bornée à `owner` s'il est donné. */
753
+ function sessionsAvecLeurLien(sessions, shares, owner) {
686
754
  const liste = Array.isArray(shares) ? shares : [];
687
755
  const parSlug = new Map(liste.map((sh) => [sh.slug, sh]));
688
756
  const parParent = new Map(liste.map((sh) => [sh.slug, sh.parent_slug || null]));
@@ -697,7 +765,7 @@ async function listSessionsForDoc(docId, owner = null) {
697
765
  const parent = lien && lien.parent_slug ? parSlug.get(lien.parent_slug) || null : null;
698
766
  sortie.push({
699
767
  ...sessionServie(s),
700
- recipient_name: (lien && lien.recipient_name) || null,
768
+ ...lienServi(lien),
701
769
  // La filiation voyage avec la session : « une session, un lecteur, visible par sa chaîne
702
770
  // d'origine » ne se lit pas si la chaîne n'est pas dans la charge utile.
703
771
  parent_slug: (lien && lien.parent_slug) || null,
@@ -727,6 +795,11 @@ async function listSessionsForDoc(docId, owner = null) {
727
795
  * IP mais un `creator_ip_hash` — un HMAC salé, lié au slug pour qu'on ne puisse pas corréler une
728
796
  * adresse d'une présentation à l'autre (0.1.114). Deux décisions opposées sur la même donnée dans
729
797
  * le même produit ; celle-ci était la permissive, et personne ne les avait confrontées.
798
+ *
799
+ * ⚠️ CETTE LISTE NE PORTE QUE LES COLONNES DE LA SESSION. Ce qu'une session dit de son LIEN —
800
+ * `recipient_name`, la filiation, et depuis le #574 `attested_recipient_email` — est ajouté nommément
801
+ * par chaque lecture, depuis la ligne du lien. L'adresse attestée sort donc par décision écrite
802
+ * (`sessionsAvecLeurLien`), jamais parce qu'une colonne de session l'aurait emportée.
730
803
  */
731
804
  const CHAMPS_SERVIS = [
732
805
  "session_id", "slug", "doc_id", "recipient_email",
@@ -760,6 +833,31 @@ const CHAMPS_RETENUS = {
760
833
  /** La projection d'une ligne de session : ce qui sort, et rien d'autre. */
761
834
  const sessionServie = (s) => Object.fromEntries(CHAMPS_SERVIS.filter((c) => c in s).map((c) => [c, s[c]]));
762
835
 
836
+ /**
837
+ * Ce qu'une session dit de son LIEN : la même liste-de-ce-qui-est-permis que `CHAMPS_SERVIS`, mais
838
+ * lue sur la ligne du lien. Un champ absent du lien sort `null` — jamais absent de la réponse.
839
+ *
840
+ * ⚠️ L'ADRESSE ATTESTÉE VIENT DU LIEN, ET N'ENTRE PAS DANS `CHAMPS_SERVIS`. Elle n'est pas une
841
+ * colonne de session et ne doit pas le devenir : la recopier dans `commercial_doc_sessions` en ferait
842
+ * un second exemplaire du même fait, que la révocation, la rétention et une correction d'adresse
843
+ * devraient chacune penser à suivre. Jusqu'au #574 elle ne sortait pas du tout : 108 sessions
844
+ * mesurées chez un hôte, temps par page compris, ne s'attribuaient à personne.
845
+ *
846
+ * ⚠️ ET `recipient_email` RESTE NUL pour un lien attesté — ce n'est pas un oubli de recopie. Ce champ
847
+ * dit aussi qui peut expédier au repartage (cf. `routes-liens.js`, « elle ne va pas dans
848
+ * `recipient_email` »). Servir l'adresse sous son propre nom laisse cette garde intacte.
849
+ *
850
+ * ⚠️ UNE LISTE, PAS DEUX LITTÉRAUX. Les deux lectures de sessions écrivaient chacune
851
+ * `recipient_name: (lien && …) || null` ; y ajouter un champ voulait dire penser aux deux, et c'est
852
+ * en le faisant pour l'adresse attestée que la garde `colonneMigreeConditionnelle` a tiré — elle lit
853
+ * toute clé `colonne:` comme une ÉCRITURE en base, or celle-ci n'en est pas une (c'est une clé de la
854
+ * réponse). On n'a pas desserré la garde : on a retiré le second exemplaire, et les deux lectures
855
+ * disent désormais la même chose d'un lien par construction. Les écritures, elles, restent sous sa
856
+ * surveillance entière.
857
+ */
858
+ const CHAMPS_DU_LIEN_SERVIS = ["recipient_name", "attested_recipient_email"];
859
+ const lienServi = (lien) => Object.fromEntries(CHAMPS_DU_LIEN_SERVIS.map((c) => [c, (lien && lien[c]) || null]));
860
+
763
861
  /** Le `select=` qui descend dans la requête — la MÊME liste, pas une seconde écriture. */
764
862
  const SELECT_SESSION = CHAMPS_SERVIS.join(",");
765
863
 
@@ -779,9 +877,12 @@ const SELECT_SESSION = CHAMPS_SERVIS.join(",");
779
877
  async function liensEtAncetres(slugs, vaguesMax = 8) {
780
878
  const parSlug = new Map();
781
879
  let aLire = [...new Set(slugs.filter(Boolean))];
880
+ // La même liste de colonnes que `listSessionsForDoc`, par la même fonction : les deux lectures de
881
+ // sessions doivent dire la même chose d'un lien, adresse attestée comprise.
882
+ const champsLien = await champsDuLien(",doc_id,doc_title");
782
883
  for (let vague = 0; vague < vaguesMax && aLire.length; vague += 1) {
783
884
  const liste = aLire.map((x) => `"${String(x).replace(/[",()]/g, "")}"`).join(",");
784
- const lus = await PLAYER.db.request(`commercial_doc_shares?slug=in.(${enc(liste)})&select=slug,parent_slug,created_by,recipient_email,recipient_name,doc_id,doc_title`);
885
+ const lus = await PLAYER.db.request(`commercial_doc_shares?slug=in.(${enc(liste)})&select=${champsLien}`);
785
886
  const vus = Array.isArray(lus) ? lus : [];
786
887
  if (!vus.length) break;
787
888
  for (const sh of vus) parSlug.set(sh.slug, sh);
@@ -877,7 +978,11 @@ async function listSessionsForRecipient(email, { owner = null, depuis = null, ap
877
978
  sessions.push({
878
979
  ...sessionServie(s),
879
980
  doc_title: (lien && lien.doc_title) || null,
880
- recipient_name: (lien && lien.recipient_name) || null,
981
+ // ⚠️ L'ADRESSE ATTESTÉE Y EST PRESQUE TOUJOURS NULLE, ET SERVIE QUAND MÊME. Cette lecture part
982
+ // de `recipient_email=eq.<adresse>`, que les sessions d'un lien attesté n'ont pas : elle les
983
+ // atteint rarement. Mais deux lectures de la même session qui ne portent pas les mêmes champs
984
+ // obligent l'hôte à savoir par laquelle il est passé — la même projection les en dispense.
985
+ ...lienServi(lien),
881
986
  parent_slug: (lien && lien.parent_slug) || null,
882
987
  parent_recipient_email: parent ? parent.recipient_email || null : null,
883
988
  parent_recipient_name: parent ? parent.recipient_name || null : null,