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",