@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.
- package/LICENSE +544 -0
- package/README.md +120 -0
- package/dist/index.js +16 -0
- package/dist/nodefony/contracts/IOAuthUserProvisioner.js +1 -0
- package/dist/nodefony/contracts/IPasswordBlocklist.js +1 -0
- package/dist/nodefony/contracts/IPasswordEncoder.js +1 -0
- package/dist/nodefony/contracts/IPasswordVerifier.js +1 -0
- package/dist/nodefony/contracts/IUser.js +1 -0
- package/dist/nodefony/contracts/IUserProfile.js +1 -0
- package/dist/nodefony/contracts/IUserProvider.js +1 -0
- package/dist/nodefony/contracts/IUserRepository.js +1 -0
- package/dist/nodefony/contracts/index.js +1 -0
- package/dist/nodefony/errors/UserNotFoundError.js +19 -0
- package/dist/nodefony/errors/WeakPasswordError.js +16 -0
- package/dist/nodefony/service/UserService.js +280 -0
- package/dist/nodefony/src/AnonymousUser.js +37 -0
- package/dist/nodefony/src/BaseUser.js +121 -0
- package/dist/nodefony/src/InMemoryUserRepository.js +230 -0
- package/dist/nodefony/src/admin/UserAdminApi.js +588 -0
- package/dist/nodefony/src/encoders/Argon2idEncoder.js +107 -0
- package/dist/nodefony/src/encoders/BcryptEncoder.js +75 -0
- package/dist/nodefony/src/encoders/MigratingEncoder.js +88 -0
- package/dist/nodefony/src/encoders/encoderFromConfig.js +37 -0
- package/dist/nodefony/src/userContract.js +247 -0
- package/dist/nodefony/src/userFilters.js +66 -0
- package/dist/nodefony/src/userProfile.js +195 -0
- package/dist/nodefony/src/userSort.js +53 -0
- package/dist/nodefony/src/userStoreRegistry.js +41 -0
- package/dist/types/index.d.ts +40 -0
- package/dist/types/nodefony/contracts/IOAuthUserProvisioner.d.ts +69 -0
- package/dist/types/nodefony/contracts/IPasswordBlocklist.d.ts +20 -0
- package/dist/types/nodefony/contracts/IPasswordEncoder.d.ts +49 -0
- package/dist/types/nodefony/contracts/IPasswordVerifier.d.ts +26 -0
- package/dist/types/nodefony/contracts/IUser.d.ts +68 -0
- package/dist/types/nodefony/contracts/IUserProfile.d.ts +29 -0
- package/dist/types/nodefony/contracts/IUserProvider.d.ts +44 -0
- package/dist/types/nodefony/contracts/IUserRepository.d.ts +129 -0
- package/dist/types/nodefony/contracts/index.d.ts +7 -0
- package/dist/types/nodefony/errors/UserNotFoundError.d.ts +15 -0
- package/dist/types/nodefony/errors/WeakPasswordError.d.ts +12 -0
- package/dist/types/nodefony/service/UserService.d.ts +179 -0
- package/dist/types/nodefony/src/AnonymousUser.d.ts +27 -0
- package/dist/types/nodefony/src/BaseUser.d.ts +98 -0
- package/dist/types/nodefony/src/InMemoryUserRepository.d.ts +73 -0
- package/dist/types/nodefony/src/admin/UserAdminApi.d.ts +119 -0
- package/dist/types/nodefony/src/encoders/Argon2idEncoder.d.ts +83 -0
- package/dist/types/nodefony/src/encoders/BcryptEncoder.d.ts +55 -0
- package/dist/types/nodefony/src/encoders/MigratingEncoder.d.ts +68 -0
- package/dist/types/nodefony/src/encoders/encoderFromConfig.d.ts +36 -0
- package/dist/types/nodefony/src/userContract.d.ts +198 -0
- package/dist/types/nodefony/src/userFilters.d.ts +80 -0
- package/dist/types/nodefony/src/userProfile.d.ts +56 -0
- package/dist/types/nodefony/src/userSort.d.ts +41 -0
- package/dist/types/nodefony/src/userStoreRegistry.d.ts +15 -0
- package/docs/ajouter-des-champs.md +189 -0
- package/docs/index.md +1113 -0
- 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 };
|