discovery-media-player 0.1.76 → 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;
@@ -1,5 +1,10 @@
1
1
  # Host contract
2
2
 
3
+ > **This document is an export of the package** — resolve it with
4
+ > `require.resolve("discovery-media-player/contrat")` (and the retention policy with
5
+ > `…/retention`). An exposed path is a promise that survives file reorganizations; reading
6
+ > `node_modules` paths by hand is a guess about our tree, and it broke twice in one day.
7
+
3
8
  What a host application may call, what it must implement, and what will not change without a
4
9
  version bump. If you are integrating the player, this page and [`API.md`](API.md) are the two you
5
10
  need.
@@ -26,7 +31,7 @@ need.
26
31
  "product": "discovery-media-player",
27
32
  "contract": 1,
28
33
  "version": "<the running version>",
29
- "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"],
30
35
  "frameAncestors": ["'self'", "https://*.vercel.app", "https://app.example.com"],
31
36
  "separateIssuer": true,
32
37
  "internalStrict": true,
@@ -0,0 +1,136 @@
1
+ # Rétention des données
2
+
3
+ Ce document est le **périmètre déclaré** de la rétention : chaque colonne du schéma dont la forme
4
+ peut porter une donnée personnelle y a une politique — *purgée après N* ou *conservée parce que*.
5
+ Une garde de forge énumère les colonnes du schéma **vivant** (`information_schema`, jamais notre
6
+ mémoire du fichier) et refuse toute colonne à forme personnelle absente d'ici : une donnée sans
7
+ politique écrite ne peut pas entrer dans le schéma sans rougir.
8
+
9
+ Le contrat de vérification a deux moitiés, volontairement **indépendantes** (aucun code partagé —
10
+ ni fonction de périmètre, ni filtre) :
11
+
12
+ 1. la **purge** (`server/retention.js`) déclare ce qu'elle a effacé, compte par compte ;
13
+ 2. le **recensement** (`supabase/recensement-retention.sql`, SQL nu) compte ce qui reste dans le
14
+ périmètre revendiqué. Les deux nombres doivent se contredire si l'un ment.
15
+
16
+ > ⚠️ **Fenêtres proposées, à valider par l'exploitant.** Les durées ci-dessous sont des défauts
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` — **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).
23
+ >
24
+ > ⚠️ **Le balayage automatique est OPT-IN STRICT** : il ne tourne que si l'hôte écrit
25
+ > `config.retention.balayage: true`. Un hôte qui consomme le contexte autonome tel quel hérite de
26
+ > toutes ses capacités par défaut — « rien à brancher parce que rien n'a été débranché » — et une
27
+ > suppression est une décision métier : elle n'agit que là où un exploitant l'a écrite. L'action
28
+ > `retention.run` (hôte de confiance ou admin) reste disponible sans opt-in : l'appeler EST la
29
+ > décision.
30
+
31
+ ## Journaux de lecture (population externe)
32
+
33
+ Finalité : statistiques de lecture d'un document envoyé. **Purge : 13 mois** après l'événement.
34
+
35
+ | colonne | contenu | sort |
36
+ |---|---|---|
37
+ | `commercial_doc_views.recipient_email` | à qui la lecture est attribuée | purgée avec la ligne, 13 mois après `at` |
38
+ | `commercial_doc_views.session_id` | corrèle les vues d'une session | idem |
39
+ | `commercial_doc_views.ua` | navigateur (User-Agent brut) | idem |
40
+ | `commercial_doc_sessions.recipient_email` | attribution de la session | purgée avec la ligne, 13 mois après `last_at` |
41
+ | `commercial_doc_sessions.session_id` | identifiant de session | idem |
42
+ | `commercial_doc_sessions.ip` | **adresse IP en clair** | idem — c'est la donnée la plus sensible du schéma |
43
+ | `commercial_doc_sessions.ua` | User-Agent brut | idem |
44
+ | `commercial_doc_sessions.num_pages` / `commercial_doc_sessions.pages_time` | comportement de lecture page par page | idem |
45
+
46
+ ## Journaux de lecture (équipe interne)
47
+
48
+ Même finalité, population interne. **Purge : 13 mois** après `last_at`.
49
+
50
+ | colonne | sort |
51
+ |---|---|
52
+ | `commercial_doc_internal_sessions.user_email` / `commercial_doc_internal_sessions.user_name` | purgées avec la ligne |
53
+ | `commercial_doc_internal_sessions.session_id` | idem |
54
+ | `commercial_doc_internal_sessions.num_pages` / `commercial_doc_internal_sessions.pages_time` | idem |
55
+
56
+ ## Liens d'envoi (`commercial_doc_shares`)
57
+
58
+ Un lien **vivant** est un enregistrement métier : ses champs restent tant que l'URL distribuée
59
+ doit fonctionner. Un lien **révoqué** ne sert plus personne : **purge 13 mois après révocation**
60
+ (alignée sur les journaux, qui référencent son slug). La révocation est **datée** par
61
+ `commercial_doc_shares.revoked_at` (migration 0013) ; les révoqués d'avant la colonne ont reçu la
62
+ date de la migration — leur horloge démarre là, compter large plutôt qu'inventer. Sans la
63
+ colonne, cette purge-là se tait (sonde de schéma), les autres tournent.
64
+
65
+ | colonne | contenu | sort |
66
+ |---|---|---|
67
+ | `commercial_doc_shares.recipient_email` | qui peut expédier au repartage | conservée tant que le lien vit ; ligne purgée 13 mois après révocation |
68
+ | `commercial_doc_shares.attested_recipient_email` | à qui l'hôte atteste le lien | idem |
69
+ | `commercial_doc_shares.recipient_name` | nom du destinataire | idem |
70
+ | `commercial_doc_shares.created_by` | email du commercial créateur | idem |
71
+ | `commercial_doc_shares.file_name` | nom du fichier (peut porter un nom de personne) | métier, purgé avec la ligne |
72
+
73
+ ## Présentations en direct
74
+
75
+ Une présentation **inactive** (terminée ou abandonnée) est une archive : **purge 12 mois après
76
+ `updated_at`** — la présentation, ses messages, ses présences, et ses pièces jointes du bucket
77
+ `present-attachments` (si l'hôte fournit `storage.remove`, sinon la limite est dite ci-dessous).
78
+
79
+ | colonne | contenu | sort |
80
+ |---|---|---|
81
+ | `doc_presentations.presenter_name` / `doc_presentations.owner_name` | identité du présentateur | purgées avec la ligne, 12 mois après la fin |
82
+ | `doc_presentations.owner_email` / `doc_presentations.owner_user_id` | propriétaire | idem |
83
+ | `doc_presentations.owner_avatar` | URL d'avatar | idem |
84
+ | `doc_presentations.control_hash` | empreinte du jeton de contrôle (pas le jeton) | idem |
85
+ | `doc_presentations.content` | contenu partagé (cartes, médias) | idem |
86
+ | `doc_presentations.file_name` | nom du fichier présenté | idem |
87
+ | `doc_presentation_messages.author_name` / `doc_presentation_messages.author_email` / `doc_presentation_messages.author_avatar` | identité de l'auteur | purgées avec la présentation |
88
+ | `doc_presentation_messages.author_hash` | empreinte du jeton d'auteur | idem |
89
+ | `doc_presentation_messages.body` | corps du message | idem |
90
+ | `doc_presentation_messages.reply_name` / `doc_presentation_messages.reply_text` | citation d'un autre message | idem |
91
+ | `doc_presentation_messages.attachment` | URL de pièce jointe | idem — fichier du bucket inclus quand `storage.remove` existe |
92
+ | `doc_presentation_messages.client_key` | clé d'idempotence d'envoi | idem |
93
+ | `doc_presentation_attendees.name` / `doc_presentation_attendees.email` / `doc_presentation_attendees.avatar` | identité du participant | purgées avec la présentation |
94
+ | `doc_presentation_attendees.attendee_key` | identifiant de présence | idem |
95
+ | `doc_presentation_attendees.pages` | pages vues par le participant | idem |
96
+
97
+ ## Sessions d'agent (`doc_bot_sessions`)
98
+
99
+ Parcours guidé par l'agent : **purge 13 mois** après `last_at`.
100
+
101
+ | colonne | sort |
102
+ |---|---|
103
+ | `doc_bot_sessions.rating` / `doc_bot_sessions.rating_comment` | avis du visiteur — purgés avec la ligne |
104
+ | `doc_bot_sessions.in_tokens` / `doc_bot_sessions.out_tokens` / `doc_bot_sessions.cache_tokens` | volumétrie IA (pas personnelle, mais portée par la ligne) — purgés avec elle |
105
+
106
+ ## Limites de débit (`player_rate_limits`)
107
+
108
+ | colonne | contenu | sort |
109
+ |---|---|---|
110
+ | `player_rate_limits.key` | peut contenir une **IP en clair** (`hshare:<ip>`) ou un email | ligne purgée dès `expires_at` dépassé (opportuniste, à chaque passage) |
111
+
112
+ ## Limites dites plutôt que tues
113
+
114
+ - **Pièces jointes orphelines** : la purge des lignes n'efface le fichier du bucket que si le
115
+ contexte hôte fournit `storage.remove` (capacité optionnelle). Sans elle, l'URL devient
116
+ introuvable depuis le produit mais l'objet survit dans le bucket — c'est dit ici plutôt que
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).
128
+ - **Le recensement ne tourne pas tout seul en production** : c'est un SQL qu'un exploitant lance
129
+ (et que la forge exécute à chaque course sur une base réelle vieillie artificiellement).
130
+ - **« Ce qui existe » a une profondeur temporelle qu'`information_schema` n'a pas** (question du
131
+ second hôte, sans réponse mécanique) : une colonne supprimée du schéma sort du périmètre des
132
+ deux textes, mais sa donnée peut survivre dans un dump, une sauvegarde ou une table d'archive.
133
+ Ce contrat couvre la BASE VIVANTE ; les copies (sauvegardes, exports, dumps de migration) sont
134
+ le périmètre de l'exploitant, nommé ici plutôt que simulé. Corollaire opératoire : supprimer
135
+ une colonne à donnée personnelle est un acte de rétention — sa ligne quitte ce document dans
136
+ le même commit, et les copies antérieures suivent la politique de sauvegarde de l'hôte.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "discovery-media-player",
3
- "version": "0.1.76",
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",
@@ -36,6 +36,7 @@
36
36
  "LICENSE",
37
37
  "LICENSE-MIT",
38
38
  "docs/HOST-CONTRACT.md",
39
+ "docs/RETENTION.md",
39
40
  "!**/__tests__"
40
41
  ],
41
42
  "exports": {
@@ -43,6 +44,8 @@
43
44
  "./shares": "./server/shares.js",
44
45
  "./presentations": "./server/presentations.js",
45
46
  "./brands": "./server/brands.js",
47
+ "./contrat": "./docs/HOST-CONTRACT.md",
48
+ "./retention": "./docs/RETENTION.md",
46
49
  "./context/standalone": "./context/standalone.js",
47
50
  "./context/storage": "./context/storage.js",
48
51
  "./bridge": {
@@ -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;