Qui parle à qui
- Une seule base : FirestorePas de SQL, pas de seconde base. Redis ne stocke aucune donnée métier.
- Lecture directe, écriture par l'APILes clients lisent Firestore via le SDK. Les règles refusent presque toutes les écritures client ; l'API écrit avec l'Admin SDK.
- Une API pour deux clientsLe web et l'app mobile appellent les mêmes routes
/api/*avec un ID token Firebase en Bearer. - Points et séries en transactionTout ce qui déplace des points, jetons ou séries tourne dans
runTransactioncôté serveur. - Quiz notés côté serveurLes bonnes réponses des QCM restent dans des documents
private/que le client ne peut pas lire. - Temps réel sans serveur dédiéPas de Socket.io ni de Pusher : les listeners Firestore poussent les changements aux clients.
Le client écoute, l'API écrit
Le temps réel repose entièrement sur les listeners onSnapshot de Firestore. Le SDK garde un flux persistant ouvert vers Google (WebChannel sur HTTPS) et reçoit chaque modification d'un document écouté. Il n'y a ni serveur WebSocket, ni Pusher, ni Socket.io à héberger : les fonctions Vercel restent sans état.
Un duel montre le mécanisme. Le joueur A envoie sa réponse à la route API, qui la valide et l'écrit en transaction. Firestore pousse alors le nouvel état aux deux joueurs, y compris à celui qui a écrit.
PERMISSION_DENIED.| Écran ou module | Ce qu'il écoute | Pour quoi faire |
|---|---|---|
ApiDataContext (web), UserContext (mobile) | users/{uid} | Série, jetons, succès, abonnement Pro, historique de révision |
NotificationsContext (web), UserContext (mobile) | users/{uid}/notifications, les plus récentes | Cloche et badge de non-lus |
| Page duel (web et mobile) | duels/{id} | Duel tour par tour entre deux joueurs |
| Page event (web et mobile) | events/{id} et events/{id}/results | Démarrage en direct et classement live |
| Magic Trivial (web et mobile) | magicTrivial/{id} et private/current | Partie multijoueur ; la question courante n'est lisible que par le juge |
Pas tout en temps réel. Les decks et les cartes sont chargés une fois avec getDocs puis rechargés après chaque modification. Le classement passe par l'API avec un cache de 5 minutes. Le temps réel est réservé à ce qui change sous les yeux de l'utilisateur.
Le moteur de révision (Leitner, 7 boîtes)
POST /api/review/answer applique applyResult() de src/lib/review/srs.ts et écrit currentBox et nextReviewDate. En fin de session, POST /api/review/finish met à jour série, succès, défi hebdo et journal d'activité dans des transactions. Hors ligne, les réponses attendent dans une file locale (localStorage sur le web, AsyncStorage sur mobile) et repartent au retour du réseau.Google en production, MySSO en dev et staging
En production, la seule façon de se connecter est Google. MySSO, le fournisseur OpenID Connect maison, n'existe qu'en dev et en staging : son bouton ne s'affiche que si NEXT_PUBLIC_ENABLE_SSO (web) ou EXPO_PUBLIC_ENABLE_SSO (mobile) vaut true, ce qui n'est jamais le cas en production.
Quel que soit le chemin, il aboutit au même endroit : le client est connecté à Firebase Auth et envoie son ID token en Bearer. Les routes API ne savent pas par quelle porte l'utilisateur est entré.
/api/auth/google-mobile/callback, crée un custom token et le renvoie à l'app par le lien magiccards://callback, où il est passé à signInWithCustomToken. Le chemin MySSO fait la même chose avec deux routes à lui. Les traits verts sont ceux de la production.Détail du flux MySSO (dev et staging)
/api/auth/token échange le code côté serveur pour contourner CORS. /api/auth/firebase-token vérifie la signature du id_token avec jose contre le JWKS de MySSO avant d'émettre le custom token. Les deux sont limitées à 10 appels par minute et par IP. Les flèches en pointillés sont des réponses. Ces routes restent déployées en production, mais aucun bouton n'y mène.- Premier login Google : le nom du compte Google n'est copié dans
displayNamequ'à la première connexion, jamais par-dessus un nom choisi. L'email de bienvenue part viaPOST /api/email/welcomesur le web et viaafter()dans le callback mobile. - Stockage des sessions :
localStoragesur le web,expo-secure-storeet la persistance AsyncStorage de Firebase Auth sur mobile.
- Admin : Basic Auth sur
/admindans le middleware, avec un cookie signé HMAC valable 30 jours, puisADMIN_UIDSet les rôles deadminRoles/{uid}. - Impersonation :
POST /api/admin/impersonateémet un custom token avec la claimimpersonation.withAuthbloque alors tout POST, PUT, PATCH et DELETE, et chaque démarrage est inscrit au journal d'audit.
Redis limite le débit, il ne cache rien
Upstash Redis a un seul rôle : compter les requêtes pour le rate limiting, de façon globale entre toutes les instances serverless. L'accès se fait en REST avec @upstash/ratelimit, algorithme à fenêtre glissante. Les variables UPSTASH_REDIS_REST_* et KV_REST_API_* (intégration Vercel Marketplace) sont acceptées. Si Redis n'est pas configuré ou ne répond pas, le code se rabat sur une Map en mémoire, propre à chaque instance.
checkRateLimit dans le code. Le middleware Edge l'utilise aussi pour /api/admin/*.| Clé de rate limit | Budget |
|---|---|
auth:token:{ip}, auth:firebase-token:{ip} | 10 / min |
admin:{ip} (middleware) | 120 / min |
review:answer:{uid} | 120 / min |
review:finish:{uid} | 10 / min |
daily-quiz:answer, events:answer | 120 / min |
cards:write:{uid} | 30 / min |
decks:create:{uid} | 10 / min |
generate-deck:{uid} (Gemini) | 10 / h |
qcm-distractors:{uid} (Gemini) | 30 / h |
profile:photo:{uid} | 10 / h |
account:delete:{uid} | 3 / h |
users:public:{ip} | 60 / min |
Toutes les couches de cache
Le cache applicatif ne passe pas par Redis. Il est réparti sur six niveaux, du navigateur jusqu'au Data Cache de Vercel.
Le tableau suivant les liste dans l'ordre où une requête les rencontre.
| Couche | Où dans le code | Durée | Contenu |
|---|---|---|---|
| Navigateur et mobile | |||
| Mémoire du module | src/lib/http/clientCache.ts | durée de l'onglet | Widgets du dashboard : catalogue de templates, quiz du jour, carte du jour |
| IndexedDB Firestore | persistentLocalCache multi-onglets | persistant | Documents Firestore déjà lus, disponibles hors ligne |
| Service Worker | public/sw.js, versionné par SHA git | jusqu'au déploiement suivant | Coquille de /dashboard, /decks, /review, /profile… pour l'usage hors ligne |
| AsyncStorage mobile | mobile/lib/cache.ts | TTL, servi périmé jusqu'à 2 × TTL | Réponses API, affichées sans spinner pendant la revalidation |
| Edge et serveur | |||
| Edge Config | src/proxy.ts, edge-config-admin.ts | live | Flag maintenance, modifié depuis /admin via l'API Vercel |
| Mémoire d'instance | src/lib/**/*-cache.ts | events 30 s · profils 5 min · templates 10 min · séries amis 1 h · défi hebdo 1 h | Lectures Admin SDK fréquentes ; invalidées à l'écriture sur la même instance |
| Vercel Data Cache | unstable_cache | classement 5 min · prix Stripe 1 h · stats landing 1 h | Partagé entre instances, survit aux cold starts |
| Upstash Redis | src/lib/http/rate-limit.ts | fenêtre glissante | Compteurs de rate limit uniquement |
Firestore et ses règles
Les règles de firestore.rules décident de ce que le SDK client peut faire. L'Admin SDK des routes API les contourne. Une règle finale match /{document=**} refuse tout ce qui n'est pas listé.
| Chemin | Contenu | Lecture client | Écriture client |
|---|---|---|---|
| Données de l'utilisateur | |||
users/{uid} | Profil, série, jetons, plan, historique | soi | champs limités plan et facturation interdits |
users/{uid}/decks/{id} | Decks | soi | non · API |
users/{uid}/cards/{id} | Cartes : currentBox, nextReviewDate, reviewCount, successCount | soi | non · API |
users/{uid}/notifications/{id} | Notifications in-app | soi | non · API |
weeklyProgress/{uid} | Progression du défi hebdo | soi | non · API |
| Jeu et social | |||
duels/{id} | Duels | participants | non · API |
magicTrivial/{id} + private/ | Parties multijoueur | participants private : juge | non · API |
events/{id} + results/ | Quiz en direct, scores finaux immuables | connecté | non · API |
events/{id}/progress/{uid} | Réponses notées par le serveur | soi | non · API |
events/{id}/private/quiz | Pool QCM avec les bonnes réponses | personne | non · API |
dailyQuiz/{date} | Quiz du jour, contient les réponses | personne results : connecté | non · API |
follows/{id} | Abonnements entre joueurs | connecté | création, suppression par soi |
friendStreaks, deckCollaborators, deckChangelog | Séries à deux, decks partagés à plusieurs | parties concernées | non · API |
| Contenu public et administration | |||
shares/{id} | Liens de partage de decks | public | propriétaire |
templates/{id} | Bibliothèque de decks publics | public | non · API |
publicRequests/{id} | Decks soumis à modération | propriétaire | création |
config/{doc} | Défi hebdo, réglages | connecté | non · API |
adminRoles, auditLogs, templateLikes, proTrialGrants | Rôles, audit, compteurs internes | personne | Admin SDK seul |
| Firebase Storage | |||
avatars/{uid}, cardImages/{uid}/{id} | Photos de profil, images de cartes | connecté | soi 5 Mo max, image/* |
Quatre crons Vercel par jour
Déclarés dans vercel.json, ils appellent des routes protégées par Authorization: Bearer ${CRON_SECRET}. La logique partagée vit dans src/lib/cron/cron-jobs.ts, et /api/admin/run-cron permet de relancer un job à la main depuis l'admin.
| Heure UTC | Route | Ce qu'elle fait |
|---|---|---|
| 00:00 | /api/cron/daily | Termine les events expirés, agrège dailyStats, remet à zéro les séries (boucliers pris en compte), distribue un bouclier gratuit par semaine, photographie les rangs, met à jour les séries entre amis, rétrograde les Pro expirés si un webhook Stripe s'est perdu. Le lundi : emails de récap hebdo, puis remise à zéro des scores de la semaine. |
| 08:00 | /api/cron/remind | Push web et Expo aux utilisateurs qui ont des cartes à réviser (decks archivés exclus). |
| 10:00 | /api/cron/daily-quiz | Clôt le quiz de la veille et crédite le top 3 dans bonusScore. Génère le QCM du jour : distracteurs en cache, un seul appel Gemini groupé, repli sur d'autres cartes. Envoie un push « nouveau quiz » à tous. |
| 18:00 | /api/cron/streak-reminder | Push aux joueurs qui ont une série active et des cartes dues mais n'ont pas encore révisé aujourd'hui. |
Quatre canaux, trois fonctions
onSnapshot que le reste du temps réel. Les tokens push (pushSubscription, expoPushToken) sont stockés sur users/{uid} et nettoyés automatiquement quand le service les déclare invalides. L'email de bienvenue part via after(), après l'envoi de la réponse HTTP. Les envois groupés Resend sont cadencés à environ 2 requêtes par seconde.Services tiers et où ils sont branchés
| Service | Branché dans | Usage |
|---|---|---|
| Google Gemini 2.5 Flash | @google/genai · generate-deck, lib/games/distractors.ts, cron quiz du jour, démarrage d'event, admin/templates/* | Génère des decks, crée les mauvaises réponses des QCM en un seul appel groupé, génère et reclasse des templates |
| Stripe | api/stripe/checkout, portal, webhook, api/store/* | Abonnement Pro mensuel et annuel, achats en boutique. Webhook : checkout.session.completed, customer.subscription.updated / deleted, invoice.payment_failed. Le plan n'est écrit que côté serveur : webhook, essai Pro, attribution admin, cron de secours. |
| Resend | src/lib/notifications/email.ts | Bienvenue, récap hebdo, envois admin depuis /admin/emails |
| Google Sign-In | Firebase Auth GoogleAuthProvider (web), api/auth/google-mobile + callback (mobile) | Seul mode de connexion en production |
| MySSO | api/auth/token, api/auth/firebase-token, expo-auth-session | Fournisseur OpenID Connect de dev et staging ; bouton masqué en production |
| sql.js + JSZip | api/import/anki, public/sql-wasm.wasm | Lit les paquets Anki .apkg : un zip contenant une base SQLite, ouverte en WebAssembly. sql.js est déclaré en serverExternalPackages. |
| Vercel Edge Config | src/proxy.ts, api/admin/maintenance | Interrupteur de maintenance sans redéploiement |
| Sentry | instrumentation*.ts, sentry.server/edge.config.ts | Erreurs client, serveur et edge. Traces désactivées (tracesSampleRate: 0), source maps envoyées au build. |
| Vercel Analytics, Speed Insights | src/lib/platform/analytics.ts | Événements produit sans donnée personnelle : signup, first_deck_created, duel_started… |
| next/og | api/og, api/og/recap | Images Open Graph générées à la volée pour les partages |
Les protections, couche par couche
| Couche | Protection |
|---|---|
| En-têtes HTTP | CSP par requête avec nonce et strict-dynamic, HSTS un an, X-Frame-Options: DENY, nosniff, Permissions-Policy (caméra, micro, géoloc coupés) |
| Middleware Edge | Basic Auth + cookie HMAC sur /admin, rate limit admin, maintenance. Plantage volontaire si l'émulateur Firebase est activé sur un déploiement Vercel. |
| Routes API | withAuth vérifie l'ID token à chaque appel, rate limit par route, impersonation en lecture seule, crons protégés par CRON_SECRET, webhook Stripe vérifié par signature |
| Firestore et Storage | Isolation par utilisateur, écritures client refusées sauf quelques champs de profil, champs plan et facturation écrits par l'Admin SDK seulement, images limitées à 5 Mo |
| Logique de jeu | QCM notés côté serveur, réponses gardées dans private/, create() pour empêcher une double soumission, transactions pour les points et les séries |
| RGPD | Push soumis au consentement cookies, opt-out email respecté par sendUserEmail(), analytics sans PII |
dev → staging → main, sans raccourci
release.yml revalide staging (lint, tests, build, E2E) puis ouvre la PR staging → main ; sa fusion déclenche le déploiement production de Vercel. Un hook git post-checkout recopie le bon .env.local selon la branche : identifiants prod sur main, staging ailleurs.- Tests unitaires (Vitest, 31 fichiers
*.test.ts) sur la logique pure : SRS, jetons, distracteurs, rate limit. - Tests d'intégration des routes API avec un faux Firestore en mémoire (
src/test/fake-firestore.ts) qui gère transactions,FieldValueet requêtes. - E2E Playwright sur Chromium contre les émulateurs Auth (:9099) et Firestore (:8080), exécution en série.
- Mobile : EAS Build avec trois profils (development, preview, production en auto-incrément), puis EAS Submit vers les stores.
- Dépendances : Renovate ouvre les PR de mise à jour.
- Hook pre-commit : ESLint sur les fichiers
src/**/*.ts(x)indexés.
Stack complète
| Techno | Version | Rôle |
|---|---|---|
| Front web | ||
| Next.js (App Router, Turbopack) | 16.3 | Pages RSC/SSR, routes API, middleware proxy.ts |
| React | 19 | Interface web |
| TypeScript | 6.0 | Web, API et mobile (5.9 côté mobile) |
| Tailwind CSS | 4 | Styles, thème par variables CSS (var(--accent)) |
| react-markdown + remark-gfm | 10 · 4 | Rendu Markdown du contenu des cartes |
| PWA : Service Worker + manifest | — | Installation, hors ligne, Web Push |
| Firebase JS SDK | 12 | Auth, Firestore (IndexedDB), Storage côté client |
| Mobile | ||
| Expo SDK · Expo Router | 54 · 6 | App iOS et Android, navigation par fichiers |
| React Native | 0.81 | Runtime natif (React 19.1) |
| expo-web-browser · expo-auth-session | 15 · 7 | Login Google (prod) et MySSO PKCE (dev, staging) |
| expo-secure-store | 15 | Stockage chiffré des tokens |
| expo-notifications | 0.32 | Push via Expo Push API |
| AsyncStorage | 2.2 | Cache API, file d'attente hors ligne, persistance Firebase Auth |
| EAS Build · EAS Submit | — | Builds cloud et publication sur les stores |
| Backend | ||
| Vercel Functions (Node) + Edge | — | Exécution des routes API et du middleware |
| firebase-admin | 13 | Écritures Firestore, vérification et émission de tokens |
| jose | 6 | Vérification JWT via JWKS (MySSO, hors prod) |
| web-push | 3.6 | Envoi Web Push signé VAPID |
| sql.js · JSZip | 1.14 · 3.10 | Import Anki (SQLite en WebAssembly) |
| Données et infrastructure | ||
| Cloud Firestore | — | Base de données unique, temps réel, règles de sécurité |
| Firebase Auth · Storage | — | Identités (custom tokens, Google), images |
| Upstash Redis · @upstash/ratelimit | 1.38 · 2.0 | Rate limiting global en fenêtre glissante |
| Vercel Edge Config | 1.4 | Flag de maintenance |
Vercel Data Cache (unstable_cache) | — | Classement, prix, stats partagés entre instances |
| Vercel Cron | — | Quatre jobs quotidiens |
| Services | ||
Google Gemini 2.5 Flash (@google/genai) | 2.9 | IA : génération de decks, distracteurs QCM |
| Stripe | 22 | Abonnements, boutique, portail client |
| Resend | 6 | Emails transactionnels |
| Google Sign-In | — | Connexion en production (web et mobile) |
| MySSO | — | Fournisseur OpenID Connect, dev et staging uniquement |
| Observabilité | ||
Sentry (@sentry/nextjs) | 10 | Suivi d'erreurs client, serveur, edge |
| Vercel Analytics · Speed Insights | 2 · 2 | Événements produit, Web Vitals |
| Qualité et outillage | ||
| Vitest (+ coverage v8) | 4.1 | Tests unitaires et d'intégration |
| Playwright | 1.59 | E2E et smoke tests |
Firebase Emulator Suite (firebase-tools) | 15 | Dev hors ligne et E2E |
ESLint (eslint-config-next) | 9 | Lint en CI et en pre-commit |
| GitHub Actions · Renovate | — | CI, release, mises à jour de dépendances |