create-easy-stack 1.0.3
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/bin/create.mjs +244 -0
- package/infra/email/files/src/core/config/email.env.ts +73 -0
- package/infra/email/files/src/core/infrastructure/email/email-dispatcher.port.ts +13 -0
- package/infra/email/files/src/core/infrastructure/email/email-layout.spec.ts +83 -0
- package/infra/email/files/src/core/infrastructure/email/email-layout.ts +88 -0
- package/infra/email/files/src/core/infrastructure/email/email-sender.factory.spec.ts +87 -0
- package/infra/email/files/src/core/infrastructure/email/email-sender.factory.ts +35 -0
- package/infra/email/files/src/core/infrastructure/email/email-sender.port.ts +19 -0
- package/infra/email/files/src/core/infrastructure/email/email.job.spec.ts +101 -0
- package/infra/email/files/src/core/infrastructure/email/email.job.ts +64 -0
- package/infra/email/files/src/core/infrastructure/email/email.module.spec.ts +60 -0
- package/infra/email/files/src/core/infrastructure/email/email.module.ts +34 -0
- package/infra/email/files/src/core/infrastructure/email/log-email-sender.spec.ts +34 -0
- package/infra/email/files/src/core/infrastructure/email/log-email-sender.ts +17 -0
- package/infra/email/files/src/core/infrastructure/email/smtp-email-sender.spec.ts +99 -0
- package/infra/email/files/src/core/infrastructure/email/smtp-email-sender.ts +63 -0
- package/infra/email/infra.json +58 -0
- package/infra/prisma-postgres/docker.service.yml +12 -0
- package/infra/prisma-postgres/files/prisma/schema.prisma +10 -0
- package/infra/prisma-postgres/files/prisma.config.mjs +10 -0
- package/infra/prisma-postgres/files/src/core/config/database.env.ts +13 -0
- package/infra/prisma-postgres/files/src/core/infrastructure/database/database.module.ts +9 -0
- package/infra/prisma-postgres/files/src/core/infrastructure/database/prisma.service.ts +26 -0
- package/infra/prisma-postgres/infra.json +60 -0
- package/infra/rabbitmq/docker.service.yml +16 -0
- package/infra/rabbitmq/files/src/core/config/messaging.env.ts +29 -0
- package/infra/rabbitmq/files/src/core/infrastructure/messaging/jobs/inline-job-queue.spec.ts +60 -0
- package/infra/rabbitmq/files/src/core/infrastructure/messaging/jobs/inline-job-queue.ts +33 -0
- package/infra/rabbitmq/files/src/core/infrastructure/messaging/jobs/job-failure.spec.ts +23 -0
- package/infra/rabbitmq/files/src/core/infrastructure/messaging/jobs/job-failure.ts +19 -0
- package/infra/rabbitmq/files/src/core/infrastructure/messaging/jobs/job-payload-cipher.spec.ts +80 -0
- package/infra/rabbitmq/files/src/core/infrastructure/messaging/jobs/job-payload-cipher.ts +66 -0
- package/infra/rabbitmq/files/src/core/infrastructure/messaging/jobs/job-queue.boundary.spec.ts +34 -0
- package/infra/rabbitmq/files/src/core/infrastructure/messaging/jobs/job-queue.factory.ts +21 -0
- package/infra/rabbitmq/files/src/core/infrastructure/messaging/jobs/job-queue.module.spec.ts +75 -0
- package/infra/rabbitmq/files/src/core/infrastructure/messaging/jobs/job-queue.module.ts +30 -0
- package/infra/rabbitmq/files/src/core/infrastructure/messaging/jobs/job-queue.port.ts +59 -0
- package/infra/rabbitmq/files/src/core/infrastructure/messaging/jobs/rabbitmq-job-queue.spec.ts +510 -0
- package/infra/rabbitmq/files/src/core/infrastructure/messaging/jobs/rabbitmq-job-queue.ts +353 -0
- package/infra/rabbitmq/files/src/core/infrastructure/messaging/rabbitmq/rabbitmq.module.ts +9 -0
- package/infra/rabbitmq/files/src/core/infrastructure/messaging/rabbitmq/rabbitmq.service.spec.ts +289 -0
- package/infra/rabbitmq/files/src/core/infrastructure/messaging/rabbitmq/rabbitmq.service.ts +207 -0
- package/infra/rabbitmq/infra.json +55 -0
- package/infra/redis/docker.service.yml +10 -0
- package/infra/redis/files/src/core/http/decorators/rate-limit.decorator.ts +26 -0
- package/infra/redis/files/src/core/http/interceptors/rate-limit.interceptor.spec.ts +180 -0
- package/infra/redis/files/src/core/http/interceptors/rate-limit.interceptor.ts +105 -0
- package/infra/redis/files/src/core/infrastructure/redis/cache.constants.ts +67 -0
- package/infra/redis/files/src/core/infrastructure/redis/idempotency/idempotency.module.ts +9 -0
- package/infra/redis/files/src/core/infrastructure/redis/idempotency/idempotency.service.spec.ts +146 -0
- package/infra/redis/files/src/core/infrastructure/redis/idempotency/idempotency.service.ts +131 -0
- package/infra/redis/files/src/core/infrastructure/redis/lock/lock.module.ts +9 -0
- package/infra/redis/files/src/core/infrastructure/redis/lock/lock.service.integration-spec.ts +111 -0
- package/infra/redis/files/src/core/infrastructure/redis/lock/lock.service.ts +82 -0
- package/infra/redis/files/src/core/infrastructure/redis/rate-limit/rate-limit.module.ts +15 -0
- package/infra/redis/files/src/core/infrastructure/redis/rate-limit/rate-limit.service.spec.ts +45 -0
- package/infra/redis/files/src/core/infrastructure/redis/rate-limit/rate-limit.service.ts +38 -0
- package/infra/redis/files/src/core/infrastructure/redis/redis.module.ts +9 -0
- package/infra/redis/files/src/core/infrastructure/redis/redis.service.ts +196 -0
- package/infra/redis/infra.json +53 -0
- package/infra/storage-cloudinary/files/src/core/config/storage.env.ts +43 -0
- package/infra/storage-cloudinary/files/src/core/infrastructure/storage/cloudinary-image-storage.spec.ts +275 -0
- package/infra/storage-cloudinary/files/src/core/infrastructure/storage/cloudinary-image-storage.ts +158 -0
- package/infra/storage-cloudinary/files/src/core/infrastructure/storage/image-storage.factory.ts +30 -0
- package/infra/storage-cloudinary/files/src/core/infrastructure/storage/image-storage.module.ts +17 -0
- package/infra/storage-cloudinary/files/src/core/infrastructure/storage/image-storage.port.ts +38 -0
- package/infra/storage-cloudinary/files/src/core/infrastructure/storage/image-type.spec.ts +48 -0
- package/infra/storage-cloudinary/files/src/core/infrastructure/storage/image-type.ts +25 -0
- package/infra/storage-cloudinary/files/src/core/infrastructure/storage/unconfigured-image-storage.ts +19 -0
- package/infra/storage-cloudinary/infra.json +73 -0
- package/lib/fs-utils.mjs +115 -0
- package/lib/registry.mjs +50 -0
- package/lib/scaffold.mjs +157 -0
- package/package.json +37 -0
- package/templates/nest-ddd/files/.env.example +5 -0
- package/templates/nest-ddd/files/.husky/commit-msg +1 -0
- package/templates/nest-ddd/files/.husky/pre-commit +1 -0
- package/templates/nest-ddd/files/.oxlintrc.json +10 -0
- package/templates/nest-ddd/files/.prettierrc +4 -0
- package/templates/nest-ddd/files/_gitignore +40 -0
- package/templates/nest-ddd/files/commitlint.config.js +3 -0
- package/templates/nest-ddd/files/nest-cli.json +8 -0
- package/templates/nest-ddd/files/package.json +66 -0
- package/templates/nest-ddd/files/plop-templates/command/command.ts.hbs +5 -0
- package/templates/nest-ddd/files/plop-templates/command/handler.ts.hbs +30 -0
- package/templates/nest-ddd/files/plop-templates/module/application/commands/create-{{kebab}}.command.ts.hbs +5 -0
- package/templates/nest-ddd/files/plop-templates/module/application/commands/create-{{kebab}}.handler.spec.ts.hbs +35 -0
- package/templates/nest-ddd/files/plop-templates/module/application/commands/create-{{kebab}}.handler.ts.hbs +25 -0
- package/templates/nest-ddd/files/plop-templates/module/application/index.ts.hbs +5 -0
- package/templates/nest-ddd/files/plop-templates/module/application/queries/get-{{kebab}}-by-id.handler.ts.hbs +29 -0
- package/templates/nest-ddd/files/plop-templates/module/application/queries/get-{{kebab}}-by-id.query.ts.hbs +5 -0
- package/templates/nest-ddd/files/plop-templates/module/domain/entities/{{kebab}}.entity.ts.hbs +37 -0
- package/templates/nest-ddd/files/plop-templates/module/domain/events/{{kebab}}-created.event.ts.hbs +10 -0
- package/templates/nest-ddd/files/plop-templates/module/domain/exceptions/{{kebab}}.exception.ts.hbs +11 -0
- package/templates/nest-ddd/files/plop-templates/module/domain/index.ts.hbs +4 -0
- package/templates/nest-ddd/files/plop-templates/module/domain/repositories/{{kebab}}.repository.ts.hbs +7 -0
- package/templates/nest-ddd/files/plop-templates/module/presentation/controllers/{{kebab}}.controller.ts.hbs +53 -0
- package/templates/nest-ddd/files/plop-templates/module/presentation/dtos/create-{{kebab}}.dto.ts.hbs +10 -0
- package/templates/nest-ddd/files/plop-templates/module/presentation/dtos/{{kebab}}-response.dto.ts.hbs +15 -0
- package/templates/nest-ddd/files/plop-templates/module/presentation/index.ts.hbs +4 -0
- package/templates/nest-ddd/files/plop-templates/module/presentation/mappers/{{kebab}}.mapper.ts.hbs +13 -0
- package/templates/nest-ddd/files/plop-templates/module/{{kebab}}.module.ts.hbs +40 -0
- package/templates/nest-ddd/files/plop-templates/persistence-memory/infrastructure/database/repositories/in-memory-{{kebab}}.repository.ts.hbs +17 -0
- package/templates/nest-ddd/files/plop-templates/persistence-memory/infrastructure/index.ts.hbs +1 -0
- package/templates/nest-ddd/files/plop-templates/persistence-prisma/infrastructure/database/mappers/{{kebab}}.mapper.ts.hbs +29 -0
- package/templates/nest-ddd/files/plop-templates/persistence-prisma/infrastructure/database/repositories/prisma-{{kebab}}.repository.ts.hbs +28 -0
- package/templates/nest-ddd/files/plop-templates/persistence-prisma/infrastructure/index.ts.hbs +2 -0
- package/templates/nest-ddd/files/plop-templates/query/handler.ts.hbs +28 -0
- package/templates/nest-ddd/files/plop-templates/query/query.ts.hbs +5 -0
- package/templates/nest-ddd/files/plop-templates/snippets/error-codes.hbs +14 -0
- package/templates/nest-ddd/files/plop-templates/snippets/prisma-model.hbs +7 -0
- package/templates/nest-ddd/files/plopfile.mjs +186 -0
- package/templates/nest-ddd/files/src/app.controller.ts +13 -0
- package/templates/nest-ddd/files/src/app.module.ts +26 -0
- package/templates/nest-ddd/files/src/core/config/app.config.ts +9 -0
- package/templates/nest-ddd/files/src/core/config/env.validation.ts +30 -0
- package/templates/nest-ddd/files/src/core/domain/base/aggregate-root.base.ts +18 -0
- package/templates/nest-ddd/files/src/core/domain/base/domain-event.base.ts +7 -0
- package/templates/nest-ddd/files/src/core/domain/base/entity.base.ts +36 -0
- package/templates/nest-ddd/files/src/core/domain/base/value-object.base.ts +17 -0
- package/templates/nest-ddd/files/src/core/domain/exceptions/domain.exception.ts +16 -0
- package/templates/nest-ddd/files/src/core/domain/exceptions/error-codes.ts +28 -0
- package/templates/nest-ddd/files/src/core/domain/types/pagination.ts +9 -0
- package/templates/nest-ddd/files/src/core/http/decorators/client-info.decorator.ts +24 -0
- package/templates/nest-ddd/files/src/core/http/decorators/current-user.decorator.ts +19 -0
- package/templates/nest-ddd/files/src/core/http/dtos/paginated-response.dto.ts +29 -0
- package/templates/nest-ddd/files/src/core/http/dtos/pagination-query.dto.ts +17 -0
- package/templates/nest-ddd/files/src/core/http/filters/all-exceptions.filter.spec.ts +163 -0
- package/templates/nest-ddd/files/src/core/http/filters/all-exceptions.filter.ts +97 -0
- package/templates/nest-ddd/files/src/core/http/guards/max-body-size.guard.spec.ts +47 -0
- package/templates/nest-ddd/files/src/core/http/guards/max-body-size.guard.ts +31 -0
- package/templates/nest-ddd/files/src/core/http/interceptors/logging.interceptor.ts +37 -0
- package/templates/nest-ddd/files/src/core/http/interceptors/response.interceptor.ts +53 -0
- package/templates/nest-ddd/files/src/core/http/logging/app.logger.ts +34 -0
- package/templates/nest-ddd/files/src/core/index.ts +33 -0
- package/templates/nest-ddd/files/src/core/utils/correlation-id.util.spec.ts +29 -0
- package/templates/nest-ddd/files/src/core/utils/correlation-id.util.ts +15 -0
- package/templates/nest-ddd/files/src/core/utils/hash.util.spec.ts +61 -0
- package/templates/nest-ddd/files/src/core/utils/hash.util.ts +47 -0
- package/templates/nest-ddd/files/src/core/utils/snowflake.util.spec.ts +40 -0
- package/templates/nest-ddd/files/src/core/utils/snowflake.util.ts +99 -0
- package/templates/nest-ddd/files/src/core/utils/string.util.spec.ts +20 -0
- package/templates/nest-ddd/files/src/core/utils/string.util.ts +21 -0
- package/templates/nest-ddd/files/src/core/utils/time.util.spec.ts +25 -0
- package/templates/nest-ddd/files/src/core/utils/time.util.ts +25 -0
- package/templates/nest-ddd/files/src/core/utils/ttl.util.ts +22 -0
- package/templates/nest-ddd/files/src/main.ts +61 -0
- package/templates/nest-ddd/files/test/setup.ts +6 -0
- package/templates/nest-ddd/files/tsconfig.build.json +9 -0
- package/templates/nest-ddd/files/tsconfig.json +28 -0
- package/templates/nest-ddd/files/vitest.config.e2e.ts +15 -0
- package/templates/nest-ddd/files/vitest.config.ts +16 -0
- package/templates/nest-ddd/template.json +8 -0
- package/templates/node-express/files/.env.example +28 -0
- package/templates/node-express/files/docker-compose.yml +52 -0
- package/templates/node-express/files/package-lock.json +11880 -0
- package/templates/node-express/files/package.json +43 -0
- package/templates/node-express/files/server.js +13 -0
- package/templates/node-express/files/src/app.js +72 -0
- package/templates/node-express/files/src/auth/authUtils.js +116 -0
- package/templates/node-express/files/src/auth/checkAuth.js +61 -0
- package/templates/node-express/files/src/bucket_pattern/comment.model.js +27 -0
- package/templates/node-express/files/src/bucket_pattern/comment.service.js +50 -0
- package/templates/node-express/files/src/config/config.aws-s3.js +28 -0
- package/templates/node-express/files/src/config/config.cloudinary.js +13 -0
- package/templates/node-express/files/src/config/config.mongodb.js +25 -0
- package/templates/node-express/files/src/config/config.multer.js +25 -0
- package/templates/node-express/files/src/config/config.nodemailer.js +16 -0
- package/templates/node-express/files/src/controllers/access.controller.js +49 -0
- package/templates/node-express/files/src/controllers/cart.controller.js +38 -0
- package/templates/node-express/files/src/controllers/checkout.controller.js +15 -0
- package/templates/node-express/files/src/controllers/comment.controller.js +29 -0
- package/templates/node-express/files/src/controllers/discount.controller.js +68 -0
- package/templates/node-express/files/src/controllers/email.controller.js +16 -0
- package/templates/node-express/files/src/controllers/inventory.controller.js +15 -0
- package/templates/node-express/files/src/controllers/notification.controller.js +15 -0
- package/templates/node-express/files/src/controllers/product.controller.js +175 -0
- package/templates/node-express/files/src/controllers/profile.controller.js +65 -0
- package/templates/node-express/files/src/controllers/rbac.controller.js +41 -0
- package/templates/node-express/files/src/controllers/template.controller.js +20 -0
- package/templates/node-express/files/src/controllers/upload.controller.js +56 -0
- package/templates/node-express/files/src/controllers/user.controller.js +36 -0
- package/templates/node-express/files/src/core/error.response.js +84 -0
- package/templates/node-express/files/src/core/success.response.js +44 -0
- package/templates/node-express/files/src/database/init.mongodb.level0.js +16 -0
- package/templates/node-express/files/src/database/init.mongodb.level1.js +36 -0
- package/templates/node-express/files/src/database/init.redis.js +119 -0
- package/templates/node-express/files/src/helpers/asyncHandler.js +7 -0
- package/templates/node-express/files/src/helpers/check.connect.js +29 -0
- package/templates/node-express/files/src/keys/private_key.pem +28 -0
- package/templates/node-express/files/src/keys/public_key.pem +9 -0
- package/templates/node-express/files/src/logger/discord.log.js +26 -0
- package/templates/node-express/files/src/logger/discord.log.v2.js +56 -0
- package/templates/node-express/files/src/logger/mylogger.log.js +130 -0
- package/templates/node-express/files/src/logger/winston.log.js +27 -0
- package/templates/node-express/files/src/middlewares/index.js +21 -0
- package/templates/node-express/files/src/middlewares/rbac.js +37 -0
- package/templates/node-express/files/src/middlewares/role.middleware.js +12 -0
- package/templates/node-express/files/src/models/KeyToken.model.js +37 -0
- package/templates/node-express/files/src/models/apiKey.model.js +31 -0
- package/templates/node-express/files/src/models/cart.model.js +47 -0
- package/templates/node-express/files/src/models/comment.model.js +43 -0
- package/templates/node-express/files/src/models/discount.model.js +92 -0
- package/templates/node-express/files/src/models/inventory.model.js +42 -0
- package/templates/node-express/files/src/models/notification.model.js +52 -0
- package/templates/node-express/files/src/models/order.model.js +68 -0
- package/templates/node-express/files/src/models/otp.model.js +32 -0
- package/templates/node-express/files/src/models/product.model.js +144 -0
- package/templates/node-express/files/src/models/repository/cart.repo.js +71 -0
- package/templates/node-express/files/src/models/repository/discount.repo.js +63 -0
- package/templates/node-express/files/src/models/repository/inventory.repo.js +45 -0
- package/templates/node-express/files/src/models/repository/product.repo.js +145 -0
- package/templates/node-express/files/src/models/repository/shop.repo.js +18 -0
- package/templates/node-express/files/src/models/repository/spu.repo.js +18 -0
- package/templates/node-express/files/src/models/repository/user.repo.js +20 -0
- package/templates/node-express/files/src/models/resource.model.js +32 -0
- package/templates/node-express/files/src/models/role.model.js +57 -0
- package/templates/node-express/files/src/models/shop.model.js +43 -0
- package/templates/node-express/files/src/models/sku.model.js +81 -0
- package/templates/node-express/files/src/models/spu.model.js +101 -0
- package/templates/node-express/files/src/models/template.model.js +25 -0
- package/templates/node-express/files/src/models/user.model.js +82 -0
- package/templates/node-express/files/src/postman/access.post.http +22 -0
- package/templates/node-express/files/src/routes/access/index.route.js +21 -0
- package/templates/node-express/files/src/routes/cart/index.route.js +20 -0
- package/templates/node-express/files/src/routes/checkout/index.route.js +10 -0
- package/templates/node-express/files/src/routes/comment/index.route.js +16 -0
- package/templates/node-express/files/src/routes/discount/index.route.js +26 -0
- package/templates/node-express/files/src/routes/index.route.js +27 -0
- package/templates/node-express/files/src/routes/inventory/index.route.js +11 -0
- package/templates/node-express/files/src/routes/notification/index.route.js +14 -0
- package/templates/node-express/files/src/routes/product/index.route.js +38 -0
- package/templates/node-express/files/src/routes/profile/index.route.js +15 -0
- package/templates/node-express/files/src/routes/rbac/index.route.js +21 -0
- package/templates/node-express/files/src/routes/template/index.route.js +11 -0
- package/templates/node-express/files/src/routes/upload/index.route.js +28 -0
- package/templates/node-express/files/src/routes/user/index.route.js +13 -0
- package/templates/node-express/files/src/services/access.service.js +257 -0
- package/templates/node-express/files/src/services/apiKey.service.js +15 -0
- package/templates/node-express/files/src/services/cart.service.js +115 -0
- package/templates/node-express/files/src/services/checkout.service.js +218 -0
- package/templates/node-express/files/src/services/comment.service.js +185 -0
- package/templates/node-express/files/src/services/discount.service.js +282 -0
- package/templates/node-express/files/src/services/email.service.js +71 -0
- package/templates/node-express/files/src/services/inventory.service.js +35 -0
- package/templates/node-express/files/src/services/keyToken.service.js +77 -0
- package/templates/node-express/files/src/services/notification.service.js +58 -0
- package/templates/node-express/files/src/services/otp.service.js +36 -0
- package/templates/node-express/files/src/services/product.service.js +228 -0
- package/templates/node-express/files/src/services/rbac.service.js +143 -0
- package/templates/node-express/files/src/services/redis.service.js +57 -0
- package/templates/node-express/files/src/services/redisPubSub.service.js +72 -0
- package/templates/node-express/files/src/services/shop.service.js +21 -0
- package/templates/node-express/files/src/services/sku.service.js +52 -0
- package/templates/node-express/files/src/services/spu.service.js +89 -0
- package/templates/node-express/files/src/services/template.service.js +41 -0
- package/templates/node-express/files/src/services/upload.s3.service.js +55 -0
- package/templates/node-express/files/src/services/upload.service.js +74 -0
- package/templates/node-express/files/src/services/user.service.js +92 -0
- package/templates/node-express/files/src/temp/1734420604996-ci-cd.png +0 -0
- package/templates/node-express/files/src/temp/1734421222213-cover-fb.jpg +0 -0
- package/templates/node-express/files/src/temp/1734421222214-cover-fb-01.jpg +0 -0
- package/templates/node-express/files/src/temp/1734421293437-cover-fb-01.jpg +0 -0
- package/templates/node-express/files/src/temp/1734421293437-cover-fb.jpg +0 -0
- package/templates/node-express/files/src/tests/inventory.test.js +21 -0
- package/templates/node-express/files/src/tests/message_queue/kafka/consumer.js +23 -0
- package/templates/node-express/files/src/tests/message_queue/kafka/producer.js +21 -0
- package/templates/node-express/files/src/tests/message_queue/rabbitMQ/consumer.js +28 -0
- package/templates/node-express/files/src/tests/message_queue/rabbitMQ/ordered.consumer.js +26 -0
- package/templates/node-express/files/src/tests/message_queue/rabbitMQ/ordered.producer.js +31 -0
- package/templates/node-express/files/src/tests/message_queue/rabbitMQ/producer.js +26 -0
- package/templates/node-express/files/src/tests/message_queue/rabbitMQ/producerDLX.js +44 -0
- package/templates/node-express/files/src/tests/mysql/connect.js +59 -0
- package/templates/node-express/files/src/tests/product.test.js +14 -0
- package/templates/node-express/files/src/utils/index.js +78 -0
- package/templates/node-express/files/src/utils/reasonPhrases.js +344 -0
- package/templates/node-express/files/src/utils/statusCodes.js +344 -0
- package/templates/node-express/files/src/utils/template.html.js +38 -0
- package/templates/node-express/template.json +11 -0
- package/templates/react-fsd/files/index.html +13 -0
- package/templates/react-fsd/files/package.json +15 -0
- package/templates/react-fsd/files/public/favicon.svg +1 -0
- package/templates/react-fsd/files/public/icons.svg +24 -0
- package/templates/react-fsd/files/src/assets/hero.png +0 -0
- package/templates/react-fsd/files/src/assets/typescript.svg +1 -0
- package/templates/react-fsd/files/src/assets/vite.svg +1 -0
- package/templates/react-fsd/files/src/counter.ts +9 -0
- package/templates/react-fsd/files/src/main.ts +60 -0
- package/templates/react-fsd/files/src/style.css +296 -0
- package/templates/react-fsd/files/tsconfig.json +24 -0
- package/templates/react-fsd/template.json +11 -0
- package/templates/react-monolithic/files/index.html +13 -0
- package/templates/react-monolithic/files/package.json +15 -0
- package/templates/react-monolithic/files/public/favicon.svg +1 -0
- package/templates/react-monolithic/files/public/icons.svg +24 -0
- package/templates/react-monolithic/files/src/assets/hero.png +0 -0
- package/templates/react-monolithic/files/src/assets/typescript.svg +1 -0
- package/templates/react-monolithic/files/src/assets/vite.svg +1 -0
- package/templates/react-monolithic/files/src/counter.ts +9 -0
- package/templates/react-monolithic/files/src/main.ts +60 -0
- package/templates/react-monolithic/files/src/style.css +296 -0
- package/templates/react-monolithic/files/tsconfig.json +24 -0
- package/templates/react-monolithic/template.json +11 -0
- package/templates/vue-fsd/files/index.html +13 -0
- package/templates/vue-fsd/files/package.json +15 -0
- package/templates/vue-fsd/files/public/favicon.svg +1 -0
- package/templates/vue-fsd/files/public/icons.svg +24 -0
- package/templates/vue-fsd/files/src/assets/hero.png +0 -0
- package/templates/vue-fsd/files/src/assets/typescript.svg +1 -0
- package/templates/vue-fsd/files/src/assets/vite.svg +1 -0
- package/templates/vue-fsd/files/src/counter.ts +9 -0
- package/templates/vue-fsd/files/src/main.ts +60 -0
- package/templates/vue-fsd/files/src/style.css +296 -0
- package/templates/vue-fsd/files/tsconfig.json +24 -0
- package/templates/vue-fsd/template.json +11 -0
- package/templates/vue-monolithic/files/index.html +13 -0
- package/templates/vue-monolithic/files/package.json +15 -0
- package/templates/vue-monolithic/files/public/favicon.svg +1 -0
- package/templates/vue-monolithic/files/public/icons.svg +24 -0
- package/templates/vue-monolithic/files/src/assets/hero.png +0 -0
- package/templates/vue-monolithic/files/src/assets/typescript.svg +1 -0
- package/templates/vue-monolithic/files/src/assets/vite.svg +1 -0
- package/templates/vue-monolithic/files/src/counter.ts +9 -0
- package/templates/vue-monolithic/files/src/main.ts +60 -0
- package/templates/vue-monolithic/files/src/style.css +296 -0
- package/templates/vue-monolithic/files/tsconfig.json +24 -0
- package/templates/vue-monolithic/template.json +11 -0
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Injectable,
|
|
3
|
+
Logger,
|
|
4
|
+
OnApplicationShutdown,
|
|
5
|
+
ServiceUnavailableException,
|
|
6
|
+
} from '@nestjs/common';
|
|
7
|
+
import * as amqp from 'amqplib';
|
|
8
|
+
|
|
9
|
+
/** Runs every time a connection and channel are (re)established. Rejecting restarts the connection. */
|
|
10
|
+
export type ConnectedHook = (channel: amqp.ConfirmChannel) => Promise<void>;
|
|
11
|
+
|
|
12
|
+
const MAX_BACKOFF_MS = 30_000;
|
|
13
|
+
const PUBLISH_CONFIRM_TIMEOUT_MS = 5_000;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The connection to RabbitMQ and nothing else: connect in the background, reconnect with
|
|
17
|
+
* exponential backoff (1 s, 2 s, 4 s ... 30 s), and publish with the broker's confirmation.
|
|
18
|
+
*
|
|
19
|
+
* It does not block the application from starting while the broker is away, and `publish`
|
|
20
|
+
* fails at once (503) instead of hanging when there is no connection. What to publish and
|
|
21
|
+
* consume belongs to the layers above (see `RabbitMqJobQueue`); they use `onConnected`
|
|
22
|
+
* to set themselves up again after every reconnect, because a new connection starts with
|
|
23
|
+
* no queues bound and no consumers.
|
|
24
|
+
*/
|
|
25
|
+
@Injectable()
|
|
26
|
+
export class RabbitMQService implements OnApplicationShutdown {
|
|
27
|
+
private readonly logger = new Logger(RabbitMQService.name);
|
|
28
|
+
private connection: amqp.ChannelModel | null = null;
|
|
29
|
+
private channel: amqp.ConfirmChannel | null = null;
|
|
30
|
+
private readonly hooks: ConnectedHook[] = [];
|
|
31
|
+
private started = false;
|
|
32
|
+
private connecting = false;
|
|
33
|
+
private shuttingDown = false;
|
|
34
|
+
private cancelWait: (() => void) | null = null;
|
|
35
|
+
|
|
36
|
+
/** Starts connecting in the background. Safe to call more than once. */
|
|
37
|
+
start(): void {
|
|
38
|
+
if (this.started) return;
|
|
39
|
+
this.started = true;
|
|
40
|
+
void this.connectLoop();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Registers a hook for every (re)connection; runs it now when already connected. */
|
|
44
|
+
onConnected(hook: ConnectedHook): void {
|
|
45
|
+
this.hooks.push(hook);
|
|
46
|
+
const channel = this.channel;
|
|
47
|
+
if (channel) {
|
|
48
|
+
hook(channel).catch((error: Error) => {
|
|
49
|
+
this.logger.error(`RabbitMQ setup hook failed: ${error.message}`);
|
|
50
|
+
this.restart();
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The live channel, or null while disconnected. */
|
|
56
|
+
getChannel(): amqp.ConfirmChannel | null {
|
|
57
|
+
return this.channel;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
isConnected(): boolean {
|
|
61
|
+
return this.channel !== null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Publishes (on `onChannel`, by default the live one) and waits for the broker to confirm it stored the message. Rejects with a
|
|
66
|
+
* 503 when there is no connection, the broker refuses, or no confirmation arrives in time.
|
|
67
|
+
*/
|
|
68
|
+
async publish(
|
|
69
|
+
exchange: string,
|
|
70
|
+
routingKey: string,
|
|
71
|
+
content: Buffer,
|
|
72
|
+
options: amqp.Options.Publish = {},
|
|
73
|
+
onChannel: amqp.ConfirmChannel | null = this.channel,
|
|
74
|
+
): Promise<void> {
|
|
75
|
+
const channel = onChannel;
|
|
76
|
+
if (!channel) {
|
|
77
|
+
throw new ServiceUnavailableException('Message broker is unavailable');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
let timer: NodeJS.Timeout | undefined;
|
|
81
|
+
try {
|
|
82
|
+
await Promise.race([
|
|
83
|
+
new Promise<void>((resolve, reject) => {
|
|
84
|
+
channel.publish(
|
|
85
|
+
exchange,
|
|
86
|
+
routingKey,
|
|
87
|
+
content,
|
|
88
|
+
{ persistent: true, ...options },
|
|
89
|
+
(error) => (error ? reject(error) : resolve()),
|
|
90
|
+
);
|
|
91
|
+
}),
|
|
92
|
+
new Promise<never>((_, reject) => {
|
|
93
|
+
timer = setTimeout(
|
|
94
|
+
() => reject(new Error('publish confirmation timed out')),
|
|
95
|
+
PUBLISH_CONFIRM_TIMEOUT_MS,
|
|
96
|
+
);
|
|
97
|
+
}),
|
|
98
|
+
]);
|
|
99
|
+
} catch (error) {
|
|
100
|
+
this.logger.error(
|
|
101
|
+
`Publish to "${routingKey}" failed: ${(error as Error).message}`,
|
|
102
|
+
);
|
|
103
|
+
throw new ServiceUnavailableException('Message broker is unavailable');
|
|
104
|
+
} finally {
|
|
105
|
+
clearTimeout(timer);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async onApplicationShutdown(): Promise<void> {
|
|
110
|
+
this.shuttingDown = true;
|
|
111
|
+
this.cancelWait?.();
|
|
112
|
+
const { channel, connection } = this;
|
|
113
|
+
this.channel = null;
|
|
114
|
+
this.connection = null;
|
|
115
|
+
try {
|
|
116
|
+
await channel?.close();
|
|
117
|
+
await connection?.close();
|
|
118
|
+
} catch {
|
|
119
|
+
// Already closed: nothing left to release
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
private async connectLoop(): Promise<void> {
|
|
124
|
+
if (this.connecting) return;
|
|
125
|
+
this.connecting = true;
|
|
126
|
+
let attempt = 0;
|
|
127
|
+
|
|
128
|
+
while (!this.shuttingDown) {
|
|
129
|
+
try {
|
|
130
|
+
const url =
|
|
131
|
+
process.env.RABBITMQ_URL || 'amqp://admin:admin@localhost:5672';
|
|
132
|
+
const connection = await amqp.connect(url);
|
|
133
|
+
if (this.shuttingDown) {
|
|
134
|
+
await connection.close().catch(() => undefined);
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
// 'error' is always followed by 'close'; reconnecting happens there
|
|
138
|
+
connection.on('error', (error: Error) =>
|
|
139
|
+
this.logger.error(`RabbitMQ connection error: ${error.message}`),
|
|
140
|
+
);
|
|
141
|
+
connection.on('close', () => this.handleClosed(connection));
|
|
142
|
+
|
|
143
|
+
const channel = await connection.createConfirmChannel();
|
|
144
|
+
// A channel error (e.g. declaring a queue with different arguments) closes only the
|
|
145
|
+
// channel; start over with a clean connection rather than carry on without one
|
|
146
|
+
channel.on('error', (error: Error) =>
|
|
147
|
+
this.logger.error(`RabbitMQ channel error: ${error.message}`),
|
|
148
|
+
);
|
|
149
|
+
channel.on('close', () => {
|
|
150
|
+
if (this.channel === channel) {
|
|
151
|
+
this.channel = null;
|
|
152
|
+
connection.close().catch(() => undefined);
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
try {
|
|
157
|
+
for (const hook of this.hooks) await hook(channel);
|
|
158
|
+
} catch (error) {
|
|
159
|
+
await connection.close().catch(() => undefined);
|
|
160
|
+
throw error;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (this.shuttingDown) {
|
|
164
|
+
await connection.close().catch(() => undefined);
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
this.connection = connection;
|
|
168
|
+
this.channel = channel;
|
|
169
|
+
this.logger.log('Connected to RabbitMQ');
|
|
170
|
+
this.connecting = false;
|
|
171
|
+
return;
|
|
172
|
+
} catch (error) {
|
|
173
|
+
const delay = Math.min(2 ** attempt * 1000, MAX_BACKOFF_MS);
|
|
174
|
+
this.logger.warn(
|
|
175
|
+
`RabbitMQ not ready (${(error as Error).message}); retrying in ${delay / 1000}s`,
|
|
176
|
+
);
|
|
177
|
+
attempt += 1;
|
|
178
|
+
await this.wait(delay);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
this.connecting = false;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
private handleClosed(connection: amqp.ChannelModel): void {
|
|
185
|
+
if (this.connection !== connection) return;
|
|
186
|
+
this.connection = null;
|
|
187
|
+
this.channel = null;
|
|
188
|
+
if (this.shuttingDown) return;
|
|
189
|
+
this.logger.warn('RabbitMQ connection closed; reconnecting');
|
|
190
|
+
void this.connectLoop();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
private restart(): void {
|
|
194
|
+
const connection = this.connection;
|
|
195
|
+
if (connection) connection.close().catch(() => undefined);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private wait(ms: number): Promise<void> {
|
|
199
|
+
return new Promise((resolve) => {
|
|
200
|
+
const timer = setTimeout(resolve, ms);
|
|
201
|
+
this.cancelWait = () => {
|
|
202
|
+
clearTimeout(timer);
|
|
203
|
+
resolve();
|
|
204
|
+
};
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "rabbitmq",
|
|
3
|
+
"label": "RabbitMQ (broker + background job queue)",
|
|
4
|
+
"default": true,
|
|
5
|
+
"templates": ["nest-ddd"],
|
|
6
|
+
"dependencies": {
|
|
7
|
+
"amqplib": "^2.0.1"
|
|
8
|
+
},
|
|
9
|
+
"devDependencies": {
|
|
10
|
+
"@types/amqplib": "^0.10.8"
|
|
11
|
+
},
|
|
12
|
+
"env": [
|
|
13
|
+
"# RabbitMQ (docker-compose.yml) — management UI on http://localhost:15672 (admin/admin)",
|
|
14
|
+
"RABBITMQ_URL=amqp://admin:admin@localhost:5672",
|
|
15
|
+
"# rabbitmq | inline (inline runs jobs inside the request: dev/tests only)",
|
|
16
|
+
"JOB_QUEUE=rabbitmq"
|
|
17
|
+
],
|
|
18
|
+
"docker": {
|
|
19
|
+
"file": "docker.service.yml",
|
|
20
|
+
"volumes": ["rabbitmq_data"]
|
|
21
|
+
},
|
|
22
|
+
"inject": [
|
|
23
|
+
{
|
|
24
|
+
"file": "src/app.module.ts",
|
|
25
|
+
"marker": "// @infra:imports",
|
|
26
|
+
"lines": ["import { JobQueueModule } from './core/infrastructure/messaging/jobs/job-queue.module.js';"]
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"file": "src/app.module.ts",
|
|
30
|
+
"marker": "// @infra:modules",
|
|
31
|
+
"lines": ["JobQueueModule,"]
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"file": "src/core/index.ts",
|
|
35
|
+
"marker": "// @infra:barrel",
|
|
36
|
+
"lines": [
|
|
37
|
+
"export * from './infrastructure/messaging/rabbitmq/rabbitmq.service.js';",
|
|
38
|
+
"export * from './infrastructure/messaging/rabbitmq/rabbitmq.module.js';",
|
|
39
|
+
"export * from './infrastructure/messaging/jobs/job-queue.port.js';",
|
|
40
|
+
"export * from './infrastructure/messaging/jobs/job-queue.module.js';",
|
|
41
|
+
"export * from './infrastructure/messaging/jobs/job-payload-cipher.js';"
|
|
42
|
+
]
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"file": "src/core/config/env.validation.ts",
|
|
46
|
+
"marker": "// @infra:env-imports",
|
|
47
|
+
"lines": ["import { validateMessagingEnv } from './messaging.env.js';"]
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
"file": "src/core/config/env.validation.ts",
|
|
51
|
+
"marker": "// @infra:env-checks",
|
|
52
|
+
"lines": ["validateMessagingEnv(config, errors);"]
|
|
53
|
+
}
|
|
54
|
+
]
|
|
55
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
redis:
|
|
2
|
+
image: redis:7-alpine
|
|
3
|
+
container_name: {{PROJECT_NAME_SNAKE}}_redis
|
|
4
|
+
# Append-only file: counters, locks and idempotency records must survive a restart
|
|
5
|
+
command: redis-server --appendonly yes --appendfsync everysec
|
|
6
|
+
ports:
|
|
7
|
+
- '6379:6379'
|
|
8
|
+
volumes:
|
|
9
|
+
- redis_data:/data
|
|
10
|
+
restart: unless-stopped
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { SetMetadata } from '@nestjs/common';
|
|
2
|
+
import type { Request } from 'express';
|
|
3
|
+
|
|
4
|
+
export const RATE_LIMIT_KEY = 'rateLimit';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Who a request is counted against:
|
|
8
|
+
* - 'ip' : client IP (default)
|
|
9
|
+
* - 'user' : authenticated user id, falls back to IP on public routes
|
|
10
|
+
* - function: custom identity, e.g. (req) => `${req.ip}:${req.body?.email}`
|
|
11
|
+
*/
|
|
12
|
+
export type RateLimitKey =
|
|
13
|
+
'ip' | 'user' | ((req: Request) => string | undefined);
|
|
14
|
+
|
|
15
|
+
export interface RateLimitOptions {
|
|
16
|
+
/** Max requests allowed per window. */
|
|
17
|
+
limit: number;
|
|
18
|
+
/** Window length in seconds — use buildTTL, e.g. buildTTL('MINUTE', 15). */
|
|
19
|
+
window: number;
|
|
20
|
+
/** Who to count against. Defaults to 'ip'. */
|
|
21
|
+
key?: RateLimitKey;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Limit a route, or every route of a controller (each route gets its own bucket). */
|
|
25
|
+
export const RateLimit = (options: RateLimitOptions) =>
|
|
26
|
+
SetMetadata(RATE_LIMIT_KEY, options);
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { CallHandler, ExecutionContext, HttpStatus } from '@nestjs/common';
|
|
3
|
+
import { Reflector } from '@nestjs/core';
|
|
4
|
+
import { createHash } from 'crypto';
|
|
5
|
+
import { of } from 'rxjs';
|
|
6
|
+
import { RateLimitInterceptor } from './rate-limit.interceptor.js';
|
|
7
|
+
import { RateLimitService } from '../../infrastructure/redis/rate-limit/rate-limit.service.js';
|
|
8
|
+
import { CACHE } from '../../infrastructure/redis/cache.constants.js';
|
|
9
|
+
|
|
10
|
+
const makeContext = (req: object) =>
|
|
11
|
+
({
|
|
12
|
+
switchToHttp: () => ({ getRequest: () => req }),
|
|
13
|
+
getHandler: () => () => {},
|
|
14
|
+
getClass: () => class {},
|
|
15
|
+
}) as unknown as ExecutionContext;
|
|
16
|
+
|
|
17
|
+
const makeRequest = (overrides: object = {}) => ({
|
|
18
|
+
ip: '1.1.1.1',
|
|
19
|
+
path: '/auth/login?x=1',
|
|
20
|
+
route: { path: '/auth/login' },
|
|
21
|
+
socket: { remoteAddress: '9.9.9.9' },
|
|
22
|
+
...overrides,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const bucket = (identifier: string) =>
|
|
26
|
+
CACHE.RATE_LIMIT.KEYS.BUCKET('/auth/login', identifier);
|
|
27
|
+
|
|
28
|
+
describe('RateLimitInterceptor', () => {
|
|
29
|
+
let interceptor: RateLimitInterceptor;
|
|
30
|
+
let reflector: { getAllAndOverride: ReturnType<typeof vi.fn> };
|
|
31
|
+
let service: { isAllowed: ReturnType<typeof vi.fn> };
|
|
32
|
+
let next: CallHandler;
|
|
33
|
+
|
|
34
|
+
beforeEach(() => {
|
|
35
|
+
reflector = { getAllAndOverride: vi.fn() };
|
|
36
|
+
service = { isAllowed: vi.fn().mockResolvedValue(true) };
|
|
37
|
+
next = { handle: vi.fn().mockReturnValue(of('handled')) };
|
|
38
|
+
interceptor = new RateLimitInterceptor(
|
|
39
|
+
reflector as unknown as Reflector,
|
|
40
|
+
service as unknown as RateLimitService,
|
|
41
|
+
);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('passes through without touching Redis when the route has no @RateLimit', async () => {
|
|
45
|
+
reflector.getAllAndOverride.mockReturnValue(undefined);
|
|
46
|
+
|
|
47
|
+
await interceptor.intercept(makeContext(makeRequest()), next);
|
|
48
|
+
|
|
49
|
+
expect(service.isAllowed).not.toHaveBeenCalled();
|
|
50
|
+
expect(next.handle).toHaveBeenCalledOnce();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('calls the handler while under the limit, bucketed by route pattern + IP by default', async () => {
|
|
54
|
+
reflector.getAllAndOverride.mockReturnValue({ limit: 5, window: 900 });
|
|
55
|
+
|
|
56
|
+
await interceptor.intercept(makeContext(makeRequest()), next);
|
|
57
|
+
|
|
58
|
+
expect(service.isAllowed).toHaveBeenCalledWith(
|
|
59
|
+
bucket('ip:1.1.1.1'),
|
|
60
|
+
5,
|
|
61
|
+
900,
|
|
62
|
+
);
|
|
63
|
+
expect(next.handle).toHaveBeenCalledOnce();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('throws 429 and does not call the handler when the limit is exceeded', async () => {
|
|
67
|
+
reflector.getAllAndOverride.mockReturnValue({ limit: 5, window: 900 });
|
|
68
|
+
service.isAllowed.mockResolvedValue(false);
|
|
69
|
+
|
|
70
|
+
await expect(
|
|
71
|
+
interceptor.intercept(makeContext(makeRequest()), next),
|
|
72
|
+
).rejects.toMatchObject({
|
|
73
|
+
status: HttpStatus.TOO_MANY_REQUESTS,
|
|
74
|
+
});
|
|
75
|
+
expect(next.handle).not.toHaveBeenCalled();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('falls back to the socket address when request.ip is missing', async () => {
|
|
79
|
+
reflector.getAllAndOverride.mockReturnValue({ limit: 5, window: 900 });
|
|
80
|
+
|
|
81
|
+
await interceptor.intercept(
|
|
82
|
+
makeContext(makeRequest({ ip: undefined })),
|
|
83
|
+
next,
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
expect(service.isAllowed).toHaveBeenCalledWith(
|
|
87
|
+
bucket('ip:9.9.9.9'),
|
|
88
|
+
5,
|
|
89
|
+
900,
|
|
90
|
+
);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('never uses the raw URL (query string) in the bucket key', async () => {
|
|
94
|
+
reflector.getAllAndOverride.mockReturnValue({ limit: 5, window: 900 });
|
|
95
|
+
|
|
96
|
+
await interceptor.intercept(makeContext(makeRequest()), next);
|
|
97
|
+
|
|
98
|
+
expect(service.isAllowed.mock.calls[0][0]).not.toContain('?x=1');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe('key', () => {
|
|
102
|
+
it("'user' counts against the authenticated user id", async () => {
|
|
103
|
+
reflector.getAllAndOverride.mockReturnValue({
|
|
104
|
+
limit: 5,
|
|
105
|
+
window: 60,
|
|
106
|
+
key: 'user',
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
await interceptor.intercept(
|
|
110
|
+
makeContext(makeRequest({ user: { id: 'u1' } })),
|
|
111
|
+
next,
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
expect(service.isAllowed).toHaveBeenCalledWith(bucket('user:u1'), 5, 60);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("'user' falls back to IP on a public route", async () => {
|
|
118
|
+
reflector.getAllAndOverride.mockReturnValue({
|
|
119
|
+
limit: 5,
|
|
120
|
+
window: 60,
|
|
121
|
+
key: 'user',
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
await interceptor.intercept(makeContext(makeRequest()), next);
|
|
125
|
+
|
|
126
|
+
expect(service.isAllowed).toHaveBeenCalledWith(
|
|
127
|
+
bucket('ip:1.1.1.1'),
|
|
128
|
+
5,
|
|
129
|
+
60,
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('a custom function is hashed so e.g. an email never lands in Redis as plain text', async () => {
|
|
134
|
+
const custom = (req: any) => `${req.ip}:${req.body.email}`;
|
|
135
|
+
reflector.getAllAndOverride.mockReturnValue({
|
|
136
|
+
limit: 5,
|
|
137
|
+
window: 60,
|
|
138
|
+
key: custom,
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
await interceptor.intercept(
|
|
142
|
+
makeContext(makeRequest({ body: { email: 'a@b.com' } })),
|
|
143
|
+
next,
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
const hash = createHash('sha256').update('1.1.1.1:a@b.com').digest('hex');
|
|
147
|
+
expect(service.isAllowed).toHaveBeenCalledWith(
|
|
148
|
+
bucket(`key:${hash}`),
|
|
149
|
+
5,
|
|
150
|
+
60,
|
|
151
|
+
);
|
|
152
|
+
expect(service.isAllowed.mock.calls[0][0]).not.toContain('a@b.com');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('a custom function returning undefined falls back to IP', async () => {
|
|
156
|
+
reflector.getAllAndOverride.mockReturnValue({
|
|
157
|
+
limit: 5,
|
|
158
|
+
window: 60,
|
|
159
|
+
key: () => undefined,
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
await interceptor.intercept(makeContext(makeRequest()), next);
|
|
163
|
+
|
|
164
|
+
expect(service.isAllowed).toHaveBeenCalledWith(
|
|
165
|
+
bucket('ip:1.1.1.1'),
|
|
166
|
+
5,
|
|
167
|
+
60,
|
|
168
|
+
);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('fails open when Redis is unavailable', async () => {
|
|
173
|
+
reflector.getAllAndOverride.mockReturnValue({ limit: 5, window: 900 });
|
|
174
|
+
service.isAllowed.mockRejectedValue(new Error('ECONNREFUSED'));
|
|
175
|
+
|
|
176
|
+
await interceptor.intercept(makeContext(makeRequest()), next);
|
|
177
|
+
|
|
178
|
+
expect(next.handle).toHaveBeenCalledOnce();
|
|
179
|
+
});
|
|
180
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CallHandler,
|
|
3
|
+
ExecutionContext,
|
|
4
|
+
HttpException,
|
|
5
|
+
HttpStatus,
|
|
6
|
+
Injectable,
|
|
7
|
+
Logger,
|
|
8
|
+
NestInterceptor,
|
|
9
|
+
} from '@nestjs/common';
|
|
10
|
+
import { Reflector } from '@nestjs/core';
|
|
11
|
+
import { createHash } from 'crypto';
|
|
12
|
+
import type { Request } from 'express';
|
|
13
|
+
import type { Observable } from 'rxjs';
|
|
14
|
+
import { CACHE } from '../../infrastructure/redis/cache.constants.js';
|
|
15
|
+
import { RateLimitService } from '../../infrastructure/redis/rate-limit/rate-limit.service.js';
|
|
16
|
+
import {
|
|
17
|
+
RATE_LIMIT_KEY,
|
|
18
|
+
RateLimitKey,
|
|
19
|
+
RateLimitOptions,
|
|
20
|
+
} from '../decorators/rate-limit.decorator.js';
|
|
21
|
+
import type { ICurrentUser } from '../decorators/current-user.decorator.js';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Enforces @RateLimit({ limit, window, key }) per route.
|
|
25
|
+
* Registered globally (RateLimitModule); routes without the decorator pass through.
|
|
26
|
+
*
|
|
27
|
+
* An interceptor rather than a guard on purpose: it runs after every guard, so
|
|
28
|
+
* request.user (set by JwtAuthGuard) is available for key: 'user' regardless of
|
|
29
|
+
* how the route orders its @UseGuards.
|
|
30
|
+
*
|
|
31
|
+
* Behind a proxy, enable Express `trust proxy` or every client shares the proxy's IP.
|
|
32
|
+
*/
|
|
33
|
+
@Injectable()
|
|
34
|
+
export class RateLimitInterceptor implements NestInterceptor {
|
|
35
|
+
private readonly logger = new Logger(RateLimitInterceptor.name);
|
|
36
|
+
|
|
37
|
+
constructor(
|
|
38
|
+
private readonly reflector: Reflector,
|
|
39
|
+
private readonly rateLimitService: RateLimitService,
|
|
40
|
+
) {}
|
|
41
|
+
|
|
42
|
+
async intercept(
|
|
43
|
+
context: ExecutionContext,
|
|
44
|
+
next: CallHandler,
|
|
45
|
+
): Promise<Observable<unknown>> {
|
|
46
|
+
const options = this.reflector.getAllAndOverride<
|
|
47
|
+
RateLimitOptions | undefined
|
|
48
|
+
>(RATE_LIMIT_KEY, [context.getHandler(), context.getClass()]);
|
|
49
|
+
if (!options) {
|
|
50
|
+
return next.handle();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const request = context
|
|
54
|
+
.switchToHttp()
|
|
55
|
+
.getRequest<Request & { user?: ICurrentUser }>();
|
|
56
|
+
// Route pattern, not the raw URL: the query string is client-controlled
|
|
57
|
+
// and would let a caller mint a fresh bucket per request.
|
|
58
|
+
const route = request.route?.path ?? request.path;
|
|
59
|
+
const identifier = this.resolveIdentifier(options.key ?? 'ip', request);
|
|
60
|
+
const key = CACHE.RATE_LIMIT.KEYS.BUCKET(route, identifier);
|
|
61
|
+
|
|
62
|
+
let allowed: boolean;
|
|
63
|
+
try {
|
|
64
|
+
allowed = await this.rateLimitService.isAllowed(
|
|
65
|
+
key,
|
|
66
|
+
options.limit,
|
|
67
|
+
options.window,
|
|
68
|
+
);
|
|
69
|
+
} catch (error: any) {
|
|
70
|
+
// Fail open: a Redis outage must not take the API down with it.
|
|
71
|
+
this.logger.error(
|
|
72
|
+
`Rate limit check failed for ${route}: ${error.message}`,
|
|
73
|
+
);
|
|
74
|
+
return next.handle();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (!allowed) {
|
|
78
|
+
this.logger.warn(`Rate limit exceeded: ${route} (${identifier})`);
|
|
79
|
+
throw new HttpException(
|
|
80
|
+
'Too Many Requests',
|
|
81
|
+
HttpStatus.TOO_MANY_REQUESTS,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
return next.handle();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private resolveIdentifier(
|
|
88
|
+
key: RateLimitKey,
|
|
89
|
+
request: Request & { user?: ICurrentUser },
|
|
90
|
+
): string {
|
|
91
|
+
const ip = `ip:${request.ip ?? request.socket.remoteAddress ?? 'unknown'}`;
|
|
92
|
+
|
|
93
|
+
if (key === 'ip') {
|
|
94
|
+
return ip;
|
|
95
|
+
}
|
|
96
|
+
if (key === 'user') {
|
|
97
|
+
return request.user ? `user:${request.user.id}` : ip;
|
|
98
|
+
}
|
|
99
|
+
const custom = key(request);
|
|
100
|
+
// Hashed: custom keys often embed an email, which must not sit in Redis keys as plain text.
|
|
101
|
+
return custom
|
|
102
|
+
? `key:${createHash('sha256').update(custom).digest('hex')}`
|
|
103
|
+
: ip;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { buildTTL } from '../../utils/ttl.util.js';
|
|
2
|
+
|
|
3
|
+
const GLOBAL_PREFIX = '{{PROJECT_NAME_SNAKE}}';
|
|
4
|
+
const GLOBAL_VER = 'v1';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Semantic TTL constants (in seconds).
|
|
8
|
+
* Pick based on data volatility — see the guidelines at the bottom of this file.
|
|
9
|
+
*/
|
|
10
|
+
export const TTL = {
|
|
11
|
+
TINY: buildTTL('MINUTE', 1), // highly volatile (stock count, live price)
|
|
12
|
+
SHORT: buildTTL('MINUTE', 5), // semi-volatile (entity detail)
|
|
13
|
+
MEDIUM: buildTTL('HOUR', 1), // semi-static (user profile, settings)
|
|
14
|
+
LONG: buildTTL('DAY', 1), // static (lookup tables, config)
|
|
15
|
+
WEEK: buildTTL('DAY', 7), // almost never changes
|
|
16
|
+
} as const;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Centralised cache-key factories.
|
|
20
|
+
*
|
|
21
|
+
* Key format: <prefix>:<global-ver>:<domain>:<domain-ver>:<type>:<field>_<value>
|
|
22
|
+
* Example: {{PROJECT_NAME_SNAKE}}:v1:orders:v1:order:id_01J8...
|
|
23
|
+
*
|
|
24
|
+
* Why a domain _VER?
|
|
25
|
+
* Bumping ORDERS._VER instantly invalidates every key under that domain without
|
|
26
|
+
* touching Redis — old keys just expire naturally.
|
|
27
|
+
*
|
|
28
|
+
* Add a block per bounded context, e.g.:
|
|
29
|
+
*
|
|
30
|
+
* ORDERS: {
|
|
31
|
+
* _VER: 'v1',
|
|
32
|
+
* KEYS: {
|
|
33
|
+
* DETAIL: (id: string): string =>
|
|
34
|
+
* `${GLOBAL_PREFIX}:${GLOBAL_VER}:orders:${CACHE.ORDERS._VER}:order:id_${id}`,
|
|
35
|
+
* },
|
|
36
|
+
* },
|
|
37
|
+
*/
|
|
38
|
+
export const CACHE = {
|
|
39
|
+
RATE_LIMIT: {
|
|
40
|
+
_VER: 'v1',
|
|
41
|
+
KEYS: {
|
|
42
|
+
// route: Express route pattern (e.g. /auth/login), identifier: e.g. ip:1.1.1.1
|
|
43
|
+
BUCKET: (route: string, identifier: string) =>
|
|
44
|
+
`${GLOBAL_PREFIX}:${GLOBAL_VER}:ratelimit:${CACHE.RATE_LIMIT._VER}:${route}:${identifier}`,
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
} as const;
|
|
48
|
+
|
|
49
|
+
/*
|
|
50
|
+
* ── Cache TTL guidelines ─────────────────────────────────────────────────────
|
|
51
|
+
*
|
|
52
|
+
* 1. Data volatility
|
|
53
|
+
* · Static (config, lookup tables) → LONG / WEEK
|
|
54
|
+
* · Semi-static (detail, profile) → MEDIUM / SHORT
|
|
55
|
+
* · Highly volatile (price, stock) → TINY / SHORT, avoid tags
|
|
56
|
+
*
|
|
57
|
+
* 2. List vs detail TTL
|
|
58
|
+
* · List keys → shorter TTL (10–30 min) or version-tracked
|
|
59
|
+
* · Detail keys → longer TTL, invalidated on write
|
|
60
|
+
*
|
|
61
|
+
* 3. Avoid thundering-herd / cache stampede
|
|
62
|
+
* RedisService.set() already adds jitter to every TTL.
|
|
63
|
+
*
|
|
64
|
+
* 4. Stale data
|
|
65
|
+
* Always invalidate (redis.invalidateDetail) right after a successful write.
|
|
66
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
67
|
+
*/
|