@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,83 @@
1
+ import type { IPasswordEncoder } from "../../contracts/IPasswordEncoder.js";
2
+ /**
3
+ * Paramètres de coût Argon2id (RFC 9106).
4
+ *
5
+ * Les défauts suivent le minimum OWASP 2026 (m=19 MiB, t=2, p=1) — déjà imposé
6
+ * par le schéma Zod de `@nodefony/security` (`encoders.memoryKiB.min(19456)`).
7
+ * L'encodeur ne valide ici que les bornes TECHNIQUES de l'algorithme : la
8
+ * politique de sécurité vit dans la config (et permet aux tests d'utiliser des
9
+ * coûts bas, rapides).
10
+ */
11
+ export interface Argon2idOptions {
12
+ /** Mémoire par hash en KiB (défaut 19456 = 19 MiB, minimum OWASP). */
13
+ memoryKiB?: number;
14
+ /** Nombre de passes sur la mémoire (défaut 3 — voir DEFAULT_TIME_COST). */
15
+ timeCost?: number;
16
+ /** Nombre de threads/lanes (défaut 1 — chaque lane alloue `memoryKiB`). */
17
+ parallelism?: number;
18
+ }
19
+ /**
20
+ * Encodeur de mot de passe **Argon2id** (RFC 9106) — recommandation NIST/OWASP 2026.
21
+ *
22
+ * Fonction de dérivation à MÉMOIRE DURE : chaque vérification exige `memoryKiB`
23
+ * de RAM en plus du CPU, ce qui ruine les attaques massivement parallèles
24
+ * (GPU/ASIC ont des milliers de cœurs mais pas 19 MiB de mémoire dédiée par
25
+ * cœur). Le variant `id` est hybride : résistant aux canaux auxiliaires
26
+ * (première moitié indépendante du mot de passe) ET aux compromis temps-mémoire.
27
+ *
28
+ * Délègue à `@node-rs/argon2` (binding NAPI Rust, exécuté hors thread principal,
29
+ * **peerDependency optionnelle**) : le binaire natif est importé DYNAMIQUEMENT
30
+ * au premier `hash`/`verify` — instancier l'encodeur ne charge rien.
31
+ */
32
+ export declare class Argon2idEncoder implements IPasswordEncoder {
33
+ /** Mémoire par hash (KiB) utilisée pour produire les nouveaux hashs. */
34
+ readonly memoryKiB: number;
35
+ /** Passes sur la mémoire utilisées pour produire les nouveaux hashs. */
36
+ readonly timeCost: number;
37
+ /** Lanes parallèles utilisées pour produire les nouveaux hashs. */
38
+ readonly parallelism: number;
39
+ /**
40
+ * @param options - coûts Argon2id ; défauts = minimum OWASP (19 MiB, t=2, p=1).
41
+ * @throws {RangeError} si un paramètre viole les bornes techniques de
42
+ * l'algorithme (entiers, `t ≥ 1`, `1 ≤ p ≤ 255`, `m ≥ 8×p`).
43
+ */
44
+ constructor(options?: Argon2idOptions);
45
+ /**
46
+ * Reconnaît un hash argon2 (tous variants `$argon2d|i|id$`) — parsing pur, sync.
47
+ *
48
+ * Tous les variants sont supportés à la VÉRIFICATION (le binding parse le
49
+ * format PHC) ; {@link needsRehash} se charge de moderniser `d`/`i` vers `id`.
50
+ *
51
+ * @param hash - hash stocké à inspecter.
52
+ * @returns `true` si le hash est au format argon2.
53
+ */
54
+ supports(hash: string): boolean;
55
+ /**
56
+ * Hache un mot de passe en clair (sel généré, paramètres inclus dans la sortie PHC).
57
+ *
58
+ * @param plain - mot de passe en clair.
59
+ * @returns le hash argon2id à persister.
60
+ */
61
+ hash(plain: string): Promise<string>;
62
+ /**
63
+ * Vérifie un mot de passe en clair contre un hash stocké (temps constant interne).
64
+ *
65
+ * Les paramètres de vérification sont LUS DANS le hash PHC (pas ceux de
66
+ * l'encodeur) : un hash produit avec d'anciens coûts reste vérifiable.
67
+ *
68
+ * @param plain - mot de passe fourni à la connexion.
69
+ * @param hash - hash argon2 stocké.
70
+ * @returns `true` si la correspondance est valide.
71
+ */
72
+ verify(plain: string, hash: string): Promise<boolean>;
73
+ /**
74
+ * Indique si un hash doit être recalculé : format non argon2, variant non
75
+ * `id`, version antérieure à 0x13, ou coûts stockés INFÉRIEURS aux coûts
76
+ * courants (politique affaiblie). Des coûts supérieurs ne déclenchent pas de
77
+ * re-hash (jamais de downgrade silencieux).
78
+ *
79
+ * @param hash - hash stocké à inspecter.
80
+ * @returns `true` si un re-hash est recommandé au prochain login réussi.
81
+ */
82
+ needsRehash(hash: string): boolean;
83
+ }
@@ -0,0 +1,55 @@
1
+ import type { IPasswordEncoder } from "../../contracts/IPasswordEncoder.js";
2
+ /**
3
+ * Encodeur de mot de passe **bcrypt** — implémentation de référence d'{@link IPasswordEncoder}.
4
+ *
5
+ * Délègue à `@node-rs/bcrypt` (binding NAPI Rust) : hachage/vérification réellement
6
+ * asynchrones (exécutés hors thread principal), ne bloquent pas la boucle
7
+ * d'événements même au coût 12. `@node-rs/bcrypt` est une **peerDependency
8
+ * optionnelle** : seules les applications qui authentifient par mot de passe local
9
+ * la tirent ; un consommateur qui n'importe que `IUser`/`BaseUser` ne charge jamais
10
+ * ce module ni le binaire natif.
11
+ *
12
+ * @remarks Le binding natif est importé DYNAMIQUEMENT au premier `hash`/`verify`
13
+ * (instancier l'encodeur ne charge rien — la peerDep optionnelle n'est requise
14
+ * qu'au premier usage réel). Le coût est paramétrable au constructeur pour
15
+ * permettre un re-hash progressif via {@link BcryptEncoder.needsRehash}.
16
+ */
17
+ export declare class BcryptEncoder implements IPasswordEncoder {
18
+ /** Coût bcrypt utilisé pour produire les nouveaux hashs. */
19
+ readonly rounds: number;
20
+ /**
21
+ * @param rounds - coût bcrypt (log2 des itérations). Entier dans `[4, 31]`.
22
+ * @throws {RangeError} si `rounds` est hors de l'intervalle valide.
23
+ */
24
+ constructor(rounds?: number);
25
+ /**
26
+ * Reconnaît un hash bcrypt (`$2a$`/`$2b$`/`$2y$` + coût) — parsing pur, sync.
27
+ *
28
+ * @param hash - hash stocké à inspecter.
29
+ * @returns `true` si le hash est au format bcrypt.
30
+ */
31
+ supports(hash: string): boolean;
32
+ /**
33
+ * Hache un mot de passe en clair (sel généré et inclus dans la sortie).
34
+ *
35
+ * @param plain - mot de passe en clair.
36
+ * @returns le hash bcrypt à persister.
37
+ */
38
+ hash(plain: string): Promise<string>;
39
+ /**
40
+ * Vérifie un mot de passe en clair contre un hash stocké (temps constant interne).
41
+ *
42
+ * @param plain - mot de passe fourni à la connexion.
43
+ * @param hash - hash bcrypt stocké.
44
+ * @returns `true` si la correspondance est valide.
45
+ */
46
+ verify(plain: string, hash: string): Promise<boolean>;
47
+ /**
48
+ * Indique si un hash doit être recalculé (coût stocké inférieur au coût courant,
49
+ * ou format non bcrypt).
50
+ *
51
+ * @param hash - hash stocké à inspecter.
52
+ * @returns `true` si un re-hash est recommandé au prochain login réussi.
53
+ */
54
+ needsRehash(hash: string): boolean;
55
+ }
@@ -0,0 +1,68 @@
1
+ import type { IPasswordEncoder } from "../../contracts/IPasswordEncoder.js";
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
+ export declare class MigratingEncoder implements IPasswordEncoder {
24
+ #private;
25
+ /** Encodeur cible — produit tous les nouveaux hashs. */
26
+ readonly primary: IPasswordEncoder;
27
+ /** Encodeurs acceptés en lecture seule pendant la migration. */
28
+ readonly legacy: readonly IPasswordEncoder[];
29
+ /**
30
+ * @param primary - encodeur cible (format des nouveaux hashs).
31
+ * @param legacy - encodeurs des formats encore présents en base (vérification seule).
32
+ */
33
+ constructor(primary: IPasswordEncoder, legacy: readonly IPasswordEncoder[]);
34
+ /**
35
+ * Reconnaît un hash si l'UN des encodeurs (principal ou legacy) le reconnaît.
36
+ *
37
+ * @param hash - hash stocké à inspecter.
38
+ * @returns `true` si un membre du composite sait vérifier ce hash.
39
+ */
40
+ supports(hash: string): boolean;
41
+ /**
42
+ * Hache avec l'encodeur PRINCIPAL uniquement (jamais un format legacy).
43
+ *
44
+ * @param plain - mot de passe en clair.
45
+ * @returns le hash au format cible.
46
+ */
47
+ hash(plain: string): Promise<string>;
48
+ /**
49
+ * Vérifie en routant vers l'encodeur dont le format correspond au hash stocké.
50
+ *
51
+ * Hash d'un format inconnu de tous → `false` (jamais d'erreur : un credential
52
+ * invérifiable est un credential invalide — l'égalisation de temps des chemins
53
+ * d'échec reste portée par `UserService`).
54
+ *
55
+ * @param plain - mot de passe fourni à la connexion.
56
+ * @param hash - hash stocké (format courant ou legacy).
57
+ * @returns `true` si la correspondance est valide.
58
+ */
59
+ verify(plain: string, hash: string): Promise<boolean>;
60
+ /**
61
+ * Recommande un re-hash si le hash n'est pas au format principal (migration),
62
+ * ou si le principal lui-même le juge obsolète (coûts augmentés).
63
+ *
64
+ * @param hash - hash stocké à inspecter.
65
+ * @returns `true` si un re-hash est recommandé au prochain login réussi.
66
+ */
67
+ needsRehash(hash: string): boolean;
68
+ }
@@ -0,0 +1,36 @@
1
+ import type { IPasswordEncoder } from "../../contracts/IPasswordEncoder.js";
2
+ /**
3
+ * Spécification déclarative d'UN encodeur de mot de passe — miroir structurel
4
+ * de la section `encoders` du schéma Zod de `@nodefony/security` (qui valide
5
+ * bornes et défauts), SANS en dépendre : ce module reste la couche basse,
6
+ * l'inversion de dépendance est préservée (security consomme user, jamais
7
+ * l'inverse).
8
+ */
9
+ export interface IEncoderSpec {
10
+ /** Algorithme : `argon2id` (RFC 9106, défaut) ou `bcrypt` (legacy). */
11
+ type: "argon2id" | "bcrypt";
12
+ /** Argon2id : mémoire par hash (KiB). */
13
+ memoryKiB?: number;
14
+ /** Argon2id : passes d'itération. */
15
+ timeCost?: number;
16
+ /** Argon2id : lanes parallèles. */
17
+ parallelism?: number;
18
+ /** bcrypt : coût (ignoré par argon2id). */
19
+ rounds?: number;
20
+ }
21
+ /**
22
+ * Traduit une liste ORDONNÉE de specs d'encodeurs en encodeur exécutable —
23
+ * le pont entre la section `encoders` de la config sécurité et le
24
+ * {@link UserService}.
25
+ *
26
+ * Sémantique de l'ordre : la **première** spec est l'encodeur PRINCIPAL
27
+ * (produit tous les nouveaux hashs), les suivantes sont les formats LEGACY
28
+ * acceptés en lecture seule — un login réussi sur un hash legacy est re-haché
29
+ * au format principal ({@link MigratingEncoder}, migration transparente).
30
+ *
31
+ * @param specs - specs ordonnées (typiquement `Object.values(config.encoders)`,
32
+ * l'ordre d'insertion du record fait foi).
33
+ * @returns l'encodeur seul (1 spec), un {@link MigratingEncoder} (N specs), ou
34
+ * l'Argon2id aux défauts OWASP si la liste est vide (défaut sûr).
35
+ */
36
+ export declare function encoderFromConfig(specs: readonly IEncoderSpec[]): IPasswordEncoder;
@@ -0,0 +1,198 @@
1
+ import type { ISocialProvider } from "../contracts/IUser.js";
2
+ /**
3
+ * Type **logique** d'une colonne de l'utilisateur — ce que la donnée EST, jamais
4
+ * comment un moteur la range.
5
+ *
6
+ * Chaque adaptateur traduit ce vocabulaire dans le sien (`text`/`json`/`bool`
7
+ * du colKit SQL, `String`/`Array`/`Object` d'un schéma Mongoose). Le contrat
8
+ * reste donc lisible par un développeur qui n'a choisi aucune base, et un
9
+ * troisième adaptateur ne demande pas de le rouvrir.
10
+ */
11
+ export type UserColumnType = "uuid" | "string" | "string[]" | "boolean" | "object" | "object[]" | "date";
12
+ /**
13
+ * D'où vient la valeur d'une colonne — ce qui décide si un adaptateur doit la
14
+ * DÉCLARER ou peut la laisser au moteur.
15
+ *
16
+ * - `identity` : la clé primaire. Déclarée en SQL (`text` + UUID applicatif),
17
+ * fournie par le moteur en document (`_id` + virtuel `id`).
18
+ * - `column` : une colonne ordinaire. **Tout adaptateur doit la déclarer** —
19
+ * c'est sur elle que porte le test de correspondance.
20
+ * - `audit` : un horodatage. Déclaré en SQL, délégué au moteur en document
21
+ * (`timestamps: true`).
22
+ */
23
+ export type UserColumnOrigin = "identity" | "column" | "audit";
24
+ /**
25
+ * Une colonne du contrat utilisateur : son nom, ce qu'elle contient, et **qui la
26
+ * lit**.
27
+ *
28
+ * `readers` n'est pas de la documentation d'agrément : c'est ce qui permet à un
29
+ * refus de démarrage de nommer le lecteur en même temps que la colonne absente
30
+ * (« `socialProviders` manque — `findBySocialProvider` la lit »), au lieu de
31
+ * laisser chercher qui casse. N'y figurent donc que les lecteurs qui touchent la
32
+ * BASE — requête, filtre, tri, projection du dépôt — pas tout code qui manipule
33
+ * un utilisateur déjà chargé.
34
+ */
35
+ export interface IUserColumn {
36
+ /** Nom de la colonne, identique sur tous les moteurs (figé en v1). */
37
+ readonly name: string;
38
+ /** Ce que la colonne contient, indépendamment du moteur. */
39
+ readonly type: UserColumnType;
40
+ /** `true` si la colonne accepte l'absence de valeur. */
41
+ readonly nullable: boolean;
42
+ /** `true` si la valeur doit être unique dans l'annuaire (implique un index). */
43
+ readonly unique?: boolean;
44
+ /** Qui fournit la valeur — cf {@link UserColumnOrigin}. */
45
+ readonly origin: UserColumnOrigin;
46
+ /**
47
+ * Fabrique du défaut, appelée à CHAQUE insertion.
48
+ *
49
+ * C'est une fabrique et non une valeur parce qu'un défaut structuré (`[]`,
50
+ * `{}`) partagé par référence serait le MÊME objet pour tous les utilisateurs :
51
+ * une modification en mémoire les contaminerait tous.
52
+ */
53
+ readonly makeDefault?: () => unknown;
54
+ /**
55
+ * `true` si la valeur est régénérée à CHAQUE écriture (horodatage de
56
+ * modification). Déclaré ici plutôt que déduit du nom de la colonne : un
57
+ * adaptateur qui reconnaîtrait `"updatedAt"` rendrait la règle invisible, et
58
+ * fausse le jour où une seconde colonne se comporte pareil.
59
+ */
60
+ readonly refreshedOnWrite?: boolean;
61
+ /** Les lecteurs qui cassent si la colonne manque — nommés dans les refus. */
62
+ readonly readers: readonly string[];
63
+ /** Rôle de la colonne, en une phrase. */
64
+ readonly description: string;
65
+ }
66
+ /**
67
+ * **Le contrat de colonnes de l'utilisateur** — la seule description de ce que
68
+ * le framework LIT sur un utilisateur persisté.
69
+ *
70
+ * Il existe parce que cette liste était écrite en cinq endroits : la table SQL,
71
+ * le schéma document, la documentation publiée, et bientôt un générateur
72
+ * d'entité et un contrôle de démarrage. Cinq copies d'une même règle divergent
73
+ * en silence, et l'écart ne se voit alors que sur l'installation d'un tiers —
74
+ * même raison, même remède que {@link USER_SORTABLE_FIELDS} et `USER_FILTERS`.
75
+ *
76
+ * Les adaptateurs le DÉRIVENT (`userTable` en SQL, `userSchema` en document) au
77
+ * lieu de le recopier ; un test par adaptateur refuse toute colonne du contrat
78
+ * qui n'aurait pas de correspondance dans la définition produite.
79
+ *
80
+ * ⚠️ **Ces noms sont figés en v1.** Le SQL natif du listing les écrit en dur, et
81
+ * surtout : desserrer un nom est additif, le resserrer est une rupture. Ajouter
82
+ * une colonne ici engage donc toute application qui possède déjà sa table.
83
+ */
84
+ /**
85
+ * Le nom de la TABLE (ou collection) qui porte les utilisateurs.
86
+ *
87
+ * Il fait partie du contrat au même titre que les noms de colonnes, et pour la
88
+ * même raison : le SQL natif du framework l'écrit en dur — recherche par compte
89
+ * externe, listing. Une divergence ne LÈVE PAS, elle rend des résultats vides,
90
+ * si bien qu'une connexion par fournisseur externe crée un compte de plus à
91
+ * chaque passage.
92
+ *
93
+ * Il vit ici plutôt que dans un adaptateur parce que trois lieux le lisent — la
94
+ * spec de table SQL, les requêtes natives, et le générateur d'entité, qui écrit
95
+ * l'entité DANS l'application. Trois copies divergent en silence ; le générateur
96
+ * en portait déjà une, et elle disait `users`.
97
+ *
98
+ * ⚠️ **Figé en v1**, comme les noms de colonnes : le changer romprait toute
99
+ * application qui possède déjà sa table.
100
+ */
101
+ export declare const USER_TABLE_NAME = "User";
102
+ export declare const USER_COLUMNS: readonly IUserColumn[];
103
+ /**
104
+ * Forme plate d'un utilisateur tel qu'un dépôt le rend, avant reconstruction en
105
+ * `BaseUser` — pendant TypeScript de {@link USER_COLUMNS}.
106
+ *
107
+ * Déclarée ici, et non dans chaque adaptateur, pour la raison qui fonde tout ce
108
+ * fichier : deux copies de la même forme divergent sans que rien ne le dise.
109
+ * Les colonnes JSON arrivent déjà désérialisées, et les dates en `Date` — c'est
110
+ * le travail du pilote, pas celui du dépôt.
111
+ */
112
+ export interface IUserRow {
113
+ id: string;
114
+ identifier: string;
115
+ password: string | null;
116
+ roles: string[];
117
+ enabled: boolean;
118
+ locked: boolean;
119
+ currentRole: string | null;
120
+ socialProviders: ISocialProvider[];
121
+ metadata: Record<string, unknown>;
122
+ createdAt: Date;
123
+ updatedAt: Date;
124
+ }
125
+ /**
126
+ * Reporte sur un utilisateur reconstruit toutes les colonnes que `BaseUser` ne
127
+ * porte pas — horodatages de la ligne, et **champs métier ajoutés par
128
+ * l'application**.
129
+ *
130
+ * Sans ce report, l'écriture passe et la lecture perd, **sans une erreur** : le
131
+ * développeur voit sa donnée en base et un objet vide dans son code. C'est
132
+ * l'asymétrie qui est le défaut, et elle ne se corrige qu'ici — les trois dépôts
133
+ * appellent cette fonction plutôt que d'en recopier chacun sa version, sinon
134
+ * l'un d'eux divergerait en silence (c'était déjà le cas : le dépôt SQL
135
+ * reportait les horodatages, le dépôt document non).
136
+ *
137
+ * ⚠️ Cette fonction est sur le chemin de CHAQUE requête portant une session
138
+ * authentifiée : elle n'alloue rien, ne construit aucun tableau intermédiaire,
139
+ * et ne parcourt que les clés propres de la ligne.
140
+ *
141
+ * @param user - l'utilisateur reconstruit, muté sur place.
142
+ * @param row - la ligne rendue par le moteur.
143
+ * @param skip - clés de plomberie propres au moteur (`_id`, `__v`…), à ne pas
144
+ * reporter. Le contrat ne peut pas les connaître : c'est au dépôt de les dire.
145
+ * @returns le même objet `user`, pour permettre `return attachExtraColumns(...)`.
146
+ */
147
+ export declare function attachExtraColumns<T extends object>(user: T, row: Readonly<Record<string, unknown>>, skip?: ReadonlySet<string>): T;
148
+ /**
149
+ * Les colonnes du contrat qu'une définition d'entité ne rend PAS.
150
+ *
151
+ * @param present - noms des colonnes que l'entité expose réellement.
152
+ * @param expected - le sous-ensemble du contrat à exiger (défaut : tout).
153
+ * @returns les colonnes manquantes, dans l'ordre du contrat (vide si complet).
154
+ */
155
+ export declare function missingUserColumns(present: Iterable<string>, expected?: readonly IUserColumn[]): readonly IUserColumn[];
156
+ /**
157
+ * Refuse une entité utilisateur incomplète, en nommant chaque colonne absente
158
+ * ET ce qui la lit.
159
+ *
160
+ * Le refus existe parce que le manque est SILENCIEUX : une application qui
161
+ * possède sa table `User` peut en retirer une colonne, la migration s'applique,
162
+ * le démarrage réussit, et la commande qui liste les comptes affiche des
163
+ * utilisateurs. Le défaut n'éclate qu'au premier accès à la colonne absente —
164
+ * une authentification, un filtre par rôle — peut-être des semaines plus tard,
165
+ * dans un chemin peu fréquenté. Échouer au démarrage déplace la découverte au
166
+ * seul moment où elle ne coûte rien.
167
+ *
168
+ * Nommer le LECTEUR, et pas seulement la colonne, est ce qui rend le message
169
+ * actionnable : « `roles` manque » n'apprend rien à qui a retiré la colonne
170
+ * exprès ; « `roles`, lue par le filtre `?role=` et par le compte des
171
+ * administrateurs actifs » dit ce qui cassera.
172
+ *
173
+ * @param present - noms des colonnes que l'entité expose réellement.
174
+ * @param origin - d'où vient l'entité examinée, cité tel quel dans le refus
175
+ * (ex. `l'entité « User » de l'application, sur le connecteur « default »`).
176
+ * @param expected - le sous-ensemble du contrat à exiger. Il existe parce que
177
+ * tous les stockages ne portent pas les mêmes colonnes EN PROPRE : un schéma
178
+ * document laisse la clé au moteur (`_id` + virtuel) et les horodatages à son
179
+ * option `timestamps`. Exiger le contrat entier là-bas refuserait une entité
180
+ * parfaitement correcte — et un refus faux apprend à passer outre les refus.
181
+ * L'appelant le DÉRIVE de ce qu'il produit lui-même, il ne le recopie pas.
182
+ * @throws BootConfigurationError si une colonne attendue manque.
183
+ *
184
+ * **Pourquoi une `BootConfigurationError` et pas une `Error`.** Ce refus est
185
+ * appelé pendant le boot d'un module, et le kernel y applique une politique de
186
+ * résilience : un hook de boot qui lève est journalisé en WARNING, le module est
187
+ * écarté, et l'application DÉMARRE — « BOOT dégradé », code de sortie nul.
188
+ * Mesuré : une application générée dont l'entité `User` avait perdu six colonnes
189
+ * du contrat démarrait, servait ses routes, et `orm:migrate:status` la déclarait
190
+ * « à jour ». Le refus était écrit, parfaitement rédigé, et sans effet.
191
+ *
192
+ * Le fail-soft est le bon comportement pour une panne TRANSITOIRE (une base qui
193
+ * ne répond pas encore) ; il est le mauvais pour une erreur de PROGRAMMATION,
194
+ * qui ne se répare pas en continuant. `BootConfigurationError` est exactement la
195
+ * frontière que le cœur a posée entre les deux — son propre TSDoc nomme le cas
196
+ * « une entité non portée sur le dialecte demandé ».
197
+ */
198
+ export declare function assertUserContract(present: Iterable<string>, origin: string, expected?: readonly IUserColumn[]): void;
@@ -0,0 +1,80 @@
1
+ import type { FacetCount, FacetCounts } from "nodefony";
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
+ export declare const USER_FILTERS: {
17
+ /** Rôle plat devant figurer dans `roles` (containment natif). */
18
+ readonly role: "string";
19
+ /** `true` = actifs seulement, `false` = inactifs seulement, absent = les deux. */
20
+ readonly enabled: "boolean";
21
+ /** `true` = verrouillés seulement (défense anti-force brute), absent = les deux. */
22
+ readonly locked: "boolean";
23
+ /** `true` = liés à au moins un fournisseur externe (OAuth), absent = les deux. */
24
+ readonly 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
+ export declare const USER_FACETS: {
41
+ /** Tous les comptes de l'annuaire. */
42
+ readonly total: {};
43
+ /** Comptes utilisables : activés et non verrouillés. */
44
+ readonly active: {
45
+ readonly enabled: true;
46
+ readonly locked: false;
47
+ };
48
+ /** Comptes désactivés par décision d'administration. */
49
+ readonly disabled: {
50
+ readonly enabled: false;
51
+ };
52
+ /** Comptes verrouillés par la défense anti-force brute. */
53
+ readonly locked: {
54
+ readonly locked: true;
55
+ };
56
+ /** Comptes liés à au moins un fournisseur d'identité externe. */
57
+ readonly social: {
58
+ readonly hasSocial: true;
59
+ };
60
+ };
61
+ /**
62
+ * Les compteurs rendus par `GET /nodefony/user/api/users/stats` — les facettes
63
+ * déclarées, plus `admins` que le service compose depuis le rôle configuré.
64
+ */
65
+ export type IUserCounts = FacetCounts<typeof USER_FACETS> & {
66
+ /** Comptes portant le rôle d'administration de la plateforme. */
67
+ admins: FacetCount;
68
+ };
69
+ /**
70
+ * Ce que l'endpoint de COMPTEURS accepte de filtrer — `USER_FILTERS` **moins**
71
+ * les champs que les facettes décomposent (`enabled`, `locked`, `hasSocial`).
72
+ *
73
+ * Les demander ici rendrait une réponse contradictoire : le total suivrait le
74
+ * filtre pendant que chaque facette l'écraserait par le sien. `role` reste :
75
+ * il découpe une AUTRE dimension, et « combien de `ROLE_SUPPORT`, et dans quel
76
+ * état ? » est une question cohérente.
77
+ */
78
+ export declare const USER_STATS_FILTERS: {
79
+ readonly role: "string";
80
+ };
@@ -0,0 +1,56 @@
1
+ import type { IUserProfile } from "../contracts/IUserProfile.js";
2
+ /**
3
+ * Logique PURE du profil utilisateur (claims OIDC stockés dans
4
+ * `metadata.profile`) — validation d'un patch client, projection en DTO par
5
+ * allowlist, fusion dans la `metadata` existante. Aucun I/O, aucun service :
6
+ * testable isolément (cœur de la garantie anti-fuite + anti-injection de clé).
7
+ */
8
+ /** Clés de profil reconnues — allowlist STRICTE (toute autre clé est ignorée). */
9
+ export declare const PROFILE_FIELDS: readonly ["givenName", "familyName", "displayName", "email", "locale", "picture"];
10
+ export type ProfileField = (typeof PROFILE_FIELDS)[number];
11
+ /**
12
+ * Valide + normalise un patch de profil reçu d'un client. Allowlist stricte :
13
+ * seules les clés {@link PROFILE_FIELDS} sont lues (anti-injection d'une clé
14
+ * `metadata` arbitraire). Chaque valeur est `trim`ée ; une chaîne vide ou `null`
15
+ * = **effacement** du champ (matérialisé `""`, retiré au merge).
16
+ *
17
+ * @param input - corps client (`request.body.profile` admin, ou `request.body`
18
+ * self).
19
+ * @returns `{ ok: true, value }` (patch normalisé) ou `{ ok: false, error }` si
20
+ * une valeur est mal typée, trop longue, ou syntaxiquement invalide.
21
+ */
22
+ export declare function validateProfilePatch(input: unknown): {
23
+ ok: true;
24
+ value: Partial<Record<ProfileField, string>>;
25
+ } | {
26
+ ok: false;
27
+ error: string;
28
+ };
29
+ /**
30
+ * Projette la `metadata` brute d'une entité vers un {@link IUserProfile} — lecture
31
+ * par **allowlist** (seules les clés connues, typées string non vide). Garantit
32
+ * qu'aucune autre clé de `metadata` (applicative/sensible) n'entre dans le DTO.
33
+ *
34
+ * @param metadata - `entity.metadata` (inconnu : l'entité ORM, pas le contrat).
35
+ */
36
+ export declare function projectProfile(metadata: unknown): IUserProfile;
37
+ /**
38
+ * Fusionne un patch validé dans la `metadata` existante en **préservant les autres
39
+ * clés**. Les champs de profil vidés (`""`) sont RETIRÉS (pas de clé fantôme).
40
+ * Rend la nouvelle `metadata` complète, prête pour `updateOne({ metadata })`.
41
+ *
42
+ * @param metadata - `metadata` actuelle de l'entité (préservée hors `profile`).
43
+ * @param patch - patch validé par {@link validateProfilePatch}.
44
+ */
45
+ export declare function mergeProfileIntoMetadata(metadata: unknown, patch: Partial<Record<ProfileField, string>>): Record<string, unknown>;
46
+ /**
47
+ * Pré-remplit un profil depuis des **claims OIDC/OAuth** (provisioning JIT d'un
48
+ * Shadow User social). Mappe les claims standard (`given_name`/`family_name`/
49
+ * `name`/`email`/`picture`/`locale`, + `avatar_url` GitHub) vers
50
+ * {@link IUserProfile}, **best-effort par champ** : chaque valeur est validée
51
+ * isolément (bornes/format) et un claim invalide est ignoré sans bloquer les
52
+ * autres ni le login. Les claims viennent d'un tiers → lecture défensive.
53
+ *
54
+ * @param claims - claims/payload du fournisseur (ex. `{...raw, name, email}`).
55
+ */
56
+ export declare function profileFromClaims(claims: Record<string, unknown>): Partial<Record<ProfileField, string>>;
@@ -0,0 +1,41 @@
1
+ import type { IPageQuery } from "nodefony";
2
+ /**
3
+ * **Le vocabulaire de tri des utilisateurs** — source unique, en noms publics.
4
+ *
5
+ * Il existe parce que cette liste était écrite DEUX fois, et qu'elle avait déjà
6
+ * divergé : l'adapter SQL autorisait `id`, l'adapter Mongo non. Un `?order=id`
7
+ * triait donc en base SQL et se faisait ignorer en base Mongo, sans erreur ni
8
+ * trace — le genre d'écart qui ne se voit qu'en production, sur l'installation
9
+ * d'un tiers.
10
+ *
11
+ * Chaque store la consomme au lieu de la recopier ; ceux qui **concatènent** le
12
+ * nom dans une requête (SQL) continuent de filtrer avec, en défense en
13
+ * profondeur — mais ils filtrent alors sur la même liste que tout le monde.
14
+ */
15
+ export declare const USER_SORTABLE_FIELDS: readonly ["identifier", "enabled", "createdAt", "updatedAt", "id"];
16
+ /**
17
+ * Sous-ensemble réellement triable par l'annuaire **en mémoire**.
18
+ *
19
+ * `BaseUser` ne porte ni `createdAt` ni `updatedAt` — ce sont des colonnes des
20
+ * schémas persistants, pas des attributs du modèle. Déclarer le vocabulaire
21
+ * complet ici reviendrait à annoncer un tri que le store ne peut pas rendre :
22
+ * la page sortirait dans un ordre arbitraire, sans erreur, et personne ne le
23
+ * verrait. La capacité réduite est donc ANNONCÉE, pas simulée — même doctrine
24
+ * que le store de sessions Redis, qui ne déclare aucun tri du tout.
25
+ *
26
+ * Ce que cela donne à l'usage : `?order=createdAt` trie sur une base SQL ou
27
+ * Mongo, et rend **400** sur le backend mémoire. Un refus explicite vaut mieux
28
+ * qu'un ordre inventé.
29
+ */
30
+ export declare const USER_SORTABLE_FIELDS_IN_MEMORY: readonly ["identifier", "enabled", "id"];
31
+ /**
32
+ * Socle garanti par **tous** les backends d'utilisateurs. C'est ce sur quoi une
33
+ * interface peut compter sans savoir quelle base est branchée.
34
+ */
35
+ export declare const USER_SORTABLE_FIELDS_COMMON: readonly ["identifier", "enabled", "id"];
36
+ /**
37
+ * Ordre appliqué quand le client n'en demande aucun : par identifiant croissant
38
+ * — le seul champ toujours présent et unique, donc le seul qui rende une
39
+ * pagination offset déterministe sans départage supplémentaire.
40
+ */
41
+ export declare const USER_DEFAULT_ORDER: NonNullable<IPageQuery["order"]>;
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Déclare un backend de persistance utilisateur disponible (idempotent).
3
+ *
4
+ * @param name - nom court du backend (`"memory"`, `"drizzle"`, `"mongoose"`).
5
+ */
6
+ export declare function registerUserStore(name: string): void;
7
+ /**
8
+ * Liste des backends utilisateur disponibles — alimente le champ `available` du
9
+ * statut user (écran Studio « Stores »). `"memory"` est mis EN TÊTE (baseline
10
+ * builtin, toujours disponible = le repli qui marche partout), les adapters ORM
11
+ * suivent triés. L'ordre est celui affiché dans la colonne « Backends dispo ».
12
+ *
13
+ * @returns noms des backends enregistrés (vide si aucun — jamais `null`).
14
+ */
15
+ export declare function listUserStores(): string[];