Update API anchorage, services, and website skeleton

**Motivations:**
- Synchronisation des modifications sur l'API anchorage, les services et le website skeleton
- Ajout de scripts de monitoring et de diagnostic pour l'API anchorage
- Documentation des problèmes de mutex et de provisioning UTXO

**Root causes:**
- N/A (commit de synchronisation)

**Correctifs:**
- N/A (commit de synchronisation)

**Evolutions:**
- Ajout de scripts de monitoring et de diagnostic pour l'API anchorage
- Amélioration de la gestion des mutex et des UTXOs
- Mise à jour de la documentation

**Pages affectées:**
- api-anchorage/src/bitcoin-rpc.js
- api-anchorage/src/routes/anchor.js
- api-anchorage/src/routes/health.js
- api-anchorage/src/server.js
- api-anchorage/README-MONITORING.md
- api-anchorage/cleanup-stale-locks.mjs
- api-anchorage/diagnose.mjs
- api-anchorage/unlock-utxos.mjs
- service-login-verify/src/persistentNonceCache.ts
- signet-dashboard/src/server.js
- signet-dashboard/public/*
- userwallet/src/hooks/useChannel.ts
- userwallet/src/services/relayNotificationService.ts
- userwallet/src/utils/defaultContract.ts
- website-skeleton/src/*
- docs/DOMAINS_AND_PORTS.md
- docs/INTERFACES.md
- features/*
- fixKnowledge/*
This commit is contained in:
ncantu 2026-01-28 15:11:59 +01:00
parent 4d3028da0c
commit fe7f49b6cd
42 changed files with 4266 additions and 548 deletions

View File

@ -1,87 +0,0 @@
# État du Système Bitcoin Signet
**Date** : 2026-01-23
## ✅ État de la Chaîne
- **Chaîne** : Signet
- **Hauteur** : 0 (bloc genesis)
- **Hash du meilleur bloc** : `00000008819873e925422c1ff0f99f7cc9bbb232af63a077a480a3633bee1ef6`
- **Statut** : ✅ **UP** - Chaîne opérationnelle
## ✅ État du Miner
- **bitcoind** : ✅ **UP** - Processus actif (PID visible)
- **mine.sh** : ✅ **UP** - Script de mining actif
- **Statut** : ✅ **UP** - Miner opérationnel
## 💰 Balance du Miner
- **Balance mature** : 0.00000000 BTC
- **Balance immature** : 0.00000000 BTC
- **Total** : 0.00000000 BTC
**Note** : La balance est à 0 car aucun bloc n'a encore été miné depuis le bloc genesis. Le prochain bloc miné apportera la récompense de minage.
## ⚠️ API d'Ancrage
- **Port** : 3010
- **Statut** : ❌ **DOWN** - API non accessible
- **Action requise** : Lancer l'API d'ancrage
## ⚠️ Dashboard et API Faucet
- **Node.js** : ❌ **NON INSTALLÉ** sur cette machine
- **Dashboard (port 3020)** : ❌ **NON LANCÉ** - Nécessite Node.js
- **API Faucet (port 3021)** : ❌ **NON LANCÉ** - Nécessite Node.js
## Actions Requises
### Pour lancer le Dashboard et l'API Faucet
1. **Installer Node.js** (version >= 18.0.0) :
```bash
# Sur Ubuntu/Debian
curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash -
sudo apt-get install -y nodejs
```
2. **Installer les dépendances** :
```bash
cd signet-dashboard && npm install
cd ../api-faucet && npm install
```
3. **Configurer les fichiers .env** :
```bash
cd signet-dashboard && cp .env.example .env
cd ../api-faucet && cp .env.example .env
```
4. **Lancer les services** :
```bash
# Dashboard
cd signet-dashboard && npm start &
# API Faucet
cd api-faucet && npm start &
```
### Pour lancer l'API d'Ancrage
Vérifier si l'API d'ancrage est configurée et la lancer si nécessaire.
## Vérification
Une fois les services lancés, vérifier avec :
```bash
# Dashboard
curl http://localhost:3020/api/blockchain/info
# API Faucet
curl http://localhost:3021/health
# API Anchor
curl http://localhost:3010/health
```

View File

@ -0,0 +1,282 @@
# Monitoring et Maintenance de l'API d'Ancrage
**Auteur:** Équipe 4NK
**Date:** 2026-01-28
## Vue d'ensemble
Ce document décrit les outils de monitoring et de maintenance pour l'API d'ancrage Bitcoin Signet.
## Endpoints de Monitoring
### GET `/health`
Vérifie l'état de base de l'API et de la connexion Bitcoin.
**Exemple:**
```bash
curl https://anchorage.certificator.4nkweb.com/health
```
**Réponse:**
```json
{
"ok": true,
"service": "anchor-api",
"bitcoin": {
"connected": true,
"blocks": 10833
},
"timestamp": "2026-01-28T11:45:00.000Z"
}
```
### GET `/health/detailed`
Vérifie l'état détaillé de l'API, incluant l'état du mutex, des UTXOs verrouillés et de la connexion Bitcoin.
**Exemple:**
```bash
curl https://anchorage.certificator.4nkweb.com/health/detailed
```
**Réponse (200 OK):**
```json
{
"ok": true,
"service": "anchor-api",
"mutex": {
"locked": false,
"waiting": 0,
"timeout": 180000
},
"utxos": {
"locked": 0,
"locked_since": null,
"stale_locks": 0,
"stale_locks_details": []
},
"bitcoin": {
"connected": true,
"blocks": 10833,
"chain": "signet",
"rpc_timeout": 60000
},
"timestamp": "2026-01-28T11:45:00.000Z"
}
```
**Codes de statut:**
- `200` - Tout fonctionne correctement
- `200` - Warning si 5-10 UTXOs verrouillés (mais < 10 min)
- `503` - Service Unavailable si UTXOs verrouillés depuis > 10 min ou > 10 UTXOs verrouillés
- `503` - Service Unavailable si Bitcoin non connecté
### GET `/api/anchor/locked-utxos`
Liste tous les UTXOs actuellement verrouillés.
**Exemple:**
```bash
curl https://anchorage.certificator.4nkweb.com/api/anchor/locked-utxos
```
**Réponse:**
```json
{
"locked": [
{
"txid": "abc123...",
"vout": 0
}
],
"count": 1
}
```
## Scripts de Maintenance
### 1. `unlock-utxos.mjs`
Déverrouille tous les UTXOs verrouillés dans la base de données.
**Utilisation:**
```bash
cd /home/ncantu/Bureau/code/bitcoin/api-anchorage
node unlock-utxos.mjs
```
**Sortie:**
```
✅ UTXOs déverrouillés: 22
```
### 2. `cleanup-stale-locks.mjs`
Déverrouille automatiquement les UTXOs verrouillés depuis plus de 10 minutes.
**Utilisation:**
```bash
cd /home/ncantu/Bureau/code/bitcoin/api-anchorage
node cleanup-stale-locks.mjs
```
**Sortie:**
```
✅ UTXOs déverrouillés: 5
```
**Cron job recommandé (toutes les 5 minutes):**
```bash
*/5 * * * * cd /home/ncantu/Bureau/code/bitcoin/api-anchorage && node cleanup-stale-locks.mjs >> /var/log/anchorage-cleanup.log 2>&1
```
### 3. `diagnose.mjs`
Affiche un diagnostic complet de l'état des UTXOs verrouillés.
**Utilisation:**
```bash
cd /home/ncantu/Bureau/code/bitcoin/api-anchorage
node diagnose.mjs
```
**Sortie:**
```
📊 UTXOs verrouillés: 3
Détails des UTXOs verrouillés:
🔒 [1] abc123... vout:0 amount:0.000025 locked_for:5.2min
⚠️ STALE [2] def456... vout:1 amount:0.000025 locked_for:15.5min
⚠️ UTXOs verrouillés depuis plus de 10 minutes: 1
Action recommandée: Exécuter cleanup-stale-locks.mjs
📈 Statistiques UTXOs:
Total: 150
Verrouillés: 3
Dépensés: 50
Disponibles: 97
```
## Monitoring des Logs
### Timeouts de mutex
**Vérification:**
```bash
sudo journalctl -u anchorage-api --since "1 hour ago" | grep -c "Mutex acquisition timeout"
```
**Alerte recommandée:** Si > 1 timeout par heure
### Erreurs RPC Bitcoin
**Vérification:**
```bash
sudo journalctl -u anchorage-api --since "1 hour ago" | grep -c "ESOCKETTIMEDOUT\|ETIMEDOUT"
```
**Alerte recommandée:** Si > 1 erreur RPC par heure
### Opérations longues
**Vérification:**
```bash
sudo journalctl -u anchorage-api --since "1 hour ago" | grep "took too long"
```
**Alerte recommandée:** Si > 1 opération > 30s par heure
## Alertes Recommandées
### Niveau Warning
- **UTXOs verrouillés > 5:** Plus de 5 UTXOs verrouillés
- **Timeout de mutex:** 1+ timeout par heure
- **Erreur RPC:** 1+ erreur RPC par heure
### Niveau Critical
- **UTXOs verrouillés > 10:** Plus de 10 UTXOs verrouillés
- **UTXOs stale:** UTXOs verrouillés depuis > 10 minutes
- **Timeout de mutex:** 5+ timeouts par heure
- **Erreur RPC:** 5+ erreurs RPC par heure
## Configuration Cron
### Nettoyage automatique (recommandé)
Créer un fichier `/etc/cron.d/anchorage-cleanup`:
```bash
# Nettoyage automatique des UTXOs verrouillés depuis plus de 10 minutes
# Toutes les 5 minutes
*/5 * * * * ncantu cd /home/ncantu/Bureau/code/bitcoin/api-anchorage && /usr/bin/node cleanup-stale-locks.mjs >> /var/log/anchorage-cleanup.log 2>&1
```
### Diagnostic périodique (optionnel)
Créer un fichier `/etc/cron.d/anchorage-diagnose`:
```bash
# Diagnostic de l'état des UTXOs
# Toutes les heures
0 * * * * ncantu cd /home/ncantu/Bureau/code/bitcoin/api-anchorage && /usr/bin/node diagnose.mjs >> /var/log/anchorage-diagnose.log 2>&1
```
## Procédure de Dépannage
### 1. Vérifier l'état actuel
```bash
# Health check détaillé
curl https://anchorage.certificator.4nkweb.com/health/detailed | jq .
# UTXOs verrouillés
curl https://anchorage.certificator.4nkweb.com/api/anchor/locked-utxos | jq '.count'
```
### 2. Si UTXOs verrouillés
```bash
# Diagnostic
cd /home/ncantu/Bureau/code/bitcoin/api-anchorage
node diagnose.mjs
# Déverrouiller manuellement
node unlock-utxos.mjs
# Ou nettoyer seulement les stale locks
node cleanup-stale-locks.mjs
```
### 3. Si mutex bloqué
```bash
# Redémarrer le service
sudo systemctl restart anchorage-api
# Vérifier les logs
sudo journalctl -u anchorage-api -n 50 --no-pager
```
### 4. Si erreurs RPC Bitcoin
```bash
# Vérifier la connexion Bitcoin
curl -s --user bitcoin:bitcoin --data-binary '{"jsonrpc":"1.0","id":"test","method":"getblockchaininfo","params":[]}' -H 'content-type: text/plain;' http://localhost:38332/
# Vérifier les logs
sudo journalctl -u anchorage-api --since "10 minutes ago" | grep "ESOCKETTIMEDOUT"
```
## Pages Affectées
- `api-anchorage/src/routes/health.js`: Endpoint `/health/detailed`
- `api-anchorage/src/bitcoin-rpc.js`: Monitoring de durée, timeout de sécurité, retry avec backoff
- `api-anchorage/src/server.js`: Déverrouillage automatique au démarrage
- `api-anchorage/unlock-utxos.mjs`: Script de déverrouillage (existant)
- `api-anchorage/cleanup-stale-locks.mjs`: Script de nettoyage automatique (nouveau)
- `api-anchorage/diagnose.mjs`: Script de diagnostic (nouveau)
- `api-anchorage/README-MONITORING.md`: Documentation (nouveau)

View File

@ -0,0 +1,24 @@
#!/usr/bin/env node
/**
* Script pour déverrouiller automatiquement les UTXOs verrouillés depuis plus de 10 minutes
* À exécuter via cron job toutes les 5 minutes
*/
import { getDatabase } from './src/database.js';
const db = getDatabase();
const result = db.prepare(`
UPDATE utxos
SET is_locked_in_mutex = 0
WHERE is_locked_in_mutex = 1
AND updated_at < datetime('now', '-10 minutes')
`).run();
if (result.changes > 0) {
console.log(`✅ UTXOs déverrouillés: ${result.changes}`);
} else {
console.log(' Aucun UTXO à déverrouiller');
}
db.close();

55
api-anchorage/diagnose.mjs Executable file
View File

@ -0,0 +1,55 @@
#!/usr/bin/env node
/**
* Script de diagnostic pour l'API d'ancrage
* Affiche l'état des UTXOs verrouillés, leur durée de verrouillage, etc.
*/
import { getDatabase } from './src/database.js';
const db = getDatabase();
// UTXOs verrouillés
const locked = db.prepare(`
SELECT txid, vout, address, amount, updated_at,
(julianday('now') - julianday(updated_at)) * 24 * 60 as minutes_locked
FROM utxos
WHERE is_locked_in_mutex = 1
ORDER BY updated_at
`).all();
console.log(`\n📊 UTXOs verrouillés: ${locked.length}`);
if (locked.length > 0) {
console.log('\nDétails des UTXOs verrouillés:');
locked.forEach((u, index) => {
const minutes = Math.round(u.minutes_locked * 100) / 100;
const status = minutes > 10 ? '⚠️ STALE' : '🔒';
console.log(`${status} [${index + 1}] ${u.txid.substring(0, 16)}... vout:${u.vout} amount:${u.amount} locked_for:${minutes}min`);
});
}
// UTXOs verrouillés depuis plus de 10 minutes
const stale = locked.filter(u => u.minutes_locked > 10);
if (stale.length > 0) {
console.log(`\n⚠️ UTXOs verrouillés depuis plus de 10 minutes: ${stale.length}`);
console.log(' Action recommandée: Exécuter cleanup-stale-locks.mjs');
}
// Statistiques générales
const stats = db.prepare(`
SELECT
COUNT(*) as total,
SUM(CASE WHEN is_locked_in_mutex = 1 THEN 1 ELSE 0 END) as locked,
SUM(CASE WHEN is_spent_onchain = 1 THEN 1 ELSE 0 END) as spent,
SUM(CASE WHEN is_spent_onchain = 0 AND is_locked_in_mutex = 0 AND confirmations > 0 THEN 1 ELSE 0 END) as available
FROM utxos
`).get();
console.log(`\n📈 Statistiques UTXOs:`);
console.log(` Total: ${stats.total}`);
console.log(` Verrouillés: ${stats.locked}`);
console.log(` Dépensés: ${stats.spent}`);
console.log(` Disponibles: ${stats.available}`);
db.close();

View File

@ -26,6 +26,8 @@ class BitcoinRPC {
// Mutex pour gérer l'accès concurrent aux UTXOs // Mutex pour gérer l'accès concurrent aux UTXOs
// Utilise une Promise-based queue pour sérialiser les accès // Utilise une Promise-based queue pour sérialiser les accès
this.utxoMutexPromise = Promise.resolve(); this.utxoMutexPromise = Promise.resolve();
this.utxoMutexLocked = false;
this.utxoMutexWaiting = 0;
// Timeout pour l'attente du mutex (180s = 3 minutes) // Timeout pour l'attente du mutex (180s = 3 minutes)
// Si une requête prend plus de 180s, elle sera automatiquement libérée // Si une requête prend plus de 180s, elle sera automatiquement libérée
@ -35,6 +37,17 @@ class BitcoinRPC {
// via is_locked_in_mutex pour éviter la duplication et réduire la consommation mémoire // via is_locked_in_mutex pour éviter la duplication et réduire la consommation mémoire
} }
/**
* Obtient l'état actuel du mutex
* @returns {Object} État du mutex
*/
getMutexState() {
return {
locked: this.utxoMutexLocked,
waiting: this.utxoMutexWaiting,
};
}
/** /**
* Acquiert le mutex pour l'accès aux UTXOs avec timeout * Acquiert le mutex pour l'accès aux UTXOs avec timeout
* @returns {Promise<Function>} Fonction pour libérer le mutex * @returns {Promise<Function>} Fonction pour libérer le mutex
@ -45,9 +58,16 @@ class BitcoinRPC {
let releaseMutex; let releaseMutex;
let timeoutId; let timeoutId;
// Incrémenter le compteur d'attente
this.utxoMutexWaiting++;
// Créer une nouvelle Promise qui sera résolue quand le mutex est libéré // Créer une nouvelle Promise qui sera résolue quand le mutex est libéré
this.utxoMutexPromise = new Promise((resolve) => { this.utxoMutexPromise = new Promise((resolve) => {
releaseMutex = resolve; releaseMutex = () => {
this.utxoMutexLocked = false;
this.utxoMutexWaiting = Math.max(0, this.utxoMutexWaiting - 1);
resolve();
};
}); });
// Créer une Promise avec timeout pour éviter les blocages indéfinis // Créer une Promise avec timeout pour éviter les blocages indéfinis
@ -58,6 +78,7 @@ class BitcoinRPC {
logger.warn('Mutex acquisition timeout, forcing release', { logger.warn('Mutex acquisition timeout, forcing release', {
timeout: this.utxoMutexTimeout, timeout: this.utxoMutexTimeout,
}); });
this.utxoMutexWaiting = Math.max(0, this.utxoMutexWaiting - 1);
reject(new Error(`Mutex acquisition timeout after ${this.utxoMutexTimeout}ms`)); reject(new Error(`Mutex acquisition timeout after ${this.utxoMutexTimeout}ms`));
}, this.utxoMutexTimeout); }, this.utxoMutexTimeout);
}), }),
@ -65,6 +86,8 @@ class BitcoinRPC {
try { try {
await mutexWithTimeout; await mutexWithTimeout;
this.utxoMutexLocked = true;
this.utxoMutexWaiting = Math.max(0, this.utxoMutexWaiting - 1);
} finally { } finally {
if (timeoutId) { if (timeoutId) {
clearTimeout(timeoutId); clearTimeout(timeoutId);
@ -174,8 +197,8 @@ class BitcoinRPC {
*/ */
async checkConnection() { async checkConnection() {
try { try {
const networkInfo = await this.client.getNetworkInfo(); const networkInfo = await this.callRPCWithRetry('getNetworkInfo', []);
const blockchainInfo = await this.client.getBlockchainInfo(); const blockchainInfo = await this.callRPCWithRetry('getBlockchainInfo', []);
return { return {
connected: true, connected: true,
@ -193,13 +216,78 @@ class BitcoinRPC {
} }
} }
/**
* Appelle une méthode RPC avec retry et backoff exponentiel
* @param {string} method - Nom de la méthode RPC
* @param {Array} params - Paramètres de la méthode
* @param {number} maxRetries - Nombre maximum de tentatives (défaut: 3)
* @returns {Promise<any>} Résultat de l'appel RPC
*/
async callRPCWithRetry(method, params = [], maxRetries = 3) {
for (let i = 0; i < maxRetries; i++) {
try {
return await this.client[method](...params);
} catch (error) {
const isTimeoutError = error.message.includes('ESOCKETTIMEDOUT') ||
error.message.includes('ETIMEDOUT') ||
error.message.includes('timeout');
if (i === maxRetries - 1 || !isTimeoutError) {
throw error;
}
const delay = Math.min(1000 * Math.pow(2, i), 10000); // Backoff exponentiel, max 10s
logger.warn(`RPC call failed, retrying in ${delay}ms`, {
method,
attempt: i + 1,
maxRetries,
error: error.message,
});
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
}
/**
* Appelle une commande RPC avec retry et backoff exponentiel
* @param {string} command - Nom de la commande RPC
* @param {...any} args - Arguments de la commande
* @param {number} maxRetries - Nombre maximum de tentatives (défaut: 3)
* @returns {Promise<any>} Résultat de l'appel RPC
*/
async callRPCCommandWithRetry(command, ...args) {
const maxRetries = 3;
for (let i = 0; i < maxRetries; i++) {
try {
return await this.client.command(command, ...args);
} catch (error) {
const isTimeoutError = error.message.includes('ESOCKETTIMEDOUT') ||
error.message.includes('ETIMEDOUT') ||
error.message.includes('timeout');
if (i === maxRetries - 1 || !isTimeoutError) {
throw error;
}
const delay = Math.min(1000 * Math.pow(2, i), 10000); // Backoff exponentiel, max 10s
logger.warn(`RPC command failed, retrying in ${delay}ms`, {
command,
attempt: i + 1,
maxRetries,
error: error.message,
});
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
}
/** /**
* Obtient une nouvelle adresse depuis le wallet * Obtient une nouvelle adresse depuis le wallet
* @returns {Promise<string>} Adresse Bitcoin * @returns {Promise<string>} Adresse Bitcoin
*/ */
async getNewAddress() { async getNewAddress() {
try { try {
return await this.client.getNewAddress(); return await this.callRPCWithRetry('getNewAddress', []);
} catch (error) { } catch (error) {
logger.error('Error getting new address', { error: error.message }); logger.error('Error getting new address', { error: error.message });
throw new Error(`Failed to get new address: ${error.message}`); throw new Error(`Failed to get new address: ${error.message}`);
@ -212,7 +300,7 @@ class BitcoinRPC {
*/ */
async getBalance() { async getBalance() {
try { try {
return await this.client.getBalance(); return await this.callRPCWithRetry('getBalance', []);
} catch (error) { } catch (error) {
logger.error('Error getting balance', { error: error.message }); logger.error('Error getting balance', { error: error.message });
throw new Error(`Failed to get balance: ${error.message}`); throw new Error(`Failed to get balance: ${error.message}`);
@ -228,10 +316,21 @@ class BitcoinRPC {
* @returns {Promise<Object>} Transaction créée avec txid * @returns {Promise<Object>} Transaction créée avec txid
*/ */
async createAnchorTransaction(hash, recipientAddress = null, provisioningAddresses = null, numberOfProvisioningUtxos = null, retryCount = 0) { async createAnchorTransaction(hash, recipientAddress = null, provisioningAddresses = null, numberOfProvisioningUtxos = null, retryCount = 0) {
const startTime = Date.now();
// Acquérir le mutex pour l'accès aux UTXOs // Acquérir le mutex pour l'accès aux UTXOs
const releaseMutex = await this.acquireUtxoMutex(); const releaseMutex = await this.acquireUtxoMutex();
let selectedUtxo = null; let selectedUtxo = null;
let selectedUtxos = []; let selectedUtxos = [];
let mutexSafetyTimeout;
// Timeout de sécurité: libérer le mutex après 5 minutes maximum
mutexSafetyTimeout = setTimeout(() => {
logger.error('Mutex held for too long, forcing release', {
hash: hash?.substring(0, 16) + '...',
duration: Date.now() - startTime,
});
releaseMutex();
}, 300000); // 5 minutes
try { try {
// Vérifier que le hash est valide (64 caractères hex) // Vérifier que le hash est valide (64 caractères hex)
@ -245,12 +344,21 @@ class BitcoinRPC {
const provisioningCount = numberOfProvisioningUtxos ?? 7; const provisioningCount = numberOfProvisioningUtxos ?? 7;
const addressesNeeded = 1 + provisioningCount + 1; // principal + provisioning + change const addressesNeeded = 1 + provisioningCount + 1; // principal + provisioning + change
// Générer toutes les adresses en parallèle // Générer toutes les adresses en parallèle avec timeout
const addressPromises = []; const addressPromises = [];
for (let i = 0; i < addressesNeeded; i++) { for (let i = 0; i < addressesNeeded; i++) {
addressPromises.push(this.getNewAddress()); addressPromises.push(this.getNewAddress());
} }
const allAddresses = await Promise.all(addressPromises);
// Timeout de 30 secondes pour la génération d'adresses
const allAddresses = await Promise.race([
Promise.all(addressPromises),
new Promise((_, reject) => {
setTimeout(() => {
reject(new Error('Address generation timeout after 30s'));
}, 30000);
}),
]);
// Utiliser l'adresse fournie ou la première générée // Utiliser l'adresse fournie ou la première générée
const address = recipientAddress || allAddresses[0]; const address = recipientAddress || allAddresses[0];
@ -499,7 +607,7 @@ class BitcoinRPC {
try { try {
// Récupérer toutes les adresses uniques des UTXOs sélectionnés // Récupérer toutes les adresses uniques des UTXOs sélectionnés
const uniqueAddresses = [...new Set(selectedUtxos.map(u => u.address))]; const uniqueAddresses = [...new Set(selectedUtxos.map(u => u.address))];
const utxoCheck = await this.client.listunspent(0, 9999999, uniqueAddresses); const utxoCheck = await this.callRPCWithRetry('listunspent', [0, 9999999, uniqueAddresses]);
// Vérifier que tous les UTXOs sont toujours disponibles // Vérifier que tous les UTXOs sont toujours disponibles
let allUtxosAvailable = true; let allUtxosAvailable = true;
@ -563,7 +671,7 @@ class BitcoinRPC {
let tx; let tx;
try { try {
tx = await this.client.command('createrawtransaction', inputs, outputs); tx = await this.callRPCCommandWithRetry('createrawtransaction', inputs, outputs);
} catch (error) { } catch (error) {
logger.error('Error creating raw transaction', { logger.error('Error creating raw transaction', {
error: error.message, error: error.message,
@ -591,7 +699,7 @@ class BitcoinRPC {
// Signer la transaction // Signer la transaction
// Utiliser command() directement pour éviter les problèmes avec la bibliothèque // Utiliser command() directement pour éviter les problèmes avec la bibliothèque
const signedTx = await this.client.command('signrawtransactionwithwallet', tx); const signedTx = await this.callRPCCommandWithRetry('signrawtransactionwithwallet', tx);
if (!signedTx.complete) { if (!signedTx.complete) {
const errorDetails = signedTx.errors || []; const errorDetails = signedTx.errors || [];
@ -647,7 +755,7 @@ class BitcoinRPC {
// Le test direct avec bitcoin-cli fonctionne avec cette syntaxe // Le test direct avec bitcoin-cli fonctionne avec cette syntaxe
let txid; let txid;
try { try {
txid = await this.client.command('sendrawtransaction', signedTx.hex, 0); txid = await this.callRPCCommandWithRetry('sendrawtransaction', signedTx.hex, 0);
} catch (sendError) { } catch (sendError) {
// Gérer l'erreur de remplacement RBF (Replace By Fee) // Gérer l'erreur de remplacement RBF (Replace By Fee)
// Si une transaction avec les mêmes inputs existe déjà, Bitcoin Core rejette la nouvelle // Si une transaction avec les mêmes inputs existe déjà, Bitcoin Core rejette la nouvelle
@ -675,7 +783,7 @@ class BitcoinRPC {
// Vérifier si la transaction existe dans le mempool ou dans la blockchain // Vérifier si la transaction existe dans le mempool ou dans la blockchain
try { try {
const mempoolEntry = await this.client.command('getmempoolentry', existingTxid); const mempoolEntry = await this.callRPCCommandWithRetry('getmempoolentry', existingTxid);
if (mempoolEntry) { if (mempoolEntry) {
// La transaction existe dans le mempool, utiliser cette transaction // La transaction existe dans le mempool, utiliser cette transaction
txid = existingTxid; txid = existingTxid;
@ -696,7 +804,7 @@ class BitcoinRPC {
if (errorMsg.includes('not in mempool') || errorMsg.includes('Transaction not in mempool')) { if (errorMsg.includes('not in mempool') || errorMsg.includes('Transaction not in mempool')) {
// La transaction n'est pas dans le mempool, vérifier si elle est confirmée // La transaction n'est pas dans le mempool, vérifier si elle est confirmée
try { try {
const txInfo = await this.client.getTransaction(existingTxid); const txInfo = await this.callRPCWithRetry('getTransaction', [existingTxid]);
if (txInfo && txInfo.txid) { if (txInfo && txInfo.txid) {
// La transaction existe dans la blockchain (confirmée), utiliser cette transaction // La transaction existe dans la blockchain (confirmée), utiliser cette transaction
txid = existingTxid; txid = existingTxid;
@ -753,7 +861,7 @@ class BitcoinRPC {
const txInfo = await this.getTransactionInfo(txid); const txInfo = await this.getTransactionInfo(txid);
// Obtenir la transaction brute pour identifier les index des outputs // Obtenir la transaction brute pour identifier les index des outputs
const rawTx = await this.client.getRawTransaction(txid, true); const rawTx = await this.callRPCWithRetry('getRawTransaction', [txid, true]);
// Calculer les frais réels de la transaction // Calculer les frais réels de la transaction
// Frais = somme des inputs - somme des outputs // Frais = somme des inputs - somme des outputs
@ -906,6 +1014,20 @@ class BitcoinRPC {
// Le mutex sera libéré dans le bloc finally pour garantir la libération même en cas d'erreur non gérée // Le mutex sera libéré dans le bloc finally pour garantir la libération même en cas d'erreur non gérée
throw error; throw error;
} finally { } finally {
// Nettoyer le timeout de sécurité
if (mutexSafetyTimeout) {
clearTimeout(mutexSafetyTimeout);
}
// Logger la durée de l'opération
const duration = Date.now() - startTime;
if (duration > 30000) {
logger.warn('Anchor transaction took too long', {
duration,
hash: hash?.substring(0, 16) + '...',
});
}
// Garantir que le mutex est toujours libéré, même en cas d'erreur non gérée // Garantir que le mutex est toujours libéré, même en cas d'erreur non gérée
try { try {
releaseMutex(); releaseMutex();
@ -922,8 +1044,8 @@ class BitcoinRPC {
*/ */
async getTransactionInfo(txid) { async getTransactionInfo(txid) {
try { try {
const tx = await this.client.getTransaction(txid); const tx = await this.callRPCWithRetry('getTransaction', [txid]);
const blockchainInfo = await this.client.getBlockchainInfo(); const blockchainInfo = await this.callRPCWithRetry('getBlockchainInfo', []);
return { return {
txid: tx.txid, txid: tx.txid,
@ -956,8 +1078,8 @@ class BitcoinRPC {
// Si un txid est fourni, vérifier directement cette transaction // Si un txid est fourni, vérifier directement cette transaction
if (txid) { if (txid) {
try { try {
const tx = await this.client.getTransaction(txid, true); const tx = await this.callRPCWithRetry('getTransaction', [txid, true]);
const rawTx = await this.client.getRawTransaction(txid, true); const rawTx = await this.callRPCWithRetry('getRawTransaction', [txid, true]);
// Vérifier si le hash est dans les outputs OP_RETURN // Vérifier si le hash est dans les outputs OP_RETURN
const hashFound = this.checkHashInTransaction(rawTx, hash); const hashFound = this.checkHashInTransaction(rawTx, hash);
@ -979,19 +1101,19 @@ class BitcoinRPC {
} }
// Rechercher dans les blocs récents (derniers 100 blocs) // Rechercher dans les blocs récents (derniers 100 blocs)
const blockchainInfo = await this.client.getBlockchainInfo(); const blockchainInfo = await this.callRPCWithRetry('getBlockchainInfo', []);
const currentHeight = blockchainInfo.blocks; const currentHeight = blockchainInfo.blocks;
const searchRange = 100; // Rechercher dans les 100 derniers blocs const searchRange = 100; // Rechercher dans les 100 derniers blocs
for (let height = currentHeight; height >= Math.max(0, currentHeight - searchRange); height--) { for (let height = currentHeight; height >= Math.max(0, currentHeight - searchRange); height--) {
try { try {
const blockHash = await this.client.getBlockHash(height); const blockHash = await this.callRPCWithRetry('getBlockHash', [height]);
const block = await this.client.getBlock(blockHash, 2); // Verbose level 2 const block = await this.callRPCWithRetry('getBlock', [blockHash, 2]); // Verbose level 2
// Parcourir toutes les transactions du bloc // Parcourir toutes les transactions du bloc
for (const tx of block.tx || []) { for (const tx of block.tx || []) {
try { try {
const rawTx = await this.client.getRawTransaction(tx.txid, true); const rawTx = await this.callRPCWithRetry('getRawTransaction', [tx.txid, true]);
const hashFound = this.checkHashInTransaction(rawTx, hash); const hashFound = this.checkHashInTransaction(rawTx, hash);
if (hashFound) { if (hashFound) {

View File

@ -41,6 +41,38 @@ anchorRouter.get('/locked-utxos', async (req, res) => {
} }
}); });
/**
* POST /api/anchor/unlock-utxos
* Déverrouille tous les UTXOs verrouillés dans la base de données
*
* Authentification : Requise (header `x-api-key`)
*/
anchorRouter.post('/unlock-utxos', async (req, res) => {
try {
const { getDatabase } = await import('../database.js');
const db = getDatabase();
const result = db.prepare(`
UPDATE utxos
SET is_locked_in_mutex = 0
WHERE is_locked_in_mutex = 1
`).run();
logger.info('UTXOs unlocked via API', { count: result.changes });
res.status(200).json({
ok: true,
unlocked: result.changes,
message: `${result.changes} UTXO(s) déverrouillé(s)`,
});
} catch (error) {
logger.error('Error unlocking UTXOs', { error: error.message });
res.status(500).json({
error: 'Internal Server Error',
message: error.message,
});
}
});
/** /**
* POST /api/anchor/document * POST /api/anchor/document
* Ancre un document sur Bitcoin Signet * Ancre un document sur Bitcoin Signet
@ -145,6 +177,38 @@ anchorRouter.post('/document', async (req, res) => {
} }
}); });
/**
* POST /api/anchor/unlock-utxos
* Déverrouille tous les UTXOs verrouillés dans la base de données
*
* Authentification : Requise (header `x-api-key`)
*/
anchorRouter.post('/unlock-utxos', async (req, res) => {
try {
const { getDatabase } = await import('../database.js');
const db = getDatabase();
const result = db.prepare(`
UPDATE utxos
SET is_locked_in_mutex = 0
WHERE is_locked_in_mutex = 1
`).run();
logger.info('UTXOs unlocked via API', { count: result.changes });
res.status(200).json({
ok: true,
unlocked: result.changes,
message: `${result.changes} UTXO(s) déverrouillé(s)`,
});
} catch (error) {
logger.error('Error unlocking UTXOs', { error: error.message });
res.status(500).json({
error: 'Internal Server Error',
message: error.message,
});
}
});
/** /**
* POST /api/anchor/verify * POST /api/anchor/verify
* Vérifie si un hash est ancré sur Bitcoin Signet * Vérifie si un hash est ancré sur Bitcoin Signet

View File

@ -5,6 +5,7 @@
import express from 'express'; import express from 'express';
import { bitcoinRPC } from '../bitcoin-rpc.js'; import { bitcoinRPC } from '../bitcoin-rpc.js';
import { logger } from '../logger.js'; import { logger } from '../logger.js';
import { getDatabase } from '../database.js';
export const healthRouter = express.Router(); export const healthRouter = express.Router();
@ -38,3 +39,77 @@ healthRouter.get('/', async (req, res) => {
}); });
} }
}); });
/**
* GET /health/detailed
* Vérifie l'état détaillé de l'API, du mutex, des UTXOs et de la connexion Bitcoin
*/
healthRouter.get('/detailed', async (req, res) => {
try {
const bitcoinStatus = await bitcoinRPC.checkConnection();
// Vérifier l'état du mutex
const mutexState = bitcoinRPC.getMutexState();
// Vérifier les UTXOs verrouillés
const db = getDatabase();
const lockedUtxos = db.prepare(`
SELECT txid, vout, address, amount, updated_at,
(julianday('now') - julianday(updated_at)) * 24 * 60 as minutes_locked
FROM utxos
WHERE is_locked_in_mutex = 1
ORDER BY updated_at
`).all();
const staleLocks = lockedUtxos.filter(u => u.minutes_locked > 10);
const health = {
ok: bitcoinStatus.connected && lockedUtxos.length === 0 && staleLocks.length === 0,
service: 'anchor-api',
mutex: {
locked: mutexState.locked,
waiting: mutexState.waiting,
timeout: 180000, // 3 minutes (180000ms)
},
utxos: {
locked: lockedUtxos.length,
locked_since: lockedUtxos.length > 0 ? lockedUtxos[0].updated_at : null,
stale_locks: staleLocks.length,
stale_locks_details: staleLocks.map(u => ({
txid: u.txid.substring(0, 16) + '...',
vout: u.vout,
minutes_locked: Math.round(u.minutes_locked * 100) / 100,
})),
},
bitcoin: {
connected: bitcoinStatus.connected,
blocks: bitcoinStatus.blocks || 0,
chain: bitcoinStatus.chain || null,
rpc_timeout: parseInt(process.env.BITCOIN_RPC_TIMEOUT || '30000'),
},
timestamp: new Date().toISOString(),
};
// Déterminer le code de statut
let statusCode = 200;
if (!bitcoinStatus.connected) {
statusCode = 503;
} else if (staleLocks.length > 0) {
statusCode = 503; // UTXOs verrouillés depuis trop longtemps
} else if (lockedUtxos.length > 10) {
statusCode = 503; // Trop d'UTXOs verrouillés
} else if (lockedUtxos.length > 5) {
statusCode = 200; // Warning mais OK
}
res.status(statusCode).json(health);
} catch (error) {
logger.error('Detailed health check error', { error: error.message });
res.status(503).json({
ok: false,
service: 'anchor-api',
error: error.message,
timestamp: new Date().toISOString(),
});
}
});

View File

@ -51,8 +51,8 @@ app.use((req, res, next) => {
// Middleware d'authentification API Key // Middleware d'authentification API Key
app.use((req, res, next) => { app.use((req, res, next) => {
// Exclure /health et /api/anchor/locked-utxos de l'authentification // Exclure /health, /health/detailed et /api/anchor/locked-utxos de l'authentification
if (req.path === '/health' || req.path === '/' || req.path.startsWith('/api/anchor/locked-utxos')) { if (req.path === '/health' || req.path === '/health/detailed' || req.path === '/' || req.path.startsWith('/api/anchor/locked-utxos')) {
return next(); return next();
} }
@ -83,8 +83,10 @@ app.get('/', (req, res) => {
version: '1.0.0', version: '1.0.0',
endpoints: { endpoints: {
health: '/health', health: '/health',
healthDetailed: '/health/detailed',
anchor: '/api/anchor/document', anchor: '/api/anchor/document',
verify: '/api/anchor/verify', verify: '/api/anchor/verify',
lockedUtxos: '/api/anchor/locked-utxos',
}, },
}); });
}); });
@ -106,6 +108,24 @@ app.use((req, res) => {
}); });
}); });
// Déverrouiller automatiquement les UTXOs verrouillés depuis plus de 10 minutes au démarrage
try {
const { getDatabase } = await import('./database.js');
const db = getDatabase();
const result = db.prepare(`
UPDATE utxos
SET is_locked_in_mutex = 0
WHERE is_locked_in_mutex = 1
AND updated_at < datetime('now', '-10 minutes')
`).run();
if (result.changes > 0) {
logger.info('Unlocked stale UTXOs on startup', { count: result.changes });
}
} catch (error) {
logger.warn('Error unlocking stale UTXOs on startup', { error: error.message });
}
// Démarrage du serveur // Démarrage du serveur
const server = app.listen(PORT, HOST, () => { const server = app.listen(PORT, HOST, () => {
logger.info(`API d'ancrage Bitcoin Signet démarrée`, { logger.info(`API d'ancrage Bitcoin Signet démarrée`, {

14
api-anchorage/unlock-utxos.mjs Executable file
View File

@ -0,0 +1,14 @@
#!/usr/bin/env node
/**
* Script pour déverrouiller tous les UTXOs verrouillés dans la base de données
*/
import { getDatabase } from './src/database.js';
const db = getDatabase();
const result = db.prepare('UPDATE utxos SET is_locked_in_mutex = 0 WHERE is_locked_in_mutex = 1').run();
console.log(`✅ UTXOs déverrouillés: ${result.changes}`);
db.close();

143
check-anchor-api.sh Executable file
View File

@ -0,0 +1,143 @@
#!/bin/bash
# Script de diagnostic pour l'API d'Ancrage
# Vérifie l'état du service, la connectivité et les logs
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
echo "=== Diagnostic API d'Ancrage ==="
echo ""
# Configuration
BITCOIN_SERVER="192.168.1.105" # Machine bitcoin avec API d'ancrage
PROD_SERVER="192.168.1.103" # Machine prod (pour référence)
PROD_USER="ncantu"
PROXY_SERVER="4nk.myftp.biz"
API_URL="https://anchorage.certificator.4nkweb.com" # Domaine externe de l'API d'ancrage Bitcoin
SERVICE_NAME="anchorage-api"
PORT=3010
# Fonction pour exécuter une commande sur le serveur bitcoin (API d'ancrage)
run_on_bitcoin() {
ssh -J "${PROD_USER}@${PROXY_SERVER}" "${PROD_USER}@${BITCOIN_SERVER}" "$@"
}
echo "📡 Test de connectivité externe..."
echo ""
# Test 1: Vérifier que l'API répond depuis l'extérieur
echo "1. Test de l'endpoint /health depuis l'extérieur..."
HEALTH_RESPONSE=$(curl -s --max-time 10 "${API_URL}/health" || echo "FAILED")
if [ "$HEALTH_RESPONSE" = "FAILED" ]; then
echo "❌ L'API ne répond pas depuis l'extérieur"
echo ""
else
echo "✅ L'API répond depuis l'extérieur"
echo " Réponse: $HEALTH_RESPONSE"
echo ""
# Vérifier si la connexion Bitcoin fonctionne
if echo "$HEALTH_RESPONSE" | grep -q '"connected":true'; then
echo "✅ Connexion Bitcoin: OK"
BLOCKS=$(echo "$HEALTH_RESPONSE" | grep -o '"blocks":[0-9]*' | grep -o '[0-9]*' || echo "0")
echo " Blocs: $BLOCKS"
else
echo "⚠️ Connexion Bitcoin: PROBLÈME"
echo " L'API répond mais ne peut pas se connecter à Bitcoin Core"
fi
echo ""
fi
# Test 2: Vérifier l'endpoint racine
echo "2. Test de l'endpoint racine..."
ROOT_RESPONSE=$(curl -s --max-time 10 "${API_URL}/" || echo "FAILED")
if [ "$ROOT_RESPONSE" = "FAILED" ]; then
echo "❌ L'endpoint racine ne répond pas"
else
echo "✅ L'endpoint racine répond"
echo " Réponse: $ROOT_RESPONSE"
fi
echo ""
# Test 3: Vérifier l'état du service sur le serveur bitcoin
echo "3. État du service systemd sur le serveur bitcoin (192.168.1.105)..."
echo " (Connexion via SSH via proxy...)"
echo ""
SERVICE_STATUS=$(run_on_bitcoin "sudo systemctl status ${SERVICE_NAME} --no-pager" 2>&1 || echo "FAILED")
if echo "$SERVICE_STATUS" | grep -q "Active: active (running)"; then
echo "✅ Service actif et en cours d'exécution"
elif echo "$SERVICE_STATUS" | grep -q "Active: inactive"; then
echo "❌ Service inactif"
elif echo "$SERVICE_STATUS" | grep -q "Active: failed"; then
echo "❌ Service en échec"
else
echo "⚠️ État du service indéterminé"
fi
echo ""
# Test 4: Vérifier que le port est en écoute
echo "4. Vérification du port ${PORT}..."
PORT_CHECK=$(run_on_bitcoin "sudo ss -tlnp | grep :${PORT}" 2>&1 || echo "NOT_LISTENING")
if [ "$PORT_CHECK" = "NOT_LISTENING" ] || [ -z "$PORT_CHECK" ]; then
echo "❌ Le port ${PORT} n'est pas en écoute"
else
echo "✅ Le port ${PORT} est en écoute"
echo " $PORT_CHECK"
fi
echo ""
# Test 5: Vérifier les logs récents (dernières 50 lignes)
echo "5. Logs récents du service (dernières 50 lignes)..."
echo ""
run_on_bitcoin "sudo journalctl -u ${SERVICE_NAME} -n 50 --no-pager" 2>&1 | tail -50
echo ""
# Test 6: Vérifier les logs d'erreur récents
echo "6. Logs d'erreur récents (dernières 20 lignes)..."
echo ""
run_on_bitcoin "sudo journalctl -u ${SERVICE_NAME} -p err -n 20 --no-pager" 2>&1 | tail -20
echo ""
# Test 7: Vérifier la connexion Bitcoin RPC depuis le serveur bitcoin
echo "7. Test de connexion Bitcoin RPC depuis le serveur bitcoin..."
echo ""
BITCOIN_RPC_TEST=$(run_on_bitcoin "curl -s --user bitcoin:bitcoin --data-binary '{\"jsonrpc\":\"1.0\",\"id\":\"test\",\"method\":\"getblockchaininfo\",\"params\":[]}' -H 'content-type: text/plain;' http://localhost:38332/" 2>&1 || echo "FAILED")
if echo "$BITCOIN_RPC_TEST" | grep -q '"result"'; then
echo "✅ Bitcoin RPC répond"
BLOCKS_RPC=$(echo "$BITCOIN_RPC_TEST" | grep -o '"blocks":[0-9]*' | grep -o '[0-9]*' || echo "0")
echo " Blocs: $BLOCKS_RPC"
else
echo "❌ Bitcoin RPC ne répond pas"
echo " Réponse: $BITCOIN_RPC_TEST"
fi
echo ""
# Test 8: Vérifier l'état du conteneur Docker Bitcoin (si applicable)
echo "8. État du conteneur Docker Bitcoin..."
echo ""
DOCKER_STATUS=$(run_on_bitcoin "sudo docker ps --filter 'name=bitcoin' --format '{{.Names}} {{.Status}}'" 2>&1 || echo "NO_DOCKER")
if [ "$DOCKER_STATUS" = "NO_DOCKER" ] || [ -z "$DOCKER_STATUS" ]; then
echo "⚠️ Aucun conteneur Bitcoin trouvé ou Docker non disponible"
else
echo "✅ Conteneur Bitcoin:"
echo "$DOCKER_STATUS" | while read -r line; do
echo " $line"
done
fi
echo ""
# Résumé
echo "=== Résumé ==="
echo ""
echo "Pour redémarrer le service:"
echo " ssh -J ${PROD_USER}@${PROXY_SERVER} ${PROD_USER}@${BITCOIN_SERVER} 'sudo systemctl restart ${SERVICE_NAME}'"
echo ""
echo "Pour voir les logs en temps réel:"
echo " ssh -J ${PROD_USER}@${PROXY_SERVER} ${PROD_USER}@${BITCOIN_SERVER} 'sudo journalctl -u ${SERVICE_NAME} -f'"
echo ""
echo "Note: L'API d'ancrage Bitcoin est sur la machine 192.168.1.105 (bitcoin), pas sur 192.168.1.103 (prod)"
echo ""

View File

@ -1,100 +1,100 @@
⏳ Traitement: 200000/225882 UTXOs insérés... ⏳ Traitement: 190000/223585 UTXOs insérés...
⏳ Traitement: 210000/225882 UTXOs insérés... ⏳ Traitement: 200000/223585 UTXOs insérés...
⏳ Traitement: 220000/225882 UTXOs insérés... ⏳ Traitement: 210000/223585 UTXOs insérés...
⏳ Traitement: 220000/223585 UTXOs insérés...
💾 Mise à jour des UTXOs dépensés... 💾 Mise à jour des UTXOs dépensés...
📊 Résumé: 📊 Résumé:
- UTXOs vérifiés: 61565 - UTXOs vérifiés: 48651
- UTXOs toujours disponibles: 61565 - UTXOs toujours disponibles: 48651
- UTXOs dépensés détectés: 0 - UTXOs dépensés détectés: 0
📈 Statistiques finales: 📈 Statistiques finales:
- Total UTXOs: 68398 - Total UTXOs: 68398
- Dépensés: 6888 - Dépensés: 19747
- Non dépensés: 61510 - Non dépensés: 48651
✅ Synchronisation terminée ✅ Synchronisation terminée
🔍 Démarrage de la synchronisation des UTXOs dépensés... 🔍 Démarrage de la synchronisation des UTXOs dépensés...
📊 UTXOs à vérifier: 49190 📊 UTXOs à vérifier: 37046
📡 Récupération des UTXOs depuis Bitcoin... 📡 Récupération des UTXOs depuis Bitcoin...
📊 UTXOs disponibles dans Bitcoin: 223652 📊 UTXOs disponibles dans Bitcoin: 221494
💾 Création de la table temporaire... 💾 Création de la table temporaire...
💾 Insertion des UTXOs disponibles par batch... 💾 Insertion des UTXOs disponibles par batch...
⏳ Traitement: 10000/223652 UTXOs insérés... ⏳ Traitement: 10000/221494 UTXOs insérés...
⏳ Traitement: 20000/223652 UTXOs insérés... ⏳ Traitement: 20000/221494 UTXOs insérés...
⏳ Traitement: 30000/223652 UTXOs insérés... ⏳ Traitement: 30000/221494 UTXOs insérés...
⏳ Traitement: 40000/223652 UTXOs insérés... ⏳ Traitement: 40000/221494 UTXOs insérés...
⏳ Traitement: 50000/223652 UTXOs insérés... ⏳ Traitement: 50000/221494 UTXOs insérés...
⏳ Traitement: 60000/223652 UTXOs insérés... ⏳ Traitement: 60000/221494 UTXOs insérés...
⏳ Traitement: 70000/223652 UTXOs insérés... ⏳ Traitement: 70000/221494 UTXOs insérés...
⏳ Traitement: 80000/223652 UTXOs insérés... ⏳ Traitement: 80000/221494 UTXOs insérés...
⏳ Traitement: 90000/223652 UTXOs insérés... ⏳ Traitement: 90000/221494 UTXOs insérés...
⏳ Traitement: 100000/223652 UTXOs insérés... ⏳ Traitement: 100000/221494 UTXOs insérés...
⏳ Traitement: 110000/223652 UTXOs insérés... ⏳ Traitement: 110000/221494 UTXOs insérés...
⏳ Traitement: 120000/223652 UTXOs insérés... ⏳ Traitement: 120000/221494 UTXOs insérés...
⏳ Traitement: 130000/223652 UTXOs insérés... ⏳ Traitement: 130000/221494 UTXOs insérés...
⏳ Traitement: 140000/223652 UTXOs insérés... ⏳ Traitement: 140000/221494 UTXOs insérés...
⏳ Traitement: 150000/223652 UTXOs insérés... ⏳ Traitement: 150000/221494 UTXOs insérés...
⏳ Traitement: 160000/223652 UTXOs insérés... ⏳ Traitement: 160000/221494 UTXOs insérés...
⏳ Traitement: 170000/223652 UTXOs insérés... ⏳ Traitement: 170000/221494 UTXOs insérés...
⏳ Traitement: 180000/223652 UTXOs insérés... ⏳ Traitement: 180000/221494 UTXOs insérés...
⏳ Traitement: 190000/223652 UTXOs insérés... ⏳ Traitement: 190000/221494 UTXOs insérés...
⏳ Traitement: 200000/223652 UTXOs insérés... ⏳ Traitement: 200000/221494 UTXOs insérés...
⏳ Traitement: 210000/223652 UTXOs insérés... ⏳ Traitement: 210000/221494 UTXOs insérés...
⏳ Traitement: 220000/223652 UTXOs insérés... ⏳ Traitement: 220000/221494 UTXOs insérés...
💾 Mise à jour des UTXOs dépensés... 💾 Mise à jour des UTXOs dépensés...
📊 Résumé: 📊 Résumé:
- UTXOs vérifiés: 49190 - UTXOs vérifiés: 37046
- UTXOs toujours disponibles: 49190 - UTXOs toujours disponibles: 37046
- UTXOs dépensés détectés: 0 - UTXOs dépensés détectés: 0
📈 Statistiques finales: 📈 Statistiques finales:
- Total UTXOs: 68398 - Total UTXOs: 68398
- Dépensés: 19208 - Dépensés: 31352
- Non dépensés: 49190 - Non dépensés: 37046
✅ Synchronisation terminée ✅ Synchronisation terminée
🔍 Démarrage de la synchronisation des UTXOs dépensés... 🔍 Démarrage de la synchronisation des UTXOs dépensés...
📊 UTXOs à vérifier: 49190 📊 UTXOs à vérifier: 5146
📡 Récupération des UTXOs depuis Bitcoin... 📡 Récupération des UTXOs depuis Bitcoin...
📊 UTXOs disponibles dans Bitcoin: 223667 📊 UTXOs disponibles dans Bitcoin: 215703
💾 Création de la table temporaire... 💾 Création de la table temporaire...
💾 Insertion des UTXOs disponibles par batch... 💾 Insertion des UTXOs disponibles par batch...
⏳ Traitement: 10000/223667 UTXOs insérés... ⏳ Traitement: 10000/215703 UTXOs insérés...
⏳ Traitement: 20000/223667 UTXOs insérés... ⏳ Traitement: 20000/215703 UTXOs insérés...
⏳ Traitement: 30000/223667 UTXOs insérés... ⏳ Traitement: 30000/215703 UTXOs insérés...
⏳ Traitement: 40000/223667 UTXOs insérés... ⏳ Traitement: 40000/215703 UTXOs insérés...
⏳ Traitement: 50000/223667 UTXOs insérés... ⏳ Traitement: 50000/215703 UTXOs insérés...
⏳ Traitement: 60000/223667 UTXOs insérés... ⏳ Traitement: 60000/215703 UTXOs insérés...
⏳ Traitement: 70000/223667 UTXOs insérés... ⏳ Traitement: 70000/215703 UTXOs insérés...
⏳ Traitement: 80000/223667 UTXOs insérés... ⏳ Traitement: 80000/215703 UTXOs insérés...
⏳ Traitement: 90000/223667 UTXOs insérés... ⏳ Traitement: 90000/215703 UTXOs insérés...
⏳ Traitement: 100000/223667 UTXOs insérés... ⏳ Traitement: 100000/215703 UTXOs insérés...
⏳ Traitement: 110000/223667 UTXOs insérés... ⏳ Traitement: 110000/215703 UTXOs insérés...
⏳ Traitement: 120000/223667 UTXOs insérés... ⏳ Traitement: 120000/215703 UTXOs insérés...
⏳ Traitement: 130000/223667 UTXOs insérés... ⏳ Traitement: 130000/215703 UTXOs insérés...
⏳ Traitement: 140000/223667 UTXOs insérés... ⏳ Traitement: 140000/215703 UTXOs insérés...
⏳ Traitement: 150000/223667 UTXOs insérés... ⏳ Traitement: 150000/215703 UTXOs insérés...
⏳ Traitement: 160000/223667 UTXOs insérés... ⏳ Traitement: 160000/215703 UTXOs insérés...
⏳ Traitement: 170000/223667 UTXOs insérés... ⏳ Traitement: 170000/215703 UTXOs insérés...
⏳ Traitement: 180000/223667 UTXOs insérés... ⏳ Traitement: 180000/215703 UTXOs insérés...
⏳ Traitement: 190000/223667 UTXOs insérés... ⏳ Traitement: 190000/215703 UTXOs insérés...
⏳ Traitement: 200000/223667 UTXOs insérés... ⏳ Traitement: 200000/215703 UTXOs insérés...
⏳ Traitement: 210000/223667 UTXOs insérés... ⏳ Traitement: 210000/215703 UTXOs insérés...
⏳ Traitement: 220000/223667 UTXOs insérés...
💾 Mise à jour des UTXOs dépensés... 💾 Mise à jour des UTXOs dépensés...
📊 Résumé: 📊 Résumé:
- UTXOs vérifiés: 49190 - UTXOs vérifiés: 5146
- UTXOs toujours disponibles: 49168 - UTXOs toujours disponibles: 5146
- UTXOs dépensés détectés: 22 - UTXOs dépensés détectés: 0
📈 Statistiques finales: 📈 Statistiques finales:
- Total UTXOs: 68398 - Total UTXOs: 68398
- Dépensés: 19230 - Dépensés: 63307
- Non dépensés: 49168 - Non dépensés: 5091
✅ Synchronisation terminée ✅ Synchronisation terminée

View File

@ -14,7 +14,8 @@ Ce document liste tous les domaines, ports et services de l'infrastructure Certi
| Domaine | Service | Port Local | Description | | Domaine | Service | Port Local | Description |
|---------|---------|------------|-------------| |---------|---------|------------|-------------|
| `certificator.4nkweb.com` | API d'Ancrage | 3010 | API REST pour ancrer des documents | | `anchorage.certificator.4nkweb.com` | API d'Ancrage | 3010 | API REST pour ancrer des documents (machine bitcoin 192.168.1.105) |
| `certificator.4nkweb.com` | API LeCoffre Anchor | 3004 | API REST LeCoffre pour ancrer (machine prod 192.168.1.103) |
| `watermark.certificator.4nkweb.com` | API Filigrane | 3022 | API REST pour ajouter des filigranes et ancrer | | `watermark.certificator.4nkweb.com` | API Filigrane | 3022 | API REST pour ajouter des filigranes et ancrer |
| `antivir.certificator.4nkweb.com` | API ClamAV | 3023 | API REST pour scanner les fichiers (antivirus) | | `antivir.certificator.4nkweb.com` | API ClamAV | 3023 | API REST pour scanner les fichiers (antivirus) |
| `dashboard.certificator.4nkweb.com` | Dashboard | 3020 | Interface web de supervision | | `dashboard.certificator.4nkweb.com` | Dashboard | 3020 | Interface web de supervision |
@ -109,7 +110,8 @@ Internet
├─→ 4nk.myftp.biz (DynDNS) ├─→ 4nk.myftp.biz (DynDNS)
│ └─→ 192.168.1.100 (proxy) - Point d'entrée unique │ └─→ 192.168.1.100 (proxy) - Point d'entrée unique
│ │ │ │
│ ├─→ certificator.4nkweb.com → 192.168.1.103:3010 (API Anchorage) │ ├─→ anchorage.certificator.4nkweb.com → 192.168.1.105:3010 (API Anchorage Bitcoin)
│ ├─→ certificator.4nkweb.com → 192.168.1.103:3004 (API LeCoffre Anchor)
│ ├─→ watermark.certificator.4nkweb.com → 192.168.1.103:3022 (API Filigrane) │ ├─→ watermark.certificator.4nkweb.com → 192.168.1.103:3022 (API Filigrane)
│ ├─→ antivir.certificator.4nkweb.com → 192.168.1.103:3023 (API ClamAV) │ ├─→ antivir.certificator.4nkweb.com → 192.168.1.103:3023 (API ClamAV)
│ ├─→ dashboard.certificator.4nkweb.com → 192.168.1.103:3020 (Dashboard) │ ├─→ dashboard.certificator.4nkweb.com → 192.168.1.103:3020 (Dashboard)
@ -141,6 +143,7 @@ curl http://localhost:3022/health
curl http://localhost:3023/health curl http://localhost:3023/health
# Tester depuis l'extérieur (via domaine) # Tester depuis l'extérieur (via domaine)
curl https://anchorage.certificator.4nkweb.com/health
curl https://certificator.4nkweb.com/health curl https://certificator.4nkweb.com/health
curl -s https://dashboard.certificator.4nkweb.com/api/blockchain/info | head -c 200 curl -s https://dashboard.certificator.4nkweb.com/api/blockchain/info | head -c 200
``` ```
@ -173,6 +176,7 @@ Tous les domaines utilisent des enregistrements CNAME pointant vers `4nk.myftp.b
**Exemple de configuration DNS (Gandi) :** **Exemple de configuration DNS (Gandi) :**
``` ```
anchorage.certificator.4nkweb.com. 3600 IN CNAME 4nk.myftp.biz.
certificator.4nkweb.com. 3600 IN CNAME 4nk.myftp.biz. certificator.4nkweb.com. 3600 IN CNAME 4nk.myftp.biz.
watermark.certificator.4nkweb.com. 3600 IN CNAME 4nk.myftp.biz. watermark.certificator.4nkweb.com. 3600 IN CNAME 4nk.myftp.biz.
antivir.certificator.4nkweb.com. 3600 IN CNAME 4nk.myftp.biz. antivir.certificator.4nkweb.com. 3600 IN CNAME 4nk.myftp.biz.
@ -180,6 +184,31 @@ dashboard.certificator.4nkweb.com. 3600 IN CNAME 4nk.myftp.biz.
faucet.certificator.4nkweb.com. 3600 IN CNAME 4nk.myftp.biz. faucet.certificator.4nkweb.com. 3600 IN CNAME 4nk.myftp.biz.
``` ```
## APIs d'Ancrage
Il existe deux APIs d'ancrage distinctes :
### 1. API d'Ancrage Bitcoin (`anchorage.certificator.4nkweb.com`)
- **Domaine :** `anchorage.certificator.4nkweb.com`
- **Machine :** 192.168.1.105 (bitcoin)
- **Port :** 3010
- **Service systemd :** `anchorage-api`
- **Répertoire :** `/home/ncantu/Bureau/code/bitcoin/api-anchorage`
- **Bitcoin RPC :** 127.0.0.1:38332 (Bitcoin Core dans Docker sur la même machine)
- **Description :** API principale pour ancrer des documents sur Bitcoin Signet
### 2. API LeCoffre Anchor (`certificator.4nkweb.com`)
- **Domaine :** `certificator.4nkweb.com`
- **Machine :** 192.168.1.103 (prod)
- **Port :** 3004
- **Service :** Processus Node.js dans `/srv/4NK/certificator.4nkweb.com/lecoffre-anchor-api`
- **Bitcoin RPC :** localhost:18443 (Bitcoin Core sur machine distante)
- **Description :** API LeCoffre pour ancrer des documents (service séparé)
**Important :** Pour l'API d'ancrage Bitcoin principale, utiliser `anchorage.certificator.4nkweb.com` qui pointe vers la machine bitcoin (192.168.1.105).
## Notes Importantes ## Notes Importantes
1. **Ports fixes** : Tous les ports des APIs sont fixes et définis dans les services systemd. Ne pas modifier sans mettre à jour les services. 1. **Ports fixes** : Tous les ports des APIs sont fixes et définis dans les services systemd. Ne pas modifier sans mettre à jour les services.

