MagicCards · architecture technique

Architecture de MagicCards

Une app de flashcards à répétition espacée, sur le web et sur mobile. Un seul backend Next.js hébergé sur Vercel sert les pages web et les 145 handlers API que le site et l'app Expo partagent. Firestore est l'unique base de données : les clients la lisent en direct et en temps réel, mais n'y écrivent jamais eux-mêmes. Toute écriture passe par une route API.

Relevé dans le code · branche dev (1276d4d) · 28 septembre 2026

Web
Next.js 16 · React 19
Mobile
Expo 54 · RN 0.81
Base de données
Cloud Firestore
Temps réel
Listeners onSnapshot
Redis
Upstash · rate limit
Hébergement
Vercel · Edge + Node
Vue d'ensemble

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 runTransaction cô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.
CLIENTS Web · PWA Next.js 16 · React 19 Tailwind 4 · sw.js hors ligne Firestore SDK + IndexedDB App mobile iOS/Android Expo 54 · React Native 0.81 Expo Router 6 · SecureStore AsyncStorage : cache + file Portail admin · /admin Basic Auth + cookie HMAC ADMIN_UIDS · adminRoles impersonation lecture seule HTTPS · pages + /api fetch /api · Bearer /admin VERCEL · EDGE Middleware Edge · src/proxy.ts CSP à nonce · maintenance (page ou 503 JSON) · Basic Auth /admin · rate limit /api/admin Vercel Edge Config flag maintenance, live lit pages /api/* VERCEL · FONCTIONS NODE Pages App Router 83 pages · RSC/SSR fr par défaut · /en next/og · sitemap Routes API · 145 handlers withAuth → verifyIdToken (Bearer) checkRateLimit · transactions Firestore caches mémoire, TTL 30 s → 1 h Vercel Cron ×4 00·08·10·18 h UTC GET HTTPS sortant · webhooks écrit · Admin SDK vérifie tokens rate limit FIREBASE · GOOGLE CLOUD Firebase Auth custom tokens Google sign-in ID token · 1 h Cloud Firestore seule base de données écriture client refusée listeners onSnapshot cache IndexedDB (web) Storage avatars/ cardImages/ 5 Mo · image/* UPSTASH Upstash Redis ratelimit via REST sliding window rate limit seulement repli : Map mémoire SDK Firebase en direct : lectures, onSnapshot, sign-in, uploads SERVICES EXTERNES Gemini 2.5 Flash decks IA · distracteurs QCM Stripe Checkout · Portal · webhook Resend emails transactionnels fr/en Web Push · Expo Push VAPID · exp.host · rappels Google OAuth · MySSO Google en prod · MySSO en dev Sentry erreurs client, serveur, edge Vercel Analytics Speed Insights · sans PII
Deux chemins vers les données. Les requêtes HTTP passent par le middleware Edge puis par les fonctions Node de Vercel, qui écrivent dans Firestore avec l'Admin SDK. En parallèle, le trait vert montre le SDK Firebase des clients qui parle directement à Firebase pour lire, écouter les changements, se connecter et envoyer des images. Vercel n'est jamais sur ce chemin. Sentry et Vercel Analytics reçoivent aussi des événements directement depuis le navigateur.
Lecture, écriture et temps réel

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.

Joueur A · web page duel/[id] Route API POST /api/duels/[id]/… Firestore duels/{id} Joueur B · mobile écran duel/[id] 1. réponse + Bearer ID token verifyIdToken rate limit (Redis) 2. runTransaction · Admin SDK 3. snapshot poussé 3. snapshot poussé écriture directe refusée · allow write: if false
Le serveur écrit, Firestore diffuse. Le client n'affiche le coup qu'une fois le snapshot reçu, sans mise à jour optimiste. Les deux joueurs voient donc le même état. Une écriture directe depuis le SDK client échoue avec PERMISSION_DENIED.
Écran ou moduleCe qu'il écoutePour 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écentesCloche 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}/resultsDémarrage en direct et classement live
Magic Trivial (web et mobile)magicTrivial/{id} et private/currentPartie 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)

réussite : boîte + 1 Boîte 1+1 j Boîte 2+2 j Boîte 3+4 j Boîte 4+7 j Boîte 5+14 j Boîte 6+30 j Boîte 7+90 j Appriseboîte > 7 échec : retour en boîte 1
Chaque réponse est une écriture serveur. 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.
Authentification

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

PRODUCTION · WEB Web · /login bouton Google popup Google via Firebase Auth signInWithPopup · aucune route API PRODUCTION · MOBILE App mobile WebBrowser ouvre google-mobile route API 302 Google OAuth choix du compte code …/callback createCustomToken deep link DEV ET STAGING · MASQUÉ EN PRODUCTION Bouton MySSO si ENABLE_SSO PKCE MySSO fournisseur OIDC code auth/token échange du code JWT firebase-token createCustomToken custom token Firebase Auth ID token · 1 h rafraîchi par le SDK Bearer Routes API verifyIdToken
Trois portes, un seul jeton. Sur le web en production, le SDK Firebase gère toute la connexion Google (popup, avec repli en redirection si elle est bloquée) sans passer par une route API. Sur mobile, le serveur échange le code Google dans /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)

Clientweb ou mobile MySSOfournisseur OIDC auth/tokenroute API · CORS firebase-tokenroute API Firebase AuthGoogle Cloud Routes APIwithAuth 1. authorize (PKCE) 2. redirect ?code 3. code + code_verifier 4. POST /token 5. id_token 6. POST id_token 7. jwtVerify via JWKS (jose) 8. createCustomToken 9. custom token 10. signInWithCustomToken → ID token 11. Authorization: Bearer <ID token> verifyIdToken
Deux routes serveur encadrent le SSO. /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 displayName qu'à la première connexion, jamais par-dessus un nom choisi. L'email de bienvenue part via POST /api/email/welcome sur le web et via after() dans le callback mobile.
  • Stockage des sessions : localStorage sur le web, expo-secure-store et la persistance AsyncStorage de Firebase Auth sur mobile.
  • Admin : Basic Auth sur /admin dans le middleware, avec un cookie signé HMAC valable 30 jours, puis ADMIN_UIDS et les rôles de adminRoles/{uid}.
  • Impersonation : POST /api/admin/impersonate émet un custom token avec la claim impersonation. withAuth bloque alors tout POST, PUT, PATCH et DELETE, et chaque démarrage est inscrit au journal d'audit.
Redis et caches

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.

Route API review:answer:{uid} checkRateLimit() clé · limite · fenêtre Upstash Redis slidingWindow · global · REST Map en mémoire par instance · repli configuré absent erreur réseau ok dépassé Autorisé suite du handler 429 Too Many Requests en-tête Retry-After
Une clé par action et par utilisateur (ou par IP pour les routes publiques). Il y a une soixantaine d'appels à checkRateLimit dans le code. Le middleware Edge l'utilise aussi pour /api/admin/*.
Clé de rate limitBudget
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:answer120 / 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.

CoucheOù dans le codeDuréeContenu
Navigateur et mobile
Mémoire du modulesrc/lib/http/clientCache.tsdurée de l'ongletWidgets du dashboard : catalogue de templates, quiz du jour, carte du jour
IndexedDB FirestorepersistentLocalCache multi-ongletspersistantDocuments Firestore déjà lus, disponibles hors ligne
Service Workerpublic/sw.js, versionné par SHA gitjusqu'au déploiement suivantCoquille de /dashboard, /decks, /review, /profile… pour l'usage hors ligne
AsyncStorage mobilemobile/lib/cache.tsTTL, servi périmé jusqu'à 2 × TTLRéponses API, affichées sans spinner pendant la revalidation
Edge et serveur
Edge Configsrc/proxy.ts, edge-config-admin.tsliveFlag maintenance, modifié depuis /admin via l'API Vercel
Mémoire d'instancesrc/lib/**/*-cache.tsevents 30 s · profils 5 min · templates 10 min · séries amis 1 h · défi hebdo 1 hLectures Admin SDK fréquentes ; invalidées à l'écriture sur la même instance
Vercel Data Cacheunstable_cacheclassement 5 min · prix Stripe 1 h · stats landing 1 hPartagé entre instances, survit aux cold starts
Upstash Redissrc/lib/http/rate-limit.tsfenêtre glissanteCompteurs de rate limit uniquement
Modèle de données

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

