@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.
Files changed (143) hide show
  1. package/LICENSE +544 -0
  2. package/README.md +162 -0
  3. package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorate.js +9 -0
  4. package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateMetadata.js +6 -0
  5. package/dist/index.js +105 -0
  6. package/dist/nodefony/command/migrateShared.js +247 -0
  7. package/dist/nodefony/command/orm-generate.js +356 -0
  8. package/dist/nodefony/command/orm-migrate-baseline.js +208 -0
  9. package/dist/nodefony/command/orm-migrate-repair.js +114 -0
  10. package/dist/nodefony/command/orm-migrate-status.js +67 -0
  11. package/dist/nodefony/command/orm-migrate.js +141 -0
  12. package/dist/nodefony/command/orm-reset.js +166 -0
  13. package/dist/nodefony/config/config.js +107 -0
  14. package/dist/nodefony/config/defineModuleConfig.js +63 -0
  15. package/dist/nodefony/entity/auditEventEntity.js +93 -0
  16. package/dist/nodefony/entity/colKit.js +260 -0
  17. package/dist/nodefony/entity/idempotencyEntity.js +74 -0
  18. package/dist/nodefony/entity/sessionEntity.js +75 -0
  19. package/dist/nodefony/entity/tokenEntity.js +198 -0
  20. package/dist/nodefony/entity/totpSecretEntity.js +98 -0
  21. package/dist/nodefony/entity/userTable.js +141 -0
  22. package/dist/nodefony/entity/webAuthnCredentialEntity.js +114 -0
  23. package/dist/nodefony/entity/webhookEndpointEntity.js +106 -0
  24. package/dist/nodefony/interfaces/IDrizzleConfig.js +1 -0
  25. package/dist/nodefony/interfaces/index.js +1 -0
  26. package/dist/nodefony/migrations-schema/mysql.js +48 -0
  27. package/dist/nodefony/migrations-schema/postgres.js +48 -0
  28. package/dist/nodefony/migrations-schema/sqlite.js +48 -0
  29. package/dist/nodefony/registerStores.js +218 -0
  30. package/dist/nodefony/service/DrizzleService.js +282 -0
  31. package/dist/nodefony/src/DrizzleAuditStore.js +203 -0
  32. package/dist/nodefony/src/DrizzleIdempotencyStore.js +278 -0
  33. package/dist/nodefony/src/DrizzleTokenStore.js +244 -0
  34. package/dist/nodefony/src/DrizzleTotpSecretStore.js +151 -0
  35. package/dist/nodefony/src/DrizzleUserRepository.js +217 -0
  36. package/dist/nodefony/src/DrizzleWebAuthnCredentialStore.js +159 -0
  37. package/dist/nodefony/src/DrizzleWebhookStore.js +169 -0
  38. package/dist/nodefony/src/SessionStorage.js +259 -0
  39. package/dist/nodefony/src/connectorTarget.js +59 -0
  40. package/dist/nodefony/src/likeSql.js +50 -0
  41. package/dist/nodefony/src/migrator/DrizzleMigrator.js +775 -0
  42. package/dist/nodefony/src/migrator/adopt.js +553 -0
  43. package/dist/nodefony/src/migrator/appSchema.js +414 -0
  44. package/dist/nodefony/src/migrator/catalog.js +76 -0
  45. package/dist/nodefony/src/migrator/destructive.js +213 -0
  46. package/dist/nodefony/src/migrator/divergence.js +84 -0
  47. package/dist/nodefony/src/migrator/drivers/index.js +39 -0
  48. package/dist/nodefony/src/migrator/drivers/mysqlDriver.js +147 -0
  49. package/dist/nodefony/src/migrator/drivers/postgresDriver.js +151 -0
  50. package/dist/nodefony/src/migrator/drivers/sqliteDriver.js +121 -0
  51. package/dist/nodefony/src/migrator/explain.js +565 -0
  52. package/dist/nodefony/src/migrator/hash.js +47 -0
  53. package/dist/nodefony/src/migrator/history.js +219 -0
  54. package/dist/nodefony/src/migrator/index.js +16 -0
  55. package/dist/nodefony/src/migrator/kit.js +296 -0
  56. package/dist/nodefony/src/migrator/name.js +68 -0
  57. package/dist/nodefony/src/migrator/paths.js +88 -0
  58. package/dist/nodefony/src/migrator/refusals.js +143 -0
  59. package/dist/nodefony/src/migrator/resolve.js +281 -0
  60. package/dist/nodefony/src/migrator/schemaDiff.js +86 -0
  61. package/dist/nodefony/src/migrator/sources.js +419 -0
  62. package/dist/nodefony/src/migrator/status.js +231 -0
  63. package/dist/nodefony/src/migrator/types.js +91 -0
  64. package/dist/nodefony/src/orm-core/DrizzleOrm.js +1154 -0
  65. package/dist/nodefony/src/orm-core/DrizzleRepository.js +610 -0
  66. package/dist/nodefony/src/orm-core/DrizzleTransaction.js +106 -0
  67. package/dist/nodefony/src/orm-core/index.js +4 -0
  68. package/dist/nodefony/src/queryKit.js +318 -0
  69. package/dist/nodefony/src/safeTarget.js +55 -0
  70. package/dist/types/index.d.ts +76 -0
  71. package/dist/types/nodefony/command/migrateShared.d.ts +137 -0
  72. package/dist/types/nodefony/command/orm-generate.d.ts +53 -0
  73. package/dist/types/nodefony/command/orm-migrate-baseline.d.ts +87 -0
  74. package/dist/types/nodefony/command/orm-migrate-repair.d.ts +66 -0
  75. package/dist/types/nodefony/command/orm-migrate-status.d.ts +38 -0
  76. package/dist/types/nodefony/command/orm-migrate.d.ts +65 -0
  77. package/dist/types/nodefony/command/orm-reset.d.ts +55 -0
  78. package/dist/types/nodefony/config/config.d.ts +110 -0
  79. package/dist/types/nodefony/config/defineModuleConfig.d.ts +24 -0
  80. package/dist/types/nodefony/entity/auditEventEntity.d.ts +56 -0
  81. package/dist/types/nodefony/entity/colKit.d.ts +130 -0
  82. package/dist/types/nodefony/entity/idempotencyEntity.d.ts +52 -0
  83. package/dist/types/nodefony/entity/sessionEntity.d.ts +50 -0
  84. package/dist/types/nodefony/entity/tokenEntity.d.ts +53 -0
  85. package/dist/types/nodefony/entity/totpSecretEntity.d.ts +61 -0
  86. package/dist/types/nodefony/entity/userTable.d.ts +65 -0
  87. package/dist/types/nodefony/entity/webAuthnCredentialEntity.d.ts +57 -0
  88. package/dist/types/nodefony/entity/webhookEndpointEntity.d.ts +58 -0
  89. package/dist/types/nodefony/interfaces/IDrizzleConfig.d.ts +17 -0
  90. package/dist/types/nodefony/interfaces/index.d.ts +1 -0
  91. package/dist/types/nodefony/migrations-schema/mysql.d.ts +9 -0
  92. package/dist/types/nodefony/migrations-schema/postgres.d.ts +9 -0
  93. package/dist/types/nodefony/migrations-schema/sqlite.d.ts +9 -0
  94. package/dist/types/nodefony/registerStores.d.ts +52 -0
  95. package/dist/types/nodefony/service/DrizzleService.d.ts +27 -0
  96. package/dist/types/nodefony/src/DrizzleAuditStore.d.ts +65 -0
  97. package/dist/types/nodefony/src/DrizzleIdempotencyStore.d.ts +129 -0
  98. package/dist/types/nodefony/src/DrizzleTokenStore.d.ts +146 -0
  99. package/dist/types/nodefony/src/DrizzleTotpSecretStore.d.ts +60 -0
  100. package/dist/types/nodefony/src/DrizzleUserRepository.d.ts +67 -0
  101. package/dist/types/nodefony/src/DrizzleWebAuthnCredentialStore.d.ts +62 -0
  102. package/dist/types/nodefony/src/DrizzleWebhookStore.d.ts +79 -0
  103. package/dist/types/nodefony/src/SessionStorage.d.ts +72 -0
  104. package/dist/types/nodefony/src/connectorTarget.d.ts +48 -0
  105. package/dist/types/nodefony/src/likeSql.d.ts +29 -0
  106. package/dist/types/nodefony/src/migrator/DrizzleMigrator.d.ts +94 -0
  107. package/dist/types/nodefony/src/migrator/adopt.d.ts +280 -0
  108. package/dist/types/nodefony/src/migrator/appSchema.d.ts +223 -0
  109. package/dist/types/nodefony/src/migrator/catalog.d.ts +100 -0
  110. package/dist/types/nodefony/src/migrator/destructive.d.ts +123 -0
  111. package/dist/types/nodefony/src/migrator/divergence.d.ts +61 -0
  112. package/dist/types/nodefony/src/migrator/drivers/index.d.ts +26 -0
  113. package/dist/types/nodefony/src/migrator/drivers/mysqlDriver.d.ts +83 -0
  114. package/dist/types/nodefony/src/migrator/drivers/postgresDriver.d.ts +81 -0
  115. package/dist/types/nodefony/src/migrator/drivers/sqliteDriver.d.ts +53 -0
  116. package/dist/types/nodefony/src/migrator/explain.d.ts +424 -0
  117. package/dist/types/nodefony/src/migrator/hash.d.ts +39 -0
  118. package/dist/types/nodefony/src/migrator/history.d.ts +139 -0
  119. package/dist/types/nodefony/src/migrator/index.d.ts +20 -0
  120. package/dist/types/nodefony/src/migrator/kit.d.ts +141 -0
  121. package/dist/types/nodefony/src/migrator/name.d.ts +52 -0
  122. package/dist/types/nodefony/src/migrator/paths.d.ts +54 -0
  123. package/dist/types/nodefony/src/migrator/refusals.d.ts +170 -0
  124. package/dist/types/nodefony/src/migrator/resolve.d.ts +201 -0
  125. package/dist/types/nodefony/src/migrator/schemaDiff.d.ts +112 -0
  126. package/dist/types/nodefony/src/migrator/sources.d.ts +119 -0
  127. package/dist/types/nodefony/src/migrator/status.d.ts +132 -0
  128. package/dist/types/nodefony/src/migrator/types.d.ts +273 -0
  129. package/dist/types/nodefony/src/orm-core/DrizzleOrm.d.ts +241 -0
  130. package/dist/types/nodefony/src/orm-core/DrizzleRepository.d.ts +98 -0
  131. package/dist/types/nodefony/src/orm-core/DrizzleTransaction.d.ts +71 -0
  132. package/dist/types/nodefony/src/orm-core/index.d.ts +11 -0
  133. package/dist/types/nodefony/src/queryKit.d.ts +136 -0
  134. package/dist/types/nodefony/src/safeTarget.d.ts +42 -0
  135. package/docs/index.md +954 -0
  136. package/docs/migrations.md +691 -0
  137. package/migrations/mysql/0000_framework_init.sql +137 -0
  138. package/migrations/mysql/meta/_journal.json +13 -0
  139. package/migrations/postgres/0000_framework_init.sql +128 -0
  140. package/migrations/postgres/meta/_journal.json +13 -0
  141. package/migrations/sqlite/0000_framework_init.sql +127 -0
  142. package/migrations/sqlite/meta/_journal.json +13 -0
  143. 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 };