discovery-media-player 0.1.69 → 0.1.71

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.
@@ -269,6 +269,18 @@ function createStandaloneContext(env = process.env) {
269
269
  }),
270
270
  // Écriture de fichier : hors périmètre d'une instance autonome (elle sert, elle ne range pas).
271
271
  async put() { throw new Error("storage.put n'est pas disponible sans câblage d'hôte"); },
272
+ // Optionnelle (rétention) : efface un objet du bucket — faux si l'API Storage est absente.
273
+ async remove(bucket, chemin) {
274
+ const base = sansBarreFinale(env.SUPABASE_URL);
275
+ const cle = String(env.SUPABASE_SERVICE_ROLE_KEY || "");
276
+ if (!base || !cle || !bucket || !chemin) return false;
277
+ try {
278
+ const r = await fetch(`${base}/storage/v1/object/${bucket}/${chemin}`, {
279
+ method: "DELETE", headers: { apikey: cle, Authorization: `Bearer ${cle}` },
280
+ });
281
+ return r.ok;
282
+ } catch { return false; }
283
+ },
272
284
 
273
285
  /**
274
286
  * URL d'envoi signée, pour une pièce jointe de chat. **Capacité de l'HÔTE**, pas du cœur.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "discovery-media-player",
3
- "version": "0.1.69",
3
+ "version": "0.1.71",
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",
@@ -55,12 +55,12 @@
55
55
  "scripts": {
56
56
  "prepare": "node tools/install-hooks.mjs",
57
57
  "start": "node bin/serve.js",
58
- "build": "node build/bundle.mjs && tsc -p tsconfig.build.json && node -e \"import('./build/bundle.mjs').then(m=>m.marquerDistEsm())\"",
58
+ "build": "node -e \"require('fs').existsSync('build/bundle.mjs')||(console.error('Ce script ne vit pas dans le paquet publié - le champ scripts annonce plus que le tarball ne contient. Clonez le depot puis npm ci : https://github.com/Juli1artha/discovery-media-player'),process.exit(1))\" && node build/bundle.mjs && tsc -p tsconfig.build.json && node -e \"import('./build/bundle.mjs').then(m=>m.marquerDistEsm())\"",
59
59
  "test": "node -e \"require('fs').existsSync('server/__tests__')||(console.error('Ce banc ne vit pas dans le paquet publié - le champ scripts annonce plus que le tarball ne contient. Clonez le depot puis npm ci : https://github.com/Juli1artha/discovery-media-player'),process.exit(1))\" && vitest run",
60
60
  "test:watch": "vitest",
61
- "lint": "eslint bin context server src build",
61
+ "lint": "node -e \"require('fs').existsSync('eslint.config.mjs')||(console.error('Ce script ne vit pas dans le paquet publié - le champ scripts annonce plus que le tarball ne contient. Clonez le depot puis npm ci : https://github.com/Juli1artha/discovery-media-player'),process.exit(1))\" && eslint bin context server src build",
62
62
  "lint:fix": "eslint bin context server src build --fix",
63
- "typecheck": "tsc --noEmit",
63
+ "typecheck": "node -e \"require('fs').existsSync('tsconfig.json')||(console.error('Ce script ne vit pas dans le paquet publié - le champ scripts annonce plus que le tarball ne contient. Clonez le depot puis npm ci : https://github.com/Juli1artha/discovery-media-player'),process.exit(1))\" && tsc --noEmit",
64
64
  "prepublishOnly": "npm run build && npm test",
65
65
  "test:e2e": "node -e \"require('fs').existsSync('vitest.e2e.config.mjs')||(console.error('Ce banc ne vit pas dans le paquet publié - le champ scripts annonce plus que le tarball ne contient. Clonez le depot puis npm ci : https://github.com/Juli1artha/discovery-media-player'),process.exit(1))\" && vitest run --config vitest.e2e.config.mjs",
66
66
  "test:base": "node -e \"require('fs').existsSync('vitest.base.config.mjs')||(console.error('Ce banc ne vit pas dans le paquet publié - le champ scripts annonce plus que le tarball ne contient. Clonez le depot puis npm ci : https://github.com/Juli1artha/discovery-media-player'),process.exit(1))\" && vitest run --config vitest.base.config.mjs"
package/server/handler.js CHANGED
@@ -29,6 +29,7 @@ function init(ctx) {
29
29
  PLAYER = ctx;
30
30
  // Le domaine reçoit le même contexte : une seule construction pour tout le player.
31
31
  require("./shares").init(ctx);
32
+ require("./retention").init(ctx);
32
33
  require("./presentations").init(ctx);
33
34
  require("./brands").init(ctx);
34
35
  // ⚠️ Réinitialisé avec le contexte : les réponses de la sonde valent pour UNE base. Un hôte qui
@@ -453,10 +454,21 @@ var Live=(function(){
453
454
  var t=document.getElementById('lModalT'),d=document.getElementById('lModalD'),y=document.getElementById('lModalYes'),n=document.getElementById('lModalNo');
454
455
  if(t)t.textContent=opts.title||'Confirmer ?';if(d){d.textContent=opts.desc||'';d.style.display=opts.desc?'block':'none';}
455
456
  if(y)y.textContent=opts.ok||'Confirmer';
456
- function close(){m.classList.remove('open');if(y)y.onclick=null;if(n)n.onclick=null;m.onclick=null;document.removeEventListener('keydown',key);}
457
- function key(e){if(e.key==='Escape'){close();}else if(e.key==='Enter'){close();if(onOk)onOk();}}
457
+ // ⚠️ UN role=dialog SANS PIÈGE DE FOCUS EST UNE DÉCLARATION SANS EFFET (septième audit) :
458
+ // Tab sortait vers la page derrière, et à la fermeture le focus tombait sur <body> — un
459
+ // utilisateur clavier repartait de zéro. Le piège boucle entre les DEUX boutons, Échap et
460
+ // Entrée existaient déjà, et l'élément qui avait le focus à l'ouverture le RETROUVE.
461
+ var avant=document.activeElement;
462
+ function close(){m.classList.remove('open');if(y)y.onclick=null;if(n)n.onclick=null;m.onclick=null;document.removeEventListener('keydown',key);
463
+ if(avant&&avant.focus)try{avant.focus();}catch(e){}}
464
+ function key(e){if(e.key==='Escape'){close();}else if(e.key==='Enter'){close();if(onOk)onOk();}
465
+ else if(e.key==='Tab'){var f=[n,y].filter(function(x){return x;});if(!f.length)return;e.preventDefault();
466
+ var i=f.indexOf(document.activeElement);var j=e.shiftKey?(i<=0?f.length-1:i-1):(i>=f.length-1?0:i+1);try{f[j].focus();}catch(err){}}}
458
467
  if(y)y.onclick=function(){close();if(onOk)onOk();};if(n)n.onclick=close;m.onclick=function(e){if(e.target===m)close();};
459
468
  document.addEventListener('keydown',key);m.classList.add('open');if(y)try{y.focus();}catch(e){}}
469
+ // Crochet interne (même famille que __presRelireEtat) : le piège de focus ne se PROUVE que
470
+ // piloté clavier dans un vrai navigateur, et la seule voie UI passe par un message à soi.
471
+ try{window.__confirmDialog=confirmDialog;}catch(e){}
460
472
  function history(){fetch('/api/doc?present='+encodeURIComponent(SLUG)+'&chat=1').then(function(r){return r.json();}).then(function(d){var box=document.getElementById('chatMsgs');if(d&&d.messages&&d.messages.length){d.messages.forEach(function(m){addMsg(m);});}else if(box&&!box.children.length){box.innerHTML='<div class=chat-empty>Aucun message. Lancez la discussion.</div>';}if(d&&typeof d.locked!=='undefined')applyLock(d.locked);_histDone=true;}).catch(function(){_histDone=true;});}
461
473
  function react(id,e){if(!ME||!id||!e)return;
462
474
  // ⚠️ ON ENVOIE L'ÉTAT VOULU, PAS « INVERSE ». Basculer n'a de sens qu'une fois : un renvoi
@@ -847,7 +859,7 @@ var Map3DD=(function(){
847
859
  var svb=document.getElementById('mapSV'); if(svb&&!svb._w){svb._w=1;svb.addEventListener('click',function(){ if(!map)return; var c=useG?[map.getCenter().lat(),map.getCenter().lng()]:[map.getCenter().lat,map.getCenter().lng]; goSV(c); });}
848
860
  var tm=document.getElementById('mapToMap'); if(tm&&!tm._w){tm._w=1;tm.addEventListener('click',toMap);} }
849
861
  function doSearch(text){ text=(text||'').trim(); var res=document.getElementById('mapRes'); if(!text||!res)return; res.innerHTML='<div style="padding:9px 14px;color:#888;font-size:12px">Recherche…</div>';
850
- fetch('https://nominatim.openstreetmap.org/search?format=json&limit=5&q='+encodeURIComponent(text),{headers:{Accept:'application/json'}}).then(function(r){return r.json();}).then(function(l){ if(!l||!l.length){res.innerHTML='<div style="padding:9px 14px;color:#888;font-size:12px">Aucun résultat.</div>';return;} res.innerHTML=l.map(function(o){var la=Number(o.lat),lo=Number(o.lon);if(!isFinite(la)||!isFinite(lo))return '';return '<button data-lat="'+la+'" data-lng="'+lo+'">'+String(o.display_name||'').replace(/</g,'&lt;')+'</button>';}).join(''); }).catch(function(){res.innerHTML='<div style="padding:9px 14px;color:#c0392b;font-size:12px">Recherche indisponible.</div>';}); }
862
+ fetch('https://nominatim.openstreetmap.org/search?format=json&limit=5&q='+encodeURIComponent(text),{headers:{Accept:'application/json'}}).then(function(r){return r.json();}).then(function(l){ if(!l||!l.length){res.innerHTML='<div style="padding:9px 14px;color:#888;font-size:12px">Aucun résultat.</div>';return;} res.innerHTML=l.map(function(o){var la=Number(o.lat),lo=Number(o.lon);if(!isFinite(la)||!isFinite(lo)||la<-90||la>90||lo<-180||lo>180)return '';return '<button data-lat="'+la+'" data-lng="'+lo+'">'+String(o.display_name||'').replace(/</g,'&lt;')+'</button>';}).join(''); }).catch(function(){res.innerHTML='<div style="padding:9px 14px;color:#c0392b;font-size:12px">Recherche indisponible.</div>';}); }
851
863
  // ── Street View (Google) ─────────────────────────────────────────────────────────────────────────
852
864
  function tempHint(t){ var h=document.getElementById('mapHint'); if(h){h.textContent=t; setTimeout(function(){ if(h.textContent===t)h.textContent=(isPres?'':'Vue du présentateur — en direct'); },2600);} }
853
865
  // Le présentateur passe en Street View depuis le centre de la carte : on cherche le panorama le plus proche.
@@ -3524,8 +3536,23 @@ async function handler(req, res) {
3524
3536
  // ⚠️ QUI a le droit de diffuser un document est en revanche une règle de l'HÔTE, pas du
3525
3537
  // player : elle passe par le contexte (`identity.canManageShares`). Le player se contente de
3526
3538
  // vérifier le jeton. Sans réponse de l'hôte : refus.
3539
+ // ── RÉTENTION (docs/RETENTION.md) ────────────────────────────────────────────────────────
3540
+ // Déclenchement explicite : hôte de confiance ou admin. Renvoie les comptes DÉCLARÉS —
3541
+ // le recensement indépendant (SQL nu) est l'autre moitié du contrat, pas cette route.
3542
+ if (body.action === "retention.run") {
3543
+ const jd = (status, obj) => { res.statusCode = status; res.setHeader("Content-Type", "application/json"); res.end(JSON.stringify(obj)); };
3544
+ try {
3545
+ const hote = !!(PLAYER.identity.isTrustedHostCall && PLAYER.identity.isTrustedHostCall(req.headers));
3546
+ let admin = false;
3547
+ if (!hote) { const u = await PLAYER.identity.verifyToken(req.headers.authorization); admin = !!(u && PLAYER.identity.isAdmin(u)); }
3548
+ if (!hote && !admin) return jd(403, { ok: false, error: "retention.run : hôte de confiance ou admin requis" });
3549
+ return jd(200, await require("./retention").purgerRetention(Date.now()));
3550
+ } catch (e) { try { PLAYER.errors.capture(e, { route: "retention" }); } catch { /* jamais bloquant */ } return jd(500, { ok: false }); }
3551
+ }
3527
3552
  if (String(body.action || "").startsWith("docshare.")) {
3528
3553
  const jd = (status, obj) => { res.statusCode = status; res.setHeader("Content-Type", "application/json"); res.end(JSON.stringify(obj)); };
3554
+ // Balayage de rétention opportuniste — au plus un par 24 h, jamais bloquant.
3555
+ try { require("./retention").tick(); } catch { /* jamais bloquant */ }
3529
3556
  try {
3530
3557
  // ── L'HÔTE PARLE EN SON NOM PROPRE ────────────────────────────────────────────────
3531
3558
  //
@@ -3613,10 +3640,21 @@ async function handler(req, res) {
3613
3640
  // demandes suivantes la trouveront par l'unicité, et les doublons d'avant 0011
3614
3641
  // s'éteignent d'eux-mêmes faute d'être resservis.
3615
3642
  const cleDispo = await require("./schema").attendue("liensUniques");
3616
- await PLAYER.db.request(`commercial_doc_shares?slug=eq.${encodeURIComponent(dejaLa[0].slug)}`, {
3617
- method: "PATCH", headers: { Prefer: "return=minimal" },
3618
- body: { doc_title: body.docTitle || null, file_url: String(body.fileUrl), file_name: body.fileName || null, revoked: false, ...(cleDispo ? { idem_key: cleHote } : {}) },
3619
- });
3643
+ // ⚠️ MÊME RATTRAPAGE QUE LE CHEMIN DE CRÉATION (septième audit : le correctif ne
3644
+ // s'était pas appliqué à lui-même). Deux doublons d'avant 0011 en concurrence :
3645
+ // l'index refuse la seconde pose de clé on relit le gagnant au lieu de rendre 500.
3646
+ try {
3647
+ await PLAYER.db.request(`commercial_doc_shares?slug=eq.${encodeURIComponent(dejaLa[0].slug)}`, {
3648
+ method: "PATCH", headers: { Prefer: "return=minimal" },
3649
+ body: { doc_title: body.docTitle || null, file_url: String(body.fileUrl), file_name: body.fileName || null, revoked: false, ...(cleDispo ? { idem_key: cleHote } : {}) },
3650
+ });
3651
+ } catch (erreur) {
3652
+ if (!String((erreur && erreur.message) || "").includes("409")) throw erreur;
3653
+ try { PLAYER.errors.capture(new Error("backfill hôte : la clé était déjà posée ailleurs — " + docId), { route: "hostshare", benin: true }); } catch { /* jamais bloquant */ }
3654
+ const gagnant = await PLAYER.db.request(`commercial_doc_shares?idem_key=eq.${encodeURIComponent(cleHote)}&select=slug&limit=1`);
3655
+ if (!Array.isArray(gagnant) || !gagnant[0]) throw erreur;
3656
+ return jd(200, { ok: true, slug: gagnant[0].slug, reused: true });
3657
+ }
3620
3658
  return jd(200, { ok: true, slug: dejaLa[0].slug, reused: true });
3621
3659
  }
3622
3660
  // `createdBy` reste NUL : personne ne l'a créé. Le filtre « mes liens » compare
@@ -3684,7 +3722,16 @@ async function handler(req, res) {
3684
3722
  const cleTest = cleIdempotence("repetition", [docId]);
3685
3723
  if (Array.isArray(ex) && ex[0]) {
3686
3724
  const cleDispo = await require("./schema").attendue("liensUniques");
3687
- await PLAYER.db.request(`commercial_doc_shares?slug=eq.${encodeURIComponent(ex[0].slug)}`, { method: "PATCH", headers: { Prefer: "return=minimal" }, body: { doc_title: body.docTitle || null, file_url: String(body.fileUrl), file_name: body.fileName || null, bot_enabled: true, bot_guided: true, bot_profile_id: (body.profileId || "").trim() || null, revoked: false, ...(cleDispo ? { idem_key: cleTest } : {}) } });
3725
+ // Même rattrapage que ci-dessus : le backfill de la répétition pose la même clé.
3726
+ try {
3727
+ await PLAYER.db.request(`commercial_doc_shares?slug=eq.${encodeURIComponent(ex[0].slug)}`, { method: "PATCH", headers: { Prefer: "return=minimal" }, body: { doc_title: body.docTitle || null, file_url: String(body.fileUrl), file_name: body.fileName || null, bot_enabled: true, bot_guided: true, bot_profile_id: (body.profileId || "").trim() || null, revoked: false, ...(cleDispo ? { idem_key: cleTest } : {}) } });
3728
+ } catch (erreur) {
3729
+ if (!String((erreur && erreur.message) || "").includes("409")) throw erreur;
3730
+ try { PLAYER.errors.capture(new Error("backfill répétition : la clé était déjà posée ailleurs — " + docId), { route: "docshare-test", benin: true }); } catch { /* jamais bloquant */ }
3731
+ const gagnant = await PLAYER.db.request(`commercial_doc_shares?idem_key=eq.${encodeURIComponent(cleTest)}&select=slug&limit=1`);
3732
+ if (!Array.isArray(gagnant) || !gagnant[0]) throw erreur;
3733
+ return jd(200, { ok: true, slug: gagnant[0].slug, reused: true });
3734
+ }
3688
3735
  return jd(200, { ok: true, slug: ex[0].slug });
3689
3736
  }
3690
3737
  // Deux ouvertures simultanées de la répétition : la contrainte tranche, le perdant relit.
@@ -0,0 +1,92 @@
1
+ // LA PURGE DÉCLARE, LE RECENSEMENT LA CONTREDIT.
2
+ //
3
+ // Ce module est UNE des deux moitiés du contrat de rétention (docs/RETENTION.md) : il efface ce
4
+ // qui a dépassé sa fenêtre et DÉCLARE ses comptes, table par table. L'autre moitié —
5
+ // `supabase/recensement-retention.sql` — recompte en SQL nu ce qui reste dans le périmètre
6
+ // revendiqué, sans partager une ligne, une fonction ni un filtre avec ce fichier : deux textes
7
+ // qui ne peuvent pas être faux de la même manière. La forge les confronte sur une base réelle.
8
+ //
9
+ // ⚠️ Le compte déclaré vient des LIGNES RENDUES par le DELETE (`Prefer: return=representation`,
10
+ // select réduit à une colonne), jamais d'un comptage préalable : ce qui est déclaré est ce qui a
11
+ // été fait, pas ce qui était prévu.
12
+ //
13
+ // ⚠️ L'absence ne se prouve pas ici : ce module peut seulement dire ce qu'il a effacé. La
14
+ // question « son périmètre couvre-t-il ce qui existe ? » appartient à la garde de forge qui
15
+ // confronte les colonnes du schéma vivant à docs/RETENTION.md.
16
+
17
+ let PLAYER = null;
18
+ const init = (ctx) => { PLAYER = ctx; };
19
+ const enc = encodeURIComponent;
20
+
21
+ // Fenêtres par défaut de docs/RETENTION.md — l'hôte ajuste via `config.retention`.
22
+ const FENETRES = { journauxMois: 13, presentationsMois: 12, liensRevoquesMois: 13 };
23
+ const fenetres = () => ({ ...FENETRES, ...((PLAYER.config && PLAYER.config.retention) || {}) });
24
+
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(); }
27
+
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;
31
+ }
32
+
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;
37
+ }
38
+
39
+ async function purgerRetention(now) {
40
+ const f = fenetres();
41
+ const efface = {};
42
+ const bJournaux = borne(now, f.journauxMois);
43
+
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`);
49
+
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.
52
+ 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;
56
+
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).
60
+ 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;
64
+ const retirer = PLAYER.storage && typeof PLAYER.storage.remove === "function" ? PLAYER.storage.remove.bind(PLAYER.storage) : null;
65
+ for (const p of (Array.isArray(mortes) ? mortes : [])) {
66
+ 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
+ }
74
+ }
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
+ }
79
+ return { ok: true, efface };
80
+ }
81
+
82
+ // Balayage opportuniste : au plus UN par fenêtre de 24 h (le verrou est le compteur de débit
83
+ // partagé — même mécanique que les limites, donc multi-processus sans coordination). Jamais
84
+ // bloquant : la route qui l'héberge répond sans l'attendre, l'échec se capture.
85
+ function tick() {
86
+ Promise.resolve()
87
+ .then(() => PLAYER.limits.allow("retention:sweep", 1, 86400))
88
+ .then((permis) => { if (permis) return purgerRetention(Date.now()); })
89
+ .catch((e) => { try { PLAYER.errors.capture(e, { route: "retention", benin: true }); } catch { /* jamais bloquant */ } });
90
+ }
91
+
92
+ module.exports = { init, purgerRetention, tick, cheminPieceJointe };
package/server/schema.js CHANGED
@@ -47,6 +47,11 @@ const ATTENDUES = {
47
47
  migration: "supabase/migrations/0011-liens-uniques.sql",
48
48
  fonction: "empêcher deux demandes simultanées de créer deux liens système pour le même usage",
49
49
  },
50
+ revocationDatee: {
51
+ table: "commercial_doc_shares", colonne: "revoked_at",
52
+ migration: "supabase/migrations/0013-revocation-datee.sql",
53
+ fonction: "dater la révocation pour borner la rétention des liens révoqués",
54
+ },
50
55
  reactionsOrdonnees: {
51
56
  table: "doc_presentation_messages", colonne: "reactions_seq",
52
57
  migration: "supabase/migrations/0006-reactions-ordonnees.sql",
package/server/shares.js CHANGED
@@ -345,7 +345,9 @@ async function sendReshareEmail({ parent, childSlug, origin, toEmail, toName })
345
345
  }
346
346
 
347
347
  async function revokeShare(slug) {
348
- await PLAYER.db.request(`commercial_doc_shares?slug=eq.${enc(String(slug || ""))}`, { method: "PATCH", headers: { Prefer: "return=minimal" }, body: { revoked: true } });
348
+ // La date borne la rétention (RETENTION.md) écrite seulement la colonne existe (0013).
349
+ const dateDispo = await require("./schema").attendue("revocationDatee");
350
+ await PLAYER.db.request(`commercial_doc_shares?slug=eq.${enc(String(slug || ""))}`, { method: "PATCH", headers: { Prefer: "return=minimal" }, body: { revoked: true, ...(dateDispo ? { revoked_at: new Date().toISOString() } : {}) } });
349
351
  }
350
352
 
351
353
  // Soft wall : (dé)verrouille l'accès d'un lien /doc — require_auth=true → connexion visiteur exigée.
package/supabase/init.sql CHANGED
@@ -39,6 +39,7 @@ create table if not exists public.commercial_doc_shares (
39
39
  created_by text,
40
40
  created_at timestamptz not null default now(),
41
41
  revoked boolean not null default false,
42
+ revoked_at timestamptz, -- date de l'acte : borne la rétention (RETENTION.md)
42
43
  parent_slug text, -- re-partage : le lien d'où celui-ci est issu
43
44
  bot_enabled boolean not null default false,
44
45
  bot_script text,
@@ -68,6 +69,12 @@ alter table public.commercial_doc_shares
68
69
  create unique index if not exists cds_idem_key_uniq
69
70
  on public.commercial_doc_shares (idem_key)
70
71
  where idem_key is not null;
72
+ comment on column public.commercial_doc_shares.idem_key is
73
+ 'Clé d''idempotence des liens SYSTÈME (hôte, répétition) : empreinte « genre:sha256(JSON des '
74
+ 'composants) » — genre = hote | repetition. Les clés historiques au format concaténé '
75
+ '« hote:<doc>|<email> » se remplacent d''elles-mêmes au réemploi (backfill). Nulle sur les '
76
+ 'liens ordinaires. Unique quand renseignée : deux demandes simultanées ne créent qu''un lien, '
77
+ 'la seconde relit le gagnant.';
71
78
  create index if not exists cds_parent_idx on public.commercial_doc_shares (parent_slug);
72
79
  create index if not exists idx_doc_shares_brand_key
73
80
  on public.commercial_doc_shares (brand_key) where brand_key is not null;
@@ -417,6 +424,9 @@ alter table public.doc_presentation_messages
417
424
  add column if not exists client_key text;
418
425
  alter table public.commercial_doc_shares
419
426
  add column if not exists idem_key text;
427
+ alter table public.commercial_doc_shares
428
+ add column if not exists revoked_at timestamptz;
429
+ update public.commercial_doc_shares set revoked_at = now() where revoked = true and revoked_at is null;
420
430
  -- Une base née d'un init.sql d'avant la 0008 porte un NOT NULL qui rend la clôture impossible.
421
431
  alter table public.doc_presentations
422
432
  alter column control_hash drop not null;
@@ -17,6 +17,9 @@
17
17
  alter table public.commercial_doc_shares
18
18
  add column if not exists idem_key text;
19
19
 
20
+ -- ⚠️ Le format décrit ci-dessous était celui de l'époque ; la clé est devenue une EMPREINTE
21
+ -- (genre:sha256) et la 0012 remet ce commentaire de colonne à jour EN BASE. Le texte d'origine
22
+ -- reste ici tel qu'appliqué — une migration ne se réécrit pas, elle se corrige par la suivante.
20
23
  comment on column public.commercial_doc_shares.idem_key is
21
24
  'Clé d''idempotence des liens SYSTÈME (hôte, répétition) : « hote:<doc>|<destinataire attesté> » '
22
25
  'ou « repetition:<doc> ». Nulle sur les liens ordinaires. Unique quand renseignée : deux '
@@ -0,0 +1,16 @@
1
+ -- LE COMMENTAIRE DE COLONNE DÉCRIVAIT UN FORMAT MORT.
2
+ --
3
+ -- La 0011 documentait la clé sous sa forme concaténée « hote:<doc>|<destinataire attesté> » ;
4
+ -- depuis, la clé est une EMPREINTE — genre:sha256(JSON.stringify(composants)) — parce que la
5
+ -- concaténation portait trois défauts (troncature à l'écriture mais relecture entière, collision
6
+ -- de préfixe, frontière « | » déplaçable — septième audit). Un commentaire de colonne se lit dans
7
+ -- la base, longtemps après les fichiers : celui-ci aurait envoyé un exploitant chercher des clés
8
+ -- lisibles qui n'existent plus.
9
+ --
10
+ -- Sans lui : rien ne casse — seule la documentation EN BASE reste fausse.
11
+ comment on column public.commercial_doc_shares.idem_key is
12
+ 'Clé d''idempotence des liens SYSTÈME (hôte, répétition) : empreinte « genre:sha256(JSON des '
13
+ 'composants) » — genre = hote | repetition. Les clés historiques au format concaténé '
14
+ '« hote:<doc>|<email> » se remplacent d''elles-mêmes au réemploi (backfill). Nulle sur les '
15
+ 'liens ordinaires. Unique quand renseignée : deux demandes simultanées ne créent qu''un lien, '
16
+ 'la seconde relit le gagnant.';
@@ -0,0 +1,21 @@
1
+ -- LA RÉVOCATION N'AVAIT PAS DE DATE — « 13 MOIS APRÈS RÉVOCATION » ÉTAIT INCALCULABLE.
2
+ --
3
+ -- docs/RETENTION.md promet la purge des liens révoqués 13 mois après leur révocation ; or le
4
+ -- schéma ne portait que `revoked boolean` et `created_at`. Sans date, la politique serait restée
5
+ -- une phrase — ou aurait pris l'âge du lien comme borne, purgeant un lien révoqué d'hier parce
6
+ -- qu'il est né il y a deux ans. La colonne date l'acte ; le backfill démarre l'horloge des
7
+ -- révoqués existants À MAINTENANT (leur date réelle est perdue — compter large plutôt qu'inventer).
8
+ --
9
+ -- Sans lui : rien ne casse — la purge des liens révoqués reste simplement inactive (sonde de
10
+ -- schéma négative), les autres purges tournent.
11
+ alter table public.commercial_doc_shares
12
+ add column if not exists revoked_at timestamptz;
13
+
14
+ comment on column public.commercial_doc_shares.revoked_at is
15
+ 'Date de révocation du lien — borne la rétention (purge N mois après, cf. docs/RETENTION.md). '
16
+ 'Nulle tant que le lien vit. Les révoqués d''avant cette colonne ont reçu la date de la '
17
+ 'migration : leur horloge démarre là.';
18
+
19
+ update public.commercial_doc_shares
20
+ set revoked_at = now()
21
+ where revoked = true and revoked_at is null;
@@ -0,0 +1,54 @@
1
+ -- LE RECENSEMENT INDÉPENDANT — L'AUTRE MOITIÉ DU CONTRAT DE RÉTENTION.
2
+ --
3
+ -- Ce fichier recompte, en SQL nu, ce qui RESTE dans le périmètre que docs/RETENTION.md revendique.
4
+ -- Il ne partage RIEN avec `server/retention.js` : ni fonction, ni filtre, ni transport — deux
5
+ -- textes écrits séparément, qui ne peuvent pas être faux de la même manière (l'erreur partagée
6
+ -- rend un compte juste ; l'indépendance est la propriété, pas un style). Les fenêtres (13/12/13
7
+ -- mois) sont le CONTRAT commun : elles viennent du document, pas du code.
8
+ --
9
+ -- Usage : psql -f supabase/recensement-retention.sql — chaque ligne rend « table|restantes ».
10
+ -- Après une purge, tout doit être à zéro ; un reste est un mensonge de la purge OU un trou de
11
+ -- son périmètre. La forge l'exécute à chaque course, après le banc base.
12
+ select 'commercial_doc_views' as perimetre, count(*) as restantes
13
+ from public.commercial_doc_views
14
+ where at < now() - interval '13 months'
15
+ union all
16
+ select 'commercial_doc_sessions', count(*)
17
+ from public.commercial_doc_sessions
18
+ where last_at < now() - interval '13 months'
19
+ union all
20
+ select 'commercial_doc_internal_sessions', count(*)
21
+ from public.commercial_doc_internal_sessions
22
+ where last_at < now() - interval '13 months'
23
+ union all
24
+ select 'doc_bot_sessions', count(*)
25
+ from public.doc_bot_sessions
26
+ where last_at < now() - interval '13 months'
27
+ union all
28
+ select 'player_rate_limits', count(*)
29
+ from public.player_rate_limits
30
+ where expires_at < now()
31
+ union all
32
+ select 'commercial_doc_shares_revoques', count(*)
33
+ from public.commercial_doc_shares
34
+ where revoked and revoked_at < now() - interval '13 months'
35
+ union all
36
+ select 'doc_presentations_mortes', count(*)
37
+ from public.doc_presentations
38
+ where not active and updated_at < now() - interval '12 months'
39
+ union all
40
+ -- Messages et présences : orphelins d'une présentation morte-ancienne OU d'aucune présentation —
41
+ -- formulé par NOT EXISTS, pas par la liste de slugs que la purge a construite.
42
+ select 'doc_presentation_messages_orphelins', count(*)
43
+ from public.doc_presentation_messages m
44
+ where not exists (
45
+ select 1 from public.doc_presentations p
46
+ where p.slug = m.slug
47
+ and (p.active or p.updated_at >= now() - interval '12 months'))
48
+ union all
49
+ select 'doc_presentation_attendees_orphelins', count(*)
50
+ from public.doc_presentation_attendees a
51
+ where not exists (
52
+ select 1 from public.doc_presentations p
53
+ where p.slug = a.slug
54
+ and (p.active or p.updated_at >= now() - interval '12 months'));