@mostajs/certificates 0.1.0
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.
- package/CHANGELOG.md +4 -0
- package/README.md +11 -0
- package/docs/00-PROPOSITION-PLAN-CERTIFICATES-18062026.md +8 -0
- package/examples/attestation/run.mjs +11 -0
- package/llms.txt +12 -0
- package/package.json +25 -0
- package/src/certificates.js +58 -0
- package/src/index.js +4 -0
- package/src/memory-repo.js +7 -0
- package/src/schemas.js +6 -0
- package/test-scripts/unit/certificates.test.mjs +36 -0
package/CHANGELOG.md
ADDED
package/README.md
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# @mostajs/certificates
|
|
2
|
+
**Auteur** : Dr Hamid MADANI <drmdh@msn.com> · **Licence** : AGPL-3.0-or-later · **Statut** : 0.1.0 (8 tests verts)
|
|
3
|
+
> Certificats/attestations vérifiables : PDF + QR + **signature numérique** (HMAC @mostajs/security). Compose qrpanel/file-export/numbering/storage.
|
|
4
|
+
```js
|
|
5
|
+
import { createCertificates, createMemoryRepositories } from '@mostajs/certificates';
|
|
6
|
+
import { hmacSha256 } from '@mostajs/security';
|
|
7
|
+
const c = createCertificates({ repositories: createMemoryRepositories(), hmac: hmacSha256, secret: env.MOSTA_CERT_SECRET, qr });
|
|
8
|
+
const cert = await c.issue({ subjectId:'eleve', title:'Anglais B1', payload:{niveau:'B1'} });
|
|
9
|
+
const { valid } = await c.verify(cert.no);
|
|
10
|
+
```
|
|
11
|
+
Lancer : `node test-scripts/unit/certificates.test.mjs && node examples/attestation/run.mjs`
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# @mostajs/certificates — DEVRULES de bout en bout (§4 + #3 condensés)
|
|
2
|
+
**Auteur** : Dr Hamid MADANI <drmdh@msn.com> · **Date** : 2026-06-18 · **Statut** : cas C (P2 + P3, générique)
|
|
3
|
+
## §4 / §3
|
|
4
|
+
Certificats/attestations **vérifiables** : génération (PDF) + **QR de vérification** + **signature numérique** (HMAC).
|
|
5
|
+
Autonome/DB-agnostique. **Compose** : security (hmac=signature), qrpanel (QR), file-export (PDF), numbering (n°), storage (archive PDF).
|
|
6
|
+
**Frontière** : la note/niveau = gradebook ; le contenu = training. **API** : `createCertificates({repositories,hmac,secret,qr?,fileExport?,numbering?,storage?})`
|
|
7
|
+
→ `issue({subjectId,title,payload,issuer})`, `verify(no|{no,signature})`, `revoke`, `get/byNo/listBySubject`, `render(id,format)`.
|
|
8
|
+
**Réutilisabilité** : ATC (attestations formation), ASSO-SEL (attestations), tout émetteur. **Jalons** : 0.1 émission+vérif+révoc · 0.2 PDF/QR rendus · 1.0 14 livrables.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Exemple §12 — attestation de formation vérifiable. node examples/attestation/run.mjs
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { createCertificates, createMemoryRepositories } from '../../src/index.js';
|
|
4
|
+
import { hmacSha256 } from '../../../mosta-security/src/index.js';
|
|
5
|
+
const c=createCertificates({ repositories:createMemoryRepositories(), hmac:hmacSha256, secret:process.env.MOSTA_CERT_SECRET||'demo-secret',
|
|
6
|
+
qr:{ generate:(p)=>'https://verify.atc.dz/?c='+encodeURIComponent(p) }, numbering:{ next:()=> 'CERT-2026-'+Math.floor(Math.random()*9999) },
|
|
7
|
+
fileExport:{ async exportDoc(fmt,doc){ return `(${fmt}) ${doc.title} #${doc.items[0].fields.valeur}`; } } });
|
|
8
|
+
const cert=await c.issue({ subjectId:'Inès Mansouri', title:'Certificat Anglais B1', payload:{ niveau:'B1', score:'78/100' }, issuer:'ATC Smart Campus' });
|
|
9
|
+
const v=await c.verify(cert.no); assert.equal(v.valid,true);
|
|
10
|
+
console.log('✅ certificates —', cert.no, '· QR:', cert.qrCode.slice(0,45)+'…', '· vérif:', v.valid);
|
|
11
|
+
console.log(' PDF:', await c.render(cert.id));
|
package/llms.txt
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# @mostajs/certificates — fiche LLM
|
|
2
|
+
RÔLE
|
|
3
|
+
Certificats vérifiables : émission + QR de vérification + signature numérique (HMAC via @mostajs/security). Autonome/DB-agnostique.
|
|
4
|
+
Compose (injectés) : security (hmac+secret=signature), qrpanel (QR), file-export (PDF), numbering (n°), storage (archive).
|
|
5
|
+
EXPORTS
|
|
6
|
+
createCertificates({ repositories, hmac, secret, qr?, fileExport?, numbering?, storage?, now? }) -> api ; createMemoryRepositories(); CertificateSchema
|
|
7
|
+
API
|
|
8
|
+
issue({subjectId,title,payload,issuer}) -> cert(no,signature,qrCode) ; verify(no|{no,signature}) -> {valid,reason,certificate}
|
|
9
|
+
revoke(id,{by}) ; get/byNo/listBySubject ; render(id,format='pdf') (compose file-export + storage)
|
|
10
|
+
PIÈGES
|
|
11
|
+
- signature = hmac(canonical(no,subjectId,title,issuedAt,payload), secret) → toute modif du contenu invalide la vérif.
|
|
12
|
+
- verify : recalcule la signature + vérifie status≠revoked. secret via .env (@mostajs/config). QR via qrpanel.
|
package/package.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mostajs/certificates",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Certificats/attestations vérifiables : génération PDF + QR de vérification + signature numérique (HMAC). DB-agnostique ; compose security/qrpanel/file-export/numbering/storage.",
|
|
5
|
+
"license": "AGPL-3.0-or-later",
|
|
6
|
+
"author": "Dr Hamid MADANI <drmdh@msn.com>",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"main": "src/index.js",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": "./src/index.js",
|
|
11
|
+
"./schemas": "./src/schemas.js"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"mostajs",
|
|
15
|
+
"certificates",
|
|
16
|
+
"attestation",
|
|
17
|
+
"qr",
|
|
18
|
+
"signature",
|
|
19
|
+
"verification"
|
|
20
|
+
],
|
|
21
|
+
"scripts": {
|
|
22
|
+
"test": "node test-scripts/unit/certificates.test.mjs",
|
|
23
|
+
"example": "node examples/attestation/run.mjs"
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// @mostajs/certificates — certificats vérifiables. Author: Dr Hamid MADANI <drmdh@msn.com>
|
|
2
|
+
// Autonome/DB-agnostique. Compose (injectés) : security (hmac=signature numérique), qrpanel (QR vérif),
|
|
3
|
+
// file-export (rendu PDF), numbering (n° certificat), storage (archivage du PDF).
|
|
4
|
+
|
|
5
|
+
const canonical = (c) => JSON.stringify({ no: c.no, subjectId: c.subjectId, title: c.title, issuedAt: c.issuedAt, payload: c.payload });
|
|
6
|
+
|
|
7
|
+
export function createCertificates({ repositories, hmac, secret, qr, fileExport, numbering, storage, now = () => new Date() } = {}) {
|
|
8
|
+
if (!repositories?.certificates) throw new Error('createCertificates: repositories.certificates requis');
|
|
9
|
+
const { certificates } = repositories;
|
|
10
|
+
const no = () => (numbering?.next ? numbering.next('certificate') : `CERT-${now().getFullYear()}-${Math.floor(now().getTime() % 1000000)}`);
|
|
11
|
+
const sign = (c) => (hmac ? hmac(canonical(c), secret) : undefined); // signature numérique (compose @mostajs/security)
|
|
12
|
+
|
|
13
|
+
const api = {
|
|
14
|
+
/** Émet un certificat signé + QR de vérification. */
|
|
15
|
+
async issue({ subjectId, title, payload = {}, issuer } = {}) {
|
|
16
|
+
if (!subjectId || !title) throw new Error('subjectId et title requis');
|
|
17
|
+
const draft = { no: no(), subjectId, title, payload, issuer, issuedAt: now(), status: 'valid' };
|
|
18
|
+
draft.signature = sign(draft);
|
|
19
|
+
draft.qrCode = qr?.generate ? qr.generate(`${draft.no}|${draft.signature || ''}`) : `CERT:${draft.no}`;
|
|
20
|
+
return certificates.create(draft);
|
|
21
|
+
},
|
|
22
|
+
get: (id) => certificates.findById(id),
|
|
23
|
+
byNo: async (no) => (await certificates.find((c) => c.no === no))[0] || null,
|
|
24
|
+
listBySubject: (subjectId) => certificates.find((c) => c.subjectId === subjectId),
|
|
25
|
+
revoke: async (id, { by } = {}) => certificates.update(id, { status: 'revoked', revokedBy: by, revokedAt: now() }),
|
|
26
|
+
|
|
27
|
+
/** Vérifie un certificat (signature recalculée + non révoqué). Accepte no ou {no, signature}. */
|
|
28
|
+
async verify(input) {
|
|
29
|
+
const no = typeof input === 'string' ? input : input?.no;
|
|
30
|
+
const c = await api.byNo(no);
|
|
31
|
+
if (!c) return { valid: false, reason: 'introuvable' };
|
|
32
|
+
if (c.status === 'revoked') return { valid: false, reason: 'révoqué', certificate: c };
|
|
33
|
+
if (hmac) {
|
|
34
|
+
const expected = sign(c);
|
|
35
|
+
if (expected !== c.signature) return { valid: false, reason: 'signature invalide', certificate: c };
|
|
36
|
+
if (typeof input === 'object' && input.signature && input.signature !== c.signature) return { valid: false, reason: 'signature fournie incohérente', certificate: c };
|
|
37
|
+
}
|
|
38
|
+
return { valid: true, certificate: c };
|
|
39
|
+
},
|
|
40
|
+
|
|
41
|
+
/** Rendu PDF (compose @mostajs/file-export) + archivage (compose @mostajs/storage), optionnels. */
|
|
42
|
+
async render(id, format = 'pdf') {
|
|
43
|
+
const c = await certificates.findById(id); if (!c) throw new Error('certificat introuvable');
|
|
44
|
+
const doc = { title: c.title, items: [
|
|
45
|
+
{ title: 'N°', fields: { valeur: c.no } },
|
|
46
|
+
{ title: 'Bénéficiaire', fields: { valeur: c.subjectId } },
|
|
47
|
+
{ title: 'Date', fields: { valeur: new Date(c.issuedAt).toISOString().slice(0, 10) } },
|
|
48
|
+
{ title: 'Vérification (QR)', fields: { valeur: c.qrCode } },
|
|
49
|
+
...Object.entries(c.payload || {}).map(([k, v]) => ({ title: k, fields: { valeur: String(v) } })),
|
|
50
|
+
] };
|
|
51
|
+
const out = fileExport?.exportDoc ? await fileExport.exportDoc(format, doc) : doc;
|
|
52
|
+
if (storage?.put && out !== doc) { const ref = await storage.put(Buffer.from(typeof out === 'string' ? out : JSON.stringify(out))); await certificates.update(id, { fileRef: typeof ref === 'string' ? ref : ref.id }); }
|
|
53
|
+
return out;
|
|
54
|
+
},
|
|
55
|
+
repositories,
|
|
56
|
+
};
|
|
57
|
+
return api;
|
|
58
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// @mostajs/certificates — repos mémoire. Author: Dr Hamid MADANI <drmdh@msn.com>
|
|
2
|
+
function coll(){ const m=new Map(); return {
|
|
3
|
+
async create(d){ const id=d.id||globalThis.crypto.randomUUID(); const now=new Date(); const r={id,createdAt:now,updatedAt:now,...d}; m.set(id,r); return {...r}; },
|
|
4
|
+
async findById(id){ const r=m.get(id); return r?{...r}:null; },
|
|
5
|
+
async update(id,p){ const r=m.get(id); if(!r)return null; const x={...r,...p,updatedAt:new Date()}; m.set(id,x); return {...x}; },
|
|
6
|
+
async find(f=()=>true){ return [...m.values()].filter(f).map(r=>({...r})); } }; }
|
|
7
|
+
export function createMemoryRepositories(){ return { certificates:coll() }; }
|
package/src/schemas.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// @mostajs/certificates — schéma (EntitySchema @mostajs/orm). Author: Dr Hamid MADANI <drmdh@msn.com>
|
|
2
|
+
export const CertificateSchema = { name:'Certificate', collection:'certificates', timestamps:true, fields:{
|
|
3
|
+
no:{type:'string',unique:true}, subjectId:{type:'string',required:true}, title:{type:'string',required:true},
|
|
4
|
+
payload:{type:'json',default:()=>({})}, issuer:{type:'string'}, issuedAt:{type:'date',default:'now'},
|
|
5
|
+
signature:{type:'string',default:null}, qrCode:{type:'string',default:null}, fileRef:{type:'string',default:null},
|
|
6
|
+
status:{type:'string',enum:['valid','revoked'],default:'valid'} }, indexes:[{fields:{subjectId:'asc'}},{fields:{no:'asc'}}] };
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// @mostajs/certificates — tests (DEVRULES §5). node test-scripts/unit/certificates.test.mjs
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { createCertificates, createMemoryRepositories } from '../../src/index.js';
|
|
4
|
+
import { hmacSha256 } from '../../../mosta-security/src/index.js'; // signature numérique = @mostajs/security
|
|
5
|
+
let pass=0; const test=async(n,f)=>{await f();pass++;console.log(' ✓',n);};
|
|
6
|
+
const mk=(o={})=>createCertificates({ repositories:createMemoryRepositories(), hmac:hmacSha256, secret:'cle-test', qr:{generate:(p)=>'QR:'+p}, ...o });
|
|
7
|
+
|
|
8
|
+
await test('émission : no + signature + QR', async()=>{
|
|
9
|
+
const c=mk(); const cert=await c.issue({subjectId:'eleve1',title:'Anglais B1',payload:{niveau:'B1'},issuer:'ATC'});
|
|
10
|
+
assert.ok(cert.no && cert.signature && cert.qrCode.startsWith('QR:'));
|
|
11
|
+
});
|
|
12
|
+
await test('vérification : valide', async()=>{
|
|
13
|
+
const c=mk(); const cert=await c.issue({subjectId:'e',title:'X'});
|
|
14
|
+
const v=await c.verify(cert.no); assert.equal(v.valid,true);
|
|
15
|
+
});
|
|
16
|
+
await test('vérification : signature altérée → invalide', async()=>{
|
|
17
|
+
const c=mk(); const cert=await c.issue({subjectId:'e',title:'X'});
|
|
18
|
+
await c.repositories.certificates.update(cert.id,{title:'FALSIFIÉ'}); // altère le contenu signé
|
|
19
|
+
const v=await c.verify(cert.no); assert.equal(v.valid,false); assert.match(v.reason,/signature/);
|
|
20
|
+
});
|
|
21
|
+
await test('révocation → invalide', async()=>{
|
|
22
|
+
const c=mk(); const cert=await c.issue({subjectId:'e',title:'X'});
|
|
23
|
+
await c.revoke(cert.id,{by:'admin'}); const v=await c.verify(cert.no);
|
|
24
|
+
assert.equal(v.valid,false); assert.equal(v.reason,'révoqué');
|
|
25
|
+
});
|
|
26
|
+
await test('introuvable', async()=>{ assert.equal((await mk().verify('CERT:none')).valid,false); });
|
|
27
|
+
await test('subjectId/title requis', async()=>{ await assert.rejects(()=>mk().issue({title:'x'}),/requis/); });
|
|
28
|
+
await test('rendu PDF (compose file-export)', async()=>{
|
|
29
|
+
const exp=[]; const c=mk({ fileExport:{ async exportDoc(fmt,doc){exp.push(fmt);return 'cert.pdf';} } });
|
|
30
|
+
const cert=await c.issue({subjectId:'e',title:'Dipl'}); assert.equal(await c.render(cert.id),'cert.pdf'); assert.equal(exp[0],'pdf');
|
|
31
|
+
});
|
|
32
|
+
await test('listBySubject', async()=>{
|
|
33
|
+
const c=mk(); await c.issue({subjectId:'s1',title:'A'}); await c.issue({subjectId:'s1',title:'B'});
|
|
34
|
+
assert.equal((await c.listBySubject('s1')).length,2);
|
|
35
|
+
});
|
|
36
|
+
console.log(`\n✅ @mostajs/certificates — ${pass} tests OK`);
|