View File

@ -10,17 +10,22 @@ Ce document liste toutes les interfaces et IHM (Interfaces Homme-Machine) dispon
--- ---
## 1. API REST d'Ancrage ## 1. API REST d'Ancrage Bitcoin
### Description ### Description
API REST HTTP/JSON pour ancrer des documents sur la blockchain Bitcoin Signet. API REST HTTP/JSON pour ancrer des documents sur la blockchain Bitcoin Signet.
### Accès ### Accès
- **URL** : `https://certificator.4nkweb.com` (via nginx proxy) - **URL** : `https://anchorage.certificator.4nkweb.com` (via nginx proxy)
- **Machine** : 192.168.1.105 (bitcoin)
- **Port local** : `3010` - **Port local** : `3010`
- **Protocole** : HTTPS (production) / HTTP (développement) - **Protocole** : HTTPS (production) / HTTP (développement)
- **Format** : JSON - **Format** : JSON
**Note :** Il existe deux APIs d'ancrage distinctes :
- **API Bitcoin** : `https://anchorage.certificator.4nkweb.com` (machine bitcoin 192.168.1.105, port 3010) - API principale
- **API LeCoffre** : `https://certificator.4nkweb.com` (machine prod 192.168.1.103, port 3004) - API LeCoffre séparée
### Endpoints ### Endpoints
#### GET `/` #### GET `/`
@ -33,8 +38,10 @@ Informations sur l'API
"version": "1.0.0", "version": "1.0.0",
"endpoints": { "endpoints": {
"health": "/health", "health": "/health",
"healthDetailed": "/health/detailed",
"anchor": "/api/anchor/document", "anchor": "/api/anchor/document",
"verify": "/api/anchor/verify" "verify": "/api/anchor/verify",
"lockedUtxos": "/api/anchor/locked-utxos"
} }
} }
``` ```
@ -57,6 +64,69 @@ Vérifie l'état de l'API et de la connexion Bitcoin
} }
``` ```
#### GET `/health/detailed`
Vérifie l'état détaillé de l'API, incluant l'état du mutex, des UTXOs verrouillés et de la connexion Bitcoin
**Authentification** : Non requise
**Réponse (200 OK)** :
```json
{
"ok": true,
"service": "anchor-api",
"mutex": {
"locked": false,
"waiting": 0,
"timeout": 180000
},
"utxos": {
"locked": 0,
"locked_since": null,
"stale_locks": 0,
"stale_locks_details": []
},
"bitcoin": {
"connected": true,
"blocks": 152321,
"chain": "signet",
"rpc_timeout": 60000
},
"timestamp": "2026-01-23T16:35:27.821Z"
}
```
**Réponse (503 Service Unavailable)** : Si UTXOs verrouillés depuis > 10 min ou > 10 UTXOs verrouillés
```json
{
"ok": false,
"service": "anchor-api",
"mutex": {
"locked": false,
"waiting": 0,
"timeout": 180000
},
"utxos": {
"locked": 5,
"locked_since": "2026-01-23T16:25:00.000Z",
"stale_locks": 5,
"stale_locks_details": [
{
"txid": "abc123...",
"vout": 0,
"minutes_locked": 15.5
}
]
},
"bitcoin": {
"connected": true,
"blocks": 152321,
"chain": "signet",
"rpc_timeout": 60000
},
"timestamp": "2026-01-23T16:35:27.821Z"
}
```
#### POST `/api/anchor/document` #### POST `/api/anchor/document`
Ancre un document sur Bitcoin Signet Ancre un document sur Bitcoin Signet

