discovery-media-player 0.1.77 → 0.1.79

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.
@@ -274,8 +274,16 @@ function createStandaloneContext(env = process.env) {
274
274
  const base = sansBarreFinale(env.SUPABASE_URL);
275
275
  const cle = String(env.SUPABASE_SERVICE_ROLE_KEY || "");
276
276
  if (!base || !cle || !bucket || !chemin) return false;
277
+ // ⚠️ DERNIÈRE BARRIÈRE avant un DELETE à la clé service_role (P1 huitième audit). Bucket en
278
+ // liste blanche, et refus de toute traversée — chaque segment sur l'alphabet des chemins
279
+ // signés. `fetch` normalise `..` : un chemin non validé sortirait du bucket visé.
280
+ if (bucket !== "present-attachments") return false;
281
+ const segs = String(chemin).split("/");
282
+ for (const seg of segs) {
283
+ if (seg === "" || seg === "." || seg === ".." || !/^[A-Za-z0-9._-]+$/.test(seg)) return false;
284
+ }
277
285
  try {
278
- const r = await fetch(`${base}/storage/v1/object/${bucket}/${chemin}`, {
286
+ const r = await fetch(`${base}/storage/v1/object/${encodeURIComponent(bucket)}/${segs.map(encodeURIComponent).join("/")}`, {
279
287
  method: "DELETE", headers: { apikey: cle, Authorization: `Bearer ${cle}` },
280
288
  });
281
289
  return r.ok;
@@ -31,7 +31,7 @@ need.
31
31
  "product": "discovery-media-player",
32
32
  "contract": 1,
33
33
  "version": "<the running version>",
34
- "capabilities": ["docshare", "presentations", "embed-denied", "host-fetch", "brand-reference", "host-auth", "host-share", "host-mail"],
34
+ "capabilities": ["docshare", "presentations", "embed-denied", "host-fetch", "brand-reference", "host-auth", "host-share", "host-mail", "retention"],
35
35
  "frameAncestors": ["'self'", "https://*.vercel.app", "https://app.example.com"],
36
36
  "separateIssuer": true,
37
37
  "internalStrict": true,
package/docs/RETENTION.md CHANGED
@@ -15,7 +15,11 @@ ni fonction de périmètre, ni filtre) :
15
15
 
16
16
  > ⚠️ **Fenêtres proposées, à valider par l'exploitant.** Les durées ci-dessous sont des défauts
17
17
  > raisonnés (journaux analytiques : 13 mois, comparaison année sur année ; archives de
18
- > présentation : 12 mois après la fin). Un hôte les ajuste via `config.retention`.
18
+ > présentation : 12 mois après la fin). Un hôte les ajuste via `config.retention` — **entiers de
19
+ > mois dans [1, 120] uniquement**. Toute valeur négative, nulle, non entière, `NaN`, `Infinity`
20
+ > ou chaîne fait ÉCHOUER la purge avant le premier `DELETE`, en nommant la clé fautive : une
21
+ > faute de configuration ne supprime jamais rien. Les bornes sont calculées en UTC, rabattues au
22
+ > dernier jour du mois cible (« 31 mars − 1 mois » = 28 février, pas le 3 mars).
19
23
  >
20
24
  > ⚠️ **Le balayage automatique est OPT-IN STRICT** : il ne tourne que si l'hôte écrit
21
25
  > `config.retention.balayage: true`. Un hôte qui consomme le contexte autonome tel quel hérite de
@@ -111,6 +115,16 @@ Parcours guidé par l'agent : **purge 13 mois** après `last_at`.
111
115
  contexte hôte fournit `storage.remove` (capacité optionnelle). Sans elle, l'URL devient
112
116
  introuvable depuis le produit mais l'objet survit dans le bucket — c'est dit ici plutôt que
113
117
  simulé.
118
+ - **La purge avance par LOTS bornés** (200 lignes, plafond 5000 par table et 500 présentations
119
+ par exécution) : elle sélectionne un lot d'identifiants, les supprime par `id=in.(…)`, et
120
+ recommence. Le rapport (`r.rapport`) porte, par table : `examinees`, `supprimees`, `tronque`
121
+ (il reste à faire au prochain passage). `retention.run` accepte `{ dryRun: true }` : elle
122
+ compte sans rien effacer — à lancer avant la première vraie purge d'un gros historique.
123
+ - **Index** (migration 0014) : `commercial_doc_sessions(last_at)`, `doc_bot_sessions(last_at)`,
124
+ `commercial_doc_shares(revoked_at) where revoked`. Sur une installation VOLUMINEUSE déjà en
125
+ production, créez-les à la main en `CREATE INDEX CONCURRENTLY` hors migration (la migration les
126
+ pose en index ordinaire, ce qui verrouille brièvement l'écriture — négligeable sur une base
127
+ jeune, à éviter sur une grosse table active).
114
128
  - **Le recensement ne tourne pas tout seul en production** : c'est un SQL qu'un exploitant lance
115
129
  (et que la forge exécute à chaque course sur une base réelle vieillie artificiellement).
116
130
  - **« Ce qui existe » a une profondeur temporelle qu'`information_schema` n'a pas** (question du
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "discovery-media-player",
3
- "version": "0.1.77",
3
+ "version": "0.1.79",
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",
@@ -329,7 +329,7 @@ var Live=(function(){
329
329
  function setReply(id){var m=msgData[id];if(!m||m.deleted)return;var nm=m.author_name||'Invité';replyCtx={id:+id,name:nm,text:(m.body||'').slice(0,120)};var el=document.getElementById('chatReply');if(el){el.style.display='flex';el.innerHTML='<span class=cq><b>'+esc(nm)+'</b> '+esc((m.body||'').slice(0,80))+'</span><button id=chatReplyX title=Annuler>×</button>';var x=document.getElementById('chatReplyX');if(x)x.addEventListener('click',clearReply);}var t=document.getElementById('chatText');if(t)t.focus();}
330
330
  function clearReply(){replyCtx=null;var el=document.getElementById('chatReply');if(el){el.style.display='none';el.innerHTML='';}}
331
331
  function send(){var i=document.getElementById('chatText');var t=(i.value||'').trim();if(!t||!ME)return;if(LOCKED&&!canMod())return;i.value='';toggleSend();
332
-
332
+
333
333
  // ⚠️ LA CLÉ EST FABRIQUÉE ICI, UNE FOIS, AVANT LE PREMIER ENVOI. Une clé tirée à chaque
334
334
  // tentative ne servirait à rien : deux envois porteraient deux clés et passeraient tous les
335
335
  // deux. C'est sa RÉUTILISATION au renvoi qui rend l'opération idempotente.
package/server/handler.js CHANGED
@@ -576,7 +576,7 @@ async function handler(req, res) {
576
576
  // muette sur les URL.
577
577
  capabilities: [
578
578
  "docshare", "presentations", "embed-denied", "host-fetch", "brand-reference", "host-auth",
579
- "host-share", "host-mail",
579
+ "host-share", "host-mail", "retention",
580
580
  ],
581
581
  // ⚠️ POUR QUELLES ORIGINES cette instance accepte d'être encadrée. Un booléen ne
582
582
  // suffisait pas : un hôte a besoin de voir que SON domaine manque, pas seulement que
@@ -310,6 +310,33 @@ async function endPresentation(slug, control) {
310
310
  // Pièce jointe : URL d'upload SIGNÉE (service role) → le client PUT directement dans le bucket. Type/taille
311
311
  // validés par le bucket (image/*+pdf, ≤10 Mo). L'URL publique finale est renvoyée pour attacher au message.
312
312
  const ATT_KINDS = { "image/png": "image", "image/jpeg": "image", "image/webp": "image", "image/gif": "image", "application/pdf": "pdf" };
313
+ const BUCKET_PIECES = "present-attachments";
314
+
315
+ // ⚠️ LE CHEMIN CANONIQUE D'UNE PIÈCE JOINTE — UNE SEULE FONCTION POUR L'ÉCRITURE ET LA
316
+ // SUPPRESSION (P1 du huitième audit). Rend le chemin RELATIF au bucket (« <slug>/fichier ») si,
317
+ // et seulement si, l'URL publique désigne EXACTEMENT le dossier du slug courant, sans traversée.
318
+ // Refuse tout le reste — `null`. La barrière est la même des deux côtés : un `startsWith` laissait
319
+ // passer `…/present-attachments/../autre-bucket/x`, inerte tant que l'URL n'était que lue, devenu
320
+ // une primitive de suppression avec la rétention.
321
+ //
322
+ // Refusés : `.`/`..` en segment, leurs formes encodées (%2e), les slash/antislash encodés
323
+ // (%2f/%5c), l'antislash brut, l'octet nul — et tout premier segment différent du slug.
324
+ function cheminPieceJointe(url, slug, base) {
325
+ const prefixe = `${String(base || "")}/storage/v1/object/public/${BUCKET_PIECES}/`;
326
+ const brut = String(url || "");
327
+ if (!brut.startsWith(prefixe)) return null;
328
+ const chemin = brut.slice(prefixe.length).split("?")[0];
329
+ if (/%2e|%2f|%5c|\\|\0/i.test(chemin)) return null; // formes encodées + antislash + nul
330
+ if (chemin.includes("\u0000")) return null;
331
+ const segments = chemin.split("/");
332
+ if (segments.length < 2) return null; // il faut « <slug>/<fichier> »
333
+ if (segments[0] !== String(slug)) return null; // premier segment = slug courant
334
+ for (const seg of segments) {
335
+ if (seg === "" || seg === "." || seg === "..") return null; // pas de segment vide ni de point
336
+ if (!/^[A-Za-z0-9._-]+$/.test(seg)) return null; // même alphabet que createUploadUrl
337
+ }
338
+ return chemin;
339
+ }
313
340
  /**
314
341
  * UNE PRÉSENTATION TERMINÉE DEVIENT UNE ARCHIVE : on la relit, on ne l'écrit plus.
315
342
  *
@@ -484,8 +511,19 @@ async function addMessage(slug, { name, email, avatar, isPresenter, isMember, bo
484
511
  // et c'est comme ça que `.replace(/\/+$/, "")` s'est retrouvé à cinq exemplaires dans ce dépôt.
485
512
  const base = String((PLAYER.config && PLAYER.config.supabaseUrl) || "");
486
513
  let att = null;
487
- if (attachment && typeof attachment === "object" && attachment.url && String(attachment.url).startsWith(base + "/storage/v1/object/public/present-attachments/")) {
488
- att = { url: String(attachment.url).slice(0, 600), name: String(attachment.name || "").slice(0, 120), type: String(attachment.type || "").slice(0, 60), kind: attachment.kind === "pdf" ? "pdf" : "image" };
514
+ if (attachment && typeof attachment === "object" && attachment.url) {
515
+ const chemin = cheminPieceJointe(attachment.url, slug, base);
516
+ if (chemin) {
517
+ // On stocke le CHEMIN validé et on RECONSTRUIT l'URL publique depuis lui — jamais l'URL
518
+ // telle que le client l'a envoyée. Deux barrières : ce chemin est revalidé à la suppression.
519
+ att = {
520
+ path: chemin,
521
+ url: `${base}/storage/v1/object/public/${BUCKET_PIECES}/${chemin}`,
522
+ name: String(attachment.name || "").slice(0, 120),
523
+ type: String(attachment.type || "").slice(0, 60),
524
+ kind: attachment.kind === "pdf" ? "pdf" : "image",
525
+ };
526
+ }
489
527
  }
490
528
  if ((!b && !att) || !slug) return { ok: false, status: 400 };
491
529
  const rt = Number.isFinite(+replyTo) ? Math.trunc(+replyTo) : null;
@@ -939,4 +977,4 @@ async function listPresentationsForDoc(docId, email, isAdmin, autoriseLarge) {
939
977
  module.exports = {
940
978
  reacteurDepuisJeton,
941
979
  purgerPerimees,
942
- messagePublic, CHAMPS_PUBLICS, init, createPresentation, getPresentation, setPage, endPresentation, addMessage, listMessages, toggleReaction, editMessage, deleteMessage, setChatLock, createUploadUrl, reclaimPresentation, touchPresentation, listActivePresentations, handoverPresentation, endPresentationByOwner, recordAttendance, presentationStats, listPresentationsForDoc, switchPresentationDoc, setPresentationContent };
980
+ messagePublic, CHAMPS_PUBLICS, cheminPieceJointe, init, createPresentation, getPresentation, setPage, endPresentation, addMessage, listMessages, toggleReaction, editMessage, deleteMessage, setChatLock, createUploadUrl, reclaimPresentation, touchPresentation, listActivePresentations, handoverPresentation, endPresentationByOwner, recordAttendance, presentationStats, listPresentationsForDoc, switchPresentationDoc, setPresentationContent };
@@ -20,63 +20,228 @@ const enc = encodeURIComponent;
20
20
 
21
21
  // Fenêtres par défaut de docs/RETENTION.md — l'hôte ajuste via `config.retention`.
22
22
  const FENETRES = { journauxMois: 13, presentationsMois: 12, liensRevoquesMois: 13 };
23
- const fenetres = () => ({ ...FENETRES, ...((PLAYER.config && PLAYER.config.retention) || {}) });
23
+ const CLES_FENETRE = Object.keys(FENETRES);
24
+ const MIN_MOIS = 1, MAX_MOIS = 120;
24
25
 
25
- // Borne calendaire : N mois avant `now`, en ISO — les fenêtres de RETENTION.md parlent en mois.
26
- function borne(now, mois) { const d = new Date(now); d.setMonth(d.getMonth() - mois); return d.toISOString(); }
26
+ // ⚠️ UNE FENÊTRE EST UN ENTIER DE MOIS DANS [1,120] — RIEN D'AUTRE (P2 huitième audit). Une valeur
27
+ // négative calculerait une borne FUTURE (perte massive), zéro purgerait tout, une chaîne/NaN/
28
+ // Infinity produirait une date invalide. On refuse AVANT le premier DELETE, en NOMMANT la clé.
29
+ // Zéro n'est PAS une purge immédiate : ce serait un geste trop dangereux pour un défaut de config.
30
+ function fenetresValidees() {
31
+ const brut = { ...FENETRES, ...((PLAYER.config && PLAYER.config.retention) || {}) };
32
+ const out = Object.create(null); // nu : la garde de forme reconnaît cet accumulateur
33
+ for (const cle of CLES_FENETRE) {
34
+ const v = brut[cle];
35
+ if (typeof v !== "number" || !Number.isInteger(v) || v < MIN_MOIS || v > MAX_MOIS) {
36
+ const e = new Error(`fenêtre de rétention invalide : ${cle}=${JSON.stringify(v)} — attendu un entier de mois dans [${MIN_MOIS},${MAX_MOIS}]. Aucune suppression.`);
37
+ e.retentionInvalide = true;
38
+ throw e;
39
+ }
40
+ out[cle] = v;
41
+ }
42
+ return out;
43
+ }
44
+
45
+ // ⚠️ Borne = N mois avant `now`, en UTC, RABATTUE au dernier jour du mois cible. `Date.setMonth`
46
+ // déborde (« 31 mars − 1 mois » → 3 mars) et l'heure locale + le changement d'heure décalaient la
47
+ // borne : on construit la date en UTC, jour rabattu sur le dernier du mois visé.
48
+ function borne(now, mois) {
49
+ const d = new Date(now);
50
+ const a = d.getUTCFullYear();
51
+ const m = d.getUTCMonth() - mois;
52
+ const dernierDuMois = new Date(Date.UTC(a, m + 1, 0)).getUTCDate(); // jour 0 du mois suivant = dernier du mois
53
+ const jour = Math.min(d.getUTCDate(), dernierDuMois);
54
+ return new Date(Date.UTC(a, m, jour, d.getUTCHours(), d.getUTCMinutes(), d.getUTCSeconds(), d.getUTCMilliseconds())).toISOString();
55
+ }
56
+
57
+ // Bornes d'exécution par défaut — un lot raisonnable, un plafond qui tient dans une fenêtre
58
+ // serverless. L'appelant peut resserrer (dryRun, taille, plafond) ; jamais dépasser sans le dire.
59
+ const LOT = 200, PLAFOND = 5000, PLAFOND_PRESENTATIONS = 500;
60
+ const MAX_TAILLE = 500, MAX_PLAFOND = 5000;
61
+
62
+ // ⚠️ LES OPTIONS D'EXÉCUTION SONT VALIDÉES AVANT TOUT DELETE (P1 neuvième audit). La route les
63
+ // reçoit d'un appelant ; un `dryRun` non booléen strict, une taille/plafond hors bornes ou une
64
+ // clé inconnue font ÉCHOUER la purge — jamais un `Number()` ni un `!!` qui transformerait
65
+ // `"false"` en purge réelle. `dryRun` est ce qu'un exploitant lance EN PREMIER : il ne doit pas
66
+ // pouvoir supprimer par une faute de type.
67
+ const CLES_OPTS = new Set(["dryRun", "taille", "plafond"]);
68
+ function optionsValidees(opts) {
69
+ const o = opts || {};
70
+ for (const cle of Object.keys(o)) {
71
+ if (!CLES_OPTS.has(cle)) { const e = new Error(`option de rétention inconnue : ${cle}`); e.retentionInvalide = true; throw e; }
72
+ }
73
+ if ("dryRun" in o && typeof o.dryRun !== "boolean") { const e = new Error("dryRun doit être un booléen strict"); e.retentionInvalide = true; throw e; }
74
+ const entierDans = (v, min, max, nom) => {
75
+ if (v === undefined) return undefined;
76
+ if (typeof v !== "number" || !Number.isInteger(v) || v < min || v > max) {
77
+ const e = new Error(`${nom} doit être un entier dans [${min},${max}] — reçu ${JSON.stringify(v)}`); e.retentionInvalide = true; throw e;
78
+ }
79
+ return v;
80
+ };
81
+ return {
82
+ dryRun: o.dryRun === true,
83
+ taille: entierDans(o.taille, 1, MAX_TAILLE, "taille") ?? LOT,
84
+ plafond: entierDans(o.plafond, 1, MAX_PLAFOND, "plafond") ?? PLAFOND,
85
+ };
86
+ }
87
+
88
+ // Une valeur pour `id=in.(…)` : double-guillemets, guillemet et antislash internes échappés —
89
+ // PostgREST exige le guillemetage dès qu'une valeur porte un caractère réservé (`:` d'une clé de
90
+ // débit, par exemple). Guillemeter TOUJOURS est correct et évite d'avoir à deviner.
91
+ const guill = (v) => '"' + String(v).replace(/\\/g, "\\\\").replace(/"/g, '\\"') + '"';
92
+
93
+ // ⚠️ PURGE PAR LOTS BORNÉS (P2 huitième audit). On SÉLECTIONNE un lot d'identifiants (borné,
94
+ // ordonné par la colonne de date), on les supprime par `id=in.(…)`, on recommence — jamais un
95
+ // DELETE non borné qui ramènerait tout l'historique d'un coup (mémoire, WAL, verrous, timeout).
96
+ // `dryRun` sélectionne sans supprimer. Le rapport remplace la liste d'identifiants : examinées,
97
+ // supprimées, tronqué (il reste à faire au prochain passage).
98
+ // ⚠️ Pagination par CURSEUR KEYSET (`colId=gt.<dernier>`), pas par `offset` — la garde de
99
+ // portabilité de la forge interdit `offset=`, et un curseur est de toute façon stable sous
100
+ // suppression concurrente. En dry-run le pool ne rétrécit pas : sans curseur, on relisait le
101
+ // même premier lot à chaque tour (P2 neuvième audit : 120 lignes comptées 300). Le lot est rogné
102
+ // au RESTE du plafond (`min(taille, plafond - examinees)`) pour ne jamais le dépasser. Et on
103
+ // compte les lignes RENDUES par le DELETE (`return=representation&select=id`), pas les ids
104
+ // présélectionnés : deux exécutions concurrentes n'annoncent pas deux fois la même suppression.
105
+ async function purgerParLots(table, filtre, colId, { dryRun = false, taille = LOT, plafond = PLAFOND } = {}) {
106
+ let examinees = 0, supprimees = 0, tronque = false, curseur = null;
107
+ for (;;) {
108
+ const reste = plafond - examinees;
109
+ if (reste <= 0) { tronque = await resteEncore(table, filtre, colId, curseur, dryRun); break; }
110
+ const limite = Math.min(taille, reste);
111
+ const borneCur = curseur != null ? `&${colId}=gt.${enc(String(curseur))}` : "";
112
+ const lot = await PLAYER.db.request(`${table}?${filtre}${borneCur}&select=${colId}&order=${colId}.asc&limit=${limite}`);
113
+ if (!Array.isArray(lot) || !lot.length) break;
114
+ examinees += lot.length;
115
+ curseur = lot[lot.length - 1][colId];
116
+ if (!dryRun) {
117
+ const ids = lot.map((r) => r[colId]).filter((v) => v != null).map(guill);
118
+ if (ids.length) {
119
+ const del = await PLAYER.db.request(`${table}?${colId}=in.(${ids.join(",")})&select=${colId}`, { method: "DELETE", headers: { Prefer: "return=representation" } });
120
+ supprimees += Array.isArray(del) ? del.length : 0;
121
+ }
122
+ }
123
+ if (lot.length < limite) break; // dernier lot (moins que demandé → plus rien après)
124
+ }
125
+ return { examinees, supprimees, tronque };
126
+ }
27
127
 
28
- async function effacer(chemin) {
29
- const lignes = await PLAYER.db.request(chemin, { method: "DELETE", headers: { Prefer: "return=representation" } });
30
- return Array.isArray(lignes) ? lignes.length : 0;
128
+ // « Reste-t-il une ligne au-delà du curseur ? » — sonde d'UNE ligne. Départage « on s'est arrêté
129
+ // pile au plafond mais tout est parti » de « il reste à faire ». En run réel les lignes lues ont
130
+ // été supprimées : la fenêtre a avancé, on relit depuis le début du filtre (curseur non requis).
131
+ async function resteEncore(table, filtre, colId, curseur, dryRun) {
132
+ const borneCur = dryRun && curseur != null ? `&${colId}=gt.${enc(String(curseur))}` : "";
133
+ const sonde = await PLAYER.db.request(`${table}?${filtre}${borneCur}&select=${colId}&order=${colId}.asc&limit=1`);
134
+ return Array.isArray(sonde) && sonde.length > 0;
31
135
  }
32
136
 
33
- // Chemin du bucket depuis l'URL publique d'une pièce jointe — nul si l'URL vient d'ailleurs.
34
- function cheminPieceJointe(url) {
35
- const m = /\/present-attachments\/(.+)$/.exec(String(url || ""));
36
- return m ? m[1].split("?")[0] : null;
137
+ // ⚠️ REVALIDATION À LA SUPPRESSION — le MÊME validateur que l'écriture (server/presentations.js),
138
+ // avec le slug de la présentation purgée. Une validation d'écriture n'est jamais la seule barrière
139
+ // d'un delete : les lignes déjà en base d'avant le correctif peuvent porter une URL piégée.
140
+ const { cheminPieceJointe: cheminSurSlug } = require("./presentations");
141
+
142
+ // Purge des messages d'une présentation morte, par lots bornés qui lisent id+attachment ENSEMBLE :
143
+ // on retire les fichiers du bucket du lot (si l'hôte sait), puis on supprime le lot. Rend `tronque`
144
+ // pour que l'appelant décide de garder ou non la présentation. Compte les lignes RENDUES.
145
+ async function purgerMessagesPresentation(slug, opts, retirer, base) {
146
+ const { dryRun, taille, plafond } = opts;
147
+ let supprimees = 0, fichiers = 0, fichiersErreur = 0, examinees = 0, tronque = false, curseur = null;
148
+ for (;;) {
149
+ const reste = plafond - examinees;
150
+ if (reste <= 0) { tronque = await resteEncore("doc_presentation_messages", `slug=eq.${enc(slug)}`, "id", curseur, dryRun); break; }
151
+ const limite = Math.min(taille, reste);
152
+ const borneCur = curseur != null ? `&id=gt.${enc(String(curseur))}` : "";
153
+ const lot = await PLAYER.db.request(`doc_presentation_messages?slug=eq.${enc(slug)}${borneCur}&select=id,attachment&order=id.asc&limit=${limite}`);
154
+ if (!Array.isArray(lot) || !lot.length) break;
155
+ examinees += lot.length;
156
+ curseur = lot[lot.length - 1].id;
157
+ if (!dryRun) {
158
+ if (retirer) {
159
+ for (const j of lot) {
160
+ const url = j.attachment && (typeof j.attachment === "object" ? j.attachment.url : j.attachment);
161
+ const chemin = cheminSurSlug(url, slug, base);
162
+ if (!chemin) continue; // hors du dossier du slug → jamais supprimé (barrière 2)
163
+ try { if (await retirer("present-attachments", chemin)) fichiers += 1; else fichiersErreur += 1; } // false = échec compté aussi
164
+ catch { fichiersErreur += 1; }
165
+ }
166
+ }
167
+ const ids = lot.map((r) => r.id).filter((v) => v != null).map(guill);
168
+ if (ids.length) {
169
+ const del = await PLAYER.db.request(`doc_presentation_messages?id=in.(${ids.join(",")})&select=id`, { method: "DELETE", headers: { Prefer: "return=representation" } });
170
+ supprimees += Array.isArray(del) ? del.length : 0;
171
+ }
172
+ }
173
+ if (lot.length < limite) break;
174
+ }
175
+ return { supprimees, fichiers, fichiersErreur, examinees, tronque };
37
176
  }
38
177
 
39
- async function purgerRetention(now) {
40
- const f = fenetres();
41
- const efface = {};
178
+ async function purgerRetention(now, optsBrutes = {}) {
179
+ let f, opts;
180
+ try { f = fenetresValidees(); opts = optionsValidees(optsBrutes); }
181
+ catch (e) {
182
+ if (!e.retentionInvalide) throw e;
183
+ try { PLAYER.errors.capture(e, { route: "retention" }); } catch { /* jamais bloquant */ }
184
+ return { ok: false, error: e.message }; // config OU option douteuse → zéro DELETE
185
+ }
186
+ const base = String((PLAYER.config && PLAYER.config.supabaseUrl) || "");
187
+ const rapport = {};
42
188
  const bJournaux = borne(now, f.journauxMois);
43
189
 
44
- efface.commercial_doc_views = await effacer(`commercial_doc_views?at=lt.${enc(bJournaux)}&select=id`);
45
- efface.commercial_doc_sessions = await effacer(`commercial_doc_sessions?last_at=lt.${enc(bJournaux)}&select=session_id`);
46
- efface.commercial_doc_internal_sessions = await effacer(`commercial_doc_internal_sessions?last_at=lt.${enc(bJournaux)}&select=session_id`);
47
- efface.doc_bot_sessions = await effacer(`doc_bot_sessions?last_at=lt.${enc(bJournaux)}&select=id`);
48
- efface.player_rate_limits = await effacer(`player_rate_limits?expires_at=lt.${enc(new Date(now).toISOString())}&select=key`);
190
+ rapport.commercial_doc_views = await purgerParLots("commercial_doc_views", `at=lt.${enc(bJournaux)}`, "id", opts);
191
+ rapport.commercial_doc_sessions = await purgerParLots("commercial_doc_sessions", `last_at=lt.${enc(bJournaux)}`, "session_id", opts);
192
+ rapport.commercial_doc_internal_sessions = await purgerParLots("commercial_doc_internal_sessions", `last_at=lt.${enc(bJournaux)}`, "session_id", opts);
193
+ rapport.doc_bot_sessions = await purgerParLots("doc_bot_sessions", `last_at=lt.${enc(bJournaux)}`, "id", opts);
194
+ rapport.player_rate_limits = await purgerParLots("player_rate_limits", `expires_at=lt.${enc(new Date(now).toISOString())}`, "key", opts);
49
195
 
50
- // Liens révoqués : seulement là où la révocation est DATÉE (0013) — sans la colonne, la purge
51
- // resterait muette plutôt que d'inventer une borne depuis l'âge du lien.
196
+ // Liens révoqués : seulement là où la révocation est DATÉE (0013).
52
197
  const dateDispo = await require("./schema").attendue("revocationDatee");
53
- efface.commercial_doc_shares = dateDispo
54
- ? await effacer(`commercial_doc_shares?revoked=eq.true&revoked_at=lt.${enc(borne(now, f.liensRevoquesMois))}&select=slug`)
55
- : 0;
198
+ rapport.commercial_doc_shares = dateDispo
199
+ ? await purgerParLots("commercial_doc_shares", `revoked=eq.true&revoked_at=lt.${enc(borne(now, f.liensRevoquesMois))}`, "slug", opts)
200
+ : { examinees: 0, supprimees: 0, tronque: false };
56
201
 
57
- // Présentations mortes : la ligne, ses messages, ses présences — et les fichiers du bucket si
58
- // l'hôte fournit storage.remove (capacité OPTIONNELLE : sans elle, la limite est écrite dans
59
- // RETENTION.md plutôt que simulée ici).
202
+ // Présentations mortes : bornées à PLAFOND_PRESENTATIONS par exécution ; la ligne, ses messages,
203
+ // ses présences — et les fichiers du bucket si storage.remove est fourni (OPTIONNELLE).
60
204
  const bPres = borne(now, f.presentationsMois);
61
- const mortes = await PLAYER.db.request(`doc_presentations?active=eq.false&updated_at=lt.${enc(bPres)}&select=slug`);
62
- efface.doc_presentations = 0; efface.doc_presentation_messages = 0;
63
- efface.doc_presentation_attendees = 0; efface.pieces_jointes = 0;
205
+ const mortes = await PLAYER.db.request(`doc_presentations?active=eq.false&updated_at=lt.${enc(bPres)}&select=slug&order=slug.asc&limit=${PLAFOND_PRESENTATIONS}`);
206
+ const presRapport = { examinees: 0, supprimees: 0, messages: 0, presences: 0, fichiers: 0, fichiersErreur: 0, tronque: Array.isArray(mortes) && mortes.length >= PLAFOND_PRESENTATIONS };
64
207
  const retirer = PLAYER.storage && typeof PLAYER.storage.remove === "function" ? PLAYER.storage.remove.bind(PLAYER.storage) : null;
65
208
  for (const p of (Array.isArray(mortes) ? mortes : [])) {
66
209
  const slug = p && p.slug; if (!slug) continue;
67
- if (retirer) {
68
- const jointes = await PLAYER.db.request(`doc_presentation_messages?slug=eq.${enc(slug)}&attachment=not.is.null&select=attachment`);
69
- for (const j of (Array.isArray(jointes) ? jointes : [])) {
70
- const chemin = cheminPieceJointe(j && j.attachment);
71
- if (!chemin) continue;
72
- try { if (await retirer("present-attachments", chemin)) efface.pieces_jointes += 1; } catch { /* le fichier survit, la ligne part quand même — limite dite */ }
73
- }
210
+ presRapport.examinees += 1;
211
+ // Messages : lot borné qui lit id ET attachment ENSEMBLE (la lecture des pièces jointes n'est
212
+ // plus une requête globale non bornée — P2), retire les fichiers du lot, puis supprime le lot.
213
+ const msgs = await purgerMessagesPresentation(slug, opts, retirer, base);
214
+ presRapport.messages += msgs.supprimees;
215
+ presRapport.fichiers += msgs.fichiers;
216
+ presRapport.fichiersErreur += msgs.fichiersErreur;
217
+ const pres = opts.dryRun ? { supprimees: 0, tronque: false } : await purgerParLots("doc_presentation_attendees", `slug=eq.${enc(slug)}`, "attendee_key", opts);
218
+ presRapport.presences += pres.supprimees;
219
+ // ⚠️ LA PRÉSENTATION N'EST SUPPRIMÉE QUE SI TOUS SES ENFANTS SONT PARTIS (P2 neuvième audit) :
220
+ // sinon 5 000 messages partent, le parent aussi, et 1 000 orphelins restent que le balayage
221
+ // suivant ne peut plus rattacher à leur présentation. Tronqués → on garde le parent inactif
222
+ // pour le passage suivant.
223
+ if (!opts.dryRun && !msgs.tronque && !pres.tronque) {
224
+ presRapport.supprimees += (await purgerParLots("doc_presentations", `slug=eq.${enc(slug)}`, "slug", opts)).supprimees;
74
225
  }
75
- efface.doc_presentation_messages += await effacer(`doc_presentation_messages?slug=eq.${enc(slug)}&select=id`);
76
- efface.doc_presentation_attendees += await effacer(`doc_presentation_attendees?slug=eq.${enc(slug)}&select=attendee_key`);
77
- efface.doc_presentations += await effacer(`doc_presentations?slug=eq.${enc(slug)}&select=slug`);
78
226
  }
79
- return { ok: true, efface };
227
+ rapport.presentations = presRapport;
228
+
229
+ // `efface` : l'ANCIENNE forme (table → nombre supprimé), dérivée du rapport — les appelants et
230
+ // essais existants continuent de lire `r.efface.commercial_doc_views`. Le rapport détaillé vit
231
+ // à côté, sous `r.rapport`.
232
+ const efface = {
233
+ commercial_doc_views: rapport.commercial_doc_views.supprimees,
234
+ commercial_doc_sessions: rapport.commercial_doc_sessions.supprimees,
235
+ commercial_doc_internal_sessions: rapport.commercial_doc_internal_sessions.supprimees,
236
+ doc_bot_sessions: rapport.doc_bot_sessions.supprimees,
237
+ player_rate_limits: rapport.player_rate_limits.supprimees,
238
+ commercial_doc_shares: rapport.commercial_doc_shares.supprimees,
239
+ doc_presentations: presRapport.supprimees,
240
+ doc_presentation_messages: presRapport.messages,
241
+ doc_presentation_attendees: presRapport.presences,
242
+ pieces_jointes: presRapport.fichiers,
243
+ };
244
+ return { ok: true, dryRun: !!opts.dryRun, efface, rapport };
80
245
  }
81
246
 
82
247
  // Balayage opportuniste : au plus UN par fenêtre de 24 h (le verrou est le compteur de débit
@@ -99,4 +264,4 @@ function tick() {
99
264
  .catch((e) => { try { PLAYER.errors.capture(e, { route: "retention", benin: true }); } catch { /* jamais bloquant */ } });
100
265
  }
101
266
 
102
- module.exports = { init, purgerRetention, tick, cheminPieceJointe };
267
+ module.exports = { init, purgerRetention, tick, borne };
@@ -32,7 +32,14 @@ async function traiter(req, res, body, slug) {
32
32
  let admin = false;
33
33
  if (!hote) { const u = await PLAYER.identity.verifyToken(req.headers.authorization); admin = !!(u && PLAYER.identity.isAdmin(u)); }
34
34
  if (!hote && !admin) return jd(403, { ok: false, error: "retention.run : hôte de confiance ou admin requis" });
35
- return jd(200, await require("./retention").purgerRetention(Date.now()));
35
+ // ⚠️ LES OPTIONS TRAVERSENT LA ROUTE (P1 neuvième audit) — dryRun compris, sinon la
36
+ // purge « à blanc » supprimait pour de vrai. La validation vit dans purgerRetention
37
+ // (avant tout DELETE) ; un refus (ok:false + error) devient un 400, pas un 200.
38
+ // Tout le corps SAUF `action` est passé au validateur : une clé inconnue (faute de
39
+ // frappe d'option) est ainsi refusée, pas ignorée en silence.
40
+ const { action: _a, ...opts } = body;
41
+ const resultat = await require("./retention").purgerRetention(Date.now(), opts);
42
+ return jd(resultat.ok === false ? 400 : 200, resultat);
36
43
  } catch (e) { try { PLAYER.errors.capture(e, { route: "retention" }); } catch { /* jamais bloquant */ } return jd(500, { ok: false }); }
37
44
  }
38
45
  if (String(body.action || "").startsWith("docshare.")) {
@@ -326,6 +333,20 @@ async function traiter(req, res, body, slug) {
326
333
  // jamais.
327
334
  return j(200, { ok: true, slug: out.slug, sent, ...(refusEnvoi ? { sendRefused: refusEnvoi } : {}) });
328
335
  }
336
+ // ⚠️ CE REPLI NE COUVRE QUE LES ÉVÉNEMENTS ANALYTIQUES (P2 huitième audit). Une action POST
337
+ // qu'aucune famille n'a reconnue tombait ici et repartait `{"ok":true}` — une faute de
338
+ // frappe (`present-pgae`) passait pour un succès, parfois même journalisée. Un `action`
339
+ // présent mais inconnu est donc REFUSÉ ; sans action, il faut un `event` autorisé. Jamais un
340
+ // événement absent ou invalide transformé en ouverture.
341
+ const EVENEMENTS = new Set(["open", "page", "heartbeat", "session"]);
342
+ if (typeof body.action === "string" && body.action.trim()) {
343
+ res.statusCode = 400; res.setHeader("Content-Type", "application/json"); res.end('{"ok":false,"error":"unknown-action"}');
344
+ return true;
345
+ }
346
+ if (!EVENEMENTS.has(body.event)) {
347
+ res.statusCode = 400; res.setHeader("Content-Type", "application/json"); res.end('{"ok":false,"error":"bad-event"}');
348
+ return true;
349
+ }
329
350
  const ua0 = req.headers["user-agent"];
330
351
  const ip0 = adresseAppelant(req);
331
352
  // ⚠️ CONSULTATION INTERNE : L'IDENTITÉ EST AFFIRMÉE PAR LE NAVIGATEUR, PAS PROUVÉE.
@@ -443,9 +464,11 @@ async function traiter(req, res, body, slug) {
443
464
  }
444
465
  res.statusCode = 200; res.setHeader("Content-Type", "application/json"); res.end('{"ok":true}');
445
466
  return;
446
- // ⚠️ PAS de `return false` ici : le bloc ci-dessus est le REPLI POST d'origine — toute action
447
- // qu'aucune famille n'a reconnue finit {"ok":true}, comme avant l'extraction. Cette famille
448
- // répond donc TOUJOURS, et doit rester la DERNIÈRE du dispatch (handler le dit aussi).
467
+ // ⚠️ PAS de `return false` ici : le bloc ci-dessus est le repli ANALYTIQUE (open/page/heartbeat/
468
+ // session). Une action inconnue N'Y ARRIVE PLUS — elle est refusée en 400 unknown-action au
469
+ // début de ce repli (neuvième audit : le commentaire disait encore « finit ok:true », c'était
470
+ // faux depuis le huitième). Cette famille répond donc toujours pour un événement valide, et doit
471
+ // rester la DERNIÈRE du dispatch (handler le dit aussi).
449
472
  }
450
473
 
451
474
  module.exports = { init, traiter };
@@ -12,7 +12,7 @@ const init = (ctx) => { PLAYER = ctx; };
12
12
  // chute au bout rend `false` et le dispatch continue. Aucune liste d'actions n'est dupliquée
13
13
  // entre ici et handler (un correctif à deux exemplaires finit par diverger) — et aucun appui
14
14
  // sur res.writableEnded, absent des `res` postiches des bancs comme de certains hôtes.
15
- async function traiter(req, res, body, slug) {
15
+ async function traiter(req, res, body, _slug) {
16
16
  // ── Connexion VISITEUR (soft wall) : demande d'un code par email, puis vérification. ──
17
17
  // Émet un jeton signé posé en cookie qui débloque les contenus gatés (require_auth).
18
18
  if (body.action === "visitor-request" || body.action === "visitor-verify" || body.action === "visitor-google") {
package/supabase/init.sql CHANGED
@@ -78,6 +78,13 @@ comment on column public.commercial_doc_shares.idem_key is
78
78
  create index if not exists cds_parent_idx on public.commercial_doc_shares (parent_slug);
79
79
  create index if not exists idx_doc_shares_brand_key
80
80
  on public.commercial_doc_shares (brand_key) where brand_key is not null;
81
+ -- Filtres de rétention (migration 0014) : mêmes index sur une base vierge.
82
+ -- ⚠️ RÈGLE (sixième audit) : un index sur une colonne apparue APRÈS un init publié est précédé
83
+ -- de son ALTER, ICI MÊME — sur une base d'hier, `create table if not exists` saute le corps de
84
+ -- table et `revoked_at` n'existe qu'au rattrapage de fin de fichier, trop tard pour cet index.
85
+ alter table public.commercial_doc_shares
86
+ add column if not exists revoked_at timestamptz;
87
+ create index if not exists idx_shares_revoked_at on public.commercial_doc_shares (revoked_at) where revoked = true;
81
88
 
82
89
  -- ── Journal des ouvertures (population EXTERNE) ────────────────────────────────────────────────
83
90
  create table if not exists public.commercial_doc_views (
@@ -120,6 +127,7 @@ create table if not exists public.commercial_doc_sessions (
120
127
  );
121
128
  create index if not exists cds_sess_slug_idx on public.commercial_doc_sessions (slug);
122
129
  create index if not exists cds_sess_doc_idx on public.commercial_doc_sessions (doc_id, last_at desc);
130
+ create index if not exists idx_cds_last_at on public.commercial_doc_sessions (last_at);
123
131
 
124
132
  create table if not exists public.commercial_doc_internal_sessions (
125
133
  session_id text primary key,
@@ -250,6 +258,7 @@ create table if not exists public.doc_bot_sessions (
250
258
  journey_step text,
251
259
  etat jsonb
252
260
  );
261
+ create index if not exists idx_bot_last_at on public.doc_bot_sessions (last_at);
253
262
  create index if not exists doc_bot_sessions_share_idx on public.doc_bot_sessions (share_slug);
254
263
 
255
264
  -- ── Limites de débit partagées ─────────────────────────────────────────────────────────────────
@@ -0,0 +1,15 @@
1
+ -- LES FILTRES DE RÉTENTION MÉRITENT LEURS INDEX.
2
+ --
3
+ -- La purge sélectionne par date : `last_at`, `revoked_at`, `expires_at`. Sans index, chaque lot
4
+ -- est un balayage séquentiel — acceptable sur une petite base, coûteux sur un gros historique
5
+ -- (P2 du huitième audit). Trois index, idempotents.
6
+ --
7
+ -- ⚠️ `CREATE INDEX CONCURRENTLY` NE PEUT PAS tourner dans une transaction (et le MCP applique en
8
+ -- transaction) : on pose donc des index ordinaires ici. Sur une installation VOLUMINEUSE déjà en
9
+ -- production, un exploitant préférera les créer à la main en `CONCURRENTLY` hors migration pour
10
+ -- ne pas verrouiller la table en écriture — c'est écrit dans docs/RETENTION.md.
11
+ --
12
+ -- Sans lui : rien ne casse — la purge tourne, plus lentement sur les grosses tables.
13
+ create index if not exists idx_cds_last_at on public.commercial_doc_sessions (last_at);
14
+ create index if not exists idx_bot_last_at on public.doc_bot_sessions (last_at);
15
+ create index if not exists idx_shares_revoked_at on public.commercial_doc_shares (revoked_at) where revoked = true;
@@ -9,21 +9,30 @@
9
9
  -- Usage : psql -f supabase/recensement-retention.sql — chaque ligne rend « table|restantes ».
10
10
  -- Après une purge, tout doit être à zéro ; un reste est un mensonge de la purge OU un trou de
11
11
  -- son périmètre. La forge l'exécute à chaque course, après le banc base.
12
+ --
13
+ -- ⚠️ FENÊTRES PARAMÉTRABLES (neuvième audit) : si un hôte configure d'autres durées via
14
+ -- `config.retention`, le recensement doit contrôler LA MÊME politique, pas les défauts figés.
15
+ -- Les variables psql ci-dessous portent les défauts de RETENTION.md ; passez-les à l'appel pour
16
+ -- une configuration non standard, ex. :
17
+ -- psql -v journaux_mois=6 -v presentations_mois=3 -v liens_mois=6 -f supabase/recensement-retention.sql
18
+ \if :{?journaux_mois}\else \set journaux_mois 13 \endif
19
+ \if :{?presentations_mois}\else \set presentations_mois 12 \endif
20
+ \if :{?liens_mois}\else \set liens_mois 13 \endif
12
21
  select 'commercial_doc_views' as perimetre, count(*) as restantes
13
22
  from public.commercial_doc_views
14
- where at < now() - interval '13 months'
23
+ where at < now() - make_interval(months => :journaux_mois)
15
24
  union all
16
25
  select 'commercial_doc_sessions', count(*)
17
26
  from public.commercial_doc_sessions
18
- where last_at < now() - interval '13 months'
27
+ where last_at < now() - make_interval(months => :journaux_mois)
19
28
  union all
20
29
  select 'commercial_doc_internal_sessions', count(*)
21
30
  from public.commercial_doc_internal_sessions
22
- where last_at < now() - interval '13 months'
31
+ where last_at < now() - make_interval(months => :journaux_mois)
23
32
  union all
24
33
  select 'doc_bot_sessions', count(*)
25
34
  from public.doc_bot_sessions
26
- where last_at < now() - interval '13 months'
35
+ where last_at < now() - make_interval(months => :journaux_mois)
27
36
  union all
28
37
  select 'player_rate_limits', count(*)
29
38
  from public.player_rate_limits
@@ -31,11 +40,11 @@ select 'player_rate_limits', count(*)
31
40
  union all
32
41
  select 'commercial_doc_shares_revoques', count(*)
33
42
  from public.commercial_doc_shares
34
- where revoked and revoked_at < now() - interval '13 months'
43
+ where revoked and revoked_at < now() - make_interval(months => :liens_mois)
35
44
  union all
36
45
  select 'doc_presentations_mortes', count(*)
37
46
  from public.doc_presentations
38
- where not active and updated_at < now() - interval '12 months'
47
+ where not active and updated_at < now() - make_interval(months => :presentations_mois)
39
48
  union all
40
49
  -- Messages et présences : orphelins d'une présentation morte-ancienne OU d'aucune présentation —
41
50
  -- formulé par NOT EXISTS, pas par la liste de slugs que la purge a construite.
@@ -44,11 +53,11 @@ select 'doc_presentation_messages_orphelins', count(*)
44
53
  where not exists (
45
54
  select 1 from public.doc_presentations p
46
55
  where p.slug = m.slug
47
- and (p.active or p.updated_at >= now() - interval '12 months'))
56
+ and (p.active or p.updated_at >= now() - make_interval(months => :presentations_mois)))
48
57
  union all
49
58
  select 'doc_presentation_attendees_orphelins', count(*)
50
59
  from public.doc_presentation_attendees a
51
60
  where not exists (
52
61
  select 1 from public.doc_presentations p
53
62
  where p.slug = a.slug
54
- and (p.active or p.updated_at >= now() - interval '12 months'));
63
+ and (p.active or p.updated_at >= now() - make_interval(months => :presentations_mois)));