diff --git a/.dockerignore b/.dockerignore index 55e3489..8d4079a 100644 --- a/.dockerignore +++ b/.dockerignore @@ -8,10 +8,17 @@ pnpm-debug.log* yarn-debug.log* yarn-error.log* +# Test sources +**/*.spec.ts +src/testing/ + # Environment variables and secrets .env .env.local .env.*.local +.env.* +**/.env +**/.env.* *.pem *.key @@ -25,6 +32,7 @@ kafka_data/ .git .gitignore .gitattributes +.github # IDE configs and system files .vscode @@ -36,5 +44,10 @@ Thumbs.db # Documentation and management scripts README.md -Makefile scripts/ +coverage/ +graphify-out/ +.lsmcp/ +.codex/ +.agents/ +AGENTS.md diff --git a/.env.local.example b/.env.local.example index 9964478..3aca24d 100644 --- a/.env.local.example +++ b/.env.local.example @@ -75,6 +75,7 @@ POSTGRES_READ_POOL_IDLE_MS="30s" REDIS_HOST="template-redis" REDIS_PORT="6379" +REDIS_SENTINEL_ENABLED="false" REDIS_DB_CACHE="0" REDIS_DB_LIMITER="1" REDIS_DB_QUEUE="2" diff --git a/.env.stand.example b/.env.stand.example index 1c5bd7a..07db73b 100644 --- a/.env.stand.example +++ b/.env.stand.example @@ -5,16 +5,15 @@ SERVICE_NAME="template-service" NODE_ENV="stand" API_VERSION="1" -APP_BASE_IMAGE="node:24.18.0-alpine3.24" APP_HOST="0.0.0.0" APP_PORT="4000" -ACCESS_CONTROL_GRPC_URL="127.0.0.1:15051" +ACCESS_CONTROL_GRPC_URL="access-control-service.monadiam-apps.svc.cluster.local:50051" THROTTLE_LIMIT="20" THROTTLE_TTL="1m" HSTS_MAX_AGE="1y" BODY_LIMIT_BYTES="5242880" -JWT_ISSUER="identity-service" -JWT_JWKS_URL="http://identity-service:4002/.well-known/jwks.json" +JWT_ISSUER="http://localhost:4002" +JWT_JWKS_URL="http://identity-service.monadiam-apps.svc.cluster.local:4002/api/v1/.well-known/jwks.json" JWT_JWKS_COOLDOWN_DURATION="30s" JWT_JWKS_TIMEOUT_DURATION="5s" JWT_JWKS_CACHE_MAX_AGE="10m" @@ -30,7 +29,7 @@ ALLOWED_UI_ORIGINS='[]' # OpenTelemetry # --------------------------------------------------------------------------- -OTEL_EXPORTER_OTLP_ENDPOINT="http://otel-collector:4317" +OTEL_EXPORTER_OTLP_ENDPOINT="http://otel-collector.monadiam-observability.svc.cluster.local:4317" OTEL_LOG_LEVEL="INFO" OTEL_LOGS_EXPORTER="none" OTEL_TRACES_SAMPLER="parentbased_traceidratio" @@ -41,7 +40,7 @@ OTEL_TRACES_SAMPLER_ARG="0.2" # --------------------------------------------------------------------------- LOKI_ENABLED="true" -LOKI_URL="http://loki:3100" +LOKI_URL="http://loki.monadiam-observability.svc.cluster.local:3100" LOKI_BATCH_INTERVAL="5" # --------------------------------------------------------------------------- @@ -51,17 +50,15 @@ LOKI_BATCH_INTERVAL="5" POSTGRES_DB="template-service" POSTGRES_SSL_ENABLED="true" POSTGRES_SSL_REJECT_UNAUTHORIZED="true" -POSTGRES_SSL_CA_FILE="/tls/ca.crt" -POSTGRES_SSL_CERT_FILE="/tls/client.crt" -POSTGRES_SSL_KEY_FILE="/tls/client.key" +POSTGRES_SSL_CA_FILE="/tls/postgresql/ca.crt" POSTGRES_LOGGING="true" # --------------------------------------------------------------------------- # PostgreSQL - write # --------------------------------------------------------------------------- -POSTGRES_WRITE_HOST="template-postgres-haproxy" -POSTGRES_WRITE_PORT="" +POSTGRES_WRITE_HOST="template-postgresql-rw.monadiam-data.svc.cluster.local" +POSTGRES_WRITE_PORT="5432" POSTGRES_WRITE_POOL_MAX="2" POSTGRES_WRITE_POOL_IDLE_MS="30s" @@ -69,8 +66,8 @@ POSTGRES_WRITE_POOL_IDLE_MS="30s" # PostgreSQL - read # --------------------------------------------------------------------------- -POSTGRES_READ_HOST="template-postgres-haproxy" -POSTGRES_READ_PORT="" +POSTGRES_READ_HOST="template-postgresql-ro.monadiam-data.svc.cluster.local" +POSTGRES_READ_PORT="5432" POSTGRES_READ_POOL_MAX="4" POSTGRES_READ_POOL_IDLE_MS="30s" @@ -78,13 +75,15 @@ POSTGRES_READ_POOL_IDLE_MS="30s" # Redis # --------------------------------------------------------------------------- -REDIS_HOST="template-redis" -REDIS_PORT="6379" +REDIS_HOST="template-redis.monadiam-data.svc.cluster.local" +REDIS_SENTINEL_ENABLED="true" +REDIS_SENTINEL_PORT="26379" +REDIS_SENTINEL_MASTER="template-redis" REDIS_TLS_ENABLED="true" REDIS_TLS_REJECT_UNAUTHORIZED="true" -REDIS_TLS_CA_FILE="/tls/ca.crt" -REDIS_TLS_CERT_FILE="/tls/client.crt" -REDIS_TLS_KEY_FILE="/tls/client.key" +REDIS_TLS_CA_FILE="/tls/redis/ca.crt" +REDIS_TLS_CERT_FILE="/tls/redis/tls.crt" +REDIS_TLS_KEY_FILE="/tls/redis/tls.key" REDIS_DB_CACHE="0" REDIS_DB_LIMITER="1" REDIS_DB_QUEUE="2" @@ -93,15 +92,13 @@ REDIS_DB_QUEUE="2" # Kafka # --------------------------------------------------------------------------- -KAFKA_BROKER="kafka:29092" +KAFKA_BROKER="kafka-main-kafka-bootstrap.monadiam-messaging.svc.cluster.local:9093" KAFKA_SSL_ENABLED="true" KAFKA_SASL_ENABLED="true" KAFKA_SSL_REJECT_UNAUTHORIZED="true" -KAFKA_SSL_CA_FILE="/tls/ca.crt" -KAFKA_SSL_CERT_FILE="/tls/client.crt" -KAFKA_SSL_KEY_FILE="/tls/client.key" +KAFKA_SSL_CA_FILE="/tls/kafka/ca.crt" KAFKA_SASL_USERNAME="template-service" -KAFKA_SASL_PASSWORD_FILE="/vault/secrets/kafka_sasl_password" +KAFKA_SASL_PASSWORD_FILE="/secrets/kafka/password" KAFKA_RETRY_ATTEMPTS="10" KAFKA_RETRY_INITIAL_TIME="300ms" KAFKAJS_NO_PARTITIONER_WARNING="1" @@ -114,7 +111,7 @@ KAFKA_CONSUMER_RETRY_MAX_DELAY="10s" # Schema Registry # --------------------------------------------------------------------------- -SCHEMA_REGISTRY_URL="http://schema-registry-main:8080/apis/ccompat/v7" +SCHEMA_REGISTRY_URL="http://schema-registry-main.monadiam-messaging.svc.cluster.local:8080/apis/ccompat/v7" SCHEMA_REGISTRY_ENABLED="true" # --------------------------------------------------------------------------- @@ -129,36 +126,15 @@ CLEANUP_BATCH_DELAY="5s" CLEANUP_BATCH_BACKOFF_DELAY="10s" CLEANUP_BATCH_ATTEMPTS="3" -# --------------------------------------------------------------------------- -# Network -# --------------------------------------------------------------------------- - -DOCKER_NETWORK_MONITORING="monitoring-network" -DOCKER_NETWORK_KAFKA="kafka-net" -DOCKER_NETWORK_POSTGRES="postgres-net" -DOCKER_NETWORK_REDIS="redis-net" -DOCKER_NETWORK_VAULT="vault-net" -DOCKER_VOLUME_VAULT_CA="vault_ca" -DOCKER_VOLUME_SERVICE_TLS="service_tls" - # --------------------------------------------------------------------------- # Vault # --------------------------------------------------------------------------- -VAULT_IMAGE="hashicorp/vault:1.21.3" VAULT_AGENT_SOCKET_PATH="/vault/proxy/agent.sock" -VAULT_PORT="8200" -VAULT_APPROLE_NAME="template-service" VAULT_TRANSIT_MOUNT="transit" VAULT_TRANSIT_AUDIT_MASK_KEY="template-audit-mask-hmac" VAULT_TRANSIT_AUDIT_LOG_KEY="template-audit-log-sig" -# --------------------------------------------------------------------------- -# Vault (bootstrap) -# --------------------------------------------------------------------------- - -VAULT_PROVISIONER_TOKEN="" - # --------------------------------------------------------------------------- # Cache TTLs # --------------------------------------------------------------------------- diff --git a/.github/scripts/packages.sh b/.github/scripts/packages.sh new file mode 100644 index 0000000..9648281 --- /dev/null +++ b/.github/scripts/packages.sh @@ -0,0 +1,166 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${GITHUB_TOKEN:?GITHUB_TOKEN is required}" + +readonly RETAINED_BUILDS=10 +readonly PACKAGE="template-service" +readonly IMAGE="ghcr.io/monadiam/$PACKAGE" +readonly LOCAL_IMAGE="monadiam/template-service:ci" +readonly PACKAGE_API="/orgs/MonadIAM/packages/container/$PACKAGE" +readonly VERSION_PATTERN='^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$' +readonly DIGEST_PATTERN='^sha256:[0-9a-f]{64}$' +readonly SHA_TAG_PATTERN='^sha-[0-9a-f]{40}$' + +commit=$(git rev-parse HEAD) +sha_tag="sha-$commit" +versions='[]' +source_digest='' +published_tag='' + +load_versions() { + local page response status body url + + versions='[]' + for ((page = 1; ; page++)); do + url="https://api.github.com$PACKAGE_API/versions?per_page=100&page=$page" + response=$(curl --silent --show-error \ + --connect-timeout 5 --max-time 30 \ + --header "Authorization: Bearer $GITHUB_TOKEN" \ + --header "Accept: application/vnd.github+json" \ + --header "X-GitHub-Api-Version: 2022-11-28" \ + --write-out $'\n%{http_code}' "$url") + status="${response##*$'\n'}" + body="${response%$'\n'*}" + + if [[ "$status" == 200 ]]; then + versions=$(jq -c --argjson batch "$body" '. + $batch' <<< "$versions") + if [[ $(jq 'length' <<< "$body") -lt 100 ]]; then + break + fi + elif [[ "$status" == 404 && "$page" == 1 ]]; then + break + else + printf 'Failed to list package versions: GET %s; HTTP %s\n%s\n' \ + "$url" "$status" "${body:0:4096}" >&2 + exit 1 + fi + done + + source_digest=$(jq -r --arg tag "$sha_tag" ' + .[] | select(.metadata.container.tags | index($tag)) | .name + ' <<< "$versions") +} + +publish_commit() { + local revision + + if [[ -n "$source_digest" ]]; then + printf 'Reusing %s:%s\n' "$IMAGE" "$sha_tag" + else + revision=$(docker inspect "$LOCAL_IMAGE" \ + --format '{{index .Config.Labels "org.opencontainers.image.revision"}}') + + if [[ "$revision" == "$commit" ]]; then + docker tag "$LOCAL_IMAGE" "$IMAGE:$sha_tag" + docker push "$IMAGE:$sha_tag" + else + printf 'Image revision does not match %s\n' "$commit" >&2 + exit 1 + fi + fi + + published_tag="$sha_tag" +} + +publish_release() { + local version existing_digest + + : "${GITHUB_REF_NAME:?GITHUB_REF_NAME is required}" + version=$(jq -er '.version' package.json) + + if [[ "$version" =~ $VERSION_PATTERN && + "$GITHUB_REF_NAME" == "v$version" && + "$source_digest" =~ $DIGEST_PATTERN ]]; then + existing_digest=$(jq -r --arg tag "$version" ' + .[] | select(.metadata.container.tags | index($tag)) | .name + ' <<< "$versions") + + if [[ -z "$existing_digest" ]]; then + docker buildx imagetools create --prefer-index=false \ + --tag "$IMAGE:$version" "$IMAGE@$source_digest" + elif [[ "$existing_digest" == "$source_digest" ]]; then + printf 'Reusing release %s\n' "$version" + else + printf 'Release %s already points to another digest\n' "$version" >&2 + exit 1 + fi + else + printf 'Release requires a matching version tag and published SHA image\n' >&2 + exit 1 + fi + + published_tag="$version" +} + +cleanup() { + local candidates id + + candidates=$(jq -r \ + --arg pattern "$SHA_TAG_PATTERN" \ + --arg current "$sha_tag" \ + --argjson keep "$RETAINED_BUILDS" ' + map(select( + .metadata.container.tags | + length > 0 and all(.[]; test($pattern)) + )) + | sort_by(.created_at) | reverse + | .[$keep:][] + | select(.metadata.container.tags | index($current) == null) + | .id + ' <<< "$versions") + + if [[ -n "$candidates" ]]; then + while IFS= read -r id; do + gh api --method DELETE "$PACKAGE_API/versions/$id" + printf 'Deleted intermediate image %s\n' "$id" + done <<< "$candidates" + fi +} + +verify_publication() { + local digest + + digest=$(docker buildx imagetools inspect "$IMAGE:$published_tag" \ + --format '{{.Manifest.Digest}}') + + if [[ "$digest" =~ $DIGEST_PATTERN && + ( -z "$source_digest" || "$digest" == "$source_digest" ) ]]; then + printf 'Image: %s:%s\nDigest: %s@%s\n' \ + "$IMAGE" "$published_tag" "$IMAGE" "$digest" \ + | tee -a "$GITHUB_STEP_SUMMARY" + else + printf 'Unexpected digest: %s\n' "$digest" >&2 + exit 1 + fi +} + +case "${1:?Usage: packages.sh sha|release|cleanup}" in + sha|release) + : "${GITHUB_STEP_SUMMARY:?GITHUB_STEP_SUMMARY is required}" + load_versions + + if [[ "$1" == "sha" ]]; then + publish_commit + else + publish_release + fi + + verify_publication ;; + cleanup) + load_versions + cleanup ;; + *) + printf 'Unknown operation: %s\n' "$1" >&2 + exit 1 ;; +esac diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f84f5b1..82941cd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -113,27 +113,18 @@ jobs: build: name: Build runs-on: ubuntu-24.04 - timeout-minutes: 15 + timeout-minutes: 20 steps: - name: Check out repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - name: Set up pnpm - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 - - - name: Set up Node.js - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 - with: - node-version-file: package.json - cache: pnpm - - - name: Install dependencies - run: pnpm install --frozen-lockfile + - name: Build service image + run: make image IMAGE=monadiam/template-service:ci VCS_REF="$GITHUB_SHA" - - name: Build application - run: pnpm run build + - name: Check compiled entrypoint + run: docker run --rm --read-only --network none --entrypoint node monadiam/template-service:ci --check dist/src/main.js ci: name: CI passed diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..560b9d7 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,49 @@ +name: Publish service image + +on: + push: + branches: [dev] + workflow_dispatch: + +permissions: + contents: read + +defaults: + run: + shell: bash + +jobs: + publish: + name: Publish service image + if: ${{ github.ref == 'refs/heads/dev' }} + runs-on: ubuntu-24.04 + timeout-minutes: 20 + permissions: + contents: read + packages: write + concurrency: + group: template-service-package + cancel-in-progress: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Build image from merged commit + run: make image IMAGE=monadiam/template-service:ci VCS_REF="$GITHUB_SHA" + + - name: Log in to GHCR + run: printf '%s' "$GITHUB_TOKEN" | docker login ghcr.io --username "$GITHUB_ACTOR" --password-stdin + + - name: Publish commit image + run: make image-publish + + - name: Prune intermediate images + run: make image-cleanup + + - name: Log out of GHCR + if: ${{ always() }} + run: docker logout ghcr.io diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..4ad0376 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,43 @@ +name: Release service image + +on: + push: + tags: ["v*"] + +permissions: + contents: read + packages: write + +concurrency: + group: template-service-package + cancel-in-progress: false + +defaults: + run: + shell: bash + +jobs: + release: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - name: Check out release commit + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Require a commit from dev + run: git merge-base --is-ancestor HEAD origin/dev + + - name: Log in to GHCR + run: printf '%s' "$GITHUB_TOKEN" | docker login ghcr.io --username "$GITHUB_ACTOR" --password-stdin + + - name: Tag published image with package version + run: make image-release + + - name: Log out of GHCR + if: ${{ always() }} + run: docker logout ghcr.io diff --git a/Makefile b/Makefile index 504a5a8..0a6e44b 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,10 @@ -.PHONY: env build up down restart lint lint-fix tsc knip secrets-check env-check intl-types intl-check lsp migrate migration empty-migration seed test utest itest coverage coverage-unit coverage-integration graphify graphify-rebuild graphify-html +.PHONY: env build up down restart lint lint-fix tsc knip secrets-check env-check intl-types intl-check lsp migrate migration empty-migration seed test utest itest coverage coverage-unit coverage-integration graphify graphify-rebuild graphify-html image image-publish image-release image-cleanup -SERVICE_EXEC_WITH_SECRETS := docker-compose exec service sh /usr/local/bin/with-vault-secrets.sh +SERVICE_EXEC_WITH_SECRETS ?= docker-compose exec service sh /usr/local/bin/with-vault-secrets.sh GRAPHIFY ?= graphify +IMAGE ?= monadiam/template-service:local +BASE_IMAGE ?= node:24.18.0-alpine3.24 +VCS_REF ?= $(shell git rev-parse HEAD) # Infrastructure env: @@ -44,6 +47,16 @@ empty-migration: seed: $(SERVICE_EXEC_WITH_SECRETS) pnpm run db:seed +# Container / GHCR +image: + docker build --file docker/app/Dockerfile --target runner --build-arg BASE_IMAGE="$(BASE_IMAGE)" --build-arg VCS_REF="$(VCS_REF)" --tag "$(IMAGE)" . +image-publish: + bash .github/scripts/packages.sh sha +image-release: + bash .github/scripts/packages.sh release +image-cleanup: + bash .github/scripts/packages.sh cleanup + # Test test: pnpm run test diff --git a/README.md b/README.md index 626df79..240e624 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Universal NestJS service boilerplate for MonadIAM — authn/permission guards wi What's Included | Area | Files | -|:-------------------------------------|:-------------------------------------------------| +| :----------------------------------- | :----------------------------------------------- | | Authn and permission guards | `src/context/infrastructure/guards` | | Access cache and invalidation | `src/context/infrastructure/queues/access-cache` | | Kafka retry/dead-letter flow | `src/context/infrastructure/queues/kafka-retry` | @@ -93,7 +93,7 @@ make up The local service container depends on: | Dependency | Address inside Docker network | -|:--------------------|:-----------------------------------| +| :------------------ | :--------------------------------- | | PostgreSQL | `template-postgresql:5432` | | Redis | `template-redis:6379` | | Kafka | `kafka:29092` | @@ -108,11 +108,32 @@ Shared Docker networks are created by the infra repository: ---- +
+Stand Configuration + +Runtime settings: [.env.stand.example](.env.stand.example). + +| Connection | Stand mode | Mounted files / credentials | +| :--------- | :------------------------------- | :------------------------------------------------------------- | +| PostgreSQL | CNPG rw / ro, TLS, password | /tls/postgresql/ca.crt; POSTGRES_USER, POSTGRES_PASSWORD | +| Redis | Sentinel, master discovery, mTLS | /tls/redis/{ca.crt,tls.crt,tls.key}; REDIS_PASSWORD | +| Kafka | TLS + SCRAM-SHA-512 | /tls/kafka/ca.crt; /secrets/kafka/password | +| Vault | Agent Unix socket | /vault/proxy/agent.sock | + +```sh +kubectl exec -n monadiam-apps -c service -- make migrate +kubectl exec -n monadiam-apps -c service -- make seed +``` + +
+ +---- +
Local Ports | Resource | Port | -|:-----------|:-------------------------------------| +| :--------- | :----------------------------------- | | HTTP API | `4000` | | PostgreSQL | `6000` on host, `5432` inside Docker | | Redis | `7000` on host, `6379` inside Docker | @@ -124,42 +145,97 @@ Shared Docker networks are created by the infra repository:
Project Commands -| Makefile | Description | -|:----------------------------------|:--------------------------------------------------------------------| -| **Infrastructure** | | -| `make env` | Create `.env` from `.env.local.example`. | -| `make build` | Build the application image. | -| `make up` | Start service, Vault bootstrap, and Vault Agent. | -| `make down` | Stop and remove service containers. | -| `make restart` | Run `make down` and `make up`. | -| **Development** | | -| `make lint` | Run ESLint code checks. | -| `make lint-fix` | Fix only Prettier violations and print changed file paths. | -| `make tsc` | Check TypeScript with tsc --noEmit. | -| `make knip` | Detect unused exports, files, and dependencies. | -| `make secrets-check` | Run the hook secret scan (staged changes). | -| `make env-check` | Validate environment DTO, templates and injected keys. | -| `make intl-types` | Generate TypeScript types from the translation dictionaries. | -| `make intl-check` | Run all six dictionary checks used by the Git hook. | -| `make lsp` | Start the local TypeScript MCP server over stdio. | -| `make graphify` | Create or update the local code graph and Markdown report. | -| `make graphify-rebuild` | Rescan all code files and regenerate the report. | -| `make graphify-html` | Update the graph and report, then export HTML visualization. | -| `make swagger` | Generate the OpenAPI Swagger JSON file. | -| `make postman` | Generate and patch the Postman collection JSON file. | -| `make docs` | Generate Swagger and Postman documentation artifacts. | -| **Database** | | -| `make migrate` | Apply pending MikroORM migrations in the running service container. | -| `make migration name="..."` | Generate a MikroORM migration in the running service container. | -| `make empty-migration name="..."` | Generate a blank MikroORM migration. | -| `make seed` | Run the PostgreSQL seeder in the running service container. | -| **Test** | | -| `make test` | Run unit and integration tests (Docker required). | -| `make utest` | Run the complete unit suite. | -| `make itest` | Run integration tests (Docker required). | -| `make coverage-unit` | Run unit tests with coverage in `coverage/unit`. | -| `make coverage-integration` | Run integration tests with coverage in `coverage/integration`. | -| `make coverage` | Run both suites with combined coverage in `coverage/all`. | +| Makefile | Description | +| :-------------------------------- | :-------------------------------------------------------------------- | +| **Infrastructure** | | +| `make env` | Create `.env` from `.env.local.example`. | +| `make build` | Build the application image. | +| `make up` | Start service, Vault bootstrap, and Vault Agent. | +| `make down` | Stop and remove service containers. | +| `make restart` | Run `make down` and `make up`. | +| **Development** | | +| `make lint` | Run ESLint code checks. | +| `make lint-fix` | Fix only Prettier violations and print changed file paths. | +| `make tsc` | Check TypeScript with tsc --noEmit. | +| `make knip` | Detect unused exports, files, and dependencies. | +| `make secrets-check` | Run the hook secret scan (staged changes). | +| `make env-check` | Validate environment DTO, templates and injected keys. | +| `make intl-types` | Generate TypeScript types from the translation dictionaries. | +| `make intl-check` | Run all six dictionary checks used by the Git hook. | +| `make lsp` | Start the local TypeScript MCP server over stdio. | +| `make graphify` | Create or update the local code graph and Markdown report. | +| `make graphify-rebuild` | Rescan all code files and regenerate the report. | +| `make graphify-html` | Update the graph and report, then export HTML visualization. | +| `make swagger` | Generate the OpenAPI Swagger JSON file. | +| `make postman` | Generate and patch the Postman collection JSON file. | +| `make docs` | Generate Swagger and Postman documentation artifacts. | +| **Database** | | +| `make migrate` | Apply pending MikroORM migrations in the running service container. | +| `make migration name="..."` | Generate a MikroORM migration in the running service container. | +| `make empty-migration name="..."` | Generate a blank MikroORM migration. | +| `make seed` | Run the PostgreSQL seeder in the running service container. | +| **Container / GHCR** | | +| `make image` | Build the production image; accepts `IMAGE`, `BASE_IMAGE`, `VCS_REF`. | +| `make image-publish` | Publish the commit SHA image to GHCR (CI). | +| `make image-release` | Tag the published SHA image with the package version (CI). | +| `make image-cleanup` | Retain 10 SHA-only image versions (CI). | +| **Test** | | +| `make test` | Run unit and integration tests (Docker required). | +| `make utest` | Run the complete unit suite. | +| `make itest` | Run integration tests (Docker required). | +| `make coverage-unit` | Run unit tests with coverage in `coverage/unit`. | +| `make coverage-integration` | Run integration tests with coverage in `coverage/integration`. | +| `make coverage` | Run both suites with combined coverage in `coverage/all`. | + +
+ +---- + +
+Container package (GHCR) + +`ghcr.io/monadiam/template-service` + +| Event | Action | +| :------------ | :------------------------------------------------------------------------------------------------------------- | +| PR to `dev` | Existing checks and production image build; no publication | +| Push to `dev` | Build merged commit and publish `sha-` | +| Tag `vX.Y.Z` | Require matching `package.json` version and a commit in `dev`; tag its SHA image as `X.Y.Z` without rebuilding | +| Cleanup | Retain 10 SHA-only versions; preserve release tags | +| Stand | Pin the released image by digest | + +[CI](.github/workflows/ci.yml), [publication](.github/workflows/publish.yml), +[release](.github/workflows/release.yml), [package operations](.github/scripts/packages.sh). + +[Dockerfile](docker/app/Dockerfile) targets: `development` for Compose and migration tooling; +`runner` for GHCR, with production dependencies, compiled application and TypeScript sources +for CLI commands. Both targets include `make` and cached `pnpm`; startup runs the service only. + +
+ +---- + +
+Releasing a new version + +1. Prepare a branch and set `version` in `package.json` (`1.0.0` for the first release): +```bash +git switch dev +git pull --ff-only +git switch -c chore/release-v1.0.0 +# Update package.json before committing. +git add package.json +git commit -m "chore: release v1.0.0" +git push -u origin chore/release-v1.0.0 +``` +2. Open a PR into `dev`, pass CI, merge and wait for **Publish service image** to succeed. +3. Tag the published commit; the tag must match `package.json`: +```bash +git switch dev +git pull --ff-only +git tag v1.0.0 +git push origin v1.0.0 +```
diff --git a/docker-compose.yml b/docker-compose.yml index 954e118..69401b7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -53,6 +53,7 @@ services: build: context: . dockerfile: docker/app/Dockerfile + target: development args: BASE_IMAGE: ${APP_BASE_IMAGE:?} image: ${SERVICE_NAME:?}:latest diff --git a/docker/app/Dockerfile b/docker/app/Dockerfile index 992f7a4..2d3f353 100644 --- a/docker/app/Dockerfile +++ b/docker/app/Dockerfile @@ -1,12 +1,16 @@ # syntax=docker/dockerfile:1.6 -# --- Stage 1: Build --- ARG BASE_IMAGE + +# --------------------------------------------------------------------------- +# Shared +# --------------------------------------------------------------------------- FROM ${BASE_IMAGE} AS builder WORKDIR /app +ENV COREPACK_HOME=/opt/corepack -RUN corepack enable && corepack prepare pnpm@11.22.0 --activate +RUN corepack enable COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ @@ -15,8 +19,54 @@ RUN --mount=type=cache,target=/root/.local/share/pnpm/store pnpm install --froze COPY . . RUN pnpm run build -# --- Stage 2: Runtime --- -FROM builder AS runner + + +# --------------------------------------------------------------------------- +# Development +# --------------------------------------------------------------------------- +FROM builder AS development + +RUN apk add --no-cache make +ENV SERVICE_EXEC_WITH_SECRETS="sh /usr/local/bin/with-vault-secrets.sh" EXPOSE 4000 CMD ["node", "dist/src/main.js"] + + + +# --------------------------------------------------------------------------- +# Production +# --------------------------------------------------------------------------- +FROM builder AS production-dependencies +RUN pnpm prune --prod + +FROM ${BASE_IMAGE} AS runner + +ARG VCS_REF +RUN : "${VCS_REF:?VCS_REF build argument is required}" + +LABEL org.opencontainers.image.source="https://github.com/MonadIAM/template-service" \ + org.opencontainers.image.revision="${VCS_REF}" \ + org.opencontainers.image.description="MonadIAM Template service" + +WORKDIR /app +ENV NODE_ENV=production \ + COREPACK_HOME=/opt/corepack \ + COREPACK_ENABLE_NETWORK=0 \ + SERVICE_EXEC_WITH_SECRETS="" + +RUN apk add --no-cache make && corepack enable + +COPY --from=builder /opt/corepack /opt/corepack + +COPY --from=production-dependencies /app/node_modules ./node_modules +COPY --from=builder /app/dist ./dist +COPY --from=builder --chown=node:node /app/src ./src +COPY --chown=node:node configs/tsconfig.base.json ./configs/tsconfig.base.json +COPY --chown=node:node package.json pnpm-lock.yaml pnpm-workspace.yaml Makefile mikro-orm.config.ts tsconfig.json env.d.ts ./ + +RUN mkdir uploads && chown node:node uploads +USER node + +EXPOSE 4000 +CMD ["node", "--require", "./dist/src/observability/tracing/script.js", "dist/src/main.js"] diff --git a/mikro-orm.config.ts b/mikro-orm.config.ts index c5fa9a4..3ddb5d4 100644 --- a/mikro-orm.config.ts +++ b/mikro-orm.config.ts @@ -53,7 +53,8 @@ const config: Options = { }, seeder: { - path: "./src/infrastructure/database", + path: "./dist/src/infrastructure/database", + pathTs: "./src/infrastructure/database", glob: "seeder.{js,ts}", defaultSeeder: "System", }, @@ -67,8 +68,6 @@ const config: Options = { driverOptions: sslEnabled ? { ssl: { - cert: readFileSync(process.env.POSTGRES_SSL_CERT_FILE!, "utf8"), - key: readFileSync(process.env.POSTGRES_SSL_KEY_FILE!, "utf8"), ca: readFileSync(process.env.POSTGRES_SSL_CA_FILE!, "utf8"), rejectUnauthorized, servername: host, diff --git a/package.json b/package.json index 23527bb..fc2c874 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "description": "Universal NestJS service boilerplate for MonadIAM — authn/permission guards wired against identity-service and access-control-service, audit/change-log capture with outbox-based ClickHouse archival, and BullMQ retention cleanup.", "author": "Daniil Egorov (https://github.com/Lambdius)", "license": "SEE LICENSE IN LICENSE", - "version": "0.0.1", + "version": "1.0.0", "main": "dist/index.js", "engines": { "node": "24.18.0" @@ -61,10 +61,13 @@ "@grpc/grpc-js": "1.14.4", "@kafkajs/confluent-schema-registry": "4.1.0", "@libpg-query/parser": "17.6.10", + "@mikro-orm/cli": "7.1.13", "@mikro-orm/core": "7.1.13", + "@mikro-orm/migrations": "7.1.13", "@mikro-orm/nestjs": "7.0.2", "@mikro-orm/postgresql": "7.1.13", - "@monadiam/shared": "github:MonadIAM/shared#v2.0.18", + "@mikro-orm/seeder": "7.1.13", + "@monadiam/shared": "github:MonadIAM/shared#v3.0.1", "@nest-lab/throttler-storage-redis": "1.2.0", "@nestjs/bullmq": "11.0.5", "@nestjs/common": "11.2.1", @@ -90,6 +93,7 @@ "bullmq-otel": "2.0.1", "class-transformer": "0.5.1", "class-validator": "0.15.1", + "dotenv": "17.4.2", "fastify": "5.11.3", "ioredis": "6.0.0", "jose": "6.2.10", @@ -106,15 +110,13 @@ "prom-client": "15.1.3", "reflect-metadata": "0.2.2", "rxjs": "7.8.2", + "tsx": "4.23.12", "undici": "8.10.0" }, "devDependencies": { "@fastify/static": "10.1.3", "@jest/globals": "30.4.1", - "@mikro-orm/cli": "7.1.13", "@mikro-orm/entity-generator": "7.1.13", - "@mikro-orm/migrations": "7.1.13", - "@mikro-orm/seeder": "7.1.13", "@mizchi/lsmcp": "0.10.0", "@nestjs/cli": "11.0.24", "@testcontainers/postgresql": "12.1.0", @@ -123,7 +125,6 @@ "@types/node": "24.13.3", "@types/pg": "8.23.1", "@typescript-eslint/parser": "8.67.0", - "dotenv": "17.4.2", "eslint": "10.9.0", "eslint-import-resolver-typescript": "4.4.5", "eslint-plugin-import-x": "4.17.1", @@ -134,7 +135,6 @@ "prettier": "3.9.6", "testcontainers": "12.1.0", "ts-jest": "29.4.12", - "tsx": "4.23.12", "typescript": "6.0.3", "typescript-eslint": "8.67.0", "typescript-language-server": "5.1.3" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2d6594a..f50c6fc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,18 +29,27 @@ importers: '@libpg-query/parser': specifier: 17.6.10 version: 17.6.10 + '@mikro-orm/cli': + specifier: 7.1.13 + version: 7.1.13 '@mikro-orm/core': specifier: 7.1.13 version: 7.1.13 + '@mikro-orm/migrations': + specifier: 7.1.13 + version: 7.1.13(@mikro-orm/core@7.1.13) '@mikro-orm/nestjs': specifier: 7.0.2 version: 7.0.2(@mikro-orm/core@7.1.13)(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.2.1)(reflect-metadata@0.2.2) '@mikro-orm/postgresql': specifier: 7.1.13 version: 7.1.13(@mikro-orm/core@7.1.13) + '@mikro-orm/seeder': + specifier: 7.1.13 + version: 7.1.13(@mikro-orm/core@7.1.13) '@monadiam/shared': - specifier: github:MonadIAM/shared#v2.0.18 - version: https://codeload.github.com/MonadIAM/shared/tar.gz/87c7b77806041337becc7027619cf0372cb4d8af + specifier: github:MonadIAM/shared#v3.0.1 + version: https://codeload.github.com/MonadIAM/shared/tar.gz/a7bbf40aa06a07617dbdad4356a5fb2b4db482ad '@nest-lab/throttler-storage-redis': specifier: 1.2.0 version: 1.2.0(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.2.1)(@nestjs/throttler@6.5.0(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.2.1)(reflect-metadata@0.2.2))(ioredis@6.0.0(supports-color@8.1.1))(reflect-metadata@0.2.2) @@ -116,6 +125,9 @@ importers: class-validator: specifier: 0.15.1 version: 0.15.1 + dotenv: + specifier: 17.4.2 + version: 17.4.2 fastify: specifier: 5.11.3 version: 5.11.3 @@ -164,6 +176,9 @@ importers: rxjs: specifier: 7.8.2 version: 7.8.2 + tsx: + specifier: 4.23.12 + version: 4.23.12 undici: specifier: 8.10.0 version: 8.10.0 @@ -174,18 +189,9 @@ importers: '@jest/globals': specifier: 30.4.1 version: 30.4.1(supports-color@8.1.1) - '@mikro-orm/cli': - specifier: 7.1.13 - version: 7.1.13 '@mikro-orm/entity-generator': specifier: 7.1.13 version: 7.1.13(@mikro-orm/core@7.1.13) - '@mikro-orm/migrations': - specifier: 7.1.13 - version: 7.1.13(@mikro-orm/core@7.1.13) - '@mikro-orm/seeder': - specifier: 7.1.13 - version: 7.1.13(@mikro-orm/core@7.1.13) '@mizchi/lsmcp': specifier: 0.10.0 version: 0.10.0 @@ -210,9 +216,6 @@ importers: '@typescript-eslint/parser': specifier: 8.67.0 version: 8.67.0(eslint@10.9.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3) - dotenv: - specifier: 17.4.2 - version: 17.4.2 eslint: specifier: 10.9.0 version: 10.9.0(jiti@2.7.0)(supports-color@8.1.1) @@ -243,9 +246,6 @@ importers: ts-jest: specifier: 29.4.12 version: 29.4.12(@babel/core@7.29.7(supports-color@8.1.1))(@jest/transform@30.4.1(supports-color@8.1.1))(@jest/types@30.4.1)(babel-jest@30.4.1(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(esbuild@0.28.2)(jest-util@30.4.1)(jest@30.4.2(@types/node@24.13.3)(supports-color@8.1.1))(typescript@6.0.3) - tsx: - specifier: 4.23.12 - version: 4.23.12 typescript: specifier: 6.0.3 version: 6.0.3 @@ -1106,9 +1106,9 @@ packages: engines: {node: '>=22.0.0'} hasBin: true - '@monadiam/shared@https://codeload.github.com/MonadIAM/shared/tar.gz/87c7b77806041337becc7027619cf0372cb4d8af': - resolution: {gitHosted: true, tarball: https://codeload.github.com/MonadIAM/shared/tar.gz/87c7b77806041337becc7027619cf0372cb4d8af} - version: 2.0.18 + '@monadiam/shared@https://codeload.github.com/MonadIAM/shared/tar.gz/a7bbf40aa06a07617dbdad4356a5fb2b4db482ad': + resolution: {gitHosted: true, tarball: https://codeload.github.com/MonadIAM/shared/tar.gz/a7bbf40aa06a07617dbdad4356a5fb2b4db482ad} + version: 3.0.1 engines: {node: 24.18.0} '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': @@ -6088,7 +6088,7 @@ snapshots: uuid: 11.1.1 zod: 3.25.76 - '@monadiam/shared@https://codeload.github.com/MonadIAM/shared/tar.gz/87c7b77806041337becc7027619cf0372cb4d8af': {} + '@monadiam/shared@https://codeload.github.com/MonadIAM/shared/tar.gz/a7bbf40aa06a07617dbdad4356a5fb2b4db482ad': {} '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': optional: true diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 9ba9e4b..df6e1b6 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -6,7 +6,7 @@ color: true loglevel: info savePrefix: "" allowBuilds: - "@monadiam/shared@https://codeload.github.com/MonadIAM/shared/tar.gz/87c7b77806041337becc7027619cf0372cb4d8af": true + "@monadiam/shared@https://codeload.github.com/MonadIAM/shared/tar.gz/a7bbf40aa06a07617dbdad4356a5fb2b4db482ad": true "@launchql/protobufjs": true "msgpackr-extract": true "unrs-resolver": true diff --git a/src/bootstrap/reference.bootstrap.ts b/src/bootstrap/reference.bootstrap.ts index a395b11..617edd6 100644 --- a/src/bootstrap/reference.bootstrap.ts +++ b/src/bootstrap/reference.bootstrap.ts @@ -4,8 +4,7 @@ import apiReference from "@scalar/fastify-api-reference"; import { readFileSync } from "fs"; import { join } from "path"; -import { name, version } from "~root/package.json"; -import { NodeEnv } from "~common/enums"; +import packageJson from "~root/package.json"; const PAGE_TITLE = "MonadIAM | API Docs"; const SECURITY_SCHEME = "identity"; @@ -27,41 +26,39 @@ export abstract class BootstrapReference { private constructor() {} public static async registerReference(application: NestFastifyApplication): Promise { - if (process.env.NODE_ENV === NodeEnv.LOCAL) { - const description = readFileSync(join(__dirname, "../assets/description.md"), "utf-8"); - const favicon = readFileSync(join(__dirname, "../assets/favicon.svg"), "utf-8"); + const description = readFileSync(join(process.cwd(), "src/assets/description.md"), "utf-8"); + const favicon = readFileSync(join(process.cwd(), "src/assets/favicon.svg"), "utf-8"); - const config = new DocumentBuilder() - .setDescription(description) - .setVersion(version) - .setTitle(name) - .addOAuth2(this.securityScheme(), SECURITY_SCHEME) - .addSecurityRequirements(SECURITY_SCHEME); + const config = new DocumentBuilder() + .setDescription(description) + .setVersion(packageJson.version) + .setTitle(packageJson.name) + .addOAuth2(this.securityScheme(), SECURITY_SCHEME) + .addSecurityRequirements(SECURITY_SCHEME); - Object.entries(REFERENCE_TAGS).forEach(([name, description]) => { - config.addTag(name, description); - }); + Object.entries(REFERENCE_TAGS).forEach(([name, description]) => { + config.addTag(name, description); + }); - const document = SwaggerModule.createDocument(application, config.build()); + const document = SwaggerModule.createDocument(application, config.build()); - this.publishDocument(application, document); + this.publishDocument(application, document); - await application.register(apiReference, { - routePrefix: REFERENCE_ROUTE, - configuration: { - pageTitle: PAGE_TITLE, - favicon: `data:image/svg+xml;base64,${Buffer.from(favicon).toString("base64")}`, - showDeveloperTools: "never", - proxyUrl: "", - sources: this.sources(document), - agent: { disabled: true }, - mcp: { disabled: true }, - withDefaultFonts: false, - hideClientButton: true, - persistAuth: true, - }, - }); - } + await application.register(apiReference, { + routePrefix: REFERENCE_ROUTE, + configuration: { + pageTitle: PAGE_TITLE, + favicon: `data:image/svg+xml;base64,${Buffer.from(favicon).toString("base64")}`, + showDeveloperTools: "never", + proxyUrl: "", + sources: this.sources(document), + agent: { disabled: true }, + mcp: { disabled: true }, + withDefaultFonts: false, + hideClientButton: true, + persistAuth: true, + }, + }); } private static publishDocument(application: NestFastifyApplication, document: object): void { diff --git a/src/bootstrap/security.bootstrap.ts b/src/bootstrap/security.bootstrap.ts index 7fd33cd..3685ba4 100644 --- a/src/bootstrap/security.bootstrap.ts +++ b/src/bootstrap/security.bootstrap.ts @@ -107,20 +107,37 @@ export abstract class BootstrapSecurity { const serviceOrigins: string[] = JSON.parse(process.env.ALLOWED_SERVICE_ORIGINS); const reference = serviceOrigins.map((url) => [`${url}/docs/openapi.json`, `${url}/docs`, `${url}/api/`]).flat(); const isLocal = process.env.NODE_ENV === NodeEnv.LOCAL; + const directives = { + scriptSrc: ["'self'"], + styleSrc: ["'self'"], + connectSrc: ["'self'"], + imgSrc: ["'self'"], + upgradeInsecureRequests: [], + defaultSrc: ["'self'"], + }; + const referenceDirectives = { + ...directives, + scriptSrc: ["'self'", "'unsafe-inline'"], + styleSrc: ["'self'", "'unsafe-inline'"], + connectSrc: ["'self'", ...reference], + imgSrc: ["'self'", "data:"], + }; + + application + .getHttpAdapter() + .getInstance() + .addHook("onRoute", (route) => { + if (route.url === "/docs" || route.url.startsWith("/docs/")) { + route.helmet = { contentSecurityPolicy: { directives: referenceDirectives } }; + } + }); await application.register(fastifyHelmet, { crossOriginEmbedderPolicy: true, crossOriginOpenerPolicy: { policy: "same-origin" }, crossOriginResourcePolicy: { policy: "same-origin" }, contentSecurityPolicy: { - directives: { - scriptSrc: isLocal ? ["'self'", "'unsafe-inline'"] : ["'self'"], - styleSrc: isLocal ? ["'self'", "'unsafe-inline'"] : ["'self'"], - connectSrc: isLocal ? ["'self'", ...reference] : ["'self'"], - imgSrc: isLocal ? ["'self'", "data:"] : ["'self'"], - upgradeInsecureRequests: [], - defaultSrc: ["'self'"], - }, + directives: isLocal ? referenceDirectives : directives, }, hsts: { maxAge: hstsMaxAge, diff --git a/src/common/dto/env.dto.ts b/src/common/dto/env.dto.ts index c1a442a..b9e5b28 100644 --- a/src/common/dto/env.dto.ts +++ b/src/common/dto/env.dto.ts @@ -20,7 +20,8 @@ export class EnvironmentVariablesDTO { declare public API_VERSION: number; @Validator.IsString() - declare public APP_BASE_IMAGE: string; + @Validator.IsOptional() + declare public APP_BASE_IMAGE?: string; @Validator.IsString() declare public APP_HOST: string; @@ -137,17 +138,10 @@ export class EnvironmentVariablesDTO { declare public POSTGRES_SSL_REJECT_UNAUTHORIZED?: boolean; @Validator.IsString() + @Validator.MinLength(1) @ValidateIf((o) => o.POSTGRES_SSL_ENABLED) declare public POSTGRES_SSL_CA_FILE?: string; - @Validator.IsString() - @ValidateIf((o) => o.POSTGRES_SSL_ENABLED) - declare public POSTGRES_SSL_CERT_FILE?: string; - - @Validator.IsString() - @ValidateIf((o) => o.POSTGRES_SSL_ENABLED) - declare public POSTGRES_SSL_KEY_FILE?: string; - @Validator.IsBoolean() declare public POSTGRES_LOGGING: boolean; @@ -194,7 +188,22 @@ export class EnvironmentVariablesDTO { @Validator.IsPositiveInt() @Validator.Max(65535) - declare public REDIS_PORT: number; + @ValidateIf((o) => !o.REDIS_SENTINEL_ENABLED) + declare public REDIS_PORT?: number; + + @Validator.IsBoolean() + @Validator.IsOptional() + declare public REDIS_SENTINEL_ENABLED?: boolean; + + @Validator.IsPositiveInt() + @Validator.Max(65535) + @ValidateIf((o) => o.REDIS_SENTINEL_ENABLED) + declare public REDIS_SENTINEL_PORT?: number; + + @Validator.IsString() + @Validator.MinLength(1) + @ValidateIf((o) => o.REDIS_SENTINEL_ENABLED) + declare public REDIS_SENTINEL_MASTER?: string; @Validator.IsString() declare public REDIS_PASSWORD: string; @@ -208,14 +217,17 @@ export class EnvironmentVariablesDTO { declare public REDIS_TLS_REJECT_UNAUTHORIZED?: boolean; @Validator.IsString() + @Validator.MinLength(1) @ValidateIf((o) => o.REDIS_TLS_ENABLED) declare public REDIS_TLS_CA_FILE?: string; @Validator.IsString() + @Validator.MinLength(1) @ValidateIf((o) => o.REDIS_TLS_ENABLED) declare public REDIS_TLS_CERT_FILE?: string; @Validator.IsString() + @Validator.MinLength(1) @ValidateIf((o) => o.REDIS_TLS_ENABLED) declare public REDIS_TLS_KEY_FILE?: string; @@ -251,17 +263,10 @@ export class EnvironmentVariablesDTO { declare public KAFKA_SSL_REJECT_UNAUTHORIZED?: boolean; @Validator.IsString() + @Validator.MinLength(1) @ValidateIf((o) => o.KAFKA_SSL_ENABLED) declare public KAFKA_SSL_CA_FILE?: string; - @Validator.IsString() - @ValidateIf((o) => o.KAFKA_SSL_ENABLED) - declare public KAFKA_SSL_CERT_FILE?: string; - - @Validator.IsString() - @ValidateIf((o) => o.KAFKA_SSL_ENABLED) - declare public KAFKA_SSL_KEY_FILE?: string; - @Validator.IsString() @ValidateIf((o) => o.KAFKA_SASL_ENABLED) declare public KAFKA_SASL_USERNAME?: string; @@ -330,54 +335,47 @@ export class EnvironmentVariablesDTO { // --------------------------------------------------------------------------- @Validator.IsString() - @ValidateIf((o) => o.NODE_ENV === NodeEnv.STAND) + @Validator.IsOptional() declare public DOCKER_NETWORK_MONITORING?: string; @Validator.IsString() - declare public DOCKER_NETWORK_KAFKA: string; - - @Validator.IsString() - declare public DOCKER_NETWORK_POSTGRES: string; - - @Validator.IsString() - declare public DOCKER_NETWORK_REDIS: string; + @Validator.IsOptional() + declare public DOCKER_NETWORK_KAFKA?: string; @Validator.IsString() - declare public DOCKER_NETWORK_VAULT: string; + @Validator.IsOptional() + declare public DOCKER_NETWORK_POSTGRES?: string; @Validator.IsString() - @ValidateIf((o) => o.NODE_ENV === NodeEnv.STAND) - declare public DOCKER_VOLUME_VAULT_CA?: string; + @Validator.IsOptional() + declare public DOCKER_NETWORK_REDIS?: string; @Validator.IsString() - @ValidateIf((o) => o.NODE_ENV === NodeEnv.STAND) - declare public DOCKER_VOLUME_SERVICE_TLS?: string; + @Validator.IsOptional() + declare public DOCKER_NETWORK_VAULT?: string; // --------------------------------------------------------------------------- // Vault // --------------------------------------------------------------------------- @Validator.IsString() - declare public VAULT_IMAGE: string; + @Validator.IsOptional() + declare public VAULT_IMAGE?: string; @Validator.IsString() declare public VAULT_AGENT_SOCKET_PATH: string; - @Validator.IsPositiveInt() - @Validator.Max(65535) - @ValidateIf((o) => o.NODE_ENV === NodeEnv.STAND) - declare public VAULT_PORT?: number; - @Validator.IsString() - @ValidateIf((o) => o.NODE_ENV === NodeEnv.LOCAL) + @Validator.IsOptional() declare public VAULT_ADDR?: string; @Validator.IsString() - @ValidateIf((o) => o.NODE_ENV === NodeEnv.LOCAL) + @Validator.IsOptional() declare public VAULT_TOKEN?: string; @Validator.IsString() - declare public VAULT_APPROLE_NAME: string; + @Validator.IsOptional() + declare public VAULT_APPROLE_NAME?: string; @Validator.IsString() declare public VAULT_TRANSIT_MOUNT: string; @@ -388,14 +386,6 @@ export class EnvironmentVariablesDTO { @Validator.IsString() declare public VAULT_TRANSIT_AUDIT_LOG_KEY: string; - // --------------------------------------------------------------------------- - // Vault (bootstrap) - // --------------------------------------------------------------------------- - - @Validator.IsString() - @ValidateIf((o) => o.NODE_ENV === NodeEnv.STAND) - declare public VAULT_PROVISIONER_TOKEN?: string; - // --------------------------------------------------------------------------- // Cache TTLs // --------------------------------------------------------------------------- diff --git a/src/common/validator/env.validator.unit.spec.ts b/src/common/validator/env.validator.unit.spec.ts index bff24b0..375403a 100644 --- a/src/common/validator/env.validator.unit.spec.ts +++ b/src/common/validator/env.validator.unit.spec.ts @@ -1,4 +1,6 @@ import { describe, expect, it } from "@jest/globals"; +import { readFileSync } from "fs"; +import { parse } from "dotenv"; import { EnvironmentVariablesDTO } from "~common/dto"; @@ -84,6 +86,76 @@ const config = { describe("[Validator] - Environment", () => { describe("[Function] - validateEnv", () => { + it.each(["local", "stand"])( + "[case] - validates the actual %s template with injected credentials", + (environment) => { + // Arrange + const template = parse(readFileSync(`.env.${environment}.example`, "utf8")); + const input = { + ...template, + POSTGRES_USER: "service", + POSTGRES_PASSWORD: "secret", + REDIS_PASSWORD: "secret", + }; + + // Act + const result = validateEnv(input); + + // Assert + expect(result.NODE_ENV).toBe(environment); + expect(result.REDIS_SENTINEL_ENABLED).toBe(environment === "stand"); + expect(result.POSTGRES_WRITE_PORT).toBe(5432); + }, + ); + + it.each([ + { REDIS_SENTINEL_PORT: undefined }, + { REDIS_SENTINEL_PORT: "65536" }, + { REDIS_SENTINEL_MASTER: undefined }, + { REDIS_SENTINEL_MASTER: "" }, + ])("[case] - rejects incomplete or invalid Sentinel settings: %j", (override) => { + // Arrange + const input = { + ...config, + REDIS_SENTINEL_ENABLED: "true", + REDIS_SENTINEL_PORT: "26379", + REDIS_SENTINEL_MASTER: "template-redis", + ...override, + }; + + // Act + const act = (): unknown => validateEnv(input); + + // Assert + expect(act).toThrow("REDIS_SENTINEL_"); + }); + + it.each(["POSTGRES_SSL", "KAFKA_SSL"])("[case] - requires a CA file when %s is enabled", (prefix) => { + // Arrange + const input = { + ...config, + [`${prefix}_ENABLED`]: "true", + [`${prefix}_REJECT_UNAUTHORIZED`]: "true", + }; + + // Act + const act = (): unknown => validateEnv(input); + + // Assert + expect(act).toThrow(`${prefix}_CA_FILE`); + }); + + it("[case] - requires a direct Redis port when Sentinel is disabled", () => { + // Arrange + const input = { ...config, REDIS_PORT: undefined, REDIS_SENTINEL_ENABLED: "false" }; + + // Act + const act = (): unknown => validateEnv(input); + + // Assert + expect(act).toThrow("REDIS_PORT"); + }); + it("[case] - returns a typed configuration with explicit number and boolean conversion", () => { // Arrange diff --git a/src/infrastructure/database/database.config.ts b/src/infrastructure/database/database.config.ts index 9287f40..31b268e 100644 --- a/src/infrastructure/database/database.config.ts +++ b/src/infrastructure/database/database.config.ts @@ -47,14 +47,13 @@ export class MikroOrmConfig implements ORM.Config.Contract { }; if (config.get("POSTGRES_SSL_ENABLED")) { - const rejectUnauthorized = config.getOrThrow("POSTGRES_SSL_REJECT_UNAUTHORIZED"); - const cert = readFileSync(config.getOrThrow("POSTGRES_SSL_CERT_FILE"), "utf8"); - const key = readFileSync(config.getOrThrow("POSTGRES_SSL_KEY_FILE"), "utf8"); - const ca = readFileSync(config.getOrThrow("POSTGRES_SSL_CA_FILE"), "utf8"); - return { ...baseOptions, - ssl: { rejectUnauthorized, servername: host, cert, key, ca }, + ssl: { + rejectUnauthorized: config.getOrThrow("POSTGRES_SSL_REJECT_UNAUTHORIZED"), + ca: readFileSync(config.getOrThrow("POSTGRES_SSL_CA_FILE"), "utf8"), + servername: host, + }, }; } else { return baseOptions; diff --git a/src/infrastructure/database/database.config.unit.spec.ts b/src/infrastructure/database/database.config.unit.spec.ts index 21c90f1..a10a922 100644 --- a/src/infrastructure/database/database.config.unit.spec.ts +++ b/src/infrastructure/database/database.config.unit.spec.ts @@ -28,8 +28,6 @@ const values = { const tls = { POSTGRES_SSL_ENABLED: true, POSTGRES_SSL_REJECT_UNAUTHORIZED: true, - POSTGRES_SSL_CERT_FILE: "/cert", - POSTGRES_SSL_KEY_FILE: "/key", POSTGRES_SSL_CA_FILE: "/ca", }; @@ -111,18 +109,34 @@ describe("[Config] - MikroOrm", () => { expect(options.driverOptions!.ssl).toEqual({ rejectUnauthorized, servername: "replica", - cert: "contents:/cert", - key: "contents:/key", ca: "contents:/ca", }); - expect(readFileSync.mock.calls).toEqual([ - ["/cert", "utf8"], - ["/key", "utf8"], - ["/ca", "utf8"], - ]); + expect(readFileSync.mock.calls).toEqual([["/ca", "utf8"]]); expect(result).toMatchObject({ max: 7 }); }); + it.each(["read", "write"] as const)( + "[case] - supports CNPG password authentication with CA-only TLS for %s", + (kind) => { + // Arrange + const config = new ConfigService({ + ...values, + ...tls, + }); + + // Act + const options = builder.buildOptions({ config, kind }); + + // Assert + expect(options.driverOptions!.ssl).toEqual({ + ca: "contents:/ca", + rejectUnauthorized: true, + servername: kind === "read" ? "replica" : "primary", + }); + expect(readFileSync.mock.calls).toEqual([["/ca", "utf8"]]); + }, + ); + it("[case] - propagates certificate read failures", () => { // Arrange const error = new Error("certificate unavailable"); diff --git a/src/infrastructure/database/database.health.ts b/src/infrastructure/database/database.health.ts index 1de3e77..8ccf020 100644 --- a/src/infrastructure/database/database.health.ts +++ b/src/infrastructure/database/database.health.ts @@ -15,26 +15,12 @@ export class DatabaseHealthIndicator { public async isHealthy(key: T): Promise> { const indicator = this.healthIndicatorService.check(key); try { - await this.assertConnection(this.writeEntityManager); - await this.assertConnection(this.readEntityManager); + await this.writeEntityManager.getConnection().execute("SELECT 1"); + await this.readEntityManager.getConnection().execute("SELECT 1"); return indicator.up(); - } catch (error) { - return indicator.down({ - message: - error instanceof Error && error.message === "connection_lost" - ? "connection_lost" - : "internal_driver_error", - }); - } - } - - private async assertConnection(entityManager: ORM.EntityManager): Promise { - const isConnected = await entityManager.getConnection().isConnected(); - if (isConnected) { - await entityManager.getConnection().execute("SELECT 1"); - } else { - throw new Error("connection_lost"); + } catch { + return indicator.down({ message: "internal_driver_error" }); } } } diff --git a/src/infrastructure/database/database.health.unit.spec.ts b/src/infrastructure/database/database.health.unit.spec.ts index 3a626f9..c0e3513 100644 --- a/src/infrastructure/database/database.health.unit.spec.ts +++ b/src/infrastructure/database/database.health.unit.spec.ts @@ -38,12 +38,12 @@ describe("[HealthIndicator] - Database", () => { // Assert expect(result).toEqual({ postgres: { status: "up" } }); for (const current of [write, read]) { - expect(current.isConnected).toHaveBeenCalledTimes(1); + expect(current.isConnected).not.toHaveBeenCalled(); expect(current.execute).toHaveBeenCalledWith("SELECT 1"); } }); - it.each(["write", "read"])("[case] - reports a disconnected %s connection", async (kind) => { + it.each(["write", "read"])("[case] - probes a lazy %s connection even when isConnected is false", async (kind) => { // Arrange const current = kind === "write" ? write : read; current.isConnected.mockResolvedValue(false); @@ -53,12 +53,10 @@ describe("[HealthIndicator] - Database", () => { // Assert expect(result).toEqual({ - database: { status: "down", message: "connection_lost" }, + database: { status: "up" }, }); - expect(current.execute).not.toHaveBeenCalled(); - if (kind === "write") { - expect(read.isConnected).not.toHaveBeenCalled(); - } + expect(current.execute).toHaveBeenCalledWith("SELECT 1"); + expect(current.isConnected).not.toHaveBeenCalled(); }); it.each([ @@ -77,9 +75,9 @@ describe("[HealthIndicator] - Database", () => { }); }); - it("[case] - handles failure while checking connection state", async () => { + it("[case] - stops after the write connection query fails", async () => { // Arrange - write.isConnected.mockRejectedValue(new Error("unavailable")); + write.execute.mockRejectedValue(new Error("unavailable")); // Act const result = await indicator.isHealthy("database"); @@ -88,8 +86,8 @@ describe("[HealthIndicator] - Database", () => { expect(result).toEqual({ database: { status: "down", message: "internal_driver_error" }, }); - expect(write.execute).not.toHaveBeenCalled(); - expect(read.isConnected).not.toHaveBeenCalled(); + expect(write.execute).toHaveBeenCalledWith("SELECT 1"); + expect(read.execute).not.toHaveBeenCalled(); }); }); }); diff --git a/src/infrastructure/database/database.integration.spec.ts b/src/infrastructure/database/database.integration.spec.ts index 26b9ef9..4ba015f 100644 --- a/src/infrastructure/database/database.integration.spec.ts +++ b/src/infrastructure/database/database.integration.spec.ts @@ -54,23 +54,51 @@ describe("[Infrastructure] - Database", () => { } }); - it("[case] - reports a closed read connection as down and returns up after reconnecting", async () => { + it("[case] - reopens a closed read connection while checking readiness", async () => { // Arrange await read!.close(true); // Act const result = await health.isHealthy("database"); - await read!.reconnect(); - const result2 = await health.isHealthy("database"); - - const result1 = registry.snapshot({ kind: "read" })?.total; + const result2 = registry.snapshot({ kind: "read" })?.total; // Assert - expect(result).toEqual({ - database: { status: "down", message: "connection_lost" }, - }); - expect(result2).toEqual({ database: { status: "up" } }); - expect(result1).toBeGreaterThan(0); + expect(result).toEqual({ database: { status: "up" } }); + expect(result2).toBeGreaterThan(0); }); + + it.each(["write", "read"] as const)( + "[case] - reports an unavailable %s database as down and recovers after restoration", + async (kind) => { + // Arrange + const config = PostgresResource.config(); + config.set("POSTGRES_DB", "health_check"); + const builder = new MikroOrmConfig(registry); + const unavailable = await MikroORM.init({ + ...builder.buildOptions({ config, kind }), + ensureDatabase: false, + debug: false, + }); + const indicator = new DatabaseHealthIndicator( + kind === "write" ? unavailable.em : write!.em, + kind === "read" ? unavailable.em : read!.em, + new HealthIndicatorService(), + ); + + try { + // Act + const result = await indicator.isHealthy("database"); + await write!.em.getConnection().execute("CREATE DATABASE health_check"); + const result2 = await indicator.isHealthy("database"); + + // Assert + expect(result).toEqual({ database: { status: "down", message: "internal_driver_error" } }); + expect(result2).toEqual({ database: { status: "up" } }); + } finally { + await unavailable.close(true); + await write!.em.getConnection().execute("DROP DATABASE IF EXISTS health_check WITH (FORCE)"); + } + }, + ); }); }); diff --git a/src/infrastructure/kafka/utils.ts b/src/infrastructure/kafka/utils.ts index e02ceca..48e1c05 100644 --- a/src/infrastructure/kafka/utils.ts +++ b/src/infrastructure/kafka/utils.ts @@ -11,21 +11,18 @@ export abstract class KafkaUtils { return { ...(options.withClientId ? { clientId } : {}), - ...this.buildSslConfig(config, broker), + ...this.buildSslConfig(config), ...this.buildSaslConfig(config), brokers: [broker], }; } - private static buildSslConfig(config: ConfigService, broker: string): Pick { + private static buildSslConfig(config: ConfigService): Pick { if (config.get("KAFKA_SSL_ENABLED")) { return { ssl: { rejectUnauthorized: config.getOrThrow("KAFKA_SSL_REJECT_UNAUTHORIZED"), - cert: readFileSync(config.getOrThrow("KAFKA_SSL_CERT_FILE"), "utf8"), ca: [readFileSync(config.getOrThrow("KAFKA_SSL_CA_FILE"), "utf8")], - key: readFileSync(config.getOrThrow("KAFKA_SSL_KEY_FILE"), "utf8"), - servername: broker.split(":")[0], }, }; } else { diff --git a/src/infrastructure/kafka/utils.unit.spec.ts b/src/infrastructure/kafka/utils.unit.spec.ts index 96b61a9..7f6c3ae 100644 --- a/src/infrastructure/kafka/utils.unit.spec.ts +++ b/src/infrastructure/kafka/utils.unit.spec.ts @@ -11,8 +11,6 @@ const base = { SERVICE_NAME: "template", KAFKA_BROKER: "broker:9093" }; const ssl = { KAFKA_SSL_ENABLED: true, KAFKA_SSL_REJECT_UNAUTHORIZED: true, - KAFKA_SSL_CERT_FILE: "/cert", - KAFKA_SSL_KEY_FILE: "/key", KAFKA_SSL_CA_FILE: "/ca", }; const sasl = { @@ -67,18 +65,11 @@ describe("[Utility] - KafkaUtils", () => { expect(result).toEqual({ brokers: ["broker:9093"], ssl: { - servername: "broker", - cert: "contents:/cert", - key: "contents:/key", ca: ["contents:/ca"], rejectUnauthorized, }, }); - expect(readFileSync.mock.calls).toEqual([ - ["/cert", "utf8"], - ["/ca", "utf8"], - ["/key", "utf8"], - ]); + expect(readFileSync.mock.calls).toEqual([["/ca", "utf8"]]); }); it("[case] - combines TLS and SASL and trims the password file", () => { @@ -89,11 +80,31 @@ describe("[Utility] - KafkaUtils", () => { const result = KafkaUtils.buildClientConfig(new ConfigService({ ...base, ...ssl, ...sasl })); // Assert - expect(result.ssl).toMatchObject({ servername: "broker" }); + expect(result.ssl).not.toHaveProperty("servername"); expect(result.sasl).toEqual({ mechanism: "scram-sha-512", username: "service", password: "secret" }); expect(readFileSync).toHaveBeenCalledWith("/password", "utf8"); }); + it("[case] - authenticates with SCRAM over CA-only TLS and lets KafkaJS select each broker hostname", () => { + // Arrange + const config = new ConfigService({ + ...base, + ...ssl, + ...sasl, + }); + + // Act + const result = KafkaUtils.buildClientConfig(config); + + // Assert + expect(result.ssl).toEqual({ ca: ["contents:/ca"], rejectUnauthorized: true }); + expect(result.sasl).toMatchObject({ mechanism: "scram-sha-512", username: "service" }); + expect(readFileSync.mock.calls).toEqual([ + ["/ca", "utf8"], + ["/password", "utf8"], + ]); + }); + it("[case] - supports SASL without TLS", () => { // Arrange diff --git a/src/infrastructure/redis/redis.config.ts b/src/infrastructure/redis/redis.config.ts index 9bf46a7..ec0a00d 100644 --- a/src/infrastructure/redis/redis.config.ts +++ b/src/infrastructure/redis/redis.config.ts @@ -7,10 +7,8 @@ export class RedisConfig { private static getBaseOptions(config: ConfigService): RedisOptions { return { - ...this.buildTlsOptions(config), + ...this.buildConnectionOptions(config), password: config.getOrThrow("REDIS_PASSWORD"), - port: config.getOrThrow("REDIS_PORT"), - host: config.getOrThrow("REDIS_HOST"), enableAutoPipelining: true, lazyConnect: true, retryStrategy: (attempt): number => { @@ -34,7 +32,7 @@ export class RedisConfig { public static buildLimiterOptions(config: ConfigService): RedisOptions { return { ...this.getBaseOptions(config), - db: config.getOrThrow("REDIS_DB_LIMITER") ?? 1, + db: config.getOrThrow("REDIS_DB_LIMITER"), maxRetriesPerRequest: 5, }; } @@ -47,6 +45,23 @@ export class RedisConfig { }; } + private static buildConnectionOptions(config: ConfigService): RedisOptions { + const host = config.getOrThrow("REDIS_HOST"); + const { tls } = this.buildTlsOptions(config); + + if (config.get("REDIS_SENTINEL_ENABLED")) { + return { + sentinels: [{ host, port: config.getOrThrow("REDIS_SENTINEL_PORT") }], + sentinelPassword: config.getOrThrow("REDIS_PASSWORD"), + name: config.getOrThrow("REDIS_SENTINEL_MASTER"), + role: "master", + ...(tls ? { tls, sentinelTLS: tls, enableTLSForSentinelMode: true } : {}), + }; + } else { + return { host, port: config.getOrThrow("REDIS_PORT"), ...(tls ? { tls } : {}) }; + } + } + private static buildTlsOptions(config: ConfigService): Pick { if (config.get("REDIS_TLS_ENABLED")) { const rejectUnauthorized = config.getOrThrow("REDIS_TLS_REJECT_UNAUTHORIZED"); diff --git a/src/infrastructure/redis/redis.config.unit.spec.ts b/src/infrastructure/redis/redis.config.unit.spec.ts index 7589026..87b05a7 100644 --- a/src/infrastructure/redis/redis.config.unit.spec.ts +++ b/src/infrastructure/redis/redis.config.unit.spec.ts @@ -76,6 +76,61 @@ describe("[Config] - Redis", () => { }); }); + describe("[Behavior] - Sentinel discovery", () => { + it.each(["buildCacheOptions", "buildLimiterOptions", "buildQueueOptions"] as const)( + "[case] - configures authenticated TLS discovery for %s without a direct port", + (method) => { + // Arrange + const config = new ConfigService({ + ...values, + ...tls, + REDIS_PORT: undefined, + REDIS_SENTINEL_ENABLED: true, + REDIS_SENTINEL_PORT: 26379, + REDIS_SENTINEL_MASTER: "template-redis", + }); + + // Act + const options = RedisConfig[method](config); + + // Assert + expect(options).toMatchObject({ + sentinels: [{ host: "redis.internal", port: 26379 }], + name: "template-redis", + role: "master", + password: "secret", + sentinelPassword: "secret", + enableTLSForSentinelMode: true, + }); + expect(options.sentinelTLS).toBe(options.tls); + expect(options.tls).toMatchObject({ servername: "redis.internal", rejectUnauthorized: true }); + expect(options).not.toHaveProperty("host"); + expect(options).not.toHaveProperty("port"); + expect(readFileSync).toHaveBeenCalledTimes(3); + }, + ); + + it("[case] - supports plaintext Sentinel without reading certificates", () => { + // Arrange + const config = new ConfigService({ + ...values, + REDIS_SENTINEL_ENABLED: true, + REDIS_SENTINEL_PORT: 26379, + REDIS_SENTINEL_MASTER: "template-redis", + }); + + // Act + const options = RedisConfig.buildCacheOptions(config); + + // Assert + expect(options.sentinels).toEqual([{ host: "redis.internal", port: 26379 }]); + expect(options).not.toHaveProperty("tls"); + expect(options).not.toHaveProperty("sentinelTLS"); + expect(options).not.toHaveProperty("enableTLSForSentinelMode"); + expect(readFileSync).not.toHaveBeenCalled(); + }); + }); + describe("[Method] - buildLimiterOptions", () => { it("[case] - preserves database zero for the limiter", () => { // Arrange diff --git a/src/main.ts b/src/main.ts index beb562f..0e89073 100644 --- a/src/main.ts +++ b/src/main.ts @@ -31,8 +31,8 @@ void (async function (): Promise { BootstrapPipes.applyGlobalPipes(application); BootstrapMetrics.registerMetricsHooks(application); - await BootstrapReference.registerReference(application); await BootstrapSecurity.registerSecurityPlugins(application); + await BootstrapReference.registerReference(application); const kafkaConfig = application.get(KAFKA_CONFIG); diff --git a/tsconfig.build.json b/tsconfig.build.json index 626e1d5..5e5cc98 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -17,16 +17,13 @@ }, }, "include": [ + "./mikro-orm.config.ts", "./env.d.ts", "src/**/*", ], "exclude": [ - "src/infrastructure/database/migrations/*", - "src/infrastructure/database/datasets/*", - "src/infrastructure/database/seeders/*", "src/**/*.integration.spec.ts", "src/**/*.unit.spec.ts", - "mikro-orm.config.ts", "jest.e2e.config.ts", "src/testing/**/*", "node_modules",