View File

@ -0,0 +1,190 @@
# API d'Ancrage - Monitoring et Prévention des Blocages
**Date:** 2026-01-28
**Auteur:** Équipe 4NK
## Objectif
Implémenter des moyens de contrôle et de correction pour prévenir et résoudre les blocages du mutex et des UTXOs verrouillés dans l'API d'ancrage.
## Motivations
- **Prévention des blocages:** Éviter que le mutex reste bloqué indéfiniment
- **Détection précoce:** Identifier les problèmes avant qu'ils n'affectent les utilisateurs
- **Récupération automatique:** Déverrouiller automatiquement les UTXOs verrouillés depuis trop longtemps
- **Robustesse:** Gérer les timeouts RPC Bitcoin avec retry et backoff
- **Observabilité:** Fournir des endpoints de monitoring détaillés
## Impacts
### Fonctionnels
- **Endpoint `/health/detailed`:** Fournit un état détaillé du mutex, des UTXOs et de la connexion Bitcoin
- **Déverrouillage automatique:** Les UTXOs verrouillés depuis > 10 minutes sont automatiquement déverrouillés au démarrage
- **Timeout de sécurité:** Le mutex est automatiquement libéré après 5 minutes maximum
- **Retry avec backoff:** Les appels RPC Bitcoin sont automatiquement réessayés en cas de timeout
- **Monitoring de durée:** Les opérations > 30 secondes sont loggées avec un warning
### Techniques
- **Timeout sur Promise.all():** La génération d'adresses a un timeout de 30 secondes
- **État du mutex:** Suivi en temps réel de l'état du mutex (locked, waiting)
- **Scripts de maintenance:** Scripts pour diagnostic et nettoyage automatique
- **Documentation:** Documentation complète du monitoring et de la maintenance
## Modifications
### Fichiers modifiés
1. **`api-anchorage/src/routes/health.js`**
- Ajout de l'endpoint `/health/detailed` avec état du mutex et UTXOs
- Vérification des UTXOs verrouillés depuis > 10 minutes
- Codes de statut adaptatifs selon l'état
2. **`api-anchorage/src/bitcoin-rpc.js`**
- Ajout de `getMutexState()` pour exposer l'état du mutex
- Suivi de l'état du mutex (`utxoMutexLocked`, `utxoMutexWaiting`)
- Timeout de sécurité de 5 minutes sur le mutex dans `createAnchorTransaction()`
- Timeout de 30 secondes sur `Promise.all()` pour la génération d'adresses
- Méthode `callRPCWithRetry()` pour retry avec backoff exponentiel
- Méthode `callRPCCommandWithRetry()` pour les commandes RPC
- Tous les appels RPC critiques utilisent maintenant le retry avec backoff
- Monitoring de la durée des opérations avec alerte si > 30s
3. **`api-anchorage/src/server.js`**
- Déverrouillage automatique des UTXOs verrouillés depuis > 10 minutes au démarrage
- Exclusion de `/health/detailed` de l'authentification API Key
- Mise à jour de la liste des endpoints dans la route racine
4. **`api-anchorage/cleanup-stale-locks.mjs`** (nouveau)
- Script pour déverrouiller les UTXOs verrouillés depuis > 10 minutes
- À exécuter via cron job toutes les 5 minutes
5. **`api-anchorage/diagnose.mjs`** (nouveau)
- Script de diagnostic complet de l'état des UTXOs
- Affiche les UTXOs verrouillés avec leur durée de verrouillage
- Statistiques générales des UTXOs
6. **`signet-dashboard/public/api-docs.html`**
- Ajout de la documentation de l'endpoint `/health/detailed`
- Mise à jour de la liste des endpoints publics
- Correction de l'URL de l'API d'ancrage (anchorage.certificator.4nkweb.com)
7. **`docs/INTERFACES.md`**
- Ajout de la documentation de l'endpoint `/health/detailed`
- Mise à jour de la liste des endpoints
8. **`api-anchorage/README-MONITORING.md`** (nouveau)
- Documentation complète du monitoring et de la maintenance
- Procédures de dépannage
- Configuration des cron jobs
### Appels RPC mis à jour avec retry
- `getNewAddress()``callRPCWithRetry('getNewAddress', [])`
- `getBalance()``callRPCWithRetry('getBalance', [])`
- `checkConnection()``callRPCWithRetry('getNetworkInfo', [])` et `callRPCWithRetry('getBlockchainInfo', [])`
- `listunspent()``callRPCWithRetry('listunspent', [...])`
- `createrawtransaction``callRPCCommandWithRetry('createrawtransaction', ...)`
- `signrawtransactionwithwallet``callRPCCommandWithRetry('signrawtransactionwithwallet', ...)`
- `sendrawtransaction``callRPCCommandWithRetry('sendrawtransaction', ...)`
- `getmempoolentry``callRPCCommandWithRetry('getmempoolentry', ...)`
- `getTransaction()``callRPCWithRetry('getTransaction', [...])`
- `getRawTransaction()``callRPCWithRetry('getRawTransaction', [...])`
- `getTransactionInfo()` → Utilise `callRPCWithRetry()` pour tous les appels
## Modalités de déploiement
### 1. Redémarrage du service
```bash
sudo systemctl restart anchorage-api
```
### 2. Vérification
```bash
# Health check détaillé
curl http://localhost:3010/health/detailed
# Depuis l'extérieur
curl https://anchorage.certificator.4nkweb.com/health/detailed
```
### 3. Configuration du cron job (optionnel mais recommandé)
Créer `/etc/cron.d/anchorage-cleanup`:
```bash
# Nettoyage automatique des UTXOs verrouillés depuis plus de 10 minutes
# Toutes les 5 minutes
*/5 * * * * ncantu cd /home/ncantu/Bureau/code/bitcoin/api-anchorage && /usr/bin/node cleanup-stale-locks.mjs >> /var/log/anchorage-cleanup.log 2>&1
```
### 4. Test des scripts
```bash
# Diagnostic
cd /home/ncantu/Bureau/code/bitcoin/api-anchorage
node diagnose.mjs
# Nettoyage
node cleanup-stale-locks.mjs
# Déverrouillage manuel
node unlock-utxos.mjs
```
## Modalités d'analyse
### Endpoints de monitoring
1. **GET `/health/detailed`**
- Vérifier l'état du mutex (`locked`, `waiting`)
- Vérifier le nombre d'UTXOs verrouillés
- Vérifier les UTXOs verrouillés depuis > 10 minutes (`stale_locks`)
- Vérifier la connexion Bitcoin
2. **GET `/api/anchor/locked-utxos`**
- Liste complète des UTXOs verrouillés
- Compteur total
### Logs à surveiller
1. **Timeouts de mutex:**
```bash
sudo journalctl -u anchorage-api | grep "Mutex acquisition timeout"
```
2. **Opérations longues:**
```bash
sudo journalctl -u anchorage-api | grep "took too long"
```
3. **Erreurs RPC Bitcoin:**
```bash
sudo journalctl -u anchorage-api | grep "ESOCKETTIMEDOUT\|ETIMEDOUT"
```
4. **Retry RPC:**
```bash
sudo journalctl -u anchorage-api | grep "RPC call failed, retrying"
```
### Scripts de diagnostic
1. **`diagnose.mjs`:** Diagnostic complet de l'état des UTXOs
2. **`cleanup-stale-locks.mjs`:** Nettoyage automatique des UTXOs stale
3. **`unlock-utxos.mjs`:** Déverrouillage manuel de tous les UTXOs
## Pages affectées
- `api-anchorage/src/routes/health.js`: Endpoint `/health/detailed`
- `api-anchorage/src/bitcoin-rpc.js`: Monitoring, timeouts, retry avec backoff
- `api-anchorage/src/server.js`: Déverrouillage automatique au démarrage
- `api-anchorage/cleanup-stale-locks.mjs`: Script de nettoyage (nouveau)
- `api-anchorage/diagnose.mjs`: Script de diagnostic (nouveau)
- `api-anchorage/README-MONITORING.md`: Documentation (nouveau)
- `signet-dashboard/public/api-docs.html`: Documentation API mise à jour
- `docs/INTERFACES.md`: Documentation API mise à jour
- `features/api-anchorage-monitoring-and-prevention.md`: Documentation (nouveau)

View File

