discovery-media-player 0.1.77 → 0.1.78

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.78",
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,150 @@ 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 ISOles 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
+ }
27
44
 
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;
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();
31
55
  }
32
56
 
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;
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
+
61
+ // Une valeur pour `id=in.(…)` : double-guillemets, guillemet et antislash internes échappés —
62
+ // PostgREST exige le guillemetage dès qu'une valeur porte un caractère réservé (`:` d'une clé de
63
+ // débit, par exemple). Guillemeter TOUJOURS est correct et évite d'avoir à deviner.
64
+ const guill = (v) => '"' + String(v).replace(/\\/g, "\\\\").replace(/"/g, '\\"') + '"';
65
+
66
+ // ⚠️ PURGE PAR LOTS BORNÉS (P2 huitième audit). On SÉLECTIONNE un lot d'identifiants (borné,
67
+ // ordonné par la colonne de date), on les supprime par `id=in.(…)`, on recommence — jamais un
68
+ // DELETE non borné qui ramènerait tout l'historique d'un coup (mémoire, WAL, verrous, timeout).
69
+ // `dryRun` sélectionne sans supprimer. Le rapport remplace la liste d'identifiants : examinées,
70
+ // supprimées, tronqué (il reste à faire au prochain passage).
71
+ async function purgerParLots(table, filtre, colId, { dryRun = false, taille = LOT, plafond = PLAFOND } = {}) {
72
+ let examinees = 0, supprimees = 0;
73
+ const maxTours = Math.max(1, Math.ceil(plafond / taille));
74
+ let tours = 0, tronque = false;
75
+ for (;;) {
76
+ if (tours >= maxTours) { tronque = true; break; }
77
+ tours += 1;
78
+ const lot = await PLAYER.db.request(`${table}?${filtre}&select=${colId}&order=${colId}.asc&limit=${taille}`);
79
+ if (!Array.isArray(lot) || !lot.length) break;
80
+ examinees += lot.length;
81
+ if (!dryRun) {
82
+ const ids = lot.map((r) => r[colId]).filter((v) => v != null).map(guill);
83
+ if (ids.length) {
84
+ await PLAYER.db.request(`${table}?${colId}=in.(${ids.join(",")})`, { method: "DELETE", headers: { Prefer: "return=minimal" } });
85
+ supprimees += ids.length;
86
+ }
87
+ }
88
+ if (lot.length < taille) break; // dernier lot
89
+ if (dryRun) { tronque = tours >= maxTours; if (tours >= maxTours) break; }
90
+ }
91
+ return { examinees, supprimees, tronque };
37
92
  }
38
93
 
39
- async function purgerRetention(now) {
40
- const f = fenetres();
41
- const efface = {};
94
+ // ⚠️ REVALIDATION À LA SUPPRESSION — le MÊME validateur que l'écriture (server/presentations.js),
95
+ // avec le slug de la présentation purgée. Une validation d'écriture n'est jamais la seule barrière
96
+ // d'un delete : les lignes déjà en base d'avant le correctif peuvent porter une URL piégée.
97
+ const { cheminPieceJointe: cheminSurSlug } = require("./presentations");
98
+
99
+ async function purgerRetention(now, opts = {}) {
100
+ let f;
101
+ try { f = fenetresValidees(); }
102
+ catch (e) {
103
+ if (!e.retentionInvalide) throw e;
104
+ try { PLAYER.errors.capture(e, { route: "retention" }); } catch { /* jamais bloquant */ }
105
+ return { ok: false, error: e.message }; // config douteuse → zéro DELETE
106
+ }
107
+ const base = String((PLAYER.config && PLAYER.config.supabaseUrl) || "");
108
+ const rapport = {};
42
109
  const bJournaux = borne(now, f.journauxMois);
43
110
 
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`);
111
+ rapport.commercial_doc_views = await purgerParLots("commercial_doc_views", `at=lt.${enc(bJournaux)}`, "id", opts);
112
+ rapport.commercial_doc_sessions = await purgerParLots("commercial_doc_sessions", `last_at=lt.${enc(bJournaux)}`, "session_id", opts);
113
+ rapport.commercial_doc_internal_sessions = await purgerParLots("commercial_doc_internal_sessions", `last_at=lt.${enc(bJournaux)}`, "session_id", opts);
114
+ rapport.doc_bot_sessions = await purgerParLots("doc_bot_sessions", `last_at=lt.${enc(bJournaux)}`, "id", opts);
115
+ rapport.player_rate_limits = await purgerParLots("player_rate_limits", `expires_at=lt.${enc(new Date(now).toISOString())}`, "key", opts);
49
116
 
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.
117
+ // Liens révoqués : seulement là où la révocation est DATÉE (0013).
52
118
  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;
119
+ rapport.commercial_doc_shares = dateDispo
120
+ ? await purgerParLots("commercial_doc_shares", `revoked=eq.true&revoked_at=lt.${enc(borne(now, f.liensRevoquesMois))}`, "slug", opts)
121
+ : { examinees: 0, supprimees: 0, tronque: false };
56
122
 
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).
123
+ // Présentations mortes : bornées à PLAFOND_PRESENTATIONS par exécution ; la ligne, ses messages,
124
+ // ses présences et les fichiers du bucket si storage.remove est fourni (OPTIONNELLE).
60
125
  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;
126
+ const mortes = await PLAYER.db.request(`doc_presentations?active=eq.false&updated_at=lt.${enc(bPres)}&select=slug&order=slug.asc&limit=${PLAFOND_PRESENTATIONS}`);
127
+ const presRapport = { examinees: 0, supprimees: 0, messages: 0, presences: 0, fichiers: 0, fichiersErreur: 0, tronque: Array.isArray(mortes) && mortes.length >= PLAFOND_PRESENTATIONS };
64
128
  const retirer = PLAYER.storage && typeof PLAYER.storage.remove === "function" ? PLAYER.storage.remove.bind(PLAYER.storage) : null;
65
129
  for (const p of (Array.isArray(mortes) ? mortes : [])) {
66
130
  const slug = p && p.slug; if (!slug) continue;
67
- if (retirer) {
131
+ presRapport.examinees += 1;
132
+ if (retirer && !opts.dryRun) {
68
133
  const jointes = await PLAYER.db.request(`doc_presentation_messages?slug=eq.${enc(slug)}&attachment=not.is.null&select=attachment`);
69
134
  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 */ }
135
+ const url = j && j.attachment && (typeof j.attachment === "object" ? j.attachment.url : j.attachment);
136
+ const chemin = cheminSurSlug(url, slug, base);
137
+ if (!chemin) continue; // hors du dossier du slug jamais supprimé (barrière 2)
138
+ try { if (await retirer("present-attachments", chemin)) presRapport.fichiers += 1; } catch { presRapport.fichiersErreur += 1; /* le fichier survit, la ligne part */ }
73
139
  }
74
140
  }
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`);
141
+ if (!opts.dryRun) {
142
+ presRapport.messages += (await purgerParLots("doc_presentation_messages", `slug=eq.${enc(slug)}`, "id", opts)).supprimees;
143
+ presRapport.presences += (await purgerParLots("doc_presentation_attendees", `slug=eq.${enc(slug)}`, "attendee_key", opts)).supprimees;
144
+ }
145
+ if (!opts.dryRun) {
146
+ presRapport.supprimees += (await purgerParLots("doc_presentations", `slug=eq.${enc(slug)}`, "slug", opts)).supprimees;
147
+ }
78
148
  }
79
- return { ok: true, efface };
149
+ rapport.presentations = presRapport;
150
+
151
+ // `efface` : l'ANCIENNE forme (table → nombre supprimé), dérivée du rapport — les appelants et
152
+ // essais existants continuent de lire `r.efface.commercial_doc_views`. Le rapport détaillé vit
153
+ // à côté, sous `r.rapport`.
154
+ const efface = {
155
+ commercial_doc_views: rapport.commercial_doc_views.supprimees,
156
+ commercial_doc_sessions: rapport.commercial_doc_sessions.supprimees,
157
+ commercial_doc_internal_sessions: rapport.commercial_doc_internal_sessions.supprimees,
158
+ doc_bot_sessions: rapport.doc_bot_sessions.supprimees,
159
+ player_rate_limits: rapport.player_rate_limits.supprimees,
160
+ commercial_doc_shares: rapport.commercial_doc_shares.supprimees,
161
+ doc_presentations: presRapport.supprimees,
162
+ doc_presentation_messages: presRapport.messages,
163
+ doc_presentation_attendees: presRapport.presences,
164
+ pieces_jointes: presRapport.fichiers,
165
+ };
166
+ return { ok: true, dryRun: !!opts.dryRun, efface, rapport };
80
167
  }
