@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,129 @@
1
+ import type { IRepository } from "@nodefony/orm-core";
2
+ import type { IPage, IPageQuery, ISortableSource } from "nodefony";
3
+ import type { IPasswordAuthenticatedUser } from "./IUser.js";
4
+ /**
5
+ * Requête de **listing paginé** d'utilisateurs — le contrat de page standard du
6
+ * core ({@link IPageQuery}) enrichi des filtres propres à l'utilisateur.
7
+ *
8
+ * Ces filtres ne sont **pas portables** au `Criteria` générique de l'ORM (d'où un
9
+ * `listPage` natif par backend, pas un `paginate()` générique) :
10
+ * - `role` = appartenance dans le tableau **plat JSON** `roles` (containment :
11
+ * `json_each`/`@>`/`JSON_CONTAINS` en SQL, élément de tableau en Mongo) ;
12
+ * - `q` (hérité) = sous-chaîne **insensible à la casse** sur `identifier`
13
+ * (`LOWER(...) LIKE` en SQL, `$regex`/`i` en Mongo) — pas un `$eq`.
14
+ *
15
+ * `enabled`, lui, correspond à la colonne `enabled` (= `isActive()`) et serait
16
+ * portable ; il vit ici pour garder **un seul** objet de filtres du store.
17
+ */
18
+ export interface IUserListQuery extends IPageQuery {
19
+ /** Rôle plat devant figurer dans `roles` (containment natif). Omis = tous rôles. */
20
+ role?: string;
21
+ /** Restreint à l'état actif (`isActive()`). Omis = actifs ET inactifs. */
22
+ enabled?: boolean;
23
+ /**
24
+ * Restreint aux comptes **verrouillés** (`isLocked()`), ou aux non verrouillés.
25
+ * Omis = les deux.
26
+ *
27
+ * Distinct d'`enabled` : un compte désactivé l'a été par décision
28
+ * d'administration, un compte verrouillé l'est par un mécanisme de défense
29
+ * (trop d'échecs d'authentification). Les deux peuvent coexister sur le même
30
+ * compte — les compter séparément est le seul moyen de savoir lequel des deux
31
+ * bloque une population.
32
+ */
33
+ locked?: boolean;
34
+ /**
35
+ * Restreint aux comptes liés à **au moins un fournisseur d'identité externe**
36
+ * (OAuth), ou à ceux qui n'en ont aucun. Omis = les deux.
37
+ *
38
+ * Répond à « combien de comptes dépendent d'un fournisseur tiers ? », question
39
+ * de gouvernance qu'aucun filtre existant ne posait — la console la calculait
40
+ * en rapatriant l'annuaire.
41
+ */
42
+ hasSocial?: boolean;
43
+ }
44
+ /**
45
+ * Repository **spécialisé utilisateur** — `IRepository<IPasswordAuthenticatedUser>`
46
+ * enrichi de finders métier.
47
+ *
48
+ * Étend le contrat CRUD portable de `@nodefony/orm-core` (`find`, `create`,
49
+ * `withTransaction`...) avec les accès propres à l'authentification. Implémenté
50
+ * une fois par adapter (Mongoose/Drizzle, P5.8–5.9) ; l'ORM concret
51
+ * reste invisible des consommateurs (DI : `@Inject('repository.user')`).
52
+ *
53
+ * ## Écrire un champ MÉTIER de l'application
54
+ *
55
+ * Ce contrat est typé sur {@link IPasswordAuthenticatedUser} : `create()` et
56
+ * `updateOne()` **refusent** en TypeScript un champ que l'application a ajouté à
57
+ * sa table (`firstName`, `department`…). Ce n'est pas un oubli — le framework ne
58
+ * connaît que les colonnes de son contrat.
59
+ *
60
+ * La porte d'écriture est le **repository générique** de l'entité, obtenu depuis
61
+ * l'ORM et déjà exercé sur cette table :
62
+ *
63
+ * ```typescript
64
+ * const users = orm.getRepository<MonUtilisateur>("User");
65
+ * await users.create({ identifier: "carol@example.com", firstName: "Carol" });
66
+ * ```
67
+ *
68
+ * En LECTURE, rien à faire : les dépôts reportent sur l'utilisateur rendu toute
69
+ * colonne hors contrat, champs métier compris. Un champ écrit se relit donc, quel
70
+ * que soit le moteur.
71
+ *
72
+ * @remarks Type d'entité = {@link IPasswordAuthenticatedUser} (credential inclus),
73
+ * pas `IUser`. Le repository **est** la frontière de persistance du mot de passe :
74
+ * seul composant qui lit/écrit le hash (consommé par `UserService` et
75
+ * `@nodefony/security`). Le split credential protège les consommateurs *en aval*
76
+ * (framework/authz reçoivent `IUser` via `IUserProvider`), pas la couche de
77
+ * stockage qui, par nature, manipule le hash.
78
+ */
79
+ export interface IUserRepository extends IRepository<IPasswordAuthenticatedUser>, ISortableSource {
80
+ /**
81
+ * Retrouve un utilisateur par son identifiant fonctionnel (email, login...).
82
+ *
83
+ * @param identifier - identifiant unique.
84
+ * @returns l'utilisateur (credential inclus), ou `null` s'il n'existe pas.
85
+ */
86
+ findByIdentifier(identifier: string): Promise<IPasswordAuthenticatedUser | null>;
87
+ /**
88
+ * Retrouve un utilisateur lié à un compte d'un fournisseur OAuth/OIDC.
89
+ *
90
+ * @param provider - fournisseur (`"google"`, `"github"`...).
91
+ * @param providerId - identifiant du compte chez le fournisseur.
92
+ * @returns l'utilisateur lié, ou `null` si aucun lien.
93
+ */
94
+ findBySocialProvider(provider: string, providerId: string): Promise<IPasswordAuthenticatedUser | null>;
95
+ /**
96
+ * Liste **paginée NATIVEMENT** d'utilisateurs — ne matérialise **jamais** plus
97
+ * d'une page en mémoire (règle perf/mémoire absolue de Nodefony). Applique les
98
+ * filtres {@link IUserListQuery} au niveau du store (SQL `WHERE`/`LIMIT/OFFSET`,
99
+ * Mongo `find/skip/limit`), pas après un chargement complet.
100
+ *
101
+ * Tri par défaut = `identifier ASC` (ordre **déterministe** requis par la
102
+ * pagination offset ; surchargé par `query.order`).
103
+ *
104
+ * @param query - filtres + fenêtre de page ({@link IUserListQuery}).
105
+ * @returns une {@link IPage} : au plus `limit` items, `hasNext`, et `total` si
106
+ * `withTotal` n'est pas `false`.
107
+ */
108
+ listPage(query: IUserListQuery): Promise<IPage<IPasswordAuthenticatedUser>>;
109
+ /**
110
+ * Compte les administrateurs **actifs** (`isActive()` **et** porteurs de
111
+ * `adminRole`) — garde-fou anti-lockout, calculé **au store** (SQL `COUNT` avec
112
+ * containment de rôle), jamais en chargeant tous les utilisateurs.
113
+ *
114
+ * @param adminRole - rôle d'administration à dénombrer (ex. `ROLE_NODEFONY_ADMIN`).
115
+ * @returns le nombre d'admins actifs portant ce rôle.
116
+ */
117
+ countActiveAdmins(adminRole: string): Promise<number>;
118
+ /**
119
+ * Compte les comptes correspondant aux filtres, **sans les énumérer**.
120
+ *
121
+ * Alimente les compteurs de tête de la console d'administration, qui portent
122
+ * sur l'annuaire entier et non sur la page affichée. `limit`/`offset` sont
123
+ * ignorés : un comptage n'a pas de fenêtre.
124
+ *
125
+ * @param query - les filtres seuls ({@link IUserListQuery}).
126
+ * @returns le nombre de comptes correspondants.
127
+ */
128
+ countUsers(query: IUserListQuery): Promise<number>;
129
+ }
@@ -0,0 +1,7 @@
1
+ export type { IUser, IPasswordAuthenticatedUser, ISocialProvider, } from "./IUser.js";
2
+ export type { IPasswordBlocklist } from "./IPasswordBlocklist.js";
3
+ export type { IPasswordEncoder } from "./IPasswordEncoder.js";
4
+ export type { IPasswordVerifier } from "./IPasswordVerifier.js";
5
+ export type { IUserProvider } from "./IUserProvider.js";
6
+ export type { IUserRepository, IUserListQuery } from "./IUserRepository.js";
7
+ export type { IOAuthProfile, IOAuthProvisionPolicy, IOAuthUserProvisioner, } from "./IOAuthUserProvisioner.js";
@@ -0,0 +1,15 @@
1
+ import { nodefonyError } from "nodefony";
2
+ /**
3
+ * Utilisateur introuvable dans la source d'identité — levée par les méthodes
4
+ * {@link IUserProvider} (contrat : jamais `null`, l'absence d'identité est un
5
+ * échec explicite).
6
+ *
7
+ * `code = 404` (sémantique interne). Les authenticators de `@nodefony/security`
8
+ * la convertissent en `AuthenticationError` générique : le détail (identifiant
9
+ * inconnu vs mauvais mot de passe) ne doit JAMAIS atteindre le client
10
+ * (anti-énumération de comptes) — il reste réservé aux logs/audit serveur.
11
+ */
12
+ export declare class UserNotFoundError extends nodefonyError {
13
+ constructor(detail: string);
14
+ }
15
+ export default UserNotFoundError;
@@ -0,0 +1,12 @@
1
+ import { nodefonyError } from "nodefony";
2
+ /**
3
+ * Mot de passe refusé par la politique (connu-compromis / interdit) — `code = 400`.
4
+ *
5
+ * Levée par `UserService.createUser`/`changePassword` quand le
6
+ * {@link IPasswordBlocklist} branché rejette le candidat. Le message reste
7
+ * générique : ni la source de la liste ni le mot de passe ne sont exposés.
8
+ */
9
+ export declare class WeakPasswordError extends nodefonyError {
10
+ constructor();
11
+ }
12
+ export default WeakPasswordError;
@@ -0,0 +1,179 @@
1
+ import { AbstractCrudService } from "@nodefony/orm-core";
2
+ import type { ServiceWiring } from "@nodefony/orm-core";
3
+ import type { IPage } from "nodefony";
4
+ import { type IUserCounts } from "../src/userFilters.js";
5
+ import type { IUser, IPasswordAuthenticatedUser } from "../contracts/IUser.js";
6
+ import type { IUserListQuery } from "../contracts/IUserRepository.js";
7
+ import type { IPasswordBlocklist } from "../contracts/IPasswordBlocklist.js";
8
+ import type { IPasswordEncoder } from "../contracts/IPasswordEncoder.js";
9
+ import type { IPasswordVerifier } from "../contracts/IPasswordVerifier.js";
10
+ import type { IUserProvider } from "../contracts/IUserProvider.js";
11
+ import type { IUserRepository } from "../contracts/IUserRepository.js";
12
+ import type { IOAuthProfile, IOAuthProvisionPolicy, IOAuthUserProvisioner } from "../contracts/IOAuthUserProvisioner.js";
13
+ /**
14
+ * Données d'entrée de création d'un utilisateur — le mot de passe est fourni en
15
+ * **clair** puis haché par {@link UserService.createUser} (jamais persisté tel quel).
16
+ */
17
+ export interface ICreateUserInput {
18
+ /** Identifiant fonctionnel (email, login...). Unique. */
19
+ identifier: string;
20
+ /** Mot de passe en clair, ou `null`/absent pour un compte sans credential local. */
21
+ plainPassword?: string | null;
22
+ /** Rôles plats initiaux. Défaut : `[]`. */
23
+ roles?: string[];
24
+ }
25
+ /** Raison d'un échec d'authentification (émise avec `onAuthenticationFailure`). */
26
+ export type AuthFailureReason = "unknown_identifier" | "disabled" | "locked" | "no_password" | "bad_credentials";
27
+ /**
28
+ * Service applicatif **utilisateur** — CRUD haché + authentification + events de cycle de vie.
29
+ *
30
+ * Spécialisation d'{@link AbstractCrudService} sur l'entité utilisateur : hérite du
31
+ * CRUD générique (`find`/`findOne`/`findById`/`count`/`create`/`update`/`delete` +
32
+ * events `onCreated`/`onUpdated`/`onDeleted`) et n'ajoute que le **spécifique
33
+ * credential** : hachage à la création (`createUser`), changement de mot de passe
34
+ * (`changePassword`), recherche par identifiant fonctionnel et authentification.
35
+ *
36
+ * Singleton DI stateless (cf {@link AbstractCrudService}) : aucun état par requête
37
+ * (le credential est lu depuis le repository, le hash leurre est un cache immuable).
38
+ *
39
+ * Events propres (en plus des events CRUD hérités) : `onPasswordChanged`,
40
+ * `onAuthenticated`, `onAuthenticationFailure` (avec {@link AuthFailureReason}).
41
+ *
42
+ * @typeParam — fixé : `T = IPasswordAuthenticatedUser` (le repository est la
43
+ * frontière credential), `R = IUserRepository` (conserve les finders métier).
44
+ */
45
+ export declare class UserService extends AbstractCrudService<IPasswordAuthenticatedUser, IUserRepository> implements IUserProvider, IPasswordVerifier, IOAuthUserProvisioner {
46
+ #private;
47
+ protected readonly encoder: IPasswordEncoder;
48
+ /**
49
+ * Liste de blocage des mots de passe compromis (NIST SP 800-63B §5.1.1.2) —
50
+ * hook opt-in consulté à la création/changement (jamais au login). `null`
51
+ * par défaut : le framework fournit le point d'extension, l'application
52
+ * branche sa source (top-10k, fichier, API k-anonymity).
53
+ */
54
+ passwordBlocklist: IPasswordBlocklist | null;
55
+ /**
56
+ * @param repository - source de persistance des utilisateurs (credential inclus).
57
+ * @param encoder - encodeur de mot de passe (hash/verify/needsRehash).
58
+ * @param wiring - câblage Service ({@link ServiceWiring}) — quasi toujours omis.
59
+ */
60
+ constructor(repository: IUserRepository, encoder: IPasswordEncoder, ...wiring: ServiceWiring);
61
+ /**
62
+ * Crée un utilisateur — hache le mot de passe en clair s'il est fourni, puis
63
+ * délègue au `create` générique (hooks + event `onCreated`).
64
+ *
65
+ * @param input - identité + mot de passe en clair optionnel + rôles.
66
+ * @returns l'utilisateur persisté (id généré, hash stocké).
67
+ */
68
+ createUser(input: ICreateUserInput): Promise<IPasswordAuthenticatedUser>;
69
+ /**
70
+ * Charge un utilisateur par son identifiant fonctionnel (email, login...).
71
+ *
72
+ * @param identifier - identifiant unique.
73
+ * @returns l'utilisateur, ou `null`.
74
+ */
75
+ findByIdentifier(identifier: string): Promise<IPasswordAuthenticatedUser | null>;
76
+ /**
77
+ * Liste **paginée nativement** d'utilisateurs (filtres role/enabled/q) — délègue
78
+ * au repository, qui ne matérialise jamais plus d'une page. À préférer
79
+ * systématiquement à `find()` pour tout listing (data plane admin, écran).
80
+ *
81
+ * @param query - filtres + fenêtre de page.
82
+ * @returns une page d'utilisateurs ({@link IPage}).
83
+ */
84
+ listPage(query: IUserListQuery): Promise<IPage<IPasswordAuthenticatedUser>>;
85
+ /**
86
+ * Champs de tri que le repository **actuellement branché** sait honorer.
87
+ *
88
+ * La capacité se CONSTATE au runtime plutôt que de se déduire : un adapter
89
+ * tiers qui ne trierait pas rend une liste vide, et le data plane refuse alors
90
+ * tout `?order=` (400) au lieu de servir une page dans un ordre arbitraire.
91
+ *
92
+ * @returns les champs triables, liste vide si le repository ne trie pas.
93
+ */
94
+ sortableFields(): readonly string[];
95
+ /**
96
+ * Compte les administrateurs actifs porteurs de `adminRole` — garde-fou
97
+ * anti-lockout calculé au store (jamais en chargeant tous les utilisateurs).
98
+ *
99
+ * @param adminRole - rôle d'administration à dénombrer.
100
+ * @returns le nombre d'admins actifs.
101
+ */
102
+ countActiveAdmins(adminRole: string): Promise<number>;
103
+ /**
104
+ * Les compteurs de tête de la console — posés sur l'annuaire ENTIER, pas sur
105
+ * la page affichée.
106
+ *
107
+ * Les populations se **recoupent** (un compte peut être désactivé ET
108
+ * verrouillé, un administrateur peut avoir un lien social) : chacune est
109
+ * comptée, aucune n'est déduite d'une autre.
110
+ *
111
+ * `admins` est composé ici et non déclaré dans {@link USER_FACETS} : le rôle
112
+ * d'administration est une valeur de configuration, pas une constante du
113
+ * vocabulaire — l'inscrire dans la table figerait `ROLE_NODEFONY_ADMIN` pour
114
+ * une plateforme qui peut le renommer.
115
+ *
116
+ * @param adminRole - rôle d'administration à dénombrer.
117
+ * @param query - filtres à appliquer avant comptage (sans fenêtre).
118
+ */
119
+ countUserFacets(adminRole: string, query?: Partial<IUserListQuery>): Promise<IUserCounts>;
120
+ /**
121
+ * Change le mot de passe d'un utilisateur — hache le clair avant persistance.
122
+ *
123
+ * Distinct du `update` générique : émet l'event credential `onPasswordChanged`,
124
+ * pas `onUpdated`.
125
+ *
126
+ * @param id - identifiant interne ciblé.
127
+ * @param plainPassword - nouveau mot de passe en clair.
128
+ * @returns l'utilisateur mis à jour, ou `null`.
129
+ */
130
+ changePassword(id: string, plainPassword: string): Promise<IPasswordAuthenticatedUser | null>;
131
+ /**
132
+ * Authentifie par identifiant + mot de passe.
133
+ *
134
+ * Vérifie l'existence, l'état du compte (actif, non verrouillé), la présence d'un
135
+ * credential local, puis le mot de passe. Au succès, re-hache de façon transparente
136
+ * si le coût stocké est obsolète ({@link IPasswordEncoder.needsRehash}). **Tous** les
137
+ * chemins d'échec (identifiant inconnu, compte verrouillé/désactivé, compte sans
138
+ * password, mauvais mot de passe) consomment exactement une opération de hachage
139
+ * (vérification réelle ou hash leurre) pour niveler le temps de réponse : le message
140
+ * 401 est uniforme, le timing doit l'être aussi (anti énumération de comptes, OWASP).
141
+ *
142
+ * @param identifier - identifiant fonctionnel saisi.
143
+ * @param plain - mot de passe en clair saisi.
144
+ * @returns l'utilisateur authentifié, ou `null` en cas d'échec. Émet
145
+ * `onAuthenticated` (succès) ou `onAuthenticationFailure` (échec + raison).
146
+ */
147
+ authenticate(identifier: string, plain: string): Promise<IPasswordAuthenticatedUser | null>;
148
+ /**
149
+ * {@inheritDoc IUserProvider.loadUserByIdentifier}
150
+ */
151
+ loadUserByIdentifier(identifier: string): Promise<IUser>;
152
+ /**
153
+ * {@inheritDoc IUserProvider.loadUserByOAuth}
154
+ *
155
+ * @remarks Pas de provisionnement *Shadow User* ici : la création de la ligne
156
+ * locale au premier login externe est portée par `provisionOAuthUser()`, que
157
+ * `OAuth2Service.exchangeAndProvision()` (`@nodefony/security`) appelle après
158
+ * l'échange du code — le provider, lui, ne fait que lire.
159
+ */
160
+ loadUserByOAuth(provider: string, providerId: string): Promise<IUser>;
161
+ /**
162
+ * {@inheritDoc IUserProvider.refreshUser}
163
+ */
164
+ refreshUser(user: IUser): Promise<IUser>;
165
+ /**
166
+ * {@inheritDoc IOAuthUserProvisioner.provisionOAuthUser}
167
+ *
168
+ * @remarks Implémentation **par défaut** (find-or-create) : lit le lien
169
+ * existant, sinon — si `allowSignup` — crée une ligne locale 100 % OAuth
170
+ * (`password: null`, rôles = `policy.defaultRoles`) liée au compte externe.
171
+ * **Aucune liaison automatique** à un compte local existant par email (un email
172
+ * non vérifié serait un vecteur d'usurpation, OWASP) : un compte externe non lié
173
+ * donne TOUJOURS un nouvel utilisateur. Le rattachement à un compte existant se
174
+ * fait explicitement, utilisateur connecté (hors P6).
175
+ */
176
+ provisionOAuthUser(profile: IOAuthProfile, policy: IOAuthProvisionPolicy): Promise<IUser>;
177
+ private fail;
178
+ private consumeDummy;
179
+ }
@@ -0,0 +1,27 @@
1
+ import type { IUser } from "../contracts/IUser.js";
2
+ /** Rôle unique d'un utilisateur non authentifié. */
3
+ export declare const ROLE_ANONYMOUS = "ROLE_ANONYMOUS";
4
+ /**
5
+ * Utilisateur **non authentifié** — implémente {@link IUser} sans credential.
6
+ *
7
+ * Permet de typer le contexte de sécurité sans `null` (Zero Trust : un visiteur
8
+ * est un utilisateur anonyme, pas une absence d'utilisateur). `@CurrentUser`
9
+ * retourne `IUser | AnonymousUser`, jamais `null`. Sans état mutable : un
10
+ * {@link anonymousUser} singleton est réutilisé pour éviter toute allocation par
11
+ * requête.
12
+ */
13
+ export declare class AnonymousUser implements IUser {
14
+ readonly id = "anonymous";
15
+ readonly identifier = "anon.";
16
+ readonly roles: string[];
17
+ hasRole(role: string): boolean;
18
+ /** Un anonyme est utilisable (non désactivé). @returns `true`. */
19
+ isActive(): boolean;
20
+ /** Un anonyme n'est jamais verrouillé. @returns `false`. */
21
+ isLocked(): boolean;
22
+ }
23
+ /**
24
+ * Singleton d'{@link AnonymousUser} — instance partagée et gelée à réutiliser à
25
+ * chaque requête non authentifiée (zéro allocation dans le hot path).
26
+ */
27
+ export declare const anonymousUser: AnonymousUser;
@@ -0,0 +1,98 @@
1
+ import type { IPasswordAuthenticatedUser, ISocialProvider } from "../contracts/IUser.js";
2
+ /**
3
+ * Options de construction d'un {@link BaseUser} — signature en objet (lisibilité + extensibilité).
4
+ */
5
+ export interface IBaseUserOptions {
6
+ /** Identifiant interne (UUID). */
7
+ id: string;
8
+ /** Identifiant fonctionnel (email, login...). */
9
+ identifier: string;
10
+ /** Rôles plats accordés (copiés défensivement). Défaut : `[]`. */
11
+ roles?: string[];
12
+ /** Hash du mot de passe local, ou `null` (compte OAuth-only). Défaut : `null`. */
13
+ password?: string | null;
14
+ /** Compte activé. Défaut : `true`. */
15
+ enabled?: boolean;
16
+ /** Compte verrouillé. Défaut : `false`. */
17
+ locked?: boolean;
18
+ /** Profil de rôle actif en session. Défaut : `null`. */
19
+ currentRole?: string | null;
20
+ /** Comptes externes liés (OAuth/OIDC). Défaut : `[]`. */
21
+ socialProviders?: ISocialProvider[];
22
+ /** Métadonnées applicatives libres. Défaut : `{}`. */
23
+ metadata?: Record<string, unknown>;
24
+ }
25
+ /**
26
+ * Implémentation POJO de référence d'un utilisateur — base partagée par tous les ORM.
27
+ *
28
+ * Porte le contrat {@link IPasswordAuthenticatedUser} plus les **champs
29
+ * anti-migration** : `socialProviders` (JSON, pas de colonnes par fournisseur),
30
+ * `metadata` (extras libres typés `Record<string, unknown>`, jamais `any`),
31
+ * `currentRole` (profil actif de session). Les entités persistées des adapters
32
+ * (`MongooseUser`, `DrizzleUser`...) étendent cette classe ; Drizzle la mappe.
33
+ *
34
+ * Hors hot path requête (instanciée à l'authentification, pas par requête) : les
35
+ * allocations de `roles`/`socialProviders`/`metadata` y sont acceptables. Pour un
36
+ * utilisateur anonyme (créé par requête non authentifiée), préférer
37
+ * {@link AnonymousUser} et son singleton.
38
+ */
39
+ export declare class BaseUser implements IPasswordAuthenticatedUser {
40
+ readonly id: string;
41
+ readonly identifier: string;
42
+ roles: string[];
43
+ password: string | null;
44
+ /** Profil de rôle actif en session (P5.11) — distinct des rôles plats. */
45
+ currentRole: string | null;
46
+ socialProviders: ISocialProvider[];
47
+ metadata: Record<string, unknown>;
48
+ protected enabled: boolean;
49
+ protected locked: boolean;
50
+ constructor(options: IBaseUserOptions);
51
+ hasRole(role: string): boolean;
52
+ isActive(): boolean;
53
+ isLocked(): boolean;
54
+ /**
55
+ * Ajoute un rôle s'il n'est pas déjà présent (idempotent).
56
+ *
57
+ * @param role - rôle à accorder.
58
+ * @returns `this` (chaînable).
59
+ */
60
+ addRole(role: string): this;
61
+ /**
62
+ * Retire un rôle s'il est présent (idempotent).
63
+ *
64
+ * @param role - rôle à révoquer.
65
+ * @returns `this` (chaînable).
66
+ */
67
+ removeRole(role: string): this;
68
+ /**
69
+ * Lie un compte externe (OAuth/OIDC) — pattern Shadow User. Idempotent sur la
70
+ * paire `(provider, providerId)`.
71
+ *
72
+ * @param link - référence du compte externe.
73
+ * @returns `this` (chaînable).
74
+ */
75
+ addSocialProvider(link: ISocialProvider): this;
76
+ /** Active le compte. @returns `this`. */
77
+ enable(): this;
78
+ /** Désactive le compte. @returns `this`. */
79
+ disable(): this;
80
+ /** Verrouille le compte. @returns `this`. */
81
+ lock(): this;
82
+ /** Déverrouille le compte. @returns `this`. */
83
+ unlock(): this;
84
+ /**
85
+ * Définit le profil de rôle actif (session). N'altère pas {@link roles}.
86
+ *
87
+ * @param role - rôle actif, ou `null` pour réinitialiser.
88
+ * @returns `this`.
89
+ */
90
+ setCurrentRole(role: string | null): this;
91
+ /**
92
+ * Remplace le hash de mot de passe stocké.
93
+ *
94
+ * @param hash - nouveau hash (déjà produit par un {@link IPasswordEncoder}), ou `null`.
95
+ * @returns `this`.
96
+ */
97
+ setPassword(hash: string | null): this;
98
+ }
@@ -0,0 +1,73 @@
1
+ import type { Criteria, ITransaction } from "@nodefony/orm-core";
2
+ import type { IPage } from "nodefony";
3
+ import type { IBaseUserOptions } from "./BaseUser.js";
4
+ import type { IPasswordAuthenticatedUser, IUserListQuery, IUserRepository } from "../contracts/index.js";
5
+ /**
6
+ * Annuaire d'utilisateurs **en mémoire** — implémentation de référence du contrat
7
+ * {@link IUserRepository} sur une `Map` (aucun ORM).
8
+ *
9
+ * Trois usages :
10
+ * - **tests de charge** : zéro I/O (pas de sync SQLite) → la mesure n'est pas
11
+ * polluée par la persistance ;
12
+ * - **scripts / tests manuels** : démarrer sans base de données ;
13
+ * - **fixture de banc** déterministe (l'état est reconstruit à chaque boot).
14
+ *
15
+ * La persistance réelle (`@nodefony/drizzle` / `@nodefony/mongoose`) prend ce rôle
16
+ * en application — **même contrat, zéro changement en aval** (`UserService`,
17
+ * authenticators). Branché sous `UserService` comme n'importe quel repository.
18
+ */
19
+ export declare class InMemoryUserRepository implements IUserRepository {
20
+ #private;
21
+ /**
22
+ * Capacité RÉELLE de cet annuaire : `BaseUser` ne porte ni `createdAt` ni
23
+ * `updatedAt`, donc ils ne sont pas annoncés. Le data plane refuse alors ces
24
+ * champs en 400 au lieu de rendre un ordre arbitraire.
25
+ */
26
+ readonly sortableFields: readonly ["identifier", "enabled", "id"];
27
+ /**
28
+ * @param seed - comptes initiaux (identité + rôles + hash de mot de passe
29
+ * éventuel). Hacher en amont (hash pré-calculé) évite tout coût CPU au boot.
30
+ */
31
+ constructor(seed?: IBaseUserOptions[]);
32
+ find(criteria?: Criteria<IPasswordAuthenticatedUser>): Promise<IPasswordAuthenticatedUser[]>;
33
+ findOne(criteria: Criteria<IPasswordAuthenticatedUser>): Promise<IPasswordAuthenticatedUser | null>;
34
+ create(data: Partial<IPasswordAuthenticatedUser>): Promise<IPasswordAuthenticatedUser>;
35
+ updateOne(criteria: Criteria<IPasswordAuthenticatedUser>, data: Partial<IPasswordAuthenticatedUser>): Promise<IPasswordAuthenticatedUser | null>;
36
+ upsert(criteria: Criteria<IPasswordAuthenticatedUser>, update: Partial<IPasswordAuthenticatedUser>, insertOnly?: Partial<IPasswordAuthenticatedUser>): Promise<IPasswordAuthenticatedUser>;
37
+ createMany(data: Partial<IPasswordAuthenticatedUser>[]): Promise<IPasswordAuthenticatedUser[]>;
38
+ exists(criteria: Criteria<IPasswordAuthenticatedUser>): Promise<boolean>;
39
+ deleteOne(criteria: Criteria<IPasswordAuthenticatedUser>): Promise<boolean>;
40
+ findOneAndDelete(criteria: Criteria<IPasswordAuthenticatedUser>): Promise<IPasswordAuthenticatedUser | null>;
41
+ increment(criteria: Criteria<IPasswordAuthenticatedUser>, changes: Partial<Record<keyof IPasswordAuthenticatedUser, number>>): Promise<IPasswordAuthenticatedUser | null>;
42
+ updateMany(criteria: Criteria<IPasswordAuthenticatedUser>, data: Partial<IPasswordAuthenticatedUser>): Promise<number>;
43
+ delete(criteria: Criteria<IPasswordAuthenticatedUser>): Promise<number>;
44
+ count(criteria?: Criteria<IPasswordAuthenticatedUser>): Promise<number>;
45
+ /**
46
+ * Déduplication en mémoire — l'annuaire est déjà entièrement chargé, il n'y a
47
+ * donc pas de parcours à éviter comme en SQL. `null`/`undefined` sont écartés
48
+ * pour tenir la même sémantique que `COUNT(DISTINCT col)`.
49
+ */
50
+ countDistinct(field: keyof IPasswordAuthenticatedUser & string, criteria?: Criteria<IPasswordAuthenticatedUser>): Promise<number>;
51
+ /** In-memory : pas de transaction — le repository est sa propre unité. */
52
+ withTransaction(_tx: ITransaction): IUserRepository;
53
+ findByIdentifier(identifier: string): Promise<IPasswordAuthenticatedUser | null>;
54
+ findBySocialProvider(provider: string, providerId: string): Promise<IPasswordAuthenticatedUser | null>;
55
+ /**
56
+ * {@inheritDoc IUserRepository.listPage}
57
+ *
58
+ * In-memory : la collection est déjà en RAM (bornée par conception), donc le
59
+ * filtrage/tri/slice se fait sur la structure — pas de matérialisation
60
+ * supplémentaire. `total` gratuit (longueur du filtré) sauf `withTotal: false`.
61
+ */
62
+ listPage(query: IUserListQuery): Promise<IPage<IPasswordAuthenticatedUser>>;
63
+ /** {@inheritDoc IUserRepository.countActiveAdmins} */
64
+ /**
65
+ * {@inheritDoc IUserRepository.countUsers}
66
+ *
67
+ * Réutilise `listPage` avec une fenêtre nulle : le filtrage est écrit une
68
+ * seule fois, donc compter et lister ne peuvent pas diverger.
69
+ */
70
+ countUsers(query: IUserListQuery): Promise<number>;
71
+ countActiveAdmins(adminRole: string): Promise<number>;
72
+ }
73
+ export default InMemoryUserRepository;
@@ -0,0 +1,119 @@
1
+ import type { Container } from "nodefony";
2
+ import type { IAdminApi, IAdminRegistry } from "nodefony";
3
+ import type { IUser } from "../../contracts/IUser.js";
4
+ import type { IUserProfile } from "../../contracts/IUserProfile.js";
5
+ /**
6
+ * Projection **publique** d'un utilisateur pour l'ADMINISTRATION (Studio, P6.15).
7
+ * Redaction PAR CONSTRUCTION (allowlist) — ne porte **jamais** `password`, jamais
8
+ * `metadata` (peut contenir du sensible), et les liens sociaux sont exposés **sans
9
+ * jeton** (`provider`/`providerId`/`createdAt` seulement).
10
+ */
11
+ export interface IUserSummary {
12
+ id: string;
13
+ identifier: string;
14
+ roles: string[];
15
+ /** Compte actif (`isActive()`), pilotable via PATCH `enabled`. */
16
+ enabled: boolean;
17
+ /** Compte verrouillé (`isLocked()`), pilotable via PATCH `locked`. */
18
+ locked: boolean;
19
+ /**
20
+ * `true` si le compte a un mot de passe LOCAL (par opposition à OAuth-only).
21
+ * Présence seulement — **jamais** le hash. Permet au self-service de proposer
22
+ * « changer » (re-auth possible) vs « pas de mot de passe » (compte externe).
23
+ */
24
+ hasPassword: boolean;
25
+ /** Profil de rôle actif en session (P5.11), ou `null`. */
26
+ currentRole: string | null;
27
+ /** Comptes externes liés — jamais de jeton, seulement la référence. */
28
+ socialProviders: {
29
+ provider: string;
30
+ providerId: string;
31
+ createdAt: number | null;
32
+ }[];
33
+ /**
34
+ * Profil d'affichage (claims OIDC : prénom/nom/email/locale/avatar), lu par
35
+ * allowlist depuis `metadata.profile` — jamais les autres clés de `metadata`.
36
+ */
37
+ profile: IUserProfile;
38
+ /** Création (epoch ms) si l'entité la porte (ORM), sinon `null`. */
39
+ createdAt: number | null;
40
+ /** Dernière mise à jour (epoch ms) si connue, sinon `null`. */
41
+ updatedAt: number | null;
42
+ /** Réserve multi-tenant (toujours `null` en mono-tenant — slot coût-0). */
43
+ tenantId: string | null;
44
+ }
45
+ /**
46
+ * Projette un {@link IUser} en {@link IUserSummary} redacté. Fonction **pure**
47
+ * (cœur de la garantie anti-fuite, testée isolément) : `currentRole`/
48
+ * `socialProviders`/timestamps sont lus **défensivement** (présents sur l'entité
49
+ * ORM, absents du contrat strict `IUser`) — `password`/`metadata` jamais lus.
50
+ */
51
+ export declare function toUserSummary(user: IUser): IUserSummary;
52
+ /**
53
+ * Nom de l'événement kernel émis quand l'accès d'un utilisateur doit être révoqué
54
+ * partout (suppression / désactivation / verrouillage). **Point d'extension** : un
55
+ * module qui possède des artefacts liés à un user (sessions, tokens, webhooks…)
56
+ * s'y abonne et nettoie LES SIENS — zéro couplage avec `@nodefony/user`.
57
+ */
58
+ export declare const USER_REVOKED_EVENT = "onUserRevoked";
59
+ /**
60
+ * Charge utile de {@link USER_REVOKED_EVENT}. `identifier` = clé de jointure des
61
+ * artefacts (session `user`, PAT `subjectId`). `tenantId` = slot multi-tenant
62
+ * (toujours `null` en mono-tenant — réserve coût-0 pour une cascade scopée).
63
+ */
64
+ export interface IUserRevokedEvent {
65
+ id: string;
66
+ identifier: string;
67
+ tenantId: string | null;
68
+ reason: "deleted" | "disabled" | "locked";
69
+ }
70
+ /**
71
+ * Statut du sous-système utilisateur — miroir consommé par la console Studio
72
+ * (« où on écrit »). `store` = **backend** de persistance (`memory`/`drizzle`/
73
+ * `mongoose`, `null` si indéterminable) — aligné sur le vocabulaire config 0.8
74
+ * (données = `store`) et l'écran Studio « Stores ». `repository` = nom de la
75
+ * classe réelle du dépôt. Aucun secret/hash : seulement la topologie.
76
+ */
77
+ export interface IUsersStatus {
78
+ enabled: boolean;
79
+ /** Backend de persistance (`memory`/`drizzle`/`mongoose`), ou `null` si indéterminable. */
80
+ store: "memory" | "drizzle" | "mongoose" | null;
81
+ /**
82
+ * Backends de persistance DISPONIBLES (`listUserStores()` : memory builtin +
83
+ * adapters ORM chargés) — le résolu `store` en fait toujours partie. Aligne la
84
+ * brique « user » sur les 7 autres de l'écran Studio « Stores » (résolu parmi
85
+ * disponibles), au lieu du trompeur `[store]`.
86
+ */
87
+ available: string[];
88
+ /** Nom de classe du repository réel (ex. `DrizzleUserRepository`), `"none"` si absent. */
89
+ repository: string;
90
+ /** Nombre d'utilisateurs si dénombrable (lecture défensive), sinon `null`. */
91
+ count: number | null;
92
+ /** Réserve multi-tenant (toujours `null` en mono-tenant — slot coût-0). */
93
+ tenantId: string | null;
94
+ }
95
+ /**
96
+ * Producteur `IAdminApi` du domaine **utilisateur** — exposé sous
97
+ * `/nodefony/user/api/users`. Défini DANS `@nodefony/user` (propriétaire du
98
+ * `UserService`/`IUser`) mais **enregistré par un module bootable**
99
+ * (`@nodefony/security`, via {@link registerUserAdminApi}) car `@nodefony/user`
100
+ * est une lib pure non-bootable — exactement le cas prévu par le core : un
101
+ * module qui ne dépend que de `nodefony` produit sa donnée d'admin.
102
+ *
103
+ * RBAC `ROLE_NODEFONY_ADMIN` (défaut broker, 403 sinon). Mutations = HTTP
104
+ * (pipeline CSRF), auditées (catégorie `authz`). Garde-fous **anti-lockout** :
105
+ * pas d'auto-déchéance, pas de suppression/désactivation du dernier admin actif.
106
+ *
107
+ * @param container - container du kernel (résolution lazy du service `users`).
108
+ */
109
+ export declare function createUserAdminApi(container: Container): IAdminApi;
110
+ /**
111
+ * Enregistre le producteur admin utilisateur sur le broker — **idempotent**.
112
+ * À appeler au `onKernelBoot` d'un module **bootable** qui dépend de
113
+ * `@nodefony/user` (typiquement `@nodefony/security`), `@nodefony/user` n'étant
114
+ * pas lui-même un module.
115
+ *
116
+ * @param registry - broker admin (`container.get("adminBroker")`).
117
+ * @param container - container du kernel (capturé par les handlers lazy).
118
+ */
119
+ export declare function registerUserAdminApi(registry: IAdminRegistry, container: Container): void;