diff --git a/.github/scripts/lib/credential.mjs b/.github/scripts/lib/credential.mjs index 341ee0e..1979adf 100644 --- a/.github/scripts/lib/credential.mjs +++ b/.github/scripts/lib/credential.mjs @@ -30,6 +30,21 @@ export function addYears(date, years) { return next; } +/** + * Date d'émission d'un badge : le renouvellement (`renewed_on`) prime sur la date + * de première certification (#27). Déterministe (les deux sont des dates fixes). + * @param {string|undefined} renewedOn date ISO de renouvellement, optionnelle + * @param {string|undefined} certifiedOn date ISO de première certification + * @returns {Date} + */ +export function resolveEmissionDate(renewedOn, certifiedOn) { + const source = (renewedOn && String(renewedOn).trim()) || certifiedOn; + if (!source) throw new Error("aucune date d'émission (renewed_on ou certifiedOn requis)"); + const date = new Date(source); + if (Number.isNaN(date.getTime())) throw new Error(`date d'émission invalide : ${source}`); + return date; +} + /** urn:uuid déterministe dérivé du handle et de la date d'émission. */ export function deterministicUuid(handle, date) { const seed = `${handle}:${date.getTime()}`; diff --git a/.github/scripts/lib/credential.test.mjs b/.github/scripts/lib/credential.test.mjs index 5c68785..09a95f8 100644 --- a/.github/scripts/lib/credential.test.mjs +++ b/.github/scripts/lib/credential.test.mjs @@ -7,6 +7,7 @@ import { deterministicUuid, buildCredential, keyUrl, + resolveEmissionDate, } from './credential.mjs'; describe('parseMemberYaml', () => { @@ -59,6 +60,31 @@ describe('deterministicUuid', () => { }); }); +describe('resolveEmissionDate', () => { + it('utilise la date de première certification en l\'absence de renouvellement', () => { + const d = resolveEmissionDate(undefined, '2026-01-01T00:00:00Z'); + assert.equal(isoSeconds(d), '2026-01-01T00:00:00Z'); + }); + + it('donne priorité à renewed_on quand il est présent', () => { + const d = resolveEmissionDate('2027-03-01T00:00:00Z', '2026-01-01T00:00:00Z'); + assert.equal(isoSeconds(d), '2027-03-01T00:00:00Z'); + }); + + it('ignore un renewed_on vide et retombe sur la première certification', () => { + const d = resolveEmissionDate(' ', '2026-01-01T00:00:00Z'); + assert.equal(isoSeconds(d), '2026-01-01T00:00:00Z'); + }); + + it('rejette l\'absence totale de date', () => { + assert.throws(() => resolveEmissionDate(undefined, undefined), /aucune date/); + }); + + it('rejette une date invalide', () => { + assert.throws(() => resolveEmissionDate('pas une date', undefined), /invalide/); + }); +}); + describe('keyUrl', () => { it('construit une URL déréférençable par kid sous le domaine permanent', () => { assert.equal(keyUrl('ABC'), 'https://verify.ai-driven-dev.fr/keys/ABC.json'); diff --git a/.github/scripts/sign-credential.mjs b/.github/scripts/sign-credential.mjs index 147327b..7bde140 100644 --- a/.github/scripts/sign-credential.mjs +++ b/.github/scripts/sign-credential.mjs @@ -6,7 +6,7 @@ // Date : env CERTIFIED_ON (date ISO du commit d'ajout) → émission déterministe. import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'; import { importPKCS8, exportJWK, calculateJwkThumbprint, SignJWT } from 'jose'; -import { parseMemberYaml, buildCredential, keyUrl, DEFAULT_BASE } from './lib/credential.mjs'; +import { parseMemberYaml, buildCredential, keyUrl, resolveEmissionDate, DEFAULT_BASE } from './lib/credential.mjs'; import { generateQrSvg } from './lib/qr.mjs'; function fail(message) { console.error(message); process.exit(2); } @@ -26,10 +26,11 @@ async function main() { const statusIndex = Number(member.status_index); if (!Number.isInteger(statusIndex) || statusIndex < 0) fail(`status_index manquant/invalide dans ${memberPath}`); - const certifiedOn = process.env.CERTIFIED_ON; - if (!certifiedOn) fail("CERTIFIED_ON absent (date ISO du commit d'ajout)"); - const issuedAt = new Date(certifiedOn); - if (Number.isNaN(issuedAt.getTime())) fail(`CERTIFIED_ON invalide : ${certifiedOn}`); + // Renouvellement (#27) : renewed_on du record prime sur la date de première + // certification (CERTIFIED_ON = date du commit d'ajout). + let issuedAt; + try { issuedAt = resolveEmissionDate(member.renewed_on, process.env.CERTIFIED_ON); } + catch (e) { fail(e.message); } const privateKey = await loadSigningKey(process.env.SIGNING_PRIVATE_KEY); // Le thumbprint ignore `d` → identique au kid de la clé publique publiée. diff --git a/docs/PRD.md b/docs/PRD.md index 4bb0e03..34c56f1 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -193,8 +193,14 @@ photo: # requis — data/members//photo.webp (objet G status_index: # requis — index permanent dans la Bitstring Status List (CT-4), assigné à l'intake website: https://... # optionnel — site personnel description: # optionnel — présentation affichée dans l'annuaire (≤ 280) +renewed_on: # optionnel — renouvellement (#27) : prime sur la date de 1re certif ``` +**Renouvellement (#27).** Par défaut, la date d'émission = date du commit qui a +ajouté le record (1re certification). Un renouvellement fixe `renewed_on` dans le +record : l'émission repart de cette date (nouvelle fenêtre de 1 an). Une preuve +déjà exportée garde son ancienne échéance et reste valide jusque-là. + **Retrait RGPD (#36).** Un retrait **supprime tout le dossier** `data/members//` (fiche + photo LFS) — l'effacement, pas une réécriture d'historique. Pour que le badge reste révoqué à vie malgré la disparition de la fiche, son `status_index` est inscrit dans un **registre de révocation** `data/revoked.json` (une liste d'entiers — pas de donnée personnelle). La liste de statuts (CT-4) se construit à partir de ce registre. Une preuve déjà exportée reste présentable mais apparaît **révoquée** à la vérification (CT-5, seule voie). Le site public `ai-driven-dev.fr` (`/communaute`) affiche l'annuaire ; il consomme le flux **`directory.json`** publié par la CI sur `verify.ai-driven-dev.fr` (nom, LinkedIn, photo, site, description, URL de vérif). Les photos sont servies sur `verify.ai-driven-dev.fr/photos/.webp`. Les membres révoqués sont exclus du flux.