@lenne.tech/nest-server 12.1.0 → 12.2.0

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 (255) hide show
  1. package/.claude/rules/architecture.md +1 -0
  2. package/.claude/rules/better-auth.md +18 -6
  3. package/.claude/rules/configurable-features.md +5 -4
  4. package/.claude/rules/package-management.md +16 -1
  5. package/.claude/rules/persistence.md +26 -0
  6. package/.claude/rules/testing.md +102 -28
  7. package/CLAUDE.md +3 -2
  8. package/FRAMEWORK-API.md +17 -2
  9. package/README.md +24 -3
  10. package/dist/core/common/helpers/file.helper.js +3 -4
  11. package/dist/core/common/helpers/file.helper.js.map +1 -1
  12. package/dist/core/common/helpers/graphql-ws-connection.helper.js.map +1 -1
  13. package/dist/core/common/interfaces/server-options.interface.d.ts +41 -0
  14. package/dist/core/common/interfaces/service-options.interface.d.ts +3 -0
  15. package/dist/core/common/persistence/core-persistence.module.js +12 -2
  16. package/dist/core/common/persistence/core-persistence.module.js.map +1 -1
  17. package/dist/core/common/persistence/data-source.runtime.js +14 -19
  18. package/dist/core/common/persistence/data-source.runtime.js.map +1 -1
  19. package/dist/core/common/persistence/data-store.interface.d.ts +24 -9
  20. package/dist/core/common/persistence/id-format.d.ts +2 -0
  21. package/dist/core/common/persistence/id-format.js +14 -0
  22. package/dist/core/common/persistence/id-format.js.map +1 -1
  23. package/dist/core/common/persistence/model-descriptor.interface.d.ts +6 -0
  24. package/dist/core/common/persistence/model-metadata.registry.d.ts +2 -1
  25. package/dist/core/common/persistence/model-metadata.registry.js +20 -2
  26. package/dist/core/common/persistence/model-metadata.registry.js.map +1 -1
  27. package/dist/core/common/persistence/mongo/sync-schema.plugin.d.ts +18 -0
  28. package/dist/core/common/persistence/mongo/sync-schema.plugin.js +368 -0
  29. package/dist/core/common/persistence/mongo/sync-schema.plugin.js.map +1 -0
  30. package/dist/core/common/persistence/mongoose-data-store.d.ts +19 -9
  31. package/dist/core/common/persistence/mongoose-data-store.js +107 -31
  32. package/dist/core/common/persistence/mongoose-data-store.js.map +1 -1
  33. package/dist/core/common/persistence/persisted-model.decorator.d.ts +3 -0
  34. package/dist/core/common/persistence/persisted-model.decorator.js +9 -0
  35. package/dist/core/common/persistence/persisted-model.decorator.js.map +1 -1
  36. package/dist/core/common/persistence/persistence-runtime.registry.d.ts +5 -1
  37. package/dist/core/common/persistence/persistence-runtime.registry.js.map +1 -1
  38. package/dist/core/common/persistence/policies/sync.policy.d.ts +17 -0
  39. package/dist/core/common/persistence/policies/sync.policy.js +77 -0
  40. package/dist/core/common/persistence/policies/sync.policy.js.map +1 -0
  41. package/dist/core/common/persistence/sql/mysql/mysql-data-store.d.ts +12 -8
  42. package/dist/core/common/persistence/sql/mysql/mysql-data-store.js +82 -29
  43. package/dist/core/common/persistence/sql/mysql/mysql-data-store.js.map +1 -1
  44. package/dist/core/common/persistence/sql/mysql/mysql-schema.compiler.d.ts +10 -0
  45. package/dist/core/common/persistence/sql/mysql/mysql-schema.compiler.js +61 -4
  46. package/dist/core/common/persistence/sql/mysql/mysql-schema.compiler.js.map +1 -1
  47. package/dist/core/common/persistence/sql/mysql/mysql-schema.migrator.d.ts +2 -0
  48. package/dist/core/common/persistence/sql/mysql/mysql-schema.migrator.js +145 -4
  49. package/dist/core/common/persistence/sql/mysql/mysql-schema.migrator.js.map +1 -1
  50. package/dist/core/common/persistence/sql/postgres/postgres-schema.compiler.d.ts +10 -0
  51. package/dist/core/common/persistence/sql/postgres/postgres-schema.compiler.js +41 -2
  52. package/dist/core/common/persistence/sql/postgres/postgres-schema.compiler.js.map +1 -1
  53. package/dist/core/common/persistence/sql/postgres/postgres-schema.migrator.js +98 -10
  54. package/dist/core/common/persistence/sql/postgres/postgres-schema.migrator.js.map +1 -1
  55. package/dist/core/common/persistence/sql/sql-data-store.d.ts +38 -14
  56. package/dist/core/common/persistence/sql/sql-data-store.js +212 -38
  57. package/dist/core/common/persistence/sql/sql-data-store.js.map +1 -1
  58. package/dist/core/common/persistence/sql/sql-dialect.d.ts +2 -0
  59. package/dist/core/common/persistence/sql/sql-dialect.js +14 -2
  60. package/dist/core/common/persistence/sql/sql-dialect.js.map +1 -1
  61. package/dist/core/common/persistence/sql/sql-errors.d.ts +2 -1
  62. package/dist/core/common/persistence/sql/sql-errors.js +3 -1
  63. package/dist/core/common/persistence/sql/sql-errors.js.map +1 -1
  64. package/dist/core/common/persistence/sync-access.registry.d.ts +17 -0
  65. package/dist/core/common/persistence/sync-access.registry.js +8 -0
  66. package/dist/core/common/persistence/sync-access.registry.js.map +1 -0
  67. package/dist/core/common/persistence/sync-model.d.ts +29 -0
  68. package/dist/core/common/persistence/sync-model.js +138 -0
  69. package/dist/core/common/persistence/sync-model.js.map +1 -0
  70. package/dist/core/common/services/core-cron-jobs.service.d.ts +27 -0
  71. package/dist/core/common/services/core-cron-jobs.service.js +269 -13
  72. package/dist/core/common/services/core-cron-jobs.service.js.map +1 -1
  73. package/dist/core/common/services/core-redis-pubsub.js +5 -5
  74. package/dist/core/common/services/core-redis-pubsub.js.map +1 -1
  75. package/dist/core/common/services/core-redis.service.d.ts +6 -0
  76. package/dist/core/common/services/core-redis.service.js +57 -0
  77. package/dist/core/common/services/core-redis.service.js.map +1 -1
  78. package/dist/core/common/services/crud.service.d.ts +13 -1
  79. package/dist/core/common/services/crud.service.js +174 -16
  80. package/dist/core/common/services/crud.service.js.map +1 -1
  81. package/dist/core/modules/better-auth/better-auth-sql.helper.d.ts +1 -0
  82. package/dist/core/modules/better-auth/better-auth-sql.helper.js +41 -14
  83. package/dist/core/modules/better-auth/better-auth-sql.helper.js.map +1 -1
  84. package/dist/core/modules/better-auth/better-auth.config.js.map +1 -1
  85. package/dist/core/modules/better-auth/core-better-auth-sql.store.d.ts +4 -7
  86. package/dist/core/modules/better-auth/core-better-auth-sql.store.js +23 -16
  87. package/dist/core/modules/better-auth/core-better-auth-sql.store.js.map +1 -1
  88. package/dist/core/modules/better-auth/core-better-auth-user.mapper.d.ts +12 -2
  89. package/dist/core/modules/better-auth/core-better-auth-user.mapper.js +82 -5
  90. package/dist/core/modules/better-auth/core-better-auth-user.mapper.js.map +1 -1
  91. package/dist/core/modules/better-auth/core-better-auth.module.js +1 -1
  92. package/dist/core/modules/better-auth/core-better-auth.module.js.map +1 -1
  93. package/dist/core/modules/error-code/error-codes.d.ts +54 -0
  94. package/dist/core/modules/error-code/error-codes.js +48 -0
  95. package/dist/core/modules/error-code/error-codes.js.map +1 -1
  96. package/dist/core/modules/hub/hub-buffer.js +3 -4
  97. package/dist/core/modules/hub/hub-buffer.js.map +1 -1
  98. package/dist/core/modules/migrate/index.js.map +1 -1
  99. package/dist/core/modules/migrate/migration-runner.d.ts +6 -0
  100. package/dist/core/modules/migrate/migration-runner.js +28 -2
  101. package/dist/core/modules/migrate/migration-runner.js.map +1 -1
  102. package/dist/core/modules/migrate/mongo-state-store.d.ts +0 -1
  103. package/dist/core/modules/migrate/mongo-state-store.js +0 -5
  104. package/dist/core/modules/migrate/mongo-state-store.js.map +1 -1
  105. package/dist/core/modules/migrate/sql-migration.helper.d.ts +15 -1
  106. package/dist/core/modules/migrate/sql-migration.helper.js +90 -5
  107. package/dist/core/modules/migrate/sql-migration.helper.js.map +1 -1
  108. package/dist/core/modules/sync/core-sync-base.controller.d.ts +7 -0
  109. package/dist/core/modules/sync/core-sync-base.controller.js +44 -0
  110. package/dist/core/modules/sync/core-sync-base.controller.js.map +1 -0
  111. package/dist/core/modules/sync/core-sync-cursor.d.ts +19 -0
  112. package/dist/core/modules/sync/core-sync-cursor.js +77 -0
  113. package/dist/core/modules/sync/core-sync-cursor.js.map +1 -0
  114. package/dist/core/modules/sync/core-sync-hints.controller.d.ts +4 -0
  115. package/dist/core/modules/sync/core-sync-hints.controller.js +55 -0
  116. package/dist/core/modules/sync/core-sync-hints.controller.js.map +1 -0
  117. package/dist/core/modules/sync/core-sync-hints.service.d.ts +28 -0
  118. package/dist/core/modules/sync/core-sync-hints.service.js +155 -0
  119. package/dist/core/modules/sync/core-sync-hints.service.js.map +1 -0
  120. package/dist/core/modules/sync/core-sync-infrastructure.models.d.ts +2 -0
  121. package/dist/core/modules/sync/core-sync-infrastructure.models.js +17 -0
  122. package/dist/core/modules/sync/core-sync-infrastructure.models.js.map +1 -0
  123. package/dist/core/modules/sync/core-sync-mutation.store.d.ts +43 -0
  124. package/dist/core/modules/sync/core-sync-mutation.store.js +88 -0
  125. package/dist/core/modules/sync/core-sync-mutation.store.js.map +1 -0
  126. package/dist/core/modules/sync/core-sync-state.store.d.ts +18 -0
  127. package/dist/core/modules/sync/core-sync-state.store.js +64 -0
  128. package/dist/core/modules/sync/core-sync-state.store.js.map +1 -0
  129. package/dist/core/modules/sync/core-sync.constants.d.ts +28 -0
  130. package/dist/core/modules/sync/core-sync.constants.js +23 -0
  131. package/dist/core/modules/sync/core-sync.constants.js.map +1 -0
  132. package/dist/core/modules/sync/core-sync.controller.d.ts +8 -0
  133. package/dist/core/modules/sync/core-sync.controller.js +79 -0
  134. package/dist/core/modules/sync/core-sync.controller.js.map +1 -0
  135. package/dist/core/modules/sync/core-sync.decorators.d.ts +12 -0
  136. package/dist/core/modules/sync/core-sync.decorators.js +28 -0
  137. package/dist/core/modules/sync/core-sync.decorators.js.map +1 -0
  138. package/dist/core/modules/sync/core-sync.module.d.ts +16 -0
  139. package/dist/core/modules/sync/core-sync.module.js +50 -0
  140. package/dist/core/modules/sync/core-sync.module.js.map +1 -0
  141. package/dist/core/modules/sync/core-sync.resolver.d.ts +9 -0
  142. package/dist/core/modules/sync/core-sync.resolver.js +46 -0
  143. package/dist/core/modules/sync/core-sync.resolver.js.map +1 -0
  144. package/dist/core/modules/sync/core-sync.service.d.ts +142 -0
  145. package/dist/core/modules/sync/core-sync.service.js +1208 -0
  146. package/dist/core/modules/sync/core-sync.service.js.map +1 -0
  147. package/dist/core/modules/sync/interfaces/core-sync.interfaces.d.ts +56 -0
  148. package/dist/core/modules/sync/interfaces/core-sync.interfaces.js +2 -0
  149. package/dist/core/modules/sync/interfaces/core-sync.interfaces.js.map +1 -0
  150. package/dist/core/modules/sync/models/core-sync-hint.model.d.ts +4 -0
  151. package/dist/core/modules/sync/models/core-sync-hint.model.js +30 -0
  152. package/dist/core/modules/sync/models/core-sync-hint.model.js.map +1 -0
  153. package/dist/core/modules/tenant/core-tenant.guard.js +8 -3
  154. package/dist/core/modules/tenant/core-tenant.guard.js.map +1 -1
  155. package/dist/core.module.js +14 -0
  156. package/dist/core.module.js.map +1 -1
  157. package/dist/index.d.ts +16 -0
  158. package/dist/index.js +16 -0
  159. package/dist/index.js.map +1 -1
  160. package/dist/server/modules/error-code/error-codes.d.ts +6 -0
  161. package/dist/server/modules/sync-note/inputs/sync-note-create.input.d.ts +4 -0
  162. package/dist/server/modules/sync-note/inputs/sync-note-create.input.js +27 -0
  163. package/dist/server/modules/sync-note/inputs/sync-note-create.input.js.map +1 -0
  164. package/dist/server/modules/sync-note/inputs/sync-note.input.d.ts +6 -0
  165. package/dist/server/modules/sync-note/inputs/sync-note.input.js +37 -0
  166. package/dist/server/modules/sync-note/inputs/sync-note.input.js.map +1 -0
  167. package/dist/server/modules/sync-note/sync-note.model.d.ts +10 -0
  168. package/dist/server/modules/sync-note/sync-note.model.js +73 -0
  169. package/dist/server/modules/sync-note/sync-note.model.js.map +1 -0
  170. package/dist/server/modules/sync-note/sync-note.module.d.ts +2 -0
  171. package/dist/server/modules/sync-note/sync-note.module.js +21 -0
  172. package/dist/server/modules/sync-note/sync-note.module.js.map +1 -0
  173. package/dist/server/modules/sync-note/sync-note.service.d.ts +10 -0
  174. package/dist/server/modules/sync-note/sync-note.service.js +35 -0
  175. package/dist/server/modules/sync-note/sync-note.service.js.map +1 -0
  176. package/docs/REQUEST-LIFECYCLE.md +3 -2
  177. package/docs/choosing-a-database.md +7 -4
  178. package/docs/offline-sync-protocol.md +410 -0
  179. package/docs/security-overrides.md +14 -0
  180. package/migration-guides/12.0.x-to-12.1.x.md +9 -4
  181. package/migration-guides/12.1.0-to-12.1.1.md +125 -0
  182. package/migration-guides/12.1.x-to-12.2.x.md +154 -0
  183. package/package.json +10 -8
  184. package/src/core/common/helpers/file.helper.ts +7 -5
  185. package/src/core/common/helpers/graphql-ws-connection.helper.ts +3 -2
  186. package/src/core/common/interfaces/cron-job-config.interface.ts +7 -2
  187. package/src/core/common/interfaces/server-options.interface.ts +149 -0
  188. package/src/core/common/interfaces/service-options.interface.ts +15 -0
  189. package/src/core/common/persistence/core-persistence.module.ts +21 -2
  190. package/src/core/common/persistence/data-source.runtime.ts +22 -23
  191. package/src/core/common/persistence/data-store.interface.ts +65 -12
  192. package/src/core/common/persistence/id-format.ts +26 -0
  193. package/src/core/common/persistence/model-descriptor.interface.ts +12 -0
  194. package/src/core/common/persistence/model-metadata.registry.ts +31 -2
  195. package/src/core/common/persistence/mongo/sync-schema.plugin.ts +508 -0
  196. package/src/core/common/persistence/mongoose-data-store.ts +171 -35
  197. package/src/core/common/persistence/persisted-model.decorator.ts +26 -0
  198. package/src/core/common/persistence/persistence-runtime.registry.ts +16 -5
  199. package/src/core/common/persistence/policies/sync.policy.ts +121 -0
  200. package/src/core/common/persistence/sql/mysql/mysql-data-store.ts +108 -30
  201. package/src/core/common/persistence/sql/mysql/mysql-schema.compiler.ts +107 -4
  202. package/src/core/common/persistence/sql/mysql/mysql-schema.migrator.ts +194 -3
  203. package/src/core/common/persistence/sql/postgres/postgres-schema.compiler.ts +74 -2
  204. package/src/core/common/persistence/sql/postgres/postgres-schema.migrator.ts +139 -7
  205. package/src/core/common/persistence/sql/sql-data-store.ts +325 -44
  206. package/src/core/common/persistence/sql/sql-dialect.ts +38 -1
  207. package/src/core/common/persistence/sql/sql-errors.ts +5 -1
  208. package/src/core/common/persistence/sql/sql-executor.interface.ts +4 -1
  209. package/src/core/common/persistence/sync-access.registry.ts +50 -0
  210. package/src/core/common/persistence/sync-model.ts +270 -0
  211. package/src/core/common/services/core-cron-jobs.service.ts +441 -27
  212. package/src/core/common/services/core-redis-pubsub.ts +8 -6
  213. package/src/core/common/services/core-redis.service.ts +99 -0
  214. package/src/core/common/services/crud.service.ts +257 -19
  215. package/src/core/modules/better-auth/README.md +25 -1
  216. package/src/core/modules/better-auth/better-auth-sql.helper.ts +56 -24
  217. package/src/core/modules/better-auth/better-auth.config.ts +4 -3
  218. package/src/core/modules/better-auth/core-better-auth-sql.store.ts +28 -21
  219. package/src/core/modules/better-auth/core-better-auth-user.mapper.ts +122 -6
  220. package/src/core/modules/better-auth/core-better-auth.module.ts +2 -2
  221. package/src/core/modules/error-code/error-codes.ts +59 -0
  222. package/src/core/modules/hub/README.md +5 -2
  223. package/src/core/modules/hub/hub-buffer.ts +13 -7
  224. package/src/core/modules/migrate/README.md +20 -11
  225. package/src/core/modules/migrate/index.ts +1 -0
  226. package/src/core/modules/migrate/migration-runner.ts +68 -3
  227. package/src/core/modules/migrate/mongo-state-store.ts +7 -30
  228. package/src/core/modules/migrate/sql-migration.helper.ts +141 -7
  229. package/src/core/modules/sync/INTEGRATION-CHECKLIST.md +95 -0
  230. package/src/core/modules/sync/README.md +188 -0
  231. package/src/core/modules/sync/SECURITY.md +65 -0
  232. package/src/core/modules/sync/core-sync-base.controller.ts +43 -0
  233. package/src/core/modules/sync/core-sync-cursor.ts +117 -0
  234. package/src/core/modules/sync/core-sync-hints.controller.ts +52 -0
  235. package/src/core/modules/sync/core-sync-hints.service.ts +178 -0
  236. package/src/core/modules/sync/core-sync-infrastructure.models.ts +40 -0
  237. package/src/core/modules/sync/core-sync-mutation.store.ts +146 -0
  238. package/src/core/modules/sync/core-sync-state.store.ts +89 -0
  239. package/src/core/modules/sync/core-sync.constants.ts +51 -0
  240. package/src/core/modules/sync/core-sync.controller.ts +68 -0
  241. package/src/core/modules/sync/core-sync.decorators.ts +84 -0
  242. package/src/core/modules/sync/core-sync.module.ts +75 -0
  243. package/src/core/modules/sync/core-sync.resolver.ts +35 -0
  244. package/src/core/modules/sync/core-sync.service.ts +1728 -0
  245. package/src/core/modules/sync/interfaces/core-sync.interfaces.ts +85 -0
  246. package/src/core/modules/sync/models/core-sync-hint.model.ts +18 -0
  247. package/src/core/modules/tenant/README.md +1 -0
  248. package/src/core/modules/tenant/core-tenant.guard.ts +16 -3
  249. package/src/core.module.ts +25 -0
  250. package/src/index.ts +21 -0
  251. package/src/server/modules/sync-note/inputs/sync-note-create.input.ts +14 -0
  252. package/src/server/modules/sync-note/inputs/sync-note.input.ts +23 -0
  253. package/src/server/modules/sync-note/sync-note.model.ts +77 -0
  254. package/src/server/modules/sync-note/sync-note.module.ts +16 -0
  255. package/src/server/modules/sync-note/sync-note.service.ts +25 -0
