API interne Bricks — architecture & décisions¶
Comment sont construites les routes /internal/* qui exposent les données Bricks aux applications
consommatrices internes (hors monorepo — voir le guide d'intégration). Une route =
une tranche verticale : un contrat zod partagé, un contrôleur Nest, un repository SQL — authentifiée
par clé d'app, validée en deux temps (zod au fil, idtlt à la ligne DB) et documentée automatiquement.
Deux tests socle garantissent que toute route est authentifiée et documentée sans qu'une PR ait à les réécrire.
Sources
- Décorateur doc :
api-zod-endpoint.decorator.ts - Auth :
api-auth.service.ts - Spec OpenAPI :
setup-internal-openapi-docs.ts - Contrat partagé :
@bricks-common/api-communication-internal - Module exemple :
internal-investor/ - Garde-fous :
internal-routes-api-key.integration-test.ts·internal-openapi-docs.integration-test.ts
Les couches¶
Une requête traverse trois couches, toujours dans le même ordre. Le contrôleur ne contient aucune SQL ; le repository ne connaît aucun détail HTTP.
| Couche | Rôle | Où |
|---|---|---|
| Contrat (zod) | Forme du request/response, partagée API ↔ consommateurs |
@bricks-common/api-communication-internal |
| Contrôleur (Nest) | Auth x-api-key → parse zod → appelle le repo → mappe la réponse + décorateur doc |
internal-<domaine>/controllers/ |
| Repository (SQL) | Requête paramétrée $1 + validation idtlt de la ligne brute |
repo prod du domaine s'il existe (méthode ajoutée au besoin), sinon internal-<domaine>/repositories/ |
Réutiliser le repo prod existant
Si la table a déjà son repository (SpecialPurposeVehiculeRepository, ProjectOwnerRepository…),
la query y vit — les tests d'intégration /internal couvrent alors du code prod. Un repo
internal-* n'existe que pour les projections sans foyer naturel (ex. le détail référentiel
24 colonnes de internal-project). Lecture leniente assumée quand le validateur strict du
domaine peut échouer sur une ligne legacy (méthode dédiée, ex. getSpvSummaryByPropertyId).
flowchart TD
Req(["GET /internal/investors/:id/referral<br/>x-api-key: ..."]):::endpoint
Auth["assertApiKeyInHeadersAnyOf(headers, consumers)"]:::action
Key{"clé valide ?"}
Unauth["401 Unauthorized"]:::failure
Parse["bodySafeParse_zod<br/>params / query / body"]:::action
Ok{"zod ok ?"}
E400["400 validation-body"]:::failure
Repo["queryPgAndValidate<br/>SQL paramétré $1 + validateur idtlt"]:::action
Row{"firstRowValidator : 0 ligne ?"}
E404["404 investor-not-found"]:::failure
Map["mapping vers la forme du contrat"]:::action
Res(["réponse typée par le zod du contrat"]):::success
Req --> Auth --> Key
Key -- non --> Unauth
Key -- oui --> Parse --> Ok
Ok -- non --> E400
Ok -- oui --> Repo --> Row
Row -- oui --> E404
Row -- non --> Map --> Res
classDef endpoint fill:#4f46e5,color:#fff,stroke:#3730a3
classDef action fill:#0ea5e9,color:#fff,stroke:#0369a1
classDef success fill:#10b981,color:#fff,stroke:#047857
classDef failure fill:#ef4444,color:#fff,stroke:#b91c1c
Le 404 est opt-in
L'étape 0 ligne → 404 n'existe que sur les routes déclarées notFound (ex. referral,
identity, balances). Les collections renvoient { items: [] } et l'auto-invest { plan: null }
— une absence n'est pas une erreur.
Le contrat partagé (zod)¶
Chaque route est décrite une seule fois dans le package @bricks-common/api-communication-internal,
sous forme d'un descripteur : méthode, path (fonction des params), schémas request et response.
C'est la source unique consommée des deux côtés.
// internalInvestorReferral.endpoint.ts
export const internalInvestorReferral_zod = z.object({
referralCodeOwn: z.string().nullable(),
referrerCodeUsed: z.string().nullable(),
referrer: z
.object({ investorId: z.uuid(), email: z.string(), firstName: z.string().nullable(), lastName: z.string().nullable() })
.nullable(),
})
export const getInternalInvestorReferralEndpoint = {
request: {
method: 'GET',
path: (investorId: string) => `/internal/investors/${investorId}/referral`,
params: internalInvestorParams_zod,
},
response: internalInvestorReferral_zod,
} as const
flowchart LR
subgraph contract ["@bricks-common/api-communication-internal"]
EP["*.endpoint.ts<br/>method / path / params / query / body / response"]:::status
end
subgraph api ["API Bricks (projects/api)"]
Ctrl["Contrôleurs<br/>@ApiZodEndpoint + bodySafeParse_zod"]:::action
Doc["/internal/docs/json<br/>(protégé x-api-key)"]:::success
end
subgraph apps ["Apps consommatrices (hors monorepo)"]
Client["client typé<br/>openapi-typescript"]:::action
end
EP -->|importe| Ctrl
Ctrl -->|SwaggerModule.createDocument| Doc
Doc -->|genere| Client
EP -.->|meme contrat de bout en bout| Client
classDef action fill:#0ea5e9,color:#fff,stroke:#0369a1
classDef status fill:#fef3c7,color:#78350f,stroke:#d97706
classDef success fill:#10b981,color:#fff,stroke:#047857
Authentification — une clé par consommateur¶
ApiAuthService.assertApiKeyInHeadersAnyOf(headers, consumers) accepte la clé de n'importe
lequel des consommateurs autorisés sur la route. Comparaison en temps constant
(crypto.timingSafeEqual), et la valeur fournie n'est jamais loggée.
assertApiKeyInHeadersAnyOf(headers: unknown, apiKeyTypes: readonly InternalToolApiKeyType[]) {
const apiKeys = lift(apiKeyTypes).map(getApiKey).compact().value()
if (apiKeys.length === 0) return // clés non configurées (hors prod) → no-op
const validation = object({ 'x-api-key': notEmptyString }).map((_) => _['x-api-key']).validate(headers)
if (!validation.ok) throw new UnauthorizedException()
// NB: on ne log pas la valeur fournie pour eviter de fuiter des cles dans les logs
if (!apiKeys.some((apiKey) => isApiKeyEqual(validation.value, apiKey))) {
throw new UnauthorizedException()
}
}
getApiKey mappe chaque consommateur vers sa variable d'env via un match(...).exhaustive() —
ajouter un consommateur ne compile pas tant que son mapping n'est pas ajouté.
| Consommateur (type doc) | Variable d'env |
|---|---|
ticketing |
TICKETING_API_KEY_INTERNAL |
ia-whatsapp |
IA_WHATSAPP_API_KEY_INTERNAL |
gestion-defauts |
GESTION_DEFAUTS_API_KEY_INTERNAL |
projet-analyse |
PROJET_ANALYSE_API_KEY_INTERNAL |
ai-agent |
AI_AGENT_API_KEY_INTERNAL |
project-owner-invoicing |
PROJECT_OWNER_INVOICING_API_KEY_INTERNAL |
Une const, deux usages → zéro drift auth/doc
Les listes d'apps autorisées sont des constantes uniques (internal-investor-consumers.ts),
réutilisées telles quelles par l'assert runtime et par le décorateur OpenAPI. Impossible que
la doc dise « ticketing » pendant que le runtime accepte autre chose.
Doc OpenAPI auto-générée¶
Un seul décorateur, @ApiZodEndpoint(endpoint, { consumers, notFound?, successStatus? }), attache
toute la doc d'une route depuis son descripteur : la liste des apps autorisées (dans la description),
le 401, le schéma de réponse de succès (converti via z.toJSONSchema), les params/query/body, et le
404 optionnel. successStatus par défaut à 200 ; le passer à 201 pour un POST qui crée une
ressource (le code réel Nest par défaut sur @Post).
export const ApiZodEndpoint = (endpoint: EndpointDoc, opts: EndpointDocOpts) =>
applyDecorators(
ApiOperation({ description: `Apps autorisées : ${opts.consumers.join(', ')}` }),
ApiUnauthorizedResponse({ description: 'x-api-key absente ou invalide' }),
ApiResponse({ status: opts.successStatus ?? 200, schema: toOpenApi(endpoint.response) }),
...(endpoint.request.body == null ? [] : [ApiBody({ schema: toOpenApi(endpoint.request.body) })]),
...perPropertyDecorators(endpoint.request.query, (name, propSchema, required) =>
ApiQuery({ name, required, schema: propSchema })),
...perPropertyDecorators(endpoint.request.params, (name, propSchema) =>
ApiParam({ name, schema: propSchema })),
...(opts.notFound == null ? [] : [ApiNotFoundResponse({ description: opts.notFound })]),
)
La spec est assemblée par setup-internal-openapi-docs.ts : buildInternalOpenApiDocument appelle
SwaggerModule.createDocument une fois au boot, filtre les paths par préfixe /internal
et mémorise le document — aucun couplage à des modules Nest. Il est
ensuite servi en JSON sur /internal/docs/json par InternalCoreController
(@Get('docs/json') + @ApiExcludeEndpoint() — l'endpoint ne se documente pas lui-même),
derrière la même clé x-api-key que les routes — le contrat n'est pas public. Pas de page HTML, pas de
dépendance @fastify/static.
Nouveau contrôleur → controllers: [] de AppModule
Un contrôleur /internal/* enregistré dans app.module.ts apparaît automatiquement dans la doc
(filtrage par préfixe de path). C'est le seul geste manuel — et le test de
parité ci-dessous échoue avec un message explicite si @ApiZodEndpoint manque.
Validation en deux temps¶
Deux validateurs, deux frontières, deux libs :
- zod valide le fil (params/query/body entrants, schéma de réponse) — c'est le contrat partagé, et la seule lib qui sait s'exporter en OpenAPI.
- idtlt valide la ligne SQL brute côté API uniquement — cohérent avec le reste du repo, et
fail-fast au boundary DB : une colonne renommée fait échouer la requête au lieu de propager un
undefinedsilencieux.
// queryPgAndValidate : SQL paramétré + validation idtlt, throw si la forme ne colle pas
const pgResult = await getAppDataSource().query(query, queryParams)
const validation = validator.validate(pgResult)
if (!validation.ok) { Logger.error('Query validation failed', { query, queryParams }); throw new Error('query-validation-failed') }
return validation.value
// firstRowValidator : extrait la 1re ligne, ou `undefined` si 0 ligne (→ le contrôleur teste `== null` → 404)
export const firstRowValidator = <T>(validator: Validator<T>) => array(validator).map((_) => _[0] ?? undefined)
Anatomie d'un module¶
Exemple internal-investor :
internal-investor/
├─ controllers/
│ ├─ internal-investor-consumers.ts ← consts d'apps autorisées (source unique)
│ ├─ internal-investor.controller.ts ← resolve, search, identity, balances, referral…
│ ├─ internal-investor-portfolio.controller.ts ← portfolio, projects, transactions
│ └─ __tests__/ ← tests d'intégration métier
├─ repositories/
│ ├─ internal-investor.repository.ts ← SQL paramétré + validateurs idtlt
│ └─ internal-investor-portfolio.repository.ts
└─ model/
└─ internal-investor.model.ts ← validateurs idtlt réutilisés
Pourquoi deux contrôleurs / deux repos ?
La limite 300 lignes/fichier : le périmètre investor (12 routes) est scindé en
internal-investor + internal-investor-portfolio, contrôleur et repo en miroir. Un validateur
idtlt utilisé une seule fois vit inline dans le repo ; réutilisé, il monte dans model/.
Une tranche verticale complète, du contrat à la ligne SQL :
@Get(':investorId/referral')
@ApiZodEndpoint(getInternalInvestorReferralEndpoint, {
consumers: ticketingConsumers,
notFound: 'Investisseur introuvable',
})
async referral(@Headers() headers: unknown, @Param() params: unknown): Promise<InternalInvestorReferral> {
ApiAuthService.assertApiKeyInHeadersAnyOf(headers, ticketingConsumers)
const parsed = bodySafeParse_zod(getInternalInvestorReferralEndpoint.request.params, params)
if (!parsed.ok) return throwApiError(parsed)
const referral = await InternalInvestorRepository.findReferral(parsed.value.investorId)
if (referral == null) throw new NotFoundException('investor-not-found')
return {
referralCodeOwn: referral.referralCodeOwn,
referrerCodeUsed: referral.referrerCodeUsed,
referrer:
referral.parrainId == null || referral.parrainEmail == null
? null
: { investorId: referral.parrainId, email: referral.parrainEmail, firstName: referral.parrainFirstName, lastName: referral.parrainLastName },
}
}
async findReferral(investorId: UUID) {
return queryPgAndValidate({
query: `
SELECT rl_own.code AS "referralCodeOwn", rl_used.code AS "referrerCodeUsed",
referrer.id::text AS "referrerId", referrer.email AS "referrerEmail",
referrer_profile."firstName" AS "referrerFirstName", referrer_profile."lastName" AS "referrerLastName"
FROM customers c
LEFT JOIN "referral-link" rl_used ON rl_used.id = c."referrerLinkId"
LEFT JOIN customers referrer ON referrer.id = rl_used."customerId"
LEFT JOIN customer_profile referrer_profile ON referrer_profile."customerId" = referrer.id
LEFT JOIN "referral-link" rl_own ON rl_own."customerId" = c.id
WHERE c.id = $1 AND c."deletedAt" IS NULL
LIMIT 1
`,
queryParams: [investorId],
validator: firstRowValidator(
object({
referralCodeOwn: string.nullable().map(mapNullableToUndefined),
referrerCodeUsed: string.nullable().map(mapNullableToUndefined),
referrerId: uuid.nullable().map(mapNullableToUndefined),
referrerEmail: string.nullable().map(mapNullableToUndefined),
referrerFirstName: string.nullable().map(mapNullableToUndefined),
referrerLastName: string.nullable().map(mapNullableToUndefined),
}),
),
})
}
Le SQL cible des tables legacy
Le code et le contrat parlent investor/project, mais le schéma DB porte encore les noms
historiques (customers, customer_profile, properties, payment_methods…) — d'où le
FROM customers ci-dessus. Dette assumée, expliquée dans Décisions & FAQ.
Segments — SQL opérateur¶
Cas à part du socle : /internal/segments (module internal-segment) exécute du SQL libre écrit
par les opérateurs marketing (segments de broadcasts/campagnes du ticketing), pas des requêtes
figées. Trois routes consolidées :
| Route | Remplace (ticketing) | Corps | Réponse |
|---|---|---|---|
POST /internal/segments/recipients |
runSegmentQuery, runEmailSegmentQuery, runCampaignQuery |
{ query, channel: 'phone'\|'email' } |
{ items: [{ investorId, email?, phone?, firstName?, lastName? }] } |
POST /internal/segments/count |
countSegmentRecipients |
{ query, recipientColumn } |
{ count } |
POST /internal/segments/preview |
testQuery |
{ query, sampleLimit?, recipientColumn? } |
{ columns, rows, totalCount, recipientCount? } |
Contrat de sortie d'un segment (route /recipients) : les 5 colonnes firstName, lastName, email, phone,
customerId (le wrapper les sélectionne explicitement — une colonne manquante échoue côté PG avec un
message clair). La réponse parle investorId (mapping depuis customerId, vocabulaire legacy).
Modèle de sécurité — le rôle PG read-only est LA frontière (consensus Metabase/PostgREST, validé en MAR-284) :
- Rôle PG read-only dédié (
DATABASE_USER_RO/DATABASE_PASS_RO, requis au boot) : SELECT-only, non-superuser,default_transaction_read_only=on, grants par table côté DevOps. Aucune écriture ne passe, quelle que soit la requête (WITH purge AS (DELETE …) SELECT …→permission denied). Aucun parsing SQL en code. - Pool dédié (
getReadOnlyPgPool) : cible le premierDATABASE_SLAVES(read replica = isolation de charge) sinon le master ;max: 5hors budget du pool applicatif ; mêmestatement_timeout. - Accès via
queryPgAndValidate({ readOnly: true }); pour le preview qui a besoin des column names,{ readOnly: true, includeColumns: true }renvoie{ rows, columns }(les.fields[]de pg, indispensables sur empty result). recipientColumnest contraint par regex au contrat et quoté à l'interpolation.
Contrat d'erreur : les échecs du SQL opérateur sont des résultats attendus, jamais des 500 —
invalid-segment-sql (toute DatabaseError pg : syntaxe, colonne inconnue, timeout, permission,
violation read-only — message pg transmis pour le debug opérateur), segment-contract-mismatch
(lignes hors contrat 5 colonnes). Le SQL opérateur est toujours encapsulé en derived table,
parenthèse fermante sur sa propre ligne (un -- commentaire final la commenterait).
Garde-fous transverses¶
Deux tests d'intégration vivent dans le module socle (internal-core) et balayent toutes les
routes /internal/* enregistrées. Conséquence : une PR qui ajoute une route n'écrit aucun test
d'auth ni de doc — le socle les impose globalement. (Le helper extractInternalRoutes lit l'arbre
printRoutes de Fastify et exclut /internal/docs : cette route est protégée par clé mais ne se
documente pas elle-même — son auth et sa forme sont couvertes par le test internal-openapi-docs.)
const routes = extractInternalRoutes(app.printRoutes({ commonPrefix: false }))
expect(routes.length).toBeGreaterThanOrEqual(12) // 12 routes investor (P1–P5)
for (const route of routes) {
const path = route.path.replace(/:[^/]+/g, randomUUID()) // l'assert clé tourne avant toute validation de param
const res = await req()[route.method](path)
expect(res.status).toBe(401)
}
const res = await req().get('/internal/docs/json').set('x-api-key', ticketingKey())
for (const route of routes) {
const docPath = route.path.replace(/:([^/]+)/g, '{$1}')
expect(
res.body.paths?.[docPath]?.[route.method],
`${route.method.toUpperCase()} ${docPath} must be documented — add @ApiZodEndpoint on the handler`,
).toBeDefined()
}
// + vérifie le schéma de sécurité x-api-key et la description « Apps autorisées : … »
Si la parité casse
Message type : « … must be documented — add @ApiZodEndpoint on the handler ». Neuf fois
sur dix : le décorateur @ApiZodEndpoint oublié sur le handler.
Ajouter une route¶
- Contrat — un
*.endpoint.tsdansapi-communication-internal/src/endpoints/(request+responsezod), exporté depuisendpoints/index.ts. Réutiliser les primitives existantes (internalInvestorParams_zod,isoDate_zod,investorContractType_zod…) plutôt qu'en créer. - Repository — une méthode SQL paramétrée
$1passée àqueryPgAndValidate, avec un validateur idtlt (inline si usage unique, sinon dansmodel/). - Contrôleur — un handler :
assertApiKeyInHeadersAnyOf(headers, <consumers>)→bodySafeParse_zod→ repo → mapping, décoré de@ApiZodEndpoint(endpoint, { consumers, notFound? }). - Nouveau contrôleur seulement — l'ajouter au tableau
controllers: []deapp.module.ts(la doc OpenAPI le découvre par préfixe de path, rien d'autre à déclarer). - Tests — les deux transverses couvrent déjà auth + doc ; ajouter un test d'intégration pour la logique métier (cf. tests d'intégration).
Décisions & FAQ¶
Le « pourquoi » des choix propres à ce chantier (les conventions d'équipe déjà établies — fixtures
.build/.seed, NestJS, __new… — ne sont pas reprises ici).
Vocabulaire & découpage¶
Pourquoi investor/project, alors que le SQL dit encore customers/properties ?
investor/project sont le vocabulaire actuel de l'app ; customer/property sont legacy, encore
présents dans le schéma DB. On aligne la surface (contrat + code) maintenant ; renommer la DB est une
dette assumée, chantier séparé. D'où le FROM customers dans les repos.
Pourquoi un package api-communication-internal séparé (pas dans l'app, ni dans api-communication) ?
Le contrat zod est la source unique partagée API↔consommateurs : il doit être importable côté client, donc
hors de l'app. Séparé du package front/BO pour ne pas le charger de schémas internes (il en dépend tout de
même pour les primitives uuid/isoDate_zod).
Pourquoi un module internal-<domaine> par domaine, à plat ?
Trop d'endpoints pour un module unique. Découpe par domaine (axe stable), pas par consommateur — un
consommateur touche plusieurs domaines et un endpoint sert plusieurs consommateurs. internal, pas
marketing : le support et d'autres outils internes consomment aussi.
Validation & accès aux données¶
Pourquoi deux libs de validation (zod et idtlt) ?
La nouveauté ici, c'est zod au contrat — seule lib qui s'exporte en OpenAPI (z.toJSONSchema). À la
ligne SQL, on garde idtlt, le pattern habituel de l'API (fail-fast au boundary DB). Détail : section
Validation en deux temps.
Pourquoi du SQL brut + queryPgAndValidate, pas un ORM ni Kysely ?
$1 paramétré (anti-injection), reprise des requêtes legacy telles quelles, et validation idtlt de la
forme. Pas Kysely : une dépendance de plus qui aurait élargi le chantier bien au-delà du raisonnable.
TypeORM, lui, est en décommissionnement.
Sécurité & documentation¶
Pourquoi l'autorisation par clé est-elle déclarée par endpoint ?
Chaque route déclare ses consumers autorisés, via une const réutilisée par l'assert runtime et le
décorateur doc (zéro drift). Tous les consommateurs n'ont pas accès à tout.
Pourquoi une doc OpenAPI auto-générée ?
Quasi gratuite (un seul @ApiZodEndpoint), et elle laisse les consommateurs générer un client typé →
contrats à jour, intégration rapide. Zéro drift code↔doc, garanti par le test de parité.
Pourquoi /internal/docs/json est-il protégé (et le YAML supprimé) ?
Le contrat révèle tous les chemins, schémas et apps autorisées : il n'est pas public. Un seul format (JSON) ; viewer local côté consommateur si besoin.
Tests¶
Pourquoi des tests d'intégration ?
On suit la reco d'équipe : coût faible, filet de sécurité. Ils valident le contrat de bout en bout (auth + parse + vrai Postgres via testcontainers/Flyway + mapping). Bénéfice annexe : on construit au passage les fixtures (investor/project) que réutiliseront les futurs endpoints.
Pourquoi deux tests « socle » transverses ?
Pour qu'une PR de route n'ait aucun test d'auth ni de doc à écrire : le sweep 401 et la parité route↔doc s'appliquent globalement (voir Garde-fous transverses).
Gotchas¶
À connaître
- Nouveau contrôleur → l'ajouter à
controllers: []dansapp.module.ts;@ApiZodEndpointobligatoire sur chaque handler, sinon parité rouge. - 300 lignes/fichier → split contrôleur + repo (cf.
internal-investor/internal-investor-portfolio). - Montants toujours en cents (
int), cast::inten SQL — jamais de float. firstRowValidatorrenvoieundefined(pasnull) sur 0 ligne → le contrôleur teste== null(attrape les deux) → 404.notFoundest opt-in : ne le déclarer que sur les routes qui peuvent vraiment 404 ; une collection vide reste un 200.- Jamais logguer la valeur d'une
x-api-key.