discovery-media-player 0.1.145 → 0.1.147

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.
@@ -712,8 +712,58 @@ function createStandaloneContext(env = process.env) {
712
712
  // avec une SÉPARATION DE DOMAINE (préfixe distinct) — il est déjà requis par la fonctionnalité
713
713
  // qui produit ce hachage, ce qui évite une variable obligatoire de plus.
714
714
  ipHashSecret: String(env.PLAYER_IP_HASH_SECRET || env.PLAYER_PRESENCE_SECRET || ""),
715
+ retention: retentionDepuisEnv(env),
715
716
  },
716
717
  };
717
718
  }
718
719
 
719
- module.exports = { createStandaloneContext, creerLimites };
720
+ /**
721
+ * La rétention, telle qu'un hôte AUTONOME peut la décider — et il ne le pouvait pas.
722
+ *
723
+ * ⚠️ CE FICHIER N'EXPOSAIT AUCUNE CLÉ DE RÉTENTION, ce qui rendait le balayage inatteignable pour
724
+ * quiconque consomme ce contexte tel quel. `server/retention.js` exige `config.retention.balayage
725
+ * === true`, et cet opt-in strict est juste : les fenêtres sont des décisions MÉTIER — ce qu'un
726
+ * conseiller peut encore prouver à un client — et une suppression ne doit agir que là où un
727
+ * exploitant l'a ÉCRITE. Mais un hôte autonome n'avait nulle part où l'écrire. Seuls ceux qui
728
+ * rédigent leur contexte à la main pouvaient armer la purge ; les autres accumulaient sans
729
+ * recours, et sans même savoir que le recours existait. Un opt-in dont la moitié du parc ne peut
730
+ * pas se saisir n'est pas un opt-in, c'est une indisponibilité.
731
+ *
732
+ * ⚠️ ET LES FENÊTRES VIENNENT AVEC, PAS SEULEMENT L'INTERRUPTEUR. N'exposer que `balayage`
733
+ * armerait la purge SUR NOS DÉFAUTS — exactement ce que le commentaire de `retention.js` décrit
734
+ * comme le mode de panne du second hôte, cette fois par une variable au lieu d'un oubli. Qui arme
735
+ * doit pouvoir décider ce qu'il arme.
736
+ *
737
+ * ⚠️ UNE CLÉ ABSENTE EST ABSENTE, JAMAIS `undefined`. `fenetresValidees` fusionne cet objet
738
+ * PAR-DESSUS les défauts : y poser `journauxMois: undefined` ferait échouer la validation et
739
+ * refuserait toute suppression chez un hôte qui a simplement armé sans régler les mois. La valeur
740
+ * n'entre donc que si la variable porte quelque chose.
741
+ *
742
+ * ⚠️ ET UNE VALEUR ILLISIBLE N'EST PAS CORRIGÉE ICI. `Number("abc")` vaut `NaN`, `"12.5"` n'est pas
743
+ * entier : le cœur les REFUSE en nommant la clé, avant le premier DELETE. La rattraper ici la
744
+ * rendrait silencieuse, et une purge est le dernier endroit où deviner.
745
+ */
746
+ // ⚠️ ET LES QUATRE LECTURES SONT ÉCRITES EN CLAIR, PAS BOUCLÉES SUR UNE TABLE DE NOMS. La première
747
+ // version faisait `env[TABLE[cle]]` — plus court, et refusé par `tools/env-lues.mjs` : un nom
748
+ // d'environnement construit à l'exécution n'est trouvable par personne. Ni un `grep`, ni la garde
749
+ // qui vérifie que chaque variable lue est documentée. La concision se paierait sur une variable
750
+ // oubliée dans `docs/CONFIGURATION.md`, que rien ne rattraperait.
751
+ function poserMois(out, cle, brut) {
752
+ const t = String(brut || "").trim();
753
+ // ⚠️ Absente = ABSENTE. `fenetresValidees` fusionne cet objet PAR-DESSUS les défauts : y poser
754
+ // `undefined` ferait échouer la validation et refuserait toute purge chez un hôte qui a
755
+ // simplement armé sans régler les mois.
756
+ if (t) out[cle] = Number(t);
757
+ }
758
+
759
+ function retentionDepuisEnv(env) {
760
+ const out = {};
761
+ if (String(env.PLAYER_RETENTION_SWEEP || "") === "1") out.balayage = true;
762
+ poserMois(out, "journauxMois", env.PLAYER_RETENTION_LOGS_MONTHS);
763
+ poserMois(out, "presentationsMois", env.PLAYER_RETENTION_PRESENTATIONS_MONTHS);
764
+ poserMois(out, "liensRevoquesMois", env.PLAYER_RETENTION_REVOKED_LINKS_MONTHS);
765
+ poserMois(out, "voixMois", env.PLAYER_RETENTION_VOICE_MONTHS);
766
+ return out;
767
+ }
768
+
769
+ module.exports = { createStandaloneContext, creerLimites, retentionDepuisEnv };
@@ -451,11 +451,11 @@ POST → { "email": "…", "role": "…", "action": "<one of the names below>"
451
451
  |---|---|
452
452
  | `create` | create a tracked link |
453
453
  | `list` | list one's own links |
454
- | `list.all` | list everyone's links |
454
+ | `list.all` | list everyone's links **and everyone's reading sessions** |
455
455
  | `revoke` | revoke a link |
456
456
  | `setauth` | change a link's access wall |
457
457
  | `overview` | read a document's aggregate figures |
458
- | `sessions` | read individual reading sessions |
458
+ | `sessions` | read individual reading sessions — of one document, or of one recipient across all of them |
459
459
  | `test` | create a rehearsal link |
460
460
  | `presentations.list.all` | list presentations **one does not own** (slugs, presenter names, counts) |
461
461
  | `presentations.stats` | read the **attendees** of a presentation one does not own — names, addresses, dwell time, pages |
@@ -467,6 +467,37 @@ than the player. That failure reads exactly like a permission problem, which is
467
467
  expensive. **Compare this table against your own at each upgrade**, and prefer a refusal that names
468
468
  the unknown action over one that looks like a role issue.
469
469
 
470
+ ⚠️ **`list.all` widens `sessions` as well from `0.1.147`, and until it nothing did.** `docshare.list`
471
+ has always asked you two questions — *may they list?* then *may they list everything?* — and
472
+ narrowed to the caller's own links when the second answer was no. `docshare.sessions` asked only the
473
+ first, and returned every session of the document: the reading sessions table carries the
474
+ recipient's **address and IP**, so any member allowed to call it read the prospects of their
475
+ colleagues. A strict door with a wide door beside it protects nothing.
476
+
477
+ `sessions` now asks the same second question, and answers with a `scope` field (`"mine"` or
478
+ `"all"`) exactly as `list` does — and so does `docshare.sessionsByRecipient`, which reads one
479
+ person's sessions across every document and is therefore the call where the scope matters most. **What changes for you:** a member to whom you answer *no* on
480
+ `list.all` now sees only the sessions of links whose chain starts with a link they created —
481
+ forwarded re-shares of their own links included, because they caused those readings. Nothing
482
+ changes for a role you already answer *yes* to. If your table predates `list.all`, see the warning
483
+ above: an unheard-of action answered *no* narrows this view rather than breaking it.
484
+
485
+ ⚠️ **The reader IP is erased, and a direct query of your own will start seeing nothing.** The
486
+ sessions table carried `ip` in the clear. `0.1.147` stops serving it — no player path reads it back,
487
+ so nothing in this contract changes — stops writing it, and ships migration **0026**, which erases
488
+ what thirteen months of journal still hold. ⚠️ **This lands in `0.1.147` and not before**: on `0.1.145`
489
+ and earlier the column is still written and still served. `npm view discovery-media-player version`
490
+ tells you which one you are about to install. **What changes for you:** nothing, unless you
491
+ read that column yourself in a report or dashboard outside the player, in which case its values are
492
+ empty from the day you apply 0026. The column itself stays for now: dropping it would fail every
493
+ session write of a host that applies migrations before deploying, so its removal is a later release.
494
+ [`docs/RETENTION.md`](RETENTION.md) sets out what the migration erases, why emptying — not
495
+ dropping — is what actually removes the bytes, and what it cannot reach: your backups. The raw `ua`
496
+ is erased too, on this table and on `commercial_doc_views`, by migration **0027** and for the same
497
+ reason: `device`, `os` and `browser` are derived from it at write time and are what a reading record
498
+ carries, so the raw string had no reader left. On the views table it had none at all — that table has
499
+ no derived columns.
500
+
470
501
  ⚠️ **`presentations.list.all` and `presentations.stats` are deliberately separate.** Seeing *that* a
471
502
  presentation happened and seeing *who attended it* are different sensitivities: the first returns
472
503
  metadata, the second returns people — often prospects. Merging them would take from you the choice of
package/docs/RETENTION.md CHANGED
@@ -35,11 +35,11 @@ Purpose: reading statistics for a document that was sent out. **Purge: 13 months
35
35
  |---|---|---|
36
36
  | `commercial_doc_views.recipient_email` | who the read is attributed to | purged with the row, 13 months after `at` |
37
37
  | `commercial_doc_views.session_id` | correlates the views of one session | same |
38
- | `commercial_doc_views.ua` | browser (raw User-Agent) | same |
38
+ | `commercial_doc_views.ua` | **emptied from `0.1.147`** | ⚠️ Emptied by migration **0027**, which ships with that release and not before. The clearest case of the three: unlike the sessions table, this one has no `device`, `os` or `browser` — it derived *nothing* from the string, wrote it, and no query in this player has ever read it back. A browser fingerprint kept for thirteen months with no reader at all, unnoticed because the question had never been asked table by table |
39
39
  | `commercial_doc_sessions.recipient_email` | session attribution | purged with the row, 13 months after `last_at` |
40
40
  | `commercial_doc_sessions.session_id` | session identifier | same |
41
- | `commercial_doc_sessions.ip` | **IP address in the clear** | same the most sensitive datum in the schema |
42
- | `commercial_doc_sessions.ua` | raw User-Agent | same |
41
+ | `commercial_doc_sessions.ip` | **emptied from `0.1.147`** | ⚠️ It held the reader's IP address in the clear and was the most sensitive datum in this schema. **`0.1.147`** stops **serving** it, stops **writing** it, and ships migration **0026**, which erases what thirteen months of journal still carry. ⚠️ **On `0.1.145` and earlier it is still written and still served** — upgrading is what stops it. The column itself survives for now — dropping it would break a host that applies migrations before deploying (see *Purging the reader IP* below); its removal is a later release. The asymmetry this table used to note ends here, upward: a presentation attendee's address is a salted HMAC, a reader's is now nothing at all |
42
+ | `commercial_doc_sessions.ua` | **emptied from `0.1.147`** | ⚠️ **`0.1.147`** stops serving it, stops writing it, and ships migration **0027**, which erases what is there. ⚠️ **On `0.1.145` and earlier it is still written.** `device`, `os` and `browser` are derived from it *at write time* and are what a reading record carries — so the raw string had no reader left, and "we might re-parse it one day" does not justify thirteen months of a fingerprint kept for nobody. Same treatment and same reason as `ip`: emptied now, column removed in a later release |
43
43
  | `commercial_doc_sessions.num_pages` / `commercial_doc_sessions.pages_time` | page-by-page reading behaviour | same |
44
44
 
45
45
  ## Reading logs (internal team)
@@ -135,6 +135,107 @@ it queryable, which is strictly worse than not having it.
135
135
  an MP3 and a JSON in a public bucket. The grouping and ceilings added in 0.1.140 bound the cost per
136
136
  hour; only this window bounds the **duration**.
137
137
 
138
+ ## Purging the reader IP and User-Agent (migrations 0026 and 0027)
139
+
140
+ > ⚠️ **UPGRADE FIRST, THEN APPLY — the order is not a preference.** Both migrations ship in
141
+ > `0.1.147`, together with the code that stops writing these columns. On `0.1.145` and earlier the
142
+ > player **still writes and still serves** the IP and the raw User-Agent, and carries migrations only
143
+ > up to `0024`. Applying 0026 or 0027 to a database whose player is older leaves that player writing
144
+ > new values into columns you have just emptied: the purge would be undone at the next heartbeat.
145
+ > `npm view discovery-media-player version` tells you what the registry serves; your own deployment
146
+ > tells you what you are running, and it is the second number that decides.
147
+ >
148
+ > This paragraph is written this precisely because its opposite stood here: a claim, in the past
149
+ > tense, that the change was already live, naming versions that had never been published. An
150
+ > integrating host caught it by unpacking what the registry actually serves, after being asked to
151
+ > apply migrations that were in no package. A guard now refuses any document naming a version the
152
+ > repository has not cut.
153
+
154
+ ⚠️ **Read this before upgrading if you have ever queried `commercial_doc_sessions.ip` directly.**
155
+ `0.1.147` stops serving it — no player path reads it back — stops writing it, and ships the migration
156
+ that empties it, so nothing in the player changes; a report or dashboard of your own
157
+ that reads values from it starts seeing empty ones. This notice exists so that it is announced
158
+ *before*, not explained afterwards.
159
+
160
+ **The column is emptied, not dropped — and emptying is what actually erases.** This is the reverse
161
+ of the intuition, so it is worth the measurement. `ALTER TABLE … DROP` of a column marks the
162
+ attribute dropped; it does **not** rewrite the rows. Measured on PostgreSQL 16.13 with
163
+ `pageinspect`, on rows carrying an address:
164
+
165
+ | after | addresses still present in the heap |
166
+ |---|---|
167
+ | dropping the column | **all of them** |
168
+ | … then routine `VACUUM` | **all of them** — the rows are *live*, so there is nothing to reclaim |
169
+ | … then `VACUUM FULL` | none — but that rewrites the table under an exclusive lock |
170
+ | `UPDATE … SET ip = NULL`, then routine `VACUUM` | **none** |
171
+
172
+ Dropping the column on its own would have left every address on disk indefinitely — invisible to
173
+ any query, and therefore never checked by anyone again, while the schema swore it was not there. The
174
+ `UPDATE` writes new row versions without the address and makes the old ones dead; **ordinary
175
+ autovacuum reclaims them by itself**, with no exclusive lock and no operator action. Verified end to
176
+ end on a populated database: 200 rows kept, 200 addresses gone after a routine vacuum, the migration
177
+ replayable with no further effect. **The erasure is therefore complete today.** What is deferred is
178
+ the shape of the schema, not the data.
179
+
180
+ **When the columns are removed, and how to know the moment has come.** Not until **every deployed
181
+ host runs a version that no longer writes them** — the release carrying 0026 and 0027, or later.
182
+ Until then a `DROP` of any of the three would fail every session and view write of a host that
183
+ applies migrations before deploying. The check is not a date: it is whether the oldest player version
184
+ still in service is at or past that release.
185
+
186
+ **Why the column itself survives, for now.** A migration here must be safe to apply *while the
187
+ previous version of the player is running* — that rule is what makes the deployment order harmless,
188
+ and it is enforced by a test. **Every version before `0.1.147` writes `ip`**, and PostgREST rejects a write carrying an
189
+ unknown column: dropping it today would fail **every** session write of a host that applies
190
+ migrations before deploying, with an error naming a column rather than a version. The column is
191
+ removed in a later release, once no supported version writes it. Until then it exists, is always
192
+ `NULL`, and carries a comment in the database saying so — `col_description()` on it is how you
193
+ attest that 0026 ran.
194
+
195
+ **What the migration cannot reach, and you can.** Write-ahead logs already written, backups, exports
196
+ and migration dumps still carry the addresses; they follow *your* retention policy, not this file.
197
+ This is the general rule stated at the end of *Limits stated rather than left unsaid* — a dropped
198
+ column is itself a retention act, and earlier copies follow the host's backup policy — in its first
199
+ concrete instance. A host that must attest a **complete** purge expires or rewrites its earlier
200
+ backups; no migration can do that on its behalf.
201
+
202
+ **The raw User-Agent goes the same way (0027), on both tables.** `0.1.147` stops serving it, stops
203
+ writing it, and 0027 erases what is there — same shape, same measurement, same
204
+ deferred column removal. `device`, `os` and `browser` are derived from the string *at write time* and
205
+ are what a reading record carries, so the raw value had no reader; "we might re-parse it one day" is
206
+ not a reason to keep a fingerprint for thirteen months. On `commercial_doc_views` the case is
207
+ starker still: that table has no derived columns at all, so it derived nothing from the string and no
208
+ query has ever read it back.
209
+
210
+ **What is not covered.** `player_rate_limits.key` may still hold an address in the clear and expires
211
+ on its own. `doc_presentation_attendees.creator_ip_hash` is a salted HMAC, not an address.
212
+
213
+ ## From what date is a purge complete end to end
214
+
215
+ A question worth answering precisely, because the honest answer has three parts and only one of them
216
+ is a number.
217
+
218
+ **1. The rows.** Reading logs are deleted **13 months** after `at` / `last_at` by default. A host
219
+ changes that through `config.retention` — whole months in `[1, 120]`.
220
+
221
+ ⚠️ **But the automatic sweep is strictly opt-in.** It runs only where a host has written
222
+ `config.retention.balayage: true`; the `retention.run` action stays available without opt-in, because
223
+ calling it *is* the decision. **On a host that has enabled neither, no row has ever been deleted, and
224
+ the 13 months describe an intent rather than an event.** Anyone attesting a retention period should
225
+ check which of the two is true of the installation in front of them, rather than quoting the default.
226
+
227
+ **2. The values inside surviving rows.** Erased by 0026 and 0027 as soon as they are applied, and
228
+ physically gone from the table once routine autovacuum has passed — no operator action, typically
229
+ minutes to hours on an active table. This part does not wait for the 13 months.
230
+
231
+ **3. Backups, write-ahead logs, exports and migration dumps.** **Outside this player's reach, and we
232
+ neither set nor observe them.** They follow the hosting platform's own settings — on a managed
233
+ provider, typically a point-in-time-recovery window plus a snapshot schedule, each with its own
234
+ retention. A purge is complete end to end at *the later of*: the day 0026/0027 were applied plus the
235
+ host's longest backup retention, and — for the rows themselves — whichever purge the host actually
236
+ runs. **Ask the platform for two numbers: the PITR window and the oldest retained snapshot.** Until
237
+ both have rolled past the migration date, earlier copies still hold the erased values.
238
+
138
239
  ## Limits stated rather than left unsaid
139
240
 
140
241
  - ⚠️ **`fichiersErreur` can be high without any removal having failed.** Each fingerprint has two
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "discovery-media-player",
3
- "version": "0.1.145",
3
+ "version": "0.1.147",
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",
@@ -28,6 +28,19 @@ const init = (ctx) => { PLAYER = ctx; };
28
28
  *
29
29
  * ⚠️ `=== true` PLUTÔT QUE VÉRIDIQUE. Une fonction, une chaîne ou un objet posé là par accident
30
30
  * ouvrirait la porte : on exige une DÉCLARATION, pas une présence.
31
+ *
32
+ * ⚠️ ET IL EST EXPORTÉ, PARCE QUE LA PAGE POSAIT LA MÊME QUESTION AVEC UNE MOITIÉ EN MOINS.
33
+ * `page-visionneuse.js` calculait `cfg.botVoice` — le champ remis au greffon de l'hôte — sur la
34
+ * CLÉ SEULE. Mesuré le 31/08, clé posée, greffon sans `wiresVoice` :
35
+ *
36
+ * cfg.botVoice = true ← ce que la page DIT au greffon
37
+ * balisage voix = absent ← ce que le lecteur a RENDU
38
+ *
39
+ * Les deux moitiés d'une même page se contredisaient, et celle qui se trompait était la moitié
40
+ * DÉRIVÉE DE LA CONFIGURATION : la clé prouve que le serveur sait synthétiser, jamais qu'un clic
41
+ * mène quelque part. `docs/HOST-CONTRACT.md` écrit cette correction en gras depuis le 26/08 —
42
+ * « ELEVENLABS_API_KEY alone no longer shows them » — et le champ, lui, portait encore l'ancienne
43
+ * règle. Une seule écriture désormais : celle-ci.
31
44
  */
32
45
  function voixProposable() {
33
46
  if (!process.env.ELEVENLABS_API_KEY) return false;
@@ -575,4 +588,4 @@ function botMarkup(share, pitch) {
575
588
  return `<div class="botc min" id=botc style="--bacc:${acc}"><div class=botc-grab id=botcGrab><i></i></div><div class=botc-h><span class=botc-av>${avatar}</span><div><b>${name}</b><span class=botc-sub>${sub}</span></div>${voiceBtn}<button class=botc-gearbtn id=botcGearBtn title="Réglages d'affichage">${ICONS.gear}</button><button class=botc-min id=botcMin title=Réduire>${ICONS.min}</button></div><button class=botc-back id=botcBack>${ICONS.prev}<span>Revenir à la présentation</span></button><div class=botc-msgs id=botcMsgs><div class=botc-choices id=botcChoices></div></div><button class=botc-resume id=botcResume>Reprendre la présentation</button><div class=botc-in><input id=botcText placeholder="Écrivez votre message…" autocomplete=off maxlength=1000><button id=botcSend title=Envoyer>${ICONS.send}</button></div></div><button class=botc-fab id=botcFab style="--bacc:${acc}" title="Assistant & réglages">${share.bot_avatar ? `<img src="${esc(share.bot_avatar)}" alt="">` : ICONS.chat}<span class=botc-badge id=botcBadge></span><span class=botc-gear>${ICONS.gear}</span><span class=fab-gear>${ICONS.gear}</span></button><button class=botc-fab2 id=botcFab2 title="Parler à ${esc(String(share.bot_name || "l'assistant"))}">${ICONS.chat}<span class=botc-badge id=botcBadge2></span></button>${voixProposable() ? `<button class="botc-fab2 botc-fab3" id=botcVoice2 title="Couper la voix"></button>` : ""}<button class="botc-fab2 botc-fab4" id=botcPlay2 title="Relancer une visite">${ICONS.play}</button><button class=botc-peek id=botcPeek></button><div class=fabmenu id=fabMenu style="--bacc:${acc}"><div class=fm-l0 id=fmL0><button class=fm-row data-p=pDisp><span class=fm-rl>Affichage</span><em id=fmVDisp></em>${ICONS.next}</button><button class=fm-row data-p=pLang><span class=fm-rl>Langue</span><em id=fmVLang></em>${ICONS.next}</button><button class=fm-row data-p=pLook><span class=fm-rl>Apparence</span><em id=fmVLook></em>${ICONS.next}</button></div><div class=fm-p id=pDisp><button class=fm-back>${ICONS.prev}<span class=fm-rl>Affichage</span></button><div class=fm-seg id=fmDisp><button data-v=panel>Panneau</button><button data-v=bubble>Bulle</button><button data-v=cap>Barre</button><button data-v=audio>Audio seul</button></div></div><div class=fm-p id=pLang><button class=fm-back>${ICONS.prev}<span class=fm-rl>Langue</span></button><div class=fm-seg id=fmLang><button data-v=fr>FR</button><button data-v=en>EN</button><button data-v=es>ES</button></div></div><div class=fm-p id=pLook><button class=fm-back>${ICONS.prev}<span class=fm-rl>Apparence</span></button><div class=fm-sec>Thème</div><div class=fm-seg id=fmTheme><button data-v=dark>Sombre</button><button data-v=light>Clair</button></div><div class=fm-sec>Style du texte</div><div class=fm-seg id=fmStyle><button data-v=classic>Classique</button><button data-v=focus>Focus</button><button data-v=fill>Encre</button><button data-v=underline>Souligné</button></div></div></div><div class=botw id=botw style="--bacc:${acc}"><button class=botw-x id=botwX aria-label=Fermer>${ICONS.close}</button><div class=botw-card><div class=botw-head><span class=botw-av>${avatar}</span><div><b>${name}</b><span>${sub}</span></div></div><div class=botw-lang id=botwLang><button data-v=fr>FR</button><button data-v=en>EN</button><button data-v=es>ES</button></div>${pitch ? `<p class=botw-pitch>${esc(pitch)}</p>` : ""}<p class=botw-q>Comment souhaitez-vous découvrir ce document ?</p><button class=botw-door id=doorPresent><i>${ICONS.play}</i><span>Je me laisse guider<small>${name} vous présente le document, à votre rythme</small></span><em class=botw-tag>Recommandé</em></button><button class=botw-door id=doorRead><i>${ICONS.book}</i><span>Je le parcours seul<small>Lecture libre — l'assistant reste disponible</small></span></button><button class=botw-door id=doorChat><i>${ICONS.chat}</i><span>J'ai des questions<small>Échangez directement avec ${name}</small></span></button></div>${s2}</div><div class=botp id=botp style="--bacc:${acc}"><div class=botp-prog id=botpProg><i id=botpFill></i></div><div class=botp-cap id=botpCap></div><div class=botp-chips id=botpChips></div><div class=botp-ctl><button class=pp id=botpPP aria-label="Lecture / pause">${ICONS.pause}${ICONS.play}</button>${pVoiceBtn}<button id=botpFs title="Plein écran">${ICONS.fs}</button><button id=botpChat title="Parler à ${esc(String(share.bot_name || "l'assistant"))}">${share.bot_avatar ? `<img src="${esc(share.bot_avatar)}" alt="">` : ICONS.chat}</button><button id=botpMore title=Options>${ICONS.more}</button></div><button class=botp-big id=botpBig aria-label=Reprendre>${ICONS.play}</button><div class=rot-hint id=rotHint><i class=rh-ph><svg viewBox="0 0 24 24" width="26" height="26" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="7" y="3" width="10" height="18" rx="2.5"/><path d="M11 18.2h2"/></svg></i><div class=rh-t><b>Plein écran</b><span>Tournez votre téléphone</span></div></div><div class=botp-menu id=botpMenu><button id=bmChat>${ICONS.chat}<span>Poser une question</span></button><button id=bmCall>${ICONS.cal}<span>Être rappelé / prendre RDV</span></button><button id=bmDl>${ICONS.dl}<span>Télécharger le document</span></button><button id=bmRestart>${ICONS.restart}<span>Recommencer la présentation</span></button><button id=bmRead>${ICONS.book}<span>Consulter tranquillement</span></button><button id=bmSet class=bm-set>${ICONS.gear}<span>Réglages</span></button></div><div class="botp-menu botp-set" id=botpSet><button class=bs-back id=bsBack>${ICONS.prev}<span>Réglages</span></button><div class=fm-sec>Vitesse de lecture</div><div class=fm-seg id=msSpd><button data-v=1>1×</button><button data-v=1.5>1,5×</button><button data-v=2>2×</button></div><div class=fm-sec>Thème</div><div class=fm-seg id=msTheme><button data-v=dark>Sombre</button><button data-v=light>Clair</button></div><div class=fm-sec>Style du texte</div><div class=fm-seg id=msStyle><button data-v=classic>Classique</button><button data-v=focus>Focus</button><button data-v=fill>Encre</button><button data-v=underline>Souligné</button></div></div></div>`;
576
589
  }
577
590
 
578
- module.exports = { init, BOT_CSS, ICO, ICONS, botMarkup };
591
+ module.exports = { init, BOT_CSS, ICO, ICONS, botMarkup, voixProposable };
@@ -6,7 +6,7 @@ const { esc, jsonPourScript } = require("./texte");
6
6
  const { PDFJS, PDFJS_WORKER, TIERS, balise } = require("./tiers");
7
7
  const { LIVE_CSS, LIVE_BAR, LIVE_PANEL, LIVE_JS } = require("./gabarit-live");
8
8
  const { MAP_CSS, MAP_MARKUP, MAP_JS } = require("./gabarit-carte");
9
- const { BOT_CSS, ICONS, botMarkup } = require("./gabarit-agent");
9
+ const { BOT_CSS, ICONS, botMarkup, voixProposable } = require("./gabarit-agent");
10
10
  const { legalFooter, LEGAL_CSS } = require("./gabarit-legal");
11
11
  const { cleSessionHote, CLE_SESSION_PLAYER, CLE_INVITE } = require("./session-cles");
12
12
 
@@ -86,7 +86,7 @@ function viewerHtml(share, nonce, logoUrl, pitch) {
86
86
  // En aperçu interne, on embarque de quoi démarrer une présentation live (URL Storage brute + métadonnées).
87
87
  // `fileName` : c'est LUI qui dit la nature du document côté page. L'URL publique est
88
88
  // `/api/doc?slug=…&file=1`, sans extension — sans ce champ, une image partait dans pdf.js.
89
- const cfg = jsonPourScript({ brand: PLAYER.branding.name, slug: preview ? "" : share.slug, fileUrl, fileName: share.file_name || "", pdfjs: PDFJS, pdfjsWorker: PDFJS_WORKER, title, preview, embed, embedded, bot: botOn, botGuided: !preview && !!share.bot_enabled && share.bot_guided !== false, botAv: (!preview && share.bot_enabled && share.bot_avatar) || "", botName: (!preview && share.bot_enabled && share.bot_name) || "", botGreet: (!preview && share.bot_enabled && share.bot_greeting) || "", botGreetDoc: (!preview && share.bot_enabled && share.bot_greeting_doc) || "", dl: share.allow_download !== false, autoPresent: !!share.auto_present, botAnim: share.bot_page_anim !== false, botVoice: !preview && !!share.bot_enabled && !!process.env.ELEVENLABS_API_KEY, vIcOn: ICONS.sound, vIcOff: ICONS.mute, kStyle: (!preview && share.bot_enabled && share.bot_karaoke) || "classic", vLayout: (!preview && share.bot_enabled && share.video_layout) || "", vClips: !preview && !!share.bot_vclips, botVAv: (!preview && share.bot_enabled && share.bot_vphoto) || "", resumeSlug: preview ? (share.resume_slug || "") : "", supaUrl: preview ? (share.supa_url || "") : "", supaKey: preview ? (share.supa_key || "") : "", internal: preview && share.internal_email ? { email: share.internal_email, name: share.presenter_name || "", docId: share.doc_id || "", it: share.internal_token || "" } : null, present: preview ? { url: share.raw_url || "", name: share.file_name || "", title: share.doc_title || "", docId: share.doc_id || "", by: share.presenter_name || "", email: share.internal_email || "", av: share.presenter_avatar || "" } : null });
89
+ const cfg = jsonPourScript({ brand: PLAYER.branding.name, slug: preview ? "" : share.slug, fileUrl, fileName: share.file_name || "", pdfjs: PDFJS, pdfjsWorker: PDFJS_WORKER, title, preview, embed, embedded, bot: botOn, botGuided: !preview && !!share.bot_enabled && share.bot_guided !== false, botAv: (!preview && share.bot_enabled && share.bot_avatar) || "", botName: (!preview && share.bot_enabled && share.bot_name) || "", botGreet: (!preview && share.bot_enabled && share.bot_greeting) || "", botGreetDoc: (!preview && share.bot_enabled && share.bot_greeting_doc) || "", dl: share.allow_download !== false, autoPresent: !!share.auto_present, botAnim: share.bot_page_anim !== false, botVoice: !preview && !!share.bot_enabled && voixProposable(), vIcOn: ICONS.sound, vIcOff: ICONS.mute, kStyle: (!preview && share.bot_enabled && share.bot_karaoke) || "classic", vLayout: (!preview && share.bot_enabled && share.video_layout) || "", vClips: !preview && !!share.bot_vclips, botVAv: (!preview && share.bot_enabled && share.bot_vphoto) || "", resumeSlug: preview ? (share.resume_slug || "") : "", supaUrl: preview ? (share.supa_url || "") : "", supaKey: preview ? (share.supa_key || "") : "", internal: preview && share.internal_email ? { email: share.internal_email, name: share.presenter_name || "", docId: share.doc_id || "", it: share.internal_token || "" } : null, present: preview ? { url: share.raw_url || "", name: share.file_name || "", title: share.doc_title || "", docId: share.doc_id || "", by: share.presenter_name || "", email: share.internal_email || "", av: share.presenter_avatar || "" } : null });
90
90
  return `<!doctype html><html lang=fr><head><meta charset=utf-8>
91
91
  <meta name=viewport content="width=device-width,initial-scale=1,maximum-scale=3,viewport-fit=cover,interactive-widget=resizes-content">
92
92
  <meta name=robots content="noindex,nofollow">
@@ -6,7 +6,7 @@
6
6
  const { adresseAppelant } = require("./appelant");
7
7
  const { jsonPour, repondreJson, etiquetteRoute } = require("./reponses.js");
8
8
  const { estConflit } = require("./erreurs-base.js");
9
- const { createShare, createReshare, sendReshareEmail, revokeShare, setShareAuth, listSharesForDoc, listSessionsForDoc, internalStatsForDoc, cleIdempotence, getShareBySlug, logView, upsertSession, upsertInternalSession, overview: docOverview } = require("./shares");
9
+ const { createShare, createReshare, sendReshareEmail, revokeShare, setShareAuth, listSharesForDoc, listSessionsForDoc, listSessionsForRecipient, internalStatsForDoc, cleIdempotence, getShareBySlug, logView, upsertSession, upsertInternalSession, overview: docOverview } = require("./shares");
10
10
  const { SESSION_QUOTA_PER_HOUR, VIEW_QUOTA_PER_HOUR } = require("./shared.generated.js");
11
11
 
12
12
  let PLAYER = null;
@@ -194,7 +194,37 @@ async function traiter(req, res, body, slug) {
194
194
  return jd(200, { ok: true, byDoc: await docOverview() });
195
195
  }
196
196
  if (body.action === "docshare.sessions") {
197
- return jd(200, { ok: true, sessions: await listSessionsForDoc(String(body.docId || "")) });
197
+ // ⚠️ LA MÊME PORTÉE QUE `docshare.list`, ET SON ABSENCE ICI ÉTAIT UNE FUITE. Les sessions
198
+ // portent `recipient_email` et `ip` : sans ce filtre, tout membre autorisé à appeler cette
199
+ // action obtenait, pour n'importe quel document, l'adresse et l'IP des prospects de ses
200
+ // collègues — ce que la distinction `list` / `list.all` empêche vingt lignes plus bas,
201
+ // depuis qu'un hôte l'a demandée. Une porte stricte à côté d'une porte large ne protège
202
+ // rien : il suffisait de passer par la seconde.
203
+ const toutVoir = await PLAYER.identity.canManageShares(u, "list.all");
204
+ const sessions = await listSessionsForDoc(String(body.docId || ""), toutVoir ? null : u.email);
205
+ return jd(200, { ok: true, sessions, scope: toutVoir ? "all" : "mine" });
206
+ }
207
+ if (body.action === "docshare.sessionsByRecipient") {
208
+ // ⚠️ MÊME PORTÉE QUE `list` ET `sessions`, ET C'EST ICI QU'ELLE COMPTE LE PLUS : cette
209
+ // action traverse TOUS les documents. Sans la borne, un membre lirait l'historique complet
210
+ // d'une personne à qui un collègue a envoyé quelque chose — la fuite qu'on vient de fermer,
211
+ // en plus large.
212
+ const email = String(body.email || "").trim();
213
+ if (!email) return jd(400, { ok: false, error: "email requis" });
214
+ const toutVoir = await PLAYER.identity.canManageShares(u, "list.all");
215
+ const { sessions, curseur } = await listSessionsForRecipient(email, {
216
+ owner: toutVoir ? null : u.email,
217
+ // `depuis` est facultatif : sans lui, la fenêtre analytique. La fiche qui promet tout
218
+ // l'historique le demande explicitement, et le dit donc à l'hôte.
219
+ depuis: body.since ? String(body.since) : null,
220
+ apres: body.cursor ? String(body.cursor) : null,
221
+ limite: body.limit,
222
+ });
223
+ // ⚠️ `cursor: null` EST LA FIN, PAS LA LONGUEUR DE LA PAGE. Sous portée restreinte, une
224
+ // page peut être plus courte que `limit` sans être la dernière : le filtre s'applique
225
+ // APRÈS la lecture, et le curseur porte la dernière ligne examinée. Un appelant qui
226
+ // s'arrêterait sur une page courte perdrait la suite.
227
+ return jd(200, { ok: true, sessions, cursor: curseur, scope: toutVoir ? "all" : "mine" });
198
228
  }
199
229
  if (body.action === "docshare.list") {
200
230
  const docId = String(body.docId || "");
package/server/shares.js CHANGED
@@ -162,11 +162,20 @@ const mesureBornee = ({ page, maxPage, seconds }) => ({
162
162
  });
163
163
 
164
164
  // Journalise un événement de consultation (ouverture / page vue / battement). Best-effort.
165
- async function logView(share, { event, page, maxPage, seconds, sessionId, ua }) {
165
+ //
166
+ // ⚠️ `ua` N'EST PLUS ÉCRIT, ET LA SIGNATURE LE DIT — même geste que pour `ip` sur les sessions, sur
167
+ // demande explicite de l'ADV le 01/09/2026. Le cas est ici PLUS NET qu'ailleurs : cette table n'a
168
+ // ni `device`, ni `os`, ni `browser`, donc elle ne dérivait RIEN de cette chaîne. Elle l'écrivait,
169
+ // et personne — aucune requête de ce dépôt — ne l'a jamais relue. Une empreinte de navigateur
170
+ // conservée treize mois sans le moindre lecteur.
171
+ //
172
+ // L'appelant continue de la passer : il ne sait pas ce que chaque table conserve, et ce n'est pas à
173
+ // lui de le savoir. La garder en paramètre nommé laisserait croire qu'elle sert.
174
+ async function logView(share, { event, page, maxPage, seconds, sessionId, ua: _ua }) {
166
175
  const row = {
167
176
  slug: share.slug, doc_id: share.doc_id, recipient_email: share.recipient_email,
168
177
  event: String(event || "open").slice(0, 16), ...mesureBornee({ page, maxPage, seconds }),
169
- session_id: String(sessionId || "").slice(0, 64) || null, ua: String(ua || "").slice(0, 300) || null,
178
+ session_id: String(sessionId || "").slice(0, 64) || null,
170
179
  };
171
180
  await PLAYER.db.request("commercial_doc_views", { method: "POST", headers: { Prefer: "return=minimal" }, body: [row] });
172
181
  }
@@ -478,31 +487,295 @@ function parseUa(ua) {
478
487
 
479
488
  // Upsert d'une session de consultation (résumé envoyé périodiquement par la visionneuse). Stocke le temps
480
489
  // PAR page (cumulatif côté client → on remplace), totaux, appareil. Conserve started_at (insert) via merge.
481
- async function upsertSession(share, p, { ip, ua }) {
490
+ // ⚠️ `ip` N'EST PLUS ÉCRITE, ET LA SIGNATURE LE DIT — comme pour la session INTERNE plus bas, et
491
+ // pour la même raison. La 0.1.146 avait cessé de la SERVIR ; la colonne a été purgée puis
492
+ // supprimée par la 0026 (arbitrage ADV du 01/09/2026). L'appelant continue de la passer : il ne
493
+ // sait pas ce que chaque table conserve, et ce n'est pas à lui de le savoir. La garder en
494
+ // paramètre nommé laisserait croire qu'elle sert — c'est comme ça qu'une donnée revient dans une
495
+ // ligne où elle n'a plus de colonne, et l'écriture partirait alors en erreur PostgREST à chaque
496
+ // battement de chaque lecteur.
497
+ async function upsertSession(share, p, { ip: _ip, ua }) {
482
498
  const sessionId = String(p.sessionId || "").slice(0, 64);
483
499
  if (!sessionId) return;
500
+ // ⚠️ `ua` SERT ENCORE ICI, ET N'EST PLUS STOCKÉ — la distinction est tout le raisonnement. La
501
+ // chaîne arrive dans l'en-tête de la requête, `parseUa` en tire trois champs lisibles, et ce sont
502
+ // EUX qu'on garde. La chaîne elle-même n'avait plus de lecteur depuis la 0.1.146 ; « pouvoir la
503
+ // ré-analyser un jour » ne justifie pas treize mois d'empreinte conservée pour personne (ADV,
504
+ // 01/09/2026). C'est donc le paramètre qui reste, pas la colonne.
484
505
  const { device, os, browser } = parseUa(ua);
485
506
  const row = {
486
507
  session_id: sessionId, slug: share.slug, doc_id: share.doc_id, recipient_email: share.recipient_email,
487
508
  // Bornées comme la session INTERNE : plafond d'entrées, clés/valeurs numériques, totaux capés.
488
509
  num_pages: bornerNombre(p.numPages, BORNES.pages), max_page: mesureBornee({ maxPage: p.maxPage }).max_page,
489
510
  total_seconds: bornerNombre(p.totalSeconds, BORNES.secondes) || 0, pages_time: bornerPagesTime(p.pagesTime),
490
- ua: String(ua || "").slice(0, 300), ip: String(ip || "").slice(0, 60), device, os, browser, last_at: new Date().toISOString(),
511
+ device, os, browser, last_at: new Date().toISOString(),
491
512
  };
492
513
  // started_at non touché par l'upsert (default à l'insert ; merge ne l'écrase pas car absent du body).
493
514
  await PLAYER.db.request("commercial_doc_sessions?on_conflict=session_id", { method: "POST", headers: { Prefer: "resolution=merge-duplicates,return=minimal" }, body: [row] });
494
515
  }
495
516
 
496
- // Sessions de consultation d'un document (détail riche par session) + nom du destinataire (jointure share).
497
- async function listSessionsForDoc(docId) {
517
+ /**
518
+ * Le lien RACINE d'une chaîne de re-partages, en remontant `parent_slug`.
519
+ *
520
+ * ⚠️ `created_by` CHANGE À CHAQUE SAUT, et c'est ce qui rend la remontée nécessaire. `createReshare`
521
+ * pose `created_by: parent.recipient_email` — le lien que Paul reçoit de Dana est donc « créé par »
522
+ * Dana, pas par le commercial qui a envoyé le document à Dana. Un filtre naïf sur
523
+ * `created_by = moi` cacherait au commercial les lectures de sa PROPRE descendance : celles qu'il a
524
+ * causées. La portée suit la chaîne d'origine, pas le dernier maillon.
525
+ *
526
+ * ⚠️ ET ELLE ÉCHOUE FERMÉE. Un maillon dont le lien n'existe pas (dérive de données), une chaîne
527
+ * qui boucle : on rend `null` plutôt que d'attribuer au hasard. Un appelant restreint ne verra pas
528
+ * cette session ; `list.all`, qui ne filtre pas, la verra comme avant.
529
+ */
530
+ function racineDuLien(slug, parParent, profondeurMax = 64) {
531
+ if (!slug || !parParent.has(slug)) return null; // lien inconnu — inattribuable
532
+ const vus = new Set();
533
+ let courant = slug;
534
+ for (let saut = 0; saut < profondeurMax; saut += 1) {
535
+ if (vus.has(courant)) return null; // chaîne qui boucle — inattribuable
536
+ vus.add(courant);
537
+ const parent = parParent.get(courant);
538
+ if (!parent) return courant; // pas de parent : c'est la racine
539
+ if (!parParent.has(parent)) return null; // maillon manquant — inattribuable
540
+ courant = parent;
541
+ }
542
+ return null; // chaîne plus longue que tout re-partage réel
543
+ }
544
+
545
+ /**
546
+ * Sessions de consultation d'un document (détail riche par session) + nom du destinataire.
547
+ *
548
+ * ⚠️ `owner` BORNE CE QUE L'APPELANT VOIT, ET SON ABSENCE ÉTAIT UNE FUITE. Cette lecture rendait
549
+ * `select=*` sur `commercial_doc_sessions` sans aucun filtre — or cette table porte
550
+ * `recipient_email` ET `ip`. Tout membre autorisé à appeler `docshare.sessions` obtenait donc, pour
551
+ * n'importe quel document, l'adresse et l'adresse IP de chaque destinataire, y compris les
552
+ * prospects de ses collègues. C'est exactement ce que `docshare.list` empêche depuis qu'un hôte
553
+ * l'a demandé, avec le commentaire qui l'explique quarante lignes plus haut ; la porte stricte
554
+ * avait une porte large à côté d'elle, et deux appels suffisaient à passer par la seconde.
555
+ *
556
+ * `null` = toutes les sessions (le rôle qui a `list.all`), une adresse = celles dont la chaîne
557
+ * d'origine part d'un lien que cette personne a créé.
558
+ */
559
+ async function listSessionsForDoc(docId, owner = null) {
498
560
  const id = enc(String(docId || ""));
499
561
  const [sessions, shares] = await Promise.all([
500
- PLAYER.db.request(`commercial_doc_sessions?doc_id=eq.${id}&select=*&order=last_at.desc&limit=500`),
501
- PLAYER.db.request(`commercial_doc_shares?doc_id=eq.${id}&is_test=not.is.true&select=slug,recipient_email,recipient_name`),
562
+ PLAYER.db.request(`commercial_doc_sessions?doc_id=eq.${id}&select=${SELECT_SESSION}&order=last_at.desc&limit=500`),
563
+ // ⚠️ TOUS LES LIENS, RÉPÉTITIONS COMPRISES. La chaîne se remonte par `parent_slug` : un maillon
564
+ // absent de cette lecture casse la remontée et fait échouer fermé une session légitime. Le
565
+ // filtre `is_test` d'avant ne servait qu'à nommer le destinataire ; il ne peut pas servir à
566
+ // reconstruire une filiation.
567
+ PLAYER.db.request(`commercial_doc_shares?doc_id=eq.${id}&select=slug,parent_slug,created_by,recipient_email,recipient_name`),
502
568
  ]);
503
- const nameBySlug = new Map();
504
- for (const sh of (Array.isArray(shares) ? shares : [])) nameBySlug.set(sh.slug, sh.recipient_name || null);
505
- return (Array.isArray(sessions) ? sessions : []).map((s) => ({ ...s, recipient_name: nameBySlug.get(s.slug) || null }));
569
+ const liste = Array.isArray(shares) ? shares : [];
570
+ const parSlug = new Map(liste.map((sh) => [sh.slug, sh]));
571
+ const parParent = new Map(liste.map((sh) => [sh.slug, sh.parent_slug || null]));
572
+ const proprietaire = low(owner || "");
573
+
574
+ const sortie = [];
575
+ for (const s of (Array.isArray(sessions) ? sessions : [])) {
576
+ const lien = parSlug.get(s.slug) || null;
577
+ const racine = racineDuLien(s.slug, parParent);
578
+ const createurRacine = racine ? low(parSlug.get(racine)?.created_by || "") : "";
579
+ if (proprietaire && createurRacine !== proprietaire) continue;
580
+ const parent = lien && lien.parent_slug ? parSlug.get(lien.parent_slug) || null : null;
581
+ sortie.push({
582
+ ...sessionServie(s),
583
+ recipient_name: (lien && lien.recipient_name) || null,
584
+ // La filiation voyage avec la session : « une session, un lecteur, visible par sa chaîne
585
+ // d'origine » ne se lit pas si la chaîne n'est pas dans la charge utile.
586
+ parent_slug: (lien && lien.parent_slug) || null,
587
+ parent_recipient_email: parent ? parent.recipient_email || null : null,
588
+ parent_recipient_name: parent ? parent.recipient_name || null : null,
589
+ });
590
+ }
591
+ return sortie;
592
+ }
593
+
594
+ /**
595
+ * Ce qu'une session laisse sortir, et ce qu'elle ne laisse pas sortir.
596
+ *
597
+ * ⚠️ UNE LISTE DE CE QUI EST PERMIS, PAS DE CE QU'ON RETIRE. Les deux lectures de sessions
598
+ * demandaient `select=*` et rendaient la ligne entière : ce que la table contient partait par
599
+ * défaut, et une colonne ajoutée demain serait partie sans que personne y pense. Dans ce sens-là
600
+ * l'oubli est une FUITE. Dans l'autre — une colonne neuve qui n'est pas servie — l'oubli est une
601
+ * absence, que le premier lecteur signale. C'est la même inversion que le périmètre de
602
+ * `image-documentee`, appliquée à ce qui SORT.
603
+ *
604
+ * ⚠️ ET L'IP N'EN EST PLUS. `docs/RETENTION.md` l'appelle en toutes lettres « the most sensitive
605
+ * datum in the schema », et rien dans ce dépôt ne la LIT : elle était écrite par `upsertSession` et
606
+ * ne servait qu'à être rendue. Une fiche commerciale n'a pas à porter l'adresse d'un lecteur pour
607
+ * dire qu'il a lu quatre pages en six minutes.
608
+ *
609
+ * ⚠️ ET LE MÊME PRODUIT A DÉJÀ TRANCHÉ AILLEURS. Les participants d'une présentation n'ont pas leur
610
+ * IP mais un `creator_ip_hash` — un HMAC salé, lié au slug pour qu'on ne puisse pas corréler une
611
+ * adresse d'une présentation à l'autre (0.1.114). Deux décisions opposées sur la même donnée dans
612
+ * le même produit ; celle-ci était la permissive, et personne ne les avait confrontées.
613
+ */
614
+ const CHAMPS_SERVIS = [
615
+ "session_id", "slug", "doc_id", "recipient_email",
616
+ "num_pages", "max_page", "total_seconds", "pages_time",
617
+ "device", "os", "browser",
618
+ "started_at", "last_at",
619
+ ];
620
+
621
+ /**
622
+ * Les colonnes qu'on NE sert PAS, avec la raison — pour qu'une absence soit une décision.
623
+ *
624
+ * ⚠️ ET C'EST BIEN UNE LISTE DE RAISONS, PAS DE CASES COCHÉES. Une colonne retirée sans motif écrit
625
+ * revient au bout de six mois, parce que personne ne sait pourquoi elle n'était pas là.
626
+ */
627
+ const CHAMPS_RETENUS = {
628
+ ip: "adresse IP en clair — VIDÉE par la 0026 et plus jamais écrite (arbitrage ADV du "
629
+ + "01/09/2026). La colonne demeure le temps qu'aucune version supportée ne l'écrive : "
630
+ + "`docs/MIGRATIONS.md` exige qu'une migration soit sûre pendant que la version PRÉCÉDENTE "
631
+ + "tourne, or celle-là l'écrit encore et PostgREST rejette une écriture portant une colonne "
632
+ + "inconnue. Sa suppression est le geste d'une livraison ultérieure ; d'ici là elle est ici, "
633
+ + "vide, et retenue",
634
+ ua: "User-Agent brut — VIDÉ par la 0027 et plus jamais écrit (demande ADV du 01/09/2026). Un "
635
+ + "vecteur d'empreinte, et surtout REDONDANT : `parseUa` en tire `device`, `os` et `browser` à "
636
+ + "l'écriture, et ces trois-là sont servis. Nous avions plaidé pour le garder — seule source "
637
+ + "d'où recalculer les trois sur des lignes déjà écrites — et c'est notre propre argument qui "
638
+ + "l'a emporté contre nous : une chaîne sans lecteur ne se garde pas treize mois pour un "
639
+ + "recalcul hypothétique. La colonne demeure le temps qu'aucune version supportée ne l'écrive, "
640
+ + "pour la même raison que `ip`",
641
+ };
642
+
643
+ /** La projection d'une ligne de session : ce qui sort, et rien d'autre. */
644
+ const sessionServie = (s) => Object.fromEntries(CHAMPS_SERVIS.filter((c) => c in s).map((c) => [c, s[c]]));
645
+
646
+ /** Le `select=` qui descend dans la requête — la MÊME liste, pas une seconde écriture. */
647
+ const SELECT_SESSION = CHAMPS_SERVIS.join(",");
648
+
649
+ /**
650
+ * Les liens nommés, plus TOUS leurs ancêtres, en remontant `parent_slug` par vagues.
651
+ *
652
+ * ⚠️ LA CHAÎNE NE TIENT PAS DANS UNE SEULE LECTURE. Pour un document, `listSessionsForDoc` lit tous
653
+ * ses liens d'un coup et la remontée est locale. Ici les sessions viennent de documents quelconques :
654
+ * on ne connaît au départ que les liens qui les portent, et leurs parents sont ailleurs. On remonte
655
+ * donc par vagues, en ne redemandant jamais un `slug` déjà lu.
656
+ *
657
+ * ⚠️ ET LE NOMBRE DE VAGUES EST BORNÉ. Une chaîne de re-partages réelle fait un ou deux sauts ; huit
658
+ * vagues couvrent très large. Au-delà, on rend ce qu'on a : la remontée qui s'appuie dessus échoue
659
+ * alors fermée, ce qui est le bon sens de l'erreur — on ne montre pas une session qu'on n'a pas su
660
+ * rattacher.
661
+ */
662
+ async function liensEtAncetres(slugs, vaguesMax = 8) {
663
+ const parSlug = new Map();
664
+ let aLire = [...new Set(slugs.filter(Boolean))];
665
+ for (let vague = 0; vague < vaguesMax && aLire.length; vague += 1) {
666
+ const liste = aLire.map((x) => `"${String(x).replace(/[",()]/g, "")}"`).join(",");
667
+ 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`);
668
+ const vus = Array.isArray(lus) ? lus : [];
669
+ if (!vus.length) break;
670
+ for (const sh of vus) parSlug.set(sh.slug, sh);
671
+ aLire = [...new Set(vus.map((sh) => sh.parent_slug).filter((x) => x && !parSlug.has(x)))];
672
+ }
673
+ return parSlug;
674
+ }
675
+
676
+ /**
677
+ * Le curseur d'une page : l'horodatage de la dernière ligne examinée, ET les sessions déjà rendues
678
+ * à CET horodatage.
679
+ *
680
+ * ⚠️ `last_at` NE SUFFIT PAS. Deux sessions peuvent porter le même horodatage — deux battements
681
+ * dans la même milliseconde — et un curseur qui ne retiendrait que le temps sauterait l'une des
682
+ * deux (`lt`) ou la rendrait deux fois (`lte`).
683
+ *
684
+ * ⚠️ ET LA FORME ÉVIDENTE EST INTERDITE ICI, POUR UNE RAISON ÉCRITE. La façon habituelle
685
+ * d'écrire ça est un `or=(last_at.lt.T,and(last_at.eq.T,session_id.lt.ID))` — et `ci.yml` refuse
686
+ * `or=(` et `and=(` dans `server/*.js` : « ce qui coûte, ce sont les jointures imbriquées et les
687
+ * arbres booléens — là, un portage cesse d'être une traduction et devient une réécriture ». Le
688
+ * zéro qu'annonçait `docs/API.md` n'était pas un nombre périmé, c'était une POLITIQUE, et je l'ai
689
+ * pris pour l'autre avant que la garde ne me reprenne.
690
+ *
691
+ * La forme portable dit la même chose sans arbre booléen : « au plus tard que T, et pas l'une de
692
+ * celles-ci » — `last_at=lte.T & session_id=not.in.(…)`, qui se traduit mot pour mot en
693
+ * `WHERE last_at <= T AND session_id NOT IN (…)`. La liste ne grandit que pour les ex æquo de
694
+ * l'horodatage de bord, et chaque page ajoute au moins une exclusion ou avance le temps : la
695
+ * progression est garantie, sans quoi une page d'ex æquo tournerait en rond.
696
+ */
697
+ const curseurDe = (at, ids) => (at ? `${at}|${[...ids].join(",")}` : null);
698
+
699
+ function curseurLu(brut) {
700
+ const texte = String(brut || "");
701
+ const coupe = texte.indexOf("|");
702
+ if (coupe <= 0) return null;
703
+ const at = texte.slice(0, coupe);
704
+ const ids = texte.slice(coupe + 1).split(",").filter(Boolean);
705
+ // Un curseur qu'on ne sait pas lire n'est pas « le début » : ce serait rendre la première page à
706
+ // qui demandait la troisième, en silence. On le REFUSE, et l'appelant le saura.
707
+ if (!ids.length || Number.isNaN(Date.parse(at))) return null;
708
+ return { at, ids };
709
+ }
710
+
711
+ /**
712
+ * Toutes les sessions d'un DESTINATAIRE, tous documents confondus.
713
+ *
714
+ * ⚠️ LA PORTÉE EST CELLE DE `listSessionsForDoc`, POUR LA MÊME RAISON — et elle ne peut pas être
715
+ * poussée dans la requête. « La chaîne d'origine part d'un lien que j'ai créé » est récursif :
716
+ * `created_by` change à chaque saut de re-partage. On lit donc une page de CANDIDATS, on résout
717
+ * leurs chaînes, et on ne rend que ce qui appartient à l'appelant.
718
+ *
719
+ * ⚠️ CONSÉQUENCE ASSUMÉE, ET ÉCRITE PLUTÔT QUE MASQUÉE : une page peut être PLUS COURTE que
720
+ * `limite` sans être la dernière. Le curseur rendu est la position de la dernière ligne EXAMINÉE,
721
+ * pas de la dernière rendue — donc rien n'est sauté ni rendu deux fois, et la fin se lit à
722
+ * `curseur: null`, jamais à la longueur de la page. Boucler jusqu'à remplir la page ferait payer à
723
+ * un appelant restreint un balayage dont il ne verrait rien, et le nombre de requêtes deviendrait
724
+ * une fonction de ce que ses collègues ont envoyé.
725
+ *
726
+ * `depuis` borne le temps (défaut : la fenêtre analytique). L'appelant peut remonter plus loin en
727
+ * la passant explicitement — la fiche promet « tout l'historique », vingt-quatre mois n'en sont que
728
+ * le défaut raisonnable.
729
+ */
730
+ async function listSessionsForRecipient(email, { owner = null, depuis = null, apres = null, limite = 100 } = {}) {
731
+ const destinataire = low(email);
732
+ if (!destinataire) return { sessions: [], curseur: null };
733
+ const borne = depuis || depuisFenetre();
734
+ const taille = Math.min(Math.max(Number(limite) || 100, 1), 500);
735
+ const position = curseurLu(apres);
736
+
737
+ // ⚠️ « AU PLUS TARD QUE T, ET PAS L'UNE DE CELLES-CI » — deux filtres plats, pas un arbre booléen.
738
+ // `not.in.(…)` se traduit en `NOT IN (…)`, que toute base sait faire ; c'est ce qui distingue une
739
+ // traduction d'une réécriture, et c'est la règle que `ci.yml` fait respecter.
740
+ const apresQuoi = position
741
+ ? `&last_at=lte.${enc(position.at)}&session_id=not.in.(${enc(position.ids.map((x) => `"${String(x).replace(/[",()]/g, "")}"`).join(","))})`
742
+ : "";
743
+ const candidats = await PLAYER.db.request(
744
+ `commercial_doc_sessions?recipient_email=eq.${enc(destinataire)}&last_at=gte.${enc(borne)}${apresQuoi}`
745
+ + `&select=${SELECT_SESSION}&order=last_at.desc,session_id.desc&limit=${taille}`);
746
+ const lignes = Array.isArray(candidats) ? candidats : [];
747
+ if (!lignes.length) return { sessions: [], curseur: null };
748
+
749
+ const parSlug = await liensEtAncetres(lignes.map((s) => s.slug));
750
+ const parParent = new Map([...parSlug].map(([slug, sh]) => [slug, sh.parent_slug || null]));
751
+ const proprietaire = low(owner || "");
752
+
753
+ const sessions = [];
754
+ for (const s of lignes) {
755
+ const lien = parSlug.get(s.slug) || null;
756
+ const racine = racineDuLien(s.slug, parParent);
757
+ const createurRacine = racine ? low(parSlug.get(racine)?.created_by || "") : "";
758
+ if (proprietaire && createurRacine !== proprietaire) continue;
759
+ const parent = lien && lien.parent_slug ? parSlug.get(lien.parent_slug) || null : null;
760
+ sessions.push({
761
+ ...sessionServie(s),
762
+ doc_title: (lien && lien.doc_title) || null,
763
+ recipient_name: (lien && lien.recipient_name) || null,
764
+ parent_slug: (lien && lien.parent_slug) || null,
765
+ parent_recipient_email: parent ? parent.recipient_email || null : null,
766
+ parent_recipient_name: parent ? parent.recipient_name || null : null,
767
+ });
768
+ }
769
+ // La page est pleine ⇒ il reste peut-être quelque chose : on rend l'horodatage de la dernière
770
+ // ligne EXAMINÉE, et les sessions déjà servies à cet horodatage. Elle ne l'est pas ⇒ la source
771
+ // est épuisée, et `null` le dit sans ambiguïté.
772
+ if (lignes.length < taille) return { sessions, curseur: null };
773
+ const bord = lignes[lignes.length - 1].last_at;
774
+ const dejaVues = lignes.filter((s) => s.last_at === bord).map((s) => s.session_id);
775
+ // Le curseur entrant portait le MÊME horodatage de bord ⇒ ses exclusions valent encore, sinon
776
+ // les ex æquo déjà servis reviendraient. Il en portait un autre ⇒ elles sont sans objet.
777
+ const exclues = position && position.at === bord ? [...new Set([...position.ids, ...dejaVues])] : dejaVues;
778
+ return { sessions, curseur: curseurDe(bord, exclues) };
506
779
  }
