couple-repo/Projects/LeBonCoup_Technical.md
StillHammer f5aa93bcbd Initial commit: Couple matters documentation + PowerPoint skill
Documentation personnelle complète
- CLAUDE.md : Instructions compactes et enrichies
- personnalités/ : Profils Alexis, Tingting, Ben, Xiaoxiao + TingtingWork.md
- couple_backlog/ : Historique conflits (16-22 octobre 2025)
- conversation_topics/ : Système suivi sujets actifs
- Projects/ : Analyses techniques et projets
- ToRemember/ : Leadership socratique, suivi conversations
- Promesses_à_tenir.md, observations_patterns.md

PowerPoint skill
- .claude/skills/pptx/ : Skill officiel Anthropic (html2pptx)
- Identité visuelle Tingting : Bordeaux + Or antique + Crème
- Exemple : personnalités/Tingting_Class73_Elegant.pptx

Organisation
- planning/, stratégie/, topics/, plan_discussion/
- .gitignore : node_modules, *.pptx (sauf personnalités/), HTML/JS temp

🎯 Repo propre : 129 fichiers essentiels, 0 dependencies
2025-10-24 14:54:57 +08:00

1026 lines
27 KiB
Markdown

# LeBonCoup - Spécifications Techniques
## Architecture Générale
### Stack recommandée pour MVP
**Backend :**
- Node.js + Express (ou NestJS pour plus de structure)
- Alternative : Laravel (si préférence PHP)
**Base de données :**
- PostgreSQL 14+ avec extension PostGIS (géolocalisation)
**Frontend :**
- React ou Vue.js 3
- PWA (Progressive Web App) pour notifications
- Alternative : WordPress headless (si expertise existante)
**Paiement :**
- Stripe (Checkout ou Elements)
**Hébergement :**
- Backend : Railway, Render, DigitalOcean, ou AWS
- BDD : Managed PostgreSQL (même providers)
- Stockage médias (v2) : AWS S3 ou Cloudflare R2
**CDN :**
- Cloudflare (gratuit pour commencer)
---
## 1. Authentification et Identité
### 1.1 Système de hash d'email
**Génération du user_id :**
```javascript
// Côté serveur : salt secret
const SERVER_SALT = process.env.SECRET_SALT; // stocké en env var
// Génération
const userId = SHA256(email.toLowerCase().trim() + SERVER_SALT);
```
**Pourquoi un salt ?**
- Empêche les attaques par rainbow table
- Même avec la BDD leakée, impossible de retrouver l'email sans le salt
- Le salt reste secret côté serveur
### 1.2 Dérivation de clés pour le chiffrement
**Côté client uniquement (JavaScript) :**
```javascript
// À la connexion
async function deriveKeys(email, password) {
const emailLower = email.toLowerCase().trim();
// Pour l'authentification (envoyé au serveur)
const userId = await sha256(emailLower + SERVER_SALT);
// Pour le chiffrement E2E (reste dans le navigateur)
const encryptionKey = await crypto.subtle.deriveKey(
{
name: "PBKDF2",
salt: new TextEncoder().encode(emailLower),
iterations: 100000,
hash: "SHA-256"
},
await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(password),
"PBKDF2",
false,
["deriveKey"]
),
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"]
);
// Stockée en sessionStorage (volatile)
sessionStorage.setItem('encryptionKey', encryptionKey);
return { userId, encryptionKey };
}
```
**Clés dérivées :**
- `userId` : identifiant pour le serveur (hash SHA256)
- `encryptionKey` : clé AES-256 pour chiffrer/déchiffrer les messages
- La `encryptionKey` **ne quitte JAMAIS** le navigateur
### 1.3 Authentification
**Inscription :**
```javascript
POST /api/auth/signup
Body: {
userId: "8f7a9b2c...", // SHA256(email + salt)
passwordHash: "bcrypt..." // password hashé côté client
}
Serveur :
1. Vérifie que userId n'existe pas
2. Hash à nouveau le passwordHash (double hash)
3. Stocke dans users table
4. Renvoie JWT
```
**Login :**
```javascript
POST /api/auth/login
Body: {
userId: "8f7a9b2c...",
password: "plaintext" // ou passwordHash si préférence
}
Serveur :
1. Trouve user par userId
2. Vérifie password avec bcrypt.compare()
3. Génère JWT (expiré 7 jours)
4. Renvoie token
```
**JWT Payload :**
```json
{
"userId": "8f7a9b2c...",
"type": "pro" | "demandeur",
"iat": 1234567890,
"exp": 1234999999
}
```
---
## 2. Base de Données
### 2.1 Schéma PostgreSQL
```sql
-- Users (pros et demandeurs)
CREATE TABLE users (
id VARCHAR(64) PRIMARY KEY, -- SHA256(email + salt)
password_hash VARCHAR(255) NOT NULL, -- bcrypt
user_type VARCHAR(20) NOT NULL, -- 'pro' ou 'demandeur'
token_balance INT DEFAULT 0,
token_premium_balance INT DEFAULT 0, -- optionnel v2
created_at TIMESTAMP DEFAULT NOW(),
banned BOOLEAN DEFAULT FALSE,
ban_reason TEXT
);
-- Demandes
CREATE TABLE demandes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
author_id VARCHAR(64) REFERENCES users(id),
title VARCHAR(100) NOT NULL,
description TEXT NOT NULL,
category VARCHAR(50) NOT NULL,
location GEOGRAPHY(POINT, 4326), -- PostGIS pour géoloc
location_display VARCHAR(100), -- "Paris 11e" (flouté)
budget_min INT,
budget_max INT,
status VARCHAR(20) DEFAULT 'active', -- active/full/resolved/expired
slots_used INT DEFAULT 0, -- nombre de pros ayant débloqué
slots_premium_used INT DEFAULT 0, -- optionnel v2
created_at TIMESTAMP DEFAULT NOW(),
expires_at TIMESTAMP, -- created_at + 30 jours
flagged BOOLEAN DEFAULT FALSE,
flag_reason TEXT
);
CREATE INDEX idx_demandes_status ON demandes(status);
CREATE INDEX idx_demandes_location ON demandes USING GIST(location);
CREATE INDEX idx_demandes_category ON demandes(category);
-- Transactions (paiements externes)
CREATE TABLE transactions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id VARCHAR(64) REFERENCES users(id),
stripe_payment_id VARCHAR(255) NOT NULL,
amount_eur DECIMAL(10, 2) NOT NULL,
tokens_credited INT NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_transactions_user ON transactions(user_id);
CREATE INDEX idx_transactions_stripe ON transactions(stripe_payment_id);
-- Unlocks (qui a débloqué quoi)
CREATE TABLE unlocks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
pro_id VARCHAR(64) REFERENCES users(id),
demande_id UUID REFERENCES demandes(id),
tokens_spent INT NOT NULL,
is_premium BOOLEAN DEFAULT FALSE,
unlocked_at TIMESTAMP DEFAULT NOW(),
refunded BOOLEAN DEFAULT FALSE,
refund_reason TEXT
);
CREATE INDEX idx_unlocks_pro ON unlocks(pro_id);
CREATE INDEX idx_unlocks_demande ON unlocks(demande_id);
-- Conversations (blobs chiffrés)
CREATE TABLE conversations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_a_id VARCHAR(64) REFERENCES users(id),
user_b_id VARCHAR(64) REFERENCES users(id),
blob_for_a TEXT, -- JSON chiffré pour A
blob_for_b TEXT, -- JSON chiffré pour B
message_count INT DEFAULT 0,
last_updated TIMESTAMP DEFAULT NOW(),
created_at TIMESTAMP DEFAULT NOW(),
auto_delete_at TIMESTAMP -- created_at + 6 mois
);
CREATE INDEX idx_conv_user_a ON conversations(user_a_id);
CREATE INDEX idx_conv_user_b ON conversations(user_b_id);
CREATE INDEX idx_conv_auto_delete ON conversations(auto_delete_at);
-- Flags (modération)
CREATE TABLE conversation_flags (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
conversation_id UUID REFERENCES conversations(id),
user_id VARCHAR(64) REFERENCES users(id), -- user qui a généré les flags
flag_type VARCHAR(50) NOT NULL, -- contact_externe, paiement_externe, suspect
count INT DEFAULT 1,
last_flagged TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_flags_conv ON conversation_flags(conversation_id);
CREATE INDEX idx_flags_user ON conversation_flags(user_id);
-- Signalements (user reports)
CREATE TABLE reports (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
reporter_id VARCHAR(64) REFERENCES users(id),
reported_id VARCHAR(64) REFERENCES users(id),
reason TEXT NOT NULL,
conversation_id UUID REFERENCES conversations(id),
status VARCHAR(20) DEFAULT 'pending', -- pending/reviewed/actioned
created_at TIMESTAMP DEFAULT NOW()
);
-- Médias (optionnel v2)
CREATE TABLE media_files (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
conversation_id UUID REFERENCES conversations(id),
uploaded_by VARCHAR(64) REFERENCES users(id),
file_encrypted BYTEA, -- ou URL S3
file_type VARCHAR(10), -- image/video
file_size INT,
uploaded_at TIMESTAMP DEFAULT NOW(),
expires_at TIMESTAMP, -- uploaded_at + 14 jours
deleted BOOLEAN DEFAULT FALSE
);
CREATE INDEX idx_media_expires ON media_files(expires_at) WHERE deleted = FALSE;
```
### 2.2 Tâches cron automatiques
**Script à exécuter quotidiennement (3h du matin) :**
```javascript
// Suppression conversations > 6 mois
DELETE FROM conversations
WHERE last_updated < NOW() - INTERVAL '6 months';
// Suppression demandes expirées
DELETE FROM demandes
WHERE expires_at < NOW();
// Suppression médias expirés
DELETE FROM media_files
WHERE expires_at < NOW() AND deleted = FALSE;
// Warning 30 jours avant suppression conversation
SELECT user_a_id, user_b_id, id
FROM conversations
WHERE last_updated < NOW() - INTERVAL '5 months'
AND last_updated > NOW() - INTERVAL '5 months 1 day';
// → Envoyer notifications
// Ban automatique si trop de flags
UPDATE users
SET banned = TRUE, ban_reason = 'Auto-ban: excessive flags'
WHERE id IN (
SELECT user_id
FROM conversation_flags
GROUP BY user_id
HAVING SUM(count) > 50
);
```
---
## 3. Paiements Stripe
### 3.1 Intégration Stripe Checkout (recommandé pour MVP)
**Flow :**
```javascript
// Frontend : User clique "Acheter 50 tokens"
const response = await fetch('/api/tokens/create-checkout', {
method: 'POST',
headers: { 'Authorization': `Bearer ${jwt}` },
body: JSON.stringify({ pack: '50' })
});
const { sessionId } = await response.json();
// Redirige vers Stripe Checkout
const stripe = Stripe(STRIPE_PUBLIC_KEY);
await stripe.redirectToCheckout({ sessionId });
```
**Backend :**
```javascript
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
app.post('/api/tokens/create-checkout', authenticateJWT, async (req, res) => {
const { pack } = req.body;
const userId = req.user.userId;
// Définir les packs
const packs = {
'10': { price: 10, tokens: 10 },
'25': { price: 25, tokens: 30 },
'50': { price: 50, tokens: 70 },
'100': { price: 100, tokens: 150 }
};
const selectedPack = packs[pack];
if (!selectedPack) return res.status(400).json({ error: 'Invalid pack' });
// Créer session Stripe
const session = await stripe.checkout.sessions.create({
payment_method_types: ['card'],
line_items: [{
price_data: {
currency: 'eur',
product_data: {
name: `${selectedPack.tokens} tokens LeBonCoup`
},
unit_amount: selectedPack.price * 100 // en centimes
},
quantity: 1
}],
mode: 'payment',
success_url: `${process.env.FRONTEND_URL}/tokens/success`,
cancel_url: `${process.env.FRONTEND_URL}/tokens/cancel`,
metadata: {
userId,
tokens: selectedPack.tokens
}
});
res.json({ sessionId: session.id });
});
```
### 3.2 Webhook Stripe (CRITIQUE)
**Configuration Stripe :**
- Dashboard Stripe → Developers → Webhooks
- Ajouter endpoint : `https://tonsite.com/api/webhooks/stripe`
- Événements à écouter : `checkout.session.completed`
**Backend :**
```javascript
app.post('/api/webhooks/stripe', express.raw({ type: 'application/json' }), async (req, res) => {
const sig = req.headers['stripe-signature'];
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET;
let event;
try {
// VÉRIFICATION DE LA SIGNATURE (CRITIQUE)
event = stripe.webhooks.constructEvent(req.body, sig, endpointSecret);
} catch (err) {
console.error('Webhook signature verification failed:', err.message);
return res.status(400).send(`Webhook Error: ${err.message}`);
}
// Traiter l'événement
if (event.type === 'checkout.session.completed') {
const session = event.data.object;
const { userId, tokens } = session.metadata;
// Créditer les tokens
await db.query(
'UPDATE users SET token_balance = token_balance + $1 WHERE id = $2',
[tokens, userId]
);
// Enregistrer la transaction
await db.query(
'INSERT INTO transactions (user_id, stripe_payment_id, amount_eur, tokens_credited) VALUES ($1, $2, $3, $4)',
[userId, session.payment_intent, session.amount_total / 100, tokens]
);
console.log(`✅ Credited ${tokens} tokens to user ${userId}`);
}
res.json({ received: true });
});
```
**⚠️ SÉCURITÉ CRITIQUE :**
- **TOUJOURS** vérifier la signature Stripe
- Sans ça, n'importe qui peut envoyer un faux webhook et s'auto-créditer des tokens
- Ne JAMAIS faire confiance à un webhook non signé
---
## 4. Messagerie Chiffrée (Système de Blobs)
### 4.1 Chiffrement côté client
**Chiffrement d'un message :**
```javascript
async function encryptBlob(plaintextJSON, encryptionKey) {
const iv = crypto.getRandomValues(new Uint8Array(12));
const encrypted = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv },
encryptionKey,
new TextEncoder().encode(JSON.stringify(plaintextJSON))
);
// Retourner IV + ciphertext encodé en base64
return {
iv: btoa(String.fromCharCode(...iv)),
data: btoa(String.fromCharCode(...new Uint8Array(encrypted)))
};
}
async function decryptBlob(encryptedBlob, encryptionKey) {
const iv = Uint8Array.from(atob(encryptedBlob.iv), c => c.charCodeAt(0));
const data = Uint8Array.from(atob(encryptedBlob.data), c => c.charCodeAt(0));
const decrypted = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv },
encryptionKey,
data
);
return JSON.parse(new TextDecoder().decode(decrypted));
}
```
### 4.2 Structure du blob (déchiffré)
```json
{
"conversation_id": "uuid",
"participants": {
"user_a": "user_id_a",
"user_b": "user_id_b"
},
"messages": [
{
"id": "msg_uuid",
"from": "user_id_a",
"type": "text",
"content": "Bonjour, je suis intéressé...",
"timestamp": 1234567890,
"read": false
},
{
"id": "msg_uuid2",
"from": "user_id_b",
"type": "text",
"content": "Parfait, voici mes disponibilités...",
"timestamp": 1234567920,
"read": true
}
],
"last_read": {
"user_a": 1234567920,
"user_b": 1234567890
}
}
```
### 4.3 Envoi d'un message
**Frontend :**
```javascript
async function sendMessage(conversationId, recipientId, content) {
// 1. Récupérer les blobs existants
const response = await fetch(`/api/conversations/${conversationId}`, {
headers: { 'Authorization': `Bearer ${jwt}` }
});
const { blob_for_a, blob_for_b, user_a_id, user_b_id } = await response.json();
// 2. Déterminer qui est qui
const currentUserId = getCurrentUserId();
const myBlob = currentUserId === user_a_id ? blob_for_a : blob_for_b;
const theirBlob = currentUserId === user_a_id ? blob_for_b : blob_for_a;
// 3. Déchiffrer mon blob
const myKey = sessionStorage.getItem('encryptionKey');
const myData = await decryptBlob(JSON.parse(myBlob), myKey);
// 4. Ajouter le nouveau message
const newMessage = {
id: generateUUID(),
from: currentUserId,
type: 'text',
content,
timestamp: Date.now(),
read: false
};
myData.messages.push(newMessage);
// 5. Re-chiffrer pour moi
const newMyBlob = await encryptBlob(myData, myKey);
// 6. Dériver la clé du destinataire et chiffrer pour lui
// NOTE: Ici on suppose qu'on a la clé du destinataire
// En réalité, chaque user chiffre avec SA clé, donc on doit :
// - Soit récupérer leur clé publique (si on passe en asymétrique)
// - Soit re-construire leur blob côté serveur (impossible car on a pas leur clé)
//
// SOLUTION RÉELLE : Chaque user chiffre son propre blob
// Donc User A met à jour son blob, User B met à jour le sien
// Version simplifiée : on envoie juste notre blob mis à jour
await fetch(`/api/conversations/${conversationId}/message`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${jwt}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
myBlob: newMyBlob,
recipientId,
messagePreview: content.substring(0, 50) // pour notif
})
});
}
```
**Backend :**
```javascript
app.post('/api/conversations/:id/message', authenticateJWT, async (req, res) => {
const { id } = req.params;
const { myBlob, recipientId, messagePreview } = req.body;
const senderId = req.user.userId;
// 1. Mettre à jour le blob de l'expéditeur
await db.query(
`UPDATE conversations
SET ${senderId === 'user_a_id' ? 'blob_for_a' : 'blob_for_b'} = $1,
message_count = message_count + 1,
last_updated = NOW()
WHERE id = $2`,
[JSON.stringify(myBlob), id]
);
// 2. Envoyer notification push au destinataire
await sendPushNotification(recipientId, {
title: 'Nouveau message',
body: messagePreview,
conversationId: id
});
res.json({ success: true });
});
```
**⚠️ PROBLÈME IDENTIFIÉ :**
Avec des clés symétriques dérivées, **chaque user ne peut chiffrer QUE pour lui-même**.
**Solutions :**
**Option A : Chiffrement asymétrique (RSA/ECDH)**
- Chaque user a une paire clé publique/privée
- Clé publique stockée sur le serveur
- Messages chiffrés avec la clé publique du destinataire
- Plus complexe
**Option B : Blob personnel uniquement**
- Chaque user gère uniquement SON blob
- Quand User A envoie un message, il met à jour son blob
- Quand User B se connecte, il récupère son blob (qui contient les messages que A lui a envoyés)
- Le serveur reçoit deux blobs séparés et les synchronise
**Recommandation pour MVP : Option B (plus simple)**
---
## 5. Système de Flags (Modération)
### 5.1 Scan côté client
**Patterns de détection :**
```javascript
const DETECTION_PATTERNS = {
phone: /\b0[67]\d{8}\b|\b\+33\s?[67]\d{8}\b/,
email: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/i,
social: /@(instagram|telegram|whatsapp|snapchat|facebook)/i,
payment: /\b(paypal|venmo|cash|espèces|liquide|virement|western\s?union)\b/i,
suspect: /\b(discret|sans facture|urgent|livraison rapide|entre nous)\b/i
};
function scanMessage(content) {
const flags = [];
if (DETECTION_PATTERNS.phone.test(content)) {
flags.push('contact_externe');
}
if (DETECTION_PATTERNS.email.test(content)) {
flags.push('contact_externe');
}
if (DETECTION_PATTERNS.social.test(content)) {
flags.push('contact_externe');
}
if (DETECTION_PATTERNS.payment.test(content)) {
flags.push('paiement_externe');
}
if (DETECTION_PATTERNS.suspect.test(content)) {
flags.push('activité_suspecte');
}
return flags;
}
```
### 5.2 Envoi des flags au serveur
```javascript
async function sendMessage(conversationId, content) {
// 1. Scan du message
const flags = scanMessage(content);
// 2. Si flags détectés, envoyer au serveur (asynchrone)
if (flags.length > 0) {
fetch('/api/flags', {
method: 'POST',
headers: {
'Authorization': `Bearer ${jwt}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
conversationId,
flags,
timestamp: Date.now()
})
}).catch(err => console.error('Failed to send flags:', err));
}
// 3. Chiffrer et envoyer le message normalement
// (ne pas bloquer l'envoi)
await encryptAndSendMessage(conversationId, content);
}
```
### 5.3 Traitement serveur
```javascript
app.post('/api/flags', authenticateJWT, async (req, res) => {
const { conversationId, flags } = req.body;
const userId = req.user.userId;
// Incrémenter les compteurs
for (const flagType of flags) {
await db.query(
`INSERT INTO conversation_flags (conversation_id, user_id, flag_type, count, last_flagged)
VALUES ($1, $2, $3, 1, NOW())
ON CONFLICT (conversation_id, user_id, flag_type)
DO UPDATE SET count = conversation_flags.count + 1, last_flagged = NOW()`,
[conversationId, userId, flagType]
);
}
// Calculer le total de flags pour cet user
const result = await db.query(
'SELECT SUM(count) as total FROM conversation_flags WHERE user_id = $1',
[userId]
);
const totalFlags = parseInt(result.rows[0].total);
// Actions automatiques
if (totalFlags >= 50) {
await db.query('UPDATE users SET banned = TRUE, ban_reason = $1 WHERE id = $2',
['Auto-ban: 50+ flags', userId]);
} else if (totalFlags >= 25) {
// Restriction temporaire (TODO: implémenter système de restrictions)
} else if (totalFlags >= 10) {
// Warning (TODO: notification in-app)
}
res.json({ success: true });
});
```
---
## 6. Géolocalisation (PostGIS)
### 6.1 Stockage d'une demande avec localisation
```javascript
app.post('/api/demandes', authenticateJWT, async (req, res) => {
const { title, description, category, lat, lon, budget_min, budget_max } = req.body;
const authorId = req.user.userId;
// Créer un point géographique
const result = await db.query(
`INSERT INTO demandes (author_id, title, description, category, location, location_display, budget_min, budget_max, expires_at)
VALUES ($1, $2, $3, $4, ST_SetSRID(ST_MakePoint($5, $6), 4326), $7, $8, $9, NOW() + INTERVAL '30 days')
RETURNING *`,
[authorId, title, description, category, lon, lat, getLocationDisplay(lat, lon), budget_min, budget_max]
);
res.json(result.rows[0]);
});
```
### 6.2 Filtrage par distance
```javascript
app.get('/api/demandes/nearby', authenticateJWT, async (req, res) => {
const { lat, lon, radius_km, category } = req.query;
const result = await db.query(
`SELECT *, ST_Distance(location, ST_SetSRID(ST_MakePoint($1, $2), 4326)) / 1000 as distance_km
FROM demandes
WHERE status = 'active'
AND category = $3
AND ST_DWithin(location::geography, ST_SetSRID(ST_MakePoint($1, $2), 4326)::geography, $4 * 1000)
ORDER BY distance_km ASC`,
[lon, lat, category, radius_km]
);
res.json(result.rows);
});
```
---
## 7. Sécurité
### 7.1 Checklist sécurité
**Backend :**
- ✅ HTTPS obligatoire (certificat SSL)
- ✅ Helmet.js (headers de sécurité)
- ✅ Rate limiting (express-rate-limit)
- ✅ CORS configuré strictement
- ✅ SQL injection : utiliser parameterized queries
- ✅ XSS : sanitize inputs (DOMPurify côté client)
- ✅ CSRF : tokens CSRF ou SameSite cookies
- ✅ JWT avec expiration courte (7 jours max)
- ✅ Webhook Stripe : vérification signature
**Frontend :**
- ✅ CSP (Content Security Policy)
- ✅ Pas de données sensibles en localStorage (sessionStorage OK)
- ✅ Clés de chiffrement en mémoire uniquement
- ✅ Logout = clear sessionStorage
**BDD :**
- ✅ Pas de port exposé publiquement
- ✅ Credentials forts
- ✅ Backups réguliers
- ✅ Chiffrement at-rest si possible
### 7.2 Rate limiting
```javascript
const rateLimit = require('express-rate-limit');
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // 100 requêtes max
message: 'Too many requests, please try again later'
});
app.use('/api/', apiLimiter);
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5, // 5 tentatives de login max
message: 'Too many login attempts, please try again later'
});
app.use('/api/auth/login', authLimiter);
```
---
## 8. Déploiement
### 8.1 Variables d'environnement
```bash
# .env
NODE_ENV=production
# Database
DATABASE_URL=postgresql://user:password@host:5432/leboncoup
# Secrets
JWT_SECRET=random_string_very_long_and_secure
SECRET_SALT=another_random_string_for_email_hashing
# Stripe
STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
STRIPE_PUBLIC_KEY=pk_live_...
# Frontend
FRONTEND_URL=https://leboncoup.fr
# Optionnel (v2)
AWS_ACCESS_KEY_ID=...
AWS_SECRET_ACCESS_KEY=...
S3_BUCKET_NAME=leboncoup-media
```
### 8.2 Dockerfile (optionnel)
```dockerfile
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
EXPOSE 3000
CMD ["node", "server.js"]
```
### 8.3 Procédure de déploiement
**Option 1 : Railway (recommandé pour MVP)**
1. Connecter repo GitHub
2. Railway détecte Node.js automatiquement
3. Ajouter PostgreSQL addon
4. Configurer les env vars
5. Deploy automatique sur chaque push
**Option 2 : DigitalOcean App Platform**
- Similaire à Railway
- Managed PostgreSQL inclus
**Option 3 : AWS (plus complexe)**
- EC2 pour backend
- RDS PostgreSQL
- S3 pour médias
- CloudFront CDN
- Route53 DNS
---
## 9. Monitoring et Logs
### 9.1 Logs essentiels
```javascript
const winston = require('winston');
const logger = winston.createLogger({
level: 'info',
format: winston.format.json(),
transports: [
new winston.transports.File({ filename: 'error.log', level: 'error' }),
new winston.transports.File({ filename: 'combined.log' })
]
});
// Logs critiques
logger.info('User login', { userId, timestamp });
logger.info('Token purchase', { userId, amount, tokens });
logger.warn('Flag threshold reached', { userId, totalFlags });
logger.error('Webhook verification failed', { error });
```
### 9.2 Alertes
**Mettre en place des alertes pour :**
- Webhook Stripe échoué
- User atteint 50+ flags
- Erreurs serveur (500)
- Usage disque > 80%
- CPU > 90%
**Outils recommandés :**
- Sentry (monitoring d'erreurs)
- Better Uptime (uptime monitoring)
- Logtail (logs centralisés)
---
## 10. Tests
### 10.1 Tests critiques
**Backend :**
```javascript
// Test webhook Stripe
test('Webhook Stripe valide crédite les tokens', async () => {
const event = createMockStripeEvent();
const response = await request(app)
.post('/api/webhooks/stripe')
.set('stripe-signature', generateValidSignature(event))
.send(event);
expect(response.status).toBe(200);
const user = await db.getUserById(userId);
expect(user.token_balance).toBe(50);
});
// Test flags auto-ban
test('User avec 50 flags est automatiquement banni', async () => {
await createFlagsForUser(userId, 50);
const user = await db.getUserById(userId);
expect(user.banned).toBe(true);
});
```
**Frontend :**
```javascript
// Test chiffrement/déchiffrement
test('Message chiffré puis déchiffré = message original', async () => {
const key = await deriveEncryptionKey('test@example.com', 'password123');
const message = { content: 'Hello', timestamp: Date.now() };
const encrypted = await encryptBlob(message, key);
const decrypted = await decryptBlob(encrypted, key);
expect(decrypted.content).toBe('Hello');
});
```
---
## 11. Performance
### 11.1 Optimisations BDD
**Index critiques :**
```sql
CREATE INDEX CONCURRENTLY idx_demandes_active ON demandes(status) WHERE status = 'active';
CREATE INDEX CONCURRENTLY idx_conversations_recent ON conversations(last_updated DESC);
CREATE INDEX CONCURRENTLY idx_unlocks_pro_recent ON unlocks(pro_id, unlocked_at DESC);
```
**Requêtes optimisées :**
- Utiliser `LIMIT` partout
- Pagination (offset/limit ou cursor-based)
- Éviter les `SELECT *` (spécifier les colonnes)
### 11.2 Cache
**Redis (optionnel pour scale) :**
```javascript
// Cache du nombre de demandes actives
const cachedCount = await redis.get('demandes:active:count');
if (cachedCount) return parseInt(cachedCount);
const count = await db.query('SELECT COUNT(*) FROM demandes WHERE status = \'active\'');
await redis.set('demandes:active:count', count, 'EX', 300); // 5 min
```
---
## 12. Migration et Maintenance
### 12.1 Scripts de migration
**Utiliser un outil comme `node-pg-migrate` :**
```javascript
// migrations/001_initial_schema.js
exports.up = (pgm) => {
pgm.createTable('users', {
id: { type: 'varchar(64)', primaryKey: true },
password_hash: { type: 'varchar(255)', notNull: true },
// ...
});
};
exports.down = (pgm) => {
pgm.dropTable('users');
};
```
### 12.2 Backups
**PostgreSQL :**
```bash
# Backup quotidien automatique
pg_dump -U user -h host leboncoup > backup_$(date +%Y%m%d).sql
# Restauration
psql -U user -h host leboncoup < backup_20250116.sql
```
---
**Date de création :** 2025-01-16
**Version :** 1.0
**Statut :** Draft pour implémentation