couple-repo/Projects/LeBonCoup/LeBonCoup_Technical.md
StillHammer ab94be513d Add Chinese learning plan + Projects documentation + Tingting work
- couple_backlog/28_octobre_2025.md: Multi-vector plan (Tandem exchange + Aissia)
- couple_backlog/25_octobre_2025.md: Previous backlog entry
- Projects/aissia.md: AISSIA project with LanguageLearningModule integration
- Projects/chinese_audio_tts_pipeline.md, groveengine_framework.md, social_network_manager.md
- Projects/LeBonCoup/: Reorganized into folder
- WorkTingting/28_10_2025-parents/: Parent meeting presentation materials
- ToRemember/Japan_Conducts.md: Cultural notes

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-13 19:36:03 +08:00

27 KiB

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 :

// 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) :

// À 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 :

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 :

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 :

{
  "userId": "8f7a9b2c...",
  "type": "pro" | "demandeur",
  "iat": 1234567890,
  "exp": 1234999999
}

2. Base de Données

2.1 Schéma PostgreSQL

-- 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) :

// 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 :

// 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 :

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 :

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 :

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é)

{
  "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 :

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 :

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 :

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

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

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

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

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

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

# .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)

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

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 :

// 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 :

// 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 :

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) :

// 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 :

// 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 :

# 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