- Créé sendInvitationWithActivationEmail() pour unifier les invitations - Modifié /api/staff/users/invite pour utiliser generateLink + email - Modifié /api/access/nouveau pour envoyer email d'activation - Modifié /api/access POST pour remplacer pending_invites par système direct - Template account-activation mis à jour : * Titre 'Activez votre compte' * Encart avec infos : invitant (statut), organisation, niveau d'accès * Message de contact formaté comme autres emails * Renommage 'Odentas Paie' → 'Espace Paie Odentas' - Fix page /activate : délai 100ms pour hash fragment + redirection 1s - Liens d'activation forcés vers paie.odentas.fr (tests depuis localhost) - Messages UI cohérents : 'Invitation envoyée' au lieu de 'Compte créé'
25 KiB
25 KiB
🍓 Odentas Sign + Raspberry Pi 5 + EIDUCIO+ - Architecture Complète
Système de signature électronique souverain avec scellement qualifié in-house
🎯 Vue d'ensemble
Workflow Complet
┌─────────────────────────────────────────────────────────────────┐
│ 1. SIGNATURE EMPLOYEUR (Odentas Sign) │
│ ↓ Interface web /signer/[requestId]/[employerId] │
│ ↓ OTP par email → Canvas signature → Upload S3 │
└─────────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────┐
│ 2. SIGNATURE SALARIÉ (Odentas Sign) │
│ ↓ Interface web /signer/[requestId]/[salarierId] │
│ ↓ OTP par email → Canvas signature → Upload S3 │
└─────────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────┐
│ 3. INJECTION SIGNATURES DANS PDF │
│ ↓ Webhook completion détecte toutes signatures │
│ ↓ pdf-lib injecte les images aux positions définies │
│ ↓ Upload PDF "avec signatures" dans S3 │
└─────────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────┐
│ 4. SCELLEMENT PAdES (Raspberry Pi 5 + EIDUCIO+) │
│ ↓ Appel API Raspberry Pi: POST /seal-contract │
│ ↓ Signature qualifiée avec clé USB EIDUCIO+ │
│ ↓ Format PAdES avec métadonnées : │
│ - Name: "Renaud [NOM], Président ODENTAS SAS" │
│ - Reason: "Certification tiers de confiance Odentas Sign" │
│ - Location: "Paris, France" │
│ ↓ Upload PDF scellé dans S3 │
└─────────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────┐
│ 5. HORODATAGE TSA QUALIFIÉ │
│ ↓ Calcul hash SHA-256 du PDF scellé │
│ ↓ Appel TSA (FreeTSA ou Universign) │
│ ↓ Réception token RFC 3161 │
│ ↓ Upload token TSA dans S3 │
└─────────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────┐
│ 6. ARCHIVAGE CONFORME (S3 Object Lock) │
│ ↓ Bucket: odentas-sign/sealed/ │
│ ↓ Object Lock: COMPLIANCE mode, 10 ans │
│ ↓ Mise à jour sign_assets (hashes, dates, clés S3) │
│ ↓ Evidence bundle complet │
└─────────────────────────────────────────────────────────────────┘
🏗️ Architecture Technique
1. Frontend : Odentas Sign (Existant)
Interface de signature : /app/signer/[requestId]/[signerId]/
- ✅ Authentification OTP par email (15 min)
- ✅ Canvas HTML5 pour dessiner la signature
- ✅ Upload d'image de signature (JPG/PNG)
- ✅ Consentement explicite (case à cocher)
- ✅ Responsive (mobile-friendly)
Composants clés :
SignatureCapture.tsx: Interface de dessin/uploadOTPVerification.tsx: Vérification code OTPDocumentPreview.tsx: Aperçu du PDF à signer
API Routes utilisées :
POST /api/odentas-sign/requests/create
POST /api/odentas-sign/signers/[id]/send-otp
POST /api/odentas-sign/signers/[id]/verify-otp
POST /api/odentas-sign/signers/[id]/sign
GET /api/odentas-sign/signers/[id]/status
2. Backend : Next.js API Routes
A. Création de demande
Route : /app/api/odentas-sign/requests/create/route.ts
// INPUT
{
contractId: "CDDU-2025-0102",
contractRef: "CDDU-2025-0102",
pdfS3Key: "source/contract-xxx.pdf",
title: "Contrat CDDU - Jean Dupont",
signers: [
{ role: "Employeur", name: "Théâtre X", email: "contact@theatre-x.fr" },
{ role: "Salarié", name: "Jean Dupont", email: "jean.dupont@example.com" }
],
positions: [
{ role: "Employeur", page: 3, x: 70, y: 120, w: 180, h: 70 },
{ role: "Salarié", page: 3, x: 350, y: 120, w: 180, h: 70 }
]
}
// ACTIONS
1. INSERT INTO sign_requests (ref, title, source_s3_key, status='pending')
2. INSERT INTO signers (2 lignes : Employeur + Salarié)
3. INSERT INTO sign_positions (2 lignes)
4. INSERT INTO sign_events ('request_created')
5. Envoyer email avec lien de signature à l'employeur
// OUTPUT
{
requestId: "uuid-xxx",
signers: [
{ id: "uuid-employeur", role: "Employeur", signUrl: "/signer/xxx/uuid-employeur" },
{ id: "uuid-salarie", role: "Salarié", signUrl: "/signer/xxx/uuid-salarie" }
]
}
B. Webhook de Completion
Route : /app/api/odentas-sign/webhooks/completion/route.ts
Déclenché quand : Les 2 signataires ont signé
async function handleCompletion(requestId: string) {
// 1. Récupérer la demande et les signataires
const { data: request } = await supabase
.from('sign_requests')
.select('*, signers(*)')
.eq('id', requestId)
.single();
// 2. Vérifier que tous ont signé
const allSigned = request.signers.every(s => s.signed_at !== null);
if (!allSigned) return;
// 3. Télécharger le PDF source depuis S3
const sourcePdf = await downloadFromS3(request.source_s3_key);
// 4. Injecter les signatures graphiques avec pdf-lib
const pdfWithSignatures = await injectSignatureImages(sourcePdf, request.signers);
// 5. Upload PDF avec signatures (non scellé encore)
const intermediateKey = `intermediate/${request.ref}.pdf`;
await uploadToS3(intermediateKey, pdfWithSignatures);
// 6. NOUVEAU : Envoyer au Raspberry Pi pour scellement PAdES
const sealedPdf = await sealWithRaspberryPi(intermediateKey, request);
// 7. NOUVEAU : Obtenir horodatage TSA
const tsaToken = await getTSATimestamp(sealedPdf);
// 8. Upload final avec Object Lock
const finalKey = `sealed/${request.ref}.pdf`;
await uploadToS3WithObjectLock(finalKey, sealedPdf, { retention: '10 years' });
// 9. Mettre à jour sign_assets
await supabase.from('sign_assets').upsert({
request_id: requestId,
signed_pdf_s3_key: finalKey,
pdf_sha256: sha256(sealedPdf),
tsa_token_sha256: sha256(tsaToken),
sealed_at: new Date(),
seal_algo: 'RSASSA_PKCS1_SHA_256',
seal_certificate_issuer: 'ChamberSign EIDUCIO+',
tsa_provider: 'FreeTSA',
retain_until: new Date(Date.now() + 10 * 365 * 24 * 60 * 60 * 1000)
});
// 10. Mettre à jour le statut
await supabase
.from('sign_requests')
.update({ status: 'completed' })
.eq('id', requestId);
}
async function sealWithRaspberryPi(pdfS3Key: string, request: SignRequest) {
const authToken = crypto
.createHmac('sha256', process.env.RASPBERRY_PI_SECRET!)
.update('seal-server')
.digest('hex');
// Télécharger le PDF
const pdfBuffer = await downloadFromS3(pdfS3Key);
// Envoyer au Raspberry Pi
const response = await fetch(`${process.env.RASPBERRY_PI_URL}/seal-contract`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${authToken}`,
'Content-Type': 'application/pdf',
'X-Request-Id': request.id,
'X-Contract-Ref': request.ref,
},
body: pdfBuffer,
});
if (!response.ok) {
throw new Error(`Raspberry Pi seal failed: ${await response.text()}`);
}
return Buffer.from(await response.arrayBuffer());
}
3. Serveur Raspberry Pi 5
Configuration Matérielle
Hardware:
Model: Raspberry Pi 5 (8GB RAM)
Storage: SD 64GB + SSD USB 128GB (logs)
Network: Ethernet Gigabit (fibre 8Gb/s)
USB: Clé ChamberSign EIDUCIO+ branchée 24/7
Power: Alimentation officielle 27W USB-C
Case: Boîtier avec ventilateur actif
Software:
OS: Raspberry Pi OS Lite (64-bit)
Runtime: Node.js 20 LTS (via nvm)
Process Manager: PM2 (auto-restart)
VPN: Tailscale (accès sécurisé depuis Vercel)
Firewall: UFW (port 3001 uniquement depuis LAN)
Coût:
One-time: ~230€ (Pi 5 + accessoires)
Annuel: 108€ TTC (EIDUCIO+) + ~10€ (électricité)
API Node.js Express
Fichier : /home/pi/signature-server/src/index.ts
import express from 'express';
import bodyParser from 'body-parser';
import helmet from 'helmet';
import rateLimit from 'express-rate-limit';
import crypto from 'crypto';
import { PKCS11 } from 'pkcs11js';
import { signPdfPAdES } from './pades-signer';
import { createLogger } from 'winston';
// Configuration
const PORT = 3001;
const PIN = process.env.USB_PIN; // PIN EIDUCIO+
const PKCS11_MODULE = '/usr/lib/aarch64-linux-gnu/opensc-pkcs11.so';
const API_SECRET = process.env.API_SECRET;
// Logger
const logger = createLogger({
transports: [
new transports.File({ filename: '/var/log/signature-server/seal.log' }),
new transports.Console()
]
});
const app = express();
// Sécurité
app.use(helmet());
app.use(bodyParser.raw({ type: 'application/pdf', limit: '10mb' }));
app.use(rateLimit({ windowMs: 60 * 60 * 1000, max: 100 }));
// Authentification
const authenticate = (req, res, next) => {
const token = req.headers['authorization']?.substring(7);
const expectedToken = crypto
.createHmac('sha256', API_SECRET)
.update('seal-server')
.digest('hex');
if (token !== expectedToken) {
logger.warn('Token invalide', { ip: req.ip });
return res.status(403).json({ error: 'Unauthorized' });
}
next();
};
// Route de scellement pour contrats
app.post('/seal-contract', authenticate, async (req, res) => {
const requestId = req.headers['x-request-id'];
const contractRef = req.headers['x-contract-ref'];
logger.info('Début scellement', { requestId, contractRef });
try {
// Metadata PAdES
const metadata = {
name: "Renaud [NOM]",
organization: "ODENTAS SAS",
role: "Président",
reason: `Certification et archivage sécurisé du contrat ${contractRef} - Odentas Sign`,
location: "Paris, France",
contactInfo: "odentas-sign@odentas.fr",
customProperties: {
service: "Odentas Sign",
tiersDConfiance: true,
contractRef,
requestId,
}
};
// Signer avec EIDUCIO+
const sealedPdf = await signPdfPAdES(req.body, metadata, {
pkcs11Module: PKCS11_MODULE,
pin: PIN,
});
logger.info('Scellement réussi', {
requestId,
size: sealedPdf.length,
duration: Date.now() - startTime
});
res.setHeader('Content-Type', 'application/pdf');
res.send(sealedPdf);
} catch (error) {
logger.error('Erreur scellement', { error: error.message, requestId });
res.status(500).json({ error: 'Seal failed', details: error.message });
}
});
// Route pour factures
app.post('/seal-invoice', authenticate, async (req, res) => {
const invoiceNumber = req.headers['x-invoice-number'];
logger.info('Début scellement facture', { invoiceNumber });
try {
const metadata = {
name: "Renaud [NOM]",
organization: "ODENTAS SAS",
role: "Président",
reason: `Facture électronique conforme - Article 289 CGI`,
location: "Paris, France",
contactInfo: "facturation@odentas.fr",
customProperties: {
invoiceNumber,
type: "invoice",
}
};
const sealedPdf = await signPdfPAdES(req.body, metadata, {
pkcs11Module: PKCS11_MODULE,
pin: PIN,
});
logger.info('Facture scellée', { invoiceNumber });
res.setHeader('Content-Type', 'application/pdf');
res.send(sealedPdf);
} catch (error) {
logger.error('Erreur facture', { error: error.message, invoiceNumber });
res.status(500).json({ error: 'Seal failed' });
}
});
// Health check
app.get('/health', (req, res) => {
res.json({
status: 'ok',
uptime: process.uptime(),
usb_key_detected: checkUSBKey(),
timestamp: new Date().toISOString()
});
});
function checkUSBKey() {
try {
const pkcs11 = new PKCS11();
pkcs11.load(PKCS11_MODULE);
pkcs11.C_Initialize();
const slots = pkcs11.C_GetSlotList(true);
pkcs11.C_Finalize();
return slots.length > 0;
} catch {
return false;
}
}
app.listen(PORT, '0.0.0.0', () => {
logger.info(`Serveur de scellement démarré`, { port: PORT });
});
Module de Signature PAdES
Fichier : /home/pi/signature-server/src/pades-signer.ts
import { PKCS11 } from 'pkcs11js';
import forge from 'node-forge';
import { PDFDocument } from 'pdf-lib';
export async function signPdfPAdES(
pdfBuffer: Buffer,
metadata: SignatureMetadata,
options: { pkcs11Module: string; pin: string }
): Promise<Buffer> {
const pkcs11 = new PKCS11();
try {
// 1. Initialiser PKCS#11
pkcs11.load(options.pkcs11Module);
pkcs11.C_Initialize();
// 2. Trouver le slot de la clé USB
const slots = pkcs11.C_GetSlotList(true);
if (slots.length === 0) throw new Error('Clé USB non détectée');
const slotId = slots[0];
// 3. Ouvrir session et se connecter
const session = pkcs11.C_OpenSession(
slotId,
pkcs11.CKF_SERIAL_SESSION | pkcs11.CKF_RW_SESSION
);
pkcs11.C_Login(session, pkcs11.CKU_USER, options.pin);
// 4. Trouver la clé privée
pkcs11.C_FindObjectsInit(session, [
{ type: pkcs11.CKA_CLASS, value: pkcs11.CKO_PRIVATE_KEY },
{ type: pkcs11.CKA_SIGN, value: true }
]);
const privateKeys = pkcs11.C_FindObjects(session, 1);
pkcs11.C_FindObjectsFinal(session);
if (privateKeys.length === 0) throw new Error('Clé privée non trouvée');
const privateKey = privateKeys[0];
// 5. Récupérer le certificat public
pkcs11.C_FindObjectsInit(session, [
{ type: pkcs11.CKA_CLASS, value: pkcs11.CKO_CERTIFICATE }
]);
const certs = pkcs11.C_FindObjects(session, 1);
pkcs11.C_FindObjectsFinal(session);
const certDer = pkcs11.C_GetAttributeValue(session, certs[0], [
{ type: pkcs11.CKA_VALUE }
])[0].value as Buffer;
const cert = forge.pki.certificateFromAsn1(
forge.asn1.fromDer(forge.util.createBuffer(certDer))
);
// 6. Préparer le PDF pour signature
const pdfDoc = await PDFDocument.load(pdfBuffer);
// 7. Créer le dictionnaire de signature PAdES
const signingTime = new Date();
const signatureDict = {
Type: 'Sig',
Filter: 'Adobe.PPKLite',
SubFilter: 'ETSI.CAdES.detached', // Format PAdES
Name: `${metadata.name}, ${metadata.role} - ${metadata.organization}`,
Reason: metadata.reason,
Location: metadata.location,
ContactInfo: metadata.contactInfo,
M: `D:${formatPdfDate(signingTime)}`,
// ByteRange et Contents seront calculés
};
// 8. Calculer le hash du PDF
const pdfHash = crypto.createHash('sha256').update(pdfBuffer).digest();
// 9. Signer avec PKCS#11
const mechanism = { mechanism: pkcs11.CKM_SHA256_RSA_PKCS };
pkcs11.C_SignInit(session, mechanism, privateKey);
const signature = pkcs11.C_Sign(session, pdfHash, Buffer.alloc(256));
// 10. Créer structure CAdES (PAdES requiert CAdES)
const signedData = createCAdESStructure(pdfHash, signature, cert, signingTime);
// 11. Intégrer la signature dans le PDF
const signedPdf = await embedSignatureInPdf(pdfBuffer, signatureDict, signedData);
// 12. Nettoyer
pkcs11.C_Logout(session);
pkcs11.C_CloseSession(session);
pkcs11.C_Finalize();
return signedPdf;
} catch (error) {
// Toujours nettoyer en cas d'erreur
try { pkcs11.C_Finalize(); } catch {}
throw error;
}
}
function createCAdESStructure(
messageDigest: Buffer,
signature: Buffer,
certificate: forge.pki.Certificate,
signingTime: Date
): Buffer {
// Créer structure CMS SignedData (simplifié)
const signedAttrs = [
{ oid: '1.2.840.113549.1.9.3', value: 'application/pdf' }, // contentType
{ oid: '1.2.840.113549.1.9.5', value: signingTime }, // signingTime
{ oid: '1.2.840.113549.1.9.4', value: messageDigest }, // messageDigest
];
// Utiliser node-forge pour créer structure ASN.1
const p7 = forge.pkcs7.createSignedData();
p7.addCertificate(certificate);
p7.addSigner({
key: { sign: () => signature }, // Signature déjà calculée
certificate: certificate,
digestAlgorithm: forge.pki.oids.sha256,
authenticatedAttributes: signedAttrs
});
return Buffer.from(forge.asn1.toDer(p7.toAsn1()).getBytes(), 'binary');
}
function embedSignatureInPdf(
pdfBuffer: Buffer,
signatureDict: any,
signedData: Buffer
): Buffer {
// Utiliser pdf-lib ou manipulation directe du PDF
// Insérer le dictionnaire de signature dans le PDF
// ByteRange = [0, offsetBeforeSignature, offsetAfterSignature, lengthAfterSignature]
// Contents = signedData en hexadécimal
// Implémentation simplifiée (voir librairies comme node-signpdf)
// ...
return modifiedPdfBuffer;
}
function formatPdfDate(date: Date): string {
// Format: YYYYMMDDHHmmssZ
return date.toISOString().replace(/[-:TZ]/g, '').slice(0, 14) + 'Z';
}
interface SignatureMetadata {
name: string;
organization: string;
role: string;
reason: string;
location: string;
contactInfo: string;
customProperties?: Record<string, any>;
}
4. Service d'Horodatage TSA
Option A : FreeTSA (Gratuit)
// /lib/tsa/freetsa.ts
import fetch from 'node-fetch';
import crypto from 'crypto';
import forge from 'node-forge';
export async function getFreeTSATimestamp(pdfHash: Buffer): Promise<Buffer> {
// 1. Créer requête TSA (RFC 3161)
const tsq = forge.pki.createTimeStampRequest({
messageImprint: {
hashAlgorithm: forge.pki.oids.sha256,
digest: pdfHash.toString('binary')
},
certReq: true
});
const tsqDer = Buffer.from(forge.asn1.toDer(tsq.toAsn1()).getBytes(), 'binary');
// 2. Envoyer à FreeTSA
const response = await fetch('https://freetsa.org/tsr', {
method: 'POST',
headers: { 'Content-Type': 'application/timestamp-query' },
body: tsqDer,
});
if (!response.ok) {
throw new Error(`TSA request failed: ${response.statusText}`);
}
// 3. Récupérer réponse (TSR)
const tsrDer = Buffer.from(await response.arrayBuffer());
// 4. Valider la réponse
const tsr = forge.pki.createTimeStampResponse(
forge.asn1.fromDer(forge.util.createBuffer(tsrDer))
);
if (tsr.status !== 0) {
throw new Error(`TSA response error: ${tsr.statusString}`);
}
return tsrDer;
}
Option B : Universign TSA (50-100€/an)
// /lib/tsa/universign.ts
export async function getUniversignTimestamp(pdfHash: Buffer): Promise<Buffer> {
const response = await fetch('https://timestamp.universign.eu/rfc3161', {
method: 'POST',
headers: {
'Content-Type': 'application/timestamp-query',
'Authorization': `Bearer ${process.env.UNIVERSIGN_API_KEY}`
},
body: createTSQRequest(pdfHash),
});
return Buffer.from(await response.arrayBuffer());
}
5. Tables Supabase - Ajouts
Migration : Colonnes Raspberry Pi
-- Ajouter colonnes pour certificat EIDUCIO+
ALTER TABLE sign_assets
ADD COLUMN seal_certificate_issuer TEXT DEFAULT 'ChamberSign EIDUCIO+',
ADD COLUMN seal_certificate_subject TEXT DEFAULT 'Renaud [NOM], Président ODENTAS SAS',
ADD COLUMN seal_certificate_serial TEXT,
ADD COLUMN tsa_provider TEXT DEFAULT 'FreeTSA',
ADD COLUMN tsa_url TEXT;
-- Index pour recherche
CREATE INDEX idx_sign_assets_sealed_at ON sign_assets(sealed_at);
CREATE INDEX idx_sign_assets_retain_until ON sign_assets(retain_until);
6. Configuration S3 Object Lock
# Créer bucket dédié avec Object Lock
aws s3api create-bucket \
--bucket odentas-sign-sealed \
--region eu-west-3 \
--object-lock-enabled-for-bucket
# Configurer Object Lock par défaut (10 ans)
aws s3api put-object-lock-configuration \
--bucket odentas-sign-sealed \
--object-lock-configuration '{
"ObjectLockEnabled": "Enabled",
"Rule": {
"DefaultRetention": {
"Mode": "COMPLIANCE",
"Years": 10
}
}
}'
# Activer versioning (requis pour Object Lock)
aws s3api put-bucket-versioning \
--bucket odentas-sign-sealed \
--versioning-configuration Status=Enabled
🔒 Sécurité
1. Réseau Raspberry Pi
# Tailscale VPN (recommandé)
curl -fsSL https://tailscale.com/install.sh | sh
sudo tailscale up
# Firewall UFW
sudo ufw allow from 192.168.1.0/24 to any port 3001
sudo ufw enable
# Fail2ban (protection brute-force)
sudo apt install fail2ban
2. Secrets Management
# .env sur Raspberry Pi
PORT=3001
USB_PIN=YOUR_EIDUCIO_PIN
API_SECRET=your-super-secret-key-256-bits
LOG_LEVEL=info
# Variables Vercel
RASPBERRY_PI_URL=http://100.64.0.50:3001 # Tailscale IP
RASPBERRY_PI_SECRET=same-as-above
3. Monitoring & Alertes
# PM2 monitoring
pm2 install pm2-logrotate
pm2 set pm2-logrotate:max_size 100M
pm2 set pm2-logrotate:retain 30
# Alertes email en cas d'erreur
npm install nodemailer
# Configurer dans le serveur pour envoyer email si clé USB déconnectée
📊 Performance
Temps de Traitement
| Étape | Durée |
|---|---|
| Signature Employeur (OTP + Canvas) | ~1-2 min (utilisateur) |
| Signature Salarié (OTP + Canvas) | ~1-2 min (utilisateur) |
| Injection signatures dans PDF | ~500ms |
| Scellement PAdES (Raspberry Pi) | ~800ms |
| Horodatage TSA | ~200ms |
| Upload S3 Object Lock | ~300ms |
| Total automatique | ~2 secondes |
💰 Coûts Récapitulatifs
| Élément | Coût |
|---|---|
| Raspberry Pi 5 + accessoires | 230€ one-time |
| Clé EIDUCIO+ ChamberSign | 108€ TTC/an |
| TSA FreeTSA | Gratuit |
| TSA Universign (optionnel) | 50-100€/an |
| S3 Storage (10 000 contrats/an) | ~5€/an |
| Électricité Raspberry Pi | ~10€/an |
| Total première année | ~353€ |
| Années suivantes | ~123€/an |
Comparaison : Service cloud (Universign/DocuSign) = 1500-3000€/an
🚀 Mise en Production
Checklist
- Commander clé EIDUCIO+ (90€ HT/an)
- Acheter Raspberry Pi 5 + accessoires
- Installer Raspberry Pi OS + Node.js + PM2
- Brancher clé USB EIDUCIO+ et tester détection
- Déployer serveur signature-server
- Configurer Tailscale VPN
- Créer bucket S3 avec Object Lock
- Migrer tables Supabase (colonnes seal_*)
- Tester workflow complet en staging
- Déployer en production
- Former équipe staff sur nouvelle interface
- Désactiver DocuSeal progressivement
📚 Documentation Complémentaire
- ODENTAS_SIGN_API.md : API complète Odentas Sign
- SIGNATURE_MULTI_PARTIES.md : Signatures multiples avec metadata
- TEST_PADES_TSA.md : Tests du workflow complet
- ODENTAS_SIGN_INTERFACE.md : Interface utilisateur
Questions ou besoin d'aide pour l'implémentation ? 🍓