discovery-media-player 0.1.174 → 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.
- package/docs/HOST-CONTRACT.md +26 -0
- package/package.json +1 -1
- package/server/routes-liens.js +48 -5
- package/server/shares.js +110 -5
package/docs/HOST-CONTRACT.md
CHANGED
|
@@ -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
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "discovery-media-player",
|
|
3
|
-
"version": "0.1.
|
|
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",
|
package/server/routes-liens.js
CHANGED
|
@@ -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
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
//
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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,
|