Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .github/scripts/lib/credential.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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()}`;
Expand Down
26 changes: 26 additions & 0 deletions .github/scripts/lib/credential.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
deterministicUuid,
buildCredential,
keyUrl,
resolveEmissionDate,
} from './credential.mjs';

describe('parseMemberYaml', () => {
Expand Down Expand Up @@ -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');
Expand Down
11 changes: 6 additions & 5 deletions .github/scripts/sign-credential.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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); }
Expand All @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions docs/PRD.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,8 +193,14 @@ photo: <chemin LFS> # requis — data/members/<handle>/photo.webp (objet G
status_index: <entier> # requis — index permanent dans la Bitstring Status List (CT-4), assigné à l'intake
website: https://... # optionnel — site personnel
description: <une ligne> # optionnel — présentation affichée dans l'annuaire (≤ 280)
renewed_on: <date ISO> # 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/<handle>/` (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/<handle>.webp`. Les membres révoqués sont exclus du flux.
Expand Down
Loading