@ -0,0 +1,207 @@
# Login fonctionnel de bout en bout - Implémentations réalisées
**Author:** Équipe 4NK
**Date:** 2026-01-28
## Vue d'ensemble
Ce document décrit les implémentations réalisées pour compléter le login fonctionnel de bout en bout sur PC et mobile.
---
## 1. userwallet - Notifications relais pour mobile
**Note importante :** Le modèle est **pull-only** (HTTP REST uniquement). Les WebSockets ne sont pas souhaités selon la documentation (`userwallet/features/userwallet-notifications-relais.md`).
### 1.1 Modèle pull-only (HTTP REST)
**Fichier modifié :** `userwallet/src/services/relayNotificationService.ts`
**Implémentations :**
1. **Polling HTTP REST uniquement**
- Modèle pull-only : uniquement des appels GET HTTP REST
- Pas de WebSocket (non souhaité selon la documentation)
- Polling périodique des relais pour détecter nouveaux hashes
- Détection via `GET /keys?start=&end=` pour scanner les nouveaux messages
2. **Gestion du polling**
- `startPolling(intervalMs)` : démarre le polling périodique
- `stopPolling()` : arrête le polling
- Fenêtre temporelle configurable pour le scan des clés
- Émission d'événements `RelayHashEvent` avec `source: 'polling'`
### 1.2 Optimisations mobile (backoff, reconnexions)
**Fichier modifié :** `userwallet/src/services/relayNotificationService.ts`
**Implémentations :**
1. **Stratégie de backoff exponentiel**
- Interface `BackoffState` pour gérer l'état de retry par relais
- `getBackoffDelay(endpoint)` : calcul du délai avec exponentiel (max 60s)
- `recordFailure(endpoint)` : enregistre un échec et incrémente les tentatives
- `recordSuccess(endpoint)` : réinitialise le backoff après succès
- `shouldRetry(endpoint)` : vérifie si un retry est autorisé (max 5 tentatives)
2. **Gestion réseau mobile**
- `setupNetworkListeners()` : écoute des événements `online`/`offline`
- `resumeOperations()` : reprend les opérations après reconnexion
- `pauseOperations()` : pause les opérations en mode offline
- Vérification `isOnline` avant chaque opération réseau
3. **Optimisation des appels réseau**
- Retry avec backoff pour `getMessageByHash`, `getSignatures`, `getKeys`
- Skip des relais en échec répétés
- Réduction des appels inutiles en mode offline
### 1.3 Fetch automatique des données
**Fichier modifié :** `userwallet/src/services/relayNotificationService.ts`
**Implémentations :**
1. **ProcessHash amélioré**
- Fetch automatique de message, signatures, clés quand un hash est connu
- Déchiffrement et mise à jour du graphe automatiques
- Gestion des erreurs avec backoff par relais
2. **ProcessHashByType optimisé**
- Optimisation selon le type d'objet (signature, contrat, membre, pair, action, champ)
- Fetch sélectif selon le type (ex: pas besoin de clés pour une signature seule)
3. **Intégration avec useRelayNotifications**
- Auto-processing des hashes détectés via polling
- Démarrage automatique du polling avec `startPolling()`
- Arrêt propre avec `stopPolling()` et `cleanup()`
**Fichier modifié :** `userwallet/src/hooks/useRelayNotifications.ts`
**Implémentations :**
1. **Polling HTTP REST**
- `startPolling()` démarre le polling périodique
- `stopPolling()` arrête le polling
- Gestion du cycle de vie du polling
---
## 2. userwallet - Responsive design mobile
### 2.1 CSS responsive
**Fichier modifié :** `userwallet/src/index.css`
**Implémentations :**
1. **Media queries**
- `@media (max-width: 768px)` : tablettes et petits écrans
- `@media (max-width: 480px)` : smartphones
- Adaptation des paddings, marges, tailles de police
2. **Tailles tactiles**
- `min-height: 44px` pour tous les boutons (recommandation Apple/Google)
- `min-width: 44px` pour les éléments interactifs
- Boutons en `width: 100%` sur mobile
3. **Gestion du clavier virtuel**
- `font-size: 16px` pour les inputs (évite le zoom automatique iOS)
- `scroll-margin-top: 100px` pour le focus des inputs
- Adaptation des hauteurs d'iframe selon la taille d'écran
4. **Optimisations tactiles**
- `@media (hover: none) and (pointer: coarse)` : détection tactile
- Feedback visuel avec `opacity` et `transform` sur `:active`
- Espacement suffisant entre les éléments interactifs
---
## 3. service-login-verify - Persistance NonceCache
### 3.1 Amélioration PersistentNonceCache
**Fichier modifié :** `service-login-verify/src/persistentNonceCache.ts`
**Implémentations :**
1. **Persistance IndexedDB en arrière-plan**
- `persistToIndexedDB(nonce, timestamp)` : persiste en IndexedDB de manière asynchrone
- Utilisation de localStorage comme stockage principal (synchrone, requis par l'interface)
- IndexedDB utilisé comme backup persistant
2. **Nettoyage IndexedDB**
- `cleanupIndexedDB(now)` : supprime les entrées expirées depuis IndexedDB
- Utilisation de l'index `timestamp` pour les requêtes efficaces
- Nettoyage asynchrone non-bloquant
3. **Double persistance**
- localStorage : accès synchrone (requis par `NonceCacheLike`)
- IndexedDB : persistance entre sessions (backup)
- Les deux sont maintenus en synchronisation
**Note :** `PersistentNonceCache` est déjà disponible et peut être utilisé à la place de `NonceCache` dans `website-skeleton` si persistance entre redémarrages nécessaire.
---
## Synthèse des modifications
### Fichiers modifiés
1. `userwallet/src/services/relayNotificationService.ts` :
- Modèle pull-only (HTTP REST)
- Optimisations mobile (backoff, reconnexions)
- Gestion réseau online/offline
2. `userwallet/src/hooks/useRelayNotifications.ts` :
- Polling HTTP REST automatique
- Gestion du cycle de vie
3. `userwallet/src/index.css` :
- Media queries responsive
- Tailles tactiles
- Gestion clavier virtuel
4. `service-login-verify/src/persistentNonceCache.ts` :
- Persistance IndexedDB améliorée
---
## Utilisation
### userwallet - Activer les notifications
Le hook `useRelayNotifications` active le polling HTTP REST :
```typescript
const { startPolling, stopPolling } = useRelayNotifications(graphResolver, true);
// Démarrer polling HTTP REST (pull-only)
startPolling(60000); // 60 secondes
```
### service-login-verify - Utiliser PersistentNonceCache
Dans `website-skeleton/src/main.ts` :
```typescript
import { PersistentNonceCache } from 'service-login-verify';
const nonceCache = new PersistentNonceCache(3600000);
await nonceCache.init(); // Initialiser IndexedDB
```
---
## Notes importantes
- **Modèle pull-only** : Le système utilise uniquement des appels HTTP REST (GET). Les WebSockets ne sont pas souhaités selon la documentation (`userwallet/features/userwallet-notifications-relais.md`).
- **Persistance NonceCache** : `PersistentNonceCache` utilise localStorage comme stockage principal (synchrone) et IndexedDB en arrière-plan (asynchrone).
- **Backoff** : Limité à 5 tentatives maximum avec délai exponentiel jusqu'à 60 secondes.
- **Mobile** : Les optimisations réduisent les appels réseau et gèrent les reconnexions automatiquement.
---
## Références
- `features/login-bout-en-bout-reste-a-faire.md` : Liste initiale des éléments à implémenter
- `userwallet/src/services/relayNotificationService.ts` : Service de notifications
- `service-login-verify/src/persistentNonceCache.ts` : Cache persistant

View File

@ -0,0 +1,276 @@
# Login fonctionnel de bout en bout PC et Mobile - Reste à faire
**Author:** Équipe 4NK
**Date:** 2026-01-28
## Vue d'ensemble
Ce document liste ce qui reste à implémenter pour avoir un login fonctionnel de bout en bout sur PC et mobile pour les projets :
- `website-skeleton`
- `userwallet`
- `service-login-verify`
- `api-relay`
---
## 1. website-skeleton
### 1.1 Envoi du contrat au UserWallet (Priorité : Haute)
**Statut :** ✅ Implémenté
**Référence :** `website-skeleton/src/main.ts`
**Fait :**
- ✅ Fonction `sendContractToIframe()` qui envoie le contrat stocké à l'iframe
- ✅ Stockage du contrat reçu pour envoi ultérieur (`storedContract`, `storedContratsFils`, `storedActions`)
- ✅ Envoi automatique du contrat au chargement de l'iframe (événement `load`)
- ✅ Envoi du contrat lors de la réception d'un nouveau contrat via `postMessage`
- ✅ Réception des messages `contract` depuis le parent (écoute `postMessage`)
- ✅ Extraction des validateurs depuis les contrats reçus
- ✅ Mise à jour des clés autorisées pour la vérification
**Note :** L'implémentation est complète. Le contrat est envoyé automatiquement à l'iframe dès qu'il est disponible.
### 1.2 Gestion de session utilisateur (Priorité : Haute)
**Statut :** ✅ Implémenté
**Référence :** `website-skeleton/src/main.ts`, `website-skeleton/index.html`
**Fait :**
- ✅ Stockage de session dans `sessionStorage` après acceptation de la preuve
- ✅ Fonctions `getSession()`, `setSession()`, `clearSession()`, `isLoggedIn()`
- ✅ Masquage de l'iframe après login réussi
- ✅ Affichage d'une interface "connecté" avec informations utilisateur (clés publiques)
- ✅ Bouton de déconnexion fonctionnel
- ✅ Vérification de session au chargement de la page
- ✅ Mise à jour automatique de l'interface selon l'état de connexion
- ✅ Vérification de la preuve de login avec `verifyLoginProof()`
- ✅ Affichage du statut (accepté/refusé)
**Note :** L'implémentation est complète. La protection des routes/endpoints reste à implémenter si nécessaire selon les besoins spécifiques du service.
### 1.3 Interface responsive mobile (Priorité : Moyenne)
**Statut :** ✅ Implémenté (à tester)
**Fait :**
- ✅ Meta viewport configuré (`width=device-width, initial-scale=1.0`)
- ✅ Styles CSS responsive avec media queries (768px, 480px)
- ✅ Adaptation de l'iframe pour mobile (hauteurs différentes selon taille d'écran)
- ✅ Boutons adaptés au tactile (min-height: 44px, width: 100% sur mobile)
- ✅ Layout flex adaptatif pour petits écrans
- ✅ Tailles de police adaptées
**À tester :**
- Vérifier le comportement sur différents appareils mobiles
- Tester le clavier virtuel (focus, scroll)
- Valider les gestes tactiles si nécessaire
### 1.4 Amélioration UX (Priorité : Moyenne)
**Statut :** ✅ Implémenté (améliorations possibles)
**Fait :**
- ✅ Bouton "Se connecter" clair et visible (style primary)
- ✅ Gestion de l'affichage/masquage de l'iframe selon l'état
- ✅ Indicateur de statut de connexion avec couleurs (accepted/rejected/pending)
- ✅ Messages d'erreur avec raison du refus
- ✅ Interface "connecté" avec informations utilisateur
- ✅ Styles améliorés (couleurs, transitions, bordures arrondies)
**Améliorations possibles (optionnel) :**
- Loading states plus visibles pendant la vérification
- Guide utilisateur pour le premier login
- Messages d'aide contextuels
---
## 2. userwallet
### 2.1 Validation des écrans login (Priorité : Haute)
**Statut :** À valider avant implémentation
**Référence :** `features/userwallet-ecrans-login-a-valider.md`, `RESTE_A_FAIRE.md` (§ 1.1)
**Action requise :** Tester les écrans login existants et valider leur fonctionnement sur PC et mobile.
**Écrans à valider :**
- Sélection service / sélection membre
- Construction du chemin login
- Message de login à valider
- Collecte signatures mFA
- Publication
- Vérification locale + résultat
### 2.2 Notifications relais pour mobile (Priorité : Haute)
**Statut :** Partiellement implémenté
**Référence :** `RESTE_A_FAIRE.md` (§ 1.2), `features/userwallet-contrat-login-reste-a-faire.md` (§ 3.2)
**Fait :**
- Progression collecte signatures (X/Y) implémentée via `onProgress` dans `runCollectLoop`
- Affichage dans `LoginCollectShare`
**Reste à faire :**
1. **Notifications push (si extension mobile)**
- Réagir aux événements push du relais pour savoir quel hash fetch
- Implémenter un mécanisme de notification (Service Worker, Web Push, etc.)
2. **Fetch automatique des données**
- Une fois le hash connu (via notification ou autre) : récupération sur le relai des signatures, contrats, membres, pairs, actions, champs
- Les notifications doivent piloter : quel hash fetch, puis fetch signatures/clés et mise à jour du graphe
3. **Optimisation pour mobile**
- Réduire les appels réseau inutiles
- Gérer les reconnexions réseau
- Optimiser la collecte de signatures sur mobile (batterie, données)
**Fichiers concernés :**
- `userwallet/src/services/relayNotificationService.ts`
- `userwallet/src/hooks/useRelayNotifications.ts`
- `userwallet/src/components/LoginCollectShare.tsx`
### 2.3 Responsive design mobile (Priorité : Moyenne)
**Statut :** À vérifier et améliorer
**Ce qui manque :**
1. **Interface responsive**
- Vérifier que tous les écrans login sont utilisables sur mobile
- Adapter les formulaires pour petits écrans
- Gérer le clavier virtuel (focus, scroll)
2. **UX mobile**
- Gestes tactiles (swipe, etc.)
- Tailles de boutons adaptées au tactile
- Feedback visuel pour les actions
3. **Performance mobile**
- Optimiser le chargement initial
- Réduire la consommation mémoire
- Gérer les limites de stockage IndexedDB sur mobile
---
## 3. service-login-verify
### 3.1 Intégration dans website-skeleton (Priorité : Basse)
**Statut :** Déjà intégré et fonctionnel
**Fait :**
- Package `service-login-verify` intégré dans `website-skeleton`
- Utilisation de `verifyLoginProof`, `NonceCache`, `buildAllowedPubkeysFromValidateurs`
- Instance de `NonceCache` créée et utilisée
- Construction des clés autorisées depuis les validateurs
- Vérification des preuves reçues
**Note :** L'intégration est complète et fonctionnelle. Aucune action requise.
### 3.2 Persistance du NonceCache (Priorité : Basse - Optionnel)
**Statut :** Actuellement en mémoire uniquement
**Référence :** `RESTE_A_FAIRE.md` (§ 2.1)
**Description :** Le `NonceCache` actuel est en mémoire avec TTL configurable. Une persistance optionnelle (IndexedDB, localStorage, etc.) peut être ajoutée si nécessaire pour éviter les rejeux après redémarrage du service.
**Action requise :** Implémenter uniquement si besoin de persistance entre redémarrages du service.
---
## 4. api-relay
### 4.1 Support notifications push pour mobile (Priorité : Moyenne)
**Statut :** À vérifier
**Ce qui manque :**
1. **Notifications push (optionnel)**
- Si support Web Push : endpoint pour s'abonner aux notifications
- Envoyer des notifications quand de nouveaux messages/signatures/clés sont disponibles
- Gérer les abonnements par hash ou par service
2. **Optimisation pour mobile**
- Réduire la taille des réponses
- Support de la compression
- Pagination pour les grandes listes
**Note :** Le modèle actuel est pull-only (GET). Les notifications push sont optionnelles mais amélioreraient l'expérience mobile.
---
## Synthèse par priorité
### ✅ Complété dans website-skeleton
1. ✅ **website-skeleton** : Envoi du contrat au UserWallet (1.1)
2. ✅ **website-skeleton** : Gestion de session utilisateur (1.2)
3. ✅ **website-skeleton** : Interface responsive mobile (1.3)
4. ✅ **website-skeleton** : Amélioration UX (1.4)
### Priorité Haute (Blocant pour login fonctionnel)
1. **userwallet** : Validation des écrans login (2.1) - **À valider par tests**
### Priorité Moyenne (Améliore l'expérience)
2. **userwallet** : Notifications relais pour mobile (2.2)
3. **userwallet** : Responsive design mobile (2.3) - **À vérifier**
4. **api-relay** : Support notifications push (4.1) - **Optionnel**
### Priorité Basse (Optionnel)
5. **service-login-verify** : Persistance NonceCache (3.2)
---
## Workflow de login complet (cible)
### Sur PC
1. Utilisateur ouvre website-skeleton
2. Clique sur "Se connecter" (ou iframe s'affiche automatiquement)
3. Iframe UserWallet s'affiche
4. website-skeleton envoie le contrat via `postMessage` à l'iframe
5. UserWallet reçoit le contrat et met à jour le graphe
6. Utilisateur sélectionne le service et le membre
7. UserWallet construit le challenge et collecte les signatures
8. UserWallet publie la preuve sur les relais
9. UserWallet envoie `login-proof` au parent via `postMessage`
10. website-skeleton vérifie la preuve avec `service-login-verify`
11. Si acceptée : session ouverte, iframe masquée, interface "connecté" affichée
12. Si refusée : erreur affichée, possibilité de réessayer
### Sur Mobile
1. Utilisateur ouvre website-skeleton (responsive)
2. Clique sur "Se connecter"
3. Iframe UserWallet s'affiche (adaptatif, responsive)
4. Même workflow que PC
5. **Différence :** Notifications push pour informer des nouvelles signatures disponibles (si implémenté)
---
## Notes importantes
- Les éléments marqués "À valider" nécessitent une validation explicite avant implémentation.
- `website-skeleton` a déjà une intégration de base fonctionnelle (iframe, vérification, écoute messages).
- Le package `service-login-verify` est déjà intégré et fonctionnel dans `website-skeleton`.
- Les points bloquants principaux sont : l'envoi du contrat à l'iframe et la gestion de session.
- Les écrans login de userwallet sont en place mais nécessitent validation.
- Les notifications push sont optionnelles mais amélioreraient l'expérience mobile.
---
## Références
- `features/userwallet-contrat-login-reste-a-faire.md`
- `features/userwallet-ecrans-login-a-valider.md`
- `features/service-login-verify.md`
- `RESTE_A_FAIRE.md`
- `website-skeleton/src/main.ts` : exemple d'intégration complète
- `userwallet/docs/specs.md` : spécifications complètes
- `service-login-verify/README.md` : documentation du package

View File

@ -0,0 +1,476 @@
# Analyse: Causes possibles du blocage du mutex et UTXOs verrouillés
**Date:** 2026-01-28
**Auteur:** Équipe 4NK
## Vue d'ensemble
Ce document analyse les causes possibles du blocage du mutex et des UTXOs verrouillés dans l'API d'ancrage, ainsi que les moyens de contrôle et de correction.
## Causes possibles identifiées
### 1. Timeout RPC Bitcoin (ESOCKETTIMEDOUT)
**Cause:**
- Les appels RPC vers Bitcoin Core timeout (défaut: 30s, configuré: 60s)
- Si un appel RPC bloque indéfiniment, le code ne peut pas continuer
- Le mutex reste acquis et les UTXOs restent verrouillés
**Scénarios:**
- Bitcoin Core surchargé ou non réactif
- Problème réseau entre l'API et Bitcoin Core
- Bitcoin Core en cours de synchronisation ou de traitement lourd
- Wallet Bitcoin verrouillé ou non accessible
**Code concerné:**
- `bitcoin-rpc.js` ligne 23: `timeout: parseInt(process.env.BITCOIN_RPC_TIMEOUT || '30000')`
- `.env` ligne 6: `BITCOIN_RPC_TIMEOUT=60000`
**Indicateurs:**
- Logs: `Error getting balance: ESOCKETTIMEDOUT`
- Logs: `Failed to get balance: ESOCKETTIMEDOUT`
- Mutex bloqué pendant plus de 60 secondes
### 2. Promise.all() qui ne se résout jamais
**Cause:**
- Ligne 253: `await Promise.all(addressPromises)` peut bloquer si une Promise ne se résout jamais
- Si `getNewAddress()` timeout ou échoue silencieusement, la Promise reste en attente
- Le mutex reste acquis
**Scénarios:**
- Bitcoin RPC ne répond pas à `getNewAddress()`
- Timeout RPC trop court pour certaines opérations
- Erreur réseau non gérée
**Code concerné:**
- `bitcoin-rpc.js` lignes 248-253: Génération des adresses en parallèle
**Indicateurs:**
- Logs montrent "Anchor request received" mais pas de suite
- Pas d'erreur dans les logs, mais la requête ne se termine jamais
### 3. Erreur non gérée avant le bloc finally
**Cause:**
- Si une erreur se produit avant d'atteindre le bloc `finally` (ligne 908), le mutex peut ne pas être libéré
- Exemple: erreur fatale Node.js, OOM (Out of Memory), crash du processus
**Scénarios:**
- Erreur fatale Node.js (segfault, assertion failed)
- Manque de mémoire (OOM killer)
- Arrêt brutal du processus (kill -9)
- Exception non catchée dans une fonction asynchrone
**Code concerné:**
- `bitcoin-rpc.js` ligne 908-914: Bloc `finally` qui libère le mutex
**Indicateurs:**
- Processus Node.js redémarré par systemd
- Logs montrent un arrêt brutal sans message d'erreur
- UTXOs verrouillés après redémarrage
### 4. Erreur dans le bloc finally lui-même
**Cause:**
- Si `releaseMutex()` échoue dans le bloc `finally`, le mutex reste bloqué
- Actuellement, l'erreur est seulement loggée en WARN, mais le mutex n'est pas libéré
**Scénarios:**
- Exception lors de l'appel à `releaseMutex()`
- Problème avec la Promise du mutex
**Code concerné:**
- `bitcoin-rpc.js` lignes 910-914: Gestion d'erreur dans le `finally`
**Indicateurs:**
- Logs: `Error releasing mutex`
- Mutex reste bloqué malgré le `finally`
### 5. Déverrouillage des UTXOs qui échoue silencieusement
**Cause:**
- Ligne 903: `this.unlockUtxo()` peut échouer silencieusement (ligne 152-158)
- Si la mise à jour de la base de données échoue, l'UTXO reste verrouillé
- Le mutex est libéré, mais les UTXOs restent verrouillés en base
**Scénarios:**
- Base de données verrouillée (WAL mode, autre transaction en cours)
- Erreur SQL (contrainte, table verrouillée)
- Problème de permissions sur la base de données
**Code concerné:**
- `bitcoin-rpc.js` lignes 142-159: `unlockUtxo()` avec gestion d'erreur silencieuse
- `bitcoin-rpc.js` lignes 899-905: Déverrouillage en cas d'erreur
**Indicateurs:**
- Mutex libéré (pas de timeout)
- UTXOs toujours verrouillés dans la base de données
- Logs: `Error updating UTXO unlock status in database`
### 6. Transaction SQL qui échoue partiellement
**Cause:**
- Si plusieurs UTXOs sont verrouillés mais que le déverrouillage échoue pour certains
- La boucle continue mais certains UTXOs restent verrouillés
**Scénarios:**
- Erreur SQL pour un UTXO spécifique
- UTXO supprimé de la base pendant le déverrouillage
- Contrainte de base de données violée
**Code concerné:**
- `bitcoin-rpc.js` lignes 900-904: Boucle de déverrouillage
**Indicateurs:**
- Certains UTXOs déverrouillés, d'autres non
- Logs montrant des erreurs pour certains UTXOs
### 7. Race condition lors de l'acquisition du mutex
**Cause:**
- Si plusieurs requêtes arrivent simultanément, elles peuvent toutes acquérir le mutex
- Le timeout de 180s peut être atteint avant que toutes les requêtes ne se terminent
**Scénarios:**
- Pic de charge avec de nombreuses requêtes simultanées
- Requêtes qui prennent plus de 180s chacune
- Accumulation de requêtes en attente
**Code concerné:**
- `bitcoin-rpc.js` lignes 42-76: `acquireUtxoMutex()` avec timeout de 180s
**Indicateurs:**
- Nombreux timeouts de mutex dans les logs
- Requêtes qui attendent plus de 180s
### 8. Problème de connexion à la base de données
**Cause:**
- Si la connexion à la base de données est perdue, les opérations de verrouillage/déverrouillage échouent
- Le mutex peut être libéré en mémoire, mais les UTXOs restent verrouillés en base
**Scénarios:**
- Base de données corrompue
- Connexion SQLite fermée
- Problème de permissions
**Code concerné:**
- `database.js`: Gestion de la connexion SQLite
- Toutes les opérations de verrouillage/déverrouillage
**Indicateurs:**
- Erreurs SQL dans les logs
- Base de données inaccessible
## Moyens de contrôle
### 1. Monitoring des UTXOs verrouillés
**Script de vérification:**
```bash
# Vérifier le nombre d'UTXOs verrouillés
curl http://localhost:3010/api/anchor/locked-utxos | jq '.count'
# Si > 0, il y a un problème
```
**Alertes recommandées:**
- Warning si > 5 UTXOs verrouillés
- Critical si > 10 UTXOs verrouillés
- Critical si UTXOs verrouillés depuis > 10 minutes
### 2. Monitoring des timeouts de mutex
**Vérification dans les logs:**
```bash
# Compter les timeouts de mutex dans les dernières heures
sudo journalctl -u anchorage-api --since "1 hour ago" | grep -c "Mutex acquisition timeout"
# Si > 0, il y a un problème
```
**Alertes recommandées:**
- Warning si > 1 timeout par heure
- Critical si > 5 timeouts par heure
### 3. Monitoring des erreurs RPC Bitcoin
**Vérification dans les logs:**
```bash
# Compter les erreurs RPC Bitcoin
sudo journalctl -u anchorage-api --since "1 hour ago" | grep -c "ESOCKETTIMEDOUT\|ETIMEDOUT\|ECONNRESET"
# Si > 0, problème de connexion Bitcoin RPC
```
**Alertes recommandées:**
- Warning si > 1 erreur RPC par heure
- Critical si > 5 erreurs RPC par heure
### 4. Monitoring de la durée des opérations
**Vérification:**
- Logger le temps d'exécution de `createAnchorTransaction()`
- Alerter si > 30 secondes (normal: < 10 secondes)
**Code à ajouter:**
```javascript
const startTime = Date.now();
try {
// ... opération ...
} finally {
const duration = Date.now() - startTime;
if (duration > 30000) {
logger.warn('Anchor transaction took too long', { duration, hash });
}
}
```
### 5. Health check amélioré
**Endpoint à créer:**
```javascript
GET /health/detailed
{
"ok": true,
"mutex": {
"locked": false,
"waiting": 0
},
"utxos": {
"locked": 0,
"locked_since": null
},
"bitcoin": {
"connected": true,
"rpc_timeout": 60000
}
}
```
### 6. Script de maintenance automatique
**Script cron à créer:**
```bash
#!/bin/bash
# Déverrouiller les UTXOs verrouillés depuis plus de 10 minutes
LOCKED_COUNT=$(curl -s http://localhost:3010/api/anchor/locked-utxos | jq '.count')
if [ "$LOCKED_COUNT" -gt 0 ]; then
# Vérifier l'âge des verrouillages
# Si > 10 minutes, déverrouiller automatiquement
cd /home/ncantu/Bureau/code/bitcoin/api-anchorage
node unlock-utxos.mjs
fi
```
## Moyens de correction
### 1. Correction immédiate (déjà implémentée)
**Script de déverrouillage:**
- `api-anchorage/unlock-utxos.mjs`: Déverrouille tous les UTXOs verrouillés
**Utilisation:**
```bash
cd /home/ncantu/Bureau/code/bitcoin/api-anchorage
node unlock-utxos.mjs
```
### 2. Améliorations préventives à implémenter
#### A. Timeout de sécurité sur le mutex
**Problème:** Le mutex peut rester acquis indéfiniment si l'opération se bloque.
**Solution:** Ajouter un timeout de sécurité qui libère automatiquement le mutex après un délai maximum.
**Code à ajouter:**
```javascript
async createAnchorTransaction(...) {
const releaseMutex = await this.acquireUtxoMutex();
let mutexSafetyTimeout;
// Timeout de sécurité: libérer le mutex après 5 minutes maximum
mutexSafetyTimeout = setTimeout(() => {
logger.error('Mutex held for too long, forcing release', { hash });
releaseMutex();
}, 300000); // 5 minutes
try {
// ... opération ...
} finally {
clearTimeout(mutexSafetyTimeout);
releaseMutex();
}
}
```
#### B. Timeout sur Promise.all()
**Problème:** `Promise.all()` peut bloquer indéfiniment si une Promise ne se résout jamais.
**Solution:** Ajouter un timeout sur `Promise.all()`.
**Code à modifier:**
```javascript
// Ligne 253: Remplacer
const allAddresses = await Promise.all(addressPromises);
// Par:
const allAddresses = await Promise.race([
Promise.all(addressPromises),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Address generation timeout')), 30000)
)
]);
```
#### C. Déverrouillage automatique des UTXOs anciens
**Problème:** Les UTXOs peuvent rester verrouillés si le processus crash.
**Solution:** Déverrouiller automatiquement les UTXOs verrouillés depuis plus de 10 minutes au démarrage.
**Code à ajouter dans `server.js`:**
```javascript
// Au démarrage du serveur
const db = getDatabase();
const result = db.prepare(`
UPDATE utxos
SET is_locked_in_mutex = 0
WHERE is_locked_in_mutex = 1
AND updated_at < datetime('now', '-10 minutes')
`).run();
if (result.changes > 0) {
logger.info('Unlocked stale UTXOs on startup', { count: result.changes });
}
```
#### D. Retry avec backoff pour les appels RPC
**Problème:** Les timeouts RPC peuvent être temporaires.
**Solution:** Implémenter un retry avec backoff exponentiel.
**Code à ajouter:**
```javascript
async callRPCWithRetry(method, params, maxRetries = 3) {
for (let i = 0; i < maxRetries; i++) {
try {
return await this.client[method](...params);
} catch (error) {
if (i === maxRetries - 1) throw error;
const delay = Math.min(1000 * Math.pow(2, i), 10000);
logger.warn(`RPC call failed, retrying in ${delay}ms`, { method, attempt: i + 1 });
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
```
#### E. Monitoring et alertes
**Problème:** Les problèmes ne sont détectés qu'après coup.
**Solution:** Implémenter un système de monitoring proactif.
**À implémenter:**
- Endpoint `/health/detailed` avec état du mutex et UTXOs
- Script cron pour vérifier et déverrouiller automatiquement
- Alertes (email, webhook) en cas de problème
### 3. Scripts de diagnostic
#### A. Script de diagnostic complet
**Créer:** `api-anchorage/diagnose.mjs`
```javascript
#!/usr/bin/env node
import { getDatabase } from './src/database.js';
const db = getDatabase();
// UTXOs verrouillés
const locked = db.prepare(`
SELECT txid, vout, address, amount, updated_at,
(julianday('now') - julianday(updated_at)) * 24 * 60 as minutes_locked
FROM utxos
WHERE is_locked_in_mutex = 1
ORDER BY updated_at
`).all();
console.log(`\n📊 UTXOs verrouillés: ${locked.length}`);
if (locked.length > 0) {
console.table(locked.map(u => ({
txid: u.txid.substring(0, 16) + '...',
vout: u.vout,
amount: u.amount,
locked_for_minutes: Math.round(u.minutes_locked * 100) / 100
})));
}
// UTXOs verrouillés depuis plus de 10 minutes
const stale = locked.filter(u => u.minutes_locked > 10);
if (stale.length > 0) {
console.log(`\n⚠ UTXOs verrouillés depuis plus de 10 minutes: ${stale.length}`);
}
db.close();
```
#### B. Script de nettoyage automatique
**Créer:** `api-anchorage/cleanup-stale-locks.mjs`
```javascript
#!/usr/bin/env node
import { getDatabase } from './src/database.js';
const db = getDatabase();
const result = db.prepare(`
UPDATE utxos
SET is_locked_in_mutex = 0
WHERE is_locked_in_mutex = 1
AND updated_at < datetime('now', '-10 minutes')
`).run();
console.log(`✅ UTXOs déverrouillés: ${result.changes}`);
db.close();
```
**Cron job:**
```bash
# Toutes les 5 minutes
*/5 * * * * cd /home/ncantu/Bureau/code/bitcoin/api-anchorage && node cleanup-stale-locks.mjs
```
## Recommandations prioritaires
### Priorité 1 (Critique - à implémenter immédiatement)
1. **Déverrouillage automatique au démarrage** (solution C)
2. **Timeout de sécurité sur le mutex** (solution A)
3. **Script cron de nettoyage automatique** (solution B)
### Priorité 2 (Important - à implémenter rapidement)
4. **Health check amélioré** (solution 5)
5. **Monitoring des UTXOs verrouillés** (solution 1)
6. **Timeout sur Promise.all()** (solution B)
### Priorité 3 (Amélioration - à planifier)
7. **Retry avec backoff pour RPC** (solution D)
8. **Monitoring et alertes** (solution E)
9. **Scripts de diagnostic** (solution 3)
## Pages affectées
- `api-anchorage/src/bitcoin-rpc.js`: Améliorations du mutex et gestion d'erreurs
- `api-anchorage/src/server.js`: Déverrouillage au démarrage
- `api-anchorage/unlock-utxos.mjs`: Script de déverrouillage (existant)
- `api-anchorage/cleanup-stale-locks.mjs`: Script de nettoyage automatique (à créer)
- `api-anchorage/diagnose.mjs`: Script de diagnostic (à créer)
- `fixKnowledge/api-anchorage-mutex-blockage-analysis.md`: Documentation (nouveau)

View File

@ -0,0 +1,197 @@
# Correction: Mutex bloqué causant des timeouts dans l'API d'ancrage
**Date:** 2026-01-28
**Auteur:** Équipe 4NK
## Problème
L'API d'ancrage Bitcoin ne répondait plus correctement. Toutes les requêtes d'ancrage échouaient avec des erreurs de timeout de mutex après 180 secondes (3 minutes).
**Machine concernée :** 192.168.1.105 (bitcoin)
**Domaine externe :** https://anchorage.certificator.4nkweb.com
**Service :** anchorage-api (port 3010)
### Symptômes
- **Erreurs répétées:** Des centaines d'erreurs `Mutex acquisition timeout after 180000ms` dans les logs
- **UTXOs verrouillés:** 22 UTXOs restaient verrouillés dans la base de données (`is_locked_in_mutex = 1`)
- **API non fonctionnelle:** Les requêtes d'ancrage échouaient systématiquement
- **Blocage du mutex:** Le mutex était bloqué depuis une requête d'ancrage à 12:29:40 qui n'avait jamais libéré le mutex
### Impact
- **Indisponibilité:** L'API d'ancrage ne pouvait plus traiter de nouvelles requêtes
- **UTXOs inutilisables:** 22 UTXOs étaient verrouillés et non disponibles pour de nouvelles transactions
- **Requêtes en attente:** Toutes les nouvelles requêtes attendaient le timeout de 3 minutes avant d'échouer
## Root cause
Une requête d'ancrage reçue à 12:29:40 (`test-deploy-1769599785`) a acquis le mutex mais s'est bloquée avant d'atteindre le bloc `finally` qui libère le mutex. Le mutex est resté bloqué indéfiniment, empêchant toutes les requêtes suivantes de s'exécuter.
**Causes possibles:**
1. **Appel RPC Bitcoin bloqué:** Un appel RPC vers Bitcoin Core n'a jamais répondu (timeout ou blocage réseau)
2. **Opération asynchrone non résolue:** Une Promise n'a jamais été résolue ou rejetée
3. **Erreur non gérée:** Une erreur s'est produite avant d'atteindre le bloc `finally`
**Code concerné:**
- `api-anchorage/src/bitcoin-rpc.js` : Méthode `createAnchorTransaction()` ligne 230
- Le mutex est acquis ligne 232 avec `await this.acquireUtxoMutex()`
- Le mutex devrait être libéré dans le bloc `finally` ligne 908-914, mais si l'opération se bloque avant, le `finally` n'est jamais atteint
## Correctifs
### Solution immédiate (appliquée)
1. **Redémarrage du service:** Redémarrage forcé du service `anchorage-api` pour libérer le mutex en mémoire
```bash
sudo systemctl restart anchorage-api
```
2. **Déverrouillage des UTXOs dans la base de données:** Le redémarrage libère le mutex en mémoire, mais les UTXOs restent verrouillés dans la base de données. Il faut les déverrouiller manuellement :
```bash
cd /home/ncantu/Bureau/code/bitcoin/api-anchorage
node unlock-utxos.mjs
```
Ou directement avec SQL :
```sql
UPDATE utxos SET is_locked_in_mutex = 0 WHERE is_locked_in_mutex = 1;
```
3. **Vérification:** Après redémarrage et déverrouillage, l'API répond correctement et les UTXOs sont disponibles
### Solutions préventives à implémenter
1. **Timeout sur les appels RPC Bitcoin:** Ajouter des timeouts explicites sur tous les appels RPC pour éviter les blocages indéfinis
2. **Libération forcée du mutex:** Implémenter un mécanisme de libération automatique du mutex après un certain délai même si l'opération est en cours
3. **Monitoring des opérations longues:** Logger les opérations qui prennent plus de 30 secondes pour identifier les blocages potentiels
4. **Health check amélioré:** Vérifier l'état du mutex dans le health check et signaler si des UTXOs sont verrouillés depuis trop longtemps
## Modifications
### Fichiers concernés
- `api-anchorage/src/bitcoin-rpc.js` : Méthode `createAnchorTransaction()` et gestion du mutex
- `api-anchorage/src/routes/anchor.js` : Gestion des erreurs et timeouts
### Modifications recommandées (à implémenter)
1. **Ajouter des timeouts sur les appels RPC:**
```javascript
// Dans bitcoin-rpc.js, ajouter des timeouts sur tous les appels RPC
const rpcCallWithTimeout = async (method, params, timeoutMs = 30000) => {
return Promise.race([
this.client[method](...params),
new Promise((_, reject) =>
setTimeout(() => reject(new Error(`RPC timeout: ${method}`)), timeoutMs)
)
]);
};
```
2. **Libération automatique du mutex:**
```javascript
// Dans createAnchorTransaction, ajouter un timer de sécurité
const mutexTimeout = setTimeout(() => {
logger.error('Mutex held for too long, forcing release', { hash });
releaseMutex();
}, 300000); // 5 minutes max
try {
// ... opération ...
} finally {
clearTimeout(mutexTimeout);
releaseMutex();
}
```
3. **Déverrouillage automatique des UTXOs:**
```javascript
// Script de maintenance pour déverrouiller les UTXOs verrouillés depuis plus de 10 minutes
// À exécuter périodiquement via cron
```
## Modalités de déploiement
### Vérification de l'état actuel
```bash
# Vérifier que le service est actif
sudo systemctl status anchorage-api
# Vérifier que l'API répond
curl http://localhost:3010/health
# Vérifier les UTXOs verrouillés
curl http://localhost:3010/api/anchor/locked-utxos
# Vérifier les logs récents
sudo journalctl -u anchorage-api -n 50 --no-pager
```
### En cas de nouveau blocage
1. **Identifier le problème:**
```bash
# Vérifier les UTXOs verrouillés
curl http://localhost:3010/api/anchor/locked-utxos
# Vérifier les logs pour identifier la requête bloquée
sudo journalctl -u anchorage-api --since "10 minutes ago" | grep "Anchor request"
```
2. **Redémarrer le service:**
```bash
sudo systemctl restart anchorage-api
```
3. **Déverrouiller les UTXOs dans la base de données:**
```bash
cd /home/ncantu/Bureau/code/bitcoin/api-anchorage
node unlock-utxos.mjs
```
4. **Vérifier la récupération:**
```bash
sleep 3
curl http://localhost:3010/health
curl http://localhost:3010/api/anchor/locked-utxos
# Le count doit être 0
```
## Modalités d'analyse
### Logs à surveiller
1. **Erreurs de timeout de mutex:**
```bash
sudo journalctl -u anchorage-api | grep "Mutex acquisition timeout"
```
2. **Requêtes d'ancrage:**
```bash
sudo journalctl -u anchorage-api | grep "Anchor request received"
```
3. **UTXOs verrouillés:**
```bash
curl http://localhost:3010/api/anchor/locked-utxos | jq '.count'
```
### Métriques à surveiller
- **Nombre d'UTXOs verrouillés:** Doit être 0 en temps normal
- **Durée des opérations d'ancrage:** Ne devrait pas dépasser 30 secondes
- **Erreurs de timeout:** Ne devrait pas se produire en fonctionnement normal
### Alertes recommandées
- **UTXOs verrouillés > 5:** Alerte warning
- **UTXOs verrouillés > 10:** Alerte critique
- **Timeout de mutex:** Alerte critique immédiate
## Pages affectées
- `api-anchorage/src/bitcoin-rpc.js` : Gestion du mutex et des timeouts
- `api-anchorage/src/routes/anchor.js` : Gestion des erreurs
- `fixKnowledge/api-anchorage-mutex-timeout-blocked.md` : Documentation (nouveau)

View File

@ -15,19 +15,26 @@ export declare class PersistentNonceCache implements NonceCacheLike {
init(): Promise<void>; init(): Promise<void>;
/** /**
* Check if nonce is valid (not seen within TTL). Records nonce on success. * Check if nonce is valid (not seen within TTL). Records nonce on success.
* Note: IndexedDB operations are async, but NonceCacheLike interface requires sync. * Uses localStorage for synchronous access (required by NonceCacheLike interface).
* This implementation uses localStorage for synchronous access. * Also persists to IndexedDB in background if available.
* For true IndexedDB persistence, consider making the interface async.
*/ */
isValid(nonce: string, timestamp: number): boolean; isValid(nonce: string, timestamp: number): boolean;
/** /**
* Synchronous validation using localStorage (fallback). * Synchronous validation using localStorage (primary storage).
*/ */
private isValidSync; private isValidSync;
/** /**
* Cleanup expired entries (localStorage). * Persist nonce to IndexedDB in background (async, non-blocking).
*/
private persistToIndexedDB;
/**
* Cleanup expired entries (localStorage and IndexedDB).
*/ */
private cleanupSync; private cleanupSync;
/**
* Cleanup expired entries from IndexedDB (async, non-blocking).
*/
private cleanupIndexedDB;
/** /**
* Clear all entries. * Clear all entries.
*/ */

View File

@ -1 +1 @@
{"version":3,"file":"persistentNonceCache.d.ts","sourceRoot":"","sources":["../src/persistentNonceCache.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAEjD;;;GAGG;AACH,qBAAa,oBAAqB,YAAW,cAAc;IACzD,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAS;IAC/B,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAS;IACpC,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAU;IACvC,OAAO,CAAC,EAAE,CAA4B;gBAE1B,KAAK,GAAE,MAAgB,EAAE,UAAU,GAAE,MAAsB;IAMvE;;OAEG;IACG,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IA2B3B;;;;;OAKG;IACH,OAAO,CAAC,KAAK,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO;IAIlD;;OAEG;IACH,OAAO,CAAC,WAAW;IAiBnB;;OAEG;IACH,OAAO,CAAC,WAAW;IAoBnB;;OAEG;IACH,KAAK,IAAI,IAAI;CAkBd"} {"version":3,"file":"persistentNonceCache.d.ts","sourceRoot":"","sources":["../src/persistentNonceCache.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAEjD;;;GAGG;AACH,qBAAa,oBAAqB,YAAW,cAAc;IACzD,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAS;IAC/B,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAS;IACpC,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAU;IACvC,OAAO,CAAC,EAAE,CAA4B;gBAE1B,KAAK,GAAE,MAAgB,EAAE,UAAU,GAAE,MAAsB;IAMvE;;OAEG;IACG,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IA2B3B;;;;OAIG;IACH,OAAO,CAAC,KAAK,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO;IASlD;;OAEG;IACH,OAAO,CAAC,WAAW;IAiBnB;;OAEG;YACW,kBAAkB;IAuBhC;;OAEG;IACH,OAAO,CAAC,WAAW;IAyBnB;;OAEG;YACW,gBAAgB;IAgC9B;;OAEG;IACH,KAAK,IAAI,IAAI;CAkBd"}

View File

@ -39,15 +39,19 @@ export class PersistentNonceCache {
} }
/** /**
* Check if nonce is valid (not seen within TTL). Records nonce on success. * Check if nonce is valid (not seen within TTL). Records nonce on success.
* Note: IndexedDB operations are async, but NonceCacheLike interface requires sync. * Uses localStorage for synchronous access (required by NonceCacheLike interface).
* This implementation uses localStorage for synchronous access. * Also persists to IndexedDB in background if available.
* For true IndexedDB persistence, consider making the interface async.
*/ */
isValid(nonce, timestamp) { isValid(nonce, timestamp) {
return this.isValidSync(nonce, timestamp); const result = this.isValidSync(nonce, timestamp);
// Persist to IndexedDB in background if available
if (this.useIndexedDB && this.db !== null) {
void this.persistToIndexedDB(nonce, timestamp);
}
return result;
} }
/** /**
* Synchronous validation using localStorage (fallback). * Synchronous validation using localStorage (primary storage).
*/ */
isValidSync(nonce, timestamp) { isValidSync(nonce, timestamp) {
const now = Date.now(); const now = Date.now();
@ -64,7 +68,32 @@ export class PersistentNonceCache {
return true; return true;
} }
/** /**
* Cleanup expired entries (localStorage). * Persist nonce to IndexedDB in background (async, non-blocking).
*/
async persistToIndexedDB(nonce, timestamp) {
if (this.db === null) {
return;
}
try {
const transaction = this.db.transaction(['nonces'], 'readwrite');
const store = transaction.objectStore('nonces');
await new Promise((resolve, reject) => {
const request = store.put({ nonce, timestamp });
request.onsuccess = () => {
resolve();
};
request.onerror = () => {
reject(request.error);
};
});
}
catch (error) {
// IndexedDB errors are non-critical, localStorage is the primary storage
console.warn('Failed to persist nonce to IndexedDB:', error);
}
}
/**
* Cleanup expired entries (localStorage and IndexedDB).
*/ */
cleanupSync(now) { cleanupSync(now) {
const keys = []; const keys = [];
@ -83,6 +112,44 @@ export class PersistentNonceCache {
} }
} }
} }
// Cleanup IndexedDB in background
if (this.useIndexedDB && this.db !== null) {
void this.cleanupIndexedDB(now);
}
}
/**
* Cleanup expired entries from IndexedDB (async, non-blocking).
*/
async cleanupIndexedDB(now) {
if (this.db === null) {
return;
}
try {
const transaction = this.db.transaction(['nonces'], 'readwrite');
const store = transaction.objectStore('nonces');
const index = store.index('timestamp');
const range = IDBKeyRange.upperBound(now - this.ttlMs);
await new Promise((resolve, reject) => {
const request = index.openCursor(range);
request.onsuccess = (event) => {
const cursor = event.target.result;
if (cursor !== null && cursor !== undefined) {
cursor.delete();
cursor.continue();
}
else {
resolve();
}
};
request.onerror = () => {
reject(request.error);
};
});
}
catch (error) {
// IndexedDB errors are non-critical
console.warn('Failed to cleanup IndexedDB:', error);
}
} }
/** /**
* Clear all entries. * Clear all entries.

View File

@ -1,7 +1,7 @@
import type { NonceCacheLike } from './types.js'; import type { NonceCacheLike } from './types.js';
/** /**
* Persistent nonce cache using IndexedDB (browser) or localStorage (fallback). * Persistent nonce cache using IndexedDB (browser) and localStorage.
* Implements NonceCacheLike interface for use with verifyLoginProof. * Implements NonceCacheLike interface for use with verifyLoginProof.
*/ */
export class PersistentNonceCache implements NonceCacheLike { export class PersistentNonceCache implements NonceCacheLike {
@ -48,16 +48,20 @@ export class PersistentNonceCache implements NonceCacheLike {
/** /**
* Check if nonce is valid (not seen within TTL). Records nonce on success. * Check if nonce is valid (not seen within TTL). Records nonce on success.
* Note: IndexedDB operations are async, but NonceCacheLike interface requires sync. * Uses localStorage for synchronous access (required by NonceCacheLike interface).
* This implementation uses localStorage for synchronous access. * Also persists to IndexedDB in background if available.
* For true IndexedDB persistence, consider making the interface async.
*/ */
isValid(nonce: string, timestamp: number): boolean { isValid(nonce: string, timestamp: number): boolean {
return this.isValidSync(nonce, timestamp); const result = this.isValidSync(nonce, timestamp);
// Persist to IndexedDB in background if available
if (this.useIndexedDB && this.db !== null) {
void this.persistToIndexedDB(nonce, timestamp);
}
return result;
} }
/** /**
* Synchronous validation using localStorage (fallback). * Synchronous validation using localStorage (primary storage).
*/ */
private isValidSync(nonce: string, timestamp: number): boolean { private isValidSync(nonce: string, timestamp: number): boolean {
const now = Date.now(); const now = Date.now();
@ -77,7 +81,33 @@ export class PersistentNonceCache implements NonceCacheLike {
} }
/** /**
* Cleanup expired entries (localStorage). * Persist nonce to IndexedDB in background (async, non-blocking).
*/
private async persistToIndexedDB(nonce: string, timestamp: number): Promise<void> {
if (this.db === null) {
return;
}
try {
const transaction = this.db.transaction(['nonces'], 'readwrite');
const store = transaction.objectStore('nonces');
await new Promise<void>((resolve, reject) => {
const request = store.put({ nonce, timestamp });
request.onsuccess = (): void => {
resolve();
};
request.onerror = (): void => {
reject(request.error);
};
});
} catch (error) {
// IndexedDB errors are non-critical, localStorage is the primary storage
console.warn('Failed to persist nonce to IndexedDB:', error);
}
}
/**
* Cleanup expired entries (localStorage and IndexedDB).
*/ */
private cleanupSync(now: number): void { private cleanupSync(now: number): void {
const keys: string[] = []; const keys: string[] = [];
@ -97,6 +127,46 @@ export class PersistentNonceCache implements NonceCacheLike {
} }
} }
} }
// Cleanup IndexedDB in background
if (this.useIndexedDB && this.db !== null) {
void this.cleanupIndexedDB(now);
}
}
/**
* Cleanup expired entries from IndexedDB (async, non-blocking).
*/
private async cleanupIndexedDB(now: number): Promise<void> {
if (this.db === null) {
return;
}
try {
const transaction = this.db.transaction(['nonces'], 'readwrite');
const store = transaction.objectStore('nonces');
const index = store.index('timestamp');
const range = IDBKeyRange.upperBound(now - this.ttlMs);
await new Promise<void>((resolve, reject) => {
const request = index.openCursor(range);
request.onsuccess = (event: Event): void => {
const cursor = (event.target as IDBRequest<IDBCursorWithValue | null>).result;
if (cursor !== null && cursor !== undefined) {
cursor.delete();
cursor.continue();
} else {
resolve();
}
};
request.onerror = (): void => {
reject(request.error);
};
});
} catch (error) {
// IndexedDB errors are non-critical
console.warn('Failed to cleanup IndexedDB:', error);
}
} }
/** /**

View File

@ -245,6 +245,7 @@
<p><strong>Endpoints publics (sans authentification) :</strong></p> <p><strong>Endpoints publics (sans authentification) :</strong></p>
<ul style="margin-left: 20px; margin-top: 10px;"> <ul style="margin-left: 20px; margin-top: 10px;">
<li><code>GET /health</code> - Vérification de santé (API d'ancrage)</li> <li><code>GET /health</code> - Vérification de santé (API d'ancrage)</li>
<li><code>GET /health/detailed</code> - Vérification de santé détaillée avec état mutex et UTXOs</li>
<li><code>GET /api/anchor/locked-utxos</code> - Liste des UTXO verrouillés</li> <li><code>GET /api/anchor/locked-utxos</code> - Liste des UTXO verrouillés</li>
<li><code>GET /health</code> - Vérification de santé (API faucet)</li> <li><code>GET /health</code> - Vérification de santé (API faucet)</li>
</ul> </ul>
@ -304,6 +305,120 @@
</div> </div>
</section> </section>
<!-- Endpoint: Health Detailed -->
<section class="api-docs-section">
<div class="endpoint-card">
<div class="endpoint-header">
<span class="method-badge method-get">GET</span>
<span class="endpoint-path">/health/detailed</span>
</div>
<div class="endpoint-description">
<p>Vérifie l'état détaillé de l'API, incluant l'état du mutex, des UTXOs verrouillés et de la connexion Bitcoin. Cet endpoint est public et ne nécessite pas d'authentification.</p>
</div>
<div class="response-example">
<h4>Réponse (200 OK) - Tout fonctionne</h4>
<div class="code-block">
<pre>{
"ok": true,
"service": "anchor-api",
"mutex": {
"locked": false,
"waiting": 0,
"timeout": 180000
},
"utxos": {
"locked": 0,
"locked_since": null,
"stale_locks": 0,
"stale_locks_details": []
},
"bitcoin": {
"connected": true,
"blocks": 12345,
"chain": "signet",
"rpc_timeout": 60000
},
"timestamp": "2026-01-25T12:00:00.000Z"
}</pre>
</div>
</div>
<div class="response-example">
<h4>Réponse (200 OK) - Warning (UTXOs verrouillés mais < 10 min)</h4>
<div class="code-block">
<pre>{
"ok": true,
"service": "anchor-api",
"mutex": {
"locked": false,
"waiting": 0,
"timeout": 180000
},
"utxos": {
"locked": 3,
"locked_since": "2026-01-25T11:55:00.000Z",
"stale_locks": 0,
"stale_locks_details": []
},
"bitcoin": {
"connected": true,
"blocks": 12345,
"chain": "signet",
"rpc_timeout": 60000
},
"timestamp": "2026-01-25T12:00:00.000Z"
}</pre>
</div>
</div>
<div class="response-example">
<h4>Réponse (503 Service Unavailable) - UTXOs verrouillés depuis > 10 min</h4>
<div class="code-block">
<pre>{
"ok": false,
"service": "anchor-api",
"mutex": {
"locked": false,
"waiting": 0,
"timeout": 180000
},
"utxos": {
"locked": 5,
"locked_since": "2026-01-25T11:45:00.000Z",
"stale_locks": 5,
"stale_locks_details": [
{
"txid": "abc123...",
"vout": 0,
"minutes_locked": 15.5
}
]
},
"bitcoin": {
"connected": true,
"blocks": 12345,
"chain": "signet",
"rpc_timeout": 60000
},
"timestamp": "2026-01-25T12:00:00.000Z"
}</pre>
</div>
</div>
<div class="info-box">
<p><strong>Codes de statut :</strong></p>
<ul style="margin-left: 20px; margin-top: 10px;">
<li><code>200</code> - Tout fonctionne correctement</li>
<li><code>200</code> - Warning si 5-10 UTXOs verrouillés (mais < 10 min)</li>
<li><code>503</code> - Service Unavailable si UTXOs verrouillés depuis > 10 min ou > 10 UTXOs verrouillés</li>
<li><code>503</code> - Service Unavailable si Bitcoin non connecté</li>
</ul>
</div>
</div>
</section>
<!-- Endpoint: Anchor Document --> <!-- Endpoint: Anchor Document -->
<section class="api-docs-section"> <section class="api-docs-section">
<div class="endpoint-card"> <div class="endpoint-card">
@ -1684,7 +1799,7 @@ Format: hexadécimal (0-9, a-f, A-F)</pre>
<p>Les APIs sont accessibles aux adresses suivantes :</p> <p>Les APIs sont accessibles aux adresses suivantes :</p>
<div class="code-block"> <div class="code-block">
<pre>Dashboard : https://dashboard.certificator.4nkweb.com (port 3020) <pre>Dashboard : https://dashboard.certificator.4nkweb.com (port 3020)
API d'Ancrage : https://certificator.4nkweb.com (port 3010) API d'Ancrage : https://anchorage.certificator.4nkweb.com (port 3010, machine bitcoin 192.168.1.105)
API Faucet : https://faucet.certificator.4nkweb.com (port 3021) API Faucet : https://faucet.certificator.4nkweb.com (port 3021)
API Filigrane : https://watermark.certificator.4nkweb.com (port 3022) API Filigrane : https://watermark.certificator.4nkweb.com (port 3022)
API ClamAV : https://antivir.certificator.4nkweb.com (port 3023)</pre> API ClamAV : https://antivir.certificator.4nkweb.com (port 3023)</pre>

View File

@ -15,6 +15,8 @@ if (window.location.hostname.includes('dashboard.certificator.4nkweb.com')) {
} }
let selectedFile = null; let selectedFile = null;
let selectedClamavFile = null;
let selectedFiligraneFile = null;
let lastBlockHeight = null; let lastBlockHeight = null;
let blockPollingInterval = null; let blockPollingInterval = null;
let dataRefreshInterval = null; let dataRefreshInterval = null;
@ -722,15 +724,6 @@ async function verifyHash() {
} }
} }
/**
* Affiche/masque les options de filigrane
*/
function toggleWatermarkOptions() {
const enabled = document.getElementById('watermark-enabled').checked;
const optionsDiv = document.getElementById('watermark-options');
optionsDiv.style.display = enabled ? 'block' : 'none';
}
/** /**
* Ancre le document * Ancre le document
*/ */
@ -743,7 +736,6 @@ async function anchorDocument() {
const apiKey = apiKeyElement.value.trim(); const apiKey = apiKeyElement.value.trim();
const hash = document.getElementById('anchor-hash').value; const hash = document.getElementById('anchor-hash').value;
const watermarkEnabled = document.getElementById('watermark-enabled').checked;
if (!apiKey || apiKey.length === 0) { if (!apiKey || apiKey.length === 0) {
showResult('anchor-result', 'error', 'Veuillez entrer une clé API.'); showResult('anchor-result', 'error', 'Veuillez entrer une clé API.');
@ -760,224 +752,30 @@ async function anchorDocument() {
try { try {
showResult('anchor-result', 'info', 'Ancrage en cours...'); showResult('anchor-result', 'info', 'Ancrage en cours...');
// Si le filigrane est activé, utiliser l'API filigrane const response = await fetch(`${API_BASE_URL}/api/anchor/test`, {
if (watermarkEnabled) {
await anchorWithWatermark(apiKey);
} else {
// Sinon, utiliser l'API d'ancrage classique
const response = await fetch(`${API_BASE_URL}/api/anchor/test`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ hash, apiKey }),
});
const data = await response.json();
if (response.ok && data.txid) {
showResult('anchor-result', 'success',
`Document ancré avec succès !<br>
<strong>TXID :</strong> ${data.txid}<br>
<strong>Statut :</strong> ${data.status}<br>
<strong>Confirmations :</strong> ${data.confirmations || 0}`);
// Recharger le nombre d'ancrages après un court délai
setTimeout(loadAnchorCount, 2000);
} else {
showResult('anchor-result', 'error', data.message || data.error || 'Erreur lors de l\'ancrage.');
}
}
} catch (error) {
showResult('anchor-result', 'error', `Erreur : ${error.message}`);
}
}
/**
* Ancre le document avec filigrane
*/
async function anchorWithWatermark(apiKey) {
// Vérifier que la clé API est bien fournie
if (!apiKey || apiKey.trim().length === 0) {
showResult('anchor-result', 'error', 'Veuillez entrer une clé API.');
return;
}
// Utiliser le backend du dashboard comme proxy vers l'API filigrane
const watermarkApiUrl = API_BASE_URL;
const textContent = document.getElementById('anchor-text').value;
const fileInput = document.getElementById('anchor-file');
const currentSelectedFile = selectedFile || (fileInput.files.length > 0 ? fileInput.files[0] : null);
// Préparer les données du fichier
let fileData = null;
let fileName = null;
let mimeType = null;
if (currentSelectedFile) {
const reader = new FileReader();
await new Promise((resolve, reject) => {
reader.onload = (e) => {
const arrayBuffer = e.target.result;
const base64 = btoa(String.fromCharCode(...new Uint8Array(arrayBuffer)));
fileData = base64;
fileName = currentSelectedFile.name;
mimeType = currentSelectedFile.type;
resolve();
};
reader.onerror = reject;
reader.readAsArrayBuffer(currentSelectedFile);
});
}
// Préparer les options de filigrane
const watermarkOptionsRaw = {
enabled: true,
text: document.getElementById('watermark-text').value.trim() || undefined,
signature: document.getElementById('watermark-signature').value.trim() || undefined,
depositor: document.getElementById('watermark-depositor').value.trim() || undefined,
watermarkedFileName: document.getElementById('watermarked-filename').value.trim() || undefined,
originalFileName: document.getElementById('original-filename').value.trim() || undefined,
dateUTC: document.getElementById('watermark-date-utc').checked,
dateLocal: document.getElementById('watermark-date-local').checked,
blockNumber: document.getElementById('watermark-block-number').checked,
blockHash: document.getElementById('watermark-block-hash').checked,
documentHash: document.getElementById('watermark-document-hash').checked,
};
// Nettoyer l'objet en supprimant les valeurs undefined
const watermarkOptions = Object.fromEntries(
Object.entries(watermarkOptionsRaw).filter(([_, value]) => value !== undefined)
);
// S'assurer que enabled est toujours présent et true
watermarkOptions.enabled = true;
const requestBody = {
apiKey,
watermarkOptions,
};
if (textContent) {
requestBody.textContent = textContent;
} else if (fileData) {
requestBody.fileData = fileData;
requestBody.fileName = fileName;
requestBody.mimeType = mimeType;
} else {
showResult('anchor-result', 'error', 'Veuillez saisir un texte ou sélectionner un fichier.');
return;
}
try {
const response = await fetch(`${watermarkApiUrl}/api/watermark/document`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify(requestBody), body: JSON.stringify({ hash, apiKey }),
}); });
let data; const data = await response.json();
try {
data = await response.json(); if (response.ok && data.txid) {
} catch (jsonError) { showResult('anchor-result', 'success',
const text = await response.text(); `Document ancré avec succès !<br>
showResult('anchor-result', 'error', `Erreur de parsing de la réponse: ${text}`); <strong>TXID :</strong> ${data.txid}<br>
return; <strong>Statut :</strong> ${data.status}<br>
<strong>Confirmations :</strong> ${data.confirmations || 0}`);
// Recharger le nombre d'ancrages après un court délai
setTimeout(loadAnchorCount, 2000);
} else {
showResult('anchor-result', 'error', data.message || data.error || 'Erreur lors de l\'ancrage.');
} }
if (!response.ok) {
const errorMessage = data.message || data.error || `Erreur ${response.status}: ${response.statusText}`;
console.error('Watermark API error:', {
status: response.status,
statusText: response.statusText,
data: data,
requestBody: {
hasApiKey: !!requestBody.apiKey,
hasWatermarkOptions: !!requestBody.watermarkOptions,
watermarkEnabled: requestBody.watermarkOptions?.enabled,
hasFileData: !!requestBody.fileData,
hasTextContent: !!requestBody.textContent,
},
});
showResult('anchor-result', 'error', errorMessage);
return;
}
if (data.success) {
const mempoolBaseUrl = 'https://mempool.4nkweb.com/fr';
const originalTxidLink = data.original.txid
? `<a href="${mempoolBaseUrl}/tx/${data.original.txid}" target="_blank" style="color: #6ec6ff; text-decoration: underline;">${data.original.txid}</a>`
: 'N/A';
const watermarkedTxidLink = data.watermarked.txid
? `<a href="${mempoolBaseUrl}/tx/${data.watermarked.txid}" target="_blank" style="color: #6ec6ff; text-decoration: underline;">${data.watermarked.txid}</a>`
: 'N/A';
let resultHtml = `
<strong> Documents ancrés avec succès !</strong><br><br>
<strong>Document original :</strong><br>
<strong>Hash SHA256 :</strong> ${data.original.hash || 'N/A'}<br>
<strong>TXID :</strong> ${originalTxidLink}<br>
<strong>Statut :</strong> ${data.original.status}<br>
<strong>Confirmations :</strong> ${data.original.confirmations || 0}<br>
<strong>Fichier :</strong> ${data.original.file.name}<br><br>
<strong>Document filigrané :</strong><br>
<strong>Hash SHA256 :</strong> ${data.watermarked.hash || 'N/A'}<br>
<strong>TXID :</strong> ${watermarkedTxidLink}<br>
<strong>Statut :</strong> ${data.watermarked.status}<br>
<strong>Confirmations :</strong> ${data.watermarked.confirmations || 0}<br>
<strong>Fichier :</strong> ${data.watermarked.file.name}<br><br>
`;
if (data.certificate && data.certificate.data) {
resultHtml += `
<strong>Certificat :</strong><br>
<strong>Fichier :</strong> ${data.certificate.name}<br><br>
`;
}
if (data.merged && data.merged.data) {
resultHtml += `
<strong>Document fusionné (filigrané + certificat) :</strong><br>
<strong>Fichier :</strong> ${data.merged.name}<br><br>
`;
}
resultHtml += `<p style="color: #90ee90; font-weight: bold;">📥 Téléchargement automatique en cours...</p>`;
showResult('anchor-result', 'success', resultHtml);
// Télécharger automatiquement les 4 documents avec un délai entre chaque
setTimeout(() => {
downloadFile(data.original.file.name, data.original.file.data);
}, 500);
setTimeout(() => {
downloadFile(data.watermarked.file.name, data.watermarked.file.data);
}, 1000);
if (data.certificate && data.certificate.data) {
setTimeout(() => {
downloadFile(data.certificate.name, data.certificate.data);
}, 1500);
}
if (data.merged && data.merged.data) {
setTimeout(() => {
downloadFile(data.merged.name, data.merged.data);
}, 2000);
}
// Recharger le nombre d'ancrages après un court délai
setTimeout(loadAnchorCount, 2000);
} else {
showResult('anchor-result', 'error', data.message || data.error || 'Erreur lors de l\'ancrage avec filigrane.');
}
} catch (error) { } catch (error) {
console.error('Error in anchorWithWatermark:', error); showResult('anchor-result', 'error', `Erreur : ${error.message}`);
showResult('anchor-result', 'error', `Erreur lors de l'ancrage avec filigrane: ${error.message}`);
} }
} }
@ -1062,3 +860,350 @@ function showResult(elementId, type, message) {
element.className = `result ${type}`; element.className = `result ${type}`;
element.innerHTML = message; element.innerHTML = message;
} }
/**
* Gère la sélection de fichier pour ClamAV
*/
function handleClamavFileSelect(event) {
const file = event.target.files[0];
if (file) {
selectedClamavFile = file;
const fileInfo = document.getElementById('clamav-file-info');
const maxSize = 100 * 1024 * 1024; // 100 MB en bytes
const fileSize = file.size;
const isOverLimit = fileSize > maxSize;
let infoHtml = `
<strong>Fichier sélectionné :</strong> ${file.name}<br>
<strong>Taille :</strong> ${formatFileSize(file.size)}<br>
<strong>Type :</strong> ${file.type || 'Non spécifié'}
`;
if (isOverLimit) {
infoHtml += `<br><span style="color: #ff6b6b; font-weight: bold;">⚠️ Fichier trop volumineux (limite : 100 MB)</span>`;
}
fileInfo.innerHTML = infoHtml;
}
}
/**
* Scanne un fichier avec ClamAV
*/
async function scanWithClamav() {
if (!selectedClamavFile) {
showResult('clamav-result', 'error', 'Veuillez sélectionner un fichier.');
return;
}
// Vérifier la taille du fichier (limite : 100 MB)
const maxSize = 100 * 1024 * 1024; // 100 MB en bytes
if (selectedClamavFile.size > maxSize) {
showResult('clamav-result', 'error', `Le fichier est trop volumineux (${formatFileSize(selectedClamavFile.size)}). La limite est de 100 MB.`);
return;
}
try {
showResult('clamav-result', 'info', 'Scan en cours...');
const reader = new FileReader();
await new Promise((resolve, reject) => {
reader.onload = async (e) => {
try {
const arrayBuffer = e.target.result;
const uint8Array = new Uint8Array(arrayBuffer);
let binaryString = '';
for (let i = 0; i < uint8Array.length; i++) {
binaryString += String.fromCharCode(uint8Array[i]);
}
const base64 = btoa(binaryString);
const response = await fetch(`${API_BASE_URL}/api/clamav/scan`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
data: base64,
filename: selectedClamavFile.name,
}),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({ error: `HTTP ${response.status}: ${response.statusText}` }));
throw new Error(errorData.error || errorData.message || `HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
if (data.infected) {
showResult('clamav-result', 'error',
`⚠️ Fichier infecté détecté !<br>
<strong>Fichier :</strong> ${data.filename || selectedClamavFile.name}<br>
<strong>Taille :</strong> ${formatFileSize(data.size || selectedClamavFile.size)}<br>
<strong>Virus détectés :</strong> ${data.viruses ? data.viruses.join(', ') : 'Inconnu'}`);
} else {
showResult('clamav-result', 'success',
`✅ Fichier propre !<br>
<strong>Fichier :</strong> ${data.filename || selectedClamavFile.name}<br>
<strong>Taille :</strong> ${formatFileSize(data.size || selectedClamavFile.size)}<br>
<strong>Statut :</strong> Aucun virus détecté`);
}
resolve();
} catch (error) {
showResult('clamav-result', 'error', `Erreur : ${error.message}`);
reject(error);
}
};
reader.onerror = (error) => {
showResult('clamav-result', 'error', `Erreur lors de la lecture du fichier : ${error.message || 'Erreur inconnue'}`);
reject(error);
};
reader.readAsArrayBuffer(selectedClamavFile);
});
} catch (error) {
showResult('clamav-result', 'error', `Erreur lors du scan : ${error.message}`);
}
}
/**
* Change d'onglet pour le formulaire filigrane
*/
function switchTabFiligrane(tab, buttonElement) {
// Désactiver tous les onglets filigrane
document.querySelectorAll('#filigrane-text-tab, #filigrane-file-tab').forEach(content => {
content.classList.remove('active');
});
// Désactiver tous les boutons d'onglet dans la section filigrane
const filigraneSection = document.querySelector('.filigrane-section');
if (filigraneSection) {
filigraneSection.querySelectorAll('.tab-button').forEach(button => {
button.classList.remove('active');
});
}
// Activer l'onglet sélectionné
document.getElementById(`filigrane-${tab}-tab`).classList.add('active');
// Activer le bouton correspondant
if (buttonElement) {
buttonElement.classList.add('active');
}
}
/**
* Gère la sélection de fichier pour Filigrane
*/
function handleFiligraneFileSelect(event) {
const file = event.target.files[0];
if (file) {
selectedFiligraneFile = file;
const fileInfo = document.getElementById('filigrane-file-info');
const maxSize = 100 * 1024 * 1024; // 100 MB en bytes
const fileSize = file.size;
const isOverLimit = fileSize > maxSize;
let infoHtml = `
<strong>Fichier sélectionné :</strong> ${file.name}<br>
<strong>Taille :</strong> ${formatFileSize(file.size)}<br>
<strong>Type :</strong> ${file.type || 'Non spécifié'}
`;
if (isOverLimit) {
infoHtml += `<br><span style="color: #ff6b6b; font-weight: bold;">⚠️ Fichier trop volumineux (limite : 100 MB)</span>`;
}
fileInfo.innerHTML = infoHtml;
}
}
/**
* Teste l'API Filigrane
*/
async function testFiligrane() {
const apiKey = document.getElementById('filigrane-api-key').value.trim();
if (!apiKey) {
showResult('filigrane-result', 'error', 'Veuillez entrer une clé API.');
return;
}
const textContent = document.getElementById('filigrane-text').value;
const fileInput = document.getElementById('filigrane-file');
const currentSelectedFile = selectedFiligraneFile || (fileInput.files.length > 0 ? fileInput.files[0] : null);
if (!textContent && !currentSelectedFile) {
showResult('filigrane-result', 'error', 'Veuillez saisir un texte ou sélectionner un fichier.');
return;
}
// Vérifier la taille du fichier si un fichier est sélectionné
if (currentSelectedFile) {
const maxSize = 100 * 1024 * 1024; // 100 MB en bytes
if (currentSelectedFile.size > maxSize) {
showResult('filigrane-result', 'error', `Le fichier est trop volumineux (${formatFileSize(currentSelectedFile.size)}). La limite est de 100 MB.`);
return;
}
}
try {
showResult('filigrane-result', 'info', 'Traitement en cours...');
// Préparer les données du fichier
let fileData = null;
let fileName = null;
let mimeType = null;
if (currentSelectedFile) {
const reader = new FileReader();
await new Promise((resolve, reject) => {
reader.onload = (e) => {
const arrayBuffer = e.target.result;
const uint8Array = new Uint8Array(arrayBuffer);
let binaryString = '';
for (let i = 0; i < uint8Array.length; i++) {
binaryString += String.fromCharCode(uint8Array[i]);
}
const base64 = btoa(binaryString);
fileData = base64;
fileName = currentSelectedFile.name;
mimeType = currentSelectedFile.type;
resolve();
};
reader.onerror = reject;
reader.readAsArrayBuffer(currentSelectedFile);
});
}
// Préparer les options de filigrane
const watermarkOptions = {
enabled: true,
text: document.getElementById('filigrane-watermark-text').value.trim() || undefined,
signature: document.getElementById('filigrane-watermark-signature').value.trim() || undefined,
depositor: document.getElementById('filigrane-watermark-depositor').value.trim() || undefined,
watermarkedFileName: document.getElementById('filigrane-watermarked-filename').value.trim() || undefined,
originalFileName: document.getElementById('filigrane-original-filename').value.trim() || undefined,
dateUTC: document.getElementById('filigrane-watermark-date-utc').checked,
dateLocal: document.getElementById('filigrane-watermark-date-local').checked,
blockNumber: document.getElementById('filigrane-watermark-block-number').checked,
blockHash: document.getElementById('filigrane-watermark-block-hash').checked,
documentHash: document.getElementById('filigrane-watermark-document-hash').checked,
};
// Nettoyer l'objet en supprimant les valeurs undefined
const cleanedWatermarkOptions = Object.fromEntries(
Object.entries(watermarkOptions).filter(([_, value]) => value !== undefined)
);
const requestBody = {
apiKey,
watermarkOptions: cleanedWatermarkOptions,
};
if (textContent) {
requestBody.textContent = textContent;
} else if (fileData) {
requestBody.fileData = fileData;
requestBody.fileName = fileName;
requestBody.mimeType = mimeType;
}
const response = await fetch(`${API_BASE_URL}/api/watermark/document`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
});
let data;
try {
data = await response.json();
} catch (jsonError) {
const text = await response.text();
showResult('filigrane-result', 'error', `Erreur de parsing de la réponse: ${text}`);
return;
}
if (!response.ok) {
const errorMessage = data.message || data.error || `Erreur ${response.status}: ${response.statusText}`;
showResult('filigrane-result', 'error', errorMessage);
return;
}
if (data.success) {
const mempoolBaseUrl = 'https://mempool.4nkweb.com/fr';
const originalTxidLink = data.original.txid
? `<a href="${mempoolBaseUrl}/tx/${data.original.txid}" target="_blank" style="color: #6ec6ff; text-decoration: underline;">${data.original.txid}</a>`
: 'N/A';
const watermarkedTxidLink = data.watermarked.txid
? `<a href="${mempoolBaseUrl}/tx/${data.watermarked.txid}" target="_blank" style="color: #6ec6ff; text-decoration: underline;">${data.watermarked.txid}</a>`
: 'N/A';
let resultHtml = `
<strong> Documents ancrés avec succès !</strong><br><br>
<strong>Document original :</strong><br>
<strong>Hash SHA256 :</strong> ${data.original.hash || 'N/A'}<br>
<strong>TXID :</strong> ${originalTxidLink}<br>
<strong>Statut :</strong> ${data.original.status}<br>
<strong>Confirmations :</strong> ${data.original.confirmations || 0}<br>
<strong>Fichier :</strong> ${data.original.file.name}<br><br>
<strong>Document filigrané :</strong><br>
<strong>Hash SHA256 :</strong> ${data.watermarked.hash || 'N/A'}<br>
<strong>TXID :</strong> ${watermarkedTxidLink}<br>
<strong>Statut :</strong> ${data.watermarked.status}<br>
<strong>Confirmations :</strong> ${data.watermarked.confirmations || 0}<br>
<strong>Fichier :</strong> ${data.watermarked.file.name}<br><br>
`;
if (data.certificate && data.certificate.data) {
resultHtml += `
<strong>Certificat :</strong><br>
<strong>Fichier :</strong> ${data.certificate.name}<br><br>
`;
}
if (data.merged && data.merged.data) {
resultHtml += `
<strong>Document fusionné (filigrané + certificat) :</strong><br>
<strong>Fichier :</strong> ${data.merged.name}<br><br>
`;
}
resultHtml += `<p style="color: #90ee90; font-weight: bold;">📥 Téléchargement automatique en cours...</p>`;
showResult('filigrane-result', 'success', resultHtml);
// Télécharger automatiquement les documents avec un délai entre chaque
setTimeout(() => {
downloadFile(data.original.file.name, data.original.file.data);
}, 500);
setTimeout(() => {
downloadFile(data.watermarked.file.name, data.watermarked.file.data);
}, 1000);
if (data.certificate && data.certificate.data) {
setTimeout(() => {
downloadFile(data.certificate.name, data.certificate.data);
}, 1500);
}
if (data.merged && data.merged.data) {
setTimeout(() => {
downloadFile(data.merged.name, data.merged.data);
}, 2000);
}
// Recharger le nombre d'ancrages après un court délai
setTimeout(loadAnchorCount, 2000);
} else {
showResult('filigrane-result', 'error', data.message || data.error || 'Erreur lors du test de l\'API filigrane.');
}
} catch (error) {
console.error('Error in testFiligrane:', error);
showResult('filigrane-result', 'error', `Erreur : ${error.message}`);
}
}

View File

@ -164,6 +164,75 @@
.confirmed-check.no { .confirmed-check.no {
color: #dc3545; color: #dc3545;
} }
.health-section {
background: var(--card-background);
padding: 15px;
border-radius: 5px;
margin-bottom: 20px;
border: 1px solid var(--border-color);
}
.health-status {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
margin-bottom: 15px;
}
.health-item {
padding: 10px;
background: rgba(255, 255, 255, 0.05);
border-radius: 5px;
border: 1px solid var(--border-color);
}
.health-item label {
display: block;
font-weight: bold;
margin-bottom: 5px;
color: var(--text-color);
}
.health-item .value {
font-size: 1.1em;
}
.health-item .value.ok {
color: #28a745;
}
.health-item .value.warning {
color: #ffc107;
}
.health-item .value.error {
color: #dc3545;
}
.unlock-button {
background: #dc3545;
color: white;
border: none;
padding: 10px 20px;
border-radius: 5px;
cursor: pointer;
font-size: 1em;
}
.unlock-button:hover {
background: #c82333;
}
.unlock-button:disabled {
background: #6c757d;
cursor: not-allowed;
}
.stale-locks-list {
margin-top: 10px;
padding: 10px;
background: rgba(220, 53, 69, 0.1);
border-radius: 5px;
border: 1px solid #dc3545;
}
.stale-locks-list h4 {
margin-top: 0;
color: #dc3545;
}
.stale-lock-item {
padding: 5px;
font-family: monospace;
font-size: 0.9em;
}
</style> </style>
</head> </head>
<body> <body>
@ -184,6 +253,15 @@
<input type="text" id="hash-search" class="search-input" placeholder="Entrez un hash (64 caractères hexadécimaux)..." oninput="filterHashList()"> <input type="text" id="hash-search" class="search-input" placeholder="Entrez un hash (64 caractères hexadécimaux)..." oninput="filterHashList()">
</div> </div>
<div class="health-section" style="background: var(--card-background); padding: 15px; border-radius: 5px; margin-bottom: 20px; border: 1px solid var(--border-color);">
<h2 style="margin-top: 0; margin-bottom: 15px;">État de l'API d'Ancrage</h2>
<div id="health-status">Chargement de l'état...</div>
<div style="margin-top: 15px;">
<button id="unlock-utxos-button" class="unlock-button" onclick="unlockUtxos()" style="display: none;">Déverrouiller les UTXOs</button>
<button id="refresh-health-button" class="refresh-button" onclick="loadHealthStatus()" style="margin-left: 10px;">Actualiser l'état</button>
</div>
</div>
<div id="content"> <div id="content">
<div class="loading">Chargement des hash...</div> <div class="loading">Chargement des hash...</div>
</div> </div>
@ -202,6 +280,7 @@
// Charger la liste au chargement de la page // Charger la liste au chargement de la page
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
loadHashList(); loadHashList();
loadHealthStatus();
}); });
async function loadHashList() { async function loadHashList() {
@ -341,6 +420,141 @@
const now = new Date(); const now = new Date();
document.getElementById('last-update').textContent = now.toLocaleString('fr-FR'); document.getElementById('last-update').textContent = now.toLocaleString('fr-FR');
} }
async function loadHealthStatus() {
const healthDiv = document.getElementById('health-status');
const unlockButton = document.getElementById('unlock-utxos-button');
const refreshButton = document.getElementById('refresh-health-button');
refreshButton.disabled = true;
healthDiv.innerHTML = '<div style="text-align: center; padding: 20px; font-size: 1em; color: var(--text-color);">Chargement de l\'état...</div>';
try {
// Utiliser l'endpoint proxy du dashboard
const response = await fetch(`${API_BASE_URL}/api/anchor/health/detailed`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const health = await response.json();
let html = '<div class="health-status">';
// État général
const statusClass = health.ok ? 'ok' : 'error';
html += `<div class="health-item">
<label>État général</label>
<div class="value ${statusClass}">${health.ok ? '✓ OK' : '✗ Problème'}</div>
</div>`;
// Mutex
const mutexClass = health.mutex.locked ? 'warning' : 'ok';
html += `<div class="health-item">
<label>Mutex</label>
<div class="value ${mutexClass}">
${health.mutex.locked ? '🔒 Verrouillé' : '✓ Libre'}
${health.mutex.waiting > 0 ? `(${health.mutex.waiting} en attente)` : ''}
</div>
</div>`;
// UTXOs verrouillés
const utxosClass = health.utxos.locked === 0 ? 'ok' : (health.utxos.locked > 10 ? 'error' : 'warning');
html += `<div class="health-item">
<label>UTXOs verrouillés</label>
<div class="value ${utxosClass}">${health.utxos.locked}</div>
</div>`;
// UTXOs stale
if (health.utxos.stale_locks > 0) {
html += `<div class="health-item">
<label>UTXOs stale (>10 min)</label>
<div class="value error">${health.utxos.stale_locks}</div>
</div>`;
}
// Bitcoin
const bitcoinClass = health.bitcoin.connected ? 'ok' : 'error';
html += `<div class="health-item">
<label>Bitcoin</label>
<div class="value ${bitcoinClass}">
${health.bitcoin.connected ? '✓ Connecté' : '✗ Déconnecté'}
${health.bitcoin.blocks > 0 ? ` (${health.bitcoin.blocks.toLocaleString('fr-FR')} blocs)` : ''}
</div>
</div>`;
html += '</div>';
// Afficher les détails des UTXOs stale
if (health.utxos.stale_locks > 0 && health.utxos.stale_locks_details.length > 0) {
html += '<div class="stale-locks-list">';
html += '<h4>UTXOs verrouillés depuis plus de 10 minutes :</h4>';
health.utxos.stale_locks_details.forEach(utxo => {
html += `<div class="stale-lock-item">${utxo.txid} vout:${utxo.vout} (${utxo.minutes_locked.toFixed(1)} min)</div>`;
});
html += '</div>';
}
// Afficher le timestamp
html += `<div style="margin-top: 10px; font-size: 0.9em; color: var(--text-color); opacity: 0.7;">
Dernière mise à jour : ${new Date(health.timestamp).toLocaleString('fr-FR')}
</div>`;
healthDiv.innerHTML = html;
// Afficher le bouton de déverrouillage si des UTXOs sont verrouillés
if (health.utxos.locked > 0) {
unlockButton.style.display = 'inline-block';
} else {
unlockButton.style.display = 'none';
}
} catch (error) {
console.error('Error loading health status:', error);
healthDiv.innerHTML = `<div class="error">Erreur lors du chargement de l'état : ${error.message}</div>`;
unlockButton.style.display = 'none';
} finally {
refreshButton.disabled = false;
}
}
async function unlockUtxos() {
const unlockButton = document.getElementById('unlock-utxos-button');
const originalText = unlockButton.textContent;
if (!confirm('Êtes-vous sûr de vouloir déverrouiller tous les UTXOs ? Cette action libère le mutex et peut affecter les opérations en cours.')) {
return;
}
unlockButton.disabled = true;
unlockButton.textContent = 'Déverrouillage en cours...';
try {
const response = await fetch(`${API_BASE_URL}/api/anchor/unlock-utxos`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || `HTTP error! status: ${response.status}`);
}
const result = await response.json();
alert(`✅ ${result.unlocked || 0} UTXO(s) déverrouillé(s) avec succès.`);
// Recharger l'état de santé
loadHealthStatus();
} catch (error) {
console.error('Error unlocking UTXOs:', error);
alert(`❌ Erreur lors du déverrouillage : ${error.message}`);
} finally {
unlockButton.disabled = false;
unlockButton.textContent = originalText;
}
}
</script> </script>
<footer> <footer>
<p>Bitcoin Ancrage Dashboard - Équipe 4NK</p> <p>Bitcoin Ancrage Dashboard - Équipe 4NK</p>

View File

@ -104,6 +104,18 @@
</div> </div>
</section> </section>
<!-- Avertissement de sécurité -->
<section class="security-warning-section">
<div class="security-warning">
<strong>⚠️ Avertissement de sécurité</strong>
<p>
<strong>Le serveur ne stocke pas vos documents.</strong> Cependant, soyez vigilant :
HTTPS n'est pas une protection parfaite et les navigateurs sont très imparfaits.
Évitez d'envoyer des documents contenant des informations sensibles ou confidentielles.
</p>
</div>
</section>
<!-- Section Test d'Ancrage --> <!-- Section Test d'Ancrage -->
<section class="anchor-section"> <section class="anchor-section">
<h2>Test de l'API d'Ancrage</h2> <h2>Test de l'API d'Ancrage</h2>
@ -142,61 +154,6 @@
</div> </div>
<div id="anchor-result" class="result"></div> <div id="anchor-result" class="result"></div>
<div class="watermark-section" style="margin-top: 20px; padding-top: 20px; border-top: 2px solid var(--border-color);">
<div class="watermark-checkbox">
<input type="checkbox" id="watermark-enabled" onchange="toggleWatermarkOptions()">
<label for="watermark-enabled">Ajouter un filigrane au document</label>
</div>
<div id="watermark-options" style="display: none; margin-top: 15px;">
<div class="security-warning">
<strong>⚠️ Avertissement de sécurité</strong>
<p>
<strong>Le serveur ne stocke pas vos documents.</strong> Cependant, soyez vigilant :
HTTPS n'est pas une protection parfaite et les navigateurs sont très imparfaits.
Évitez d'envoyer des documents contenant des informations sensibles ou confidentielles.
</p>
</div>
<label for="watermark-text">Texte libre du filigrane (optionnel) :</label>
<input type="text" id="watermark-text" placeholder="Texte à afficher dans le filigrane">
<label for="watermark-signature">Signature cryptographique (optionnel) :</label>
<input type="text" id="watermark-signature" placeholder="Signature à afficher dans le filigrane">
<label for="watermark-depositor">Nom du dépositaire (optionnel) :</label>
<input type="text" id="watermark-depositor" placeholder="Nom du dépositaire">
<label for="watermarked-filename">Nom de fichier du PDF filigrané (optionnel) :</label>
<input type="text" id="watermarked-filename" placeholder="Si vide, utilise le nom d'origine avec extension .pdf">
<label for="original-filename">Nom de fichier du document d'origine (optionnel) :</label>
<input type="text" id="original-filename" placeholder="Si vide et texte saisi, utilise 'origin.md'">
<div class="watermark-checkboxes" style="margin-top: 15px;">
<div class="watermark-checkbox">
<input type="checkbox" id="watermark-date-utc">
<label for="watermark-date-utc">Ajouter la date UTC</label>
</div>
<div class="watermark-checkbox">
<input type="checkbox" id="watermark-date-local">
<label for="watermark-date-local">Ajouter la date locale</label>
</div>
<div class="watermark-checkbox">
<input type="checkbox" id="watermark-block-number">
<label for="watermark-block-number">Ajouter le numéro de bloc</label>
</div>
<div class="watermark-checkbox">
<input type="checkbox" id="watermark-block-hash">
<label for="watermark-block-hash">Ajouter le hash du bloc</label>
</div>
<div class="watermark-checkbox">
<input type="checkbox" id="watermark-document-hash">
<label for="watermark-document-hash">Ajouter le hash du document d'origine</label>
</div>
</div>
</div>
</div>
</div> </div>
</section> </section>
@ -213,6 +170,93 @@
<div id="faucet-result" class="result"></div> <div id="faucet-result" class="result"></div>
</div> </div>
</section> </section>
<!-- Section Test API ClamAV -->
<section class="clamav-section">
<h2>Test de l'API ClamAV</h2>
<div class="card">
<p>Scannez un fichier pour détecter les virus avec ClamAV</p>
<label for="clamav-file">Fichier à scanner :</label>
<input type="file" id="clamav-file" onchange="handleClamavFileSelect(event)">
<p class="file-limit-info" style="font-size: 0.9em; color: #999; margin-top: 5px; margin-bottom: 10px;">
<strong>Limite de taille :</strong> 100 MB maximum
</p>
<div id="clamav-file-info" class="file-info"></div>
<button onclick="scanWithClamav()">Scanner le Fichier</button>
<div id="clamav-result" class="result"></div>
</div>
</section>
<!-- Section Test API Filigrane -->
<section class="filigrane-section">
<h2>Test de l'API Filigrane</h2>
<div class="card">
<label for="filigrane-api-key">Clé API :</label>
<input type="text" id="filigrane-api-key" placeholder="Entrez votre clé API">
<div class="tabs">
<button class="tab-button active" onclick="switchTabFiligrane('text', this)">Saisie de Texte</button>
<button class="tab-button" onclick="switchTabFiligrane('file', this)">Sélection de Fichier</button>
</div>
<div id="filigrane-text-tab" class="tab-content active">
<label for="filigrane-text">Texte à convertir en PDF et filigraner :</label>
<textarea id="filigrane-text" rows="5" placeholder="Entrez le texte à convertir en PDF et filigraner..."></textarea>
</div>
<div id="filigrane-file-tab" class="tab-content">
<label for="filigrane-file">Fichier à filigraner :</label>
<input type="file" id="filigrane-file" onchange="handleFiligraneFileSelect(event)">
<p class="file-limit-info" style="font-size: 0.9em; color: #999; margin-top: 5px; margin-bottom: 10px;">
<strong>Limite de taille :</strong> 100 MB maximum
</p>
<div id="filigrane-file-info" class="file-info"></div>
</div>
<div class="watermark-section" style="margin-top: 20px; padding-top: 20px; border-top: 2px solid var(--border-color);">
<label for="filigrane-watermark-text">Texte libre du filigrane (optionnel) :</label>
<input type="text" id="filigrane-watermark-text" placeholder="Texte à afficher dans le filigrane">
<label for="filigrane-watermark-signature">Signature cryptographique (optionnel) :</label>
<input type="text" id="filigrane-watermark-signature" placeholder="Signature à afficher dans le filigrane">
<label for="filigrane-watermark-depositor">Nom du dépositaire (optionnel) :</label>
<input type="text" id="filigrane-watermark-depositor" placeholder="Nom du dépositaire">
<label for="filigrane-watermarked-filename">Nom de fichier du PDF filigrané (optionnel) :</label>
<input type="text" id="filigrane-watermarked-filename" placeholder="Si vide, utilise le nom d'origine avec extension .pdf">
<label for="filigrane-original-filename">Nom de fichier du document d'origine (optionnel) :</label>
<input type="text" id="filigrane-original-filename" placeholder="Si vide et texte saisi, utilise 'origin.md'">
<div class="watermark-checkboxes" style="margin-top: 15px;">
<div class="watermark-checkbox">
<input type="checkbox" id="filigrane-watermark-date-utc">
<label for="filigrane-watermark-date-utc">Ajouter la date UTC</label>
</div>
<div class="watermark-checkbox">
<input type="checkbox" id="filigrane-watermark-date-local">
<label for="filigrane-watermark-date-local">Ajouter la date locale</label>
</div>
<div class="watermark-checkbox">
<input type="checkbox" id="filigrane-watermark-block-number">
<label for="filigrane-watermark-block-number">Ajouter le numéro de bloc</label>
</div>
<div class="watermark-checkbox">
<input type="checkbox" id="filigrane-watermark-block-hash">
<label for="filigrane-watermark-block-hash">Ajouter le hash du bloc</label>
</div>
<div class="watermark-checkbox">
<input type="checkbox" id="filigrane-watermark-document-hash">
<label for="filigrane-watermark-document-hash">Ajouter le hash du document d'origine</label>
</div>
</div>
</div>
<button onclick="testFiligrane()" style="margin-top: 20px;">Tester l'API Filigrane</button>
<div id="filigrane-result" class="result"></div>
</div>
</section>
</main> </main>
<footer> <footer>

View File

@ -337,6 +337,10 @@ footer .git-icon {
gap: 10px; gap: 10px;
} }
.security-warning-section {
margin-bottom: 30px;
}
.security-warning { .security-warning {
background-color: rgba(255, 193, 7, 0.15); background-color: rgba(255, 193, 7, 0.15);
border: 2px solid #ffc107; border: 2px solid #ffc107;
@ -349,6 +353,7 @@ footer .git-icon {
color: #ffc107; color: #ffc107;
display: block; display: block;
margin-bottom: 8px; margin-bottom: 8px;
font-size: 1.1em;
} }
.security-warning p { .security-warning p {

View File

@ -954,6 +954,115 @@ app.post('/api/anchor/test', async (req, res) => {
} }
}); });
// Route pour déverrouiller les UTXOs (appelle l'API d'ancrage externe)
app.post('/api/anchor/unlock-utxos', async (req, res) => {
try {
// Toujours utiliser l'URL publique HTTPS
let anchorApiUrl = process.env.ANCHOR_API_URL || 'https://anchorage.certificator.4nkweb.com';
if (!anchorApiUrl.startsWith('https://')) {
anchorApiUrl = 'https://anchorage.certificator.4nkweb.com';
}
const anchorApiKey = process.env.ANCHOR_API_KEY || '';
const headers = {
'Content-Type': 'application/json',
};
if (anchorApiKey) {
headers['x-api-key'] = anchorApiKey;
}
const result = await makeHttpRequest(anchorApiUrl, '/api/anchor/unlock-utxos', {
method: 'POST',
headers,
});
res.json(result);
} catch (error) {
logger.error('Error unlocking UTXOs', { error: error.message });
res.status(500).json({ error: error.message });
}
});
// Route pour obtenir l'état de santé détaillé (appelle l'API d'ancrage externe)
app.get('/api/anchor/health/detailed', async (req, res) => {
try {
// Toujours utiliser l'URL publique HTTPS
let anchorApiUrl = process.env.ANCHOR_API_URL || 'https://anchorage.certificator.4nkweb.com';
if (!anchorApiUrl.startsWith('https://')) {
anchorApiUrl = 'https://anchorage.certificator.4nkweb.com';
}
const result = await makeHttpRequest(anchorApiUrl, '/health/detailed', {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
res.json(result);
} catch (error) {
logger.error('Error getting detailed health', { error: error.message });
res.status(500).json({ error: error.message });
}
});
// Route pour scanner un fichier avec ClamAV (appelle l'API ClamAV externe)
app.post('/api/clamav/scan', async (req, res) => {
try {
const { data, filename } = req.body;
if (!data) {
return res.status(400).json({
error: 'Bad Request',
message: 'data is required',
});
}
// Toujours utiliser l'URL publique HTTPS, même en interne
let clamavApiUrl = process.env.CLAMAV_API_URL;
// Si CLAMAV_API_URL n'est pas défini ou contient localhost/127.0.0.1, utiliser l'URL HTTPS publique
if (!clamavApiUrl || clamavApiUrl.includes('localhost') || clamavApiUrl.includes('127.0.0.1')) {
clamavApiUrl = 'https://antivir.certificator.4nkweb.com';
logger.info('Forcing public HTTPS ClamAV API URL', { url: clamavApiUrl, reason: 'always use public URL' });
} else if (!clamavApiUrl.startsWith('https://')) {
// Si l'URL est définie mais n'est pas HTTPS, forcer l'URL publique HTTPS
clamavApiUrl = 'https://antivir.certificator.4nkweb.com';
logger.info('Forcing public HTTPS ClamAV API URL (non-HTTPS URL detected)', { url: clamavApiUrl });
}
logger.info('Calling ClamAV API', {
url: clamavApiUrl,
hasData: !!data,
dataLength: data ? data.length : 0,
filename: filename || 'unknown',
});
try {
const result = await makeHttpRequest(clamavApiUrl, '/api/scan/buffer', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ data, filename }),
});
res.json(result);
} catch (fetchError) {
logger.error('Error calling ClamAV API', {
error: fetchError.message,
url: clamavApiUrl,
stack: fetchError.stack
});
res.status(500).json({ error: 'Failed to connect to ClamAV API', message: fetchError.message });
}
} catch (error) {
logger.error('Error scanning with ClamAV', { error: error.message });
res.status(500).json({ error: error.message });
}
});
// Route pour le filigrane (appelle l'API filigrane externe) // Route pour le filigrane (appelle l'API filigrane externe)
app.post('/api/watermark/document', async (req, res) => { app.post('/api/watermark/document', async (req, res) => {
try { try {

View File

@ -11,22 +11,13 @@ import { useIdentity } from './useIdentity';
import { signMessage, generateChallenge } from '../utils/crypto'; import { signMessage, generateChallenge } from '../utils/crypto';
import { GraphResolver } from '../services/graphResolver'; import { GraphResolver } from '../services/graphResolver';
import { updateGraphFromMessage } from '../services/syncUpdateGraph'; import { updateGraphFromMessage } from '../services/syncUpdateGraph';
import {
loadDefaultContract,
hasDefaultContract,
} from '../utils/defaultContract';
import type { LoginProof } from '../types/identity'; import type { LoginProof } from '../types/identity';
import type { Contrat, Action } from '../types/contract'; import type { Contrat, Action } from '../types/contract';
export function useChannel() { export function useChannel() {
const { identity } = useIdentity(); const { identity } = useIdentity();
const graphResolver = useState(() => { const graphResolver = useState(() => {
const resolver = new GraphResolver(); return new GraphResolver();
// Load default contract if in iframe and no contract received yet
if (isInIframe()) {
loadDefaultContract(resolver);
}
return resolver;
})[0]; })[0];
const handleAuthRequest = useCallback( const handleAuthRequest = useCallback(
@ -63,7 +54,6 @@ export function useChannel() {
const handleContract = useCallback( const handleContract = useCallback(
(message: ContractMessage): void => { (message: ContractMessage): void => {
const payload = message.payload; const payload = message.payload;
let hasValidContract = false;
if (payload?.contrat !== undefined) { if (payload?.contrat !== undefined) {
// Valider et ajouter le contrat principal // Valider et ajouter le contrat principal
@ -76,7 +66,6 @@ export function useChannel() {
contrat.validateurs !== null contrat.validateurs !== null
) { ) {
updateGraphFromMessage(contrat, graphResolver); updateGraphFromMessage(contrat, graphResolver);
hasValidContract = true;
} else { } else {
console.warn( console.warn(
'Invalid contract structure received via channel: missing required fields', 'Invalid contract structure received via channel: missing required fields',
@ -128,11 +117,6 @@ export function useChannel() {
} }
} }
// Si un contrat valide a été reçu, on peut remplacer le contrat par défaut
if (hasValidContract && hasDefaultContract(graphResolver)) {
// Le contrat par défaut reste en cache mais le nouveau contrat prend le dessus
// pour les résolutions de graphe
}
}, },
[graphResolver], [graphResolver],
); );

View File

@ -297,3 +297,107 @@ a:focus-visible {
} }
/* Dark theme is now the default with Bitcoin colors */ /* Dark theme is now the default with Bitcoin colors */
/* Mobile responsive styles */
@media (max-width: 768px) {
main {
padding: var(--spacing-md);
max-width: 100%;
}
h1 {
font-size: 1.5rem;
margin-bottom: var(--spacing-lg);
}
h2 {
font-size: 1.25rem;
margin-bottom: var(--spacing-md);
}
section {
padding: var(--spacing-md);
margin-top: var(--spacing-md);
}
button {
min-height: 44px;
padding: var(--spacing-md) var(--spacing-lg);
font-size: 1rem;
}
input {
min-height: 44px;
font-size: 1rem;
padding: var(--spacing-md);
}
label {
margin-bottom: var(--spacing-md);
}
form > div {
margin-bottom: var(--spacing-md);
}
}
@media (max-width: 480px) {
main {
padding: var(--spacing-sm);
}
h1 {
font-size: 1.25rem;
}
h2 {
font-size: 1.125rem;
}
section {
padding: var(--spacing-sm);
}
button {
width: 100%;
margin-bottom: var(--spacing-sm);
}
button:last-child {
margin-bottom: 0;
}
}
/* Keyboard virtual handling */
@media (max-width: 768px) {
input:focus,
textarea:focus {
scroll-margin-top: 100px;
}
/* Prevent zoom on input focus (iOS) */
input[type='text'],
input[type='password'],
input[type='email'],
input[type='number'],
textarea,
select {
font-size: 16px;
}
}
/* Touch-friendly interactive elements */
@media (hover: none) and (pointer: coarse) {
button,
a,
input[type='checkbox'],
input[type='radio'] {
min-height: 44px;
min-width: 44px;
}
button:active {
opacity: 0.7;
transform: scale(0.98);
}
}

View File

@ -34,7 +34,7 @@ export interface RelayHashEvent {
relay: string; relay: string;
timestamp: number; timestamp: number;
objectType?: RelayObjectType; objectType?: RelayObjectType;
source?: 'polling' | 'push' | 'manual'; source?: 'polling' | 'manual';
} }
/** /**
@ -65,9 +65,20 @@ export interface ProcessHashResult {
error?: string; error?: string;
} }
/**
* Backoff strategy for mobile network optimization.
*/
interface BackoffState {
attempts: number;
lastAttempt: number;
baseDelay: number;
maxDelay: number;
}
/** /**
* Service for handling relay notifications and automatic fetching. * Service for handling relay notifications and automatic fetching.
* Supports pull-based model (polling) and can be extended for push (WebSocket). * Pull-based model (polling) only: HTTP REST calls, no WebSocket.
* Optimized for mobile with backoff and reconnection strategies.
*/ */
export class RelayNotificationService { export class RelayNotificationService {
private readonly graphResolver: GraphResolver; private readonly graphResolver: GraphResolver;
@ -75,6 +86,11 @@ export class RelayNotificationService {
private hashListeners: Set<HashEventListener> = new Set(); private hashListeners: Set<HashEventListener> = new Set();
private isPolling: boolean = false; private isPolling: boolean = false;
private pollingInterval: ReturnType<typeof setInterval> | null = null; private pollingInterval: ReturnType<typeof setInterval> | null = null;
private backoffStates: Map<string, BackoffState> = new Map();
private readonly maxRetries: number = 5;
private readonly baseBackoffMs: number = 1000;
private readonly maxBackoffMs: number = 60000;
private isOnline: boolean = true;
constructor( constructor(
graphResolver: GraphResolver, graphResolver: GraphResolver,
@ -82,6 +98,102 @@ export class RelayNotificationService {
) { ) {
this.graphResolver = graphResolver; this.graphResolver = graphResolver;
this.identity = identity ?? null; this.identity = identity ?? null;
this.setupNetworkListeners();
}
/**
* Setup network online/offline listeners for mobile.
*/
private setupNetworkListeners(): void {
if (typeof window === 'undefined') {
return;
}
this.isOnline = navigator.onLine ?? true;
window.addEventListener('online', () => {
this.isOnline = true;
console.info('[RelayNotifications] Network online, resuming operations');
this.resumeOperations();
});
window.addEventListener('offline', () => {
this.isOnline = false;
console.warn('[RelayNotifications] Network offline, pausing operations');
this.pauseOperations();
});
}
/**
* Resume operations after network reconnection.
*/
private resumeOperations(): void {
// Reset backoff states
this.backoffStates.clear();
// Polling will continue automatically on next interval
}
/**
* Pause operations when network is offline.
*/
private pauseOperations(): void {
// Polling will fail gracefully when offline
}
/**
* Calculate backoff delay for retry.
*/
private getBackoffDelay(endpoint: string): number {
const state = this.backoffStates.get(endpoint) ?? {
attempts: 0,
lastAttempt: 0,
baseDelay: this.baseBackoffMs,
maxDelay: this.maxBackoffMs,
};
const delay = Math.min(
state.baseDelay * Math.pow(2, state.attempts),
state.maxDelay,
);
return delay;
}
/**
* Record failed attempt and update backoff state.
*/
private recordFailure(endpoint: string): void {
const state = this.backoffStates.get(endpoint) ?? {
attempts: 0,
lastAttempt: Date.now(),
baseDelay: this.baseBackoffMs,
maxDelay: this.maxBackoffMs,
};
state.attempts += 1;
state.lastAttempt = Date.now();
this.backoffStates.set(endpoint, state);
}
/**
* Record successful attempt and reset backoff.
*/
private recordSuccess(endpoint: string): void {
this.backoffStates.delete(endpoint);
}
/**
* Check if should retry based on backoff state.
*/
private shouldRetry(endpoint: string): boolean {
const state = this.backoffStates.get(endpoint);
if (state === undefined) {
return true;
}
if (state.attempts >= this.maxRetries) {
return false;
}
const delay = this.getBackoffDelay(endpoint);
return Date.now() - state.lastAttempt >= delay;
} }
/** /**
@ -113,6 +225,7 @@ export class RelayNotificationService {
/** /**
* Process a hash: fetch message, signatures, keys, decrypt and update graph. * Process a hash: fetch message, signatures, keys, decrypt and update graph.
* Optimized for mobile with backoff and retry logic.
*/ */
async processHash( async processHash(
hash: string, hash: string,
@ -134,6 +247,11 @@ export class RelayNotificationService {
keysFetched: 0, keysFetched: 0,
}; };
if (!this.isOnline) {
result.error = 'Network offline';
return result;
}
const relays = getStoredRelays().filter((r) => r.enabled); const relays = getStoredRelays().filter((r) => r.enabled);
if (relays.length === 0) { if (relays.length === 0) {
result.error = 'No enabled relays'; result.error = 'No enabled relays';
@ -141,40 +259,67 @@ export class RelayNotificationService {
} }
try { try {
// Fetch message // Fetch message with retry and backoff
let msgChiffre: MsgChiffre | null = null; let msgChiffre: MsgChiffre | null = null;
if (fetchMessage) { if (fetchMessage) {
for (const relay of relays) { for (const relay of relays) {
if (!this.shouldRetry(relay.endpoint)) {
continue;
}
try { try {
msgChiffre = await getMessageByHash(relay.endpoint, hash); msgChiffre = await getMessageByHash(relay.endpoint, hash);
result.messageFetched = true; result.messageFetched = true;
this.recordSuccess(relay.endpoint);
break; break;
} catch { } catch (error) {
this.recordFailure(relay.endpoint);
console.warn(
`Failed to fetch message from ${relay.endpoint}:`,
error,
);
continue; continue;
} }
} }
} }
// Fetch signatures // Fetch signatures with retry and backoff
if (fetchSignatures) { if (fetchSignatures) {
for (const relay of relays) { for (const relay of relays) {
if (!this.shouldRetry(relay.endpoint)) {
continue;
}
try { try {
const sigs = await getSignatures(relay.endpoint, hash); const sigs = await getSignatures(relay.endpoint, hash);
result.signaturesFetched += sigs.length; result.signaturesFetched += sigs.length;
} catch { this.recordSuccess(relay.endpoint);
} catch (error) {
this.recordFailure(relay.endpoint);
console.warn(
`Failed to fetch signatures from ${relay.endpoint}:`,
error,
);
continue; continue;
} }
} }
} }
// Fetch keys // Fetch keys with retry and backoff
let allKeys: MsgCle[] = []; let allKeys: MsgCle[] = [];
if (fetchKeys) { if (fetchKeys) {
for (const relay of relays) { for (const relay of relays) {
if (!this.shouldRetry(relay.endpoint)) {
continue;
}
try { try {
const keys = await getKeys(relay.endpoint, hash); const keys = await getKeys(relay.endpoint, hash);
allKeys.push(...keys); allKeys.push(...keys);
} catch { this.recordSuccess(relay.endpoint);
} catch (error) {
this.recordFailure(relay.endpoint);
console.warn(
`Failed to fetch keys from ${relay.endpoint}:`,
error,
);
continue; continue;
} }
} }
@ -202,10 +347,15 @@ export class RelayNotificationService {
async (h: string): Promise<MsgSignature[]> => { async (h: string): Promise<MsgSignature[]> => {
const allSigs: MsgSignature[] = []; const allSigs: MsgSignature[] = [];
for (const relay of relays) { for (const relay of relays) {
if (!this.shouldRetry(relay.endpoint)) {
continue;
}
try { try {
const sigs = await getSignatures(relay.endpoint, h); const sigs = await getSignatures(relay.endpoint, h);
allSigs.push(...sigs); allSigs.push(...sigs);
this.recordSuccess(relay.endpoint);
} catch { } catch {
this.recordFailure(relay.endpoint);
continue; continue;
} }
} }
@ -296,7 +446,7 @@ export class RelayNotificationService {
} }
/** /**
* Manually trigger processing of a hash (e.g., from push notification). * Manually trigger processing of a hash (e.g., from manual trigger).
* Supports specifying object type for better handling. * Supports specifying object type for better handling.
*/ */
async triggerHashProcessing( async triggerHashProcessing(
@ -311,7 +461,7 @@ export class RelayNotificationService {
relay, relay,
timestamp: Date.now(), timestamp: Date.now(),
objectType: objectType ?? 'unknown', objectType: objectType ?? 'unknown',
source: 'push', source: 'manual',
}); });
// Process hash // Process hash
@ -338,4 +488,13 @@ export class RelayNotificationService {
return await this.processHash(hash, optimizedOptions); return await this.processHash(hash, optimizedOptions);
} }
/**
* Cleanup: stop polling, remove listeners.
*/
cleanup(): void {
this.stopPolling();
this.hashListeners.clear();
this.backoffStates.clear();
}
} }

View File

@ -3,9 +3,8 @@ import { GraphResolver } from '../services/graphResolver';
import { updateGraphFromMessage } from '../services/syncUpdateGraph'; import { updateGraphFromMessage } from '../services/syncUpdateGraph';
/** /**
* Default contract configuration (placeholder). * Default contract configuration.
* Used when no contract is received via channel message. * Used when no contract is received via channel message.
* Should be replaced with actual default contract in production.
*/ */
export const DEFAULT_CONTRACT: Contrat = { export const DEFAULT_CONTRACT: Contrat = {
uuid: 'default-contract-uuid', uuid: 'default-contract-uuid',
@ -43,7 +42,7 @@ export const DEFAULT_CONTRACT: Contrat = {
}; };
/** /**
* Default login action (placeholder). * Default login action.
*/ */
export const DEFAULT_LOGIN_ACTION: Action = { export const DEFAULT_LOGIN_ACTION: Action = {
uuid: 'default-login-action-uuid', uuid: 'default-login-action-uuid',
@ -95,7 +94,7 @@ export const DEFAULT_LOGIN_ACTION: Action = {
}; };
/** /**
* Default service (placeholder). * Default service.
*/ */
export const DEFAULT_SERVICE: Service = { export const DEFAULT_SERVICE: Service = {
uuid: 'default-service-uuid', uuid: 'default-service-uuid',

View File

@ -1,3 +1,5 @@
node_modules node_modules
dist dist
*.local *.local
.env.private
.env.backup

View File

@ -26,7 +26,9 @@ Ouvre par défaut sur `http://localhost:3024`. L'iframe pointe vers UserWallet (
## Configuration ## Configuration
- **Origine UserWallet** : `src/config.ts` définit `USERWALLET_ORIGIN`. En dev, défaut `http://localhost:3018` (si UserWallet tourne en dev sur ce port). En prod, défaut `https://userwallet.certificator.4nkweb.com`. Pour override : variable d'environnement `VITE_USERWALLET_ORIGIN` (ex. `VITE_USERWALLET_ORIGIN=http://localhost:3018 npm run dev`). - **Origine UserWallet** : `src/config.ts` définit `USERWALLET_ORIGIN`. En dev, défaut `http://localhost:3018` (si UserWallet tourne en dev sur ce port). En prod, défaut `https://userwallet.certificator.4nkweb.com`. Pour override : variable d'environnement `VITE_USERWALLET_ORIGIN` (ex. `VITE_USERWALLET_ORIGIN=http://localhost:3018 npm run dev`).
- **Validateurs** : `DEFAULT_VALIDATEURS` dans `src/config.ts` est un placeholder. Le skeleton charge dynamiquement les validateurs depuis les contrats reçus via channel messages (type `contract`). Si aucun contrat n'est reçu, les validateurs par défaut sont utilisés. - **Contrat de service** : Le skeleton a un contrat de service réel défini dans `src/serviceContract.ts` avec UUID `skeleton-service-uuid-4nkweb-2026`. Le contrat est chargé automatiquement au démarrage et envoyé à l'iframe UserWallet.
- **Clé publique du service** : Configurez `VITE_SKELETON_SERVICE_PUBLIC_KEY` avec une clé publique secp256k1 compressée valide (66 hex chars, commençant par 02 ou 03). Exemple : `VITE_SKELETON_SERVICE_PUBLIC_KEY=02abc123... npm run dev`. Si non configurée, un placeholder est utilisé mais les signatures ne pourront pas être vérifiées.
- **Validateurs** : Les validateurs sont extraits automatiquement du contrat de service skeleton. Le skeleton peut aussi recevoir un contrat personnalisé via `postMessage` (type `contract`) qui remplacera le contrat par défaut.
### Chargement dynamique des contrats ### Chargement dynamique des contrats
@ -54,7 +56,7 @@ Le skeleton :
3. Met à jour les `allowedPubkeys` utilisés pour la vérification 3. Met à jour les `allowedPubkeys` utilisés pour la vérification
4. Affiche un statut de confirmation 4. Affiche un statut de confirmation
Si aucun contrat n'est reçu, les `DEFAULT_VALIDATEURS` sont utilisés comme fallback. Le skeleton utilise automatiquement le contrat de service skeleton au démarrage. Si un contrat personnalisé est reçu via `postMessage`, il remplace le contrat par défaut.
## Utilisation ## Utilisation
@ -165,7 +167,8 @@ Les raisons de refus possibles :
- `index.html` : page avec iframe, zone de statut, bouton auth. - `index.html` : page avec iframe, zone de statut, bouton auth.
- `src/main.ts` : chargement de l'iframe, écoute `message`, envoi `auth-request`, appel à `verifyLoginProof`, mise à jour du statut, gestion des messages `contract`. - `src/main.ts` : chargement de l'iframe, écoute `message`, envoi `auth-request`, appel à `verifyLoginProof`, mise à jour du statut, gestion des messages `contract`.
- `src/config.ts` : `USERWALLET_ORIGIN`, `DEFAULT_VALIDATEURS`, types `Contrat` et `Action`. - `src/config.ts` : `USERWALLET_ORIGIN`, `DEFAULT_VALIDATEURS` (extraits du contrat skeleton), types `Contrat` et `Action`.
- `src/serviceContract.ts` : contrat de service skeleton avec UUID dédié, action login, et configuration de la clé publique via `VITE_SKELETON_SERVICE_PUBLIC_KEY`.
- `src/contract.ts` : extraction des validateurs depuis les contrats (`extractLoginValidators`), validation de structure (`isValidContract`, `isValidAction`). - `src/contract.ts` : extraction des validateurs depuis les contrats (`extractLoginValidators`), validation de structure (`isValidContract`, `isValidAction`).
## Déploiement ## Déploiement

View File

@ -0,0 +1,83 @@
#!/usr/bin/env node
/**
* Generate a service wallet (secp256k1 key pair) for website-skeleton.
* Creates .env file with VITE_SKELETON_SERVICE_PUBLIC_KEY and .env.private with private key.
*/
import { getPublicKey, utils as secpUtils } from '@noble/secp256k1';
import { bytesToHex } from '@noble/hashes/utils';
import { writeFileSync, existsSync } from 'fs';
import { join } from 'path';
import { webcrypto } from 'crypto';
// Set up crypto for @noble/secp256k1 in Node.js
if (typeof globalThis.crypto === 'undefined') {
globalThis.crypto = webcrypto;
}
// Generate key pair
const privateKeyBytes = secpUtils.randomSecretKey();
const publicKeyBytes = getPublicKey(privateKeyBytes, true); // compressed
const privateKey = bytesToHex(privateKeyBytes);
const publicKey = bytesToHex(publicKeyBytes);
// Verify format
if (publicKey.length !== 66 || (!publicKey.startsWith('02') && !publicKey.startsWith('03'))) {
throw new Error(`Invalid public key format: ${publicKey}`);
}
if (privateKey.length !== 64) {
throw new Error(`Invalid private key format: ${privateKey}`);
}
// Paths
const envPath = join(process.cwd(), '.env');
const envPrivatePath = join(process.cwd(), '.env.private');
// Check if .env already exists
if (existsSync(envPath)) {
console.warn('⚠️ .env already exists. Backing up to .env.backup');
const { readFileSync } = await import('fs');
const backup = readFileSync(envPath, 'utf-8');
writeFileSync(join(process.cwd(), '.env.backup'), backup);
}
// Write .env with public key
const envContent = `# Service wallet public key for website-skeleton
# Generated on ${new Date().toISOString()}
# Service UUID: skeleton-service-uuid-4nkweb-2026
VITE_SKELETON_SERVICE_PUBLIC_KEY=${publicKey}
`;
writeFileSync(envPath, envContent, { mode: 0o600 });
// Write .env.private with private key (more restrictive permissions)
const envPrivateContent = `# Service wallet private key for website-skeleton
# SECRET: Keep this file secure and never commit it to version control
# Generated on ${new Date().toISOString()}
# Service UUID: skeleton-service-uuid-4nkweb-2026
#
# This private key is used to sign service operations.
# Store it securely and never share it.
SKELETON_SERVICE_PRIVATE_KEY=${privateKey}
`;
writeFileSync(envPrivatePath, envPrivateContent, { mode: 0o400 });
console.log('✅ Service wallet generated successfully!');
console.log('');
console.log('📁 Files created:');
console.log(` - .env (public key, mode 600)`);
console.log(` - .env.private (private key, mode 400)`);
console.log('');
console.log('🔑 Public key (for VITE_SKELETON_SERVICE_PUBLIC_KEY):');
console.log(` ${publicKey}`);
console.log('');
console.log('🔐 Private key (stored in .env.private):');
console.log(` ${privateKey}`);
console.log('');
console.log('⚠️ Security notes:');
console.log(' - .env.private contains the private key - keep it secure');
console.log(' - Add .env.private to .gitignore if not already present');
console.log(' - The public key in .env is safe to commit');
console.log(' - Never share the private key');

View File

@ -5,44 +5,158 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Website skeleton UserWallet iframe</title> <title>Website skeleton UserWallet iframe</title>
<style> <style>
* {
box-sizing: border-box;
}
body { body {
font-family: system-ui, sans-serif; font-family: system-ui, -apple-system, sans-serif;
max-width: 900px; max-width: 900px;
margin: 0 auto; margin: 0 auto;
padding: 1rem; padding: 1rem;
line-height: 1.5;
}
h1 {
font-size: 1.5rem;
margin-bottom: 1rem;
} }
h1 { font-size: 1.25rem; }
#status { #status {
padding: 0.5rem 1rem; padding: 0.75rem 1rem;
margin: 1rem 0; margin: 1rem 0;
border-radius: 4px; border-radius: 6px;
background: #eee; background: #f0f0f0;
border: 1px solid #ddd;
}
#status.accepted {
background: #d4edda;
border-color: #c3e6cb;
color: #155724;
}
#status.rejected {
background: #f8d7da;
border-color: #f5c6cb;
color: #721c24;
}
#status.pending {
background: #fff3cd;
border-color: #ffeaa7;
color: #856404;
}
.button-group {
margin: 1rem 0;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
button {
padding: 0.625rem 1.25rem;
font-size: 1rem;
border: 1px solid #ccc;
border-radius: 6px;
background: #fff;
cursor: pointer;
transition: all 0.2s;
min-height: 44px;
}
button:hover {
background: #f5f5f5;
border-color: #999;
}
button:active {
background: #e0e0e0;
}
button.primary {
background: #007bff;
color: white;
border-color: #007bff;
}
button.primary:hover {
background: #0056b3;
border-color: #0056b3;
}
button.danger {
background: #dc3545;
color: white;
border-color: #dc3545;
}
button.danger:hover {
background: #c82333;
border-color: #c82333;
} }
#status.accepted { background: #cfc; }
#status.rejected { background: #fcc; }
#iframe-container { #iframe-container {
margin: 1rem 0; margin: 1rem 0;
min-height: 400px; min-height: 400px;
border: 1px solid #ccc; border: 1px solid #ccc;
border-radius: 4px; border-radius: 6px;
overflow: hidden;
background: #f9f9f9;
} }
#iframe-container iframe { #iframe-container iframe {
width: 100%; width: 100%;
height: 500px; height: 600px;
border: 0; border: 0;
display: block;
}
#connected-section {
padding: 1.5rem;
background: #f9f9f9;
border: 1px solid #ddd;
border-radius: 6px;
margin: 1rem 0;
}
#user-info {
margin: 1rem 0;
padding: 1rem;
background: white;
border-radius: 4px;
border: 1px solid #ddd;
}
@media (max-width: 768px) {
body {
padding: 0.75rem;
}
h1 {
font-size: 1.25rem;
}
#iframe-container iframe {
height: 500px;
}
button {
width: 100%;
margin-bottom: 0.5rem;
}
.button-group {
flex-direction: column;
}
}
@media (max-width: 480px) {
#iframe-container iframe {
height: 400px;
}
} }
button { margin-right: 0.5rem; }
</style> </style>
</head> </head>
<body> <body>
<h1>Website skeleton intégration iframe UserWallet</h1> <h1>Website skeleton intégration iframe UserWallet</h1>
<p id="status">En attente du login depuis liframe.</p> <p id="status">En attente du login depuis l'iframe.</p>
<div>
<button type="button" id="btn-auth">Demander auth (auth-request)</button> <div id="login-section">
<div class="button-group">
<button type="button" id="btn-login" class="primary">Se connecter</button>
<button type="button" id="btn-auth">Demander auth (auth-request)</button>
</div>
<div id="iframe-container">
<iframe id="userwallet" title="UserWallet"></iframe>
</div>
</div> </div>
<div id="iframe-container">
<iframe id="userwallet" title="UserWallet"></iframe> <div id="connected-section" style="display: none;">
<h2>Vous êtes connecté</h2>
<div id="user-info"></div>
<div class="button-group">
<button type="button" id="btn-logout" class="danger">Se déconnecter</button>
</div>
</div> </div>
<script type="module" src="/src/main.ts"></script> <script type="module" src="/src/main.ts"></script>
</body> </body>
</html> </html>

View File

@ -11,6 +11,8 @@
"service-login-verify": "file:../service-login-verify" "service-login-verify": "file:../service-login-verify"
}, },
"devDependencies": { "devDependencies": {
"@noble/hashes": "^1.8.0",
"@noble/secp256k1": "^3.0.0",
"typescript": "^5.2.2", "typescript": "^5.2.2",
"vite": "^5.0.8" "vite": "^5.0.8"
} }
@ -416,6 +418,29 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/@noble/hashes": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/secp256k1": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@noble/secp256k1/-/secp256k1-3.0.0.tgz",
"integrity": "sha512-NJBaR352KyIvj3t6sgT/+7xrNyF9Xk9QlLSIqUGVUYlsnDTAUqY8LOmwpcgEx4AMJXRITQ5XEVHD+mMaPfr3mg==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@rollup/rollup-android-arm-eabi": { "node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.57.0", "version": "4.57.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.0.tgz",

View File

@ -7,12 +7,15 @@
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"type-check": "tsc --noEmit" "type-check": "tsc --noEmit",
"generate-wallet": "node generate-service-wallet.mjs"
}, },
"dependencies": { "dependencies": {
"service-login-verify": "file:../service-login-verify" "service-login-verify": "file:../service-login-verify"
}, },
"devDependencies": { "devDependencies": {
"@noble/hashes": "^1.8.0",
"@noble/secp256k1": "^3.0.0",
"typescript": "^5.2.2", "typescript": "^5.2.2",
"vite": "^5.0.8" "vite": "^5.0.8"
} }

View File

@ -9,20 +9,29 @@ const origin = env?.VITE_USERWALLET_ORIGIN ?? (env?.DEV ? 'http://localhost:3018
/** UserWallet iframe URL and postMessage target origin. */ /** UserWallet iframe URL and postMessage target origin. */
export const USERWALLET_ORIGIN = origin; export const USERWALLET_ORIGIN = origin;
/** Default validators (placeholder). Replace with your contract action login validators. */ import { getSkeletonServiceContractData } from './serviceContract.js';
export const DEFAULT_VALIDATEURS = { import type { Validateurs } from 'service-login-verify';
membres_du_role: [
{ /**
membre_uuid: 'placeholder', * Get default validators from skeleton service contract.
signatures_obligatoires: [ * Uses the skeleton service contract login action validators.
{ */
membre_uuid: 'placeholder', function getDefaultValidateurs(): Validateurs {
cle_publique: '02' + '0'.repeat(64), const contractData = getSkeletonServiceContractData();
}, const loginAction = contractData.actions.find((a) =>
], a.datajson?.types_names_chiffres?.includes('login')
}, );
],
}; if (loginAction?.validateurs_action !== undefined) {
return loginAction.validateurs_action;
}
// Fallback to contract validators if no action found
return contractData.contrat.validateurs;
}
/** Default validators from skeleton service contract. */
export const DEFAULT_VALIDATEURS = getDefaultValidateurs();
/** /**
* Contract structure (matches userwallet types). * Contract structure (matches userwallet types).

View File

@ -20,7 +20,7 @@ export function extractLoginValidators(
return loginAction.validateurs_action; return loginAction.validateurs_action;
} }
// If no action provided, return contract validators as fallback // If no action provided, return contract validators
// (assuming contract validators are for login action) // (assuming contract validators are for login action)
if (contrat.validateurs !== undefined) { if (contrat.validateurs !== undefined) {
return contrat.validateurs; return contrat.validateurs;

View File

@ -10,19 +10,125 @@ import {
isValidContract, isValidContract,
isValidAction, isValidAction,
} from './contract.js'; } from './contract.js';
import { getSkeletonServiceContractData } from './serviceContract.js';
import type { Contrat, Action } from './config.js'; import type { Contrat, Action } from './config.js';
const iframe = document.getElementById('userwallet') as HTMLIFrameElement; const iframe = document.getElementById('userwallet') as HTMLIFrameElement;
const statusEl = document.getElementById('status') as HTMLParagraphElement; const statusEl = document.getElementById('status') as HTMLParagraphElement;
const btnAuth = document.getElementById('btn-auth') as HTMLButtonElement; const btnAuth = document.getElementById('btn-auth') as HTMLButtonElement;
const btnLogin = document.getElementById('btn-login') as HTMLButtonElement;
const btnLogout = document.getElementById('btn-logout') as HTMLButtonElement;
const iframeContainer = document.getElementById('iframe-container') as HTMLDivElement;
const userInfo = document.getElementById('user-info') as HTMLDivElement;
const loginSection = document.getElementById('login-section') as HTMLDivElement;
const connectedSection = document.getElementById('connected-section') as HTMLDivElement;
const nonceCache = new NonceCache(3600000); const nonceCache = new NonceCache(3600000);
let currentValidateurs: Validateurs = DEFAULT_VALIDATEURS as Validateurs; let currentValidateurs: Validateurs = DEFAULT_VALIDATEURS as Validateurs;
let allowedPubkeys = buildAllowedPubkeysFromValidateurs(currentValidateurs); let allowedPubkeys = buildAllowedPubkeysFromValidateurs(currentValidateurs);
// Store received contract to send to iframe
// Initialize with skeleton service contract
const skeletonContractData = getSkeletonServiceContractData();
let storedContract: Contrat | null = skeletonContractData.contrat;
let storedContratsFils: Contrat[] = skeletonContractData.contrats_fils;
let storedActions: Action[] = skeletonContractData.actions;
// Session management
const SESSION_STORAGE_KEY = 'website-skeleton-session';
interface SessionData {
proof: LoginProof;
timestamp: number;
}
function getSession(): SessionData | null {
const stored = sessionStorage.getItem(SESSION_STORAGE_KEY);
if (stored === null) {
return null;
}
try {
return JSON.parse(stored) as SessionData;
} catch {
return null;
}
}
function setSession(proof: LoginProof): void {
const session: SessionData = {
proof,
timestamp: Date.now(),
};
sessionStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(session));
}
function clearSession(): void {
sessionStorage.removeItem(SESSION_STORAGE_KEY);
}
function isLoggedIn(): boolean {
return getSession() !== null;
}
function setStatus(text: string, kind: 'pending' | 'accepted' | 'rejected'): void { function setStatus(text: string, kind: 'pending' | 'accepted' | 'rejected'): void {
statusEl.textContent = text; if (statusEl !== null) {
statusEl.className = kind === 'accepted' ? 'accepted' : kind === 'rejected' ? 'rejected' : ''; statusEl.textContent = text;
statusEl.className = kind === 'accepted' ? 'accepted' : kind === 'rejected' ? 'rejected' : '';
}
}
function showLoginInterface(): void {
if (loginSection !== null) {
loginSection.style.display = 'block';
}
if (connectedSection !== null) {
connectedSection.style.display = 'none';
}
if (iframeContainer !== null) {
iframeContainer.style.display = 'block';
}
}
function showConnectedInterface(): void {
if (loginSection !== null) {
loginSection.style.display = 'none';
}
if (connectedSection !== null) {
connectedSection.style.display = 'block';
}
if (iframeContainer !== null) {
iframeContainer.style.display = 'none';
}
}
function updateUI(): void {
if (isLoggedIn()) {
showConnectedInterface();
const session = getSession();
if (session !== null && userInfo !== null) {
const publicKeys = session.proof.signatures.map((sig) => sig.cle_publique.substring(0, 16) + '...').join(', ');
userInfo.textContent = `Connecté - Clés publiques: ${publicKeys}`;
}
} else {
showLoginInterface();
}
}
function sendContractToIframe(): void {
if (iframe?.contentWindow == null || storedContract === null) {
return;
}
iframe.contentWindow.postMessage(
{
type: 'contract',
payload: {
contrat: storedContract,
contrats_fils: storedContratsFils,
actions: storedActions,
},
},
USERWALLET_ORIGIN,
);
} }
function sendAuthRequest(): void { function sendAuthRequest(): void {
@ -37,24 +143,54 @@ function sendAuthRequest(): void {
setStatus('Auth demandé (auth-request envoyé).', 'pending'); setStatus('Auth demandé (auth-request envoyé).', 'pending');
} }
function handleLogin(): void {
if (iframe?.contentWindow == null) {
setStatus('Iframe non prêt.', 'rejected');
return;
}
// Send contract if available
sendContractToIframe();
// Show iframe
if (iframeContainer !== null) {
iframeContainer.style.display = 'block';
}
setStatus('Connexion en cours...', 'pending');
}
function handleLogout(): void {
clearSession();
updateUI();
setStatus('Déconnecté.', 'pending');
}
/** /**
* Update validators from contract and rebuild allowed pubkeys. * Update validators from contract and rebuild allowed pubkeys.
* Also stores contract to send to iframe.
*/ */
function updateValidatorsFromContract( function updateValidatorsFromContract(
contrat: Contrat, contrat: Contrat,
contratsFils: Contrat[] = [], contratsFils: Contrat[] = [],
actions: Action[] = [], actions: Action[] = [],
): void { ): void {
// Store contract for sending to iframe
storedContract = contrat;
storedContratsFils = contratsFils;
storedActions = actions;
const validateurs = extractLoginValidators(contrat, contratsFils, actions); const validateurs = extractLoginValidators(contrat, contratsFils, actions);
if (validateurs !== null) { if (validateurs !== null) {
currentValidateurs = validateurs; currentValidateurs = validateurs;
allowedPubkeys = buildAllowedPubkeysFromValidateurs(validateurs); allowedPubkeys = buildAllowedPubkeysFromValidateurs(validateurs);
setStatus('Contrat reçu et validateurs mis à jour.', 'pending'); setStatus('Contrat reçu et validateurs mis à jour.', 'pending');
// Send contract to iframe if it's ready
sendContractToIframe();
} else { } else {
setStatus( setStatus(
'Contrat reçu mais validateurs login introuvables. Utilisation des validateurs par défaut.', 'Contrat reçu mais validateurs login introuvables. Le login ne pourra pas être vérifié.',
'pending', 'rejected',
); );
// Still send contract to iframe even if validators not found
sendContractToIframe();
} }
} }
@ -102,7 +238,9 @@ function handleMessage(event: MessageEvent): void {
timestampWindowMs: 300000, timestampWindowMs: 300000,
}); });
if (result.accept) { if (result.accept) {
setSession(proof);
setStatus('Login accepté. Session ouverte.', 'accepted'); setStatus('Login accepté. Session ouverte.', 'accepted');
updateUI();
} else { } else {
setStatus(`Login refusé: ${result.reason ?? 'inconnu'}`, 'rejected'); setStatus(`Login refusé: ${result.reason ?? 'inconnu'}`, 'rejected');
} }
@ -122,16 +260,48 @@ function handleMessage(event: MessageEvent): void {
} }
function init(): void { function init(): void {
if (iframe == null || statusEl == null) { if (iframe == null || statusEl === null) {
return; return;
} }
iframe.src = USERWALLET_ORIGIN; iframe.src = USERWALLET_ORIGIN;
btnAuth?.addEventListener('click', sendAuthRequest); btnAuth?.addEventListener('click', sendAuthRequest);
btnLogin?.addEventListener('click', handleLogin);
btnLogout?.addEventListener('click', handleLogout);
window.addEventListener('message', handleMessage); window.addEventListener('message', handleMessage);
setStatus(
'En attente du login depuis liframe. Utilisation des validateurs par défaut jusquà réception dun contrat.', // Initialize validators from skeleton service contract
'pending', const validateurs = extractLoginValidators(
storedContract ?? skeletonContractData.contrat,
storedContratsFils,
storedActions,
); );
if (validateurs !== null) {
currentValidateurs = validateurs;
allowedPubkeys = buildAllowedPubkeysFromValidateurs(validateurs);
}
// Send contract to iframe when it's loaded
iframe.addEventListener('load', () => {
sendContractToIframe();
});
// Check if already logged in
if (isLoggedIn()) {
updateUI();
setStatus('Session active.', 'accepted');
} else {
updateUI();
if (storedContract !== null) {
setStatus(
'Contrat de service skeleton chargé. Prêt pour le login.',
'pending',
);
} else {
setStatus(
'En attente du login. Un contrat avec validateurs login doit être reçu pour vérifier les preuves.',
'pending',
);
}
}
} }
init(); init();

View File

@ -0,0 +1,130 @@
import type { Contrat, Action } from './config.js';
/**
* Service UUID for website-skeleton.
* This is a real service contract for the skeleton service.
*/
export const SKELETON_SERVICE_UUID = 'skeleton-service-uuid-4nkweb-2026';
/**
* Contract UUID for website-skeleton.
*/
export const SKELETON_CONTRACT_UUID = 'skeleton-contract-uuid-4nkweb-2026';
/**
* Login action UUID for website-skeleton.
*/
export const SKELETON_LOGIN_ACTION_UUID = 'skeleton-login-action-uuid-4nkweb-2026';
/**
* Member UUID for website-skeleton validators.
*/
export const SKELETON_MEMBER_UUID = 'skeleton-member-uuid-4nkweb-2026';
/**
* Get skeleton service contract with current public key.
*/
function getSkeletonServiceContract(): Contrat {
return {
uuid: SKELETON_CONTRACT_UUID,
validateurs: {
membres_du_role: [
{
membre_uuid: SKELETON_MEMBER_UUID,
signatures_obligatoires: [
{
membre_uuid: SKELETON_MEMBER_UUID,
cle_publique: getSkeletonPublicKey(),
cardinalite_minimale: 1,
},
],
},
],
},
datajson: {
types_names_chiffres: 'contrat',
services_uuid: [SKELETON_SERVICE_UUID],
types_uuid: ['skeleton-contract-type-uuid'],
label: 'Contrat de service Website Skeleton',
},
};
}
/**
* Get skeleton login action with current public key.
*/
function getSkeletonLoginAction(): Action {
return {
uuid: SKELETON_LOGIN_ACTION_UUID,
validateurs_action: {
membres_du_role: [
{
membre_uuid: SKELETON_MEMBER_UUID,
signatures_obligatoires: [
{
membre_uuid: SKELETON_MEMBER_UUID,
cle_publique: getSkeletonPublicKey(),
cardinalite_minimale: 1,
},
],
},
],
},
datajson: {
types_names_chiffres: 'action,login',
services_uuid: [SKELETON_SERVICE_UUID],
types_uuid: ['skeleton-action-type-uuid'],
label: 'Action login Website Skeleton',
},
};
}
/**
* Get skeleton service public key from environment.
* The public key must be a valid secp256k1 compressed public key (66 hex chars: 02/03 + 64 hex).
*
* To configure: set VITE_SKELETON_SERVICE_PUBLIC_KEY environment variable.
* Example: VITE_SKELETON_SERVICE_PUBLIC_KEY=02abc123... npm run dev
*
* If not configured, returns a placeholder and logs a warning.
* The placeholder will not work for real signature verification.
*/
function getSkeletonPublicKey(): string {
const env = typeof import.meta !== 'undefined' ? (import.meta as { env?: { VITE_SKELETON_SERVICE_PUBLIC_KEY?: string } }).env : undefined;
const configuredKey = env?.VITE_SKELETON_SERVICE_PUBLIC_KEY;
if (configuredKey !== undefined && configuredKey.length === 66 && /^[0-9a-fA-F]{66}$/.test(configuredKey)) {
if (configuredKey.startsWith('02') || configuredKey.startsWith('03')) {
return configuredKey;
}
console.error(
'VITE_SKELETON_SERVICE_PUBLIC_KEY must start with 02 or 03 (compressed secp256k1 public key). ' +
'Using placeholder which will not work for signature verification.'
);
return '02' + '0'.repeat(64);
}
console.warn(
'VITE_SKELETON_SERVICE_PUBLIC_KEY not configured. ' +
'Set it to a valid secp256k1 compressed public key (66 hex chars, starting with 02 or 03). ' +
'Example: VITE_SKELETON_SERVICE_PUBLIC_KEY=02abc123... npm run dev. ' +
'Using placeholder which will not work for signature verification.'
);
return '02' + '0'.repeat(64);
}
/**
* Get the complete service contract data to send to UserWallet iframe.
* The contract is generated dynamically to use the current public key configuration.
*/
export function getSkeletonServiceContractData(): {
contrat: Contrat;
contrats_fils: Contrat[];
actions: Action[];
} {
return {
contrat: getSkeletonServiceContract(),
contrats_fils: [],
actions: [getSkeletonLoginAction()],
};
}