CheminContenuLecture clientÉcriture client
Données de l'utilisateur
users/{uid}Profil, série, jetons, plan, historiquesoichamps limités plan et facturation interdits
users/{uid}/decks/{id}Deckssoinon · API
users/{uid}/cards/{id}Cartes : currentBox, nextReviewDate, reviewCount, successCountsoinon · API
users/{uid}/notifications/{id}Notifications in-appsoinon · API
weeklyProgress/{uid}Progression du défi hebdosoinon · API
Jeu et social
duels/{id}Duelsparticipantsnon · API
magicTrivial/{id} + private/Parties multijoueurparticipants private : jugenon · API
events/{id} + results/Quiz en direct, scores finaux immuablesconnecténon · API
events/{id}/progress/{uid}Réponses notées par le serveursoinon · API
events/{id}/private/quizPool QCM avec les bonnes réponsespersonnenon · API
dailyQuiz/{date}Quiz du jour, contient les réponsespersonne results : connecténon · API
follows/{id}Abonnements entre joueursconnectécréation, suppression par soi
friendStreaks, deckCollaborators, deckChangelogSéries à deux, decks partagés à plusieursparties concernéesnon · API
Contenu public et administration
shares/{id}Liens de partage de deckspublicpropriétaire
templates/{id}Bibliothèque de decks publicspublicnon · API
publicRequests/{id}Decks soumis à modérationpropriétairecréation
config/{doc}Défi hebdo, réglagesconnecténon · API
adminRoles, auditLogs, templateLikes, proTrialGrantsRôles, audit, compteurs internespersonneAdmin SDK seul
Firebase Storage
avatars/{uid}, cardImages/{uid}/{id}Photos de profil, images de cartesconnectésoi 5 Mo max, image/*
Tâches planifiées

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 UTCRouteCe qu'elle fait
00:00/api/cron/dailyTermine 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/remindPush web et Expo aux utilisateurs qui ont des cartes à réviser (decks archivés exclus).
10:00/api/cron/daily-quizClô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-reminderPush aux joueurs qui ont une série active et des cartes dues mais n'ont pas encore révisé aujourd'hui.
Notifications

Quatre canaux, trois fonctions

Déclencheurs crons 08 h · 10 h · 18 h duel lancé ou terminé nouvel abonné, deck partagé résultat d'event, quiz gagné annonces admin récap hebdo (lundi) bienvenue à l'inscription createNotification() lib/notifications/notify.ts sendPushToUser() + broadcastPush() sendUserEmail() lib/notifications/email.ts écrit API In-app · users/{uid}/notifications onSnapshot → cloche et badge de non-lus Web Push · web-push + VAPID sw.js du navigateur · 404/410 → abonnement purgé Expo Push · exp.host, lots de 100 APNs / FCM · DeviceNotRegistered → token purgé Email · Resend opt-out RGPD · désinscription signée HMAC · fr/en
La notification in-app est une simple écriture Firestore. Elle arrive dans l'interface par le même mécanisme 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.
Intégrations

Services tiers et où ils sont branchés

ServiceBranché dansUsage
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
Stripeapi/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.
Resendsrc/lib/notifications/email.tsBienvenue, récap hebdo, envois admin depuis /admin/emails
Google Sign-InFirebase Auth GoogleAuthProvider (web), api/auth/google-mobile + callback (mobile)Seul mode de connexion en production
MySSOapi/auth/token, api/auth/firebase-token, expo-auth-sessionFournisseur OpenID Connect de dev et staging ; bouton masqué en production
sql.js + JSZipapi/import/anki, public/sql-wasm.wasmLit les paquets Anki .apkg : un zip contenant une base SQLite, ouverte en WebAssembly. sql.js est déclaré en serverExternalPackages.
Vercel Edge Configsrc/proxy.ts, api/admin/maintenanceInterrupteur de maintenance sans redéploiement
Sentryinstrumentation*.ts, sentry.server/edge.config.tsErreurs client, serveur et edge. Traces désactivées (tracesSampleRate: 0), source maps envoyées au build.
Vercel Analytics, Speed Insightssrc/lib/platform/analytics.tsÉvénements produit sans donnée personnelle : signup, first_deck_created, duel_started…
next/ogapi/og, api/og/recapImages Open Graph générées à la volée pour les partages
Sécurité

Les protections, couche par couche

CoucheProtection
En-têtes HTTPCSP 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 EdgeBasic Auth + cookie HMAC sur /admin, rate limit admin, maintenance. Plantage volontaire si l'émulateur Firebase est activé sur un déploiement Vercel.
Routes APIwithAuth 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 StorageIsolation 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 jeuQCM 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
RGPDPush soumis au consentement cookies, opt-out email respecté par sendUserEmail(), analytics sans PII
CI/CD et environnements

dev → staging → main, sans raccourci

feature/* · fix/* travail local dev intégration staging pré-production main production PR PR PR npm run dev déploie déploie déploie Émulateurs demo-magiccards Vercel Firebase staging Vercel Firebase staging Vercel Production Firebase prod vérifie CI · GitHub Actions (ci.yml) lint · Vitest · build · Playwright E2E sur émulateurs — à chaque PR vers dev, staging, main et à chaque push sur dev
Aucune branche ne saute d'étape, correctifs urgents compris. Le workflow manuel 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, FieldValue et 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.
Inventaire

Stack complète

TechnoVersionRôle
Front web
Next.js (App Router, Turbopack)16.3Pages RSC/SSR, routes API, middleware proxy.ts
React19Interface web
TypeScript6.0Web, API et mobile (5.9 côté mobile)
Tailwind CSS4Styles, thème par variables CSS (var(--accent))
react-markdown + remark-gfm10 · 4Rendu Markdown du contenu des cartes
PWA : Service Worker + manifest—Installation, hors ligne, Web Push
Firebase JS SDK12Auth, Firestore (IndexedDB), Storage côté client
Mobile
Expo SDK · Expo Router54 · 6App iOS et Android, navigation par fichiers
React Native0.81Runtime natif (React 19.1)
expo-web-browser · expo-auth-session15 · 7Login Google (prod) et MySSO PKCE (dev, staging)
expo-secure-store15Stockage chiffré des tokens
expo-notifications0.32Push via Expo Push API
AsyncStorage2.2Cache 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-admin13Écritures Firestore, vérification et émission de tokens
jose6Vérification JWT via JWKS (MySSO, hors prod)
web-push3.6Envoi Web Push signé VAPID
sql.js · JSZip1.14 · 3.10Import 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/ratelimit1.38 · 2.0Rate limiting global en fenêtre glissante
Vercel Edge Config1.4Flag 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.9IA : génération de decks, distracteurs QCM
Stripe22Abonnements, boutique, portail client
Resend6Emails transactionnels
Google Sign-In—Connexion en production (web et mobile)
MySSO—Fournisseur OpenID Connect, dev et staging uniquement
Observabilité
Sentry (@sentry/nextjs)10Suivi d'erreurs client, serveur, edge
Vercel Analytics · Speed Insights2 · 2Événements produit, Web Vitals
Qualité et outillage
Vitest (+ coverage v8)4.1Tests unitaires et d'intégration
Playwright1.59E2E et smoke tests
Firebase Emulator Suite (firebase-tools)15Dev hors ligne et E2E
ESLint (eslint-config-next)9Lint en CI et en pre-commit
GitHub Actions · Renovate—CI, release, mises à jour de dépendances