Audit — pilier data-platform
Premier tour de la rotation trimestrielle (docs/team/roster.md, @codebase-auditor). Douze piliers n'avaient aucun audit au dossier ; data-platform est le premier dans l'ordre du roster. Lecture seule sur le code. Aucun…
Premier tour de la rotation trimestrielle (
docs/team/roster.md, @codebase-auditor). Douze piliers n'avaient aucun audit au dossier ;data-platformest le premier dans l'ordre du roster.Lecture seule sur le code. Aucun fichier de
src/ouprisma/n'est modifie par cet audit : l'auditeur qui corrige devient juge et partie. Ce document et les trois items de backlog sont la sortie complete.Perimetre :
prisma/**,src/services/database/**,src/lib/core/**,src/lib/cache/**,src/lib/utils|format|severity|storage/**,src/app/api/admin/db/**,scripts/generate-schema-guard.mjs,scripts/marketplace-fts-index.mjs.Etat du depot :
origin/maina49c783c7, 25 aout 2026.
Ce qui a ete verifie
| Sonde | Resultat |
|---|---|
Relations tenant (storeId / orgId / userId) sans onDelete explicite | 0 sur 76 — 69 Cascade, 7 SetNull |
Colonnes SetNull non nullables (incoherence qui casse la suppression) | 0 sur 7 |
| Cles etrangeres sans index couvrant | 4 sur ~180 |
| Paires d'index dont l'un est le prefixe de l'autre | 12 |
Tables citees dans CLAUDE.md absentes du schema | 0 sur 28 |
Modeles cites en prose dans CLAUDE.md et inexistants | 0 |
Instanciations de PrismaClient en production | 1 (le singleton, le reste est en specs d'integration) |
/api/admin/db derriere une garde admin | oui — withAdminRoute |
pnpm deadcode (knip) sur le perimetre | sort 0 ; exports inutilises listes ci-dessous |
Le schema est en bon etat. 147 modeles, 47 enums, et aucune relation tenant ne laisse son comportement de suppression au hasard : c'est le resultat le plus important de cet audit et il est bon. Les constats ci-dessous sont reels mais aucun n'est une urgence.
Constat 1 — le cache d'un membre retire n'est jamais invalide
Severite : moyenne. → backlog/data-platform/0047
invalidateOrgCache (src/lib/cache/me-cache.ts:101)
liste les membres a purger en interrogeant la base :
prisma.organizationMember.findMany({ where: { orgId }, select: { userId: true } })
Les deux chemins qui retirent un membre suppriment la ligne avant d'appeler cette fonction :
| Chemin | Sequence |
|---|---|
DELETE /api/organizations/[orgId]/members/[memberId] | organizationMember.delete (l.134) → invalidateOrgCache (l.150) |
POST /api/organizations/[orgId]/leave | organizationMember.delete (l.58) → invalidateOrgCache (l.74) |
Au moment de l'appel, l'utilisateur retire n'est plus dans le
findMany. Sa cle me:<sha>:<userId> n'est donc jamais supprimee, et son
/api/me continue de renvoyer l'organisation pendant la duree du TTL :
30 secondes (CACHE_TTL_SECONDS, src/app/api/me/route.ts:41).
C'est la forme classique du defaut : invalider en demandant a la base qui est concerne, apres avoir modifie la base. Le seul utilisateur qu'il faut purger est precisement celui que la requete ne trouve plus.
Ce que ce n'est pas. Pas une elevation de privilege. L'autorisation est
re-derivee a chaque requete par getOrgAccess, pas lue depuis ce cache : un
membre retire ne peut rien faire dans l'organisation. Ce qui persiste est le
payload, son interface continue de lister l'org pendant 30 s.
Pourquoi ca merite quand meme un item. Le proprietaire est couvert (la
fonction ajoute Organization.ownerId explicitement), ce qui montre que le
cas « quelqu'un que le findMany ne renvoie pas » a deja ete rencontre et
traite une fois : pour le proprietaire, pas pour le membre retire. La correction est
du meme ordre : passer l'id concerne au lieu de le redemander.
Constat 2 — un fichier orphelin fabrique des secrets, et son nom masque le vrai
Severite : moyenne (risque latent, aucune exploitation actuelle).
→ backlog/data-platform/0048
src/lib/utils/id.ts n'est importe nulle
part : aucun import dans src/, aucun barrel ne le re-exporte. Il
definit trois fonctions, dont deux fabriquent des secrets.
Depuis : le fichier a ete supprime, donc ce nom n'est plus un lien. Le constat reste tel quel — c'est un compte rendu date, pas une page tenue a jour — et seul le renvoi mort a ete retire (
platform-ops/0071).
Le probleme n'est pas qu'il soit mort. C'est que generateApiKey existe
deux fois dans le depot, sous le meme nom, avec deux contrats opposes :
lib/utils/id.ts:11 (orphelin) | lib/security/api-keys.ts:170 (le vrai) | |
|---|---|---|
| Signature | generateApiKey(storeID) | generateApiKey(type, entityId) |
| Retour | une chaine nue | { key, keyHash, keyPrefix } |
| Hash | aucun | hashApiKey(key) |
| Entropie | crypto.randomUUID() | generateRandomBytes(24) |
| Teste | non | oui (api-keys.test.ts) |
Exporte par lib/security/index.ts | non | oui |
| Appele | personne | api-keys.ts:227 |
Le vrai renvoie un hash parce que le secret en clair ne doit jamais etre
stocke. L'orphelin renvoie la chaine seule : quiconque l'autocomplete et
la persiste ecrit un secret en clair, sans qu'aucune revue ne voie passer un
appel a hashApiKey.
generateWebhookSecret() a le meme defaut d'une autre facon : elle fabrique
un whsec_<hex>, alors que dans ce depot les whsec_ sont fournis par
Svix/Resend et seulement consommes
(src/modules/email/webhook-signature.ts:72),
ou ils sont decodes en base64. Un secret genere ici ne serait verifie par
rien.
Du code mort est du poids. Du code mort qui ressemble a la facon sanctionnee de fabriquer une credential est un piege.
Constat 3 — douze index qui coutent des ecritures et ne servent rien
Severite : faible. → backlog/data-platform/0049
Un index sur [a] est entierement servi par un index sur [a, b] : le
court ne repond a aucune requete que le long ne repond pas, et il est
maintenu a chaque INSERT / UPDATE. Douze paires de ce type :
| Modele | Redondant | Deja couvert par |
|---|---|---|
Message | [conversationId] | [conversationId, createdAt] |
AuditLog | [orgId] | [orgId, createdAt] |
Credit | [orgId] | [orgId, expiresAt] |
DevStorePool | [status] | [status, transferRequestedAt] et [status, lastCheckedAt] |
MarketplaceListing | [sellerId] | [sellerId, status, publishedAt] |
Offer | [listingId, status] | [listingId, status, createdAt] |
DealThread | [sellerId], [buyerId] | […, status, createdAt] |
Order | [buyerId], [listingId] | […, status, …] |
ProductPerformanceDaily | [storeId, day] | [storeId, day, revenueCents] |
Verifie colonne par colonne : dans les douze cas la colonne de tete est en
ASC des deux cotes, les sort: Desc presents portent sur une colonne
suivante, ce qui ne change pas la capacite du composite a servir une
egalite sur la tete. La redondance tient.
Ou ca coute vraiment : Message et AuditLog sont des tables en append
quasi continu.
Contrainte a connaitre avant d'ouvrir le sujet. Supprimer un index est
du DDL destructif, et le schema guard ne fait jamais que de l'additif
(schema-guard.ts, invariant declare). Ce n'est donc pas un changement
qu'un deploy peut porter : c'est une migration operateur deliberee. C'est ce
qui rend l'item petit en code et non trivial en execution.
Repli inclus dans le meme item : quatre cles etrangeres n'ont aucun
index dont elles soient la colonne de tete, AffiliateRedemption.orgId,
PhaseTransition.actorId, RoadmapVote.userId, StoreNote.fromReport.
Aucune requete du depot ne filtre dessus ; le cout est uniquement sur le
DELETE du parent (Postgres doit scanner la table enfant pour verifier la
contrainte). PhaseTransition et RoadmapVote pendent a User, donc sur
le chemin de /api/auth/delete-account. Meme famille, meme migration.
Releve, sans item
Deux constats reels que je n'ouvre pas, pour ne pas gonfler le backlog avec ce qui ne merite pas une PR :
- Deux portes pour le meme module.
dbetcreateDatabasesont accessibles par@/services/databaseet par@/services/database/client(un barrel de 8 lignes). Les appelants se partagent les deux ;createDatabasere-exporte parclient.tsn'est pris par personne. Aucune consequence fonctionnelle : a fusionner le jour ou quelqu'un touche ce fichier pour une autre raison. knip.jsonporte un motif d'entree redondant pourprisma/seed.ts(deja couvert par un autre motif). Une ligne de configuration.
Ce que cet audit n'a pas couvert
Dit explicitement, pour que le prochain tour sache ou reprendre :
- le comportement runtime du cache :
visitor-stats.tsetkv.tsont ete lus, pas exerces ; aucune sonde sur les TTL reels ni sur le comportement quand Redis est absent ; prisma-provider.ts(602 lignes) — la plus grosse surface non generee du pilier, parcourue seulement pour verifier l'instanciation du client ;- les performances reelles — aucune mesure, aucun
EXPLAIN. Les constats d'index sont structurels, pas mesures.
Prochain pilier de la rotation : security-identity. Il devient plus
urgent qu'avant : le chantier Studio vient d'ajouter 11 permissions
studio.* et 4 roles preconfigures, et personne n'a jamais audite si ces
gardes tiennent, alors que trois personnes exterieures se connectent
desormais a l'app avec.