507
780
 
508
781
  // Envoi AUTO du re-partage via 3D Discovery (Resend). Contenu 100% templé (pas de texte libre → anti-spam),
@@ -671,4 +944,4 @@ async function internalStatsForDoc(docId) {
671
944
  }
672
945
 
673
946
  module.exports = {
674
- cleIdempotence, init, createShare, createReshare, sendReshareEmail, getShareBySlug, logView, upsertSession, listSharesForDoc, listSessionsForDoc, revokeShare, setShareAuth, overview, upsertInternalSession, internalStatsForDoc };
947
+ cleIdempotence, init, createShare, createReshare, sendReshareEmail, getShareBySlug, logView, upsertSession, listSharesForDoc, listSessionsForDoc, listSessionsForRecipient, racineDuLien, curseurDe, curseurLu, sessionServie, CHAMPS_SERVIS, CHAMPS_RETENUS, revokeShare, setShareAuth, overview, upsertInternalSession, internalStatsForDoc };
package/supabase/init.sql CHANGED
@@ -108,6 +108,10 @@ create table if not exists public.commercial_doc_views (
108
108
  seconds integer constraint ck_views_seconds_borne check (seconds is null or (seconds >= 0 and seconds <= 86400)),
109
109
  session_id text,
110
110
  at timestamptz not null default now(),
111
+ -- ⚠️ VIDE, ET PLUS JAMAIS ÉCRITE (0027, demande ADV du 01/09/2026). Cette table n'a ni `device`,
112
+ -- ni `os`, ni `browser` : elle ne dérivait RIEN de cette chaîne, l'écrivait, et aucune requête ne
113
+ -- l'a jamais relue. Elle demeure le temps qu'aucune version supportée ne l'écrive — voir la note
114
+ -- de `commercial_doc_sessions.ip` ci-dessous pour la raison, qui est la même.
111
115
  ua text
112
116
  );