@@ -70,6 +70,7 @@ proves the build loads and runs with them unresolvable. Rules: `.claude/rules/pe
70
70
  | **Hub** | Build-free ADMIN-gated operator cockpit at `/hub` (config-gated per environment; 17 panels, runtime collectors, mailbox, admin actions) |
71
71
  | **Migrate** | Database migration utilities |
72
72
  | **Permissions** | ADMIN-gated security-map report (routes + roles + `@Restricted` fields); also surfaced in the Hub's "Routes / Permissions" panel |
73
+ | **Sync** | Opt-in (`sync`) offline sync for models declared `@PersistedModel({ sync })`: pull by cursor, push with idempotency and conflict strategies, resync on access loss — through the model's CrudService; change hints over SSE and GraphQL, filtered per subscriber; hourly tombstone purge under a lease |
73
74
  | **SystemSetup** | Initial admin creation for fresh deployments |
74
75
  | **Tus** | Resumable file uploads via tus.io protocol |
75
76
  | **User** | Core user management functionality |
@@ -508,22 +508,32 @@ removed. `tests/better-auth-signin-csrf.e2e-spec.ts` does; mutations `iam-signin
508
508
 
509
509
  Better-Auth keeps its data where the user model lives. `DataSourceRuntime.iamStorage()` finds the persisted model whose
510
510
  table is `users`; when that model is routed to a SQL source, `CoreBetterAuthModule.createInstance()` hands Better-Auth
511
- the application role's `pg` pool of that source, and Better-Auth runs its own Kysely adapter on it (it recognises a pool
512
- by its `connect` method — the core imports no Kysely). On MongoDB nothing changes.
511
+ the application role's pool of that source, and Better-Auth runs its own Kysely adapter on it — the core imports no
512
+ Kysely. Which pool is the dialect's answer (`SqlDialect.foreignPool()`): the `pg` pool on PostgreSQL (Better-Auth
513
+ recognises it by `connect`), and on MySQL / MariaDB (12.1.1+, DEV-3904) the `mysql2` pool's promise wrapper — Better-Auth
514
+ recognises it by `getConnection` and builds `new MysqlDialect(wrapper)`, whose `pool` field is the callback pool Kysely
515
+ needs. Passing the callback pool itself leaves that field empty. Either way it is the framework's own pool, so Better-Auth's
516
+ statements run in the session the framework set up (strict mode, UTC). On MongoDB nothing changes.
513
517
 
514
518
  | Piece | Where | Rule |
515
519
  |---|---|---|
516
520
  | Naming | `withSqlStorage()` in `better-auth-sql.helper.ts` | `user` → `users`, every table and field snake_case — the plugins' too, on copies, never the plugin objects; ids are ObjectId hex (`generateId`), so a Better-Auth user id IS the framework's id and `iamId` equals it |
517
521
  | One row per user | the same helper + `registerForeignColumns('users', …)` | Better-Auth's own columns (`name`, `email_verified`, `image`) live in the framework's `users` table; its required ones are added nullable first, so a row the user model writes alone stays valid, and the framework's schema planner does not report them as undeclared |
518
- | Schema | `applyBetterAuthSchema()` | Better-Auth validates its schema when the instance is created, so its migrations run BEFORE, as the owner, under the framework's schema lock, followed by the grants and the safe-role check. Without an owner connection the boot stops while anything is pending — the application role never runs DDL |
522
+ | Schema | `applyBetterAuthSchema()` | Better-Auth validates its schema when the instance is created, so its migrations run BEFORE, as the owner, under the framework's schema lock (`SqlDialect.withForeignSchemaLock()`), followed by the grants and the safe-role check. Without an owner connection the boot stops while anything is pending — the application role never runs DDL. On MySQL / MariaDB the lock is `GET_LOCK`, the database-level grant of the framework's own schema run already covers Better-Auth's tables, and the database's default collation is `utf8mb4_bin` while Better-Auth creates a table (`withFrameworkDefaultCollation()`): Better-Auth names no collation, MariaDB refuses its foreign keys to `users.id` across collations, and on MySQL its tokens would compare case-insensitively (mutation `mysql-iam-tables-default-collation`). Only on a run that creates a table (`anyTableMissing()`), and with `lock_wait_timeout` of 5 s: `ALTER DATABASE` takes an exclusive lock on every table, so on every boot it stalled all traffic behind any open transaction (mutation `mysql-collation-switch-every-boot`). The columns Better-Auth needs on `users` are added in each dialect's own SQL (`addRequiredUserColumnsAsNullable()`) |
519
523
  | Framework access | `BetterAuthSqlStore` (`core-better-auth-sql.store.ts`), reached through the import-free leaf `core-better-auth-sql.registry.ts` | framework fields of the user row go through the user model's `DataStore` (its policies apply); Better-Auth's own fields and tables go through Better-Auth's `internalAdapter` / `adapter` (its hooks run — a deleted session reaches `wsSessionRevocationPlugin()`). Every MongoDB path keeps its code; the SQL branch sits beside it (`*OnSql` in the user mapper) |
520
524
  | Passkey challenges | `storeChallenge()` | Better-Auth verification values with the identifier prefix `lt-passkey-challenge:` — no table of the framework's own, expiry checked on read, expired ones removed as new ones arrive |
521
525
  | Expired rows | `BetterAuthSqlStore.deleteExpired()`, scheduled by `CoreBetterAuthService.startExpirySweep()` | Better-Auth never deletes an expired session and clears expired verification values only when one is looked up; on MongoDB the verification TTL index does it. On SQL the expiry sweep (`scheduleExpirySweep()`, every 15 min, one replica per interval) removes both — through Better-Auth's adapter, so its naming applies. Stopped in `onModuleDestroy()`, before the data sources close (mutation `iam-expired-sessions-kept`) |
522
526
  | Per-request path | token service, middlewares, guard, web and cookie helpers | ids are strings; only `BetterAuthMongoUsers` (`core-better-auth-mongo.users.ts`) knows what an ObjectId is. Pinned by `tests/unit/iam-request-path-ids.spec.ts` |
523
527
  | Boot steps | `CoreBetterAuthService.onModuleInit()` | `ensureIndices()` and the account-issuer backfill concern MongoDB's collections and are skipped on SQL |
524
- | Counting | `BetterAuthSqlStore.migrationCounts()` | counts in SQL (`count(*)`, `EXISTS` over `account`), names from `$context.tables`. **Never read a whole table through Better-Auth's `adapter.findMany()`**: without a `limit` it stops at 100 rows (`defaultFindManyLimit`), which capped the migration status at 100 migrated users (mutation `iam-migration-status-capped-at-a-page`) |
528
+ | Counting | `BetterAuthSqlStore.migrationCounts()` | counts in SQL (`count(*)`, `EXISTS` over `account`), names from `$context.tables` — one statement for every SQL database, only the identifier quotes differ, and the counts are converted to numbers (MySQL answers them as strings). **Never read a whole table through Better-Auth's `adapter.findMany()`**: without a `limit` it stops at 100 rows (`defaultFindManyLimit`), which capped the migration status at 100 migrated users (mutation `iam-migration-status-capped-at-a-page`) |
525
529
  | Readers outside the module | `CoreAiMcpOAuthService.loadUser()` | every native read of `users` needs the SQL branch too — an OAuth subject's roles came from MongoDB, i.e. nobody or a stale copy (mutation `mcp-oauth-user-on-mongo`). Find candidates with `grep -rn "collection('users')" src/core` |
526
530
 
531
+ **The `mysql2` peer floor is load-bearing on MySQL / MariaDB.** Better-Auth's Kysely adapter sends its values through
532
+ `mysql2`'s client-side escaping (`sql-escaper`), and `/iam/update-user` accepts any JSON for `name` and `image`. From
533
+ `sql-escaper` 1.5.1 on, an object turns into `key = value` pairs only after a bare `SET ?`, which Kysely never writes
534
+ (it writes `col = ?`); before that, `{"name": {"roles": "[\"admin\"]"}}` assigned `roles` — a signed-in user making
535
+ themselves an administrator. The peer range (`>= 3.24.6`) guarantees the fixed escaper; never lower it.
536
+
527
537
  **Sign-up holds a pool connection while it hashes (accepted).** Better-Auth runs its Kysely adapter with transactions
528
538
  on a `pg` pool, and `/sign-up/email` hashes the password inside its transaction — on the framework's application pool
