@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,230 @@
|
|
|
1
|
+
import { BaseUser } from "./BaseUser.js";
|
|
2
|
+
import { USER_DEFAULT_ORDER, USER_SORTABLE_FIELDS_IN_MEMORY } from "./userSort.js";
|
|
3
|
+
import { attachExtraColumns } from "./userContract.js";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { assertPageQuery, compareByOrder } from "nodefony";
|
|
6
|
+
//#region nodefony/src/InMemoryUserRepository.ts
|
|
7
|
+
/**
|
|
8
|
+
* Annuaire d'utilisateurs **en mémoire** — implémentation de référence du contrat
|
|
9
|
+
* {@link IUserRepository} sur une `Map` (aucun ORM).
|
|
10
|
+
*
|
|
11
|
+
* Trois usages :
|
|
12
|
+
* - **tests de charge** : zéro I/O (pas de sync SQLite) → la mesure n'est pas
|
|
13
|
+
* polluée par la persistance ;
|
|
14
|
+
* - **scripts / tests manuels** : démarrer sans base de données ;
|
|
15
|
+
* - **fixture de banc** déterministe (l'état est reconstruit à chaque boot).
|
|
16
|
+
*
|
|
17
|
+
* La persistance réelle (`@nodefony/drizzle` / `@nodefony/mongoose`) prend ce rôle
|
|
18
|
+
* en application — **même contrat, zéro changement en aval** (`UserService`,
|
|
19
|
+
* authenticators). Branché sous `UserService` comme n'importe quel repository.
|
|
20
|
+
*/
|
|
21
|
+
var InMemoryUserRepository = class {
|
|
22
|
+
#store = /* @__PURE__ */ new Map();
|
|
23
|
+
/**
|
|
24
|
+
* Capacité RÉELLE de cet annuaire : `BaseUser` ne porte ni `createdAt` ni
|
|
25
|
+
* `updatedAt`, donc ils ne sont pas annoncés. Le data plane refuse alors ces
|
|
26
|
+
* champs en 400 au lieu de rendre un ordre arbitraire.
|
|
27
|
+
*/
|
|
28
|
+
sortableFields = USER_SORTABLE_FIELDS_IN_MEMORY;
|
|
29
|
+
/**
|
|
30
|
+
* @param seed - comptes initiaux (identité + rôles + hash de mot de passe
|
|
31
|
+
* éventuel). Hacher en amont (hash pré-calculé) évite tout coût CPU au boot.
|
|
32
|
+
*/
|
|
33
|
+
constructor(seed = []) {
|
|
34
|
+
for (const options of seed) {
|
|
35
|
+
const user = new BaseUser(options);
|
|
36
|
+
this.#store.set(user.id, user);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
#match(user, criteria) {
|
|
40
|
+
if (!criteria) return true;
|
|
41
|
+
return Object.entries(criteria).every(([key, value]) => user[key] === value);
|
|
42
|
+
}
|
|
43
|
+
find(criteria) {
|
|
44
|
+
return Promise.resolve([...this.#store.values()].filter((u) => this.#match(u, criteria)));
|
|
45
|
+
}
|
|
46
|
+
findOne(criteria) {
|
|
47
|
+
return Promise.resolve([...this.#store.values()].find((u) => this.#match(u, criteria)) ?? null);
|
|
48
|
+
}
|
|
49
|
+
create(data) {
|
|
50
|
+
const d = data;
|
|
51
|
+
const user = new BaseUser({
|
|
52
|
+
id: randomUUID(),
|
|
53
|
+
identifier: d.identifier,
|
|
54
|
+
roles: d.roles ? [...d.roles] : [],
|
|
55
|
+
password: d.password ?? null,
|
|
56
|
+
socialProviders: d.socialProviders,
|
|
57
|
+
enabled: d.enabled,
|
|
58
|
+
locked: d.locked
|
|
59
|
+
});
|
|
60
|
+
this.#store.set(user.id, user);
|
|
61
|
+
return Promise.resolve(attachExtraColumns(user, data));
|
|
62
|
+
}
|
|
63
|
+
updateOne(criteria, data) {
|
|
64
|
+
const user = [...this.#store.values()].find((u) => this.#match(u, criteria));
|
|
65
|
+
if (!user) return Promise.resolve(null);
|
|
66
|
+
this.#apply(user, data);
|
|
67
|
+
return Promise.resolve(user);
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Applique un patch sur une instance, **comme le ferait un backend réel**.
|
|
71
|
+
*
|
|
72
|
+
* Le contrat d'`IRepository.updateOne` promet d'écrire « les champs à
|
|
73
|
+
* modifier » : n'en honorer qu'une partie ferait de ce dépôt un menteur
|
|
74
|
+
* silencieux — un `{ enabled: false }` semblerait réussir (l'entité est
|
|
75
|
+
* renvoyée) sans rien désactiver, alors que Drizzle et Mongoose, eux,
|
|
76
|
+
* l'appliqueraient. Un banc de charge ou un test manuel en `NF_USER_STORE=memory`
|
|
77
|
+
* n'exercerait alors pas le comportement de production.
|
|
78
|
+
*
|
|
79
|
+
* `enabled`/`locked` sont `protected` sur {@link BaseUser} (l'état de compte
|
|
80
|
+
* s'exprime par des verbes) → on passe par ses méthodes plutôt que de forcer
|
|
81
|
+
* l'accès. `id` et `identifier` sont `readonly` : sur ce backend l'entité EST
|
|
82
|
+
* l'instance, sa clé n'est pas réécrivable — les ignorer est plus honnête que
|
|
83
|
+
* de muter un champ déclaré immuable.
|
|
84
|
+
*/
|
|
85
|
+
#apply(user, data) {
|
|
86
|
+
const d = data;
|
|
87
|
+
if ("password" in d) user.password = d.password ?? null;
|
|
88
|
+
if (d.roles) user.roles = [...d.roles];
|
|
89
|
+
if (d.socialProviders) user.socialProviders = [...d.socialProviders];
|
|
90
|
+
if (d.metadata) user.metadata = { ...d.metadata };
|
|
91
|
+
if ("currentRole" in d) user.currentRole = d.currentRole ?? null;
|
|
92
|
+
if (d.enabled !== void 0) {
|
|
93
|
+
if (d.enabled) user.enable();
|
|
94
|
+
else user.disable();
|
|
95
|
+
}
|
|
96
|
+
if (d.locked !== void 0) {
|
|
97
|
+
if (d.locked) user.lock();
|
|
98
|
+
else user.unlock();
|
|
99
|
+
}
|
|
100
|
+
attachExtraColumns(user, data);
|
|
101
|
+
}
|
|
102
|
+
async upsert(criteria, update, insertOnly) {
|
|
103
|
+
const updated = await this.updateOne(criteria, update);
|
|
104
|
+
if (updated) return updated;
|
|
105
|
+
return this.create({
|
|
106
|
+
...criteria,
|
|
107
|
+
...insertOnly,
|
|
108
|
+
...update
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
async createMany(data) {
|
|
112
|
+
const out = [];
|
|
113
|
+
for (const d of data) out.push(await this.create(d));
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
exists(criteria) {
|
|
117
|
+
return Promise.resolve([...this.#store.values()].some((u) => this.#match(u, criteria)));
|
|
118
|
+
}
|
|
119
|
+
deleteOne(criteria) {
|
|
120
|
+
for (const [id, user] of this.#store) if (this.#match(user, criteria)) {
|
|
121
|
+
this.#store.delete(id);
|
|
122
|
+
return Promise.resolve(true);
|
|
123
|
+
}
|
|
124
|
+
return Promise.resolve(false);
|
|
125
|
+
}
|
|
126
|
+
findOneAndDelete(criteria) {
|
|
127
|
+
for (const [id, user] of this.#store) if (this.#match(user, criteria)) {
|
|
128
|
+
this.#store.delete(id);
|
|
129
|
+
return Promise.resolve(user);
|
|
130
|
+
}
|
|
131
|
+
return Promise.resolve(null);
|
|
132
|
+
}
|
|
133
|
+
increment(criteria, changes) {
|
|
134
|
+
const user = [...this.#store.values()].find((u) => this.#match(u, criteria));
|
|
135
|
+
if (!user) return Promise.resolve(null);
|
|
136
|
+
const rec = user;
|
|
137
|
+
for (const [field, delta] of Object.entries(changes)) rec[field] = (rec[field] ?? 0) + delta;
|
|
138
|
+
return Promise.resolve(user);
|
|
139
|
+
}
|
|
140
|
+
async updateMany(criteria, data) {
|
|
141
|
+
const users = await this.find(criteria);
|
|
142
|
+
for (const user of users) await this.updateOne({ id: user.id }, data);
|
|
143
|
+
return users.length;
|
|
144
|
+
}
|
|
145
|
+
delete(criteria) {
|
|
146
|
+
let deleted = 0;
|
|
147
|
+
for (const [id, user] of this.#store) if (this.#match(user, criteria)) {
|
|
148
|
+
this.#store.delete(id);
|
|
149
|
+
deleted += 1;
|
|
150
|
+
}
|
|
151
|
+
return Promise.resolve(deleted);
|
|
152
|
+
}
|
|
153
|
+
async count(criteria) {
|
|
154
|
+
return (await this.find(criteria)).length;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Déduplication en mémoire — l'annuaire est déjà entièrement chargé, il n'y a
|
|
158
|
+
* donc pas de parcours à éviter comme en SQL. `null`/`undefined` sont écartés
|
|
159
|
+
* pour tenir la même sémantique que `COUNT(DISTINCT col)`.
|
|
160
|
+
*/
|
|
161
|
+
async countDistinct(field, criteria) {
|
|
162
|
+
const seen = /* @__PURE__ */ new Set();
|
|
163
|
+
for (const user of await this.find(criteria)) {
|
|
164
|
+
const value = user[field];
|
|
165
|
+
if (value !== null && value !== void 0) seen.add(value);
|
|
166
|
+
}
|
|
167
|
+
return seen.size;
|
|
168
|
+
}
|
|
169
|
+
/** In-memory : pas de transaction — le repository est sa propre unité. */
|
|
170
|
+
withTransaction(_tx) {
|
|
171
|
+
return this;
|
|
172
|
+
}
|
|
173
|
+
findByIdentifier(identifier) {
|
|
174
|
+
return Promise.resolve([...this.#store.values()].find((u) => u.identifier === identifier) ?? null);
|
|
175
|
+
}
|
|
176
|
+
findBySocialProvider(provider, providerId) {
|
|
177
|
+
return Promise.resolve([...this.#store.values()].find((u) => u.socialProviders.some((s) => s.provider === provider && s.providerId === providerId)) ?? null);
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* {@inheritDoc IUserRepository.listPage}
|
|
181
|
+
*
|
|
182
|
+
* In-memory : la collection est déjà en RAM (bornée par conception), donc le
|
|
183
|
+
* filtrage/tri/slice se fait sur la structure — pas de matérialisation
|
|
184
|
+
* supplémentaire. `total` gratuit (longueur du filtré) sauf `withTotal: false`.
|
|
185
|
+
*/
|
|
186
|
+
listPage(query) {
|
|
187
|
+
assertPageQuery(query, "offset");
|
|
188
|
+
const limit = Math.max(1, Math.floor(query.limit));
|
|
189
|
+
const offset = Math.max(0, Math.floor(query.offset ?? 0));
|
|
190
|
+
const q = query.q?.toLowerCase();
|
|
191
|
+
let filtered = [...this.#store.values()];
|
|
192
|
+
if (query.role !== void 0) filtered = filtered.filter((u) => u.roles.includes(query.role));
|
|
193
|
+
if (query.enabled !== void 0) filtered = filtered.filter((u) => u.isActive() === query.enabled);
|
|
194
|
+
if (query.locked !== void 0) filtered = filtered.filter((u) => u.isLocked() === query.locked);
|
|
195
|
+
if (query.hasSocial !== void 0) filtered = filtered.filter((u) => u.socialProviders.length > 0 === query.hasSocial);
|
|
196
|
+
if (q !== void 0 && q.length > 0) filtered = filtered.filter((u) => u.identifier.toLowerCase().includes(q));
|
|
197
|
+
const order = query.order?.length ? query.order : USER_DEFAULT_ORDER;
|
|
198
|
+
filtered.sort(compareByOrder(order, (u, field) => u[field]));
|
|
199
|
+
const items = filtered.slice(offset, offset + limit);
|
|
200
|
+
const total = query.withTotal === false ? void 0 : filtered.length;
|
|
201
|
+
return Promise.resolve({
|
|
202
|
+
items,
|
|
203
|
+
total,
|
|
204
|
+
limit,
|
|
205
|
+
offset,
|
|
206
|
+
hasNext: offset + items.length < filtered.length
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
/** {@inheritDoc IUserRepository.countActiveAdmins} */
|
|
210
|
+
/**
|
|
211
|
+
* {@inheritDoc IUserRepository.countUsers}
|
|
212
|
+
*
|
|
213
|
+
* Réutilise `listPage` avec une fenêtre nulle : le filtrage est écrit une
|
|
214
|
+
* seule fois, donc compter et lister ne peuvent pas diverger.
|
|
215
|
+
*/
|
|
216
|
+
async countUsers(query) {
|
|
217
|
+
return (await this.listPage({
|
|
218
|
+
...query,
|
|
219
|
+
limit: 1,
|
|
220
|
+
offset: 0
|
|
221
|
+
})).total ?? 0;
|
|
222
|
+
}
|
|
223
|
+
countActiveAdmins(adminRole) {
|
|
224
|
+
let count = 0;
|
|
225
|
+
for (const u of this.#store.values()) if (u.isActive() && u.roles.includes(adminRole)) count += 1;
|
|
226
|
+
return Promise.resolve(count);
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
//#endregion
|
|
230
|
+
export { InMemoryUserRepository, InMemoryUserRepository as default };
|