@nodefony/user 10.0.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/LICENSE +544 -0
  2. package/README.md +120 -0
  3. package/dist/index.js +16 -0
  4. package/dist/nodefony/contracts/IOAuthUserProvisioner.js +1 -0
  5. package/dist/nodefony/contracts/IPasswordBlocklist.js +1 -0
  6. package/dist/nodefony/contracts/IPasswordEncoder.js +1 -0
  7. package/dist/nodefony/contracts/IPasswordVerifier.js +1 -0
  8. package/dist/nodefony/contracts/IUser.js +1 -0
  9. package/dist/nodefony/contracts/IUserProfile.js +1 -0
  10. package/dist/nodefony/contracts/IUserProvider.js +1 -0
  11. package/dist/nodefony/contracts/IUserRepository.js +1 -0
  12. package/dist/nodefony/contracts/index.js +1 -0
  13. package/dist/nodefony/errors/UserNotFoundError.js +19 -0
  14. package/dist/nodefony/errors/WeakPasswordError.js +16 -0
  15. package/dist/nodefony/service/UserService.js +280 -0
  16. package/dist/nodefony/src/AnonymousUser.js +37 -0
  17. package/dist/nodefony/src/BaseUser.js +121 -0
  18. package/dist/nodefony/src/InMemoryUserRepository.js +230 -0
  19. package/dist/nodefony/src/admin/UserAdminApi.js +588 -0
  20. package/dist/nodefony/src/encoders/Argon2idEncoder.js +107 -0
  21. package/dist/nodefony/src/encoders/BcryptEncoder.js +75 -0
  22. package/dist/nodefony/src/encoders/MigratingEncoder.js +88 -0
  23. package/dist/nodefony/src/encoders/encoderFromConfig.js +37 -0
  24. package/dist/nodefony/src/userContract.js +247 -0
  25. package/dist/nodefony/src/userFilters.js +66 -0
  26. package/dist/nodefony/src/userProfile.js +195 -0
  27. package/dist/nodefony/src/userSort.js +53 -0
  28. package/dist/nodefony/src/userStoreRegistry.js +41 -0
  29. package/dist/types/index.d.ts +40 -0
  30. package/dist/types/nodefony/contracts/IOAuthUserProvisioner.d.ts +69 -0
  31. package/dist/types/nodefony/contracts/IPasswordBlocklist.d.ts +20 -0
  32. package/dist/types/nodefony/contracts/IPasswordEncoder.d.ts +49 -0
  33. package/dist/types/nodefony/contracts/IPasswordVerifier.d.ts +26 -0
  34. package/dist/types/nodefony/contracts/IUser.d.ts +68 -0
  35. package/dist/types/nodefony/contracts/IUserProfile.d.ts +29 -0
  36. package/dist/types/nodefony/contracts/IUserProvider.d.ts +44 -0
  37. package/dist/types/nodefony/contracts/IUserRepository.d.ts +129 -0
  38. package/dist/types/nodefony/contracts/index.d.ts +7 -0
  39. package/dist/types/nodefony/errors/UserNotFoundError.d.ts +15 -0
  40. package/dist/types/nodefony/errors/WeakPasswordError.d.ts +12 -0
  41. package/dist/types/nodefony/service/UserService.d.ts +179 -0
  42. package/dist/types/nodefony/src/AnonymousUser.d.ts +27 -0
  43. package/dist/types/nodefony/src/BaseUser.d.ts +98 -0
  44. package/dist/types/nodefony/src/InMemoryUserRepository.d.ts +73 -0
  45. package/dist/types/nodefony/src/admin/UserAdminApi.d.ts +119 -0
  46. package/dist/types/nodefony/src/encoders/Argon2idEncoder.d.ts +83 -0
  47. package/dist/types/nodefony/src/encoders/BcryptEncoder.d.ts +55 -0
  48. package/dist/types/nodefony/src/encoders/MigratingEncoder.d.ts +68 -0
  49. package/dist/types/nodefony/src/encoders/encoderFromConfig.d.ts +36 -0
  50. package/dist/types/nodefony/src/userContract.d.ts +198 -0
  51. package/dist/types/nodefony/src/userFilters.d.ts +80 -0
  52. package/dist/types/nodefony/src/userProfile.d.ts +56 -0
  53. package/dist/types/nodefony/src/userSort.d.ts +41 -0
  54. package/dist/types/nodefony/src/userStoreRegistry.d.ts +15 -0
  55. package/docs/ajouter-des-champs.md +189 -0
  56. package/docs/index.md +1113 -0
  57. package/package.json +90 -0