529
539
  (10 connections). A burst of concurrent sign-ups therefore makes other queries wait (measured: ~0.16 s at 20, ~1.3 s
@@ -544,10 +554,12 @@ mapping was stored and no `challengeId` returned, on every database (mutation `p
544
554
  `passkey.webAuthnChallengeCookie` is the KEY; it reaches the plugin as `advanced.webAuthnChallengeCookie`, the only
545
555
  place the plugin reads it.
546
556
 
547
- Tests: every IAM suite runs on MongoDB and on PostgreSQL with each engine (`.claude/rules/testing.md` → the IAM
557
+ Tests: every IAM suite runs on MongoDB, on PostgreSQL with each engine, and on MySQL and MariaDB (`.claude/rules/testing.md` → the IAM
548
558
  projects); `tests/better-auth-storage-paths.e2e-spec.ts` covers the four paths the stories pass either way (session
549
559
  token as bearer, token service, passkey challenge, e-mail verified), `tests/persistence-sql-wiring.e2e-spec.ts` the
550
- boot (one row, ownership, a second boot, the system setup).
560
+ boot (one row, ownership, a second boot, the system setup), `tests/persistence-mysql-wiring.e2e-spec.ts` the MySQL /
561
+ MariaDB boot on a database in the server's default collation, and the `iam-*` cases of the core module matrix the
562
+ security rules on every database.
551
563
 
552
564
  ## Summary
553
565
 
@@ -321,23 +321,24 @@ This pattern is currently applied to:
321
321
  | Translate Response Interceptor | `security.translateResponseInterceptor` | Boolean Shorthand | `true` (enabled) |
322
322
  | Mongoose CastError Filter | `security.mongooseCastErrorFilter` | Explicit Boolean — **safe default** | **`true` since 11.42.16.** `MongooseCastErrorFilter`, registered by `CoreModule` as `APP_FILTER` (so `main.ts` and `Test.createTestingModule()` setups need nothing). A malformed id reached Mongoose and its `CastError` — not an `HttpException` — escaped every `@Catch(HttpException)` filter, so `GET /users/<malformed-id>` and every other `:id` route answered **500**. The filter decides by WHERE the failing value came from: a route parameter or a top-level GraphQL argument → **404** `ErrorCode.RESOURCE_NOT_FOUND` (answered like an unknown id); the query string, an update or filter body, or a nested GraphQL input → **400** `ErrorCode.INVALID_FIELD_FORMAT [<path>]`; a value the request does not contain (a broken stored id, a server-computed value) → **stays a 500**, with Nest's own body and log line (context `ExceptionsHandler`), because that is a data or code defect monitoring must keep seeing — relabelling it as a client error is the mistake the refusal tests pin (mutations `cast-error-server-value-relabelled-*`). Only primitives and arrays of primitives (`$in`) are matched: an object cast as a whole yields no candidates, so its inner strings cannot match the body by coincidence. Never sends the Mongoose message (it names the model). REST bodies use the `HttpExceptionLogFilter` shape (`{ ...exception }`). A project's own catch-all filter via `app.useGlobalFilters()` is consulted first and wins. Covers casts Mongoose raises while running a QUERY or an UPDATE (`findById`, filters, `findByIdAndUpdate` — `CrudService.update()`). NOT covered, both stay a 500: a malformed id in a document being CREATED (`save()` / `create()` / `insertMany()` — `CrudService.create()`), which Mongoose reports as a `ValidationError` wrapping the CastError (validate in the input: `validator: () => [IsMongoId()]`); and a `BSONError` from `new Types.ObjectId(bad)`, which carries no value (check the id in the service, `CoreFileService.isMalformedFileId()`). `false` restores the 500. Implementation: `common/filters/mongoose-cast-error.filter.ts` |
323
323
  | Secret Fields Removal | `security.secretFields` | Array | `['password', 'verificationToken', ...]` |
324
- | Data Sources | `database` | Explicit Value — absent = today's `mongoose` config | **Absent → the `mongoose` configuration is the only data source, exactly as before; nothing SQL is loaded or installed.** A URL string (`'postgres://…'`) sets the default source (driver from the scheme, `sqlEngine` defaults to `typeorm`; `kysely` and `drizzle` are the alternatives — optional peers, each with `pg`); an object adds `ownerUrl` (default source), `sources` (name → URL or `{ url, ownerUrl, sqlEngine }`) and per-model exceptions (`models`); `false` means no database. Precedence for a model: `database.models` > the `dataSource` of a `CorePersistenceModule.forFeature()` definition > `@PersistedModel({ dataSource })` (read from the definition's `model:` class, otherwise from the decorated class named like the model; two such classes naming different sources refuse to guess) > default. When `database` sets a default and the project ALSO configures `mongoose` explicitly, that MongoDB becomes the source `mongoose` — only the project's own `mongoose` counts, because `CoreModule.forRoot()` always merges a fallback URI. **Since phase 1 (DEV-3829) PostgreSQL sources are wired**: only models registered with `CorePersistenceModule.forFeature()` are routed — **since phase 2 (DEV-3830) that includes the core's own models**: the user model (the reference `UserModule`), tenant memberships and API tokens, whose services switch to the routed store (a service that only received `@InjectModel()` follows its model's store; the Mongoose model of a model routed to SQL refuses every data access with a named error, so a direct `mainDbModel` call cannot split a model's data). **Since phase 3 (DEV-3831) Better-Auth follows the user model**: with the user model (table `users`) on a SQL source, Better-Auth runs its built-in Kysely adapter on that source's application pool — ONE `users` row per user shared with the user model (Better-Auth's own columns `name`, `email_verified`, `image` beside the framework's; its required ones are added nullable, so a row the user model writes alone stays valid), snake_case tables (`session`, `account`, `verification`, `jwks`, `two_factor`, `passkey`), ObjectId-hex ids. Its schema is applied as the OWNER at boot, under the same schema lock and grants as the framework's tables; without `ownerUrl` the application role only verifies and the boot stops while Better-Auth's migrations are pending. The framework's own reads and writes of Better-Auth's data follow it (session lookup by token, token service, JWKS, passkey challenge mappings — as Better-Auth verification values —, the e-mail-verified hook, user mapper, system setup, migration status — counted in SQL —, the MCP OAuth subject's roles); the MongoDB boot steps (indices, account-issuer backfill) are skipped there. Nothing is copied from MongoDB: a project that moves its users brings them over itself. **Since phase 4 (DEV-3832) the remaining core persistence follows too**: the AI module's eleven models and the three MCP OAuth stores (clients, codes, refresh tokens) are registered through `CorePersistenceModule.forFeature()` and run on their routed store (one code path; an upsert is update-then-insert with a retry on a duplicate, `sum()` replaces the budget aggregation, `find(…, { exclude })` the `-field` projection); the S3 and filesystem file metadata are two framework models (`CoreS3File`, `CoreFilesystemFile`, tables `s3_files` / `filesystem_files`) read and written through `fileMetadataCollection()` by the file service, the TUS hand-off and the Hub — GridFS stays MongoDB-only. A collection name with a hyphen becomes a table with an underscore. A TTL (`expires`, `index: { expireAfterSeconds }`) is a plain index on SQL; an **expiry sweep** (`scheduleExpirySweep()`, every 15 min, one replica per interval through a lease in the default SQL source's `lt_locks`, fail-open) deletes the expired rows of every routed TTL model, and — with Better-Auth on SQL — expired sessions and verification values. Nothing relies on it for correctness: every reader checks the expiry (an MCP authorization code older than ten minutes is refused on read on both databases). MongoDB keeps its TTL indexes and gets no sweep. The framework's leases (system-setup claim, cron, migrations) live in the default source: on MongoDB in the 12.0.0 documents (a rolling deploy with older replicas shares them), on SQL in `lt_locks`, namespaced, swept an hour after expiry. With `healthCheck.configs.database` every SQL source answers as `<key>:<source>`; `createMigrationStore(<owner URL>)` keeps the migration state in `lt_migrations` with an advisory run lock, on the first SQL engine installed (the CLI cannot read `database.sqlEngine`), and the Hub refuses to run migrations while that table is empty and the MongoDB collection is not (a `migrate.js` still writing MongoDB); the Hub's Persistence panel shows the routing. a SQL model's table is migrated as the OWNER (`ownerUrl`) when its store is created — without `ownerUrl` the application role only verifies and the boot stops while changes are pending; ONE owner connection per source, released once the application is bootstrapped — and the boot refuses an application role that is superuser, has `BYPASSRLS`, owns (or inherits the owner of) a framework table or may `TRUNCATE` it. Two roles in every environment (plan §3.9). Changes the framework never applies on its own (type change, new unique index, rename collision) stop the boot with the suggested statements; `migrate create <name> --sql` + `runSqlMigration()` apply them after review. **Since phase 5 (DEV-3833, 12.1.0) MongoDB is optional**: `mongoose`, `@nestjs/mongoose` and `mongodb` are optional peers, loaded through `common/persistence/mongo/mongo-packages.ts`; `CoreModule` connects MongoDB only when a source uses it (`usesMongoDb()` — `mongoose` configured explicitly, `database` naming MongoDB, or `database` absent), otherwise `@InjectConnection()` receives `null` and a model token without a connection is a stand-in that refuses data access. A configured MongoDB feature without the packages fails the boot with `MongoPackagesMissingError`. `database: false` runs a service without any database: a registered model's store refuses with the reason, Better-Auth is refused at boot. Proven by `pnpm run check:without-mongodb` (dist, packages hidden) and `check:without-mongodb:package` (the tarball installed without them, publish path). **Since phase 6 (DEV-3848) MySQL 8.4 and MariaDB 11.8 sources are wired** (`mysql://`, `mariadb://` — the scheme must name the server the URL reaches): `mysql2` is an optional peer beside the engine, all three engines run on one pool the framework creates (strict session with backslash escapes, UTC), `MysqlDataStore` writes the statements, the owner grants the application account `SELECT, INSERT, UPDATE, DELETE` on the database once and the boot refuses an application account that may alter, drop, index, trigger or grant; leases, migration state (`createMigrationStore()` / `runSqlMigration()` under `GET_LOCK`), the expiry sweep, health check and Hub work there. **Neither database has row-level security**: tenant isolation is the store's, the boot warns once per source, and native / raw access on a tenant table must filter by tenant itself. Better-Auth is refused at boot while the user model lives there. An indexed text field is `varchar(255)` (longer: 400); a DDL run is not transactional. Still refused with a named `DataSourceConfigError`: a second MongoDB source, and an `ownerUrl` without `url` — what `NSC__DATABASE__OWNER_URL` leaves of a `database` given as a URL string, because the environment merge replaces the string with an object (write `database: { url }`, or set `NSC__DATABASE__URL` too). An update path the model does not declare is dropped on SQL as by MongoDB's strict mode (`CrudService.update()` adds `updatedBy` to every update with a current user). URLs appear in errors and logs as scheme + host only. Implementation: `common/persistence/data-source.config.ts`, `data-source.runtime.ts`, `sql/` (plan: `.claude/plans/db-agnostic/PLAN.md` §3.9) |
324
+ | Data Sources | `database` | Explicit Value — absent = today's `mongoose` config | **Absent → the `mongoose` configuration is the only data source, exactly as before; nothing SQL is loaded or installed.** A URL string (`'postgres://…'`) sets the default source (driver from the scheme, `sqlEngine` defaults to `typeorm`; `kysely` and `drizzle` are the alternatives — optional peers, each with `pg`); an object adds `ownerUrl` (default source), `sources` (name → URL or `{ url, ownerUrl, sqlEngine }`) and per-model exceptions (`models`); `false` means no database. Precedence for a model: `database.models` > the `dataSource` of a `CorePersistenceModule.forFeature()` definition > `@PersistedModel({ dataSource })` (read from the definition's `model:` class, otherwise from the decorated class named like the model; two such classes naming different sources refuse to guess) > default. When `database` sets a default and the project ALSO configures `mongoose` explicitly, that MongoDB becomes the source `mongoose` — only the project's own `mongoose` counts, because `CoreModule.forRoot()` always merges a fallback URI. **Since phase 1 (DEV-3829) PostgreSQL sources are wired**: only models registered with `CorePersistenceModule.forFeature()` are routed — **since phase 2 (DEV-3830) that includes the core's own models**: the user model (the reference `UserModule`), tenant memberships and API tokens, whose services switch to the routed store (a service that only received `@InjectModel()` follows its model's store; the Mongoose model of a model routed to SQL refuses every data access with a named error, so a direct `mainDbModel` call cannot split a model's data). **Since phase 3 (DEV-3831) Better-Auth follows the user model**: with the user model (table `users`) on a SQL source, Better-Auth runs its built-in Kysely adapter on that source's application pool — ONE `users` row per user shared with the user model (Better-Auth's own columns `name`, `email_verified`, `image` beside the framework's; its required ones are added nullable, so a row the user model writes alone stays valid), snake_case tables (`session`, `account`, `verification`, `jwks`, `two_factor`, `passkey`), ObjectId-hex ids. Its schema is applied as the OWNER at boot, under the same schema lock and grants as the framework's tables; without `ownerUrl` the application role only verifies and the boot stops while Better-Auth's migrations are pending. The framework's own reads and writes of Better-Auth's data follow it (session lookup by token, token service, JWKS, passkey challenge mappings — as Better-Auth verification values —, the e-mail-verified hook, user mapper, system setup, migration status — counted in SQL —, the MCP OAuth subject's roles); the MongoDB boot steps (indices, account-issuer backfill) are skipped there. Nothing is copied from MongoDB: a project that moves its users brings them over itself. **Since phase 4 (DEV-3832) the remaining core persistence follows too**: the AI module's eleven models and the three MCP OAuth stores (clients, codes, refresh tokens) are registered through `CorePersistenceModule.forFeature()` and run on their routed store (one code path; an upsert is update-then-insert with a retry on a duplicate, `sum()` replaces the budget aggregation, `find(…, { exclude })` the `-field` projection); the S3 and filesystem file metadata are two framework models (`CoreS3File`, `CoreFilesystemFile`, tables `s3_files` / `filesystem_files`) read and written through `fileMetadataCollection()` by the file service, the TUS hand-off and the Hub — GridFS stays MongoDB-only. A collection name with a hyphen becomes a table with an underscore. A TTL (`expires`, `index: { expireAfterSeconds }`) is a plain index on SQL; an **expiry sweep** (`scheduleExpirySweep()`, every 15 min, one replica per interval through a lease in the default SQL source's `lt_locks`, fail-open) deletes the expired rows of every routed TTL model, and — with Better-Auth on SQL — expired sessions and verification values. Nothing relies on it for correctness: every reader checks the expiry (an MCP authorization code older than ten minutes is refused on read on both databases). MongoDB keeps its TTL indexes and gets no sweep. The framework's leases (system-setup claim, cron, migrations) live in the default source: on MongoDB in the 12.0.0 documents (a rolling deploy with older replicas shares them), on SQL in `lt_locks`, namespaced, swept an hour after expiry. With `healthCheck.configs.database` every SQL source answers as `<key>:<source>`; `createMigrationStore(<owner URL>)` keeps the migration state in `lt_migrations` with an advisory run lock, on the first SQL engine installed (the CLI cannot read `database.sqlEngine`), and the Hub refuses to run migrations while that table is empty and the MongoDB collection is not (a `migrate.js` still writing MongoDB); the Hub's Persistence panel shows the routing. a SQL model's table is migrated as the OWNER (`ownerUrl`) when its store is created — without `ownerUrl` the application role only verifies and the boot stops while changes are pending; ONE owner connection per source, released once the application is bootstrapped — and the boot refuses an application role that is superuser, has `BYPASSRLS`, owns (or inherits the owner of) a framework table or may `TRUNCATE` it. Two roles in every environment (plan §3.9). Changes the framework never applies on its own (type change, new unique index, rename collision) stop the boot with the suggested statements; `migrate create <name> --sql` + `runSqlMigration()` apply them after review. **Since phase 5 (DEV-3833, 12.1.0) MongoDB is optional**: `mongoose`, `@nestjs/mongoose` and `mongodb` are optional peers, loaded through `common/persistence/mongo/mongo-packages.ts`; `CoreModule` connects MongoDB only when a source uses it (`usesMongoDb()` — `mongoose` configured explicitly, `database` naming MongoDB, or `database` absent), otherwise `@InjectConnection()` receives `null` and a model token without a connection is a stand-in that refuses data access. A configured MongoDB feature without the packages fails the boot with `MongoPackagesMissingError`. `database: false` runs a service without any database: a registered model's store refuses with the reason, Better-Auth is refused at boot. Proven by `pnpm run check:without-mongodb` (dist, packages hidden) and `check:without-mongodb:package` (the tarball installed without them, publish path). **Since phase 6 (DEV-3848) MySQL 8.4 and MariaDB 11.8 sources are wired** (`mysql://`, `mariadb://` — the scheme must name the server the URL reaches): `mysql2` is an optional peer beside the engine, all three engines run on one pool the framework creates (strict session with backslash escapes, UTC), `MysqlDataStore` writes the statements, the owner grants the application account `SELECT, INSERT, UPDATE, DELETE` on the database once and the boot refuses an application account that may alter, drop, index, trigger or grant; leases, migration state (`createMigrationStore()` / `runSqlMigration()` under `GET_LOCK`), the expiry sweep, health check and Hub work there. **Neither database has row-level security**: tenant isolation is the store's, the boot warns once per source, and native / raw access on a tenant table must filter by tenant itself. **Since 12.1.1 (DEV-3904) Better-Auth runs there too**: its Kysely adapter on the source's `mysql2` pool (the promise wrapper — Better-Auth builds `MysqlDialect` from it), its migrations as the owner under `GET_LOCK`, with the database's default collation switched to `utf8mb4_bin` while it creates a table and restored (`withFrameworkDefaultCollation()`; only on such a run and with a 5 s lock wait, because `ALTER DATABASE` locks every table) — Better-Auth names no collation, and MariaDB refuses a foreign key to `users.id` across collations; the database-level grant covers its tables. 12.1.0 refused it at boot. An indexed text field is `varchar(255)` (longer: 400); a DDL run is not transactional. Still refused with a named `DataSourceConfigError`: a second MongoDB source, and an `ownerUrl` without `url` — what `NSC__DATABASE__OWNER_URL` leaves of a `database` given as a URL string, because the environment merge replaces the string with an object (write `database: { url }`, or set `NSC__DATABASE__URL` too). An update path the model does not declare is dropped on SQL as by MongoDB's strict mode (`CrudService.update()` adds `updatedBy` to every update with a current user). URLs appear in errors and logs as scheme + host only. Implementation: `common/persistence/data-source.config.ts`, `data-source.runtime.ts`, `sql/` (plan: `.claude/plans/db-agnostic/PLAN.md` §3.9) |
325
325
  | Multi-Tenancy | `multiTenancy` | Presence Implies Enabled | **`excludeSchemas` is an OFF SWITCH for isolation, per model** — a listed schema gets no tenant filter at all; since 11.35.0 the plugin WARNS once per model when the excluded schema declares `tenantId` (i.e. was built for isolation). The framework's own docs recommended `['User', 'Session']` until then; that is withdrawn — it is only right for the global-user model. Since 11.35.0 GraphQL operations over the WEBSOCKET also run inside a `RequestContext` (`CoreModule` installs a context-aware `execute`/`subscribe` pair inside `subscriptions`, where `ApolloDriver` forwards it): the tenant comes from the handshake header validated against an active membership, else from the subscriber's memberships, and an unresolvable tenant leaves the safety net to refuse the read rather than returning everything. `headerName: 'x-tenant-id'`, `membershipModel: 'TenantMember'`, `adminBypass: true`, `excludeSchemas: []`, `roleHierarchy: { member: 1, manager: 2, owner: 3 }`, `cacheTtlMs: 30000` (0 disables, process-local). System roles (`S_EVERYONE`, `S_USER`, `S_VERIFIED`) are checked as OR alternatives before real roles; method-level system roles take precedence; membership validated for context when system role grants access + header present. Hierarchy roles use level comparison, normal roles use exact match. Use `DefaultHR` or `createHierarchyRoles()` for type-safe role constants. Bypass: `RequestContext.runWithBypassTenantGuard()`. Cache invalidation: `invalidateUser(userId)` / `invalidateAll()` are **instance** methods on the singleton `CoreTenantGuard` — inject it and call `this.tenantGuard?.invalidateUser(userId)`, never `CoreTenantGuard.invalidateUser(...)` (there is no static). With `redis` configured, both additionally BROADCAST the invalidation to every replica (`<keyPrefix>:tenant-cache:invalidate` pub/sub); without Redis they clear the local process only, so other replicas stay stale until `cacheTtlMs`. A received broadcast clears locally without re-publishing. **`isTenantActive` (11.42.0)**: optional hook `(tenantId, { connection, store }) => boolean \| Promise<boolean>`, absent = every tenant active (no change). `store(modelName)` (12.x, db-agnostic) is the data store of a model registered with `CorePersistenceModule.forFeature()`, wherever `database` routes it, and THROWS for any other model — an `undefined` would read as "active" in a hook written with `?.` and open every deactivated tenant. Asked AFTER the membership check on every path that establishes a tenant: 403 on header paths and for tenant API tokens, no enrichment on `S_EVERYONE`, dropped from the header-less tenant list, no tenant for a NEW WebSocket subscription, and since 11.42.3 an OPEN one is closed with 4403 on `invalidateTenant()` (or at its next event once `graphQl.subscriptionRevalidationMs` has passed — see the GraphQL Subscription Re-check row); an admin under `adminBypass` is exempt (someone must be able to switch it back on). Only `false` deactivates. Cached for `cacheTtlMs`; `invalidateTenant(tenantId)` clears and broadcasts (`{ scope: 'tenant' }`). A function, so `config.env.ts` only — not reachable through `NSC__*`. **`adminBypass: false` (11.42.0)** additionally withholds two things that went around it: `CoreUserService.update()` refuses changing another account's e-mail or password (`assertCredentialChangeAllowed()`, administrator targets included — promote-then-change — system work without a current user exempt), and the AI tool registry grants no ADMIN bypass |
326
- | GraphQL Subscription Re-check | `graphQl.subscriptionRevalidationMs` | Numeric — `0` = STRICTEST, `false` = off, invalid → default (a fifth shape: Family C's "invalid never means unbounded", but `0` asks for MORE checking, not less) | **30000 since 11.42.3.** Before, a WebSocket was authorized once, at the handshake, and every event and operation on it ran on that snapshot until the client reconnected. Every connection a `CoreModule` `onConnect` accepts is now registered (`registerWsConnection()`) and re-checked — at once when an invalidation names it (`CoreTenantGuard.invalidateUser()` / `invalidateTenant()` / `invalidateAll()`, `CoreUserService.setRoles()` and role/verification `update()`s, the legacy password reset, and Better-Auth deleting a session through `wsSessionRevocationPlugin()`), and otherwise before the next event or operation once the last check is older than this value. The check re-runs the handshake's own authentication (session store, Better-Auth JWT signature/expiry, or for legacy tokens `CoreAuthService.verifyJwt()` — signature/expiry, verified on the WebSocket since 11.42.3 — plus the device binding) and the tenant resolution; any difference in user id, roles, verification state or tenant scope closes the socket with **4403**, which the graphql-ws client retries — the user continues with current rights or is refused at the handshake. `0` re-checks on every event and operation (one session lookup each); `false` switches only the TIME-based check off, invalidations still apply. Process-local registry: an invalidation reaches other replicas only where the tenant guard's Redis broadcast carries it; everything else is bounded by the interval. A project that replaces `onConnect` through `graphQl.driver.subscriptions` must call `registerWsConnection()` itself to keep this. Implementation: `common/helpers/graphql-ws-connection.helper.ts` |
326
+ | GraphQL Subscription Re-check | `graphQl.subscriptionRevalidationMs` | Numeric — `0` = STRICTEST, `false` = off, invalid → default (a fifth shape: Family C's "invalid never means unbounded", but `0` asks for MORE checking, not less) | **30000 since 11.42.3.** Before, a WebSocket was authorized once, at the handshake, and every event and operation on it ran on that snapshot until the client reconnected. Every connection a `CoreModule` `onConnect` accepts is now registered (`registerWsConnection()`) and re-checked — at once when an invalidation names it (`CoreTenantGuard.invalidateUser()` / `invalidateTenant()` / `invalidateAll()`, `CoreUserService.setRoles()` and role/verification `update()`s, the legacy password reset, and Better-Auth deleting a session through `wsSessionRevocationPlugin()`), and otherwise before the next event or operation once the last check is older than this value. The check re-runs the handshake's own authentication (session store, Better-Auth JWT signature/expiry, or for legacy tokens `CoreAuthService.verifyJwt()` — signature/expiry, verified on the WebSocket since 11.42.3 — plus the device binding) and the tenant resolution; any difference in user id, roles, verification state or tenant scope closes the socket with **4403**, which the graphql-ws client retries — the user continues with current rights or is refused at the handshake. `0` re-checks on every event and operation (one session lookup each); `false` switches only the TIME-based check off, invalidations still apply. Process-local registry: an invalidation reaches other replicas only where a Redis broadcast carries it — the tenant guard's (memberships, tenants) and, since 12.1.1, the Better-Auth user mapper's (`invalidateUserCache()`: roles and verification of IAM users); everything else is bounded by the interval. A project that replaces `onConnect` through `graphQl.driver.subscriptions` must call `registerWsConnection()` itself to keep this. Implementation: `common/helpers/graphql-ws-connection.helper.ts` |
327
327
  | API Tokens | `apiTokens` | Boolean Shorthand + Presence Implies Enabled — absent = off | `prefix: 'ltt'`, `scopes: []` (vocabulary; empty = no token creatable, boot warning), `manageRole`: highest hierarchy role, `maxAssertionLifetimeSeconds: 900` (invalid → default, never unbounded — Family C), `rateLimit: { max: 600, windowSeconds: 60 }` per token (**on by default**, `false` disables; Redis-shared when configured), `userTokens: true`, `tenantTokens: true` (effective only with `multiTenancy`), `encryptionKey` → `SECRETS_ENCRYPTION_KEY` → dev default (boot FAILS in production/staging). Top-level, NOT under `multiTenancy`: user tokens must work without it. **Deny by default**: every guard refuses a token on a route without `@ApiTokenScopes()`, public routes included, via ONE shared function (`enforceApiTokenRoute`) called BEFORE each guard's public-route shortcut. USER tokens act as their user with global roles REMOVED (no admin bypass); TENANT tokens act with the lowest hierarchy role in their own tenant, and a route guarded ONLY by `S_SELF` / `S_CREATOR` refuses them (a tenant token is nobody's self — skipping those roles would grant for any target). A tenant token depends on no member, so it outlives an off-boarded or deleted tenant until the project calls `deleteAllForTenant()`. The boot refuses a hierarchy in which that lowest role reaches `manageRole`. A prefixed credential that fails answers 401 on every route (not anonymous); `CoreBetterAuthMiddleware` skips prefixed credentials entirely, cookie fallback included. A password reset that ends the user's sessions ends their USER tokens too (legacy reset always; IAM reset while `betterAuth.emailAndPassword.revokeSessionsOnPasswordReset` is on), wired through `core-api-token.registry.ts` so neither reset path imports the optional module. Implementation: `src/core/modules/api-token/` |
328
+ | Offline Sync | `sync` | Boolean Shorthand + Presence Implies Enabled — absent = off | **Absent → nothing registered**: no route, no infrastructure model, no listener on the write paths. `true` / `{}` enables `GET /sync/{model}` (pull) and `POST /sync/{model}` (push) for every model whose CrudService carries `@SyncService({ model, createInput, updateInput })` and whose model is declared with `@PersistedModel({ sync: { roles, strategy } })`. Defaults: `path: 'sync'`, `pull: { defaultLimit: 200, maxLimit: 1000 (ceiling 1000), maxBytes: 1048576 }`, `push: { maxItems: 100 }`, `retention: { mutationsDays: 30, tombstonesDays: 90 }`, `hints: { debounceMs: 250 }`, `rateLimit: { pull: { max: 120, windowSeconds: 60 }, push: { max: 60, windowSeconds: 60 } }` per user (`false` switches both or one off; Redis-shared when configured). `cursorSecret` → `SECRETS_ENCRYPTION_KEY` → development default (boot FAILS in production/staging); changing it answers every cursor `410 invalid`. `pull.windowSeconds` (MongoDB only) overrides the derived window W: replica set or sharded — the server's `transactionLifetimeLimitSeconds` (60 when unreadable) + 30, standalone — 30; it must cover the longest time a write takes from its stamp until it is visible, a smaller one loses changes for good. **Operation roles are the model's** (`sync.roles.pull/create/update/delete`): the roles a project puts on its own routes never reach `/sync`, and an operation without roles is ADMIN only (boot warning). Every pull and push runs through the model's CrudService (`process()`, `securityCheck()`, tenant policy, RLS). Without `X-Tenant-Id` a request spans the caller's memberships. Cookie pushes need `Content-Type: application/json`; while cookies are in use `Sec-Fetch-Site: cross-site` and an untrusted `Origin` answer 403. API tokens are refused unless a project controller releases a scope. Error codes `LTNS_0700`–`LTNS_0799`. **Hints (12.x, DEV-3902)**: `GET /sync/hints` (Server-Sent Events, event `change`, `?models=` narrows) and the GraphQL subscription `syncHint(models)` tell a client "pull now" — payload `{ model, at }` only, never content or ids. Sent from `CrudService.create()` / `update()` / `delete()` of a synchronized model (`CoreSyncService.notifyChange()` for a write past the CrudService), at most one per model and tenant per `hints.debounceMs`; received only by a subscriber with the model's pull roles and access to the tenant. With `redis` every replica's subscribers receive them (`<keyPrefix>:sync:hints`), without it only this replica's. A stream is registered like a WebSocket (`registerWsConnection()`) and ENDS on every invalidation or re-check, the client reconnects with its current rights. `hints: false` switches both channels off, `hints.sse` / `hints.graphql: false` one; no hint is a correctness signal — a missed one is caught by the next pull. **Tombstone purge**: tombstones older than `retention.tombstonesDays` (90; `false` keeps them for good, an invalid value → 90) are hard-deleted every hour on one replica (lease: Redis → the default SQL source's `lt_locks` → `cron-locks`), after the retention horizon moved past them — a client whose cursor predates the horizon answers `410 horizon`. `purgeTombstones()` runs it by hand. **Data migrations** of a synchronized table go through `runSqlMigration()` with `{ syncUpdate: { table, set, where } }`, which raises `version` and numbers the rows like a store write. **Client duties** (delete local data on logout and account switch, no token in `localStorage`, no cached API responses): `src/core/modules/sync/SECURITY.md`; reference test clients (PWA on IndexedDB, Node on SQLite) in `tests/clients/`. Overrides: `CoreModule.forRoot(env, { sync: { controller, hintsController, resolver, service } })` — the hints route is a controller of its own, registered before the pull route, because a project controller re-declaring `pull()` (`@Get(':model')`) would otherwise read `hints` as a model name. The purge deletes in batches of 1000 by id (one `DELETE … WHERE deleted_at < ?` locks every row it scans on MySQL / MariaDB). Implementation: `src/core/modules/sync/` (README, INTEGRATION-CHECKLIST), protocol `docs/offline-sync-protocol.md` |
328
329
  | BetterAuth Tenant Skip | `betterAuth.skipTenantCheck` | Explicit Boolean | `true` (default). When `true` and no `X-Tenant-Id` header is sent, IAM endpoints (controller + resolver) skip `CoreTenantGuard` tenant validation. When header IS present, normal membership validation runs regardless. Set `false` for tenant-aware auth scenarios (subdomain-based, invite links, SSO per tenant) |
329
330
  | Debug Process Input | `debugProcessInput` | Explicit Boolean | `false` (default). When `true`, logs a debug message when `prepareInput()` changes the input type during `process()`. Has performance cost due to `JSON.stringify` on every `process()` call — enable only for debugging |
330
331
  | JSONTransport Production Guard | `email.smtp` with `jsonTransport` | Runtime Guard | Throws `Error` when `email.smtp` has a truthy `jsonTransport` property in `production` or `staging` environments (read from config `env` field). JSONTransport silently discards all outgoing mail — the guard prevents accidental misconfiguration that causes password-reset, 2FA, and verification emails to vanish. Use `{ jsonTransport: true }` only in CI/e2e/local environments |
331
332
  | Cookies | `cookies` | Boolean Shorthand (default true) | `true` (enabled), `exposeTokenInBody: false`. When enabled: loads `cookie-parser`, sets CORS `credentials: true`, sets signed httpOnly session cookies. When `exposeTokenInBody: true`: a token stays in the response body alongside the cookies — but **the two carry DIFFERENT values** once the JWT plugin is also active. The body gets a JWT (what a bearer client needs), the cookie keeps the **opaque session token**, because Better-Auth resolves a session by exactly that value. Reading this row as "the same token in two places" is what produced 11.36.3: the cookie was written from the body's JWT, sign-in succeeded and every following request was anonymous. `setSessionCookies()` now refuses a JWT-shaped cookie value outright. Note `assertCookiesProductionSafe()` forbids `exposeTokenInBody` in `production`/`staging`, so this combination exists only in development and CI. JWT via `Authorization: Bearer` always works independently. **BetterAuth cookie name (since v11.27.6):** `createBetterAuthInstance()` pins `advanced.useSecureCookies: false` so BetterAuth's native handlers read the same UNPREFIXED `<cookiePrefix>.session_token` the helper writes (fixes a `401` split-brain on 2FA/passkey/`/token`); the `Secure` attribute is still applied on an `https://` baseURL via `advanced.defaultCookieAttributes`. **Since 11.42.12 the framework's own cookie helpers follow the same rule**: `BetterAuthCookieHelper` reads it off the instance (`CoreBetterAuthService.usesSecureCookies()`, passed as `secureTransport` by the controller and the API middleware) and the legacy helper from an `https://` `baseUrl`; `production` / `staging` keep `Secure` regardless. Before, both followed the environment NAME alone, so the deployed `develop` / `test` stages of a starter-based project wrote the session cookie without `Secure` from the NestJS sign-in path while Better-Auth's handlers set it. Call sites are pinned by `tests/unit/auth-cookie-secure-wiring.spec.ts`. Opt back into the `__Secure-` prefix with `betterAuth.options.advanced.useSecureCookies: true` only when BetterAuth manages cookies entirely |
332
333
  | CORS | `cors` | Boolean Shorthand | `enabled: true`, `allowAll: false`, `deriveAppUrl: true`. Origins come from `appUrl`/`baseUrl`, resolved by the shared `resolveServerUrls()` helper (`cookies.helper.ts`) that ALL three CORS layers use (GraphQL, REST, BetterAuth `trustedOrigins`) — they can no longer drift. `appUrl` resolution: explicit → derived from a **host-split** localhost `baseUrl` (its `api.` label strips to a sibling host: `https://api.crm.localhost` → `https://crm.localhost`, as served by `lt dev up`; the port is preserved) → localhost default (`http://localhost:3001`, only for `env: local`/`ci`/`e2e` with a **port-split** localhost `baseUrl` — one host, API `:3000`, app `:3001`; `https://api.localhost` strips to the bare `localhost` the API already answers on and is therefore a port split, not a host split) → derived from `baseUrl` by stripping a leading `api.` label (`https://api.example.com` → `https://example.com`). **Security:** the derived origin receives credentialed CORS; set `deriveAppUrl: false` when the apex domain is not trusted, then list the frontend origin via `appUrl`/`allowedOrigins` (a host-split localhost `baseUrl` then falls back to the localhost default). The derivation never yields a bare TLD (`https://api.dev` unchanged) and never emits the opaque `null` origin (non-http(s) `baseUrl` passes through verbatim). `allowAll: true` mirrors the request origin for REST/GraphQL, but BetterAuth's `trustedOrigins` still resolve to `[appUrl]` (+ passkey origins) — an origin check has no "allow everything" mode, so a separately hosted frontend must appear in `appUrl`/`allowedOrigins` (or set `betterAuth.trustedOrigins` explicitly). `enabled: false` disables CORS on all layers including BetterAuth (`trustedOrigins: []`, which still trusts BetterAuth's own `baseURL`). Explicit `betterAuth.trustedOrigins` always takes precedence |
333
- | Central Redis | `redis` | Boolean Shorthand + Presence Implies Enabled | `host: 'localhost'`, `port: 6379`, `db: 0`, `keyPrefix: <package.json name, slugified>` (per APPLICATION, not per framework — a constant default silently collides when two apps share one Redis; set it explicitly when sharing IS intended); `url` (takes precedence over host/port/db/credentials), `username`/`password`, `options` (passed to the ioredis constructor). `true` / `{}` enables with defaults; `{ enabled: false }` pre-configures without enabling; **absent = every consumer keeps its process-local fallback** (that fallback is the whole backward-compatibility story of 11.33.0). Requires the OPTIONAL peer `ioredis` — configured-but-missing **fails the boot** with a named error rather than crashing on first use. `keyPrefix` is applied by the framework per key, NOT as ioredis `keyPrefix` (that would collide with BullMQ's own prefix). One `CoreRedisService` serves all features: shared client (`getClient()`), one cached subscriber (`getSubscriber()` — a subscribing client cannot run commands), dedicated connections (`createClient(label)`); all are tracked and quit on shutdown. Switches on automatically: exact cross-replica rate limits via `RedisRateLimitStore` (see the Legacy Auth / BetterAuth / AI rate-limit rows), cron dedup (see Cron Job Deduplication row), `CoreRedisPubSub` as `PUB_SUB` for cluster-wide GraphQL subscriptions (**payloads must be JSON-serializable — `Date`, class instances, `Map`/`Set`, `undefined` do not survive**), tenant-cache invalidation broadcast, Hub collector mirroring, MCP session registry (turns a wrong-replica request from a misleading `404` into a `409` — `/ai/mcp` still REQUIRES sticky sessions, sessions are not portable) |
334
+ | Central Redis | `redis` | Boolean Shorthand + Presence Implies Enabled | `host: 'localhost'`, `port: 6379`, `db: 0`, `keyPrefix: <package.json name, slugified>` (per APPLICATION, not per framework — a constant default silently collides when two apps share one Redis; set it explicitly when sharing IS intended); `url` (takes precedence over host/port/db/credentials), `username`/`password`, `options` (passed to the ioredis constructor). `true` / `{}` enables with defaults; `{ enabled: false }` pre-configures without enabling; **absent = every consumer keeps its process-local fallback** (that fallback is the whole backward-compatibility story of 11.33.0). Requires the OPTIONAL peer `ioredis` — configured-but-missing **fails the boot** with a named error rather than crashing on first use. `keyPrefix` is applied by the framework per key, NOT as ioredis `keyPrefix` (that would collide with BullMQ's own prefix). One `CoreRedisService` serves all features: shared client (`getClient()`), one cached subscriber (`getSubscriber()` — a subscribing client cannot run commands), dedicated connections (`createClient(label)`); all are tracked and quit on shutdown. Switches on automatically: exact cross-replica rate limits via `RedisRateLimitStore` (see the Legacy Auth / BetterAuth / AI rate-limit rows), cron dedup (see Cron Job Deduplication row), `CoreRedisPubSub` as `PUB_SUB` for cluster-wide GraphQL subscriptions (**payloads must be JSON-serializable — `Date`, class instances, `Map`/`Set`, `undefined` do not survive**), tenant-cache invalidation broadcast, the Better-Auth user-cache invalidation broadcast (12.1.1+: `CoreBetterAuthUserMapper.invalidateUserCache()` clears the 15 s roles/verification cache on every replica and re-checks the user's open WebSockets there; before, a withdrawn role kept working on the other replicas until the entry expired), Hub collector mirroring, MCP session registry (turns a wrong-replica request from a misleading `404` into a `409` — `/ai/mcp` still REQUIRES sticky sessions, sessions are not portable). **Outages (12.1.1+)**: `enabled` only says Redis is configured; `isReady()` says whether the shared connection is up. `onModuleInit` pings once (bounded, 3 s) and WARNS when Redis does not answer, then boots anyway — failing the boot would take the fleet down with Redis. Rate limits, cron leases, the MCP session registry and the tenant broadcast fall back to process-local behaviour; GraphQL subscriptions and TUS upload locks have NO fallback (new subscriptions fail, published events are dropped with a warning, resumable uploads fail until Redis answers). Channels on the shared subscriber go through `subscribeChannel()` / `releaseChannel()` (counted per holder) and are subscribed again on every `ready` of the subscriber connection: ioredis re-subscribes only what once succeeded, so a subscribe that failed at boot (the tenant-cache invalidation, before 12.1.1 logged on debug) used to leave that replica deaf for its whole life |
334
335
  | File Access Roles | `file.downloadRoles`, `file.uploadRoles`, `file.deleteRoles` | Config-Driven Role List | `[RoleEnum.ADMIN]` each. **These are the only breaking change a single-replica project gets in 11.33.0** — six members moved from `@Roles(S_EVERYONE)` to these knobs: `GET /files/id/:id`, `GET /files/:filename` and `getFileInfo` (`downloadRoles`), `uploadFile` / `uploadFiles` (`uploadRoles`), `deleteFile` (`deleteRoles`). Plain role STRINGS, not `RoleEnum` members, so project roles work (`['company-admin', 'editor']`). Applied at boot by `applyFileRoles()` via `Reflect.defineMetadata('roles', …)` on the base-class methods — the same runtime mechanism `CorePermissionsModule` uses, because the value is only known from config. **`[]`, a non-array, or an array holding a non-string is REJECTED with a warning and the default applies** — an all-empty role set reads to the guards as "no roles required" and would OPEN the route, the exact opposite of the intent. **ADMIN is always unioned in**: both `CoreFileController` and `CoreFileResolver` carry a class-level `@Roles(RoleEnum.ADMIN)` and the guards UNION class + handler metadata, so these knobs can grant but never exclude admins. Both classes also carry `@SkipTenantCheck()` — GridFS and the S3 metadata collection are reached outside Mongoose, so `mongooseTenantPlugin` never scopes them and a role name alone cannot express a per-tenant rule; roles resolve against `user.roles`, never `membership.role`. **A subclass that OVERRIDES a member opts out permanently** (decorator metadata lives on the function object, and an override is a different function) — inherit the member instead. **Since 11.35.1 that opt-out is REPORTED**: `CoreFileAccessAuditInitializer` reads the roles off the class the project actually registered at `onApplicationBootstrap` (the earliest point at which the route table exists, which is why it is a provider and not part of `applyFileRoles()`) and warns when a member is open beyond ADMIN for a reason the configuration cannot explain — the case the construction-time warning below is structurally blind to. It only REPORTS: overwriting an override's metadata, the way `TusModule.applyRoles()` does, would silently relax a route a project pinned on purpose. Roles are the coarse filter only; per-file rules belong in `CoreFileService.checkRights()`, which now receives `currentUser` and can read raw metadata via `getRawFileInfo()`. **Since 11.35.0 a reused FILENAME resolves to the most recent file** in all three stores (`uploadDate` desc, `_id` tie-break) and every by-name read path resolves a document and then reads by ID — under GridFS the by-name metadata lookup answered the oldest document while `openDownloadStreamByName()` served the newest, so a per-file rule approved one file and the bytes of another came back. Consequence: `duplicateById()` keeps the source's filename and the copy carries no metadata, so the copy now wins the name and an `ownerId` rule refuses it — give the copy its own metadata or its own name. **Since 11.35.0 `CoreFileService` WARNS at construction** when `multiTenancy` is active AND a knob names a role other than ADMIN AND `checkRights()` is not overridden — the file stores are reached outside Mongoose, so these role names resolve against `user.roles` (global) and every holder reaches every tenant's files; only `checkRights()` can narrow that. Silent on the admin-only default and silent when a per-file rule exists. Implementation: `src/core/modules/file/file-roles.config.ts` (`FILE_ROLE_DEFAULTS`, `FILE_ROLE_MEMBERS`, the three warnings, the shared `hasDeclaredFilePolicy()` silencer) + `file-roles.helper.ts` (`applyFileRoles`) + `core-file-access-audit.initializer.ts` (the registered-class audit) |
335
336
  | TUS Roles | `tus.roles` | Config-Driven Role List | `[RoleEnum.S_USER]` (was `S_EVERYONE` — breaking). A TUS upload writes into the SAME store the download routes guard, and the termination extension (on by default) can delete from it, so anonymous writes into a store only privileged callers may read is the wrong way round. Applied by `TusModule.applyRoles()` onto the registered controller class **and** onto `handleTus` / `handleTusWithId`. Same rejection rule as the file roles: `[]` / non-array / non-string → warning + `DEFAULT_TUS_CONFIG.roles`. **`OPTIONS` is deliberately exempt**: `handleTusOptions` / `handleTusOptionsWithId` keep their own handler-level `@Roles(RoleEnum.S_EVERYONE)`, because that is the CORS preflight — browsers send it WITHOUT credentials, and it returns server capabilities only. Gating it would make every browser upload fail before the first byte. `CoreTusController` also carries `@SkipTenantCheck()`. A custom controller is covered as long as it INHERITS the handlers; one that re-declares `@All()` / `@Roles()` carries its own metadata and thereby opts out — the documented way to hard-code a policy config must not be able to change. **Set `roles: [RoleEnum.S_EVERYONE]` explicitly if you accept attachments on a public form.** **Since 11.35.0 `tus.roles` is no longer the only gate**: `onUploadCreate` records the authenticated creator under `TUS_OWNER_METADATA_KEY` (`ltOwnerId`, always OVERWRITING any client-supplied value) and `onIncomingRequest` refuses HEAD/PATCH/DELETE naming an upload the caller does not own — with **404**, the file module's refusal policy. Before that, any authenticated caller who learned an upload id could APPEND BYTES to somebody else's upload, which are then migrated into the file store under the victim's filename. The finished file also gains `metadata.ownerId`, without which the documented per-file ownership rule could never authorize a tus-uploaded file (admin-only in practice). An OWNER-LESS upload stays reachable — uploads predating 11.35.0 have none, and neither does a deliberately public form. `readRequestUserId()` / `assertUploadOwnership()` are `protected`. **Since 11.41.9 the tenant is recorded too, but ONLY with `multiTenancy` configured** (not `enabled: false`) and a tenant guard registered: `onUploadCreate` validates the tenant header against the caller's active memberships through `core-tenant-context.registry.ts` (the routes carry `@SkipTenantCheck()`, so the guard did not), refuses a non-member with **403**, writes it under `TUS_TENANT_METADATA_KEY` (`ltTenantId`, always overwritten like the owner key) and the finished file gains `metadata.tenantId`. No header → tenant-less, as before; without `multiTenancy` nothing is read or written. A project hooks in through `TusModule.forRoot({ service })` (a `CoreTusService` subclass, constructed by the module as `(connection, options)` — project providers come from `options.moduleRef` at call time) and the `protected onUploadCreate(req, upload)`, the one point where `Upload-Length` is known and nothing is stored yet; an override must return `super.onUploadCreate()`. Note that `@tus/server` v2 hands its hooks a WHATWG `ServerRequest`, so a guard's `req.user` is reachable only via `runtime.node.req`. **CORS (11.42.1)**: the tus handler's `Access-Control-Allow-Origin` comes from `buildCorsConfig()` (or `tus.allowedOrigins`), plus `allowedCredentials` — without it `@tus/server` answered `*`, which browsers refuse on a credentialed request, so cookie-authenticated uploads across split app/API origins failed in every browser while the server answered 201; no credentialed CORS configured → the `*` default stays. **compression (11.42.2)**: `@tus/server` finishes responses with `res.end(callback)`, which `compression`'s two-argument `res.end(chunk, encoding)` patch reads as a chunk (`ERR_INVALID_ARG_TYPE … Received function`); with the starter's `filter: () => true, threshold: 0` every finished upload HUNG without an answer AFTER the file was stored (headers already sent, so no error response could follow). `CoreTusService` wraps `server.handle()` and normalises `res.end` per response (`normalizeEndCallback()`, callback on `finish`/`close`), so no `main.ts` change is needed. Implementation: `src/core/modules/tus/tus.module.ts`, `src/core/modules/tus/core-tus.service.ts`, `src/core/modules/tus/tus.constants.ts` |
336
337
  | File Storage | `file.storage` + `file.storageDir` + `s3` | Explicit Enum with DERIVED default | `'filesystem' \| 'gridfs' \| 's3'`. **Unset → derived**, most capable first: `'s3'` when `s3.bucket` is set, else `'filesystem'` with `database: false` or when `database` routes the file metadata to a SQL source (`fileMetadataSqlSource()`; GridFS would put bytes and metadata into MongoDB — an explicit `'gridfs'` keeps working), else `'gridfs'` when `mongoose.uri` is set, else `'filesystem'`. **Set → enforced**: an unavailable store FAILS THE BOOT (`assertFileStorageAvailable()`), it never falls back — a silent fallback puts files in a store the operator does not believe they are in, unrecoverably. A DERIVED driver is enforced too (`s3.bucket` set but `s3Service` not forwarded to `super()` → boot error). Metadata stays queryable whichever driver holds the bytes (`fs.files` / `s3-files` / `filesystem-files`, the latter two as the tables `s3_files` / `filesystem_files` when `database` routes them to a SQL source) — `findFileInfo()` and `checkRights()` need it. Reads consult ALL stores so switching drivers is forward-only with no migration; writes go to the active driver only. `'filesystem'` is pod-local: not shared between replicas, lost on restart unless `storageDir` is a mounted volume. `s3`: `bucket` (required — the one thing S3 cannot default, hence the eligibility test), `region: 'us-east-1'`, `forcePathStyle: false`, `autoCreateBucket: false` (off by design: production buckets come from infrastructure code, whose credentials usually cannot CreateBucket), `stagingBucket: bucket`, `presignedDownloads: false` (`true`/`{}` → `expiresInSeconds: 300`), `endpoint` (MinIO/RustFS), `accessKeyId`/`secretAccessKey` (omit → AWS default credential chain), `enabled: false` to pre-configure. Requires the OPTIONAL peer `@aws-sdk/client-s3`, plus `@aws-sdk/s3-request-presigner` for presigned downloads. **Uploads are never buffered on the S3 path** (11.41.7): a known length (`FileUploadSource.size`, TUS, a buffer) goes up as one streamed PutObject up to `CoreS3Service.MAX_SINGLE_PUT_BYTES` (5 GiB — AWS S3 refuses more in one request, `EntityTooLarge`) and as a multipart upload above it, an unknown one — every GraphQL and streamed REST upload — as a multipart upload via the OPTIONAL peer `@aws-sdk/lib-storage`. Missing peer → one boot warning and the old in-memory buffering, NOT a boot failure (multipart optimises a path that works without it). `size` must be the file's EXACT length — never a multipart request's `Content-Length`; anything but a positive integer counts as unknown (`isKnownUploadSize()`), and the recorded length of an unknown-length upload comes from a HEAD of the stored object. `presignedDownloads` makes `GET /files/id/:id` answer `302` to a time-limited S3 URL instead of streaming — the URL is a bearer capability, authorized once at issue time. The resolved driver is logged at boot (`[CoreFileStorage] File storage: …`) |
337
338
  | TUS S3 Staging | `tus.s3Staging` | Explicit Boolean (default ON when S3 configured) | `true` when `s3` is configured, otherwise inert. Stages in-progress uploads in `s3.stagingBucket` via `@tus/s3-store` instead of `tus.uploadDir` on local disk, so resumable uploads survive replica restarts and need no sticky sessions. Set `false` to force local disk. Missing OPTIONAL peer `@tus/s3-store` → warning + fall back to local disk (NOT a boot failure). **Give the staging bucket a lifecycle rule expiring incomplete multipart uploads** — the framework's own expiration cleanup is skipped in S3 mode (S3 is the right place for that policy), so aborted uploads otherwise accumulate parts nothing removes |
338
339
  | TUS Configuration Source, Path & Extensions | `tus` (server config) / `TusModule.forRoot({ config })` | Enabled by Default + explicit-config-wins | **Since 11.42.9 `TusModule.forRoot()` without `config` reads the server config's `tus` key** (`ConfigService.get('tus')`, the zero-config rule `CoreBetterAuthModule.forRoot()` follows); an explicit `config` wins. Before, the key was never read: `tus: false`, `allowedTypes`, `maxSize`, `roles` in `config.env.ts` did nothing — real projects had upload restrictions they believed were enforced. **`path`** has three readings (`applyPath()`): unset → follows the controller's route; a new route (`/uploads`) → mounts the registered controller there (writes `PATH_METADATA`, config wins like the roles; the declared route is remembered per class and restored by a later `forRoot()` without `path`); a path ENDING with the controller's route (`/api/tus`, `/files/tus`) → the PUBLIC URL: the controller stays and upload URLs use it. The third reading is load-bearing: it was the only working setting under a global prefix or a prefix-stripping proxy before 11.42.9, and treating it as a route doubled the prefix (`/api/api/tus`, 404 on every upload — caught in the second pre-release review). The upload URL (`Location`) is built from the path the creation request arrived at (`uploadCollectionPath(req)`), or from `path` when that is longer and ends with it (proxy strip); `@tus/server` built it from `path` alone, which broke every resume below a global prefix, a URI version or a project route. **`relativeLocation`** (new): the framework owns `generateUrl`, which takes precedence over `@tus/server`'s option of that name, so it is honoured from `tus.relativeLocation` AND from the tus server's own options (a vendored core may pass it there). **Extensions**: `@tus/server` consults no flag, so `creation`, `creationWithUpload` and `termination` are enforced by `CoreTusService` — the store's advertised `extensions` are narrowed, and `assertExtensionEnabled()` answers 501 inside the server's `onIncomingRequest` / `onUploadCreate` options (not in the overridable hook, so an override without `super` cannot reopen it). `checksum` / `concatenation` have no effect: neither built-in store supports them. `configure(false)` now yields a DISABLED config (before it kept the defaults and started a tus server behind a module with no endpoint). Implementation: `tus.module.ts`, `core-tus.service.ts` |
339
340
  | Health Check | `healthCheck` | Presence Implies Enabled | Absent → no endpoints. Any object registers `GET /health-check` + the `healthCheck` query; **`enabled: false` leaves them out (honoured since 11.42.9 — before, it was declared and ignored)**. Individual checks under `configs.*` each have their own `enabled`. Implementation: `core.module.ts` |
340
- | Cron Job Deduplication | per job: `distributed` in `CronJobConfig` | Explicit Boolean | **`true` when `redis` is configured, otherwise `false`** — a single-replica project that upgrades must not silently gain a `cron-locks` collection, a lease write per tick, and a new way for a tick to be skipped. A Redis-less multi-replica fleet opts in per job with `distributed: true` (MongoDB lease). Mechanism: BullMQ job scheduler when Redis + the OPTIONAL peer `bullmq` are present AND `cronTime` is a string without `utcOffset`; otherwise local timer + lease (Redis `SET NX`, else a TTL-indexed `cron-locks` document). Tick lease TTL 3600 s. **Leases fail open** — an unreachable lease store runs the tick everywhere rather than stopping all scheduled work fleet-wide. **`runOnInit` (default `true`) deduplicates over a FIXED per-job key with a 300 s TTL**, because replicas do not share a boot instant: replicas booting within 5 min run the startup tick once between them, and a replica restarting inside that window SKIPS its startup tick — set `distributed: false` on jobs whose `runOnInit` work is per-process (warming a process-local cache). No constructor change needed: `CoreModule` fills `core-cron-jobs.registry.ts` via `CoreCronJobsInitializer` and `CoreCronJobs` reads it lazily; explicit `{ connection, redisService }` in `CoreCronJobsOptions` wins. With neither source it warns once and every replica runs every tick |
341
+ | Cron Job Deduplication | per job: `distributed` in `CronJobConfig` | Explicit Boolean | **`true` when `redis` is configured, otherwise `false`** — a single-replica project that upgrades must not silently gain a `cron-locks` collection, a lease write per tick, and a new way for a tick to be skipped. A Redis-less multi-replica fleet opts in per job with `distributed: true` (lease in the SQL lease table of the default source, else in MongoDB). Mechanism: BullMQ job scheduler when Redis + the OPTIONAL peer `bullmq` are present AND `cronTime` is a string without `utcOffset`; otherwise local timer + lease (Redis `SET NX`, else the SQL lease table of the default source (`lt_locks`), else a TTL-indexed `cron-locks` document). Tick lease TTL 3600 s. **Leases fail open** — an unreachable lease store runs the tick everywhere rather than stopping all scheduled work fleet-wide. **`runOnInit` (default `true`) deduplicates over a FIXED per-job key with a 300 s TTL**, because replicas do not share a boot instant: replicas booting within 5 min run the startup tick once between them, and a replica restarting inside that window SKIPS its startup tick — set `distributed: false` on jobs whose `runOnInit` work is per-process (warming a process-local cache). No constructor change needed: `CoreModule` fills `core-cron-jobs.registry.ts` via `CoreCronJobsInitializer` and `CoreCronJobs` reads it lazily; explicit `{ connection, redisService }` in `CoreCronJobsOptions` wins. With neither source it warns once and every replica runs every tick. **A BullMQ schedule lost in Redis comes back (12.1.1+)**: `upsertJobScheduler` writes it once at bootstrap, and a Redis restart without persistence, a FLUSHDB or an eviction removed it with nothing re-registering it, so no replica ran the job until the next deploy. `restoreBullSchedulers()` now runs after every `ready` of the queue connection and every `bullSchedulerCheckIntervalMs` (protected, 60000; `0` switches only the timer off) and registers the schedules the queue's listing (`getJobSchedulers()`) no longer contains, with a warning — the listing, because `getJobScheduler()` does not exist before bullmq 5.23 and answers a deleted scheduler with an object up to 5.40.2, both inside the peer range. Only MISSING ones: an upsert of an existing scheduler removes its last planned job even when it is due and waits for a free worker, so a blind periodic upsert would drop that tick. A scheduler `removeStaleBullSchedulers()` removed on purpose carries a `cron-scheduler-removed:<job>` key, so a replica of the previous version still running in a rolling deploy does not bring it back (a job moved to a local timer would otherwise run twice per tick); the next BullMQ registration of that job deletes the key, and a real data loss takes it along. Only the schedule comes back (no second `runOnInit`, no second registry entry); a tick that fell due while it was missing is not run. **Since 12.1.1 (DEV-3896)**: (1) a timer job's tick lease is keyed on the tick's SCHEDULED instant, captured from `nextDate()` when the job is armed and after each scheduled fire, and moved forward through the schedule to the last instant at or before the fire when the job did not fire for a while (stopped and started in the Hub; not for a `utcOffset` job) — cron 4 sets `lastDate()` to the moment its timer fired, so two replicas firing on either side of a second boundary computed different keys and both ran the tick. A fire that does not advance `lastDate()` is a manual run (the Hub's "Run now") with a key of its own, also on a job stopped in the Hub. (2) `runParallel: false` (or `1`) holds across replicas for a distributed job, in timer and BullMQ mode: the run holds a `<job>:running` lease (60 s, renewed every 20 s, released in `finally`; Redis — owner-checked renew and release —, else the SQL lease table, else `cron-locks`), and a replica that finds it held skips its tick; fail-open like the tick lease. A run that never ends therefore holds the job on EVERY replica while its process lives, not only on its own. A larger `runParallel` stays per process. (3) A failed TTL index on `cron-locks` is tried again on the next tick instead of being cached as done. (4) Without `redis`, startup logs ONE info line naming the jobs that run on every replica because they did not set `distributed` (silent with `log: false`, and for jobs that set `distributed: false`). (5) BullMQ scheduler jobs carry `bullJobOptions` (protected): `attempts: 1` — a failed tick is not repeated, as with the local timer —, `removeOnComplete: { count: 100 }`, `removeOnFail: { count: 500 }`; before, every completed and failed tick stayed in Redis for good. Existing schedulers get them at the next bootstrap, which upserts every scheduler — after `trimBullJobBacklog()` has removed the backlog an earlier version kept, oldest first, 1000 jobs per `clean()` call: BullMQ trims by `count` in one Lua call without a limit, and the first tick under the new options would otherwise delete the whole backlog at once (measured 2 s for 300k jobs, 4.4 s for 700k, with Redis answering nobody) |
341
342
  | Shutdown Delay | `shutdownDelayMs` | Numeric Sentinel — Family B (`0` = off) | `0` (default, no delay, no log). Waits N ms **in the SIGTERM/SIGINT handler, before `close()` is entered**, so a load balancer can finish deregistering while the instance is still fully healthy. NOT a lifecycle hook: `close()` runs `onModuleDestroy` → `beforeApplicationShutdown` → dispose → `onApplicationShutdown`, so a delay in `beforeApplicationShutdown` would wait with every module already torn down while the socket still accepts — worse than no delay. **Requires `installGracefulShutdown(app)` in main.ts, which REPLACES `server.enableShutdownHooks()`** — keeping both makes Nest close the app in parallel with the wait, so the delay silently never happens. At delay `0` the helper IS `enableShutdownHooks()`. **Keep the value well below the orchestrator grace period AND leave room for the drain that follows**: Compose `stop_grace_period` 10s, Kubernetes `terminationGracePeriodSeconds` 30s, and `installProcessDiagnostics()` force-exits after 30s — exceed any and the process is SIGKILLed mid-wait with no hook running. Warns above `10000`, capped at `60000`. Non-numeric / negative values behave like `0` |
342
343
  | Body Parser Limits | `bodyParser.json.limit`, `bodyParser.urlencoded.limit` | Explicit Value (Express/body-parser default) — invalid → default, never unbounded | **Unset = 100 kB each, exactly as NestJS registers the parsers** (body-parser's own default — a limit nobody chose, and before 11.42.4 the only one in force: over it a request got a bare `request entity too large`, before auth and routing, GraphQL included). A number is bytes, a string a 1024-based size (`'500kb'`, `'2mb'`, units `b`/`kb`/`mb`/`gb`). The two limits are separate on purpose — raising JSON must not raise form posts. **Not raised by default because the parser runs before authentication** and `JSON.parse` blocks the event loop for the whole body: ~0.5 ms for 100 kB, ~16 ms for 1 MB, ~55 ms for 2 MB per request on a realistic document shape. Applied by `CoreBodyParserInitializer` (CoreModule provider) in `onModuleInit` by **swapping the handle of the global `jsonParser`/`urlencodedParser` layers NestJS registered, in place** — NestJS registers them inside `app.init()` before any init hook, and a parser appended later would sit behind the routes. Same position, `extended: true` for URL-encoded, `req.rawBody` kept when the app came from `NestFactory.create(…, { rawBody: true })` (read from the container's context options; under `Test.createTestingModule()` those options never reach the container, so a test app with `rawBody: true` gets no `req.rawBody` from a CONFIGURED parser). Because it runs during module init it **holds under `Test.createTestingModule()`**, which never runs `main.ts` — the reason a `useBodyParser()` in `main.ts` was not enough. A configured value also replaces a global parser registered in `main.ts`; a **path-scoped** one (`server.use('/upload', json({ limit }))`) is left alone and stays the way to give one path its own limit (the parser runs before routing; GraphQL shares one path). **Keep `server.useBodyParser('json')` in `main.ts` next to a path-scoped parser**: NestJS registers its global parser only when no layer with the same FUNCTION NAME exists (`isMiddlewareApplied()`), so a path-scoped `json()` registered before `app.init()` suppresses it and every other path gets an empty body (a sign-in fails with a validation error). Since 11.42.6 the initializer warns at boot about exactly that (`warnMissingGlobalParser()`). It deliberately does not re-insert the parser: NestJS would have placed it after everything `main.ts` registered, a position no longer knowable during module init, and a raw-body webhook or proxy registered after the path-scoped parser must keep seeing the unread stream. An invalid value (`0`, negative, `NaN`, a word) is refused with an error log and the parser keeps its limit. **Independent of configuration**, every 413 from a global parser is answered as `PayloadTooLargeException` with `#LTNS_0304: Request body too large [<n> bytes, limit <m> bytes]` (size only when a `Content-Length` was sent) and logged as a WARN naming method, path without query string, and the config key — no boot warning, because the framework cannot know which routes expect large bodies; the first oversized request is the earliest point anything can be said. Other parser errors pass through unchanged. Pinned by `tests/unit/body-parser.spec.ts` and `tests/body-parser.e2e-spec.ts` (mutations `body-parser-*`). Implementation: `common/services/core-body-parser.initializer.ts` |
343
344
  | Trust Proxy | `trustProxy` | Explicit Value (pass-through, Express default) | `false` (Express's own default — the forwarded chain is not trusted). Passed verbatim to `app.set('trust proxy', …)` by `CoreTrustProxyInitializer`, a `CoreModule` provider, so a consumer inherits it by upgrading and needs no `main.ts` edit. Accepts `false` / a hop count (`1`, `2`) / `'loopback'` / a subnet list — **not** Express's predicate function: the value must survive `NEST_SERVER_CONFIG` / `NSC__*` (JSON) and the ConfigService deep clone. **This is what makes `request.ip` correct, and every IP-keyed rate limit depends on it**: unset behind Caddy/nginx/an ingress, `req.ip` is the PROXY address for every request, so all clients share ONE bucket and `auth.rateLimit.max` throttles everybody at once — exactly fleet-wide once `redis` is configured. Trusting MORE hops than exist is the opposite failure: a client prepends its own header entry and picks a fresh bucket per request. Applied at module init (inside `app.init()`/`listen()`, i.e. AFTER `main.ts`), so a configured value wins over a hand-written `app.set()`; an UNSET value is never applied, which keeps `app.set('trust proxy', fn)` in `main.ts` available as the escape hatch for the predicate form. **Unset + an IP-keyed limiter enabled (`auth.rateLimit` / `betterAuth.rateLimit`) logs a boot warning naming the shared-bucket consequence**; `trustProxy: false` is the explicit "nothing proxies me" answer that silences it. The AI limiter keys on the user id and is unaffected |
@@ -401,6 +401,13 @@ any has moved:
401
401
  second version of the same consumer is a path nobody assessed, and comparing by name alone let
402
402
  one ride along on the other's declaration, with the winner decided by lockfile order.
403
403
 
404
+ **Several consumers (12.1.1+)** are joined with ` + `, each at its assessed version —
405
+ `'@nestjs/cli@12.0.11 + nodemon@3.1.14 cannot use 11.0.1'`. One vulnerable package often sits under
406
+ several dev tools at once, and before this a declaration could name only one, so such a finding had no
407
+ honest way in. Every listed consumer gets checks 1 and 3, and one more: a listed consumer that no
408
+ longer pulls an affected package fails too (mutation `unusable-fix-accepts-idle-consumer`), because a
409
+ list is where a path that is gone would otherwise keep covering.
410
+
404
411
  The third check reads **both halves** of the lockfile. `importers:` holds what the repository itself
405
412
  declares (`.`, `projects/api`, `projects/app`), `snapshots:` the edges between third-party packages
406
413
  — and the first is the likelier production path, because a direct dependency can simply be raised.
@@ -430,7 +437,15 @@ plus one for the opposite failure, a guard that rejects a declaration which is s
430
437
  matters just as much: a check that cries wolf on correct entries is a check that gets switched off,
431
438
  which is how the hole this feature closes came about in the first place.
432
439
 
433
- **No current entry (since 2026-10-24).** `GHSA-vfj7-8cjw-p6xm` (braces, via nodemon / npm-watch) was
440
+ **Current entries (since 2026-10-31, owner decision, DEV-3899).** `GHSA-g3hv-75wg-m249` (minimatch
441
+ `< 11.0.1`) and `GHSA-3g3m-2fjr-6pw4` (glob `<= 14.0.1`), both moderate, both fixed only in a new major
442
+ released 2026-10-27 that none of their consumers can take yet — five for minimatch (`@nestjs/cli` pins
443
+ 10.2.6 exactly), rimraf for glob. The justification is in the comment above `ignoreGhsas`, the
444
+ declarations in `unusableFixConsumers`. `tests/unit/audit-suppression-scope.spec.ts` keeps glob out of
445
+ the production tree and allows minimatch there only under `@ts-morph/common` (ts-morph, the Permissions
446
+ module's scanner, matching globs the developer writes) — its `prodVia` form.
447
+
448
+ **Before that, no entry from 2026-10-24.** `GHSA-vfj7-8cjw-p6xm` (braces, via nodemon / npm-watch) was
434
449
  listed from 2026-10-11 to 2026-10-24: a dev-tooling-only path of an advisory with no patched release
435
450
  at all. GitHub withdrew the advisory on 2026-10-23, and `check:overrides` reported the entry as
436
451
  obsolete on the next run — the guard doing exactly its job, since a withdrawal changes nothing in
@@ -91,6 +91,32 @@ On every SQL database a path segment `__proto__`, `constructor` or `prototype` i
91
91
  object from path segments would otherwise reach `Object.prototype` — on MySQL that switched tenant isolation off for the
92
92
  whole process. Never build an update path from request data without validating it against the model.
93
93
 
94
+ ## Synchronized models (offline sync, 12.x)
95
+
96
+ `@PersistedModel({ sync: true | { idFormat, scope } })` makes a model synchronizable (`common/persistence/sync-model.ts`,
97
+ plan `.claude/plans/offline-sync/PLAN.md` §3.3). Only a model registered with `CorePersistenceModule.forFeature()`; a
98
+ model without `sync` keeps every behaviour bit for bit.
99
+
100
+ | Rule | Why |
101
+ |---|---|
102
+ | `version`, `deletedAt` and the change position (`syncSeq` on SQL, `syncAt` on MongoDB) are written by the store only — never put them in an input or update | they are stripped anyway; a version the caller could set would defeat the conditional change, a `deletedAt` would delete with update rights |
103
+ | Every framework delete of such a model leaves a tombstone (`CrudService.delete()`, `deleteById/One/Many`); `hardDelete` with `includeTombstones` is the purge's | a hard delete through a normal route reaches no client — the record stays on every device |
104
+ | Reads leave tombstones out; only the sync module passes `includeTombstones` (`ServiceOptions` or the port's options) | counts, distinct values and populated references must not show deleted records |
105
+ | A conditional change is `ServiceOptions.expectedVersion` on `CrudService.update()` / `delete()` — one statement with the version as its condition, `VersionConflictError` (409) on another version | read-then-write lets two clients both apply their change |
106
+ | A unique index applies to live records only, also for a missing value (MongoDB partial index, PostgreSQL `NULLS NOT DISTINCT … WHERE deleted_at IS NULL`, MySQL / MariaDB virtual `lt_lk_*` columns); a unique index inside an embedded object is refused | delete-then-recreate with the same value must work; tombstones keep no content, so their missing values must not collide |
107
+ | Making an EXISTING model synchronized is a migration on every database. SQL: the boot refuses with the statements (the `deleted_at` column, the live-only index, `DROP` of the full index it replaces); the other columns, the trigger and the numbering of existing rows follow automatically. MongoDB: the boot refuses with `SyncMigrationRequiredError` — drop the old unique indexes and give every document `version`, `deletedAt` and `syncAt` (the error prints both statements); Mongoose then builds the live-only indexes | a full unique index left in place refuses the second tombstone; a document without `syncAt` never reaches a client |
108
+ | No TTL on a synchronized model (refused at registration); no `replaceOne` / pipeline update on MongoDB (refused) | an expired record vanishes without a tombstone; a replacement carries no version increment |
109
+ | On SQL the database numbers changes: a trigger per table (PostgreSQL: advisory lock + sequence; MySQL / MariaDB: the counter row `lt_sync_counters`); a write that sets `sync_seq` itself is not renumbered. On MySQL / MariaDB every store write takes its number LAST instead (marks the row `-1`, then numbers it before COMMIT) | tier-2/3 writes are numbered too; on MySQL / MariaDB `FOR UPDATE` locks index gaps, and a write that held the counter while it waited for one deadlocked with a multi-row write (measured) — a multi-row write outside the store must lock its rows first and set the numbers last, as `updateRows()` does |
110
+ | On MySQL with binary logging, creating the triggers needs `SUPER` or `log_bin_trust_function_creators=1` for the owner account | MySQL refuses `CREATE TRIGGER` otherwise (error 1419); the application account never needs it |
111
+ | MongoDB stamps updates with `$currentDate` and inserts with the server clock read through `hello.localTime` — never `Date.now()` | application clocks differ between replicas; the pull's window relies on one clock |
112
+ | A model reaches `/sync` only with `@SyncService({ model, createInput, updateInput })` on its CrudService AND `sync.roles` for every operation (`pull`, `create`, `update`, `delete`); without roles an operation is ADMIN only | the roles of a project's own routes never reach the sync routes — a push without its own roles lets any reader overwrite |
113
+ | The sync module reads and writes through the model's CrudService only (`find`, `get`, `create`, `update`, `delete` with `expectedVersion`), never `native()` / `raw()` or the driver name; what differs it asks `store.capabilities` and `store.changeClock()` | the security pipeline (`process()`, `securityCheck()`, tenant policy, RLS) is the point; DEV-3819 forbids a lean path |
114
+ | A write that moves a synchronized record to another tenant or away from a scope user OUTSIDE `CrudService.update()` (store, native API, migration) calls `CoreSyncService.bumpAccessEpoch()` | only `CrudService.update()` reports such a move; otherwise the clients that lost the record keep their copy until a full resync |
115
+ | A write of a synchronized model OUTSIDE the CrudService (store, native API, migration) calls `CoreSyncService.notifyChange(model, tenantIds)` | only `CrudService.create()` / `update()` / `delete()` send a hint; without it online clients learn of the change at their next scheduled pull instead of at once — nothing is lost, it is only later |
116
+ | A data migration that changes rows of a synchronized table uses `runSqlMigration()` with `{ syncUpdate: { table, set, where } }`, never a bare `UPDATE` | the helper raises `version` and numbers the rows like a store write; a bare `UPDATE` keeps the old version, so a client's conditional push overwrites the migrated value without a conflict |
117
+ | Tombstones are removed only by the purge (`retention.tombstonesDays`), which moves the retention horizon first | a tombstone removed before the horizon passes it is never seen by a client whose cursor is older — the record stays on that device |
118
+ | A client id on create goes through `ServiceOptions.createId`, never inside the input | an input class keeps only the fields it declares — mapping it drops an `id` |
119
+
94
120
  ## Tests
95
121
 
96
122
  A suite proves a behaviour on both databases: the `e2e` vitest project runs it on MongoDB, the `postgres` project on
@@ -32,10 +32,10 @@ that every `*.spec.ts` / `*.test.ts` in the repo is claimed by **exactly one** r
32
32
  mis-named suite fails the build instead of silently passing. Type-only tests
33
33
  (`tests/types/*.type-test.ts`) are compiled by `pnpm run test:types`, never executed.
34
34
 
35
- ## Infrastructure containers (Redis + S3 + PostgreSQL + MySQL + MariaDB)
35
+ ## Infrastructure containers (Redis + S3 + PostgreSQL + MySQL + MariaDB + MongoDB replica set)
36
36
 
37
- **Twenty** e2e suites talk to a **real** Redis, a **real** S3-compatible store and/or a **real**
38
- PostgreSQL, MySQL or MariaDB rather than a mock, and they **fail loudly** when it is missing — a silently skipped
37
+ **Twenty-two** e2e suites talk to a **real** Redis, a **real** S3-compatible store and/or a **real**
38
+ PostgreSQL, MySQL, MariaDB or MongoDB replica set rather than a mock, and they **fail loudly** when it is missing — a silently skipped
39
39
  infrastructure test is how an untested driver ships:
40
40
 
41
41
  | Suite | Needs |
@@ -60,13 +60,15 @@ infrastructure test is how an untested driver ships:
60
60
  | `tests/persistence-mysql-executor.e2e-spec.ts` | MySQL + MariaDB |
61
61
  | `tests/persistence-mysql-schema.e2e-spec.ts` | MySQL + MariaDB |
62
62
  | `tests/persistence-mysql-wiring.e2e-spec.ts` | MySQL + MariaDB |
63
+ | `tests/sync-contract.e2e-spec.ts` | PostgreSQL + MySQL + MariaDB + MongoDB replica set (and MongoDB) |
64
+ | `tests/sync-clock-skew.e2e-spec.ts` | PostgreSQL + MySQL + MongoDB replica set (and MongoDB) |
63
65
 
64
- Beyond these twenty, the `postgres` vitest project runs the whole e2e suite once more on PostgreSQL (see "Behaviour
66
+ Beyond these twenty-two, the `postgres` vitest project runs the whole e2e suite once more on PostgreSQL (see "Behaviour
65
67
  matrices" below), so without the PostgreSQL container that project fails as a whole while `e2e` runs.
66
68
 
67
- `tests/global-setup.ts` starts all five containers automatically, so `pnpm test` works from a clean
69
+ `tests/global-setup.ts` starts all six containers automatically, so `pnpm test` works from a clean
68
70
  machine with no manual docker command. It is idempotent (a running container is reused) and never
69
- fatal: without Docker every other suite still runs, and the twenty above report their own actionable
71
+ fatal: without Docker every other suite still runs, and the twenty-two above report their own actionable
70
72
  error.
71
73
 
72
74
  ```bash
@@ -75,10 +77,10 @@ pnpm run test:infra:status # what is running
75
77
  pnpm run test:infra:down # stop and remove
76
78
  ```
77
79
 
78
- | | Redis | S3 (RustFS) | PostgreSQL | MySQL 8.4 | MariaDB 11.8 |
79
- |---|---|---|---|---|---|
80
- | Port | 6380 (6379 is an auth-protected TurboOps Redis on lt dev machines) | 9102 | 5440 | 3316 | 3317 |
81
- | Container | `nest-server-2985-redis` | `nest-server-2985-rustfs` | `nest-server-2985-postgres` | `nest-server-2985-mysql` | `nest-server-2985-mariadb` |
80
+ | | Redis | S3 (RustFS) | PostgreSQL | MySQL 8.4 | MariaDB 11.8 | MongoDB 8.0 replica set |
81
+ |---|---|---|---|---|---|---|
82
+ | Port | 6380 (6379 is an auth-protected TurboOps Redis on lt dev machines) | 9102 | 5440 | 3316 | 3317 | 27019 |
83
+ | Container | `nest-server-2985-redis` | `nest-server-2985-rustfs` | `nest-server-2985-postgres` | `nest-server-2985-mysql` | `nest-server-2985-mariadb` | `nest-server-2985-mongo-rs` |
82
84
 
83
85
  **PostgreSQL runs with two roles, as every environment does** (`.claude/plans/db-agnostic/PLAN.md`
84
86
  §3.9): `nest_owner` owns the databases and their schema, `nest_app` owns nothing and has neither
@@ -98,6 +100,12 @@ Overridable via `MYSQL_HOST`, `MYSQL_PORT`, `MARIADB_HOST`, `MARIADB_PORT`, `MYS
98
100
  whole e2e suite: the matrices carry their columns (persistence: one per engine; core modules and where
99
101
  parity: one per server).
100
102
 
103
+ **The MongoDB replica set** (one node, `rs0`) exists for the sync contract matrix (DEV-3901): on a replica set a
104
+ project's transaction can commit a synchronized write long after its stamp — what the pull window must cover — and the
105
+ standalone MongoDB every other suite uses shows nothing of that. The readiness step initiates the set idempotently;
106
+ the suite connects with `directConnection=true` and drops its database itself in `afterAll` (the database lifecycle
107
+ collects only the standalone). Overridable via `MONGO_RS_HOST`, `MONGO_RS_PORT`.
108
+
101
109
  **CI provisions its own** containers in `.github/workflows/*.yml` and sets `LT_TEST_INFRA=0` so the
102
110
  automatic start stays out of the way. Same images, same ports — only who starts them differs. Set
103
111
  `LT_TEST_INFRA=0` locally too if you want to manage them yourself.
@@ -258,10 +266,11 @@ but not on PostgreSQL. The PostgreSQL environment routes the two metadata models
258
266
  The **core modules** follow it one level up: `tests/helpers/core-module-matrix.ts` declares what the framework's own
259
267
  modules answer through REST and GraphQL — users and legacy sign-in, tenant memberships and the tenant guard, API
260
268
  tokens, health check, Hub panels — and `tests/core-module-parity.e2e-spec.ts` assembles them like the reference server
261
- once with MongoDB alone and once with PostgreSQL as the default source (default engine; the persistence matrix covers
262
- the engines). `tests/unit/core-module-parity-matrix.spec.ts` is its structural guard. The PostgreSQL column signs in
263
- through the legacy endpoints with `betterAuth: false`, so it proves the legacy-auth path on SQL; IAM on SQL is the job of
264
- the projects below.
269
+ once per database — MongoDB alone, then PostgreSQL, MySQL and MariaDB as the default source (default engine; the
270
+ persistence matrix covers the engines). `tests/unit/core-module-parity-matrix.spec.ts` is its structural guard. Every
271
+ column runs Better-Auth beside the legacy endpoints, as the reference server does (12.1.1+): the user cases prove the
272
+ legacy-auth path, the `iam-*` cases Better-Auth's security rules (no role through input, hashed credentials, sign-out
273
+ revokes the session) on every database; the IAM suites below cover Better-Auth in depth.
265
274
 
266
275
  The **whole e2e suite runs on both databases** (12.1.0+): the `e2e` project on MongoDB, the `postgres` project again
267
276
  with PostgreSQL as the default data source (`POSTGRES_E2E_ENGINE`, the default engine TypeORM). Each test file gets a
@@ -288,17 +297,18 @@ alone). Where a behaviour differs by design — PostgreSQL's CHECK constraint re
288
297
  lets a raw insert plant — the case asserts what happens instead, per `iamDb.driver`.
289
298
 
290
299
  The **IAM suites** run on the two other engines as well, through vitest projects in `vitest-e2e.config.mts`:
291
- `iam-sql-kysely` and `iam-sql-drizzle` (`IAM_SQL_ENGINES`), each over the files in `IAM_SQL_TEST_INCLUDE`
300
+ `iam-sql-kysely` and `iam-sql-drizzle` (`IAM_SQL_ENGINES`) on PostgreSQL, and `iam-mysql` / `iam-mariadb`
301
+ (`IAM_MYSQL_VARIANTS`, 12.1.1+) with MySQL / MariaDB as the default source and the default engine, each over the files in `IAM_SQL_TEST_INCLUDE`
292
302
  (`vitest.include-globs.mts`: the Better-Auth stories, privilege escalation, sign-in CSRF, migration status, the storage
293
303
  paths, the bidirectional sync, and since phase 4 the AI suite and both MCP OAuth suites). A project's global setup (`tests/iam-sql.global-setup.ts`) creates its PostgreSQL
294
- database per run and drops it afterwards; its setup file (`tests/iam-sql.setup.ts`) points `NSC__DATABASE__URL`,
295
- `NSC__DATABASE__OWNER_URL` and `NSC__DATABASE__SQL_ENGINE` at it, so the unchanged `ServerModule` boots with its users —
296
- and Better-Auth with them — on PostgreSQL. The suites read and prepare state through `tests/helpers/iam-test-db.ts`,
304
+ (or MySQL / MariaDB) database per run and drops it afterwards; its setup file (`tests/iam-sql.setup.ts`) points
305
+ `NSC__DATABASE__URL`, `NSC__DATABASE__OWNER_URL` and — for an engine project — `NSC__DATABASE__SQL_ENGINE` at it, so
306
+ the unchanged `ServerModule` boots with its users — and Better-Auth with them — on that database. The suites read and prepare state through `tests/helpers/iam-test-db.ts`,
297
307
  which answers from whichever database the run routes the users to (camelCase fields, string ids, a column never written
298
308
  left out rather than `null`), never through a MongoDB client of their own; the AI suite does the same through
299
309
  `tests/helpers/ai-test-stores.ts` (the stores the application routes the AI models to, `system()` for fixtures that span
300
- tenants). `tests/ai-mcp-oauth-tenant.e2e-spec.ts` boots with `multiTenancy` and therefore creates a PostgreSQL database
301
- of its own in those projects: a table that loses tenant isolation stops the boot of a suite configured without it, so
310
+ tenants). `tests/ai-mcp-oauth-tenant.e2e-spec.ts` boots with `multiTenancy` and therefore creates a database of its own on the
311
+ project's server in those projects (`tests/helpers/sql-test-db.ts`): a table that loses tenant isolation stops the boot of a suite configured without it, so
302
312
  two suites with different `multiTenancy` must never share one. Keep the root config free of `include`: with
303
313
  `extends: true` it is merged into every project, and the IAM projects would run the whole suite. vitest runs the global
304
314
  setup of EVERY project before any test, and the project setups before the root one — so a throw in
@@ -309,6 +319,36 @@ reason, never fall back to MongoDB, while the `e2e` project runs. `--project e2e
309
319
 
310
320
  The **data store** contract follows the same discipline: `tests/helpers/persistence-matrix.ts` declares the cases (CRUD contract plus the security policies), `tests/persistence-parity.e2e-spec.ts` executes them against a store obtained via `CorePersistenceModule.forFeature()` + `@InjectStore()` with every security plugin registered, and `tests/unit/persistence-parity-matrix.spec.ts` guards that every (case, database) cell is declared. It has ten columns: `mongodb`, PostgreSQL once per engine (`postgres-typeorm`, `postgres-kysely`, `postgres-drizzle`) — a `SqlDataStore` over the schema the migrator created, connected as the application role, so row-level security binds every case — and MySQL and MariaDB once per engine (`mysql-*`, `mariadb-*`, phase 6), a `MysqlDataStore` connected as the DML-only application account. Without row-level security there, `native-api-tenant-refused` is DIFFERENT-BY-DESIGN for those six columns and its complement asserts that a native query reaches every tenant while the store still isolates. Each tenant layer of the SQL store is additionally proven ALONE in `tests/persistence-sql-store.e2e-spec.ts` — the matrix runs them together, where one layer hides the loss of another.
311
321
 
322
+ The **offline-sync protocol** follows it one level above the store: `tests/helpers/sync-contract-matrix.ts` declares
323
+ what `GET/POST /sync/{model}` promise — pull, resume, resync, push with every strategy, idempotency, two tabs, tenant
324
+ isolation, the infrastructure stores, the change hints over SSE and GraphQL (tenant and role filter, the stream ending
325
+ on a re-check), the tombstone purge behind the horizon and the `syncUpdate` migration helper — and
326
+ `tests/sync-contract.e2e-spec.ts` runs it through HTTP on seven columns: MongoDB standalone and replica set, PostgreSQL
327
+ × three engines, MySQL, MariaDB. The window repeats of MongoDB are DIFFERENT-BY-DESIGN against SQL's "never repeated",
328
+ each side with its own case; the migration helper is IMPOSSIBLE on the two MongoDB columns (it is a SQL statement). Its
329
+ structural guard is `tests/unit/sync-contract-matrix.spec.ts`; the reference model it runs on is
330
+ `src/server/modules/sync-note/`. Hints across replicas need a second application on one Redis and live in
331
+ `tests/multi-replica.e2e-spec.ts`.
332
+
333
+ **Offline sync end to end (DEV-3903).** Three suites prove the protocol from the client's side, all on the reference
334
+ server (`tests/helpers/sync-test-app.ts`, which the contract matrix boots too):
335
+
336
+ | Suite | What | Needs |
337
+ |-------|------|-------|
338
+ | `tests/sync-pwa-client.e2e-spec.ts` | The PWA test client (`tests/clients/pwa/`, IndexedDB, service worker) in Chrome, served by the test server, offline via Playwright | an installed Chrome (`playwright-core` downloads none; GitHub's Ubuntu runners ship one; `SYNC_PWA_CHROME` names another executable) |
339
+ | `tests/sync-node-client.e2e-spec.ts` | The same client on SQLite (`node:sqlite`), bearer session token | nothing extra |
340
+ | `tests/sync-clock-skew.e2e-spec.ts` | Two server PROCESSES with clocks 5 s ahead and behind, parallel writers on both, readers on either instance and one switching instances per page: no change lost. MongoDB runs with `pull.windowSeconds: 2` — the derived window (30 s / 90 s) would cover any skew — and needs the reader that STAYS with the fast clock: a switching reader soon starts a run on the slow one and picks a misstamped change up again, so with it alone the suite stayed green against mutation `sync-mongo-stamp-app-clock` | the containers above |
341
+
342
+ The two client suites share one protocol implementation (`tests/clients/sync-client.mjs`) and one scenario list
343
+ (`tests/helpers/sync-client-scenarios.ts`); `tests/unit/sync-client-scenarios.spec.ts` fails when a suite misses a
344
+ scenario, and `tests/unit/sync-client.spec.ts` checks the client against an in-memory server, so a client bug does not
345
+ cost an e2e run. Both run on MongoDB (`e2e`) and PostgreSQL (`postgres`) like every suite. The clock-skew suite starts
346
+ its servers through the framework's SWC loader (`tests/helpers/sync-server.process.ts`, relative imports with `.js`)
347
+ with `tests/helpers/skewed-clock.mjs` preloaded, because two Nest applications in one process share the persistence
348
+ registry; it brings its own databases and is excluded from the `postgres` project. The skewed `Date` keeps the name
349
+ `Date` and `prototype.constructor`: Mongoose resolves a schema type by the constructor's name, and `@UnifiedField`
350
+ tells a class from a type factory by `prototype.constructor` — without both the server does not boot.
351
+
312
352
  ### A cell is EXECUTED, IMPOSSIBLE, or DIFFERENT-BY-DESIGN — never merely absent
313
353
 
314
354
  Omitting a case for one implementation and declaring it impossible there produce the same artefact
@@ -370,8 +410,9 @@ pnpm run check:mutations -- --id=<id> # one mutation
370
410
  pnpm run check:mutations -- --list # the registry, without running anything
371
411
  pnpm run check:mutations -- --allow-dirty # when the fix and its evidence share a working tree
372
412
  pnpm run check:mutations -- --jobs=4 # N mutations at a time (default: 2, or 4 on >=12 cores)
373
- pnpm run check:mutations -- --no-infra # only the 222 that need no MongoDB
413
+ pnpm run check:mutations -- --no-infra # only the 260 that need no MongoDB
374
414
  pnpm run check:mutations -- --since=<ref> # only mutations touching files changed since <ref>
415
+ pnpm run check:mutations -- --shard=2/4 # this job's quarter of the selection (CI splits the full run)
375
416
  ```
376
417
 
377
418
  `--no-infra` and `--since` narrow the run for LOCAL work and are deliberately not wired into the
@@ -389,7 +430,7 @@ when the registry, a vitest config or a setup file changed, since those can move
389
430
  commit, and in CI alongside the publish without blocking it. The price of caching is a cache that
390
431
  has to model each spec's full dependency closure correctly, and getting that wrong produces a stale
391
432
  PASS for a test that has since gone vacuous — exactly what the gate is there to prevent. Bad trade
392
- at 407 mutations.
433
+ at 476 mutations.
393
434
 
394
435
  **Decided 2026-10-03 (repo owner): keep the full run.** Measured at 128 mutations, the CI job for
395
436
  11.41.6 took **14 minutes**. The trigger to revisit is that wall time, not the mutation count —
@@ -397,6 +438,32 @@ the count was never the cost: re-evaluate once the "Regression evidence" job in
397
438
  exceeds **30 minutes** (`gh run view <id> --json jobs` shows start and end). Below that line,
398
439
  adding a mutation needs no decision.
399
440
 
441
+ **Re-evaluated 2026-10-30 (DEV-3910): the run stays full, it is SPLIT.** The line was crossed by far —
442
+ 58.5 min for 407 mutations at the 12.1.0 publish, 53 min for 442 at 12.1.1, almost all of it the cold
443
+ start below. Both workflows now run the gate as a matrix of four jobs, each `--shard=i/4` of the same
444
+ selection: every mutation in exactly one shard, unit and e2e mutations dealt out separately so no shard
445
+ collects the slow ones, nothing cached or skipped. The new trigger: **the slowest shard exceeds 30
446
+ minutes → raise the shard count** (matrix and `--shard` together, one line per workflow). What the split
447
+ must keep is pinned by `tests/unit/mutation-sharding.spec.ts`:
448
+
449
+ | Property | Why it is load-bearing |
450
+ |----------|------------------------|
451
+ | Every mutation in exactly one shard (mutation `mutation-shard-drops-residue`) | a lost mutation is invisible — every shard is green, each honestly reporting what it ran |
452
+ | The matrix lists 1..N for the N `--shard` names (mutations `evidence-matrix-misses-a-shard`, and `evidence-plan-lists-fewer-shards` for the on-demand plan job) | a shorter matrix never starts the missing shards |
453
+ | `fail-fast: false` in both workflows | GitHub cancels the rest of a matrix on the first failure, and with it their verdicts |
454
+ | One issue per red run, from the `regression-evidence-report` job after the matrix | two red shards each looking for an open issue at once both create one |
455
+
456
+ A shard that holds none of a non-empty selection (`--id=<x>` with four shards) ends 0 and says so; an
457
+ empty SELECTION still fails before sharding (2 for `--since` / `--no-infra`, 1 for an unmatched
458
+ `--id`). `regression-evidence.yml` takes a `shards` input (default 4) so a single `--id` run can use
459
+ one job. `/lt-dev:publish` reads the workflow run's conclusion, which any red shard turns red.
460
+
461
+ **More shards stop helping at the slowest single mutation.** A mutation that makes tests HANG costs
462
+ retries × timeout per red test — 3 attempts × 60 s in low-resource mode. At 12.1.1 four such mutations
463
+ took 44 % of all lane time, the largest (`gridfs-upload-source-error-unhandled`) ~18 minutes on its
464
+ own, so no shard count brings a shard below that. When the slowest shard is one such mutation, shorten
465
+ the spec's failure path (a tighter per-test timeout, a fail-fast assertion), not the split.
466
+
400
467
  Not part of `pnpm run check` — it edits source and re-runs whole e2e suites. It belongs in review
401
468
  and on the publish path. It is also reachable on demand, without cutting a release:
402
469
 
@@ -434,9 +501,9 @@ exactly the environment where the answer matters.
434
501
 
435
502
  ### The cost is vitest's cold start, not the tests
436
503
 
437
- Worth knowing before optimising the wrong thing: the specs behind all 185 e2e mutations add up to
504
+ Worth knowing before optimising the wrong thing: the specs behind all 216 e2e mutations add up to
438
505
  **~40 seconds**. The step takes ~740s. The remaining ~700s is paying vitest's startup — process
439
- spawn, transform, module graph, mongod connect, DB create and drop — once per mutation, 407 times.
506
+ spawn, transform, module graph, mongod connect, DB create and drop — once per mutation, 476 times.
440
507
  That work is largely single-threaded I/O and barely scales with cores: the full registry measures
441
508
  **744s on a 12-core laptop and 777s on a 4-vCPU CI runner**.
442
509
 
@@ -779,10 +846,17 @@ consumer's `devDependencies`, never a runtime pin. The better-auth trio is the r
779
846
  holds it as devDependencies (its own test versions; the peer range is the contract), a project holds
780
847
  it as dependencies and owns that version, because its api and app must resolve the same better-auth.
781
848
  Letting the framework's test pin drive it would move the api on the next `pnpm run update` and leave
782
- the app behind (mutation `consumer-gate-devdeps-drive-runtime-pins`). `RAISE_SOURCES` in
783
- `scripts/check-consumer.mjs` and in the starter's `extras/align-package-versions.mjs` encode this;
784
- the two must stay identical — a gate that is more generous than the consumer's script certifies an
785
- update path nobody has.
849
+ the app behind (mutation `consumer-gate-devdeps-drive-runtime-pins`).
850
+
851
+ One exception since 12.1.0: an **optional** framework peer (`peerDependenciesMeta.<name>.optional`).
852
+ mongoose, @nestjs/mongoose and mongodb arrive that way — pinned only as framework devDependencies,
853
+ kept by a MongoDB project as dependencies — so their runtime pin follows the framework's
854
+ devDependency, or `pnpm run update` would never raise them again. better-auth is a REQUIRED peer and
855
+ stays the project's call (mutations `consumer-gate-ignores-optional-peers`,
856
+ `consumer-gate-required-peer-as-optional`). `raiseSources()` in `scripts/check-consumer.mjs` and in the
857
+ starter's `extras/align-package-versions.mjs` encode all of this; the two must stay identical — a gate
858
+ that is more generous than the consumer's script certifies an update path nobody has, and one that is
859
+ narrower never tests the pins projects get.
786
860
 
787
861
  Wired into `.github/workflows/publish.yml` before the publish step, with `--fast`. Deliberately not
788
862
  on every push (~5 minutes), and deliberately not the starter's full `check` in CI: that also runs
package/CLAUDE.md CHANGED
@@ -200,11 +200,12 @@ pnpm run reinit # Clean reinstall + tests + build
200
200
  **Key Components:**
201
201
  - `CoreModule` - Dynamic module with GraphQL (optional, disable via `graphQl: false`), MongoDB, security
202
202
  - `src/core/common/` - Decorators, helpers, interceptors, services
203
- - `src/core/modules/` - ApiToken, Auth, BetterAuth, ErrorCode, File, HealthCheck, Hub, Migrate, Permissions, SystemSetup, Tus, User
203
+ - `src/core/modules/` - ApiToken, Auth, BetterAuth, ErrorCode, File, HealthCheck, Hub, Migrate, Permissions, Sync, SystemSetup, Tus, User
204
204
  - **ApiToken** - Opt-in (`apiTokens`) USER and TENANT tokens + signed assertions. Denied on every route without `@ApiTokenScopes()`; all three guards share `enforceApiTokenRoute()`. See `src/core/modules/api-token/README.md`.
205
205
  - **Hub** - Build-free ADMIN-gated operator cockpit at `/hub` (config-gated per environment; 17 panels, runtime collectors, mailbox, admin actions). See `src/core/modules/hub/README.md`.
206
206
  - **Permissions** - ADMIN-gated security-map report (routes + roles + `@Restricted` fields); also surfaced in the Hub's "Routes / Permissions" panel.
207
- - **Optional central infrastructure** (11.33.0+) - `CoreRedisService` (`redis` config) and `CoreS3Service` (`s3` config) turn process-local state into shared state for multi-replica deployments: rate-limit counters, cron deduplication, GraphQL subscriptions, tenant-cache invalidation, Hub collectors, file storage (`file.storage: 's3'`; the driver is otherwise derived — S3 → GridFS → filesystem — and an unavailable one fails the boot rather than falling back) and TUS staging. Both are inert without their config, and their client libraries are **optional peer dependencies** (`ioredis`, `bullmq`, `@aws-sdk/client-s3`, `@aws-sdk/lib-storage`, `@aws-sdk/s3-request-presigner`, `@tus/s3-store`) — a project that uses neither installs nothing extra. When adding a distributed feature, always keep the non-configured fallback path.
207
+ - **Sync** - Opt-in (`sync`) offline sync: `GET/POST /sync/{model}` for models declared `@PersistedModel({ sync })` whose CrudService carries `@SyncService()`, change hints over SSE (`GET /sync/hints`) and GraphQL (`syncHint`), hourly tombstone purge; protocol in `docs/offline-sync-protocol.md`, client duties in the module's `SECURITY.md`, reference test clients in `tests/clients/`. See `src/core/modules/sync/README.md`.
208
+ - **Optional central infrastructure** (11.33.0+) - `CoreRedisService` (`redis` config) and `CoreS3Service` (`s3` config) turn process-local state into shared state for multi-replica deployments: rate-limit counters, cron deduplication, GraphQL subscriptions, tenant-cache and IAM role-cache invalidation, Hub collectors, file storage (`file.storage: 's3'`; the driver is otherwise derived — S3 → GridFS → filesystem — and an unavailable one fails the boot rather than falling back) and TUS staging. Both are inert without their config, and their client libraries are **optional peer dependencies** (`ioredis`, `bullmq`, `@aws-sdk/client-s3`, `@aws-sdk/lib-storage`, `@aws-sdk/s3-request-presigner`, `@tus/s3-store`) — a project that uses neither installs nothing extra. When adding a distributed feature, always keep the non-configured fallback path.
208
209
 
209
210
  See `.claude/rules/architecture.md` for detailed documentation.
210
211
  See [`docs/REQUEST-LIFECYCLE.md`](docs/REQUEST-LIFECYCLE.md) for the complete request lifecycle, security architecture, and interceptor/decorator reference.