@nodefony/drizzle 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 +162 -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 +105 -0
- package/dist/nodefony/command/migrateShared.js +247 -0
- package/dist/nodefony/command/orm-generate.js +356 -0
- package/dist/nodefony/command/orm-migrate-baseline.js +208 -0
- package/dist/nodefony/command/orm-migrate-repair.js +114 -0
- package/dist/nodefony/command/orm-migrate-status.js +67 -0
- package/dist/nodefony/command/orm-migrate.js +141 -0
- package/dist/nodefony/command/orm-reset.js +166 -0
- package/dist/nodefony/config/config.js +107 -0
- package/dist/nodefony/config/defineModuleConfig.js +63 -0
- package/dist/nodefony/entity/auditEventEntity.js +93 -0
- package/dist/nodefony/entity/colKit.js +260 -0
- package/dist/nodefony/entity/idempotencyEntity.js +74 -0
- package/dist/nodefony/entity/sessionEntity.js +75 -0
- package/dist/nodefony/entity/tokenEntity.js +198 -0
- package/dist/nodefony/entity/totpSecretEntity.js +98 -0
- package/dist/nodefony/entity/userTable.js +141 -0
- package/dist/nodefony/entity/webAuthnCredentialEntity.js +114 -0
- package/dist/nodefony/entity/webhookEndpointEntity.js +106 -0
- package/dist/nodefony/interfaces/IDrizzleConfig.js +1 -0
- package/dist/nodefony/interfaces/index.js +1 -0
- package/dist/nodefony/migrations-schema/mysql.js +48 -0
- package/dist/nodefony/migrations-schema/postgres.js +48 -0
- package/dist/nodefony/migrations-schema/sqlite.js +48 -0
- package/dist/nodefony/registerStores.js +218 -0
- package/dist/nodefony/service/DrizzleService.js +282 -0
- package/dist/nodefony/src/DrizzleAuditStore.js +203 -0
- package/dist/nodefony/src/DrizzleIdempotencyStore.js +278 -0
- package/dist/nodefony/src/DrizzleTokenStore.js +244 -0
- package/dist/nodefony/src/DrizzleTotpSecretStore.js +151 -0
- package/dist/nodefony/src/DrizzleUserRepository.js +217 -0
- package/dist/nodefony/src/DrizzleWebAuthnCredentialStore.js +159 -0
- package/dist/nodefony/src/DrizzleWebhookStore.js +169 -0
- package/dist/nodefony/src/SessionStorage.js +259 -0
- package/dist/nodefony/src/connectorTarget.js +59 -0
- package/dist/nodefony/src/likeSql.js +50 -0
- package/dist/nodefony/src/migrator/DrizzleMigrator.js +775 -0
- package/dist/nodefony/src/migrator/adopt.js +553 -0
- package/dist/nodefony/src/migrator/appSchema.js +414 -0
- package/dist/nodefony/src/migrator/catalog.js +76 -0
- package/dist/nodefony/src/migrator/destructive.js +213 -0
- package/dist/nodefony/src/migrator/divergence.js +84 -0
- package/dist/nodefony/src/migrator/drivers/index.js +39 -0
- package/dist/nodefony/src/migrator/drivers/mysqlDriver.js +147 -0
- package/dist/nodefony/src/migrator/drivers/postgresDriver.js +151 -0
- package/dist/nodefony/src/migrator/drivers/sqliteDriver.js +121 -0
- package/dist/nodefony/src/migrator/explain.js +565 -0
- package/dist/nodefony/src/migrator/hash.js +47 -0
- package/dist/nodefony/src/migrator/history.js +219 -0
- package/dist/nodefony/src/migrator/index.js +16 -0
- package/dist/nodefony/src/migrator/kit.js +296 -0
- package/dist/nodefony/src/migrator/name.js +68 -0
- package/dist/nodefony/src/migrator/paths.js +88 -0
- package/dist/nodefony/src/migrator/refusals.js +143 -0
- package/dist/nodefony/src/migrator/resolve.js +281 -0
- package/dist/nodefony/src/migrator/schemaDiff.js +86 -0
- package/dist/nodefony/src/migrator/sources.js +419 -0
- package/dist/nodefony/src/migrator/status.js +231 -0
- package/dist/nodefony/src/migrator/types.js +91 -0
- package/dist/nodefony/src/orm-core/DrizzleOrm.js +1154 -0
- package/dist/nodefony/src/orm-core/DrizzleRepository.js +610 -0
- package/dist/nodefony/src/orm-core/DrizzleTransaction.js +106 -0
- package/dist/nodefony/src/orm-core/index.js +4 -0
- package/dist/nodefony/src/queryKit.js +318 -0
- package/dist/nodefony/src/safeTarget.js +55 -0
- package/dist/types/index.d.ts +76 -0
- package/dist/types/nodefony/command/migrateShared.d.ts +137 -0
- package/dist/types/nodefony/command/orm-generate.d.ts +53 -0
- package/dist/types/nodefony/command/orm-migrate-baseline.d.ts +87 -0
- package/dist/types/nodefony/command/orm-migrate-repair.d.ts +66 -0
- package/dist/types/nodefony/command/orm-migrate-status.d.ts +38 -0
- package/dist/types/nodefony/command/orm-migrate.d.ts +65 -0
- package/dist/types/nodefony/command/orm-reset.d.ts +55 -0
- package/dist/types/nodefony/config/config.d.ts +110 -0
- package/dist/types/nodefony/config/defineModuleConfig.d.ts +24 -0
- package/dist/types/nodefony/entity/auditEventEntity.d.ts +56 -0
- package/dist/types/nodefony/entity/colKit.d.ts +130 -0
- package/dist/types/nodefony/entity/idempotencyEntity.d.ts +52 -0
- package/dist/types/nodefony/entity/sessionEntity.d.ts +50 -0
- package/dist/types/nodefony/entity/tokenEntity.d.ts +53 -0
- package/dist/types/nodefony/entity/totpSecretEntity.d.ts +61 -0
- package/dist/types/nodefony/entity/userTable.d.ts +65 -0
- package/dist/types/nodefony/entity/webAuthnCredentialEntity.d.ts +57 -0
- package/dist/types/nodefony/entity/webhookEndpointEntity.d.ts +58 -0
- package/dist/types/nodefony/interfaces/IDrizzleConfig.d.ts +17 -0
- package/dist/types/nodefony/interfaces/index.d.ts +1 -0
- package/dist/types/nodefony/migrations-schema/mysql.d.ts +9 -0
- package/dist/types/nodefony/migrations-schema/postgres.d.ts +9 -0
- package/dist/types/nodefony/migrations-schema/sqlite.d.ts +9 -0
- package/dist/types/nodefony/registerStores.d.ts +52 -0
- package/dist/types/nodefony/service/DrizzleService.d.ts +27 -0
- package/dist/types/nodefony/src/DrizzleAuditStore.d.ts +65 -0
- package/dist/types/nodefony/src/DrizzleIdempotencyStore.d.ts +129 -0
- package/dist/types/nodefony/src/DrizzleTokenStore.d.ts +146 -0
- package/dist/types/nodefony/src/DrizzleTotpSecretStore.d.ts +60 -0
- package/dist/types/nodefony/src/DrizzleUserRepository.d.ts +67 -0
- package/dist/types/nodefony/src/DrizzleWebAuthnCredentialStore.d.ts +62 -0
- package/dist/types/nodefony/src/DrizzleWebhookStore.d.ts +79 -0
- package/dist/types/nodefony/src/SessionStorage.d.ts +72 -0
- package/dist/types/nodefony/src/connectorTarget.d.ts +48 -0
- package/dist/types/nodefony/src/likeSql.d.ts +29 -0
- package/dist/types/nodefony/src/migrator/DrizzleMigrator.d.ts +94 -0
- package/dist/types/nodefony/src/migrator/adopt.d.ts +280 -0
- package/dist/types/nodefony/src/migrator/appSchema.d.ts +223 -0
- package/dist/types/nodefony/src/migrator/catalog.d.ts +100 -0
- package/dist/types/nodefony/src/migrator/destructive.d.ts +123 -0
- package/dist/types/nodefony/src/migrator/divergence.d.ts +61 -0
- package/dist/types/nodefony/src/migrator/drivers/index.d.ts +26 -0
- package/dist/types/nodefony/src/migrator/drivers/mysqlDriver.d.ts +83 -0
- package/dist/types/nodefony/src/migrator/drivers/postgresDriver.d.ts +81 -0
- package/dist/types/nodefony/src/migrator/drivers/sqliteDriver.d.ts +53 -0
- package/dist/types/nodefony/src/migrator/explain.d.ts +424 -0
- package/dist/types/nodefony/src/migrator/hash.d.ts +39 -0
- package/dist/types/nodefony/src/migrator/history.d.ts +139 -0
- package/dist/types/nodefony/src/migrator/index.d.ts +20 -0
- package/dist/types/nodefony/src/migrator/kit.d.ts +141 -0
- package/dist/types/nodefony/src/migrator/name.d.ts +52 -0
- package/dist/types/nodefony/src/migrator/paths.d.ts +54 -0
- package/dist/types/nodefony/src/migrator/refusals.d.ts +170 -0
- package/dist/types/nodefony/src/migrator/resolve.d.ts +201 -0
- package/dist/types/nodefony/src/migrator/schemaDiff.d.ts +112 -0
- package/dist/types/nodefony/src/migrator/sources.d.ts +119 -0
- package/dist/types/nodefony/src/migrator/status.d.ts +132 -0
- package/dist/types/nodefony/src/migrator/types.d.ts +273 -0
- package/dist/types/nodefony/src/orm-core/DrizzleOrm.d.ts +241 -0
- package/dist/types/nodefony/src/orm-core/DrizzleRepository.d.ts +98 -0
- package/dist/types/nodefony/src/orm-core/DrizzleTransaction.d.ts +71 -0
- package/dist/types/nodefony/src/orm-core/index.d.ts +11 -0
- package/dist/types/nodefony/src/queryKit.d.ts +136 -0
- package/dist/types/nodefony/src/safeTarget.d.ts +42 -0
- package/docs/index.md +954 -0
- package/docs/migrations.md +691 -0
- package/migrations/mysql/0000_framework_init.sql +137 -0
- package/migrations/mysql/meta/_journal.json +13 -0
- package/migrations/postgres/0000_framework_init.sql +128 -0
- package/migrations/postgres/meta/_journal.json +13 -0
- package/migrations/sqlite/0000_framework_init.sql +127 -0
- package/migrations/sqlite/meta/_journal.json +13 -0
- package/package.json +126 -0
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import { TOKEN_ENTITY_NAMES } from "../entity/tokenEntity.js";
|
|
2
|
+
import { assertPageQuery, pickOrder } from "nodefony";
|
|
3
|
+
import { paginate } from "@nodefony/orm-core";
|
|
4
|
+
import { TOKEN_DEFAULT_ORDER, TOKEN_SORTABLE_FIELDS, tokenStatusCriteria } from "@nodefony/security";
|
|
5
|
+
//#region nodefony/src/DrizzleTokenStore.ts
|
|
6
|
+
/**
|
|
7
|
+
* Traduit les filtres de listing en `Criteria` portable (champs indexés/simples).
|
|
8
|
+
*
|
|
9
|
+
* L'état de vie (`status`) vient de `tokenStatusCriteria`, partagé avec
|
|
10
|
+
* l'adapter Mongo : une seule écriture de la règle « révoqué l'emporte sur
|
|
11
|
+
* expiré ». Tout descend en `WHERE` natif — aucun post-filtre en mémoire.
|
|
12
|
+
*/
|
|
13
|
+
function tokenListCriteria(query, now) {
|
|
14
|
+
const criteria = { ...tokenStatusCriteria(query.status, now) };
|
|
15
|
+
if (query.subjectId !== void 0) criteria.subjectId = query.subjectId;
|
|
16
|
+
if (query.kind !== void 0) criteria.kind = query.kind;
|
|
17
|
+
return criteria;
|
|
18
|
+
}
|
|
19
|
+
/** Fenêtre par défaut de conservation d'un PAT révoqué sans expiration (30 j). */
|
|
20
|
+
const DEFAULT_RETENTION_REVOKED_MS = 2592e6;
|
|
21
|
+
/**
|
|
22
|
+
* Store de jetons **Drizzle** (driver `better-sqlite3`) — implémentation SQL
|
|
23
|
+
* d'{@link ITokenStore} au-dessus de trois repositories `@nodefony/orm-core`
|
|
24
|
+
* (`access_token`, `denied_jti`, `subject_revocation`).
|
|
25
|
+
*
|
|
26
|
+
* **Approche B** (validée 2026-06-14) : l'ORM ne connaît `@nodefony/security`
|
|
27
|
+
* qu'en `import type` → 0 dépendance runtime. C'est l'application qui enregistre
|
|
28
|
+
* la fabrique (`registerTokenStore("drizzle", ({ container }) =>
|
|
29
|
+
* DrizzleTokenStore.from(container.get("…orm…")))`) et les entités
|
|
30
|
+
* (`registerTokenEntities(orm)` avant `orm.connect()`).
|
|
31
|
+
*
|
|
32
|
+
* **100 % portable** (aucun SQL natif) — toutes les opérations passent par le
|
|
33
|
+
* contrat `IRepository`, donc le code se transpose tel quel aux autres drivers.
|
|
34
|
+
* Les écritures conditionnelles portent leur condition dans le `WHERE` plutôt
|
|
35
|
+
* que dans un `if` JS après lecture (`{ revokedAt: { $null: true } }`) : chacune
|
|
36
|
+
* est une instruction unique, donc atomique — un `findOne` suivi d'un `update`
|
|
37
|
+
* laisse deux appels concurrents agir sur un état déjà périmé.
|
|
38
|
+
*
|
|
39
|
+
* Horloge injectable (`now`) pour des tests déterministes.
|
|
40
|
+
*/
|
|
41
|
+
var DrizzleTokenStore = class DrizzleTokenStore {
|
|
42
|
+
/**
|
|
43
|
+
* {@inheritDoc ITokenStore.sortableFields}
|
|
44
|
+
*
|
|
45
|
+
* Le moteur SQL trie sur n'importe laquelle de ces colonnes : capacité pleine.
|
|
46
|
+
*/
|
|
47
|
+
sortableFields = TOKEN_SORTABLE_FIELDS;
|
|
48
|
+
#records;
|
|
49
|
+
#denied;
|
|
50
|
+
#revocations;
|
|
51
|
+
#now;
|
|
52
|
+
#retentionRevokedMs;
|
|
53
|
+
#location;
|
|
54
|
+
/**
|
|
55
|
+
* @param records - repository de la table `access_token` (PAT + refresh).
|
|
56
|
+
* @param denied - repository de la denylist `denied_jti`.
|
|
57
|
+
* @param revocations - repository des seuils `subject_revocation`.
|
|
58
|
+
* @param now - horloge (epoch ms) injectable pour les tests.
|
|
59
|
+
* @param retentionRevokedMs - rétention d'un PAT révoqué sans `exp` avant purge.
|
|
60
|
+
* @param location - emplacement physique de la base (fichier SQLite) pour Studio
|
|
61
|
+
* ({@link DrizzleOrm.location}) ; `undefined` pour un backend réseau/`:memory:`.
|
|
62
|
+
*/
|
|
63
|
+
constructor(records, denied, revocations, now = Date.now, retentionRevokedMs = DEFAULT_RETENTION_REVOKED_MS, location) {
|
|
64
|
+
this.#records = records;
|
|
65
|
+
this.#denied = denied;
|
|
66
|
+
this.#revocations = revocations;
|
|
67
|
+
this.#now = now;
|
|
68
|
+
this.#retentionRevokedMs = retentionRevokedMs;
|
|
69
|
+
this.#location = location;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Emplacement physique de la base (fichier SQLite) pour l'écran Studio « Stores »
|
|
73
|
+
* — lu par `readStoreLocation`. `undefined` = backend réseau ou `:memory:`.
|
|
74
|
+
*/
|
|
75
|
+
get location() {
|
|
76
|
+
return this.#location;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Construit le store depuis un {@link DrizzleOrm} connecté. Les entités
|
|
80
|
+
* (`registerTokenEntities`) doivent avoir été enregistrées **avant**
|
|
81
|
+
* `orm.connect()`.
|
|
82
|
+
*
|
|
83
|
+
* @param orm - ORM Drizzle connecté hébergeant les tables du store.
|
|
84
|
+
* @param now - horloge injectable (tests).
|
|
85
|
+
* @param retentionRevokedMs - rétention des PAT révoqués sans `exp`.
|
|
86
|
+
*/
|
|
87
|
+
static from(orm, now, retentionRevokedMs) {
|
|
88
|
+
return new DrizzleTokenStore(orm.getRepository(TOKEN_ENTITY_NAMES.records), orm.getRepository(TOKEN_ENTITY_NAMES.denied), orm.getRepository(TOKEN_ENTITY_NAMES.revocations), now, retentionRevokedMs, orm.location);
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Insère ou remplace un record (PAT / refresh) — 1 requête, `upsert` atomique
|
|
92
|
+
* sur la PK `id` plutôt qu'un `findOne` d'existence + `create`/`updateOne`
|
|
93
|
+
* (2 round-trips). `put` pose le record COMPLET (`createdAt` inclus) → tout
|
|
94
|
+
* hors `id` est ré-appliqué en cas de conflit ; pas de champ insert-only.
|
|
95
|
+
*
|
|
96
|
+
* ⚠️ **Limite `ON CONFLICT`, propre à cette table** : `access_token` porte
|
|
97
|
+
* DEUX contraintes uniques (`id` PK + `secretHash`), or un upsert n'arbitre
|
|
98
|
+
* qu'UN index. Deux INSERT **concurrents** d'un record **absent** partageant
|
|
99
|
+
* le même `secretHash` feraient donc lever le perdant (PG : `23505` sur
|
|
100
|
+
* `access_token_secretHash_unique`) — l'arbitre `id` ne couvre pas la seconde
|
|
101
|
+
* unique. Ce n'est pas atteignable : les trois appelants (`tokenService`
|
|
102
|
+
* émission + rotation, `apiKeys`) posent un `id` **généré** (`randomUUID` /
|
|
103
|
+
* `#randomId`), donc jamais deux `put` du même id neuf ; le seul `put`
|
|
104
|
+
* concurrent d'un même id porte sur une ligne **existante** (rotation
|
|
105
|
+
* rejouée), qui tombe sur le chemin DO UPDATE et passe. Une entité à deux
|
|
106
|
+
* uniques dont les DEUX seraient réellement disputées demanderait un autre
|
|
107
|
+
* remède (réservation en deux instructions, cf `reserveIdempotencyKeyMysql`).
|
|
108
|
+
*
|
|
109
|
+
* @param record - le record complet à persister.
|
|
110
|
+
*/
|
|
111
|
+
async put(record) {
|
|
112
|
+
const { id, ...rest } = record;
|
|
113
|
+
await this.#records.upsert({ id }, rest);
|
|
114
|
+
}
|
|
115
|
+
findById(id) {
|
|
116
|
+
return this.#records.findOne({ id });
|
|
117
|
+
}
|
|
118
|
+
findByHash(secretHash) {
|
|
119
|
+
return this.#records.findOne({ secretHash });
|
|
120
|
+
}
|
|
121
|
+
findBySubject(subjectId) {
|
|
122
|
+
return this.#records.find({ subjectId });
|
|
123
|
+
}
|
|
124
|
+
/** Tous les jetons (PAT + refresh) — vue d'administration cross-porteur. */
|
|
125
|
+
listAll() {
|
|
126
|
+
return this.#records.find({});
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* {@inheritDoc ITokenStore.listPage}
|
|
130
|
+
*
|
|
131
|
+
* Portable à 100 % : le helper `paginate()` d'orm-core (LIMIT/OFFSET + COUNT
|
|
132
|
+
* optionnel) sur un `Criteria` simple — ne matérialise qu'une page. Le tri
|
|
133
|
+
* demandé descend dans le `ORDER BY` (jamais de tri après découpe : la 2ᵉ page
|
|
134
|
+
* doit continuer la 1ʳᵉ) ; à défaut, l'ordre contractuel `createdAt DESC, id
|
|
135
|
+
* DESC` rend l'offset déterministe. Les noms de colonnes SQL sont ceux du
|
|
136
|
+
* vocabulaire public — aucune traduction n'est nécessaire ici.
|
|
137
|
+
*/
|
|
138
|
+
listPage(query) {
|
|
139
|
+
assertPageQuery(query, "offset");
|
|
140
|
+
return paginate(this.#records, {
|
|
141
|
+
criteria: tokenListCriteria(query, this.#now()),
|
|
142
|
+
limit: query.limit,
|
|
143
|
+
offset: query.offset,
|
|
144
|
+
withTotal: query.withTotal,
|
|
145
|
+
order: pickOrder(query.order, this.sortableFields, TOKEN_DEFAULT_ORDER)
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
/** {@inheritDoc ITokenStore.countTokens} */
|
|
149
|
+
countTokens(query) {
|
|
150
|
+
return this.#records.count(tokenListCriteria(query, this.#now()));
|
|
151
|
+
}
|
|
152
|
+
async markUsed(id, usage) {
|
|
153
|
+
await this.#records.updateOne({ id }, {
|
|
154
|
+
lastUsedAt: usage.at,
|
|
155
|
+
lastUsedIp: usage.ip ?? null,
|
|
156
|
+
lastUsedUserAgent: usage.userAgent ?? null
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Révoque un jeton — **idempotent** : la 1ʳᵉ date/raison de révocation est
|
|
161
|
+
* conservée (l'audit ne se réécrit pas).
|
|
162
|
+
*
|
|
163
|
+
* Le « pas encore révoqué » vit dans le `WHERE` (`revokedAt IS NULL`), pas
|
|
164
|
+
* dans un `if` JS après lecture : une seule instruction, donc deux révocations
|
|
165
|
+
* concurrentes ne peuvent plus se recouvrir (la seconde n'affecte 0 ligne au
|
|
166
|
+
* lieu d'écraser la date de la première).
|
|
167
|
+
*
|
|
168
|
+
* @param id - identifiant du jeton.
|
|
169
|
+
* @param reason - motif de révocation, posé seulement à la 1ʳᵉ.
|
|
170
|
+
*/
|
|
171
|
+
async revoke(id, reason) {
|
|
172
|
+
await this.#records.updateOne({
|
|
173
|
+
id,
|
|
174
|
+
revokedAt: { $null: true }
|
|
175
|
+
}, {
|
|
176
|
+
revokedAt: this.#now(),
|
|
177
|
+
revokedReason: reason
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Coupe toute une famille de refresh (détection de rejeu, RFC 9700) — les
|
|
182
|
+
* membres déjà révoqués (ex. `rotated`) gardent leur raison d'origine.
|
|
183
|
+
*
|
|
184
|
+
* Un seul `UPDATE … WHERE family = ? AND revokedAt IS NULL` : atomique, et
|
|
185
|
+
* N+1 requêtes (1 SELECT + 1 UPDATE par membre actif) tombent à 1.
|
|
186
|
+
*
|
|
187
|
+
* @param family - famille de refresh à couper.
|
|
188
|
+
* @param reason - motif appliqué aux membres encore actifs.
|
|
189
|
+
*/
|
|
190
|
+
async revokeFamily(family, reason) {
|
|
191
|
+
await this.#records.updateMany({
|
|
192
|
+
family,
|
|
193
|
+
revokedAt: { $null: true }
|
|
194
|
+
}, {
|
|
195
|
+
revokedAt: this.#now(),
|
|
196
|
+
revokedReason: reason
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
async denyJti(jti, expiresAt) {
|
|
200
|
+
await this.#denied.upsert({ jti }, { expiresAt });
|
|
201
|
+
}
|
|
202
|
+
async isJtiDenied(jti) {
|
|
203
|
+
return await this.#denied.findOne({
|
|
204
|
+
jti,
|
|
205
|
+
expiresAt: { $gt: this.#now() }
|
|
206
|
+
}) !== null;
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Pose le seuil de révocation en masse d'un porteur (« déconnecte-moi de
|
|
210
|
+
* partout ») : tout jeton émis avant `invalidBefore` est mort.
|
|
211
|
+
*
|
|
212
|
+
* **Monotone — le seuil ne recule JAMAIS**, y compris sous deux logouts
|
|
213
|
+
* simultanés : la comparaison vit dans la valeur écrite (`$max`), pas dans un
|
|
214
|
+
* `if` JS après lecture. Une lecture suivie d'une écriture laisserait les deux
|
|
215
|
+
* appels voir le même état et écrire tous les deux — c'est le dernier qui
|
|
216
|
+
* resterait, même porteur d'un seuil plus ANCIEN, et les jetons que le logout
|
|
217
|
+
* le plus récent venait d'invalider repasseraient sous le seuil : **des jetons
|
|
218
|
+
* révoqués redeviendraient valides**. Une instruction unique sur les 4
|
|
219
|
+
* backends (`MAX()` sqlite, `GREATEST()` pg/mysql, `$max` Mongo) — un `WHERE`
|
|
220
|
+
* sur le `DO UPDATE` n'existe pas en MySQL.
|
|
221
|
+
*
|
|
222
|
+
* @param subjectId - porteur visé.
|
|
223
|
+
* @param invalidBefore - seuil (epoch ms) ; ignoré s'il est antérieur au seuil courant.
|
|
224
|
+
*/
|
|
225
|
+
async revokeAllForSubject(subjectId, invalidBefore) {
|
|
226
|
+
await this.#revocations.upsert({ subjectId }, { invalidBefore: { $max: invalidBefore } });
|
|
227
|
+
}
|
|
228
|
+
async getInvalidBefore(subjectId) {
|
|
229
|
+
const row = await this.#revocations.findOne({ subjectId });
|
|
230
|
+
return row ? row.invalidBefore : null;
|
|
231
|
+
}
|
|
232
|
+
async gc(now = this.#now()) {
|
|
233
|
+
let purged = 0;
|
|
234
|
+
purged += await this.#denied.delete({ expiresAt: { $lte: now } });
|
|
235
|
+
purged += await this.#records.delete({ expiresAt: { $lte: now } });
|
|
236
|
+
purged += await this.#records.delete({
|
|
237
|
+
revokedAt: { $lte: now - this.#retentionRevokedMs },
|
|
238
|
+
expiresAt: { $null: true }
|
|
239
|
+
});
|
|
240
|
+
return purged;
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
//#endregion
|
|
244
|
+
export { DrizzleTokenStore };
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { TOTP_SECRET_ENTITY } from "../entity/totpSecretEntity.js";
|
|
2
|
+
import { assertPageQuery } from "nodefony";
|
|
3
|
+
import { paginate, searchCriteria } from "@nodefony/orm-core";
|
|
4
|
+
//#region nodefony/src/DrizzleTotpSecretStore.ts
|
|
5
|
+
/**
|
|
6
|
+
* Store de secrets TOTP **Drizzle** (driver `better-sqlite3`) — implémentation SQL
|
|
7
|
+
* d'{@link ITotpSecretStore} au-dessus d'un unique repository `@nodefony/orm-core`
|
|
8
|
+
* (`totp_secret`). Comble le gap « 2FA persistant sans fichier » : là où
|
|
9
|
+
* `MemoryTotpSecretStore` est volatile, ce store survit au redémarrage et se
|
|
10
|
+
* partage entre pods (base durable).
|
|
11
|
+
*
|
|
12
|
+
* **Modèle 1 secret / utilisateur** (clé = `userId`) → `save` est un upsert par PK.
|
|
13
|
+
*
|
|
14
|
+
* **Approche B** : l'ORM ne connaît `@nodefony/security` qu'en `import type` → 0
|
|
15
|
+
* dépendance runtime. L'entité (`registerTotpSecretEntity(orm)`) doit être
|
|
16
|
+
* enregistrée **avant** `orm.connect()`.
|
|
17
|
+
*
|
|
18
|
+
* **100 % portable** (aucun SQL natif) — toutes les opérations passent par le
|
|
19
|
+
* contrat `IRepository`, donc le code se transpose tel quel aux autres drivers.
|
|
20
|
+
*
|
|
21
|
+
* **`secretEnc` opaque** : le store persiste le secret DÉJÀ chiffré (AES-256-GCM
|
|
22
|
+
* côté service) — il ne déchiffre jamais, ne voit que des octets.
|
|
23
|
+
*/
|
|
24
|
+
var DrizzleTotpSecretStore = class DrizzleTotpSecretStore {
|
|
25
|
+
#repo;
|
|
26
|
+
#location;
|
|
27
|
+
/**
|
|
28
|
+
* @param repo - repository de la table `totp_secret`.
|
|
29
|
+
* @param location - emplacement physique de la base (fichier SQLite) pour Studio
|
|
30
|
+
* ({@link DrizzleOrm.location}) ; `undefined` pour un backend réseau/`:memory:`.
|
|
31
|
+
*/
|
|
32
|
+
constructor(repo, location) {
|
|
33
|
+
this.#repo = repo;
|
|
34
|
+
this.#location = location;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Emplacement physique de la base (fichier SQLite) pour l'écran Studio « Stores »
|
|
38
|
+
* — lu par `readStoreLocation`. `undefined` = backend réseau ou `:memory:`.
|
|
39
|
+
*/
|
|
40
|
+
get location() {
|
|
41
|
+
return this.#location;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Construit le store depuis un {@link DrizzleOrm} connecté. L'entité
|
|
45
|
+
* (`registerTotpSecretEntity`) doit avoir été enregistrée **avant** `orm.connect()`.
|
|
46
|
+
*
|
|
47
|
+
* @param orm - ORM Drizzle connecté hébergeant la table du store.
|
|
48
|
+
*/
|
|
49
|
+
static from(orm) {
|
|
50
|
+
return new DrizzleTotpSecretStore(orm.getRepository(TOTP_SECRET_ENTITY), orm.location);
|
|
51
|
+
}
|
|
52
|
+
/** Row plate → secret du contrat (recoveryCodes copié = mutable indépendant). */
|
|
53
|
+
#toSecret(row) {
|
|
54
|
+
return {
|
|
55
|
+
userId: row.userId,
|
|
56
|
+
secretEnc: row.secretEnc,
|
|
57
|
+
algorithm: row.algorithm,
|
|
58
|
+
digits: row.digits,
|
|
59
|
+
period: row.period,
|
|
60
|
+
recoveryCodes: [...row.recoveryCodes],
|
|
61
|
+
confirmedAt: row.confirmedAt,
|
|
62
|
+
lastUsedStep: row.lastUsedStep,
|
|
63
|
+
createdAt: row.createdAt,
|
|
64
|
+
lastUsedAt: row.lastUsedAt
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
/** Secret du contrat → row plate (colonnes `notNull` toutes fournies). */
|
|
68
|
+
#toRow(s) {
|
|
69
|
+
return {
|
|
70
|
+
userId: s.userId,
|
|
71
|
+
secretEnc: s.secretEnc,
|
|
72
|
+
algorithm: s.algorithm,
|
|
73
|
+
digits: s.digits,
|
|
74
|
+
period: s.period,
|
|
75
|
+
recoveryCodes: [...s.recoveryCodes],
|
|
76
|
+
confirmedAt: s.confirmedAt,
|
|
77
|
+
lastUsedStep: s.lastUsedStep,
|
|
78
|
+
createdAt: s.createdAt,
|
|
79
|
+
lastUsedAt: s.lastUsedAt
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
async findByUser(userId) {
|
|
83
|
+
const row = await this.#repo.findOne({ userId });
|
|
84
|
+
return row ? this.#toSecret(row) : null;
|
|
85
|
+
}
|
|
86
|
+
async save(secret) {
|
|
87
|
+
const { userId, ...rest } = this.#toRow(secret);
|
|
88
|
+
await this.#repo.upsert({ userId }, rest);
|
|
89
|
+
}
|
|
90
|
+
async update(userId, patch) {
|
|
91
|
+
const set = {};
|
|
92
|
+
if (patch.confirmedAt !== void 0) set.confirmedAt = patch.confirmedAt;
|
|
93
|
+
if (patch.recoveryCodes !== void 0) set.recoveryCodes = patch.recoveryCodes;
|
|
94
|
+
if (patch.lastUsedStep !== void 0) set.lastUsedStep = patch.lastUsedStep;
|
|
95
|
+
if (patch.lastUsedAt !== void 0) set.lastUsedAt = patch.lastUsedAt;
|
|
96
|
+
if (Object.keys(set).length === 0) return;
|
|
97
|
+
await this.#repo.updateOne({ userId }, set);
|
|
98
|
+
}
|
|
99
|
+
async delete(userId) {
|
|
100
|
+
await this.#repo.delete({ userId });
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Critère portable des filtres du listing. `confirmed` s'exprime en `$null`
|
|
104
|
+
* sur `confirmedAt` (pas de colonne booléenne dérivée à maintenir), `q` en
|
|
105
|
+
* `$like` **ancré à gauche** (`préfixe%`) — donc indexable, contrairement à
|
|
106
|
+
* une recherche `%…%`.
|
|
107
|
+
*/
|
|
108
|
+
#listCriteria(query) {
|
|
109
|
+
const criteria = {};
|
|
110
|
+
if (query.confirmed !== void 0) criteria.confirmedAt = { $null: !query.confirmed };
|
|
111
|
+
Object.assign(criteria, searchCriteria(query.q, ["userId"]) ?? {});
|
|
112
|
+
return criteria;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* {@inheritDoc ITotpSecretStore.listPage}
|
|
116
|
+
*
|
|
117
|
+
* 100 % portable : le helper `paginate()` d'orm-core (LIMIT/OFFSET + COUNT
|
|
118
|
+
* optionnel) sur un critère simple. La projection en vue d'enrôlement retire
|
|
119
|
+
* `secretEnc` et les condensats — ils ne franchissent jamais la frontière du
|
|
120
|
+
* store, quel que soit l'appelant.
|
|
121
|
+
*/
|
|
122
|
+
async listPage(query) {
|
|
123
|
+
assertPageQuery(query, "offset");
|
|
124
|
+
const page = await paginate(this.#repo, {
|
|
125
|
+
criteria: this.#listCriteria(query),
|
|
126
|
+
limit: query.limit,
|
|
127
|
+
offset: query.offset,
|
|
128
|
+
withTotal: query.withTotal,
|
|
129
|
+
order: [["createdAt", "DESC"], ["userId", "ASC"]]
|
|
130
|
+
});
|
|
131
|
+
return {
|
|
132
|
+
...page,
|
|
133
|
+
items: page.items.map((row) => ({
|
|
134
|
+
userId: row.userId,
|
|
135
|
+
algorithm: row.algorithm,
|
|
136
|
+
digits: row.digits,
|
|
137
|
+
period: row.period,
|
|
138
|
+
confirmedAt: row.confirmedAt,
|
|
139
|
+
createdAt: row.createdAt,
|
|
140
|
+
lastUsedAt: row.lastUsedAt,
|
|
141
|
+
recoveryCodesLeft: row.recoveryCodes.length
|
|
142
|
+
}))
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
/** {@inheritDoc ITotpSecretStore.countEnrollments} */
|
|
146
|
+
countEnrollments(query) {
|
|
147
|
+
return this.#repo.count(this.#listCriteria(query));
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
//#endregion
|
|
151
|
+
export { DrizzleTotpSecretStore };
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { countUsers, findUserIdBySocialProvider, listUserIdsPage } from "./queryKit.js";
|
|
2
|
+
import { isFrameworkFallbackEntity } from "../registerStores.js";
|
|
3
|
+
import { assertPageQuery } from "nodefony";
|
|
4
|
+
import { BaseUser, USER_DEFAULT_ORDER, USER_SORTABLE_FIELDS, attachExtraColumns } from "@nodefony/user";
|
|
5
|
+
//#region nodefony/src/DrizzleUserRepository.ts
|
|
6
|
+
/**
|
|
7
|
+
* Adapter Drizzle du contrat {@link IUserRepository} — implémentation SQL **par
|
|
8
|
+
* défaut** de la persistance utilisateur (P5.9).
|
|
9
|
+
*
|
|
10
|
+
* Décore le repository portable générique (`IRepository<UserRow>` de
|
|
11
|
+
* {@link DrizzleOrm}) de deux responsabilités propres à l'utilisateur :
|
|
12
|
+
* - **mapping ligne ↔ `BaseUser`** : les consommateurs reçoivent un objet
|
|
13
|
+
* porteur du comportement (`hasRole`/`isActive`/`isLocked`), pas une ligne nue ;
|
|
14
|
+
* - **finders métier** : `findByIdentifier` (lookup unique) et
|
|
15
|
+
* `findBySocialProvider` (recherche dans le JSON `socialProviders`, routée
|
|
16
|
+
* par dialecte via le queryKit — pattern Shadow User OAuth).
|
|
17
|
+
*
|
|
18
|
+
* Le credential (`password`) transite par cette frontière — c'est attendu : le
|
|
19
|
+
* repository **est** la frontière de persistance du hash (cf `IUserRepository`).
|
|
20
|
+
*/
|
|
21
|
+
/**
|
|
22
|
+
* Refuse de servir un annuaire dont la table n'existera nulle part.
|
|
23
|
+
*
|
|
24
|
+
* L'entité `User` du framework est un REPLI : elle dépanne quand l'application
|
|
25
|
+
* n'a pas encore la sienne. Depuis que la table appartient à l'application, elle
|
|
26
|
+
* n'est plus dans aucune chaîne de migration — donc hors développement, où le
|
|
27
|
+
* schéma est dérivé du code, **personne ne la crée**. Sans ce refus,
|
|
28
|
+
* l'application démarrerait, servirait ses pages, et échouerait à la première
|
|
29
|
+
* authentification sur une table absente : le pire des trois moments pour
|
|
30
|
+
* l'apprendre.
|
|
31
|
+
*
|
|
32
|
+
* Le refus ne vise que le cas où les deux conditions se rencontrent — entité de
|
|
33
|
+
* repli ET schéma non dérivé. Une application qui possède son entité, ou qui
|
|
34
|
+
* tourne en développement, ne le voit jamais.
|
|
35
|
+
*
|
|
36
|
+
* @param orm - l'ORM sur lequel l'annuaire s'apprête à lire.
|
|
37
|
+
* @throws Error si la table `User` ne sera créée par personne.
|
|
38
|
+
*/
|
|
39
|
+
function assertUserTableIsOwned(orm) {
|
|
40
|
+
if (orm.derivesSchema || !isFrameworkFallbackEntity("User", orm.name)) return;
|
|
41
|
+
throw new Error(`Cette application doit posséder son entité « User » : le framework a posé la sienne par défaut, et elle n'est dans AUCUNE migration — la table n'existera donc pas sur le connecteur « ${orm.name} », où le schéma appartient aux migrations.\n → la créer : nodefony create entity User\n → puis générer sa migration : nodefony orm:generate --name user\n → puis l'appliquer : nodefony orm:migrate\n(en développement le schéma est dérivé du code, ce qui masquait le manque.)`);
|
|
42
|
+
}
|
|
43
|
+
var DrizzleUserRepository = class DrizzleUserRepository {
|
|
44
|
+
/**
|
|
45
|
+
* Même vocabulaire public que les autres repositories — ici le tri part
|
|
46
|
+
* dans la requête, où il ne coûte qu'un index.
|
|
47
|
+
*/
|
|
48
|
+
sortableFields = USER_SORTABLE_FIELDS;
|
|
49
|
+
#base;
|
|
50
|
+
#db;
|
|
51
|
+
#dialect;
|
|
52
|
+
/**
|
|
53
|
+
* @param base - repository portable sur la table `User` (CRUD + criteria).
|
|
54
|
+
* @param db - handle Drizzle (racine ou transaction) pour les requêtes JSON brutes.
|
|
55
|
+
* @param dialect - dialecte SQL du connecteur (route les requêtes du queryKit).
|
|
56
|
+
*/
|
|
57
|
+
constructor(base, db, dialect = "sqlite") {
|
|
58
|
+
this.#base = base;
|
|
59
|
+
this.#db = db;
|
|
60
|
+
this.#dialect = dialect;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Construit le repository utilisateur depuis un {@link DrizzleOrm} connecté.
|
|
64
|
+
* L'entité `User` doit avoir été enregistrée (cf `registerUserEntity`) avant
|
|
65
|
+
* `orm.connect()` — sur la variante de table du dialecte de l'ORM.
|
|
66
|
+
*
|
|
67
|
+
* @param orm - ORM Drizzle connecté.
|
|
68
|
+
* @returns le repository utilisateur prêt à l'emploi.
|
|
69
|
+
*/
|
|
70
|
+
static from(orm) {
|
|
71
|
+
assertUserTableIsOwned(orm);
|
|
72
|
+
return new DrizzleUserRepository(orm.getRepository("User"), orm.getNativeConnection(), orm.dialect);
|
|
73
|
+
}
|
|
74
|
+
/** Mappe une ligne plate en {@link BaseUser} — colonnes hors contrat comprises. */
|
|
75
|
+
#toUser(row) {
|
|
76
|
+
const user = new BaseUser({
|
|
77
|
+
id: row.id,
|
|
78
|
+
identifier: row.identifier,
|
|
79
|
+
roles: row.roles,
|
|
80
|
+
password: row.password,
|
|
81
|
+
enabled: row.enabled,
|
|
82
|
+
locked: row.locked,
|
|
83
|
+
currentRole: row.currentRole,
|
|
84
|
+
socialProviders: row.socialProviders,
|
|
85
|
+
metadata: row.metadata
|
|
86
|
+
});
|
|
87
|
+
return attachExtraColumns(user, row);
|
|
88
|
+
}
|
|
89
|
+
async find(criteria, options) {
|
|
90
|
+
return (await this.#base.find(criteria, options)).map((row) => this.#toUser(row));
|
|
91
|
+
}
|
|
92
|
+
async findOne(criteria, options) {
|
|
93
|
+
const row = await this.#base.findOne(criteria, options);
|
|
94
|
+
return row ? this.#toUser(row) : null;
|
|
95
|
+
}
|
|
96
|
+
async create(data) {
|
|
97
|
+
const row = await this.#base.create(data);
|
|
98
|
+
return this.#toUser(row);
|
|
99
|
+
}
|
|
100
|
+
async updateOne(criteria, data) {
|
|
101
|
+
const row = await this.#base.updateOne(criteria, data);
|
|
102
|
+
return row ? this.#toUser(row) : null;
|
|
103
|
+
}
|
|
104
|
+
async upsert(criteria, update, insertOnly) {
|
|
105
|
+
const row = await this.#base.upsert(criteria, update, insertOnly);
|
|
106
|
+
return this.#toUser(row);
|
|
107
|
+
}
|
|
108
|
+
async createMany(data) {
|
|
109
|
+
return (await this.#base.createMany(data)).map((row) => this.#toUser(row));
|
|
110
|
+
}
|
|
111
|
+
exists(criteria) {
|
|
112
|
+
return this.#base.exists(criteria);
|
|
113
|
+
}
|
|
114
|
+
deleteOne(criteria) {
|
|
115
|
+
return this.#base.deleteOne(criteria);
|
|
116
|
+
}
|
|
117
|
+
async findOneAndDelete(criteria) {
|
|
118
|
+
const row = await this.#base.findOneAndDelete(criteria);
|
|
119
|
+
return row ? this.#toUser(row) : null;
|
|
120
|
+
}
|
|
121
|
+
async increment(criteria, changes) {
|
|
122
|
+
const row = await this.#base.increment(criteria, changes);
|
|
123
|
+
return row ? this.#toUser(row) : null;
|
|
124
|
+
}
|
|
125
|
+
updateMany(criteria, data) {
|
|
126
|
+
return this.#base.updateMany(criteria, data);
|
|
127
|
+
}
|
|
128
|
+
delete(criteria) {
|
|
129
|
+
return this.#base.delete(criteria);
|
|
130
|
+
}
|
|
131
|
+
count(criteria) {
|
|
132
|
+
return this.#base.count(criteria);
|
|
133
|
+
}
|
|
134
|
+
countDistinct(field, criteria) {
|
|
135
|
+
return this.#base.countDistinct(field, criteria);
|
|
136
|
+
}
|
|
137
|
+
withTransaction(tx) {
|
|
138
|
+
return new DrizzleUserRepository(this.#base.withTransaction(tx), tx.getNative(), this.#dialect);
|
|
139
|
+
}
|
|
140
|
+
findByIdentifier(identifier) {
|
|
141
|
+
return this.findOne({ identifier });
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Recherche par compte externe lié — cherche dans le JSON `socialProviders`
|
|
145
|
+
* via le queryKit (forme native du dialecte : `json_each` SQLite / `@>`
|
|
146
|
+
* jsonb PG, 1 requête), récupère l'`id`, puis recharge par le chemin typé
|
|
147
|
+
* (parsing JSON/booléens cohérent). `null` si aucun lien.
|
|
148
|
+
*/
|
|
149
|
+
async findBySocialProvider(provider, providerId) {
|
|
150
|
+
const id = await findUserIdBySocialProvider(this.#db, this.#dialect, provider, providerId);
|
|
151
|
+
if (id === null) return null;
|
|
152
|
+
return this.findOne({ id });
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* {@inheritDoc IUserRepository.listPage}
|
|
156
|
+
*
|
|
157
|
+
* SQL natif (queryKit, routé par dialecte) → **uniquement les `id`** de la page
|
|
158
|
+
* (containment de rôle + `LIKE` insensible casse non exprimables par le query
|
|
159
|
+
* builder portable), puis rechargement des lignes complètes par le chemin typé
|
|
160
|
+
* (`find({ id: $in })`, parsing JSON/booléens cohérent), **ré-ordonnées** selon
|
|
161
|
+
* le tri SQL. Jamais plus d'une page matérialisée.
|
|
162
|
+
*/
|
|
163
|
+
async listPage(query) {
|
|
164
|
+
assertPageQuery(query, "offset");
|
|
165
|
+
const limit = Math.max(1, Math.floor(query.limit));
|
|
166
|
+
const offset = Math.max(0, Math.floor(query.offset ?? 0));
|
|
167
|
+
const filters = {
|
|
168
|
+
role: query.role,
|
|
169
|
+
enabled: query.enabled,
|
|
170
|
+
locked: query.locked,
|
|
171
|
+
hasSocial: query.hasSocial,
|
|
172
|
+
q: query.q
|
|
173
|
+
};
|
|
174
|
+
const order = query.order?.length ? query.order : USER_DEFAULT_ORDER;
|
|
175
|
+
const { ids, hasNext } = await listUserIdsPage(this.#db, this.#dialect, filters, {
|
|
176
|
+
limit,
|
|
177
|
+
offset,
|
|
178
|
+
order
|
|
179
|
+
});
|
|
180
|
+
const total = query.withTotal === false ? void 0 : await countUsers(this.#db, this.#dialect, filters);
|
|
181
|
+
if (ids.length === 0) return {
|
|
182
|
+
items: [],
|
|
183
|
+
total,
|
|
184
|
+
limit,
|
|
185
|
+
offset,
|
|
186
|
+
hasNext
|
|
187
|
+
};
|
|
188
|
+
const rows = await this.#base.find({ id: { $in: ids } });
|
|
189
|
+
const byId = new Map(rows.map((row) => [row.id, this.#toUser(row)]));
|
|
190
|
+
return {
|
|
191
|
+
items: ids.map((id) => byId.get(id)).filter((u) => u !== void 0),
|
|
192
|
+
total,
|
|
193
|
+
limit,
|
|
194
|
+
offset,
|
|
195
|
+
hasNext
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
/** {@inheritDoc IUserRepository.countUsers} */
|
|
199
|
+
countUsers(query) {
|
|
200
|
+
return countUsers(this.#db, this.#dialect, {
|
|
201
|
+
role: query.role,
|
|
202
|
+
enabled: query.enabled,
|
|
203
|
+
locked: query.locked,
|
|
204
|
+
hasSocial: query.hasSocial,
|
|
205
|
+
q: query.q
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
/** {@inheritDoc IUserRepository.countActiveAdmins} */
|
|
209
|
+
countActiveAdmins(adminRole) {
|
|
210
|
+
return countUsers(this.#db, this.#dialect, {
|
|
211
|
+
enabled: true,
|
|
212
|
+
role: adminRole
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
//#endregion
|
|
217
|
+
export { DrizzleUserRepository };
|