@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,109 @@
|
|
|
1
|
+
import { entityRegistry } from "@nodefony/orm-core";
|
|
2
|
+
//#region nodefony/entity/webhookEndpointEntity.ts
|
|
3
|
+
/**
|
|
4
|
+
* Schéma Mongoose du **store d'endpoints webhook** `@nodefony/security` (P6.13,
|
|
5
|
+
* pendant documentaire de la table Drizzle) — implémentation NoSQL d'
|
|
6
|
+
* `IWebhookStore` (registre durable des destinations notifiées).
|
|
7
|
+
*
|
|
8
|
+
* ⚠️ **`_id` = clé naturelle (String), PAS un ObjectId auto** : l'`id` d'un
|
|
9
|
+
* endpoint est un identifiant `wh_<random>` **fourni** (pas généré par Mongo) →
|
|
10
|
+
* on force `_id: String` → l'id EST la clé primaire. Le contrat `IRepository`
|
|
11
|
+
* traduit le critère `{ id }` en `{ _id }` ; le virtuel `id` (activé par
|
|
12
|
+
* `MongooseOrm`, `toObject:{virtuals:true}`) renvoie `String(_id)` = l'id.
|
|
13
|
+
*
|
|
14
|
+
* ⚠️ **Horodatages = `Number` (epoch ms), `timestamps:false`** : `IWebhookEndpoint`
|
|
15
|
+
* porte des `number` (`Date.now()`) et l'appelant fournit `createdAt`/`updatedAt`.
|
|
16
|
+
*
|
|
17
|
+
* `events` = tableau de strings ; `metadata` = objet libre (`Object`/Mixed, idem
|
|
18
|
+
* `tokenEntity.metadata`). Le store réécrit ces champs en bloc (pas de mutation
|
|
19
|
+
* partielle in-place) → pas de souci de change-tracking Mixed.
|
|
20
|
+
*/
|
|
21
|
+
const webhookEndpointSchema = {
|
|
22
|
+
_id: { type: String },
|
|
23
|
+
url: {
|
|
24
|
+
type: String,
|
|
25
|
+
required: true
|
|
26
|
+
},
|
|
27
|
+
secretEnc: {
|
|
28
|
+
type: String,
|
|
29
|
+
required: true
|
|
30
|
+
},
|
|
31
|
+
events: {
|
|
32
|
+
type: [String],
|
|
33
|
+
default: []
|
|
34
|
+
},
|
|
35
|
+
enabled: {
|
|
36
|
+
type: Boolean,
|
|
37
|
+
required: true
|
|
38
|
+
},
|
|
39
|
+
description: {
|
|
40
|
+
type: String,
|
|
41
|
+
default: null
|
|
42
|
+
},
|
|
43
|
+
tenantId: {
|
|
44
|
+
type: String,
|
|
45
|
+
default: null
|
|
46
|
+
},
|
|
47
|
+
createdBy: {
|
|
48
|
+
type: String,
|
|
49
|
+
default: null
|
|
50
|
+
},
|
|
51
|
+
createdAt: {
|
|
52
|
+
type: Number,
|
|
53
|
+
required: true
|
|
54
|
+
},
|
|
55
|
+
updatedAt: {
|
|
56
|
+
type: Number,
|
|
57
|
+
required: true
|
|
58
|
+
},
|
|
59
|
+
lastDeliveryAt: {
|
|
60
|
+
type: Number,
|
|
61
|
+
default: null
|
|
62
|
+
},
|
|
63
|
+
lastDeliveryStatus: {
|
|
64
|
+
type: Number,
|
|
65
|
+
default: null
|
|
66
|
+
},
|
|
67
|
+
lastDeliveryError: {
|
|
68
|
+
type: String,
|
|
69
|
+
default: null
|
|
70
|
+
},
|
|
71
|
+
failureCount: {
|
|
72
|
+
type: Number,
|
|
73
|
+
required: true
|
|
74
|
+
},
|
|
75
|
+
metadata: {
|
|
76
|
+
type: Object,
|
|
77
|
+
default: {}
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
/** Nom logique de l'entité (clé de lookup `getRepository`). */
|
|
81
|
+
const WEBHOOK_ENDPOINT_ENTITY = "webhook_endpoint";
|
|
82
|
+
/**
|
|
83
|
+
* Construit le descripteur d'entité du store webhook pour un ORM nommé.
|
|
84
|
+
*
|
|
85
|
+
* Le `connector` est **dynamique** (nom du connecteur de l'app, ex. `"nodefony"`) : le
|
|
86
|
+
* schéma est statique mais sa liaison à un ORM dépend de la config → pas
|
|
87
|
+
* d'`@entity` figé. `timestamps:false`. À enregistrer **avant** `orm.connect()`.
|
|
88
|
+
*
|
|
89
|
+
* @param orm - clé de l'ORM cible dans le `ormRegistry`.
|
|
90
|
+
*/
|
|
91
|
+
function createWebhookEndpointEntity(connector) {
|
|
92
|
+
return {
|
|
93
|
+
connector,
|
|
94
|
+
name: WEBHOOK_ENDPOINT_ENTITY,
|
|
95
|
+
module: "security",
|
|
96
|
+
schema: webhookEndpointSchema
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Enregistre l'entité du store webhook dans le `entityRegistry` pour un ORM
|
|
101
|
+
* donné. À appeler **avant** `orm.connect()` (le modèle est compilé au connect).
|
|
102
|
+
*
|
|
103
|
+
* @param connector - nom de la connexion cible (clé du registre).
|
|
104
|
+
*/
|
|
105
|
+
function registerWebhookEndpointEntity(connector) {
|
|
106
|
+
entityRegistry.register(createWebhookEndpointEntity(connector));
|
|
107
|
+
}
|
|
108
|
+
//#endregion
|
|
109
|
+
export { WEBHOOK_ENDPOINT_ENTITY, createWebhookEndpointEntity, registerWebhookEndpointEntity, webhookEndpointSchema };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { MongooseOrm } from "./src/orm-core/MongooseOrm.js";
|
|
2
|
+
import "./src/orm-core/index.js";
|
|
3
|
+
import { TOKEN_ENTITY_NAMES, registerTokenEntities } from "./entity/tokenEntity.js";
|
|
4
|
+
import { WEBAUTHN_CREDENTIAL_ENTITY, registerWebAuthnCredentialEntity } from "./entity/webAuthnCredentialEntity.js";
|
|
5
|
+
import { WEBHOOK_ENDPOINT_ENTITY, registerWebhookEndpointEntity } from "./entity/webhookEndpointEntity.js";
|
|
6
|
+
import { MongooseTokenStore } from "./src/MongooseTokenStore.js";
|
|
7
|
+
import { MongooseWebAuthnCredentialStore } from "./src/MongooseWebAuthnCredentialStore.js";
|
|
8
|
+
import { MongooseWebhookStore } from "./src/MongooseWebhookStore.js";
|
|
9
|
+
import { entityRegistry, ormRegistry } from "@nodefony/orm-core";
|
|
10
|
+
import { getTokenStoreFactory, getWebAuthnStoreFactory, getWebhookStoreFactory, registerTokenStore, registerWebAuthnStore, registerWebhookStore } from "@nodefony/security";
|
|
11
|
+
//#region nodefony/registerStores.ts
|
|
12
|
+
/**
|
|
13
|
+
* AUTO-ENREGISTREMENT des backends framework portés par Mongoose — « charger le
|
|
14
|
+
* module = ses backends deviennent sélectionnables par simple nom » (convention-
|
|
15
|
+
* frère : `registerDrizzleFrameworkStores` de `@nodefony/drizzle`).
|
|
16
|
+
*
|
|
17
|
+
* Appelé par `Mongoose.onKernelRegister` (AVANT le connect de `onBoot` — les
|
|
18
|
+
* modèles sont compilés à la connexion). Pas de dialecte (NoSQL) : les schémas
|
|
19
|
+
* sont portables par construction.
|
|
20
|
+
*
|
|
21
|
+
* Couverture PARTIELLE assumée : session (auto via `@entity`), tokens, webauthn,
|
|
22
|
+
* webhooks. PAS d'implémentation mongoose pour l'audit ni l'idempotence — les
|
|
23
|
+
* sélectionner sur mongoose échoue franc (« store inconnu »), jamais en silence.
|
|
24
|
+
*
|
|
25
|
+
* Mêmes garde-fous que Drizzle : entité `has`-guarded (l'app garde la main),
|
|
26
|
+
* fabrique `get`-guarded (premier-arrivé-premier-servi).
|
|
27
|
+
*/
|
|
28
|
+
/**
|
|
29
|
+
* Connecteur conventionnel qui héberge le schéma framework (`"nodefony"` pour
|
|
30
|
+
* Mongoose, ≠ `"default"` de Drizzle — isole les entités homonymes dans le
|
|
31
|
+
* `entityRegistry` process-wide si les deux ORM cohabitent).
|
|
32
|
+
*/
|
|
33
|
+
const FRAMEWORK_CONNECTOR = "nodefony";
|
|
34
|
+
/**
|
|
35
|
+
* Résout l'ORM `nodefony` CONNECTÉ pour une fabrique de store — échec FRANC avec
|
|
36
|
+
* la cause exacte (module absent / ordre de boot) : principe « pas de dégradation
|
|
37
|
+
* silencieuse ».
|
|
38
|
+
*/
|
|
39
|
+
function resolveConnectedOrm(store) {
|
|
40
|
+
let orm;
|
|
41
|
+
try {
|
|
42
|
+
orm = ormRegistry.get(FRAMEWORK_CONNECTOR);
|
|
43
|
+
} catch {
|
|
44
|
+
throw new Error(`${store} : ORM "${FRAMEWORK_CONNECTOR}" introuvable — charger @nodefony/mongoose AVANT @nodefony/security dans le manifeste "modules".`);
|
|
45
|
+
}
|
|
46
|
+
if (!(orm instanceof MongooseOrm)) throw new Error(`${store} : l'ORM "${FRAMEWORK_CONNECTOR}" n'est pas un MongooseOrm (connecteur homonyme d'un autre driver ?).`);
|
|
47
|
+
if (!orm.isConnected()) throw new Error(`${store} : ORM "${FRAMEWORK_CONNECTOR}" non connecté au montage du store (ordre de boot).`);
|
|
48
|
+
return orm;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Déclare les entités framework Mongoose (connecteur `nodefony`) et enregistre
|
|
52
|
+
* leurs fabriques de stores dans les registres de `@nodefony/security`.
|
|
53
|
+
* Idempotent (guards) — rejouable sans effet.
|
|
54
|
+
*
|
|
55
|
+
* @returns bilan à logger (registered / appOwned)
|
|
56
|
+
*/
|
|
57
|
+
function registerMongooseFrameworkStores() {
|
|
58
|
+
const report = {
|
|
59
|
+
registered: [],
|
|
60
|
+
appOwned: []
|
|
61
|
+
};
|
|
62
|
+
const wire = (entityName, registerEntity, registerFactory) => {
|
|
63
|
+
if (entityRegistry.has(entityName, "nodefony")) report.appOwned.push(entityName);
|
|
64
|
+
else {
|
|
65
|
+
registerEntity();
|
|
66
|
+
report.registered.push(entityName);
|
|
67
|
+
}
|
|
68
|
+
registerFactory();
|
|
69
|
+
};
|
|
70
|
+
wire(TOKEN_ENTITY_NAMES.records, () => registerTokenEntities(FRAMEWORK_CONNECTOR), () => {
|
|
71
|
+
if (getTokenStoreFactory("mongoose")) return;
|
|
72
|
+
registerTokenStore("mongoose", (ctx) => {
|
|
73
|
+
const orm = resolveConnectedOrm(`tokenStore "mongoose"`);
|
|
74
|
+
const days = ctx?.config?.tokenStore?.retentionRevokedDays;
|
|
75
|
+
return MongooseTokenStore.from(orm, void 0, typeof days === "number" ? days * 864e5 : void 0);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
wire(WEBAUTHN_CREDENTIAL_ENTITY, () => registerWebAuthnCredentialEntity(FRAMEWORK_CONNECTOR), () => {
|
|
79
|
+
if (getWebAuthnStoreFactory("mongoose")) return;
|
|
80
|
+
registerWebAuthnStore("mongoose", () => MongooseWebAuthnCredentialStore.from(resolveConnectedOrm(`passkeys.store "mongoose"`)));
|
|
81
|
+
});
|
|
82
|
+
wire(WEBHOOK_ENDPOINT_ENTITY, () => registerWebhookEndpointEntity(FRAMEWORK_CONNECTOR), () => {
|
|
83
|
+
if (getWebhookStoreFactory("mongoose")) return;
|
|
84
|
+
registerWebhookStore("mongoose", () => MongooseWebhookStore.from(resolveConnectedOrm(`webhooks.store "mongoose"`)));
|
|
85
|
+
});
|
|
86
|
+
return report;
|
|
87
|
+
}
|
|
88
|
+
//#endregion
|
|
89
|
+
export { FRAMEWORK_CONNECTOR, registerMongooseFrameworkStores };
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { MongooseOrm } from "../src/orm-core/MongooseOrm.js";
|
|
2
|
+
import mongoose from "mongoose";
|
|
3
|
+
import { Service } from "nodefony";
|
|
4
|
+
import { queryFlowMonitor, resolveOrmFlowEnabled } from "@nodefony/orm-core";
|
|
5
|
+
//#region nodefony/service/MongooseService.ts
|
|
6
|
+
const serviceName = "mongoose";
|
|
7
|
+
/**
|
|
8
|
+
* Service bootable du module `@nodefony/mongoose` (driver NoSQL).
|
|
9
|
+
*
|
|
10
|
+
* Au boot du kernel (`onBoot`), instancie un {@link MongooseOrm} (adapter
|
|
11
|
+
* orm-core) **par connecteur** déclaré dans la config et le connecte ; chaque
|
|
12
|
+
* ORM s'auto-enregistre dans le `ormRegistry`. Ferme proprement les connexions
|
|
13
|
+
* à `onTerminate`.
|
|
14
|
+
*
|
|
15
|
+
* Refonte de l'ancien `Mongoose extends Orm` (core legacy) : ce service ne
|
|
16
|
+
* dérive plus de la base ORM du core — il **orchestre** des adapters orm-core
|
|
17
|
+
* autonomes, exactement comme `DrizzleService`. Le core ne connaît plus l'ORM.
|
|
18
|
+
*/
|
|
19
|
+
var MongooseService = class MongooseService extends Service {
|
|
20
|
+
module;
|
|
21
|
+
/** ORM connectés, indexés par nom de connecteur. */
|
|
22
|
+
#orms = /* @__PURE__ */ new Map();
|
|
23
|
+
constructor(module) {
|
|
24
|
+
super(serviceName, module.container, module.notificationsCenter, module.options ?? {});
|
|
25
|
+
this.module = module;
|
|
26
|
+
this.module.hookKernel("onBoot", async () => {
|
|
27
|
+
queryFlowMonitor.setEnabled(resolveOrmFlowEnabled(this.kernel));
|
|
28
|
+
await this.connectAll().catch((e) => {
|
|
29
|
+
this.log(e, "ERROR");
|
|
30
|
+
throw e;
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
this.kernel?.once("onTerminate", async () => {
|
|
34
|
+
await this.disconnectAll().catch(() => {});
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
/** Config validée (Zod) exposée par le Module (`this.module.config`). */
|
|
38
|
+
#config() {
|
|
39
|
+
return this.module.config;
|
|
40
|
+
}
|
|
41
|
+
/** Connecte tous les connecteurs déclarés en config (validée Zod). */
|
|
42
|
+
async connectAll() {
|
|
43
|
+
const config = this.#config();
|
|
44
|
+
if (config?.debug) mongoose.set("debug", true);
|
|
45
|
+
const connectors = config?.connectors ?? {};
|
|
46
|
+
for (const [name, cfg] of Object.entries(connectors)) await this.#connectOne(name, cfg);
|
|
47
|
+
}
|
|
48
|
+
/** Assemble l'URI de connexion à partir de la config (`uri` ou composants). */
|
|
49
|
+
static buildUri(cfg) {
|
|
50
|
+
if (cfg.uri) return cfg.uri;
|
|
51
|
+
return `mongodb://${cfg.host ?? "localhost"}:${cfg.port ?? 27017}/${cfg.dbname ?? "nodefony"}`;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Options de connexion Mongoose d'un connecteur.
|
|
55
|
+
*
|
|
56
|
+
* `options` reste un fourre-tout transmis tel quel — Mongoose valide ses
|
|
57
|
+
* propres `ConnectOptions`, les re-modéliser en Zod serait une duplication
|
|
58
|
+
* qui dériverait. `autoIndex` fait exception, et une seule : il décide si les
|
|
59
|
+
* contraintes d'unicité sont construites au démarrage, ce qui mérite un champ
|
|
60
|
+
* typé, décrit, et visible dans la configuration d'un connecteur.
|
|
61
|
+
*
|
|
62
|
+
* Il **prime** donc sur une clé homonyme écrite dans `options` : entre deux
|
|
63
|
+
* canaux, celui qui est déclaré gagne — la même règle que les délais de
|
|
64
|
+
* connexion, où un choix explicite l'emporte sur un défaut.
|
|
65
|
+
*
|
|
66
|
+
* @param cfg - configuration validée du connecteur.
|
|
67
|
+
* @returns les options à passer à la connexion, ou `undefined` s'il n'y en a aucune.
|
|
68
|
+
*/
|
|
69
|
+
static buildConnectOptions(cfg) {
|
|
70
|
+
if (cfg.autoIndex === void 0) return cfg.options;
|
|
71
|
+
return {
|
|
72
|
+
...cfg.options ?? {},
|
|
73
|
+
autoIndex: cfg.autoIndex
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
/** Connecte un connecteur (URI + options d'auth/pool). */
|
|
77
|
+
async #connectOne(name, cfg) {
|
|
78
|
+
const uri = MongooseService.buildUri(cfg);
|
|
79
|
+
const orm = new MongooseOrm(name, uri, MongooseService.buildConnectOptions(cfg));
|
|
80
|
+
await orm.connect();
|
|
81
|
+
this.#orms.set(name, orm);
|
|
82
|
+
this.log(`Mongoose ORM "${name}" connected (${orm.safeTarget()})`, "INFO");
|
|
83
|
+
}
|
|
84
|
+
/** Ferme toutes les connexions. */
|
|
85
|
+
async disconnectAll() {
|
|
86
|
+
for (const orm of this.#orms.values()) await orm.disconnect();
|
|
87
|
+
this.#orms.clear();
|
|
88
|
+
}
|
|
89
|
+
/** Retourne l'ORM Mongoose d'un connecteur (défaut : `"nodefony"`). */
|
|
90
|
+
getOrm(name = "nodefony") {
|
|
91
|
+
return this.#orms.get(name);
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
//#endregion
|
|
95
|
+
export { MongooseService as default };
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { TOKEN_ENTITY_NAMES } from "../entity/tokenEntity.js";
|
|
2
|
+
import { mongoOrder } from "./mongoOrder.js";
|
|
3
|
+
import { assertPageQuery } from "nodefony";
|
|
4
|
+
import { paginate } from "@nodefony/orm-core";
|
|
5
|
+
import { TOKEN_DEFAULT_ORDER, TOKEN_SORTABLE_FIELDS, tokenStatusCriteria } from "@nodefony/security";
|
|
6
|
+
//#region nodefony/src/MongooseTokenStore.ts
|
|
7
|
+
/**
|
|
8
|
+
* Traduit les filtres de listing en `Criteria` portable (`id`→`_id` géré par le
|
|
9
|
+
* repo).
|
|
10
|
+
*
|
|
11
|
+
* L'état de vie (`status`) vient de `tokenStatusCriteria`, partagé avec
|
|
12
|
+
* l'adapter SQL : une seule écriture de la règle « révoqué l'emporte sur
|
|
13
|
+
* expiré », donc aucune divergence possible entre les deux backends.
|
|
14
|
+
*/
|
|
15
|
+
function tokenListCriteria(query, now) {
|
|
16
|
+
const criteria = { ...tokenStatusCriteria(query.status, now) };
|
|
17
|
+
if (query.subjectId !== void 0) criteria.subjectId = query.subjectId;
|
|
18
|
+
if (query.kind !== void 0) criteria.kind = query.kind;
|
|
19
|
+
return criteria;
|
|
20
|
+
}
|
|
21
|
+
/** Fenêtre par défaut de conservation d'un PAT révoqué sans expiration (30 j). */
|
|
22
|
+
const DEFAULT_RETENTION_REVOKED_MS = 2592e6;
|
|
23
|
+
/**
|
|
24
|
+
* Store de jetons **Mongoose** (NoSQL) — implémentation d'{@link ITokenStore} au
|
|
25
|
+
* dessus de trois repositories `@nodefony/orm-core` (`access_token`, `denied_jti`,
|
|
26
|
+
* `subject_revocation`). Pendant documentaire de `DrizzleTokenStore`.
|
|
27
|
+
*
|
|
28
|
+
* **Approche B** : `@nodefony/security` n'est connu qu'en `import type` (0 dép
|
|
29
|
+
* runtime). C'est l'application qui enregistre la fabrique (`registerTokenStore`)
|
|
30
|
+
* et les entités (`registerTokenEntities(orm)` avant `orm.connect()`).
|
|
31
|
+
*
|
|
32
|
+
* **Spécificité Mongo** : la clé naturelle (`jti` / `subjectId`) est portée par
|
|
33
|
+
* `_id` (cf {@link tokenEntity}). Le contrat traduit `{ id }` → `{ _id }`, donc
|
|
34
|
+
* les lookups passent par le champ `id` ; les **écritures** posent explicitement
|
|
35
|
+
* `_id` (Mongo ne génère pas notre jti). Les reads sont normalisés (`id` ← `_id`)
|
|
36
|
+
* pour ne pas dépendre du virtuel. Le reste est identique au store Drizzle : `gc`
|
|
37
|
+
* via `$lte` (le type bracketing Mongo exclut les `null`), idempotence de `revoke`
|
|
38
|
+
* par read-then-write.
|
|
39
|
+
*/
|
|
40
|
+
var MongooseTokenStore = class MongooseTokenStore {
|
|
41
|
+
/**
|
|
42
|
+
* {@inheritDoc ITokenStore.sortableFields}
|
|
43
|
+
*
|
|
44
|
+
* Capacité pleine : le vocabulaire public entier est trié par Mongo, `id`
|
|
45
|
+
* compris — traduit en `_id` au moment de la requête.
|
|
46
|
+
*/
|
|
47
|
+
sortableFields = TOKEN_SORTABLE_FIELDS;
|
|
48
|
+
#records;
|
|
49
|
+
#denied;
|
|
50
|
+
#revocations;
|
|
51
|
+
#now;
|
|
52
|
+
#retentionRevokedMs;
|
|
53
|
+
/**
|
|
54
|
+
* @param records - repository de `access_token` (PAT + refresh).
|
|
55
|
+
* @param denied - repository de la denylist `denied_jti`.
|
|
56
|
+
* @param revocations - repository des seuils `subject_revocation`.
|
|
57
|
+
* @param now - horloge (epoch ms) injectable pour les tests.
|
|
58
|
+
* @param retentionRevokedMs - rétention d'un PAT révoqué sans `exp` avant purge.
|
|
59
|
+
*/
|
|
60
|
+
constructor(records, denied, revocations, now = Date.now, retentionRevokedMs = DEFAULT_RETENTION_REVOKED_MS) {
|
|
61
|
+
this.#records = records;
|
|
62
|
+
this.#denied = denied;
|
|
63
|
+
this.#revocations = revocations;
|
|
64
|
+
this.#now = now;
|
|
65
|
+
this.#retentionRevokedMs = retentionRevokedMs;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Construit le store depuis un {@link MongooseOrm} connecté. Les entités
|
|
69
|
+
* (`registerTokenEntities`) doivent avoir été enregistrées **avant** `connect()`.
|
|
70
|
+
*
|
|
71
|
+
* @param orm - ORM Mongoose connecté hébergeant les collections du store.
|
|
72
|
+
* @param now - horloge injectable (tests).
|
|
73
|
+
* @param retentionRevokedMs - rétention des PAT révoqués sans `exp`.
|
|
74
|
+
*/
|
|
75
|
+
static from(orm, now, retentionRevokedMs) {
|
|
76
|
+
return new MongooseTokenStore(orm.getRepository(TOKEN_ENTITY_NAMES.records), orm.getRepository(TOKEN_ENTITY_NAMES.denied), orm.getRepository(TOKEN_ENTITY_NAMES.revocations), now, retentionRevokedMs);
|
|
77
|
+
}
|
|
78
|
+
/** Identité réelle d'un record (jti) : `_id` fait foi, le virtuel `id` en repli. */
|
|
79
|
+
#idOf(row) {
|
|
80
|
+
return row._id ?? row.id;
|
|
81
|
+
}
|
|
82
|
+
/** Normalise `id` (← `_id`) sur un record lu, sans dépendre du virtuel Mongoose. */
|
|
83
|
+
#withId(row) {
|
|
84
|
+
if (row) row.id = this.#idOf(row);
|
|
85
|
+
return row;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Insère ou remplace un record (PAT / refresh) — 1 round-trip, `upsert`
|
|
89
|
+
* atomique sur la PK plutôt qu'un `findOne` d'existence + `create`/`updateOne`
|
|
90
|
+
* (dont l'`await` laisse deux put concurrents du même id lire « absent » et
|
|
91
|
+
* insérer tous les deux → E11000 pour le perdant). `put` pose le record
|
|
92
|
+
* COMPLET → tout hors `id` est ré-appliqué au conflit.
|
|
93
|
+
*
|
|
94
|
+
* `id` en critère suffit à poser `_id` : Mongo ajoute les égalités du filtre
|
|
95
|
+
* au document inséré (cf `MongooseRepository.upsert`), plus besoin du `_id`
|
|
96
|
+
* explicite. Parité stricte avec l'adapter Drizzle.
|
|
97
|
+
*
|
|
98
|
+
* @param record - le record complet à persister.
|
|
99
|
+
*/
|
|
100
|
+
async put(record) {
|
|
101
|
+
const { id, ...rest } = record;
|
|
102
|
+
await this.#records.upsert({ id }, rest);
|
|
103
|
+
}
|
|
104
|
+
async findById(id) {
|
|
105
|
+
return this.#withId(await this.#records.findOne({ id }));
|
|
106
|
+
}
|
|
107
|
+
async findByHash(secretHash) {
|
|
108
|
+
return this.#withId(await this.#records.findOne({ secretHash }));
|
|
109
|
+
}
|
|
110
|
+
async findBySubject(subjectId) {
|
|
111
|
+
const rows = await this.#records.find({ subjectId });
|
|
112
|
+
for (const row of rows) row.id = this.#idOf(row);
|
|
113
|
+
return rows;
|
|
114
|
+
}
|
|
115
|
+
/** Tous les jetons (PAT + refresh) — vue d'administration cross-porteur. */
|
|
116
|
+
async listAll() {
|
|
117
|
+
const rows = await this.#records.find({});
|
|
118
|
+
for (const row of rows) row.id = this.#idOf(row);
|
|
119
|
+
return rows;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* {@inheritDoc ITokenStore.listPage}
|
|
123
|
+
*
|
|
124
|
+
* `paginate()` d'orm-core (skip/limit + countDocuments) sur un filtre portable ;
|
|
125
|
+
* les `id` sont re-normalisés (`_id` → `id`) comme dans {@link listAll}.
|
|
126
|
+
*
|
|
127
|
+
* Le tri demandé est **traduit** avant de descendre : au repos, le jeton n'a
|
|
128
|
+
* pas de champ `id` (le `jti` EST le `_id`), et Mongo ne se plaint pas d'un tri
|
|
129
|
+
* sur un champ absent — il rend un ordre arbitraire. Sans traduction, un
|
|
130
|
+
* `?order=id` serait donc inerte ici et correct partout ailleurs.
|
|
131
|
+
*/
|
|
132
|
+
async listPage(query) {
|
|
133
|
+
assertPageQuery(query, "offset");
|
|
134
|
+
const page = await paginate(this.#records, {
|
|
135
|
+
criteria: tokenListCriteria(query, this.#now()),
|
|
136
|
+
limit: query.limit,
|
|
137
|
+
offset: query.offset,
|
|
138
|
+
withTotal: query.withTotal,
|
|
139
|
+
order: mongoOrder(query.order, this.sortableFields, TOKEN_DEFAULT_ORDER)
|
|
140
|
+
});
|
|
141
|
+
for (const row of page.items) row.id = this.#idOf(row);
|
|
142
|
+
return page;
|
|
143
|
+
}
|
|
144
|
+
/** {@inheritDoc ITokenStore.countTokens} */
|
|
145
|
+
countTokens(query) {
|
|
146
|
+
return this.#records.count(tokenListCriteria(query, this.#now()));
|
|
147
|
+
}
|
|
148
|
+
async markUsed(id, usage) {
|
|
149
|
+
await this.#records.updateOne({ id }, {
|
|
150
|
+
lastUsedAt: usage.at,
|
|
151
|
+
lastUsedIp: usage.ip ?? null,
|
|
152
|
+
lastUsedUserAgent: usage.userAgent ?? null
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Révoque un jeton — **idempotent** : la 1ʳᵉ date/raison est conservée.
|
|
157
|
+
*
|
|
158
|
+
* Le « pas encore révoqué » est dans le filtre (`revokedAt: { $null: true }`),
|
|
159
|
+
* pas dans un `if` JS après lecture : une seule instruction, donc deux
|
|
160
|
+
* révocations concurrentes ne se recouvrent plus (la 2ᵉ ne matche rien au lieu
|
|
161
|
+
* d'écraser la date/raison de la 1ʳᵉ). Parité stricte avec l'adapter Drizzle.
|
|
162
|
+
*
|
|
163
|
+
* @param id - identifiant du jeton.
|
|
164
|
+
* @param reason - motif, posé seulement à la 1ʳᵉ révocation.
|
|
165
|
+
*/
|
|
166
|
+
async revoke(id, reason) {
|
|
167
|
+
await this.#records.updateOne({
|
|
168
|
+
id,
|
|
169
|
+
revokedAt: { $null: true }
|
|
170
|
+
}, {
|
|
171
|
+
revokedAt: this.#now(),
|
|
172
|
+
revokedReason: reason
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Coupe toute une famille de refresh (détection de rejeu, RFC 9700) — les
|
|
177
|
+
* membres déjà révoqués gardent leur raison d'origine.
|
|
178
|
+
*
|
|
179
|
+
* Un seul `updateMany` filtré : atomique, et N+1 requêtes (1 find + 1 update
|
|
180
|
+
* par membre actif) tombent à 1.
|
|
181
|
+
*
|
|
182
|
+
* @param family - famille de refresh à couper.
|
|
183
|
+
* @param reason - motif appliqué aux membres encore actifs.
|
|
184
|
+
*/
|
|
185
|
+
async revokeFamily(family, reason) {
|
|
186
|
+
await this.#records.updateMany({
|
|
187
|
+
family,
|
|
188
|
+
revokedAt: { $null: true }
|
|
189
|
+
}, {
|
|
190
|
+
revokedAt: this.#now(),
|
|
191
|
+
revokedReason: reason
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
async denyJti(jti, expiresAt) {
|
|
195
|
+
await this.#denied.upsert({ id: jti }, { expiresAt });
|
|
196
|
+
}
|
|
197
|
+
async isJtiDenied(jti) {
|
|
198
|
+
return await this.#denied.findOne({
|
|
199
|
+
id: jti,
|
|
200
|
+
expiresAt: { $gt: this.#now() }
|
|
201
|
+
}) !== null;
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Pose le seuil de révocation en masse d'un porteur (« déconnecte-moi de
|
|
205
|
+
* partout ») : tout jeton émis avant `invalidBefore` est mort.
|
|
206
|
+
*
|
|
207
|
+
* **Monotone — le seuil ne recule JAMAIS**, y compris sous deux logouts
|
|
208
|
+
* simultanés : la comparaison vit dans la valeur écrite (`$max`, natif Mongo),
|
|
209
|
+
* pas dans un `if` JS après lecture. Une lecture suivie d'une écriture
|
|
210
|
+
* laisserait les deux appels voir le même état et écrire tous les deux — c'est
|
|
211
|
+
* le dernier qui resterait, même porteur d'un seuil plus ANCIEN, et **des
|
|
212
|
+
* jetons révoqués redeviendraient valides**. Parité stricte avec l'adapter
|
|
213
|
+
* Drizzle (`GREATEST`/`MAX` SQL).
|
|
214
|
+
*
|
|
215
|
+
* @param subjectId - porteur visé.
|
|
216
|
+
* @param invalidBefore - seuil (epoch ms) ; ignoré s'il est antérieur au seuil courant.
|
|
217
|
+
*/
|
|
218
|
+
async revokeAllForSubject(subjectId, invalidBefore) {
|
|
219
|
+
await this.#revocations.upsert({ id: subjectId }, { invalidBefore: { $max: invalidBefore } });
|
|
220
|
+
}
|
|
221
|
+
async getInvalidBefore(subjectId) {
|
|
222
|
+
const row = await this.#revocations.findOne({ id: subjectId });
|
|
223
|
+
return row ? row.invalidBefore : null;
|
|
224
|
+
}
|
|
225
|
+
async gc(now = this.#now()) {
|
|
226
|
+
let purged = 0;
|
|
227
|
+
purged += await this.#denied.delete({ expiresAt: { $lte: now } });
|
|
228
|
+
purged += await this.#records.delete({ expiresAt: { $lte: now } });
|
|
229
|
+
purged += await this.#records.delete({
|
|
230
|
+
revokedAt: { $lte: now - this.#retentionRevokedMs },
|
|
231
|
+
expiresAt: { $null: true }
|
|
232
|
+
});
|
|
233
|
+
return purged;
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
//#endregion
|
|
237
|
+
export { MongooseTokenStore };
|