@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,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 };