113
117
  create index if not exists cdv_slug_idx on public.commercial_doc_views (slug);
@@ -128,7 +132,17 @@ create table if not exists public.commercial_doc_sessions (
128
132
  max_page integer constraint ck_sessions_max_page_borne check (max_page is null or (max_page >= 0 and max_page <= 10000)),
129
133
  total_seconds integer default 0 constraint ck_sessions_total_seconds_borne check (total_seconds is null or (total_seconds >= 0 and total_seconds <= 86400)),
130
134
  pages_time jsonb default '{}'::jsonb,
135
+ -- ⚠️ VIDE, ET PLUS JAMAIS ÉCRITE (0027). `device`, `os` et `browser` ci-dessous en sont dérivés À
136
+ -- L'ÉCRITURE et sont, eux, servis : la chaîne brute n'a plus de lecteur. Même sort que `ip`, même
137
+ -- raison de survivre encore.
131
138
  ua text,
139
+ -- ⚠️ VIDE, ET PLUS JAMAIS ÉCRITE (0026, arbitrage ADV du 01/09/2026). Elle a porté l'adresse du
140
+ -- lecteur en clair ; la 0.1.146 a cessé de la SERVIR, la 0026 efface ce qui restait et plus
141
+ -- aucune écriture ne la remplit. Elle demeure ici parce qu'une migration doit rester sûre
142
+ -- pendant que la version PRÉCÉDENTE du code tourne — celle-là l'écrit encore, et PostgREST
143
+ -- rejette une écriture portant une colonne inconnue : la supprimer aujourd'hui ferait échouer
144
+ -- TOUTES les écritures de session d'un hôte pas encore déployé. Sa suppression est le geste
145
+ -- d'une livraison ULTÉRIEURE, quand plus aucune version supportée ne l'écrit.
132
146
  ip text,
133
147
  device text,
134
148
  os text,
@@ -139,6 +153,12 @@ create table if not exists public.commercial_doc_sessions (
139
153
  create index if not exists cds_sess_slug_idx on public.commercial_doc_sessions (slug);
140
154
  create index if not exists cds_sess_doc_idx on public.commercial_doc_sessions (doc_id, last_at desc);
141
155
  create index if not exists idx_cds_last_at on public.commercial_doc_sessions (last_at);
156
+ -- « Toutes les lectures de cette personne, la plus récente d'abord » — la question de la fiche
157
+ -- par destinataire. Partiel : un lien anonyme laisse `recipient_email` nul et ne répond jamais à
158
+ -- cette question ; le porter dans l'index le grossirait sans qu'aucune requête ne l'y cherche.
159
+ create index if not exists idx_cds_recipient_last_at
160
+ on public.commercial_doc_sessions (recipient_email, last_at desc)
161
+ where recipient_email is not null;
142
162
 
143
163
  create table if not exists public.commercial_doc_internal_sessions (
144
164
  session_id text primary key,
@@ -787,6 +807,27 @@ update public.commercial_doc_shares set revoked_at = now() where revoked = true
787
807
  alter table public.doc_presentations
788
808
  add column if not exists view_rotation integer not null default 0;
789
809
 
810
+ -- ⚠️ ET LE RATTRAPAGE VAUT AUSSI POUR CE QU'ON EFFACE (0026). Une base installée avant aujourd'hui
811
+ -- porte treize mois d'adresses ; `create table if not exists` ne touche pas une table déjà là, donc
812
+ -- rejouer ce fichier ne les effacerait jamais. Le geste est celui de la 0026, à l'identique, et il
813
+ -- ne coûte rien sur une base neuve — où la colonne est vide par construction.
814
+ update public.commercial_doc_sessions set ip = null where ip is not null;
815
+ -- 0027 — même geste pour le User-Agent brut, sur les DEUX tables.
816
+ update public.commercial_doc_sessions set ua = null where ua is not null;
817
+ update public.commercial_doc_views set ua = null where ua is not null;
818
+ comment on column public.commercial_doc_sessions.ua is
819
+ 'VIDE ET PLUS JAMAIS ECRITE depuis la 0027. A porte le User-Agent brut du lecteur. Les champs '
820
+ 'device, os et browser en sont derives A L''ECRITURE et sont, eux, servis. Conservee le temps '
821
+ 'qu''aucune version supportee du lecteur ne l''ecrive. Voir docs/RETENTION.md.';
822
+ comment on column public.commercial_doc_views.ua is
823
+ 'VIDE ET PLUS JAMAIS ECRITE depuis la 0027. A porte le User-Agent brut du lecteur. Cette table '
824
+ 'n''en derivait rien et aucune requete ne l''a jamais relue. Conservee le temps qu''aucune '
825
+ 'version supportee du lecteur ne l''ecrive. Voir docs/RETENTION.md.';
826
+ comment on column public.commercial_doc_sessions.ip is
827
+ 'VIDE ET PLUS JAMAIS ECRITE depuis la 0026. A porte l''adresse IP du lecteur en clair. '
828
+ 'Conservee le temps qu''aucune version supportee du lecteur ne l''ecrive : la supprimer '
829
+ 'aujourd''hui casserait les ecritures d''un hote pas encore deploye. Voir docs/RETENTION.md.';
830
+
790
831
  -- ⚠️ ET LE RATTRAPAGE VAUT AUSSI POUR LES CONTRAINTES, PAS SEULEMENT POUR LES COLONNES (0020).
791
832
  -- Elles sont déclarées dans le corps des tables ci-dessus, ce qui règle la base VIERGE — et ne
792
833
  -- touche pas une base déjà installée, où `create table if not exists` ne fait rien. Le scénario
@@ -0,0 +1,47 @@
1
+ -- LIRE LES SESSIONS D'UNE PERSONNE, TOUS DOCUMENTS CONFONDUS, SANS BALAYER LA TABLE.
2
+ --
3
+ -- ⚠️ CE QUI MANQUAIT. Les trois index de `commercial_doc_sessions` répondent à trois questions :
4
+ -- « les sessions de ce lien » (`cds_sess_slug_idx`), « les sessions de ce document, les plus
5
+ -- récentes d'abord » (`cds_sess_doc_idx`), et « les sessions récentes, tous documents confondus »
6
+ -- (`idx_cds_last_at`, posé par la 0014 pour la rétention). Aucun ne répond à « toutes les lectures
7
+ -- de cette personne » — la question que pose la fiche par destinataire.
8
+ --
9
+ -- Sans lui, `recipient_email=eq.…&order=last_at.desc` est un balayage complet suivi d'un tri : le
10
+ -- coût croît avec le JOURNAL, pas avec ce qu'on rend. C'est la forme de requête qui va bien tant
11
+ -- que la table est jeune et qui devient le point de rupture le jour où elle ne l'est plus — et ce
12
+ -- jour-là, elle ne casse pas, elle ralentit tout le reste avec elle.
13
+ --
14
+ -- ⚠️ POURQUOI `(recipient_email, last_at desc)` ET PAS DEUX INDEX SÉPARÉS. La question est toujours
15
+ -- posée dans cet ordre : une personne, puis ses lectures de la plus récente à la plus ancienne, par
16
+ -- pages. Un index composite sert le filtre ET le tri ET la pagination par curseur en une seule
17
+ -- descente ; deux index simples obligeraient le planificateur à choisir, et à trier après coup ce
18
+ -- qu'il aurait filtré. `desc` est écrit parce que la lecture est toujours descendante : c'est
19
+ -- l'ordre de la fiche, pas une préférence.
20
+ --
21
+ -- ⚠️ ON N'INDEXE PAS LES LIGNES SANS DESTINATAIRE. Un lien anonyme laisse `recipient_email` nul, et
22
+ -- ces lignes ne peuvent JAMAIS répondre à « les lectures de telle personne » — les porter dans
23
+ -- l'index le grossirait sans qu'aucune requête ne les y cherche. L'index partiel dit cette règle
24
+ -- plutôt que de la laisser deviner.
25
+ --
26
+ -- ⚠️ SANS LUI : RIEN NE CASSE. `docshare.sessionsByRecipient` répond de la même façon chez un hôte
27
+ -- non migré — plus lentement, et d'autant plus lentement que son journal est vieux. Aucune colonne
28
+ -- n'est ajoutée, aucune donnée n'est réécrite : c'est une aide au planificateur, pas un contrat.
29
+ --
30
+ -- ⚠️ `CONCURRENTLY` N'EST PAS EMPLOYÉ ICI, ET C'EST DÉLIBÉRÉ. Il ne peut pas tourner dans une
31
+ -- transaction, or ce dossier est joué comme un script transactionnel par les hôtes qui l'appliquent
32
+ -- d'un bloc. Sur une table de journal, la pose verrouille les écritures le temps de la
33
+ -- construction : quelques secondes sur des volumes ordinaires, et les sessions perdues pendant ce
34
+ -- temps sont des upserts qui repasseront au battement suivant. Un hôte dont le journal est
35
+ -- volumineux peut poser l'index à la main en `concurrently` AVANT d'appliquer ce fichier — le
36
+ -- `if not exists` le rend alors sans effet.
37
+ --
38
+ -- ⚠️ IDEMPOTENTE : `if not exists`, rejouable sans effet.
39
+
40
+ create index if not exists idx_cds_recipient_last_at
41
+ on public.commercial_doc_sessions (recipient_email, last_at desc)
42
+ where recipient_email is not null;
43
+
44
+ comment on index public.idx_cds_recipient_last_at is
45
+ 'Sert « toutes les lectures de cette personne, la plus récente d''abord », la question de la fiche '
46
+ 'par destinataire : filtre, tri et pagination par curseur en une seule descente. Partiel sur '
47
+ 'recipient_email non nul — un lien anonyme ne répond jamais à cette question.';
@@ -0,0 +1,73 @@
1
+ -- L'ADRESSE IP D'UN LECTEUR EST EFFACÉE — ARBITRAGE DE L'ADV, RENDU LE 01/09/2026.
2
+ --
3
+ -- La 0.1.146 a cessé de la SERVIR : ni `docshare.sessions` ni `docshare.sessionsByRecipient` ne la
4
+ -- rendent, et ce qui sort d'une session est depuis une liste de ce qui est PERMIS. Restait la
5
+ -- moitié qui ne se règle pas dans le code : treize mois de journal la portaient encore en clair.
6
+ -- Ne plus servir une donnée et ne plus la garder sont deux décisions ; voici la seconde.
7
+ --
8
+ -- ⚠️ SANS LUI : rien ne casse, et c'est bien le problème. Les adresses déjà écrites restent en
9
+ -- base, en clair. Aucun chemin du lecteur ne les relit depuis la 0.1.146 et plus rien ne les écrit
10
+ -- depuis la 0.1.147 — un hôte non migré cesse donc d'en accumuler dès qu'il déploie le code, mais
11
+ -- il CONSERVE tout l'historique. La dégradation n'est pas une panne : c'est une rétention qui
12
+ -- continue en silence, jusqu'à la purge des treize mois, ligne par ligne.
13
+ --
14
+ -- ⚠️ POURQUOI CE FICHIER NE SUPPRIME PAS LA COLONNE, alors que la demande le préférait. La règle
15
+ -- de ce dossier — `docs/MIGRATIONS.md`, éprouvée par un banc — est qu'une migration doit être sûre
16
+ -- à appliquer PENDANT QUE LA VERSION PRÉCÉDENTE DU CODE TOURNE. La 0.1.146 écrit encore `ip`, et
17
+ -- PostgREST rejette une écriture portant une colonne inconnue : supprimer la colonne aujourd'hui
18
+ -- ferait échouer TOUTES les écritures de session d'un hôte qui applique les migrations avant de
19
+ -- déployer — pas seulement celles qui touchent l'adresse. Et le message parlerait d'une colonne,
20
+ -- pas d'une version : il n'aurait aucun moyen de le deviner. La suppression est donc le geste
21
+ -- d'une livraison ULTÉRIEURE, quand plus aucune version supportée ne l'écrit.
22
+ --
23
+ -- ⚠️ ET CE REPORT NE COÛTE RIEN À L'EFFACEMENT — c'est la mesure qui le dit, pas une commodité.
24
+ -- Mesuré le 01/09 sur PostgreSQL 16.13 avec `pageinspect`, sur des lignes portant une adresse :
25
+ --
26
+ -- après ALTER TABLE … DROP COLUMN toutes encore dans les pages
27
+ -- après VACUUM ordinaire toutes encore là — les lignes sont VIVANTES
28
+ -- après VACUUM FULL aucune — mais il RÉÉCRIT la table, sous verrou
29
+ --
30
+ -- après UPDATE … SET ip = NULL anciennes versions de ligne, désormais MORTES
31
+ -- après VACUUM ordinaire aucune — celui qui passe TOUT SEUL, sans verrou
32
+ --
33
+ -- Autrement dit : `DROP COLUMN` n'efface RIEN. Il marque l'attribut supprimé et laisse les octets
34
+ -- dans les pages jusqu'à une réécriture complète de la table — indéfiniment chez un hôte qui ne
35
+ -- fait rien de particulier, et invisibles à toute requête, donc plus jamais vérifiés par personne.
36
+ -- C'est l'UPDATE qui efface : il écrit de nouvelles versions de ligne sans l'adresse et rend les
37
+ -- anciennes mortes, que l'autovacuum de routine récupère de lui-même. Ce fichier fait donc
38
+ -- AUJOURD'HUI tout ce qui relève de l'effacement ; ce qui est reporté est la forme du schéma, pas
39
+ -- la donnée.
40
+ --
41
+ -- ⚠️ CE QU'IL NE PEUT PAS ATTEINDRE, ET QUI DOIT ÊTRE DIT PLUTÔT QUE SIMULÉ. Les WAL déjà écrits,
42
+ -- les sauvegardes, les exports et les dumps portent encore les adresses ; ils suivent la politique
43
+ -- de rétention de l'hôte, pas ce fichier. `docs/RETENTION.md` posait déjà la règle générale — une
44
+ -- colonne de données personnelles qu'on retire est elle-même un acte de rétention, et les copies
45
+ -- antérieures suivent la politique de sauvegarde de l'hôte. Ce fichier en est le premier cas
46
+ -- concret. Un hôte qui doit attester une purge COMPLÈTE fait expirer ou réécrit ses sauvegardes.
47
+ --
48
+ -- ⚠️ CE QUI RESTE, ET POURQUOI. `ua` reste : elle n'était pas demandée, et elle est la SOURCE de
49
+ -- `device`, `os` et `browser` — la seule à permettre de les recalculer sur les lignes déjà écrites
50
+ -- si l'analyse s'améliore. Elle n'est plus servie depuis la 0.1.146. La supprimer se défend et se
51
+ -- décidera séparément : une destruction irréversible se demande, elle ne se déduit pas.
52
+ -- `player_rate_limits.key` peut porter une adresse en clair : elle expire d'elle-même.
53
+ -- `doc_presentation_attendees.creator_ip_hash` est un HMAC salé, pas une adresse — l'asymétrie que
54
+ -- `docs/RETENTION.md` signalait entre les deux tables cesse ici, par le haut.
55
+ --
56
+ -- ⚠️ LE SIGNE QU'UN HÔTE PEUT SONDER est le COMMENTAIRE de la colonne, `col_description()`. Une
57
+ -- migration qui n'efface que des données ne laisse aucune trace dans `information_schema` : elle
58
+ -- serait inattestable, et une purge est justement la migration qu'un DPO demandera de prouver.
59
+ --
60
+ -- ⚠️ ADDITIVE au sens de `docs/MIGRATIONS.md` : aucune colonne, table ou contrainte n'est retirée,
61
+ -- rien n'est renommé, rien ne devient obligatoire. Sûre pendant que la 0.1.146 tourne — elle
62
+ -- continue d'écrire dans une colonne qui existe toujours.
63
+ --
64
+ -- ⚠️ IDEMPOTENTE : rejouable sans effet ; le second passage ne trouve plus rien à vider.
65
+
66
+ update public.commercial_doc_sessions
67
+ set ip = null
68
+ where ip is not null;
69
+
70
+ comment on column public.commercial_doc_sessions.ip is
71
+ 'VIDE ET PLUS JAMAIS ECRITE depuis la 0026. A porte l''adresse IP du lecteur en clair. '
72
+ 'Conservee le temps qu''aucune version supportee du lecteur ne l''ecrive : la supprimer '
73
+ 'aujourd''hui casserait les ecritures d''un hote pas encore deploye. Voir docs/RETENTION.md.';
@@ -0,0 +1,67 @@
1
+ -- LE USER-AGENT BRUT EST EFFACÉ, SUR LES DEUX TABLES — DEMANDE EXPLICITE DE L'ADV, 01/09/2026.
2
+ --
3
+ -- La 0.1.146 avait cessé de le SERVIR. La 0026 a effacé l'adresse IP par la même mécanique et l'a
4
+ -- laissé de côté : il n'était pas demandé, et nous avions plaidé pour le garder — seule source d'où
5
+ -- `device`, `os` et `browser` pourraient être recalculés sur les lignes déjà écrites. L'ADV a
6
+ -- tranché, et l'argument qui emporte est le nôtre retourné contre nous : les trois champs dérivés
7
+ -- sont extraits À L'ÉCRITURE et servis, la chaîne brute n'a plus de lecteur, et « pouvoir
8
+ -- recalculer un jour » ne justifie pas treize mois d'empreinte conservée pour personne. Une donnée
9
+ -- sans lecteur n'a pas de raison d'exister.
10
+ --
11
+ -- ⚠️ SANS LUI : rien ne casse, et c'est le même piège que pour l'adresse. Les chaînes déjà écrites
12
+ -- restent en base. Aucune requête de ce dépôt ne les relit — vérifié en énumérant les six requêtes
13
+ -- qui touchent ces deux tables : `ua` n'apparaît dans aucun `select=`. La 0.1.147 cesse en plus de
14
+ -- les ÉCRIRE. La dégradation n'est donc pas une panne : c'est une rétention qui continue en
15
+ -- silence jusqu'à la purge des treize mois, ligne par ligne.
16
+ --
17
+ -- ⚠️ LE CAS DE `commercial_doc_views` EST PLUS NET ENCORE. Cette table n'a ni `device`, ni `os`, ni
18
+ -- `browser` : elle ne dérivait RIEN de cette chaîne. Elle l'écrivait, et personne ne l'a jamais
19
+ -- relue. Là où les sessions gardaient au moins une justification discutable, les consultations n'en
20
+ -- avaient aucune — et personne ne l'avait remarqué, parce que la question n'avait jamais été posée
21
+ -- table par table.
22
+ --
23
+ -- ⚠️ MÊME FORME QUE LA 0026, POUR LA MÊME RAISON MESURÉE. On VIDE, on ne supprime pas la colonne.
24
+ -- `DROP COLUMN` marque l'attribut supprimé sans réécrire une seule ligne : les octets restent dans
25
+ -- les pages jusqu'à une réécriture complète de la table, qu'un hôte ne déclenche pas de lui-même —
26
+ -- indéfiniment, et désormais invisibles à toute requête, donc plus jamais vérifiés par personne.
27
+ -- C'est l'UPDATE qui efface : il écrit de nouvelles versions de ligne sans la chaîne et rend les
28
+ -- anciennes mortes, que l'autovacuum de routine récupère seul, sans verrou. La mesure complète est
29
+ -- dans la 0026 et dans `docs/RETENTION.md`.
30
+ --
31
+ -- ⚠️ ET LA SUPPRESSION DES COLONNES RESTE DIFFÉRÉE, pour la raison qui vaut déjà pour `ip` :
32
+ -- `docs/MIGRATIONS.md` exige qu'une migration soit sûre à appliquer PENDANT QUE LA VERSION
33
+ -- PRÉCÉDENTE DU CODE TOURNE. La 0.1.146 écrit encore ces colonnes, et PostgREST rejette une
34
+ -- écriture portant une colonne inconnue : les supprimer aujourd'hui ferait échouer TOUTES les
35
+ -- écritures de consultation et de session d'un hôte qui migre avant de déployer. Les trois
36
+ -- colonnes — `commercial_doc_sessions.ip`, `commercial_doc_sessions.ua`,
37
+ -- `commercial_doc_views.ua` — partiront ensemble, dans une livraison ultérieure, quand plus aucune
38
+ -- version supportée ne les écrira. L'effacement, lui, est complet aujourd'hui.
39
+ --
40
+ -- ⚠️ CE QU'IL NE PEUT PAS ATTEINDRE : les WAL déjà écrits, les sauvegardes, les exports et les
41
+ -- dumps. Ils suivent la politique de l'hébergeur, pas ce fichier. `docs/RETENTION.md` le dit, avec
42
+ -- ce que la rétention de ce lecteur fait expirer et ce qu'elle ne touche pas.
43
+ --
44
+ -- ⚠️ LE SIGNE SONDABLE est le COMMENTAIRE des colonnes, `col_description()` — une migration qui
45
+ -- n'efface que des données ne laisse aucune trace dans `information_schema`, et c'est justement la
46
+ -- migration qu'un hôte devra prouver.
47
+ --
48
+ -- ⚠️ ADDITIVE : aucune colonne, table ou contrainte retirée, rien renommé, rien rendu obligatoire.
49
+ -- ⚠️ IDEMPOTENTE : rejouable sans effet ; le second passage ne trouve plus rien à vider.
50
+
51
+ update public.commercial_doc_sessions
52
+ set ua = null
53
+ where ua is not null;
54
+
55
+ update public.commercial_doc_views
56
+ set ua = null
57
+ where ua is not null;
58
+
59
+ comment on column public.commercial_doc_sessions.ua is
60
+ 'VIDE ET PLUS JAMAIS ECRITE depuis la 0027. A porte le User-Agent brut du lecteur. Les champs '
61
+ 'device, os et browser en sont derives A L''ECRITURE et sont, eux, servis. Conservee le temps '
62
+ 'qu''aucune version supportee du lecteur ne l''ecrive. Voir docs/RETENTION.md.';
63
+
64
+ comment on column public.commercial_doc_views.ua is
65
+ 'VIDE ET PLUS JAMAIS ECRITE depuis la 0027. A porte le User-Agent brut du lecteur. Cette table '
66
+ 'n''en derivait rien et aucune requete ne l''a jamais relue. Conservee le temps qu''aucune '
67
+ 'version supportee du lecteur ne l''ecrive. Voir docs/RETENTION.md.';