@@ -0,0 +1,75 @@
1
+ //#region nodefony/src/encoders/BcryptEncoder.ts
2
+ let bcrypt = null;
3
+ const loadBcrypt = async () => bcrypt ??= await import("@node-rs/bcrypt");
4
+ /** Coût bcrypt par défaut — 2^12 itérations (recommandation OWASP courante). */
5
+ const DEFAULT_ROUNDS = 12;
6
+ const BCRYPT_HASH_RE = /^\$2[aby]\$(\d{2})\$/;
7
+ /**
8
+ * Encodeur de mot de passe **bcrypt** — implémentation de référence d'{@link IPasswordEncoder}.
9
+ *
10
+ * Délègue à `@node-rs/bcrypt` (binding NAPI Rust) : hachage/vérification réellement
11
+ * asynchrones (exécutés hors thread principal), ne bloquent pas la boucle
12
+ * d'événements même au coût 12. `@node-rs/bcrypt` est une **peerDependency
13
+ * optionnelle** : seules les applications qui authentifient par mot de passe local
14
+ * la tirent ; un consommateur qui n'importe que `IUser`/`BaseUser` ne charge jamais
15
+ * ce module ni le binaire natif.
16
+ *
17
+ * @remarks Le binding natif est importé DYNAMIQUEMENT au premier `hash`/`verify`
18
+ * (instancier l'encodeur ne charge rien — la peerDep optionnelle n'est requise
19
+ * qu'au premier usage réel). Le coût est paramétrable au constructeur pour
20
+ * permettre un re-hash progressif via {@link BcryptEncoder.needsRehash}.
21
+ */
22
+ var BcryptEncoder = class {
23
+ /** Coût bcrypt utilisé pour produire les nouveaux hashs. */
24
+ rounds;
25
+ /**
26
+ * @param rounds - coût bcrypt (log2 des itérations). Entier dans `[4, 31]`.
27
+ * @throws {RangeError} si `rounds` est hors de l'intervalle valide.
28
+ */
29
+ constructor(rounds = DEFAULT_ROUNDS) {
30
+ if (!Number.isInteger(rounds) || rounds < 4 || rounds > 31) throw new RangeError(`BcryptEncoder: rounds must be an integer in [4, 31], got ${rounds}`);
31
+ this.rounds = rounds;
32
+ }
33
+ /**
34
+ * Reconnaît un hash bcrypt (`$2a$`/`$2b$`/`$2y$` + coût) — parsing pur, sync.
35
+ *
36
+ * @param hash - hash stocké à inspecter.
37
+ * @returns `true` si le hash est au format bcrypt.
38
+ */
39
+ supports(hash) {
40
+ return BCRYPT_HASH_RE.test(hash);
41
+ }
42
+ /**
43
+ * Hache un mot de passe en clair (sel généré et inclus dans la sortie).
44
+ *
45
+ * @param plain - mot de passe en clair.
46
+ * @returns le hash bcrypt à persister.
47
+ */
48
+ async hash(plain) {
49
+ return (await loadBcrypt()).hash(plain, this.rounds);
50
+ }
51
+ /**
52
+ * Vérifie un mot de passe en clair contre un hash stocké (temps constant interne).
53
+ *
54
+ * @param plain - mot de passe fourni à la connexion.
55
+ * @param hash - hash bcrypt stocké.
56
+ * @returns `true` si la correspondance est valide.
57
+ */
58
+ async verify(plain, hash) {
59
+ return (await loadBcrypt()).verify(plain, hash);
60
+ }
61
+ /**
62
+ * Indique si un hash doit être recalculé (coût stocké inférieur au coût courant,
63
+ * ou format non bcrypt).
64
+ *
65
+ * @param hash - hash stocké à inspecter.
66
+ * @returns `true` si un re-hash est recommandé au prochain login réussi.
67
+ */
68
+ needsRehash(hash) {
69
+ const match = BCRYPT_HASH_RE.exec(hash);
70
+ if (match === null) return true;
71
+ return Number.parseInt(match[1], 10) < this.rounds;
72
+ }
73
+ };
74
+ //#endregion
75
+ export { BcryptEncoder };
@@ -0,0 +1,88 @@
1
+ //#region nodefony/src/encoders/MigratingEncoder.ts
2
+ /**
3
+ * Encodeur composite de **migration d'algorithme** — accepte les hashs legacy à
4
+ * la vérification, ne produit que des hashs au format courant.
5
+ *
6
+ * Une base existante (ex. bcrypt) ne peut pas être convertie hors-ligne : les
7
+ * mots de passe n'existent qu'au moment du login. Ce composite fait la
8
+ * transition sans rien casser :
9
+ *
10
+ * - {@link hash} → toujours l'encodeur PRINCIPAL (le format cible) ;
11
+ * - {@link verify} → routé vers le premier encodeur qui {@link IPasswordEncoder.supports | reconnaît}
12
+ * le hash stocké (principal d'abord, puis legacy dans l'ordre) ;
13
+ * - {@link needsRehash} → `true` dès que le hash n'est pas au format principal
14
+ * → `UserService.authenticate` re-hashe au prochain login réussi (seul moment
15
+ * où le clair existe) = migration transparente, comptes legacy compris.
16
+ *
17
+ * Exemple — migration bcrypt → argon2id :
18
+ * ```ts
19
+ * new MigratingEncoder(new Argon2idEncoder(), [new BcryptEncoder()]);
20
+ * ```
21
+ * Le jour où plus aucun hash bcrypt ne subsiste, retirer le legacy.
22
+ */
23
+ var MigratingEncoder = class {
24
+ /** Encodeur cible — produit tous les nouveaux hashs. */
25
+ primary;
26
+ /** Encodeurs acceptés en lecture seule pendant la migration. */
27
+ legacy;
28
+ /**
29
+ * @param primary - encodeur cible (format des nouveaux hashs).
30
+ * @param legacy - encodeurs des formats encore présents en base (vérification seule).
31
+ */
32
+ constructor(primary, legacy) {
33
+ this.primary = primary;
34
+ this.legacy = legacy;
35
+ }
36
+ /**
37
+ * Reconnaît un hash si l'UN des encodeurs (principal ou legacy) le reconnaît.
38
+ *
39
+ * @param hash - hash stocké à inspecter.
40
+ * @returns `true` si un membre du composite sait vérifier ce hash.
41
+ */
42
+ supports(hash) {
43
+ return this.#resolve(hash) !== null;
44
+ }
45
+ /**
46
+ * Hache avec l'encodeur PRINCIPAL uniquement (jamais un format legacy).
47
+ *
48
+ * @param plain - mot de passe en clair.
49
+ * @returns le hash au format cible.
50
+ */
51
+ async hash(plain) {
52
+ return this.primary.hash(plain);
53
+ }
54
+ /**
55
+ * Vérifie en routant vers l'encodeur dont le format correspond au hash stocké.
56
+ *
57
+ * Hash d'un format inconnu de tous → `false` (jamais d'erreur : un credential
58
+ * invérifiable est un credential invalide — l'égalisation de temps des chemins
59
+ * d'échec reste portée par `UserService`).
60
+ *
61
+ * @param plain - mot de passe fourni à la connexion.
62
+ * @param hash - hash stocké (format courant ou legacy).
63
+ * @returns `true` si la correspondance est valide.
64
+ */
65
+ async verify(plain, hash) {
66
+ const encoder = this.#resolve(hash);
67
+ if (encoder === null) return false;
68
+ return encoder.verify(plain, hash);
69
+ }
70
+ /**
71
+ * Recommande un re-hash si le hash n'est pas au format principal (migration),
72
+ * ou si le principal lui-même le juge obsolète (coûts augmentés).
73
+ *
74
+ * @param hash - hash stocké à inspecter.
75
+ * @returns `true` si un re-hash est recommandé au prochain login réussi.
76
+ */
77
+ needsRehash(hash) {
78
+ if (!this.primary.supports(hash)) return true;
79
+ return this.primary.needsRehash(hash);
80
+ }
81
+ #resolve(hash) {
82
+ if (this.primary.supports(hash)) return this.primary;
83
+ for (const encoder of this.legacy) if (encoder.supports(hash)) return encoder;
84
+ return null;
85
+ }
86
+ };
87
+ //#endregion
88
+ export { MigratingEncoder };
@@ -0,0 +1,37 @@
1
+ import { BcryptEncoder } from "./BcryptEncoder.js";
2
+ import { Argon2idEncoder } from "./Argon2idEncoder.js";
3
+ import { MigratingEncoder } from "./MigratingEncoder.js";
4
+ //#region nodefony/src/encoders/encoderFromConfig.ts
5
+ function buildOne(spec) {
6
+ switch (spec.type) {
7
+ case "bcrypt": return new BcryptEncoder(spec.rounds);
8
+ case "argon2id": return new Argon2idEncoder({
9
+ memoryKiB: spec.memoryKiB,
10
+ timeCost: spec.timeCost,
11
+ parallelism: spec.parallelism
12
+ });
13
+ }
14
+ }
15
+ /**
16
+ * Traduit une liste ORDONNÉE de specs d'encodeurs en encodeur exécutable —
17
+ * le pont entre la section `encoders` de la config sécurité et le
18
+ * {@link UserService}.
19
+ *
20
+ * Sémantique de l'ordre : la **première** spec est l'encodeur PRINCIPAL
21
+ * (produit tous les nouveaux hashs), les suivantes sont les formats LEGACY
22
+ * acceptés en lecture seule — un login réussi sur un hash legacy est re-haché
23
+ * au format principal ({@link MigratingEncoder}, migration transparente).
24
+ *
25
+ * @param specs - specs ordonnées (typiquement `Object.values(config.encoders)`,
26
+ * l'ordre d'insertion du record fait foi).
27
+ * @returns l'encodeur seul (1 spec), un {@link MigratingEncoder} (N specs), ou
28
+ * l'Argon2id aux défauts OWASP si la liste est vide (défaut sûr).
29
+ */
30
+ function encoderFromConfig(specs) {
31
+ if (specs.length === 0) return new Argon2idEncoder();
32
+ const [primary, ...legacy] = specs.map(buildOne);
33
+ if (legacy.length === 0) return primary;
34
+ return new MigratingEncoder(primary, legacy);
35
+ }
36
+ //#endregion
37
+ export { encoderFromConfig };
@@ -0,0 +1,247 @@
1
+ import { BootConfigurationError } from "nodefony";
2
+ //#region nodefony/src/userContract.ts
3
+ /**
4
+ * **Le contrat de colonnes de l'utilisateur** — la seule description de ce que
5
+ * le framework LIT sur un utilisateur persisté.
6
+ *
7
+ * Il existe parce que cette liste était écrite en cinq endroits : la table SQL,
8
+ * le schéma document, la documentation publiée, et bientôt un générateur
9
+ * d'entité et un contrôle de démarrage. Cinq copies d'une même règle divergent
10
+ * en silence, et l'écart ne se voit alors que sur l'installation d'un tiers —
11
+ * même raison, même remède que {@link USER_SORTABLE_FIELDS} et `USER_FILTERS`.
12
+ *
13
+ * Les adaptateurs le DÉRIVENT (`userTable` en SQL, `userSchema` en document) au
14
+ * lieu de le recopier ; un test par adaptateur refuse toute colonne du contrat
15
+ * qui n'aurait pas de correspondance dans la définition produite.
16
+ *
17
+ * ⚠️ **Ces noms sont figés en v1.** Le SQL natif du listing les écrit en dur, et
18
+ * surtout : desserrer un nom est additif, le resserrer est une rupture. Ajouter
19
+ * une colonne ici engage donc toute application qui possède déjà sa table.
20
+ */
21
+ /**
22
+ * Le nom de la TABLE (ou collection) qui porte les utilisateurs.
23
+ *
24
+ * Il fait partie du contrat au même titre que les noms de colonnes, et pour la
25
+ * même raison : le SQL natif du framework l'écrit en dur — recherche par compte
26
+ * externe, listing. Une divergence ne LÈVE PAS, elle rend des résultats vides,
27
+ * si bien qu'une connexion par fournisseur externe crée un compte de plus à
28
+ * chaque passage.
29
+ *
30
+ * Il vit ici plutôt que dans un adaptateur parce que trois lieux le lisent — la
31
+ * spec de table SQL, les requêtes natives, et le générateur d'entité, qui écrit
32
+ * l'entité DANS l'application. Trois copies divergent en silence ; le générateur
33
+ * en portait déjà une, et elle disait `users`.
34
+ *
35
+ * ⚠️ **Figé en v1**, comme les noms de colonnes : le changer romprait toute
36
+ * application qui possède déjà sa table.
37
+ */
38
+ const USER_TABLE_NAME = "User";
39
+ const USER_COLUMNS = [
40
+ {
41
+ name: "id",
42
+ type: "uuid",
43
+ nullable: false,
44
+ origin: "identity",
45
+ readers: ["IUserRepository.findById", "SessionAuthenticator"],
46
+ description: "Identifiant interne, stable pour la vie du compte — c'est lui que porte une session."
47
+ },
48
+ {
49
+ name: "identifier",
50
+ type: "string",
51
+ nullable: false,
52
+ unique: true,
53
+ origin: "column",
54
+ readers: [
55
+ "IUserRepository.findByIdentifier",
56
+ "UserService.authenticate",
57
+ "listUserIdsPage (recherche ?q= et tri par défaut)"
58
+ ],
59
+ description: "Identifiant fonctionnel d'authentification (courriel, login) — unique dans l'annuaire."
60
+ },
61
+ {
62
+ name: "password",
63
+ type: "string",
64
+ nullable: true,
65
+ origin: "column",
66
+ readers: ["UserService.authenticate"],
67
+ description: "Empreinte du mot de passe, ou absente pour un compte sans mot de passe local (100 % externe)."
68
+ },
69
+ {
70
+ name: "roles",
71
+ type: "string[]",
72
+ nullable: false,
73
+ makeDefault: () => [],
74
+ origin: "column",
75
+ readers: ["listUserIdsPage (filtre ?role=)", "IUserRepository.countActiveAdmins"],
76
+ description: "Rôles plats accordés, sans hiérarchie résolue."
77
+ },
78
+ {
79
+ name: "enabled",
80
+ type: "boolean",
81
+ nullable: false,
82
+ makeDefault: () => true,
83
+ origin: "column",
84
+ readers: [
85
+ "UserService.authenticate",
86
+ "listUserIdsPage (filtre ?enabled=)",
87
+ "IUserRepository.countActiveAdmins"
88
+ ],
89
+ description: "Compte utilisable, ou désactivé par décision d'administration."
90
+ },
91
+ {
92
+ name: "locked",
93
+ type: "boolean",
94
+ nullable: false,
95
+ makeDefault: () => false,
96
+ origin: "column",
97
+ readers: ["UserService.authenticate", "listUserIdsPage (filtre ?locked=)"],
98
+ description: "Compte verrouillé par la défense contre la force brute."
99
+ },
100
+ {
101
+ name: "currentRole",
102
+ type: "string",
103
+ nullable: true,
104
+ origin: "column",
105
+ readers: ["BaseUser.currentRole"],
106
+ description: "Rôle endossé pour la session en cours, parmi ceux que le compte détient."
107
+ },
108
+ {
109
+ name: "socialProviders",
110
+ type: "object[]",
111
+ nullable: false,
112
+ makeDefault: () => [],
113
+ origin: "column",
114
+ readers: ["IUserRepository.findBySocialProvider", "listUserIdsPage (filtre ?hasSocial=)"],
115
+ description: "Comptes externes liés — tableau libre, pour qu'un nouveau fournisseur ne demande aucune migration."
116
+ },
117
+ {
118
+ name: "metadata",
119
+ type: "object",
120
+ nullable: false,
121
+ makeDefault: () => ({}),
122
+ origin: "column",
123
+ readers: ["projectProfile", "mergeProfileIntoMetadata"],
124
+ description: "Dictionnaire libre du compte, dont le profil public — sans schéma, donc sans migration."
125
+ },
126
+ {
127
+ name: "createdAt",
128
+ type: "date",
129
+ nullable: false,
130
+ origin: "audit",
131
+ readers: ["listUserIdsPage (tri ?order=createdAt)", "toUserSummary"],
132
+ description: "Date de création du compte."
133
+ },
134
+ {
135
+ name: "updatedAt",
136
+ type: "date",
137
+ nullable: false,
138
+ origin: "audit",
139
+ refreshedOnWrite: true,
140
+ readers: ["listUserIdsPage (tri ?order=updatedAt)", "toUserSummary"],
141
+ description: "Date de dernière modification du compte."
142
+ }
143
+ ];
144
+ /**
145
+ * Les colonnes que `BaseUser` reconstruit lui-même — tout le reste d'une ligne
146
+ * est « en plus », et doit donc être reporté sur l'objet rendu.
147
+ *
148
+ * La correspondance avec l'origine n'est pas une coïncidence : un horodatage
149
+ * n'appartient pas au contrat `IUser` (il décrit la LIGNE, pas l'identité), donc
150
+ * `BaseUser` ne le porte pas. Un test la garde, parce qu'elle se romprait sans
151
+ * bruit le jour où une colonne changerait de statut.
152
+ */
153
+ const REBUILT_BY_BASE_USER = new Set(USER_COLUMNS.filter((column) => column.origin !== "audit").map((column) => column.name));
154
+ /**
155
+ * Reporte sur un utilisateur reconstruit toutes les colonnes que `BaseUser` ne
156
+ * porte pas — horodatages de la ligne, et **champs métier ajoutés par
157
+ * l'application**.
158
+ *
159
+ * Sans ce report, l'écriture passe et la lecture perd, **sans une erreur** : le
160
+ * développeur voit sa donnée en base et un objet vide dans son code. C'est
161
+ * l'asymétrie qui est le défaut, et elle ne se corrige qu'ici — les trois dépôts
162
+ * appellent cette fonction plutôt que d'en recopier chacun sa version, sinon
163
+ * l'un d'eux divergerait en silence (c'était déjà le cas : le dépôt SQL
164
+ * reportait les horodatages, le dépôt document non).
165
+ *
166
+ * ⚠️ Cette fonction est sur le chemin de CHAQUE requête portant une session
167
+ * authentifiée : elle n'alloue rien, ne construit aucun tableau intermédiaire,
168
+ * et ne parcourt que les clés propres de la ligne.
169
+ *
170
+ * @param user - l'utilisateur reconstruit, muté sur place.
171
+ * @param row - la ligne rendue par le moteur.
172
+ * @param skip - clés de plomberie propres au moteur (`_id`, `__v`…), à ne pas
173
+ * reporter. Le contrat ne peut pas les connaître : c'est au dépôt de les dire.
174
+ * @returns le même objet `user`, pour permettre `return attachExtraColumns(...)`.
175
+ */
176
+ function attachExtraColumns(user, row, skip) {
177
+ const target = user;
178
+ for (const key in row) {
179
+ if (!Object.prototype.hasOwnProperty.call(row, key)) continue;
180
+ if (REBUILT_BY_BASE_USER.has(key)) continue;
181
+ if (skip?.has(key)) continue;
182
+ if (key === "__proto__" || key === "constructor" || key === "prototype") continue;
183
+ target[key] = row[key];
184
+ }
185
+ return user;
186
+ }
187
+ /**
188
+ * Les colonnes du contrat qu'une définition d'entité ne rend PAS.
189
+ *
190
+ * @param present - noms des colonnes que l'entité expose réellement.
191
+ * @param expected - le sous-ensemble du contrat à exiger (défaut : tout).
192
+ * @returns les colonnes manquantes, dans l'ordre du contrat (vide si complet).
193
+ */
194
+ function missingUserColumns(present, expected = USER_COLUMNS) {
195
+ const known = new Set(present);
196
+ return expected.filter((column) => !known.has(column.name));
197
+ }
198
+ /**
199
+ * Refuse une entité utilisateur incomplète, en nommant chaque colonne absente
200
+ * ET ce qui la lit.
201
+ *
202
+ * Le refus existe parce que le manque est SILENCIEUX : une application qui
203
+ * possède sa table `User` peut en retirer une colonne, la migration s'applique,
204
+ * le démarrage réussit, et la commande qui liste les comptes affiche des
205
+ * utilisateurs. Le défaut n'éclate qu'au premier accès à la colonne absente —
206
+ * une authentification, un filtre par rôle — peut-être des semaines plus tard,
207
+ * dans un chemin peu fréquenté. Échouer au démarrage déplace la découverte au
208
+ * seul moment où elle ne coûte rien.
209
+ *
210
+ * Nommer le LECTEUR, et pas seulement la colonne, est ce qui rend le message
211
+ * actionnable : « `roles` manque » n'apprend rien à qui a retiré la colonne
212
+ * exprès ; « `roles`, lue par le filtre `?role=` et par le compte des
213
+ * administrateurs actifs » dit ce qui cassera.
214
+ *
215
+ * @param present - noms des colonnes que l'entité expose réellement.
216
+ * @param origin - d'où vient l'entité examinée, cité tel quel dans le refus
217
+ * (ex. `l'entité « User » de l'application, sur le connecteur « default »`).
218
+ * @param expected - le sous-ensemble du contrat à exiger. Il existe parce que
219
+ * tous les stockages ne portent pas les mêmes colonnes EN PROPRE : un schéma
220
+ * document laisse la clé au moteur (`_id` + virtuel) et les horodatages à son
221
+ * option `timestamps`. Exiger le contrat entier là-bas refuserait une entité
222
+ * parfaitement correcte — et un refus faux apprend à passer outre les refus.
223
+ * L'appelant le DÉRIVE de ce qu'il produit lui-même, il ne le recopie pas.
224
+ * @throws BootConfigurationError si une colonne attendue manque.
225
+ *
226
+ * **Pourquoi une `BootConfigurationError` et pas une `Error`.** Ce refus est
227
+ * appelé pendant le boot d'un module, et le kernel y applique une politique de
228
+ * résilience : un hook de boot qui lève est journalisé en WARNING, le module est
229
+ * écarté, et l'application DÉMARRE — « BOOT dégradé », code de sortie nul.
230
+ * Mesuré : une application générée dont l'entité `User` avait perdu six colonnes
231
+ * du contrat démarrait, servait ses routes, et `orm:migrate:status` la déclarait
232
+ * « à jour ». Le refus était écrit, parfaitement rédigé, et sans effet.
233
+ *
234
+ * Le fail-soft est le bon comportement pour une panne TRANSITOIRE (une base qui
235
+ * ne répond pas encore) ; il est le mauvais pour une erreur de PROGRAMMATION,
236
+ * qui ne se répare pas en continuant. `BootConfigurationError` est exactement la
237
+ * frontière que le cœur a posée entre les deux — son propre TSDoc nomme le cas
238
+ * « une entité non portée sur le dialecte demandé ».
239
+ */
240
+ function assertUserContract(present, origin, expected = USER_COLUMNS) {
241
+ const missing = missingUserColumns(present, expected);
242
+ if (missing.length === 0) return;
243
+ const details = missing.map((column) => ` • ${column.name} (${column.type}) — lue par ${column.readers.join(", ")}\n ${column.description}`).join("\n");
244
+ throw new BootConfigurationError(`${origin} ne porte pas ${missing.length === 1 ? "une colonne" : `${missing.length} colonnes`} que le framework LIT :\n${details}\n → ajouter ${missing.length === 1 ? "cette colonne" : "ces colonnes"} à l'entité, puis : nodefony orm:generate --name user && nodefony orm:migrate\n(le contrat complet est exporté par @nodefony/user sous USER_COLUMNS.)`);
245
+ }
246
+ //#endregion
247
+ export { USER_COLUMNS, USER_TABLE_NAME, assertUserContract, attachExtraColumns, missingUserColumns };
@@ -0,0 +1,66 @@
1
+ //#region nodefony/src/userFilters.ts
2
+ /**
3
+ * **Le vocabulaire de filtre des utilisateurs**, en noms PUBLICS — ceux qu'un
4
+ * administrateur écrit dans l'URL (`?role=ROLE_ADMIN&enabled=false`).
5
+ *
6
+ * Frère de `USER_SORTABLE_FIELDS` (`userSort.ts`), et posé au même endroit pour
7
+ * la même raison : le vocabulaire appartient au module propriétaire du contrat,
8
+ * la mécanique de lecture au cœur (`parseFilters`).
9
+ *
10
+ * `role` reste une chaîne LIBRE : la hiérarchie de rôles est celle de
11
+ * l'application (`ROLE_*` de tenant) autant que de la plateforme
12
+ * (`ROLE_NODEFONY_*`), et aucune allowlist du framework ne peut la connaître.
13
+ * `enabled` est un booléen strict — `?enabled=1` est désormais refusé plutôt que
14
+ * lu comme `false`, ce que faisait la comparaison `enabled === "true"`.
15
+ */
16
+ const USER_FILTERS = {
17
+ /** Rôle plat devant figurer dans `roles` (containment natif). */
18
+ role: "string",
19
+ /** `true` = actifs seulement, `false` = inactifs seulement, absent = les deux. */
20
+ enabled: "boolean",
21
+ /** `true` = verrouillés seulement (défense anti-force brute), absent = les deux. */
22
+ locked: "boolean",
23
+ /** `true` = liés à au moins un fournisseur externe (OAuth), absent = les deux. */
24
+ hasSocial: "boolean"
25
+ };
26
+ /**
27
+ * **Les facettes des utilisateurs** — les questions fermées posées à l'annuaire
28
+ * ENTIER pour les cartes de tête.
29
+ *
30
+ * Les populations se **recoupent** délibérément : un compte peut être désactivé
31
+ * ET verrouillé, un administrateur peut avoir un lien social. Aucune facette
32
+ * n'est donc déduite d'une autre — « désactivés » et « verrouillés » sont deux
33
+ * questions distinctes, et les confondre en un seul « inactifs » masquait
34
+ * lequel des deux mécanismes bloque réellement un compte.
35
+ *
36
+ * `admins` n'y figure pas : le rôle d'administration est une valeur de
37
+ * configuration (`ROLE_NODEFONY_ADMIN` par défaut, surchargeable), pas une
38
+ * constante du vocabulaire. Le service la lit et compose la facette lui-même.
39
+ */
40
+ const USER_FACETS = {
41
+ /** Tous les comptes de l'annuaire. */
42
+ total: {},
43
+ /** Comptes utilisables : activés et non verrouillés. */
44
+ active: {
45
+ enabled: true,
46
+ locked: false
47
+ },
48
+ /** Comptes désactivés par décision d'administration. */
49
+ disabled: { enabled: false },
50
+ /** Comptes verrouillés par la défense anti-force brute. */
51
+ locked: { locked: true },
52
+ /** Comptes liés à au moins un fournisseur d'identité externe. */
53
+ social: { hasSocial: true }
54
+ };
55
+ /**
56
+ * Ce que l'endpoint de COMPTEURS accepte de filtrer — `USER_FILTERS` **moins**
57
+ * les champs que les facettes décomposent (`enabled`, `locked`, `hasSocial`).
58
+ *
59
+ * Les demander ici rendrait une réponse contradictoire : le total suivrait le
60
+ * filtre pendant que chaque facette l'écraserait par le sien. `role` reste :
61
+ * il découpe une AUTRE dimension, et « combien de `ROLE_SUPPORT`, et dans quel
62
+ * état ? » est une question cohérente.
63
+ */
64
+ const USER_STATS_FILTERS = { role: "string" };
65
+ //#endregion
66
+ export { USER_FACETS, USER_FILTERS, USER_STATS_FILTERS };