81
168
 
82
169
  // Balayage opportuniste : au plus UN par fenêtre de 24 h (le verrou est le compteur de débit
@@ -99,4 +186,4 @@ function tick() {
99
186
  .catch((e) => { try { PLAYER.errors.capture(e, { route: "retention", benin: true }); } catch { /* jamais bloquant */ } });
100
187
  }
101
188
 
102
- module.exports = { init, purgerRetention, tick, cheminPieceJointe };
189
+ module.exports = { init, purgerRetention, tick, borne };
@@ -326,6 +326,20 @@ async function traiter(req, res, body, slug) {
326
326
  // jamais.
327
327
  return j(200, { ok: true, slug: out.slug, sent, ...(refusEnvoi ? { sendRefused: refusEnvoi } : {}) });
328
328
  }
329
+ // ⚠️ CE REPLI NE COUVRE QUE LES ÉVÉNEMENTS ANALYTIQUES (P2 huitième audit). Une action POST
330
+ // qu'aucune famille n'a reconnue tombait ici et repartait `{"ok":true}` — une faute de
331
+ // frappe (`present-pgae`) passait pour un succès, parfois même journalisée. Un `action`
332
+ // présent mais inconnu est donc REFUSÉ ; sans action, il faut un `event` autorisé. Jamais un
333
+ // événement absent ou invalide transformé en ouverture.
334
+ const EVENEMENTS = new Set(["open", "page", "heartbeat", "session"]);
335
+ if (typeof body.action === "string" && body.action.trim()) {
336
+ res.statusCode = 400; res.setHeader("Content-Type", "application/json"); res.end('{"ok":false,"error":"unknown-action"}');
337
+ return true;
338
+ }
339
+ if (!EVENEMENTS.has(body.event)) {
340
+ res.statusCode = 400; res.setHeader("Content-Type", "application/json"); res.end('{"ok":false,"error":"bad-event"}');
341
+ return true;
342
+ }
329
343
  const ua0 = req.headers["user-agent"];
330
344
  const ip0 = adresseAppelant(req);
331
345
  // ⚠️ CONSULTATION INTERNE : L'IDENTITÉ EST AFFIRMÉE PAR LE NAVIGATEUR, PAS PROUVÉE.
@@ -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;