@lenne.tech/nest-server 12.1.1 → 12.3.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.
- package/.claude/rules/architecture.md +1 -0
- package/.claude/rules/better-auth.md +31 -4
- package/.claude/rules/configurable-features.md +8 -6
- package/.claude/rules/package-management.md +29 -2
- package/.claude/rules/persistence.md +27 -1
- package/.claude/rules/testing.md +80 -15
- package/.claude/rules/versioning.md +13 -2
- package/CLAUDE.md +7 -1
- package/FRAMEWORK-API.md +20 -4
- package/README.md +20 -2
- package/dist/config.env.js +2 -2
- package/dist/config.env.js.map +1 -1
- package/dist/core/common/helpers/graphql-ws-connection.helper.d.ts +4 -0
- package/dist/core/common/helpers/graphql-ws-connection.helper.js +17 -3
- package/dist/core/common/helpers/graphql-ws-connection.helper.js.map +1 -1
- package/dist/core/common/interfaces/server-options.interface.d.ts +49 -0
- package/dist/core/common/interfaces/service-options.interface.d.ts +3 -0
- package/dist/core/common/persistence/core-persistence.module.js +12 -2
- package/dist/core/common/persistence/core-persistence.module.js.map +1 -1
- package/dist/core/common/persistence/data-source.runtime.js +4 -2
- package/dist/core/common/persistence/data-source.runtime.js.map +1 -1
- package/dist/core/common/persistence/data-store.interface.d.ts +24 -9
- package/dist/core/common/persistence/id-format.d.ts +2 -0
- package/dist/core/common/persistence/id-format.js +14 -0
- package/dist/core/common/persistence/id-format.js.map +1 -1
- package/dist/core/common/persistence/model-descriptor.interface.d.ts +6 -0
- package/dist/core/common/persistence/model-metadata.registry.d.ts +2 -1
- package/dist/core/common/persistence/model-metadata.registry.js +20 -2
- package/dist/core/common/persistence/model-metadata.registry.js.map +1 -1
- package/dist/core/common/persistence/mongo/sync-schema.plugin.d.ts +18 -0
- package/dist/core/common/persistence/mongo/sync-schema.plugin.js +368 -0
- package/dist/core/common/persistence/mongo/sync-schema.plugin.js.map +1 -0
- package/dist/core/common/persistence/mongoose-data-store.d.ts +19 -9
- package/dist/core/common/persistence/mongoose-data-store.js +107 -31
- package/dist/core/common/persistence/mongoose-data-store.js.map +1 -1
- package/dist/core/common/persistence/persisted-model.decorator.d.ts +3 -0
- package/dist/core/common/persistence/persisted-model.decorator.js +9 -0
- package/dist/core/common/persistence/persisted-model.decorator.js.map +1 -1
- package/dist/core/common/persistence/policies/sync.policy.d.ts +17 -0
- package/dist/core/common/persistence/policies/sync.policy.js +77 -0
- package/dist/core/common/persistence/policies/sync.policy.js.map +1 -0
- package/dist/core/common/persistence/sql/mysql/mysql-data-store.d.ts +12 -8
- package/dist/core/common/persistence/sql/mysql/mysql-data-store.js +82 -29
- package/dist/core/common/persistence/sql/mysql/mysql-data-store.js.map +1 -1
- package/dist/core/common/persistence/sql/mysql/mysql-schema.compiler.d.ts +10 -0
- package/dist/core/common/persistence/sql/mysql/mysql-schema.compiler.js +61 -4
- package/dist/core/common/persistence/sql/mysql/mysql-schema.compiler.js.map +1 -1
- package/dist/core/common/persistence/sql/mysql/mysql-schema.migrator.js +101 -4
- package/dist/core/common/persistence/sql/mysql/mysql-schema.migrator.js.map +1 -1
- package/dist/core/common/persistence/sql/postgres/postgres-schema.compiler.d.ts +10 -0
- package/dist/core/common/persistence/sql/postgres/postgres-schema.compiler.js +41 -2
- package/dist/core/common/persistence/sql/postgres/postgres-schema.compiler.js.map +1 -1
- package/dist/core/common/persistence/sql/postgres/postgres-schema.migrator.js +98 -10
- package/dist/core/common/persistence/sql/postgres/postgres-schema.migrator.js.map +1 -1
- package/dist/core/common/persistence/sql/sql-data-store.d.ts +38 -14
- package/dist/core/common/persistence/sql/sql-data-store.js +212 -38
- package/dist/core/common/persistence/sql/sql-data-store.js.map +1 -1
- package/dist/core/common/persistence/sql/sql-errors.d.ts +2 -1
- package/dist/core/common/persistence/sql/sql-errors.js +3 -1
- package/dist/core/common/persistence/sql/sql-errors.js.map +1 -1
- package/dist/core/common/persistence/sync-access.registry.d.ts +17 -0
- package/dist/core/common/persistence/sync-access.registry.js +8 -0
- package/dist/core/common/persistence/sync-access.registry.js.map +1 -0
- package/dist/core/common/persistence/sync-model.d.ts +29 -0
- package/dist/core/common/persistence/sync-model.js +138 -0
- package/dist/core/common/persistence/sync-model.js.map +1 -0
- package/dist/core/common/services/crud.service.d.ts +13 -1
- package/dist/core/common/services/crud.service.js +174 -16
- package/dist/core/common/services/crud.service.js.map +1 -1
- package/dist/core/modules/ai/core-ai-mcp.controller.d.ts +3 -0
- package/dist/core/modules/ai/core-ai-mcp.controller.js +29 -0
- package/dist/core/modules/ai/core-ai-mcp.controller.js.map +1 -1
- package/dist/core/modules/ai/core-ai.constants.d.ts +2 -0
- package/dist/core/modules/ai/core-ai.constants.js +2 -0
- package/dist/core/modules/ai/core-ai.constants.js.map +1 -1
- package/dist/core/modules/ai/core-ai.controller.d.ts +6 -0
- package/dist/core/modules/ai/core-ai.controller.js +31 -1
- package/dist/core/modules/ai/core-ai.controller.js.map +1 -1
- package/dist/core/modules/ai/core-ai.module.d.ts +2 -0
- package/dist/core/modules/ai/core-ai.module.js +8 -1
- package/dist/core/modules/ai/core-ai.module.js.map +1 -1
- package/dist/core/modules/ai/core-ai.resolver.d.ts +6 -0
- package/dist/core/modules/ai/core-ai.resolver.js +32 -0
- package/dist/core/modules/ai/core-ai.resolver.js.map +1 -1
- package/dist/core/modules/ai/helpers/ai-mcp-oauth-consent.helper.d.ts +65 -1
- package/dist/core/modules/ai/helpers/ai-mcp-oauth-consent.helper.js +6 -3
- package/dist/core/modules/ai/helpers/ai-mcp-oauth-consent.helper.js.map +1 -1
- package/dist/core/modules/ai/helpers/ai-mcp-oauth.helper.js +48 -1
- package/dist/core/modules/ai/helpers/ai-mcp-oauth.helper.js.map +1 -1
- package/dist/core/modules/ai/helpers/ai-tool-authorization.helper.d.ts +9 -0
- package/dist/core/modules/ai/helpers/ai-tool-authorization.helper.js +54 -0
- package/dist/core/modules/ai/helpers/ai-tool-authorization.helper.js.map +1 -0
- package/dist/core/modules/ai/index.d.ts +7 -0
- package/dist/core/modules/ai/index.js +6 -0
- package/dist/core/modules/ai/index.js.map +1 -1
- package/dist/core/modules/ai/inputs/core-ai-settings.input.d.ts +3 -0
- package/dist/core/modules/ai/inputs/core-ai-settings.input.js +31 -0
- package/dist/core/modules/ai/inputs/core-ai-settings.input.js.map +1 -0
- package/dist/core/modules/ai/interfaces/ai-tool.interface.d.ts +2 -0
- package/dist/core/modules/ai/models/core-ai-settings.model.d.ts +4 -0
- package/dist/core/modules/ai/models/core-ai-settings.model.js +30 -0
- package/dist/core/modules/ai/models/core-ai-settings.model.js.map +1 -0
- package/dist/core/modules/ai/models/core-ai-user-setting.model.d.ts +8 -0
- package/dist/core/modules/ai/models/core-ai-user-setting.model.js +45 -0
- package/dist/core/modules/ai/models/core-ai-user-setting.model.js.map +1 -0
- package/dist/core/modules/ai/services/core-ai-conversation.service.d.ts +4 -1
- package/dist/core/modules/ai/services/core-ai-conversation.service.js +16 -4
- package/dist/core/modules/ai/services/core-ai-conversation.service.js.map +1 -1
- package/dist/core/modules/ai/services/core-ai-mcp-oauth.service.js +6 -6
- package/dist/core/modules/ai/services/core-ai-mcp-oauth.service.js.map +1 -1
- package/dist/core/modules/ai/services/core-ai-mcp.service.d.ts +27 -2
- package/dist/core/modules/ai/services/core-ai-mcp.service.js +118 -11
- package/dist/core/modules/ai/services/core-ai-mcp.service.js.map +1 -1
- package/dist/core/modules/ai/services/core-ai-prompt-builder.service.js +9 -6
- package/dist/core/modules/ai/services/core-ai-prompt-builder.service.js.map +1 -1
- package/dist/core/modules/ai/services/core-ai-user-setting.service.d.ts +17 -0
- package/dist/core/modules/ai/services/core-ai-user-setting.service.js +72 -0
- package/dist/core/modules/ai/services/core-ai-user-setting.service.js.map +1 -0
- package/dist/core/modules/ai/services/core-ai.service.d.ts +8 -3
- package/dist/core/modules/ai/services/core-ai.service.js +68 -29
- package/dist/core/modules/ai/services/core-ai.service.js.map +1 -1
- package/dist/core/modules/better-auth/better-auth.config.js +3 -1
- package/dist/core/modules/better-auth/better-auth.config.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js +2 -2
- package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth-path-guard.middleware.d.ts +8 -0
- package/dist/core/modules/better-auth/core-better-auth-path-guard.middleware.js +64 -0
- package/dist/core/modules/better-auth/core-better-auth-path-guard.middleware.js.map +1 -0
- package/dist/core/modules/better-auth/core-better-auth-rate-limit.middleware.js +2 -1
- package/dist/core/modules/better-auth/core-better-auth-rate-limit.middleware.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.d.ts +1 -1
- package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.js +5 -3
- package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth.module.js +2 -0
- package/dist/core/modules/better-auth/core-better-auth.module.js.map +1 -1
- package/dist/core/modules/better-auth/index.d.ts +1 -0
- package/dist/core/modules/better-auth/index.js +1 -0
- package/dist/core/modules/better-auth/index.js.map +1 -1
- package/dist/core/modules/error-code/error-codes.d.ts +72 -0
- package/dist/core/modules/error-code/error-codes.js +64 -0
- package/dist/core/modules/error-code/error-codes.js.map +1 -1
- package/dist/core/modules/migrate/sql-migration.helper.d.ts +15 -1
- package/dist/core/modules/migrate/sql-migration.helper.js +90 -5
- package/dist/core/modules/migrate/sql-migration.helper.js.map +1 -1
- package/dist/core/modules/sync/core-sync-base.controller.d.ts +7 -0
- package/dist/core/modules/sync/core-sync-base.controller.js +44 -0
- package/dist/core/modules/sync/core-sync-base.controller.js.map +1 -0
- package/dist/core/modules/sync/core-sync-cursor.d.ts +19 -0
- package/dist/core/modules/sync/core-sync-cursor.js +77 -0
- package/dist/core/modules/sync/core-sync-cursor.js.map +1 -0
- package/dist/core/modules/sync/core-sync-hints.controller.d.ts +4 -0
- package/dist/core/modules/sync/core-sync-hints.controller.js +55 -0
- package/dist/core/modules/sync/core-sync-hints.controller.js.map +1 -0
- package/dist/core/modules/sync/core-sync-hints.service.d.ts +28 -0
- package/dist/core/modules/sync/core-sync-hints.service.js +155 -0
- package/dist/core/modules/sync/core-sync-hints.service.js.map +1 -0
- package/dist/core/modules/sync/core-sync-infrastructure.models.d.ts +2 -0
- package/dist/core/modules/sync/core-sync-infrastructure.models.js +17 -0
- package/dist/core/modules/sync/core-sync-infrastructure.models.js.map +1 -0
- package/dist/core/modules/sync/core-sync-mutation.store.d.ts +43 -0
- package/dist/core/modules/sync/core-sync-mutation.store.js +88 -0
- package/dist/core/modules/sync/core-sync-mutation.store.js.map +1 -0
- package/dist/core/modules/sync/core-sync-state.store.d.ts +18 -0
- package/dist/core/modules/sync/core-sync-state.store.js +64 -0
- package/dist/core/modules/sync/core-sync-state.store.js.map +1 -0
- package/dist/core/modules/sync/core-sync.constants.d.ts +28 -0
- package/dist/core/modules/sync/core-sync.constants.js +23 -0
- package/dist/core/modules/sync/core-sync.constants.js.map +1 -0
- package/dist/core/modules/sync/core-sync.controller.d.ts +8 -0
- package/dist/core/modules/sync/core-sync.controller.js +79 -0
- package/dist/core/modules/sync/core-sync.controller.js.map +1 -0
- package/dist/core/modules/sync/core-sync.decorators.d.ts +12 -0
- package/dist/core/modules/sync/core-sync.decorators.js +28 -0
- package/dist/core/modules/sync/core-sync.decorators.js.map +1 -0
- package/dist/core/modules/sync/core-sync.module.d.ts +16 -0
- package/dist/core/modules/sync/core-sync.module.js +50 -0
- package/dist/core/modules/sync/core-sync.module.js.map +1 -0
- package/dist/core/modules/sync/core-sync.resolver.d.ts +9 -0
- package/dist/core/modules/sync/core-sync.resolver.js +46 -0
- package/dist/core/modules/sync/core-sync.resolver.js.map +1 -0
- package/dist/core/modules/sync/core-sync.service.d.ts +143 -0
- package/dist/core/modules/sync/core-sync.service.js +1237 -0
- package/dist/core/modules/sync/core-sync.service.js.map +1 -0
- package/dist/core/modules/sync/interfaces/core-sync.interfaces.d.ts +56 -0
- package/dist/core/modules/sync/interfaces/core-sync.interfaces.js +2 -0
- package/dist/core/modules/sync/interfaces/core-sync.interfaces.js.map +1 -0
- package/dist/core/modules/sync/models/core-sync-hint.model.d.ts +4 -0
- package/dist/core/modules/sync/models/core-sync-hint.model.js +30 -0
- package/dist/core/modules/sync/models/core-sync-hint.model.js.map +1 -0
- package/dist/core/modules/tenant/core-tenant.guard.js +14 -0
- package/dist/core/modules/tenant/core-tenant.guard.js.map +1 -1
- package/dist/core/modules/tenant/core-tenant.service.d.ts +21 -1
- package/dist/core/modules/tenant/core-tenant.service.js +153 -1
- package/dist/core/modules/tenant/core-tenant.service.js.map +1 -1
- package/dist/core.module.js +14 -0
- package/dist/core.module.js.map +1 -1
- package/dist/index.d.ts +16 -0
- package/dist/index.js +16 -0
- package/dist/index.js.map +1 -1
- package/dist/server/modules/error-code/error-codes.d.ts +8 -0
- package/dist/server/modules/sync-note/inputs/sync-note-create.input.d.ts +4 -0
- package/dist/server/modules/sync-note/inputs/sync-note-create.input.js +27 -0
- package/dist/server/modules/sync-note/inputs/sync-note-create.input.js.map +1 -0
- package/dist/server/modules/sync-note/inputs/sync-note.input.d.ts +6 -0
- package/dist/server/modules/sync-note/inputs/sync-note.input.js +37 -0
- package/dist/server/modules/sync-note/inputs/sync-note.input.js.map +1 -0
- package/dist/server/modules/sync-note/sync-note.model.d.ts +10 -0
- package/dist/server/modules/sync-note/sync-note.model.js +73 -0
- package/dist/server/modules/sync-note/sync-note.model.js.map +1 -0
- package/dist/server/modules/sync-note/sync-note.module.d.ts +2 -0
- package/dist/server/modules/sync-note/sync-note.module.js +21 -0
- package/dist/server/modules/sync-note/sync-note.module.js.map +1 -0
- package/dist/server/modules/sync-note/sync-note.service.d.ts +10 -0
- package/dist/server/modules/sync-note/sync-note.service.js +35 -0
- package/dist/server/modules/sync-note/sync-note.service.js.map +1 -0
- package/dist/test/test.helper.d.ts +1 -0
- package/dist/test/test.helper.js +9 -2
- package/dist/test/test.helper.js.map +1 -1
- package/docs/REQUEST-LIFECYCLE.md +4 -2
- package/docs/error-codes.md +14 -0
- package/docs/offline-sync-protocol.md +111 -68
- package/docs/security-overrides.md +14 -0
- package/migration-guides/12.0.x-to-12.1.x.md +0 -1
- package/migration-guides/12.1.x-to-12.2.x.md +154 -0
- package/migration-guides/12.2.x-to-12.3.x.md +440 -0
- package/package.json +23 -21
- package/src/config.env.ts +6 -2
- package/src/core/common/helpers/graphql-ws-connection.helper.ts +32 -4
- package/src/core/common/interfaces/server-options.interface.ts +229 -8
- package/src/core/common/interfaces/service-options.interface.ts +15 -0
- package/src/core/common/persistence/core-persistence.module.ts +21 -2
- package/src/core/common/persistence/data-source.runtime.ts +5 -2
- package/src/core/common/persistence/data-store.interface.ts +65 -12
- package/src/core/common/persistence/id-format.ts +26 -0
- package/src/core/common/persistence/model-descriptor.interface.ts +12 -0
- package/src/core/common/persistence/model-metadata.registry.ts +31 -2
- package/src/core/common/persistence/mongo/sync-schema.plugin.ts +508 -0
- package/src/core/common/persistence/mongoose-data-store.ts +171 -35
- package/src/core/common/persistence/persisted-model.decorator.ts +26 -0
- package/src/core/common/persistence/policies/sync.policy.ts +121 -0
- package/src/core/common/persistence/sql/mysql/mysql-data-store.ts +108 -30
- package/src/core/common/persistence/sql/mysql/mysql-schema.compiler.ts +107 -4
- package/src/core/common/persistence/sql/mysql/mysql-schema.migrator.ts +129 -3
- package/src/core/common/persistence/sql/postgres/postgres-schema.compiler.ts +74 -2
- package/src/core/common/persistence/sql/postgres/postgres-schema.migrator.ts +139 -7
- package/src/core/common/persistence/sql/sql-data-store.ts +325 -44
- package/src/core/common/persistence/sql/sql-errors.ts +5 -1
- package/src/core/common/persistence/sync-access.registry.ts +50 -0
- package/src/core/common/persistence/sync-model.ts +270 -0
- package/src/core/common/services/crud.service.ts +257 -19
- package/src/core/modules/ai/INTEGRATION-CHECKLIST.md +36 -15
- package/src/core/modules/ai/README.md +163 -25
- package/src/core/modules/ai/core-ai-mcp.controller.ts +48 -1
- package/src/core/modules/ai/core-ai.constants.ts +6 -0
- package/src/core/modules/ai/core-ai.controller.ts +36 -1
- package/src/core/modules/ai/core-ai.module.ts +12 -0
- package/src/core/modules/ai/core-ai.resolver.ts +36 -0
- package/src/core/modules/ai/helpers/ai-mcp-oauth-consent.helper.ts +35 -4
- package/src/core/modules/ai/helpers/ai-mcp-oauth.helper.ts +68 -1
- package/src/core/modules/ai/helpers/ai-tool-authorization.helper.ts +99 -0
- package/src/core/modules/ai/index.ts +18 -0
- package/src/core/modules/ai/inputs/core-ai-settings.input.ts +24 -0
- package/src/core/modules/ai/interfaces/ai-hook.interface.ts +1 -1
- package/src/core/modules/ai/interfaces/ai-tool.interface.ts +38 -26
- package/src/core/modules/ai/models/core-ai-mode.model.ts +1 -1
- package/src/core/modules/ai/models/core-ai-settings.model.ts +23 -0
- package/src/core/modules/ai/models/core-ai-tool-policy.model.ts +1 -1
- package/src/core/modules/ai/models/core-ai-user-setting.model.ts +46 -0
- package/src/core/modules/ai/services/core-ai-conversation.service.ts +31 -6
- package/src/core/modules/ai/services/core-ai-mcp-oauth.service.ts +12 -9
- package/src/core/modules/ai/services/core-ai-mcp.service.ts +206 -13
- package/src/core/modules/ai/services/core-ai-prompt-builder.service.ts +13 -6
- package/src/core/modules/ai/services/core-ai-user-setting.service.ts +85 -0
- package/src/core/modules/ai/services/core-ai.service.ts +134 -35
- package/src/core/modules/api-token/README.md +4 -2
- package/src/core/modules/better-auth/CUSTOMIZATION.md +1 -1
- package/src/core/modules/better-auth/INTEGRATION-CHECKLIST.md +7 -0
- package/src/core/modules/better-auth/README.md +32 -1
- package/src/core/modules/better-auth/better-auth.config.ts +12 -1
- package/src/core/modules/better-auth/core-better-auth-email-verification.service.ts +5 -2
- package/src/core/modules/better-auth/core-better-auth-path-guard.middleware.ts +85 -0
- package/src/core/modules/better-auth/core-better-auth-rate-limit.middleware.ts +5 -2
- package/src/core/modules/better-auth/core-better-auth-rate-limiter.service.ts +14 -7
- package/src/core/modules/better-auth/core-better-auth.module.ts +12 -2
- package/src/core/modules/better-auth/index.ts +1 -0
- package/src/core/modules/error-code/error-codes.ts +77 -0
- package/src/core/modules/migrate/sql-migration.helper.ts +141 -7
- package/src/core/modules/sync/INTEGRATION-CHECKLIST.md +95 -0
- package/src/core/modules/sync/README.md +198 -0
- package/src/core/modules/sync/SECURITY.md +65 -0
- package/src/core/modules/sync/core-sync-base.controller.ts +43 -0
- package/src/core/modules/sync/core-sync-cursor.ts +117 -0
- package/src/core/modules/sync/core-sync-hints.controller.ts +52 -0
- package/src/core/modules/sync/core-sync-hints.service.ts +178 -0
- package/src/core/modules/sync/core-sync-infrastructure.models.ts +40 -0
- package/src/core/modules/sync/core-sync-mutation.store.ts +146 -0
- package/src/core/modules/sync/core-sync-state.store.ts +89 -0
- package/src/core/modules/sync/core-sync.constants.ts +51 -0
- package/src/core/modules/sync/core-sync.controller.ts +68 -0
- package/src/core/modules/sync/core-sync.decorators.ts +84 -0
- package/src/core/modules/sync/core-sync.module.ts +75 -0
- package/src/core/modules/sync/core-sync.resolver.ts +35 -0
- package/src/core/modules/sync/core-sync.service.ts +1773 -0
- package/src/core/modules/sync/interfaces/core-sync.interfaces.ts +85 -0
- package/src/core/modules/sync/models/core-sync-hint.model.ts +18 -0
- package/src/core/modules/tenant/INTEGRATION-CHECKLIST.md +12 -1
- package/src/core/modules/tenant/README.md +49 -3
- package/src/core/modules/tenant/core-tenant.guard.ts +26 -2
- package/src/core/modules/tenant/core-tenant.service.ts +287 -2
- package/src/core.module.ts +25 -0
- package/src/index.ts +21 -0
- package/src/server/modules/sync-note/inputs/sync-note-create.input.ts +14 -0
- package/src/server/modules/sync-note/inputs/sync-note.input.ts +23 -0
- package/src/server/modules/sync-note/sync-note.model.ts +77 -0
- package/src/server/modules/sync-note/sync-note.module.ts +16 -0
- package/src/server/modules/sync-note/sync-note.service.ts +25 -0
- package/src/test/test.helper.ts +17 -3
|
@@ -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 |
|
|
@@ -478,10 +478,36 @@ exports of `better-auth/plugins` but NOT in its runtime exports, verified on 1.7
|
|
|
478
478
|
| What the controller handles | `CONTROLLER_HANDLED_PATHS` (same file) | the middleware and the Swagger helper read the same list — an explicit route always wins over a generated one |
|
|
479
479
|
|
|
480
480
|
Pinned by `tests/better-auth-openapi.e2e-spec.ts` (404 against the assembled `ServerModule` for the
|
|
481
|
-
canonical path
|
|
482
|
-
normalise them away; mutation `openapi-plugin-routable` drops `serverOnlyPlugin()` and turns
|
|
483
|
-
red
|
|
484
|
-
REAL generator output rather than a fixture.
|
|
481
|
+
canonical path, and 400 or 404 for six dot-segment spellings, sent raw with `http.request` because supertest
|
|
482
|
+
and fetch normalise them away; mutation `openapi-plugin-routable` drops `serverOnlyPlugin()` and turns the
|
|
483
|
+
canonical case red — the spellings are refused by the path guard below either way) and the "framework routes"
|
|
484
|
+
cases in `tests/unit/swagger-helper.spec.ts`, which run against the REAL generator output rather than a fixture.
|
|
485
|
+
|
|
486
|
+
### The path guard: the general answer to "the check reads a different path" (12.3.0)
|
|
487
|
+
|
|
488
|
+
The same mismatch reached past the open-api routes: the rate limiter skipped `/callback/../sign-in/email` as a
|
|
489
|
+
callback (its skip list matched the raw path) while Better-Auth ran it as a sign-in — no counter on sign-in at all.
|
|
490
|
+
`CoreBetterAuthPathGuardMiddleware` (`core-better-auth-path-guard.middleware.ts`) is therefore the FIRST middleware
|
|
491
|
+
under the base path, registered unconditionally in `CoreBetterAuthModule.configure()`, and refuses with 400
|
|
492
|
+
(`#LTNS_0305`) any path with a `.` / `..` segment (also `%2e`-encoded), a backslash, an encoded `/` or `\`, or a `#`
|
|
493
|
+
(Express routes the part before it, the forwarding to Better-Auth reads the whole string, so `sign-in/email#x` skipped
|
|
494
|
+
the controller and its e-mail verification check). No
|
|
495
|
+
route under the base path needs any of them. Behind it, the limiter still decides on `canonicalRequestPath()` and
|
|
496
|
+
matches its lists by whole segments (`pathContainsSegments()`), so it stays correct where the guard is not
|
|
497
|
+
registered.
|
|
498
|
+
|
|
499
|
+
| Rule | Why |
|
|
500
|
+
|---|---|
|
|
501
|
+
| A new check on the request path under the base path reads it AFTER the guard, never instead of it | the guard is what makes "the path as sent" and "the path Better-Auth routes" the same string |
|
|
502
|
+
| Do not widen the guard into a deny-list of routes | it refuses a SHAPE no route has; a route list is the §9 trap again |
|
|
503
|
+
| Match route lists by whole segments, not `includes()` | `includes('/session')` also matched `/sessions-export` |
|
|
504
|
+
| A middleware under the base path reads `req.originalUrl`, never `req.path` | Nest mounts it on `${basePath}/*path`, and Express moves the whole match into `req.baseUrl`: `req.path` is "/" there. The rate-limit middleware read it until 12.3.0, so every IAM request of a client shared one counter with the full limit and neither the skip list nor the strict sign-in limit applied over HTTP |
|
|
505
|
+
| Lower-case before comparing or keying | Express routes case-insensitively: `/sign-in/EMAIL` is the same sign-in and must share its counter |
|
|
506
|
+
|
|
507
|
+
Pinned by `tests/unit/better-auth-path-guard.spec.ts` (mutations `iam-path-guard-dot-segments-allowed`,
|
|
508
|
+
`iam-rate-limit-reads-raw-path`, `iam-rate-limit-case-sensitive`, `iam-rate-limit-middleware-reads-req-path`) and the
|
|
509
|
+
raw-request cases in `tests/better-auth-openapi.e2e-spec.ts` (mutations `iam-path-guard-not-registered`,
|
|
510
|
+
`iam-http-rate-limit-reads-req-path`), which also check the limit headers over HTTP.
|
|
485
511
|
|
|
486
512
|
## 10. Direct `api.*` calls must hand Better-Auth the request — or its CSRF protection does not run
|
|
487
513
|
|
|
@@ -574,5 +600,6 @@ security rules on every database.
|
|
|
574
600
|
| Session cookie | Always the opaque session token — never the body JWT (§7) |
|
|
575
601
|
| `account` reads / writes | Reads filter on `providerId` alone, never `issuer`. Writes set `issuer` only on better-auth 1.7.0–1.7.2, via the helper (§8) |
|
|
576
602
|
| `openAPI()` plugin | Registered internally for `setupSwagger()`, kept off the router (`SERVER_ONLY`) unless the project registered it — never by a path check (§9) |
|
|
603
|
+
| Request path under the base path | `CoreBetterAuthPathGuardMiddleware` refuses a non-canonical path first; checks match whole segments of the canonical path (§9) |
|
|
577
604
|
| Direct `api.*` calls | Hand Better-Auth the request (CSRF headers only) + `asResponse: false`, or its login-CSRF check is skipped (§10) |
|
|
578
605
|
| SQL source | Better-Auth follows the users table; the framework reaches its data through `BetterAuthSqlStore`, ids stay strings (§11) |
|
|
@@ -306,7 +306,8 @@ This pattern is currently applied to:
|
|
|
306
306
|
| BetterAuth 2FA Plugin | `betterAuth.twoFactor` | Boolean Shorthand | `appName: 'Nest Server'` |
|
|
307
307
|
| BetterAuth Passkey Plugin | `betterAuth.passkey` | Boolean Shorthand | `rpName: 'Nest Server'` |
|
|
308
308
|
| BetterAuth Cross-Subdomain Cookies | `betterAuth.crossSubDomainCookies` | Boolean Shorthand | `domain: auto (appUrl → baseUrl without api. prefix)` |
|
|
309
|
-
| BetterAuth Disable Sign-Up | `betterAuth.emailAndPassword.disableSignUp` | Explicit Boolean | `false` (sign-up enabled). `true` also removes `POST /iam/sign-up/email` from the document `setupSwagger()` builds — the route answers `SIGNUP_DISABLED` to every caller then, so documenting it only misleads. The rule is `CoreBetterAuthService.isSignUpEnabled()`'s (`=== true`); a project controller extending `CoreBetterAuthController` that overrides `signUp` is covered, and so is the route Better-Auth generates for it. Implementation: `switchedOffBetterAuthPaths()` in `core-better-auth-openapi.helper.ts`, applied by `swagger.helper.ts` |
|
|
309
|
+
| BetterAuth Disable Sign-Up | `betterAuth.emailAndPassword.disableSignUp` | Explicit Boolean | `false` (sign-up enabled). `true` also removes `POST /iam/sign-up/email` from the document `setupSwagger()` builds — the route answers `SIGNUP_DISABLED` to every caller then, so documenting it only misleads. The rule is `CoreBetterAuthService.isSignUpEnabled()`'s (`=== true`); a project controller extending `CoreBetterAuthController` that overrides `signUp` is covered, and so is the route Better-Auth generates for it. **Since 12.3.0 it closes social sign-up too:** Better-Auth reads the flag for `/sign-up/email` only and decides a social sign-up per provider, so `buildSocialProviders()` hands every provider it as `disableSignUp` — a first social sign-in of an unknown user is refused (`error=signup_disabled` on the callback, 401 on `/sign-in/social` with an `idToken`), existing users keep signing in. `socialProviders.<name>.disableSignUp` is the deliberate exception per provider (`false` opens, `true` closes only that one); only a boolean counts there, any other value follows the global flag, so a string from `NEST_SERVER_CONFIG` cannot open a closed registration. Plugins a project adds through `betterAuth.options.plugins` are not covered. Pinned by `tests/unit/better-auth-social-sign-up.spec.ts` against the REAL `createBetterAuthInstance()`, including Better-Auth's own `/sign-in/social` handler (mutations `iam-social-sign-up-ignores-disable-sign-up`, `iam-social-sign-up-string-opens`). Implementation: `switchedOffBetterAuthPaths()` in `core-better-auth-openapi.helper.ts`, applied by `swagger.helper.ts`; `buildSocialProviders()` in `better-auth.config.ts` |
|
|
310
|
+
| BetterAuth Auto Sign-In After Verification | `betterAuth.emailVerification.autoSignInAfterVerification` | Explicit Boolean — **safe default** | **`false` since 12.3.0** (was `true`, against Better-Auth's own default). With it on, `GET /iam/verify-email` creates a session for the account the link belongs to in WHATEVER browser opens it. The link is a bearer credential that travels by mail: someone who signs up with their own address and forwards the unopened link has the recipient's browser signed in to their account, and what the recipient enters there (payment data, documents, messages) lands with them — login CSRF, the same class §10 of `.claude/rules/better-auth.md` closes for sign-in. Off, the user verifies and then signs in once. Matched with `=== true` in BOTH places that read it — `better-auth.config.ts` (what Better-Auth receives) and `CoreBetterAuthEmailVerificationService.shouldAutoSignIn()` (what a project asks) — so a truthy non-boolean (the string `"true"` in `NEST_SERVER_CONFIG`; `NSC__*` already coerces `true`) cannot switch it on, and the two cannot disagree. Pinned by `tests/unit/better-auth-auto-sign-in.spec.ts` against the REAL `createBetterAuthInstance()` (mutations `iam-auto-sign-in-after-verification-default-on`, `iam-auto-sign-in-service-default-on`) |
|
|
310
311
|
| BetterAuth Native Password Reset | `betterAuth.emailAndPassword.passwordReset` | Explicit Boolean | **`true` — ON by default, and there is no other off switch.** Better-Auth treats the PRESENCE of an `emailAndPassword.sendResetPassword` hook as the enable flag, and `CoreBetterAuthModule` always injects one (delegating to `CoreBetterAuthEmailVerificationService.sendPasswordResetEmail()`). So `POST /iam/request-password-reset` is a live, **unauthenticated**, token-minting, mail-sending endpoint on every deployment — before 11.36.1 it answered `RESET_PASSWORD_DISABLED` because no hook was wired. Set `false` to withhold the hook and restore that refusal, for deployments whose reset policy is support-mediated or SSO-primary. **Two things bound the abuse and both need checking:** a per-ADDRESS cooldown in the mailer (reuses `emailVerification.resendCooldownSeconds`, own namespace so a pending verification mail cannot block a reset), and the IP-axis `betterAuth.rateLimit` in front of the route — which is OFF unless configured. `/request-password-reset` is in `strictEndpoints`, so it gets the halved limit. Implementation: `better-auth.config.ts` (graft), `core-better-auth-email-verification.service.ts` (delivery) **Swagger (11.42.9):** `false` also removes `/request-password-reset`, `/reset-password` and `/reset-password/{token}` from the document `setupSwagger()` builds — without the hook no token can be minted, so none of them can succeed |
|
|
311
312
|
| BetterAuth Password-Reset Brevo Template | `betterAuth.emailVerification.passwordResetBrevoTemplateId` | Presence Implies Enabled | `undefined` → SMTP/EJS path. When set, the reset mail goes out through the Brevo transactional API with that template id. **Deliberately does NOT fall back to `brevoTemplateId`**: that one is the VERIFICATION mail, and reusing it would tell somebody who asked to reset their password to confirm their address instead. A Brevo send that resolves to `null` (its way of reporting failure unless `brevo.throwOnError` is set) falls THROUGH to SMTP rather than returning, so an outage does not leave a locked-out user with no mail at all. Template variables: `name`, `link`, `appName` |
|
|
312
313
|
| BetterAuth Revoke Sessions on Password Reset | `betterAuth.emailAndPassword.revokeSessionsOnPasswordReset` | Explicit Boolean | `false` (existing sessions survive a reset). Forwarded to Better-Auth's native flag, which honours it on **all three** reset routes (password reset, email-OTP, phone-number). **Off by default because it is a behaviour change for an existing deployment** — after a reset the user is signed out everywhere, including on the devices they still hold. Turn it on where a reset is what somebody reaches for after a suspected takeover: otherwise the attacker keeps their session and the new password changes nothing for them. Matched with `=== true`, so a truthy-but-not-`true` value arriving as JSON through `NSC__*` / `NEST_SERVER_CONFIG` does not silently enable a sign-out-everywhere behaviour. **Prefer the named field over `betterAuth.options`**: it is validated (`=== true`), typed and discoverable. Until 11.36.1 the reason was harsher — `config.options` was spread SHALLOWLY over the resolved config (only `advanced` was deep-merged), so an `options.emailAndPassword` REPLACED the whole block including the scrypt `password.hash` / `password.verify` pair the framework installs, and every credential in the database stopped verifying, at runtime and without a boot error. Since 11.36.1 `emailAndPassword` is deep-merged too, with `password` merged key-by-key on top of the framework's pair (an explicit override still wins; an explicitly-`undefined` half is ignored, because Better-Auth resolves `password?.hash || hashPassword` and would otherwise switch hasher for writes while scrypt still handled reads). That merge is pinned against the REAL `createBetterAuthInstance()` by `tests/unit/better-auth-password-reset-wiring.spec.ts`, with registered mutation `betterauth-emailandpassword-shallow-merge` as its evidence. Implementation: `better-auth.config.ts` (`emailAndPassword` block) |
|
|
@@ -321,10 +322,11 @@ This pattern is currently applied to:
|
|
|
321
322
|
| Translate Response Interceptor | `security.translateResponseInterceptor` | Boolean Shorthand | `true` (enabled) |
|
|
322
323
|
| 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
324
|
| 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. **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/` (
|
|
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 |
|
|
325
|
+
| 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/` (db-agnostic plan §3.9) |
|
|
326
|
+
| 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' }`) — since 12.3.0 together with the tenant's cached memberships and every cached tenant list holding it, so memberships removed without `invalidateUser()` (a deleted tenant) stop counting at once rather than after `cacheTtlMs` (a tenant switched off through `isTenantActive` was already refused at once, its answer being cleared). **Removing a tenant (12.3.0)**: `CoreTenantService.purgeTenantData(tenantId, { dryRun?, exclude? })` first ends every way in — the memberships (`deleteAllMembers()`) and the tenant's API tokens, which need no member — then deletes, in batches of 1000 ids, every registered model with a `tenantId` path (MongoDB and SQL), the core references without one (the AI settings with `scope: 'tenant'`) and the AI connections — one only this tenant could use is deleted, from a shared one the tenant is removed (an empty `tenantIds` means every tenant). `excludeSchemas` and `exclude` are left out, so are users and files; the protected `purgeTenantRecords()` is where a project adds its own references. 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
327
|
| 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
|
-
| 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
|
+
| 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()` or `CoreTenantService.purgeTenantData()`. 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/` |
|
|
329
|
+
| 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
330
|
| 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
331
|
| 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
332
|
| 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 |
|
|
@@ -344,7 +346,7 @@ This pattern is currently applied to:
|
|
|
344
346
|
| Outbound URL Guard (SSRF) | `security.allowPrivateOutboundTargets` | Explicit Boolean with DERIVED default | **Opt-in helper for URLs a USER entered** (webhooks, export targets, ERP addresses) — `outboundFetch()`, `assertOutboundUrlAllowed()`, `createOutboundDispatcher()` in `common/helpers/outbound-url.helper.ts` (11.42.0). Nothing is applied globally: configured targets (AI providers, SMTP, S3, Redis, Brevo) never go through it. Refuses loopback, RFC 1918, link-local incl. `169.254.169.254`, CGNAT, `0/8`, benchmarking, multicast/reserved, IPv6 unspecified/loopback/IPv4-compatible (`::/96`), NAT64, ULA, link-local and multicast, and IPv4-mapped IPv6 in every notation (canonicalised through the URL parser). Checked TWICE: on the URL before sending (readable `OutboundUrlBlockedError`) and on every connection through an undici `Agent` with a guarded connector — the only place that sees a redirect's next hop and a DNS answer at connect time (rebinding). **Unset → derived from the environment**: allowed in `local`, `e2e`, `ci`, refused everywhere else — deliberately including `test`, a DEPLOYED stage in starter-based projects; `true`/`false` (also the strings, from `NSC__SECURITY__ALLOW_PRIVATE_OUTBOUND_TARGETS`) decide explicitly. Per call, `allowedHosts` frees single internal hosts (`'erp.local:8443'` = that port, `'erp.local'` = any port) without opening the rest — re-checked per connection, so a redirect does not inherit it. `outboundFetch()` uses undici's OWN `fetch` (runtime dependency `undici` since 11.42.0), because Node 22's built-in fetch bundles a different undici major than the agent |
|
|
345
347
|
| AI Egress Allowlist (SSRF) | `ai.allowedBaseUrlHosts` | Explicit Value — **unset = permissive**, malformed = INTERPRETED or reported, never silently off | `undefined` → no restriction, so a local provider (Ollama on `localhost:11434`) works out of the box. Set → only those hosts are reachable, matched against `URL.host` (incl. port) and `URL.hostname`. **Accepts an array OR a comma-separated string, and the string form is not a convenience** — it is what the framework's own env mapping produces: `NSC__AI__ALLOWED_BASE_URL_HOSTS` becomes `{ ai: { allowedBaseUrlHosts: '<string>' } }` and lodash `merge` assigns that scalar straight over a configured array. Until 11.39.x a bare `!Array.isArray(...) -> return` read that as "not configured" and skipped the check entirely, so the canonical `NSC__` spelling silently disabled SSRF egress control with no log line at all. Entries are trimmed, lowercased and stripped of a fully-qualifying trailing dot, and the SAME normalisation is applied to the URL, so neither side wins by spelling one DNS name differently; a bare hostname entry matches any port, and an entry naming the scheme's default port (`example.com:443` on https) also matches the portless URL. A value that is neither array nor string carries no hostnames — the allowlist is then inactive and that is LOGGED as an error, because from the outside it looks exactly like "correctly unset". Enforced on **all three** outbound paths (`chat()`, the capability `probe()`, and `probeContextWindow()` — the last was unguarded before 11.39.x and is reachable from an ordinary user prompt via `detectAndPersistCapabilities()`, before the rate limit). Implementation: `openai-compatible.provider.ts` → `assertBaseUrlAllowed()` / `resolveAllowedBaseUrlHosts()` |
|
|
346
348
|
| AI Run Time Limit | `ai.maxRunMs` | **Numeric Sentinel — Family A** (`0` = no limit) | `0` (omitted = unbounded, the previous behaviour). Wall-clock ceiling for ONE prompt run, checked before each agent-loop iteration; once exceeded the run stops and answers with whatever it has. **Without it a run's only bound is `maxIterations` MULTIPLIED BY the connection's per-call timeout** — 8 iterations at the 120s default is a request that can legitimately hold a socket, its message buffer and a request context for 16 minutes, and compaction can add a call per iteration on top. Guarded as `maxRunMs > 0`, never `<= 0`: a value arriving as a non-numeric string through `NSC__AI__MAX_RUN_MS` (the reader coerces only `'true'`, `'false'` and numbers) yields `NaN`, and `NaN > 0` is `false`, so a misconfiguration degrades to "no limit" rather than to an instantly-expired deadline that would break every prompt. Set it to something a client would actually wait for. Implementation: `core-ai.service.ts` -> `runDeadlineExceeded()` |
|
|
347
|
-
| AI Assistant | `ai` | Presence Implies Enabled | Core: `maxIterations: 5`, `defaultMode: 'auto'` (or `'plan'`), `rateLimit` (presence implies enabled: `max: 20`, `windowSeconds: 60`), `systemPrompt`, `documentation` (injected into the system prompt), `encryptionSecret`. **DB-backed LLM connections** (`aiConnections`, admin CRUD) with AES-256-GCM-encrypted API keys (`AiCryptoService`, secret from `ai.encryptionSecret` / `NSC__AI__ENCRYPTION_SECRET` / `SECRETS_ENCRYPTION_KEY`; `apiKeyEncrypted` is a global `secretFields` entry, never returned — only `hasApiKey`); optional `defaultConnection` one-time seed. **Provider abstraction** (`ILlmProvider`, default `OpenAiCompatibleProvider` for any OpenAI-compatible endpoint via `fetch`; per-connection `supportsNativeTools`/`supportsJsonResponse` capabilities, emulated tool calling when native tools are unavailable). **Tool registry** (`AiToolRegistry`, tools self-register, role-filtered — under multi-tenancy (11.42.0+) a tool's roles resolve like `@Roles()` in `CoreTenantGuard`: global roles against `user.roles`, tenant roles against the request's membership role, ADMIN bypass only where `adminBypass` grants it, so chat and MCP never exceed the user's own rights; an MCP session reads the user per request, so a revoked role stops working without a reconnect; tools may be `mutating`/`destructive` and define `authorize()` for pre-flight data-level checks). **Plan mode** (`input.mode: 'plan'`): full plan → pre-flight authorize ALL steps → all-or-nothing execution with a translated (de/en) error when any step is not permitted. **Confirmation policy**: `confirmation.mutating: { default, enforced }` + client `input.requireConfirmation` (ignored when enforced); `destructive` always confirms. **Client metadata** (`input.metadata`: URL/nav/console logs, untrusted+capped). **Multi-turn conversations** (`aiConversations`, owner-scoped). **SSE streaming** (`POST /ai/stream`). **Audit** (`audit: false` → persist to `aiInteractions`, admin-readable). **Token budgets** (`budget: { period: 'day'|'month'|'none', user: { maxTokens?, maxPrompts? }, tenant: { maxTokens?, maxPrompts? } }`, requires audit): per-user AND per-tenant limits with config defaults; admins override per user/tenant at runtime (`aiBudgetLimits`, `CoreAiBudgetService`). Resolution: override → default → unlimited (missing/0 = unlimited). Enforced before the run (HTTP 429 + translated). Each response carries a compact `budget` summary (promptTokens, usedTokens, remainingTokens, resetAt); full breakdown via `aiUsage` query / `GET /ai/usage`. **Self-optimizing prompts**: the system prompt is assembled from keyed fragments (`CoreAiPromptBuilderService` ships built-in defaults; works with zero rows). Admin-editable overrides per slot (`aiSlots`, admin CRUD, `/ai/slots`) scoped by `key`/`locale`/`capability`/`tenantId`, with tenant override/reset semantics and placeholder tokens resolved at run time via the placeholder registry. **Governed learning loop** (`promptLearning: { enabled: true, autoApply: false }`): tool errors record `suggested` hints (`aiPromptHints`, admin CRUD, `/ai/prompt-hints`) that only reach the prompt once admin-approved (or auto-approved when `autoApply`); hints only ADD guidance, never relax permissions. **Context window** (`contextWindow`, default 8192; auto-detected per connection via `ILlmProvider.detectContextWindow()` — Ollama `/api/show` probe / known-model table / Claude alias — and persisted): per-user/session history is trimmed (oldest non-system turns dropped, last truncated) and tool-results capped to `maxToolResultChars` (default 12000) so a session never overflows the model. A connection's window can be seeded via `ai.defaultConnection.contextWindow` (validated: a non-positive/non-integer value is dropped with a warning). **Capability drift check** (`capabilityDriftCheck`, default `false`): opt-in boot self-check that probes each enabled connection with an EXPLICIT `supportsNativeTools`/`supportsJsonResponse` (built with those flags cleared so the endpoint is actually re-probed) and logs a warning on mismatch — the stored value is never changed. OFF by default because it makes outbound calls to the LLM endpoints on every boot; also skipped in the ci/e2e runners. **Deferred tool schemas** (`deferToolSchemas`, default `false`): the system-prompt tool catalog then lists only tool NAMES + descriptions instead of full JSON schemas, and the model fetches a schema on demand via the built-in `search_tools` meta-tool — with a large registry the schemas alone can dominate a small context window. `deferToolSummaryChars` (default `0` = untruncated) additionally caps each description in that DEFERRED catalog: whole sentences up to the cap (always at least the first), word-boundary cut when the first sentence already exceeds it, and a `…` marker appended ON TOP of the cap. The default of `0` keeps the saving opt-in, so enabling `deferToolSchemas` alone never changes what a description says; set roughly 200–400 alongside it to actually reclaim the context. Both apply to EMULATED providers only — a connection with `supportsNativeTools: true` receives every full description + schema via `buildToolSchemas()` regardless, so truncation and the banner are skipped there rather than asserting a cut the tool payload contradicts. The omitted tail is where preconditions and role restrictions usually live — the catalog banner tells the model to fetch the full text via `search_tools` first, but this is model GUIDANCE only: authorization is enforced server-side by the registry's role filter (`forUser()`), the execution-time re-check, and the `mutating`/`destructive` flags read by the confirmation gate — never by what the catalog shows. (`AiTool.authorize()` runs in PLAN MODE only; in auto mode and over MCP, data-level checks must live inside `execute()`.) **MCP server** (`mcp: false` → `/ai/mcp` Streamable HTTP, Bearer auth, lazy `@modelcontextprotocol/sdk`; `mcp: { oauth: true, oauthSecret }` adds OAuth 2.1 — HMAC tokens + PKCE S256 + dynamic registration. **Since 11.43.0 `CoreAiMcpOAuthInitializer` mounts the endpoints during `app.init()` (`onModuleInit`)** — behind the Nest middleware and routes, ahead of Nest's not-found handler; `mountAiMcpOAuth(app)` after `app.init()`, the documented way before, sat behind that handler and answered 404 everywhere, and is a no-op now. The protected resource is described at the path Nest registered `/ai/mcp` at (global prefix + exclusions, URI versioning, `RouterModule`, via Nest's own `RoutePathFactory`), and the MCP 401 names that metadata URL — plain `Bearer` without OAuth. Issuer: `mcp.oauthBaseUrl` (its origin is added to Better-Auth's `trustedOrigins`, explicit list included, via `withMcpOAuthIssuerOrigin()`), else `baseUrl`; a deployed environment with neither fails the boot. Signing secret required everywhere except `local` / `ci` / `e2e` (`allowsDevelopmentSecret()` → `isLocalEnv`), deployed `develop` / `test` stages included. Under a global prefix the consent page resolves the session itself (the Better-Auth middleware runs below the prefix only) and signs in at the prefixed IAM path. Express only (boot error otherwise); an application context without HTTP server mounts nothing. Consent: a built-in page (`authorizeConsent()` default) — sign-in through Better-Auth on the API origin (password + TOTP/backup code; passkey/social via `mcp.oauthLoginUrl`, default `<appUrl>/auth/login`), then Allow/Deny as a form POST with a CSRF token signed and bound to user, client, redirect URI and PKCE challenge (10 min); Allow is `disabled` until a nonce'd guard arms it and counts a click only after intent (visible + focused + pointer move / key / touch ≥ 600 ms later — against double-clickjacking); the account is shown first with "use another account"; own CSP with nonces + `frame-ancestors 'none'`, everything escaped; boot warning without Better-Auth; restyle via `renderConsentPage()`, replace via the `mcpOAuthService` override. A code saved while answering a GET to `/authorize` is refused for every consent implementation. Refusals are SDK `OAuthError` classes (`invalid_grant` etc.), never a plain `Error`, which the SDK answers as 500). Overrides via `CoreModule.forRoot(env, { ai: { budgetService, connectionResolver, connectionService, controller, conversationService, interactionService, mcpClientService, mcpOAuthService, modeService, placeholderRegistry, preferenceService, promptBuilder, promptHintService, promptService, resolver, service, slotService, toolGrantService, toolPolicyService } })` |
|
|
349
|
+
| AI Assistant | `ai` | Presence Implies Enabled | Core: `maxIterations: 5`, `defaultMode: 'auto'` (or `'plan'`), `rateLimit` (presence implies enabled: `max: 20`, `windowSeconds: 60`), `systemPrompt`, `documentation` (injected into the system prompt), `encryptionSecret`. **DB-backed LLM connections** (`aiConnections`, admin CRUD) with AES-256-GCM-encrypted API keys (`AiCryptoService`, secret from `ai.encryptionSecret` / `NSC__AI__ENCRYPTION_SECRET` / `SECRETS_ENCRYPTION_KEY`; `apiKeyEncrypted` is a global `secretFields` entry, never returned — only `hasApiKey`); optional `defaultConnection` one-time seed. **Provider abstraction** (`ILlmProvider`, default `OpenAiCompatibleProvider` for any OpenAI-compatible endpoint via `fetch`; per-connection `supportsNativeTools`/`supportsJsonResponse` capabilities, emulated tool calling when native tools are unavailable). **Tool registry** (`AiToolRegistry`, tools self-register, role-filtered — under multi-tenancy (11.42.0+) a tool's roles resolve like `@Roles()` in `CoreTenantGuard`: global roles against `user.roles`, tenant roles against the request's membership role, ADMIN bypass only where `adminBypass` grants it, so chat and MCP never exceed the user's own rights; an MCP session reads the user per request, so a revoked role stops working without a reconnect; tools may be `mutating`/`destructive` and define `authorize()` for pre-flight data-level checks). **Plan mode** (`input.mode: 'plan'`): full plan → pre-flight authorize ALL steps → all-or-nothing execution with a translated (de/en) error when any step is not permitted. **Confirmation policy**: `confirmation.mutating: { default, enforced }` + client `input.requireConfirmation` (ignored when enforced); `destructive` always confirms. **Client metadata** (`input.metadata`: URL/nav/console logs, untrusted+capped). **Multi-turn conversations** (`aiConversations`, owner-scoped). **SSE streaming** (`POST /ai/stream`). **Audit** (`audit: false` → persist to `aiInteractions`, admin-readable). **Token budgets** (`budget: { period: 'day'|'month'|'none', user: { maxTokens?, maxPrompts? }, tenant: { maxTokens?, maxPrompts? } }`, requires audit): per-user AND per-tenant limits with config defaults; admins override per user/tenant at runtime (`aiBudgetLimits`, `CoreAiBudgetService`). Resolution: override → default → unlimited (missing/0 = unlimited). Enforced before the run (HTTP 429 + translated). Each response carries a compact `budget` summary (promptTokens, usedTokens, remainingTokens, resetAt); full breakdown via `aiUsage` query / `GET /ai/usage`. **Self-optimizing prompts**: the system prompt is assembled from keyed fragments (`CoreAiPromptBuilderService` ships built-in defaults; works with zero rows). Admin-editable overrides per slot (`aiSlots`, admin CRUD, `/ai/slots`) scoped by `key`/`locale`/`capability`/`tenantId`, with tenant override/reset semantics and placeholder tokens resolved at run time via the placeholder registry. **Governed learning loop** (`promptLearning: { enabled: true, autoApply: false }`): tool errors record `suggested` hints (`aiPromptHints`, admin CRUD, `/ai/prompt-hints`) that only reach the prompt once admin-approved (or auto-approved when `autoApply`); hints only ADD guidance, never relax permissions. **Context window** (`contextWindow`, default 8192 — the fallback for connections without a window of their own; native tool definitions count against the input budget and a system prompt plus tools above half of it is warned about once per connection since 12.3.0; auto-detected per connection via `ILlmProvider.detectContextWindow()` — Ollama `/api/show` probe / known-model table / Claude alias — and persisted): per-user/session history is trimmed (oldest non-system turns dropped, last truncated) and tool-results capped to `maxToolResultChars` (default 12000) so a session never overflows the model. A connection's window can be seeded via `ai.defaultConnection.contextWindow` (validated: a non-positive/non-integer value is dropped with a warning). **Capability drift check** (`capabilityDriftCheck`, default `false`): opt-in boot self-check that probes each enabled connection with an EXPLICIT `supportsNativeTools`/`supportsJsonResponse` (built with those flags cleared so the endpoint is actually re-probed) and logs a warning on mismatch — the stored value is never changed. OFF by default because it makes outbound calls to the LLM endpoints on every boot; also skipped in the ci/e2e runners. **Deferred tool schemas** (`deferToolSchemas`, default `false`): the system-prompt tool catalog then lists only tool NAMES + descriptions instead of full JSON schemas, and the model fetches a schema on demand via the built-in `search_tools` meta-tool — with a large registry the schemas alone can dominate a small context window. `deferToolSummaryChars` (default `0` = untruncated) additionally caps each description in that DEFERRED catalog: whole sentences up to the cap (always at least the first), word-boundary cut when the first sentence already exceeds it, and a `…` marker appended ON TOP of the cap. The default of `0` keeps the saving opt-in, so enabling `deferToolSchemas` alone never changes what a description says; set roughly 200–400 alongside it to actually reclaim the context. Both apply to EMULATED providers only — a connection with `supportsNativeTools: true` receives every full description + schema via `buildToolSchemas()` regardless, so truncation and the banner are skipped there rather than asserting a cut the tool payload contradicts. **Native catalog** (`nativeToolCatalog`, 12.3.0+): such a connection's prompt catalog lists names only (`'names'`, default) instead of repeating what the payload carries; `'full'` restores it. Emulated connections and plan mode keep the full catalog. The omitted tail is where preconditions and role restrictions usually live — the catalog banner tells the model to fetch the full text via `search_tools` first, but this is model GUIDANCE only: authorization is enforced server-side by the registry's role filter (`forUser()`), the execution-time re-check, and the `mutating`/`destructive` flags read by the confirmation gate — never by what the catalog shows. (`AiTool.authorize()` runs before EVERY execution since 12.3.0 — auto mode, plan mode, MCP — on the arguments after the PreToolUse hooks, fail-closed; a refusal is `TOOL_NOT_AVAILABLE` with its reason and no learning signal, and `data` reaches `execute()` as `context.authorization.data`. Data access in `execute()` still goes through `CrudService` with `context.serviceOptions`.) **MCP server** (`mcp: false` → `/ai/mcp` Streamable HTTP, Bearer auth, lazy `@modelcontextprotocol/sdk`; since 12.3.0 `tools/call` applies the admin's tool policies (`deny` refuses, `ask` asks), `authorize()` and the confirmation of `destructive` tools (and `mutating` ones under `ai.confirmation.mutating`) through MCP elicitation — only an explicit yes runs the tool, a client without elicitation gets a refusal; `mcp.confirmation` (`true` default, `false`, `{ default, enforced }`, only `false` / `enforced: true` count) is the default each user may switch for themselves like a "bypass permissions" mode via `GET/PUT /ai/settings` / `aiSettings` / `updateAiSettings` (stored in `aiUserSettings`, 403 `#LTNS_0609` while enforced); `tools/list` annotates `destructive` / `mutating` tools (`destructiveHint`, `readOnlyHint: false`), an unflagged tool gets none — never advertised as read-only; `mcp.stateless: true` (12.3.0+, default `false`) serves `/ai/mcp` without sessions — a server and transport per POST, no `mcp-session-id`, a sent one ignored, GET / DELETE 405 with `Allow: POST`, auth and tenant per request — so any replica answers and no sticky sessions are needed; no server-initiated messages or requests there, so no elicitation: a tool that needs confirmation is refused unless the user or `mcp.confirmation` switched the question off; the consent page's wording and CSS change through `renderMcpOAuthConsentPage(view, { texts, styles })` from an overridden `renderConsentPage()` (code/config only; a `<` in styles is neutralized, CSP blocks external loads); `mcp: { oauth: true, oauthSecret }` adds OAuth 2.1 — HMAC tokens + PKCE S256 + dynamic registration. **Since 11.43.0 `CoreAiMcpOAuthInitializer` mounts the endpoints during `app.init()` (`onModuleInit`)** — behind the Nest middleware and routes, ahead of Nest's not-found handler; `mountAiMcpOAuth(app)` after `app.init()`, the documented way before, sat behind that handler and answered 404 everywhere, and is a no-op now. The protected resource is described at the path Nest registered `/ai/mcp` at (global prefix + exclusions, URI versioning, `RouterModule`, via Nest's own `RoutePathFactory`), and the MCP 401 names that metadata URL — plain `Bearer` without OAuth. Issuer: `mcp.oauthBaseUrl` (its origin is added to Better-Auth's `trustedOrigins`, explicit list included, via `withMcpOAuthIssuerOrigin()`), else `baseUrl`; a deployed environment with neither fails the boot. Signing secret required everywhere except `local` / `ci` / `e2e` (`allowsDevelopmentSecret()` → `isLocalEnv`), deployed `develop` / `test` stages included. Under a global prefix the consent page resolves the session itself (the Better-Auth middleware runs below the prefix only) and signs in at the prefixed IAM path. Express only (boot error otherwise); an application context without HTTP server mounts nothing. Consent: a built-in page (`authorizeConsent()` default) — sign-in through Better-Auth on the API origin (password + TOTP/backup code; passkey/social via `mcp.oauthLoginUrl`, default `<appUrl>/auth/login`), then Allow/Deny as a form POST with a CSRF token signed and bound to user, client, redirect URI and PKCE challenge (10 min); Allow is `disabled` until a nonce'd guard arms it and counts a click only after intent (visible + focused + pointer move / key / touch ≥ 600 ms later — against double-clickjacking); the account is shown first with "use another account"; own CSP with nonces + `frame-ancestors 'none'`, everything escaped; boot warning without Better-Auth; restyle via `renderConsentPage()`, replace via the `mcpOAuthService` override. A code saved while answering a GET to `/authorize` is refused for every consent implementation. Since 12.3.0 an error the SDK finds before the provider's `authorize` runs (a malformed request) is answered with 400 instead of a 302 to the client's self-registered redirect URI — that was an open redirect on the API domain (RFC 9700 §4.11.2); registration stays open (no redirect-URI allowlist, claude.ai / ChatGPT / Cursor / Claude Code register their own), and the built-in Deny needs the consent token like Allow (`mountAiMcpOAuthRouter()` wraps the SDK router, `authorizeConsent()`). Refusals are SDK `OAuthError` classes (`invalid_grant` etc.), never a plain `Error`, which the SDK answers as 500). Overrides via `CoreModule.forRoot(env, { ai: { budgetService, connectionResolver, connectionService, controller, conversationService, interactionService, mcpClientService, mcpOAuthService, modeService, placeholderRegistry, preferenceService, promptBuilder, promptHintService, promptService, resolver, service, slotService, toolGrantService, toolPolicyService, userSettingService } })` |
|
|
348
350
|
| Swagger API-Token View | `setupSwagger(app, { apiTokenView })` (helper option, not server config) | Boolean Shorthand / Presence Implies Enabled | **Off without the option** (11.42.7+). `true` / `{}` serves a second document with only the routes that release a scope via `@ApiTokenScopes()`: UI at `swagger-api-tokens`, JSON at `/api-docs-api-tokens-json`, title `<title> — API tokens`, bearer as the only scheme, unused schemas pruned; the tenant header stays while `apiTokens.userTokens` is on (a personal token chooses the company like a session) and is dropped with company tokens only. Keep its path OUTSIDE the full UI's path: Swagger UI serves its assets below its own path. The full document's enrichment (global bearer + session-cookie requirement, `security: []` on public routes, `x-api-token-scopes` + description line, `TenantHeader` parameter) is not optional — it is what `setupSwagger()` is for, and it reads the same metadata as the guards. Implementation: `common/helpers/swagger.helper.ts` **Better-Auth routes (11.42.9):** the full document also lists every route Better-Auth serves itself under the base path — whatever the configured plugins add — from Better-Auth's own generator, under the project's Better-Auth controller (its tags and `operationTags`, ids `<Controller>_betterAuth_<id>`, schemas prefixed `BetterAuth`, OpenAPI 3.1 constructs converted to 3.0). Their security is what Better-Auth enforces, not the generator's blanket bearer-only: a route behind one of Better-Auth's session middlewares (or one of the three social-account routes in `HANDLER_SESSION_PATHS`, which check in the handler) gets the global requirement, every other route `security: []` — except routes of plugins nest-server does not register itself (`BUILT_IN_BETTER_AUTH_PLUGIN_IDS`), which always keep the global requirement — and if the instance's middlewares cannot be identified (two copies of better-auth), all of them keep the global requirement rather than risk documenting a session route as public. They carry the "not allowed" API-token line, and never reach the API-token view. The `openAPI()` plugin this needs is registered internally; its routes answer 404 (see `.claude/rules/better-auth.md` §9). **Only usable operations (11.42.9):** an operation that answers an error to every caller under the running configuration is left out — `@ApiMethods()` on `@All()` handlers (TUS), legacy `/auth/*` while `auth.legacyEndpoints` keeps REST closed, Better-Auth routes refused by the configuration or the live instance, Hub endpoints of switched-off panels. `/ai/mcp` and the MCP OAuth endpoints that are actually mounted are documented. An explicit `@ApiBearerAuth()` keeps a guard-public route from being documented as public. Rules: `isSwitchedOffOperation()` in `swagger.helper.ts` |
|
|
349
351
|
|
|
350
352
|
## Module Override Pattern (via `ICoreModuleOverrides`)
|
|
@@ -377,7 +379,7 @@ CoreModule.forRoot(CoreAuthService, AuthModule.forRoot(envConfig.jwt), envConfig
|
|
|
377
379
|
|
|
378
380
|
| Module | Fields | Description |
|
|
379
381
|
|--------|--------|-------------|
|
|
380
|
-
| `ai` | `budgetService`, `connectionResolver`, `connectionService`, `controller`, `conversationService`, `interactionService`, `mcpClientService`, `mcpOAuthService`, `modeService`, `placeholderRegistry`, `preferenceService`, `promptBuilder`, `promptHintService`, `promptService`, `resolver`, `service`, `slotService`, `toolGrantService`, `toolPolicyService` | Custom AI orchestrator, connection-resolution chain, connection/preference/budget/conversation/interaction/MCP-client services, MCP OAuth service (the consent step), slot + user-prompt + learning-hint stores, mode service, placeholder registry, tool-grant + tool-policy services, endpoints |
|
|
382
|
+
| `ai` | `budgetService`, `connectionResolver`, `connectionService`, `controller`, `conversationService`, `interactionService`, `mcpClientService`, `mcpOAuthService`, `modeService`, `placeholderRegistry`, `preferenceService`, `promptBuilder`, `promptHintService`, `promptService`, `resolver`, `service`, `slotService`, `toolGrantService`, `toolPolicyService`, `userSettingService` | Custom AI orchestrator, connection-resolution chain, connection/preference/budget/conversation/interaction/MCP-client services, MCP OAuth service (the consent step), slot + user-prompt + learning-hint stores, mode service, placeholder registry, tool-grant + tool-policy services, personal-settings store, endpoints |
|
|
381
383
|
| `errorCode` | `controller`, `service` | Custom error code endpoint and/or service |
|
|
382
384
|
| `betterAuth` | `controller`, `resolver` | Custom IAM REST controller and/or GraphQL resolver |
|
|
383
385
|
| `hub` | `controller`, `actionsController`, `service`, `htmlService`, `actionsService` | Custom Hub page/actions controllers and aggregator/HTML/actions services (each must extend its `Core*` base) |
|
|
@@ -370,7 +370,7 @@ removed an export its consumer imports, or the consumer pins the vulnerable rang
|
|
|
370
370
|
`FIX AVAILABLE` is not advice but a dead end, and the only ways out used to be a permanently red
|
|
371
371
|
build or a guard switched off for that entry.
|
|
372
372
|
|
|
373
|
-
Measured case (
|
|
373
|
+
Measured case (a consuming Nuxt project, 2026-10-06): three `simple-git` advisories, one CRITICAL requiring `>=4.0.1`,
|
|
374
374
|
reachable only via `nuxt > @nuxt/devtools > simple-git`. simple-git 4 dropped the default export
|
|
375
375
|
devtools imports, so the override made `nuxt prepare` fail outright. The newest devtools in the 3.x
|
|
376
376
|
line still imports it that way, and devtools is a hard `dependencies` entry of `nuxt` — so it can be
|
|
@@ -401,6 +401,25 @@ 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
|
+
**"Pulls an affected package" means an affected VERSION (12.3.0).** Checks 3 and the idle check below
|
|
405
|
+
count an edge only when its resolved version lies in one of the advisory's `vulnerable_version_range`s.
|
|
406
|
+
A parent pulling the FIXED release is no consumer of the vulnerability. Until 12.3.0 every parent
|
|
407
|
+
counted, so lifting one subtree to the fixed major (glob 14.0.3 pulling minimatch 11.0.3 next to
|
|
408
|
+
consumers still on minimatch 10) failed a correct declaration for good. An edge whose version cannot be
|
|
409
|
+
read (`link:`, `file:`), or any edge of an advisory that lists no range, still counts: "I could not tell
|
|
410
|
+
which version it pulls" must not read as "it pulls the fixed one" (mutations
|
|
411
|
+
`unusable-fix-counts-fixed-edges`, `unusable-fix-skips-unreadable-edge`). An edge is matched by the package it
|
|
412
|
+
PULLS, not by its key: pnpm keys an npm alias by the alias (`mm-cjs: minimatch@5.1.9`, an importer's
|
|
413
|
+
`version: minimatch@5.1.9`), so a key match missed every consumer behind an alias and read an edge keyed
|
|
414
|
+
`minimatch` that pulls a fork as a minimatch edge (mutation `unusable-fix-matches-alias-by-key`).
|
|
415
|
+
|
|
416
|
+
**Several consumers (12.1.1+)** are joined with ` + `, each at its assessed version —
|
|
417
|
+
`'@nestjs/cli@12.0.11 + nodemon@3.1.14 cannot use 11.0.1'`. One vulnerable package often sits under
|
|
418
|
+
several dev tools at once, and before this a declaration could name only one, so such a finding had no
|
|
419
|
+
honest way in. Every listed consumer gets checks 1 and 3, and one more: a listed consumer that no
|
|
420
|
+
longer pulls an affected package fails too (mutation `unusable-fix-accepts-idle-consumer`), because a
|
|
421
|
+
list is where a path that is gone would otherwise keep covering.
|
|
422
|
+
|
|
404
423
|
The third check reads **both halves** of the lockfile. `importers:` holds what the repository itself
|
|
405
424
|
declares (`.`, `projects/api`, `projects/app`), `snapshots:` the edges between third-party packages
|
|
406
425
|
— and the first is the likelier production path, because a direct dependency can simply be raised.
|
|
@@ -430,7 +449,15 @@ plus one for the opposite failure, a guard that rejects a declaration which is s
|
|
|
430
449
|
matters just as much: a check that cries wolf on correct entries is a check that gets switched off,
|
|
431
450
|
which is how the hole this feature closes came about in the first place.
|
|
432
451
|
|
|
433
|
-
**
|
|
452
|
+
**Current entries (since 2026-10-31, owner decision, DEV-3899).** `GHSA-g3hv-75wg-m249` (minimatch
|
|
453
|
+
`< 11.0.1`) and `GHSA-3g3m-2fjr-6pw4` (glob `<= 14.0.1`), both moderate, both fixed only in a new major
|
|
454
|
+
released 2026-10-27 that none of their consumers can take yet — five for minimatch (`@nestjs/cli` pins
|
|
455
|
+
10.2.6 exactly), rimraf for glob. The justification is in the comment above `ignoreGhsas`, the
|
|
456
|
+
declarations in `unusableFixConsumers`. `tests/unit/audit-suppression-scope.spec.ts` keeps glob out of
|
|
457
|
+
the production tree and allows minimatch there only under `@ts-morph/common` (ts-morph, the Permissions
|
|
458
|
+
module's scanner, matching globs the developer writes) — its `prodVia` form.
|
|
459
|
+
|
|
460
|
+
**Before that, no entry from 2026-10-24.** `GHSA-vfj7-8cjw-p6xm` (braces, via nodemon / npm-watch) was
|
|
434
461
|
listed from 2026-10-11 to 2026-10-24: a dev-tooling-only path of an advisory with no patched release
|
|
435
462
|
at all. GitHub withdrew the advisory on 2026-10-23, and `check:overrides` reported the entry as
|
|
436
463
|
obsolete on the next run — the guard doing exactly its job, since a withdrawal changes nothing in
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Persistence Rules (MongoDB, PostgreSQL, MySQL and MariaDB)
|
|
2
2
|
|
|
3
3
|
These rules apply to every model, service and query — in `src/core/` and in projects. The design and its
|
|
4
|
-
reasoning are in
|
|
4
|
+
reasoning are in the db-agnostic design plan (internal, not part of this repository); which database a project uses is decided by
|
|
5
5
|
`docs/choosing-a-database.md`; the configuration reference is the "Data Sources" row of
|
|
6
6
|
`.claude/rules/configurable-features.md`.
|
|
7
7
|
|
|
@@ -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
|
+
offline-sync plan §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
|
package/.claude/rules/testing.md
CHANGED
|
@@ -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
|
|
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
|
|
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,12 +77,12 @@ 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
|
-
**PostgreSQL runs with two roles, as every environment does** (
|
|
85
|
+
**PostgreSQL runs with two roles, as every environment does** (db-agnostic plan
|
|
84
86
|
§3.9): `nest_owner` owns the databases and their schema, `nest_app` owns nothing and has neither
|
|
85
87
|
superuser nor `BYPASSRLS` — so a suite running as the application role meets row-level security the
|
|
86
88
|
way production does. The readiness step creates `nest_app`, which is why the container is started
|
|
@@ -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.
|
|
@@ -311,6 +319,36 @@ reason, never fall back to MongoDB, while the `e2e` project runs. `--project e2e
|
|
|
311
319
|
|
|
312
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.
|
|
313
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
|
+
|
|
314
352
|
### A cell is EXECUTED, IMPOSSIBLE, or DIFFERENT-BY-DESIGN — never merely absent
|
|
315
353
|
|
|
316
354
|
Omitting a case for one implementation and declaring it impossible there produce the same artefact
|
|
@@ -372,8 +410,9 @@ pnpm run check:mutations -- --id=<id> # one mutation
|
|
|
372
410
|
pnpm run check:mutations -- --list # the registry, without running anything
|
|
373
411
|
pnpm run check:mutations -- --allow-dirty # when the fix and its evidence share a working tree
|
|
374
412
|
pnpm run check:mutations -- --jobs=4 # N mutations at a time (default: 2, or 4 on >=12 cores)
|
|
375
|
-
pnpm run check:mutations -- --no-infra # only the
|
|
413
|
+
pnpm run check:mutations -- --no-infra # only the 311 that need no MongoDB
|
|
376
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)
|
|
377
416
|
```
|
|
378
417
|
|
|
379
418
|
`--no-infra` and `--since` narrow the run for LOCAL work and are deliberately not wired into the
|
|
@@ -391,7 +430,7 @@ when the registry, a vitest config or a setup file changed, since those can move
|
|
|
391
430
|
commit, and in CI alongside the publish without blocking it. The price of caching is a cache that
|
|
392
431
|
has to model each spec's full dependency closure correctly, and getting that wrong produces a stale
|
|
393
432
|
PASS for a test that has since gone vacuous — exactly what the gate is there to prevent. Bad trade
|
|
394
|
-
at
|
|
433
|
+
at 530 mutations.
|
|
395
434
|
|
|
396
435
|
**Decided 2026-10-03 (repo owner): keep the full run.** Measured at 128 mutations, the CI job for
|
|
397
436
|
11.41.6 took **14 minutes**. The trigger to revisit is that wall time, not the mutation count —
|
|
@@ -399,6 +438,32 @@ the count was never the cost: re-evaluate once the "Regression evidence" job in
|
|
|
399
438
|
exceeds **30 minutes** (`gh run view <id> --json jobs` shows start and end). Below that line,
|
|
400
439
|
adding a mutation needs no decision.
|
|
401
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
|
+
|
|
402
467
|
Not part of `pnpm run check` — it edits source and re-runs whole e2e suites. It belongs in review
|
|
403
468
|
and on the publish path. It is also reachable on demand, without cutting a release:
|
|
404
469
|
|
|
@@ -436,9 +501,9 @@ exactly the environment where the answer matters.
|
|
|
436
501
|
|
|
437
502
|
### The cost is vitest's cold start, not the tests
|
|
438
503
|
|
|
439
|
-
Worth knowing before optimising the wrong thing: the specs behind all
|
|
504
|
+
Worth knowing before optimising the wrong thing: the specs behind all 219 e2e mutations add up to
|
|
440
505
|
**~40 seconds**. The step takes ~740s. The remaining ~700s is paying vitest's startup — process
|
|
441
|
-
spawn, transform, module graph, mongod connect, DB create and drop — once per mutation,
|
|
506
|
+
spawn, transform, module graph, mongod connect, DB create and drop — once per mutation, 530 times.
|
|
442
507
|
That work is largely single-threaded I/O and barely scales with cores: the full registry measures
|
|
443
508
|
**744s on a 12-core laptop and 777s on a 4-vCPU CI runner**.
|
|
444
509
|
|
|
@@ -25,6 +25,13 @@
|
|
|
25
25
|
1. **Document breaking changes** clearly in commit messages when incrementing MINOR version
|
|
26
26
|
2. **Update nest-server-starter** with migration instructions for breaking changes
|
|
27
27
|
3. **Consider downstream impact** - this package is used by multiple projects
|
|
28
|
+
4. **Every published version gets its nest-server-starter version** (repo owner, 2026-11-03). The
|
|
29
|
+
starter's `version` always equals the `@lenne.tech/nest-server` version it pins. Every version
|
|
30
|
+
published to npm is followed by the starter commit `Updated to nest-server version <X.Y.Z>`, tagged
|
|
31
|
+
`v<X.Y.Z>` by the starter's `tag.yml`, also when the bump is the only change. Starter changes
|
|
32
|
+
between two releases never change its version: the starter follows nest-server, never the other
|
|
33
|
+
way round. This covers the maintenance line too: a release from `release/11.x` is followed on the
|
|
34
|
+
starter's `release/11.x` branch.
|
|
28
35
|
|
|
29
36
|
## Release Process
|
|
30
37
|
|
|
@@ -36,8 +43,12 @@
|
|
|
36
43
|
(You can also sync manually any time via `pnpm run spectaql:sync`.)
|
|
37
44
|
3. Build the package (`pnpm run build`) — this also regenerates `FRAMEWORK-API.md` with the new version
|
|
38
45
|
4. Publish to npm
|
|
39
|
-
5.
|
|
40
|
-
|
|
46
|
+
5. **Release [nest-server-starter](https://github.com/lenneTech/nest-server-starter) with the same
|
|
47
|
+
version — a required step, not an optional "update and test".** Raise the framework and the
|
|
48
|
+
starter's own `version` to the new version (`pnpm run update`, migration guides applied), test, and
|
|
49
|
+
commit as `Updated to nest-server version <X.Y.Z>`; the starter's `tag.yml` tags it `v<X.Y.Z>` (rule 4).
|
|
50
|
+
6. A release made with `/lt-dev:publish --skip-downstream` leaves a gap in the starter's tag series.
|
|
51
|
+
Close it before the next nest-server release.
|
|
41
52
|
|
|
42
53
|
### Version Consistency Check
|
|
43
54
|
|
package/CLAUDE.md
CHANGED
|
@@ -192,6 +192,11 @@ pnpm run reinit # Clean reinstall + tests + build
|
|
|
192
192
|
|
|
193
193
|
## Code Architecture
|
|
194
194
|
|
|
195
|
+
**Internal notes are not in this repository.** The review agents' memory (`.claude/agent-memory`) and the design
|
|
196
|
+
plans of larger epics (`.claude/plans`, cited in code comments as `plan §3.4` or `offline-sync plan §3.4`) live in a
|
|
197
|
+
private repository of the team and are linked in as gitignored symlinks. This repository is public: never copy their
|
|
198
|
+
contents into a tracked file, a commit message or a PR. Pinned by `tests/unit/public-repo-hygiene.spec.ts`.
|
|
199
|
+
|
|
195
200
|
**Two-Layer Structure:**
|
|
196
201
|
- `src/core/` - Reusable framework components (exported)
|
|
197
202
|
- `src/server/` - Internal test implementation (not exported)
|
|
@@ -200,10 +205,11 @@ pnpm run reinit # Clean reinstall + tests + build
|
|
|
200
205
|
**Key Components:**
|
|
201
206
|
- `CoreModule` - Dynamic module with GraphQL (optional, disable via `graphQl: false`), MongoDB, security
|
|
202
207
|
- `src/core/common/` - Decorators, helpers, interceptors, services
|
|
203
|
-
- `src/core/modules/` - ApiToken, Auth, BetterAuth, ErrorCode, File, HealthCheck, Hub, Migrate, Permissions, SystemSetup, Tus, User
|
|
208
|
+
- `src/core/modules/` - ApiToken, Auth, BetterAuth, ErrorCode, File, HealthCheck, Hub, Migrate, Permissions, Sync, SystemSetup, Tus, User
|
|
204
209
|
- **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
210
|
- **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
211
|
- **Permissions** - ADMIN-gated security-map report (routes + roles + `@Restricted` fields); also surfaced in the Hub's "Routes / Permissions" panel.
|
|
212
|
+
- **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`.
|
|
207
213
|
- **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
214
|
|
|
209
215
|
See `.claude/rules/architecture.md` for detailed documentation.
|