@nodefony/mongoose 10.0.0-alpha.8 → 10.0.0-alpha.9
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/README.md +32 -0
- package/dist/_virtual/{_@oxc-project_runtime@0.150.0 → _@oxc-project_runtime@0.151.0}/helpers/esm/decorate.js +1 -1
- package/dist/_virtual/{_@oxc-project_runtime@0.150.0 → _@oxc-project_runtime@0.151.0}/helpers/esm/decorateMetadata.js +1 -1
- package/dist/index.js +10 -4
- package/dist/nodefony/entity/auditEventEntity.js +124 -0
- package/dist/nodefony/entity/idempotencyEntity.js +96 -0
- package/dist/nodefony/entity/sessionEntity.js +1 -1
- package/dist/nodefony/entity/totpSecretEntity.js +103 -0
- package/dist/nodefony/registerStores.js +41 -4
- package/dist/nodefony/service/MongooseService.js +6 -3
- package/dist/nodefony/src/MongooseAuditStore.js +213 -0
- package/dist/nodefony/src/MongooseIdempotencyStore.js +260 -0
- package/dist/nodefony/src/MongooseTokenStore.js +60 -17
- package/dist/nodefony/src/MongooseTotpSecretStore.js +151 -0
- package/dist/nodefony/src/MongooseWebAuthnCredentialStore.js +13 -1
- package/dist/nodefony/src/MongooseWebhookStore.js +14 -2
- package/dist/nodefony/src/SessionStorage.js +8 -0
- package/dist/nodefony/src/orm-core/MongooseOrm.js +62 -12
- package/dist/nodefony/src/orm-core/MongooseRepository.js +124 -14
- package/dist/nodefony/src/orm-core/MongooseTransaction.js +19 -6
- package/dist/types/index.d.ts +9 -0
- package/dist/types/nodefony/entity/auditEventEntity.d.ts +76 -0
- package/dist/types/nodefony/entity/idempotencyEntity.d.ts +75 -0
- package/dist/types/nodefony/entity/totpSecretEntity.d.ts +75 -0
- package/dist/types/nodefony/registerStores.d.ts +6 -3
- package/dist/types/nodefony/src/MongooseAuditStore.d.ts +70 -0
- package/dist/types/nodefony/src/MongooseIdempotencyStore.d.ts +121 -0
- package/dist/types/nodefony/src/MongooseTokenStore.d.ts +8 -1
- package/dist/types/nodefony/src/MongooseTotpSecretStore.d.ts +63 -0
- package/dist/types/nodefony/src/MongooseWebAuthnCredentialStore.d.ts +6 -0
- package/dist/types/nodefony/src/MongooseWebhookStore.d.ts +6 -0
- package/dist/types/nodefony/src/SessionStorage.d.ts +6 -0
- package/dist/types/nodefony/src/orm-core/MongooseOrm.d.ts +10 -3
- package/dist/types/nodefony/src/orm-core/MongooseRepository.d.ts +22 -1
- package/dist/types/nodefony/src/orm-core/MongooseTransaction.d.ts +12 -4
- package/docs/configuration.md +22 -22
- package/docs/index.md +107 -63
- package/package.json +21 -17
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { AUDIT_ENTITY_NAMES } from "../entity/auditEventEntity.js";
|
|
2
|
+
import { assertPageQuery } from "nodefony";
|
|
3
|
+
//#region nodefony/src/MongooseAuditStore.ts
|
|
4
|
+
/** Rétention par défaut d'un événement : 365 jours (aligné `MemoryAuditStore`). */
|
|
5
|
+
const DEFAULT_RETENTION_MS = 31536e6;
|
|
6
|
+
/** Taille de page par défaut / plafond (alignés `MemoryAuditStore`). */
|
|
7
|
+
const DEFAULT_LIMIT = 100;
|
|
8
|
+
const MAX_LIMIT = 500;
|
|
9
|
+
/** Séparateur du curseur composite `<ts>:<id>` (cf `#parseCursor`). */
|
|
10
|
+
const CURSOR_SEPARATOR = ":";
|
|
11
|
+
/**
|
|
12
|
+
* Journal d'audit **Mongoose** (NoSQL) — implémentation documentaire d'
|
|
13
|
+
* {@link IAuditStore} (append-only, tamper-evident) pour la **rétention longue**
|
|
14
|
+
* et le **partage cross-pod**, là où le store mémoire par défaut reste affine à
|
|
15
|
+
* un pod et volatile. Pendant documentaire de `DrizzleAuditStore`.
|
|
16
|
+
*
|
|
17
|
+
* **Append-only** : `append` est la seule écriture ; aucune mutation ni
|
|
18
|
+
* suppression ciblée d'un événement — seul {@link MongooseAuditStore.gc}
|
|
19
|
+
* (rétention) retire des documents. L'immuabilité EST la garantie d'audit.
|
|
20
|
+
*
|
|
21
|
+
* **Pagination curseur EXACTE (`listPage`)** — l'ordre total est `(ts DESC, _id
|
|
22
|
+
* DESC)` : `ts` porte l'ordre chronologique, `_id` (= l'id d'événement) casse
|
|
23
|
+
* les collisions à la milliseconde (rafales de login). Le curseur transporte
|
|
24
|
+
* **les deux** (`<ts>:<id>`) et se compare en **composite** — en Mongo, un `$or`
|
|
25
|
+
* de deux clauses, qui sort du `Criteria` AND-only d'`IRepository`, d'où la
|
|
26
|
+
* query native (même trappe que le frère SQL). Une ligne de garde (`limit + 1`)
|
|
27
|
+
* détermine `hasNext` sans page vide parasite.
|
|
28
|
+
*
|
|
29
|
+
* ⚠️ **Le tri se fait sur `_id`, jamais sur un champ `id`** : au repos un
|
|
30
|
+
* document Mongo n'a PAS de champ `id` (c'est un virtuel de lecture), et Mongo
|
|
31
|
+
* ne se plaint pas d'un tri sur un champ absent — il rend un ordre arbitraire.
|
|
32
|
+
* Un ordre total qui n'ordonne rien casserait le curseur en silence.
|
|
33
|
+
*
|
|
34
|
+
* **Résolution LAZY + dégradation gracieuse** (calqué sur le frère SQL) : le
|
|
35
|
+
* modèle est résolu à CHAQUE appel, pas capturé à la construction — l'ORM se
|
|
36
|
+
* déconnecte au shutdown avant le drain des serveurs, et un `connect()` rejoué
|
|
37
|
+
* recrée la connexion. Si l'ORM n'est pas connecté : `append` est un no-op
|
|
38
|
+
* **best-effort** (l'audit ne bloque ni ne fait échouer le flux métier — un
|
|
39
|
+
* événement au shutdown est perdu plutôt que de crasher un login), `listPage`
|
|
40
|
+
* rend une page vide et `gc` rend 0.
|
|
41
|
+
*
|
|
42
|
+
* Horloge injectable (`now`) pour des tests déterministes.
|
|
43
|
+
*/
|
|
44
|
+
var MongooseAuditStore = class MongooseAuditStore {
|
|
45
|
+
#resolveModel;
|
|
46
|
+
#now;
|
|
47
|
+
#retentionMs;
|
|
48
|
+
/**
|
|
49
|
+
* @param resolveModel - résolveur **lazy** du modèle Mongoose (`null` = ORM non
|
|
50
|
+
* connecté → dégradation gracieuse).
|
|
51
|
+
* @param now - horloge (epoch ms) injectable pour des tests déterministes.
|
|
52
|
+
* @param retentionMs - fenêtre de rétention (ms) avant purge par `gc`.
|
|
53
|
+
*/
|
|
54
|
+
constructor(resolveModel, now = Date.now, retentionMs = DEFAULT_RETENTION_MS) {
|
|
55
|
+
this.#resolveModel = resolveModel;
|
|
56
|
+
this.#now = now;
|
|
57
|
+
this.#retentionMs = retentionMs;
|
|
58
|
+
}
|
|
59
|
+
/** Connecteur ORM qui porte ce store — posé par {@link MongooseAuditStore.from}. */
|
|
60
|
+
#connector;
|
|
61
|
+
/**
|
|
62
|
+
* Connecteur ORM qui porte ce store, lu par `readStoreConnector` pour le
|
|
63
|
+
* registre des stores : la console rattache la brique à SON connecteur au
|
|
64
|
+
* lieu de le déduire. `undefined` pour un store construit sans ORM (bancs).
|
|
65
|
+
*/
|
|
66
|
+
get connector() {
|
|
67
|
+
return this.#connector;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Construit le store depuis un {@link MongooseOrm}. Le modèle est résolu
|
|
71
|
+
* **lazy** (gardé par `isConnected()` → `null` tant que l'ORM n'est pas/plus
|
|
72
|
+
* connecté). L'entité (`registerAuditEntities`) doit avoir été enregistrée
|
|
73
|
+
* **avant** `orm.connect()` (le modèle est compilé au connect).
|
|
74
|
+
*
|
|
75
|
+
* @param orm - ORM Mongoose hébergeant la collection `audit_event`.
|
|
76
|
+
* @param now - horloge injectable (tests).
|
|
77
|
+
* @param retentionMs - fenêtre de rétention (ms).
|
|
78
|
+
*/
|
|
79
|
+
static from(orm, now, retentionMs) {
|
|
80
|
+
const store = new MongooseAuditStore(() => {
|
|
81
|
+
if (!orm.isConnected()) return null;
|
|
82
|
+
return orm.getNativeConnection().model(AUDIT_ENTITY_NAMES.events);
|
|
83
|
+
}, now, retentionMs);
|
|
84
|
+
store.#connector = orm.name;
|
|
85
|
+
return store;
|
|
86
|
+
}
|
|
87
|
+
async append(event) {
|
|
88
|
+
const model = this.#resolveModel();
|
|
89
|
+
if (!model) return;
|
|
90
|
+
await model.create({
|
|
91
|
+
_id: event.id,
|
|
92
|
+
ts: event.ts,
|
|
93
|
+
category: event.category,
|
|
94
|
+
action: event.action,
|
|
95
|
+
outcome: event.outcome,
|
|
96
|
+
actor: event.actor ?? null,
|
|
97
|
+
resource: event.resource ?? null,
|
|
98
|
+
reason: event.reason ?? null,
|
|
99
|
+
ip: event.ip ?? null,
|
|
100
|
+
userAgent: event.userAgent ?? null,
|
|
101
|
+
requestId: event.requestId ?? null,
|
|
102
|
+
flags: event.flags ?? null,
|
|
103
|
+
metadata: event.metadata ?? null
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
async listPage(query) {
|
|
107
|
+
assertPageQuery(query, "cursor");
|
|
108
|
+
const limit = Math.min(Math.max(1, query.limit ?? DEFAULT_LIMIT), MAX_LIMIT);
|
|
109
|
+
const model = this.#resolveModel();
|
|
110
|
+
if (!model) return {
|
|
111
|
+
items: [],
|
|
112
|
+
limit,
|
|
113
|
+
hasNext: false,
|
|
114
|
+
nextCursor: null,
|
|
115
|
+
total: 0
|
|
116
|
+
};
|
|
117
|
+
const filter = this.#buildFilter(query);
|
|
118
|
+
let total;
|
|
119
|
+
if (query.withTotal !== false) total = await model.countDocuments(filter).exec();
|
|
120
|
+
let where = filter;
|
|
121
|
+
const cursor = this.#parseCursor(query.cursor);
|
|
122
|
+
if (cursor) {
|
|
123
|
+
const after = { $or: [{ ts: { $lt: cursor.ts } }, {
|
|
124
|
+
ts: cursor.ts,
|
|
125
|
+
_id: { $lt: cursor.id }
|
|
126
|
+
}] };
|
|
127
|
+
where = Object.keys(filter).length > 0 ? { $and: [filter, after] } : after;
|
|
128
|
+
}
|
|
129
|
+
const docs = await model.find(where).sort({
|
|
130
|
+
ts: -1,
|
|
131
|
+
_id: -1
|
|
132
|
+
}).limit(limit + 1).exec();
|
|
133
|
+
const hasNext = docs.length > limit;
|
|
134
|
+
const rows = (hasNext ? docs.slice(0, limit) : docs).map((doc) => doc.toObject({ virtuals: true }));
|
|
135
|
+
const items = rows.map((row) => this.#toEvent(row));
|
|
136
|
+
const last = rows[rows.length - 1];
|
|
137
|
+
return {
|
|
138
|
+
items,
|
|
139
|
+
limit,
|
|
140
|
+
hasNext,
|
|
141
|
+
nextCursor: hasNext && last ? `${last.ts}${CURSOR_SEPARATOR}${this.#idOf(last)}` : null,
|
|
142
|
+
...total !== void 0 ? { total } : {}
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Décode le curseur composite `<ts>:<id>` (format privé au store, comme ses
|
|
147
|
+
* pendants mémoire et SQL). Jeton absent ou malformé → `null` : la lecture
|
|
148
|
+
* repart de la page la plus récente plutôt que d'échouer sur une consultation.
|
|
149
|
+
*/
|
|
150
|
+
#parseCursor(cursor) {
|
|
151
|
+
if (cursor === void 0) return null;
|
|
152
|
+
const sep = cursor.indexOf(CURSOR_SEPARATOR);
|
|
153
|
+
if (sep <= 0) return null;
|
|
154
|
+
const ts = Number(cursor.slice(0, sep));
|
|
155
|
+
return Number.isFinite(ts) ? {
|
|
156
|
+
ts,
|
|
157
|
+
id: cursor.slice(sep + 1)
|
|
158
|
+
} : null;
|
|
159
|
+
}
|
|
160
|
+
async gc(now = this.#now()) {
|
|
161
|
+
const model = this.#resolveModel();
|
|
162
|
+
if (!model) return 0;
|
|
163
|
+
const threshold = now - this.#retentionMs;
|
|
164
|
+
return (await model.deleteMany({ ts: { $lt: threshold } }).exec()).deletedCount ?? 0;
|
|
165
|
+
}
|
|
166
|
+
/** Compose le filtre Mongo des critères AND ; `{}` si aucun (= tout). */
|
|
167
|
+
#buildFilter(filter) {
|
|
168
|
+
const where = {};
|
|
169
|
+
if (filter.category !== void 0) where.category = filter.category;
|
|
170
|
+
if (filter.outcome !== void 0) where.outcome = filter.outcome;
|
|
171
|
+
if (filter.actor !== void 0) where.actor = filter.actor;
|
|
172
|
+
if (filter.action !== void 0) where.action = filter.action;
|
|
173
|
+
if (filter.requestId !== void 0) where.requestId = filter.requestId;
|
|
174
|
+
if (filter.since !== void 0 || filter.until !== void 0) {
|
|
175
|
+
const ts = {};
|
|
176
|
+
if (filter.since !== void 0) ts.$gte = filter.since;
|
|
177
|
+
if (filter.until !== void 0) ts.$lte = filter.until;
|
|
178
|
+
where.ts = ts;
|
|
179
|
+
}
|
|
180
|
+
return where;
|
|
181
|
+
}
|
|
182
|
+
/** Identité réelle d'un événement : `_id` fait foi, le virtuel `id` en repli. */
|
|
183
|
+
#idOf(row) {
|
|
184
|
+
return row._id ?? row.id;
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Mappe un document vers un événement du contrat.
|
|
188
|
+
*
|
|
189
|
+
* Les champs optionnels (`flags`, `metadata`) ne sont posés que s'ils portent
|
|
190
|
+
* une valeur : `IAuditEvent` les déclare **absents**, pas `null`, et un banc de
|
|
191
|
+
* parité compare les événements relus à ceux qui ont été écrits.
|
|
192
|
+
*/
|
|
193
|
+
#toEvent(row) {
|
|
194
|
+
const event = {
|
|
195
|
+
id: this.#idOf(row),
|
|
196
|
+
ts: row.ts,
|
|
197
|
+
category: row.category,
|
|
198
|
+
action: row.action,
|
|
199
|
+
outcome: row.outcome,
|
|
200
|
+
actor: row.actor,
|
|
201
|
+
resource: row.resource,
|
|
202
|
+
reason: row.reason,
|
|
203
|
+
ip: row.ip,
|
|
204
|
+
userAgent: row.userAgent,
|
|
205
|
+
requestId: row.requestId
|
|
206
|
+
};
|
|
207
|
+
if (row.flags) event.flags = row.flags;
|
|
208
|
+
if (row.metadata) event.metadata = row.metadata;
|
|
209
|
+
return event;
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
//#endregion
|
|
213
|
+
export { MongooseAuditStore };
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { IDEMPOTENCY_ENTITY_NAME } from "../entity/idempotencyEntity.js";
|
|
2
|
+
import { assertPageQuery, escapeRegExp } from "nodefony";
|
|
3
|
+
//#region nodefony/src/MongooseIdempotencyStore.ts
|
|
4
|
+
/** Bail par défaut d'une entrée *in-flight* : 60 s (au-delà = exécution abandonnée). */
|
|
5
|
+
const DEFAULT_LEASE_MS = 6e4;
|
|
6
|
+
/** Rétention par défaut d'une réponse mémorisée : 10 min (rejeu plausible). */
|
|
7
|
+
const DEFAULT_TTL_MS = 6e5;
|
|
8
|
+
/** Code d'erreur MongoDB d'une violation de contrainte d'unicité. */
|
|
9
|
+
const DUPLICATE_KEY = 11e3;
|
|
10
|
+
/**
|
|
11
|
+
* L'erreur est-elle une violation d'unicité MongoDB (`E11000`) ?
|
|
12
|
+
*
|
|
13
|
+
* C'est le **verdict de la réservation**, pas un incident : sur ce store, une
|
|
14
|
+
* violation d'unicité signifie « la clé était vivante, quelqu'un d'autre la
|
|
15
|
+
* détient ». Toute autre erreur (réseau, autorisation) doit remonter — la
|
|
16
|
+
* confondre avec une contention ferait passer une panne pour une dédup.
|
|
17
|
+
*/
|
|
18
|
+
function isDuplicateKeyError(error) {
|
|
19
|
+
return typeof error === "object" && error !== null && error.code === DUPLICATE_KEY;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Store d'idempotence **Mongoose** (NoSQL) — implémentation documentaire d'
|
|
23
|
+
* {@link IIdempotencyStore} (contrat au CORE) pour dédoublonner les mutations
|
|
24
|
+
* rejouées PARTAGÉ cross-pod, là où le store mémoire par défaut reste affine à
|
|
25
|
+
* un pod. Pendant documentaire de `DrizzleIdempotencyStore`.
|
|
26
|
+
*
|
|
27
|
+
* **Pourquoi MongoDB plutôt que Redis** : un cluster qui possède déjà une base
|
|
28
|
+
* Mongo mais pas de Redis obtient la dédup cross-pod sans nouvelle infra — le
|
|
29
|
+
* même argument qui fonde la variante SQL.
|
|
30
|
+
*
|
|
31
|
+
* **Réservation atomique (`begin`)** — clé de voûte. Un `findOneAndUpdate`
|
|
32
|
+
* filtré sur l'entrée MORTE, en `upsert`, est l'équivalent Mongo du `SET … NX
|
|
33
|
+
* PX` Redis et du `INSERT … ON CONFLICT … WHERE expiré` SQL, en **une seule
|
|
34
|
+
* instruction atomique** côté serveur :
|
|
35
|
+
* - clé absente → le filtre ne matche rien → l'`upsert` insère → `fresh` ;
|
|
36
|
+
* - clé présente mais **morte** (bail/rétention expirés) → le filtre matche →
|
|
37
|
+
* l'update la **vole** atomiquement → `fresh` ;
|
|
38
|
+
* - clé présente et **vivante** → le filtre ne matche pas → l'`upsert` tente
|
|
39
|
+
* d'insérer → violation d'unicité sur `_id` (`E11000`) → on lit l'état
|
|
40
|
+
* (`in-flight` / `replayed` / `mismatch`).
|
|
41
|
+
*
|
|
42
|
+
* C'est la **clé primaire** qui porte cette unicité, jamais un index secondaire :
|
|
43
|
+
* Mongoose construit ses index en tâche de fond, donc la contrainte n'existerait
|
|
44
|
+
* pas pendant la fenêtre de construction, et deux `begin` concurrents
|
|
45
|
+
* renverraient tous deux `fresh` — exactement le double-effet que ce store
|
|
46
|
+
* existe pour empêcher.
|
|
47
|
+
*
|
|
48
|
+
* Le store ne renvoie JAMAIS `fresh` sur le chemin de contention → anti
|
|
49
|
+
* double-effet garanti (l'invariant capital d'un store d'idempotence).
|
|
50
|
+
*
|
|
51
|
+
* **Pas de TTL natif** (≠ Redis `PX`, et un index TTL Mongo ignorerait la
|
|
52
|
+
* bascule bail → rétention) → un {@link MongooseIdempotencyStore.gc} applicatif
|
|
53
|
+
* purge les entrées expirées, comme la variante SQL.
|
|
54
|
+
*
|
|
55
|
+
* **Empreinte préservée à la complétion** : `complete()` ne touche pas le champ
|
|
56
|
+
* `fingerprint` → un rejeu de la clé avec un AUTRE payload après complétion est
|
|
57
|
+
* toujours détecté (`mismatch` 422, draft §2.7).
|
|
58
|
+
*
|
|
59
|
+
* **Résolution LAZY + dégradation gracieuse** (calqué sur le frère SQL) : le
|
|
60
|
+
* modèle est résolu à CHAQUE appel, jamais capturé à la construction — le
|
|
61
|
+
* framework fabrique ce store à `onKernelBoot`, AVANT le connect de l'ORM
|
|
62
|
+
* (`onBoot`), et l'ORM se déconnecte au shutdown avant le drain des serveurs. Si
|
|
63
|
+
* l'ORM n'est pas connecté, `begin` renvoie `fresh` (la mutation s'exécute SANS
|
|
64
|
+
* dédup) et `complete`/`abort`/`gc` sont des no-op — l'idempotence est
|
|
65
|
+
* temporairement inactive plutôt que de crasher une mutation en vol.
|
|
66
|
+
*/
|
|
67
|
+
var MongooseIdempotencyStore = class MongooseIdempotencyStore {
|
|
68
|
+
#resolveModel;
|
|
69
|
+
#now;
|
|
70
|
+
#leaseMs;
|
|
71
|
+
#ttlMs;
|
|
72
|
+
/** Compteur LOCAL best-effort des réservations faites par CE pod (cf {@link size}). */
|
|
73
|
+
#pending = 0;
|
|
74
|
+
/**
|
|
75
|
+
* @param resolveModel - résolveur **lazy** du modèle Mongoose (`null` = ORM non
|
|
76
|
+
* connecté → dégradation gracieuse). Lazy car l'ordre de boot/shutdown n'est
|
|
77
|
+
* pas garanti à la construction.
|
|
78
|
+
* @param now - horloge (epoch ms) injectable pour des tests déterministes.
|
|
79
|
+
* @param leaseMs - bail d'une entrée *in-flight* (ms).
|
|
80
|
+
* @param ttlMs - rétention d'une réponse mémorisée (ms).
|
|
81
|
+
* @param connector - connecteur ORM qui porte la collection, publié au registre des stores.
|
|
82
|
+
*/
|
|
83
|
+
constructor(resolveModel, now = Date.now, leaseMs = DEFAULT_LEASE_MS, ttlMs = DEFAULT_TTL_MS, connector) {
|
|
84
|
+
this.#connector = connector;
|
|
85
|
+
this.#resolveModel = resolveModel;
|
|
86
|
+
this.#now = now;
|
|
87
|
+
this.#leaseMs = leaseMs;
|
|
88
|
+
this.#ttlMs = ttlMs;
|
|
89
|
+
}
|
|
90
|
+
/** Connecteur ORM qui porte ce store — reçu du constructeur. */
|
|
91
|
+
#connector;
|
|
92
|
+
/**
|
|
93
|
+
* Connecteur ORM qui porte ce store, lu par `readStoreConnector` pour le
|
|
94
|
+
* registre des stores : la console rattache la brique à SON connecteur au
|
|
95
|
+
* lieu de le déduire. `undefined` pour un store construit sans ORM (bancs).
|
|
96
|
+
*/
|
|
97
|
+
get connector() {
|
|
98
|
+
return this.#connector;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Construit le store depuis un {@link MongooseOrm}. Le modèle est résolu
|
|
102
|
+
* **lazy** (gardé par `isConnected()` → `null` tant que l'ORM n'est pas/plus
|
|
103
|
+
* connecté). L'entité (`registerIdempotencyEntities`) doit avoir été
|
|
104
|
+
* enregistrée **avant** `orm.connect()` (le modèle est compilé au connect).
|
|
105
|
+
*
|
|
106
|
+
* @param orm - ORM Mongoose hébergeant la collection `idempotency_key`.
|
|
107
|
+
* @param now - horloge injectable (tests).
|
|
108
|
+
* @param leaseMs - bail *in-flight* (ms).
|
|
109
|
+
* @param ttlMs - rétention d'une réponse mémorisée (ms).
|
|
110
|
+
*/
|
|
111
|
+
static from(orm, now, leaseMs, ttlMs) {
|
|
112
|
+
return new MongooseIdempotencyStore(() => {
|
|
113
|
+
if (!orm.isConnected()) return null;
|
|
114
|
+
return orm.getNativeConnection().model(IDEMPOTENCY_ENTITY_NAME);
|
|
115
|
+
}, now, leaseMs, ttlMs, orm.name);
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Approximation **per-pod, best-effort** : compteur local des réservations
|
|
119
|
+
* faites par CE pod (incrémenté au `fresh`, décrémenté au `complete`/`abort`),
|
|
120
|
+
* non décrémenté si le bail expire sans complétion, et désaligné cross-pod. La
|
|
121
|
+
* vérité cluster passe par un `countDocuments`, jamais ce getter (sync). Borné à ≥ 0.
|
|
122
|
+
*/
|
|
123
|
+
get size() {
|
|
124
|
+
return this.#pending < 0 ? 0 : this.#pending;
|
|
125
|
+
}
|
|
126
|
+
async begin(key, fingerprint) {
|
|
127
|
+
const model = this.#resolveModel();
|
|
128
|
+
if (!model) return { state: "fresh" };
|
|
129
|
+
const now = this.#now();
|
|
130
|
+
const leaseExpiresAt = now + this.#leaseMs;
|
|
131
|
+
let reserved;
|
|
132
|
+
try {
|
|
133
|
+
await model.findOneAndUpdate({
|
|
134
|
+
_id: key,
|
|
135
|
+
expiresAt: { $lt: now }
|
|
136
|
+
}, { $set: {
|
|
137
|
+
key,
|
|
138
|
+
fingerprint,
|
|
139
|
+
state: "if",
|
|
140
|
+
response: null,
|
|
141
|
+
expiresAt: leaseExpiresAt
|
|
142
|
+
} }, {
|
|
143
|
+
upsert: true,
|
|
144
|
+
returnDocument: "after"
|
|
145
|
+
}).exec();
|
|
146
|
+
reserved = true;
|
|
147
|
+
} catch (error) {
|
|
148
|
+
if (!isDuplicateKeyError(error)) throw error;
|
|
149
|
+
reserved = false;
|
|
150
|
+
}
|
|
151
|
+
if (reserved) {
|
|
152
|
+
this.#pending++;
|
|
153
|
+
return { state: "fresh" };
|
|
154
|
+
}
|
|
155
|
+
const existing = await model.findById(key).lean().exec();
|
|
156
|
+
if (existing === null) return { state: "in-flight" };
|
|
157
|
+
if (existing.fingerprint !== fingerprint) return { state: "mismatch" };
|
|
158
|
+
if (existing.state === "done" && existing.response !== null) return {
|
|
159
|
+
state: "replayed",
|
|
160
|
+
response: existing.response
|
|
161
|
+
};
|
|
162
|
+
return { state: "in-flight" };
|
|
163
|
+
}
|
|
164
|
+
async complete(key, response) {
|
|
165
|
+
const model = this.#resolveModel();
|
|
166
|
+
if (!model) return;
|
|
167
|
+
if (((await model.updateOne({
|
|
168
|
+
_id: key,
|
|
169
|
+
state: "if"
|
|
170
|
+
}, { $set: {
|
|
171
|
+
state: "done",
|
|
172
|
+
response,
|
|
173
|
+
expiresAt: this.#now() + this.#ttlMs
|
|
174
|
+
} }).exec()).modifiedCount ?? 0) > 0) this.#dec();
|
|
175
|
+
}
|
|
176
|
+
async abort(key) {
|
|
177
|
+
const model = this.#resolveModel();
|
|
178
|
+
if (!model) return;
|
|
179
|
+
if (((await model.deleteOne({
|
|
180
|
+
_id: key,
|
|
181
|
+
state: "if"
|
|
182
|
+
}).exec()).deletedCount ?? 0) > 0) this.#dec();
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Purge les entrées mortes (`expiresAt <= now`) — supplée l'absence de TTL
|
|
186
|
+
* exploitable ici (cf le schéma : l'échéance bascule du bail à la rétention).
|
|
187
|
+
* À déclencher périodiquement (le framework arme un `GcScheduler` au boot
|
|
188
|
+
* quand cette méthode existe).
|
|
189
|
+
*
|
|
190
|
+
* @param now - horloge de purge (défaut : horloge injectée).
|
|
191
|
+
* @returns le nombre d'entrées supprimées.
|
|
192
|
+
*/
|
|
193
|
+
async gc(now = this.#now()) {
|
|
194
|
+
const model = this.#resolveModel();
|
|
195
|
+
if (!model) return 0;
|
|
196
|
+
return (await model.deleteMany({ expiresAt: { $lte: now } }).exec()).deletedCount ?? 0;
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* {@inheritDoc IIdempotencyStore.listPage}
|
|
200
|
+
*
|
|
201
|
+
* Query native projetée : on ne lit QUE les champs exposés — la réponse
|
|
202
|
+
* mémorisée (`response`) ne quitte jamais la base par ce chemin, quelle que
|
|
203
|
+
* soit la taille de la page (anti-IDOR sur le cache).
|
|
204
|
+
*
|
|
205
|
+
* Les entrées expirées sont exclues (`expiresAt > now`) : le GC applicatif
|
|
206
|
+
* passe plus tard, mais une clé échue n'est déjà plus opposable.
|
|
207
|
+
*
|
|
208
|
+
* ⚠️ **Le préfixe `q` est ÉCHAPPÉ**, donc littéral. Une chaîne saisie dans une
|
|
209
|
+
* console n'est pas une expression régulière : laisser passer les
|
|
210
|
+
* métacaractères ouvrirait un balayage de collection arbitraire — et pire, une
|
|
211
|
+
* catastrophe de retour sur trace. L'écart avec le frère SQL (où `%` et `_`
|
|
212
|
+
* restent des jokers) est assumé dans ce sens : ici le contrat — « `q` =
|
|
213
|
+
* préfixe » — est tenu au pied de la lettre.
|
|
214
|
+
*/
|
|
215
|
+
async listPage(query) {
|
|
216
|
+
assertPageQuery(query, "offset");
|
|
217
|
+
const limit = Math.max(1, Math.floor(query.limit));
|
|
218
|
+
const offset = Math.max(0, Math.floor(query.offset ?? 0));
|
|
219
|
+
const model = this.#resolveModel();
|
|
220
|
+
if (!model) return {
|
|
221
|
+
items: [],
|
|
222
|
+
total: 0,
|
|
223
|
+
limit,
|
|
224
|
+
offset,
|
|
225
|
+
hasNext: false
|
|
226
|
+
};
|
|
227
|
+
const filter = { expiresAt: { $gt: this.#now() } };
|
|
228
|
+
if (query.state !== void 0) filter.state = query.state === "in-flight" ? "if" : "done";
|
|
229
|
+
if (query.q !== void 0 && query.q.length > 0) filter._id = { $regex: `^${escapeRegExp(query.q)}` };
|
|
230
|
+
const docs = await model.find(filter, {
|
|
231
|
+
_id: 1,
|
|
232
|
+
state: 1,
|
|
233
|
+
expiresAt: 1
|
|
234
|
+
}).sort({
|
|
235
|
+
expiresAt: 1,
|
|
236
|
+
_id: 1
|
|
237
|
+
}).skip(offset).limit(limit + 1).lean().exec();
|
|
238
|
+
const hasNext = docs.length > limit;
|
|
239
|
+
const page = hasNext ? docs.slice(0, limit) : docs;
|
|
240
|
+
const total = query.withTotal === false ? void 0 : await model.countDocuments(filter).exec();
|
|
241
|
+
return {
|
|
242
|
+
items: page.map((doc) => ({
|
|
243
|
+
key: doc._id,
|
|
244
|
+
state: doc.state === "if" ? "in-flight" : "done",
|
|
245
|
+
expiresAtMs: Number(doc.expiresAt),
|
|
246
|
+
hasResponse: doc.state !== "if"
|
|
247
|
+
})),
|
|
248
|
+
total,
|
|
249
|
+
limit,
|
|
250
|
+
offset,
|
|
251
|
+
hasNext
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
/** Décrémente le compteur local borné à 0. */
|
|
255
|
+
#dec() {
|
|
256
|
+
if (this.#pending > 0) this.#pending--;
|
|
257
|
+
}
|
|
258
|
+
};
|
|
259
|
+
//#endregion
|
|
260
|
+
export { MongooseIdempotencyStore };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { TOKEN_ENTITY_NAMES } from "../entity/tokenEntity.js";
|
|
2
1
|
import { mongoOrder } from "./mongoOrder.js";
|
|
2
|
+
import { TOKEN_ENTITY_NAMES } from "../entity/tokenEntity.js";
|
|
3
3
|
import { assertPageQuery } from "nodefony";
|
|
4
4
|
import { paginate } from "@nodefony/orm-core";
|
|
5
5
|
import { TOKEN_DEFAULT_ORDER, TOKEN_SORTABLE_FIELDS, tokenStatusCriteria } from "@nodefony/security";
|
|
@@ -64,6 +64,16 @@ var MongooseTokenStore = class MongooseTokenStore {
|
|
|
64
64
|
this.#now = now;
|
|
65
65
|
this.#retentionRevokedMs = retentionRevokedMs;
|
|
66
66
|
}
|
|
67
|
+
/** Connecteur ORM qui porte ce store — posé par {@link MongooseTokenStore.from}. */
|
|
68
|
+
#connector;
|
|
69
|
+
/**
|
|
70
|
+
* Connecteur ORM qui porte ce store, lu par `readStoreConnector` pour le
|
|
71
|
+
* registre des stores : la console rattache la brique à SON connecteur au
|
|
72
|
+
* lieu de le déduire. `undefined` pour un store construit sans ORM (bancs).
|
|
73
|
+
*/
|
|
74
|
+
get connector() {
|
|
75
|
+
return this.#connector;
|
|
76
|
+
}
|
|
67
77
|
/**
|
|
68
78
|
* Construit le store depuis un {@link MongooseOrm} connecté. Les entités
|
|
69
79
|
* (`registerTokenEntities`) doivent avoir été enregistrées **avant** `connect()`.
|
|
@@ -73,16 +83,50 @@ var MongooseTokenStore = class MongooseTokenStore {
|
|
|
73
83
|
* @param retentionRevokedMs - rétention des PAT révoqués sans `exp`.
|
|
74
84
|
*/
|
|
75
85
|
static from(orm, now, retentionRevokedMs) {
|
|
76
|
-
|
|
86
|
+
const store = new MongooseTokenStore(orm.getRepository(TOKEN_ENTITY_NAMES.records), orm.getRepository(TOKEN_ENTITY_NAMES.denied), orm.getRepository(TOKEN_ENTITY_NAMES.revocations), now, retentionRevokedMs);
|
|
87
|
+
store.#connector = orm.name;
|
|
88
|
+
return store;
|
|
77
89
|
}
|
|
78
90
|
/** Identité réelle d'un record (jti) : `_id` fait foi, le virtuel `id` en repli. */
|
|
79
91
|
#idOf(row) {
|
|
80
92
|
return row._id ?? row.id;
|
|
81
93
|
}
|
|
82
|
-
/**
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
94
|
+
/**
|
|
95
|
+
* Document lu → record du contrat, champ par champ. Rendre le document tel
|
|
96
|
+
* quel laisserait fuir `_id` et `__v` — des champs du MOTEUR que Drizzle ne
|
|
97
|
+
* rend pas, et qui partiraient jusqu'aux vues d'administration.
|
|
98
|
+
*/
|
|
99
|
+
#toRecord(row) {
|
|
100
|
+
return {
|
|
101
|
+
id: this.#idOf(row),
|
|
102
|
+
kind: row.kind,
|
|
103
|
+
name: row.name,
|
|
104
|
+
prefix: row.prefix,
|
|
105
|
+
subjectId: row.subjectId,
|
|
106
|
+
subjectType: row.subjectType,
|
|
107
|
+
tenantId: row.tenantId,
|
|
108
|
+
scopes: row.scopes,
|
|
109
|
+
audience: row.audience,
|
|
110
|
+
resources: row.resources,
|
|
111
|
+
secretHash: row.secretHash,
|
|
112
|
+
hashAlg: row.hashAlg,
|
|
113
|
+
clientId: row.clientId,
|
|
114
|
+
cnf: row.cnf,
|
|
115
|
+
family: row.family,
|
|
116
|
+
replacedBy: row.replacedBy,
|
|
117
|
+
createdAt: row.createdAt,
|
|
118
|
+
expiresAt: row.expiresAt,
|
|
119
|
+
lastUsedAt: row.lastUsedAt,
|
|
120
|
+
lastUsedIp: row.lastUsedIp,
|
|
121
|
+
lastUsedUserAgent: row.lastUsedUserAgent,
|
|
122
|
+
revokedAt: row.revokedAt,
|
|
123
|
+
revokedReason: row.revokedReason,
|
|
124
|
+
metadata: row.metadata
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
/** `#toRecord` tolérant à l'absence (lecture unitaire). */
|
|
128
|
+
#toRecordOrNull(row) {
|
|
129
|
+
return row ? this.#toRecord(row) : null;
|
|
86
130
|
}
|
|
87
131
|
/**
|
|
88
132
|
* Insère ou remplace un record (PAT / refresh) — 1 round-trip, `upsert`
|
|
@@ -102,27 +146,24 @@ var MongooseTokenStore = class MongooseTokenStore {
|
|
|
102
146
|
await this.#records.upsert({ id }, rest);
|
|
103
147
|
}
|
|
104
148
|
async findById(id) {
|
|
105
|
-
return this.#
|
|
149
|
+
return this.#toRecordOrNull(await this.#records.findOne({ id }));
|
|
106
150
|
}
|
|
107
151
|
async findByHash(secretHash) {
|
|
108
|
-
return this.#
|
|
152
|
+
return this.#toRecordOrNull(await this.#records.findOne({ secretHash }));
|
|
109
153
|
}
|
|
110
154
|
async findBySubject(subjectId) {
|
|
111
|
-
|
|
112
|
-
for (const row of rows) row.id = this.#idOf(row);
|
|
113
|
-
return rows;
|
|
155
|
+
return (await this.#records.find({ subjectId })).map((row) => this.#toRecord(row));
|
|
114
156
|
}
|
|
115
157
|
/** Tous les jetons (PAT + refresh) — vue d'administration cross-porteur. */
|
|
116
158
|
async listAll() {
|
|
117
|
-
|
|
118
|
-
for (const row of rows) row.id = this.#idOf(row);
|
|
119
|
-
return rows;
|
|
159
|
+
return (await this.#records.find({})).map((row) => this.#toRecord(row));
|
|
120
160
|
}
|
|
121
161
|
/**
|
|
122
162
|
* {@inheritDoc ITokenStore.listPage}
|
|
123
163
|
*
|
|
124
164
|
* `paginate()` d'orm-core (skip/limit + countDocuments) sur un filtre portable ;
|
|
125
|
-
* les
|
|
165
|
+
* les documents sont convertis en records (`_id` → `id`, sans `__v`) comme dans
|
|
166
|
+
* {@link listAll}.
|
|
126
167
|
*
|
|
127
168
|
* Le tri demandé est **traduit** avant de descendre : au repos, le jeton n'a
|
|
128
169
|
* pas de champ `id` (le `jti` EST le `_id`), et Mongo ne se plaint pas d'un tri
|
|
@@ -138,8 +179,10 @@ var MongooseTokenStore = class MongooseTokenStore {
|
|
|
138
179
|
withTotal: query.withTotal,
|
|
139
180
|
order: mongoOrder(query.order, this.sortableFields, TOKEN_DEFAULT_ORDER)
|
|
140
181
|
});
|
|
141
|
-
|
|
142
|
-
|
|
182
|
+
return {
|
|
183
|
+
...page,
|
|
184
|
+
items: page.items.map((row) => this.#toRecord(row))
|
|
185
|
+
};
|
|
143
186
|
}
|
|
144
187
|
/** {@inheritDoc ITokenStore.countTokens} */
|
|
145
188
|
countTokens(query) {
|