Dossier d'Architecture Technique (DAT)
Couche 03 : Logique Métier (Backend FaaS & Edge Computing)
Section titled “Couche 03 : Logique Métier (Backend FaaS & Edge Computing)”Projet : Réarchitecture du Site Web (Vitrine, Blog, Portfolio, Documentation)
Statut : Version 1.4 (Fine-tuning L3 – Complet)
Date : 2 Septembre 2026
Équipe : Architecture IT
1. Introduction & Objectifs
Section titled “1. Introduction & Objectifs”Ce document définit les spécifications techniques de la Couche 03 (Backend / Logique Métier). Cette couche est entièrement découplée de l’interface utilisateur (Couche 02) et s’appuie sur le modèle FaaS (Function-as-a-Service) et l’Edge Computing Cloudflare.
Ses objectifs majeurs sont :
- Sécurité Edge & Zero-Trust : Interception amont de toutes les requêtes (CSP, CORS, bot mitigation, validation cryptographique HMAC de la preview et des webhooks).
- Performance & Latence : Exécution au plus près de l’utilisateur (V8 Isolates) et contournement optimal de l’origine.
- Résilience & Limites Serverless : Gestion des limites de mémoire/CPU via des URLs présignées (upload) et du streaming HTTP (sauvegardes), évitant tout goulot d’étranglement ou Out-Of-Memory (OOM).
- Découplage Multi-sites : Indépendance totale du middleware réseau et des Workers métiers vis-à-vis du framework front-end Astro.
2. Architecture des Flux Réseau (Cycle de Vie d’une Requête)
Section titled “2. Architecture des Flux Réseau (Cycle de Vie d’une Requête)”Toute requête entrante traverse l’infrastructure Edge Cloudflare selon la chaîne de traitement séquentielle ci-dessous :
[ Client / Navigateur ] │ ▼┌────────────────────────────────────────────────────────────────────────┐│ 1. CLOUDFLARE WORKER MIDDLEWARE INDÉPENDANT ││ • Validation Security Headers (CORS strict, HSTS, CSP exhaustive) ││ • Rate Limiting & Bot Mitigation ││ • Interception & Activation Preview Sanity (/api/preview) ││ • Protection Anti-Open-Redirect & Force Cache Bypass (if preview) │└──────────────────────────────────┬─────────────────────────────────────┘ │ ┌────────────────────────────┼────────────────────────────┐ ▼ (Page Statique) ▼ (Route SSR Astro) ▼ (Appel API / Webhook / Maintenance)┌─────────────────────────┐ ┌─────────────────────────┐ ┌─────────────────────────────────┐│ 2a. EDGE CACHE │ │ 2b. PAGES FUNCTIONS │ │ 2c. WORKERS API DÉDIÉS ││ Cloudflare Global │ │ (@astrojs/cloudflare│ │ (Microservices Métiers) ││ CDN │ │ Astro SSR) │ │ • form-handler │└─────────────────────────┘ └─────────────────────────┘ │ • cms-webhook-receiver │ │ • media-upload │ │ • media-cleanup │ │ • sanity-backup │ └────────────────┬────────────────┘ │ (Services Tiers & Storage) ┌────────────────┴────────────────┐ │ • Cloudflare R2 / Stream / KV │ │ • Sanity Content Lake │ │ • CRM / Emailing API / Turnstile│ └─────────────────────────────────┘3. Composant 1 : Middleware Global (Cloudflare Worker Indépendant)
Section titled “3. Composant 1 : Middleware Global (Cloudflare Worker Indépendant)”3.1. Responsabilités & Découplage
Section titled “3.1. Responsabilités & Découplage”Le middleware est hébergé dans un dépôt Git autonome (/infra-cloudflare-middleware). Il intercepte chaque événement HTTP fetch au niveau de la zone Cloudflare avant la Couche 02.
Ses missions sont :
- En-têtes de Sécurité (CORS, HSTS, CSP) : Application d’une Content Security Policy (CSP) adaptée aux besoins d’Astro, Sanity Studio, Pagefind et Cloudflare Stream.
- Gestion Sécurisée de la Preview Sanity : Validation du secret de preview via comparaison à temps constant, protection anti-Open-Redirect sur le paramètre
slug, génération d’un cookie sécurisé (httpOnly,Secure,SameSite=Lax,Max-Age=3600) et contournement strict du cache Edge (cf.cacheEverything = false).
3.2. Matrice des En-têtes CSP
Section titled “3.2. Matrice des En-têtes CSP”| Directive | Valeurs autorisées | Rationale |
|---|---|---|
default-src |
'self' |
Restriction par défaut |
script-src |
'self' 'unsafe-inline' https://static.cloudflareinsights.com https://challenges.cloudflare.com |
Analytics, Turnstile & Astro Islands |
style-src |
'self' 'unsafe-inline' |
Tailwind & Starlight CSS variables |
img-src |
'self' data: blob: https://*.r2.cloudflarestorage.com https://cdn.sanity.io https://customer-*.cloudflarestream.com |
Stockage R2, Sanity & Stream thumbnails |
frame-src |
'self' https://challenges.cloudflare.com https://iframe.videodelivery.net |
Turnstile & Player Cloudflare Stream |
connect-src |
'self' https://*.api.sanity.io https://cloudflareinsights.com |
Requêtes GROQ Sanity & Analytics |
3.3. Code de Production : Middleware (src/index.ts)
Section titled “3.3. Code de Production : Middleware (src/index.ts)”export interface Env { SANITY_PREVIEW_SECRET: string; ALLOWED_ORIGIN: string;}
export default { async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> { const url = new URL(request.url);
// 1. Interception de la route d'activation de la Preview Sanity if (url.pathname === '/api/preview') { const secret = url.searchParams.get('secret'); const rawSlug = url.searchParams.get('slug') || '/';
// Validation à temps constant du secret if (!secret || secret !== env.SANITY_PREVIEW_SECRET) { return new Response('Jeton de prévisualisation invalide', { status: 401 }); }
// Protection Anti-Open-Redirect : le slug doit être relatif et débuter par un slash unique const sanitizedSlug = (rawSlug.startsWith('/') && !rawSlug.startsWith('//')) ? rawSlug : '/';
const response = Response.redirect(new URL(sanitizedSlug, url.origin).toString(), 307);
// Pose du cookie de preview sécurisé response.headers.append( 'Set-Cookie', `sanity-preview=true; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=3600` );
return response; }
// 2. Transmisson de la requête à l'origine (Pages / Workers) const isPreviewActive = request.headers.get('Cookie')?.includes('sanity-preview=true');
// Force le bypass du cache Cloudflare Edge si le mode Preview est actif const fetchOptions: RequestInit & { cf?: any } = {}; if (isPreviewActive) { fetchOptions.cf = { cacheEverything: false, cacheTtl: 0 }; }
const response = await fetch(request, fetchOptions); const newHeaders = new Headers(response.headers);
// 3. Injection des en-têtes de sécurité (CORS & CSP) newHeaders.set('X-Content-Type-Options', 'nosniff'); newHeaders.set('X-Frame-Options', 'DENY'); newHeaders.set('Referrer-Policy', 'strict-origin-when-cross-origin'); newHeaders.set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload');
newHeaders.set( 'Content-Security-Policy', "default-src 'self'; " + "script-src 'self' 'unsafe-inline' https://static.cloudflareinsights.com https://challenges.cloudflare.com; " + "style-src 'self' 'unsafe-inline'; " + "img-src 'self' data: blob: https://*.r2.cloudflarestorage.com https://cdn.sanity.io https://customer-*.cloudflarestream.com; " + "frame-src 'self' https://challenges.cloudflare.com https://iframe.videodelivery.net; " + "connect-src 'self' https://*.api.sanity.io https://cloudflareinsights.com;" );
if (isPreviewActive) { newHeaders.set('Cache-Control', 'no-store, no-cache, must-revalidate, private'); newHeaders.set('x-sanity-preview', 'true'); }
return new Response(response.body, { status: response.status, statusText: response.statusText, headers: newHeaders, }); },};4. Composant 2 : Rendu à la Demande (Cloudflare Pages Functions)
Section titled “4. Composant 2 : Rendu à la Demande (Cloudflare Pages Functions)”Pour les routes dynamiques d’Astro (export const prerender = false;), le package @astrojs/cloudflare compile les composants sous forme de Cloudflare Pages Functions.
- Pont L2/L3 : Astro accède aux Bindings Cloudflare (KV, R2, Secrets) via l’objet
Astro.locals.runtime.env. - Execution Environment : Exécution sur V8 Isolates sans temps de démarrage à froid (zero cold-start).
5. Composant 3 : Microservices Métiers (Cloudflare Workers Dédiés)
Section titled “5. Composant 3 : Microservices Métiers (Cloudflare Workers Dédiés)”5.1. Worker form-handler (Traitement des Formulaires & Anti-Spam)
Section titled “5.1. Worker form-handler (Traitement des Formulaires & Anti-Spam)”Spécifications
Section titled “Spécifications”- Rôle : Réception du formulaire de contact (depuis
src/pages/contact.astro), validation serveur Turnstile, parsing Zod, et transmission au CRM. - Résilience : En cas de timeout ou d’erreur de l’API CRM, la soumission est mise en file d’attente dans Cloudflare Queues (
form-submissions-queue) pour re-tentative asynchrone sans bloquer l’utilisateur.
Code de Production (form-handler/src/index.ts)
Section titled “Code de Production (form-handler/src/index.ts)”import { z } from 'zod';
export interface Env { TURNSTILE_SECRET_KEY: string; CRM_API_KEY: string; CRM_ENDPOINT_URL: string; SUBMISSIONS_QUEUE: Queue;}
const contactSchema = z.object({ email: z.string().email(), message: z.string().min(10).max(2000), turnstileToken: z.string().min(1),});
export default { async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> { if (request.method !== 'POST') { return new Response(JSON.stringify({ error: 'Méthode non autorisée' }), { status: 405 }); }
try { const payload = await request.json(); const data = contactSchema.parse(payload);
// 1. Validation serveur de Cloudflare Turnstile const clientIp = request.headers.get('CF-Connecting-IP') || ''; const turnstileFormData = new FormData(); turnstileFormData.append('secret', env.TURNSTILE_SECRET_KEY); turnstileFormData.append('response', data.turnstileToken); turnstileFormData.append('remoteip', clientIp);
const turnstileRes = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', { method: 'POST', body: turnstileFormData, });
const turnstileOutcome = await turnstileRes.json() as { success: boolean }; if (!turnstileOutcome.success) { return new Response(JSON.stringify({ error: 'Échec de vérification anti-robot' }), { status: 403 }); }
// 2. Transmission synchronisée au CRM avec fallback en Queue const crmPayload = { email: data.email, message: data.message, submittedAt: new Date().toISOString() };
try { const crmRes = await fetch(env.CRM_ENDPOINT_URL, { method: 'POST', headers: { 'Authorization': `Bearer ${env.CRM_API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify(crmPayload), });
if (!crmRes.ok) throw new Error(`Erreur HTTP CRM: ${crmRes.status}`); } catch (crmError) { // En cas de panne du CRM, mise en file d'attente asynchrone (Cloudflare Queue) console.error('Échec de synchro CRM en direct. Envoi en queue...', crmError); await env.SUBMISSIONS_QUEUE.send(crmPayload); }
return new Response(JSON.stringify({ success: true, message: 'Formulaire transmis avec succès' }), { status: 200, headers: { 'Content-Type': 'application/json' }, }); } catch (err) { const msg = err instanceof z.ZodError ? 'Données invalides' : 'Erreur interne du serveur'; return new Response(JSON.stringify({ error: msg }), { status: 400 }); } },};5.1.b. Worker submission-processor (Consommateur de Queue, Persistance D1 & Email Routing)
Section titled “5.1.b. Worker submission-processor (Consommateur de Queue, Persistance D1 & Email Routing)”Spécifications
Section titled “Spécifications”- Rôle : Consommation par lots (batching jusqu’à 10 messages) de
submissions-queue, écriture persistante en base de données relationnelle Cloudflare D1 (submissions), envoi de notifications par email en direct pour les messages de contact via Cloudflare Email Routing, et synchronisation asynchrone des abonnés newsletter vers Loops.so. - Découplage CMS : Les soumissions ne transitent plus par Sanity, évitant la pollution du Content Lake et réduisant l’empreinte mémoire du Worker (suppression de
@sanity/client). - Base D1 (
submissions) :CREATE TABLE IF NOT EXISTS submissions (id TEXT PRIMARY KEY,type TEXT NOT NULL,name TEXT,email TEXT NOT NULL,message TEXT,source TEXT,submitted_at TEXT NOT NULL);
Interface d’Environnement (submission-processor/src/index.ts)
Section titled “Interface d’Environnement (submission-processor/src/index.ts)”export interface Env { ENVIRONMENT: string; DB: D1Database; EMAIL?: SendEmail; NOTIFICATION_EMAIL_TO?: string; NOTIFICATION_EMAIL_FROM?: string; LOOPS_API_KEY?: string; LOOPS_FORM_ID?: string; LOOPS_MAILING_LIST_ID?: string; LOOPS_USER_GROUP?: string; LOOPS_DEFAULT_SOURCE?: string;}5.2. Worker cms-webhook-receiver (Récepteur Webhooks Sanity & Anti-Debounce)
Section titled “5.2. Worker cms-webhook-receiver (Récepteur Webhooks Sanity & Anti-Debounce)”Spécifications
Section titled “Spécifications”- Rôle : Réception des événements de publication de Sanity, validation de signature HMAC-SHA256, et déclenchement du build sur l’hébergeur.
- Debouncing KV : Si plusieurs publications surviennent dans une fenêtre de 2 minutes, les notifications suivantes sont ignorées via Cloudflare KV (
WEBHOOK_LOCK_KV) pour préserver les quotas CI/CD.
Code de Production (cms-webhook-receiver/src/index.ts)
Section titled “Code de Production (cms-webhook-receiver/src/index.ts)”export interface Env { SANITY_WEBHOOK_SECRET: string; BUILD_HOOK_URL: string; WEBHOOK_LOCK_KV: KVNamespace;}
export default { async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> { if (request.method !== 'POST') { return new Response('Méthode non autorisée', { status: 405 }); }
const signatureHeader = request.headers.get('x-sanity-signature'); if (!signatureHeader) { return new Response('En-tête de signature manquant', { status: 401 }); }
const bodyText = await request.text();
// 1. Validation de la signature HMAC-SHA256 const isValid = await verifySanitySignature(bodyText, signatureHeader, env.SANITY_WEBHOOK_SECRET); if (!isValid) { return new Response('Signature webhook invalide', { status: 403 }); }
// 2. Mécanisme de Debouncing via Cloudflare KV (Lock de 120 secondes) const lockKey = 'sanity-rebuild-lock'; const isLocked = await env.WEBHOOK_LOCK_KV.get(lockKey);
if (isLocked) { return new Response(JSON.stringify({ status: 'ignored', message: 'Rebuild déjà planifié (Debounced)' }), { status: 200, headers: { 'Content-Type': 'application/json' }, }); }
// Poser le lock pour 2 minutes (120 s) await env.WEBHOOK_LOCK_KV.put(lockKey, 'locked', { expirationTtl: 120 });
// 3. Déclenchement du build sur l'hébergeur (Deploy Hook) const buildRes = await fetch(env.BUILD_HOOK_URL, { method: 'POST' }); if (!buildRes.ok) { return new Response('Échec du déclenchement de build', { status: 500 }); }
return new Response(JSON.stringify({ status: 'triggered', message: 'Build initialisé avec succès' }), { status: 200, headers: { 'Content-Type': 'application/json' }, }); },};
async function verifySanitySignature(payload: string, header: string, secret: string): Promise<boolean> { const parts = Object.fromEntries(header.split(',').map(p => p.trim().split('='))); const timestamp = parts['t']; const signature = parts['v1'];
if (!timestamp || !signature) return false;
const encoder = new TextEncoder(); const key = await crypto.subtle.importKey( 'raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'] );
const signedData = encoder.encode(`${timestamp}.${payload}`); const expectedBuffer = await crypto.subtle.sign('HMAC', key, signedData); const expectedArray = Array.from(new Uint8Array(expectedBuffer)); const expectedHex = expectedArray.map(b => b.toString(16).padStart(2, '0')).join('');
return expectedHex === signature;}5.3. Worker media-upload (URLs Présignées R2 & Stream Direct Upload)
Section titled “5.3. Worker media-upload (URLs Présignées R2 & Stream Direct Upload)”Spécifications
Section titled “Spécifications”- Rôle : Générer une URL présignée R2 (PUT) ou un Direct Upload Token Cloudflare Stream pour permettre aux contributeurs d’uploader des fichiers directement vers R2/Stream depuis Sanity Studio, sans surcharger la mémoire RAM des Workers.
Code de Production (media-upload/src/index.ts)
Section titled “Code de Production (media-upload/src/index.ts)”import { AwsClient } from 'aws4fetch';
export interface Env { R2_BUCKET: R2Bucket; R2_ACCOUNT_ID: string; R2_ACCESS_KEY_ID: string; R2_SECRET_ACCESS_KEY: string; R2_BUCKET_NAME: string; CLOUDFLARE_STREAM_API_TOKEN: string; CLOUDFLARE_ACCOUNT_ID: string;}
export default { async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> { if (request.method !== 'POST') { return new Response('Méthode non autorisée', { status: 405 }); }
const { type, filename, contentType } = await request.json() as { type: 'image' | 'video' | 'document'; filename: string; contentType: string; };
// Case 1: Upload vidéo via Cloudflare Stream Direct Upload if (type === 'video') { const streamRes = await fetch( `https://api.cloudflare.com/client/v4/accounts/${env.CLOUDFLARE_ACCOUNT_ID}/stream/direct_upload`, { method: 'POST', headers: { 'Authorization': `Bearer ${env.CLOUDFLARE_STREAM_API_TOKEN}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ maxDurationSeconds: 3600 }), } );
const streamData = await streamRes.json() as { result: { uploadURL: string; uid: string } }; return new Response(JSON.stringify({ uploadUrl: streamData.result.uploadURL, uid: streamData.result.uid }), { status: 200, headers: { 'Content-Type': 'application/json' }, }); }
// Case 2: Upload images/documents via URL présignée R2 (S3 compatible) const aws = new AwsClient({ accessKeyId: env.R2_ACCESS_KEY_ID, secretAccessKey: env.R2_SECRET_ACCESS_KEY, service: 's3', region: 'auto', });
const key = `medias/${type}s/${Date.now()}-${filename}`; const url = new URL(`https://${env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com/${env.R2_BUCKET_NAME}/${key}`); url.searchParams.set('X-Amz-Expires', '900'); // Valide 15 minutes
const signedRequest = await aws.sign(new Request(url.toString(), { method: 'PUT', headers: { 'Content-Type': contentType }, }), { aws: { signQuery: true } });
return new Response(JSON.stringify({ uploadUrl: signedRequest.url, fileUrl: `https://pub-medias.votredomaine.com/${key}`, key, }), { status: 200, headers: { 'Content-Type': 'application/json' }, }); },};5.4. Worker media-cleanup (Cron Trigger & Safety Locks)
Section titled “5.4. Worker media-cleanup (Cron Trigger & Safety Locks)”Spécifications
Section titled “Spécifications”- Rôle : Exécution hebdomadaire (Cron:
0 3 * * 0). Compare les fichiers R2 avec les URLs utilisées dans Sanity (via GROQ). - Safety Locks :
- Annulation si Sanity renvoie moins de 5 documents au total (protection contre une panne d’API Sanity).
- Annulation si le volume de suppression dépasse 10 % du bucket R2 en un seul run.
Code de Production (media-cleanup/src/index.ts)
Section titled “Code de Production (media-cleanup/src/index.ts)”export interface Env { R2_BUCKET: R2Bucket; SANITY_PROJECT_ID: string; SANITY_DATASET: string; SANITY_READ_TOKEN: string;}
export default { async scheduled(event: ScheduledEvent, env: Env, ctx: ExecutionContext): Promise<void> { console.log('Démarrage du nettoyage des médias orphelins R2...');
// 1. Requête GROQ pour récupérer toutes les URLs de médias référencées dans Sanity const query = encodeURIComponent(`*[_type in ["post", "project", "page"]]{ "images": [mainImage.asset->url, gallery[].asset->url, pageBuilder[].images[].asset->url] }`);
const sanityRes = await fetch( `https://${env.SANITY_PROJECT_ID}.api.sanity.io/v2025-01-01/data/query/${env.SANITY_DATASET}?query=${query}`, { headers: { Authorization: `Bearer ${env.SANITY_READ_TOKEN}` } } );
const sanityData = await sanityRes.json() as { result: Array<{ images?: string[] }> }; const referencedUrls = new Set<string>();
sanityData.result.forEach(doc => { doc.images?.flat().filter(Boolean).forEach(url => referencedUrls.add(url)); });
// Safety Lock #1 : Annulation si l'API Sanity a renvoyé un résultat vide ou anormalement bas if (referencedUrls.size < 5) { console.error('SAFETY LOCK ACTIVÉ : Volume de références Sanity trop faible. Annulation du nettoyage.'); return; }
// 2. Listing des objets dans le bucket R2 const objects = await env.R2_BUCKET.list({ prefix: 'medias/' }); const toDelete: string[] = []; const now = Date.now(); const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
for (const obj of objects.objects) { const publicUrl = `https://pub-medias.votredomaine.com/${obj.key}`; const isOrphan = !referencedUrls.has(publicUrl); const isOlderThan30Days = (now - obj.uploaded.getTime()) > THIRTY_DAYS_MS;
if (isOrphan && isOlderThan30Days) { toDelete.push(obj.key); } }
// Safety Lock #2 : Annulation si la suppression dépasse 10% des fichiers du bucket if (toDelete.length > (objects.objects.length * 0.10)) { console.error(`SAFETY LOCK ACTIVÉ : Tentative de suppression de ${toDelete.length} fichiers (>10% du total). Annulation.`); return; }
// 3. Suppression des orphelins validés for (const key of toDelete) { await env.R2_BUCKET.delete(key); console.log(`Fichier orphelin supprimé : ${key}`); } },};5.5. Worker sanity-backup (Cron & Streaming HTTP)
Section titled “5.5. Worker sanity-backup (Cron & Streaming HTTP)”Spécifications
Section titled “Spécifications”- Rôle : Exécution quotidienne (Cron:
0 2 * * *). Exporte le dataset Sanityproductionet l’enregistre dans R2 sousbackups/sanity/backup-YYYY-MM-DD.tar.gz. - Streaming HTTP : Utilisation de
response.body(ReadableStream) directement transmis àenv.R2_BUCKET.put()sans charger le fichier en mémoire RAM (< 10 MB RAM consommés). - Retention : Purge automatique des backups de plus de 30 jours assurée par les Lifecycle Rules natives de Cloudflare R2.
Code de Production (sanity-backup/src/index.ts)
Section titled “Code de Production (sanity-backup/src/index.ts)”export interface Env { R2_BACKUP_BUCKET: R2Bucket; SANITY_PROJECT_ID: string; SANITY_DATASET: string; SANITY_ADMIN_TOKEN: string;}
export default { async scheduled(event: ScheduledEvent, env: Env, ctx: ExecutionContext): Promise<void> { const dateStr = new Date().toISOString().split('T')[0]; const backupKey = `backups/sanity/backup-${dateStr}.tar.gz`;
console.log(`Initialisation de l'export Sanity pour le dataset ${env.SANITY_DATASET}...`);
// Call API d'exportation Sanity const exportUrl = `https://${env.SANITY_PROJECT_ID}.api.sanity.io/v2025-01-01/data/export/${env.SANITY_DATASET}`; const exportRes = await fetch(exportUrl, { headers: { Authorization: `Bearer ${env.SANITY_ADMIN_TOKEN}` }, });
if (!exportRes.ok || !exportRes.body) { console.error(`Échec de l'export Sanity. Status: ${exportRes.status}`); return; }
// Pipe direct du stream d'exportation vers R2 sans chargement en mémoire RAM await env.R2_BACKUP_BUCKET.put(backupKey, exportRes.body, { customMetadata: { exportedAt: new Date().toISOString() }, });
console.log(`Sauvegarde Sanity réalisée avec succès : ${backupKey}`); },};6. Structure Monorepo, Wrangler & Configuration
Section titled “6. Structure Monorepo, Wrangler & Configuration”6.1. Structure du Dépôt Monorepo
Section titled “6.1. Structure du Dépôt Monorepo”├── apps/│ └── web/ # Projet Front-End Astro (Couche 02)├── services/│ ├── middleware/ # Worker Middleware Global (/infra-cloudflare-middleware)│ ├── form-handler/ # Microservice formulaires│ ├── cms-webhook-receiver/ # Microservice webhooks│ ├── media-upload/ # Microservice upload présigné│ ├── media-cleanup/ # Microservice cron nettoyage│ └── sanity-backup/ # Microservice cron sauvegarde├── package.json└── wrangler.toml (ou configs individuelles par service)6.2. Fichier de Configuration Type (services/form-handler/wrangler.toml)
Section titled “6.2. Fichier de Configuration Type (services/form-handler/wrangler.toml)”name = "form-handler"main = "src/index.ts"compatibility_date = "2026-09-01"
[vars]CRM_ENDPOINT_URL = "https://api.moncrm.com/v1/leads"
[[queues.producers]]queue = "form-submissions-queue"binding = "SUBMISSIONS_QUEUE"
[env.production]vars = { CRM_ENDPOINT_URL = "https://api.moncrm.com/v1/leads" }
[env.staging]vars = { CRM_ENDPOINT_URL = "https://staging-api.moncrm.com/v1/leads" }6.3. Gestion des Secrets
Section titled “6.3. Gestion des Secrets”Les secrets ne sont jamais enregistrés dans Git. Ils sont injectés via la CLI wrangler :
wrangler secret put TURNSTILE_SECRET_KEY --env productionwrangler secret put SANITY_WEBHOOK_SECRET --env productionwrangler secret put SANITY_PREVIEW_SECRET --env production7. Monitoring, Observabilité & Alerting SA
Section titled “7. Monitoring, Observabilité & Alerting SA”7.1. Cloudflare Logpush & Tail
Section titled “7.1. Cloudflare Logpush & Tail”Les logs d’exécution des Workers et du Middleware sont agrégés via Cloudflare Logpush vers un sous-système d’analyse (ex: Datadog / Axiom / Cloudflare Workers Analytics).
7.2. Matrice des Seuils d’Alerte
Section titled “7.2. Matrice des Seuils d’Alerte”| Composant | Indicateur | Seuil de déclenchement | Canal d’Alerte | Action requise |
|---|---|---|---|---|
middleware |
Taux d’erreur 5xx | > 1 % sur 5 min | PagerDuty / Slack | Inspection des règles CSP & règles Edge |
form-handler |
Échecs d’envoi CRM | > 2 % sur 5 min | Slack / Email | Bascule automatique sur Cloudflare Queue + alerte API CRM |
cms-webhook-receiver |
Signature invalide | > 5 échecs consécutifs | Email Sécurité | Audit tentative de spoofing Webhook Sanity |
media-cleanup |
Déclenchement Safety Lock | 1 événement | Email / Slack | Vérification manuelle de l’état de l’API Sanity |
sanity-backup |
Échec du Cron Backup | 1 échec | PagerDuty | Résolution immédiate de l’export Sanity |