@nodefony/mongoose 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 +97 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorate.js +9 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateMetadata.js +6 -0
- package/dist/index.js +90 -0
- package/dist/nodefony/config/config.js +57 -0
- package/dist/nodefony/config/defineModuleConfig.js +60 -0
- package/dist/nodefony/entity/sessionEntity.js +63 -0
- package/dist/nodefony/entity/tokenEntity.js +184 -0
- package/dist/nodefony/entity/userEntity.js +106 -0
- package/dist/nodefony/entity/webAuthnCredentialEntity.js +97 -0
- package/dist/nodefony/entity/webhookEndpointEntity.js +109 -0
- package/dist/nodefony/interfaces/IMongooseConfig.js +1 -0
- package/dist/nodefony/interfaces/index.js +1 -0
- package/dist/nodefony/registerStores.js +89 -0
- package/dist/nodefony/service/MongooseService.js +95 -0
- package/dist/nodefony/src/MongooseTokenStore.js +237 -0
- package/dist/nodefony/src/MongooseUserRepository.js +205 -0
- package/dist/nodefony/src/MongooseWebAuthnCredentialStore.js +144 -0
- package/dist/nodefony/src/MongooseWebhookStore.js +181 -0
- package/dist/nodefony/src/SessionStorage.js +241 -0
- package/dist/nodefony/src/mongoOrder.js +49 -0
- package/dist/nodefony/src/orm-core/MongooseOrm.js +440 -0
- package/dist/nodefony/src/orm-core/MongooseRepository.js +300 -0
- package/dist/nodefony/src/orm-core/MongooseTransaction.js +53 -0
- package/dist/nodefony/src/orm-core/index.js +4 -0
- package/dist/types/index.d.ts +74 -0
- package/dist/types/nodefony/config/config.d.ts +21 -0
- package/dist/types/nodefony/config/defineModuleConfig.d.ts +26 -0
- package/dist/types/nodefony/entity/sessionEntity.d.ts +42 -0
- package/dist/types/nodefony/entity/tokenEntity.d.ts +59 -0
- package/dist/types/nodefony/entity/userEntity.d.ts +54 -0
- package/dist/types/nodefony/entity/webAuthnCredentialEntity.d.ts +61 -0
- package/dist/types/nodefony/entity/webhookEndpointEntity.d.ts +62 -0
- package/dist/types/nodefony/interfaces/IMongooseConfig.d.ts +17 -0
- package/dist/types/nodefony/interfaces/index.d.ts +1 -0
- package/dist/types/nodefony/registerStores.d.ts +37 -0
- package/dist/types/nodefony/service/MongooseService.d.ts +48 -0
- package/dist/types/nodefony/src/MongooseTokenStore.d.ts +126 -0
- package/dist/types/nodefony/src/MongooseUserRepository.d.ts +82 -0
- package/dist/types/nodefony/src/MongooseWebAuthnCredentialStore.d.ts +52 -0
- package/dist/types/nodefony/src/MongooseWebhookStore.d.ts +72 -0
- package/dist/types/nodefony/src/SessionStorage.d.ts +63 -0
- package/dist/types/nodefony/src/mongoOrder.d.ts +40 -0
- package/dist/types/nodefony/src/orm-core/MongooseOrm.d.ts +132 -0
- package/dist/types/nodefony/src/orm-core/MongooseRepository.d.ts +51 -0
- package/dist/types/nodefony/src/orm-core/MongooseTransaction.d.ts +37 -0
- package/dist/types/nodefony/src/orm-core/index.d.ts +9 -0
- package/docs/configuration.md +776 -0
- package/docs/index.md +881 -0
- package/package.json +97 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { mongoOrder, toMongoSort } from "./mongoOrder.js";
|
|
2
|
+
import { DOCUMENT_USER_COLUMNS } from "../entity/userEntity.js";
|
|
3
|
+
import { assertPageQuery, escapeRegExp } from "nodefony";
|
|
4
|
+
import { BaseUser, USER_DEFAULT_ORDER, USER_SORTABLE_FIELDS, assertUserContract, attachExtraColumns } from "@nodefony/user";
|
|
5
|
+
//#region nodefony/src/MongooseUserRepository.ts
|
|
6
|
+
/**
|
|
7
|
+
* Ce que le MOTEUR ajoute à chaque document, et qui n'appartient à personne
|
|
8
|
+
* d'autre : la clé primaire brute et la clé de version. Le contrat utilisateur
|
|
9
|
+
* ne peut pas les connaître — il ignore qu'il existe un Mongo — donc c'est ce
|
|
10
|
+
* dépôt qui les tait, au lieu de les laisser passer pour des champs métier.
|
|
11
|
+
*/
|
|
12
|
+
const MONGOOSE_INTERNAL_KEYS = /* @__PURE__ */ new Set(["_id", "__v"]);
|
|
13
|
+
/**
|
|
14
|
+
* Adapter Mongoose du contrat {@link IUserRepository} — persistance NoSQL de
|
|
15
|
+
* l'utilisateur (P5.8), pendant documentaire de `DrizzleUserRepository` (P5.9).
|
|
16
|
+
*
|
|
17
|
+
* Décore le repository portable (`IRepository<UserRow>` de {@link MongooseOrm}) de
|
|
18
|
+
* deux responsabilités propres à l'utilisateur :
|
|
19
|
+
* - **mapping document ↔ `BaseUser`** : les consommateurs reçoivent le comportement
|
|
20
|
+
* (`hasRole`/`isActive`/`isLocked`), pas un document nu ;
|
|
21
|
+
* - **finders métier** : `findByIdentifier` (lookup unique) et
|
|
22
|
+
* `findBySocialProvider` (scan du tableau `socialProviders` via `$elemMatch` —
|
|
23
|
+
* équivalent Mongo du `json_each` SQL de Drizzle, pattern Shadow User OAuth).
|
|
24
|
+
*
|
|
25
|
+
* Le credential (`password`) transite par cette frontière — attendu : le repository
|
|
26
|
+
* **est** la frontière de persistance du hash (cf `IUserRepository`).
|
|
27
|
+
*/
|
|
28
|
+
var MongooseUserRepository = class MongooseUserRepository {
|
|
29
|
+
/**
|
|
30
|
+
* Même vocabulaire public que les autres repositories — ici le tri part
|
|
31
|
+
* dans la requête, où il ne coûte qu'un index.
|
|
32
|
+
*/
|
|
33
|
+
sortableFields = USER_SORTABLE_FIELDS;
|
|
34
|
+
#base;
|
|
35
|
+
#model;
|
|
36
|
+
#session;
|
|
37
|
+
/**
|
|
38
|
+
* @param base - repository portable sur l'entité `User` (CRUD + criteria).
|
|
39
|
+
* @param model - modèle Mongoose natif `User` (pour le scan `$elemMatch`).
|
|
40
|
+
* @param session - session transactionnelle liée aux ops natives, ou `null`.
|
|
41
|
+
*/
|
|
42
|
+
constructor(base, model, session = null) {
|
|
43
|
+
this.#base = base;
|
|
44
|
+
this.#model = model;
|
|
45
|
+
this.#session = session;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Construit le repository utilisateur depuis un {@link MongooseOrm} connecté.
|
|
49
|
+
* L'entité `User` doit avoir été enregistrée (cf `registerUserEntity`) **avant**
|
|
50
|
+
* `orm.connect()` (le modèle est compilé au connect).
|
|
51
|
+
*
|
|
52
|
+
* @param orm - ORM Mongoose connecté.
|
|
53
|
+
* @returns le repository utilisateur prêt à l'emploi.
|
|
54
|
+
*/
|
|
55
|
+
static from(orm) {
|
|
56
|
+
const model = orm.getNativeConnection().model("User");
|
|
57
|
+
assertUserContract(Object.keys(model.schema.paths), `L'entité « User » de cette application (connecteur « ${orm.name} », mongoose)`, DOCUMENT_USER_COLUMNS);
|
|
58
|
+
return new MongooseUserRepository(orm.getRepository("User"), model);
|
|
59
|
+
}
|
|
60
|
+
/** Mappe une ligne plate en `BaseUser` (comportement + champs anti-migration). */
|
|
61
|
+
#toUser(row) {
|
|
62
|
+
const user = new BaseUser({
|
|
63
|
+
id: row.id,
|
|
64
|
+
identifier: row.identifier,
|
|
65
|
+
roles: row.roles,
|
|
66
|
+
password: row.password,
|
|
67
|
+
enabled: row.enabled,
|
|
68
|
+
locked: row.locked,
|
|
69
|
+
currentRole: row.currentRole,
|
|
70
|
+
socialProviders: row.socialProviders,
|
|
71
|
+
metadata: row.metadata
|
|
72
|
+
});
|
|
73
|
+
return attachExtraColumns(user, row, MONGOOSE_INTERNAL_KEYS);
|
|
74
|
+
}
|
|
75
|
+
async find(criteria, options) {
|
|
76
|
+
return (await this.#base.find(criteria, options)).map((row) => this.#toUser(row));
|
|
77
|
+
}
|
|
78
|
+
async findOne(criteria, options) {
|
|
79
|
+
const row = await this.#base.findOne(criteria, options);
|
|
80
|
+
return row ? this.#toUser(row) : null;
|
|
81
|
+
}
|
|
82
|
+
async create(data) {
|
|
83
|
+
const row = await this.#base.create(data);
|
|
84
|
+
return this.#toUser(row);
|
|
85
|
+
}
|
|
86
|
+
async updateOne(criteria, data) {
|
|
87
|
+
const row = await this.#base.updateOne(criteria, data);
|
|
88
|
+
return row ? this.#toUser(row) : null;
|
|
89
|
+
}
|
|
90
|
+
async upsert(criteria, update, insertOnly) {
|
|
91
|
+
const row = await this.#base.upsert(criteria, update, insertOnly);
|
|
92
|
+
return this.#toUser(row);
|
|
93
|
+
}
|
|
94
|
+
async createMany(data) {
|
|
95
|
+
return (await this.#base.createMany(data)).map((row) => this.#toUser(row));
|
|
96
|
+
}
|
|
97
|
+
exists(criteria) {
|
|
98
|
+
return this.#base.exists(criteria);
|
|
99
|
+
}
|
|
100
|
+
deleteOne(criteria) {
|
|
101
|
+
return this.#base.deleteOne(criteria);
|
|
102
|
+
}
|
|
103
|
+
async findOneAndDelete(criteria) {
|
|
104
|
+
const row = await this.#base.findOneAndDelete(criteria);
|
|
105
|
+
return row ? this.#toUser(row) : null;
|
|
106
|
+
}
|
|
107
|
+
async increment(criteria, changes) {
|
|
108
|
+
const row = await this.#base.increment(criteria, changes);
|
|
109
|
+
return row ? this.#toUser(row) : null;
|
|
110
|
+
}
|
|
111
|
+
updateMany(criteria, data) {
|
|
112
|
+
return this.#base.updateMany(criteria, data);
|
|
113
|
+
}
|
|
114
|
+
delete(criteria) {
|
|
115
|
+
return this.#base.delete(criteria);
|
|
116
|
+
}
|
|
117
|
+
count(criteria) {
|
|
118
|
+
return this.#base.count(criteria);
|
|
119
|
+
}
|
|
120
|
+
countDistinct(field, criteria) {
|
|
121
|
+
return this.#base.countDistinct(field, criteria);
|
|
122
|
+
}
|
|
123
|
+
withTransaction(tx) {
|
|
124
|
+
return new MongooseUserRepository(this.#base.withTransaction(tx), this.#model, tx.getNative());
|
|
125
|
+
}
|
|
126
|
+
findByIdentifier(identifier) {
|
|
127
|
+
return this.findOne({ identifier });
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Recherche par compte externe lié — scanne le tableau `socialProviders` via
|
|
131
|
+
* `$elemMatch` (1 requête, équivalent Mongo du `json_each` Drizzle). `null` si
|
|
132
|
+
* aucun lien. Liée à la session transactionnelle courante le cas échéant.
|
|
133
|
+
*/
|
|
134
|
+
async findBySocialProvider(provider, providerId) {
|
|
135
|
+
let query = this.#model.findOne({ socialProviders: { $elemMatch: {
|
|
136
|
+
provider,
|
|
137
|
+
providerId
|
|
138
|
+
} } });
|
|
139
|
+
if (this.#session) query = query.session(this.#session);
|
|
140
|
+
const doc = await query.exec();
|
|
141
|
+
return doc ? this.#toUser(doc.toObject({ virtuals: true })) : null;
|
|
142
|
+
}
|
|
143
|
+
/** Construit le filtre Mongo natif des filtres de listing (role/enabled/q). */
|
|
144
|
+
#listFilter(query) {
|
|
145
|
+
const filter = {};
|
|
146
|
+
if (query.role !== void 0) filter.roles = query.role;
|
|
147
|
+
if (query.enabled !== void 0) filter.enabled = query.enabled;
|
|
148
|
+
if (query.locked !== void 0) filter.locked = query.locked;
|
|
149
|
+
if (query.hasSocial !== void 0) filter["socialProviders.0"] = { $exists: query.hasSocial };
|
|
150
|
+
if (query.q !== void 0 && query.q.length > 0) filter.identifier = {
|
|
151
|
+
$regex: escapeRegExp(query.q),
|
|
152
|
+
$options: "i"
|
|
153
|
+
};
|
|
154
|
+
return filter;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* {@inheritDoc IUserRepository.listPage}
|
|
158
|
+
*
|
|
159
|
+
* Query native Mongo : `find(filter).sort().skip().limit(limit + 1)` — le store
|
|
160
|
+
* ne renvoie qu'une page (jamais de matérialisation complète). `roles: role` =
|
|
161
|
+
* containment de tableau natif, `$regex/i` = sous-chaîne insensible casse.
|
|
162
|
+
* `_id` en tiebreaker de tri (pagination offset déterministe).
|
|
163
|
+
*/
|
|
164
|
+
async listPage(query) {
|
|
165
|
+
assertPageQuery(query, "offset");
|
|
166
|
+
const limit = Math.max(1, Math.floor(query.limit));
|
|
167
|
+
const offset = Math.max(0, Math.floor(query.offset ?? 0));
|
|
168
|
+
const filter = this.#listFilter(query);
|
|
169
|
+
const sort = toMongoSort(mongoOrder(query.order, this.sortableFields, USER_DEFAULT_ORDER));
|
|
170
|
+
if (sort._id === void 0) sort._id = 1;
|
|
171
|
+
let cursor = this.#model.find(filter).sort(sort).skip(offset).limit(limit + 1);
|
|
172
|
+
if (this.#session) cursor = cursor.session(this.#session);
|
|
173
|
+
const docs = await cursor.exec();
|
|
174
|
+
const hasNext = docs.length > limit;
|
|
175
|
+
const items = (hasNext ? docs.slice(0, limit) : docs).map((doc) => this.#toUser(doc.toObject({ virtuals: true })));
|
|
176
|
+
let total;
|
|
177
|
+
if (query.withTotal !== false) {
|
|
178
|
+
let countQuery = this.#model.countDocuments(filter);
|
|
179
|
+
if (this.#session) countQuery = countQuery.session(this.#session);
|
|
180
|
+
total = await countQuery.exec();
|
|
181
|
+
}
|
|
182
|
+
return {
|
|
183
|
+
items,
|
|
184
|
+
total,
|
|
185
|
+
limit,
|
|
186
|
+
offset,
|
|
187
|
+
hasNext
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
/** {@inheritDoc IUserRepository.countActiveAdmins} */
|
|
191
|
+
/** {@inheritDoc IUserRepository.countUsers} */
|
|
192
|
+
countUsers(query) {
|
|
193
|
+
return this.#model.countDocuments(this.#listFilter(query)).exec();
|
|
194
|
+
}
|
|
195
|
+
async countActiveAdmins(adminRole) {
|
|
196
|
+
let countQuery = this.#model.countDocuments({
|
|
197
|
+
enabled: true,
|
|
198
|
+
roles: adminRole
|
|
199
|
+
});
|
|
200
|
+
if (this.#session) countQuery = countQuery.session(this.#session);
|
|
201
|
+
return countQuery.exec();
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
//#endregion
|
|
205
|
+
export { MongooseUserRepository };
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { WEBAUTHN_CREDENTIAL_ENTITY } from "../entity/webAuthnCredentialEntity.js";
|
|
2
|
+
import { assertPageQuery } from "nodefony";
|
|
3
|
+
import { paginate } from "@nodefony/orm-core";
|
|
4
|
+
//#region nodefony/src/MongooseWebAuthnCredentialStore.ts
|
|
5
|
+
/**
|
|
6
|
+
* Store de credentials WebAuthn **Mongoose** (NoSQL) — implémentation d'
|
|
7
|
+
* {@link IWebAuthnCredentialStore} au-dessus d'un unique repository
|
|
8
|
+
* `@nodefony/orm-core` (`webauthn_credential`). Pendant documentaire de
|
|
9
|
+
* `DrizzleWebAuthnCredentialStore`.
|
|
10
|
+
*
|
|
11
|
+
* **Approche B** : `@nodefony/security` n'est connu qu'en `import type` (0 dép
|
|
12
|
+
* runtime). C'est l'application qui enregistre la fabrique
|
|
13
|
+
* (`registerWebAuthnStore("mongoose", …)`) et l'entité
|
|
14
|
+
* (`registerWebAuthnCredentialEntity(orm)` avant `orm.connect()`).
|
|
15
|
+
*
|
|
16
|
+
* **Spécificité Mongo** : la clé naturelle (credentialId) est portée par `_id`
|
|
17
|
+
* (cf {@link webAuthnCredentialSchema}). Le contrat traduit `{ id }` → `{ _id }`,
|
|
18
|
+
* donc les lookups passent par le champ `id` ; les **écritures** posent
|
|
19
|
+
* explicitement `_id` (Mongo ne génère pas notre credentialId). Le mapping
|
|
20
|
+
* `Row ↔ IWebAuthnCredential` normalise `nickname` (`null` → omis).
|
|
21
|
+
*/
|
|
22
|
+
var MongooseWebAuthnCredentialStore = class MongooseWebAuthnCredentialStore {
|
|
23
|
+
#repo;
|
|
24
|
+
/** @param repo - repository de la collection `webauthn_credential`. */
|
|
25
|
+
constructor(repo) {
|
|
26
|
+
this.#repo = repo;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Construit le store depuis un {@link MongooseOrm} connecté. L'entité
|
|
30
|
+
* (`registerWebAuthnCredentialEntity`) doit avoir été enregistrée **avant**
|
|
31
|
+
* `connect()`.
|
|
32
|
+
*
|
|
33
|
+
* @param orm - ORM Mongoose connecté hébergeant la collection du store.
|
|
34
|
+
*/
|
|
35
|
+
static from(orm) {
|
|
36
|
+
return new MongooseWebAuthnCredentialStore(orm.getRepository(WEBAUTHN_CREDENTIAL_ENTITY));
|
|
37
|
+
}
|
|
38
|
+
/** Identité réelle d'un credential : `_id` fait foi, le virtuel `id` en repli. */
|
|
39
|
+
#idOf(row) {
|
|
40
|
+
return row._id ?? row.id;
|
|
41
|
+
}
|
|
42
|
+
/** Row plate → credential du contrat (`nickname?` omis si `null`/absent). */
|
|
43
|
+
#toCredential(row) {
|
|
44
|
+
return {
|
|
45
|
+
id: this.#idOf(row),
|
|
46
|
+
userId: row.userId,
|
|
47
|
+
publicKey: row.publicKey,
|
|
48
|
+
signCount: row.signCount,
|
|
49
|
+
transports: row.transports,
|
|
50
|
+
backupEligible: row.backupEligible,
|
|
51
|
+
backupState: row.backupState,
|
|
52
|
+
uvInitialized: row.uvInitialized,
|
|
53
|
+
createdAt: row.createdAt,
|
|
54
|
+
lastUsedAt: row.lastUsedAt,
|
|
55
|
+
...row.nickname != null ? { nickname: row.nickname } : {}
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
async findById(credentialId) {
|
|
59
|
+
const row = await this.#repo.findOne({ id: credentialId });
|
|
60
|
+
return row ? this.#toCredential(row) : null;
|
|
61
|
+
}
|
|
62
|
+
async findByUser(userId) {
|
|
63
|
+
return (await this.#repo.find({ userId })).map((row) => this.#toCredential(row));
|
|
64
|
+
}
|
|
65
|
+
/** `countDocuments` natif — jamais un `find().length` (le plafond ne charge rien). */
|
|
66
|
+
countByUser(userId) {
|
|
67
|
+
return this.#repo.count({ userId });
|
|
68
|
+
}
|
|
69
|
+
async save(credential) {
|
|
70
|
+
const data = {
|
|
71
|
+
userId: credential.userId,
|
|
72
|
+
publicKey: credential.publicKey,
|
|
73
|
+
signCount: credential.signCount,
|
|
74
|
+
transports: [...credential.transports],
|
|
75
|
+
backupEligible: credential.backupEligible,
|
|
76
|
+
backupState: credential.backupState,
|
|
77
|
+
uvInitialized: credential.uvInitialized,
|
|
78
|
+
nickname: credential.nickname ?? null,
|
|
79
|
+
createdAt: credential.createdAt,
|
|
80
|
+
lastUsedAt: credential.lastUsedAt
|
|
81
|
+
};
|
|
82
|
+
await this.#repo.upsert({ id: credential.id }, data);
|
|
83
|
+
}
|
|
84
|
+
async update(credentialId, patch) {
|
|
85
|
+
await this.#repo.updateOne({ id: credentialId }, {
|
|
86
|
+
signCount: patch.signCount,
|
|
87
|
+
backupState: patch.backupState,
|
|
88
|
+
uvInitialized: patch.uvInitialized,
|
|
89
|
+
lastUsedAt: patch.lastUsedAt
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
async delete(credentialId) {
|
|
93
|
+
await this.#repo.delete({ id: credentialId });
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Critères du listing admin. `q` = PRÉFIXE d'`userId` (`$like 'x%'`, traduit en
|
|
97
|
+
* ancrage `^` côté Mongo), jamais une recherche non ancrée.
|
|
98
|
+
*/
|
|
99
|
+
#listCriteria(query) {
|
|
100
|
+
const criteria = {};
|
|
101
|
+
if (query.userId !== void 0) criteria.userId = query.userId;
|
|
102
|
+
else if (query.q !== void 0 && query.q.length > 0) criteria.userId = { $like: `${query.q.replace(/[\\%_]/g, (c) => "\\" + c)}%` };
|
|
103
|
+
if (query.backedUp !== void 0) criteria.backupState = query.backedUp;
|
|
104
|
+
return criteria;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* {@inheritDoc IWebAuthnCredentialStore.listPage}
|
|
108
|
+
*
|
|
109
|
+
* Même helper `paginate()` que l'adapter SQL (`skip`/`limit` + `countDocuments`).
|
|
110
|
+
* La projection retire `publicKey` — elle ne franchit jamais la frontière du store.
|
|
111
|
+
* ⚠️ L'identité vient de `_id` (`#idOf`), pas du champ `id` de la row.
|
|
112
|
+
*/
|
|
113
|
+
async listPage(query) {
|
|
114
|
+
assertPageQuery(query, "offset");
|
|
115
|
+
const page = await paginate(this.#repo, {
|
|
116
|
+
criteria: this.#listCriteria(query),
|
|
117
|
+
limit: query.limit,
|
|
118
|
+
offset: query.offset,
|
|
119
|
+
withTotal: query.withTotal,
|
|
120
|
+
order: [["createdAt", "DESC"], ["_id", "ASC"]]
|
|
121
|
+
});
|
|
122
|
+
return {
|
|
123
|
+
...page,
|
|
124
|
+
items: page.items.map((row) => ({
|
|
125
|
+
id: this.#idOf(row),
|
|
126
|
+
userId: row.userId,
|
|
127
|
+
transports: row.transports,
|
|
128
|
+
backupEligible: row.backupEligible,
|
|
129
|
+
backupState: row.backupState,
|
|
130
|
+
uvInitialized: row.uvInitialized,
|
|
131
|
+
signCount: row.signCount,
|
|
132
|
+
createdAt: row.createdAt,
|
|
133
|
+
lastUsedAt: row.lastUsedAt,
|
|
134
|
+
...row.nickname != null ? { nickname: row.nickname } : {}
|
|
135
|
+
}))
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
/** {@inheritDoc IWebAuthnCredentialStore.countCredentials} */
|
|
139
|
+
countCredentials(query) {
|
|
140
|
+
return this.#repo.count(this.#listCriteria(query));
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
//#endregion
|
|
144
|
+
export { MongooseWebAuthnCredentialStore };
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { WEBHOOK_ENDPOINT_ENTITY } from "../entity/webhookEndpointEntity.js";
|
|
2
|
+
import { mongoOrder, toMongoSort } from "./mongoOrder.js";
|
|
3
|
+
import { assertPageQuery, escapeRegExp } from "nodefony";
|
|
4
|
+
import { WEBHOOK_DEFAULT_ORDER, WEBHOOK_SORTABLE_FIELDS } from "@nodefony/security";
|
|
5
|
+
//#region nodefony/src/MongooseWebhookStore.ts
|
|
6
|
+
/**
|
|
7
|
+
* Store d'endpoints webhook **Mongoose** (NoSQL) — implémentation d'
|
|
8
|
+
* {@link IWebhookStore} au-dessus d'un unique repository `@nodefony/orm-core`
|
|
9
|
+
* (`webhook_endpoint`). Pendant documentaire de `DrizzleWebhookStore` ; registre
|
|
10
|
+
* DURABLE des endpoints (survit au redémarrage, ≠ `MemoryWebhookStore`).
|
|
11
|
+
*
|
|
12
|
+
* **Approche B** : `@nodefony/security` n'est connu qu'en `import type` (0 dép
|
|
13
|
+
* runtime). C'est l'application qui enregistre la fabrique
|
|
14
|
+
* (`registerWebhookStore("mongoose", …)`) et l'entité
|
|
15
|
+
* (`registerWebhookEndpointEntity(orm)` avant `orm.connect()`).
|
|
16
|
+
*
|
|
17
|
+
* **Spécificité Mongo** : la clé naturelle (`wh_<random>`) est portée par `_id`
|
|
18
|
+
* (cf {@link webhookEndpointSchema}). Le contrat traduit `{ id }` → `{ _id }`,
|
|
19
|
+
* donc les lookups passent par `id` ; les **écritures** posent explicitement
|
|
20
|
+
* `_id` (Mongo ne génère pas notre id). Mapping `Row ↔ IWebhookEndpoint` :
|
|
21
|
+
* `IWebhookEndpoint` est déjà « plat tout `| null` », seuls les champs JSON
|
|
22
|
+
* `events`/`metadata` sont copiés défensivement.
|
|
23
|
+
*/
|
|
24
|
+
var MongooseWebhookStore = class MongooseWebhookStore {
|
|
25
|
+
/**
|
|
26
|
+
* {@inheritDoc IWebhookStore.sortableFields}
|
|
27
|
+
*
|
|
28
|
+
* Capacité pleine : le vocabulaire public entier est trié par Mongo, `id`
|
|
29
|
+
* compris — traduit en `_id` au moment de la requête.
|
|
30
|
+
*/
|
|
31
|
+
sortableFields = WEBHOOK_SORTABLE_FIELDS;
|
|
32
|
+
#repo;
|
|
33
|
+
#model;
|
|
34
|
+
/**
|
|
35
|
+
* @param repo - repository de la collection `webhook_endpoint`.
|
|
36
|
+
* @param model - modèle Mongoose natif, requis par le seul listing paginé
|
|
37
|
+
* (recherche `q` = `$or` sur deux champs, hors `Criteria` AND-only).
|
|
38
|
+
* `null` = `listPage` refuse plutôt que de tout charger en silence.
|
|
39
|
+
*/
|
|
40
|
+
constructor(repo, model = null) {
|
|
41
|
+
this.#repo = repo;
|
|
42
|
+
this.#model = model;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Construit le store depuis un {@link MongooseOrm} connecté. L'entité
|
|
46
|
+
* (`registerWebhookEndpointEntity`) doit avoir été enregistrée **avant**
|
|
47
|
+
* `connect()`.
|
|
48
|
+
*
|
|
49
|
+
* @param orm - ORM Mongoose connecté hébergeant la collection du store.
|
|
50
|
+
*/
|
|
51
|
+
static from(orm) {
|
|
52
|
+
const connection = orm.getNativeConnection();
|
|
53
|
+
return new MongooseWebhookStore(orm.getRepository(WEBHOOK_ENDPOINT_ENTITY), connection.model(WEBHOOK_ENDPOINT_ENTITY));
|
|
54
|
+
}
|
|
55
|
+
/** Identité réelle d'un endpoint : `_id` fait foi, le virtuel `id` en repli. */
|
|
56
|
+
#idOf(row) {
|
|
57
|
+
return row._id ?? row.id;
|
|
58
|
+
}
|
|
59
|
+
/** Row plate → endpoint du contrat (sans `_id`/`__v` ; `events` en `readonly`). */
|
|
60
|
+
#toEndpoint(row) {
|
|
61
|
+
return {
|
|
62
|
+
id: this.#idOf(row),
|
|
63
|
+
url: row.url,
|
|
64
|
+
secretEnc: row.secretEnc,
|
|
65
|
+
events: row.events,
|
|
66
|
+
enabled: row.enabled,
|
|
67
|
+
description: row.description,
|
|
68
|
+
tenantId: row.tenantId,
|
|
69
|
+
createdBy: row.createdBy,
|
|
70
|
+
createdAt: row.createdAt,
|
|
71
|
+
updatedAt: row.updatedAt,
|
|
72
|
+
lastDeliveryAt: row.lastDeliveryAt,
|
|
73
|
+
lastDeliveryStatus: row.lastDeliveryStatus,
|
|
74
|
+
lastDeliveryError: row.lastDeliveryError,
|
|
75
|
+
failureCount: row.failureCount,
|
|
76
|
+
metadata: row.metadata
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
async save(endpoint) {
|
|
80
|
+
const data = {
|
|
81
|
+
url: endpoint.url,
|
|
82
|
+
secretEnc: endpoint.secretEnc,
|
|
83
|
+
events: [...endpoint.events],
|
|
84
|
+
enabled: endpoint.enabled,
|
|
85
|
+
description: endpoint.description,
|
|
86
|
+
tenantId: endpoint.tenantId,
|
|
87
|
+
createdBy: endpoint.createdBy,
|
|
88
|
+
createdAt: endpoint.createdAt,
|
|
89
|
+
updatedAt: endpoint.updatedAt,
|
|
90
|
+
lastDeliveryAt: endpoint.lastDeliveryAt,
|
|
91
|
+
lastDeliveryStatus: endpoint.lastDeliveryStatus,
|
|
92
|
+
lastDeliveryError: endpoint.lastDeliveryError,
|
|
93
|
+
failureCount: endpoint.failureCount,
|
|
94
|
+
metadata: { ...endpoint.metadata }
|
|
95
|
+
};
|
|
96
|
+
await this.#repo.upsert({ id: endpoint.id }, data);
|
|
97
|
+
}
|
|
98
|
+
async findById(id) {
|
|
99
|
+
const row = await this.#repo.findOne({ id });
|
|
100
|
+
return row ? this.#toEndpoint(row) : null;
|
|
101
|
+
}
|
|
102
|
+
async update(id, patch) {
|
|
103
|
+
const { events, metadata, ...rest } = patch;
|
|
104
|
+
const data = { ...rest };
|
|
105
|
+
if (events !== void 0) data.events = [...events];
|
|
106
|
+
if (metadata !== void 0) data.metadata = { ...metadata };
|
|
107
|
+
await this.#repo.updateOne({ id }, data);
|
|
108
|
+
}
|
|
109
|
+
async delete(id) {
|
|
110
|
+
await this.#repo.delete({ id });
|
|
111
|
+
}
|
|
112
|
+
async listAll() {
|
|
113
|
+
return (await this.#repo.find({})).map((row) => this.#toEndpoint(row));
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Modèle natif ou erreur explicite — un `listPage` qui retomberait sur un
|
|
117
|
+
* `find({})` complet trahirait silencieusement la garantie du contrat.
|
|
118
|
+
*/
|
|
119
|
+
#nativeModel() {
|
|
120
|
+
if (this.#model === null) throw new Error("MongooseWebhookStore: listing paginé indisponible (store construit sans modèle natif). Utiliser MongooseWebhookStore.from(orm).");
|
|
121
|
+
return this.#model;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Filtre Mongo des filtres du listing. `events: <event>` = **containment de
|
|
125
|
+
* tableau natif** (Mongo matche un scalaire contre chaque élément) ; `q` =
|
|
126
|
+
* `$or` de deux `$regex/i` — le `$or` sort du `Criteria` AND-only, d'où la
|
|
127
|
+
* query native.
|
|
128
|
+
*/
|
|
129
|
+
#listFilter(query) {
|
|
130
|
+
const filter = {};
|
|
131
|
+
if (query.enabled !== void 0) filter.enabled = query.enabled;
|
|
132
|
+
if (query.failing !== void 0) filter.failureCount = query.failing ? { $gt: 0 } : 0;
|
|
133
|
+
if (query.event !== void 0) filter.events = query.event;
|
|
134
|
+
if (query.q !== void 0 && query.q.length > 0) {
|
|
135
|
+
const needle = escapeRegExp(query.q);
|
|
136
|
+
filter.$or = [{ url: {
|
|
137
|
+
$regex: needle,
|
|
138
|
+
$options: "i"
|
|
139
|
+
} }, { description: {
|
|
140
|
+
$regex: needle,
|
|
141
|
+
$options: "i"
|
|
142
|
+
} }];
|
|
143
|
+
}
|
|
144
|
+
return filter;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* {@inheritDoc IWebhookStore.listPage}
|
|
148
|
+
*
|
|
149
|
+
* Query native : `find(filter).sort(…).skip().limit(limit+1)` — une page,
|
|
150
|
+
* jamais la collection. Le `limit + 1` donne `hasNext` sans compter.
|
|
151
|
+
*
|
|
152
|
+
* Le tri demandé est **traduit** avant de descendre : au repos, l'endpoint n'a
|
|
153
|
+
* pas de champ `id` (l'identifiant EST le `_id`), et Mongo ne se plaint pas
|
|
154
|
+
* d'un tri sur un champ absent — il rend un ordre arbitraire. Sans traduction,
|
|
155
|
+
* un `?order=id` serait donc inerte ici et correct partout ailleurs. À défaut
|
|
156
|
+
* d'`order`, l'ordre par défaut `createdAt DESC, _id ASC` reste déterministe.
|
|
157
|
+
*/
|
|
158
|
+
async listPage(query) {
|
|
159
|
+
assertPageQuery(query, "offset");
|
|
160
|
+
const limit = Math.max(1, Math.floor(query.limit));
|
|
161
|
+
const offset = Math.max(0, Math.floor(query.offset ?? 0));
|
|
162
|
+
const model = this.#nativeModel();
|
|
163
|
+
const filter = this.#listFilter(query);
|
|
164
|
+
const order = mongoOrder(query.order, this.sortableFields, WEBHOOK_DEFAULT_ORDER);
|
|
165
|
+
const docs = await model.find(filter).sort(toMongoSort(order)).skip(offset).limit(limit + 1).exec();
|
|
166
|
+
const hasNext = docs.length > limit;
|
|
167
|
+
return {
|
|
168
|
+
items: (hasNext ? docs.slice(0, limit) : docs).map((doc) => this.#toEndpoint(doc.toObject({ virtuals: true }))),
|
|
169
|
+
total: query.withTotal === false ? void 0 : await model.countDocuments(filter).exec(),
|
|
170
|
+
limit,
|
|
171
|
+
offset,
|
|
172
|
+
hasNext
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
/** {@inheritDoc IWebhookStore.countEndpoints} */
|
|
176
|
+
async countEndpoints(query) {
|
|
177
|
+
return this.#nativeModel().countDocuments(this.#listFilter(query)).exec();
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
//#endregion
|
|
181
|
+
export { MongooseWebhookStore };
|