Couche HTTP (Fastify)
À quoi sert cette couche
Section intitulée « À quoi sert cette couche »La couche HTTP est la porte d’entrée réseau de l’API. Tout le reste du backend (use cases, domaine, infrastructure) est volontairement ignorant du protocole HTTP : il manipule des objets et des ports. Le rôle de api/src/http/ est de faire le pont entre le monde extérieur (requêtes JSON, en-têtes, cookies, tokens) et ces use cases.
Concrètement, cette couche fait trois choses, et rien d’autre :
- Parser et valider l’entrée (corps, query, params) avec Zod.
- Appeler le use case correspondant via le conteneur d’injection.
- Sérialiser la sortie (entité domaine → DTO JSON) et choisir le code HTTP.
Tout ce qui est transversal — authentification, autorisation par scope, idempotence, audit, gestion d’erreur, identifiant de corrélation — est délégué à des plugins Fastify branchés sur des hooks, pour ne jamais polluer les handlers.
flowchart LR C["Client (SDK / Console / PSP)"] --> P["Plugins (hooks: auth, idempotency, audit...)"] P --> H["Handler de route (parse Zod)"] H --> U["Use case (application/)"] U --> D["Domaine + Infrastructure"] H --> S["Sérialiseur (entité -> DTO)"] S --> C
1. Démarrage : server.ts → buildHttpApp
Section intitulée « 1. Démarrage : server.ts → buildHttpApp »Le point d’entrée du processus est api/src/http/server.ts. Sa fonction main() fait, dans l’ordre :
- Charge la config (
loadConfig()depuisconfig/) — c’est le seul endroit qui litprocess.env. - Vérifie la présence de
PSP_CONFIG_KEK(clé de chiffrement des secrets PSP), sinon échec immédiat. - Construit le logger Pino avec une liste de
redactPaths(PAN, CVV, tokens, secrets, en-têtesauthorization/cookie/stripe-signature/x-titreo-signaturecensurés[REDACTED]). - Ouvre la connexion Firebird (
FirebirdDb.connect), instancie les adapters d’infrastructure (repos, AEAD, registres PSP/gift, signer de manifeste…). - Appelle
buildContainer(...)(voir Couche Application) pour obtenir le conteneur d’injection. - Appelle
buildHttpApp(...)(dansapp.ts) qui assemble l’instance Fastify. - Branche
SIGINT/SIGTERMsur unshutdown(ferme l’app puis la DB) et appelleapp.listen({ port, host }).
buildHttpApp(opts) dans api/src/http/app.ts est la fabrique testable de l’application : elle reçoit le container déjà construit plus des options (clientTokenSecret, cookieSecret, nodeEnv, corsAllowedOrigins…) et retourne une FastifyInstance. La séparation server.ts (câblage des dépendances réelles) / app.ts (assemblage HTTP) permet aux tests d’intégration de construire l’app avec des ports mockés sans toucher au réseau.
sequenceDiagram
participant Node as "Process Node"
participant Srv as "server.ts main()"
participant Cfg as "config/loadConfig"
participant DB as "FirebirdDb"
participant Cnt as "buildContainer"
participant App as "app.ts buildHttpApp"
Node->>Srv: démarrage
Srv->>Cfg: loadConfig()
Srv->>DB: connect(config.firebird)
Srv->>Cnt: buildContainer(ports réels)
Srv->>App: buildHttpApp({ container, options })
App-->>Srv: FastifyInstance
Srv->>App: app.listen({ port, host })
Dans buildHttpApp, l’ordre d’enregistrement est significatif (les hooks s’exécutent dans l’ordre d’inscription) :
validatorCompiler/serializerCompilerdefastify-type-provider-zod→ Zod devient le moteur de validation et de sérialisation des schémas de route.@fastify/helmet(en-têtes de sécurité, CSP désactivée ici).@fastify/rate-limit(global désactivé :global: false; chaque route opte-in viaconfig.rateLimit).- Plugins maison :
error-handler,request-context, puis@fastify/cors,@fastify/cookie,user-session,auth,idempotency,audit. - En non-production uniquement :
@fastify/swagger+ Swagger UI sur/docs. - Tous les modules de routes (
healthRoutes,meRoutes(),sessionsRoutes(...), etc.).
2. Les plugins (transversaux)
Section intitulée « 2. Les plugins (transversaux) »Tous sont dans api/src/http/plugins/. Sauf require-scope et require-session-action (de simples fabriques de preHandler, pas des plugins fastify-plugin), ils sont enveloppés par fp(...) et déclarent leurs dépendances.
| Fichier | Hook(s) | Rôle |
|---|---|---|
plugins/request-context.ts |
onRequest |
Génère/propage un req.correlationId (depuis l’en-tête x-correlation-id ou un randomUUID), le renvoie dans la réponse. |
plugins/error-handler.ts |
setErrorHandler |
Convertit ZodError, DomainError, erreurs de validation Fastify et erreurs inconnues en réponses { code, message, details? } + code HTTP. Voir détail ci-dessous. |
plugins/user-session.ts |
onRequest |
Lit le cookie tep_session, résout la session utilisateur (ResolveUserSession) et la pose sur req.userSession (admin ou marchand). Dépend de @fastify/cookie. |
plugins/auth.ts |
onRequest + preHandler |
Cœur de l’authentification : valide le Bearer (API key OU client token), ou synthétise une auth depuis req.userSession, et pose req.auth = { merchantId, scopes, apiKeyId, sessionId? }. Dépend de user-session. |
plugins/idempotency.ts |
preHandler + onSend |
Sur méthode mutante + en-tête Idempotency-Key : rejoue une réponse 2xx déjà enregistrée, détecte les conflits de payload, persiste la réponse après coup. Voir Idempotence. |
plugins/audit.ts |
onRequest |
Expose req.audit(event) ; chaque route appelle cette fonction pour journaliser une action sensible (création de clé, refund, etc.) via le use case WriteAuditLog. |
plugins/require-scope.ts |
preHandler (fabrique) |
requireScope(scope) → 403 si req.auth.scopes ne contient pas le scope requis (le scope admin passe tout). |
plugins/require-session-action.ts |
preHandler (fabrique) |
requireSessionAction(action) → autorise soit via le scope serveur (sessions:read/write), soit via un client token sur l’allowlist d’actions. |
Chaîne d’authentification (auth.ts)
Section intitulée « Chaîne d’authentification (auth.ts) »Le hook onRequest du plugin auth laisse passer les chemins publics (/health, /docs*, /v1/webhooks/*, /v1/auth/*) puis distingue trois sources d’identité :
1. En-tête "Authorization: Bearer <token>" ├─ token = client token (issu de /client-token) -> scopes = ['sessions:client'], sessionId lié └─ sinon token = API key -> AuthenticateApiKey.execute(token) -> merchantId + scopes2. Pas de Bearer mais cookie de session valide (req.userSession) -> synthèse d'une auth : admin (scope 'admin') OU marchand (scopes dérivés du rôle owner/developer/ops)3. Ni l'un ni l'autre -> 401 UNAUTHORIZEDLe second hook preHandler du plugin auth verrouille les client tokens : si req.auth.sessionId est présent, la requête doit cibler une route /v1/sessions/:id/* dont le :id correspond exactement au sessionId du token, sinon 403. C’est ce qui empêche un token navigateur d’agir sur une autre session. Voir Sécurité.
Gestion d’erreur (error-handler.ts)
Section intitulée « Gestion d’erreur (error-handler.ts) »Une seule fonction setErrorHandler normalise toutes les sorties d’erreur :
| Type d’erreur | Code HTTP | Corps |
|---|---|---|
ZodError |
400 | { code: 'VALIDATION_ERROR', message, details: flatten() } |
DomainError (domain/shared/errors.ts) |
via table STATUS_BY_CODE |
{ code, message, details? } (details filtrés en prod) |
Erreur de validation Fastify (.validation) |
400 | { code: 'VALIDATION_ERROR', ... } |
| Autre / inconnue | statusCode ?? 500 |
message masqué en Internal server error si ≥ 500 |
La table STATUS_BY_CODE mappe : VALIDATION_ERROR/CURRENCY_MISMATCH → 400, INVALID_STATE_TRANSITION/INVARIANT_VIOLATION → 409, INSUFFICIENT_BALANCE → 422, PROVIDER_ERROR → 502. En production, sanitizeDetails n’autorise qu’une whitelist de champs par code (ex. currentState/expectedState), pour éviter de fuiter des IDs internes inter-tenant.
3. Toutes les routes
Section intitulée « 3. Toutes les routes »Chaque module de route est une fabrique xxxRoutes(deps) qui retourne un plugin Fastify et reçoit ses use cases depuis le conteneur. Les routes typées utilisent app.withTypeProvider<ZodTypeProvider>() pour brancher les schémas Zod. Préfixe commun : /v1 (sauf /health).
Colonne « auth » : public = pas de Bearer requis ; scope:X = requireScope('X') ; session-action:X = requireSessionAction('X') (API key OU client token autorisé) ; cookie = résolu via session utilisateur ; — = authentifié mais sans garde de scope supplémentaire (/v1/me, /v1/merchants/me, /v1/audit-logs).
Santé & identité
Section intitulée « Santé & identité »| Fichier | Méthode + Path | Use case | Auth |
|---|---|---|---|
routes/health.routes.ts |
GET /health |
(inline) { status, uptime } |
public |
routes/me.routes.ts |
GET /v1/me |
(inline) renvoie req.auth |
— |
Sessions & legs — routes/sessions.routes.ts
Section intitulée « Sessions & legs — routes/sessions.routes.ts »| Méthode + Path | Use case | Auth |
|---|---|---|
POST /v1/sessions |
createSession |
scope:sessions:write |
GET /v1/sessions |
listSessions |
scope:sessions:read |
GET /v1/sessions/:id |
getSession |
session-action:session:read |
POST /v1/sessions/:id/legs/gift |
addGiftLeg (+ getSession) |
session-action:session:add_gift_leg |
POST /v1/sessions/:id/legs/psp |
setPspLeg (+ getSession) |
session-action:session:add_psp_leg |
DELETE /v1/sessions/:id/legs/:legId |
removeLeg (+ getSession) |
session-action:session:remove_leg |
POST /v1/sessions/:id/gift-cards/check-balance |
checkGiftBalance |
session-action:session:check_balance |
POST /v1/sessions/:id/legs/psp/confirm |
confirmPspAction |
scope:sessions:write |
POST /v1/sessions/:id/submit |
submitSession |
session-action:session:submit |
POST /v1/sessions/:id/client-token |
(inline) signSessionToken |
scope:sessions:write |
POST /v1/sessions/:id/refund |
refundSession (+ req.audit) |
scope:sessions:write |
Cartes cadeaux standalone — routes/gift-cards.routes.ts
Section intitulée « Cartes cadeaux standalone — routes/gift-cards.routes.ts »Endpoints « hors session » qui pilotent directement le GiftCardProvider (pour les surfaces Shopify où Shopify fait le split de tender).
| Méthode + Path | Use case | Auth |
|---|---|---|
POST /v1/gift-cards/check-balance |
lookupGiftBalance |
scope:sessions:read |
POST /v1/gift-cards/debit |
debitGiftCard |
scope:sessions:write |
POST /v1/gift-cards/refund |
refundGiftCard |
scope:sessions:write |
debit/refund exigent un Idempotency-Key (400 IDEMPOTENCY_KEY_REQUIRED sinon). Une erreur transport émetteur (TITREO_HTTP/TITREO_UNKNOWN) est renvoyée en 502 non mis en cache (donc rejouable), les refus métier déterministes en 200.
Bootstrap SDK & webhooks PSP
Section intitulée « Bootstrap SDK & webhooks PSP »| Fichier | Méthode + Path | Use case | Auth |
|---|---|---|---|
routes/bootstrap.routes.ts |
GET /v1/sessions/:id/bootstrap |
generateManifest (+ lecture merchant/pspConfigs) |
session-action:session:read |
routes/webhooks.routes.ts |
POST /v1/webhooks/:providerType/:merchantId |
handlePspWebhook |
public (signature vérifiée dans le use case) |
Espace marchand (/v1/merchants/me/*)
Section intitulée « Espace marchand (/v1/merchants/me/*) »| Fichier | Méthode + Path | Use case | Auth |
|---|---|---|---|
routes/merchants-me.routes.ts |
GET /v1/merchants/me |
getMerchant |
— |
routes/merchants-me.routes.ts |
GET /v1/merchants/me/onboarding |
getMerchantOnboardingState |
— |
routes/merchants-me.routes.ts |
PUT /v1/merchants/me |
updateMerchant (+ audit) |
scope:webhooks:write |
routes/api-keys.routes.ts |
POST /v1/merchants/me/api-keys |
createApiKey (+ audit) |
scope:api_keys:write |
routes/api-keys.routes.ts |
GET /v1/merchants/me/api-keys |
listApiKeys |
scope:api_keys:read |
routes/api-keys.routes.ts |
DELETE /v1/merchants/me/api-keys/:id |
revokeApiKey (+ audit) |
scope:api_keys:write |
routes/branding.routes.ts |
GET /v1/merchants/me/branding |
getBranding |
scope:branding:read |
routes/branding.routes.ts |
PUT /v1/merchants/me/branding |
updateBranding (+ audit) |
scope:branding:write |
routes/psp-configs.routes.ts |
GET /v1/merchants/me/psp-configs |
listPspConfigs |
scope:psp:read |
routes/psp-configs.routes.ts |
GET /v1/merchants/me/psp-configs/:provider |
getPspConfig |
scope:psp:read |
routes/psp-configs.routes.ts |
PUT /v1/merchants/me/psp-configs/:provider |
upsertPspConfig (+ valid. format + audit) |
scope:psp:write |
routes/psp-configs.routes.ts |
DELETE /v1/merchants/me/psp-configs/:provider |
deletePspConfig (+ audit) |
scope:psp:write |
routes/merchant-users.routes.ts |
GET /v1/merchants/me/users |
listMerchantUsers |
scope:users:read |
routes/merchant-users.routes.ts |
POST /v1/merchants/me/users/invites |
inviteMerchantUser (+ audit) |
scope:users:write |
routes/merchant-users.routes.ts |
DELETE /v1/merchants/me/users/invites/:id |
revokeMerchantInvite (+ audit) |
scope:users:write |
routes/merchant-users.routes.ts |
PATCH /v1/merchants/me/users/:id |
updateMerchantUser (+ audit) |
scope:users:write |
routes/webhook-deliveries.routes.ts |
GET /v1/merchants/me/webhook-deliveries |
repo.list (port direct) |
scope:webhooks:read |
routes/webhook-deliveries.routes.ts |
GET /v1/merchants/me/webhook-deliveries/:id |
repo.findById |
scope:webhooks:read |
routes/webhook-deliveries.routes.ts |
POST /v1/merchants/me/webhook-deliveries/:id/replay |
outbox.enqueue (MERCHANT_WEBHOOK) |
scope:webhooks:write |
webhook-deliveries est l’un des rares modules qui appelle des ports directement (WebhookDeliveriesRepository, OutboxRepository) plutôt qu’un use case dédié : le « replay » fabrique une entrée d’outbox MERCHANT_WEBHOOK avec une idempotencyKey déterministe. La pagination utilise un curseur opaque base64url { createdAt, id } (helpers encodeCursor/decodeCursor).
Espace admin Titreo (/v1/admin/* — scope admin)
Section intitulée « Espace admin Titreo (/v1/admin/* — scope admin) »| Fichier | Méthode + Path | Use case | Auth |
|---|---|---|---|
routes/admin-merchants.routes.ts |
POST /v1/admin/merchants |
createMerchant (+ audit) |
scope:admin |
routes/admin-merchants.routes.ts |
GET /v1/admin/merchants |
listMerchants |
scope:admin |
routes/admin-merchants.routes.ts |
GET /v1/admin/merchants/:id |
getMerchant |
scope:admin |
routes/admin-merchants.routes.ts |
PATCH /v1/admin/merchants/:id |
adminUpdateMerchant (+ audit) |
scope:admin |
routes/admin-api-keys.routes.ts |
POST /v1/admin/merchants/:id/api-keys |
createApiKey (+ audit) |
scope:admin |
routes/admin-api-keys.routes.ts |
GET /v1/admin/merchants/:id/api-keys |
listApiKeys |
scope:admin |
routes/admin-api-keys.routes.ts |
DELETE /v1/admin/merchants/:id/api-keys/:keyId |
revokeApiKey (+ audit) |
scope:admin |
routes/admin-psp-configs.routes.ts |
GET /v1/admin/merchants/:id/psp-configs |
listPspConfigs |
scope:admin |
routes/admin-psp-configs.routes.ts |
DELETE /v1/admin/merchants/:id/psp-configs/:provider |
deletePspConfig (+ audit) |
scope:admin |
routes/admin-users.routes.ts |
GET /v1/admin/users |
repo.listAll (port direct) |
scope:admin |
routes/admin-users.routes.ts |
POST /v1/admin/users |
createAdminUser (+ audit) |
scope:admin |
routes/audit-logs.routes.ts |
GET /v1/audit-logs |
listAuditLogs |
— (admin voit tout / cible merchantId, marchand voit le sien) |
Authentification utilisateur (/v1/auth/* — public, par cookie)
Section intitulée « Authentification utilisateur (/v1/auth/* — public, par cookie) »routes/auth.routes.ts gère le cycle de vie des sessions navigateur (Console marchand & Dashboard admin). Toutes ces routes sont publiques (préfixe /v1/auth/) avec rate-limit serré.
| Méthode + Path | Use case | Notes |
|---|---|---|
POST /v1/auth/signup |
signupMerchant |
rate-limit 3/min ; verificationLink exposé seulement si exposeVerificationLink |
POST /v1/auth/verify-email |
verifyMerchantEmail |
3/min |
POST /v1/auth/accept-invite |
acceptMerchantInvite |
3/min |
POST /v1/auth/login |
loginMerchant |
5/min ; pose le cookie tep_session |
POST /v1/auth/admin/login |
loginAdmin |
5/min ; pose le cookie tep_session |
POST /v1/auth/logout |
logoutUser |
efface le cookie |
GET /v1/auth/me |
(inline) lit req.userSession |
401 si pas de session |
Le cookie est posé par setSessionCookie : httpOnly, secure (selon config), sameSite: 'strict', path: '/', expiration alignée sur le token. Voir Sécurité.
4. Schémas Zod & sérialiseurs
Section intitulée « 4. Schémas Zod & sérialiseurs »schemas/sessions.ts
Section intitulée « schemas/sessions.ts »Ce fichier centralise les schémas Zod de la zone sessions (les autres modules définissent leurs schémas inline). Il sert à la fois à valider l’entrée et à typer/sérialiser la sortie via le ZodTypeProvider.
| Schéma | Usage |
|---|---|
CreateSessionBodySchema |
corps de POST /v1/sessions (reference, totalAmount entier positif, currency ∈ EUR/CHF/USD/GBP, metadata?) |
SessionIdParamsSchema / SessionLegParamsSchema |
params :id / :id + :legId |
AddGiftLegBodySchema |
ajout d’un leg carte cadeau (cardToken, cardLast4, emitter, availableBalance, debitAmount?, cvv?) |
SetPspLegBodySchema |
ajout d’un leg PSP (providerType, paymentMethod) |
LegResponseSchema / SessionResponseSchema |
DTO de sortie (forme exacte produite par les sérialiseurs) |
SubmitResponseSchema / RefundResponseSchema |
retours submit / refund |
ListSessionsQuerySchema / ListSessionsResponseSchema |
filtres de liste (status, reference, from/to, limit, cursor) + page |
ErrorResponseSchema |
enveloppe d’erreur partagée { code, message, details? } |
Les types SessionResponse et LegResponse sont inférés (z.infer<...>) et importés par les sérialiseurs : la forme du DTO et son schéma de validation ne peuvent pas diverger.
serializers.ts
Section intitulée « serializers.ts »Deux fonctions pures convertissent les entités domaine en DTO HTTP. C’est la frontière de sortie : aucune entité du domaine ne fuit telle quelle vers le client.
serializeLeg(leg: PaymentLeg): LegResponse— appelleleg.snapshot()puis aplatitamount/currency, expose les champs provider, balances,requiresAction/actionUrl, et convertit les dates en ISO 8601.serializeSession(session: PaymentSession): SessionResponse— aplatittotalAmount/remainingAmount, exposestatus,metadata, dates ISO, et mappesession.legsviaserializeLeg.
// Extrait : la sortie HTTP est toujours un DTO, jamais l'agrégat brut.export function serializeSession(session: PaymentSession): SessionResponse { return { id: session.id, totalAmount: session.totalAmount.amount, // Money -> entier remainingAmount: session.remainingAmount.amount, status: session.status, expiresAt: session.expiresAt.toISOString(), // Date -> ISO legs: session.legs.map(serializeLeg), // ... }}Un chemin d’appel complet, commenté
Section intitulée « Un chemin d’appel complet, commenté »POST /v1/sessions/:id/refund (refund croisé d'une session)│├─ onRequest request-context -> pose req.correlationId├─ onRequest user-session -> (si cookie) pose req.userSession├─ onRequest auth -> valide Bearer / synthétise -> req.auth { merchantId, scopes }├─ onRequest audit -> expose req.audit(...)├─ preHandler auth -> (client token) vérifie sessionId == :id, sinon 403├─ preHandler idempotency -> si Idempotency-Key déjà vu : rejoue la réponse 2xx├─ preHandler requireScope('sessions:write') -> 403 si scope manquant│├─ handler (sessions.routes.ts)│ 1. validation Zod du body (RefundBodySchema) [parse]│ 2. assertOwnedSession(...) -> 404 si pas le bon marchand│ 3. container.useCases.refundSession.execute() [use case : orchestration + outbox]│ 4. req.audit({ action: 'session.refund', ... })│ 5. reply.send({ sessionId, status, refundedLegIds, outboxIds }) [sérialise]│└─ onSend idempotency -> persiste la réponse 2xx pour rejeu futurPour la suite de l’exécution (use case → domaine → outbox → workers), voir L’orchestrateur, Outbox & orchestration, Machines à états et Workers. Pour la persistance, voir Couche Infrastructure et Base de données. Vue d’ensemble : Parcours d’appel.