diff --git a/.env.example b/.env.example index 2ecb17e..d05408b 100644 --- a/.env.example +++ b/.env.example @@ -1,9 +1,15 @@ # Environment Configuration Example for LyttlenGinx Application +# +# This file is intentionally safe to commit. +# Do not place live secrets in this file or commit your local `.env` / `.env.*` overrides. +# For production, inject sensitive values through Docker Swarm secrets or an external secret store. # =============================================== # DATABASE CONFIGURATION (REQUIRED) # =============================================== -DATABASE_URL="postgresql://user:password@localhost:5432/lyttlenginx?connection_limit=20&pool_timeout=10&connect_timeout=10" +DATABASE_URL="postgresql://lyttlenginx:@localhost:5432/lyttlenginx?connect_timeout=360&pool_timeout=360&pool_size=1&schema=public&sslmode=no-verify" +# If you provide `pool_size`, the runtime will mirror it into Prisma's +# `connection_limit` setting automatically. # =============================================== # ADMIN CONFIGURATION (REQUIRED FOR PRODUCTION) @@ -13,12 +19,45 @@ DATABASE_URL="postgresql://user:password@localhost:5432/lyttlenginx?connection_l ADMIN_EMAIL="admin@example.com" # =============================================== -# SECURITY (REQUIRED - CHANGE THESE IN PRODUCTION!) +# SECURITY (REQUIRED - INJECT THESE AT RUNTIME) # =============================================== -# Generate secure random strings for production: -# - Use: openssl rand -base64 32 -# API_KEY supports multiple comma-separated keys -API_KEY="your-secure-api-key-change-this" +# Generate strong secrets at deploy time, for example: +# openssl rand -base64 32 +# API_KEY supports multiple comma-separated keys. +# Keep the real value in a Swarm secret or external secret manager. +# API keys remain available as a temporary compatibility bridge, but bearer +# tokens should be the default client credential format going forward. +API_KEY="" + +# JWT / bearer-token foundation. +# AUTH_JWT_SECRET enables locally signed HS256 access tokens (including +# `/auth/token` exchange from a legacy API key). For external identity providers, +# prefer AUTH_JWT_PUBLIC_KEY plus the expected issuer/audience below. +AUTH_JWT_SECRET="" +# AUTH_JWT_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----" +AUTH_JWT_ISSUER="lyttle-nginx" +AUTH_JWT_AUDIENCE="lyttle-nginx-admin" +AUTH_JWT_ALLOWED_ALGS="HS256,RS256" +AUTH_ACCESS_TOKEN_TTL_SECONDS="900" + +# Temporary default identity mapping for legacy API keys during the bearer-token migration. +# Supported RBAC roles are: viewer, operator, security-admin, platform-admin. +AUTH_DEFAULT_ADMIN_ROLES="platform-admin" +AUTH_DEFAULT_ADMIN_SCOPES="admin:full" + +# Certificate private-key encryption at rest. +# The current build uses a local application-layer envelope-encryption provider by default. +# Generate a strong secret, for example: openssl rand -base64 32 +# Change PRIVATE_KEY_ENCRYPTION_KEY_VERSION when you intentionally rotate the master key. +PRIVATE_KEY_ENCRYPTION_PROVIDER="local" +PRIVATE_KEY_ENCRYPTION_MASTER_KEY="" +PRIVATE_KEY_ENCRYPTION_KEY_VERSION="v1" + +# Backup encryption and integrity protection. +# This key encrypts downloaded backup artifacts and signs their manifests. +# Generate a strong secret, for example: openssl rand -base64 32 +BACKUP_ENCRYPTION_KEY="" +BACKUP_ENCRYPTION_KEY_VERSION="v1" # =============================================== # APPLICATION SETTINGS @@ -26,6 +65,48 @@ API_KEY="your-secure-api-key-change-this" NODE_ENV="production" PORT="3000" +# Restrict `nginx_custom_code` root/alias directives to approved static-content +# prefixes. The application validates fragments against this allowlist before any +# staged config activation occurs. +NGINX_CUSTOM_CODE_ALLOWED_PATH_PREFIXES="/var/www,/srv/www,/etc/nginx/custom,/usr/share/nginx/html,/etc/nginx/html" + +# =============================================== +# CLUSTER CONTROL-PLANE SETTINGS +# =============================================== +# Configure the host/address and port that peer nodes should use for inter-node +# control-plane traffic. Do not rely on public-IP autodiscovery. +# +# Single-node/local example: +# CLUSTER_CONTROL_ADDRESS="app" +# CLUSTER_CONTROL_PORT="3000" +# +# Swarm global-mode example with host-published admin port: +# CLUSTER_CONTROL_ADDRESS="node-a.internal" +# CLUSTER_CONTROL_PORT="3003" +# +# You may alternatively provide CLUSTER_CONTROL_URL="http://node-a.internal:3003" +# instead of the address/port pair. +CLUSTER_CONTROL_ADDRESS="node-a.internal" +CLUSTER_CONTROL_PORT="3003" +CLUSTER_CONTROL_PROTOCOL="http" + +# Leader coordination lease tuning. +# Keep the TTL comfortably larger than the renew interval so transient delays do +# not cause avoidable leader loss. +CLUSTER_LEASE_TTL_SECONDS="30" +CLUSTER_LEASE_RENEW_INTERVAL_MS="10000" + +# Health/readiness freshness thresholds for local config apply and certificate sync. +HEALTH_CONFIG_APPLY_MAX_AGE_MS="900000" +HEALTH_CERTIFICATE_SYNC_MAX_AGE_MS="900000" + +# Metrics/alerting freshness thresholds used by /metrics and the recommended +# Prometheus alerts documented in docs/runbooks/monitoring-alerts.md. +METRICS_CLUSTER_OPERATION_STALE_MAX_AGE_MS="900000" +METRICS_CLUSTER_OPERATION_FAILURE_WINDOW_MS="3600000" +METRICS_CERTIFICATE_ORDER_STALE_MAX_AGE_MS="1800000" +METRICS_BACKUP_MAX_AGE_MS="86400000" + # =============================================== # CERTIFICATE MANAGEMENT SETTINGS # =============================================== @@ -39,6 +120,28 @@ ALERT_THRESHOLD_DAYS="14" # Set to "true" to avoid rate limits during testing # LETSENCRYPT_STAGING="false" +# ACME challenge strategy selection. +# - auto : production default; currently resolves to the built-in shared HTTP-01 flow +# - http-01: force the built-in shared HTTP-01 flow explicitly +# Wildcard orders are intentionally rejected because the hardened ACME flow must +# not require DNS TXT changes. +ACME_CHALLENGE_STRATEGY="auto" + +# Optional explicit ACME directory URL override. +# Leave unset for the default Let's Encrypt production/staging selection. +# ACME_DIRECTORY_URL="https://acme-v02.api.letsencrypt.org/directory" + +# ACME account key persistence. The application creates this key on first use and +# reuses it for later orders instead of relying on external shell scripts. +ACME_ACCOUNT_PRIVATE_KEY_PATH="/app/state/acme/account.pem" + +# Built-in HTTP-01 configuration. +ACME_HTTP01_PROVIDER="database-http-01" +ACME_HTTP01_PROPAGATION_SECONDS="5" +ACME_HTTP01_VERIFICATION_TIMEOUT_MS="30000" +ACME_HTTP01_POLL_INTERVAL_MS="2000" + + # =============================================== # EMAIL/SMTP CONFIGURATION (OPTIONAL - RECOMMENDED) # =============================================== @@ -48,7 +151,7 @@ SMTP_HOST="" SMTP_PORT="587" SMTP_SECURE="false" SMTP_USER="" -SMTP_PASS="" +SMTP_PASS="" ALERT_EMAIL="" ALERT_FROM_EMAIL="" @@ -56,10 +159,10 @@ ALERT_FROM_EMAIL="" # ALERT WEBHOOKS (OPTIONAL - RECOMMENDED) # =============================================== # Slack webhook URL for alerts -SLACK_WEBHOOK_URL="" +SLACK_WEBHOOK_URL="" # Discord webhook URL for alerts -DISCORD_WEBHOOK_URL="" +DISCORD_WEBHOOK_URL="" # =============================================== # RATE LIMITING (OPTIONAL) @@ -67,5 +170,11 @@ DISCORD_WEBHOOK_URL="" RATE_LIMIT_TTL="60" RATE_LIMIT_MAX="100" - - +# =============================================== +# SECRET-HANDLING NOTES +# =============================================== +# - `.env.example` is the only env file intended to be tracked in git. +# - Keep real `.env`, `.env.production`, `.env.local`, and similar files out of version control. +# - Prefer Docker Swarm secrets, Vault, AWS Secrets Manager, GCP Secret Manager, or equivalent. +# - Until the application gains first-class *_FILE secret ingestion, inject runtime values from +# your deployment system rather than committing them to the repository. diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 4fda026..bb27bfb 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,16 +1,184 @@ -name: MAIN Workflow +name: CI and Release Gates on: + pull_request: + branches: + - main push: branches: - main + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + NODE_VERSION: 24.16.0 + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true' + COVERAGE_MIN_LINES: '70' + COVERAGE_MIN_BRANCHES: '67' + COVERAGE_MIN_FUNCTIONS: '76' + +permissions: + contents: read jobs: + lint: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6.0.3 + + - name: Set up Node.js + uses: actions/setup-node@v6.4.0 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Run CI lint gate + run: npm run lint:ci + + typecheck: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6.0.3 + + - name: Set up Node.js + uses: actions/setup-node@v6.4.0 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Generate Prisma client + run: npm run prisma:generate + + - name: Run TypeScript typecheck + run: npm run typecheck + + tests: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6.0.3 + + - name: Set up Node.js + uses: actions/setup-node@v6.4.0 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Generate Prisma client + run: npm run prisma:generate + + - name: Run tests with coverage gate + run: npm run test:coverage:ci + build: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6.0.3 + + - name: Set up Node.js + uses: actions/setup-node@v6.4.0 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Generate Prisma client + run: npm run prisma:generate + + - name: Build application + run: npm run build + + dependency-audit: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6.0.3 + + - name: Set up Node.js + uses: actions/setup-node@v6.4.0 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Run production dependency audit + run: npm run audit:prod + + container-scan: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6.0.3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build container image for scanning + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + load: true + pull: true + tags: lyttle-nginx:ci-${{ github.sha }} + + - name: Scan container image with Trivy + uses: aquasecurity/trivy-action@v0.36.0 + with: + image-ref: lyttle-nginx:ci-${{ github.sha }} + format: table + exit-code: '1' + ignore-unfixed: true + vuln-type: os,library + severity: HIGH,CRITICAL + + publish-image: + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + needs: + - lint + - typecheck + - tests + - build + - dependency-audit + - container-scan + permissions: + contents: read + packages: write + steps: + - name: Checkout repository + uses: actions/checkout@v6.0.3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Prepare container metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ github.repository_owner }}/lyttlenginx + tags: | + type=raw,value=main + type=sha,prefix=sha- - name: Login to GitHub Container Registry uses: docker/login-action@v3 @@ -19,8 +187,12 @@ jobs: username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Build API Docker image - run: docker build -t ghcr.io/lyttle-development/lyttlenginx:main -f Dockerfile . - - - name: Push API Docker image to GitHub Container Registry - run: docker push ghcr.io/lyttle-development/lyttlenginx:main + - name: Build and push API Docker image + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + pull: true + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/.gitignore b/.gitignore index e6f2346..7216ec2 100644 --- a/.gitignore +++ b/.gitignore @@ -6,8 +6,25 @@ venv .git cogs/.DS_Store .DS_Store + +# Local secrets and environment overrides .env +.env.* +!.env.example *.env +.envrc +*.local +secrets/ + +# Generated certificate and backup material +*.pem +*.key +*.crt +*.csr +*.p12 +*.pfx +certs/ +backups/ # Logs *.log @@ -79,9 +96,6 @@ typings/ # Yarn Integrity file .yarn-integrity -# dotenv environment variables file -.env -.env.test # parcel-bundler cache (https://parceljs.org/) .cache diff --git a/Dockerfile b/Dockerfile index 8bc3bec..f461c37 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # Multi-stage build for smaller, more secure image -FROM node:24-bookworm-slim AS builder +FROM node:24.16.0-bookworm-slim AS builder WORKDIR /app @@ -7,28 +7,28 @@ WORKDIR /app COPY package*.json ./ COPY prisma ./prisma/ -# Install ALL dependencies (including devDependencies needed for build) -RUN npm ci && \ - npx prisma generate +# Install ALL dependencies (including devDependencies needed for verification and build) +RUN npm ci -# Copy source and build +# Copy source and run the full repository verification contract inside the build COPY . . -RUN npm run build +RUN npm run verify:ci # Prune dev dependencies after build -RUN npm prune --production && \ +RUN npm prune --omit=dev && \ npm cache clean --force # Production stage -FROM debian:bookworm-slim +FROM node:24.16.0-bookworm-slim # Add labels for better container management LABEL maintainer="lyttle-development" LABEL description="NGINX proxy with SSL management and database integration" LABEL version="1.0" -# Install system packages with specific versions for reproducibility +# Install runtime system packages RUN apt-get update && \ + apt-get dist-upgrade -y && \ apt-get install -y --no-install-recommends \ curl \ ca-certificates \ @@ -36,13 +36,9 @@ RUN apt-get update && \ openssl \ bash \ tini \ - certbot \ - python3-certbot-nginx \ procps \ netcat-openbsd \ postgresql-client && \ - curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - && \ - apt-get install -y --no-install-recommends nodejs && \ apt-get clean && \ rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* @@ -57,9 +53,7 @@ COPY --from=builder /app/prisma ./prisma # Copy entrypoint scripts and make them executable COPY docker-entrypoint.sh /docker-entrypoint.sh COPY healthcheck.sh /healthcheck.sh -COPY certbot-auth-hook.sh /certbot-auth-hook.sh -COPY certbot-cleanup-hook.sh /certbot-cleanup-hook.sh -RUN chmod +x /docker-entrypoint.sh /healthcheck.sh /certbot-auth-hook.sh /certbot-cleanup-hook.sh +RUN chmod +x /docker-entrypoint.sh /healthcheck.sh # Copy nginx config to /app/nginx (for reloader service to access) COPY nginx /app/nginx @@ -74,9 +68,7 @@ RUN mkdir -p /var/log/nginx /var/lib/nginx /var/run && \ touch /var/log/nginx/error.log /var/log/nginx/access.log && \ chmod 666 /var/log/nginx/*.log && \ mkdir -p /etc/nginx/ssl && chmod 755 /etc/nginx/ssl && \ - mkdir -p /var/www/certbot && chmod 755 /var/www/certbot && \ - mkdir -p /etc/letsencrypt/live /etc/letsencrypt/archive && \ - chmod 755 /etc/letsencrypt/live /etc/letsencrypt/archive && \ + mkdir -p /etc/letsencrypt/live && chmod 755 /etc/letsencrypt/live && \ mkdir -p /app/logs && chmod 755 /app/logs # Create nginx user/group and set ownership @@ -84,8 +76,8 @@ RUN groupadd --system --gid 101 nginx || true && \ useradd --system --no-create-home --uid 101 --gid 101 nginx || true && \ chown -R nginx:nginx /etc/nginx /var/log/nginx /var/lib/nginx -# Create app state directory for crash recovery -RUN mkdir -p /app/state && chmod 755 /app/state +# Create app state directory for crash recovery and persistent ACME account state +RUN mkdir -p /app/state/acme && chmod 755 /app/state /app/state/acme # Expose API and Nginx ports EXPOSE 80 443 3000 diff --git a/IMPLEMENTATION_PLAN_BY_SESSION.md b/IMPLEMENTATION_PLAN_BY_SESSION.md deleted file mode 100644 index 249e5e0..0000000 --- a/IMPLEMENTATION_PLAN_BY_SESSION.md +++ /dev/null @@ -1,804 +0,0 @@ -# LyttleNGINX Implementation Plan by Copilot Session - -## Purpose - -This document converts `PRODUCTION_READINESS_ASSESSMENT.md` into a practical, session-by-session implementation roadmap. - -Each session is designed to be: -- focused -- testable -- reviewable -- low-risk -- independently shippable where possible - ---- - -## Working rules for every session - -- keep scope tight -- update docs for the touched area -- add or update tests -- avoid unrelated refactors -- finish each session with clear acceptance criteria - ---- - -## Global definition of done - -Before this project can be called production-ready, all of the following must be true: - -- all mutating APIs are authenticated and authorized -- inter-node traffic is authenticated and integrity-protected -- config deployment is transactional with rollback -- certificate lifecycle is modeled as an auditable state machine -- auto-recovery works under process, container, and node failure -- health probes correctly reflect liveness/readiness/startup state -- secrets and private keys are handled securely -- cluster-wide operations return operation status and node ACKs -- automated tests cover critical flows -- CI blocks insecure or broken releases - ---- - -# Phase 0 — Delivery setup and guardrails - -## Session 1 — Delivery scaffolding and progress tracking - -### Goal -Create the delivery scaffolding so later sessions are consistent. - -### Scope -- add an implementation status tracker -- add an architecture decisions log -- normalize build/lint/test/typecheck scripts -- document local vs single-node vs swarm expectations - -### Likely files -- `package.json` -- `README.md` -- new tracking docs - -### Deliverables -- `IMPLEMENTATION_STATUS.md` -- `ARCHITECTURE_DECISIONS.md` -- standard repo verification scripts - -### Acceptance criteria -- the repo has one place to track status -- common verification commands are defined -- future sessions can follow a stable format - ---- - -## Session 2 — Dependency hygiene and secret-handling cleanup - -### Goal -Remove immediate supply-chain and secret risks. - -### Scope -- patch vulnerable dependencies found in the assessment -- tighten env examples -- verify sensitive env files are not tracked -- document secret handling via Swarm secrets or external secret stores - -### Likely files -- `package.json` -- `package-lock.json` -- `.gitignore` -- `.env.example` -- `README.md` - -### Acceptance criteria -- direct CVE findings are addressed -- env examples are safe to publish -- secret handling guidance is explicit - ---- - -# Phase 1 — Emergency hardening and correctness fixes - -## Session 3 — Lock down public mutating endpoints - -### Goal -Close the immediate P0 security gap where dangerous write endpoints are public. - -### Scope -- require auth on all mutating endpoints -- define an explicit public endpoint allowlist -- separate public health/metrics endpoints from admin endpoints -- add tests proving unauthenticated writes fail - -### Likely files -- `src/certificate/certificate.controller.ts` -- `src/certificate/tls.controller.ts` -- `src/app.controller.ts` -- auth-related modules/guards - -### Acceptance criteria -- all write endpoints reject unauthenticated access -- public probe endpoints remain intentionally public -- tests cover the new access rules - ---- - -## Session 4 — Fix health, readiness, liveness, and startup semantics - -### Goal -Make health signaling reliable for orchestration and recovery. - -### Scope -- split `/health/live`, `/health/ready`, `/health/startup` -- optionally add `/health/deep` -- readiness must validate DB, NGINX, last successful config apply, and last certificate sync -- make unhealthy readiness return non-200 -- update `healthcheck.sh` - -### Likely files -- `src/health/health.controller.ts` -- `src/health/health.service.ts` -- `healthcheck.sh` - -### Acceptance criteria -- DB outage causes readiness failure -- liveness and readiness are clearly distinct -- Docker/Swarm can act on real unhealthy state - ---- - -## Session 5 — Fix container and process auto-recovery behavior - -### Goal -Ensure process failure leads to correct container recovery. - -### Scope -- rewrite `docker-entrypoint.sh` failure handling -- remove `exit 0` on error paths -- remove `sleep infinity` -- properly supervise both Node and NGINX -- tune restart policy in compose/swarm files - -### Likely files -- `docker-entrypoint.sh` -- `docker-compose.yml` -- `docker-compose.swarm.yml` - -### Acceptance criteria -- Node crash causes correct restart behavior -- NGINX crash is restarted or fails the container correctly -- no wedged state blocks auto recovery - ---- - -## Session 6 — Fix inter-node addressing and Swarm communication model - -### Goal -Stop relying on incorrect or fragile node discovery. - -### Scope -- remove public-IP discovery from cluster comms -- define explicit internal control-plane address + port configuration -- fix assumptions around `PORT`, published port, and container port -- validate registered node addresses - -### Likely files -- `src/utils/network-utils.ts` -- `src/distributed-lock/cluster-heartbeat.service.ts` -- `src/distributed-lock/cluster.controller.ts` -- `src/certificate/certificate.service.ts` -- swarm config files - -### Acceptance criteria -- inter-node calls do not depend on public internet services -- cluster URLs are built from explicit, reachable internal addresses -- published and internal ports are not conflated - ---- - -# Phase 2 — Security model foundation - -## Session 7 — Introduce a real auth foundation - -### Goal -Move beyond simple shared API keys and prepare for proper identity. - -### Scope -- design request identity model for admins and internal nodes -- introduce JWT/OIDC-compatible auth foundation -- keep temporary compatibility if API keys must remain during migration -- attach actor identity to request context - -### Likely files -- `src/auth/*` -- `src/main.ts` -- global guards/interceptors - -### Acceptance criteria -- requests carry identity, not just boolean auth -- the codebase is ready for RBAC and audit logging - ---- - -## Session 8 — Add RBAC and authorization policies - -### Goal -Control which actors can perform which actions. - -### Scope -- define roles such as `viewer`, `operator`, `security-admin`, `platform-admin`, `internal-node` -- add permission decorators and guards -- map all endpoints to permissions - -### Likely files -- `src/auth/*` -- controllers across `src/` - -### Acceptance criteria -- sensitive actions require appropriate roles -- endpoint permission matrix is explicit and testable - ---- - -## Session 9 — Add audit logging for privileged and mutating operations - -### Goal -Make all sensitive actions attributable and reviewable. - -### Scope -- add `AuditEvent` model -- log actor, action, target, result, timestamp, correlation ID -- capture successful and failed privileged actions - -### Likely files -- `prisma/schema.prisma` -- new migration -- audit/logging services -- write controllers/services - -### Acceptance criteria -- all mutating admin operations create audit entries -- failed privileged actions are also captured - ---- - -# Phase 3 — Cluster coordination redesign - -## Session 10 — Add lease-based coordination primitives - -### Goal -Replace advisory-lock-centric leadership with a durable lease model. - -### Scope -- add `ClusterLease` schema/model -- implement lease acquisition, renewal, release -- add generation/fencing token semantics - -### Likely files -- `prisma/schema.prisma` -- new migration -- `src/distributed-lock/*` - -### Acceptance criteria -- leader identity is lease-based -- stale leaders can be rejected using generation tokens - ---- - -## Session 11 — Move heartbeat and leader flows onto leases - -### Goal -Make membership and leadership internally consistent. - -### Scope -- refactor heartbeat service to use lease-backed truth -- remove dangerous deadlock recovery assumptions -- improve stale-node handling and diagnostics - -### Likely files -- `src/distributed-lock/cluster-heartbeat.service.ts` -- `src/distributed-lock/cluster.controller.ts` - -### Acceptance criteria -- leadership state and DB state do not silently diverge -- split-brain recovery logic is simplified and testable - ---- - -## Session 12 — Add cluster operations and per-node ACK tracking - -### Goal -Support cluster-aware APIs and tracked asynchronous actions. - -### Scope -- add `ClusterOperation` -- add `ClusterOperationAck` -- optionally add desired/applied state version tables -- convert cluster-wide mutations to tracked async operations - -### Likely files -- `prisma/schema.prisma` -- new migrations -- cluster/reloader/certificate services and controllers - -### Acceptance criteria -- cluster-wide changes return operation IDs instead of local-only success -- each node can ACK success/failure per operation - ---- - -# Phase 4 — Configuration rollout and rollback safety - -## Session 13 — Implement staged NGINX config generation and atomic activation - -### Goal -Stop destructive config deployment. - -### Scope -- generate full config in a staging directory -- validate staged config with `nginx -t` -- atomically activate only validated config -- preserve last-known-good config -- record apply metadata - -### Likely files -- `src/reloader/reloader.service.ts` -- `src/nginx/nginx.service.ts` - -### Acceptance criteria -- invalid config never replaces active config -- rollback path exists and is testable - ---- - -## Session 14 — Restrict or redesign `nginx_custom_code` - -### Goal -Eliminate arbitrary config injection risk while preserving flexibility. - -### Scope -- replace raw config injection with allowlisted directives or reviewed advanced fragments -- validate custom config before rollout -- bind advanced access to stricter authorization - -### Likely files -- `src/nginx/nginx.service.ts` -- `src/reloader/reloader.service.ts` - -### Acceptance criteria -- unsafe config cannot be injected casually -- invalid fragments are rejected before apply - ---- - -# Phase 5 — Certificate lifecycle redesign - -## Session 15 — Add strict domain validation and safe process execution - -### Goal -Remove injection and path risks in certificate workflows. - -### Scope -- add strict domain normalization/validation -- enforce wildcard/punycode/FQDN rules -- replace shell string interpolation with safe process execution helpers using argument arrays -- sanitize filesystem path derivation - -### Likely files -- `src/utils/domain-utils.ts` -- certificate DTOs -- `src/certificate/certificate.service.ts` -- `src/certificate/tls-config.service.ts` - -### Acceptance criteria -- malicious or malformed domain input is rejected early -- OS command execution does not rely on unsafe shell concatenation - ---- - -## Session 16 — Add certificate order state machine - -### Goal -Model issuance as a durable, auditable workflow. - -### Scope -- add models such as `CertificateOrder`, `CertificateArtifactVersion`, optionally `CertificateActivation` -- define explicit states: requested, challenge-published, validating, issued, distributing, activated, failed, revoked -- persist retry/backoff history - -### Likely files -- `prisma/schema.prisma` -- new migrations -- certificate services/controllers - -### Acceptance criteria -- every certificate workflow is queryable and auditable -- mid-flight failures can be resumed or retried safely - ---- - -## Session 17 — Rework cluster certificate distribution and activation - -### Goal -Ensure issued certificates are safely propagated across nodes. - -### Scope -- separate issuance from activation -- tie activation to cluster operations and ACKs -- track per-node distribution status -- support rollback to prior artifact version - -### Likely files -- `src/certificate/certificate.service.ts` -- cluster operation services/controllers - -### Acceptance criteria -- certificate completion is not just local leader success -- nodes explicitly ACK activation success/failure - ---- - -## Session 18 — Harden the ACME strategy for clustered production - -### Goal -Make certificate issuance robust in global mode. - -### Scope -- decide between hardened HTTP-01, DNS-01 support, or an abstraction that allows both -- formalize challenge publication/finalization workflow -- document the provider strategy - -### Likely files -- `src/certificate/*` -- ACME hooks/scripts if retained -- deployment docs - -### Acceptance criteria -- challenge handling is explicit and testable -- cluster issuance works when any node receives public challenge traffic - ---- - -# Phase 6 — Secrets, backups, and data protection - -## Session 19 — Encrypt private key material at rest - -### Goal -Protect certificate private keys stored in the database. - -### Scope -- add application-layer encryption for private keys -- create an abstraction for Vault/KMS/HSM integration -- store encryption metadata/versioning - -### Likely files -- Prisma schema -- certificate storage services -- backup/export code paths - -### Acceptance criteria -- private keys are not stored plaintext in the DB -- encryption supports future key rotation - ---- - -## Session 20 — Harden backup, export, import, and restore flows - -### Goal -Make backup and recovery production-grade. - -### Scope -- encrypt backup artifacts -- add integrity manifests/signatures/checksums -- validate imported/restored content before acceptance -- tighten export permissions and audit coverage - -### Likely files -- `src/certificate/certificate-backup.service.ts` -- `src/certificate/backup.controller.ts` -- auth/RBAC/audit wiring - -### Acceptance criteria -- backups are encrypted and verifiable -- imports/restores reject invalid or tampered content -- key export/import operations are strictly authorized and audited - ---- - -# Phase 7 — Operational API expansion - -## Session 21 — Add proxy management API - -### Goal -Manage proxy configuration through the API instead of direct DB mutation. - -### Scope -- add CRUD endpoints for proxies -- add validation/test endpoint(s) -- prepare desired-state version hooks - -### Likely files -- new proxy controller/service/module or related extensions -- DTOs and validators - -### Acceptance criteria -- proxies can be managed through authenticated API endpoints -- invalid upstream/config combinations are rejected before rollout - ---- - -## Session 22 — Add cluster operations and node-status admin APIs - -### Goal -Expose the cluster coordination model to operators. - -### Scope -- add endpoints for cluster status, operations, node detail, per-node config/cert state -- normalize response contracts for async operations - -### Likely files -- `src/distributed-lock/cluster.controller.ts` -- related services/DTOs - -### Acceptance criteria -- operators can inspect cluster convergence through the API -- async operations are queryable by operation ID - ---- - -## Session 23 — Add security administration APIs - -### Goal -Support operational security tasks safely. - -### Scope -- add security status endpoints -- add token/key rotation or bridge flows as applicable -- add internal cert rotation hooks for future mTLS lifecycle -- document break-glass procedures - -### Likely files -- auth/security modules -- docs/runbooks -- audit logging integration - -### Acceptance criteria -- security maintenance actions are explicit, authorized, and audited - ---- - -# Phase 8 — Logging, metrics, and SRE readiness - -## Session 24 — Replace ad hoc logging with structured operational and audit logging - -### Goal -Make logs production-usable and safe for centralized collection. - -### Scope -- emit structured JSON logs -- add request IDs, node IDs, operation IDs, actor identity -- separate operational logging from audit logging -- remove synchronous local file logging as the primary path -- add secret redaction - -### Likely files -- `src/logs/logs.service.ts` -- `src/app.module.ts` -- middleware/interceptors -- audit service - -### Acceptance criteria -- logs are machine-parseable and consistent -- sensitive data is redacted -- cluster workflows are traceable end-to-end - ---- - -## Session 25 — Expand metrics and alerting - -### Goal -Provide the signals needed to detect and diagnose incidents quickly. - -### Scope -- add metrics for leases, cluster operations, config apply, cert orders, backups, DB health -- expose richer dependency health detail -- document recommended alert rules - -### Likely files -- `src/metrics/metrics.service.ts` -- `src/metrics/metrics.controller.ts` -- health/cluster/certificate services - -### Acceptance criteria -- key workflows emit Prometheus metrics -- failure modes from the assessment are observable - ---- - -# Phase 9 — Test harness and release gates - -## Session 26 — Add automated test harness and baseline coverage - -### Goal -Create the missing automated test foundation. - -### Scope -- add test framework configuration and scripts -- add baseline tests for auth, health, leases, config generation, certificate order transitions - -### Likely files -- `package.json` -- test configuration files -- new test files - -### Acceptance criteria -- the repo can run unit/integration/e2e tests -- critical new behaviors are covered by tests - ---- - -## Session 27 — Add chaos and fault-injection validation - -### Goal -Prove the auto-recovery and coordination claims. - -### Scope -- add reproducible tests/scripts for DB outage, leader crash, NGINX crash, bad config apply, node comms failure, partial cert activation failure - -### Likely files -- test scripts/harness -- supporting docs - -### Acceptance criteria -- major failure modes from the assessment are reproducibly testable -- recovery behavior is demonstrated, not assumed - ---- - -## Session 28 — Upgrade CI/CD and release gating - -### Goal -Prevent broken or insecure releases from shipping. - -### Scope -- add CI jobs for lint, typecheck, tests, vuln scan, container scan, optionally SBOM generation -- block image push unless verification passes - -### Likely files -- `.github/workflows/main.yml` -- supporting configs - -### Acceptance criteria -- image publication occurs only after all gates pass -- insecure or broken builds are blocked - ---- - -# Phase 10 — Documentation and final validation - -## Session 29 — Reconcile README, architecture docs, and runbooks with reality - -### Goal -Make documentation accurate and useful for operators. - -### Scope -- update `README.md` -- add/refresh architecture docs -- add runbooks for leader failure, restore, rollback, issuance failure, credential rotation - -### Likely files -- `README.md` -- new docs files - -### Acceptance criteria -- docs no longer overstate readiness -- operators have documented procedures for high-risk scenarios - ---- - -## Session 30 — Final production-readiness validation pass - -### Goal -Perform the final convergence check against the assessment and roadmap. - -### Scope -- review remaining gaps against the assessment and this plan -- mark everything done, deferred, or replaced by an accepted alternative -- produce final go-live checklist - -### Deliverables -- `FINAL_PRODUCTION_CHECKLIST.md` -- remaining gap/deferment register if needed - -### Acceptance criteria -- every assessment item is accounted for -- final release checklist exists - ---- - -# Recommended execution order - -## Immediate start order -1. Session 3 — lock down public mutating endpoints -2. Session 4 — fix health semantics -3. Session 5 — fix auto-recovery behavior -4. Session 6 — fix inter-node address/port model -5. Session 2 — patch vulnerable dependencies and clean secret handling - -## Then build the foundation -6. Session 7 — auth foundation -7. Session 8 — RBAC -8. Session 9 — audit logging -9. Session 10 — leases -10. Session 11 — heartbeat/leader refactor -11. Session 12 — cluster operations + ACKs - -## Then platform safety -12. Session 13 — transactional config apply -13. Session 14 — safe custom NGINX extensibility -14. Session 15 — strict domain validation + safe process execution -15. Session 16 — certificate order state machine -16. Session 17 — certificate distribution/activation ACK flow -17. Session 18 — ACME strategy hardening -18. Session 19 — encrypt private key material -19. Session 20 — secure backup/restore - -## Then operability and proof -20. Session 21 — proxy management API -21. Session 22 — cluster operation APIs -22. Session 23 — security admin APIs -23. Session 24 — structured logging -24. Session 25 — advanced metrics/alerts -25. Session 26 — automated test harness -26. Session 27 — chaos/fault tests -27. Session 28 — CI/CD gating -28. Session 29 — docs and runbooks -29. Session 30 — final readiness validation - ---- - -# Suggested first implementation session - -## Start with Session 3 - -Why: -- it closes an immediate P0 risk -- it is relatively small and reviewable -- it reduces exposure while larger architecture work is still pending - -### First-session scope -- protect all currently public mutating endpoints -- define the public endpoint allowlist -- add auth tests for those endpoints -- update API docs for the new access rules - -### First-session exit criteria -- unauthenticated write/admin actions fail -- public probe endpoints still work intentionally -- tests cover the new behavior - ---- - -# Progress tracking template - -Use this format per session: - -```markdown -## Session N — -- Status: not started | in progress | done | blocked -- Objective: -- Files touched: -- Tests added/updated: -- Risks: -- Follow-up sessions: -- Notes: -``` - ---- - -# Final note - -This roadmap is intentionally ordered so we first reduce the biggest risks, then strengthen the architecture, then prove the system through tests and release gates. - -The best next step is: - -> Start Session 3 and implement the immediate endpoint lockdown and auth-enforcement pass. - diff --git a/PRODUCTION_READINESS_ASSESSMENT.md b/PRODUCTION_READINESS_ASSESSMENT.md deleted file mode 100644 index 5eadfd7..0000000 --- a/PRODUCTION_READINESS_ASSESSMENT.md +++ /dev/null @@ -1,1174 +0,0 @@ -# LyttleNGINX Production Readiness Assessment - -## Executive summary - -**Current verdict: not production ready for a global Docker Swarm deployment with strict auto-recovery requirements.** - -The project already contains useful building blocks: -- NestJS control plane -- NGINX config generation -- PostgreSQL-backed certificate and cluster metadata -- ACME challenge storage in the database -- basic leader-election and reload workflows - -However, the implementation still has multiple **P0/P1 blockers** that would cause outages, unsafe recovery behavior, split-brain risk, security exposure, and operational blind spots in production. - -The biggest blockers are: -1. **Auto-recovery is not reliable** -2. **Security is too weak for administrative control of edge infrastructure** -3. **Inter-node communication is not robust or secure** -4. **Certificate issuance/distribution is not safe enough for a global cluster** -5. **NGINX reload/config deployment is destructive instead of transactional** -6. **Testing, CI/CD, observability, and documentation do not match production claims** - ---- - -## Scope of this assessment - -This assessment was based on source review of the current workspace, including: -- `src/` -- `prisma/` -- `Dockerfile` -- `docker-compose.yml` -- `docker-compose.swarm.yml` -- `docker-entrypoint.sh` -- `healthcheck.sh` -- NGINX configuration templates -- GitHub Actions workflow -- environment examples - -Additional checks performed: -- dependency CVE scan on direct npm dependencies -- IDE error check on key TypeScript files -- attempted build/lint execution from this session - -### Validation note - -A full terminal build/lint run could **not** be completed from this workspace session because `npm` is not installed in the current execution environment. Static file error checks on sampled core files returned no TypeScript editor errors, but this is **not** a substitute for a full CI build/test pipeline. - ---- - -## Production target definition - -Based on your requirements, “production ready” here means: - -- **global Docker Swarm deployment** with one instance per node -- **very fast self-recovery** after process, container, node, or transient dependency failure -- **certificate issuance that is safe in multi-node mode** -- **any node may receive public traffic**, including ACME challenge traffic -- **strong admin API**, but only for authenticated and authorized operators/services -- **cluster-aware responses** for mutating operations -- **safe config rollout and rollback** -- **security-first storage and transport of private keys and admin traffic** -- **verifiable operations** through metrics, logs, events, audit records, and tests - ---- - -# 1. Critical weaknesses (P0) - -## 1.1 Auto-recovery is currently not trustworthy - -### Findings - -#### A. `docker-entrypoint.sh` exits with success even on failure paths -`docker-entrypoint.sh` defines a `cleanup()` function that ends with `exit 0`. -That function is called in failure scenarios from `monitor_processes()`. - -**Impact:** -- the container can terminate with success after an application failure -- Swarm `restart_policy.condition: on-failure` may not trigger -- this directly violates the “100% auto recover” requirement - -#### B. the process monitor is not actually monitoring both processes continuously -In `monitor_processes()`: -- it checks PIDs once -- then immediately calls `wait "$NODE_PID"` -- once blocked in `wait`, it no longer actively checks whether NGINX dies - -**Impact:** -- if NGINX dies while Node stays up, the script may not restart NGINX promptly or at all -- traffic can fail while the container still appears alive - -#### C. restart suppression intentionally disables recovery -`docker-entrypoint.sh` tracks restart count and after too many restarts it enters: -- `sleep infinity` - -**Impact:** -- recovery stops entirely -- the service becomes permanently wedged until manual intervention -- this is the opposite of the required operational model - -#### D. Swarm restart policy is capped too aggressively -`docker-compose.swarm.yml` uses: -- `restart_policy.condition: on-failure` -- `max_attempts: 3` - -**Impact:** -- a transient failure can permanently stop a node after 3 tries -- unacceptable for edge software that must self-heal - -#### E. health checks are too weak to detect real failure -`healthcheck.sh` only checks whether `/ready` returns HTTP 200. -But `HealthController` always returns HTTP 200, and `HealthService.ready()` can report `status: degraded` without failing the endpoint. - -**Impact:** -- unhealthy nodes can stay marked healthy -- Swarm will not restart or reschedule them -- traffic can keep flowing to degraded nodes - -#### F. readiness does not check the most important dependency: PostgreSQL -The current readiness flow does not fail when the database is unavailable. - -**Impact:** -- the node can appear ready while certificate issuance, cluster coordination, or config sync is broken - -### Required changes - -- remove any failure path that exits with code 0 -- remove `sleep infinity` failure mode entirely -- implement proper supervision of both NGINX and the Node process -- preferably split Node control-plane and NGINX dataplane into separate containers/services, or use a real process supervisor if they must stay together -- change restart policy to favor continued recovery rather than permanent stop -- implement separate **startup**, **liveness**, and **readiness** semantics -- readiness must validate at least: - - database connectivity - - NGINX master process health - - last successful config apply - - last successful certificate sync - - cluster communication health -- healthcheck must inspect response body, not just status code -- expose degraded/unhealthy as non-200 when recovery is required - ---- - -## 1.2 Several dangerous write endpoints are public - -### Findings - -The following mutating endpoints are currently missing `ApiKeyGuard`: -- `POST /certificates/upload` -- `POST /certificates/generate-self-signed` -- `POST /certificates/sync` -- `POST /tls/dhparam` - -### Impact - -An unauthenticated caller may be able to: -- upload certificates and private keys -- generate self-signed certificates -- force sync behavior on nodes -- trigger expensive DH parameter generation - -This is a **critical production security issue**. - -### Required changes - -- make **all mutating endpoints authenticated by default** -- introduce a deny-by-default security model -- require stronger internal auth for node-to-node operations than public admin auth -- separate public health/metrics endpoints from admin/control endpoints - ---- - -## 1.3 Inter-node communication is not secure enough - -### Findings - -Current node-to-node behavior uses: -- direct HTTP -- IP addresses discovered via `getNodeIpAddress()` -- optional API key on some calls -- **no auth at all** on `POST /certificates/sync` -- no mTLS -- no request signing -- no replay protection -- no per-node identity - -### Impact - -- any reachable actor on the internal path could spoof a node -- admin/control traffic can be intercepted or replayed -- cluster state changes are not strongly attributable to an authenticated node identity - -### Required changes - -- create a dedicated **internal control network** -- require **mTLS between nodes** -- issue each node a unique cluster identity certificate -- use short-lived service tokens or mutual TLS SPIFFE-style identities -- sign inter-node requests and include timestamps/nonces -- require auth on **all** internal endpoints -- separate external admin API from internal node API - ---- - -## 1.4 Cluster communication likely fails in Swarm as currently written - -### Findings - -#### A. wrong port assumption for inter-node calls -In several places the code uses: -- `process.env.PORT || 3000` - -But `docker-compose.swarm.yml` publishes: -- container `3000` as host `3003` - -Inter-node requests using `http://<node-ip>:3000/...` are likely wrong in host-published Swarm mode. - -#### B. node address selection depends on public-IP discovery services -`src/utils/network-utils.ts` tries external services like: -- `api.ipify.org` -- `icanhazip.com` -- `ifconfig.me` - -### Impact - -- cluster behavior depends on third-party internet services -- nodes may register a public/NAT address that is not reachable from peers -- security and privacy leakage during address detection -- broadcasts/reloads/syncs may silently fail - -### Required changes - -- stop discovering node identity via public internet services -- use Swarm-provided node metadata, overlay networking, or explicit cluster configuration -- store both: - - stable node ID - - internal control-plane address -- never assume published-port == internal-port -- make control port configuration explicit and validated -- add per-node connectivity checks and ACK tracking - ---- - -## 1.5 Certificate issuance and distribution is not robust enough for clustered production - -### Findings - -#### A. certificate state machine is too weak -The system stores some certificate status metadata, but it does not model a full order lifecycle such as: -- requested -- authorizing -- challenge-published -- validation-pending -- issued -- distributing -- activated -- failed -- rollback -- revoked - -**Impact:** -- poor visibility -- weak recovery after mid-flight failure -- hard to reason about certbot/ACME retries safely - -#### B. issuance uses shell commands with interpolated user/domain data -`CertificateService` and `TlsConfigService` build shell commands using string interpolation. -Domain DTOs only validate that values are non-empty strings. - -**Impact:** -- command injection risk -- path traversal / invalid path materialization risk -- malformed domain values can poison certbot, OpenSSL, filesystem paths, or generated NGINX config - -#### C. cert storage path uses raw domain values -Certificate directories are built from the primary domain. -Without strict canonical validation, this is unsafe. - -#### D. certbot account/certificate metadata is not persisted in Swarm config -`docker-compose.swarm.yml` defines no persistent volumes for: -- `/etc/letsencrypt` -- `/etc/nginx/ssl` -- `/var/www/certbot` -- backup storage - -Even if certificate PEMs are stored in PostgreSQL, loss of local certbot account metadata is still operationally dangerous. - -#### E. challenge serving is globally reachable, but issuance coordination is still fragile -The ACME challenge-from-DB design is directionally correct for a cluster, but issuance coordination still relies on advisory locks + DB bookkeeping without a stronger lease/fencing model. - -#### F. rate-limit handling is naive -The rate-limit logic counts local DB records by string `contains` on domains. -This is not a reliable Let’s Encrypt rate-limit model. - -#### G. failed-cert retry logic may repeatedly churn without strong backoff orchestration across nodes -The current model is better than no retry, but still not robust enough for fleet-wide issuance incidents. - -### Required changes - -- replace ad-hoc shell command construction with safe process execution using argument arrays -- validate domain names strictly: - - FQDN rules - - wildcard rules - - lowercase normalization - - punycode handling - - no path separators, whitespace, shell metacharacters, or control characters -- implement a full **certificate order state machine** -- add durable per-order history and audit records -- persist and version certificate activation state -- track per-node distribution/activation acknowledgements -- persist certbot account material if certbot remains in use -- strongly consider moving to: - - **DNS-01** for wildcard/global resilience, or - - a dedicated ACME control-plane service -- if staying with HTTP-01: - - only one leader creates challenges - - all nodes serve them from shared storage - - activation only occurs after challenge publication and validation windows are confirmed -- add per-order fencing tokens and idempotency keys -- add emergency revoke, rollback-to-previous-cert, and freeze-issuance controls - ---- - -## 1.6 NGINX config deployment is destructive, not transactional - -### Findings - -`ReloaderService.reloadConfig()` currently: -1. clears `/etc/nginx` -2. copies project config into `/etc/nginx` -3. generates files -4. runs `nginx -t` -5. reloads - -### Impact - -- a mid-process failure can leave NGINX broken or incomplete -- one bad config entry can take out a node -- this is not safe enough for production edge config management - -### Additional issue: arbitrary config injection -`nginx_custom_code` is injected directly into generated server blocks. - -**Impact:** -- config syntax corruption -- privilege abuse by anyone who can write DB state -- unsafe directives could break serving, leak files, or disable security - -### Required changes - -- implement **staged config generation** in a separate temp directory -- validate entire config tree there -- only atomically swap/symlink to active config after validation passes -- keep last-known-good config and allow immediate rollback -- store apply metadata: - - config version - - source revision - - apply node - - apply time - - validation output -- replace raw `nginx_custom_code` with: - - either a restricted directive allowlist - - or a reviewed “advanced mode” with separate approval and validation -- add dry-run, diff, and staged-commit workflows - ---- - -# 2. High-priority weaknesses (P1) - -## 2.1 Authentication and authorization model is too weak - -### Findings - -Current auth is effectively: -- shared API key(s) in env -- exact string match in memory -- no RBAC -- no user identity -- no session/service-account model -- no audit identity -- no key rotation workflow -- no constant-time comparison -- some endpoints are public by mistake - -### Impact - -This is not adequate for production infrastructure administration. - -### Required changes - -Implement a real security model: - -#### External admin API -- OIDC or JWT-based authentication -- short-lived tokens -- RBAC roles such as: - - `viewer` - - `operator` - - `security-admin` - - `platform-admin` -- optional IP/network policy for admin endpoints -- optional MFA enforced by upstream auth provider - -#### Internal node API -- mTLS-only -- service identity per node -- scoped permissions for: - - sync - - reload - - lease/heartbeat - - cluster ack - -#### Security controls -- audit log every mutating action -- token/key rotation endpoints and procedures -- break-glass credentials with expiration and audit trail - ---- - -## 2.2 Private keys are stored and exported insecurely - -### Findings - -- certificate private keys are stored in plaintext in PostgreSQL -- backup ZIP archives contain plaintext private keys -- certificate export endpoints return raw key material -- no encryption-at-rest strategy is defined at application layer - -### Impact - -Compromise of: -- database -- backup directory -- backup transport -- application memory -- admin endpoint access - -would expose TLS private keys. - -### Required changes - -- encrypt private keys before storing them in the database -- use envelope encryption with a KMS/HSM/Vault-managed master key -- version and rotate data encryption keys -- encrypt backups with strong authenticated encryption -- provide checksum/signature validation for backups -- restrict raw key export to a dedicated privileged role -- log and alert on all key export actions - ---- - -## 2.3 Backup and restore are not production-grade - -### Findings - -Current backup behavior lacks: -- encryption -- integrity signatures -- retention policy -- restore validation -- transactionally consistent snapshots -- off-site storage strategy -- cluster-wide restore workflow - -`importCertificates()` also accepts loosely typed payloads and does not perform strong validation. - -### Required changes - -- define backup classes: - - config backup - - certificate backup - - database backup - - disaster-recovery bundle -- encrypt all backups -- add integrity manifest + signing -- add retention/rotation policy -- validate imported certificates before acceptance -- support point-in-time recovery for database state -- support cluster restore with node reconciliation - ---- - -## 2.4 Leader election is not strong enough for high-stakes coordination - -### Findings - -Current approach uses PostgreSQL advisory locks plus DB booleans. -Problems include: -- no fencing token / generation counter -- lock state and DB state can diverge -- deadlock recovery logic comments overstate what `pg_advisory_unlock_all()` can do from a single session -- lock management is tied to the general Prisma client rather than an isolated lock session -- leader intent is duplicated across services - -### Impact - -Under restart storms, DB hiccups, or timing races, this can still create inconsistent control-plane behavior. - -### Required changes - -Use a real lease model: -- `cluster_leases` table or dedicated coordination backend -- fields: - - `lease_name` - - `owner_node_id` - - `generation` - - `expires_at` - - `renewed_at` -- leader operations must include the active generation/fencing token -- stale generation writes must be rejected -- one component should own leader/lease decisions centrally -- optionally move to etcd/Consul if stronger consensus is required - ---- - -## 2.5 Health, metrics, and alerting are incomplete - -### Findings - -Current metrics are useful but too shallow. -Missing metrics include: -- per-node sync lag -- config apply success/failure counts -- last successful NGINX reload timestamp -- leader lease age -- certificate order states -- ACME challenge publication latency -- inter-node request failure rate -- backup success/failure/age -- DB connectivity and latency -- reconcile loop duration - -Current alerting also lacks: -- timeout/retry discipline -- deduplication -- escalation routing -- silence windows/maintenance mode - -### Required changes - -- instrument all control-plane workflows -- export Prometheus counters, histograms, and gauges for every critical state machine -- add `/health/live`, `/health/ready`, `/health/startup`, `/health/deep` -- add alert rules for: - - no leader - - stale leader lease - - challenge publication failure - - no successful reload in X minutes - - no successful cert sync in X minutes - - node drift from desired config - - backup age exceeds threshold - - DB unavailable / latency high - ---- - -## 2.6 Rate limiting is not production-tuned - -### Findings - -- throttling is hard-coded in `RateLimitModule` -- env vars for rate limits are present in compose files but not actually wired into the module -- no endpoint-specific policy -- no exemption/alternate policy for internal node traffic - -### Required changes - -- make limits configurable per environment -- define separate policies for: - - public health/metrics - - admin reads - - admin writes - - internal node communication -- add burst control and IP/account-based controls - ---- - -## 2.7 Logging is not production-grade - -### Findings - -- synchronous file writes in `LogsService` -- duplicate stdout/stderr writes in multiple places -- no structured JSON logging standard -- no correlation IDs -- no audit/event log separation -- possible sensitive data exposure in logs - -### Required changes - -- emit structured JSON logs -- include: - - request ID - - node ID - - cluster operation ID - - certificate order ID - - actor identity -- send logs to stdout only and use platform log shipping -- separate operational logs from audit logs -- redact secrets and private material consistently - ---- - -# 3. Moderate weaknesses (P2) - -## 3.1 CI/CD does not validate production claims - -### Findings - -`.github/workflows/main.yml` only: -- checks out code -- builds Docker image -- pushes image - -There is no: -- test execution -- lint gate -- typecheck gate -- security scan -- dependency audit gate -- container scan -- SBOM generation -- deployment smoke test - -README claims such as “production-ready”, “build passing”, and “coverage ready” are not supported by the actual repository state. - -### Required changes - -Add mandatory CI stages: -- install -- lint -- typecheck -- unit tests -- integration tests -- e2e tests -- migration test -- Docker build test -- Trivy/Grype image scan -- npm audit / CVE gate -- SBOM generation -- signed image provenance - ---- - -## 3.2 There are effectively no automated tests in the project - -### Findings - -No test files were found in the workspace for unit or e2e coverage. -`package.json` also does not define normal test scripts. - -### Required changes - -Minimum required test suite: -- unit tests for services/utilities -- integration tests for Prisma and ACME challenge flows -- e2e tests for auth and controller behavior -- NGINX config generation snapshot tests -- certificate order recovery tests -- leader-election and failover tests -- chaos/fault-injection tests for: - - DB blips - - node restart - - NGINX crash - - stale leader - - partial cluster reachability - ---- - -## 3.3 Documentation is inconsistent with repository reality - -### Findings - -`README.md` references many docs that are not present in the visible repository structure. -The README also advertises production readiness and coverage that are not substantiated by tests or CI. - -### Required changes - -- keep documentation honest and generated from actual state -- add architecture docs for cluster mode -- add runbooks for: - - failed issuance - - node replacement - - DB failover - - backup restore - - emergency cert revoke - - broken config rollback - ---- - -## 3.4 Docker packaging has security and reproducibility issues - -### Findings - -- runtime image installs Node via external setup script during build -- comment says versions are specific/reproducible, but most apt packages are not actually pinned -- container appears to run as root -- NGINX logs are made world-writable (`chmod 666`) -- secret env vars are passed directly rather than via Swarm secrets/configs - -### Required changes - -- run as non-root wherever possible -- pin base images and packages appropriately -- avoid curl-to-shell where feasible -- use Swarm secrets for sensitive values -- tighten file permissions on key/cert/log locations -- add read-only root filesystem where possible -- drop Linux capabilities not required -- add seccomp/apparmor/capability policy guidance - ---- - -## 3.5 The single-instance compose file is not validly production usable - -### Findings - -`docker-compose.yml` contains both: -- `network_mode: host` -- `ports:` mappings - -with comments saying “USE ... OR ... NOT BOTH”, but both are present. - -### Impact - -- ambiguous/invalid deployment behavior -- not safe for operators to use as-is - -### Required changes - -- provide separate, valid deployment examples for: - - local dev - - single-node prod - - Swarm global mode -- remove contradictory config - ---- - -## 3.6 NGINX template issues exist even in defaults - -### Findings - -`nginx/conf.d/default.conf` references `/errors/50x.html`, while the workspace contains `nginx/html/errors/5xx.html`. - -### Impact - -- error-page behavior is inconsistent and may fail during real incidents - -### Required changes - -- correct default assets/templates -- add automated tests validating rendered NGINX configs and referenced files - ---- - -# 4. Dependency and supply-chain concerns - -## 4.1 Known dependency vulnerabilities - -Dependency scan results found direct dependency issues including: -- `@nestjs/core@11.1.9` → upgrade to `11.1.18+` -- `nodemailer@7.0.10` → upgrade to a fixed release line - -### Required changes - -- patch vulnerable direct dependencies immediately -- generate SBOM -- add automated vulnerability scans to CI -- define dependency update cadence and emergency patch SLA - ---- - -# 5. Architecture changes required for true production readiness - -## 5.1 Recommended target architecture - -### Preferred model: split control plane from edge dataplane - -#### Control plane -Responsible for: -- admin API -- desired-state management -- certificate order workflow -- leader lease / coordination -- config versioning -- audit logging -- backup orchestration - -#### Edge agent / node runtime -Responsible for: -- local NGINX runtime -- serving ACME HTTP-01 challenges from shared state -- applying validated config bundles -- activating certificate bundles -- reporting ACK/status/health to control plane - -### Why this is better - -It reduces the risk that every node performs every critical control-plane function independently. -It also makes it easier to implement: -- strict RBAC -- cleaner failure domains -- transactional config rollout -- node reconciliation -- controlled certificate activation - ---- - -## 5.2 Cluster-safe certificate design for global mode - -### Recommended workflow - -1. Admin/API request reaches **any node** -2. Receiving node authenticates/authorizes request -3. Request is forwarded to leader over internal mTLS, or leader is resolved and the request is proxied internally -4. Leader creates a **certificate order record** with idempotency key -5. Leader acquires a lease/fencing token for issuance -6. Leader publishes ACME challenge records to shared storage -7. All nodes serve challenge responses from shared storage -8. Leader finalizes ACME order -9. New cert/key bundle is encrypted and stored centrally -10. Desired-state version is incremented -11. Each node pulls the bundle, validates it locally, writes atomically, runs `nginx -t`, reloads, and ACKs success/failure -12. Leader reports completion only after required ACK policy is met - -### ACK policy options - -- `all-nodes` -- `majority` -- `at-least-one-per-region` -- `best-effort` - -For certificate activation, I strongly recommend: -- **all eligible nodes ACK** before the operation is considered complete -- failure should leave old cert active where possible - ---- - -## 5.3 Stronger coordination model - -Replace advisory-lock-only logic with: -- lease record with expiration -- generation number -- idempotency keys on all mutating requests -- operation journal/event table -- per-node apply acknowledgements - -Suggested tables: -- `ClusterLease` -- `ClusterOperation` -- `ClusterOperationAck` -- `DesiredConfigVersion` -- `NodeAppliedConfigVersion` -- `CertificateOrder` -- `CertificateArtifactVersion` - ---- - -# 6. API expansion required for production operations - -You asked for an extensive API with cluster-aware admin tools. Below is the recommended surface. - -## 6.1 Authentication / authorization API - -- `POST /auth/login` (if local auth is used) -- `POST /auth/token/refresh` -- `GET /auth/me` -- `GET /auth/roles` -- `POST /auth/service-accounts` -- `POST /auth/service-accounts/:id/rotate` -- `GET /auth/audit` - -## 6.2 Cluster topology and control - -- `GET /cluster/status` -- `GET /cluster/nodes` -- `GET /cluster/nodes/:nodeId` -- `POST /cluster/nodes/:nodeId/drain` -- `POST /cluster/nodes/:nodeId/undrain` -- `POST /cluster/nodes/:nodeId/maintenance` -- `POST /cluster/nodes/:nodeId/reconcile` -- `GET /cluster/leader` -- `GET /cluster/lease` -- `POST /cluster/lease/transfer` -- `POST /cluster/reconcile` -- `GET /cluster/operations` -- `GET /cluster/operations/:operationId` - -## 6.3 Desired state / config rollout - -- `GET /config/current` -- `POST /config/draft` -- `POST /config/draft/:id/validate` -- `GET /config/draft/:id/diff` -- `POST /config/draft/:id/commit` -- `POST /config/rollback/:version` -- `GET /config/versions` -- `GET /config/versions/:version/acks` -- `POST /config/test-render` - -## 6.4 Proxy management - -Currently this is a major gap. Add: -- `GET /proxies` -- `POST /proxies` -- `GET /proxies/:id` -- `PATCH /proxies/:id` -- `DELETE /proxies/:id` -- `POST /proxies/:id/validate` -- `POST /proxies/:id/test-upstream` -- `POST /proxies/:id/disable` -- `POST /proxies/:id/enable` - -## 6.5 Certificate lifecycle - -- `GET /certificates` -- `POST /certificates/orders` -- `GET /certificates/orders/:id` -- `POST /certificates/orders/:id/retry` -- `POST /certificates/orders/:id/cancel` -- `POST /certificates/:id/renew` -- `POST /certificates/:id/revoke` -- `POST /certificates/import` -- `POST /certificates/export` -- `GET /certificates/:id/history` -- `GET /certificates/:id/distribution` -- `POST /certificates/:id/activate` -- `POST /certificates/:id/rollback` -- `GET /certificates/challenges` - -## 6.6 Node sync/apply status - -- `GET /nodes/:nodeId/certs` -- `GET /nodes/:nodeId/config` -- `GET /nodes/:nodeId/health` -- `GET /nodes/:nodeId/last-ack` -- `POST /nodes/:nodeId/reload` -- `POST /nodes/:nodeId/sync` - -## 6.7 Backup / restore / disaster recovery - -- `POST /backups` -- `GET /backups` -- `GET /backups/:id` -- `POST /backups/:id/verify` -- `POST /restore/plan` -- `POST /restore/execute` -- `GET /restore/jobs/:id` - -## 6.8 Observability and audit - -- `GET /metrics` -- `GET /events` -- `GET /audit` -- `GET /logs/search` -- `GET /health/live` -- `GET /health/ready` -- `GET /health/deep` -- `GET /health/dependencies` - -## 6.9 Security administration - -- `GET /security/status` -- `GET /security/secrets/health` -- `POST /security/rotate/api-key` (if API keys remain) -- `POST /security/rotate/internal-certs` -- `GET /security/policy` -- `GET /security/access-review` - -### API behavior rule for cluster-wide mutating actions - -For actions that affect cluster state, the API should not simply return local-node success. -It should return one of: -- accepted with operation ID (`202`) -- completed with ACK summary -- failed with per-node error detail - -That means every cluster mutation should be represented as an operation object with: -- operation ID -- initiator -- desired state version -- target nodes -- ACK state per node -- timeout -- final status - ---- - -# 7. Security hardening checklist - -## Must-have -- authenticate every mutating endpoint -- RBAC -- mTLS internal traffic -- secrets via Swarm secrets / Vault / KMS -- at-rest encryption for key material -- audit trail for all sensitive operations -- structured secret redaction in logs -- signed backups -- dependency scanning and image scanning -- non-root runtime where feasible -- strict domain validation -- safe process execution without shell interpolation - -## Strongly recommended -- OIDC SSO for admins -- IP allowlisting for admin surface -- WAF/rate controls in front of admin API -- immutable config versions -- signed configuration bundles -- config approval workflow for dangerous directives - ---- - -# 8. Observability and SRE requirements - -## Metrics to add -- `cluster_leader_lease_seconds_remaining` -- `cluster_operation_duration_seconds` -- `cluster_operation_failures_total` -- `node_config_version` -- `node_desired_config_version` -- `node_cert_sync_lag_seconds` -- `nginx_reload_failures_total` -- `certificate_orders_total{state=...}` -- `certificate_activation_failures_total` -- `db_connectivity_status` -- `db_query_duration_seconds` -- `backup_age_seconds` - -## Alerts to add -- no active leader -- leader lease expires soon -- node missed heartbeat -- config apply failed on any node -- cluster desired/applied version drift -- certificate order stuck > X minutes -- no successful backup in X hours -- DB unavailable -- repeated container restarts -- unhealthy node still receiving traffic - -## Runbooks required -- replace failed node -- DB failover event -- ACME provider outage -- certificate issuance stuck -- rollback bad NGINX config -- restore lost certificate material -- rotate compromised admin credentials - ---- - -# 9. Data model changes required - -## Current schema gaps -The current schema is too small for reliable clustered operations. - -## Add at minimum -- `CertificateOrder` -- `CertificateArtifact` -- `CertificateActivation` -- `ClusterLease` -- `ClusterOperation` -- `ClusterOperationAck` -- `DesiredStateVersion` -- `NodeState` -- `AuditEvent` -- `SecretMetadata` - -This will let the system track: -- intent -- current state -- per-node convergence -- who changed what -- rollback points - ---- - -# 10. Testing strategy required before calling this production-ready - -## Unit tests -- domain parsing/validation -- lock/lease service logic -- NGINX config generation -- certificate validation helpers - -## Integration tests -- Prisma migration and boot -- ACME challenge publication/retrieval -- certificate order state transitions -- config staging + atomic swap - -## E2E tests -- auth + RBAC -- certificate request flow -- cluster reload flow -- backup/restore flow -- node ACK aggregation - -## Fault-injection / chaos tests -- DB temporary outage -- leader crash during issuance -- NGINX crash during config apply -- node restart during cert activation -- inter-node network partition -- stale leader lease recovery - -## Swarm tests -- rolling update in global mode -- node join/leave behavior -- published-port/internal-port communication validation -- host-mode traffic behavior under failure - ---- - -# 11. Prioritized remediation plan - -## Phase 0: stop the biggest risks immediately -1. lock down all public mutating endpoints -2. remove `exit 0` failure masking from entrypoint -3. remove `sleep infinity` restart suppression -4. fix health/readiness semantics -5. fix Swarm inter-node address/port model -6. stop using public-IP discovery services for node comms -7. patch vulnerable dependencies -8. use Swarm secrets for credentials/API keys - -## Phase 1: make cluster behavior correct -1. implement lease + fencing token model -2. build operation journal + node ACK tracking -3. make reload/config apply transactional with rollback -4. define stable internal node identities and mTLS -5. harden certificate order workflow - -## Phase 2: make it secure and operable -1. introduce RBAC + audit logging -2. encrypt key material and backups -3. add structured logs, richer metrics, alerts, runbooks -4. add proxy CRUD/config versioning APIs - -## Phase 3: prove it works -1. add unit/integration/e2e/chaos tests -2. add CI/CD gates and supply-chain checks -3. run staged cluster simulations and failure drills -4. publish an honest operations guide and SLOs - ---- - -# 12. Final conclusion - -LyttleNGINX is **not yet safe to classify as production-ready** for the operating model you described. - -It has a promising foundation, especially: -- database-backed ACME challenge serving -- cluster metadata tracking -- NGINX generation -- certificate synchronization ideas - -But today it still has critical weaknesses in: -- recovery behavior -- endpoint security -- inter-node trust -- config rollout safety -- certificate lifecycle control -- observability -- test coverage -- operational rigor - -If you want this system to be trusted as a global edge control component, the next milestone should be: - -> **Convert it from a “feature-complete prototype” into a lease-driven, transactional, audited, cluster-reconciled control plane with strict security boundaries.** - -That is the level required to satisfy your no-long-outage / auto-recovery / any-node-can-receive-traffic production goal. - diff --git a/README.md b/README.md index bfd4ed3..be29ff8 100644 --- a/README.md +++ b/README.md @@ -1,939 +1,264 @@ # 🔒 LyttleNGINX -<p align="center"> - <img src="https://img.shields.io/badge/status-production--ready-success" alt="Status" /> - <img src="https://img.shields.io/badge/build-passing-brightgreen" alt="Build" /> - <img src="https://img.shields.io/badge/coverage-ready-blue" alt="Coverage" /> - <img src="https://img.shields.io/badge/license-UNLICENSED-red" alt="License" /> -</p> +![Current architecture documented](https://img.shields.io/badge/status-current%20architecture%20documented-blue) +![Phase 10 complete](https://img.shields.io/badge/roadmap-phase%2010%20complete-brightgreen) +![Controlled rollout ready](https://img.shields.io/badge/readiness-controlled%20rollout%20ready-yellowgreen) +![License: UNLICENSED](https://img.shields.io/badge/license-UNLICENSED-red) -**Enterprise-grade NGINX proxy management with automated SSL/TLS certificate management, real-time monitoring, and comprehensive backup solutions.** +**NestJS-based NGINX control plane for proxy configuration, certificate lifecycle automation, cluster coordination, and operator observability.** -Built with [NestJS](https://nestjs.com/) • Powered by [PostgreSQL](https://www.postgresql.org/) • Secured by [Let's Encrypt](https://letsencrypt.org/) +> **Current status:** The refactor is documented against the current implementation, not the historical delivery roadmap. Final readiness artifacts live in [`FINAL_PRODUCTION_CHECKLIST.md`](FINAL_PRODUCTION_CHECKLIST.md) and [`PRODUCTION_DEFERMENT_REGISTER.md`](PRODUCTION_DEFERMENT_REGISTER.md). Treat the repository as ready for a controlled production rollout of the documented current architecture only after the checklist items are completed and the listed deferments are explicitly accepted. ---- +## Current state at a glance -## 🌟 Features +- authenticated-by-default admin API with explicit public probe/metrics/ACME allowlist +- JWT-compatible auth foundation with RBAC roles and durable audit logging +- lease-based cluster leadership plus operation journal and per-node ACK tracking +- staged NGINX release activation with automatic rollback on reload failure +- durable certificate orders, artifact activation/rollback, and shared HTTP-01 challenge handling +- application-layer private-key encryption at rest plus encrypted/signed backup artifacts +- structured JSON operational logs, public health drilldowns, and expanded Prometheus metrics +- classified unit/integration/e2e/chaos test harness plus CI release gates -### 🔐 SSL/TLS Certificate Management +## Important current boundaries -- **Automatic Let's Encrypt** - Zero-touch certificate issuance and renewal -- **Manual Certificate Upload** - Support for custom/purchased certificates -- **Self-Signed Certificates** - One-click generation for development -- **Certificate Validation** - Automatic cert/key pair validation -- **Multi-Domain Support** - SAN (Subject Alternative Names) support -- **Certificate Backup/Restore** - Complete backup and recovery solution +These constraints are still real and are intentionally documented here: -### 🚀 NGINX Proxy Management +- **Production rollout still carries accepted deferments.** Review [`PRODUCTION_DEFERMENT_REGISTER.md`](PRODUCTION_DEFERMENT_REGISTER.md) before go-live and make sure the unresolved items are consciously accepted. +- **Internal node traffic is still authenticated HTTP, not mTLS.** Identity and RBAC exist, but transport hardening remains future work. +- **NestJS and NGINX still share a single container.** The runtime is fail-fast and restart-friendly, but the preferred split control-plane/dataplane architecture is not implemented yet. +- **Manual config rollback is not a dedicated API today.** Automatic rollback covers reload failures; logical rollback still requires reverting desired state and reloading. +- **Runtime secret ingestion is still env-driven.** Operators should use Swarm secrets or an external secret store to inject values. -- **Dynamic Configuration** - Database-driven proxy configuration -- **HTTP to HTTPS Redirect** - Automatic when SSL is enabled -- **Reverse Proxy** - Full reverse proxy support -- **URL Redirects** - 301/302 redirect support -- **Custom NGINX Config** - Inject custom configuration per proxy -- **WebSocket Support** - Full WebSocket proxying capability +## Deployment expectations -### 📊 Monitoring & Observability +| Mode | Use it for | Current expectation | +| --- | --- | --- | +| Local development | coding, debugging, manual verification | best-supported workflow today | +| Single-node Compose | demos, evaluation, non-HA deployments | usable for evaluation; not final hardened production guidance | +| Docker Swarm global mode | target clustered operating model | ready for controlled rollout when the final checklist is completed and deferments are accepted | -- **Prometheus Metrics** - 7+ metrics for Grafana dashboards -- **Health Checks** - Automated daily certificate health monitoring -- **Real-time Status** - Live certificate expiry tracking -- **JSON API** - Query certificate and proxy status -- **Alert System** - Multi-channel notifications (email, Slack, Discord) - -### 🔔 Alert System - -- **Email Alerts** - SMTP-based email notifications -- **Slack Integration** - Real-time Slack webhook alerts -- **Discord Integration** - Discord webhook notifications -- **Configurable Thresholds** - Set custom alert timing (default: 14 days) -- **Alert Types** - Expiring soon, expired, renewal success/failure - -### 🌐 Cluster Management - -- **Distributed Locking** - PostgreSQL advisory locks for coordination -- **Leader Election** - Automatic leader election with fail-over -- **Node Heartbeats** - Real-time cluster health monitoring -- **Stale Node Cleanup** - Automatic removal of dead nodes (2 min timeout) -- **Split-Brain Prevention** - Multiple safety layers enforce single leader -- **Auto-Recovery** - Self-healing from crashes and network issues -- **Admin Endpoints** - Manual cluster management and diagnostics -- **Health Monitoring** - Built-in scripts for cluster health checks - -### 🛡️ Security Features - -- **TLS 1.2/1.3 Only** - No legacy protocol support -- **Strong Cipher Suites** - ECDHE, AES-GCM, ChaCha20-Poly1305 -- **OCSP Stapling** - Enhanced SSL performance and privacy -- **Security Headers** - HSTS, X-Frame-Options, CSP support -- **Input Validation** - Comprehensive DTO validation -- **Rate Limiting** - 3-tier rate limiting (10/sec, 60/min, 100/15min) -- **HTTP/2 Support** - Modern protocol support - -### 💾 Backup & Recovery - -- **Automated Backups** - ZIP archives with all certificates -- **Export/Import** - Individual certificate export/import -- **Backup Management** - List, download, delete backups via API -- **Metadata Tracking** - Complete backup history -- **Disaster Recovery** - Full restoration capability - -### 🎯 Developer Experience - -- **REST API** - Complete REST API for all operations -- **OpenAPI Ready** - API documentation ready -- **Error Handling** - Structured error responses with codes -- **Comprehensive Docs** - 2,500+ lines of documentation -- **Docker Support** - Production-ready Docker configuration -- **TypeScript** - Fully typed codebase - ---- - -## 📋 Table of Contents - -- [Quick Start](#-quick-start) -- [Installation](#-installation) -- [Configuration](#-configuration) -- [API Documentation](#-api-documentation) -- [Certificate Management](#-certificate-management) -- [Monitoring & Alerts](#-monitoring--alerts) -- [Backup & Recovery](#-backup--recovery) -- [Docker Deployment](#-docker-deployment) -- [Development](#-development) -- [Documentation](#-documentation) -- [Troubleshooting](#-troubleshooting) - ---- - -## 🚀 Quick Start +## Quick start ### Prerequisites -- Node.js 22.19.0 or higher -- PostgreSQL 12 or higher -- Docker & Docker Compose (optional) +- Node.js `24.16.0` +- npm `11.15.0` +- PostgreSQL `12+` +- Docker / Docker Compose or Docker Swarm if you are evaluating container deployment -### 1. Clone and Install +### Local setup ```bash -git clone <repository-url> -cd LyttleNGINX npm install -``` - -### 2. Configure Environment - -```bash cp .env.example .env -nano .env -``` - -**Minimum Configuration:** - -```bash -# Connection pooling is automatically configured, but you can customize: -DATABASE_URL=postgresql://user:password@localhost:5432/lyttlenginx?connection_limit=10&pool_timeout=10&connect_timeout=10 -ADMIN_EMAIL=admin@example.com -NODE_ENV=production -``` - -> **Note:** Connection pooling limits are automatically applied (10 connections per instance). For high-traffic deployments, see [Database Connection Management](docs/DATABASE_CONNECTION_MANAGEMENT.md). - -### 3. Setup Database - -```bash -npm run prisma:generate -npm run prisma:migrate -``` - -### 4. Build and Run - -```bash -npm run build -npm run start:prod -``` - -### 5. Verify Installation - -```bash -# Check health -curl http://localhost:3000/ready - -# View metrics -curl http://localhost:3000/metrics/json - -# List certificates -curl http://localhost:3000/certificates -``` - -**🎉 You're ready to go!** - ---- - -## 📦 Installation - -### Development Setup - -```bash -# Install dependencies -npm install - -# Generate Prisma client npm run prisma:generate - -# Run migrations npm run prisma:migrate - -# Start in development mode -npm run start:dev -``` - -### Production Setup - -```bash -# Install production dependencies -npm ci --only=production - -# Generate Prisma client -npm run prisma:generate - -# Build application npm run build - -# Run migrations -npm run prisma:deploy - -# Start production server npm run start:prod ``` -### Docker Setup - -```bash -# Build image -docker-compose build - -# Start services -docker-compose up -d - -# View logs -docker-compose logs -f app -``` - ---- - -## ⚙️ Configuration - -### Environment Variables - -#### Required - -```bash -DATABASE_URL=postgresql://user:pass@host:5432/db -ADMIN_EMAIL=admin@example.com # For Let's Encrypt -NODE_ENV=production # production | development -``` - -#### TLS Configuration - -```bash -RENEW_BEFORE_DAYS=30 # Days before expiry to renew -``` - -#### Alert Configuration - -```bash -# Email Alerts -ALERT_EMAIL=alerts@example.com -ALERT_FROM_EMAIL=noreply@example.com -SMTP_HOST=smtp.gmail.com -SMTP_PORT=587 -SMTP_SECURE=false -SMTP_USER=your-email@gmail.com -SMTP_PASS=your-app-password - -# Alert Threshold -ALERT_THRESHOLD_DAYS=14 # Alert when expiring within X days - -# Webhook Alerts -SLACK_WEBHOOK_URL=https://hooks.slack.com/services/... -DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/... -``` - -#### Backup Configuration - -```bash -BACKUP_DIR=/var/backups/certificates -``` - -### Database Schema - -The application uses PostgreSQL with Prisma ORM. Key models: - -- **ProxyEntry** - NGINX proxy configurations -- **Certificate** - SSL/TLS certificates with metadata - -Run migrations with: - -```bash -npm run prisma:migrate -``` - ---- - -## 📚 API Documentation - -### Certificate Endpoints - -| Method | Endpoint | Description | -|--------|--------------------------------------|-----------------------------------| -| GET | `/certificates` | List all certificates with status | -| GET | `/certificates/:id` | Get certificate details | -| POST | `/certificates/upload` | Upload custom certificate | -| POST | `/certificates/generate-self-signed` | Generate self-signed cert | -| POST | `/certificates/renew/:id` | Renew specific certificate | -| POST | `/certificates/renew-all` | Renew all certificates | -| DELETE | `/certificates/:id` | Delete certificate | -| GET | `/certificates/validate/:domain` | Validate domain | - -### Backup Endpoints - -| Method | Endpoint | Description | -|--------|-----------------------------------|---------------------| -| POST | `/certificates/backup` | Create backup | -| GET | `/certificates/backup` | List backups | -| GET | `/certificates/backup/:filename` | Download backup | -| DELETE | `/certificates/backup/:filename` | Delete backup | -| POST | `/certificates/backup/import` | Import certificates | -| GET | `/certificates/backup/export/:id` | Export certificate | - -### Metrics Endpoints - -| Method | Endpoint | Description | -|--------|-----------------|--------------------| -| GET | `/metrics` | Prometheus metrics | -| GET | `/metrics/json` | JSON metrics | - -### TLS Configuration Endpoints - -| Method | Endpoint | Description | -|--------|-----------------------------------|---------------------| -| GET | `/tls/config/:domain` | Get TLS config | -| GET | `/tls/test/:domain` | Test TLS connection | -| POST | `/tls/dhparam` | Generate DH params | -| GET | `/tls/dhparam/status` | Check DH params | -| POST | `/tls/certificate/info` | Parse certificate | -| POST | `/tls/certificate/validate-chain` | Validate chain | - -### Authentication Endpoints - -| Method | Endpoint | Description | Auth | -|--------|----------------|-----------------------------|----------| -| GET | `/auth/status` | Check authentication status | Required | -| GET | `/auth/info` | Get auth configuration info | Public | - -### Health Endpoints - -| Method | Endpoint | Description | Auth | -|--------|------------------|---------------------|--------| -| GET | `/health` | Health check | Public | -| GET | `/ready` | Readiness check | Public | -| POST | `/health/reload` | Reload NGINX config | Public | - -**📖 Complete API documentation:** [API_REFERENCE_ENHANCED.md](API_REFERENCE_ENHANCED.md) - ---- - -## 🔐 Certificate Management - -### Automatic Let's Encrypt - -Certificates are automatically obtained and renewed for proxy entries with `ssl = true`. - -```sql --- Enable SSL for a proxy entry -UPDATE "ProxyEntry" -SET ssl = true -WHERE id = 1; -``` - -The system will: - -1. Generate HTTP-only NGINX config -2. Obtain Let's Encrypt certificate via ACME -3. Update config with HTTPS + HTTP→HTTPS redirect -4. Auto-renew when within `RENEW_BEFORE_DAYS` threshold - -### Upload Custom Certificate - -```bash -curl -X POST http://localhost:3000/certificates/upload \ - -H "Content-Type: application/json" \ - -d '{ - "domains": ["example.com", "www.example.com"], - "certPem": "-----BEGIN CERTIFICATE-----\n...", - "keyPem": "-----BEGIN PRIVATE KEY-----\n...", - "chainPem": "-----BEGIN CERTIFICATE-----\n..." - }' -``` - -### Generate Self-Signed Certificate - -Perfect for development and testing: - -```bash -curl -X POST http://localhost:3000/certificates/generate-self-signed \ - -H "Content-Type: application/json" \ - -d '{"domains": ["localhost", "*.localhost"]}' -``` - -### Certificate Status - -Certificates have three statuses: - -- **valid** - More than `RENEW_BEFORE_DAYS` until expiry -- **expiring_soon** - Within renewal threshold -- **expired** - Past expiration date - -```bash -# Check certificate status -curl http://localhost:3000/certificates | jq '.[] | {domain: .domains[0], status, days: .daysUntilExpiry}' -``` - ---- - -## 📊 Monitoring & Alerts - -### Prometheus Metrics - -Expose metrics for Grafana dashboards: +### Smoke checks ```bash -# Prometheus format -curl http://localhost:3000/metrics - -# JSON format +curl http://localhost:3000/health/live +curl http://localhost:3000/health/ready curl http://localhost:3000/metrics/json ``` -**Available Metrics:** - -- `lyttle_certificates_total` - Total certificates -- `lyttle_certificates_valid` - Valid certificates -- `lyttle_certificates_expiring_soon` - Expiring soon -- `lyttle_certificates_expired` - Expired certificates -- `lyttle_certificates_avg_days_until_expiry` - Average days until expiry -- `lyttle_proxy_entries_total` - Total proxy entries -- `lyttle_proxy_entries_ssl` - Proxies with SSL enabled - -### Configure Prometheus Scraping - -**prometheus.yml:** - -```yaml -scrape_configs: - - job_name: 'lyttlenginx' - static_configs: - - targets: [ 'app:3000' ] - metrics_path: '/metrics' - scrape_interval: 30s -``` - -### Email Alerts - -Configure SMTP for email notifications: - -```bash -ALERT_EMAIL=alerts@example.com -SMTP_HOST=smtp.gmail.com -SMTP_PORT=587 -SMTP_USER=your-email@gmail.com -SMTP_PASS=your-app-password -``` - -### Webhook Alerts - -**Slack:** - -```bash -SLACK_WEBHOOK_URL=https://hooks.slack.com/services/YOUR/WEBHOOK/URL -``` - -**Discord:** - -```bash -DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/YOUR/WEBHOOK/URL -``` - -### Alert Types - -1. **Certificate Expiring Soon** - Sent 14 days before expiry (configurable) -2. **Certificate Expired** - Sent immediately when cert expires -3. **Renewal Success** - Confirmation after successful renewal -4. **Renewal Failure** - Alert when renewal fails - -The monitoring service runs **daily at 9 AM** automatically. - ---- - -## 💾 Backup & Recovery - -### Create Backup - -```bash -curl -X POST http://localhost:3000/certificates/backup -``` - -Creates a ZIP file containing: - -- `certificates.json` - Database export -- `certs/{domain}/fullchain.pem` - Certificate files -- `certs/{domain}/privkey.pem` - Private keys -- `metadata.json` - Backup metadata - -### List Backups - -```bash -curl http://localhost:3000/certificates/backup -``` - -### Download Backup - -```bash -curl http://localhost:3000/certificates/backup/certificates-backup-2025-11-22.zip \ - --output backup.zip -``` - -### Restore from Backup - -```bash -# Extract backup -unzip backup.zip - -# Import certificates -curl -X POST http://localhost:3000/certificates/backup/import \ - -H "Content-Type: application/json" \ - -d @certificates.json -``` - -### Automated Backups - -**Create backup script:** - -```bash -#!/bin/bash -# /scripts/backup-daily.sh - -curl -X POST http://localhost:3000/certificates/backup -FILENAME=$(curl -s http://localhost:3000/certificates/backup | jq -r '.[0].filename') -curl http://localhost:3000/certificates/backup/$FILENAME -o /backups/$FILENAME -``` - -**Add to crontab:** - -```cron -0 2 * * * /scripts/backup-daily.sh >> /var/log/backup.log 2>&1 -``` - ---- - -## 🐳 Docker Deployment - -### Docker Swarm (Recommended for Production) 🆕 - -Deploy LyttleNGINX in **global mode** across your Docker Swarm cluster with built-in distributed coordination: +### Example authenticated checks ```bash -# Quick deployment with script -./deploy-swarm.sh +curl http://localhost:3000/auth/status \ + -H "X-API-Key: $API_KEY" -# Or manually -docker stack deploy -c docker-compose.swarm.yml lyttlenginx +curl http://localhost:3000/certificates \ + -H "Authorization: Bearer $JWT" ``` -**Key Features:** - -- ✅ Runs on every node (global mode) -- ✅ Distributed locking prevents certificate conflicts -- ✅ Automatic leader election for renewals -- ✅ Zero-downtime rolling updates -- ✅ Automatic failure recovery - -**View cluster status:** - -```bash -# See all nodes -curl -H "Authorization: Bearer $JWT" http://localhost:3003/cluster/nodes - -# View leader -docker service logs lyttlenginx_lyttlenginx 2>&1 | grep "LEADER" - -# Check service health -docker service ps lyttlenginx_lyttlenginx -``` - -**📖 Complete guide:** [DEPLOYMENT_GUIDE.md](DEPLOYMENT_GUIDE.md) - ---- - -### Docker Compose (Single Instance) - -**docker-compose.yml:** - -```yaml -version: '3.8' - -services: - postgres: - image: postgres:16-alpine - environment: - POSTGRES_DB: lyttlenginx - POSTGRES_USER: lyttlenginx - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} - volumes: - - postgres-data:/var/lib/postgresql/data - - app: - image: ghcr.io/lyttle-development/lyttlenginx:main - depends_on: - - postgres - environment: - DATABASE_URL: postgresql://lyttlenginx:${POSTGRES_PASSWORD}@postgres:5432/lyttlenginx - ADMIN_EMAIL: ${ADMIN_EMAIL} - NODE_ENV: production - volumes: - - letsencrypt-data:/etc/letsencrypt - - certbot-webroot:/var/www/certbot - - nginx-ssl:/etc/nginx/ssl - network_mode: host - restart: unless-stopped - -volumes: - postgres-data: - letsencrypt-data: - certbot-webroot: - nginx-ssl: -``` +## Repository verification -### Deploy +The current repository verification contract is: ```bash -# Create .env file -echo "POSTGRES_PASSWORD=your-secure-password" > .env -echo "ADMIN_EMAIL=admin@example.com" >> .env - -# Start services -docker-compose up -d - -# Check logs -docker-compose logs -f app - -# Check status -docker-compose ps -``` - -**📖 More Docker examples:** [DOCKER_COMPOSE_EXAMPLES.md](DOCKER_COMPOSE_EXAMPLES.md) - ---- - -## 🔧 Development - -### Project Structure - -``` -LyttleNGINX/ -├── src/ -│ ├── alert/ # Alert system (email, Slack, Discord) -│ ├── certificate/ # Certificate management -│ │ ├── errors/ # Custom error types -│ │ └── dto/ # Data transfer objects -│ ├── filters/ # Global exception filters -│ ├── health/ # Health check endpoints -│ ├── logs/ # Logging service -│ ├── metrics/ # Prometheus metrics -│ ├── nginx/ # NGINX configuration generation -│ ├── prisma/ # Database client -│ ├── rate-limit/ # Rate limiting -│ ├── reloader/ # Config reload service -│ └── utils/ # Utility functions -├── nginx/ # NGINX configuration templates -├── prisma/ # Database schema and migrations -└── docs/ # Documentation files -``` - -### Available Scripts - -```bash -# Development -npm run start:dev # Start with hot reload -npm run start:debug # Start with debugger - -# Production -npm run build # Build application -npm run start:prod # Start production server - -# Database -npm run prisma:generate # Generate Prisma client -npm run prisma:migrate # Run migrations -npm run prisma:deploy # Deploy migrations (production) -npm run prisma:format # Format Prisma schema - -# Docker -npm run docker:build # Build Docker image -npm run docker:setup # Setup for Docker - -# Code Quality -npm run lint # Run ESLint -npm run format # Format with Prettier -``` - -### Adding a New Feature - -1. **Create service:** - ```bash - nest g service feature - ``` - -2. **Create controller:** - ```bash - nest g controller feature - ``` - -3. **Create module:** - ```bash - nest g module feature - ``` - -4. **Add to app.module.ts:** - ```typescript - imports: [ - // ... existing imports - FeatureModule, - ] - ``` - -### Testing - -```bash -# Unit tests (when implemented) +npm run lint +npm run lint:ci +npm run typecheck npm run test - -# E2E tests (when implemented) +npm run test:unit +npm run test:integration npm run test:e2e - -# Test coverage (when implemented) -npm run test:cov -``` - ---- - -## 📖 Documentation - -### Complete Documentation Set - -1. **[README.md](README.md)** (this file) - Project overview -2. **[API_AUTHENTICATION_GUIDE.md](API_AUTHENTICATION_GUIDE.md)** - Authentication setup and usage -3. **[TLS_DOCUMENTATION.md](TLS_DOCUMENTATION.md)** - Complete TLS guide (600+ lines) -4. **[ENHANCED_FEATURES.md](ENHANCED_FEATURES.md)** - Enhanced features guide (500+ lines) -5. **[API_REFERENCE_ENHANCED.md](API_REFERENCE_ENHANCED.md)** - Full API documentation (700+ lines) -6. **[API_EXAMPLES.md](API_EXAMPLES.md)** - API usage examples with curl, TypeScript, Python -7. **[DOCKER_COMPOSE_EXAMPLES.md](DOCKER_COMPOSE_EXAMPLES.md)** - Docker deployment examples -8. **[TLS_QUICK_REFERENCE.md](TLS_QUICK_REFERENCE.md)** - Quick command reference -9. **[QUICK_START_ENHANCED.md](QUICK_START_ENHANCED.md)** - Enhanced features quick start -10. **[docs/DATABASE_CONNECTION_MANAGEMENT.md](docs/DATABASE_CONNECTION_MANAGEMENT.md)** - Connection pooling & optimization ⭐ NEW -11. **[docs/QUICK_REFERENCE_CONNECTIONS.md](docs/QUICK_REFERENCE_CONNECTIONS.md)** - Connection monitoring quick reference ⭐ NEW -12. **[DEPLOYMENT_CHECKLIST.md](DEPLOYMENT_CHECKLIST.md)** - Deployment guide for connection fixes ⭐ NEW -13. **[.env.example](.env.example)** - Environment configuration template - -### Key Topics - -- **Authentication** → [API_AUTHENTICATION_GUIDE.md](API_AUTHENTICATION_GUIDE.md) -- **Certificate Management** → [TLS_DOCUMENTATION.md](TLS_DOCUMENTATION.md) -- **Database Connections** → [docs/DATABASE_CONNECTION_MANAGEMENT.md](docs/DATABASE_CONNECTION_MANAGEMENT.md) ⭐ NEW -- **API Reference** → [API_REFERENCE_ENHANCED.md](API_REFERENCE_ENHANCED.md) -- **Examples** → [API_EXAMPLES.md](API_EXAMPLES.md) -- **Docker** → [DOCKER_COMPOSE_EXAMPLES.md](DOCKER_COMPOSE_EXAMPLES.md) -- **Monitoring** → [ENHANCED_FEATURES.md](ENHANCED_FEATURES.md) -- **Quick Reference** → [docs/QUICK_REFERENCE_CONNECTIONS.md](docs/QUICK_REFERENCE_CONNECTIONS.md) ⭐ NEW - ---- - -## 🐛 Troubleshooting - -### Common Issues - -#### Build Fails - -```bash -# Clean and rebuild -rm -rf node_modules dist -npm install +npm run test:chaos +npm run test:coverage +npm run test:coverage:ci npm run build -``` +npm run audit:prod +npm run verify +npm run verify:ci +``` + +`npm run test` executes the full classified harness, including the deterministic chaos suite. + +## Public vs protected API surface + +### Public allowlist + +These routes are intentionally public: + +- `GET /health/live` +- `GET /health/startup` +- `GET /health/ready` +- `GET /health/dependencies` +- `GET /health/deep` +- `GET /health` +- `GET /ready` +- `GET /metrics` +- `GET /metrics/json` +- `GET /.well-known/acme-challenge/:token` + +### Authenticated admin or internal routes + +Everything else should be treated as protected control-plane traffic and requires one of: + +- `Authorization: Bearer <jwt>` +- `X-API-Key: <key>` +- `Authorization: ApiKey <key>` + +### Current RBAC catalog + +- `viewer` +- `operator` +- `security-admin` +- `platform-admin` +- `internal-node` + +## What is implemented today -#### Database Connection Issues +### Cluster operations -```bash -# Check DATABASE_URL format -# postgresql://user:password@host:port/database +Key operator-facing endpoints: -# Test connection -psql $DATABASE_URL -``` +- `GET /cluster/status` +- `GET /cluster/lease` +- `GET /cluster/leader` +- `GET /cluster/leader/status` +- `GET /cluster/operations` +- `GET /cluster/operations/:operationId` +- `GET /cluster/nodes` +- `GET /cluster/nodes/:nodeId` +- `GET /cluster/nodes/:nodeId/config` +- `GET /cluster/nodes/:nodeId/certificates` +- `POST /cluster/reload` -#### Certificate Not Being Issued +### Certificates and backup/restore -```bash -# 1. Check DNS resolution -curl http://localhost:3000/certificates/validate/yourdomain.com +- `GET /certificates` +- `GET /certificates/orders` +- `GET /certificates/orders/:id` +- `POST /certificates/orders/:id/retry` +- `GET /certificates/challenges` +- `POST /certificates/upload` +- `POST /certificates/generate-self-signed` +- `POST /certificates/renew/:id` +- `POST /certificates/renew-all` +- `POST /certificates/:id/rollback` +- `POST /certificates/sync` +- `POST /certificates/backup` +- `GET /certificates/backup` +- `POST /certificates/backup/:filename/verify` +- `POST /certificates/backup/:filename/restore` +- `POST /certificates/backup/import` +- `GET /certificates/backup/export/:id` *(break-glass only)* -# 2. Check logs -docker-compose logs app | grep -i certbot +### Security and observability -# 3. Verify email is set -echo $ADMIN_EMAIL +- `GET /auth/status` +- `GET /auth/info` +- `GET /auth/me` +- `POST /auth/token` +- `GET /audit` +- `GET /logs` +- `GET /security/status` +- `GET /security/policy` +- `GET /security/secrets/health` +- `GET /security/access-review` +- `POST /security/rotate/api-key` +- `POST /security/rotate/private-key-encryption` +- `POST /security/rotate/internal-certs` *(forward-looking contract; mTLS rotation not implemented yet)* -# 4. Ensure ports 80 and 443 are accessible -``` - -#### NGINX Won't Reload +## Certificate strategy notes -```bash -# Test config syntax -docker-compose exec app nginx -t +The shipped ACME implementation is intentionally conservative: -# Check certificate files -docker-compose exec app ls -la /etc/letsencrypt/live/ +- built-in ACME strategy is shared **HTTP-01** +- wildcard issuance is intentionally rejected +- any node can serve shared challenge records from the database +- certificate activation is tracked as a cluster operation with node ACKs +- private keys are encrypted at rest in PostgreSQL +- backup artifacts are encrypted and signed -# View error logs -docker-compose exec app cat /var/log/nginx/error.log -``` +## Documentation map -#### Alerts Not Sending +### Canonical project docs -```bash -# Check configuration -docker-compose exec app printenv | grep -E "(ALERT|SMTP|SLACK)" +- [`PRODUCTION_READINESS_ASSESSMENT.md`](PRODUCTION_READINESS_ASSESSMENT.md) — current risk inventory and production gap analysis +- [`FINAL_PRODUCTION_CHECKLIST.md`](FINAL_PRODUCTION_CHECKLIST.md) — final assessment reconciliation and go-live checklist +- [`PRODUCTION_DEFERMENT_REGISTER.md`](PRODUCTION_DEFERMENT_REGISTER.md) — accepted gaps, compensating controls, and follow-up work +- [`ARCHITECTURE_DECISIONS.md`](ARCHITECTURE_DECISIONS.md) — ADR log for the current architecture and delivery policy +- [`docs/architecture/current-architecture.md`](docs/architecture/current-architecture.md) — current implementation architecture and explicit boundaries -# Check if alert service initialized -docker-compose logs app | grep -i "alert" +### Runbooks -# View alert logs -docker-compose logs app | grep -E "(Alert|Monitor)" -``` +- [`docs/runbooks/leader-failure.md`](docs/runbooks/leader-failure.md) +- [`docs/runbooks/nginx-config-rollback.md`](docs/runbooks/nginx-config-rollback.md) +- [`docs/runbooks/restore-from-encrypted-backup.md`](docs/runbooks/restore-from-encrypted-backup.md) +- [`docs/runbooks/certificate-issuance-failure.md`](docs/runbooks/certificate-issuance-failure.md) +- [`docs/runbooks/credential-rotation.md`](docs/runbooks/credential-rotation.md) +- [`docs/runbooks/security-break-glass.md`](docs/runbooks/security-break-glass.md) +- [`docs/runbooks/monitoring-alerts.md`](docs/runbooks/monitoring-alerts.md) -### Debug Mode +## Incident-first operator flow -Enable debug logging: +When a node or workflow looks unhealthy, start with: ```bash -# Set in .env -LOG_LEVEL=debug - -# Restart -docker-compose restart app - -# View logs -docker-compose logs -f app +curl http://localhost:3000/health/deep | jq +curl http://localhost:3000/cluster/status -H "Authorization: Bearer $JWT" | jq +curl http://localhost:3000/cluster/lease -H "Authorization: Bearer $JWT" | jq +curl http://localhost:3000/cluster/operations -H "Authorization: Bearer $JWT" | jq +curl http://localhost:3000/logs -H "Authorization: Bearer $JWT" | jq +curl http://localhost:3000/audit -H "Authorization: Bearer $JWT" | jq ``` -### Support +Then move into the dedicated runbook for the incident type. -For issues and questions: +## Deployment notes -1. Check [TLS_DOCUMENTATION.md](TLS_DOCUMENTATION.md) troubleshooting section -2. Review [API_EXAMPLES.md](API_EXAMPLES.md) for usage examples -3. Check application logs: `docker-compose logs -f app` +### Single-node Compose ---- +Use [`docker-compose.yml`](docker-compose.yml) for local or single-node evaluation. -## 🏆 Features at a Glance +### Swarm -| Feature | Status | Description | -|-------------------|--------|-------------------------------| -| 🔐 Auto SSL | ✅ | Let's Encrypt integration | -| 📤 Upload Cert | ✅ | Custom certificate upload | -| 🔧 Self-Signed | ✅ | Development certificates | -| 🔄 Auto Renew | ✅ | Automatic renewal (12h check) | -| 🚦 HTTP→HTTPS | ✅ | Automatic redirect | -| 📊 Prometheus | ✅ | Metrics export | -| 📧 Email Alerts | ✅ | SMTP notifications | -| 💬 Slack/Discord | ✅ | Webhook alerts | -| 💾 Backup/Restore | ✅ | Complete backup solution | -| ⚡ Rate Limiting | ✅ | 3-tier protection | -| ✅ Validation | ✅ | Input validation | -| 🛡️ TLS 1.3 | ✅ | Modern protocols only | -| 🔒 OCSP Stapling | ✅ | Enhanced performance | -| 📈 Monitoring | ✅ | Daily health checks | -| 🐳 Docker | ✅ | Production-ready | +Use [`docker-compose.swarm.yml`](docker-compose.swarm.yml) for controlled clustered testing. ---- +Important current Swarm notes: -## 📊 Statistics +- peer communication uses explicit advertised control-plane settings (`CLUSTER_CONTROL_ADDRESS`, `CLUSTER_CONTROL_PORT`, or `CLUSTER_CONTROL_URL`) +- treat `CLUSTER_CONTROL_PORT` as the peer-facing port other nodes must dial; do not assume it matches PORT +- cluster leadership uses durable lease state with generation/fencing metadata +- cluster-wide reload and certificate activation flows use tracked operation IDs and per-node ACKs +- internal node transport is still authenticated HTTP, not mTLS -- **Lines of Code:** ~12,000+ -- **Documentation:** 2,500+ lines -- **API Endpoints:** 35+ -- **Services:** 17+ -- **Controllers:** 9 -- **Modules:** 11+ -- **Build Status:** ✅ Passing +## Security reporting ---- +For now, follow the project’s internal security process and review the current posture endpoints before performing high-risk maintenance: -## 🔒 Security +- `GET /security/status` +- `GET /security/policy` +- `GET /security/secrets/health` +- `GET /security/access-review` -### Security Features - -- ✅ TLS 1.2/1.3 only (no legacy protocols) -- ✅ Strong cipher suites (ECDHE, AES-GCM, ChaCha20-Poly1305) -- ✅ OCSP stapling enabled -- ✅ Security headers (HSTS, X-Frame-Options, CSP) -- ✅ Input validation on all endpoints -- ✅ Rate limiting (3-tier) -- ✅ Certificate/key pair validation -- ✅ HTTP/2 support - -### Reporting Security Issues - -Please report security vulnerabilities to: admin@example.com - ---- - -## 📜 License +## License UNLICENSED - Private project by Lyttle Development - ---- - -## 🙏 Acknowledgments - -Built with: - -- [NestJS](https://nestjs.com/) - Progressive Node.js framework -- [Prisma](https://www.prisma.io/) - Next-generation ORM -- [NGINX](https://nginx.org/) - High-performance web server -- [Let's Encrypt](https://letsencrypt.org/) - Free SSL certificates -- [PostgreSQL](https://www.postgresql.org/) - Advanced database - ---- - -## 🚀 Getting Started - -Ready to deploy? Follow these steps: - -1. **[Installation](#-installation)** - Set up the project -2. **[Configuration](#-configuration)** - Configure environment -3. **[Quick Start](#-quick-start)** - Get running in 5 minutes -4. **[Documentation](#-documentation)** - Read the docs -5. **[Deploy](#-docker-deployment)** - Go to production - ---- - -<p align="center"> - <strong>LyttleNGINX - Enterprise Certificate Management Made Simple</strong> -</p> - -<p align="center"> - <sub>Built with ❤️ by Lyttle Development</sub> -</p> diff --git a/certbot-auth-hook.sh b/certbot-auth-hook.sh deleted file mode 100755 index 0817184..0000000 --- a/certbot-auth-hook.sh +++ /dev/null @@ -1,89 +0,0 @@ -#!/bin/bash -# Don't use set -e at the top - we want to handle errors explicitly -# Log to stderr so certbot sees it -exec 2>&1 - -echo "[Auth Hook] Starting authentication hook..." - -# Check required environment variables -TOKEN="${CERTBOT_TOKEN:-}" -VALIDATION="${CERTBOT_VALIDATION:-}" -DOMAIN="${CERTBOT_DOMAIN:-}" - -if [ -z "$TOKEN" ] || [ -z "$VALIDATION" ] || [ -z "$DOMAIN" ]; then - echo "[Auth Hook] ERROR: Missing required certbot environment variables" - echo "[Auth Hook] TOKEN='$TOKEN', VALIDATION length=${#VALIDATION}, DOMAIN='$DOMAIN'" - exit 1 -fi - -echo "[Auth Hook] Domain: $DOMAIN" -echo "[Auth Hook] Token: $TOKEN" - -# Check DB credentials -DB_PASSWORD="${DB_PASSWORD:-}" -DB_HOST="${DB_HOST:-}" -DB_PORT="${DB_PORT:-}" -DB_USER="${DB_USER:-}" -DB_NAME="${DB_NAME:-}" - -if [ -z "$DB_PASSWORD" ] || [ -z "$DB_HOST" ] || [ -z "$DB_PORT" ] || [ -z "$DB_USER" ] || [ -z "$DB_NAME" ]; then - echo "[Auth Hook] ERROR: Missing database credentials" - echo "[Auth Hook] DB_HOST='$DB_HOST', DB_PORT='$DB_PORT', DB_USER='$DB_USER', DB_NAME='$DB_NAME', DB_PASSWORD_SET=${DB_PASSWORD:+yes}" - exit 1 -fi - -echo "[Auth Hook] Database: $DB_USER@$DB_HOST:$DB_PORT/$DB_NAME" - -# Check if psql is available -if ! command -v psql >/dev/null 2>&1; then - echo "[Auth Hook] ERROR: psql command not found" - echo "[Auth Hook] PATH=$PATH" - exit 1 -fi - -echo "[Auth Hook] psql found: $(which psql)" - -# Calculate expiry (1 hour from now) -EXPIRES_AT=$(date -u -d '+1 hour' '+%Y-%m-%d %H:%M:%S' 2>&1) -if [ $? -ne 0 ]; then - echo "[Auth Hook] ERROR: Failed to calculate expiry date" - echo "[Auth Hook] date command output: $EXPIRES_AT" - exit 1 -fi - -echo "[Auth Hook] Expiry: $EXPIRES_AT" - -# Escape single quotes by doubling them for SQL -TOKEN_ESC="${TOKEN//\'/\'\'}" -VALIDATION_ESC="${VALIDATION//\'/\'\'}" -DOMAIN_ESC="${DOMAIN//\'/\'\'}" - -# Store challenge in database -echo "[Auth Hook] Storing challenge in database..." - -SQL_OUTPUT=$(PGPASSWORD="$DB_PASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -v ON_ERROR_STOP=1 2>&1 <<EOSQL -INSERT INTO "AcmeChallenge" (token, "keyAuth", domain, "expiresAt", "createdAt") -VALUES ('$TOKEN_ESC', '$VALIDATION_ESC', '$DOMAIN_ESC', TIMESTAMP '$EXPIRES_AT', NOW()) -ON CONFLICT (token) -DO UPDATE SET - "keyAuth" = EXCLUDED."keyAuth", - domain = EXCLUDED.domain, - "expiresAt" = EXCLUDED."expiresAt"; -EOSQL -) - -SQL_EXIT_CODE=$? - -echo "[Auth Hook] SQL exit code: $SQL_EXIT_CODE" -echo "[Auth Hook] SQL output: $SQL_OUTPUT" - -if [ $SQL_EXIT_CODE -eq 0 ]; then - echo "[Auth Hook] Challenge stored successfully" - # Give Let's Encrypt time to propagate and query our endpoint - sleep 2 - exit 0 -else - echo "[Auth Hook] ERROR: Failed to store challenge (exit code: $SQL_EXIT_CODE)" - exit 1 -fi - diff --git a/certbot-cleanup-hook.sh b/certbot-cleanup-hook.sh deleted file mode 100755 index d87cd9f..0000000 --- a/certbot-cleanup-hook.sh +++ /dev/null @@ -1,55 +0,0 @@ -#!/bin/bash -# Don't use set -e - handle errors explicitly -# Log to stderr so certbot sees it -exec 2>&1 - -echo "[Cleanup Hook] Starting cleanup hook..." - -TOKEN="${CERTBOT_TOKEN:-}" - -if [ -z "$TOKEN" ]; then - echo "[Cleanup Hook] ERROR: Missing CERTBOT_TOKEN" - exit 1 -fi - -echo "[Cleanup Hook] Token: $TOKEN" - -# Check DB credentials -DB_PASSWORD="${DB_PASSWORD:-}" -DB_HOST="${DB_HOST:-}" -DB_PORT="${DB_PORT:-}" -DB_USER="${DB_USER:-}" -DB_NAME="${DB_NAME:-}" - -if [ -z "$DB_PASSWORD" ] || [ -z "$DB_HOST" ] || [ -z "$DB_PORT" ] || [ -z "$DB_USER" ] || [ -z "$DB_NAME" ]; then - echo "[Cleanup Hook] ERROR: Missing database credentials" - echo "[Cleanup Hook] DB_HOST='$DB_HOST', DB_PORT='$DB_PORT', DB_USER='$DB_USER', DB_NAME='$DB_NAME', DB_PASSWORD_SET=${DB_PASSWORD:+yes}" - exit 1 -fi - -echo "[Cleanup Hook] Database: $DB_USER@$DB_HOST:$DB_PORT/$DB_NAME" - -# Escape single quotes in token -TOKEN_ESC="${TOKEN//\'/\'\'}" - -# Remove challenge from database -echo "[Cleanup Hook] Deleting challenge from database..." - -SQL_OUTPUT=$(PGPASSWORD="$DB_PASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -v ON_ERROR_STOP=1 2>&1 <<EOSQL -DELETE FROM "AcmeChallenge" WHERE token = '$TOKEN_ESC'; -EOSQL -) - -SQL_EXIT_CODE=$? - -echo "[Cleanup Hook] SQL exit code: $SQL_EXIT_CODE" -echo "[Cleanup Hook] SQL output: $SQL_OUTPUT" - -if [ $SQL_EXIT_CODE -eq 0 ]; then - echo "[Cleanup Hook] Challenge removed successfully" - exit 0 -else - echo "[Cleanup Hook] ERROR: Failed to remove challenge (exit code: $SQL_EXIT_CODE)" - exit 1 -fi - diff --git a/docker-compose.swarm.yml b/docker-compose.swarm.yml index 8bba0c2..ea7b43a 100644 --- a/docker-compose.swarm.yml +++ b/docker-compose.swarm.yml @@ -33,10 +33,9 @@ services: monitor: 20s restart_policy: - condition: on-failure - delay: 10s - max_attempts: 3 - window: 120s + condition: any + delay: 5s + window: 60s labels: - "app=lyttlenginx" @@ -46,6 +45,10 @@ services: - DATABASE_URL=${DATABASE_URL} - ADMIN_EMAIL=${ADMIN_EMAIL} - NODE_ENV=${NODE_ENV:-production} + - PORT=${PORT:-3000} + - CLUSTER_CONTROL_ADDRESS={{.Node.Hostname}} + - CLUSTER_CONTROL_PORT=${CLUSTER_CONTROL_PORT:-3003} + - CLUSTER_CONTROL_PROTOCOL=${CLUSTER_CONTROL_PROTOCOL:-http} - RENEW_BEFORE_DAYS=${RENEW_BEFORE_DAYS:-30} - ALERT_THRESHOLD_DAYS=${ALERT_THRESHOLD_DAYS:-14} - LETSENCRYPT_STAGING=${LETSENCRYPT_STAGING:-false} @@ -84,6 +87,8 @@ services: retries: 2 start_period: 30s + stop_grace_period: 45s + logging: driver: "json-file" options: diff --git a/docker-compose.yml b/docker-compose.yml index c3a5fe2..d4fae10 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,6 +6,10 @@ services: DATABASE_URL: ${DATABASE_URL:-postgresql://user:pass@host:port/db} ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@example.com} NODE_ENV: ${NODE_ENV:-production} + PORT: ${PORT:-3000} + CLUSTER_CONTROL_ADDRESS: ${CLUSTER_CONTROL_ADDRESS:-app} + CLUSTER_CONTROL_PORT: ${CLUSTER_CONTROL_PORT:-3000} + CLUSTER_CONTROL_PROTOCOL: ${CLUSTER_CONTROL_PROTOCOL:-http} API_KEY: ${API_KEY} RENEW_BEFORE_DAYS: ${RENEW_BEFORE_DAYS:-30} ALERT_THRESHOLD_DAYS: ${ALERT_THRESHOLD_DAYS:-14} @@ -22,14 +26,12 @@ services: RATE_LIMIT_TTL: ${RATE_LIMIT_TTL:-60} RATE_LIMIT_MAX: ${RATE_LIMIT_MAX:-100} - # USE - network_mode: host - # OR ports: - 80:80 - 443:443 - 3003:3000 - # NOT BOTH + restart: unless-stopped + stop_grace_period: 45s healthcheck: test: [ "CMD-SHELL", "/healthcheck.sh" ] diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 5a621d2..25b32c6 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -1,5 +1,5 @@ #!/bin/bash -set -e +set -Eeuo pipefail # Color codes for logging RED='\033[0;31m' @@ -8,6 +8,21 @@ YELLOW='\033[1;33m' BLUE='\033[0;34m' NC='\033[0m' # No Color +NODE_PID="" +NGINX_PID="" +SHUTDOWN_REQUESTED=0 +FINAL_EXIT_CODE=0 +SERVICE_STARTUP_GRACE_SECONDS="${SERVICE_STARTUP_GRACE_SECONDS:-3}" +NODE_SHUTDOWN_TIMEOUT_SECONDS="${NODE_SHUTDOWN_TIMEOUT_SECONDS:-30}" +NGINX_SHUTDOWN_TIMEOUT_SECONDS="${NGINX_SHUTDOWN_TIMEOUT_SECONDS:-10}" +DB_CONNECT_RETRY_DELAY_SECONDS="${DB_CONNECT_RETRY_DELAY_SECONDS:-5}" +MIGRATION_RETRY_DELAY_SECONDS="${MIGRATION_RETRY_DELAY_SECONDS:-5}" +NGINX_RUNTIME_DIR="${NGINX_RUNTIME_DIR:-/etc/nginx/runtime}" +NGINX_RUNTIME_RELEASES_DIR="${NGINX_RUNTIME_RELEASES_DIR:-${NGINX_RUNTIME_DIR}/releases}" +NGINX_RUNTIME_CURRENT_LINK="${NGINX_RUNTIME_CURRENT_LINK:-${NGINX_RUNTIME_DIR}/current}" +NGINX_RUNTIME_LAST_KNOWN_GOOD_LINK="${NGINX_RUNTIME_LAST_KNOWN_GOOD_LINK:-${NGINX_RUNTIME_DIR}/last-known-good}" +NGINX_BUNDLED_SOURCE_DIR="${NGINX_BUNDLED_SOURCE_DIR:-/app/nginx}" + log_info() { echo -e "${BLUE}[ENTRYPOINT] INFO: $1${NC}" } @@ -24,99 +39,108 @@ log_warn() { echo -e "${YELLOW}[ENTRYPOINT] WARN: $1${NC}" } -# State file for tracking restarts -STATE_DIR="/app/state" -STATE_FILE="${STATE_DIR}/restart.state" -MAX_RESTART_ATTEMPTS=5 -RESTART_WINDOW=300 # 5 minutes - -# Cleanup function for graceful shutdown -cleanup() { - log_info "Received shutdown signal, cleaning up..." - - # Stop Node.js app gracefully - if [ ! -z "$NODE_PID" ] && kill -0 "$NODE_PID" 2>/dev/null; then - log_info "Stopping Node.js application (PID: $NODE_PID)..." - kill -TERM "$NODE_PID" 2>/dev/null || true - - # Wait up to 30 seconds for graceful shutdown - for i in {1..30}; do - if ! kill -0 "$NODE_PID" 2>/dev/null; then - log_success "Node.js application stopped gracefully" - break - fi - sleep 1 - done +normalize_exit_code() { + local exit_code="${1:-1}" + + if ! [[ "$exit_code" =~ ^[0-9]+$ ]]; then + echo 1 + return + fi + + if [ "$exit_code" -eq 0 ]; then + echo 1 + else + echo "$exit_code" + fi +} + +remember_exit_code() { + local exit_code="$1" - # Force kill if still running - if kill -0 "$NODE_PID" 2>/dev/null; then - log_warn "Force killing Node.js application" - kill -KILL "$NODE_PID" 2>/dev/null || true + if [ "$FINAL_EXIT_CODE" -eq 0 ] || [ "$exit_code" -ne 0 ]; then + FINAL_EXIT_CODE="$exit_code" + fi +} + +wait_for_process_exit() { + local pid="$1" + local timeout_seconds="$2" + local waited=0 + + while kill -0 "$pid" 2>/dev/null; do + if [ "$waited" -ge "$timeout_seconds" ]; then + return 1 fi + + sleep 1 + waited=$((waited + 1)) + done + + wait "$pid" 2>/dev/null || true + return 0 +} + +stop_process() { + local pid="$1" + local signal="$2" + local timeout_seconds="$3" + local process_name="$4" + + if [ -z "$pid" ] || ! kill -0 "$pid" 2>/dev/null; then + return fi - # Stop NGINX gracefully - if [ ! -z "$NGINX_PID" ] && kill -0 "$NGINX_PID" 2>/dev/null; then - log_info "Stopping NGINX (PID: $NGINX_PID)..." - nginx -s quit || kill -TERM "$NGINX_PID" 2>/dev/null || true + log_info "Stopping ${process_name} (PID: ${pid}) with SIG${signal}..." + kill -"$signal" "$pid" 2>/dev/null || true - # Wait up to 10 seconds - for i in {1..10}; do - if ! kill -0 "$NGINX_PID" 2>/dev/null; then - log_success "NGINX stopped gracefully" - break - fi - sleep 1 - done + if wait_for_process_exit "$pid" "$timeout_seconds"; then + log_success "${process_name} stopped gracefully" + return fi - log_success "Cleanup complete" - exit 0 + log_warn "${process_name} did not stop within ${timeout_seconds}s; force killing" + kill -KILL "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true } -# Set up signal handlers -trap cleanup SIGTERM SIGINT SIGQUIT - -# Check restart state -check_restart_state() { - if [ -f "$STATE_FILE" ]; then - LAST_RESTART=$(cat "$STATE_FILE") - CURRENT_TIME=$(date +%s) - TIME_DIFF=$((CURRENT_TIME - LAST_RESTART)) - - if [ $TIME_DIFF -lt $RESTART_WINDOW ]; then - RESTART_COUNT_FILE="${STATE_DIR}/restart.count" - if [ -f "$RESTART_COUNT_FILE" ]; then - COUNT=$(cat "$RESTART_COUNT_FILE") - COUNT=$((COUNT + 1)) - else - COUNT=1 - fi +stop_services() { + stop_process "$NODE_PID" TERM "$NODE_SHUTDOWN_TIMEOUT_SECONDS" "Node.js application" + stop_process "$NGINX_PID" TERM "$NGINX_SHUTDOWN_TIMEOUT_SECONDS" "NGINX" +} - echo $COUNT > "$RESTART_COUNT_FILE" +request_shutdown() { + local exit_code="${1:-0}" + local reason="${2:-shutdown requested}" - if [ $COUNT -ge $MAX_RESTART_ATTEMPTS ]; then - log_error "Too many restarts ($COUNT) in ${RESTART_WINDOW}s window. Entering failure mode." - log_error "Manual intervention required. Check logs for details." - sleep infinity - fi + remember_exit_code "$exit_code" - log_warn "Restart #$COUNT within ${RESTART_WINDOW}s window" - else - # Reset counter if outside window - echo 0 > "${STATE_DIR}/restart.count" - fi + if [ "$SHUTDOWN_REQUESTED" -eq 1 ]; then + return fi - echo $(date +%s) > "$STATE_FILE" + SHUTDOWN_REQUESTED=1 + log_info "${reason}; shutting down supervised services..." + stop_services } +handle_signal() { + local signal_name="$1" + request_shutdown 0 "Received ${signal_name}" + log_info "Container supervision finished with exit code $FINAL_EXIT_CODE" + exit "$FINAL_EXIT_CODE" +} + +# Set up signal handlers +trap 'handle_signal SIGTERM' SIGTERM +trap 'handle_signal SIGINT' SIGINT +trap 'handle_signal SIGQUIT' SIGQUIT + # Verify prerequisites verify_prerequisites() { log_info "Verifying prerequisites..." # Check if required environment variables are set - if [ -z "$DATABASE_URL" ]; then + if [ -z "${DATABASE_URL:-}" ]; then log_error "DATABASE_URL environment variable is not set" exit 1 fi @@ -140,8 +164,8 @@ verify_prerequisites() { if nc -z -w5 "$DB_HOST" "$DB_PORT" 2>/dev/null; then log_success "Database is accessible at $DB_HOST:$DB_PORT" else - log_warn "Cannot connect to $DB_HOST:$DB_PORT, will retry..." - sleep 5 + log_warn "Cannot connect to $DB_HOST:$DB_PORT, will retry in ${DB_CONNECT_RETRY_DELAY_SECONDS}s..." + sleep "$DB_CONNECT_RETRY_DELAY_SECONDS" if ! nc -z -w5 "$DB_HOST" "$DB_PORT" 2>/dev/null; then log_error "Cannot connect to database after retry" log_error "Check DATABASE_URL and ensure database is running" @@ -169,38 +193,85 @@ verify_prerequisites() { MIGRATION_SUCCESS=false for i in $(seq 1 $MIGRATION_RETRIES); do - log_info "Migration attempt $i/$MIGRATION_RETRIES..." + log_info "Migration attempt $i/$MIGRATION_RETRIES..." if npx prisma migrate deploy 2>&1; then - MIGRATION_SUCCESS=true - break + MIGRATION_SUCCESS=true + break else - if [ $i -lt $MIGRATION_RETRIES ]; then - log_warn "Migration failed, waiting 5s before retry..." - sleep 5 - fi + if [ $i -lt $MIGRATION_RETRIES ]; then + log_warn "Migration failed, waiting ${MIGRATION_RETRY_DELAY_SECONDS}s before retry..." + sleep "$MIGRATION_RETRY_DELAY_SECONDS" + fi fi - done - - if [ "$MIGRATION_SUCCESS" = false ]; then - log_error "Failed to run Prisma migrations after $MIGRATION_RETRIES attempts" - log_error "Common causes:" - log_error " 1. Database is not accessible (check DATABASE_URL)" - log_error " 2. Database permissions are insufficient" - log_error " 3. Database schema is corrupted" - log_error " 4. Network issues between container and database" - log_error "" - log_error "DATABASE_URL: ${DATABASE_URL%%@*}@***" - exit 1 - fi + done - log_success "Prisma migrations completed" + if [ "$MIGRATION_SUCCESS" = false ]; then + log_error "Failed to run Prisma migrations after $MIGRATION_RETRIES attempts" + log_error "Common causes:" + log_error " 1. Database is not accessible (check DATABASE_URL)" + log_error " 2. Database permissions are insufficient" + log_error " 3. Database schema is corrupted" + log_error " 4. Network issues between container and database" + log_error "" + log_error "DATABASE_URL: ${DATABASE_URL%%@*}@***" + exit 1 + fi + + log_success "Prisma migrations completed" log_success "Prerequisites verified" } # Start NGINX +bootstrap_nginx_runtime_layout() { + local bootstrap_release="${NGINX_RUNTIME_RELEASES_DIR}/bootstrap" + + log_info "Ensuring managed NGINX runtime layout exists..." + if ! mkdir -p "$NGINX_RUNTIME_RELEASES_DIR"; then + log_warn "Cannot create NGINX runtime directory $NGINX_RUNTIME_RELEASES_DIR; continuing without bootstrap layout" + return 0 + fi + + if [ ! -d "$bootstrap_release" ]; then + log_info "Creating bootstrap NGINX release at $bootstrap_release" + mkdir -p "$bootstrap_release" + + if [ -d "$NGINX_BUNDLED_SOURCE_DIR" ]; then + cp -a "$NGINX_BUNDLED_SOURCE_DIR/." "$bootstrap_release/" + elif [ -d /etc/nginx/conf.d ]; then + log_warn "Bundled NGINX source directory $NGINX_BUNDLED_SOURCE_DIR is missing; falling back to existing /etc/nginx contents" + mkdir -p "$bootstrap_release/conf.d" + cp -a /etc/nginx/conf.d/. "$bootstrap_release/conf.d/" 2>/dev/null || true + if [ -d /etc/nginx/html ]; then + mkdir -p "$bootstrap_release/html" + cp -a /etc/nginx/html/. "$bootstrap_release/html/" 2>/dev/null || true + fi + if [ -f /etc/nginx/mime.types ]; then + cp -a /etc/nginx/mime.types "$bootstrap_release/mime.types" 2>/dev/null || true + fi + else + log_warn "Bundled NGINX source directory $NGINX_BUNDLED_SOURCE_DIR is missing; creating an empty bootstrap release" + mkdir -p "$bootstrap_release/conf.d" "$bootstrap_release/html/errors" + fi + fi + + if [ ! -L "$NGINX_RUNTIME_CURRENT_LINK" ]; then + log_info "Initializing current NGINX release symlink" + if ! ln -sfn "$bootstrap_release" "$NGINX_RUNTIME_CURRENT_LINK"; then + log_warn "Failed to initialize current NGINX release symlink at $NGINX_RUNTIME_CURRENT_LINK" + fi + fi + + if [ ! -L "$NGINX_RUNTIME_LAST_KNOWN_GOOD_LINK" ]; then + log_info "Initializing last-known-good NGINX release symlink" + if ! ln -sfn "$bootstrap_release" "$NGINX_RUNTIME_LAST_KNOWN_GOOD_LINK"; then + log_warn "Failed to initialize last-known-good NGINX release symlink at $NGINX_RUNTIME_LAST_KNOWN_GOOD_LINK" + fi + fi +} + start_nginx() { log_info "Starting NGINX..." @@ -210,13 +281,21 @@ start_nginx() { exit 1 fi - # Start NGINX - nginx - NGINX_PID=$(pgrep -x nginx | head -n1) + # Start NGINX as a supervised foreground child so container exits when it dies + nginx -g 'daemon off;' & + NGINX_PID=$! - if [ -z "$NGINX_PID" ]; then - log_error "Failed to start NGINX" - exit 1 + sleep "$SERVICE_STARTUP_GRACE_SECONDS" + + if ! kill -0 "$NGINX_PID" 2>/dev/null; then + local exit_code=0 + if wait "$NGINX_PID"; then + exit_code=0 + else + exit_code=$? + fi + log_error "NGINX exited during startup with code $exit_code" + exit "$(normalize_exit_code "$exit_code")" fi log_success "NGINX started (PID: $NGINX_PID)" @@ -233,10 +312,17 @@ start_node_app() { log_success "Node.js application started (PID: $NODE_PID)" # Wait a moment and verify it's still running - sleep 3 + sleep "$SERVICE_STARTUP_GRACE_SECONDS" if ! kill -0 "$NODE_PID" 2>/dev/null; then - log_error "Node.js application crashed immediately after start" - exit 1 + local exit_code=0 + if wait "$NODE_PID"; then + exit_code=0 + else + exit_code=$? + fi + log_error "Node.js application crashed immediately after start with code $exit_code" + request_shutdown "$(normalize_exit_code "$exit_code")" "Node.js startup failure" + exit "$FINAL_EXIT_CODE" fi log_success "Node.js application is running stably" @@ -244,30 +330,40 @@ start_node_app() { # Monitor processes monitor_processes() { - log_info "Monitoring processes (Node PID: $NODE_PID, NGINX PID: $NGINX_PID)..." + log_info "Monitoring supervised processes (Node PID: $NODE_PID, NGINX PID: $NGINX_PID)..." while true; do - # Check Node.js app - if ! kill -0 "$NODE_PID" 2>/dev/null; then - log_error "Node.js application died unexpectedly" - cleanup - exit 1 + local exited_pid="" + local exit_code=0 + + if wait -n -p exited_pid "$NODE_PID" "$NGINX_PID"; then + exit_code=0 + else + exit_code=$? fi - # Check NGINX - if ! kill -0 "$NGINX_PID" 2>/dev/null; then - log_error "NGINX died unexpectedly, attempting restart..." - start_nginx + if [ "$SHUTDOWN_REQUESTED" -eq 1 ]; then + log_info "Shutdown in progress; child PID ${exited_pid:-unknown} exited with code $exit_code" + break fi - # Wait for Node.js to exit (normal shutdown) - wait "$NODE_PID" - EXIT_CODE=$? + if [ "$exited_pid" = "$NODE_PID" ]; then + log_error "Node.js application exited unexpectedly with code $exit_code" + elif [ "$exited_pid" = "$NGINX_PID" ]; then + log_error "NGINX exited unexpectedly with code $exit_code" + else + log_error "A supervised child process exited unexpectedly with code $exit_code" + fi - log_info "Node.js application exited with code $EXIT_CODE" - cleanup - exit $EXIT_CODE + request_shutdown "$(normalize_exit_code "$exit_code")" "Fail-fast supervision triggered container restart" + break done + + wait "$NODE_PID" 2>/dev/null || true + wait "$NGINX_PID" 2>/dev/null || true + + log_info "Container supervision finished with exit code $FINAL_EXIT_CODE" + exit "$FINAL_EXIT_CODE" } # Main execution @@ -278,19 +374,20 @@ log_info "Node version: $(node --version)" log_info "NPM version: $(npm --version)" # Show sanitized DATABASE_URL (hide password) -if [ -n "$DATABASE_URL" ]; then +if [ -n "${DATABASE_URL:-}" ]; then SANITIZED_URL=$(echo "$DATABASE_URL" | sed 's|://[^:]*:[^@]*@|://***:***@|') log_info "Database: $SANITIZED_URL" else log_error "DATABASE_URL is not set!" fi -# Check restart state -check_restart_state # Verify prerequisites verify_prerequisites +# Ensure the runtime-managed NGINX release layout exists before validation/startup +bootstrap_nginx_runtime_layout + # Start services start_nginx start_node_app diff --git a/docs/architecture/current-architecture.md b/docs/architecture/current-architecture.md new file mode 100644 index 0000000..47cbee7 --- /dev/null +++ b/docs/architecture/current-architecture.md @@ -0,0 +1,266 @@ +# Current architecture overview + +Last updated: 2026-05-28 + +This document describes the architecture that is actually present in the repository today. It is intentionally grounded in shipped code and current operator workflows, not just in the longer-term target architecture. + +> Status note: treat this as the current implementation baseline. Final rollout decisions should be made together with [`FINAL_PRODUCTION_CHECKLIST.md`](../../FINAL_PRODUCTION_CHECKLIST.md) and [`PRODUCTION_DEFERMENT_REGISTER.md`](../../PRODUCTION_DEFERMENT_REGISTER.md). + +## 1. System shape + +LyttleNGINX currently consists of a **NestJS control plane plus local NGINX runtime** backed by **PostgreSQL**. + +At a high level: + +- **NestJS application** + - admin and internal control-plane APIs + - auth, RBAC, and audit hooks + - cluster lease and operation tracking + - certificate lifecycle orchestration + - backup and restore logic + - health, metrics, and structured operational logging +- **NGINX runtime** + - serves proxied traffic + - serves ACME HTTP-01 challenges from shared state + - consumes staged runtime releases under `/etc/nginx/runtime` +- **PostgreSQL** + - source of truth for proxy entries, certificates, orders, ACME challenges, leases, operations, ACKs, and audit events +- **Filesystem runtime state** + - staged NGINX release directories and metadata + - local certificate material synchronized from the database + - encrypted backup artifacts + - ACME account private key file when configured + +## 2. Request classes and trust boundaries + +The API is **authenticated by default**. + +### Public allowlist + +These routes are intentionally public: + +- `GET /health/live` +- `GET /health/startup` +- `GET /health/ready` +- `GET /health/dependencies` +- `GET /health/deep` +- `GET /health` +- `GET /ready` +- `GET /metrics` +- `GET /metrics/json` +- `GET /.well-known/acme-challenge/:token` + +### Authenticated admin and internal traffic + +All other routes require one of: + +- `Authorization: Bearer <jwt>` +- `X-API-Key: <key>` +- `Authorization: ApiKey <key>` + +Current actor types: + +- `admin` +- `internal-node` + +Current RBAC catalog: + +- `viewer` +- `operator` +- `security-admin` +- `platform-admin` +- `internal-node` + +## 3. Runtime topology today + +The shipped deployment model is still a **combined runtime**: + +- NestJS and NGINX run in the same container +- `docker-entrypoint.sh` supervises both processes +- if either process exits unexpectedly, the container exits non-zero so Docker or Swarm can restart it + +This is materially safer than the earlier masked-failure model, but it is **not** yet the preferred split control-plane/dataplane architecture described in the broader readiness assessment. + +## 4. Core control-plane workflows + +### 4.1 Health and readiness + +Health is split into dedicated routes: + +- liveness → `GET /health/live` +- startup → `GET /health/startup` +- readiness → `GET /health/ready` +- dependency drilldown → `GET /health/dependencies` +- full deep view → `GET /health/deep` + +Readiness returns non-200 when critical dependencies are unhealthy, including: + +- PostgreSQL connectivity +- NGINX master process health +- recent config-apply success +- recent certificate-sync success + +### 4.2 Cluster coordination + +Cluster leadership is lease-based: + +- `ClusterLease` stores leader ownership and generation/fencing state +- `ClusterNode` provides heartbeat and observability data +- cluster-wide mutations are represented as `ClusterOperation` records with per-node `ClusterOperationAck` rows + +Operator inspection APIs include: + +- `GET /cluster/status` +- `GET /cluster/lease` +- `GET /cluster/leader` +- `GET /cluster/leader/status` +- `GET /cluster/operations` +- `GET /cluster/operations/:operationId` +- `GET /cluster/nodes` +- `GET /cluster/nodes/:nodeId` +- `GET /cluster/nodes/:nodeId/config` +- `GET /cluster/nodes/:nodeId/certificates` + +### 4.3 NGINX config rollout + +NGINX config deployment is staged and rollback-aware: + +- each reload builds a staged release under `/etc/nginx/runtime/releases/<release-id>` +- staged config is validated with `nginx -t` before activation +- activation swaps the `current` symlink atomically +- `last-known-good` is preserved +- failed reload activation triggers automatic rollback +- local release metadata is written to `lyttle-nginx-release.json` + +Current limitation: + +- there is **not yet** a dedicated first-class API to manually roll back to an arbitrary prior config release +- operator rollback today means either relying on built-in rollback when reload fails or reverting desired state and triggering a fresh reload + +### 4.4 Certificate lifecycle + +Current workflow primitives include: + +- strict normalized domain validation +- durable `CertificateOrder` state +- versioned `CertificateArtifactVersion` history +- ACK-backed artifact activation across the cluster +- rollback to the prior artifact version +- shared PostgreSQL-backed ACME HTTP-01 challenge state +- envelope encryption for stored private keys +- encrypted backup artifacts with signed manifests + +Relevant APIs include: + +- `GET /certificates` +- `GET /certificates/orders` +- `GET /certificates/orders/:id` +- `POST /certificates/orders/:id/retry` +- `GET /certificates/challenges` +- `POST /certificates/upload` +- `POST /certificates/generate-self-signed` +- `POST /certificates/renew/:id` +- `POST /certificates/renew-all` +- `POST /certificates/artifacts/:artifactId/activate` +- `POST /certificates/:id/rollback` +- `POST /certificates/sync` + +### 4.5 Backups and restore + +The backup model is: + +- encrypted `.lyttlebackup` artifacts +- signed manifest and checksums +- server-side verify endpoint +- server-side restore endpoint +- direct raw PEM export treated as break-glass only + +Relevant APIs include: + +- `POST /certificates/backup` +- `GET /certificates/backup` +- `GET /certificates/backup/:filename` +- `POST /certificates/backup/:filename/verify` +- `POST /certificates/backup/:filename/restore` +- `POST /certificates/backup/import` +- `GET /certificates/backup/export/:id` + +## 5. Observability model + +The observability surface has three layers: + +### Operational logs + +- structured JSON emitted to stdout/stderr +- request correlation IDs +- actor identity context when available +- node and operation context when available +- secret redaction for common sensitive fields + +### Audit events + +- persisted in PostgreSQL +- exposed separately via `GET /audit` +- capture privileged and mutating actions, denied attempts, and controller/service failures on protected routes + +### Health and metrics + +- public health and drilldown endpoints +- Prometheus metrics at `GET /metrics` +- JSON metrics at `GET /metrics/json` +- state-based gauges for dependencies, leases, operations, orders, backups, and certificate inventory + +## 6. Deployment expectations + +| Mode | Current expectation | +| --- | --- | +| Local development | best-supported path for coding and verification | +| Single-node Compose | evaluation and non-HA use; not final hardened production guidance | +| Docker Swarm global mode | target clustered operating model for controlled rollout once the checklist is completed and deferments are accepted | + +## 7. Important current limitations + +The documentation should stay explicit about the boundaries that still exist today: + +1. **Internal node traffic is still authenticated HTTP, not mTLS** + - node identity and RBAC exist + - transport-level mutual TLS is still future work +2. **NestJS and NGINX still share a single container** + - supervision is fail-fast now + - the split control-plane/dataplane target architecture is not implemented yet +3. **Secret ingestion is still environment-variable driven** + - docs recommend Swarm secrets or an external secret store + - first-class `*_FILE` or external secret-provider integration is still future work +4. **Manual config rollback is not yet a dedicated API** + - automatic rollback exists for reload failures + - logical rollback still requires desired-state reversion plus a new reload + +## 8. Incident-navigation map + +When something looks wrong, start here: + +1. `GET /health/deep` +2. `GET /cluster/status` +3. `GET /cluster/lease` +4. `GET /cluster/operations` +5. `GET /logs` +6. `GET /audit` + +Then move into the dedicated runbook: + +- leader failure → `docs/runbooks/leader-failure.md` +- config rollback → `docs/runbooks/nginx-config-rollback.md` +- encrypted restore → `docs/runbooks/restore-from-encrypted-backup.md` +- certificate issuance failure → `docs/runbooks/certificate-issuance-failure.md` +- credential rotation → `docs/runbooks/credential-rotation.md` +- break-glass secret handling → `docs/runbooks/security-break-glass.md` +- monitoring and alert response → `docs/runbooks/monitoring-alerts.md` + +## 9. Related references + +- `README.md` +- `PRODUCTION_READINESS_ASSESSMENT.md` +- `FINAL_PRODUCTION_CHECKLIST.md` +- `PRODUCTION_DEFERMENT_REGISTER.md` +- `ARCHITECTURE_DECISIONS.md` + diff --git a/docs/runbooks/certificate-issuance-failure.md b/docs/runbooks/certificate-issuance-failure.md new file mode 100644 index 0000000..49670be --- /dev/null +++ b/docs/runbooks/certificate-issuance-failure.md @@ -0,0 +1,196 @@ +# Certificate issuance failure runbook + +Last updated: 2026-05-28 + +Use this runbook when certificate issuance, retry, distribution, or activation fails. + +## Current scope + +The shipped certificate workflow supports: + +- durable certificate orders +- shared PostgreSQL-backed ACME HTTP-01 challenge publication +- cluster ACK-backed artifact activation +- rollback to the prior artifact version + +Important current constraint: + +- the built-in hardened ACME flow is **HTTP-01 only** +- wildcard issuance is intentionally rejected by the shipped implementation + +## Step 1: Identify the failing order or certificate + +Start with the order and challenge views. + +```bash +curl -s http://localhost:3000/certificates/orders \ + -H "Authorization: Bearer $VIEWER_TOKEN" | jq + +curl -s http://localhost:3000/certificates/challenges \ + -H "Authorization: Bearer $VIEWER_TOKEN" | jq + +curl -s http://localhost:3000/certificates \ + -H "Authorization: Bearer $VIEWER_TOKEN" | jq +``` + +If the incident is tied to a particular order: + +```bash +curl -s http://localhost:3000/certificates/orders/<order-id> \ + -H "Authorization: Bearer $VIEWER_TOKEN" | jq +``` + +## Step 2: Classify the failure mode + +### Case A — Validation rejected before ACME ran + +Examples: + +- malformed domain +- unsupported wildcard domain +- local/non-FQDN value + +Typical signal: + +- the order is `failed` early +- the error message references domain normalization or wildcard support + +Action: + +- correct the requested domains +- do not keep retrying the same invalid order input + +### Case B — HTTP-01 challenge publication or reachability failed + +Typical signal: + +- the order is stuck or failed around `challenge-published` / `validating` +- `GET /certificates/challenges` shows failed or stale challenge records + +Action: + +1. confirm the challenge route is reachable from the public edge +2. confirm the relevant node health is good +3. confirm the shared challenge record exists +4. retry the order after fixing reachability + +Useful checks: + +```bash +curl -s http://localhost:3000/health/dependencies | jq +curl -s http://localhost:3000/cluster/status -H "Authorization: Bearer $VIEWER_TOKEN" | jq +curl -s http://localhost:3000/certificates/challenges -H "Authorization: Bearer $VIEWER_TOKEN" | jq +``` + +### Case C — Issuance succeeded, but cluster activation failed + +Typical signal: + +- the order reached `issued` or `distributing` +- a linked cluster operation has failed ACKs +- the old active artifact remains in place + +Action: + +```bash +curl -s http://localhost:3000/cluster/operations \ + -H "Authorization: Bearer $VIEWER_TOKEN" | jq +``` + +Inspect the relevant operation and per-node ACKs, then decide whether to: + +- retry the order if the artifact still needs activation +- repair the failing node and re-run activation +- roll back the certificate if an incorrect artifact became active on some nodes + +### Case D — Dependency outage blocked the workflow + +Typical signal: + +- readiness or dependency health is bad +- logs show database, NGINX, or certificate-sync freshness issues + +Action: + +- stabilize the dependency first +- then retry the order or rerun the activation flow + +## Step 3: Use the least-destructive recovery action + +### Retry a failed order + +```bash +curl -s -X POST http://localhost:3000/certificates/orders/<order-id>/retry \ + -H "Authorization: Bearer $OPERATOR_TOKEN" | jq +``` + +Use retry when: + +- the order input is still valid +- the failure was environmental or transient +- you want to preserve the order history rather than creating a fresh workflow immediately + +### Roll back the active certificate + +If the wrong certificate artifact became active and you need to restore the prior good version: + +```bash +curl -s -X POST http://localhost:3000/certificates/<certificate-id>/rollback \ + -H "Authorization: Bearer $SECURITY_ADMIN_TOKEN" | jq +``` + +### Re-check cluster convergence + +```bash +curl -s http://localhost:3000/cluster/operations \ + -H "Authorization: Bearer $VIEWER_TOKEN" | jq + +curl -s http://localhost:3000/cluster/nodes/<node-id>/certificates \ + -H "Authorization: Bearer $VIEWER_TOKEN" | jq +``` + +## Step 4: Validate recovery + +Recovery is successful when: + +- the order has moved out of `failed` / stale state as expected +- challenge state is no longer stuck +- the target certificate is active where expected +- cluster ACKs are healthy +- readiness and dependency health are good again + +Useful final checks: + +```bash +curl -s http://localhost:3000/certificates/orders/<order-id> \ + -H "Authorization: Bearer $VIEWER_TOKEN" | jq + +curl -s http://localhost:3000/health/deep | jq + +curl -s http://localhost:3000/cluster/status \ + -H "Authorization: Bearer $VIEWER_TOKEN" | jq +``` + +## Common mistakes to avoid + +- retrying wildcard ACME orders even though wildcard issuance is intentionally unsupported +- retrying while PostgreSQL or NGINX health is still broken +- assuming local issuance success means the cluster activation succeeded +- using raw certificate export as a normal recovery path instead of the built-in order, rollback, and backup workflows + +## Escalate when + +Escalate beyond routine retry when: + +- the same order fails repeatedly after challenge reachability is confirmed +- multiple nodes report certificate activation ACK failures +- the old active artifact cannot be restored cleanly +- challenge records are present but public challenge traffic never reaches any healthy node + +## Related procedures + +- leader repair → `docs/runbooks/leader-failure.md` +- config rollback → `docs/runbooks/nginx-config-rollback.md` +- encrypted restore → `docs/runbooks/restore-from-encrypted-backup.md` +- break-glass export → `docs/runbooks/security-break-glass.md` + diff --git a/docs/runbooks/credential-rotation.md b/docs/runbooks/credential-rotation.md new file mode 100644 index 0000000..b190626 --- /dev/null +++ b/docs/runbooks/credential-rotation.md @@ -0,0 +1,175 @@ +# Credential rotation runbook + +Last updated: 2026-05-28 + +This runbook documents the operator-facing credential and encryption-key rotation procedures that are actually implemented today. + +## Current rotation surface + +Implemented today: + +- legacy `API_KEY` overlap planning +- bearer-token bridge issuance from `POST /auth/token` +- private-key encryption re-encryption via `POST /security/rotate/private-key-encryption` +- backup key rotation through configuration change plus new artifact creation + +Not fully implemented yet: + +- internal node certificate rotation for mTLS + +## Required access + +- `platform-admin` for API-key planning +- `security-admin` for private-key re-encryption and secret posture review + +## 1. Legacy API key rotation + +The application still supports legacy API keys as a migration bridge. +The current rotation model is **manual secret update + redeploy**, not in-process hot reload. + +### Plan the overlap window + +```bash +curl -s -X POST http://localhost:3000/security/rotate/api-key \ + -H "Authorization: Bearer $PLATFORM_ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "nextApiKey": "<new-key>", + "issueBridgeToken": true + }' | jq +``` + +This endpoint: + +- validates the proposed key +- returns safe fingerprint/ID information +- optionally issues a short-lived bearer-token bridge when `AUTH_JWT_SECRET` is configured +- does **not** store the new key for you + +### Execute the rotation + +1. update the external secret/config source so both old and new keys are available during the overlap window +2. redeploy all nodes +3. validate the new key +4. remove the retired key from the secret/config source +5. redeploy again + +Validation commands: + +```bash +curl -s http://localhost:3000/auth/status -H "X-API-Key: <new-key>" | jq +curl -s http://localhost:3000/security/status -H "Authorization: Bearer $PLATFORM_ADMIN_TOKEN" | jq +``` + +## 2. Bearer-token bridge during migration + +If `AUTH_JWT_SECRET` is configured, you can exchange a legacy API-key-authenticated session for a short-lived bearer token: + +```bash +curl -s -X POST http://localhost:3000/auth/token \ + -H "X-API-Key: <current-key>" | jq +``` + +Use this to reduce dependence on long-lived API keys while the remaining clients migrate. + +## 3. Private-key master-key rotation + +This rotates the application-layer encryption version used for stored certificate private keys. + +### Preparation + +1. update the injected runtime secrets/config so the new values are available: + - `PRIVATE_KEY_ENCRYPTION_MASTER_KEY` + - `PRIVATE_KEY_ENCRYPTION_KEY_VERSION` +2. redeploy the application so the new target key version is active + +### Run the re-encryption pass + +```bash +curl -s -X POST http://localhost:3000/security/rotate/private-key-encryption \ + -H "Authorization: Bearer $SECURITY_ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "targetKeyVersion": "v2" + }' | jq +``` + +### Validate afterward + +- inspect `GET /security/status` +- inspect `GET /security/secrets/health` +- create and verify a fresh backup artifact + +```bash +curl -s http://localhost:3000/security/status \ + -H "Authorization: Bearer $SECURITY_ADMIN_TOKEN" | jq + +curl -s http://localhost:3000/security/secrets/health \ + -H "Authorization: Bearer $SECURITY_ADMIN_TOKEN" | jq +``` + +## 4. Backup encryption key rotation + +Backup artifact protection is configuration-driven. +There is no separate in-app migration endpoint for historical backup files. + +### Current procedure + +1. update: + - `BACKUP_ENCRYPTION_KEY` + - `BACKUP_ENCRYPTION_KEY_VERSION` +2. redeploy the application +3. create fresh encrypted backups under the new version +4. verify the new artifacts +5. retire older artifacts according to your retention policy + +Example follow-up: + +```bash +curl -s -X POST http://localhost:3000/certificates/backup \ + -H "Authorization: Bearer $SECURITY_ADMIN_TOKEN" | jq + +curl -s -X POST http://localhost:3000/certificates/backup/<new-file>.lyttlebackup/verify \ + -H "Authorization: Bearer $SECURITY_ADMIN_TOKEN" | jq +``` + +## 5. Internal certificate rotation status + +`POST /security/rotate/internal-certs` is currently a forward-looking contract only. + +Current reality: + +- inter-node traffic is still authenticated HTTP +- mTLS node certificates are not yet active +- the endpoint exists to document prerequisites and preserve a future admin contract + +You can inspect that status with: + +```bash +curl -s -X POST http://localhost:3000/security/rotate/internal-certs \ + -H "Authorization: Bearer $PLATFORM_ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{}' | jq +``` + +## Post-rotation checks + +After any credential or key rotation: + +- validate `GET /auth/status` or the affected security endpoint with the new credential +- inspect `GET /security/status` +- review `GET /audit` for the rotation-planning or rotation action +- confirm no unexpected authorization failures appear in structured logs + +## Important limitations + +- API keys are not hot-reloaded by the application +- backup-envelope key rotation does not retroactively rewrite older artifacts +- internal mTLS certificate rotation is not yet implemented + +## Related procedures + +- break-glass handling → `docs/runbooks/security-break-glass.md` +- encrypted restore → `docs/runbooks/restore-from-encrypted-backup.md` +- architecture/current boundaries → `docs/architecture/current-architecture.md` + diff --git a/docs/runbooks/leader-failure.md b/docs/runbooks/leader-failure.md new file mode 100644 index 0000000..6d98a93 --- /dev/null +++ b/docs/runbooks/leader-failure.md @@ -0,0 +1,172 @@ +# Leader failure runbook + +Last updated: 2026-05-28 + +Use this runbook when alerts or operators detect that the cluster leader is missing, stale, or inconsistent. + +## When to use it + +Typical triggers: + +- `lyttle_cluster_leader_present == 0` +- `lyttle_cluster_leader_lease_expired == 1` +- `GET /cluster/status` reports no healthy leader +- cluster-wide reload or certificate activation operations stop progressing +- `GET /cluster/leader/status` reports lease drift or leader inconsistency issues + +## Required access + +- `viewer` to inspect health, lease, nodes, and operations +- `platform-admin` to use the manual leader repair endpoints + +## Before making changes + +1. Confirm the problem is not just a database outage. +2. Avoid making multiple competing manual leader changes from different nodes at the same time. +3. Record the incident ID and the node you are working from. + +## Step 1: Check dependency health first + +Run these checks from any reachable node: + +```bash +curl -s http://localhost:3000/health/ready | jq +curl -s http://localhost:3000/health/dependencies | jq +curl -s http://localhost:3000/cluster/status -H "Authorization: Bearer $JWT" | jq +curl -s http://localhost:3000/cluster/lease -H "Authorization: Bearer $JWT" | jq +curl -s http://localhost:3000/cluster/leader/status -H "Authorization: Bearer $JWT" | jq +``` + +If PostgreSQL is unhealthy, fix database connectivity first. Lease repair is unlikely to succeed while the database is unavailable. + +## Step 2: Identify the failure mode + +Use `GET /cluster/leader/status` and `GET /cluster/nodes?includeInactive=true` to classify the issue. + +```bash +curl -s "http://localhost:3000/cluster/nodes?includeInactive=true" -H "Authorization: Bearer $JWT" | jq +curl -s http://localhost:3000/cluster/leader/status -H "Authorization: Bearer $JWT" | jq +curl -s http://localhost:3000/cluster/operations -H "Authorization: Bearer $JWT" | jq +``` + +Common cases: + +### Case A — No active lease + +Symptoms: + +- `status: "no-leader"` +- no `lease.ownerNodeId` +- cluster operations are not advancing + +Recommended action: + +- use `GET /cluster/admin/ensure-leader` + +### Case B — Lease exists, but owner node is stale or missing + +Symptoms: + +- `lease.ownerNodeId` is present +- `leaseOwnerRecord` is missing or inactive +- `issues` includes lease/owner mismatch conditions + +Recommended action: + +- verify the owner node is actually gone or unhealthy +- then use `GET /cluster/admin/ensure-leader` +- if stale node records are hanging around, run `GET /cluster/admin/cleanup` + +### Case C — Lease and DB annotations disagree + +Symptoms: + +- lease exists +- `dbLeader` or `allLeadersInDb` does not match the lease owner +- `issues` shows `LEASE_DB_MISMATCH` + +Recommended action: + +- use `GET /cluster/admin/enforce-leader` + +### Case D — You need the local node to attempt leadership + +Symptoms: + +- there is no healthy leader +- you are intentionally repairing from a specific node + +Recommended action: + +- use `GET /cluster/admin/become-leader` +- only do this from one chosen repair node + +## Step 3: Apply the minimum repair action + +### Ensure a leader exists + +```bash +curl -s http://localhost:3000/cluster/admin/ensure-leader \ + -H "Authorization: Bearer $PLATFORM_ADMIN_TOKEN" | jq +``` + +### Clean up stale nodes + +```bash +curl -s http://localhost:3000/cluster/admin/cleanup \ + -H "Authorization: Bearer $PLATFORM_ADMIN_TOKEN" | jq +``` + +### Reconcile leader flags to the active lease + +```bash +curl -s http://localhost:3000/cluster/admin/enforce-leader \ + -H "Authorization: Bearer $PLATFORM_ADMIN_TOKEN" | jq +``` + +### Ask the current node to try becoming leader + +```bash +curl -s http://localhost:3000/cluster/admin/become-leader \ + -H "Authorization: Bearer $PLATFORM_ADMIN_TOKEN" | jq +``` + +## Step 4: Validate recovery + +After the repair action: + +```bash +curl -s http://localhost:3000/cluster/leader/status -H "Authorization: Bearer $JWT" | jq +curl -s http://localhost:3000/cluster/status -H "Authorization: Bearer $JWT" | jq +curl -s http://localhost:3000/cluster/operations -H "Authorization: Bearer $JWT" | jq +``` + +Recovery is considered successful when: + +- `GET /cluster/leader/status` reports `status: "healthy"` +- the lease owner is active and matches the DB-visible leader state +- new cluster operations advance normally +- readiness is healthy on the relevant nodes + +## Step 5: Post-incident checks + +- review `GET /audit` for the manual repair actions +- capture the lease generation/fencing token after recovery +- check recent structured logs for the failing node and the new leader +- confirm no certificate activation or reload operation is left stuck + +## Important current limitations + +- internal node transport is still authenticated HTTP rather than mTLS +- the system still uses a combined Node + NGINX container model + +## Escalate when + +Escalate beyond normal operator repair if any of the following are true: + +- PostgreSQL remains unhealthy +- `ensure-leader` repeatedly fails after dependency health is restored +- leader lease generation changes rapidly in a loop +- cluster operations continue to stall after leader recovery +- multiple nodes appear to believe they are leader even after `enforce-leader` + diff --git a/docs/runbooks/monitoring-alerts.md b/docs/runbooks/monitoring-alerts.md new file mode 100644 index 0000000..b850649 --- /dev/null +++ b/docs/runbooks/monitoring-alerts.md @@ -0,0 +1,193 @@ +# Monitoring and alert recommendations + +Last updated: 2026-05-28 + +This runbook captures the recommended Prometheus/Grafana alert surface for the current implementation. +It is intentionally focused on the metrics that now exist in `GET /metrics` and the richer dependency drilldowns in `GET /health/dependencies` and `GET /health/deep`. + +## Primary scrape targets + +- `GET /metrics` + - Prometheus text exposition + - cluster, certificate, backup, and dependency gauges +- `GET /metrics/json` + - dashboard/debug-friendly JSON view of the same data +- `GET /health/ready` + - orchestration readiness gate +- `GET /health/dependencies` + - dependency drilldown for DB, NGINX, config apply, and certificate sync freshness +- `GET /health/deep` + - combined liveness/startup/readiness/dependency report for incident triage + +## Recommended alert rules + +### 1. Control-plane dependency health + +Alert when any of these stay unhealthy for more than a short debounce window: + +- `lyttle_db_connectivity_status == 0` +- `lyttle_nginx_master_process_status == 0` +- `lyttle_config_apply_status == 0` +- `lyttle_certificate_sync_status == 0` +- `lyttle_health_dependency_status{name!="database"} == 0` + +Suggested severity: + +- critical: database, NGINX master, config apply +- warning: certificate sync freshness + +Suggested action: + +1. inspect `GET /health/dependencies` +2. inspect recent structured logs via platform log shipping or `GET /logs` +3. confirm whether the node should be drained or restarted + +### 2. Leader / lease health + +Alert when: + +- `lyttle_cluster_leader_present == 0` +- `lyttle_cluster_leader_lease_expired == 1` +- `lyttle_cluster_leader_lease_seconds_remaining < 10` +- `lyttle_cluster_leases_expired > 0` for a sustained period + +Suggested severity: + +- critical: no leader, expired leader lease +- warning: leader lease close to expiry repeatedly + +Suggested action: + +1. inspect `GET /cluster/lease` +2. inspect `GET /cluster/status` +3. verify PostgreSQL availability and cluster heartbeat freshness + +### 3. Cluster operation convergence + +Alert when: + +- `lyttle_cluster_operations_stale_total > 0` +- `lyttle_cluster_operations_recent_failures_total > 0` +- `lyttle_cluster_operation_acks_total{status="failed"} > 0` + +Suggested action: + +1. inspect `GET /cluster/operations` +2. drill into the failed `operationId` +3. inspect per-node status via `GET /cluster/nodes/:nodeId` + +### 4. Certificate lifecycle drift + +Alert when: + +- `lyttle_certificate_orders_stale_total > 0` +- `lyttle_certificate_orders_retry_due_total > 0` for too long +- `lyttle_certificates_expired > 0` +- `lyttle_certificates_expiring_soon > 0` with low operator coverage + +Suggested action: + +1. inspect `GET /certificates/orders` +2. inspect `GET /certificates/challenges` for active HTTP-01 issues +3. review recent certificate activation operations in `GET /cluster/operations` + +### 5. Backup freshness + +Alert when: + +- `lyttle_backup_freshness_status == 0` +- `lyttle_backups_total == 0` + +Suggested action: + +1. create a fresh encrypted backup +2. verify the new artifact with `POST /certificates/backup/:filename/verify` +3. investigate backup storage volume availability and permissions + +### 6. Metrics self-observability + +Alert when: + +- `lyttle_metrics_collection_errors_total > 0` +- `lyttle_metrics_collection_status{section="health"} == 0` +- `up{job="lyttlenginx"} == 0` + +Suggested action: + +1. determine whether the problem is scrape transport, app health, or a single failing metrics section +2. inspect the `collection.errors` payload from `GET /metrics/json` +3. use `GET /health/deep` to confirm whether the process is still serving requests safely + +## Example Prometheus rule snippets + +```yaml +groups: + - name: lyttle-nginx-control-plane + rules: + - alert: LyttleNginxDatabaseUnavailable + expr: lyttle_db_connectivity_status == 0 + for: 2m + labels: + severity: critical + annotations: + summary: LyttleNGINX database connectivity failed + description: Inspect /health/dependencies and recent control-plane logs. + + - alert: LyttleNginxNoLeader + expr: lyttle_cluster_leader_present == 0 or lyttle_cluster_leader_lease_expired == 1 + for: 1m + labels: + severity: critical + annotations: + summary: LyttleNGINX cluster has no healthy leader lease + description: Inspect /cluster/lease and /cluster/status. + + - alert: LyttleNginxStaleClusterOperation + expr: lyttle_cluster_operations_stale_total > 0 + for: 5m + labels: + severity: warning + annotations: + summary: Cluster operation is stuck past the stale threshold + description: Inspect /cluster/operations for failed or long-running work. + + - alert: LyttleNginxCertificateOrdersStale + expr: lyttle_certificate_orders_stale_total > 0 + for: 10m + labels: + severity: warning + annotations: + summary: Certificate order workflow is stale + description: Inspect /certificates/orders and /certificates/challenges. + + - alert: LyttleNginxBackupStale + expr: lyttle_backup_freshness_status == 0 + for: 15m + labels: + severity: warning + annotations: + summary: No fresh encrypted backup is available + description: Create and verify a fresh backup artifact. +``` + +## Dashboard recommendations + +Minimum dashboard panels: + +- DB connectivity status and latency +- NGINX master status +- config apply age vs max age +- certificate sync age vs max age +- leader lease seconds remaining and generation +- cluster operations by status and recent failure count +- certificate orders by status and stale count +- certificate expiry distribution +- backup freshness and total retained artifacts + +## Operational notes + +- Treat `/health/ready` as the deployment/orchestration gate. +- Treat `/health/dependencies` and `/health/deep` as diagnosis surfaces, not just binary probes. +- Prefer alerting on gauges that represent *current bad state* or *recent failure windows* instead of all-time counts. +- When a Prometheus alert fires, capture the matching `operationId`, `correlationId`, or `nodeId` from logs and cluster-operation APIs for faster triage. + diff --git a/docs/runbooks/nginx-config-rollback.md b/docs/runbooks/nginx-config-rollback.md new file mode 100644 index 0000000..02ba5f8 --- /dev/null +++ b/docs/runbooks/nginx-config-rollback.md @@ -0,0 +1,143 @@ +# NGINX config rollback runbook + +Last updated: 2026-05-28 + +Use this runbook when a config rollout fails or when a newly activated config must be backed out. + +## Important reality check + +The current implementation has **automatic rollback on failed activation**, but it does **not** yet expose a dedicated API to roll back to an arbitrary prior NGINX release. + +That means recovery currently depends on which failure mode you are in: + +1. **Activation failed** + - the runtime should automatically roll back to `last-known-good` +2. **Activation succeeded, but the new desired state is operationally wrong** + - revert the desired state through the relevant API or data change + - then trigger a fresh reload/cluster reload + +This runbook documents the real workflow, not an unimplemented manual-rollback API. + +## Step 1: Determine what failed + +Inspect the cluster operation and node config view first. + +```bash +curl -s http://localhost:3000/cluster/operations \ + -H "Authorization: Bearer $VIEWER_TOKEN" | jq + +curl -s http://localhost:3000/cluster/nodes/<node-id>/config \ + -H "Authorization: Bearer $VIEWER_TOKEN" | jq + +curl -s http://localhost:3000/health/deep | jq +``` + +Look for: + +- failed `cluster.reload` operations +- the latest reload ACK payload for the node +- current vs last-known-good runtime metadata on the local node +- readiness failures caused by config apply or NGINX health + +## Case A: Reload failed during activation + +Expected current behavior: + +- the staged release was validated before activation +- if `nginx -s reload` failed after activation, the service attempted rollback automatically +- `last-known-good` should still point at the prior release + +### Recovery steps + +1. confirm the failed operation is no longer progressing +2. confirm the node health has returned or that the rollback left the node in a safe state +3. inspect the underlying config change that triggered the rollout +4. correct the desired state before attempting another reload + +Validation commands: + +```bash +curl -s http://localhost:3000/cluster/nodes/<node-id>/config \ + -H "Authorization: Bearer $VIEWER_TOKEN" | jq + +curl -s http://localhost:3000/cluster/status \ + -H "Authorization: Bearer $VIEWER_TOKEN" | jq +``` + +## Case B: Config activated successfully, but the change is bad + +Examples: + +- wrong upstream target +- incorrect redirect behavior +- unexpected header behavior from a custom allowed fragment +- a domain or certificate mapping that is logically wrong even though syntax is valid + +### Recovery steps + +1. identify the source of the desired-state change +2. revert that source using the appropriate workflow +3. trigger a fresh reload after the desired state is corrected + +Typical rollback sources: + +- proxy change → revert via `PATCH /proxies/:id` or `DELETE /proxies/:id` +- certificate activation issue → use `POST /certificates/:id/rollback` +- emergency certificate sync issue → restore or re-activate the correct artifact, then reload if needed + +Example commands: + +```bash +curl -s -X POST http://localhost:3000/certificates/<certificate-id>/rollback \ + -H "Authorization: Bearer $SECURITY_ADMIN_TOKEN" | jq + +curl -s -X POST http://localhost:3000/cluster/reload \ + -H "Authorization: Bearer $OPERATOR_TOKEN" | jq +``` + +## Case C: Emergency local rollback with shell access + +Prefer the API-driven desired-state revert path above. + +If the node is unhealthy and an operator has container/shell access, the current runtime layout exposes: + +- `/etc/nginx/runtime/current` +- `/etc/nginx/runtime/last-known-good` +- per-release metadata files under `/etc/nginx/runtime/releases/<release-id>/lyttle-nginx-release.json` + +Use that only as a break-glass local repair path when the API surface cannot restore service quickly enough. If you do it: + +1. capture the current and target release IDs +2. record the incident/change number +3. restore service first +4. reconcile the desired state in the control plane afterward so the next reload does not reapply the bad config + +## Post-rollback validation + +After recovery: + +```bash +curl -s http://localhost:3000/health/ready | jq +curl -s http://localhost:3000/cluster/status -H "Authorization: Bearer $VIEWER_TOKEN" | jq +curl -s http://localhost:3000/cluster/operations -H "Authorization: Bearer $VIEWER_TOKEN" | jq +``` + +Confirm: + +- readiness is healthy again +- no `cluster.reload` operation is left stuck or failing repeatedly +- the node config view shows the expected current runtime state +- the reverted desired state matches what you intend to keep + +## Current limitations to remember + +- there is no first-class API yet for “roll back to release X” +- automatic rollback covers failed reload activation, not every logically bad config change +- desired-state rollback may require both a source correction and a new reload operation + +## Related procedures + +- proxy change validation → see `README.md` +- encrypted restore → `docs/runbooks/restore-from-encrypted-backup.md` +- certificate issuance or activation failure → `docs/runbooks/certificate-issuance-failure.md` + diff --git a/docs/runbooks/restore-from-encrypted-backup.md b/docs/runbooks/restore-from-encrypted-backup.md new file mode 100644 index 0000000..00e852e --- /dev/null +++ b/docs/runbooks/restore-from-encrypted-backup.md @@ -0,0 +1,126 @@ +# Restore from encrypted backup + +Last updated: 2026-05-28 + +Use this runbook to restore certificate state from a hardened `.lyttlebackup` artifact. + +## Scope + +This runbook covers the shipped restore flow: + +- encrypted backup artifact +- signed manifest verification +- server-side restore + +It does **not** cover legacy plaintext `.zip` backup restore as a supported recovery path. + +## Required access + +- `security-admin` + +## Before you start + +1. Confirm you are restoring the correct environment. +2. Record the incident/change ID. +3. Prefer restoring from the newest verified `.lyttlebackup` artifact. +4. If you only need one certificate and not a full restore, assess whether direct import or a normal certificate re-issuance is safer. + +## Step 1: Inspect available backups + +```bash +curl -s http://localhost:3000/certificates/backup \ + -H "Authorization: Bearer $SECURITY_ADMIN_TOKEN" | jq +``` + +Choose the artifact you intend to restore. + +## Step 2: Verify the backup before restore + +Always verify before restore. + +```bash +curl -s -X POST \ + http://localhost:3000/certificates/backup/<filename>.lyttlebackup/verify \ + -H "Authorization: Bearer $SECURITY_ADMIN_TOKEN" | jq +``` + +Do not continue if verification reports signature, checksum, or decryption failures. + +## Step 3: Run the restore + +```bash +curl -s -X POST \ + http://localhost:3000/certificates/backup/<filename>.lyttlebackup/restore \ + -H "Authorization: Bearer $SECURITY_ADMIN_TOKEN" | jq +``` + +Expected outcome: + +- the application validates the encrypted artifact server-side +- certificate rows are recreated from the verified payload +- the response identifies the restored records or reports why restore was rejected + +## Step 4: Validate the restored state + +After restore, confirm the cluster can see the expected certificate inventory. + +```bash +curl -s http://localhost:3000/certificates \ + -H "Authorization: Bearer $VIEWER_TOKEN" | jq + +curl -s http://localhost:3000/health/deep | jq + +curl -s http://localhost:3000/cluster/status \ + -H "Authorization: Bearer $VIEWER_TOKEN" | jq +``` + +If the restore was part of a larger recovery event, also inspect: + +```bash +curl -s http://localhost:3000/cluster/operations \ + -H "Authorization: Bearer $VIEWER_TOKEN" | jq +``` + +## Step 5: Reconcile runtime state if needed + +Restore rebuilds database-backed certificate state, but you may still need to drive the runtime to converge. + +Depending on the incident, the follow-up may be: + +- a certificate sync +- a cluster reload +- a certificate artifact activation or rollback + +Current operator-facing examples: + +```bash +curl -s -X POST http://localhost:3000/certificates/sync \ + -H "Authorization: Bearer $PLATFORM_ADMIN_TOKEN" | jq + +curl -s -X POST http://localhost:3000/cluster/reload \ + -H "Authorization: Bearer $OPERATOR_TOKEN" | jq +``` + +## If restore fails + +Check the following first: + +- the artifact really ends with `.lyttlebackup` +- `BACKUP_ENCRYPTION_KEY` and `BACKUP_ENCRYPTION_KEY_VERSION` match the artifact generation era +- the backup file exists and is readable on the node handling the request +- the artifact verification endpoint reports success before restore is attempted + +If verification fails, stop and investigate the artifact or key mismatch instead of retrying restore blindly. + +## Important current limitations + +- older plaintext `.zip` backups remain legacy artifacts and are not trusted by the hardened verify/restore flow +- restore focuses on certificate-state recovery; it does not fully reconstruct all historical order/artifact workflow detail from older backups +- raw PEM export remains a separate break-glass workflow and should not be used as the default restore path + +## Related procedures + +- break-glass export → `docs/runbooks/security-break-glass.md` +- config rollback after restore → `docs/runbooks/nginx-config-rollback.md` +- credential rotation after a suspected key exposure → `docs/runbooks/credential-rotation.md` + diff --git a/docs/runbooks/security-break-glass.md b/docs/runbooks/security-break-glass.md new file mode 100644 index 0000000..cea8b58 --- /dev/null +++ b/docs/runbooks/security-break-glass.md @@ -0,0 +1,124 @@ +# Security break-glass runbook + +Last updated: 2026-05-28 + +## Purpose + +This runbook documents the emergency-only flows that intentionally bypass the safer default paths. + +Current break-glass scope: + +- `GET /certificates/backup/export/:id` — returns decrypted certificate PEM + private key material +- manual legacy `API_KEY` overlap rotation while bearer-token migration is still in progress + +These actions are high-risk because they can expose secrets outside the normal encrypted-at-rest and encrypted-backup workflows. + +--- + +## Before you start + +1. confirm that a safer workflow will not solve the problem: + - prefer `POST /certificates/backup/:filename/verify` + - prefer `POST /certificates/backup/:filename/restore` + - prefer bearer tokens over long-lived API keys +2. make sure the caller has the right role: + - decrypted certificate export requires `platform-admin` + - security review endpoints require at least `security-admin` +3. record the business justification, ticket/incident number, and expected rollback/cleanup window +4. notify the on-call/security owner if decrypted key material will leave the runtime boundary + +--- + +## Break-glass certificate export + +### When to use it + +Use raw certificate export only when encrypted backup restore or normal certificate activation cannot meet the recovery need, for example: + +- emergency restoration of a third-party device that requires direct PEM upload +- immediate temporary handoff of certificate material to a separate recovery environment +- forensic comparison of a certificate/key pair during an incident + +### Procedure + +1. authenticate as a `platform-admin` +2. fetch current posture first: + - `GET /security/status` + - `GET /security/access-review` +3. export the specific certificate only: + - `GET /certificates/backup/export/:id` +4. store the output in the smallest possible trusted boundary +5. complete the recovery task +6. securely delete any temporary exported files from operator workstations and jump hosts +7. review `GET /audit` for the export event and confirm the correlation ID, actor, and target certificate match the incident record +8. if the private key may have been exposed beyond the intended trust boundary, revoke/replace the certificate and rotate any related credentials + +### Post-action checklist + +- [ ] exported files deleted from temporary locations +- [ ] audit event reviewed +- [ ] incident/ticket updated with correlation ID +- [ ] follow-up certificate rotation scheduled if exposure is uncertain + +--- + +## Legacy API-key rotation during bearer-token migration + +### Goal + +Minimize overlap time while keeping the admin surface reachable during rollout. + +### Procedure + +1. authenticate as `platform-admin` +2. call `POST /security/rotate/api-key` with: + - `nextApiKey` + - optional `retireApiKeyId` + - optional `issueBridgeToken: true` +3. review the returned fingerprint, validation result, and recommended steps +4. update the injected `API_KEY` secret/config outside the app so it contains both old and new keys during the overlap window +5. redeploy all nodes +6. validate the new key against: + - `GET /auth/status` + - `GET /security/status` +7. migrate any remaining automation to bearer tokens where possible +8. remove retired keys from the injected secret/config and redeploy again +9. review `GET /audit` for the rotation-planning action and any unexpected auth failures during the overlap window + +### Important constraints + +- the application does **not** hot-reload API keys +- the planning endpoint does **not** store the new key +- a bearer-token bridge depends on `AUTH_JWT_SECRET` being configured + +--- + +## Internal certificate rotation status + +`POST /security/rotate/internal-certs` exists as a forward-looking contract only. + +Current state: + +- inter-node traffic is still authenticated HTTP +- mTLS node certificates are not active yet +- the endpoint returns the prerequisites for the future PKI rotation workflow instead of rotating anything today + +Use the endpoint to confirm the current gap and to preserve a stable admin contract for later mTLS work. + +--- + +## Related endpoints + +- `GET /security/status` +- `GET /security/policy` +- `GET /security/secrets/health` +- `GET /security/access-review` +- `GET /audit` +- `POST /certificates/backup/:filename/verify` +- `POST /certificates/backup/:filename/restore` + +## Related runbooks + +- `docs/runbooks/credential-rotation.md` +- `docs/runbooks/restore-from-encrypted-backup.md` + diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..3d158af --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,34 @@ +const tsParser = require('@typescript-eslint/parser'); +const tsPlugin = require('@typescript-eslint/eslint-plugin'); +const prettierPlugin = require('eslint-plugin-prettier'); +const eslintConfigPrettier = require('eslint-config-prettier'); + +module.exports = [ + { + ignores: ['dist/**', 'coverage/**', 'node_modules/**', '.eslintrc.js'], + }, + { + files: ['src/**/*.ts', 'test/**/*.ts'], + languageOptions: { + parser: tsParser, + parserOptions: { + project: ['tsconfig.json', 'tsconfig.test.json'], + tsconfigRootDir: __dirname, + sourceType: 'module', + ecmaVersion: 'latest', + }, + }, + plugins: { + '@typescript-eslint': tsPlugin, + prettier: prettierPlugin, + }, + rules: { + ...tsPlugin.configs.recommended.rules, + ...eslintConfigPrettier.rules, + 'prettier/prettier': 'error', + '@typescript-eslint/explicit-function-return-type': 'off', + '@typescript-eslint/explicit-module-boundary-types': 'off', + '@typescript-eslint/no-explicit-any': 'off', + }, + }, +]; diff --git a/healthcheck.sh b/healthcheck.sh index 834356a..677ebf8 100644 --- a/healthcheck.sh +++ b/healthcheck.sh @@ -1,15 +1,19 @@ #!/bin/bash -# Simplified health check - only check if app responds -# Check if the /ready endpoint responds with a 200 status -# Use --connect-timeout for faster failure and --max-time for overall timeout -HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --connect-timeout 2 --max-time 4 http://127.0.0.1:3000/ready 2>/dev/null) +RESPONSE=$(curl -sS --connect-timeout 2 --max-time 4 -w "\n%{http_code}" \ + http://127.0.0.1:3000/health/ready 2>/dev/null) -if [ "$HTTP_CODE" = "200" ]; then - echo "[HEALTH] OK: Application is ready (HTTP $HTTP_CODE)" +HTTP_CODE=$(printf '%s\n' "$RESPONSE" | tail -n 1) +BODY=$(printf '%s\n' "$RESPONSE" | sed '$d') + +if [ "$HTTP_CODE" = "200" ] && printf '%s' "$BODY" | grep -Eq '"status"[[:space:]]*:[[:space:]]*"ok"'; then + echo "[HEALTH] OK: readiness probe passed" exit 0 -else - echo "[HEALTH] FAIL: Application not responding properly (HTTP $HTTP_CODE)" >&2 - exit 1 fi +echo "[HEALTH] FAIL: readiness probe failed (HTTP $HTTP_CODE)" >&2 +if [ -n "$BODY" ]; then + printf '%s\n' "$BODY" >&2 +fi +exit 1 + diff --git a/nginx/conf.d/default.conf b/nginx/conf.d/default.conf index cb534aa..02ea1a3 100644 --- a/nginx/conf.d/default.conf +++ b/nginx/conf.d/default.conf @@ -22,7 +22,7 @@ server { error_page 400 401 402 403 404 405 408 409 410 411 412 413 414 415 416 417 418 421 422 423 424 425 426 428 429 431 444 451 494 495 496 497 =200 /index.html; # Ensure 5xx render the custom error page (and are not swallowed by SPA) - error_page 500 501 502 503 504 505 506 507 508 510 511 /errors/50x.html; + error_page 500 501 502 503 504 505 506 507 508 510 511 /errors/5xx.html; # Allow direct access to the error asset location ^~ /errors/ { diff --git a/nginx/nginx.conf b/nginx/nginx.conf index f3be667..54f5127 100644 --- a/nginx/nginx.conf +++ b/nginx/nginx.conf @@ -120,6 +120,6 @@ http { # Root for serving the error asset and static files root /etc/nginx/html; - # Include all virtual host configs - include /etc/nginx/conf.d/*.conf; + # Include the currently activated virtual host release + include /etc/nginx/runtime/current/conf.d/*.conf; } diff --git a/package-lock.json b/package-lock.json index e5b3631..6e69f35 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,63 +9,65 @@ "version": "0.0.1", "license": "UNLICENSED", "dependencies": { - "@nestjs/common": "^11.1.9", - "@nestjs/core": "^11.1.9", - "@nestjs/mapped-types": "^2.1.0", - "@nestjs/platform-express": "^11.1.9", - "@nestjs/schedule": "^6.0.1", - "@nestjs/terminus": "^11.0.0", - "@nestjs/throttler": "^6.4.0", - "@prisma/client": "^6.19.0", - "@promster/express": "^15.4.2", + "@nestjs/common": "^11.1.23", + "@nestjs/core": "^11.1.23", + "@nestjs/mapped-types": "^2.1.1", + "@nestjs/platform-express": "^11.1.23", + "@nestjs/schedule": "^6.1.3", + "@nestjs/terminus": "^11.1.1", + "@nestjs/throttler": "^6.5.0", + "@prisma/client": "^7.8.0", + "@promster/express": "^15.5.1", "@types/archiver": "^7.0.0", - "@types/nodemailer": "^7.0.4", - "archiver": "^7.0.1", + "@types/nodemailer": "^8.0.0", + "acme-client": "^5.4.0", + "archiver": "^8.0.0", "class-transformer": "^0.5.1", - "class-validator": "^0.14.2", - "date-fns": "^4.1.0", - "nodemailer": "^7.0.10", - "prisma": "^6.19.0", + "class-validator": "^0.15.1", + "date-fns": "^4.3.0", + "nodemailer": "^8.0.8", + "prisma": "^7.8.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.2" }, "devDependencies": { - "@nestjs/cli": "^11.0.12", - "@nestjs/schematics": "^11.0.9", - "@nestjs/testing": "^11.1.9", - "@types/express": "^5.0.5", + "@nestjs/cli": "^11.0.21", + "@nestjs/schematics": "^11.1.0", + "@nestjs/testing": "^11.1.23", + "@types/express": "^5.0.6", "@types/jest": "^30.0.0", - "@types/node": "^24.10.1", - "@types/supertest": "^6.0.3", - "@typescript-eslint/eslint-plugin": "^8.47.0", - "@typescript-eslint/parser": "^8.47.0", - "eslint": "^9.39.1", + "@types/node": "^25.9.1", + "@types/supertest": "^7.2.0", + "@typescript-eslint/eslint-plugin": "^8.59.4", + "@typescript-eslint/parser": "^8.59.4", + "eslint": "^10.4.0", "eslint-config-prettier": "^10.1.8", - "eslint-plugin-prettier": "^5.5.4", - "prettier": "^3.6.2", + "eslint-plugin-prettier": "^5.5.5", + "prettier": "^3.8.3", "source-map-support": "^0.5.21", - "supertest": "^7.1.4", - "ts-jest": "^29.4.5", - "ts-loader": "^9.5.4", + "supertest": "^7.2.2", + "ts-jest": "^29.4.11", + "ts-loader": "^9.5.7", "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", - "typescript": "^5.9.2" + "typescript": "^6.0.3" }, "engines": { - "node": "24.11.1" + "node": "24.16.0", + "npm": "11.15.0" } }, "node_modules/@angular-devkit/core": { - "version": "19.2.19", - "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-19.2.19.tgz", - "integrity": "sha512-JbLL+4IMLMBgjLZlnPG4lYDfz4zGrJ/s6Aoon321NJKuw1Kb1k5KpFu9dUY0BqLIe8xPQ2UJBpI+xXdK5MXMHQ==", + "version": "19.2.24", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-19.2.24.tgz", + "integrity": "sha512-Kd49warf6U/EyWe5BszF/eebN3zQ3bk7tgfEljAw8q/rX95UUtriJubWvp6pgzHfzBA4jwq8f+QiNZB8eBEXPA==", "dev": true, "license": "MIT", "dependencies": { - "ajv": "8.17.1", + "ajv": "8.18.0", "ajv-formats": "3.0.1", "jsonc-parser": "3.3.1", - "picomatch": "4.0.2", + "picomatch": "4.0.4", "rxjs": "7.8.1", "source-map": "0.7.4" }, @@ -83,6 +85,23 @@ } } }, + "node_modules/@angular-devkit/core/node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/@angular-devkit/core/node_modules/rxjs": { "version": "7.8.1", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", @@ -94,13 +113,13 @@ } }, "node_modules/@angular-devkit/schematics": { - "version": "19.2.19", - "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-19.2.19.tgz", - "integrity": "sha512-J4Jarr0SohdrHcb40gTL4wGPCQ952IMWF1G/MSAQfBAPvA9ZKApYhpxcY7PmehVePve+ujpus1dGsJ7dPxz8Kg==", + "version": "19.2.24", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-19.2.24.tgz", + "integrity": "sha512-lnw+ZM1Io+cJAkReC0NPDjqObL8NtKzKIkdgEEKC8CUmkhurYhedbicN8Y8NYHgG1uLd2GozW3+/QqPRZaN+Lw==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "19.2.19", + "@angular-devkit/core": "19.2.24", "jsonc-parser": "3.3.1", "magic-string": "0.30.17", "ora": "5.4.1", @@ -113,14 +132,14 @@ } }, "node_modules/@angular-devkit/schematics-cli": { - "version": "19.2.19", - "resolved": "https://registry.npmjs.org/@angular-devkit/schematics-cli/-/schematics-cli-19.2.19.tgz", - "integrity": "sha512-7q9UY6HK6sccL9F3cqGRUwKhM7b/XfD2YcVaZ2WD7VMaRlRm85v6mRjSrfKIAwxcQU0UK27kMc79NIIqaHjzxA==", + "version": "19.2.24", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics-cli/-/schematics-cli-19.2.24.tgz", + "integrity": "sha512-bsStZQG67J1HBqTmWxtIcobvgrn32L4UOdL7hGyOru5VxDWPNA8pRnDYavT3hnJeBkJYPoQIw8u7Dm0ecoQprw==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "19.2.19", - "@angular-devkit/schematics": "19.2.19", + "@angular-devkit/core": "19.2.24", + "@angular-devkit/schematics": "19.2.24", "@inquirer/prompts": "7.3.2", "ansi-colors": "4.1.3", "symbol-observable": "4.0.0", @@ -175,718 +194,11 @@ "tslib": "^2.1.0" } }, - "node_modules/@aws-crypto/sha256-browser": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", - "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/sha256-js": "^5.2.0", - "@aws-crypto/supports-web-crypto": "^5.2.0", - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "@aws-sdk/util-locate-window": "^3.0.0", - "@smithy/util-utf8": "^2.0.0", - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/is-array-buffer": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", - "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-buffer-from": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", - "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/is-array-buffer": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-utf8": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", - "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/util-buffer-from": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@aws-crypto/sha256-js": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", - "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-crypto/supports-web-crypto": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", - "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-crypto/util": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", - "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.222.0", - "@smithy/util-utf8": "^2.0.0", - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-crypto/util/node_modules/@smithy/is-array-buffer": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", - "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@aws-crypto/util/node_modules/@smithy/util-buffer-from": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", - "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/is-array-buffer": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@aws-crypto/util/node_modules/@smithy/util-utf8": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", - "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/util-buffer-from": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@aws-sdk/client-sesv2": { - "version": "3.938.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-sesv2/-/client-sesv2-3.938.0.tgz", - "integrity": "sha512-GW07FQuZkW5ASm0WP+CWLetcortqup9l3+p1OlvuUN3rLBIzlWRqYd5Nf2GTS72sPbaNowE3dYJXCtwu1IlLuQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "3.936.0", - "@aws-sdk/credential-provider-node": "3.936.0", - "@aws-sdk/middleware-host-header": "3.936.0", - "@aws-sdk/middleware-logger": "3.936.0", - "@aws-sdk/middleware-recursion-detection": "3.936.0", - "@aws-sdk/middleware-user-agent": "3.936.0", - "@aws-sdk/region-config-resolver": "3.936.0", - "@aws-sdk/signature-v4-multi-region": "3.936.0", - "@aws-sdk/types": "3.936.0", - "@aws-sdk/util-endpoints": "3.936.0", - "@aws-sdk/util-user-agent-browser": "3.936.0", - "@aws-sdk/util-user-agent-node": "3.936.0", - "@smithy/config-resolver": "^4.4.3", - "@smithy/core": "^3.18.5", - "@smithy/fetch-http-handler": "^5.3.6", - "@smithy/hash-node": "^4.2.5", - "@smithy/invalid-dependency": "^4.2.5", - "@smithy/middleware-content-length": "^4.2.5", - "@smithy/middleware-endpoint": "^4.3.12", - "@smithy/middleware-retry": "^4.4.12", - "@smithy/middleware-serde": "^4.2.6", - "@smithy/middleware-stack": "^4.2.5", - "@smithy/node-config-provider": "^4.3.5", - "@smithy/node-http-handler": "^4.4.5", - "@smithy/protocol-http": "^5.3.5", - "@smithy/smithy-client": "^4.9.8", - "@smithy/types": "^4.9.0", - "@smithy/url-parser": "^4.2.5", - "@smithy/util-base64": "^4.3.0", - "@smithy/util-body-length-browser": "^4.2.0", - "@smithy/util-body-length-node": "^4.2.1", - "@smithy/util-defaults-mode-browser": "^4.3.11", - "@smithy/util-defaults-mode-node": "^4.2.14", - "@smithy/util-endpoints": "^3.2.5", - "@smithy/util-middleware": "^4.2.5", - "@smithy/util-retry": "^4.2.5", - "@smithy/util-utf8": "^4.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/client-sso": { - "version": "3.936.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.936.0.tgz", - "integrity": "sha512-0G73S2cDqYwJVvqL08eakj79MZG2QRaB56Ul8/Ps9oQxllr7DMI1IQ/N3j3xjxgpq/U36pkoFZ8aK1n7Sbr3IQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "3.936.0", - "@aws-sdk/middleware-host-header": "3.936.0", - "@aws-sdk/middleware-logger": "3.936.0", - "@aws-sdk/middleware-recursion-detection": "3.936.0", - "@aws-sdk/middleware-user-agent": "3.936.0", - "@aws-sdk/region-config-resolver": "3.936.0", - "@aws-sdk/types": "3.936.0", - "@aws-sdk/util-endpoints": "3.936.0", - "@aws-sdk/util-user-agent-browser": "3.936.0", - "@aws-sdk/util-user-agent-node": "3.936.0", - "@smithy/config-resolver": "^4.4.3", - "@smithy/core": "^3.18.5", - "@smithy/fetch-http-handler": "^5.3.6", - "@smithy/hash-node": "^4.2.5", - "@smithy/invalid-dependency": "^4.2.5", - "@smithy/middleware-content-length": "^4.2.5", - "@smithy/middleware-endpoint": "^4.3.12", - "@smithy/middleware-retry": "^4.4.12", - "@smithy/middleware-serde": "^4.2.6", - "@smithy/middleware-stack": "^4.2.5", - "@smithy/node-config-provider": "^4.3.5", - "@smithy/node-http-handler": "^4.4.5", - "@smithy/protocol-http": "^5.3.5", - "@smithy/smithy-client": "^4.9.8", - "@smithy/types": "^4.9.0", - "@smithy/url-parser": "^4.2.5", - "@smithy/util-base64": "^4.3.0", - "@smithy/util-body-length-browser": "^4.2.0", - "@smithy/util-body-length-node": "^4.2.1", - "@smithy/util-defaults-mode-browser": "^4.3.11", - "@smithy/util-defaults-mode-node": "^4.2.14", - "@smithy/util-endpoints": "^3.2.5", - "@smithy/util-middleware": "^4.2.5", - "@smithy/util-retry": "^4.2.5", - "@smithy/util-utf8": "^4.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/core": { - "version": "3.936.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.936.0.tgz", - "integrity": "sha512-eGJ2ySUMvgtOziHhDRDLCrj473RJoL4J1vPjVM3NrKC/fF3/LoHjkut8AAnKmrW6a2uTzNKubigw8dEnpmpERw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "3.936.0", - "@aws-sdk/xml-builder": "3.930.0", - "@smithy/core": "^3.18.5", - "@smithy/node-config-provider": "^4.3.5", - "@smithy/property-provider": "^4.2.5", - "@smithy/protocol-http": "^5.3.5", - "@smithy/signature-v4": "^5.3.5", - "@smithy/smithy-client": "^4.9.8", - "@smithy/types": "^4.9.0", - "@smithy/util-base64": "^4.3.0", - "@smithy/util-middleware": "^4.2.5", - "@smithy/util-utf8": "^4.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-env": { - "version": "3.936.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.936.0.tgz", - "integrity": "sha512-dKajFuaugEA5i9gCKzOaVy9uTeZcApE+7Z5wdcZ6j40523fY1a56khDAUYkCfwqa7sHci4ccmxBkAo+fW1RChA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "3.936.0", - "@aws-sdk/types": "3.936.0", - "@smithy/property-provider": "^4.2.5", - "@smithy/types": "^4.9.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.936.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.936.0.tgz", - "integrity": "sha512-5FguODLXG1tWx/x8fBxH+GVrk7Hey2LbXV5h9SFzYCx/2h50URBm0+9hndg0Rd23+xzYe14F6SI9HA9c1sPnjg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "3.936.0", - "@aws-sdk/types": "3.936.0", - "@smithy/fetch-http-handler": "^5.3.6", - "@smithy/node-http-handler": "^4.4.5", - "@smithy/property-provider": "^4.2.5", - "@smithy/protocol-http": "^5.3.5", - "@smithy/smithy-client": "^4.9.8", - "@smithy/types": "^4.9.0", - "@smithy/util-stream": "^4.5.6", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.936.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.936.0.tgz", - "integrity": "sha512-TbUv56ERQQujoHcLMcfL0Q6bVZfYF83gu/TjHkVkdSlHPOIKaG/mhE2XZSQzXv1cud6LlgeBbfzVAxJ+HPpffg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "3.936.0", - "@aws-sdk/credential-provider-env": "3.936.0", - "@aws-sdk/credential-provider-http": "3.936.0", - "@aws-sdk/credential-provider-login": "3.936.0", - "@aws-sdk/credential-provider-process": "3.936.0", - "@aws-sdk/credential-provider-sso": "3.936.0", - "@aws-sdk/credential-provider-web-identity": "3.936.0", - "@aws-sdk/nested-clients": "3.936.0", - "@aws-sdk/types": "3.936.0", - "@smithy/credential-provider-imds": "^4.2.5", - "@smithy/property-provider": "^4.2.5", - "@smithy/shared-ini-file-loader": "^4.4.0", - "@smithy/types": "^4.9.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-login": { - "version": "3.936.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.936.0.tgz", - "integrity": "sha512-8DVrdRqPyUU66gfV7VZNToh56ZuO5D6agWrkLQE/xbLJOm2RbeRgh6buz7CqV8ipRd6m+zCl9mM4F3osQLZn8Q==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "3.936.0", - "@aws-sdk/nested-clients": "3.936.0", - "@aws-sdk/types": "3.936.0", - "@smithy/property-provider": "^4.2.5", - "@smithy/protocol-http": "^5.3.5", - "@smithy/shared-ini-file-loader": "^4.4.0", - "@smithy/types": "^4.9.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.936.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.936.0.tgz", - "integrity": "sha512-rk/2PCtxX9xDsQW8p5Yjoca3StqmQcSfkmD7nQ61AqAHL1YgpSQWqHE+HjfGGiHDYKG7PvE33Ku2GyA7lEIJAw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/credential-provider-env": "3.936.0", - "@aws-sdk/credential-provider-http": "3.936.0", - "@aws-sdk/credential-provider-ini": "3.936.0", - "@aws-sdk/credential-provider-process": "3.936.0", - "@aws-sdk/credential-provider-sso": "3.936.0", - "@aws-sdk/credential-provider-web-identity": "3.936.0", - "@aws-sdk/types": "3.936.0", - "@smithy/credential-provider-imds": "^4.2.5", - "@smithy/property-provider": "^4.2.5", - "@smithy/shared-ini-file-loader": "^4.4.0", - "@smithy/types": "^4.9.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.936.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.936.0.tgz", - "integrity": "sha512-GpA4AcHb96KQK2PSPUyvChvrsEKiLhQ5NWjeef2IZ3Jc8JoosiedYqp6yhZR+S8cTysuvx56WyJIJc8y8OTrLA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "3.936.0", - "@aws-sdk/types": "3.936.0", - "@smithy/property-provider": "^4.2.5", - "@smithy/shared-ini-file-loader": "^4.4.0", - "@smithy/types": "^4.9.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.936.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.936.0.tgz", - "integrity": "sha512-wHlEAJJvtnSyxTfNhN98JcU4taA1ED2JvuI2eePgawqBwS/Tzi0mhED1lvNIaWOkjfLd+nHALwszGrtJwEq4yQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/client-sso": "3.936.0", - "@aws-sdk/core": "3.936.0", - "@aws-sdk/token-providers": "3.936.0", - "@aws-sdk/types": "3.936.0", - "@smithy/property-provider": "^4.2.5", - "@smithy/shared-ini-file-loader": "^4.4.0", - "@smithy/types": "^4.9.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.936.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.936.0.tgz", - "integrity": "sha512-v3qHAuoODkoRXsAF4RG+ZVO6q2P9yYBT4GMpMEfU9wXVNn7AIfwZgTwzSUfnjNiGva5BKleWVpRpJ9DeuLFbUg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "3.936.0", - "@aws-sdk/nested-clients": "3.936.0", - "@aws-sdk/types": "3.936.0", - "@smithy/property-provider": "^4.2.5", - "@smithy/shared-ini-file-loader": "^4.4.0", - "@smithy/types": "^4.9.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/middleware-host-header": { - "version": "3.936.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.936.0.tgz", - "integrity": "sha512-tAaObaAnsP1XnLGndfkGWFuzrJYuk9W0b/nLvol66t8FZExIAf/WdkT2NNAWOYxljVs++oHnyHBCxIlaHrzSiw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "3.936.0", - "@smithy/protocol-http": "^5.3.5", - "@smithy/types": "^4.9.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/middleware-logger": { - "version": "3.936.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.936.0.tgz", - "integrity": "sha512-aPSJ12d3a3Ea5nyEnLbijCaaYJT2QjQ9iW+zGh5QcZYXmOGWbKVyPSxmVOboZQG+c1M8t6d2O7tqrwzIq8L8qw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "3.936.0", - "@smithy/types": "^4.9.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/middleware-recursion-detection": { - "version": "3.936.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.936.0.tgz", - "integrity": "sha512-l4aGbHpXM45YNgXggIux1HgsCVAvvBoqHPkqLnqMl9QVapfuSTjJHfDYDsx1Xxct6/m7qSMUzanBALhiaGO2fA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "3.936.0", - "@aws/lambda-invoke-store": "^0.2.0", - "@smithy/protocol-http": "^5.3.5", - "@smithy/types": "^4.9.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/middleware-sdk-s3": { - "version": "3.936.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.936.0.tgz", - "integrity": "sha512-UQs/pVq4cOygsnKON0pOdSKIWkfgY0dzq4h+fR+xHi/Ng3XzxPJhWeAE6tDsKrcyQc1X8UdSbS70XkfGYr5hng==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "3.936.0", - "@aws-sdk/types": "3.936.0", - "@aws-sdk/util-arn-parser": "3.893.0", - "@smithy/core": "^3.18.5", - "@smithy/node-config-provider": "^4.3.5", - "@smithy/protocol-http": "^5.3.5", - "@smithy/signature-v4": "^5.3.5", - "@smithy/smithy-client": "^4.9.8", - "@smithy/types": "^4.9.0", - "@smithy/util-config-provider": "^4.2.0", - "@smithy/util-middleware": "^4.2.5", - "@smithy/util-stream": "^4.5.6", - "@smithy/util-utf8": "^4.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/middleware-user-agent": { - "version": "3.936.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.936.0.tgz", - "integrity": "sha512-YB40IPa7K3iaYX0lSnV9easDOLPLh+fJyUDF3BH8doX4i1AOSsYn86L4lVldmOaSX+DwiaqKHpvk4wPBdcIPWw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "3.936.0", - "@aws-sdk/types": "3.936.0", - "@aws-sdk/util-endpoints": "3.936.0", - "@smithy/core": "^3.18.5", - "@smithy/protocol-http": "^5.3.5", - "@smithy/types": "^4.9.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/nested-clients": { - "version": "3.936.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.936.0.tgz", - "integrity": "sha512-eyj2tz1XmDSLSZQ5xnB7cLTVKkSJnYAEoNDSUNhzWPxrBDYeJzIbatecOKceKCU8NBf8gWWZCK/CSY0mDxMO0A==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "3.936.0", - "@aws-sdk/middleware-host-header": "3.936.0", - "@aws-sdk/middleware-logger": "3.936.0", - "@aws-sdk/middleware-recursion-detection": "3.936.0", - "@aws-sdk/middleware-user-agent": "3.936.0", - "@aws-sdk/region-config-resolver": "3.936.0", - "@aws-sdk/types": "3.936.0", - "@aws-sdk/util-endpoints": "3.936.0", - "@aws-sdk/util-user-agent-browser": "3.936.0", - "@aws-sdk/util-user-agent-node": "3.936.0", - "@smithy/config-resolver": "^4.4.3", - "@smithy/core": "^3.18.5", - "@smithy/fetch-http-handler": "^5.3.6", - "@smithy/hash-node": "^4.2.5", - "@smithy/invalid-dependency": "^4.2.5", - "@smithy/middleware-content-length": "^4.2.5", - "@smithy/middleware-endpoint": "^4.3.12", - "@smithy/middleware-retry": "^4.4.12", - "@smithy/middleware-serde": "^4.2.6", - "@smithy/middleware-stack": "^4.2.5", - "@smithy/node-config-provider": "^4.3.5", - "@smithy/node-http-handler": "^4.4.5", - "@smithy/protocol-http": "^5.3.5", - "@smithy/smithy-client": "^4.9.8", - "@smithy/types": "^4.9.0", - "@smithy/url-parser": "^4.2.5", - "@smithy/util-base64": "^4.3.0", - "@smithy/util-body-length-browser": "^4.2.0", - "@smithy/util-body-length-node": "^4.2.1", - "@smithy/util-defaults-mode-browser": "^4.3.11", - "@smithy/util-defaults-mode-node": "^4.2.14", - "@smithy/util-endpoints": "^3.2.5", - "@smithy/util-middleware": "^4.2.5", - "@smithy/util-retry": "^4.2.5", - "@smithy/util-utf8": "^4.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/region-config-resolver": { - "version": "3.936.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.936.0.tgz", - "integrity": "sha512-wOKhzzWsshXGduxO4pqSiNyL9oUtk4BEvjWm9aaq6Hmfdoydq6v6t0rAGHWPjFwy9z2haovGRi3C8IxdMB4muw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "3.936.0", - "@smithy/config-resolver": "^4.4.3", - "@smithy/node-config-provider": "^4.3.5", - "@smithy/types": "^4.9.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/signature-v4-multi-region": { - "version": "3.936.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.936.0.tgz", - "integrity": "sha512-8qS0GFUqkmwO7JZ0P8tdluBmt1UTfYUah8qJXGzNh9n1Pcb0AIeT117cCSiCUtwk+gDbJvd4hhRIhJCNr5wgjg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/middleware-sdk-s3": "3.936.0", - "@aws-sdk/types": "3.936.0", - "@smithy/protocol-http": "^5.3.5", - "@smithy/signature-v4": "^5.3.5", - "@smithy/types": "^4.9.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/token-providers": { - "version": "3.936.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.936.0.tgz", - "integrity": "sha512-vvw8+VXk0I+IsoxZw0mX9TMJawUJvEsg3EF7zcCSetwhNPAU8Xmlhv7E/sN/FgSmm7b7DsqKoW6rVtQiCs1PWQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "3.936.0", - "@aws-sdk/nested-clients": "3.936.0", - "@aws-sdk/types": "3.936.0", - "@smithy/property-provider": "^4.2.5", - "@smithy/shared-ini-file-loader": "^4.4.0", - "@smithy/types": "^4.9.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/types": { - "version": "3.936.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.936.0.tgz", - "integrity": "sha512-uz0/VlMd2pP5MepdrHizd+T+OKfyK4r3OA9JI+L/lPKg0YFQosdJNCKisr6o70E3dh8iMpFYxF1UN/4uZsyARg==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.9.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/util-arn-parser": { - "version": "3.893.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-arn-parser/-/util-arn-parser-3.893.0.tgz", - "integrity": "sha512-u8H4f2Zsi19DGnwj5FSZzDMhytYF/bCh37vAtBsn3cNDL3YG578X5oc+wSX54pM3tOxS+NY7tvOAo52SW7koUA==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/util-endpoints": { - "version": "3.936.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.936.0.tgz", - "integrity": "sha512-0Zx3Ntdpu+z9Wlm7JKUBOzS9EunwKAb4KdGUQQxDqh5Lc3ta5uBoub+FgmVuzwnmBu9U1Os8UuwVTH0Lgu+P5w==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "3.936.0", - "@smithy/types": "^4.9.0", - "@smithy/url-parser": "^4.2.5", - "@smithy/util-endpoints": "^3.2.5", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/util-locate-window": { - "version": "3.893.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.893.0.tgz", - "integrity": "sha512-T89pFfgat6c8nMmpI8eKjBcDcgJq36+m9oiXbcUzeU55MP9ZuGgBomGjGnHaEyF36jenW9gmg3NfZDm0AO2XPg==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/util-user-agent-browser": { - "version": "3.936.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.936.0.tgz", - "integrity": "sha512-eZ/XF6NxMtu+iCma58GRNRxSq4lHo6zHQLOZRIeL/ghqYJirqHdenMOwrzPettj60KWlv827RVebP9oNVrwZbw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "3.936.0", - "@smithy/types": "^4.9.0", - "bowser": "^2.11.0", - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-sdk/util-user-agent-node": { - "version": "3.936.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.936.0.tgz", - "integrity": "sha512-XOEc7PF9Op00pWV2AYCGDSu5iHgYjIO53Py2VUQTIvP7SRCaCsXmA33mjBvC2Ms6FhSyWNa4aK4naUGIz0hQcw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/middleware-user-agent": "3.936.0", - "@aws-sdk/types": "3.936.0", - "@smithy/node-config-provider": "^4.3.5", - "@smithy/types": "^4.9.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "aws-crt": ">=1.0.0" - }, - "peerDependenciesMeta": { - "aws-crt": { - "optional": true - } - } - }, - "node_modules/@aws-sdk/xml-builder": { - "version": "3.930.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.930.0.tgz", - "integrity": "sha512-YIfkD17GocxdmlUVc3ia52QhcWuRIUJonbF8A2CYfcWNV3HzvAqpcPeC0bYUhkK+8e8YO1ARnLKZQE0TlwzorA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.9.0", - "fast-xml-parser": "5.2.5", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws/lambda-invoke-store": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.1.tgz", - "integrity": "sha512-sIyFcoPZkTtNu9xFeEoynMef3bPJIAbOfUh+ueYcfhVl6xm2VRtMcMclSxmZCMnHHd4hlYKJeq/aggmBEWynww==", - "license": "Apache-2.0", - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", @@ -903,6 +215,7 @@ "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=6.9.0" } @@ -945,6 +258,7 @@ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, "license": "ISC", + "peer": true, "bin": { "semver": "bin/semver.js" } @@ -953,8 +267,9 @@ "version": "7.28.5", "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", - "devOptional": true, + "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/parser": "^7.28.5", "@babel/types": "^7.28.5", @@ -972,6 +287,7 @@ "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/compat-data": "^7.27.2", "@babel/helper-validator-option": "^7.27.1", @@ -989,6 +305,7 @@ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, "license": "ISC", + "peer": true, "bin": { "semver": "bin/semver.js" } @@ -997,8 +314,9 @@ "version": "7.28.0", "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", - "devOptional": true, + "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=6.9.0" } @@ -1009,6 +327,7 @@ "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" @@ -1023,6 +342,7 @@ "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-module-imports": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1", @@ -1041,6 +361,7 @@ "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=6.9.0" } @@ -1049,8 +370,9 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "devOptional": true, + "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=6.9.0" } @@ -1059,7 +381,7 @@ "version": "7.28.5", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -1071,6 +393,7 @@ "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=6.9.0" } @@ -1081,6 +404,7 @@ "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/template": "^7.27.2", "@babel/types": "^7.28.4" @@ -1093,8 +417,9 @@ "version": "7.28.5", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", - "devOptional": true, + "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/types": "^7.28.5" }, @@ -1111,6 +436,7 @@ "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, @@ -1124,6 +450,7 @@ "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, @@ -1137,6 +464,7 @@ "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.12.13" }, @@ -1150,6 +478,7 @@ "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.14.5" }, @@ -1166,6 +495,7 @@ "integrity": "sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, @@ -1182,6 +512,7 @@ "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.10.4" }, @@ -1195,6 +526,7 @@ "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, @@ -1208,6 +540,7 @@ "integrity": "sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, @@ -1224,6 +557,7 @@ "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.10.4" }, @@ -1237,6 +571,7 @@ "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, @@ -1250,6 +585,7 @@ "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.10.4" }, @@ -1263,6 +599,7 @@ "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, @@ -1276,6 +613,7 @@ "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, @@ -1289,6 +627,7 @@ "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, @@ -1302,6 +641,7 @@ "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.14.5" }, @@ -1318,6 +658,7 @@ "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.14.5" }, @@ -1334,6 +675,7 @@ "integrity": "sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, @@ -1348,8 +690,9 @@ "version": "7.27.2", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", - "devOptional": true, + "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/parser": "^7.27.2", @@ -1363,8 +706,9 @@ "version": "7.28.5", "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", - "devOptional": true, + "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -1382,8 +726,9 @@ "version": "7.28.5", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", - "devOptional": true, + "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" @@ -1397,7 +742,8 @@ "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@borewit/text-codec": { "version": "0.1.1", @@ -1409,6 +755,18 @@ "url": "https://github.com/sponsors/Borewit" } }, + "node_modules/@chainsafe/prometheus-gc-stats": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@chainsafe/prometheus-gc-stats/-/prometheus-gc-stats-1.0.2.tgz", + "integrity": "sha512-h3mFKduSX85XMVbOdWOYvx9jNq99jGcRVNyW5goGOqju1CsI+ZJLhu5z4zBb/G+ksL0R4uLVulu/mIMe7Y0rNg==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "prom-client": ">= 10" + } + }, "node_modules/@colors/colors": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", @@ -1444,6 +802,33 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@electric-sql/pglite": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.4.1.tgz", + "integrity": "sha512-mZ9NzzUSYPOCnxHH1oAHPRzoMFJHY472raDKwXl/+6oPbpdJ7g8LsCN4FSaIIfkiCKHhb3iF/Zqo3NYxaIhU7Q==", + "license": "Apache-2.0" + }, + "node_modules/@electric-sql/pglite-socket": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite-socket/-/pglite-socket-0.1.1.tgz", + "integrity": "sha512-p2hoXw3Z3LQHwTeikdZNsFBOvXGqKY2hk51BBw+8NKND8eoH+8LFOtW9Z8CQKmTJ2qqGYu82ipqiyFZOTTXNfw==", + "license": "Apache-2.0", + "bin": { + "pglite-server": "dist/scripts/server.js" + }, + "peerDependencies": { + "@electric-sql/pglite": "0.4.1" + } + }, + "node_modules/@electric-sql/pglite-tools": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite-tools/-/pglite-tools-0.3.1.tgz", + "integrity": "sha512-C+T3oivmy9bpQvSxVqXA1UDY8cB9Eb9vZHL9zxWwEUfDixbXv4G3r2LjoTdR33LD8aomR3O9ZXEO3XEwr/cUCA==", + "license": "Apache-2.0", + "peerDependencies": { + "@electric-sql/pglite": "0.4.1" + } + }, "node_modules/@emnapi/core": { "version": "1.7.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.7.1.tgz", @@ -1451,6 +836,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" @@ -1463,6 +849,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "tslib": "^2.4.0" } @@ -1474,14 +861,15 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "tslib": "^2.4.0" } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", - "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1508,187 +896,119 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.21.1", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", - "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/object-schema": "^2.1.7", + "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", - "minimatch": "^3.1.2" + "minimatch": "^10.2.4" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "node_modules/@eslint/config-array/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "node_modules/@eslint/config-array/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "dev": true, "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/@eslint/config-array/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/@eslint/config-helpers": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", - "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.17.0" - }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "18 || 20 || >=22" } }, - "node_modules/@eslint/core": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", - "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", "dependencies": { - "@types/json-schema": "^7.0.15" + "balanced-match": "^4.0.2" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "18 || 20 || >=22" } }, - "node_modules/@eslint/eslintrc": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", - "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "dev": true, - "license": "MIT", + "license": "BlueOak-1.0.0", "dependencies": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.1.2", - "strip-json-comments": "^3.1.1" + "brace-expansion": "^5.0.5" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "18 || 20 || >=22" }, "funding": { - "url": "https://opencollective.com/eslint" + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@eslint/eslintrc/node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "node_modules/@eslint/config-helpers": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", + "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" + "@eslint/core": "^1.2.1" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/@eslint/eslintrc/node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, - "license": "MIT", "engines": { - "node": ">= 4" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@eslint/eslintrc/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", "dev": true, - "license": "ISC", + "license": "Apache-2.0", "dependencies": { - "brace-expansion": "^1.1.7" + "@types/json-schema": "^7.0.15" }, "engines": { - "node": "*" - } - }, - "node_modules/@eslint/js": { - "version": "9.39.1", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.1.tgz", - "integrity": "sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/object-schema": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", - "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/plugin-kit": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", - "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.1.tgz", + "integrity": "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.17.0", + "@eslint/core": "^1.2.1", "levn": "^0.4.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.13", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.13.tgz", + "integrity": "sha512-TsQLe4i2gvoTtrHje625ngThGBySOgSK3Xo2XRYOdqGN1teR8+I7vchQC46uLJi8OF62YTYA3AhSpumtkhsaKQ==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" } }, "node_modules/@humanfs/core": { @@ -2093,34 +1413,13 @@ } } }, - "node_modules/@isaacs/balanced-match": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", - "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@isaacs/brace-expansion": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", - "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@isaacs/balanced-match": "^4.0.1" - }, - "engines": { - "node": "20 || >=22" - } - }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, "license": "ISC", + "peer": true, "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", @@ -2137,7 +1436,9 @@ "version": "6.2.3", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -2149,13 +1450,17 @@ "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "license": "MIT" + "dev": true, + "license": "MIT", + "peer": true }, "node_modules/@isaacs/cliui/node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, "license": "MIT", + "peer": true, "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", @@ -2172,7 +1477,9 @@ "version": "8.1.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", @@ -2191,6 +1498,7 @@ "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", "dev": true, "license": "ISC", + "peer": true, "dependencies": { "camelcase": "^5.3.1", "find-up": "^4.1.0", @@ -2208,6 +1516,7 @@ "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "sprintf-js": "~1.0.2" } @@ -2218,6 +1527,7 @@ "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=6" } @@ -2228,6 +1538,7 @@ "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" @@ -2242,6 +1553,7 @@ "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" @@ -2256,6 +1568,7 @@ "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "p-locate": "^4.1.0" }, @@ -2269,6 +1582,7 @@ "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "p-try": "^2.0.0" }, @@ -2285,6 +1599,7 @@ "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "p-limit": "^2.2.0" }, @@ -2298,6 +1613,7 @@ "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -2308,6 +1624,7 @@ "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -2318,6 +1635,7 @@ "integrity": "sha512-+O1ifRjkvYIkBqASKWgLxrpEhQAAE7hY77ALLUufSk5717KfOShg6IbqLmdsLMPdUiFvA2kTs0R7YZy+l0IzZQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/types": "30.2.0", "@types/node": "*", @@ -2336,6 +1654,7 @@ "integrity": "sha512-03W6IhuhjqTlpzh/ojut/pDB2LPRygyWX8ExpgHtQA8H/3K7+1vKmcINx5UzeOX1se6YEsBsOHQ1CRzf3fOwTQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/console": "30.2.0", "@jest/pattern": "30.0.1", @@ -2394,6 +1713,7 @@ "integrity": "sha512-/QPTL7OBJQ5ac09UDRa3EQes4gt1FTEG/8jZ/4v5IVzx+Cv7dLxlVIvfvSVRiiX2drWyXeBjkMSR8hvOWSog5g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/fake-timers": "30.2.0", "@jest/types": "30.2.0", @@ -2410,6 +1730,7 @@ "integrity": "sha512-V9yxQK5erfzx99Sf+7LbhBwNWEZ9eZay8qQ9+JSC0TrMR1pMDHLMY+BnVPacWU6Jamrh252/IKo4F1Xn/zfiqA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "expect": "30.2.0", "jest-snapshot": "30.2.0" @@ -2437,6 +1758,7 @@ "integrity": "sha512-HI3tRLjRxAbBy0VO8dqqm7Hb2mIa8d5bg/NJkyQcOk7V118ObQML8RC5luTF/Zsg4474a+gDvhce7eTnP4GhYw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/types": "30.2.0", "@sinonjs/fake-timers": "^13.0.0", @@ -2465,6 +1787,7 @@ "integrity": "sha512-b63wmnKPaK+6ZZfpYhz9K61oybvbI1aMcIs80++JI1O1rR1vaxHUCNqo3ITu6NU0d4V34yZFoHMn/uoKr/Rwfw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/environment": "30.2.0", "@jest/expect": "30.2.0", @@ -2495,6 +1818,7 @@ "integrity": "sha512-DRyW6baWPqKMa9CzeiBjHwjd8XeAyco2Vt8XbcLFjiwCOEKOvy82GJ8QQnJE9ofsxCMPjH4MfH8fCWIHHDKpAQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@bcoe/v8-coverage": "^0.2.3", "@jest/console": "30.2.0", @@ -2538,6 +1862,7 @@ "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", "dev": true, "license": "ISC", + "peer": true, "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", @@ -2559,6 +1884,7 @@ "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", "dev": true, "license": "BlueOak-1.0.0", + "peer": true, "dependencies": { "@isaacs/cliui": "^8.0.2" }, @@ -2574,7 +1900,8 @@ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "dev": true, - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/@jest/reporters/node_modules/path-scurry": { "version": "1.11.1", @@ -2582,6 +1909,7 @@ "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", "dev": true, "license": "BlueOak-1.0.0", + "peer": true, "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" @@ -2612,6 +1940,7 @@ "integrity": "sha512-0aVxM3RH6DaiLcjj/b0KrIBZhSX1373Xci4l3cW5xiUWPctZ59zQ7jj4rqcJQ/Z8JuN/4wX3FpJSa3RssVvCug==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/types": "30.2.0", "chalk": "^4.1.2", @@ -2628,6 +1957,7 @@ "integrity": "sha512-MIRWMUUR3sdbP36oyNyhbThLHyJ2eEDClPCiHVbrYAe5g3CHRArIVpBw7cdSB5fr+ofSfIb2Tnsw8iEHL0PYQg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "callsites": "^3.1.0", @@ -2643,6 +1973,7 @@ "integrity": "sha512-RF+Z+0CCHkARz5HT9mcQCBulb1wgCP3FBvl9VFokMX27acKphwyQsNuWH3c+ojd1LeWBLoTYoxF0zm6S/66mjg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/console": "30.2.0", "@jest/types": "30.2.0", @@ -2659,6 +1990,7 @@ "integrity": "sha512-wXKgU/lk8fKXMu/l5Hog1R61bL4q5GCdT6OJvdAFz1P+QrpoFuLU68eoKuVc4RbrTtNnTL5FByhWdLgOPSph+Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/test-result": "30.2.0", "graceful-fs": "^4.2.11", @@ -2675,6 +2007,7 @@ "integrity": "sha512-XsauDV82o5qXbhalKxD7p4TZYYdwcaEXC77PPD2HixEFF+6YGppjrAAQurTl2ECWcEomHBMMNS9AH3kcCFx8jA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/core": "^7.27.4", "@jest/types": "30.2.0", @@ -2719,7 +2052,7 @@ "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", @@ -2732,6 +2065,7 @@ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" @@ -2741,7 +2075,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -2762,20 +2096,26 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", + "license": "MIT" + }, "node_modules/@lukeed/csprng": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@lukeed/csprng/-/csprng-1.1.0.tgz", @@ -2785,76 +2125,30 @@ "node": ">=8" } }, - "node_modules/@mapbox/node-pre-gyp": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz", - "integrity": "sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==", - "license": "BSD-3-Clause", + "node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", + "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "dev": true, + "license": "MIT", "optional": true, + "peer": true, "dependencies": { - "detect-libc": "^2.0.0", - "https-proxy-agent": "^5.0.0", - "make-dir": "^3.1.0", - "node-fetch": "^2.6.7", - "nopt": "^5.0.0", - "npmlog": "^5.0.1", - "rimraf": "^3.0.2", - "semver": "^7.3.5", - "tar": "^6.1.11" - }, - "bin": { - "node-pre-gyp": "bin/node-pre-gyp" - } - }, - "node_modules/@mapbox/node-pre-gyp/node_modules/make-dir": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", - "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", - "license": "MIT", - "optional": true, - "dependencies": { - "semver": "^6.0.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@mapbox/node-pre-gyp/node_modules/make-dir/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "license": "ISC", - "optional": true, - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "0.2.12", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", - "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "^1.4.3", - "@emnapi/runtime": "^1.4.3", - "@tybys/wasm-util": "^0.10.0" + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.10.0" } }, "node_modules/@nestjs/cli": { - "version": "11.0.12", - "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-11.0.12.tgz", - "integrity": "sha512-V3fD1xESlFcJ1xpwOtUhn0edLvIa76Sx8mkvdR1s8cM4c/rZO+yGmXP30ZQwPfIJPTgBvsw93F/i+87eV96wcQ==", + "version": "11.0.21", + "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-11.0.21.tgz", + "integrity": "sha512-F8mV0Sj/zVEouzR3NxBuJy08YHTUOmC5Xdcx3qIIaJWzrm8Vw86CHkhkaPBJ5ewRMHPDCShPmhsfwhpCcjts3A==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "19.2.19", - "@angular-devkit/schematics": "19.2.19", - "@angular-devkit/schematics-cli": "19.2.19", + "@angular-devkit/core": "19.2.24", + "@angular-devkit/schematics": "19.2.24", + "@angular-devkit/schematics-cli": "19.2.24", "@inquirer/prompts": "7.10.1", "@nestjs/schematics": "^11.0.1", "ansis": "4.2.0", @@ -2862,13 +2156,13 @@ "cli-table3": "0.6.5", "commander": "4.1.1", "fork-ts-checker-webpack-plugin": "9.1.0", - "glob": "12.0.0", + "glob": "13.0.6", "node-emoji": "1.11.0", "ora": "5.4.1", "tsconfig-paths": "4.2.0", "tsconfig-paths-webpack-plugin": "4.2.0", "typescript": "5.9.3", - "webpack": "5.100.2", + "webpack": "5.106.0", "webpack-node-externals": "3.0.0" }, "bin": { @@ -2878,7 +2172,7 @@ "node": ">= 20.11" }, "peerDependencies": { - "@swc/cli": "^0.1.62 || ^0.3.0 || ^0.4.0 || ^0.5.0 || ^0.6.0 || ^0.7.0", + "@swc/cli": "^0.1.62 || ^0.3.0 || ^0.4.0 || ^0.5.0 || ^0.6.0 || ^0.7.0 || ^0.8.0", "@swc/core": "^1.3.62" }, "peerDependenciesMeta": { @@ -2890,148 +2184,27 @@ } } }, - "node_modules/@nestjs/cli/node_modules/ajv-formats": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", - "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, - "node_modules/@nestjs/cli/node_modules/eslint-scope": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", - "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^4.1.1" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/@nestjs/cli/node_modules/estraverse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", - "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/@nestjs/cli/node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/@nestjs/cli/node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/@nestjs/cli/node_modules/schema-utils": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", - "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/json-schema": "^7.0.9", - "ajv": "^8.9.0", - "ajv-formats": "^2.1.1", - "ajv-keywords": "^5.1.0" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, - "node_modules/@nestjs/cli/node_modules/webpack": { - "version": "5.100.2", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.100.2.tgz", - "integrity": "sha512-QaNKAvGCDRh3wW1dsDjeMdDXwZm2vqq3zn6Pvq4rHOEOGSaUMgOOjG2Y9ZbIGzpfkJk9ZYTHpDqgDfeBDcnLaw==", + "node_modules/@nestjs/cli/node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, - "license": "MIT", - "dependencies": { - "@types/eslint-scope": "^3.7.7", - "@types/estree": "^1.0.8", - "@types/json-schema": "^7.0.15", - "@webassemblyjs/ast": "^1.14.1", - "@webassemblyjs/wasm-edit": "^1.14.1", - "@webassemblyjs/wasm-parser": "^1.14.1", - "acorn": "^8.15.0", - "acorn-import-phases": "^1.0.3", - "browserslist": "^4.24.0", - "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.17.2", - "es-module-lexer": "^1.2.1", - "eslint-scope": "5.1.1", - "events": "^3.2.0", - "glob-to-regexp": "^0.4.1", - "graceful-fs": "^4.2.11", - "json-parse-even-better-errors": "^2.3.1", - "loader-runner": "^4.2.0", - "mime-types": "^2.1.27", - "neo-async": "^2.6.2", - "schema-utils": "^4.3.2", - "tapable": "^2.1.1", - "terser-webpack-plugin": "^5.3.11", - "watchpack": "^2.4.1", - "webpack-sources": "^3.3.3" - }, + "license": "Apache-2.0", "bin": { - "webpack": "bin/webpack.js" + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" }, "engines": { - "node": ">=10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependenciesMeta": { - "webpack-cli": { - "optional": true - } + "node": ">=14.17" } }, "node_modules/@nestjs/common": { - "version": "11.1.9", - "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.9.tgz", - "integrity": "sha512-zDntUTReRbAThIfSp3dQZ9kKqI+LjgLp5YZN5c1bgNRDuoeLySAoZg46Bg1a+uV8TMgIRziHocglKGNzr6l+bQ==", + "version": "11.1.23", + "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.23.tgz", + "integrity": "sha512-qKgEqwQXHIVu8TwiISmgbTrGHAFBsseP86KNolBZwAiHQryinJ5FPiDpp0ZJBBryY+WEMnsqaCa4TSxVuQEWug==", "license": "MIT", - "peer": true, "dependencies": { - "file-type": "21.1.0", + "file-type": "21.3.4", "iterare": "1.2.1", "load-esm": "1.0.3", "tslib": "2.8.1", @@ -3057,17 +2230,16 @@ } }, "node_modules/@nestjs/core": { - "version": "11.1.9", - "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.1.9.tgz", - "integrity": "sha512-a00B0BM4X+9z+t3UxJqIZlemIwCQdYoPKrMcM+ky4z3pkqqG1eTWexjs+YXpGObnLnjtMPVKWlcZHp3adDYvUw==", + "version": "11.1.23", + "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.1.23.tgz", + "integrity": "sha512-Yd+mVFUilw4A6PzV7tyfiW+zrG2wmRXnFZVmNQA+fl1N0k6km4bhhNboxjLu//dzl+XiZI5AsOHHOTegzvOgNQ==", "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@nuxt/opencollective": "0.4.1", "fast-safe-stringify": "2.1.1", "iterare": "1.2.1", - "path-to-regexp": "8.3.0", + "path-to-regexp": "8.4.2", "tslib": "2.8.1", "uid": "2.0.2" }, @@ -3099,14 +2271,14 @@ } }, "node_modules/@nestjs/mapped-types": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-2.1.0.tgz", - "integrity": "sha512-W+n+rM69XsFdwORF11UqJahn4J3xi4g/ZEOlJNL6KoW5ygWSmBB2p0S2BZ4FQeS/NDH72e6xIcu35SfJnE8bXw==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-2.1.1.tgz", + "integrity": "sha512-SCCoMEJ6jdeI5h/N+KCVF1+pmg/hmEkNA5nHTS8Gvww7T/LCl4o1gFLinw2iQ60w7slFkszHcGLKGdazVI4F8A==", "license": "MIT", "peerDependencies": { "@nestjs/common": "^10.0.0 || ^11.0.0", "class-transformer": "^0.4.0 || ^0.5.0", - "class-validator": "^0.13.0 || ^0.14.0", + "class-validator": "^0.13.0 || ^0.14.0 || ^0.15.0", "reflect-metadata": "^0.1.12 || ^0.2.0" }, "peerDependenciesMeta": { @@ -3119,16 +2291,15 @@ } }, "node_modules/@nestjs/platform-express": { - "version": "11.1.9", - "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.9.tgz", - "integrity": "sha512-GVd3+0lO0mJq2m1kl9hDDnVrX3Nd4oH3oDfklz0pZEVEVS0KVSp63ufHq2Lu9cyPdSBuelJr9iPm2QQ1yX+Kmw==", + "version": "11.1.23", + "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.23.tgz", + "integrity": "sha512-7TQ9v2Z9lej8btTYK1VUVB3nZr0lXFNFiyI/iOc0jAOg31dHdpAWtnMMm9iUvyAfq/ssw7dsBhjCHOmEJJKr0A==", "license": "MIT", - "peer": true, "dependencies": { - "cors": "2.8.5", - "express": "5.1.0", - "multer": "2.0.2", - "path-to-regexp": "8.3.0", + "cors": "2.8.6", + "express": "5.2.1", + "multer": "2.1.1", + "path-to-regexp": "8.4.2", "tslib": "2.8.1" }, "funding": { @@ -3141,12 +2312,12 @@ } }, "node_modules/@nestjs/schedule": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-6.0.1.tgz", - "integrity": "sha512-v3yO6cSPAoBSSyH67HWnXHzuhPhSNZhRmLY38JvCt2sqY8sPMOODpcU1D79iUMFf7k16DaMEbL4Mgx61ZhiC8Q==", + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-6.1.3.tgz", + "integrity": "sha512-RflMFOpR16Dwd1jAUbeB4mfGTCh65fvEdL4mSjQPJChpkRGRjIXjb+6YQcK2faQrVT60c9DmLmoVR7/ONCtuYQ==", "license": "MIT", "dependencies": { - "cron": "4.3.3" + "cron": "4.4.0" }, "peerDependencies": { "@nestjs/common": "^10.0.0 || ^11.0.0", @@ -3154,83 +2325,32 @@ } }, "node_modules/@nestjs/schematics": { - "version": "11.0.9", - "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-11.0.9.tgz", - "integrity": "sha512-0NfPbPlEaGwIT8/TCThxLzrlz3yzDNkfRNpbL7FiplKq3w4qXpJg0JYwqgMEJnLQZm3L/L/5XjoyfJHUO3qX9g==", + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-11.1.0.tgz", + "integrity": "sha512-lVxGZ46tcdItFMoXr6vyKWlnOsm1SZm/GUqAEDvy2RL4Q4O+3bkziAhrO7Y8JLssFUUvNFEGqAizI52WAxhjDw==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "19.2.17", - "@angular-devkit/schematics": "19.2.17", - "comment-json": "4.4.1", + "@angular-devkit/core": "19.2.24", + "@angular-devkit/schematics": "19.2.24", + "comment-json": "5.0.0", "jsonc-parser": "3.3.1", "pluralize": "8.0.0" }, "peerDependencies": { + "prettier": "^3.0.0", "typescript": ">=4.8.2" - } - }, - "node_modules/@nestjs/schematics/node_modules/@angular-devkit/core": { - "version": "19.2.17", - "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-19.2.17.tgz", - "integrity": "sha512-Ah008x2RJkd0F+NLKqIpA34/vUGwjlprRCkvddjDopAWRzYn6xCkz1Tqwuhn0nR1Dy47wTLKYD999TYl5ONOAQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "8.17.1", - "ajv-formats": "3.0.1", - "jsonc-parser": "3.3.1", - "picomatch": "4.0.2", - "rxjs": "7.8.1", - "source-map": "0.7.4" - }, - "engines": { - "node": "^18.19.1 || ^20.11.1 || >=22.0.0", - "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", - "yarn": ">= 1.13.0" - }, - "peerDependencies": { - "chokidar": "^4.0.0" }, "peerDependenciesMeta": { - "chokidar": { + "prettier": { "optional": true } } }, - "node_modules/@nestjs/schematics/node_modules/@angular-devkit/schematics": { - "version": "19.2.17", - "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-19.2.17.tgz", - "integrity": "sha512-ADfbaBsrG8mBF6Mfs+crKA/2ykB8AJI50Cv9tKmZfwcUcyAdmTr+vVvhsBCfvUAEokigSsgqgpYxfkJVxhJYeg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@angular-devkit/core": "19.2.17", - "jsonc-parser": "3.3.1", - "magic-string": "0.30.17", - "ora": "5.4.1", - "rxjs": "7.8.1" - }, - "engines": { - "node": "^18.19.1 || ^20.11.1 || >=22.0.0", - "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", - "yarn": ">= 1.13.0" - } - }, - "node_modules/@nestjs/schematics/node_modules/rxjs": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", - "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.1.0" - } - }, "node_modules/@nestjs/terminus": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/@nestjs/terminus/-/terminus-11.0.0.tgz", - "integrity": "sha512-c55LOo9YGovmQHtFUMa/vDaxGZ2cglMTZejqgHREaApt/GArTfgYYGwhRXPLq8ZwiQQlLuYB+79e9iA8mlDSLA==", + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/@nestjs/terminus/-/terminus-11.1.1.tgz", + "integrity": "sha512-Ssql79H+EQY/Wg108eJqN4NiNsO/tLrj+qbzOWSQUf2JE4vJQ2RG3WTqUOrYjfjWmVHD3+Ys0+azed7LSMKScw==", "license": "MIT", "dependencies": { "boxen": "5.1.2", @@ -3298,9 +2418,9 @@ } }, "node_modules/@nestjs/testing": { - "version": "11.1.9", - "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-11.1.9.tgz", - "integrity": "sha512-UFxerBDdb0RUNxQNj25pvkvNE7/vxKhXYWBt3QuwBFnYISzRIzhVlyIqLfoV5YI3zV0m0Nn4QAn1KM0zzwfEng==", + "version": "11.1.23", + "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-11.1.23.tgz", + "integrity": "sha512-cpE+WzbZiUxT9Wd4TvYvfDFJ7LAGlFE4qCubIfpuqXuSQkjNJfLGfVv2wif1/2Ery1zXGPVH0yz/TQcctL10aA==", "dev": true, "license": "MIT", "dependencies": { @@ -3326,9 +2446,9 @@ } }, "node_modules/@nestjs/throttler": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/@nestjs/throttler/-/throttler-6.4.0.tgz", - "integrity": "sha512-osL67i0PUuwU5nqSuJjtUJZMkxAnYB4VldgYUMGzvYRJDCqGRFMWbsbzm/CkUtPLRL30I8T74Xgt/OQxnYokiA==", + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@nestjs/throttler/-/throttler-6.5.0.tgz", + "integrity": "sha512-9j0ZRfH0QE1qyrj9JjIRDz5gQLPqq9yVC2nHsrosDVAfI5HHw08/aUAWx9DZLSdQf4HDkmhTTEGLrRFHENvchQ==", "license": "MIT", "peerDependencies": { "@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0", @@ -3349,44 +2469,6 @@ "url": "https://paulmillr.com/funding/" } }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/@nuxt/opencollective": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/@nuxt/opencollective/-/opencollective-0.4.1.tgz", @@ -3404,10 +2486,11 @@ } }, "node_modules/@opentelemetry/api": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", - "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", + "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=8.0.0" } @@ -3422,783 +2505,582 @@ "@noble/hashes": "^1.1.5" } }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=14" - } - }, - "node_modules/@pkgr/core": { - "version": "0.2.9", - "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", - "integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==", - "dev": true, + "node_modules/@peculiar/asn1-cms": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.7.0.tgz", + "integrity": "sha512-hew63shtzzvBcSHbhm+cyAmKe6AIfinT9hzEqSPjDC6opTTMKmTkQ0gHuN2KsWlvqiKw1S/fS94fhag/FJkioQ==", "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.18.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/pkgr" - } - }, - "node_modules/@prisma/client": { - "version": "6.19.0", - "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.0.tgz", - "integrity": "sha512-QXFT+N/bva/QI2qoXmjBzL7D6aliPffIwP+81AdTGq0FXDoLxLkWivGMawG8iM5B9BKfxLIXxfWWAF6wbuJU6g==", - "hasInstallScript": true, - "license": "Apache-2.0", - "peer": true, - "engines": { - "node": ">=18.18" - }, - "peerDependencies": { - "prisma": "*", - "typescript": ">=5.1.0" - }, - "peerDependenciesMeta": { - "prisma": { - "optional": true - }, - "typescript": { - "optional": true - } - } - }, - "node_modules/@prisma/config": { - "version": "6.19.0", - "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.19.0.tgz", - "integrity": "sha512-zwCayme+NzI/WfrvFEtkFhhOaZb/hI+X8TTjzjJ252VbPxAl2hWHK5NMczmnG9sXck2lsXrxIZuK524E25UNmg==", - "license": "Apache-2.0", - "dependencies": { - "c12": "3.1.0", - "deepmerge-ts": "7.1.5", - "effect": "3.18.4", - "empathic": "2.0.0" - } - }, - "node_modules/@prisma/debug": { - "version": "6.19.0", - "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.19.0.tgz", - "integrity": "sha512-8hAdGG7JmxrzFcTzXZajlQCidX0XNkMJkpqtfbLV54wC6LSSX6Vni25W/G+nAANwLnZ2TmwkfIuWetA7jJxJFA==", - "license": "Apache-2.0" - }, - "node_modules/@prisma/engines": { - "version": "6.19.0", - "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.19.0.tgz", - "integrity": "sha512-pMRJ+1S6NVdXoB8QJAPIGpKZevFjxhKt0paCkRDTZiczKb7F4yTgRP8M4JdVkpQwmaD4EoJf6qA+p61godDokw==", - "hasInstallScript": true, - "license": "Apache-2.0", - "dependencies": { - "@prisma/debug": "6.19.0", - "@prisma/engines-version": "6.19.0-26.2ba551f319ab1df4bc874a89965d8b3641056773", - "@prisma/fetch-engine": "6.19.0", - "@prisma/get-platform": "6.19.0" - } - }, - "node_modules/@prisma/engines-version": { - "version": "6.19.0-26.2ba551f319ab1df4bc874a89965d8b3641056773", - "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.19.0-26.2ba551f319ab1df4bc874a89965d8b3641056773.tgz", - "integrity": "sha512-gV7uOBQfAFlWDvPJdQxMT1aSRur3a0EkU/6cfbAC5isV67tKDWUrPauyaHNpB+wN1ebM4A9jn/f4gH+3iHSYSQ==", - "license": "Apache-2.0" - }, - "node_modules/@prisma/fetch-engine": { - "version": "6.19.0", - "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.19.0.tgz", - "integrity": "sha512-OOx2Lda0DGrZ1rodADT06ZGqHzr7HY7LNMaFE2Vp8dp146uJld58sRuasdX0OiwpHgl8SqDTUKHNUyzEq7pDdQ==", - "license": "Apache-2.0", - "dependencies": { - "@prisma/debug": "6.19.0", - "@prisma/engines-version": "6.19.0-26.2ba551f319ab1df4bc874a89965d8b3641056773", - "@prisma/get-platform": "6.19.0" - } - }, - "node_modules/@prisma/get-platform": { - "version": "6.19.0", - "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.19.0.tgz", - "integrity": "sha512-ym85WDO2yDhC3fIXHWYpG3kVMBA49cL1XD2GCsCF8xbwoy2OkDQY44gEbAt2X46IQ4Apq9H6g0Ex1iFfPqEkHA==", - "license": "Apache-2.0", "dependencies": { - "@prisma/debug": "6.19.0" + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/asn1-x509": "^2.7.0", + "@peculiar/asn1-x509-attr": "^2.7.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" } }, - "node_modules/@promster/express": { - "version": "15.4.2", - "resolved": "https://registry.npmjs.org/@promster/express/-/express-15.4.2.tgz", - "integrity": "sha512-2r5ZFop9NqzY2LwZSKQFVixXupo+5wlKo6Z+qU7azReUp2bwJqu8RnQITiIaOU6kMtfrbnIU6+eZ70TVZrhEIg==", + "node_modules/@peculiar/asn1-csr": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.7.0.tgz", + "integrity": "sha512-VVsAyGqErT9D1SY4aEqozThXMVI+ssVRiv2DDeYuvpBKLIgZ3hYs3Ay3u/VSoKq6ESFi9cf6rf3IOOzfwh7oMA==", "license": "MIT", "dependencies": { - "@promster/metrics": "15.4.2", - "merge-options": "3.0.4", - "tslib": "2.8.1" - }, - "engines": { - "node": ">=20", - "npm": ">=8" + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/asn1-x509": "^2.7.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" } }, - "node_modules/@promster/metrics": { - "version": "15.4.2", - "resolved": "https://registry.npmjs.org/@promster/metrics/-/metrics-15.4.2.tgz", - "integrity": "sha512-MypskcUIYAuBCu+XcF0wZ3Nu1zAn6ae0WHoF10TX6UW3Qk+IACwE6x4xKjr5k9fLNjbP8BBqN12qXgaXyzT38A==", + "node_modules/@peculiar/asn1-ecc": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.7.0.tgz", + "integrity": "sha512-n7KEs/Q/wrB415cxy4fHOBhegp4NdJ15fkJPwcB/3/8iNBQC2L/N7SChJPKDJPZGYH0jD4Tg4/0vnHmwghnbKw==", "license": "MIT", "dependencies": { - "lodash.memoize": "4.1.2", - "lodash.once": "4.1.1", - "merge-options": "3.0.4", - "prometheus-gc-stats": "1.1.0", - "tslib": "2.8.1", - "url-value-parser": "2.2.0" - }, - "engines": { - "node": ">=20", - "npm": ">=8" - }, - "peerDependencies": { - "prom-client": "13.x.x || 14.x || 15.x" - } - }, - "node_modules/@sematext/gc-stats": { - "version": "1.5.9", - "resolved": "https://registry.npmjs.org/@sematext/gc-stats/-/gc-stats-1.5.9.tgz", - "integrity": "sha512-7aUJttre+TwhnewUH9QT5+mC/c+H8k6HJBam6EBcem8s0zT3rVJ+dOzpi2nsQYRytn7H02JCq/XvKpXVamUWDg==", - "hasInstallScript": true, - "license": "Unlicense", - "optional": true, - "dependencies": { - "@mapbox/node-pre-gyp": "^1.0.10", - "depcheck": "^1.4.3", - "nan": "^2.17.0" - }, - "engines": { - "node": ">=6" + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/asn1-x509": "^2.7.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" } }, - "node_modules/@sinclair/typebox": { - "version": "0.34.41", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.41.tgz", - "integrity": "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g==", - "dev": true, - "license": "MIT" - }, - "node_modules/@sinonjs/commons": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", - "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "type-detect": "4.0.8" - } - }, - "node_modules/@sinonjs/fake-timers": { - "version": "13.0.5", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-13.0.5.tgz", - "integrity": "sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@sinonjs/commons": "^3.0.1" - } - }, - "node_modules/@smithy/abort-controller": { - "version": "4.2.5", - "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.5.tgz", - "integrity": "sha512-j7HwVkBw68YW8UmFRcjZOmssE77Rvk0GWAIN1oFBhsaovQmZWYCIcGa9/pwRB0ExI8Sk9MWNALTjftjHZea7VA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.9.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/config-resolver": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.3.tgz", - "integrity": "sha512-ezHLe1tKLUxDJo2LHtDuEDyWXolw8WGOR92qb4bQdWq/zKenO5BvctZGrVJBK08zjezSk7bmbKFOXIVyChvDLw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/node-config-provider": "^4.3.5", - "@smithy/types": "^4.9.0", - "@smithy/util-config-provider": "^4.2.0", - "@smithy/util-endpoints": "^3.2.5", - "@smithy/util-middleware": "^4.2.5", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/core": { - "version": "3.18.5", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.18.5.tgz", - "integrity": "sha512-6gnIz3h+PEPQGDj8MnRSjDvKBah042jEoPgjFGJ4iJLBE78L4lY/n98x14XyPF4u3lN179Ub/ZKFY5za9GeLQw==", - "license": "Apache-2.0", + "node_modules/@peculiar/asn1-pfx": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.7.0.tgz", + "integrity": "sha512-V/nrlQVmhg7lYAsM7E13UDL5erAwFv6kCIVFqNaMIHSVi7dngcT839JkRTkQBqznMG98l2XjxYk74ZztAohZzA==", + "license": "MIT", "dependencies": { - "@smithy/middleware-serde": "^4.2.6", - "@smithy/protocol-http": "^5.3.5", - "@smithy/types": "^4.9.0", - "@smithy/util-base64": "^4.3.0", - "@smithy/util-body-length-browser": "^4.2.0", - "@smithy/util-middleware": "^4.2.5", - "@smithy/util-stream": "^4.5.6", - "@smithy/util-utf8": "^4.2.0", - "@smithy/uuid": "^1.1.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" + "@peculiar/asn1-cms": "^2.7.0", + "@peculiar/asn1-pkcs8": "^2.7.0", + "@peculiar/asn1-rsa": "^2.7.0", + "@peculiar/asn1-schema": "^2.7.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" } }, - "node_modules/@smithy/credential-provider-imds": { - "version": "4.2.5", - "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.5.tgz", - "integrity": "sha512-BZwotjoZWn9+36nimwm/OLIcVe+KYRwzMjfhd4QT7QxPm9WY0HiOV8t/Wlh+HVUif0SBVV7ksq8//hPaBC/okQ==", - "license": "Apache-2.0", + "node_modules/@peculiar/asn1-pkcs8": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.7.0.tgz", + "integrity": "sha512-9GTl1nE8Mx1kTZ+7QyYatDyKsm34QcWRBFkY1iPvWC3X4Dona5s/tlLiQsx5WzVdZqiMBZNYT0buyw4/vbhnjw==", + "license": "MIT", "dependencies": { - "@smithy/node-config-provider": "^4.3.5", - "@smithy/property-provider": "^4.2.5", - "@smithy/types": "^4.9.0", - "@smithy/url-parser": "^4.2.5", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/asn1-x509": "^2.7.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" } }, - "node_modules/@smithy/fetch-http-handler": { - "version": "5.3.6", - "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.6.tgz", - "integrity": "sha512-3+RG3EA6BBJ/ofZUeTFJA7mHfSYrZtQIrDP9dI8Lf7X6Jbos2jptuLrAAteDiFVrmbEmLSuRG/bUKzfAXk7dhg==", - "license": "Apache-2.0", + "node_modules/@peculiar/asn1-pkcs9": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.7.0.tgz", + "integrity": "sha512-Bh7m+OuIaSEllPQcSd9OSp93F4ROWH7sbITWV8MI+8dwsjE5111/87VxiWVvYFKyww3vp39geLv9ENqhwWHcew==", + "license": "MIT", "dependencies": { - "@smithy/protocol-http": "^5.3.5", - "@smithy/querystring-builder": "^4.2.5", - "@smithy/types": "^4.9.0", - "@smithy/util-base64": "^4.3.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" + "@peculiar/asn1-cms": "^2.7.0", + "@peculiar/asn1-pfx": "^2.7.0", + "@peculiar/asn1-pkcs8": "^2.7.0", + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/asn1-x509": "^2.7.0", + "@peculiar/asn1-x509-attr": "^2.7.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" } }, - "node_modules/@smithy/hash-node": { - "version": "4.2.5", - "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.5.tgz", - "integrity": "sha512-DpYX914YOfA3UDT9CN1BM787PcHfWRBB43fFGCYrZFUH0Jv+5t8yYl+Pd5PW4+QzoGEDvn5d5QIO4j2HyYZQSA==", - "license": "Apache-2.0", + "node_modules/@peculiar/asn1-rsa": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.7.0.tgz", + "integrity": "sha512-/qvENQrXyTZURjMqSeofHul0JJt2sNSzSwk36pl2olkHbaioMQgrASDZAlHXl0xUlnVbHj0uGgOrBMTb5x2aJQ==", + "license": "MIT", "dependencies": { - "@smithy/types": "^4.9.0", - "@smithy/util-buffer-from": "^4.2.0", - "@smithy/util-utf8": "^4.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/asn1-x509": "^2.7.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" } }, - "node_modules/@smithy/invalid-dependency": { - "version": "4.2.5", - "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.5.tgz", - "integrity": "sha512-2L2erASEro1WC5nV+plwIMxrTXpvpfzl4e+Nre6vBVRR2HKeGGcvpJyyL3/PpiSg+cJG2KpTmZmq934Olb6e5A==", - "license": "Apache-2.0", + "node_modules/@peculiar/asn1-schema": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.7.0.tgz", + "integrity": "sha512-W8ZfWzLmQnrcky+eh3tni4IozMdqBDiHWU0N+vve/UGjMaUs8c0L7A2oEdkBXS8rTpWDpK/aoI3DG/L/hxmxPg==", + "license": "MIT", "dependencies": { - "@smithy/types": "^4.9.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" } }, - "node_modules/@smithy/is-array-buffer": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.2.0.tgz", - "integrity": "sha512-DZZZBvC7sjcYh4MazJSGiWMI2L7E0oCiRHREDzIxi/M2LY79/21iXt6aPLHge82wi5LsuRF5A06Ds3+0mlh6CQ==", - "license": "Apache-2.0", + "node_modules/@peculiar/asn1-x509": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.7.0.tgz", + "integrity": "sha512-mUn9RRrkGDnG4ALfunDmzyRW5dg+sWCj/pfnCCqEHYbkGxEpvUt6iVJv8Yw1cyp6SWZ26ZE5oSmI5SqEaen15g==", + "license": "MIT", "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" } }, - "node_modules/@smithy/middleware-content-length": { - "version": "4.2.5", - "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.5.tgz", - "integrity": "sha512-Y/RabVa5vbl5FuHYV2vUCwvh/dqzrEY/K2yWPSqvhFUwIY0atLqO4TienjBXakoy4zrKAMCZwg+YEqmH7jaN7A==", - "license": "Apache-2.0", + "node_modules/@peculiar/asn1-x509-attr": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.7.0.tgz", + "integrity": "sha512-NS8e7SOgXipkzUPLF/sce7ukpMpWjhxYsH0n6Y+bHYo4TTxOb95Zv7hqwSuL212mj5YxovjdOKQOgH1As3E94w==", + "license": "MIT", "dependencies": { - "@smithy/protocol-http": "^5.3.5", - "@smithy/types": "^4.9.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/asn1-x509": "^2.7.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" } }, - "node_modules/@smithy/middleware-endpoint": { - "version": "4.3.12", - "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.3.12.tgz", - "integrity": "sha512-9pAX/H+VQPzNbouhDhkW723igBMLgrI8OtX+++M7iKJgg/zY/Ig3i1e6seCcx22FWhE6Q/S61BRdi2wXBORT+A==", - "license": "Apache-2.0", + "node_modules/@peculiar/utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz", + "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==", + "license": "MIT", "dependencies": { - "@smithy/core": "^3.18.5", - "@smithy/middleware-serde": "^4.2.6", - "@smithy/node-config-provider": "^4.3.5", - "@smithy/shared-ini-file-loader": "^4.4.0", - "@smithy/types": "^4.9.0", - "@smithy/url-parser": "^4.2.5", - "@smithy/util-middleware": "^4.2.5", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" + "tslib": "^2.8.1" } }, - "node_modules/@smithy/middleware-retry": { - "version": "4.4.12", - "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.12.tgz", - "integrity": "sha512-S4kWNKFowYd0lID7/DBqWHOQxmxlsf0jBaos9chQZUWTVOjSW1Ogyh8/ib5tM+agFDJ/TCxuCTvrnlc+9cIBcQ==", - "license": "Apache-2.0", + "node_modules/@peculiar/x509": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.3.tgz", + "integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==", + "license": "MIT", "dependencies": { - "@smithy/node-config-provider": "^4.3.5", - "@smithy/protocol-http": "^5.3.5", - "@smithy/service-error-classification": "^4.2.5", - "@smithy/smithy-client": "^4.9.8", - "@smithy/types": "^4.9.0", - "@smithy/util-middleware": "^4.2.5", - "@smithy/util-retry": "^4.2.5", - "@smithy/uuid": "^1.1.0", - "tslib": "^2.6.2" + "@peculiar/asn1-cms": "^2.6.0", + "@peculiar/asn1-csr": "^2.6.0", + "@peculiar/asn1-ecc": "^2.6.0", + "@peculiar/asn1-pkcs9": "^2.6.0", + "@peculiar/asn1-rsa": "^2.6.0", + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.0", + "pvtsutils": "^1.3.6", + "reflect-metadata": "^0.2.2", + "tslib": "^2.8.1", + "tsyringe": "^4.10.0" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, - "node_modules/@smithy/middleware-serde": { - "version": "4.2.6", - "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.6.tgz", - "integrity": "sha512-VkLoE/z7e2g8pirwisLz8XJWedUSY8my/qrp81VmAdyrhi94T+riBfwP+AOEEFR9rFTSonC/5D2eWNmFabHyGQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/protocol-http": "^5.3.5", - "@smithy/types": "^4.9.0", - "tslib": "^2.6.2" - }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, "engines": { - "node": ">=18.0.0" + "node": ">=14" } }, - "node_modules/@smithy/middleware-stack": { - "version": "4.2.5", - "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.5.tgz", - "integrity": "sha512-bYrutc+neOyWxtZdbB2USbQttZN0mXaOyYLIsaTbJhFsfpXyGWUxJpEuO1rJ8IIJm2qH4+xJT0mxUSsEDTYwdQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.9.0", - "tslib": "^2.6.2" - }, + "node_modules/@pkgr/core": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", + "integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/node-config-provider": { - "version": "4.3.5", - "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.5.tgz", - "integrity": "sha512-UTurh1C4qkVCtqggI36DGbLB2Kv8UlcFdMXDcWMbqVY2uRg0XmT9Pb4Vj6oSQ34eizO1fvR0RnFV4Axw4IrrAg==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/property-provider": "^4.2.5", - "@smithy/shared-ini-file-loader": "^4.4.0", - "@smithy/types": "^4.9.0", - "tslib": "^2.6.2" + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" }, - "engines": { - "node": ">=18.0.0" + "funding": { + "url": "https://opencollective.com/pkgr" } }, - "node_modules/@smithy/node-http-handler": { - "version": "4.4.5", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.4.5.tgz", - "integrity": "sha512-CMnzM9R2WqlqXQGtIlsHMEZfXKJVTIrqCNoSd/QpAyp+Dw0a1Vps13l6ma1fH8g7zSPNsA59B/kWgeylFuA/lw==", + "node_modules/@prisma/client": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.8.0.tgz", + "integrity": "sha512-HFp3Dawv/3sU3JtlPha90IB+48lS7zHiH4LKZPjmcE8YH5P9DOXGPvo8dqOtO7MqLDd1p2hOWMcFlRT1DMblHw==", "license": "Apache-2.0", "dependencies": { - "@smithy/abort-controller": "^4.2.5", - "@smithy/protocol-http": "^5.3.5", - "@smithy/querystring-builder": "^4.2.5", - "@smithy/types": "^4.9.0", - "tslib": "^2.6.2" + "@prisma/client-runtime-utils": "7.8.0" }, "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/property-provider": { - "version": "4.2.5", - "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.5.tgz", - "integrity": "sha512-8iLN1XSE1rl4MuxvQ+5OSk/Zb5El7NJZ1td6Tn+8dQQHIjp59Lwl6bd0+nzw6SKm2wSSriH2v/I9LPzUic7EOg==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.9.0", - "tslib": "^2.6.2" + "node": "^20.19 || ^22.12 || >=24.0" }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/protocol-http": { - "version": "5.3.5", - "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.5.tgz", - "integrity": "sha512-RlaL+sA0LNMp03bf7XPbFmT5gN+w3besXSWMkA8rcmxLSVfiEXElQi4O2IWwPfxzcHkxqrwBFMbngB8yx/RvaQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.9.0", - "tslib": "^2.6.2" + "peerDependencies": { + "prisma": "*", + "typescript": ">=5.4.0" }, - "engines": { - "node": ">=18.0.0" + "peerDependenciesMeta": { + "prisma": { + "optional": true + }, + "typescript": { + "optional": true + } } }, - "node_modules/@smithy/querystring-builder": { - "version": "4.2.5", - "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.5.tgz", - "integrity": "sha512-y98otMI1saoajeik2kLfGyRp11e5U/iJYH/wLCh3aTV/XutbGT9nziKGkgCaMD1ghK7p6htHMm6b6scl9JRUWg==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.9.0", - "@smithy/util-uri-escape": "^4.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } + "node_modules/@prisma/client-runtime-utils": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/client-runtime-utils/-/client-runtime-utils-7.8.0.tgz", + "integrity": "sha512-5NQZztQ0oY/ADFkmd9gPuweH5A1/CCY8YQPorLLO0Mu6a87mY5gsnDkzmFmIHs9NFaLnZojzgddFVN4RpKYrdw==", + "license": "Apache-2.0" }, - "node_modules/@smithy/querystring-parser": { - "version": "4.2.5", - "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.5.tgz", - "integrity": "sha512-031WCTdPYgiQRYNPXznHXof2YM0GwL6SeaSyTH/P72M1Vz73TvCNH2Nq8Iu2IEPq9QP2yx0/nrw5YmSeAi/AjQ==", + "node_modules/@prisma/config": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/config/-/config-7.8.0.tgz", + "integrity": "sha512-HFESzd9rx2ZQxlK+TL7tu1HPvCqrHiL6LCxYykI2c34mvaUuIVVl3lYuicJD/MNnzgPnyeBEMlK4WTomJCV5jw==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.9.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" + "c12": "3.3.4", + "deepmerge-ts": "7.1.5", + "effect": "3.20.0", + "empathic": "2.0.0" } }, - "node_modules/@smithy/service-error-classification": { - "version": "4.2.5", - "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.2.5.tgz", - "integrity": "sha512-8fEvK+WPE3wUAcDvqDQG1Vk3ANLR8Px979te96m84CbKAjBVf25rPYSzb4xU4hlTyho7VhOGnh5i62D/JVF0JQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.9.0" - }, - "engines": { - "node": ">=18.0.0" - } + "node_modules/@prisma/debug": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.8.0.tgz", + "integrity": "sha512-p+QZReysDUqXC+mk17q9a+Y/qzh4c2KYliDK30buYUyfrGeTGSyfmc0AIrJRhZJrLHhRiJa9Au/J72h3C+szvA==", + "license": "Apache-2.0" }, - "node_modules/@smithy/shared-ini-file-loader": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.0.tgz", - "integrity": "sha512-5WmZ5+kJgJDjwXXIzr1vDTG+RhF9wzSODQBfkrQ2VVkYALKGvZX1lgVSxEkgicSAFnFhPj5rudJV0zoinqS0bA==", - "license": "Apache-2.0", + "node_modules/@prisma/dev": { + "version": "0.24.3", + "resolved": "https://registry.npmjs.org/@prisma/dev/-/dev-0.24.3.tgz", + "integrity": "sha512-ffHlQuKXZiaDt9Go0OnCTdJZrHxK0k7omJKNV86/VjpsXu5EIHZLK0T7JSWgvNlJwh56kW9JFu9v0qJciFzepg==", + "license": "ISC", "dependencies": { - "@smithy/types": "^4.9.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" + "@electric-sql/pglite": "0.4.1", + "@electric-sql/pglite-socket": "0.1.1", + "@electric-sql/pglite-tools": "0.3.1", + "@hono/node-server": "1.19.11", + "@prisma/get-platform": "7.2.0", + "@prisma/query-plan-executor": "7.2.0", + "@prisma/streams-local": "0.1.2", + "foreground-child": "3.3.1", + "get-port-please": "3.2.0", + "hono": "^4.12.8", + "http-status-codes": "2.3.0", + "pathe": "2.0.3", + "proper-lockfile": "4.1.2", + "remeda": "2.33.4", + "std-env": "3.10.0", + "valibot": "1.2.0", + "zeptomatch": "2.1.0" } }, - "node_modules/@smithy/signature-v4": { - "version": "5.3.5", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.5.tgz", - "integrity": "sha512-xSUfMu1FT7ccfSXkoLl/QRQBi2rOvi3tiBZU2Tdy3I6cgvZ6SEi9QNey+lqps/sJRnogIS+lq+B1gxxbra2a/w==", + "node_modules/@prisma/engines": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.8.0.tgz", + "integrity": "sha512-jx3rCnNNrt5uzbkKlegtQ2GZHxSlihMCzutgT/BP6UIDF1r9tDI39hV/0T/cHZgzJ3ELbuQPXlVZy+Y1n0pcgw==", + "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@smithy/is-array-buffer": "^4.2.0", - "@smithy/protocol-http": "^5.3.5", - "@smithy/types": "^4.9.0", - "@smithy/util-hex-encoding": "^4.2.0", - "@smithy/util-middleware": "^4.2.5", - "@smithy/util-uri-escape": "^4.2.0", - "@smithy/util-utf8": "^4.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" + "@prisma/debug": "7.8.0", + "@prisma/engines-version": "7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a", + "@prisma/fetch-engine": "7.8.0", + "@prisma/get-platform": "7.8.0" } }, - "node_modules/@smithy/smithy-client": { - "version": "4.9.8", - "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.9.8.tgz", - "integrity": "sha512-8xgq3LgKDEFoIrLWBho/oYKyWByw9/corz7vuh1upv7ZBm0ZMjGYBhbn6v643WoIqA9UTcx5A5htEp/YatUwMA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.18.5", - "@smithy/middleware-endpoint": "^4.3.12", - "@smithy/middleware-stack": "^4.2.5", - "@smithy/protocol-http": "^5.3.5", - "@smithy/types": "^4.9.0", - "@smithy/util-stream": "^4.5.6", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } + "node_modules/@prisma/engines-version": { + "version": "7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a.tgz", + "integrity": "sha512-fJPQxCkLgA5EayWaW8eArgCvjJ+N+Kz3VyeNKMEeYiQC4alNkxRKFVAGxv/ZUzuJISKqdw+zGeDbS6mn6RCPOA==", + "license": "Apache-2.0" }, - "node_modules/@smithy/types": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.9.0.tgz", - "integrity": "sha512-MvUbdnXDTwykR8cB1WZvNNwqoWVaTRA0RLlLmf/cIFNMM2cKWz01X4Ly6SMC4Kks30r8tT3Cty0jmeWfiuyHTA==", + "node_modules/@prisma/engines/node_modules/@prisma/get-platform": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.8.0.tgz", + "integrity": "sha512-WlxgRGnolL8VH2EmkH1R/DkKNr/mVdS3G2h42IZFFZ3eUrH9OT6t73kIOSlkkrv50wG123Iq8d96ufv5LlZktw==", "license": "Apache-2.0", "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" + "@prisma/debug": "7.8.0" } }, - "node_modules/@smithy/url-parser": { - "version": "4.2.5", - "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.5.tgz", - "integrity": "sha512-VaxMGsilqFnK1CeBX+LXnSuaMx4sTL/6znSZh2829txWieazdVxr54HmiyTsIbpOTLcf5nYpq9lpzmwRdxj6rQ==", + "node_modules/@prisma/fetch-engine": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-7.8.0.tgz", + "integrity": "sha512-gwB0Euiz/DDRyxFRpLXYlK3RfaZUj1c5dAYMuhZYfApg7arknJlcb9bIsOHDppJmbqYaVA+yBIiFMDBfprsNPQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/querystring-parser": "^4.2.5", - "@smithy/types": "^4.9.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" + "@prisma/debug": "7.8.0", + "@prisma/engines-version": "7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a", + "@prisma/get-platform": "7.8.0" } }, - "node_modules/@smithy/util-base64": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.3.0.tgz", - "integrity": "sha512-GkXZ59JfyxsIwNTWFnjmFEI8kZpRNIBfxKjv09+nkAWPt/4aGaEWMM04m4sxgNVWkbt2MdSvE3KF/PfX4nFedQ==", + "node_modules/@prisma/fetch-engine/node_modules/@prisma/get-platform": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.8.0.tgz", + "integrity": "sha512-WlxgRGnolL8VH2EmkH1R/DkKNr/mVdS3G2h42IZFFZ3eUrH9OT6t73kIOSlkkrv50wG123Iq8d96ufv5LlZktw==", "license": "Apache-2.0", "dependencies": { - "@smithy/util-buffer-from": "^4.2.0", - "@smithy/util-utf8": "^4.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" + "@prisma/debug": "7.8.0" } }, - "node_modules/@smithy/util-body-length-browser": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.2.0.tgz", - "integrity": "sha512-Fkoh/I76szMKJnBXWPdFkQJl2r9SjPt3cMzLdOB6eJ4Pnpas8hVoWPYemX/peO0yrrvldgCUVJqOAjUrOLjbxg==", + "node_modules/@prisma/get-platform": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.2.0.tgz", + "integrity": "sha512-k1V0l0Td1732EHpAfi2eySTezyllok9dXb6UQanajkJQzPUGi3vO2z7jdkz67SypFTdmbnyGYxvEvYZdZsMAVA==", "license": "Apache-2.0", "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" + "@prisma/debug": "7.2.0" } }, - "node_modules/@smithy/util-body-length-node": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.2.1.tgz", - "integrity": "sha512-h53dz/pISVrVrfxV1iqXlx5pRg3V2YWFcSQyPyXZRrZoZj4R4DeWRDo1a7dd3CPTcFi3kE+98tuNyD2axyZReA==", + "node_modules/@prisma/get-platform/node_modules/@prisma/debug": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.2.0.tgz", + "integrity": "sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw==", + "license": "Apache-2.0" + }, + "node_modules/@prisma/query-plan-executor": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@prisma/query-plan-executor/-/query-plan-executor-7.2.0.tgz", + "integrity": "sha512-EOZmNzcV8uJ0mae3DhTsiHgoNCuu1J9mULQpGCh62zN3PxPTd+qI9tJvk5jOst8WHKQNwJWR3b39t0XvfBB0WQ==", + "license": "Apache-2.0" + }, + "node_modules/@prisma/streams-local": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@prisma/streams-local/-/streams-local-0.1.2.tgz", + "integrity": "sha512-l49yTxKKF2odFxaAXTmwmkBKL3+bVQ1tFOooGifu4xkdb9NMNLxHj27XAhTylWZod8I+ISGM5erU1xcl/oBCtg==", "license": "Apache-2.0", "dependencies": { - "tslib": "^2.6.2" + "ajv": "^8.12.0", + "better-result": "^2.7.0", + "env-paths": "^3.0.0", + "proper-lockfile": "^4.1.2" }, "engines": { - "node": ">=18.0.0" + "bun": ">=1.3.6", + "node": ">=22.0.0" } }, - "node_modules/@smithy/util-buffer-from": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.2.0.tgz", - "integrity": "sha512-kAY9hTKulTNevM2nlRtxAG2FQ3B2OR6QIrPY3zE5LqJy1oxzmgBGsHLWTcNhWXKchgA0WHW+mZkQrng/pgcCew==", + "node_modules/@prisma/studio-core": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@prisma/studio-core/-/studio-core-0.27.3.tgz", + "integrity": "sha512-AADjNFPdsrglxHQVTmHFqv6DuKQZ5WY4p5/gVFY017twvNrSwpLJ9lqUbYYxEu2W7nbvVxTZA8deJ8LseNALsw==", "license": "Apache-2.0", "dependencies": { - "@smithy/is-array-buffer": "^4.2.0", - "tslib": "^2.6.2" + "@radix-ui/react-toggle": "1.1.10", + "chart.js": "4.5.1" }, "engines": { - "node": ">=18.0.0" + "node": "^20.19 || ^22.12 || >=24.0", + "pnpm": "8" + }, + "peerDependencies": { + "@types/react": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" } }, - "node_modules/@smithy/util-config-provider": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.2.0.tgz", - "integrity": "sha512-YEjpl6XJ36FTKmD+kRJJWYvrHeUvm5ykaUS5xK+6oXffQPHeEM4/nXlZPe+Wu0lsgRUcNZiliYNh/y7q9c2y6Q==", - "license": "Apache-2.0", + "node_modules/@promster/express": { + "version": "15.5.1", + "resolved": "https://registry.npmjs.org/@promster/express/-/express-15.5.1.tgz", + "integrity": "sha512-fwiUQnEsPHTh4e5kFdlQc3i/6rCVq0GofgQ1RZFnRtRsDKnV3QjENbUwPob8zlid6xD6P4qIaPC955mKSnpRzg==", + "license": "MIT", "dependencies": { - "tslib": "^2.6.2" + "@promster/metrics": "15.5.1", + "merge-options": "3.0.4", + "tslib": "2.8.1" }, "engines": { - "node": ">=18.0.0" + "node": ">=20", + "npm": ">=8" } }, - "node_modules/@smithy/util-defaults-mode-browser": { - "version": "4.3.11", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.11.tgz", - "integrity": "sha512-yHv+r6wSQXEXTPVCIQTNmXVWs7ekBTpMVErjqZoWkYN75HIFN5y9+/+sYOejfAuvxWGvgzgxbTHa/oz61YTbKw==", - "license": "Apache-2.0", + "node_modules/@promster/metrics": { + "version": "15.5.1", + "resolved": "https://registry.npmjs.org/@promster/metrics/-/metrics-15.5.1.tgz", + "integrity": "sha512-wQ84Pt8EVv9Emr2lt0ZRdrwd7u32RhIDNwOj0KGGj5huZrzEMIt2blOXWpV9FI3T00JP+Y6bztN+j120L8x0cw==", + "license": "MIT", "dependencies": { - "@smithy/property-provider": "^4.2.5", - "@smithy/smithy-client": "^4.9.8", - "@smithy/types": "^4.9.0", - "tslib": "^2.6.2" + "@chainsafe/prometheus-gc-stats": "1.0.2", + "es-toolkit": "1.46.1", + "merge-options": "3.0.4", + "tslib": "2.8.1", + "url-value-parser": "2.2.0" }, "engines": { - "node": ">=18.0.0" + "node": ">=20", + "npm": ">=8" + }, + "peerDependencies": { + "prom-client": "13.x.x || 14.x || 15.x" } }, - "node_modules/@smithy/util-defaults-mode-node": { - "version": "4.2.14", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.14.tgz", - "integrity": "sha512-ljZN3iRvaJUgulfvobIuG97q1iUuCMrvXAlkZ4msY+ZuVHQHDIqn7FKZCEj+bx8omz6kF5yQXms/xhzjIO5XiA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/config-resolver": "^4.4.3", - "@smithy/credential-provider-imds": "^4.2.5", - "@smithy/node-config-provider": "^4.3.5", - "@smithy/property-provider": "^4.2.5", - "@smithy/smithy-client": "^4.9.8", - "@smithy/types": "^4.9.0", - "tslib": "^2.6.2" + "node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, - "engines": { - "node": ">=18.0.0" + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@smithy/util-endpoints": { - "version": "3.2.5", - "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.2.5.tgz", - "integrity": "sha512-3O63AAWu2cSNQZp+ayl9I3NapW1p1rR5mlVHcF6hAB1dPZUQFfRPYtplWX/3xrzWthPGj5FqB12taJJCfH6s8A==", - "license": "Apache-2.0", + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", "dependencies": { - "@smithy/node-config-provider": "^4.3.5", - "@smithy/types": "^4.9.0", - "tslib": "^2.6.2" + "@radix-ui/react-slot": "1.2.3" }, - "engines": { - "node": ">=18.0.0" + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } } }, - "node_modules/@smithy/util-hex-encoding": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.0.tgz", - "integrity": "sha512-CCQBwJIvXMLKxVbO88IukazJD9a4kQ9ZN7/UMGBjBcJYvatpWk+9g870El4cB8/EJxfe+k+y0GmR9CAzkF+Nbw==", - "license": "Apache-2.0", + "node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", "dependencies": { - "tslib": "^2.6.2" + "@radix-ui/react-compose-refs": "1.1.2" }, - "engines": { - "node": ">=18.0.0" + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@smithy/util-middleware": { - "version": "4.2.5", - "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.5.tgz", - "integrity": "sha512-6Y3+rvBF7+PZOc40ybeZMcGln6xJGVeY60E7jy9Mv5iKpMJpHgRE6dKy9ScsVxvfAYuEX4Q9a65DQX90KaQ3bA==", - "license": "Apache-2.0", + "node_modules/@radix-ui/react-toggle": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz", + "integrity": "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==", + "license": "MIT", "dependencies": { - "@smithy/types": "^4.9.0", - "tslib": "^2.6.2" + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" }, - "engines": { - "node": ">=18.0.0" + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } } }, - "node_modules/@smithy/util-retry": { - "version": "4.2.5", - "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.2.5.tgz", - "integrity": "sha512-GBj3+EZBbN4NAqJ/7pAhsXdfzdlznOh8PydUijy6FpNIMnHPSMO2/rP4HKu+UFeikJxShERk528oy7GT79YiJg==", - "license": "Apache-2.0", + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "license": "MIT", "dependencies": { - "@smithy/service-error-classification": "^4.2.5", - "@smithy/types": "^4.9.0", - "tslib": "^2.6.2" + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" }, - "engines": { - "node": ">=18.0.0" + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@smithy/util-stream": { - "version": "4.5.6", - "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.6.tgz", - "integrity": "sha512-qWw/UM59TiaFrPevefOZ8CNBKbYEP6wBAIlLqxn3VAIo9rgnTNc4ASbVrqDmhuwI87usnjhdQrxodzAGFFzbRQ==", - "license": "Apache-2.0", + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "license": "MIT", "dependencies": { - "@smithy/fetch-http-handler": "^5.3.6", - "@smithy/node-http-handler": "^4.4.5", - "@smithy/types": "^4.9.0", - "@smithy/util-base64": "^4.3.0", - "@smithy/util-buffer-from": "^4.2.0", - "@smithy/util-hex-encoding": "^4.2.0", - "@smithy/util-utf8": "^4.2.0", - "tslib": "^2.6.2" + "@radix-ui/react-use-layout-effect": "1.1.1" }, - "engines": { - "node": ">=18.0.0" + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@smithy/util-uri-escape": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.2.0.tgz", - "integrity": "sha512-igZpCKV9+E/Mzrpq6YacdTQ0qTiLm85gD6N/IrmyDvQFA4UnU3d5g3m8tMT/6zG/vVkWSU+VxeUyGonL62DuxA==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, - "engines": { - "node": ">=18.0.0" + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@smithy/util-utf8": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.0.tgz", - "integrity": "sha512-zBPfuzoI8xyBtR2P6WQj63Rz8i3AmfAaJLuNG8dWsfvPe8lO4aCPYLn879mEgHndZH1zQ2oXmG8O1GGzzaoZiw==", - "license": "Apache-2.0", + "node_modules/@sinclair/typebox": { + "version": "0.34.41", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.41.tgz", + "integrity": "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", + "peer": true, "dependencies": { - "@smithy/util-buffer-from": "^4.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" + "type-detect": "4.0.8" } }, - "node_modules/@smithy/uuid": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@smithy/uuid/-/uuid-1.1.0.tgz", - "integrity": "sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw==", - "license": "Apache-2.0", + "node_modules/@sinonjs/fake-timers": { + "version": "13.0.5", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-13.0.5.tgz", + "integrity": "sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==", + "dev": true, + "license": "BSD-3-Clause", + "peer": true, "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" + "@sinonjs/commons": "^3.0.1" } }, "node_modules/@standard-schema/spec": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", - "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", "license": "MIT" }, "node_modules/@tokenizer/inflate": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.3.1.tgz", - "integrity": "sha512-4oeoZEBQdLdt5WmP/hx1KZ6D3/Oid/0cUb2nk4F0pTDAWy+KCH3/EnAkZF/bvckWo8I33EqBm01lIPgmgc8rCA==", + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz", + "integrity": "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==", "license": "MIT", "dependencies": { - "debug": "^4.4.1", - "fflate": "^0.8.2", - "token-types": "^6.0.0" + "debug": "^4.4.3", + "token-types": "^6.1.1" }, "engines": { "node": ">=18" @@ -4249,6 +3131,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "tslib": "^2.4.0" } @@ -4268,6 +3151,7 @@ "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", @@ -4282,6 +3166,7 @@ "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/types": "^7.0.0" } @@ -4292,6 +3177,7 @@ "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" @@ -4303,6 +3189,7 @@ "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/types": "^7.28.2" } @@ -4341,7 +3228,6 @@ "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "*", "@types/json-schema": "*" @@ -4358,6 +3244,13 @@ "@types/estree": "*" } }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -4366,15 +3259,15 @@ "license": "MIT" }, "node_modules/@types/express": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.5.tgz", - "integrity": "sha512-LuIQOcb6UmnF7C1PCFmEU1u2hmiHL43fgFQX67sN3H4Z+0Yk0Neo++mFsBjhOAuLzvlQeqAAkeDOZrJs9rzumQ==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", "dev": true, "license": "MIT", "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", - "@types/serve-static": "^1" + "@types/serve-static": "^2" } }, "node_modules/@types/express-serve-static-core": { @@ -4455,47 +3348,24 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/mime": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", - "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/minimatch": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.5.tgz", - "integrity": "sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ==", - "license": "MIT", - "optional": true - }, "node_modules/@types/node": { - "version": "24.10.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", - "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", + "version": "25.9.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz", + "integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==", "license": "MIT", - "peer": true, "dependencies": { - "undici-types": "~7.16.0" + "undici-types": ">=7.24.0 <7.24.7" } }, "node_modules/@types/nodemailer": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.4.tgz", - "integrity": "sha512-ee8fxWqOchH+Hv6MDDNNy028kwvVnLplrStm4Zf/3uHWw5zzo8FoYYeffpJtGs2wWysEumMH0ZIdMGMY1eMAow==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.0.tgz", + "integrity": "sha512-fyf8jWULsCo0d0BuoQ75i6IeoHs47qcqxWc7yUdUcV0pOZGjUTTOvwdG1PRXUDqN/8A64yQdQdnA2pZgcdi+cA==", "license": "MIT", "dependencies": { - "@aws-sdk/client-sesv2": "^3.839.0", "@types/node": "*" } }, - "node_modules/@types/parse-json": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", - "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", - "license": "MIT", - "optional": true - }, "node_modules/@types/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", @@ -4510,6 +3380,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/react": { + "version": "19.2.15", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz", + "integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==", + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, "node_modules/@types/readdir-glob": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/@types/readdir-glob/-/readdir-glob-1.1.5.tgz", @@ -4530,25 +3410,13 @@ } }, "node_modules/@types/serve-static": { - "version": "1.15.10", - "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", - "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", "dev": true, "license": "MIT", "dependencies": { "@types/http-errors": "*", - "@types/node": "*", - "@types/send": "<1" - } - }, - "node_modules/@types/serve-static/node_modules/@types/send": { - "version": "0.17.6", - "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", - "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/mime": "^1", "@types/node": "*" } }, @@ -4573,9 +3441,9 @@ } }, "node_modules/@types/supertest": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-6.0.3.tgz", - "integrity": "sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-7.2.0.tgz", + "integrity": "sha512-uh2Lv57xvggst6lCqNdFAmDSvoMG7M/HDtX4iUCquxQ5EGPtaPM5PL5Hmi7LCvOG8db7YaCPNJEeoI8s/WzIQw==", "dev": true, "license": "MIT", "dependencies": { @@ -4607,21 +3475,20 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.47.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.47.0.tgz", - "integrity": "sha512-fe0rz9WJQ5t2iaLfdbDc9T80GJy0AeO453q8C3YCilnGozvOyCG5t+EZtg7j7D88+c3FipfP/x+wzGnh1xp8ZA==", + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.4.tgz", + "integrity": "sha512-PegsU+XfyJJNjd4+u/k6f9yTyp0lEXXiPopUNobZcIAUJFGICFLN+sP0Rb3JehVmiij1Ph0dFGYqODoRo/2+6A==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.47.0", - "@typescript-eslint/type-utils": "8.47.0", - "@typescript-eslint/utils": "8.47.0", - "@typescript-eslint/visitor-keys": "8.47.0", - "graphemer": "^1.4.0", - "ignore": "^7.0.0", + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.59.4", + "@typescript-eslint/type-utils": "8.59.4", + "@typescript-eslint/utils": "8.59.4", + "@typescript-eslint/visitor-keys": "8.59.4", + "ignore": "^7.0.5", "natural-compare": "^1.4.0", - "ts-api-utils": "^2.1.0" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4631,24 +3498,23 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.47.0", - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "@typescript-eslint/parser": "^8.59.4", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/parser": { - "version": "8.47.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.47.0.tgz", - "integrity": "sha512-lJi3PfxVmo0AkEY93ecfN+r8SofEqZNGByvHAI3GBLrvt1Cw6H5k1IM02nSzu0RfUafr2EvFSw0wAsZgubNplQ==", + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.4.tgz", + "integrity": "sha512-zORHqO/tuhxY1zWuTvMUqddRxpiFJ72xVfcNoWpqdLjs6lfPbuQBJuW4pk+49/uBMy7Ssr4bzgjiKmmDB1UbZQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@typescript-eslint/scope-manager": "8.47.0", - "@typescript-eslint/types": "8.47.0", - "@typescript-eslint/typescript-estree": "8.47.0", - "@typescript-eslint/visitor-keys": "8.47.0", - "debug": "^4.3.4" + "@typescript-eslint/scope-manager": "8.59.4", + "@typescript-eslint/types": "8.59.4", + "@typescript-eslint/typescript-estree": "8.59.4", + "@typescript-eslint/visitor-keys": "8.59.4", + "debug": "^4.4.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4658,20 +3524,20 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.47.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.47.0.tgz", - "integrity": "sha512-2X4BX8hUeB5JcA1TQJ7GjcgulXQ+5UkNb0DL8gHsHUHdFoiCTJoYLTpib3LtSDPZsRET5ygN4qqIWrHyYIKERA==", + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.4.tgz", + "integrity": "sha512-Ly00Vu4oAacfDeHp2Zg85ioNG6l8HG+tN1D7J+xTHSxu9y0awYKJ2zH1rFBn8ZSfuGK+7FxK3Cgl3uAz0aZZLg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.47.0", - "@typescript-eslint/types": "^8.47.0", - "debug": "^4.3.4" + "@typescript-eslint/tsconfig-utils": "^8.59.4", + "@typescript-eslint/types": "^8.59.4", + "debug": "^4.4.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4681,18 +3547,18 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.47.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.47.0.tgz", - "integrity": "sha512-a0TTJk4HXMkfpFkL9/WaGTNuv7JWfFTQFJd6zS9dVAjKsojmv9HT55xzbEpnZoY+VUb+YXLMp+ihMLz/UlZfDg==", + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.4.tgz", + "integrity": "sha512-mUeR/3H1WrTAddJrwut8OoPjfauaztMQmRwV5fQTUyNVJCLiUXXe4lGEyYIL2oFDpP7UtgbGJXCt72wT0z2S3Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.47.0", - "@typescript-eslint/visitor-keys": "8.47.0" + "@typescript-eslint/types": "8.59.4", + "@typescript-eslint/visitor-keys": "8.59.4" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4703,9 +3569,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.47.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.47.0.tgz", - "integrity": "sha512-ybUAvjy4ZCL11uryalkKxuT3w3sXJAuWhOoGS3T/Wu+iUu1tGJmk5ytSY8gbdACNARmcYEB0COksD2j6hfGK2g==", + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.4.tgz", + "integrity": "sha512-DLCpnKgD4alVxTBSKulK+gU1KCqOgUXfDRDXh2mZgzokQKa/70ax93I2uVO3m/LLvIAtWZIFoiifudmIqAxpMA==", "dev": true, "license": "MIT", "engines": { @@ -4716,21 +3582,21 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.47.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.47.0.tgz", - "integrity": "sha512-QC9RiCmZ2HmIdCEvhd1aJELBlD93ErziOXXlHEZyuBo3tBiAZieya0HLIxp+DoDWlsQqDawyKuNEhORyku+P8A==", + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.4.tgz", + "integrity": "sha512-uonTuPAAKr9XaBGqJ3LjYTh72zy5DyGesljO9gtmk/eFW0W1fRHjnwVYKB35Lm8d5Q5CluEW3gPHjTvZTmgrfA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.47.0", - "@typescript-eslint/typescript-estree": "8.47.0", - "@typescript-eslint/utils": "8.47.0", - "debug": "^4.3.4", - "ts-api-utils": "^2.1.0" + "@typescript-eslint/types": "8.59.4", + "@typescript-eslint/typescript-estree": "8.59.4", + "@typescript-eslint/utils": "8.59.4", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4740,14 +3606,14 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/types": { - "version": "8.47.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.47.0.tgz", - "integrity": "sha512-nHAE6bMKsizhA2uuYZbEbmp5z2UpffNrPEqiKIeN7VsV6UY/roxanWfoRrf6x/k9+Obf+GQdkm0nPU+vnMXo9A==", + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.4.tgz", + "integrity": "sha512-F1o7WJcCq+bc8dwcO/YsSEOudAH8RDtaOhM6wcAQhcUsFhnWQl81JKy48q1hoxAU0qrzM89+31GYh1515Zde3Q==", "dev": true, "license": "MIT", "engines": { @@ -4759,22 +3625,21 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.47.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.47.0.tgz", - "integrity": "sha512-k6ti9UepJf5NpzCjH31hQNLHQWupTRPhZ+KFF8WtTuTpy7uHPfeg2NM7cP27aCGajoEplxJDFVCEm9TGPYyiVg==", + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.4.tgz", + "integrity": "sha512-F+RuOmcDXo4+TPdfd/TCLS3m2nw8gE9XXyZLrA3JBfaA5tz9TtdkyD3YJFmPxulyc2cKbEok/CvFE3MgSLWnag==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.47.0", - "@typescript-eslint/tsconfig-utils": "8.47.0", - "@typescript-eslint/types": "8.47.0", - "@typescript-eslint/visitor-keys": "8.47.0", - "debug": "^4.3.4", - "fast-glob": "^3.3.2", - "is-glob": "^4.0.3", - "minimatch": "^9.0.4", - "semver": "^7.6.0", - "ts-api-utils": "^2.1.0" + "@typescript-eslint/project-service": "8.59.4", + "@typescript-eslint/tsconfig-utils": "8.59.4", + "@typescript-eslint/types": "8.59.4", + "@typescript-eslint/visitor-keys": "8.59.4", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4784,20 +3649,59 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/@typescript-eslint/utils": { - "version": "8.47.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.47.0.tgz", - "integrity": "sha512-g7XrNf25iL4TJOiPqatNuaChyqt49a/onq5YsJ9+hXeugK+41LVg7AxikMfM02PC6jbNtZLCJj6AUcQXJS/jGQ==", + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.4.tgz", + "integrity": "sha512-cYXeNAUsG4lJo5dbc1FcKm+JwIWrj1/UpTORsC6tGMjEZ81DYcvIr9/ueikhMa/Y/gDQYGp+YX9/xQrXje5BJw==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.47.0", - "@typescript-eslint/types": "8.47.0", - "@typescript-eslint/typescript-estree": "8.47.0" + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.59.4", + "@typescript-eslint/types": "8.59.4", + "@typescript-eslint/typescript-estree": "8.59.4" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4807,19 +3711,19 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.47.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.47.0.tgz", - "integrity": "sha512-SIV3/6eftCy1bNzCQoPmbWsRLujS8t5iDIZ4spZOBHqrM+yfX2ogg8Tt3PDTAVKw3sSCiUgg30uOAvK2r9zGjQ==", + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.4.tgz", + "integrity": "sha512-U3gxVaDVnuZKhSspW/MzMxE1kq7zOdc072FcSNoqA1I9p8HyKbBFfEHoWckBAMgNMph4MamwS5iTVzFmrnt8TQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.47.0", - "eslint-visitor-keys": "^4.2.1" + "@typescript-eslint/types": "8.59.4", + "eslint-visitor-keys": "^5.0.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4830,13 +3734,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" @@ -4847,7 +3751,8 @@ "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", "dev": true, - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/@unrs/resolver-binding-android-arm-eabi": { "version": "1.11.1", @@ -4861,7 +3766,8 @@ "optional": true, "os": [ "android" - ] + ], + "peer": true }, "node_modules/@unrs/resolver-binding-android-arm64": { "version": "1.11.1", @@ -4875,7 +3781,8 @@ "optional": true, "os": [ "android" - ] + ], + "peer": true }, "node_modules/@unrs/resolver-binding-darwin-arm64": { "version": "1.11.1", @@ -4889,7 +3796,8 @@ "optional": true, "os": [ "darwin" - ] + ], + "peer": true }, "node_modules/@unrs/resolver-binding-darwin-x64": { "version": "1.11.1", @@ -4903,7 +3811,8 @@ "optional": true, "os": [ "darwin" - ] + ], + "peer": true }, "node_modules/@unrs/resolver-binding-freebsd-x64": { "version": "1.11.1", @@ -4917,7 +3826,8 @@ "optional": true, "os": [ "freebsd" - ] + ], + "peer": true }, "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { "version": "1.11.1", @@ -4931,7 +3841,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { "version": "1.11.1", @@ -4945,7 +3856,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { "version": "1.11.1", @@ -4959,7 +3871,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@unrs/resolver-binding-linux-arm64-musl": { "version": "1.11.1", @@ -4973,7 +3886,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { "version": "1.11.1", @@ -4987,7 +3901,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { "version": "1.11.1", @@ -5001,7 +3916,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { "version": "1.11.1", @@ -5015,7 +3931,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { "version": "1.11.1", @@ -5029,7 +3946,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@unrs/resolver-binding-linux-x64-gnu": { "version": "1.11.1", @@ -5043,7 +3961,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@unrs/resolver-binding-linux-x64-musl": { "version": "1.11.1", @@ -5057,7 +3976,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@unrs/resolver-binding-wasm32-wasi": { "version": "1.11.1", @@ -5069,6 +3989,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@napi-rs/wasm-runtime": "^0.2.11" }, @@ -5088,7 +4009,8 @@ "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { "version": "1.11.1", @@ -5102,7 +4024,8 @@ "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@unrs/resolver-binding-win32-x64-msvc": { "version": "1.11.1", @@ -5116,78 +4039,8 @@ "optional": true, "os": [ "win32" - ] - }, - "node_modules/@vue/compiler-core": { - "version": "3.5.24", - "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.24.tgz", - "integrity": "sha512-eDl5H57AOpNakGNAkFDH+y7kTqrQpJkZFXhWZQGyx/5Wh7B1uQYvcWkvZi11BDhscPgj8N7XV3oRwiPnx1Vrig==", - "license": "MIT", - "optional": true, - "dependencies": { - "@babel/parser": "^7.28.5", - "@vue/shared": "3.5.24", - "entities": "^4.5.0", - "estree-walker": "^2.0.2", - "source-map-js": "^1.2.1" - } - }, - "node_modules/@vue/compiler-dom": { - "version": "3.5.24", - "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.24.tgz", - "integrity": "sha512-1QHGAvs53gXkWdd3ZMGYuvQFXHW4ksKWPG8HP8/2BscrbZ0brw183q2oNWjMrSWImYLHxHrx1ItBQr50I/q2zw==", - "license": "MIT", - "optional": true, - "dependencies": { - "@vue/compiler-core": "3.5.24", - "@vue/shared": "3.5.24" - } - }, - "node_modules/@vue/compiler-sfc": { - "version": "3.5.24", - "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.24.tgz", - "integrity": "sha512-8EG5YPRgmTB+YxYBM3VXy8zHD9SWHUJLIGPhDovo3Z8VOgvP+O7UP5vl0J4BBPWYD9vxtBabzW1EuEZ+Cqs14g==", - "license": "MIT", - "optional": true, - "dependencies": { - "@babel/parser": "^7.28.5", - "@vue/compiler-core": "3.5.24", - "@vue/compiler-dom": "3.5.24", - "@vue/compiler-ssr": "3.5.24", - "@vue/shared": "3.5.24", - "estree-walker": "^2.0.2", - "magic-string": "^0.30.21", - "postcss": "^8.5.6", - "source-map-js": "^1.2.1" - } - }, - "node_modules/@vue/compiler-sfc/node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "license": "MIT", - "optional": true, - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/@vue/compiler-ssr": { - "version": "3.5.24", - "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.24.tgz", - "integrity": "sha512-trOvMWNBMQ/odMRHW7Ae1CdfYx+7MuiQu62Jtu36gMLXcaoqKvAyh+P73sYG9ll+6jLB6QPovqoKGGZROzkFFg==", - "license": "MIT", - "optional": true, - "dependencies": { - "@vue/compiler-dom": "3.5.24", - "@vue/shared": "3.5.24" - } - }, - "node_modules/@vue/shared": { - "version": "3.5.24", - "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.24.tgz", - "integrity": "sha512-9cwHL2EsJBdi8NY22pngYYWzkTDhld6fAD6jlaeloNGciNSJL6bLpbxVgXl96X00Jtc6YWQv96YA/0sxex/k1A==", - "license": "MIT", - "optional": true + ], + "peer": true }, "node_modules/@webassemblyjs/ast": { "version": "1.14.1", @@ -5364,13 +4217,6 @@ "dev": true, "license": "Apache-2.0" }, - "node_modules/abbrev": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", - "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", - "license": "ISC", - "optional": true - }, "node_modules/abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", @@ -5396,13 +4242,28 @@ "node": ">= 0.6" } }, + "node_modules/acme-client": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/acme-client/-/acme-client-5.4.0.tgz", + "integrity": "sha512-mORqg60S8iML6XSmVjqjGHJkINrCGLMj2QvDmFzI9vIlv1RGlyjmw3nrzaINJjkNsYXC41XhhD5pfy7CtuGcbA==", + "license": "MIT", + "dependencies": { + "@peculiar/x509": "^1.11.0", + "asn1js": "^3.0.5", + "axios": "^1.7.2", + "debug": "^4.3.5", + "node-forge": "^1.3.1" + }, + "engines": { + "node": ">= 16" + } + }, "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5451,7 +4312,6 @@ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", "license": "MIT", - "optional": true, "dependencies": { "debug": "4" }, @@ -5460,12 +4320,10 @@ } }, "node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", - "dev": true, + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -5533,6 +4391,7 @@ "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "type-fest": "^0.21.3" }, @@ -5549,6 +4408,7 @@ "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", "dev": true, "license": "(MIT OR CC0-1.0)", + "peer": true, "engines": { "node": ">=10" }, @@ -5560,7 +4420,9 @@ "version": "6.2.2", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -5599,6 +4461,7 @@ "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", "dev": true, "license": "ISC", + "peer": true, "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" @@ -5613,6 +4476,7 @@ "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8.6" }, @@ -5626,134 +4490,36 @@ "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", "license": "MIT" }, - "node_modules/aproba": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz", - "integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==", - "license": "ISC", - "optional": true - }, "node_modules/archiver": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/archiver/-/archiver-7.0.1.tgz", - "integrity": "sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-8.0.0.tgz", + "integrity": "sha512-fV1orZfsnPn9BaSByR/qE67rJCLJEy2Ox5bq7nJh+jquWaNh6Sfec75kJ2T6PtdGUbPQlrVoSVCEOa5SdiTQ1g==", "license": "MIT", "dependencies": { - "archiver-utils": "^5.0.2", "async": "^3.2.4", "buffer-crc32": "^1.0.0", + "is-stream": "^4.0.0", + "lazystream": "^1.0.0", + "normalize-path": "^3.0.0", "readable-stream": "^4.0.0", - "readdir-glob": "^1.1.2", + "readdir-glob": "^3.0.0", "tar-stream": "^3.0.0", - "zip-stream": "^6.0.1" + "zip-stream": "^7.0.2" }, "engines": { - "node": ">= 14" + "node": ">=18" } }, - "node_modules/archiver-utils": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-5.0.2.tgz", - "integrity": "sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==", + "node_modules/archiver/node_modules/is-stream": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", "license": "MIT", - "dependencies": { - "glob": "^10.0.0", - "graceful-fs": "^4.2.0", - "is-stream": "^2.0.1", - "lazystream": "^1.0.0", - "lodash": "^4.17.15", - "normalize-path": "^3.0.0", - "readable-stream": "^4.0.0" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/archiver-utils/node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/archiver-utils/node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, - "node_modules/archiver-utils/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "license": "ISC" - }, - "node_modules/archiver-utils/node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, "engines": { - "node": ">=16 || 14 >=14.18" + "node": ">=18" }, "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/are-we-there-yet": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz", - "integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==", - "deprecated": "This package is no longer supported.", - "license": "ISC", - "optional": true, - "dependencies": { - "delegates": "^1.0.0", - "readable-stream": "^3.6.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/are-we-there-yet/node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "license": "MIT", - "optional": true, - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/arg": { @@ -5770,16 +4536,6 @@ "dev": true, "license": "Python-2.0" }, - "node_modules/array-differ": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/array-differ/-/array-differ-3.0.0.tgz", - "integrity": "sha512-THtfYS6KtME/yIAhKjZ2ul7XI96lQGHRputJQHO80LAWQnuGP4iCIN8vdMRboGbIEYBwU33q8Tch1os2+X0kMg==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=8" - } - }, "node_modules/array-timsort": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/array-timsort/-/array-timsort-1.0.3.tgz", @@ -5787,26 +4543,6 @@ "dev": true, "license": "MIT" }, - "node_modules/array-union": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", - "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/arrify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/arrify/-/arrify-2.0.1.tgz", - "integrity": "sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=8" - } - }, "node_modules/asap": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", @@ -5814,6 +4550,20 @@ "dev": true, "license": "MIT" }, + "node_modules/asn1js": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz", + "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==", + "license": "BSD-3-Clause", + "dependencies": { + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.5", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", @@ -5824,9 +4574,29 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true, "license": "MIT" }, + "node_modules/aws-ssl-profiles": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", + "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/axios": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz", + "integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, "node_modules/b4a": { "version": "1.7.3", "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.3.tgz", @@ -5847,6 +4617,7 @@ "integrity": "sha512-0YiBEOxWqKkSQWL9nNGGEgndoeL0ZpWrbLMNL5u/Kaxrli3Eaxlt3ZtIDktEvXt4L/R9r3ODr2zKwGM/2BjxVw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/transform": "30.2.0", "@types/babel__core": "^7.20.5", @@ -5869,6 +4640,7 @@ "integrity": "sha512-D8Z6Qm8jCvVXtIRkBnqNHX0zJ37rQcFJ9u8WOS6tkYOsRdHBzypCstaxWiu5ZIlqQtviRYbgnRLSoCEvjqcqbA==", "dev": true, "license": "BSD-3-Clause", + "peer": true, "workspaces": [ "test/babel-8" ], @@ -5889,6 +4661,7 @@ "integrity": "sha512-ftzhzSGMUnOzcCXd6WHdBGMyuwy15Wnn0iyyWGKgBDLxf9/s5ABuraCSpBX2uG0jUg4rqJnxsLc5+oYBqoxVaA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/babel__core": "^7.20.5" }, @@ -5902,6 +4675,7 @@ "integrity": "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/plugin-syntax-async-generators": "^7.8.4", "@babel/plugin-syntax-bigint": "^7.8.3", @@ -5929,6 +4703,7 @@ "integrity": "sha512-US4Z3NOieAQumwFnYdUWKvUKh8+YSnS/gB3t6YBiz0bskpu7Pine8pPCheNxlPEW4wnUkma2a94YuW2q3guvCQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "babel-plugin-jest-hoist": "30.2.0", "babel-preset-current-node-syntax": "^1.2.0" @@ -5944,6 +4719,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, "license": "MIT" }, "node_modules/bare-events": { @@ -5981,20 +4757,30 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.8.30", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.30.tgz", - "integrity": "sha512-aTUKW4ptQhS64+v2d6IkPzymEzzhw+G0bA1g3uBRV3+ntkH+svttKseW5IOR4Ed6NUVKqnY7qT3dKvzQ7io4AA==", + "version": "2.10.32", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.32.tgz", + "integrity": "sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==", "dev": true, "license": "Apache-2.0", "bin": { - "baseline-browser-mapping": "dist/cli.js" + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" } }, + "node_modules/better-result": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/better-result/-/better-result-2.9.2.tgz", + "integrity": "sha512-WIFoBPCdnTOdk9inkE1ZRvCZ4P0CpSkAiLlchC65N7n9DcjZ3NhqkBOlafzpOVnO8ixyi37kicmSJ3ENhPZl7Q==", + "license": "MIT" + }, "node_modules/bintrees": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/bintrees/-/bintrees-1.0.2.tgz", "integrity": "sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/bl": { "version": "4.1.0", @@ -6024,43 +4810,29 @@ } }, "node_modules/body-parser": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", - "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", "license": "MIT", "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", - "debug": "^4.4.0", + "debug": "^4.4.3", "http-errors": "^2.0.0", - "iconv-lite": "^0.6.3", + "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", - "qs": "^6.14.0", - "raw-body": "^3.0.0", - "type-is": "^2.0.0" + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" }, "engines": { "node": ">=18" - } - }, - "node_modules/body-parser/node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" }, - "engines": { - "node": ">=0.10.0" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/bowser": { - "version": "2.12.1", - "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.12.1.tgz", - "integrity": "sha512-z4rE2Gxh7tvshQ4hluIT7XcFrgLIQaw9X3A+kTTRdovCz5PMukm/0QC/BKSYPj3omF5Qfypn9O/c5kgpmvYUCw==", - "license": "MIT" - }, "node_modules/boxen": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/boxen/-/boxen-5.1.2.tgz", @@ -6125,7 +4897,9 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, "license": "MIT", + "peer": true, "dependencies": { "balanced-match": "^1.0.0" } @@ -6134,7 +4908,7 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "fill-range": "^7.1.1" @@ -6144,9 +4918,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.0", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.0.tgz", - "integrity": "sha512-tbydkR/CxfMwelN0vwdP/pLkDwyAASZ+VfWm4EOwlB6SWhx1sYnWLqo8N5j0rAzPfzfRaxt0mM/4wPU/Su84RQ==", + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", "dev": true, "funding": [ { @@ -6163,13 +4937,12 @@ } ], "license": "MIT", - "peer": true, "dependencies": { - "baseline-browser-mapping": "^2.8.25", - "caniuse-lite": "^1.0.30001754", - "electron-to-chromium": "^1.5.249", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.1.4" + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" @@ -6197,6 +4970,7 @@ "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "node-int64": "^0.4.0" } @@ -6262,26 +5036,26 @@ } }, "node_modules/c12": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz", - "integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==", - "license": "MIT", - "dependencies": { - "chokidar": "^4.0.3", - "confbox": "^0.2.2", - "defu": "^6.1.4", - "dotenv": "^16.6.1", - "exsolve": "^1.0.7", - "giget": "^2.0.0", - "jiti": "^2.4.2", + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/c12/-/c12-3.3.4.tgz", + "integrity": "sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==", + "license": "MIT", + "dependencies": { + "chokidar": "^5.0.0", + "confbox": "^0.2.4", + "defu": "^6.1.6", + "dotenv": "^17.3.1", + "exsolve": "^1.0.8", + "giget": "^3.2.0", + "jiti": "^2.6.1", "ohash": "^2.0.11", "pathe": "^2.0.3", - "perfect-debounce": "^1.0.0", - "pkg-types": "^2.2.0", - "rc9": "^2.1.2" + "perfect-debounce": "^2.1.0", + "pkg-types": "^2.3.0", + "rc9": "^3.0.1" }, "peerDependencies": { - "magicast": "^0.3.5" + "magicast": "*" }, "peerDependenciesMeta": { "magicast": { @@ -6289,6 +5063,34 @@ } } }, + "node_modules/c12/node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/c12/node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -6318,20 +5120,11 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/callsite": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/callsite/-/callsite-1.0.0.tgz", - "integrity": "sha512-0vdNRFXn5q+dtOqjfFtmtlI9N2eVZ7LMyEV2iKC5mEEFvSg/69Ml6b/WU2qF8W1nLRa0wiSrDT3Y5jOHZCwKPQ==", - "optional": true, - "engines": { - "node": "*" - } - }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -6350,9 +5143,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001756", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001756.tgz", - "integrity": "sha512-4HnCNKbMLkLdhJz3TToeVWHSnfJvPaq6vu/eRP0Ahub/07n484XHhBF5AJoSGHdVrS8tKFauUQz8Bp9P7LVx7A==", + "version": "1.0.30001793", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", + "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", "dev": true, "funding": [ { @@ -6392,6 +5185,7 @@ "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" } @@ -6403,6 +5197,18 @@ "dev": true, "license": "MIT" }, + "node_modules/chart.js": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", + "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", + "license": "MIT", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, "node_modules/check-disk-space": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/check-disk-space/-/check-disk-space-3.4.0.tgz", @@ -6416,6 +5222,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, "license": "MIT", "dependencies": { "readdirp": "^4.0.1" @@ -6427,16 +5234,6 @@ "url": "https://paulmillr.com/funding/" } }, - "node_modules/chownr": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", - "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", - "license": "ISC", - "optional": true, - "engines": { - "node": ">=10" - } - }, "node_modules/chrome-trace-event": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", @@ -6463,39 +5260,29 @@ "node": ">=8" } }, - "node_modules/citty": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", - "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==", - "license": "MIT", - "dependencies": { - "consola": "^3.2.3" - } - }, "node_modules/cjs-module-lexer": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.1.1.tgz", "integrity": "sha512-+CmxIZ/L2vNcEfvNtLdU0ZQ6mbq3FZnwAP2PPTiKP+1QOoKwlKlPgb8UKV0Dds7QVaMnHm+FwSft2VB0s/SLjQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/class-transformer": { "version": "0.5.1", "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/class-validator": { - "version": "0.14.2", - "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.2.tgz", - "integrity": "sha512-3kMVRF2io8N8pY1IFIXlho9r8IPUUIfHe2hYVtiebvAzU2XeQFXTv+XI4WX+TnXmtwXMDcjngcpkiPM0O9PvLw==", + "version": "0.15.1", + "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.15.1.tgz", + "integrity": "sha512-LqoS80HBBSCVhz/3KloUly0ovokxpdOLR++Al3J3+dHXWt9sTKlKd4eYtoxhxyUjoe5+UcIM+5k9MIxyBWnRTw==", "license": "MIT", - "peer": true, "dependencies": { - "@types/validator": "^13.11.8", + "@types/validator": "^13.15.3", "libphonenumber-js": "^1.11.1", - "validator": "^13.9.0" + "validator": "^13.15.22" } }, "node_modules/cli-boxes": { @@ -6568,6 +5355,7 @@ "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", "dev": true, "license": "ISC", + "peer": true, "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", @@ -6583,6 +5371,7 @@ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -6593,6 +5382,7 @@ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1" }, @@ -6606,6 +5396,7 @@ "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", @@ -6634,6 +5425,7 @@ "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "iojs": ">= 1.0.0", "node": ">= 0.12.0" @@ -6644,7 +5436,8 @@ "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz", "integrity": "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/color-convert": { "version": "2.0.1", @@ -6664,21 +5457,10 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, - "node_modules/color-support": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", - "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", - "license": "ISC", - "optional": true, - "bin": { - "color-support": "bin.js" - } - }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" @@ -6698,14 +5480,13 @@ } }, "node_modules/comment-json": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/comment-json/-/comment-json-4.4.1.tgz", - "integrity": "sha512-r1To31BQD5060QdkC+Iheai7gHwoSZobzunqkf2/kQ6xIAfJyrKNAFUwdKvkK7Qgu7pVTKQEa7ok7Ed3ycAJgg==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/comment-json/-/comment-json-5.0.0.tgz", + "integrity": "sha512-uiqLcOiVDJtBP8WGkZHEP+FZIhTzP1dxvn59EfoYUi9gqupjrBWVQkO2atDrbnKPwLeotFYDsuNb26uBMqB+hw==", "dev": true, "license": "MIT", "dependencies": { "array-timsort": "^1.0.3", - "core-util-is": "^1.0.3", "esprima": "^4.0.1" }, "engines": { @@ -6723,26 +5504,38 @@ } }, "node_modules/compress-commons": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-6.0.2.tgz", - "integrity": "sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-7.0.1.tgz", + "integrity": "sha512-g0S8KAD8qf4+V//pr3BfB1aBnARLXNz2Gx+jmHU0LEriUuoQUOPOulVquHKTJ8+EAIIO7fhseNDr9wK5Q9FKBQ==", "license": "MIT", "dependencies": { "crc-32": "^1.2.0", - "crc32-stream": "^6.0.0", - "is-stream": "^2.0.1", + "crc32-stream": "^7.0.1", + "is-stream": "^4.0.0", "normalize-path": "^3.0.0", "readable-stream": "^4.0.0" }, "engines": { - "node": ">= 14" + "node": ">=18" + } + }, + "node_modules/compress-commons/node_modules/is-stream": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/concat-stream": { @@ -6775,9 +5568,9 @@ } }, "node_modules/confbox": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.2.tgz", - "integrity": "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==", + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", "license": "MIT" }, "node_modules/consola": { @@ -6789,13 +5582,6 @@ "node": "^14.18.0 || >=16.10.0" } }, - "node_modules/console-control-strings": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", - "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", - "license": "ISC", - "optional": true - }, "node_modules/content-disposition": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", @@ -6823,7 +5609,8 @@ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/cookie": { "version": "0.7.2", @@ -6857,9 +5644,9 @@ "license": "MIT" }, "node_modules/cors": { - "version": "2.8.5", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", - "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", "license": "MIT", "dependencies": { "object-assign": "^4", @@ -6909,16 +5696,16 @@ } }, "node_modules/crc32-stream": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-6.0.0.tgz", - "integrity": "sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-7.0.1.tgz", + "integrity": "sha512-IBWsY8xznyQrcHn8h4bC8/4ErNke5elzgG8GcqF4RFPw6aHkWWRc7Tgw6upjaTX/CT/yQgqYENkxYsTYN+hW2g==", "license": "MIT", "dependencies": { "crc-32": "^1.2.0", "readable-stream": "^4.0.0" }, "engines": { - "node": ">= 14" + "node": ">=18" } }, "node_modules/create-require": { @@ -6929,357 +5716,147 @@ "license": "MIT" }, "node_modules/cron": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/cron/-/cron-4.3.3.tgz", - "integrity": "sha512-B/CJj5yL3sjtlun6RtYHvoSB26EmQ2NUmhq9ZiJSyKIM4K/fqfh9aelDFlIayD2YMeFZqWLi9hHV+c+pq2Djkw==", + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/cron/-/cron-4.4.0.tgz", + "integrity": "sha512-fkdfq+b+AHI4cKdhZlppHveI/mgz2qpiYxcm+t5E5TsxX7QrLS1VE0+7GENEk9z0EeGPcpSciGv6ez24duWhwQ==", "license": "MIT", "dependencies": { "@types/luxon": "~3.7.0", - "luxon": "~3.7.0" - }, - "engines": { - "node": ">=18.x" - } - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/date-fns": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", - "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/kossnocorp" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/dedent": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.0.tgz", - "integrity": "sha512-HGFtf8yhuhGhqO07SV79tRp+br4MnbdjeVxotpn1QBl30pcLLCQjX5b2295ll0fv8RKDKsmWYrl05usHM9CewQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "babel-plugin-macros": "^3.1.0" - }, - "peerDependenciesMeta": { - "babel-plugin-macros": { - "optional": true - } - } - }, - "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/deepmerge": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", - "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/deepmerge-ts": { - "version": "7.1.5", - "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", - "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/defaults": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", - "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "clone": "^1.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/defu": { - "version": "6.1.4", - "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", - "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", - "license": "MIT" - }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/delegates": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", - "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", - "license": "MIT", - "optional": true - }, - "node_modules/depcheck": { - "version": "1.4.7", - "resolved": "https://registry.npmjs.org/depcheck/-/depcheck-1.4.7.tgz", - "integrity": "sha512-1lklS/bV5chOxwNKA/2XUUk/hPORp8zihZsXflr8x0kLwmcZ9Y9BsS6Hs3ssvA+2wUVbG0U2Ciqvm1SokNjPkA==", - "license": "MIT", - "optional": true, - "dependencies": { - "@babel/parser": "^7.23.0", - "@babel/traverse": "^7.23.2", - "@vue/compiler-sfc": "^3.3.4", - "callsite": "^1.0.0", - "camelcase": "^6.3.0", - "cosmiconfig": "^7.1.0", - "debug": "^4.3.4", - "deps-regex": "^0.2.0", - "findup-sync": "^5.0.0", - "ignore": "^5.2.4", - "is-core-module": "^2.12.0", - "js-yaml": "^3.14.1", - "json5": "^2.2.3", - "lodash": "^4.17.21", - "minimatch": "^7.4.6", - "multimatch": "^5.0.0", - "please-upgrade-node": "^3.2.0", - "readdirp": "^3.6.0", - "require-package-name": "^2.0.1", - "resolve": "^1.22.3", - "resolve-from": "^5.0.0", - "semver": "^7.5.4", - "yargs": "^16.2.0" - }, - "bin": { - "depcheck": "bin/depcheck.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/depcheck/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/depcheck/node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "license": "MIT", - "optional": true, - "dependencies": { - "sprintf-js": "~1.0.2" - } - }, - "node_modules/depcheck/node_modules/cliui": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", - "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", - "license": "ISC", - "optional": true, - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.0", - "wrap-ansi": "^7.0.0" - } - }, - "node_modules/depcheck/node_modules/cosmiconfig": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", - "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", - "license": "MIT", - "optional": true, - "dependencies": { - "@types/parse-json": "^4.0.0", - "import-fresh": "^3.2.1", - "parse-json": "^5.0.0", - "path-type": "^4.0.0", - "yaml": "^1.10.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/depcheck/node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">= 4" - } - }, - "node_modules/depcheck/node_modules/js-yaml": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", - "license": "MIT", - "optional": true, - "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/depcheck/node_modules/minimatch": { - "version": "7.4.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-7.4.6.tgz", - "integrity": "sha512-sBz8G/YjVniEz6lKPNpKxXwazJe4c19fEfV2GDMX6AjFz+MX9uDWIZW8XreVhkFW3fkIdTv/gxWr/Kks5FFAVw==", - "license": "ISC", - "optional": true, - "dependencies": { - "brace-expansion": "^2.0.1" + "luxon": "~3.7.0" }, "engines": { - "node": ">=10" + "node": ">=18.x" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "type": "ko-fi", + "url": "https://ko-fi.com/intcreator" } }, - "node_modules/depcheck/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "license": "MIT", - "optional": true, - "engines": { - "node": ">=8.6" + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT", + "peer": true + }, + "node_modules/date-fns": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.3.0.tgz", + "integrity": "sha512-OYcL+3N/jyWbYdFGqoMAhytDgxP9pbYPUUiRCOgn4Fewaadk9l/Wam4Avciiyp2BgkpfQyBV9B+ehnVJych+eQ==", + "license": "MIT", "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" } }, - "node_modules/depcheck/node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", - "optional": true, "dependencies": { - "picomatch": "^2.2.1" + "ms": "^2.1.3" }, "engines": { - "node": ">=8.10.0" + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } } }, - "node_modules/depcheck/node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "node_modules/dedent": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.0.tgz", + "integrity": "sha512-HGFtf8yhuhGhqO07SV79tRp+br4MnbdjeVxotpn1QBl30pcLLCQjX5b2295ll0fv8RKDKsmWYrl05usHM9CewQ==", + "dev": true, "license": "MIT", - "optional": true, - "engines": { - "node": ">=8" + "peer": true, + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } } }, - "node_modules/depcheck/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, "license": "MIT", - "optional": true, - "dependencies": { - "ansi-regex": "^5.0.1" - }, "engines": { - "node": ">=8" + "node": ">=0.10.0" } }, - "node_modules/depcheck/node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "node_modules/deepmerge-ts": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", + "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/defaults": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", + "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", + "dev": true, "license": "MIT", - "optional": true, "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" + "clone": "^1.0.2" }, "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/depcheck/node_modules/yargs": { - "version": "16.2.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", - "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "node_modules/defu": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", "license": "MIT", - "optional": true, - "dependencies": { - "cliui": "^7.0.2", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.0", - "y18n": "^5.0.5", - "yargs-parser": "^20.2.2" - }, "engines": { - "node": ">=10" + "node": ">=0.4.0" } }, - "node_modules/depcheck/node_modules/yargs-parser": { - "version": "20.2.9", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", - "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", - "license": "ISC", - "optional": true, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", "engines": { - "node": ">=10" + "node": ">=0.10" } }, "node_modules/depd": { @@ -7291,45 +5868,19 @@ "node": ">= 0.8" } }, - "node_modules/deps-regex": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/deps-regex/-/deps-regex-0.2.0.tgz", - "integrity": "sha512-PwuBojGMQAYbWkMXOY9Pd/NWCDNHVH12pnS7WHqZkTSeMESe4hwnKKRp0yR87g37113x4JPbo/oIvXY+s/f56Q==", - "license": "MIT", - "optional": true - }, "node_modules/destr": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", "license": "MIT" }, - "node_modules/detect-file": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/detect-file/-/detect-file-1.0.0.tgz", - "integrity": "sha512-DtCOLG98P007x7wiiOmfI0fi3eIKyWiLTGJ2MDnVi/E04lWGbf+JzrRHMm0rgIIZJGtHpKpbVgLWHrv8xXpc3Q==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "license": "Apache-2.0", - "optional": true, - "engines": { - "node": ">=8" - } - }, "node_modules/detect-newline": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -7356,9 +5907,9 @@ } }, "node_modules/dotenv": { - "version": "16.6.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", - "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", "license": "BSD-2-Clause", "engines": { "node": ">=12" @@ -7385,7 +5936,9 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "license": "MIT" + "dev": true, + "license": "MIT", + "peer": true }, "node_modules/ee-first": { "version": "1.1.1", @@ -7394,9 +5947,9 @@ "license": "MIT" }, "node_modules/effect": { - "version": "3.18.4", - "resolved": "https://registry.npmjs.org/effect/-/effect-3.18.4.tgz", - "integrity": "sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA==", + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/effect/-/effect-3.20.0.tgz", + "integrity": "sha512-qMLfDJscrNG8p/aw+IkT9W7fgj50Z4wG5bLBy0Txsxz8iUHjDIkOgO3SV0WZfnQbNG2VJYb0b+rDLMrhM4+Krw==", "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.0.0", @@ -7404,9 +5957,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.259", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.259.tgz", - "integrity": "sha512-I+oLXgpEJzD6Cwuwt1gYjxsDmu/S/Kd41mmLA3O+/uH2pFRO/DvOjUyGozL8j3KeLV6WyZ7ssPwELMsXCcsJAQ==", + "version": "1.5.361", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.361.tgz", + "integrity": "sha512-Q6Hts7N9FnJc5LeGRINFvLhCI9xZmNtTDe5ZbcVezQz7cU4a8Aua3GH1b8J2XY8Al9PF+OCwYqhgsOOheMdvkA==", "dev": true, "license": "ISC" }, @@ -7416,6 +5969,7 @@ "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -7448,37 +6002,36 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.18.3", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", - "integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==", + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.22.0.tgz", + "integrity": "sha512-xYcDWrpELkFzz9SpZ3PlI6Eu6eD93Yf0WLDRxikGhWJ3MAir2SNZTIVCVZqZ/NUyx8AdMc2gT9C0gPiw18kG+A==", "dev": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", - "tapable": "^2.2.0" + "tapable": "^2.3.3" }, "engines": { "node": ">=10.13.0" } }, - "node_modules/entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", - "license": "BSD-2-Clause", - "optional": true, + "node_modules/env-paths": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", + "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", + "license": "MIT", "engines": { - "node": ">=0.12" + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" }, "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/error-ex": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "is-arrayish": "^0.2.1" @@ -7503,9 +6056,9 @@ } }, "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", "dev": true, "license": "MIT" }, @@ -7525,7 +6078,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -7537,11 +6089,21 @@ "node": ">= 0.4" } }, + "node_modules/es-toolkit": { + "version": "1.46.1", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.46.1.tgz", + "integrity": "sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -7567,34 +6129,30 @@ } }, "node_modules/eslint": { - "version": "9.39.1", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz", - "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.4.0.tgz", + "integrity": "sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.1", - "@eslint/config-helpers": "^0.4.2", - "@eslint/core": "^0.17.0", - "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.39.1", - "@eslint/plugin-kit": "^0.4.1", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.6.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", - "ajv": "^6.12.4", - "chalk": "^4.0.0", + "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.4.0", - "eslint-visitor-keys": "^4.2.1", - "espree": "^10.4.0", - "esquery": "^1.5.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", @@ -7604,8 +6162,7 @@ "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", + "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, @@ -7613,7 +6170,7 @@ "eslint": "bin/eslint.js" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://eslint.org/donate" @@ -7633,7 +6190,6 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", - "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -7645,14 +6201,14 @@ } }, "node_modules/eslint-plugin-prettier": { - "version": "5.5.4", - "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.4.tgz", - "integrity": "sha512-swNtI95SToIz05YINMA6Ox5R057IMAmWZ26GqPxusAp1TZzj+IdY9tXNWWD3vkF/wEqydCONcwjTFpxybBqZsg==", + "version": "5.5.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.5.tgz", + "integrity": "sha512-hscXkbqUZ2sPithAuLm5MXL+Wph+U7wHngPBv9OMWwlP8iaflyxpjTYZkmdgB4/vPIhemRlBEoLrH7UC1n7aUw==", "dev": true, "license": "MIT", "dependencies": { - "prettier-linter-helpers": "^1.0.0", - "synckit": "^0.11.7" + "prettier-linter-helpers": "^1.0.1", + "synckit": "^0.11.12" }, "engines": { "node": "^14.18.0 || >=16.0.0" @@ -7676,17 +6232,19 @@ } }, "node_modules/eslint-scope": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", - "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" @@ -7706,9 +6264,9 @@ } }, "node_modules/eslint/node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", "dependencies": { @@ -7722,25 +6280,37 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/eslint/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" } }, "node_modules/eslint/node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" @@ -7764,44 +6334,47 @@ "license": "MIT" }, "node_modules/eslint/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^1.1.7" + "brace-expansion": "^5.0.5" }, "engines": { - "node": "*" + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/espree": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", - "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "acorn": "^8.15.0", + "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.1" + "eslint-visitor-keys": "^5.0.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" } }, "node_modules/espree/node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" @@ -7811,7 +6384,7 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "devOptional": true, + "dev": true, "license": "BSD-2-Clause", "bin": { "esparse": "bin/esparse.js", @@ -7822,9 +6395,9 @@ } }, "node_modules/esquery": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", - "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -7857,13 +6430,6 @@ "node": ">=4.0" } }, - "node_modules/estree-walker": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", - "license": "MIT", - "optional": true - }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -7916,6 +6482,7 @@ "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", @@ -7939,7 +6506,8 @@ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "dev": true, - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/exit-x": { "version": "0.2.2", @@ -7947,23 +6515,11 @@ "integrity": "sha512-+I6B/IkJc1o/2tiURyz/ivu/O0nKNEArIUB5O7zBrlDVJr22SCLH3xTeEry428LvFhRzIA1g8izguxJ/gbNcVQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 0.8.0" } }, - "node_modules/expand-tilde": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/expand-tilde/-/expand-tilde-2.0.2.tgz", - "integrity": "sha512-A5EmesHW6rfnZ9ysHQjPdJRni0SRar0tjtG5MNtm9n5TUvsYU8oozprtRD4AqHxcZWWlVuAmQo2nWKfN9oyjTw==", - "license": "MIT", - "optional": true, - "dependencies": { - "homedir-polyfill": "^1.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/expect": { "version": "30.2.0", "resolved": "https://registry.npmjs.org/expect/-/expect-30.2.0.tgz", @@ -7983,18 +6539,19 @@ } }, "node_modules/express": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", - "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", "dependencies": { "accepts": "^2.0.0", - "body-parser": "^2.2.0", + "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", + "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", @@ -8072,51 +6629,20 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-diff": { "version": "1.3.0", - "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", - "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/fast-fifo": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", - "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", - "license": "MIT" - }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", + "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } + "license": "Apache-2.0" + }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "license": "MIT" }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", @@ -8139,10 +6665,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", - "dev": true, + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", "funding": [ { "type": "github", @@ -8155,49 +6680,34 @@ ], "license": "BSD-3-Clause" }, - "node_modules/fast-xml-parser": { - "version": "5.2.5", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.2.5.tgz", - "integrity": "sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "dependencies": { - "strnum": "^2.1.0" - }, - "bin": { - "fxparser": "src/cli/cli.js" - } - }, - "node_modules/fastq": { - "version": "1.19.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", - "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" - } - }, "node_modules/fb-watchman": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "bser": "2.1.1" } }, - "node_modules/fflate": { - "version": "0.8.2", - "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", - "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", - "license": "MIT" + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } }, "node_modules/file-entry-cache": { "version": "8.0.0", @@ -8213,14 +6723,14 @@ } }, "node_modules/file-type": { - "version": "21.1.0", - "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.1.0.tgz", - "integrity": "sha512-boU4EHmP3JXkwDo4uhyBhTt5pPstxB6eEXKJBu2yu2l7aAMMm7QQYQEzssJmKReZYrFdFOJS8koVo6bXIBGDqA==", + "version": "21.3.4", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.4.tgz", + "integrity": "sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==", "license": "MIT", "dependencies": { - "@tokenizer/inflate": "^0.3.1", - "strtok3": "^10.3.1", - "token-types": "^6.0.0", + "@tokenizer/inflate": "^0.4.1", + "strtok3": "^10.3.4", + "token-types": "^6.1.1", "uint8array-extras": "^1.4.0" }, "engines": { @@ -8234,7 +6744,7 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "to-regex-range": "^5.0.1" @@ -8277,22 +6787,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/findup-sync": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/findup-sync/-/findup-sync-5.0.0.tgz", - "integrity": "sha512-MzwXju70AuyflbgeOhzvQWAvvQdo1XL0A9bVvlXsYcFEBM87WR4OakL4OfZq+QRmr+duJubio+UtNQCPsVESzQ==", - "license": "MIT", - "optional": true, - "dependencies": { - "detect-file": "^1.0.0", - "is-glob": "^4.0.3", - "micromatch": "^4.0.4", - "resolve-dir": "^1.0.1" - }, - "engines": { - "node": ">= 10.13.0" - } - }, "node_modules/flat-cache": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", @@ -8314,6 +6808,26 @@ "dev": true, "license": "ISC" }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -8386,7 +6900,6 @@ "version": "4.0.5", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", - "dev": true, "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -8403,7 +6916,6 @@ "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -8413,7 +6925,6 @@ "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, "license": "MIT", "dependencies": { "mime-db": "1.52.0" @@ -8473,39 +6984,6 @@ "node": ">=12" } }, - "node_modules/fs-minipass": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", - "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", - "license": "ISC", - "optional": true, - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/fs-minipass/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "license": "ISC", - "optional": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/fs-minipass/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "license": "ISC", - "optional": true - }, "node_modules/fs-monkey": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/fs-monkey/-/fs-monkey-1.1.0.tgz", @@ -8517,8 +6995,9 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "devOptional": true, - "license": "ISC" + "dev": true, + "license": "ISC", + "peer": true }, "node_modules/fsevents": { "version": "2.3.3", @@ -8531,6 +7010,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } @@ -8544,56 +7024,13 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/gauge": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz", - "integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==", - "deprecated": "This package is no longer supported.", - "license": "ISC", - "optional": true, - "dependencies": { - "aproba": "^1.0.3 || ^2.0.0", - "color-support": "^1.1.2", - "console-control-strings": "^1.0.0", - "has-unicode": "^2.0.1", - "object-assign": "^4.1.1", - "signal-exit": "^3.0.0", - "string-width": "^4.2.3", - "strip-ansi": "^6.0.1", - "wide-align": "^1.1.2" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/gauge/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/gauge/node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "license": "ISC", - "optional": true - }, - "node_modules/gauge/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "node_modules/generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", "license": "MIT", - "optional": true, "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" + "is-property": "^1.0.2" } }, "node_modules/gensync": { @@ -8602,6 +7039,7 @@ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=6.9.0" } @@ -8610,8 +7048,9 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "devOptional": true, + "dev": true, "license": "ISC", + "peer": true, "engines": { "node": "6.* || 8.* || >= 10.*" } @@ -8646,10 +7085,17 @@ "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8.0.0" } }, + "node_modules/get-port-please": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/get-port-please/-/get-port-please-3.2.0.tgz", + "integrity": "sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A==", + "license": "MIT" + }, "node_modules/get-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", @@ -8669,6 +7115,7 @@ "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -8677,41 +7124,27 @@ } }, "node_modules/giget": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz", - "integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/giget/-/giget-3.2.0.tgz", + "integrity": "sha512-GvHTWcykIR/fP8cj8dMpuMMkvaeJfPvYnhq0oW+chSeIr+ldX21ifU2Ms6KBoyKZQZmVaUAAhQ2EZ68KJF8a7A==", "license": "MIT", - "dependencies": { - "citty": "^0.1.6", - "consola": "^3.4.0", - "defu": "^6.1.4", - "node-fetch-native": "^1.6.6", - "nypm": "^0.6.0", - "pathe": "^2.0.3" - }, "bin": { "giget": "dist/cli.mjs" } }, "node_modules/glob": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-12.0.0.tgz", - "integrity": "sha512-5Qcll1z7IKgHr5g485ePDdHcNQY0k2dtv/bjYy0iuyGxQw2qSOiiXUXJ+AYQpg3HNoUMHqAruX478Jeev7UULw==", + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "foreground-child": "^3.3.1", - "jackspeak": "^4.1.1", - "minimatch": "^10.1.1", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^2.0.0" - }, - "bin": { - "glob": "dist/esm/bin.mjs" + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -8737,78 +7170,43 @@ "dev": true, "license": "BSD-2-Clause" }, - "node_modules/glob/node_modules/minimatch": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", - "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==", + "node_modules/glob/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/brace-expansion": "^5.0.0" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/global-modules": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/global-modules/-/global-modules-1.0.0.tgz", - "integrity": "sha512-sKzpEkf11GpOFuw0Zzjzmt4B4UZwjOcG757PPvrfhxcLFbq0wpsgpOqxpxtxFiCG4DtG93M6XRVbF2oGdev7bg==", "license": "MIT", - "optional": true, - "dependencies": { - "global-prefix": "^1.0.1", - "is-windows": "^1.0.1", - "resolve-dir": "^1.0.0" - }, "engines": { - "node": ">=0.10.0" + "node": "18 || 20 || >=22" } }, - "node_modules/global-prefix": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-1.0.2.tgz", - "integrity": "sha512-5lsx1NUDHtSjfg0eHlmYvZKv8/nVqX4ckFbM+FrGcQ+04KWcWFo9P5MxPZYSzUvyzmdTbI7Eix8Q4IbELDqzKg==", + "node_modules/glob/node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, "license": "MIT", - "optional": true, "dependencies": { - "expand-tilde": "^2.0.2", - "homedir-polyfill": "^1.0.1", - "ini": "^1.3.4", - "is-windows": "^1.0.1", - "which": "^1.2.14" + "balanced-match": "^4.0.2" }, "engines": { - "node": ">=0.10.0" + "node": "18 || 20 || >=22" } }, - "node_modules/global-prefix/node_modules/which": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", - "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", - "license": "ISC", - "optional": true, + "node_modules/glob/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", "dependencies": { - "isexe": "^2.0.0" + "brace-expansion": "^5.0.5" }, - "bin": { - "which": "bin/which" - } - }, - "node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", - "dev": true, - "license": "MIT", "engines": { - "node": ">=18" + "node": "18 || 20 || >=22" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/gopd": { @@ -8829,17 +7227,22 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, - "node_modules/graphemer": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", - "dev": true, + "node_modules/grammex": { + "version": "3.1.12", + "resolved": "https://registry.npmjs.org/grammex/-/grammex-3.1.12.tgz", + "integrity": "sha512-6ufJOsSA7LcQehIJNCO7HIBykfM7DXQual0Ny780/DEcJIpBlHRvcqEBWGPYd7hrXL2GJ3oJI1MIhaXjWmLQOQ==", + "license": "MIT" + }, + "node_modules/graphmatch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/graphmatch/-/graphmatch-1.1.1.tgz", + "integrity": "sha512-5ykVn/EXM1hF0XCaWh05VbYvEiOL2lY1kBxZtaYsyvjp7cmWOU1XsAdfQBwClraEofXDT197lFbXOEVMHpvQOg==", "license": "MIT" }, "node_modules/handlebars": { - "version": "4.7.8", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", - "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", + "version": "4.7.9", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", + "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", "dev": true, "license": "MIT", "dependencies": { @@ -8893,7 +7296,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -8905,13 +7307,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/has-unicode": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", - "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", - "license": "ISC", - "optional": true - }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -8924,17 +7319,13 @@ "node": ">= 0.4" } }, - "node_modules/homedir-polyfill": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/homedir-polyfill/-/homedir-polyfill-1.0.3.tgz", - "integrity": "sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA==", + "node_modules/hono": { + "version": "4.12.22", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.22.tgz", + "integrity": "sha512-7fvVPbB92zNRsQke+uiRGwtTuef0tB2Dg4hWxYfFNvkQhIltWoyi0ONReM5LWA+jJWS3nfT5lTq+qbsIpX0IQw==", "license": "MIT", - "optional": true, - "dependencies": { - "parse-passwd": "^1.0.0" - }, "engines": { - "node": ">=0.10.0" + "node": ">=16.9.0" } }, "node_modules/html-escaper": { @@ -8942,7 +7333,8 @@ "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/http-errors": { "version": "2.0.1", @@ -8964,12 +7356,17 @@ "url": "https://opencollective.com/express" } }, + "node_modules/http-status-codes": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/http-status-codes/-/http-status-codes-2.3.0.tgz", + "integrity": "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==", + "license": "MIT" + }, "node_modules/https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", "license": "MIT", - "optional": true, "dependencies": { "agent-base": "6", "debug": "4" @@ -8984,6 +7381,7 @@ "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", "dev": true, "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=10.17.0" } @@ -9038,7 +7436,7 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "parent-module": "^1.0.0", @@ -9057,6 +7455,7 @@ "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "pkg-dir": "^4.2.0", "resolve-cwd": "^3.0.0" @@ -9086,8 +7485,9 @@ "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "devOptional": true, + "dev": true, "license": "ISC", + "peer": true, "dependencies": { "once": "^1.3.0", "wrappy": "1" @@ -9099,13 +7499,6 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, - "node_modules/ini": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "license": "ISC", - "optional": true - }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -9119,30 +7512,14 @@ "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", - "devOptional": true, + "dev": true, "license": "MIT" }, - "node_modules/is-core-module": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", - "license": "MIT", - "optional": true, - "dependencies": { - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -9163,6 +7540,7 @@ "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=6" } @@ -9171,7 +7549,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" @@ -9194,7 +7572,7 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=0.12.0" @@ -9215,11 +7593,19 @@ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "license": "MIT" }, + "node_modules/is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", + "license": "MIT" + }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" }, @@ -9240,16 +7626,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-windows": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", - "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", @@ -9268,6 +7644,7 @@ "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", "dev": true, "license": "BSD-3-Clause", + "peer": true, "engines": { "node": ">=8" } @@ -9278,6 +7655,7 @@ "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", "dev": true, "license": "BSD-3-Clause", + "peer": true, "dependencies": { "@babel/core": "^7.23.9", "@babel/parser": "^7.23.9", @@ -9295,6 +7673,7 @@ "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", "dev": true, "license": "BSD-3-Clause", + "peer": true, "dependencies": { "istanbul-lib-coverage": "^3.0.0", "make-dir": "^4.0.0", @@ -9310,6 +7689,7 @@ "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", "dev": true, "license": "BSD-3-Clause", + "peer": true, "dependencies": { "@jridgewell/trace-mapping": "^0.3.23", "debug": "^4.1.1", @@ -9325,6 +7705,7 @@ "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", "dev": true, "license": "BSD-3-Clause", + "peer": true, "dependencies": { "html-escaper": "^2.0.0", "istanbul-lib-report": "^3.0.0" @@ -9342,22 +7723,6 @@ "node": ">=6" } }, - "node_modules/jackspeak": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.1.1.tgz", - "integrity": "sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/jest": { "version": "30.2.0", "resolved": "https://registry.npmjs.org/jest/-/jest-30.2.0.tgz", @@ -9392,6 +7757,7 @@ "integrity": "sha512-L8lR1ChrRnSdfeOvTrwZMlnWV8G/LLjQ0nG9MBclwWZidA2N5FviRki0Bvh20WRMOX31/JYvzdqTJrk5oBdydQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "execa": "^5.1.1", "jest-util": "30.2.0", @@ -9407,6 +7773,7 @@ "integrity": "sha512-Fh0096NC3ZkFx05EP2OXCxJAREVxj1BcW/i6EWqqymcgYKWjyyDpral3fMxVcHXg6oZM7iULer9wGRFvfpl+Tg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/environment": "30.2.0", "@jest/expect": "30.2.0", @@ -9439,6 +7806,7 @@ "integrity": "sha512-Os9ukIvADX/A9sLt6Zse3+nmHtHaE6hqOsjQtNiugFTbKRHYIYtZXNGNK9NChseXy7djFPjndX1tL0sCTlfpAA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "30.2.0", "@jest/test-result": "30.2.0", @@ -9472,6 +7840,7 @@ "integrity": "sha512-g4WkyzFQVWHtu6uqGmQR4CQxz/CH3yDSlhzXMWzNjDx843gYjReZnMRanjRCq5XZFuQrGDxgUaiYWE8BRfVckA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/core": "^7.27.4", "@jest/get-type": "30.1.0", @@ -9524,6 +7893,7 @@ "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", "dev": true, "license": "ISC", + "peer": true, "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", @@ -9545,6 +7915,7 @@ "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", "dev": true, "license": "BlueOak-1.0.0", + "peer": true, "dependencies": { "@isaacs/cliui": "^8.0.2" }, @@ -9560,7 +7931,8 @@ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "dev": true, - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/jest-config/node_modules/path-scurry": { "version": "1.11.1", @@ -9568,6 +7940,7 @@ "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", "dev": true, "license": "BlueOak-1.0.0", + "peer": true, "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" @@ -9601,6 +7974,7 @@ "integrity": "sha512-tR/FFgZKS1CXluOQzZvNH3+0z9jXr3ldGSD8bhyuxvlVUwbeLOGynkunvlTMxchC5urrKndYiwCFC0DLVjpOCA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "detect-newline": "^3.1.0" }, @@ -9614,6 +7988,7 @@ "integrity": "sha512-lpWlJlM7bCUf1mfmuqTA8+j2lNURW9eNafOy99knBM01i5CQeY5UH1vZjgT9071nDJac1M4XsbyI44oNOdhlDQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/get-type": "30.1.0", "@jest/types": "30.2.0", @@ -9631,6 +8006,7 @@ "integrity": "sha512-ElU8v92QJ9UrYsKrxDIKCxu6PfNj4Hdcktcn0JX12zqNdqWHB0N+hwOnnBBXvjLd2vApZtuLUGs1QSY+MsXoNA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/environment": "30.2.0", "@jest/fake-timers": "30.2.0", @@ -9650,6 +8026,7 @@ "integrity": "sha512-sQA/jCb9kNt+neM0anSj6eZhLZUIhQgwDt7cPGjumgLM4rXsfb9kpnlacmvZz3Q5tb80nS+oG/if+NBKrHC+Xw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/types": "30.2.0", "@types/node": "*", @@ -9675,6 +8052,7 @@ "integrity": "sha512-M6jKAjyzjHG0SrQgwhgZGy9hFazcudwCNovY/9HPIicmNSBuockPSedAP9vlPK6ONFJ1zfyH/M2/YYJxOz5cdQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/get-type": "30.1.0", "pretty-format": "30.2.0" @@ -9741,6 +8119,7 @@ "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=6" }, @@ -9769,6 +8148,7 @@ "integrity": "sha512-TCrHSxPlx3tBY3hWNtRQKbtgLhsXa1WmbJEqBlTBrGafd5fiQFByy2GNCEoGR+Tns8d15GaL9cxEzKOO3GEb2A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "chalk": "^4.1.2", "graceful-fs": "^4.2.11", @@ -9789,6 +8169,7 @@ "integrity": "sha512-xTOIGug/0RmIe3mmCqCT95yO0vj6JURrn1TKWlNbhiAefJRWINNPgwVkrVgt/YaerPzY3iItufd80v3lOrFJ2w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "jest-regex-util": "30.0.1", "jest-snapshot": "30.2.0" @@ -9803,6 +8184,7 @@ "integrity": "sha512-PqvZ2B2XEyPEbclp+gV6KO/F1FIFSbIwewRgmROCMBo/aZ6J1w8Qypoj2pEOcg3G2HzLlaP6VUtvwCI8dM3oqQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/console": "30.2.0", "@jest/environment": "30.2.0", @@ -9837,6 +8219,7 @@ "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", "dev": true, "license": "BSD-3-Clause", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -9847,6 +8230,7 @@ "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" @@ -9858,6 +8242,7 @@ "integrity": "sha512-p1+GVX/PJqTucvsmERPMgCPvQJpFt4hFbM+VN3n8TMo47decMUcJbt+rgzwrEme0MQUA/R+1de2axftTHkKckg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/environment": "30.2.0", "@jest/fake-timers": "30.2.0", @@ -9892,6 +8277,7 @@ "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", "dev": true, "license": "ISC", + "peer": true, "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", @@ -9913,6 +8299,7 @@ "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", "dev": true, "license": "BlueOak-1.0.0", + "peer": true, "dependencies": { "@isaacs/cliui": "^8.0.2" }, @@ -9928,7 +8315,8 @@ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "dev": true, - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/jest-runtime/node_modules/path-scurry": { "version": "1.11.1", @@ -9936,6 +8324,7 @@ "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", "dev": true, "license": "BlueOak-1.0.0", + "peer": true, "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" @@ -9953,6 +8342,7 @@ "integrity": "sha512-5WEtTy2jXPFypadKNpbNkZ72puZCa6UjSr/7djeecHWOu7iYhSXSnHScT8wBz3Rn8Ena5d5RYRcsyKIeqG1IyA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/core": "^7.27.4", "@babel/generator": "^7.27.5", @@ -10004,6 +8394,7 @@ "integrity": "sha512-FBGWi7dP2hpdi8nBoWxSsLvBFewKAg0+uSQwBaof4Y4DPgBabXgpSYC5/lR7VmnIlSpASmCi/ntRWPbv7089Pw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/get-type": "30.1.0", "@jest/types": "30.2.0", @@ -10022,6 +8413,7 @@ "integrity": "sha512-PYxa28dxJ9g777pGm/7PrbnMeA0Jr7osHP9bS7eJy9DuAjMgdGtxgf0uKMyoIsTWAkIbUW5hSDdJ3urmgXBqxg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/test-result": "30.2.0", "@jest/types": "30.2.0", @@ -10042,6 +8434,7 @@ "integrity": "sha512-0Q4Uk8WF7BUwqXHuAjc23vmopWJw5WH7w2tqBoUOZpOjW/ZnR44GXXd1r82RvnmI2GZge3ivrYXk/BE2+VtW2g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/node": "*", "@ungap/structured-clone": "^1.3.0", @@ -10059,6 +8452,7 @@ "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -10082,7 +8476,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/js-yaml": { @@ -10102,8 +8496,9 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "devOptional": true, + "dev": true, "license": "MIT", + "peer": true, "bin": { "jsesc": "bin/jsesc" }, @@ -10122,14 +8517,13 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, "license": "MIT" }, "node_modules/json-stable-stringify-without-jsonify": { @@ -10143,7 +8537,7 @@ "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "devOptional": true, + "dev": true, "license": "MIT", "bin": { "json5": "lib/cli.js" @@ -10230,6 +8624,7 @@ "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=6" } @@ -10258,7 +8653,7 @@ "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/load-esm": { @@ -10314,27 +8709,16 @@ "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "dev": true, "license": "MIT" }, "node_modules/lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", - "license": "MIT" - }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true, "license": "MIT" }, - "node_modules/lodash.once": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", - "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", - "license": "MIT" - }, "node_modules/log-symbols": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", @@ -10352,16 +8736,38 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", "dev": true, "license": "ISC", + "peer": true, "dependencies": { "yallist": "^3.0.2" } }, + "node_modules/lru.min": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz", + "integrity": "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=1.30.0", + "node": ">=8.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wellwelwel" + } + }, "node_modules/luxon": { "version": "3.7.2", "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", @@ -10387,6 +8793,7 @@ "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "semver": "^7.5.3" }, @@ -10410,6 +8817,7 @@ "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", "dev": true, "license": "BSD-3-Clause", + "peer": true, "dependencies": { "tmpl": "1.0.5" } @@ -10476,16 +8884,6 @@ "dev": true, "license": "MIT" }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, "node_modules/methods": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", @@ -10500,7 +8898,7 @@ "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "braces": "^3.0.3", @@ -10514,7 +8912,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=8.6" @@ -10575,7 +8973,9 @@ "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, "license": "ISC", + "peer": true, "dependencies": { "brace-expansion": "^2.0.1" }, @@ -10590,66 +8990,22 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "license": "ISC", + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", "engines": { "node": ">=16 || 14 >=14.17" } }, - "node_modules/minizlib": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", - "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", - "license": "MIT", - "optional": true, - "dependencies": { - "minipass": "^3.0.0", - "yallist": "^4.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/minizlib/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "license": "ISC", - "optional": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minizlib/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "license": "ISC", - "optional": true - }, - "node_modules/mkdirp": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", - "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", - "license": "MIT", - "dependencies": { - "minimist": "^1.2.6" - }, - "bin": { - "mkdirp": "bin/cmd.js" - } - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -10657,18 +9013,15 @@ "license": "MIT" }, "node_modules/multer": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz", - "integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz", + "integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==", "license": "MIT", "dependencies": { "append-field": "^1.0.0", "busboy": "^1.6.0", "concat-stream": "^2.0.0", - "mkdirp": "^0.5.6", - "object-assign": "^4.1.1", - "type-is": "^1.6.18", - "xtend": "^4.0.2" + "type-is": "^1.6.18" }, "engines": { "node": ">= 10.16.0" @@ -10717,50 +9070,6 @@ "node": ">= 0.6" } }, - "node_modules/multimatch": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/multimatch/-/multimatch-5.0.0.tgz", - "integrity": "sha512-ypMKuglUrZUD99Tk2bUQ+xNQj43lPEfAeX2o9cTteAmShXy2VHDJpuwu1o0xqoKCt9jLVAvwyFKdLTPXKAfJyA==", - "license": "MIT", - "optional": true, - "dependencies": { - "@types/minimatch": "^3.0.3", - "array-differ": "^3.0.0", - "array-union": "^2.1.0", - "arrify": "^2.0.1", - "minimatch": "^3.0.4" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/multimatch/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "license": "MIT", - "optional": true, - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/multimatch/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "license": "ISC", - "optional": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, "node_modules/mute-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", @@ -10771,30 +9080,36 @@ "node": "^18.17.0 || >=20.5.0" } }, - "node_modules/nan": { - "version": "2.23.1", - "resolved": "https://registry.npmjs.org/nan/-/nan-2.23.1.tgz", - "integrity": "sha512-r7bBUGKzlqk8oPBDYxt6Z0aEdF1G1rwlMcLk8LCOMbOzf0mG+JUfUzG4fIMWwHWP0iyaLWEQZJmtB7nOHEm/qw==", + "node_modules/mysql2": { + "version": "3.15.3", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.15.3.tgz", + "integrity": "sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==", "license": "MIT", - "optional": true + "dependencies": { + "aws-ssl-profiles": "^1.1.1", + "denque": "^2.1.0", + "generate-function": "^2.3.1", + "iconv-lite": "^0.7.0", + "long": "^5.2.1", + "lru.min": "^1.0.0", + "named-placeholders": "^1.1.3", + "seq-queue": "^0.0.5", + "sqlstring": "^2.3.2" + }, + "engines": { + "node": ">= 8.0" + } }, - "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], + "node_modules/named-placeholders": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz", + "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==", "license": "MIT", - "optional": true, - "bin": { - "nanoid": "bin/nanoid.cjs" + "dependencies": { + "lru.min": "^1.1.0" }, "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + "node": ">=8.0.0" } }, "node_modules/napi-postinstall": { @@ -10803,6 +9118,7 @@ "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "napi-postinstall": "lib/cli.js" }, @@ -10853,72 +9169,42 @@ "lodash": "^4.17.21" } }, - "node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "license": "MIT", - "optional": true, - "dependencies": { - "whatwg-url": "^5.0.0" - }, + "node_modules/node-forge": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz", + "integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==", + "license": "(BSD-3-Clause OR GPL-2.0)", "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } + "node": ">= 6.13.0" } }, - "node_modules/node-fetch-native": { - "version": "1.6.7", - "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", - "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", - "license": "MIT" - }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/node-releases": { - "version": "2.0.27", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", - "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "version": "2.0.46", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.46.tgz", + "integrity": "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/nodemailer": { - "version": "7.0.10", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.10.tgz", - "integrity": "sha512-Us/Se1WtT0ylXgNFfyFSx4LElllVLJXQjWi2Xz17xWw7amDKO2MLtFnVp1WACy7GkVGs+oBlRopVNUzlrGSw1w==", + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.8.tgz", + "integrity": "sha512-p+XsnzXGdtIHXUu2ugxdfG+eX2nehsGhMjW9h0CWj1BhE30hrFz0kh0yIM0/VjUgVsRrDj+80ZO+I1nSkGE4tA==", "license": "MIT-0", "engines": { "node": ">=6.0.0" } }, - "node_modules/nopt": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", - "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", - "license": "ISC", - "optional": true, - "dependencies": { - "abbrev": "1" - }, - "bin": { - "nopt": "bin/nopt.js" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -10934,6 +9220,7 @@ "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "path-key": "^3.0.0" }, @@ -10941,39 +9228,6 @@ "node": ">=8" } }, - "node_modules/npmlog": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz", - "integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==", - "deprecated": "This package is no longer supported.", - "license": "ISC", - "optional": true, - "dependencies": { - "are-we-there-yet": "^2.0.0", - "console-control-strings": "^1.1.0", - "gauge": "^3.0.0", - "set-blocking": "^2.0.0" - } - }, - "node_modules/nypm": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.2.tgz", - "integrity": "sha512-7eM+hpOtrKrBDCh7Ypu2lJ9Z7PNZBdi/8AT3AX8xoCj43BBVHD0hPSTEvMtkMpfs8FCqBGhxB+uToIQimA111g==", - "license": "MIT", - "dependencies": { - "citty": "^0.1.6", - "consola": "^3.4.2", - "pathe": "^2.0.3", - "pkg-types": "^2.3.0", - "tinyexec": "^1.0.1" - }, - "bin": { - "nypm": "dist/cli.mjs" - }, - "engines": { - "node": "^14.16.0 || >=16.10.0" - } - }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -11038,12 +9292,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/optional": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/optional/-/optional-0.1.4.tgz", - "integrity": "sha512-gtvrrCfkE08wKcgXaVwQVgwEQ8vel2dc5DDBn9RLQZ3YtmtkBss6A2HY6BnJH4N/4Ku97Ri/SF8sNWE2225WJw==", - "license": "MIT" - }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -11147,6 +9395,7 @@ "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=6" } @@ -11155,13 +9404,15 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "license": "BlueOak-1.0.0" + "dev": true, + "license": "BlueOak-1.0.0", + "peer": true }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "callsites": "^3.0.0" @@ -11174,7 +9425,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.0.0", @@ -11189,16 +9440,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/parse-passwd": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/parse-passwd/-/parse-passwd-1.0.0.tgz", - "integrity": "sha512-1Y1A//QUXEZK7YKz+rD9WydcE1+EuPr6ZBgKecAB8tmoW6UFv0NREVJe1p+jRxtThkcbbKkfwIbWJe/IeE6m2Q==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -11222,8 +9463,9 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "devOptional": true, + "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -11237,17 +9479,10 @@ "node": ">=8" } }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "license": "MIT", - "optional": true - }, "node_modules/path-scurry": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz", - "integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { @@ -11255,26 +9490,26 @@ "minipass": "^7.1.2" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, "node_modules/path-scurry/node_modules/lru-cache": { - "version": "11.2.2", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.2.tgz", - "integrity": "sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==", + "version": "11.5.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.0.tgz", + "integrity": "sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" } }, "node_modules/path-to-regexp": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", - "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", "license": "MIT", "funding": { "type": "opencollective", @@ -11285,7 +9520,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -11298,22 +9533,22 @@ "license": "MIT" }, "node_modules/perfect-debounce": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", - "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz", + "integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==", "license": "MIT" }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "devOptional": true, + "dev": true, "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", - "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -11329,6 +9564,7 @@ "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 6" } @@ -11339,6 +9575,7 @@ "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "find-up": "^4.0.0" }, @@ -11352,6 +9589,7 @@ "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" @@ -11366,6 +9604,7 @@ "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "p-locate": "^4.1.0" }, @@ -11379,6 +9618,7 @@ "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "p-try": "^2.0.0" }, @@ -11395,6 +9635,7 @@ "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "p-limit": "^2.2.0" }, @@ -11403,26 +9644,16 @@ } }, "node_modules/pkg-types": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", - "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz", + "integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==", "license": "MIT", "dependencies": { - "confbox": "^0.2.2", - "exsolve": "^1.0.7", + "confbox": "^0.2.4", + "exsolve": "^1.0.8", "pathe": "^2.0.3" } }, - "node_modules/please-upgrade-node": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/please-upgrade-node/-/please-upgrade-node-3.2.0.tgz", - "integrity": "sha512-gQR3WpIgNIKwBMVLkpMUeR3e1/E1y42bqDQZfql+kDeXd8COYfM8PQA4X6y7a8u9Ua9FHmsrrmirW2vHs45hWg==", - "license": "MIT", - "optional": true, - "dependencies": { - "semver-compare": "^1.0.0" - } - }, "node_modules/pluralize": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", @@ -11433,33 +9664,17 @@ "node": ">=4" } }, - "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "optional": true, - "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, + "node_modules/postgres": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.7.tgz", + "integrity": "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==", + "license": "Unlicense", "engines": { - "node": "^10 || ^12 || >=14" + "node": ">=12" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/porsager" } }, "node_modules/prelude-ls": { @@ -11473,12 +9688,11 @@ } }, "node_modules/prettier": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", - "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", + "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -11490,9 +9704,9 @@ } }, "node_modules/prettier-linter-helpers": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz", - "integrity": "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.1.tgz", + "integrity": "sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==", "dev": true, "license": "MIT", "dependencies": { @@ -11531,26 +9745,33 @@ } }, "node_modules/prisma": { - "version": "6.19.0", - "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.0.tgz", - "integrity": "sha512-F3eX7K+tWpkbhl3l4+VkFtrwJlLXbAM+f9jolgoUZbFcm1DgHZ4cq9AgVEgUym2au5Ad/TDLN8lg83D+M10ycw==", + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-7.8.0.tgz", + "integrity": "sha512-yfN4yrw7HV9kEJhoy1+jgah0jafEIQsf7uWouSsM8MvJtlubsk+kM7AIBWZ8+GJl74Yj3c+nbYqBkMOxtsZ3Lw==", "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "dependencies": { - "@prisma/config": "6.19.0", - "@prisma/engines": "6.19.0" + "@prisma/config": "7.8.0", + "@prisma/dev": "0.24.3", + "@prisma/engines": "7.8.0", + "@prisma/studio-core": "0.27.3", + "mysql2": "3.15.3", + "postgres": "3.4.7" }, "bin": { "prisma": "build/index.js" }, "engines": { - "node": ">=18.18" + "node": "^20.19 || ^22.12 || >=24.0" }, "peerDependencies": { - "typescript": ">=5.1.0" + "better-sqlite3": ">=9.0.0", + "typescript": ">=5.4.0" }, "peerDependenciesMeta": { + "better-sqlite3": { + "optional": true + }, "typescript": { "optional": true } @@ -11585,24 +9806,23 @@ "node": "^16 || ^18 || >=20" } }, - "node_modules/prometheus-gc-stats": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/prometheus-gc-stats/-/prometheus-gc-stats-1.1.0.tgz", - "integrity": "sha512-z2JefryWO0KzgejYHpc/i838P3LjERBzZdnUPYr8LbAOIZ+zkPDRTv1ryer2n09sliODvxea4JcLoYo0+yZApQ==", + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", "license": "MIT", "dependencies": { - "optional": "^0.1.3" - }, - "engines": { - "node": ">=10" - }, - "optionalDependencies": { - "@sematext/gc-stats": "^1.5.9" - }, - "peerDependencies": { - "prom-client": ">= 10 <= 15" + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" } }, + "node_modules/proper-lockfile/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -11616,6 +9836,15 @@ "node": ">= 0.10" } }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -11641,12 +9870,31 @@ "url": "https://opencollective.com/fast-check" } ], - "license": "MIT" + "license": "MIT", + "peer": true + }, + "node_modules/pvtsutils": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/pvutils": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz", + "integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } }, "node_modules/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -11658,37 +9906,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/randombytes": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", - "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "safe-buffer": "^5.1.0" - } - }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -11714,13 +9931,36 @@ } }, "node_modules/rc9": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz", - "integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/rc9/-/rc9-3.0.1.tgz", + "integrity": "sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==", + "license": "MIT", + "dependencies": { + "defu": "^6.1.6", + "destr": "^2.0.5" + } + }, + "node_modules/react": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", + "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz", + "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==", "license": "MIT", + "peer": true, "dependencies": { - "defu": "^6.1.4", - "destr": "^2.0.3" + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.6" } }, "node_modules/react-is": { @@ -11771,30 +10011,61 @@ } }, "node_modules/readdir-glob": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", - "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-3.0.0.tgz", + "integrity": "sha512-AhNB2KgKeVJr16nK9LLZbJNWnYoT23ZrumNKFDebHBdkC8KHSqWo871JAUhoWC/RtjEVdqNMFpM6qrwRbaUqpw==", "license": "Apache-2.0", "dependencies": { - "minimatch": "^5.1.0" + "minimatch": "^10.2.2" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/yqnn" + } + }, + "node_modules/readdir-glob/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/readdir-glob/node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" } }, "node_modules/readdir-glob/node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", - "license": "ISC", + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^5.0.5" }, "engines": { - "node": ">=10" + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 14.18.0" @@ -11808,15 +10079,24 @@ "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", - "license": "Apache-2.0", - "peer": true + "license": "Apache-2.0" + }, + "node_modules/remeda": { + "version": "2.33.4", + "resolved": "https://registry.npmjs.org/remeda/-/remeda-2.33.4.tgz", + "integrity": "sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/remeda" + } }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "devOptional": true, + "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -11825,46 +10105,18 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" } }, - "node_modules/require-package-name": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/require-package-name/-/require-package-name-2.0.1.tgz", - "integrity": "sha512-uuoJ1hU/k6M0779t3VMVIYpb2VMJk05cehCaABFhXaibcbvfgR8wKiozLjVFSzJPmQMRqIcO0HMyTFqfV09V6Q==", - "license": "MIT", - "optional": true - }, - "node_modules/resolve": { - "version": "1.22.11", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", - "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", - "license": "MIT", - "optional": true, - "dependencies": { - "is-core-module": "^2.16.1", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/resolve-cwd": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "resolve-from": "^5.0.0" }, @@ -11878,29 +10130,16 @@ "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" } }, - "node_modules/resolve-dir": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/resolve-dir/-/resolve-dir-1.0.1.tgz", - "integrity": "sha512-R7uiTjECzvOsWSfdM0QKFNBVFcK27aHOUwdvK53BcW8zqnGdYp0Fbj82cy54+2A4P2tFM22J5kRfe1R+lM/1yg==", - "license": "MIT", - "optional": true, - "dependencies": { - "expand-tilde": "^2.0.0", - "global-modules": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=4" @@ -11920,85 +10159,20 @@ "node": ">=8" } }, - "node_modules/restore-cursor/node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "dev": true, - "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, - "node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", - "license": "ISC", - "optional": true, - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/rimraf/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "license": "MIT", - "optional": true, - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/rimraf/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", - "license": "ISC", - "optional": true, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/rimraf/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "license": "ISC", - "optional": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, + "node_modules/restore-cursor/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "license": "MIT", "engines": { - "node": "*" + "node": ">= 4" } }, "node_modules/router": { @@ -12017,36 +10191,11 @@ "node": ">= 18" } }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, "node_modules/rxjs": { "version": "7.8.2", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^2.1.0" } @@ -12077,6 +10226,13 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT", + "peer": true + }, "node_modules/schema-utils": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", @@ -12102,7 +10258,6 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -12132,10 +10287,10 @@ "license": "MIT" }, "node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "devOptional": true, + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -12144,13 +10299,6 @@ "node": ">=10" } }, - "node_modules/semver-compare": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", - "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", - "license": "MIT", - "optional": true - }, "node_modules/send": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", @@ -12173,15 +10321,10 @@ "node": ">= 18" } }, - "node_modules/serialize-javascript": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", - "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "randombytes": "^2.1.0" - } + "node_modules/seq-queue": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz", + "integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==" }, "node_modules/serve-static": { "version": "2.2.0", @@ -12198,13 +10341,6 @@ "node": ">= 18" } }, - "node_modules/set-blocking": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", - "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", - "license": "ISC", - "optional": true - }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -12336,16 +10472,6 @@ "node": ">= 8" } }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "license": "BSD-3-Clause", - "optional": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/source-map-support": { "version": "0.5.21", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", @@ -12371,8 +10497,18 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", - "devOptional": true, - "license": "BSD-3-Clause" + "dev": true, + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/sqlstring": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", + "integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } }, "node_modules/stack-utils": { "version": "2.0.6", @@ -12406,6 +10542,12 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "license": "MIT" + }, "node_modules/streamsearch": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", @@ -12440,6 +10582,7 @@ "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "char-regex": "^1.0.2", "strip-ansi": "^6.0.0" @@ -12454,6 +10597,7 @@ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -12464,6 +10608,7 @@ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1" }, @@ -12490,7 +10635,9 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, "license": "MIT", + "peer": true, "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", @@ -12504,7 +10651,9 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -12513,7 +10662,9 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1" }, @@ -12546,7 +10697,9 @@ "version": "7.1.2", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^6.0.1" }, @@ -12562,7 +10715,9 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1" }, @@ -12574,7 +10729,9 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -12585,6 +10742,7 @@ "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -12595,6 +10753,7 @@ "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=6" } @@ -12605,6 +10764,7 @@ "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" }, @@ -12612,18 +10772,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/strnum": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.1.tgz", - "integrity": "sha512-7ZvoFTiCnGxBtDqJ//Cu6fWtZtc7Y3x+QOirG15wztbdngGSkht27o2pyGWrVy0b4WAy3jbKmnoK6g5VlVNUUw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT" - }, "node_modules/strtok3": { "version": "10.3.4", "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.4.tgz", @@ -12641,9 +10789,9 @@ } }, "node_modules/superagent": { - "version": "10.2.3", - "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.2.3.tgz", - "integrity": "sha512-y/hkYGeXAj7wUMjxRbB21g/l6aAEituGXM9Rwl4o20+SX3e8YOSV6BxFXl+dL3Uk0mjSL3kCbNkwURm8/gEDig==", + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", + "integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==", "dev": true, "license": "MIT", "dependencies": { @@ -12651,25 +10799,26 @@ "cookiejar": "^2.1.4", "debug": "^4.3.7", "fast-safe-stringify": "^2.1.1", - "form-data": "^4.0.4", + "form-data": "^4.0.5", "formidable": "^3.5.4", "methods": "^1.1.2", "mime": "2.6.0", - "qs": "^6.11.2" + "qs": "^6.14.1" }, "engines": { "node": ">=14.18.0" } }, "node_modules/supertest": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.1.4.tgz", - "integrity": "sha512-tjLPs7dVyqgItVFirHYqe2T+MfWc2VOBQ8QFKKbWTA3PU7liZR8zoSpAi/C1k1ilm9RsXIKYf197oap9wXGVYg==", + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz", + "integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==", "dev": true, "license": "MIT", "dependencies": { + "cookie-signature": "^1.2.2", "methods": "^1.1.2", - "superagent": "^10.2.3" + "superagent": "^10.3.0" }, "engines": { "node": ">=14.18.0" @@ -12687,19 +10836,6 @@ "node": ">=8" } }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/symbol-observable": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-4.0.0.tgz", @@ -12711,9 +10847,9 @@ } }, "node_modules/synckit": { - "version": "0.11.11", - "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.11.tgz", - "integrity": "sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==", + "version": "0.11.12", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.12.tgz", + "integrity": "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==", "dev": true, "license": "MIT", "dependencies": { @@ -12727,9 +10863,9 @@ } }, "node_modules/tapable": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", - "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", "dev": true, "license": "MIT", "engines": { @@ -12740,24 +10876,6 @@ "url": "https://opencollective.com/webpack" } }, - "node_modules/tar": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", - "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", - "license": "ISC", - "optional": true, - "dependencies": { - "chownr": "^2.0.0", - "fs-minipass": "^2.0.0", - "minipass": "^5.0.0", - "minizlib": "^2.1.1", - "mkdirp": "^1.0.3", - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/tar-stream": { "version": "3.1.7", "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", @@ -12769,49 +10887,20 @@ "streamx": "^2.15.0" } }, - "node_modules/tar/node_modules/minipass": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", - "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", - "license": "ISC", - "optional": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/tar/node_modules/mkdirp": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", - "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", - "license": "MIT", - "optional": true, - "bin": { - "mkdirp": "bin/cmd.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/tar/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "license": "ISC", - "optional": true - }, "node_modules/tdigest": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/tdigest/-/tdigest-0.1.2.tgz", "integrity": "sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==", "license": "MIT", + "peer": true, "dependencies": { "bintrees": "1.0.2" } }, "node_modules/terser": { - "version": "5.44.1", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.44.1.tgz", - "integrity": "sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw==", + "version": "5.48.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.48.0.tgz", + "integrity": "sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -12828,16 +10917,15 @@ } }, "node_modules/terser-webpack-plugin": { - "version": "5.3.14", - "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.14.tgz", - "integrity": "sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw==", + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.6.0.tgz", + "integrity": "sha512-Eum+5ajkaOhf5KbM26osvv21kLD7BaGqQ1UA4Ami4arYwylmGUQTgHFpHDdmJod1q4QXa66p0to/FBKID+J1vA==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "jest-worker": "^27.4.5", "schema-utils": "^4.3.0", - "serialize-javascript": "^6.0.2", "terser": "^5.31.1" }, "engines": { @@ -12851,12 +10939,39 @@ "webpack": "^5.1.0" }, "peerDependenciesMeta": { + "@minify-html/node": { + "optional": true + }, "@swc/core": { "optional": true }, + "@swc/css": { + "optional": true + }, + "@swc/html": { + "optional": true + }, + "clean-css": { + "optional": true + }, + "cssnano": { + "optional": true + }, + "csso": { + "optional": true + }, "esbuild": { "optional": true }, + "html-minifier-terser": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "postcss": { + "optional": true + }, "uglify-js": { "optional": true } @@ -12944,6 +11059,7 @@ "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", "dev": true, "license": "ISC", + "peer": true, "dependencies": { "@istanbuljs/schema": "^0.1.2", "glob": "^7.1.4", @@ -12959,6 +11075,7 @@ "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -12971,6 +11088,7 @@ "deprecated": "Glob versions prior to v9 are no longer supported", "dev": true, "license": "ISC", + "peer": true, "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -12992,6 +11110,7 @@ "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "dev": true, "license": "ISC", + "peer": true, "dependencies": { "brace-expansion": "^1.1.7" }, @@ -13008,13 +11127,21 @@ "b4a": "^1.6.4" } }, - "node_modules/tinyexec": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", - "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, "engines": { - "node": ">=18" + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" } }, "node_modules/tmpl": { @@ -13022,13 +11149,14 @@ "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", "dev": true, - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "peer": true }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "is-number": "^7.0.0" @@ -13064,17 +11192,10 @@ "url": "https://github.com/sponsors/Borewit" } }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "license": "MIT", - "optional": true - }, "node_modules/ts-api-utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", - "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", "dev": true, "license": "MIT", "engines": { @@ -13085,19 +11206,19 @@ } }, "node_modules/ts-jest": { - "version": "29.4.5", - "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.5.tgz", - "integrity": "sha512-HO3GyiWn2qvTQA4kTgjDcXiMwYQt68a1Y8+JuLRVpdIzm+UOLSHgl/XqR4c6nzJkq5rOkjc02O2I7P7l/Yof0Q==", + "version": "29.4.11", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.11.tgz", + "integrity": "sha512-IrFl7l9AuB/qrNw5quqvAv/hmKMb8dhWOH4jQOGo0Oq8tCeo1O86/iTFG1FaRimgUkF13l4PcepO8ATFT6Ns4g==", "dev": true, "license": "MIT", "dependencies": { "bs-logger": "^0.2.6", "fast-json-stable-stringify": "^2.1.0", - "handlebars": "^4.7.8", + "handlebars": "^4.7.9", "json5": "^2.2.3", "lodash.memoize": "^4.1.2", "make-error": "^1.3.6", - "semver": "^7.7.3", + "semver": "^7.8.0", "type-fest": "^4.41.0", "yargs-parser": "^21.1.1" }, @@ -13114,7 +11235,7 @@ "babel-jest": "^29.0.0 || ^30.0.0", "jest": "^29.0.0 || ^30.0.0", "jest-util": "^29.0.0 || ^30.0.0", - "typescript": ">=4.3 <6" + "typescript": ">=4.3 <7" }, "peerDependenciesMeta": { "@babel/core": { @@ -13151,9 +11272,9 @@ } }, "node_modules/ts-loader": { - "version": "9.5.4", - "resolved": "https://registry.npmjs.org/ts-loader/-/ts-loader-9.5.4.tgz", - "integrity": "sha512-nCz0rEwunlTZiy6rXFByQU1kVVpCIgUpc/psFiKVrUwrizdnIbRFu8w7bxhUF0X613DYwT4XzrZHpVyMe758hQ==", + "version": "9.5.7", + "resolved": "https://registry.npmjs.org/ts-loader/-/ts-loader-9.5.7.tgz", + "integrity": "sha512-/ZNrKgA3K3PtpMYOC71EeMWIloGw3IYEa5/t1cyz2r5/PyUwTXGzYJvcD3kfUvmhlfpz1rhV8B2O6IVTQ0avsg==", "dev": true, "license": "MIT", "dependencies": { @@ -13177,7 +11298,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -13263,6 +11383,24 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tsyringe": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz", + "integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==", + "license": "MIT", + "dependencies": { + "tslib": "^1.9.3" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/tsyringe/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -13282,6 +11420,7 @@ "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=4" } @@ -13319,12 +11458,11 @@ "license": "MIT" }, "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -13372,9 +11510,9 @@ } }, "node_modules/undici-types": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", - "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", "license": "MIT" }, "node_modules/universalify": { @@ -13403,6 +11541,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "napi-postinstall": "^0.3.0" }, @@ -13432,9 +11571,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz", - "integrity": "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", "dev": true, "funding": [ { @@ -13500,6 +11639,7 @@ "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", "dev": true, "license": "ISC", + "peer": true, "dependencies": { "@jridgewell/trace-mapping": "^0.3.12", "@types/istanbul-lib-coverage": "^2.0.1", @@ -13509,6 +11649,20 @@ "node": ">=10.12.0" } }, + "node_modules/valibot": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.2.0.tgz", + "integrity": "sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==", + "license": "MIT", + "peerDependencies": { + "typescript": ">=5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/validator": { "version": "13.15.23", "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.23.tgz", @@ -13533,14 +11687,15 @@ "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "makeerror": "1.0.12" } }, "node_modules/watchpack": { - "version": "2.4.4", - "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.4.tgz", - "integrity": "sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA==", + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz", + "integrity": "sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==", "dev": true, "license": "MIT", "dependencies": { @@ -13561,20 +11716,12 @@ "defaults": "^1.0.3" } }, - "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "license": "BSD-2-Clause", - "optional": true - }, "node_modules/webpack": { - "version": "5.103.0", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.103.0.tgz", - "integrity": "sha512-HU1JOuV1OavsZ+mfigY0j8d1TgQgbZ6M+J75zDkpEAwYeXjWSqrGJtgnPblJjd/mAyTNQ7ygw0MiKOn6etz8yw==", + "version": "5.106.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.106.0.tgz", + "integrity": "sha512-Pkx5joZ9RrdgO5LBkyX1L2ZAJeK/Taz3vqZ9CbcP0wS5LEMx5QkKsEwLl29QJfihZ+DKRBFldzy1O30pJ1MDpA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -13582,12 +11729,12 @@ "@webassemblyjs/ast": "^1.14.1", "@webassemblyjs/wasm-edit": "^1.14.1", "@webassemblyjs/wasm-parser": "^1.14.1", - "acorn": "^8.15.0", + "acorn": "^8.16.0", "acorn-import-phases": "^1.0.3", - "browserslist": "^4.26.3", + "browserslist": "^4.28.1", "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.17.3", - "es-module-lexer": "^1.2.1", + "enhanced-resolve": "^5.20.0", + "es-module-lexer": "^2.0.0", "eslint-scope": "5.1.1", "events": "^3.2.0", "glob-to-regexp": "^0.4.1", @@ -13598,9 +11745,9 @@ "neo-async": "^2.6.2", "schema-utils": "^4.3.3", "tapable": "^2.3.0", - "terser-webpack-plugin": "^5.3.11", - "watchpack": "^2.4.4", - "webpack-sources": "^3.3.3" + "terser-webpack-plugin": "^5.3.17", + "watchpack": "^2.5.1", + "webpack-sources": "^3.3.4" }, "bin": { "webpack": "bin/webpack.js" @@ -13629,9 +11776,9 @@ } }, "node_modules/webpack-sources": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.3.3.tgz", - "integrity": "sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==", + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.5.0.tgz", + "integrity": "sha512-HPuy+uuoTCaaoEoI1LQ3JN9+vrPBvEesnnX1jADHy728cHSMlq4wUc4afYqahq2B1mhQVZxCXOkNTnXltr+2vQ==", "dev": true, "license": "MIT", "engines": { @@ -13723,17 +11870,6 @@ "url": "https://opencollective.com/webpack" } }, - "node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "license": "MIT", - "optional": true, - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -13749,16 +11885,6 @@ "node": ">= 8" } }, - "node_modules/wide-align": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", - "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", - "license": "ISC", - "optional": true, - "dependencies": { - "string-width": "^1.0.2 || 2 || 3 || 4" - } - }, "node_modules/widest-line": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-3.1.0.tgz", @@ -13808,7 +11934,9 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", @@ -13825,7 +11953,9 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -13834,7 +11964,9 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1" }, @@ -13877,6 +12009,7 @@ "integrity": "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==", "dev": true, "license": "ISC", + "peer": true, "dependencies": { "imurmurhash": "^0.1.4", "signal-exit": "^4.0.1" @@ -13885,21 +12018,13 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, - "node_modules/xtend": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", - "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", - "license": "MIT", - "engines": { - "node": ">=0.4" - } - }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "devOptional": true, + "dev": true, "license": "ISC", + "peer": true, "engines": { "node": ">=10" } @@ -13909,17 +12034,8 @@ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true, - "license": "ISC" - }, - "node_modules/yaml": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", - "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", "license": "ISC", - "optional": true, - "engines": { - "node": ">= 6" - } + "peer": true }, "node_modules/yargs": { "version": "17.7.2", @@ -13927,6 +12043,7 @@ "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", @@ -13986,18 +12103,28 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zeptomatch": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/zeptomatch/-/zeptomatch-2.1.0.tgz", + "integrity": "sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA==", + "license": "MIT", + "dependencies": { + "grammex": "^3.1.11", + "graphmatch": "^1.1.0" + } + }, "node_modules/zip-stream": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-6.0.1.tgz", - "integrity": "sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==", + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-7.0.5.tgz", + "integrity": "sha512-dSvYKdvLsAHCDqPOhIwk/q5CvuWtTB3Dgpoe0uVEFjTzIOAmsQpprX25InCvrvJsirEbu1OHyy67n/kAj1Sw/w==", "license": "MIT", "dependencies": { - "archiver-utils": "^5.0.0", - "compress-commons": "^6.0.2", + "compress-commons": "^7.0.0", + "normalize-path": "^3.0.0", "readable-stream": "^4.0.0" }, "engines": { - "node": ">= 14" + "node": ">=18" } } } diff --git a/package.json b/package.json index accf6e2..8823c5c 100644 --- a/package.json +++ b/package.json @@ -5,9 +5,21 @@ "author": "lyttle-development", "private": true, "license": "UNLICENSED", + "packageManager": "npm@11.15.0", "scripts": { + "clean": "rm -rf dist coverage", "build": "nest build", - "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", + "format": "prettier --write \"src/**/*.ts\" \"test/**/*.js\"", + "format:check": "prettier --check \"src/**/*.ts\" \"test/**/*.js\"", + "typecheck": "tsc --noEmit -p tsconfig.json", + "test": "node ./test/run-suite.js all", + "test:all": "node ./test/run-suite.js all", + "test:unit": "node ./test/run-suite.js unit", + "test:integration": "node ./test/run-suite.js integration", + "test:e2e": "node ./test/run-suite.js e2e", + "test:chaos": "node ./test/run-suite.js chaos", + "test:coverage": "node ./test/run-suite.js all --coverage", + "test:list": "node ./test/run-suite.js --list", "start": "nest start", "start:dev": "nest start --watch", "start:debug": "nest start --debug --watch", @@ -17,58 +29,74 @@ "prisma:dangerously-db-push": "prisma db push", "prisma:migrate": "prisma migrate dev --name merge-upstream", "prisma:deploy": "prisma migrate deploy", - "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", + "lint": "eslint \"{src,test}/**/*.ts\"", + "lint:ci": "eslint --max-warnings=0 --rule '{\"prettier/prettier\":\"off\"}' \"src/**/*.ts\"", + "lint:fix": "npm run lint -- --fix", "docker:setup": "npm run prisma:generate && npm run build", "docker:build": "docker build -t lyttle-nginx .", + "verify": "npm run lint && npm run typecheck && npm run test && npm run build", + "test:coverage:ci": "node ./scripts/ci/assert-coverage.js", + "audit:prod": "npm audit --omit=dev --audit-level=high", + "verify:ci": "npm run prisma:generate && npm run lint:ci && npm run typecheck && npm run test:coverage:ci && npm run build && npm run audit:prod", "update": "npm update --save" }, "volta": { - "node": "24.11.1" + "node": "24.16.0", + "npm": "11.15.0" }, "engines": { - "node": "24.11.1" + "node": "24.16.0", + "npm": "11.15.0" }, "dependencies": { - "@nestjs/common": "^11.1.9", - "@nestjs/core": "^11.1.9", - "@nestjs/mapped-types": "^2.1.0", - "@nestjs/platform-express": "^11.1.9", - "@nestjs/schedule": "^6.0.1", - "@nestjs/terminus": "^11.0.0", - "@nestjs/throttler": "^6.4.0", - "@prisma/client": "^6.19.0", - "@promster/express": "^15.4.2", + "@nestjs/common": "^11.1.23", + "@nestjs/core": "^11.1.23", + "@nestjs/mapped-types": "^2.1.1", + "@nestjs/platform-express": "^11.1.23", + "@nestjs/schedule": "^6.1.3", + "@nestjs/terminus": "^11.1.1", + "@nestjs/throttler": "^6.5.0", + "@prisma/client": "^7.8.0", + "@promster/express": "^15.5.1", "@types/archiver": "^7.0.0", - "@types/nodemailer": "^7.0.4", - "archiver": "^7.0.1", + "@types/nodemailer": "^8.0.0", + "acme-client": "^5.4.0", + "archiver": "^8.0.0", "class-transformer": "^0.5.1", - "class-validator": "^0.14.2", - "date-fns": "^4.1.0", - "nodemailer": "^7.0.10", - "prisma": "^6.19.0", + "class-validator": "^0.15.1", + "date-fns": "^4.3.0", + "nodemailer": "^8.0.8", + "prisma": "^7.8.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.2" }, + "overrides": { + "@hono/node-server": "1.19.13", + "@prisma/streams-local": { + "ajv": "8.20.0" + }, + "fast-uri": "3.1.2" + }, "devDependencies": { - "@nestjs/cli": "^11.0.12", - "@nestjs/schematics": "^11.0.9", - "@nestjs/testing": "^11.1.9", - "@types/express": "^5.0.5", + "@nestjs/cli": "^11.0.21", + "@nestjs/schematics": "^11.1.0", + "@nestjs/testing": "^11.1.23", + "@types/express": "^5.0.6", "@types/jest": "^30.0.0", - "@types/node": "^24.10.1", - "@types/supertest": "^6.0.3", - "@typescript-eslint/eslint-plugin": "^8.47.0", - "@typescript-eslint/parser": "^8.47.0", - "eslint": "^9.39.1", + "@types/node": "^25.9.1", + "@types/supertest": "^7.2.0", + "@typescript-eslint/eslint-plugin": "^8.59.4", + "@typescript-eslint/parser": "^8.59.4", + "eslint": "^10.4.0", "eslint-config-prettier": "^10.1.8", - "eslint-plugin-prettier": "^5.5.4", - "prettier": "^3.6.2", + "eslint-plugin-prettier": "^5.5.5", + "prettier": "^3.8.3", "source-map-support": "^0.5.21", - "supertest": "^7.1.4", - "ts-jest": "^29.4.5", - "ts-loader": "^9.5.4", + "supertest": "^7.2.2", + "ts-jest": "^29.4.11", + "ts-loader": "^9.5.7", "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", - "typescript": "^5.9.2" + "typescript": "^6.0.3" } } diff --git a/prisma.config.ts b/prisma.config.ts new file mode 100644 index 0000000..d86931f --- /dev/null +++ b/prisma.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'prisma/config'; + +const DATABASE_URL_FALLBACK = + 'postgresql://postgres:postgres@127.0.0.1:5432/lyttle_nginx?schema=public'; + +export default defineConfig({ + schema: 'prisma/schema.prisma', + migrations: { + path: 'prisma/migrations', + }, + datasource: { + url: process.env['DATABASE_URL'] || DATABASE_URL_FALLBACK, + }, +}); diff --git a/prisma/migrations/20260524153000_session9_audit_events/migration.sql b/prisma/migrations/20260524153000_session9_audit_events/migration.sql new file mode 100644 index 0000000..d42bfe1 --- /dev/null +++ b/prisma/migrations/20260524153000_session9_audit_events/migration.sql @@ -0,0 +1,34 @@ +CREATE TABLE "AuditEvent" ( + "id" TEXT NOT NULL, + "action" TEXT NOT NULL, + "targetType" TEXT, + "targetId" TEXT, + "targetDisplay" TEXT, + "outcome" TEXT NOT NULL, + "result" TEXT NOT NULL, + "correlationId" TEXT NOT NULL, + "actorId" TEXT, + "actorSubject" TEXT, + "actorType" TEXT, + "actorDisplayName" TEXT, + "actorAuthMethod" TEXT, + "actorRoles" TEXT, + "requestMethod" TEXT NOT NULL, + "requestPath" TEXT NOT NULL, + "responseStatus" INTEGER NOT NULL, + "nodeId" TEXT, + "ipAddress" TEXT, + "errorCode" TEXT, + "errorMessage" TEXT, + "metadata" JSONB, + "occurredAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "AuditEvent_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "AuditEvent_occurredAt_idx" ON "AuditEvent"("occurredAt"); +CREATE INDEX "AuditEvent_action_idx" ON "AuditEvent"("action"); +CREATE INDEX "AuditEvent_outcome_idx" ON "AuditEvent"("outcome"); +CREATE INDEX "AuditEvent_actorSubject_idx" ON "AuditEvent"("actorSubject"); +CREATE INDEX "AuditEvent_correlationId_idx" ON "AuditEvent"("correlationId"); + diff --git a/prisma/migrations/20260524161500_session10_cluster_leases/migration.sql b/prisma/migrations/20260524161500_session10_cluster_leases/migration.sql new file mode 100644 index 0000000..0cd140e --- /dev/null +++ b/prisma/migrations/20260524161500_session10_cluster_leases/migration.sql @@ -0,0 +1,22 @@ +CREATE TABLE "ClusterLease" ( + "id" TEXT NOT NULL, + "leaseName" TEXT NOT NULL, + "ownerNodeId" TEXT, + "ownerHostname" TEXT, + "generation" INTEGER NOT NULL DEFAULT 1, + "ttlSeconds" INTEGER NOT NULL DEFAULT 30, + "acquiredAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "renewedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expiresAt" TIMESTAMP(3) NOT NULL, + "metadata" JSONB, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "ClusterLease_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "ClusterLease_leaseName_key" ON "ClusterLease"("leaseName"); +CREATE INDEX "ClusterLease_ownerNodeId_idx" ON "ClusterLease"("ownerNodeId"); +CREATE INDEX "ClusterLease_expiresAt_idx" ON "ClusterLease"("expiresAt"); +CREATE INDEX "ClusterLease_ownerNodeId_expiresAt_idx" ON "ClusterLease"("ownerNodeId", "expiresAt"); + diff --git a/prisma/migrations/20260524182000_session12_cluster_operations/migration.sql b/prisma/migrations/20260524182000_session12_cluster_operations/migration.sql new file mode 100644 index 0000000..fd0f5b3 --- /dev/null +++ b/prisma/migrations/20260524182000_session12_cluster_operations/migration.sql @@ -0,0 +1,57 @@ +CREATE TABLE "ClusterOperation" ( + "id" TEXT NOT NULL, + "operationType" TEXT NOT NULL, + "scope" TEXT NOT NULL DEFAULT 'cluster', + "status" TEXT NOT NULL DEFAULT 'pending', + "initiatorNodeId" TEXT, + "initiatorHostname" TEXT, + "initiatorActorId" TEXT, + "initiatorActorType" TEXT, + "initiatorActorDisplayName" TEXT, + "correlationId" TEXT, + "requestPath" TEXT, + "targetNodeCount" INTEGER NOT NULL DEFAULT 0, + "completedNodeCount" INTEGER NOT NULL DEFAULT 0, + "successfulNodeCount" INTEGER NOT NULL DEFAULT 0, + "failedNodeCount" INTEGER NOT NULL DEFAULT 0, + "startedAt" TIMESTAMP(3), + "completedAt" TIMESTAMP(3), + "lastError" TEXT, + "metadata" JSONB, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "ClusterOperation_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "ClusterOperationAck" ( + "id" TEXT NOT NULL, + "operationId" TEXT NOT NULL, + "nodeInstanceId" TEXT NOT NULL, + "nodeHostname" TEXT, + "endpointUrl" TEXT, + "status" TEXT NOT NULL DEFAULT 'pending', + "responseStatus" INTEGER, + "errorMessage" TEXT, + "startedAt" TIMESTAMP(3), + "ackedAt" TIMESTAMP(3), + "details" JSONB, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "ClusterOperationAck_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "ClusterOperation_status_createdAt_idx" ON "ClusterOperation"("status", "createdAt"); +CREATE INDEX "ClusterOperation_operationType_createdAt_idx" ON "ClusterOperation"("operationType", "createdAt"); +CREATE INDEX "ClusterOperation_correlationId_idx" ON "ClusterOperation"("correlationId"); + +CREATE UNIQUE INDEX "ClusterOperationAck_operationId_nodeInstanceId_key" ON "ClusterOperationAck"("operationId", "nodeInstanceId"); +CREATE INDEX "ClusterOperationAck_status_ackedAt_idx" ON "ClusterOperationAck"("status", "ackedAt"); +CREATE INDEX "ClusterOperationAck_nodeInstanceId_status_idx" ON "ClusterOperationAck"("nodeInstanceId", "status"); + +ALTER TABLE "ClusterOperationAck" + ADD CONSTRAINT "ClusterOperationAck_operationId_fkey" + FOREIGN KEY ("operationId") REFERENCES "ClusterOperation"("id") + ON DELETE CASCADE ON UPDATE CASCADE; + diff --git a/prisma/migrations/20260524191500_session16_certificate_orders/migration.sql b/prisma/migrations/20260524191500_session16_certificate_orders/migration.sql new file mode 100644 index 0000000..d13a230 --- /dev/null +++ b/prisma/migrations/20260524191500_session16_certificate_orders/migration.sql @@ -0,0 +1,97 @@ +CREATE TABLE "CertificateOrder" ( + "id" TEXT NOT NULL, + "domains" TEXT NOT NULL, + "domainsHash" TEXT NOT NULL, + "primaryDomain" TEXT NOT NULL, + "sourceType" TEXT NOT NULL DEFAULT 'acme', + "status" TEXT NOT NULL DEFAULT 'requested', + "attemptCount" INTEGER NOT NULL DEFAULT 1, + "retryCount" INTEGER NOT NULL DEFAULT 0, + "nextRetryAt" TIMESTAMP(3), + "lastError" TEXT, + "requestedByNode" TEXT, + "certificateId" TEXT, + "requestedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "startedAt" TIMESTAMP(3), + "challengePublishedAt" TIMESTAMP(3), + "validatingAt" TIMESTAMP(3), + "issuedAt" TIMESTAMP(3), + "distributingAt" TIMESTAMP(3), + "activatedAt" TIMESTAMP(3), + "failedAt" TIMESTAMP(3), + "revokedAt" TIMESTAMP(3), + "completedAt" TIMESTAMP(3), + "metadata" JSONB, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "CertificateOrder_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "CertificateOrderEvent" ( + "id" TEXT NOT NULL, + "orderId" TEXT NOT NULL, + "eventType" TEXT NOT NULL, + "fromStatus" TEXT, + "toStatus" TEXT, + "message" TEXT, + "attemptNumber" INTEGER, + "retryAt" TIMESTAMP(3), + "details" JSONB, + "occurredAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "CertificateOrderEvent_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "CertificateArtifactVersion" ( + "id" TEXT NOT NULL, + "orderId" TEXT, + "certificateId" TEXT, + "domains" TEXT NOT NULL, + "domainsHash" TEXT NOT NULL, + "version" INTEGER NOT NULL, + "sourceType" TEXT NOT NULL DEFAULT 'acme', + "certPem" TEXT NOT NULL, + "keyPem" TEXT NOT NULL, + "issuedAt" TIMESTAMP(3) NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "activatedAt" TIMESTAMP(3), + "createdByNode" TEXT, + "metadata" JSONB, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "CertificateArtifactVersion_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "CertificateOrder_domainsHash_createdAt_idx" ON "CertificateOrder"("domainsHash", "createdAt"); +CREATE INDEX "CertificateOrder_status_nextRetryAt_idx" ON "CertificateOrder"("status", "nextRetryAt"); +CREATE INDEX "CertificateOrder_certificateId_idx" ON "CertificateOrder"("certificateId"); + +CREATE INDEX "CertificateOrderEvent_orderId_occurredAt_idx" ON "CertificateOrderEvent"("orderId", "occurredAt"); +CREATE INDEX "CertificateOrderEvent_toStatus_occurredAt_idx" ON "CertificateOrderEvent"("toStatus", "occurredAt"); + +CREATE UNIQUE INDEX "CertificateArtifactVersion_domainsHash_version_key" ON "CertificateArtifactVersion"("domainsHash", "version"); +CREATE INDEX "CertificateArtifactVersion_orderId_idx" ON "CertificateArtifactVersion"("orderId"); +CREATE INDEX "CertificateArtifactVersion_certificateId_createdAt_idx" ON "CertificateArtifactVersion"("certificateId", "createdAt"); +CREATE INDEX "CertificateArtifactVersion_domainsHash_createdAt_idx" ON "CertificateArtifactVersion"("domainsHash", "createdAt"); + +ALTER TABLE "CertificateOrder" + ADD CONSTRAINT "CertificateOrder_certificateId_fkey" + FOREIGN KEY ("certificateId") REFERENCES "Certificate"("id") + ON DELETE SET NULL ON UPDATE CASCADE; + +ALTER TABLE "CertificateOrderEvent" + ADD CONSTRAINT "CertificateOrderEvent_orderId_fkey" + FOREIGN KEY ("orderId") REFERENCES "CertificateOrder"("id") + ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "CertificateArtifactVersion" + ADD CONSTRAINT "CertificateArtifactVersion_orderId_fkey" + FOREIGN KEY ("orderId") REFERENCES "CertificateOrder"("id") + ON DELETE SET NULL ON UPDATE CASCADE; + +ALTER TABLE "CertificateArtifactVersion" + ADD CONSTRAINT "CertificateArtifactVersion_certificateId_fkey" + FOREIGN KEY ("certificateId") REFERENCES "Certificate"("id") + ON DELETE SET NULL ON UPDATE CASCADE; + diff --git a/prisma/migrations/20260524200000_session17_certificate_distribution_activation/migration.sql b/prisma/migrations/20260524200000_session17_certificate_distribution_activation/migration.sql new file mode 100644 index 0000000..eec43d0 --- /dev/null +++ b/prisma/migrations/20260524200000_session17_certificate_distribution_activation/migration.sql @@ -0,0 +1,9 @@ +ALTER TABLE "CertificateArtifactVersion" + ADD COLUMN "isCurrent" BOOLEAN NOT NULL DEFAULT false, + ADD COLUMN "distributionStatus" TEXT, + ADD COLUMN "distributionOperationId" TEXT, + ADD COLUMN "distributionCompletedAt" TIMESTAMP(3); + +CREATE INDEX "CertificateArtifactVersion_domainsHash_isCurrent_idx" + ON "CertificateArtifactVersion"("domainsHash", "isCurrent"); + diff --git a/prisma/migrations/20260524213000_session18_acme_strategy_hardening/migration.sql b/prisma/migrations/20260524213000_session18_acme_strategy_hardening/migration.sql new file mode 100644 index 0000000..0324c77 --- /dev/null +++ b/prisma/migrations/20260524213000_session18_acme_strategy_hardening/migration.sql @@ -0,0 +1,21 @@ +-- Harden the ACME strategy for clustered production + +ALTER TABLE "AcmeChallenge" +ADD COLUMN "orderId" TEXT, +ADD COLUMN "challengeType" TEXT NOT NULL DEFAULT 'http-01', +ADD COLUMN "provider" TEXT, +ADD COLUMN "status" TEXT NOT NULL DEFAULT 'presented', +ADD COLUMN "metadata" JSONB, +ADD COLUMN "presentedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, +ADD COLUMN "cleanedUpAt" TIMESTAMP(3), +ADD COLUMN "finalizedAt" TIMESTAMP(3), +ADD COLUMN "lastServedAt" TIMESTAMP(3); + +UPDATE "AcmeChallenge" +SET "presentedAt" = COALESCE("createdAt", CURRENT_TIMESTAMP), + "status" = COALESCE(NULLIF("status", ''), 'presented'), + "challengeType" = COALESCE(NULLIF("challengeType", ''), 'http-01'); + +CREATE INDEX "AcmeChallenge_orderId_createdAt_idx" ON "AcmeChallenge"("orderId", "createdAt"); +CREATE INDEX "AcmeChallenge_status_expiresAt_idx" ON "AcmeChallenge"("status", "expiresAt"); + diff --git a/prisma/migrations/20260526202000_session19_private_key_encryption/migration.sql b/prisma/migrations/20260526202000_session19_private_key_encryption/migration.sql new file mode 100644 index 0000000..19fcdab --- /dev/null +++ b/prisma/migrations/20260526202000_session19_private_key_encryption/migration.sql @@ -0,0 +1,8 @@ +-- Encrypt private key material at rest + +ALTER TABLE "Certificate" +ADD COLUMN "keyEncryption" JSONB; + +ALTER TABLE "CertificateArtifactVersion" +ADD COLUMN "keyEncryption" JSONB; + diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ab195fd..f88f047 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -4,7 +4,6 @@ generator client { datasource db { provider = "postgresql" - url = env("DATABASE_URL") } model ProxyEntry { @@ -27,6 +26,7 @@ model Certificate { domainsHash String // SHA256 hash of sorted split domains certPem String // Certificate PEM keyPem String // Private Key PEM + keyEncryption Json? expiresAt DateTime issuedAt DateTime lastUsedAt DateTime @@ -38,17 +38,102 @@ model Certificate { retryAfter DateTime? failureCount Int @default(0) issuedByNode String? + orders CertificateOrder[] + artifactVersions CertificateArtifactVersion[] @@unique([domainsHash]) @@index([domainsHash]) @@index([retryAfter]) } +model CertificateOrder { + id String @id @default(uuid()) + domains String // ';'-joined list of domains for this order + domainsHash String + primaryDomain String + sourceType String @default("acme") // acme, uploaded, self-signed, imported + status String @default("requested") // requested, challenge-published, validating, issued, distributing, activated, failed, revoked + attemptCount Int @default(1) + retryCount Int @default(0) + nextRetryAt DateTime? + lastError String? + requestedByNode String? + certificateId String? + requestedAt DateTime @default(now()) + startedAt DateTime? + challengePublishedAt DateTime? + validatingAt DateTime? + issuedAt DateTime? + distributingAt DateTime? + activatedAt DateTime? + failedAt DateTime? + revokedAt DateTime? + completedAt DateTime? + metadata Json? + certificate Certificate? @relation(fields: [certificateId], references: [id], onDelete: SetNull) + events CertificateOrderEvent[] + artifacts CertificateArtifactVersion[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([domainsHash, createdAt]) + @@index([status, nextRetryAt]) + @@index([certificateId]) +} + +model CertificateOrderEvent { + id String @id @default(uuid()) + orderId String + eventType String + fromStatus String? + toStatus String? + message String? + attemptNumber Int? + retryAt DateTime? + details Json? + order CertificateOrder @relation(fields: [orderId], references: [id], onDelete: Cascade) + occurredAt DateTime @default(now()) + + @@index([orderId, occurredAt]) + @@index([toStatus, occurredAt]) +} + +model CertificateArtifactVersion { + id String @id @default(uuid()) + orderId String? + certificateId String? + domains String // ';'-joined list of domains represented by this artifact + domainsHash String + version Int + sourceType String @default("acme") // acme, uploaded, self-signed, imported + certPem String + keyPem String + keyEncryption Json? + issuedAt DateTime + expiresAt DateTime + activatedAt DateTime? + isCurrent Boolean @default(false) + distributionStatus String? + distributionOperationId String? + distributionCompletedAt DateTime? + createdByNode String? + metadata Json? + order CertificateOrder? @relation(fields: [orderId], references: [id], onDelete: SetNull) + certificate Certificate? @relation(fields: [certificateId], references: [id], onDelete: SetNull) + createdAt DateTime @default(now()) + + @@unique([domainsHash, version]) + @@index([orderId]) + @@index([certificateId, createdAt]) + @@index([domainsHash, createdAt]) + @@index([domainsHash, isCurrent]) +} + model ClusterNode { id String @id @default(uuid()) hostname String instanceId String @unique - ipAddress String? // IP address of the node for direct communication + ipAddress String? // Advertised control-plane host/address used for peer communication isLeader Boolean @default(false) lastHeartbeat DateTime version String? @@ -62,17 +147,129 @@ model ClusterNode { @@index([ipAddress]) } +model ClusterLease { + id String @id @default(uuid()) + leaseName String @unique + ownerNodeId String? // Currently stores the active instanceId until stable node IDs land in later sessions + ownerHostname String? + generation Int @default(1) + ttlSeconds Int @default(30) + acquiredAt DateTime @default(now()) + renewedAt DateTime @default(now()) + expiresAt DateTime + metadata Json? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([ownerNodeId]) + @@index([expiresAt]) + @@index([ownerNodeId, expiresAt]) +} + +model ClusterOperation { + id String @id @default(uuid()) + operationType String + scope String @default("cluster") + status String @default("pending") + initiatorNodeId String? + initiatorHostname String? + initiatorActorId String? + initiatorActorType String? + initiatorActorDisplayName String? + correlationId String? + requestPath String? + targetNodeCount Int @default(0) + completedNodeCount Int @default(0) + successfulNodeCount Int @default(0) + failedNodeCount Int @default(0) + startedAt DateTime? + completedAt DateTime? + lastError String? + metadata Json? + acknowledgements ClusterOperationAck[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([status, createdAt]) + @@index([operationType, createdAt]) + @@index([correlationId]) +} + +model ClusterOperationAck { + id String @id @default(uuid()) + operationId String + nodeInstanceId String + nodeHostname String? + endpointUrl String? + status String @default("pending") + responseStatus Int? + errorMessage String? + startedAt DateTime? + ackedAt DateTime? + details Json? + operation ClusterOperation @relation(fields: [operationId], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([operationId, nodeInstanceId]) + @@index([status, ackedAt]) + @@index([nodeInstanceId, status]) +} + model AcmeChallenge { - id String @id @default(uuid()) - token String @unique // The challenge token (filename) - keyAuth String // The key authorization (file content) - domain String // Domain this challenge is for - createdAt DateTime @default(now()) - expiresAt DateTime // Challenges expire after 1 hour + id String @id @default(uuid()) + orderId String? + token String @unique // The challenge token (filename) + keyAuth String // The key authorization (file content) + domain String // Domain this challenge is for + challengeType String @default("http-01") + provider String? + status String @default("presented") // presented, cleaned-up, validated, failed, expired + metadata Json? + createdAt DateTime @default(now()) + presentedAt DateTime @default(now()) + cleanedUpAt DateTime? + finalizedAt DateTime? + lastServedAt DateTime? + expiresAt DateTime // Challenges expire after 1 hour @@index([token]) + @@index([orderId, createdAt]) @@index([domain]) + @@index([status, expiresAt]) @@index([expiresAt]) } +model AuditEvent { + id String @id @default(uuid()) + action String + targetType String? + targetId String? + targetDisplay String? + outcome String + result String + correlationId String + actorId String? + actorSubject String? + actorType String? + actorDisplayName String? + actorAuthMethod String? + actorRoles String? + requestMethod String + requestPath String + responseStatus Int + nodeId String? + ipAddress String? + errorCode String? + errorMessage String? + metadata Json? + occurredAt DateTime @default(now()) + + @@index([occurredAt]) + @@index([action]) + @@index([outcome]) + @@index([actorSubject]) + @@index([correlationId]) +} + diff --git a/scripts/ci/assert-coverage.js b/scripts/ci/assert-coverage.js new file mode 100644 index 0000000..13e56d3 --- /dev/null +++ b/scripts/ci/assert-coverage.js @@ -0,0 +1,113 @@ +#!/usr/bin/env node + +const { spawn } = require('node:child_process'); +const path = require('node:path'); + +const repoRoot = path.resolve(__dirname, '..', '..'); +const coverageCommand = [ + path.join(repoRoot, 'test/run-suite.js'), + 'all', + '--coverage', +]; + +const thresholds = { + lines: readThreshold('COVERAGE_MIN_LINES', 70), + branches: readThreshold('COVERAGE_MIN_BRANCHES', 67), + functions: readThreshold('COVERAGE_MIN_FUNCTIONS', 76), +}; + +const child = spawn(process.execPath, coverageCommand, { + cwd: repoRoot, + env: process.env, + stdio: ['inherit', 'pipe', 'pipe'], +}); + +let transcript = ''; + +child.stdout.on('data', (chunk) => { + const text = chunk.toString(); + transcript += text; + process.stdout.write(chunk); +}); + +child.stderr.on('data', (chunk) => { + const text = chunk.toString(); + transcript += text; + process.stderr.write(chunk); +}); + +child.on('close', (code, signal) => { + if (typeof code === 'number' && code !== 0) { + process.exit(code); + } + + if (signal) { + console.error(`Coverage run terminated by signal: ${signal}`); + process.exit(1); + } + + const summary = parseCoverageSummary(transcript); + if (!summary) { + console.error('Unable to locate the final "all files" coverage summary in the test output.'); + process.exit(1); + } + + console.log( + `Coverage summary: lines=${summary.lines.toFixed(2)}% branches=${summary.branches.toFixed(2)}% functions=${summary.functions.toFixed(2)}%`, + ); + console.log( + `Coverage thresholds: lines>=${thresholds.lines}% branches>=${thresholds.branches}% functions>=${thresholds.functions}%`, + ); + + const failures = []; + if (summary.lines < thresholds.lines) { + failures.push(`lines ${summary.lines.toFixed(2)}% < ${thresholds.lines}%`); + } + if (summary.branches < thresholds.branches) { + failures.push(`branches ${summary.branches.toFixed(2)}% < ${thresholds.branches}%`); + } + if (summary.functions < thresholds.functions) { + failures.push(`functions ${summary.functions.toFixed(2)}% < ${thresholds.functions}%`); + } + + if (failures.length > 0) { + console.error(`Coverage gate failed: ${failures.join(', ')}`); + process.exit(1); + } + + console.log('Coverage gate passed.'); +}); + +function readThreshold(envName, fallback) { + const rawValue = process.env[envName]; + if (rawValue == null || rawValue.trim() === '') { + return fallback; + } + + const parsed = Number(rawValue); + if (!Number.isFinite(parsed) || parsed < 0 || parsed > 100) { + throw new Error(`${envName} must be a number between 0 and 100. Received: ${rawValue}`); + } + + return parsed; +} + +function parseCoverageSummary(output) { + const normalized = stripAnsi(output); + const match = normalized.match(/all files\s*\|\s*([\d.]+)\s*\|\s*([\d.]+)\s*\|\s*([\d.]+)\s*\|/); + + if (!match) { + return null; + } + + return { + lines: Number(match[1]), + branches: Number(match[2]), + functions: Number(match[3]), + }; +} + +function stripAnsi(value) { + return value.replace(/\u001B\[[0-9;]*m/g, ''); +} + diff --git a/src/app.controller.ts b/src/app.controller.ts index 03a9eaf..d33eb2a 100644 --- a/src/app.controller.ts +++ b/src/app.controller.ts @@ -1,14 +1,26 @@ -import { Controller, Post, Res, UseGuards } from '@nestjs/common'; +import { + Controller, + HttpCode, + HttpStatus, + Post, + Res, + UseGuards, +} from '@nestjs/common'; import { Response } from 'express'; import { ReloaderService } from './reloader/reloader.service'; +import { AuthorizeAdmin } from './auth/decorators/authorize.decorator'; import { ApiKeyGuard } from './auth/guards/api-key.guard'; +import { Audit } from './audit/decorators/audit.decorator'; @Controller() export class AppController { constructor(private reloader: ReloaderService) {} @Post('reload') + @HttpCode(HttpStatus.OK) @UseGuards(ApiKeyGuard) + @AuthorizeAdmin('operator') + @Audit({ action: 'config.reload.local' }) async reload(@Res() res: Response) { const result = await this.reloader.reloadConfig(); if (result.ok) { diff --git a/src/app.module.ts b/src/app.module.ts index 203ea81..e9301e6 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,4 +1,4 @@ -import { Logger, Module } from '@nestjs/common'; +import { Module } from '@nestjs/common'; import { AppController } from './app.controller'; import { PrismaModule } from './prisma/prisma.module'; import { ReloaderModule } from './reloader/reloader.module'; @@ -7,20 +7,25 @@ import { AppService } from './app.service'; import { CertificateModule } from './certificate/certificate.module'; import { HealthModule } from './health/health.module'; import { LogsModule } from './logs/logs.module'; -import { LogsService } from './logs/logs.service'; import { RateLimitModule } from './rate-limit/rate-limit.module'; import { MetricsModule } from './metrics/metrics.module'; import { AuthModule } from './auth/auth.module'; import { DistributedLockModule } from './distributed-lock/distributed-lock.module'; +import { AuditModule } from './audit/audit.module'; +import { ProxyModule } from './proxy/proxy.module'; +import { SecurityModule } from './security/security.module'; @Module({ imports: [ + AuditModule, AuthModule, RateLimitModule, MetricsModule, PrismaModule, DistributedLockModule, + ProxyModule, + SecurityModule, ReloaderModule, NginxModule, CertificateModule, @@ -31,28 +36,4 @@ import { DistributedLockModule } from './distributed-lock/distributed-lock.modul controllers: [AppController], }) export class AppModule { - constructor(logsService: LogsService) { - // Use custom logger for NestJS, but preserve stdout for docker logs - const logger = new Logger('App'); - logger.log = (...args) => { - logsService.log(args.join(' ')); - process.stdout.write(args.join(' ') + '\n'); - }; - logger.error = (...args) => { - logsService.error(args.join(' ')); - process.stderr.write(args.join(' ') + '\n'); - }; - logger.warn = (...args) => { - logsService.warn(args.join(' ')); - process.stdout.write(args.join(' ') + '\n'); - }; - logger.debug = (...args) => { - logsService.debug(args.join(' ')); - process.stdout.write(args.join(' ') + '\n'); - }; - logger.verbose = (...args) => { - logsService.verbose(args.join(' ')); - process.stdout.write(args.join(' ') + '\n'); - }; - } } diff --git a/src/audit/audit-context.ts b/src/audit/audit-context.ts new file mode 100644 index 0000000..5fe71ba --- /dev/null +++ b/src/audit/audit-context.ts @@ -0,0 +1,79 @@ +import { randomUUID } from 'crypto'; +import { AuthenticatedRequest } from '../auth/interfaces/authenticated-request.interface'; + +function firstHeaderValue( + value: string | string[] | undefined, +): string | undefined { + if (typeof value === 'string' && value.trim()) { + return value.trim(); + } + + if (Array.isArray(value)) { + const first = value.find( + (entry): entry is string => + typeof entry === 'string' && entry.trim().length > 0, + ); + return first?.trim(); + } + + return undefined; +} + +function sanitizeCorrelationId(value: string | undefined): string | undefined { + if (!value) { + return undefined; + } + + const normalized = value.trim(); + if (!normalized || normalized.length > 128) { + return undefined; + } + + return /^[A-Za-z0-9._:-]+$/.test(normalized) ? normalized : undefined; +} + +export function ensureAuditContext( + request: AuthenticatedRequest, + response?: { setHeader?: (name: string, value: string) => void }, +) { + const existing = request.auditContext; + if (existing?.correlationId) { + response?.setHeader?.('X-Correlation-Id', existing.correlationId); + return existing; + } + + const correlationId = + sanitizeCorrelationId( + firstHeaderValue(request.headers?.['x-correlation-id']), + ) || + sanitizeCorrelationId( + firstHeaderValue(request.headers?.['x-request-id']), + ) || + randomUUID(); + + request.auditContext = { + correlationId, + startedAt: new Date().toISOString(), + }; + + response?.setHeader?.('X-Correlation-Id', correlationId); + + return request.auditContext; +} + +export function getRequestPath(request: AuthenticatedRequest) { + return request.originalUrl || request.url || request.route?.path || '/'; +} + +export function getRequestIpAddress(request: AuthenticatedRequest) { + const forwardedFor = firstHeaderValue(request.headers?.['x-forwarded-for']); + if (forwardedFor) { + return forwardedFor.split(',')[0]?.trim(); + } + + return ( + request.ip || + request.socket?.remoteAddress || + request.connection?.remoteAddress + ); +} diff --git a/src/audit/audit-record.utils.ts b/src/audit/audit-record.utils.ts new file mode 100644 index 0000000..8097f31 --- /dev/null +++ b/src/audit/audit-record.utils.ts @@ -0,0 +1,171 @@ +import { HttpException } from '@nestjs/common'; +import { AuthenticatedRequest } from '../auth/interfaces/authenticated-request.interface'; +import { getRequestPath } from './audit-context'; +import { + AuditOptions, + AuditTargetDescriptor, + AuditTargetValue, +} from './types/audit.types'; + +function readNestedString( + value: Record<string, unknown> | undefined, + key: string, +): string | undefined { + const candidate = value?.[key]; + return typeof candidate === 'string' && candidate.trim() + ? candidate.trim() + : undefined; +} + +export function resolveAuditAction( + request: AuthenticatedRequest, + options?: AuditOptions, +) { + return ( + options?.action?.trim() || + `${request.method?.toUpperCase() || 'REQUEST'} ${getRequestPath(request)}` + ); +} + +export function resolveAuditTarget( + request: AuthenticatedRequest, + options?: AuditOptions, + responseBody?: unknown, +): AuditTargetValue { + const configuredTarget = options?.target; + + if (typeof configuredTarget === 'function') { + return configuredTarget(request, responseBody); + } + + if (configuredTarget !== undefined) { + return configuredTarget; + } + + return inferAuditTarget(request, responseBody); +} + +export function resolveAuditOutcome(statusCode: number) { + if (statusCode === 401 || statusCode === 403) { + return 'denied' as const; + } + + return statusCode >= 400 ? ('failure' as const) : ('success' as const); +} + +export function resolveAuditError(error: unknown): { + errorCode?: string; + errorMessage?: string; +} { + if (error instanceof HttpException) { + const response = error.getResponse(); + if (typeof response === 'string') { + return { errorMessage: response }; + } + + if (typeof response === 'object' && response !== null) { + const message = response['message']; + return { + errorCode: readNestedString( + response as Record<string, unknown>, + 'code', + ), + errorMessage: Array.isArray(message) + ? message + .filter((item): item is string => typeof item === 'string') + .join('; ') + : typeof message === 'string' + ? message + : error.message, + }; + } + + return { errorMessage: error.message }; + } + + if (error instanceof Error) { + return { errorMessage: error.message }; + } + + if (typeof error === 'string' && error.trim()) { + return { errorMessage: error.trim() }; + } + + return {}; +} + +export function resolveAuditStatusCode( + error: unknown, + fallbackStatusCode?: number, +) { + if (error instanceof HttpException) { + return error.getStatus(); + } + + if (fallbackStatusCode && fallbackStatusCode >= 400) { + return fallbackStatusCode; + } + + return 500; +} + +function inferAuditTarget( + request: AuthenticatedRequest, + responseBody?: unknown, +): AuditTargetValue { + const params = request.params || {}; + const body = request.body || {}; + const responseRecord = + typeof responseBody === 'object' && responseBody !== null + ? (responseBody as Record<string, unknown>) + : undefined; + + if (typeof params['id'] === 'string' && params['id'].trim()) { + return { id: params['id'].trim() }; + } + + if (typeof params['filename'] === 'string' && params['filename'].trim()) { + return { + type: 'backup', + id: params['filename'].trim(), + label: params['filename'].trim(), + }; + } + + if (typeof params['domain'] === 'string' && params['domain'].trim()) { + return { + type: 'domain', + id: params['domain'].trim(), + label: params['domain'].trim(), + }; + } + + if ( + typeof responseRecord?.['id'] === 'string' && + responseRecord['id'].trim() + ) { + return { id: responseRecord['id'].trim() }; + } + + if (typeof body['id'] === 'string' && body['id'].trim()) { + return { id: body['id'].trim() }; + } + + if (Array.isArray(body['domains']) && body['domains'].length > 0) { + const domains = body['domains'] + .filter( + (domain): domain is string => + typeof domain === 'string' && domain.trim().length > 0, + ) + .map((domain) => domain.trim()); + + if (domains.length > 0) { + return { + type: 'certificate', + label: domains.join(','), + } satisfies AuditTargetDescriptor; + } + } + + return undefined; +} diff --git a/src/audit/audit.controller.ts b/src/audit/audit.controller.ts new file mode 100644 index 0000000..2cba66b --- /dev/null +++ b/src/audit/audit.controller.ts @@ -0,0 +1,45 @@ +import { BadRequestException, Controller, Get, Query } from '@nestjs/common'; +import { AuthorizeAdmin } from '../auth/decorators/authorize.decorator'; +import { AuditService } from './audit.service'; +import { AuditOutcome } from './types/audit.types'; + +const AUDIT_OUTCOMES: AuditOutcome[] = ['success', 'failure', 'denied']; + +@Controller('audit') +@AuthorizeAdmin('security-admin') +export class AuditController { + constructor(private readonly auditService: AuditService) {} + + @Get() + async listEvents( + @Query('limit') limit?: string, + @Query('action') action?: string, + @Query('actorSubject') actorSubject?: string, + @Query('correlationId') correlationId?: string, + @Query('outcome') outcome?: AuditOutcome, + ) { + if (outcome && !AUDIT_OUTCOMES.includes(outcome)) { + throw new BadRequestException( + `Invalid audit outcome. Expected one of: ${AUDIT_OUTCOMES.join(', ')}`, + ); + } + + const parsedLimit = Number.parseInt(limit || '', 10); + const resolvedLimit = Number.isFinite(parsedLimit) + ? Math.min(Math.max(parsedLimit, 1), 200) + : 50; + + const events = await this.auditService.listEvents({ + action: action?.trim() || undefined, + actorSubject: actorSubject?.trim() || undefined, + correlationId: correlationId?.trim() || undefined, + limit: resolvedLimit, + outcome, + }); + + return { + count: events.length, + events, + }; + } +} diff --git a/src/audit/audit.interceptor.ts b/src/audit/audit.interceptor.ts new file mode 100644 index 0000000..139caf5 --- /dev/null +++ b/src/audit/audit.interceptor.ts @@ -0,0 +1,121 @@ +import { + CallHandler, + ExecutionContext, + Injectable, + NestInterceptor, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { Response } from 'express'; +import { Observable, catchError, tap, throwError } from 'rxjs'; +import { IS_PUBLIC_KEY } from '../auth/decorators/public.decorator'; +import { AuthenticatedRequest } from '../auth/interfaces/authenticated-request.interface'; +import { + ensureAuditContext, + getRequestIpAddress, + getRequestPath, +} from './audit-context'; +import { + resolveAuditAction, + resolveAuditError, + resolveAuditOutcome, + resolveAuditStatusCode, + resolveAuditTarget, +} from './audit-record.utils'; +import { + AUDIT_OPTIONS_KEY, + shouldAuditHttpRequest, +} from './decorators/audit.decorator'; +import { AuditService } from './audit.service'; +import { AuditOptions } from './types/audit.types'; + +@Injectable() +export class AuditInterceptor implements NestInterceptor { + constructor( + private readonly reflector: Reflector, + private readonly auditService: AuditService, + ) {} + + intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> { + const request = context.switchToHttp().getRequest<AuthenticatedRequest>(); + const response = context.switchToHttp().getResponse<Response>(); + const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [ + context.getHandler(), + context.getClass(), + ]); + const auditOptions = this.reflector.getAllAndOverride<AuditOptions>( + AUDIT_OPTIONS_KEY, + [context.getHandler(), context.getClass()], + ); + + if (isPublic || !shouldAuditHttpRequest(request.method, auditOptions)) { + return next.handle(); + } + + const auditContext = ensureAuditContext(request, response); + const action = resolveAuditAction(request, auditOptions); + + return next.handle().pipe( + tap((responseBody) => { + const responseStatus = response.statusCode || 200; + const outcome = resolveAuditOutcome(responseStatus); + const { errorMessage } = + outcome === 'success' + ? {} + : this.readManualFailureMessage(responseBody); + + void this.auditService.recordEvent({ + action, + actor: request.auth, + correlationId: auditContext.correlationId, + errorMessage, + ipAddress: getRequestIpAddress(request), + outcome, + requestMethod: request.method || 'UNKNOWN', + requestPath: getRequestPath(request), + responseStatus, + target: resolveAuditTarget(request, auditOptions, responseBody), + }); + }), + catchError((error: unknown) => { + const responseStatus = resolveAuditStatusCode( + error, + response.statusCode, + ); + const { errorCode, errorMessage } = resolveAuditError(error); + + void this.auditService.recordEvent({ + action, + actor: request.auth, + correlationId: auditContext.correlationId, + errorCode, + errorMessage, + ipAddress: getRequestIpAddress(request), + outcome: resolveAuditOutcome(responseStatus), + requestMethod: request.method || 'UNKNOWN', + requestPath: getRequestPath(request), + responseStatus, + target: resolveAuditTarget(request, auditOptions), + }); + + return throwError(() => error); + }), + ); + } + + private readManualFailureMessage(responseBody: unknown) { + if (typeof responseBody !== 'object' || responseBody === null) { + return {}; + } + + const record = responseBody as Record<string, unknown>; + if (typeof record['error'] === 'string' && record['error'].trim()) { + return { errorMessage: record['error'].trim() }; + } + + if (typeof record['message'] === 'string' && record['message'].trim()) { + return { errorMessage: record['message'].trim() }; + } + + return {}; + } +} diff --git a/src/audit/audit.module.ts b/src/audit/audit.module.ts new file mode 100644 index 0000000..f6e3a6d --- /dev/null +++ b/src/audit/audit.module.ts @@ -0,0 +1,20 @@ +import { Module } from '@nestjs/common'; +import { APP_INTERCEPTOR } from '@nestjs/core'; +import { PrismaModule } from '../prisma/prisma.module'; +import { AuditController } from './audit.controller'; +import { AuditInterceptor } from './audit.interceptor'; +import { AuditService } from './audit.service'; + +@Module({ + imports: [PrismaModule], + controllers: [AuditController], + providers: [ + AuditService, + { + provide: APP_INTERCEPTOR, + useClass: AuditInterceptor, + }, + ], + exports: [AuditService], +}) +export class AuditModule {} diff --git a/src/audit/audit.service.ts b/src/audit/audit.service.ts new file mode 100644 index 0000000..24d6217 --- /dev/null +++ b/src/audit/audit.service.ts @@ -0,0 +1,169 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { AuditEvent as PrismaAuditEvent, Prisma } from '@prisma/client'; +import { PrismaService } from '../prisma/prisma.service'; +import { + AuditQueryOptions, + AuditTargetDescriptor, + AuditTargetValue, + AuditWriteInput, +} from './types/audit.types'; + +@Injectable() +export class AuditService { + private readonly logger = new Logger(AuditService.name); + private readonly nodeId = + process.env['CLUSTER_NODE_ID']?.trim() || + process.env['HOSTNAME']?.trim() || + 'unknown-node'; + + constructor(private readonly prisma: PrismaService) {} + + async recordEvent(input: AuditWriteInput) { + const normalizedTarget = this.normalizeTarget(input.target); + const metadata = input.metadata + ? (input.metadata as Prisma.InputJsonValue) + : undefined; + + try { + await this.prisma.auditEvent.create({ + data: { + action: input.action, + outcome: input.outcome, + result: this.resolveResultSummary(input), + correlationId: input.correlationId, + actorId: input.actor?.id, + actorSubject: input.actor?.subject, + actorType: input.actor?.actorType, + actorDisplayName: input.actor?.displayName, + actorAuthMethod: input.actor?.authMethod, + actorRoles: input.actor?.roles?.join(','), + requestMethod: input.requestMethod, + requestPath: input.requestPath, + responseStatus: input.responseStatus, + nodeId: this.nodeId, + ipAddress: input.ipAddress, + errorCode: input.errorCode, + errorMessage: input.errorMessage, + targetType: normalizedTarget?.type, + targetId: normalizedTarget?.id, + targetDisplay: normalizedTarget?.label, + metadata, + }, + }); + } catch (error) { + this.logger.error( + `Failed to persist audit event for ${input.action}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + async listEvents(options: AuditQueryOptions) { + const where: Prisma.AuditEventWhereInput = {}; + + if (options.action) { + where.action = { + contains: options.action, + mode: 'insensitive', + }; + } + + if (options.actorSubject) { + where.actorSubject = { + contains: options.actorSubject, + mode: 'insensitive', + }; + } + + if (options.correlationId) { + where.correlationId = options.correlationId; + } + + if (options.outcome) { + where.outcome = options.outcome; + } + + const records = await this.prisma.auditEvent.findMany({ + where, + orderBy: { occurredAt: 'desc' }, + take: options.limit, + }); + + return records.map((record) => this.presentEvent(record)); + } + + private resolveResultSummary(input: AuditWriteInput) { + if (input.outcome === 'success') { + return `HTTP ${input.responseStatus}`; + } + + if (input.errorMessage) { + return input.errorMessage; + } + + return `HTTP ${input.responseStatus}`; + } + + private normalizeTarget( + target: AuditTargetValue, + ): AuditTargetDescriptor | undefined { + if (!target) { + return undefined; + } + + if (typeof target === 'string') { + return { + label: target, + }; + } + + return { + type: target.type?.trim() || undefined, + id: target.id?.trim() || undefined, + label: target.label?.trim() || undefined, + }; + } + + private presentEvent(record: PrismaAuditEvent) { + return { + id: record.id, + action: record.action, + outcome: record.outcome, + result: record.result, + correlationId: record.correlationId, + occurredAt: record.occurredAt, + actor: record.actorId + ? { + id: record.actorId, + subject: record.actorSubject, + actorType: record.actorType, + displayName: record.actorDisplayName, + authMethod: record.actorAuthMethod, + roles: record.actorRoles?.split(',').filter(Boolean) || [], + } + : null, + target: + record.targetType || record.targetId || record.targetDisplay + ? { + type: record.targetType, + id: record.targetId, + label: record.targetDisplay, + } + : null, + request: { + method: record.requestMethod, + path: record.requestPath, + responseStatus: record.responseStatus, + ipAddress: record.ipAddress, + }, + nodeId: record.nodeId, + error: + record.errorCode || record.errorMessage + ? { + code: record.errorCode, + message: record.errorMessage, + } + : null, + metadata: record.metadata, + }; + } +} diff --git a/src/audit/decorators/audit.decorator.ts b/src/audit/decorators/audit.decorator.ts new file mode 100644 index 0000000..1f1b0af --- /dev/null +++ b/src/audit/decorators/audit.decorator.ts @@ -0,0 +1,24 @@ +import { SetMetadata } from '@nestjs/common'; +import { AuditOptions } from '../types/audit.types'; + +export const AUDIT_OPTIONS_KEY = 'audit:options'; + +export const Audit = (options: AuditOptions = {}) => + SetMetadata(AUDIT_OPTIONS_KEY, options); + +export function shouldAuditHttpRequest( + method: string | undefined, + options?: AuditOptions, +) { + if (options) { + return true; + } + + const normalizedMethod = method?.toUpperCase(); + return ( + normalizedMethod === 'POST' || + normalizedMethod === 'PUT' || + normalizedMethod === 'PATCH' || + normalizedMethod === 'DELETE' + ); +} diff --git a/src/audit/types/audit.types.ts b/src/audit/types/audit.types.ts new file mode 100644 index 0000000..8c262df --- /dev/null +++ b/src/audit/types/audit.types.ts @@ -0,0 +1,45 @@ +import { AuthIdentity } from '../../auth/types/auth-identity'; +import { AuthenticatedRequest } from '../../auth/interfaces/authenticated-request.interface'; + +export type AuditOutcome = 'success' | 'failure' | 'denied'; + +export interface AuditTargetDescriptor { + type?: string; + id?: string; + label?: string; +} + +export type AuditTargetValue = string | AuditTargetDescriptor | undefined; + +export type AuditTargetResolver = ( + request: AuthenticatedRequest, + responseBody?: unknown, +) => AuditTargetValue; + +export interface AuditOptions { + action?: string; + target?: AuditTargetValue | AuditTargetResolver; +} + +export interface AuditWriteInput { + action: string; + outcome: AuditOutcome; + responseStatus: number; + correlationId: string; + requestMethod: string; + requestPath: string; + target?: AuditTargetValue; + actor?: AuthIdentity; + errorCode?: string; + errorMessage?: string; + metadata?: Record<string, unknown>; + ipAddress?: string; +} + +export interface AuditQueryOptions { + limit: number; + action?: string; + actorSubject?: string; + correlationId?: string; + outcome?: AuditOutcome; +} diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index 55c5ea6..a7613b9 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -1,26 +1,83 @@ -import { Controller, Get, UseGuards } from '@nestjs/common'; +import { + BadRequestException, + Controller, + Get, + HttpCode, + HttpStatus, + Post, + UseGuards, +} from '@nestjs/common'; import { AuthService } from './auth.service'; +import { Authorize, AuthorizeAdmin } from './decorators/authorize.decorator'; import { ApiKeyGuard } from './guards/api-key.guard'; +import { CurrentIdentity } from './decorators/current-identity.decorator'; +import { AuthIdentity } from './types/auth-identity'; +import { Audit } from '../audit/decorators/audit.decorator'; @Controller('auth') +@AuthorizeAdmin('viewer') export class AuthController { - constructor(private authService: AuthService) {} + private readonly authService: AuthService; + + constructor(authService: AuthService) { + this.authService = authService; + } @Get('status') @UseGuards(ApiKeyGuard) - async getStatus() { + @Authorize({ actorTypes: ['admin'] }, { actorTypes: ['internal-node'] }) + async getStatus(@CurrentIdentity() identity: AuthIdentity) { return { authenticated: true, - message: 'API key authentication is working', + message: `${identity.authMethod} authentication is working`, + identity: this.presentIdentity(identity), }; } @Get('info') async getAuthInfo() { + return this.authService.getAuthCapabilities(); + } + + @Get('me') + @Authorize({ actorTypes: ['admin'] }, { actorTypes: ['internal-node'] }) + async getCurrentIdentity(@CurrentIdentity() identity: AuthIdentity) { + return { + authenticated: true, + identity: this.presentIdentity(identity), + }; + } + + @Post('token') + @HttpCode(HttpStatus.OK) + @Authorize({ actorTypes: ['admin'] }) + @Audit({ action: 'auth.token.exchange' }) + async exchangeLegacyApiKey(@CurrentIdentity() identity: AuthIdentity) { + if (identity.authMethod !== 'api-key') { + throw new BadRequestException( + 'Token exchange is only available for legacy API-key authenticated requests', + ); + } + + return this.authService.issueAccessToken(identity); + } + + private presentIdentity(identity: AuthIdentity) { return { - authEnabled: this.authService.isAuthEnabled(), - methods: ['api-key'], - apiKeyConfigured: !!process.env.API_KEY, + id: identity.id, + subject: identity.subject, + actorType: identity.actorType, + authMethod: identity.authMethod, + displayName: identity.displayName, + roles: identity.roles, + scopes: identity.scopes, + audience: identity.audience, + issuer: identity.issuer, + apiKeyId: identity.apiKeyId, + nodeId: identity.nodeId, + tokenId: identity.tokenId, + issuedAt: identity.issuedAt, + expiresAt: identity.expiresAt, }; } } diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index a1b6b71..e8ee4ad 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -1,11 +1,25 @@ import { Module } from '@nestjs/common'; +import { APP_GUARD } from '@nestjs/core'; import { AuthService } from './auth.service'; import { AuthController } from './auth.controller'; +import { ApiKeyGuard } from './guards/api-key.guard'; +import { AuthorizationGuard } from './guards/authorization.guard'; +import { AuditModule } from '../audit/audit.module'; @Module({ - imports: [], + imports: [AuditModule], controllers: [AuthController], - providers: [AuthService], + providers: [ + AuthService, + { + provide: APP_GUARD, + useClass: ApiKeyGuard, + }, + { + provide: APP_GUARD, + useClass: AuthorizationGuard, + }, + ], exports: [AuthService], }) export class AuthModule {} diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index e00f60f..46df79f 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -1,35 +1,541 @@ -import { Injectable } from '@nestjs/common'; +import { + Injectable, + ServiceUnavailableException, + UnauthorizedException, +} from '@nestjs/common'; +import { + createHmac, + createPublicKey, + KeyObject, + randomUUID, + createHash, + timingSafeEqual, + verify, +} from 'crypto'; +import { + AuthCapabilities, + AuthIdentity, + ActorType, + ConfiguredApiKeySummary, +} from './types/auth-identity'; +import { AUTH_ROLES, AUTH_ROLE_HIERARCHY } from './types/auth-role'; @Injectable() export class AuthService { - // In production, these should come from a database - private readonly validApiKeys: Set<string>; + private readonly validApiKeys: Map<string, AuthIdentity>; + private readonly defaultAdminRoles: string[]; + private readonly defaultAdminScopes: string[]; + private readonly jwtIssuer?: string; + private readonly jwtAudience?: string; + private readonly jwtAllowedAlgorithms: string[]; + private readonly jwtSecret?: string; + private readonly jwtPublicKey?: KeyObject; + private readonly accessTokenTtlSeconds: number; constructor() { - // Load API keys from environment (comma-separated) - const apiKeys = process.env.API_KEY?.split(',').filter(Boolean) || []; - this.validApiKeys = new Set(apiKeys); + const apiKeys = + process.env['API_KEY'] + ?.split(',') + .map((apiKey) => apiKey.trim()) + .filter(Boolean) || []; + this.defaultAdminRoles = this.parseDelimitedEnv( + process.env['AUTH_DEFAULT_ADMIN_ROLES'], + ['platform-admin'], + ); + this.defaultAdminScopes = this.parseDelimitedEnv( + process.env['AUTH_DEFAULT_ADMIN_SCOPES'], + ['admin:full'], + ); + this.jwtIssuer = process.env['AUTH_JWT_ISSUER']?.trim() || 'lyttle-nginx'; + this.jwtAudience = + process.env['AUTH_JWT_AUDIENCE']?.trim() || 'lyttle-nginx-admin'; + this.jwtAllowedAlgorithms = this.parseDelimitedEnv( + process.env['AUTH_JWT_ALLOWED_ALGS'], + ['HS256', 'RS256'], + ); + this.jwtSecret = process.env['AUTH_JWT_SECRET']?.trim() || undefined; + this.jwtPublicKey = this.loadJwtPublicKey( + process.env['AUTH_JWT_PUBLIC_KEY'], + ); + this.accessTokenTtlSeconds = this.parsePositiveInteger( + process.env['AUTH_ACCESS_TOKEN_TTL_SECONDS'], + 900, + ); + this.validApiKeys = new Map( + apiKeys.map((apiKey, index) => { + const apiKeyId = this.buildApiKeyId(apiKey, index); + const identity: AuthIdentity = { + id: `api-key:${apiKeyId}`, + subject: `api-key:${apiKeyId}`, + actorType: 'admin', + authMethod: 'api-key', + displayName: `legacy-api-key-${index + 1}`, + roles: [...this.defaultAdminRoles], + scopes: [...this.defaultAdminScopes], + audience: this.jwtAudience ? [this.jwtAudience] : [], + issuer: this.jwtIssuer, + apiKeyId, + }; + + return [apiKey, identity] as const; + }), + ); + } + + authenticateApiKey(apiKey: string): AuthIdentity | null { + const normalizedApiKey = apiKey.trim(); + if (!normalizedApiKey || this.validApiKeys.size === 0) { + return null; + } + + const provided = Buffer.from(normalizedApiKey); + + for (const [validApiKey, identity] of this.validApiKeys.entries()) { + const candidate = Buffer.from(validApiKey); + if ( + provided.length === candidate.length && + timingSafeEqual(provided, candidate) + ) { + return { + ...identity, + roles: [...identity.roles], + scopes: [...identity.scopes], + audience: [...identity.audience], + claims: identity.claims ? { ...identity.claims } : undefined, + }; + } + } + + return null; } - /** - * Validate API key - */ validateApiKey(apiKey: string): boolean { - // If no API keys configured, allow access (development mode) + return this.authenticateApiKey(apiKey) !== null; + } + + isAuthEnabled(): boolean { + return this.validApiKeys.size > 0 || this.canVerifyBearerTokens(); + } + + getAuthCapabilities(): AuthCapabilities { + const methods: Array<'api-key' | 'bearer-token'> = []; + if (this.validApiKeys.size > 0) { + methods.push('api-key'); + } + if (this.canVerifyBearerTokens()) { + methods.push('bearer-token'); + } + + return { + authEnabled: this.isAuthEnabled(), + methods, + apiKeyConfigured: this.validApiKeys.size > 0, + apiKeyCount: this.validApiKeys.size, + bearerTokenVerificationConfigured: this.canVerifyBearerTokens(), + tokenExchangeEnabled: this.canIssueAccessTokens(), + issuer: this.jwtIssuer, + audience: this.jwtAudience, + supportedBearerAlgorithms: this.getSupportedBearerAlgorithms(), + supportedRoles: [...AUTH_ROLES], + roleHierarchy: { ...AUTH_ROLE_HIERARCHY }, + defaultAdminRoles: [...this.defaultAdminRoles], + defaultAdminScopes: [...this.defaultAdminScopes], + }; + } + + listConfiguredApiKeys(): ConfiguredApiKeySummary[] { + return [...this.validApiKeys.entries()].map(([apiKey, identity]) => ({ + apiKeyId: identity.apiKeyId ?? this.buildApiKeyId(apiKey, 0), + fingerprint: this.fingerprintApiKey(apiKey), + displayName: identity.displayName, + roles: [...identity.roles], + scopes: [...identity.scopes], + })); + } + + fingerprintApiKey(apiKey: string): string { + return createHash('sha256') + .update(apiKey.trim()) + .digest('hex') + .slice(0, 16); + } + + canIssueAccessTokens(): boolean { + return Boolean(this.jwtSecret); + } + + canVerifyBearerTokens(): boolean { + return Boolean(this.jwtSecret || this.jwtPublicKey); + } + + issueAccessToken(identity: AuthIdentity) { + if (!this.jwtSecret) { + throw new ServiceUnavailableException( + 'JWT token issuance is not configured', + ); + } + + const now = Math.floor(Date.now() / 1000); + const exp = now + this.accessTokenTtlSeconds; + const payload = { + sub: identity.subject, + iss: this.jwtIssuer, + aud: this.jwtAudience, + iat: now, + nbf: now, + exp, + jti: randomUUID(), + name: identity.displayName, + roles: identity.roles, + scope: identity.scopes.join(' '), + actor_type: identity.actorType, + api_key_id: identity.apiKeyId, + node_id: identity.nodeId, + }; + + const header = { + alg: 'HS256', + typ: 'JWT', + }; + const signingInput = `${this.base64UrlEncodeJson(header)}.${this.base64UrlEncodeJson(payload)}`; + const signature = createHmac('sha256', this.jwtSecret) + .update(signingInput) + .digest('base64url'); + const accessToken = `${signingInput}.${signature}`; + + return { + tokenType: 'Bearer', + accessToken, + expiresInSeconds: this.accessTokenTtlSeconds, + expiresAt: new Date(exp * 1000).toISOString(), + actor: { + subject: identity.subject, + actorType: identity.actorType, + roles: identity.roles, + scopes: identity.scopes, + }, + }; + } + + authenticateBearerToken(token: string): AuthIdentity { + if (!this.canVerifyBearerTokens()) { + throw new UnauthorizedException( + 'Bearer token authentication is not configured', + ); + } + + const segments = token.split('.'); + if (segments.length !== 3) { + throw new UnauthorizedException('Malformed bearer token'); + } + + const [headerSegment, payloadSegment, signatureSegment] = segments; + const header = this.parseJsonSegment<Record<string, unknown>>( + headerSegment, + 'Invalid bearer token header', + ); + const payload = this.parseJsonSegment<Record<string, unknown>>( + payloadSegment, + 'Invalid bearer token payload', + ); + const algorithm = this.readStringClaim(header.alg); + + if (!algorithm || !this.jwtAllowedAlgorithms.includes(algorithm)) { + throw new UnauthorizedException('Unsupported bearer token algorithm'); + } + + const signingInput = `${headerSegment}.${payloadSegment}`; + this.verifyJwtSignature(algorithm, signingInput, signatureSegment); + this.validateRegisteredClaims(payload); + + const subject = + this.readStringClaim(payload['sub']) || + this.readStringClaim(payload['client_id']); + if (!subject) { + throw new UnauthorizedException('Bearer token subject is required'); + } + + const actorType = this.resolveActorType(payload); + const audience = this.extractAudience(payload.aud); + const roles = this.extractRoles(payload); + const scopes = this.extractScopes(payload); + + return { + id: `${actorType}:${subject}`, + subject, + actorType, + authMethod: 'bearer-token', + displayName: + this.readStringClaim(payload['name']) || + this.readStringClaim(payload['preferred_username']) || + subject, + roles, + scopes, + audience, + issuer: this.readStringClaim(payload['iss']), + tokenId: this.readStringClaim(payload['jti']), + nodeId: this.readStringClaim(payload['node_id']), + issuedAt: this.toIsoTimestamp(payload['iat']), + expiresAt: this.toIsoTimestamp(payload['exp']), + claims: payload, + }; + } + + private parseDelimitedEnv( + value: string | undefined, + defaults: string[], + ): string[] { + const parsed = + value + ?.split(',') + .map((item) => item.trim()) + .filter(Boolean) ?? []; + return parsed.length > 0 ? [...new Set(parsed)] : defaults; + } + + private parsePositiveInteger( + value: string | undefined, + fallback: number, + ): number { + const parsed = Number.parseInt(value ?? '', 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; + } + + private buildApiKeyId(apiKey: string, index: number): string { + const digest = createHmac('sha256', 'lyttle-nginx-api-key-fingerprint') + .update(apiKey) + .digest('hex') + .slice(0, 12); + return `${index + 1}-${digest}`; + } + + private loadJwtPublicKey(value: string | undefined): KeyObject | undefined { + const normalized = value?.replace(/\\n/g, '\n').trim(); + if (!normalized) { + return undefined; + } + + try { + return createPublicKey(normalized); + } catch { + return undefined; + } + } + + private getSupportedBearerAlgorithms(): string[] { + const supported: string[] = []; + if (this.jwtSecret && this.jwtAllowedAlgorithms.includes('HS256')) { + supported.push('HS256'); + } + if (this.jwtPublicKey && this.jwtAllowedAlgorithms.includes('RS256')) { + supported.push('RS256'); + } + return supported; + } + + private verifyJwtSignature( + algorithm: string, + signingInput: string, + signatureSegment: string, + ) { + const signature = this.base64UrlDecode( + signatureSegment, + 'Invalid bearer token signature', + ); + + if (algorithm === 'HS256') { + if (!this.jwtSecret) { + throw new UnauthorizedException( + 'HS256 bearer token verification is unavailable', + ); + } + + const expected = Buffer.from( + createHmac('sha256', this.jwtSecret).update(signingInput).digest(), + ); + if ( + expected.length !== signature.length || + !timingSafeEqual(expected, Buffer.from(signature)) + ) { + throw new UnauthorizedException('Invalid bearer token signature'); + } + return; + } + + if (algorithm === 'RS256') { + if (!this.jwtPublicKey) { + throw new UnauthorizedException( + 'RS256 bearer token verification is unavailable', + ); + } + + const valid = verify( + 'RSA-SHA256', + Buffer.from(signingInput), + this.jwtPublicKey, + signature, + ); + if (!valid) { + throw new UnauthorizedException('Invalid bearer token signature'); + } + return; + } + + throw new UnauthorizedException('Unsupported bearer token algorithm'); + } + + private validateRegisteredClaims(payload: Record<string, unknown>) { + const now = Math.floor(Date.now() / 1000); + const expiresAt = this.readNumericClaim(payload['exp']); + if (expiresAt !== undefined && expiresAt <= now) { + throw new UnauthorizedException('Bearer token has expired'); + } + + const notBefore = this.readNumericClaim(payload['nbf']); + if (notBefore !== undefined && notBefore > now) { + throw new UnauthorizedException('Bearer token is not active yet'); + } + + const issuer = this.readStringClaim(payload['iss']); + if (this.jwtIssuer && issuer && issuer !== this.jwtIssuer) { + throw new UnauthorizedException('Bearer token issuer is invalid'); + } + + const audience = this.extractAudience(payload['aud']); if ( - this.validApiKeys.size === 0 && - process.env.NODE_ENV === 'development' + this.jwtAudience && + audience.length > 0 && + !audience.includes(this.jwtAudience) ) { - return true; + throw new UnauthorizedException('Bearer token audience is invalid'); + } + } + + private resolveActorType(payload: Record<string, unknown>): ActorType { + const actorTypeClaim = this.readStringClaim(payload['actor_type']); + if (actorTypeClaim === 'admin' || actorTypeClaim === 'internal-node') { + return actorTypeClaim; } - return this.validApiKeys.has(apiKey); + const scopes = this.extractScopes(payload); + if ( + this.readStringClaim(payload['node_id']) || + scopes.includes('cluster:internal') || + scopes.includes('node:sync') + ) { + return 'internal-node'; + } + + return 'admin'; } - /** - * Check if authentication is enabled - */ - isAuthEnabled(): boolean { - return this.validApiKeys.size > 0; + private extractRoles(payload: Record<string, unknown>): string[] { + const roles = new Set<string>(); + + for (const value of this.readStringArrayClaim(payload['roles'])) { + roles.add(value); + } + + for (const value of this.readStringArrayClaim(payload['role'])) { + roles.add(value); + } + + const realmAccess = this.readRecordClaim(payload['realm_access']); + if (realmAccess) { + for (const value of this.readStringArrayClaim(realmAccess['roles'])) { + roles.add(value); + } + } + + return [...roles]; + } + + private extractScopes(payload: Record<string, unknown>): string[] { + const scopes = new Set<string>(); + + const scope = this.readStringClaim(payload['scope']); + if (scope) { + for (const value of scope + .split(/\s+/) + .map((item) => item.trim()) + .filter(Boolean)) { + scopes.add(value); + } + } + + for (const value of this.readStringArrayClaim(payload['scp'])) { + scopes.add(value); + } + + for (const value of this.readStringArrayClaim(payload['permissions'])) { + scopes.add(value); + } + + return [...scopes]; + } + + private extractAudience(value: unknown): string[] { + return this.readStringArrayClaim(value); + } + + private readRecordClaim(value: unknown): Record<string, unknown> | undefined { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return undefined; + } + return value as Record<string, unknown>; + } + + private readStringClaim(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() ? value.trim() : undefined; + } + + private readNumericClaim(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) + ? value + : undefined; + } + + private readStringArrayClaim(value: unknown): string[] { + if (typeof value === 'string' && value.trim()) { + return [value.trim()]; + } + + if (!Array.isArray(value)) { + return []; + } + + return value.filter( + (item): item is string => + typeof item === 'string' && item.trim().length > 0, + ); + } + + private parseJsonSegment<T>(segment: string, errorMessage: string): T { + try { + return JSON.parse( + this.base64UrlDecode(segment, errorMessage).toString('utf8'), + ) as T; + } catch { + throw new UnauthorizedException(errorMessage); + } + } + + private base64UrlEncodeJson(value: Record<string, unknown>): string { + return Buffer.from(JSON.stringify(value)).toString('base64url'); + } + + private base64UrlDecode(value: string, errorMessage: string): Buffer { + try { + return Buffer.from(value, 'base64url'); + } catch { + throw new UnauthorizedException(errorMessage); + } + } + + private toIsoTimestamp(value: unknown): string | undefined { + const timestamp = this.readNumericClaim(value); + return timestamp !== undefined + ? new Date(timestamp * 1000).toISOString() + : undefined; } } diff --git a/src/auth/decorators/authorize.decorator.ts b/src/auth/decorators/authorize.decorator.ts new file mode 100644 index 0000000..5fd3916 --- /dev/null +++ b/src/auth/decorators/authorize.decorator.ts @@ -0,0 +1,27 @@ +import { SetMetadata } from '@nestjs/common'; +import { AuthorizationRule } from '../types/authorization-policy'; +import { AuthRole } from '../types/auth-role'; + +export const AUTHORIZATION_POLICY_KEY = 'authorizationPolicy'; + +export const Authorize = (...rules: AuthorizationRule[]) => + SetMetadata(AUTHORIZATION_POLICY_KEY, rules); + +export const AuthorizeAdmin = (...roles: AuthRole[]) => + Authorize({ + actorTypes: ['admin'], + roles: roles.length > 0 ? roles : undefined, + }); + +export const AuthorizeInternalNode = () => + Authorize({ actorTypes: ['internal-node'] }); + +export const AuthorizeInternalNodeOrAdmin = (...roles: AuthRole[]) => + Authorize( + { actorTypes: ['internal-node'] }, + { + actorTypes: ['admin'], + roles: roles.length > 0 ? roles : undefined, + }, + ); + diff --git a/src/auth/decorators/current-identity.decorator.ts b/src/auth/decorators/current-identity.decorator.ts new file mode 100644 index 0000000..e604499 --- /dev/null +++ b/src/auth/decorators/current-identity.decorator.ts @@ -0,0 +1,9 @@ +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; +import { AuthenticatedRequest } from '../interfaces/authenticated-request.interface'; + +export const CurrentIdentity = createParamDecorator( + (_data: unknown, context: ExecutionContext) => { + const request = context.switchToHttp().getRequest<AuthenticatedRequest>(); + return request.auth; + }, +); diff --git a/src/auth/decorators/public.decorator.ts b/src/auth/decorators/public.decorator.ts new file mode 100644 index 0000000..052aaa7 --- /dev/null +++ b/src/auth/decorators/public.decorator.ts @@ -0,0 +1,5 @@ +import { SetMetadata } from '@nestjs/common'; + +export const IS_PUBLIC_KEY = 'isPublic'; +export const Public = () => SetMetadata(IS_PUBLIC_KEY, true); + diff --git a/src/auth/guards/api-key.guard.ts b/src/auth/guards/api-key.guard.ts index a00b8d7..56e8082 100644 --- a/src/auth/guards/api-key.guard.ts +++ b/src/auth/guards/api-key.guard.ts @@ -2,33 +2,165 @@ import { CanActivate, ExecutionContext, Injectable, + Optional, UnauthorizedException, } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; import { AuthService } from '../auth.service'; +import { IS_PUBLIC_KEY } from '../decorators/public.decorator'; +import { AuthenticatedRequest } from '../interfaces/authenticated-request.interface'; +import { + ensureAuditContext, + getRequestIpAddress, + getRequestPath, +} from '../../audit/audit-context'; +import { + resolveAuditAction, + resolveAuditError, + resolveAuditTarget, +} from '../../audit/audit-record.utils'; +import { + AUDIT_OPTIONS_KEY, + shouldAuditHttpRequest, +} from '../../audit/decorators/audit.decorator'; +import { AuditService } from '../../audit/audit.service'; +import { AuditOptions } from '../../audit/types/audit.types'; @Injectable() export class ApiKeyGuard implements CanActivate { - constructor(private authService: AuthService) {} + private readonly authService: AuthService; + private readonly reflector: Reflector; + private readonly auditService?: AuditService; + + constructor( + authService: AuthService, + reflector: Reflector, + @Optional() auditService?: AuditService, + ) { + this.authService = authService; + this.reflector = reflector; + this.auditService = auditService; + } canActivate(context: ExecutionContext): boolean { - // Skip authentication in development if no API keys configured - if (process.env.NODE_ENV === 'development' && !process.env.API_KEY) { + const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [ + context.getHandler(), + context.getClass(), + ]); + + if (isPublic) { return true; } - const request = context.switchToHttp().getRequest(); - const apiKey = - request.headers['x-api-key'] || - request.headers['authorization']?.replace('ApiKey ', ''); + const request = context.switchToHttp().getRequest<AuthenticatedRequest>(); + const response = context.switchToHttp().getResponse(); + const auditOptions = this.reflector.getAllAndOverride<AuditOptions>( + AUDIT_OPTIONS_KEY, + [context.getHandler(), context.getClass()], + ); + const bearerToken = this.extractBearerToken(request.headers); + + if (bearerToken) { + try { + const identity = this.authService.authenticateBearerToken(bearerToken); + request.auth = identity; + request.user = identity; + return true; + } catch (error) { + this.recordDeniedAttempt(request, response, auditOptions, error); + throw error; + } + } + const apiKey = this.extractApiKey(request.headers); if (!apiKey) { - throw new UnauthorizedException('API key is required'); + const error = new UnauthorizedException( + 'Authentication credentials are required', + ); + this.recordDeniedAttempt(request, response, auditOptions, error); + throw error; } - if (!this.authService.validateApiKey(apiKey)) { - throw new UnauthorizedException('Invalid API key'); + const identity = this.authService.authenticateApiKey(apiKey); + if (!identity) { + const error = new UnauthorizedException('Invalid API key'); + this.recordDeniedAttempt(request, response, auditOptions, error); + throw error; } + request.auth = identity; + request.user = identity; + return true; } + + private recordDeniedAttempt( + request: AuthenticatedRequest, + response: { setHeader?: (name: string, value: string) => void }, + auditOptions?: AuditOptions, + error?: unknown, + ) { + if ( + !this.auditService || + !shouldAuditHttpRequest(request.method, auditOptions) + ) { + return; + } + + const auditContext = ensureAuditContext(request, response); + const { errorCode, errorMessage } = resolveAuditError(error); + + void this.auditService.recordEvent({ + action: resolveAuditAction(request, auditOptions), + actor: request.auth, + correlationId: auditContext.correlationId, + errorCode, + errorMessage, + ipAddress: getRequestIpAddress(request), + outcome: 'denied', + requestMethod: request.method || 'UNKNOWN', + requestPath: getRequestPath(request), + responseStatus: 401, + target: resolveAuditTarget(request, auditOptions), + }); + } + + private extractBearerToken( + headers: Record<string, string | string[] | undefined>, + ) { + const authorization = headers['authorization']; + if (typeof authorization !== 'string') { + return undefined; + } + + const [scheme, ...credentials] = authorization.trim().split(/\s+/); + if (scheme?.toLowerCase() !== 'bearer' || credentials.length === 0) { + return undefined; + } + + const token = credentials.join(' ').trim(); + return token || undefined; + } + + private extractApiKey( + headers: Record<string, string | string[] | undefined>, + ) { + const headerApiKey = headers['x-api-key']; + if (typeof headerApiKey === 'string' && headerApiKey.trim()) { + return headerApiKey.trim(); + } + + const authorization = headers['authorization']; + if (typeof authorization !== 'string') { + return undefined; + } + + const [scheme, ...credentials] = authorization.trim().split(/\s+/); + if (scheme?.toLowerCase() !== 'apikey' || credentials.length === 0) { + return undefined; + } + + const apiKey = credentials.join(' ').trim(); + return apiKey || undefined; + } } diff --git a/src/auth/guards/authorization.guard.ts b/src/auth/guards/authorization.guard.ts new file mode 100644 index 0000000..883ad81 --- /dev/null +++ b/src/auth/guards/authorization.guard.ts @@ -0,0 +1,134 @@ +import { + CanActivate, + ExecutionContext, + ForbiddenException, + Injectable, + Optional, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { AUTHORIZATION_POLICY_KEY } from '../decorators/authorize.decorator'; +import { IS_PUBLIC_KEY } from '../decorators/public.decorator'; +import { AuthenticatedRequest } from '../interfaces/authenticated-request.interface'; +import { AuthorizationRule } from '../types/authorization-policy'; +import { resolveEffectiveRoles } from '../types/auth-role'; +import { + ensureAuditContext, + getRequestIpAddress, + getRequestPath, +} from '../../audit/audit-context'; +import { + resolveAuditAction, + resolveAuditError, + resolveAuditTarget, +} from '../../audit/audit-record.utils'; +import { + AUDIT_OPTIONS_KEY, + shouldAuditHttpRequest, +} from '../../audit/decorators/audit.decorator'; +import { AuditService } from '../../audit/audit.service'; +import { AuditOptions } from '../../audit/types/audit.types'; + +@Injectable() +export class AuthorizationGuard implements CanActivate { + constructor( + private readonly reflector: Reflector, + @Optional() private readonly auditService?: AuditService, + ) {} + + canActivate(context: ExecutionContext): boolean { + const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [ + context.getHandler(), + context.getClass(), + ]); + + if (isPublic) { + return true; + } + + const policy = this.reflector.getAllAndOverride<AuthorizationRule[]>( + AUTHORIZATION_POLICY_KEY, + [context.getHandler(), context.getClass()], + ); + + if (!policy || policy.length === 0) { + throw new ForbiddenException( + 'No authorization policy is configured for this endpoint', + ); + } + + const request = context.switchToHttp().getRequest<AuthenticatedRequest>(); + const response = context.switchToHttp().getResponse(); + const auditOptions = this.reflector.getAllAndOverride<AuditOptions>( + AUDIT_OPTIONS_KEY, + [context.getHandler(), context.getClass()], + ); + const identity = request.auth; + + if (!identity) { + const error = new ForbiddenException( + 'Authenticated identity is required', + ); + this.recordDeniedAttempt(request, response, auditOptions, error); + throw error; + } + + const effectiveRoles = new Set(resolveEffectiveRoles(identity)); + const allowed = policy.some((rule) => + this.matchesRule(rule, identity, effectiveRoles), + ); + + if (!allowed) { + const error = new ForbiddenException( + 'Insufficient permissions for this endpoint', + ); + this.recordDeniedAttempt(request, response, auditOptions, error); + throw error; + } + + return true; + } + + private recordDeniedAttempt( + request: AuthenticatedRequest, + response: { setHeader?: (name: string, value: string) => void }, + auditOptions?: AuditOptions, + error?: unknown, + ) { + if ( + !this.auditService || + !shouldAuditHttpRequest(request.method, auditOptions) + ) { + return; + } + + const auditContext = ensureAuditContext(request, response); + const { errorCode, errorMessage } = resolveAuditError(error); + + void this.auditService.recordEvent({ + action: resolveAuditAction(request, auditOptions), + actor: request.auth, + correlationId: auditContext.correlationId, + errorCode, + errorMessage, + ipAddress: getRequestIpAddress(request), + outcome: 'denied', + requestMethod: request.method || 'UNKNOWN', + requestPath: getRequestPath(request), + responseStatus: 403, + target: resolveAuditTarget(request, auditOptions), + }); + } + + private matchesRule( + rule: AuthorizationRule, + identity: NonNullable<AuthenticatedRequest['auth']>, + effectiveRoles: ReadonlySet<string>, + ): boolean { + const actorTypesAllowed = + !rule.actorTypes || rule.actorTypes.includes(identity.actorType); + const rolesAllowed = + !rule.roles || rule.roles.some((role) => effectiveRoles.has(role)); + + return actorTypesAllowed && rolesAllowed; + } +} diff --git a/src/auth/interfaces/authenticated-request.interface.ts b/src/auth/interfaces/authenticated-request.interface.ts new file mode 100644 index 0000000..4daa75d --- /dev/null +++ b/src/auth/interfaces/authenticated-request.interface.ts @@ -0,0 +1,27 @@ +import { AuthIdentity } from '../types/auth-identity'; + +export interface AuthenticatedRequest { + headers: Record<string, string | string[] | undefined>; + method?: string; + url?: string; + originalUrl?: string; + ip?: string; + params?: Record<string, unknown>; + query?: Record<string, unknown>; + body?: Record<string, unknown>; + route?: { + path?: string; + }; + socket?: { + remoteAddress?: string; + }; + connection?: { + remoteAddress?: string; + }; + auth?: AuthIdentity; + user?: AuthIdentity; + auditContext?: { + correlationId: string; + startedAt: string; + }; +} diff --git a/src/auth/types/auth-identity.ts b/src/auth/types/auth-identity.ts new file mode 100644 index 0000000..67e9ede --- /dev/null +++ b/src/auth/types/auth-identity.ts @@ -0,0 +1,47 @@ +import { AuthRole } from './auth-role'; + +export type AuthMethod = 'api-key' | 'bearer-token'; + +export type ActorType = 'admin' | 'internal-node'; + +export interface AuthIdentity { + id: string; + subject: string; + actorType: ActorType; + authMethod: AuthMethod; + displayName: string; + roles: string[]; + scopes: string[]; + audience: string[]; + issuer?: string; + tokenId?: string; + apiKeyId?: string; + nodeId?: string; + issuedAt?: string; + expiresAt?: string; + claims?: Record<string, unknown>; +} + +export interface ConfiguredApiKeySummary { + apiKeyId: string; + fingerprint: string; + displayName: string; + roles: string[]; + scopes: string[]; +} + +export interface AuthCapabilities { + authEnabled: boolean; + methods: AuthMethod[]; + apiKeyConfigured: boolean; + apiKeyCount: number; + bearerTokenVerificationConfigured: boolean; + tokenExchangeEnabled: boolean; + issuer?: string; + audience?: string; + supportedBearerAlgorithms: string[]; + supportedRoles: AuthRole[]; + roleHierarchy: Record<AuthRole, AuthRole[]>; + defaultAdminRoles: string[]; + defaultAdminScopes: string[]; +} diff --git a/src/auth/types/auth-role.ts b/src/auth/types/auth-role.ts new file mode 100644 index 0000000..aa0bed7 --- /dev/null +++ b/src/auth/types/auth-role.ts @@ -0,0 +1,52 @@ +import { AuthIdentity } from './auth-identity'; + +export const AUTH_ROLES = [ + 'viewer', + 'operator', + 'security-admin', + 'platform-admin', + 'internal-node', +] as const; + +export type AuthRole = (typeof AUTH_ROLES)[number]; + +export const AUTH_ROLE_HIERARCHY: Record<AuthRole, AuthRole[]> = { + viewer: [], + operator: ['viewer'], + 'security-admin': ['viewer'], + 'platform-admin': ['viewer', 'operator', 'security-admin'], + 'internal-node': [], +}; + +export function isAuthRole(value: string): value is AuthRole { + return AUTH_ROLES.includes(value as AuthRole); +} + +export function expandAuthRoles(roles: Iterable<string>): AuthRole[] { + const expanded = new Set<AuthRole>(); + + for (const role of roles) { + if (!isAuthRole(role)) { + continue; + } + + expanded.add(role); + + for (const inheritedRole of AUTH_ROLE_HIERARCHY[role]) { + expanded.add(inheritedRole); + } + } + + return [...expanded]; +} + +export function resolveEffectiveRoles(identity: AuthIdentity): AuthRole[] { + const declaredRoles = new Set(identity.roles); + + if (identity.actorType === 'internal-node') { + declaredRoles.add('internal-node'); + } + + return expandAuthRoles(declaredRoles); +} + diff --git a/src/auth/types/authorization-policy.ts b/src/auth/types/authorization-policy.ts new file mode 100644 index 0000000..1ef481d --- /dev/null +++ b/src/auth/types/authorization-policy.ts @@ -0,0 +1,8 @@ +import { ActorType } from './auth-identity'; +import { AuthRole } from './auth-role'; + +export interface AuthorizationRule { + actorTypes?: ActorType[]; + roles?: AuthRole[]; +} + diff --git a/src/certificate/acme-strategy.ts b/src/certificate/acme-strategy.ts new file mode 100644 index 0000000..7c1ba7a --- /dev/null +++ b/src/certificate/acme-strategy.ts @@ -0,0 +1,129 @@ +import * as path from 'node:path'; +import { containsWildcardDomain, normalizeDomains } from '../utils/domain-utils'; + +export const ACME_CHALLENGE_STRATEGIES = ['auto', 'http-01'] as const; +export type AcmeChallengeStrategy = (typeof ACME_CHALLENGE_STRATEGIES)[number]; + +export const ACME_CHALLENGE_TYPES = ['http-01'] as const; +export type AcmeChallengeType = (typeof ACME_CHALLENGE_TYPES)[number]; + +export type ResolvedAcmeStrategy = { + requestedStrategy: AcmeChallengeStrategy; + challengeType: AcmeChallengeType; + provider: string; + wildcard: boolean; + sharedChallengeStore: boolean; + propagationSeconds: number | null; + challengeStore: 'database-http'; + visibleInChallengeApi: boolean; +}; + +function parsePositiveInteger( + value: string | undefined, + fallback: number, +): number { + const parsed = Number.parseInt(value ?? '', 10); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback; +} + +function normalizeRequestedStrategy( + value: string | undefined, +): AcmeChallengeStrategy { + const normalized = value?.trim().toLowerCase() ?? 'auto'; + if (normalized === 'dns-01') { + throw new Error( + 'ACME_CHALLENGE_STRATEGY=dns-01 is not supported by the current production-hardened flow because it would require DNS TXT record changes. Use auto/http-01 with non-wildcard hostnames or import a certificate instead.', + ); + } + + if ((ACME_CHALLENGE_STRATEGIES as readonly string[]).includes(normalized)) { + return normalized as AcmeChallengeStrategy; + } + + throw new Error( + `Unsupported ACME_CHALLENGE_STRATEGY ${JSON.stringify(value)}. Expected one of: ${ACME_CHALLENGE_STRATEGIES.join(', ')}`, + ); +} + +function parseAbsolutePath( + value: string | undefined, + envName: string, +): string | null { + if (!value || value.trim().length === 0) { + return null; + } + + if (!path.isAbsolute(value)) { + throw new Error(`${envName} must be an absolute filesystem path`); + } + + return value; +} + +export function resolveAcmeStrategy( + domainsInput: string[], + env: NodeJS.ProcessEnv = process.env, +): ResolvedAcmeStrategy { + const domains = normalizeDomains(domainsInput, { allowWildcard: true }); + const wildcard = containsWildcardDomain(domains); + const requestedStrategy = normalizeRequestedStrategy( + env['ACME_CHALLENGE_STRATEGY'], + ); + if (wildcard) { + throw new Error( + 'Wildcard certificate issuance is not supported by the current production-hardened ACME flow because it would require DNS TXT record changes. Use explicit hostnames or import/upload the certificate instead.', + ); + } + + return { + requestedStrategy, + challengeType: 'http-01', + provider: env['ACME_HTTP01_PROVIDER']?.trim() || 'database-http-01', + wildcard, + sharedChallengeStore: true, + propagationSeconds: parsePositiveInteger( + env['ACME_HTTP01_PROPAGATION_SECONDS'], + 5, + ), + challengeStore: 'database-http', + visibleInChallengeApi: true, + }; +} + +export function getAcmeDirectoryUrl( + env: NodeJS.ProcessEnv = process.env, +): string { + const override = env['ACME_DIRECTORY_URL']?.trim(); + if (override) { + return override; + } + + return (env['LETSENCRYPT_STAGING'] ?? '').trim().toLowerCase() === 'true' + ? 'https://acme-staging-v02.api.letsencrypt.org/directory' + : 'https://acme-v02.api.letsencrypt.org/directory'; +} + +export function getAcmeAccountKeyPath( + env: NodeJS.ProcessEnv = process.env, +): string { + return ( + parseAbsolutePath(env['ACME_ACCOUNT_PRIVATE_KEY_PATH'], 'ACME_ACCOUNT_PRIVATE_KEY_PATH') ?? + '/app/state/acme/account.pem' + ); +} + + +export function getAcmeHttpVerificationTimeoutMs( + env: NodeJS.ProcessEnv = process.env, +): number { + return parsePositiveInteger( + env['ACME_HTTP01_VERIFICATION_TIMEOUT_MS'], + 30 * 1000, + ); +} + +export function getAcmeHttpPollIntervalMs( + env: NodeJS.ProcessEnv = process.env, +): number { + return parsePositiveInteger(env['ACME_HTTP01_POLL_INTERVAL_MS'], 2000); +} diff --git a/src/certificate/acme.controller.ts b/src/certificate/acme.controller.ts index bc5c276..556d165 100644 --- a/src/certificate/acme.controller.ts +++ b/src/certificate/acme.controller.ts @@ -7,13 +7,15 @@ import { Res, } from '@nestjs/common'; import { Response } from 'express'; -import { PrismaService } from '../prisma/prisma.service'; +import { Public } from '../auth/decorators/public.decorator'; +import { AcmeService } from './acme.service'; @Controller('.well-known/acme-challenge') +@Public() export class AcmeController { private readonly logger = new Logger(AcmeController.name); - constructor(private prisma: PrismaService) {} + constructor(private readonly acmeService: AcmeService) {} /** * Serve ACME challenges from database @@ -29,35 +31,34 @@ export class AcmeController { try { // Look up challenge in database this.logger.debug(`[ACME] Looking up challenge in database: ${token}`); - const challenge = await this.prisma.acmeChallenge.findUnique({ - where: { token }, - }); + const result = await this.acmeService.getPresentedHttpChallenge(token); - if (!challenge) { + if (result.status === 'missing') { this.logger.warn(`[ACME] Challenge not found in database: ${token}`); res.status(HttpStatus.NOT_FOUND).send('Challenge not found'); return; } - this.logger.log(`[ACME] Challenge found for domain: ${challenge.domain}`); - - // Check if expired - if (new Date() > challenge.expiresAt) { - this.logger.warn( - `[ACME] Challenge expired for token ${token} (expired at: ${challenge.expiresAt})`, - ); - // Clean up expired challenge - await this.prisma.acmeChallenge.delete({ - where: { id: challenge.id }, - }); + if (result.status === 'expired') { + this.logger.warn(`[ACME] Challenge expired for token ${token}`); res.status(HttpStatus.NOT_FOUND).send('Challenge expired'); return; } + if (result.status !== 'found') { + res.status(HttpStatus.NOT_FOUND).send('Challenge not found'); + return; + } + + const challenge = result.challenge; + + this.logger.log(`[ACME] Challenge found for domain: ${challenge.domain}`); + // Return the key authorization (Let's Encrypt expects plain text) this.logger.log( `[ACME] Returning challenge response for ${challenge.domain} (keyAuth length: ${challenge.keyAuth.length})`, ); + await this.acmeService.markChallengeServed(challenge.id); res .status(HttpStatus.OK) .contentType('text/plain') diff --git a/src/certificate/acme.service.ts b/src/certificate/acme.service.ts new file mode 100644 index 0000000..bb80eea --- /dev/null +++ b/src/certificate/acme.service.ts @@ -0,0 +1,452 @@ +import { Injectable, Logger } from '@nestjs/common'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as acme from 'acme-client'; +import { PrismaService } from '../prisma/prisma.service'; +import { toPrismaNullableJsonValue } from '../prisma/prisma-json.util'; +import { + getAcmeAccountKeyPath, + getAcmeDirectoryUrl, + getAcmeHttpPollIntervalMs, + getAcmeHttpVerificationTimeoutMs, + resolveAcmeStrategy, + type ResolvedAcmeStrategy, +} from './acme-strategy'; +import { normalizeDomains } from '../utils/domain-utils'; +import { AcmeChallengeInfoDto } from './dto/acme-challenge.dto'; +import type { Authorization, Client } from 'acme-client'; +import type { Challenge } from 'acme-client/types/rfc8555'; + +const DEFAULT_CHALLENGE_TTL_MS = 60 * 60 * 1000; + +type AcmeChallengeRecord = { + id: string; + orderId: string | null; + token: string; + keyAuth: string; + domain: string; + challengeType: string; + provider: string | null; + status: string; + metadata: Record<string, unknown> | null; + createdAt: Date; + presentedAt: Date; + cleanedUpAt: Date | null; + finalizedAt: Date | null; + lastServedAt: Date | null; + expiresAt: Date; +}; + +export type IssuedAcmeCertificate = { + certPem: string; + keyPem: string; + issuedAt: Date; + expiresAt: Date; + metadata: Record<string, unknown>; +}; + +@Injectable() +export class AcmeService { + private readonly logger = new Logger(AcmeService.name); + + constructor(private readonly prisma: PrismaService) {} + + async obtainCertificate(params: { + orderId: string; + domains: string[]; + instanceId: string; + adminEmail: string; + }): Promise<IssuedAcmeCertificate> { + const domains = normalizeDomains(params.domains, { allowWildcard: true }); + const strategy = resolveAcmeStrategy(domains); + const accountKey = await this.loadOrCreateAccountKey(); + const client = new acme.Client({ + directoryUrl: getAcmeDirectoryUrl(), + accountKey, + }); + const [certificateKey, csr] = await acme.crypto.createCsr({ + commonName: domains[0], + altNames: domains, + }); + + try { + const certificate = await client.auto({ + csr, + email: params.adminEmail, + termsOfServiceAgreed: true, + skipChallengeVerification: true, + challengePriority: [strategy.challengeType], + challengeCreateFn: async (authz, challenge, keyAuthorization) => { + await this.presentChallenge({ + orderId: params.orderId, + instanceId: params.instanceId, + strategy, + authz, + challenge, + keyAuthorization, + }); + + await this.waitForChallengeReadiness({ + client, + authz, + challenge, + }); + }, + challengeRemoveFn: async (authz, challenge, keyAuthorization) => { + await this.cleanupChallenge({ + orderId: params.orderId, + strategy, + authz, + challenge, + keyAuthorization, + }); + }, + }); + + await this.finalizeChallengesForOrder(params.orderId, { + status: 'validated', + }); + + const certPem = certificate.toString(); + const keyPem = Buffer.isBuffer(certificateKey) + ? certificateKey.toString('utf8') + : String(certificateKey); + const certificateInfo = acme.crypto.readCertificateInfo(certPem); + const accountKeyPath = getAcmeAccountKeyPath(); + + return { + certPem, + keyPem, + issuedAt: certificateInfo.notBefore ?? new Date(), + expiresAt: certificateInfo.notAfter, + metadata: { + acme: { + ...strategy, + directoryUrl: getAcmeDirectoryUrl(), + accountKeyPath, + }, + }, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + await this.finalizeChallengesForOrder(params.orderId, { + status: 'failed', + error: message, + }); + throw error; + } + } + + async listChallenges(options: { + status?: string; + limit?: number; + } = {}): Promise<{ count: number; challenges: AcmeChallengeInfoDto[] }> { + const take = Number.isFinite(options.limit) + ? Math.min(Math.max(options.limit ?? 25, 1), 100) + : 25; + const normalizedStatus = options.status?.trim(); + const challenges = (await this.prisma.acmeChallenge.findMany({ + where: normalizedStatus + ? { + status: normalizedStatus, + } + : undefined, + take, + orderBy: [{ presentedAt: 'desc' }, { createdAt: 'desc' }], + })) as AcmeChallengeRecord[]; + + return { + count: challenges.length, + challenges: challenges.map((challenge) => this.toChallengeDto(challenge)), + }; + } + + async getPresentedHttpChallenge(token: string): Promise< + | { status: 'found'; challenge: AcmeChallengeRecord } + | { status: 'missing' | 'expired' } + > { + const challenge = (await this.prisma.acmeChallenge.findFirst({ + where: { + token, + challengeType: 'http-01', + status: 'presented', + }, + })) as AcmeChallengeRecord | null; + + if (!challenge) { + return { status: 'missing' }; + } + + if (new Date() > challenge.expiresAt) { + await this.prisma.acmeChallenge.update({ + where: { id: challenge.id }, + data: { + status: 'expired', + finalizedAt: new Date(), + }, + }); + return { status: 'expired' }; + } + + return { status: 'found', challenge }; + } + + async markChallengeServed(challengeId: string): Promise<void> { + await this.prisma.acmeChallenge.update({ + where: { id: challengeId }, + data: { + lastServedAt: new Date(), + }, + }); + } + + async finalizeChallengesForOrder( + orderId: string, + params: { + status: 'validated' | 'failed'; + error?: string | null; + }, + ): Promise<void> { + const challenges = (await this.prisma.acmeChallenge.findMany({ + where: { + orderId, + status: { + in: ['presented', 'cleaned-up'], + }, + }, + orderBy: [{ presentedAt: 'desc' }, { createdAt: 'desc' }], + })) as AcmeChallengeRecord[]; + + if (challenges.length === 0) { + return; + } + + const finalizedAt = new Date(); + await Promise.all( + challenges.map((challenge) => + this.prisma.acmeChallenge.update({ + where: { id: challenge.id }, + data: { + status: params.status, + finalizedAt, + metadata: toPrismaNullableJsonValue( + this.mergeMetadata(challenge.metadata, { + finalization: { + status: params.status, + error: params.error ?? null, + finalizedAt: finalizedAt.toISOString(), + }, + }), + ), + }, + }), + ), + ); + } + + private async presentChallenge(params: { + orderId: string; + instanceId: string; + strategy: ResolvedAcmeStrategy; + authz: Authorization; + challenge: Challenge; + keyAuthorization: string; + }): Promise<void> { + const metadata = this.buildChallengeMetadata(params); + const expiresAt = this.getChallengeExpiry(params.authz); + const now = new Date(); + + await this.prisma.acmeChallenge.upsert({ + where: { token: params.challenge.token }, + create: { + orderId: params.orderId, + token: params.challenge.token, + keyAuth: params.keyAuthorization, + domain: params.authz.identifier.value, + challengeType: params.challenge.type, + provider: params.strategy.provider, + status: 'presented', + metadata: toPrismaNullableJsonValue(metadata), + presentedAt: now, + expiresAt, + }, + update: { + orderId: params.orderId, + keyAuth: params.keyAuthorization, + domain: params.authz.identifier.value, + challengeType: params.challenge.type, + provider: params.strategy.provider, + status: 'presented', + metadata: toPrismaNullableJsonValue(metadata), + presentedAt: now, + cleanedUpAt: null, + finalizedAt: null, + expiresAt, + }, + }); + + this.logger.log( + `[ACME] Presented ${params.challenge.type} challenge for ${params.authz.identifier.value} (token: ${params.challenge.token})`, + ); + } + + private async cleanupChallenge(params: { + orderId: string; + strategy: ResolvedAcmeStrategy; + authz: Authorization; + challenge: Challenge; + keyAuthorization: string; + }): Promise<void> { + const existing = (await this.prisma.acmeChallenge.findUnique({ + where: { token: params.challenge.token }, + })) as AcmeChallengeRecord | null; + + await this.prisma.acmeChallenge.upsert({ + where: { token: params.challenge.token }, + create: { + orderId: params.orderId, + token: params.challenge.token, + keyAuth: params.keyAuthorization, + domain: params.authz.identifier.value, + challengeType: params.challenge.type, + provider: params.strategy.provider, + status: 'cleaned-up', + metadata: toPrismaNullableJsonValue(this.buildChallengeMetadata(params)), + presentedAt: new Date(), + cleanedUpAt: new Date(), + expiresAt: this.getChallengeExpiry(params.authz), + }, + update: { + status: 'cleaned-up', + cleanedUpAt: new Date(), + metadata: toPrismaNullableJsonValue( + this.mergeMetadata(existing?.metadata, { + cleanup: { + cleanedUpAt: new Date().toISOString(), + }, + }), + ), + }, + }); + } + + private async waitForChallengeReadiness(params: { + client: Client; + authz: Authorization; + challenge: Challenge; + }): Promise<void> { + const timeoutMs = getAcmeHttpVerificationTimeoutMs(); + const pollIntervalMs = getAcmeHttpPollIntervalMs(); + const deadline = Date.now() + timeoutMs; + let lastError: Error | null = null; + + while (Date.now() <= deadline) { + try { + await params.client.verifyChallenge(params.authz, params.challenge); + return; + } catch (error) { + lastError = + error instanceof Error ? error : new Error(String(error ?? 'unknown')); + await this.sleep(pollIntervalMs); + } + } + + throw new Error( + `Timed out waiting for ${params.challenge.type} challenge readiness for ${params.authz.identifier.value}: ${lastError?.message ?? 'verification never succeeded'}`, + ); + } + + private async loadOrCreateAccountKey(): Promise<string> { + const accountKeyPath = getAcmeAccountKeyPath(); + if (fs.existsSync(accountKeyPath)) { + return fs.readFileSync(accountKeyPath, 'utf8'); + } + + fs.mkdirSync(path.dirname(accountKeyPath), { recursive: true, mode: 0o700 }); + const privateKey = await acme.crypto.createPrivateRsaKey(4096); + const pem = Buffer.isBuffer(privateKey) + ? privateKey.toString('utf8') + : String(privateKey); + + fs.writeFileSync(accountKeyPath, pem, { + encoding: 'utf8', + mode: 0o600, + }); + + this.logger.log(`[ACME] Created persistent ACME account key at ${accountKeyPath}`); + return pem; + } + + private buildChallengeMetadata(params: { + strategy: ResolvedAcmeStrategy; + instanceId?: string; + authz: Authorization; + challenge: Challenge; + keyAuthorization: string; + }): Record<string, unknown> { + return { + authorizationUrl: params.authz.url, + challengeUrl: params.challenge.url, + provider: params.strategy.provider, + requestedStrategy: params.strategy.requestedStrategy, + challengeStore: params.strategy.challengeStore, + visibleInChallengeApi: params.strategy.visibleInChallengeApi, + sharedChallengeStore: params.strategy.sharedChallengeStore, + propagationSeconds: params.strategy.propagationSeconds, + wildcard: Boolean(params.authz.wildcard), + presentedByNode: params.instanceId ?? null, + httpPath: `/.well-known/acme-challenge/${params.challenge.token}`, + verificationMode: 'shared-http-01', + }; + } + + private getChallengeExpiry(authz: Authorization): Date { + if (typeof authz.expires === 'string') { + const parsed = new Date(authz.expires); + if (!Number.isNaN(parsed.getTime())) { + return parsed; + } + } + + return new Date(Date.now() + DEFAULT_CHALLENGE_TTL_MS); + } + + private toChallengeDto(challenge: AcmeChallengeRecord): AcmeChallengeInfoDto { + return { + id: challenge.id, + orderId: challenge.orderId, + token: challenge.token, + domain: challenge.domain, + challengeType: challenge.challengeType, + provider: challenge.provider, + status: challenge.status, + presentedAt: challenge.presentedAt, + cleanedUpAt: challenge.cleanedUpAt, + finalizedAt: challenge.finalizedAt, + lastServedAt: challenge.lastServedAt, + expiresAt: challenge.expiresAt, + metadata: challenge.metadata, + createdAt: challenge.createdAt, + }; + } + + private mergeMetadata( + current: unknown, + patch: Record<string, unknown>, + ): Record<string, unknown> { + const base = + current && typeof current === 'object' && !Array.isArray(current) + ? { ...(current as Record<string, unknown>) } + : {}; + + return { + ...base, + ...patch, + }; + } + + private async sleep(ms: number): Promise<void> { + await new Promise((resolve) => setTimeout(resolve, ms)); + } +} + diff --git a/src/certificate/backup.controller.ts b/src/certificate/backup.controller.ts index fbb8613..6e20488 100644 --- a/src/certificate/backup.controller.ts +++ b/src/certificate/backup.controller.ts @@ -13,15 +13,20 @@ import { } from '@nestjs/common'; import { Response } from 'express'; import { CertificateBackupService } from './certificate-backup.service'; +import { AuthorizeAdmin } from '../auth/decorators/authorize.decorator'; import { ApiKeyGuard } from '../auth/guards/api-key.guard'; +import { Audit } from '../audit/decorators/audit.decorator'; +import { ImportCertificatesDto } from './dto/import-certificates.dto'; @Controller('certificates/backup') +@AuthorizeAdmin('security-admin') export class BackupController { constructor(private readonly backupService: CertificateBackupService) {} @Post() @UseGuards(ApiKeyGuard) @HttpCode(HttpStatus.CREATED) + @Audit({ action: 'certificate.backup.create' }) async createBackup() { const result = await this.backupService.createBackup(); return { @@ -32,27 +37,48 @@ export class BackupController { @Get() @UseGuards(ApiKeyGuard) + @Audit({ action: 'certificate.backup.list' }) async listBackups() { return this.backupService.listBackups(); } @Get(':filename') @UseGuards(ApiKeyGuard) + @Audit({ action: 'certificate.backup.download' }) async downloadBackup( @Param('filename') filename: string, @Res({ passthrough: true }) res: Response, ): Promise<StreamableFile> { const stream = await this.backupService.getBackupStream(filename); res.set({ - 'Content-Type': 'application/zip', + 'Content-Type': filename.endsWith('.zip') + ? 'application/zip' + : 'application/octet-stream', 'Content-Disposition': `attachment; filename="${filename}"`, }); return new StreamableFile(stream); } + @Post(':filename/verify') + @UseGuards(ApiKeyGuard) + @HttpCode(HttpStatus.OK) + @Audit({ action: 'certificate.backup.verify' }) + async verifyBackup(@Param('filename') filename: string) { + return this.backupService.verifyBackup(filename); + } + + @Post(':filename/restore') + @UseGuards(ApiKeyGuard) + @HttpCode(HttpStatus.OK) + @Audit({ action: 'certificate.backup.restore' }) + async restoreBackup(@Param('filename') filename: string) { + return this.backupService.restoreBackup(filename); + } + @Delete(':filename') @UseGuards(ApiKeyGuard) @HttpCode(HttpStatus.NO_CONTENT) + @Audit({ action: 'certificate.backup.delete' }) async deleteBackup(@Param('filename') filename: string) { await this.backupService.deleteBackup(filename); } @@ -60,12 +86,15 @@ export class BackupController { @Post('import') @UseGuards(ApiKeyGuard) @HttpCode(HttpStatus.OK) - async importCertificates(@Body() data: any) { + @Audit({ action: 'certificate.backup.import' }) + async importCertificates(@Body() data: ImportCertificatesDto) { return this.backupService.importCertificates(data.certificates); } @Get('export/:id') + @AuthorizeAdmin('platform-admin') @UseGuards(ApiKeyGuard) + @Audit({ action: 'certificate.export' }) async exportCertificate(@Param('id') id: string) { return this.backupService.exportCertificate(id); } diff --git a/src/certificate/certificate-backup.service.ts b/src/certificate/certificate-backup.service.ts index cb85d53..081e0fb 100644 --- a/src/certificate/certificate-backup.service.ts +++ b/src/certificate/certificate-backup.service.ts @@ -1,77 +1,213 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { + BadRequestException, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; -import * as archiver from 'archiver'; -import * as fs from 'fs'; -import * as path from 'path'; -import { Readable } from 'stream'; +import * as crypto from 'node:crypto'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { Readable } from 'node:stream'; +import { + getCertificateStorageName, + hashDomains, + joinDomains, + normalizeDomains, + parseDomains, +} from '../utils/domain-utils'; +import { PrivateKeyEncryptionService } from './private-key-encryption.service'; + +const BACKUP_ENVELOPE_FORMAT = 'lyttle-backup-envelope/v1'; +const BACKUP_PAYLOAD_FORMAT = 'lyttle-backup-payload/v1'; +const BACKUP_MANIFEST_FORMAT = 'lyttle-backup-manifest/v1'; +const BACKUP_VERSION = '2.0'; +const DEVELOPMENT_FALLBACK_BACKUP_KEY = + 'lyttle-nginx-development-only-backup-key'; +const CERTIFICATES_ENTRY_PATH = 'certificates.json'; +const METADATA_ENTRY_PATH = 'metadata.json'; +const BACKUP_FILENAME_PATTERN = + /^[A-Za-z0-9][A-Za-z0-9._-]*\.(?:lyttlebackup|zip)$/; + +type BackupFileEntry = { + path: string; + contentType: 'application/json' | 'application/x-pem-file'; + content: string; +}; + +type BackupManifestEntry = { + path: string; + contentType: BackupFileEntry['contentType']; + bytes: number; + sha256: string; +}; + +type BackupManifest = { + format: typeof BACKUP_MANIFEST_FORMAT; + version: typeof BACKUP_VERSION; + createdAt: string; + certificateCount: number; + encryptionKeyVersion: string; + entries: BackupManifestEntry[]; +}; + +type BackupPayload = { + format: typeof BACKUP_PAYLOAD_FORMAT; + version: typeof BACKUP_VERSION; + createdAt: string; + entries: BackupFileEntry[]; +}; + +type BackupEnvelope = { + format: typeof BACKUP_ENVELOPE_FORMAT; + version: typeof BACKUP_VERSION; + createdAt: string; + manifest: BackupManifest; + manifestSignature: string; + encryption: { + scheme: 'aes-256-gcm'; + keyVersion: string; + keyId: string; + iv: string; + authTag: string; + aad: string; + }; + payload: string; +}; + +type ImportCertificateRecord = { + domains: string[]; + certPem: string; + keyPem: string; + expiresAt: string | Date; + issuedAt: string | Date; +}; + +type NormalizedImportCertificateRecord = { + domains: string[]; + certPem: string; + keyPem: string; + expiresAt: Date; + issuedAt: Date; +}; + +type BackupProtectionConfig = { + key: Buffer; + keyVersion: string; + keyId: string; +}; @Injectable() export class CertificateBackupService { private readonly logger = new Logger(CertificateBackupService.name); private readonly backupDir = process.env.BACKUP_DIR || '/tmp/cert-backups'; - constructor(private prisma: PrismaService) {} + constructor( + private prisma: PrismaService, + private readonly privateKeyEncryption: PrivateKeyEncryptionService = new PrivateKeyEncryptionService(), + ) {} + + private decryptCertificateKey(cert: { + keyPem: string; + keyEncryption?: unknown; + domainsHash: string; + }) { + return this.privateKeyEncryption.decryptPrivateKey( + cert.keyPem, + cert.keyEncryption ?? null, + { + scope: 'certificate', + domainsHash: cert.domainsHash, + }, + ); + } async createBackup(): Promise<{ filename: string; path: string }> { - this.logger.log('[Backup] Creating certificate backup...'); + this.logger.log('[Backup] Creating encrypted certificate backup...'); + this.ensureBackupDirectory(); - // Ensure backup directory exists - if (!fs.existsSync(this.backupDir)) { - fs.mkdirSync(this.backupDir, { recursive: true }); + const createdAt = new Date().toISOString(); + const timestamp = createdAt.replace(/[:.]/g, '-'); + const filename = `certificates-backup-${timestamp}.lyttlebackup`; + const filepath = path.join(this.backupDir, filename); + const protection = this.getBackupProtectionConfig(); + + const certificates = await this.prisma.certificate.findMany(); + const exportCertificates = certificates.map((cert) => ({ + domains: parseDomains(cert.domains, { allowWildcard: true }), + certPem: cert.certPem, + keyPem: this.decryptCertificateKey(cert), + issuedAt: new Date(cert.issuedAt).toISOString(), + expiresAt: new Date(cert.expiresAt).toISOString(), + })); + + const metadata = { + exportDate: createdAt, + totalCertificates: exportCertificates.length, + version: BACKUP_VERSION, + format: BACKUP_ENVELOPE_FORMAT, + encrypted: true, + integrity: 'manifest+hmac+aes-256-gcm', + }; + + const entries: BackupFileEntry[] = [ + { + path: CERTIFICATES_ENTRY_PATH, + contentType: 'application/json', + content: JSON.stringify(exportCertificates, null, 2), + }, + { + path: METADATA_ENTRY_PATH, + contentType: 'application/json', + content: JSON.stringify(metadata, null, 2), + }, + ]; + + for (const cert of exportCertificates) { + const prefix = `certs/${getCertificateStorageName(cert.domains[0])}`; + entries.push( + { + path: `${prefix}/fullchain.pem`, + contentType: 'application/x-pem-file', + content: cert.certPem, + }, + { + path: `${prefix}/privkey.pem`, + contentType: 'application/x-pem-file', + content: cert.keyPem, + }, + ); } - const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); - const filename = `certificates-backup-${timestamp}.zip`; - const filepath = path.join(this.backupDir, filename); + const payload: BackupPayload = { + format: BACKUP_PAYLOAD_FORMAT, + version: BACKUP_VERSION, + createdAt, + entries, + }; - const output = fs.createWriteStream(filepath); - const archive = archiver('zip', { zlib: { level: 9 } }); + const manifest: BackupManifest = { + format: BACKUP_MANIFEST_FORMAT, + version: BACKUP_VERSION, + createdAt, + certificateCount: exportCertificates.length, + encryptionKeyVersion: protection.keyVersion, + entries: entries.map((entry) => ({ + path: entry.path, + contentType: entry.contentType, + bytes: Buffer.byteLength(entry.content, 'utf8'), + sha256: this.sha256(entry.content), + })), + }; - return new Promise((resolve, reject) => { - output.on('close', () => { - this.logger.log( - `[Backup] Created backup: ${filename} (${archive.pointer()} bytes)`, - ); - resolve({ filename, path: filepath }); - }); + const envelope = this.encryptBackupPayload(payload, manifest, protection); + fs.writeFileSync(filepath, JSON.stringify(envelope, null, 2), 'utf8'); - archive.on('error', (err) => { - this.logger.error('[Backup] Failed to create backup', err); - reject(err); - }); + this.logger.log( + `[Backup] Created encrypted backup: ${filename} (${fs.statSync(filepath).size} bytes)`, + ); - archive.pipe(output); - - // Add database export - this.prisma.certificate - .findMany() - .then((certificates) => { - const data = JSON.stringify(certificates, null, 2); - archive.append(data, { name: 'certificates.json' }); - - // Add individual certificate files - certificates.forEach((cert) => { - const domains = cert.domains.split(';')[0].trim(); - const prefix = `certs/${domains}`; - - archive.append(cert.certPem, { name: `${prefix}/fullchain.pem` }); - archive.append(cert.keyPem, { name: `${prefix}/privkey.pem` }); - }); - - // Add metadata - const metadata = { - exportDate: new Date().toISOString(), - totalCertificates: certificates.length, - version: '1.0', - }; - archive.append(JSON.stringify(metadata, null, 2), { - name: 'metadata.json', - }); - - archive.finalize(); - }) - .catch(reject); - }); + return { filename, path: filepath }; } async exportCertificate( @@ -81,40 +217,55 @@ export class CertificateBackupService { const cert = await this.prisma.certificate.findUnique({ where: { id } }); if (!cert) { - throw new Error(`Certificate not found: ${id}`); + throw new NotFoundException(`Certificate not found: ${id}`); } return { certPem: cert.certPem, - keyPem: cert.keyPem, - domains: cert.domains.split(';').map((d) => d.trim()), + keyPem: this.decryptCertificateKey(cert), + domains: parseDomains(cert.domains, { allowWildcard: true }), }; } async importCertificates( - data: Array<{ - domains: string[]; - certPem: string; - keyPem: string; - expiresAt: Date; - issuedAt: Date; - }>, + data: ImportCertificateRecord[], ): Promise<{ imported: number; skipped: number; errors: number }> { + if (!Array.isArray(data)) { + throw new BadRequestException( + 'Import payload must contain a certificates array', + ); + } + this.logger.log(`[Import] Importing ${data.length} certificates...`); const results = { imported: 0, skipped: 0, errors: 0 }; - for (const cert of data) { + for (const [index, cert] of data.entries()) { try { + const validated = this.validateImportRecord(cert, index); + const normalizedDomains = validated.domains; + const domainsHash = hashDomains(normalizedDomains, { + allowWildcard: true, + }); + const encryptedKey = this.privateKeyEncryption.encryptPrivateKey( + validated.keyPem, + { + scope: 'certificate', + domainsHash, + }, + ); + // Check if certificate already exists - const domainsStr = cert.domains.join(';'); + const domainsStr = joinDomains(normalizedDomains, { + allowWildcard: true, + }); const existing = await this.prisma.certificate.findFirst({ where: { domains: domainsStr }, }); if (existing) { this.logger.log( - `[Import] Skipping existing cert: ${cert.domains[0]}`, + `[Import] Skipping existing cert: ${normalizedDomains[0]}`, ); results.skipped++; continue; @@ -124,21 +275,26 @@ export class CertificateBackupService { await this.prisma.certificate.create({ data: { domains: domainsStr, - domainsHash: this.hashDomains(cert.domains), - certPem: cert.certPem, - keyPem: cert.keyPem, - expiresAt: new Date(cert.expiresAt), - issuedAt: new Date(cert.issuedAt), + domainsHash, + certPem: validated.certPem, + keyPem: encryptedKey.keyPem, + keyEncryption: encryptedKey.keyEncryption ?? undefined, + expiresAt: validated.expiresAt, + issuedAt: validated.issuedAt, lastUsedAt: new Date(), isOrphaned: false, }, - }); + } as any); - this.logger.log(`[Import] Imported cert: ${cert.domains[0]}`); + this.logger.log(`[Import] Imported cert: ${normalizedDomains[0]}`); results.imported++; } catch (error) { + const label = + cert && typeof cert === 'object' && Array.isArray(cert.domains) + ? String(cert.domains[0] ?? `entry-${index + 1}`) + : `entry-${index + 1}`; this.logger.error( - `[Import] Failed to import cert: ${cert.domains[0]}`, + `[Import] Failed to import cert: ${label}`, error instanceof Error ? error.stack : String(error), ); results.errors++; @@ -151,6 +307,71 @@ export class CertificateBackupService { return results; } + async verifyBackup(filename: string): Promise<{ + verified: true; + filename: string; + createdAt: string; + version: string; + certificateCount: number; + entryCount: number; + keyVersion: string; + }> { + const { envelope, payload } = this.readAndVerifyBackup(filename); + + return { + verified: true, + filename: this.sanitizeBackupFilename(filename), + createdAt: envelope.createdAt, + version: envelope.version, + certificateCount: envelope.manifest.certificateCount, + entryCount: payload.entries.length, + keyVersion: envelope.encryption.keyVersion, + }; + } + + async restoreBackup( + filename: string, + ): Promise< + { + imported: number; + skipped: number; + errors: number; + } & { + verifiedBackup: Awaited<ReturnType<CertificateBackupService['verifyBackup']>>; + } + > { + const verification = await this.verifyBackup(filename); + const { payload } = this.readAndVerifyBackup(filename); + const certificatesEntry = payload.entries.find( + (entry) => entry.path === CERTIFICATES_ENTRY_PATH, + ); + + if (!certificatesEntry) { + throw new BadRequestException( + 'Backup payload is missing certificates.json', + ); + } + + let certificates: ImportCertificateRecord[]; + try { + const parsed = JSON.parse(certificatesEntry.content) as unknown; + if (!Array.isArray(parsed)) { + throw new Error('certificates.json must contain an array'); + } + certificates = parsed as ImportCertificateRecord[]; + } catch (error) { + throw new BadRequestException( + `Backup payload contains invalid certificates.json content: ${error instanceof Error ? error.message : String(error)}`, + ); + } + + const results = await this.importCertificates(certificates); + return { + ...results, + verifiedBackup: verification, + }; + } + async listBackups(): Promise< Array<{ filename: string; size: number; created: Date }> > { @@ -160,7 +381,7 @@ export class CertificateBackupService { const files = fs.readdirSync(this.backupDir); const backups = files - .filter((f) => f.endsWith('.zip')) + .filter((f) => BACKUP_FILENAME_PATTERN.test(f)) .map((filename) => { const filepath = path.join(this.backupDir, filename); const stats = fs.statSync(filepath); @@ -176,24 +397,444 @@ export class CertificateBackupService { } async getBackupStream(filename: string): Promise<Readable> { - const filepath = path.join(this.backupDir, filename); + const filepath = this.resolveBackupFilePath(filename); if (!fs.existsSync(filepath)) { - throw new Error(`Backup file not found: ${filename}`); + throw new NotFoundException(`Backup file not found: ${filename}`); } return fs.createReadStream(filepath); } async deleteBackup(filename: string): Promise<void> { - const filepath = path.join(this.backupDir, filename); + const filepath = this.resolveBackupFilePath(filename); if (fs.existsSync(filepath)) { fs.unlinkSync(filepath); this.logger.log(`[Backup] Deleted: ${filename}`); } } - private hashDomains(domains: string[]): string { - const crypto = require('crypto'); - const sorted = [...domains].sort(); - return crypto.createHash('sha256').update(sorted.join(';')).digest('hex'); + private ensureBackupDirectory() { + if (!fs.existsSync(this.backupDir)) { + fs.mkdirSync(this.backupDir, { recursive: true }); + } + } + + private getBackupProtectionConfig(): BackupProtectionConfig { + const keyVersion = + process.env.BACKUP_ENCRYPTION_KEY_VERSION?.trim() || 'v1'; + const keyMaterial = + process.env.BACKUP_ENCRYPTION_KEY?.trim() || + this.resolveDevelopmentFallbackBackupKey(); + + if (!process.env.BACKUP_ENCRYPTION_KEY?.trim() && process.env.NODE_ENV === 'production') { + throw new BadRequestException( + 'BACKUP_ENCRYPTION_KEY must be configured in production before backup, verify, or restore operations can run', + ); + } + + return { + key: this.deriveSymmetricKey(keyMaterial), + keyVersion, + keyId: `backup:${keyVersion}`, + }; + } + + private resolveDevelopmentFallbackBackupKey(): string { + return DEVELOPMENT_FALLBACK_BACKUP_KEY; + } + + private deriveSymmetricKey(rawSecret: string): Buffer { + const trimmed = rawSecret.trim(); + + if (/^[a-fA-F0-9]{64}$/.test(trimmed)) { + return Buffer.from(trimmed, 'hex'); + } + + const compact = trimmed.replace(/\s+/g, ''); + const decoded = Buffer.from(compact, 'base64'); + if (decoded.length === 32) { + return decoded; + } + + return crypto.createHash('sha256').update(trimmed, 'utf8').digest(); + } + + private encryptBackupPayload( + payload: BackupPayload, + manifest: BackupManifest, + protection: BackupProtectionConfig, + ): BackupEnvelope { + const payloadBuffer = Buffer.from(JSON.stringify(payload), 'utf8'); + const aad = Buffer.from( + JSON.stringify({ + format: BACKUP_ENVELOPE_FORMAT, + version: BACKUP_VERSION, + keyVersion: protection.keyVersion, + }), + 'utf8', + ); + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv('aes-256-gcm', protection.key, iv); + cipher.setAAD(aad); + + const ciphertext = Buffer.concat([ + cipher.update(payloadBuffer), + cipher.final(), + ]); + const authTag = cipher.getAuthTag(); + + return { + format: BACKUP_ENVELOPE_FORMAT, + version: BACKUP_VERSION, + createdAt: manifest.createdAt, + manifest, + manifestSignature: this.signManifest(manifest, protection.key), + encryption: { + scheme: 'aes-256-gcm', + keyVersion: protection.keyVersion, + keyId: protection.keyId, + iv: iv.toString('base64'), + authTag: authTag.toString('base64'), + aad: aad.toString('base64'), + }, + payload: ciphertext.toString('base64'), + }; + } + + private readAndVerifyBackup(filename: string): { + envelope: BackupEnvelope; + payload: BackupPayload; + } { + const filepath = this.resolveBackupFilePath(filename); + if (!fs.existsSync(filepath)) { + throw new NotFoundException(`Backup file not found: ${filename}`); + } + + if (filename.endsWith('.zip')) { + throw new BadRequestException( + 'Legacy plaintext .zip backups cannot be verified or restored by the current hardened backup flow; create a new encrypted backup first', + ); + } + + const protection = this.getBackupProtectionConfig(); + + let envelope: BackupEnvelope; + try { + envelope = JSON.parse(fs.readFileSync(filepath, 'utf8')) as BackupEnvelope; + } catch (error) { + throw new BadRequestException( + `Backup file is not valid JSON: ${error instanceof Error ? error.message : String(error)}`, + ); + } + + if (envelope.format !== BACKUP_ENVELOPE_FORMAT) { + throw new BadRequestException( + `Unsupported backup format: ${String((envelope as Partial<BackupEnvelope>).format ?? 'unknown')}`, + ); + } + + const expectedSignature = this.signManifest(envelope.manifest, protection.key); + if (!this.constantTimeEquals(expectedSignature, envelope.manifestSignature)) { + throw new BadRequestException( + 'Backup manifest signature verification failed', + ); + } + + let payload: BackupPayload; + try { + const decipher = crypto.createDecipheriv( + envelope.encryption.scheme, + protection.key, + Buffer.from(envelope.encryption.iv, 'base64'), + ); + decipher.setAAD(Buffer.from(envelope.encryption.aad, 'base64')); + decipher.setAuthTag(Buffer.from(envelope.encryption.authTag, 'base64')); + const plaintext = Buffer.concat([ + decipher.update(Buffer.from(envelope.payload, 'base64')), + decipher.final(), + ]); + payload = JSON.parse(plaintext.toString('utf8')) as BackupPayload; + } catch (error) { + throw new BadRequestException( + `Backup decryption failed or the payload has been tampered with: ${error instanceof Error ? error.message : String(error)}`, + ); + } + + if (payload.format !== BACKUP_PAYLOAD_FORMAT) { + throw new BadRequestException( + `Unsupported decrypted backup payload format: ${String((payload as Partial<BackupPayload>).format ?? 'unknown')}`, + ); + } + + if (payload.entries.length !== envelope.manifest.entries.length) { + throw new BadRequestException( + 'Backup payload entry count does not match the signed manifest', + ); + } + + for (const manifestEntry of envelope.manifest.entries) { + const payloadEntry = payload.entries.find( + (entry) => entry.path === manifestEntry.path, + ); + + if (!payloadEntry) { + throw new BadRequestException( + `Backup payload is missing signed entry ${manifestEntry.path}`, + ); + } + + if (payloadEntry.contentType !== manifestEntry.contentType) { + throw new BadRequestException( + `Backup entry ${manifestEntry.path} content type does not match the signed manifest`, + ); + } + + if (Buffer.byteLength(payloadEntry.content, 'utf8') !== manifestEntry.bytes) { + throw new BadRequestException( + `Backup entry ${manifestEntry.path} size does not match the signed manifest`, + ); + } + + if (this.sha256(payloadEntry.content) !== manifestEntry.sha256) { + throw new BadRequestException( + `Backup entry ${manifestEntry.path} checksum verification failed`, + ); + } + } + + const certificatesEntry = payload.entries.find( + (entry) => entry.path === CERTIFICATES_ENTRY_PATH, + ); + if (!certificatesEntry) { + throw new BadRequestException( + 'Backup payload is missing certificates.json', + ); + } + + const certificateCount = this.countBackupCertificates(certificatesEntry.content); + if (certificateCount !== envelope.manifest.certificateCount) { + throw new BadRequestException( + 'Backup certificate count does not match the signed manifest', + ); + } + + return { envelope, payload }; + } + + private countBackupCertificates(serializedCertificates: string): number { + try { + const parsed = JSON.parse(serializedCertificates) as unknown; + if (!Array.isArray(parsed)) { + throw new Error('not an array'); + } + return parsed.length; + } catch (error) { + throw new BadRequestException( + `Backup certificates.json payload is invalid: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + private validateImportRecord( + cert: ImportCertificateRecord, + index: number, + ): NormalizedImportCertificateRecord { + if (!cert || typeof cert !== 'object' || Array.isArray(cert)) { + throw new BadRequestException( + `Import entry ${index + 1} must be an object`, + ); + } + + const normalizedDomains = normalizeDomains( + this.requireDomainArray(cert.domains, index), + { + allowWildcard: true, + }, + ); + const certPem = this.requirePem(cert.certPem, 'certificate', index); + const keyPem = this.requirePem(cert.keyPem, 'private key', index); + const issuedAt = this.requireDate(cert.issuedAt, 'issuedAt', index); + const expiresAt = this.requireDate(cert.expiresAt, 'expiresAt', index); + + if (expiresAt <= issuedAt) { + throw new BadRequestException( + `Import entry ${index + 1} has expiresAt earlier than or equal to issuedAt`, + ); + } + + let certificate: crypto.X509Certificate; + try { + certificate = new crypto.X509Certificate(certPem); + } catch (error) { + throw new BadRequestException( + `Import entry ${index + 1} contains an invalid X.509 certificate: ${error instanceof Error ? error.message : String(error)}`, + ); + } + + try { + const certificatePublicKey = certificate.publicKey.export({ + type: 'spki', + format: 'der', + }); + const privateKeyPublicKey = crypto + .createPublicKey(crypto.createPrivateKey(keyPem)) + .export({ type: 'spki', format: 'der' }); + + if (!Buffer.from(certificatePublicKey).equals(Buffer.from(privateKeyPublicKey))) { + throw new BadRequestException( + `Import entry ${index + 1} contains a certificate/private-key mismatch`, + ); + } + } catch (error) { + if (error instanceof BadRequestException) { + throw error; + } + + throw new BadRequestException( + `Import entry ${index + 1} contains an invalid private key: ${error instanceof Error ? error.message : String(error)}`, + ); + } + + const certificateDomains = this.extractCertificateDomains(certificate); + const missingDomains = normalizedDomains.filter( + (domain) => !certificateDomains.has(domain), + ); + if (missingDomains.length > 0) { + throw new BadRequestException( + `Import entry ${index + 1} is missing certificate SAN/CN coverage for: ${missingDomains.join(', ')}`, + ); + } + + const certificateNotBefore = new Date(certificate.validFrom); + const certificateNotAfter = new Date(certificate.validTo); + if (Number.isNaN(certificateNotBefore.getTime()) || Number.isNaN(certificateNotAfter.getTime())) { + throw new BadRequestException( + `Import entry ${index + 1} contains a certificate with invalid validity timestamps`, + ); + } + + if (issuedAt.getTime() + 60_000 < certificateNotBefore.getTime()) { + throw new BadRequestException( + `Import entry ${index + 1} has issuedAt earlier than the certificate validity window`, + ); + } + + if (expiresAt.getTime() > certificateNotAfter.getTime() + 60_000) { + throw new BadRequestException( + `Import entry ${index + 1} has expiresAt later than the certificate validity window`, + ); + } + + return { + domains: normalizedDomains, + certPem, + keyPem, + issuedAt, + expiresAt, + }; + } + + private requireDomainArray(domains: unknown, index: number): string[] { + if (!Array.isArray(domains) || domains.length === 0) { + throw new BadRequestException( + `Import entry ${index + 1} must include at least one domain`, + ); + } + + return domains.map((domain) => { + if (typeof domain !== 'string' || !domain.trim()) { + throw new BadRequestException( + `Import entry ${index + 1} contains an invalid domain value`, + ); + } + return domain; + }); + } + + private requirePem(value: unknown, label: string, index: number): string { + if (typeof value !== 'string' || !value.trim()) { + throw new BadRequestException( + `Import entry ${index + 1} is missing ${label} PEM data`, + ); + } + + return value; + } + + private requireDate(value: unknown, label: string, index: number): Date { + const parsed = value instanceof Date ? new Date(value) : new Date(String(value)); + if (Number.isNaN(parsed.getTime())) { + throw new BadRequestException( + `Import entry ${index + 1} has an invalid ${label} timestamp`, + ); + } + + return parsed; + } + + private extractCertificateDomains(certificate: crypto.X509Certificate): Set<string> { + const domains = new Set<string>(); + const subjectAltName = certificate.subjectAltName ?? ''; + + for (const match of subjectAltName.matchAll(/DNS:([^,\n]+)/g)) { + const rawDomain = match[1]?.trim(); + if (!rawDomain) { + continue; + } + + try { + const normalized = normalizeDomains([rawDomain], { allowWildcard: true })[0]; + domains.add(normalized); + } catch { + // Ignore malformed SAN entries; the import will still fail if the requested + // domains are not covered by a valid SAN/CN value. + } + } + + const commonNameMatch = certificate.subject.match(/(?:^|[\n,\/])\s*CN\s*=\s*([^,\n/]+)/); + const commonName = commonNameMatch?.[1]?.trim(); + if (commonName) { + try { + const normalized = normalizeDomains([commonName], { allowWildcard: true })[0]; + domains.add(normalized); + } catch { + // Ignore malformed CN values. + } + } + + return domains; + } + + private resolveBackupFilePath(filename: string): string { + return path.join(this.backupDir, this.sanitizeBackupFilename(filename)); + } + + private sanitizeBackupFilename(filename: string): string { + if (path.basename(filename) !== filename || !BACKUP_FILENAME_PATTERN.test(filename)) { + throw new BadRequestException('Invalid backup filename'); + } + + return filename; + } + + private sha256(value: string): string { + return crypto.createHash('sha256').update(value, 'utf8').digest('hex'); + } + + private signManifest(manifest: BackupManifest, key: Buffer): string { + return crypto + .createHmac('sha256', key) + .update(JSON.stringify(manifest), 'utf8') + .digest('base64'); + } + + private constantTimeEquals(left: string, right: string): boolean { + const leftBuffer = Buffer.from(left, 'utf8'); + const rightBuffer = Buffer.from(right, 'utf8'); + + if (leftBuffer.length !== rightBuffer.length) { + return false; + } + + return crypto.timingSafeEqual(leftBuffer, rightBuffer); } } diff --git a/src/certificate/certificate-cleanup.service.ts b/src/certificate/certificate-cleanup.service.ts index 5508efd..fa0f6f8 100644 --- a/src/certificate/certificate-cleanup.service.ts +++ b/src/certificate/certificate-cleanup.service.ts @@ -17,7 +17,11 @@ export class CertificateCleanupService { this.logger.log('Fetching all ProxyEntry domain hashes...'); const entries = await this.prisma.proxyEntry.findMany(); const usedDomainHashes = new Set( - entries.map((e) => hashDomains(parseDomains(e.domains))), + entries.map((e) => + hashDomains(parseDomains(e.domains, { allowWildcard: true }), { + allowWildcard: true, + }), + ), ); this.logger.log( `Found ${usedDomainHashes.size} unique ProxyEntry domain hashes in use.`, diff --git a/src/certificate/certificate-monitor.service.ts b/src/certificate/certificate-monitor.service.ts index a405db4..0ddcce8 100644 --- a/src/certificate/certificate-monitor.service.ts +++ b/src/certificate/certificate-monitor.service.ts @@ -42,7 +42,7 @@ export class CertificateMonitorService implements OnModuleInit { const daysUntilExpiry = Math.ceil( (cert.expiresAt.getTime() - now.getTime()) / (1000 * 60 * 60 * 24), ); - const domains = parseDomains(cert.domains); + const domains = parseDomains(cert.domains, { allowWildcard: true }); if (daysUntilExpiry < 0) { // Certificate has expired @@ -109,7 +109,7 @@ export class CertificateMonitorService implements OnModuleInit { return { id: cert.id, - domains: parseDomains(cert.domains), + domains: parseDomains(cert.domains, { allowWildcard: true }), expiresAt: cert.expiresAt, daysUntilExpiry, status, diff --git a/src/certificate/certificate-order.constants.ts b/src/certificate/certificate-order.constants.ts new file mode 100644 index 0000000..dfba7ca --- /dev/null +++ b/src/certificate/certificate-order.constants.ts @@ -0,0 +1,40 @@ +export const CERTIFICATE_ORDER_STATUSES = [ + 'requested', + 'challenge-published', + 'validating', + 'issued', + 'distributing', + 'activated', + 'failed', + 'revoked', +] as const; + +export type CertificateOrderStatus = + (typeof CERTIFICATE_ORDER_STATUSES)[number]; + +export const CERTIFICATE_ORDER_SOURCE_TYPES = [ + 'acme', + 'uploaded', + 'self-signed', + 'imported', +] as const; + +export type CertificateOrderSourceType = + (typeof CERTIFICATE_ORDER_SOURCE_TYPES)[number]; + +export const IN_PROGRESS_CERTIFICATE_ORDER_STATUSES: readonly CertificateOrderStatus[] = + ['requested', 'challenge-published', 'validating', 'issued', 'distributing']; + +export const TERMINAL_CERTIFICATE_ORDER_STATUSES: readonly CertificateOrderStatus[] = + ['activated', 'failed', 'revoked']; + +export const RETRYABLE_CERTIFICATE_ORDER_STATUSES: readonly CertificateOrderStatus[] = + ['failed']; + +export function isTerminalCertificateOrderStatus( + status: string, +): status is CertificateOrderStatus { + return TERMINAL_CERTIFICATE_ORDER_STATUSES.includes( + status as CertificateOrderStatus, + ); +} diff --git a/src/certificate/certificate-order.service.ts b/src/certificate/certificate-order.service.ts new file mode 100644 index 0000000..b43c517 --- /dev/null +++ b/src/certificate/certificate-order.service.ts @@ -0,0 +1,710 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { + hashDomains, + joinDomains, + normalizeDomains, + parseDomains, +} from '../utils/domain-utils'; +import { + CERTIFICATE_ORDER_SOURCE_TYPES, + type CertificateOrderSourceType, + type CertificateOrderStatus, + IN_PROGRESS_CERTIFICATE_ORDER_STATUSES, + RETRYABLE_CERTIFICATE_ORDER_STATUSES, +} from './certificate-order.constants'; +import { + CertificateOrderDetailDto, + CertificateOrderSummaryDto, +} from './dto/certificate-order.dto'; +import { PrivateKeyEncryptionService } from './private-key-encryption.service'; +import { toPrismaNullableJsonValue } from '../prisma/prisma-json.util'; + +const STATUS_TIMESTAMP_FIELDS: Partial<Record<CertificateOrderStatus, string>> = + { + requested: 'requestedAt', + 'challenge-published': 'challengePublishedAt', + validating: 'validatingAt', + issued: 'issuedAt', + distributing: 'distributingAt', + activated: 'activatedAt', + failed: 'failedAt', + revoked: 'revokedAt', + }; + +type CertificateOrderRecord = { + id: string; + domains: string; + domainsHash: string; + primaryDomain: string; + sourceType: CertificateOrderSourceType; + status: CertificateOrderStatus; + attemptCount: number; + retryCount: number; + nextRetryAt: Date | null; + lastError: string | null; + certificateId: string | null; + requestedByNode: string | null; + requestedAt: Date; + startedAt: Date | null; + challengePublishedAt: Date | null; + validatingAt: Date | null; + issuedAt: Date | null; + distributingAt: Date | null; + activatedAt: Date | null; + failedAt: Date | null; + revokedAt: Date | null; + completedAt: Date | null; + metadata: Record<string, unknown> | null; + createdAt: Date; + updatedAt: Date; +}; + +type CertificateOrderEventRecord = { + id: string; + eventType: string; + fromStatus: CertificateOrderStatus | null; + toStatus: CertificateOrderStatus | null; + message: string | null; + attemptNumber: number | null; + retryAt: Date | null; + details: Record<string, unknown> | null; + occurredAt: Date; +}; + +type CertificateArtifactRecord = { + id: string; + orderId: string | null; + certificateId: string | null; + domains: string; + domainsHash: string; + version: number; + sourceType: CertificateOrderSourceType; + keyEncryption: Record<string, unknown> | null; + issuedAt: Date; + expiresAt: Date; + activatedAt: Date | null; + isCurrent: boolean; + distributionStatus: string | null; + distributionOperationId: string | null; + distributionCompletedAt: Date | null; + createdByNode: string | null; + createdAt: Date; +}; + +type ClusterOperationAckRecord = { + nodeInstanceId: string; + nodeHostname: string | null; + endpointUrl: string | null; + status: string; + responseStatus: number | null; + errorMessage: string | null; + startedAt: Date | null; + ackedAt: Date | null; + details: Record<string, unknown> | null; +}; + +type ClusterOperationWithAcks = { + id: string; + status: string; + completedAt: Date | null; + acknowledgements: ClusterOperationAckRecord[]; +}; + +type LatestDistributionRecord = { + artifact: CertificateArtifactRecord; + operation: ClusterOperationWithAcks; +}; + +@Injectable() +export class CertificateOrderService { + private readonly logger = new Logger(CertificateOrderService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly privateKeyEncryption: PrivateKeyEncryptionService = new PrivateKeyEncryptionService(), + ) {} + + async getOrCreateOrder(params: { + domains: string[]; + sourceType: CertificateOrderSourceType; + requestedByNode?: string | null; + existingOrderId?: string; + metadata?: Record<string, unknown>; + }): Promise<CertificateOrderRecord> { + const domains = normalizeDomains(params.domains, { allowWildcard: true }); + const domainsHash = hashDomains(domains, { allowWildcard: true }); + + if (params.existingOrderId) { + const existingOrder = (await this.prisma.certificateOrder.findUnique({ + where: { id: params.existingOrderId }, + })) as CertificateOrderRecord | null; + + if (!existingOrder) { + throw new Error( + `Certificate order not found: ${params.existingOrderId}`, + ); + } + + return existingOrder; + } + + const activeOrder = (await this.prisma.certificateOrder.findFirst({ + where: { + domainsHash, + status: { in: [...IN_PROGRESS_CERTIFICATE_ORDER_STATUSES] }, + }, + orderBy: { createdAt: 'desc' }, + })) as CertificateOrderRecord | null; + + if (activeOrder) { + return activeOrder; + } + + const retryableOrder = (await this.prisma.certificateOrder.findFirst({ + where: { + domainsHash, + status: { in: [...RETRYABLE_CERTIFICATE_ORDER_STATUSES] }, + }, + orderBy: { createdAt: 'desc' }, + })) as CertificateOrderRecord | null; + + if ( + retryableOrder && + (!retryableOrder.nextRetryAt || retryableOrder.nextRetryAt <= new Date()) + ) { + return this.resumeOrder(retryableOrder.id, { + reason: 'Automatic retry window reached', + force: true, + }); + } + + const order = (await this.prisma.certificateOrder.create({ + data: { + domains: joinDomains(domains, { allowWildcard: true }), + domainsHash, + primaryDomain: domains[0], + sourceType: params.sourceType, + status: 'requested', + attemptCount: 1, + retryCount: 0, + requestedByNode: params.requestedByNode ?? null, + requestedAt: new Date(), + metadata: + params.metadata === undefined + ? undefined + : toPrismaNullableJsonValue(params.metadata), + }, + })) as CertificateOrderRecord; + + await this.recordEvent(order.id, { + eventType: 'created', + toStatus: 'requested', + message: `Created ${params.sourceType} certificate order for ${order.primaryDomain}`, + attemptNumber: order.attemptCount, + details: params.metadata ?? null, + }); + + return order; + } + + async resumeOrder( + orderId: string, + options: { + reason: string; + force?: boolean; + }, + ): Promise<CertificateOrderRecord> { + const order = (await this.prisma.certificateOrder.findUnique({ + where: { id: orderId }, + })) as CertificateOrderRecord | null; + + if (!order) { + throw new Error(`Certificate order not found: ${orderId}`); + } + + if ( + !options.force && + order.nextRetryAt && + order.nextRetryAt.getTime() > Date.now() + ) { + throw new Error( + `Certificate order ${orderId} is scheduled to retry at ${order.nextRetryAt.toISOString()}`, + ); + } + + const now = new Date(); + const updated = (await this.prisma.certificateOrder.update({ + where: { id: orderId }, + data: { + status: 'requested', + nextRetryAt: null, + lastError: null, + startedAt: null, + challengePublishedAt: null, + validatingAt: null, + issuedAt: null, + distributingAt: null, + activatedAt: null, + failedAt: null, + revokedAt: null, + completedAt: null, + requestedAt: now, + attemptCount: { increment: 1 }, + retryCount: { increment: 1 }, + }, + })) as CertificateOrderRecord; + + await this.recordEvent(orderId, { + eventType: 'retry-requested', + fromStatus: order.status, + toStatus: 'requested', + message: options.reason, + attemptNumber: updated.attemptCount, + details: { + forced: Boolean(options.force), + }, + }); + + return updated; + } + + async transitionOrder( + orderId: string, + nextStatus: CertificateOrderStatus, + options: { + message?: string; + details?: Record<string, unknown> | null; + lastError?: string | null; + nextRetryAt?: Date | null; + data?: Record<string, unknown>; + } = {}, + ): Promise<CertificateOrderRecord> { + const order = (await this.prisma.certificateOrder.findUnique({ + where: { id: orderId }, + })) as CertificateOrderRecord | null; + + if (!order) { + throw new Error(`Certificate order not found: ${orderId}`); + } + + const now = new Date(); + const data: Record<string, unknown> = { + ...options.data, + status: nextStatus, + }; + + const timestampField = STATUS_TIMESTAMP_FIELDS[nextStatus]; + if (timestampField) { + data[timestampField] = now; + } + + if (nextStatus === 'challenge-published' && !order.startedAt) { + data.startedAt = now; + } + + if (['activated', 'failed', 'revoked'].includes(nextStatus)) { + data.completedAt = now; + } + + if (options.lastError !== undefined) { + data.lastError = options.lastError; + } + + if (options.nextRetryAt !== undefined) { + data.nextRetryAt = options.nextRetryAt; + } else if (nextStatus !== 'failed') { + data.nextRetryAt = null; + } + + const updated = (await this.prisma.certificateOrder.update({ + where: { id: orderId }, + data, + })) as CertificateOrderRecord; + + await this.recordEvent(orderId, { + eventType: 'state-transition', + fromStatus: order.status, + toStatus: nextStatus, + message: options.message ?? null, + attemptNumber: updated.attemptCount, + retryAt: options.nextRetryAt ?? null, + details: options.details ?? null, + }); + + return updated; + } + + async markFailure( + orderId: string, + error: string, + nextRetryAt: Date | null, + details: Record<string, unknown> | null = null, + ): Promise<CertificateOrderRecord> { + const updated = await this.transitionOrder(orderId, 'failed', { + message: error, + lastError: error, + nextRetryAt, + details, + }); + + if (nextRetryAt) { + await this.recordEvent(orderId, { + eventType: 'retry-scheduled', + fromStatus: 'failed', + toStatus: 'failed', + message: `Retry scheduled for ${nextRetryAt.toISOString()}`, + attemptNumber: updated.attemptCount, + retryAt: nextRetryAt, + details, + }); + } + + return updated; + } + + async completeWithCertificate( + orderId: string, + params: { + certificateId: string; + message: string; + details?: Record<string, unknown> | null; + }, + ): Promise<CertificateOrderRecord> { + return this.transitionOrder(orderId, 'activated', { + message: params.message, + details: params.details ?? null, + data: { + certificateId: params.certificateId, + lastError: null, + }, + }); + } + + async recordArtifact(params: { + orderId: string; + certificateId?: string | null; + domains: string[]; + sourceType: CertificateOrderSourceType; + certPem: string; + keyPem: string; + issuedAt: Date; + expiresAt: Date; + activatedAt?: Date | null; + createdByNode?: string | null; + metadata?: Record<string, unknown> | null; + }): Promise<{ id: string; version: number }> { + const domains = normalizeDomains(params.domains, { allowWildcard: true }); + const domainsHash = hashDomains(domains, { allowWildcard: true }); + const latestArtifact = + (await this.prisma.certificateArtifactVersion.findFirst({ + where: { domainsHash }, + orderBy: { version: 'desc' }, + })) as { version: number } | null; + const version = (latestArtifact?.version ?? 0) + 1; + const encryptedKey = this.privateKeyEncryption.encryptPrivateKey( + params.keyPem, + { + scope: 'certificate-artifact', + domainsHash, + version, + }, + ); + + const artifact = (await this.prisma.certificateArtifactVersion.create({ + data: { + orderId: params.orderId, + certificateId: params.certificateId ?? null, + domains: joinDomains(domains, { allowWildcard: true }), + domainsHash, + version, + sourceType: params.sourceType, + certPem: params.certPem, + keyPem: encryptedKey.keyPem, + keyEncryption: encryptedKey.keyEncryption ?? undefined, + issuedAt: params.issuedAt, + expiresAt: params.expiresAt, + activatedAt: params.activatedAt ?? null, + createdByNode: params.createdByNode ?? null, + metadata: + params.metadata === undefined + ? undefined + : toPrismaNullableJsonValue(params.metadata), + }, + } as any)) as { id: string; version: number }; + + await this.recordEvent(params.orderId, { + eventType: 'artifact-created', + message: `Stored certificate artifact version ${version}`, + attemptNumber: null, + details: { + artifactId: artifact.id, + version, + certificateId: params.certificateId, + sourceType: params.sourceType, + }, + }); + + return artifact; + } + + async getLatestArtifactForOrder(orderId: string) { + return (await this.prisma.certificateArtifactVersion.findFirst({ + where: { orderId }, + orderBy: { version: 'desc' }, + })) as CertificateArtifactRecord | null; + } + + async getArtifact(artifactId: string) { + return (await this.prisma.certificateArtifactVersion.findUnique({ + where: { id: artifactId }, + })) as (CertificateArtifactRecord & { + domainsHash: string; + certPem: string; + keyPem: string; + keyEncryption: Record<string, unknown> | null; + metadata: Record<string, unknown> | null; + orderId: string | null; + }) | null; + } + + async getCurrentArtifactForDomainsHash(domainsHash: string) { + return (await this.prisma.certificateArtifactVersion.findFirst({ + where: { + domainsHash, + isCurrent: true, + }, + orderBy: { version: 'desc' }, + })) as CertificateArtifactRecord | null; + } + + async getRollbackArtifactForDomainsHash( + domainsHash: string, + currentVersion: number, + ) { + return (await this.prisma.certificateArtifactVersion.findFirst({ + where: { + domainsHash, + version: { lt: currentVersion }, + activatedAt: { not: null }, + }, + orderBy: { version: 'desc' }, + })) as CertificateArtifactRecord | null; + } + + async listOrders(limit = 25): Promise<{ + count: number; + orders: CertificateOrderSummaryDto[]; + }> { + const take = Number.isFinite(limit) + ? Math.min(Math.max(limit, 1), 100) + : 25; + const orders = (await this.prisma.certificateOrder.findMany({ + take, + orderBy: { createdAt: 'desc' }, + })) as CertificateOrderRecord[]; + + return { + count: orders.length, + orders: orders.map((order) => this.toSummaryDto(order)), + }; + } + + async getOrder(orderId: string): Promise<CertificateOrderDetailDto> { + const order = (await this.prisma.certificateOrder.findUnique({ + where: { id: orderId }, + include: { + events: { + orderBy: { occurredAt: 'desc' }, + }, + artifacts: { + orderBy: { version: 'desc' }, + }, + }, + })) as + | (CertificateOrderRecord & { + events: CertificateOrderEventRecord[]; + artifacts: CertificateArtifactRecord[]; + }) + | null; + + if (!order) { + throw new Error(`Certificate order not found: ${orderId}`); + } + + const latestDistribution = await this.getLatestDistribution(order.artifacts); + + return this.toDetailDto(order, latestDistribution); + } + + async validateRetryableOrder( + orderId: string, + ): Promise<CertificateOrderRecord> { + const order = (await this.prisma.certificateOrder.findUnique({ + where: { id: orderId }, + })) as CertificateOrderRecord | null; + + if (!order) { + throw new Error(`Certificate order not found: ${orderId}`); + } + + if (!CERTIFICATE_ORDER_SOURCE_TYPES.includes(order.sourceType)) { + throw new Error( + `Certificate order ${orderId} has unsupported source type ${String(order.sourceType)}`, + ); + } + + return order; + } + + private async recordEvent( + orderId: string, + params: { + eventType: string; + fromStatus?: CertificateOrderStatus | null; + toStatus?: CertificateOrderStatus | null; + message?: string | null; + attemptNumber?: number | null; + retryAt?: Date | null; + details?: Record<string, unknown> | null; + }, + ): Promise<void> { + await this.prisma.certificateOrderEvent.create({ + data: { + orderId, + eventType: params.eventType, + fromStatus: params.fromStatus ?? null, + toStatus: params.toStatus ?? null, + message: params.message ?? null, + attemptNumber: params.attemptNumber ?? null, + retryAt: params.retryAt ?? null, + details: + params.details === undefined + ? undefined + : toPrismaNullableJsonValue(params.details), + }, + }); + } + + private toSummaryDto( + order: CertificateOrderRecord, + ): CertificateOrderSummaryDto { + return { + id: order.id, + domains: parseDomains(order.domains, { allowWildcard: true }), + primaryDomain: order.primaryDomain, + sourceType: order.sourceType, + status: order.status, + attemptCount: order.attemptCount, + retryCount: order.retryCount, + nextRetryAt: order.nextRetryAt, + lastError: order.lastError, + certificateId: order.certificateId, + requestedByNode: order.requestedByNode, + requestedAt: order.requestedAt, + startedAt: order.startedAt, + challengePublishedAt: order.challengePublishedAt, + validatingAt: order.validatingAt, + issuedAt: order.issuedAt, + distributingAt: order.distributingAt, + activatedAt: order.activatedAt, + failedAt: order.failedAt, + revokedAt: order.revokedAt, + completedAt: order.completedAt, + metadata: order.metadata, + createdAt: order.createdAt, + updatedAt: order.updatedAt, + }; + } + + private toDetailDto( + order: CertificateOrderRecord & { + events: CertificateOrderEventRecord[]; + artifacts: CertificateArtifactRecord[]; + }, + latestDistribution: LatestDistributionRecord | null, + ): CertificateOrderDetailDto { + return { + ...this.toSummaryDto(order), + events: order.events.map((event) => ({ + id: event.id, + eventType: event.eventType, + fromStatus: event.fromStatus, + toStatus: event.toStatus, + message: event.message, + attemptNumber: event.attemptNumber, + retryAt: event.retryAt, + details: event.details, + occurredAt: event.occurredAt, + })), + artifacts: order.artifacts.map((artifact) => ({ + id: artifact.id, + certificateId: artifact.certificateId, + version: artifact.version, + sourceType: artifact.sourceType, + issuedAt: artifact.issuedAt, + expiresAt: artifact.expiresAt, + activatedAt: artifact.activatedAt, + isCurrent: artifact.isCurrent, + distributionStatus: artifact.distributionStatus, + distributionOperationId: artifact.distributionOperationId, + distributionCompletedAt: artifact.distributionCompletedAt, + createdByNode: artifact.createdByNode, + createdAt: artifact.createdAt, + })), + latestDistribution: + latestDistribution + ? { + artifactId: latestDistribution.artifact.id, + version: latestDistribution.artifact.version, + status: latestDistribution.operation.status, + operationId: latestDistribution.operation.id, + completedAt: latestDistribution.operation.completedAt, + acknowledgements: latestDistribution.operation.acknowledgements.map((ack) => ({ + nodeInstanceId: ack.nodeInstanceId, + nodeHostname: ack.nodeHostname, + endpointUrl: ack.endpointUrl, + status: ack.status, + responseStatus: ack.responseStatus, + errorMessage: ack.errorMessage, + startedAt: ack.startedAt, + ackedAt: ack.ackedAt, + details: ack.details, + })), + } + : null, + }; + } + + private async getLatestDistribution( + artifacts: CertificateArtifactRecord[], + ): Promise<LatestDistributionRecord | null> { + const latestArtifact = artifacts + .filter((artifact) => typeof artifact.distributionOperationId === 'string') + .sort((left, right) => { + const leftTime = left.distributionCompletedAt?.getTime() ?? 0; + const rightTime = right.distributionCompletedAt?.getTime() ?? 0; + return rightTime - leftTime || right.version - left.version; + })[0]; + + if (!latestArtifact?.distributionOperationId) { + return null; + } + + const operation = (await this.prisma.clusterOperation.findUnique({ + where: { id: latestArtifact.distributionOperationId }, + include: { + acknowledgements: { + orderBy: [{ nodeHostname: 'asc' }, { nodeInstanceId: 'asc' }], + }, + }, + })) as ClusterOperationWithAcks | null; + + return operation + ? { + artifact: latestArtifact, + operation, + } + : null; + } +} diff --git a/src/certificate/certificate.controller.ts b/src/certificate/certificate.controller.ts index ddf90db..3be3649 100644 --- a/src/certificate/certificate.controller.ts +++ b/src/certificate/certificate.controller.ts @@ -8,15 +8,30 @@ import { HttpStatus, Param, Post, + Query, + Res, UseGuards, } from '@nestjs/common'; +import { Response } from 'express'; import { CertificateService } from './certificate.service'; import { UploadCertificateDto } from './dto/upload-certificate.dto'; import { GenerateSelfSignedDto } from './dto/generate-self-signed.dto'; import { CertificateInfoDto } from './dto/certificate-info.dto'; +import { AcmeChallengeInfoDto } from './dto/acme-challenge.dto'; +import { + CertificateOrderDetailDto, + CertificateOrderSummaryDto, +} from './dto/certificate-order.dto'; +import { + AuthorizeAdmin, + AuthorizeInternalNodeOrAdmin, +} from '../auth/decorators/authorize.decorator'; import { ApiKeyGuard } from '../auth/guards/api-key.guard'; +import { Audit } from '../audit/decorators/audit.decorator'; +import { NormalizedDomainPipe } from '../utils/pipes/normalized-domain.pipe'; @Controller('certificates') +@AuthorizeAdmin('viewer') export class CertificateController { constructor(private readonly certificateService: CertificateService) {} @@ -25,13 +40,81 @@ export class CertificateController { return this.certificateService.listCertificates(); } - @Get(':id') - async getCertificate(@Param('id') id: string): Promise<CertificateInfoDto> { - return this.certificateService.getCertificateInfo(id); + @Get('orders') + async listCertificateOrders( + @Query('limit') limit?: string, + ): Promise<{ count: number; orders: CertificateOrderSummaryDto[] }> { + const parsedLimit = + typeof limit === 'string' && limit.trim().length > 0 + ? Number.parseInt(limit, 10) + : undefined; + + if ( + parsedLimit !== undefined && + (!Number.isFinite(parsedLimit) || parsedLimit <= 0) + ) { + throw new BadRequestException( + 'Query parameter "limit" must be a positive integer', + ); + } + + return this.certificateService.listCertificateOrders(parsedLimit); + } + + @Get('orders/:id') + async getCertificateOrder( + @Param('id') id: string, + ): Promise<CertificateOrderDetailDto> { + return this.certificateService.getCertificateOrder(id); + } + + @Get('challenges') + async listAcmeChallenges( + @Query('status') status?: string, + @Query('limit') limit?: string, + ): Promise<{ count: number; challenges: AcmeChallengeInfoDto[] }> { + const parsedLimit = + typeof limit === 'string' && limit.trim().length > 0 + ? Number.parseInt(limit, 10) + : undefined; + + if ( + parsedLimit !== undefined && + (!Number.isFinite(parsedLimit) || parsedLimit <= 0) + ) { + throw new BadRequestException( + 'Query parameter "limit" must be a positive integer', + ); + } + + return this.certificateService.listAcmeChallenges({ + status, + limit: parsedLimit, + }); } @Post('upload') @HttpCode(HttpStatus.CREATED) + @AuthorizeAdmin('security-admin') + @Audit({ + action: 'certificate.upload', + target: (request, responseBody) => ({ + type: 'certificate', + id: + typeof (responseBody as Record<string, unknown> | undefined)?.['id'] === + 'string' + ? ((responseBody as Record<string, unknown>)['id'] as string) + : undefined, + label: Array.isArray(request.body?.['domains']) + ? request.body['domains'] + .filter( + (domain): domain is string => + typeof domain === 'string' && domain.trim().length > 0, + ) + .join(',') + : undefined, + }), + }) async uploadCertificate(@Body() dto: UploadCertificateDto) { try { return await this.certificateService.uploadCertificate(dto); @@ -44,6 +127,26 @@ export class CertificateController { @Post('generate-self-signed') @HttpCode(HttpStatus.CREATED) + @AuthorizeAdmin('security-admin') + @Audit({ + action: 'certificate.generate-self-signed', + target: (request, responseBody) => ({ + type: 'certificate', + id: + typeof (responseBody as Record<string, unknown> | undefined)?.['id'] === + 'string' + ? ((responseBody as Record<string, unknown>)['id'] as string) + : undefined, + label: Array.isArray(request.body?.['domains']) + ? request.body['domains'] + .filter( + (domain): domain is string => + typeof domain === 'string' && domain.trim().length > 0, + ) + .join(',') + : undefined, + }), + }) async generateSelfSigned(@Body() dto: GenerateSelfSignedDto) { try { return await this.certificateService.generateSelfSignedCertificate( @@ -58,9 +161,43 @@ export class CertificateController { } } + @Post('orders/:id/retry') + @HttpCode(HttpStatus.OK) + @AuthorizeAdmin('operator') + @Audit({ action: 'certificate.order.retry' }) + async retryCertificateOrder( + @Param('id') id: string, + ): Promise<CertificateOrderDetailDto> { + try { + return await this.certificateService.retryCertificateOrder(id); + } catch (error) { + throw new BadRequestException( + error instanceof Error + ? error.message + : 'Failed to retry certificate order', + ); + } + } + + @Post(':id/rollback') + @HttpCode(HttpStatus.OK) + @AuthorizeAdmin('security-admin') + @Audit({ action: 'certificate.rollback' }) + async rollbackCertificate(@Param('id') id: string) { + try { + return await this.certificateService.rollbackCertificate(id); + } catch (error) { + throw new BadRequestException( + error instanceof Error ? error.message : 'Failed to roll back certificate', + ); + } + } + @Post('renew/:id') @UseGuards(ApiKeyGuard) @HttpCode(HttpStatus.OK) + @AuthorizeAdmin('operator') + @Audit({ action: 'certificate.renew' }) async renewCertificate(@Param('id') id: string) { return this.certificateService.renewCertificateById(id); } @@ -68,6 +205,8 @@ export class CertificateController { @Post('renew-all') @UseGuards(ApiKeyGuard) @HttpCode(HttpStatus.OK) + @AuthorizeAdmin('operator') + @Audit({ action: 'certificate.renew-all' }) async renewAllCertificates() { await this.certificateService.renewAllCertificates(); return { message: 'Certificate renewal process initiated' }; @@ -76,12 +215,16 @@ export class CertificateController { @Delete(':id') @UseGuards(ApiKeyGuard) @HttpCode(HttpStatus.NO_CONTENT) + @AuthorizeAdmin('security-admin') + @Audit({ action: 'certificate.delete' }) async deleteCertificate(@Param('id') id: string) { await this.certificateService.deleteCertificate(id); } @Get('validate/:domain') - async validateDomain(@Param('domain') domain: string) { + async validateDomain( + @Param('domain', new NormalizedDomainPipe()) domain: string, + ) { return this.certificateService.validateDomainForCertificate(domain); } @@ -90,6 +233,51 @@ export class CertificateController { return this.certificateService.checkAllCertificatesOcspSupport(); } + @Post('artifacts/:artifactId/activate') + @HttpCode(HttpStatus.OK) + @AuthorizeInternalNodeOrAdmin('platform-admin') + @Audit({ action: 'certificate.artifact.activate' }) + async activateArtifact( + @Param('artifactId') artifactId: string, + @Query('operationId') operationId: string | undefined, + @Res({ passthrough: true }) response: Response, + ) { + try { + const result = await this.certificateService.activateCertificateArtifact( + artifactId, + operationId, + ); + + return operationId + ? { + operationId, + status: 'succeeded', + ...result, + } + : result; + } catch (error) { + const message = + error instanceof Error ? error.message : 'Failed to activate artifact'; + + if (operationId) { + response.status(HttpStatus.INTERNAL_SERVER_ERROR); + return { + operationId, + status: 'failed', + artifactId, + error: message, + }; + } + + throw new BadRequestException(message); + } + } + + @Get(':id') + async getCertificate(@Param('id') id: string): Promise<CertificateInfoDto> { + return this.certificateService.getCertificateInfo(id); + } + /** * Sync certificates from database to filesystem * This endpoint is used by other nodes to trigger immediate sync after cert changes @@ -97,7 +285,25 @@ export class CertificateController { */ @Post('sync') @HttpCode(HttpStatus.OK) - async syncCertificates() { - return this.certificateService.syncCertificates(); + @AuthorizeInternalNodeOrAdmin('platform-admin') + @Audit({ action: 'certificate.sync' }) + async syncCertificates( + @Query('operationId') operationId: string | undefined, + @Res({ passthrough: true }) response: Response, + ) { + const result = await this.certificateService.syncCertificates(); + + if (operationId && !result.success) { + response.status(HttpStatus.INTERNAL_SERVER_ERROR); + } + + return operationId + ? { + operationId, + status: result.success ? 'succeeded' : 'failed', + syncedCount: result.syncedCount, + errors: result.errors, + } + : result; } } diff --git a/src/certificate/certificate.module.ts b/src/certificate/certificate.module.ts index 09fc5eb..b7e1c42 100644 --- a/src/certificate/certificate.module.ts +++ b/src/certificate/certificate.module.ts @@ -7,28 +7,35 @@ import { AcmeController } from './acme.controller'; import { CertificateCleanupService } from './certificate-cleanup.service'; import { CertificateMonitorService } from './certificate-monitor.service'; import { CertificateBackupService } from './certificate-backup.service'; +import { CertificateOrderService } from './certificate-order.service'; +import { PrivateKeyEncryptionService } from './private-key-encryption.service'; +import { AcmeService } from './acme.service'; import { TlsConfigService } from './tls-config.service'; import { PrismaService } from '../prisma/prisma.service'; import { ScheduleModule } from '@nestjs/schedule'; import { AlertModule } from '../alert/alert.module'; import { AuthModule } from '../auth/auth.module'; +import { HealthModule } from '../health/health.module'; @Module({ - imports: [ScheduleModule.forRoot(), AlertModule, AuthModule], + imports: [ScheduleModule.forRoot(), AlertModule, AuthModule, HealthModule], controllers: [ + BackupController, CertificateController, TlsController, - BackupController, AcmeController, ], providers: [ CertificateService, + AcmeService, + CertificateOrderService, CertificateCleanupService, CertificateMonitorService, CertificateBackupService, + PrivateKeyEncryptionService, TlsConfigService, PrismaService, ], - exports: [CertificateService, TlsConfigService], + exports: [CertificateService, TlsConfigService, PrivateKeyEncryptionService], }) export class CertificateModule {} diff --git a/src/certificate/certificate.service.ts b/src/certificate/certificate.service.ts index dd1f386..f7cae7a 100644 --- a/src/certificate/certificate.service.ts +++ b/src/certificate/certificate.service.ts @@ -6,18 +6,32 @@ import { OnApplicationShutdown, OnModuleInit, } from '@nestjs/common'; -import { exec as execCb } from 'child_process'; -import { promisify } from 'util'; import { PrismaService } from '../prisma/prisma.service'; import * as process from 'node:process'; +import * as os from 'node:os'; import * as fs from 'fs'; import * as path from 'path'; import { addDays } from 'date-fns'; -import { hashDomains, joinDomains, parseDomains } from '../utils/domain-utils'; +import { + getCertificateStorageName, + hashDomains, + joinDomains, + normalizeDomain, + normalizeDomains, + parseDomains, +} from '../utils/domain-utils'; import { AlertService } from '../alert/alert.service'; +import { CertificateOrderService } from './certificate-order.service'; +import { CertificateOrderSourceType } from './certificate-order.constants'; +import { ClusterHeartbeatService } from '../distributed-lock/cluster-heartbeat.service'; +import { ClusterOperationsService } from '../distributed-lock/cluster-operations.service'; import { DistributedLockService } from '../distributed-lock/distributed-lock.service'; - -const exec = promisify(execCb); +import { HealthService } from '../health/health.service'; +import { runCommand } from '../utils/process-utils'; +import { resolveAcmeStrategy } from './acme-strategy'; +import { AcmeService } from './acme.service'; +import { PrivateKeyEncryptionService } from './private-key-encryption.service'; +import { toPrismaNullableJsonValue } from '../prisma/prisma-json.util'; // Validate ADMIN_EMAIL is set (required by Let's Encrypt) const adminEmail = process.env.ADMIN_EMAIL; @@ -30,6 +44,13 @@ if (!adminEmail) { const RENEW_BEFORE_DAYS = parseInt(process.env.RENEW_BEFORE_DAYS || '30', 10); +class ArtifactActivationError extends Error { + constructor(message: string) { + super(message); + this.name = 'ArtifactActivationError'; + } +} + @Injectable() export class CertificateService implements OnModuleInit, OnApplicationShutdown { private readonly logger = new Logger(CertificateService.name); @@ -41,15 +62,14 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown { constructor( private prisma: PrismaService, private alertService: AlertService, + private certificateOrders: CertificateOrderService, + private clusterOperations: ClusterOperationsService, private distributedLock: DistributedLockService, - @Inject( - forwardRef( - () => - require('../distributed-lock/cluster-heartbeat.service') - .ClusterHeartbeatService, - ), - ) - private clusterHeartbeat: any, + private healthService: HealthService, + @Inject(forwardRef(() => ClusterHeartbeatService)) + private clusterHeartbeat: ClusterHeartbeatService | null, + private readonly acmeService: AcmeService | undefined = undefined, + private readonly privateKeyEncryption: PrivateKeyEncryptionService = new PrivateKeyEncryptionService(), ) {} async onModuleInit() { @@ -57,6 +77,16 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown { `[Init] Instance ID: ${this.distributedLock.getInstanceId()}`, ); + const migratedKeys = await this.privateKeyEncryption.migrateStoredPrivateKeys(); + if ( + migratedKeys.certificatesUpdated > 0 || + migratedKeys.artifactsUpdated > 0 + ) { + this.logger.log( + `[Init] Encrypted ${migratedKeys.certificatesUpdated} certificate key(s) and ${migratedKeys.artifactsUpdated} artifact key(s) already stored in the database`, + ); + } + // Start leader election process - only leader will renew certificates this.logger.log( `[Init] Starting leader election for certificate renewal (check interval: 30s)`, @@ -109,13 +139,14 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown { for (const cert of certs) { try { - const domains = parseDomains(cert.domains); + const domains = parseDomains(cert.domains, { allowWildcard: true }); const primaryDomain = domains[0]; + const decryptedKeyPem = this.decryptStoredCertificateKey(cert); // Quick validation check const validation = await this.validateCertificate( cert.certPem, - cert.keyPem, + decryptedKeyPem, domains, ); if (!validation.valid) { @@ -159,7 +190,7 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown { const currentCert = fs.readFileSync(certPath, 'utf8'); const currentKey = fs.readFileSync(keyPath, 'utf8'); - if (currentCert !== cert.certPem || currentKey !== cert.keyPem) { + if (currentCert !== cert.certPem || currentKey !== decryptedKeyPem) { needsWrite = true; this.logger.debug( `[Sync] Certificate content mismatch for ${primaryDomain}`, @@ -169,7 +200,7 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown { if (needsWrite) { this.logger.log(`[Sync] Updating certificate for ${primaryDomain}`); - this.writeCertToFs(primaryDomain, cert.certPem, cert.keyPem); + this.writeCertToFs(primaryDomain, cert.certPem, decryptedKeyPem); syncedCount++; } } catch (certError) { @@ -190,13 +221,17 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown { // Reload NGINX to pick up new certificates try { - await exec('nginx -t'); - await exec('nginx -s reload'); + await runCommand('nginx', ['-t']); + await runCommand('nginx', ['-s', 'reload']); this.logger.log('[Sync] NGINX reloaded successfully'); } catch (reloadErr) { const errorMsg = reloadErr instanceof Error ? reloadErr.message : String(reloadErr); this.logger.error(`[Sync] Failed to reload NGINX: ${errorMsg}`); + errors.push({ + domain: '__nginx_reload__', + error: `Failed to reload NGINX after sync: ${errorMsg}`, + }); // Send alert for critical NGINX reload failure await this.alertService @@ -240,12 +275,23 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown { ); } + if (errors.length > 0) { + this.healthService.recordCertificateSyncFailure( + `encountered ${errors.length} certificate sync error(s)`, + ); + } else { + this.healthService.recordCertificateSyncSuccess( + `synced ${syncedCount} certificate(s)`, + ); + } + return { success: true, syncedCount, errors }; } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); this.logger.error( `[Sync] Certificate synchronization failed: ${errorMsg}`, ); + this.healthService.recordCertificateSyncFailure(errorMsg); // Send critical alert await this.alertService @@ -448,7 +494,54 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown { } private certDir(primaryDomain: string) { - return `/etc/letsencrypt/live/${primaryDomain}`; + return `/etc/letsencrypt/live/${getCertificateStorageName(primaryDomain)}`; + } + + private createTempDirectory(prefix: string): string { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + } + + private async getCertificateExpiryFromFile(certPath: string): Promise<Date> { + const { stdout } = await runCommand('openssl', [ + 'x509', + '-enddate', + '-noout', + '-in', + certPath, + ]); + const match = stdout.match(/notAfter=(.*)/); + return match ? new Date(match[1]) : addDays(new Date(), 365); + } + + private async getCertificatePublicKey(certPath: string): Promise<string> { + const { stdout } = await runCommand('openssl', [ + 'x509', + '-in', + certPath, + '-noout', + '-pubkey', + ]); + return stdout.trim(); + } + + private async getPrivateKeyPublicKey(keyPath: string): Promise<string> { + const { stdout } = await runCommand('openssl', [ + 'pkey', + '-in', + keyPath, + '-pubout', + ]); + return stdout.trim(); + } + + private calculateRetryDelayMs(attemptNumber: number): number { + const cappedAttempt = Math.max(1, attemptNumber); + + return Math.min( + 1000 * 60 * 60 * 24, + 1000 * 60 * 2 ** Math.min(5, cappedAttempt) + + Math.floor(Math.random() * 30000), + ); } /** @@ -530,7 +623,11 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown { * Examples: subdomain.example.com -> example.com, www.example.co.uk -> example.co.uk */ private getRegisteredDomain(domain: string): string { - const parts = domain.split('.'); + const normalizedDomain = normalizeDomain(domain, { allowWildcard: true }); + const hostname = normalizedDomain.startsWith('*.') + ? normalizedDomain.slice(2) + : normalizedDomain; + const parts = hostname.split('.'); // Handle special TLDs like .co.uk, .com.au, etc. const specialTlds = ['co.uk', 'com.au', 'co.nz', 'co.za', 'com.br']; @@ -570,8 +667,7 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown { } // 2. Write to temp files for validation - const tempDir = `/tmp/cert-validation-${Date.now()}`; - fs.mkdirSync(tempDir, { recursive: true }); + const tempDir = this.createTempDirectory('lyttlenginx-cert-validation-'); const tempCertPath = path.join(tempDir, 'cert.pem'); const tempKeyPath = path.join(tempDir, 'key.pem'); @@ -581,36 +677,26 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown { try { // 3. Validate certificate structure - const certCheck = await exec( - `openssl x509 -in ${tempCertPath} -noout -text 2>&1`, - ); - if (certCheck.stderr) { + const certCheck = await runCommand('openssl', [ + 'x509', + '-in', + tempCertPath, + '-noout', + '-text', + ]); + if (!certCheck.stdout.trim()) { return { valid: false, - error: `Certificate validation failed: ${certCheck.stderr}`, + error: + 'Certificate validation failed: no certificate data returned', }; } - // 4. Validate private key - const keyCheck = await exec( - `openssl rsa -in ${tempKeyPath} -check -noout 2>&1`, - ); - if (keyCheck.stderr && !keyCheck.stderr.includes('ok')) { - return { - valid: false, - error: `Private key validation failed: ${keyCheck.stderr}`, - }; - } + // 4. Validate private key and 5. Verify cert and key match + const certPublicKey = await this.getCertificatePublicKey(tempCertPath); + const keyPublicKey = await this.getPrivateKeyPublicKey(tempKeyPath); - // 5. Verify cert and key match - const certModulus = await exec( - `openssl x509 -noout -modulus -in ${tempCertPath} | openssl md5`, - ); - const keyModulus = await exec( - `openssl rsa -noout -modulus -in ${tempKeyPath} | openssl md5`, - ); - - if (certModulus.stdout.trim() !== keyModulus.stdout.trim()) { + if (certPublicKey !== keyPublicKey) { return { valid: false, error: 'Certificate and private key do not match', @@ -618,9 +704,13 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown { } // 6. Check expiry - const expiryOutput = await exec( - `openssl x509 -enddate -noout -in ${tempCertPath}`, - ); + const expiryOutput = await runCommand('openssl', [ + 'x509', + '-enddate', + '-noout', + '-in', + tempCertPath, + ]); const match = expiryOutput.stdout.match(/notAfter=(.*)/); if (!match) { return { valid: false, error: 'Could not parse expiry date' }; @@ -642,11 +732,8 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown { } // 7. Verify domains (SAN check) - const sanOutput = await exec( - `openssl x509 -in ${tempCertPath} -noout -text | grep -A1 "Subject Alternative Name"`, - ); const certDomains = - sanOutput.stdout + certCheck.stdout .match(/DNS:([^,\s]+)/g) ?.map((d) => d.replace('DNS:', '')) || []; @@ -749,22 +836,391 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown { } } - async ensureCertificate(domainsInput: string[] | string): Promise<void> { + private getCertificateActivationTimeoutMs() { + const parsed = Number.parseInt( + process.env.CERTIFICATE_ACTIVATION_TIMEOUT_MS ?? '', + 10, + ); + + return Number.isFinite(parsed) && parsed > 0 ? parsed : 30000; + } + + private mergeMetadata( + current: unknown, + patch: Record<string, unknown>, + ): Record<string, unknown> { + const base = + current && typeof current === 'object' && !Array.isArray(current) + ? { ...(current as Record<string, unknown>) } + : {}; + + return { + ...base, + ...patch, + }; + } + + private decryptStoredCertificateKey(record: { + keyPem: string; + keyEncryption?: unknown; + domainsHash: string; + }) { + return this.privateKeyEncryption.decryptPrivateKey( + record.keyPem, + record.keyEncryption ?? null, + { + scope: 'certificate', + domainsHash: record.domainsHash, + }, + ); + } + + private decryptStoredArtifactKey(record: { + keyPem: string; + keyEncryption?: unknown; + domainsHash: string; + version: number; + }) { + return this.privateKeyEncryption.decryptPrivateKey( + record.keyPem, + record.keyEncryption ?? null, + { + scope: 'certificate-artifact', + domainsHash: record.domainsHash, + version: record.version, + }, + ); + } + + private encryptPrivateKeyForCertificate(params: { + keyPem: string; + domainsHash: string; + }) { + return this.privateKeyEncryption.encryptPrivateKey(params.keyPem, { + scope: 'certificate', + domainsHash: params.domainsHash, + }); + } + + private summarizeOperationFailure(operation: { + status: string; + acknowledgements: Array<{ + nodeHostname: string | null; + nodeInstanceId: string; + errorMessage: string | null; + status: string; + }>; + }) { + const failures = operation.acknowledgements.filter( + (ack) => ack.status === 'failed', + ); + + if (failures.length === 0) { + return `Certificate activation finished with status ${operation.status}`; + } + + return failures + .map( + (ack) => + `${ack.nodeHostname ?? ack.nodeInstanceId}: ${ack.errorMessage ?? 'activation failed'}`, + ) + .join('; '); + } + + private async activateArtifactLocally( + artifactId: string, + operationId?: string, + ) { + const artifact = await this.certificateOrders.getArtifact(artifactId); + + if (!artifact) { + throw new Error(`Certificate artifact not found: ${artifactId}`); + } + + const domains = parseDomains(artifact.domains, { allowWildcard: true }); + const primaryDomain = domains[0]; + const artifactPrivateKeyPem = this.decryptStoredArtifactKey(artifact); + const validation = await this.validateCertificate( + artifact.certPem, + artifactPrivateKeyPem, + domains, + ); + + if (!validation.valid) { + throw new Error( + `Artifact ${artifactId} failed validation before activation: ${validation.error}`, + ); + } + + this.writeCertToFs(primaryDomain, artifact.certPem, artifactPrivateKeyPem); + await runCommand('nginx', ['-t']); + await runCommand('nginx', ['-s', 'reload']); + + return { + operationId: operationId ?? null, + artifactId: artifact.id, + version: artifact.version, + primaryDomain, + domains, + status: 'activated', + }; + } + + private async activateArtifactAcrossCluster(params: { + artifactId: string; + orderId: string; + action: 'activate' | 'rollback'; + }) { + const artifact = await this.certificateOrders.getArtifact(params.artifactId); + + if (!artifact) { + throw new Error(`Certificate artifact not found: ${params.artifactId}`); + } + const artifactPrivateKeyPem = this.decryptStoredArtifactKey(artifact); + + const existingOrder = await this.prisma.certificateOrder.findUnique({ + where: { id: params.orderId }, + select: { + id: true, + attemptCount: true, + }, + }); + + if (!existingOrder) { + throw new Error(`Certificate order not found: ${params.orderId}`); + } + + const currentArtifact = await this.certificateOrders.getCurrentArtifactForDomainsHash( + artifact.domainsHash, + ); + const actionLabel = params.action === 'rollback' ? 'rollback' : 'activation'; + + await this.certificateOrders.transitionOrder(params.orderId, 'distributing', { + message: `Queued cluster certificate ${actionLabel} for artifact version ${artifact.version}`, + details: { + artifactId: artifact.id, + artifactVersion: artifact.version, + action: params.action, + previousArtifactId: currentArtifact?.id ?? null, + }, + }); + + const operation = await this.clusterOperations.enqueueBroadcastOperation({ + operationType: + params.action === 'rollback' + ? 'certificate.rollback' + : 'certificate.activate', + broadcast: true, + remotePath: `/certificates/artifacts/${artifact.id}/activate`, + executionTimeoutMs: this.getCertificateActivationTimeoutMs(), + metadata: { + orderId: params.orderId, + artifactId: artifact.id, + artifactVersion: artifact.version, + domainsHash: artifact.domainsHash, + action: params.action, + previousArtifactId: currentArtifact?.id ?? null, + }, + localAction: async (operationId) => + this.activateArtifactLocally(artifact.id, operationId), + }); + + await this.prisma.certificateArtifactVersion.update({ + where: { id: artifact.id }, + data: { + distributionOperationId: operation.operationId, + distributionStatus: 'running', + distributionCompletedAt: null, + metadata: toPrismaNullableJsonValue( + this.mergeMetadata(artifact.metadata, { + latestDistribution: { + operationId: operation.operationId, + action: params.action, + previousArtifactId: currentArtifact?.id ?? null, + queuedAt: new Date().toISOString(), + }, + }), + ), + }, + }); + + const settledOperation = await this.clusterOperations.waitForOperationToSettle( + operation.operationId, + { + timeoutMs: this.getCertificateActivationTimeoutMs() + 5000, + }, + ); + + const completedAt = settledOperation.completedAt ?? new Date(); + + await this.prisma.certificateArtifactVersion.update({ + where: { id: artifact.id }, + data: { + distributionStatus: settledOperation.status, + distributionCompletedAt: completedAt, + }, + }); + + if (settledOperation.status !== 'succeeded') { + const failureSummary = this.summarizeOperationFailure(settledOperation); + const retryAfter = new Date( + Date.now() + this.calculateRetryDelayMs(existingOrder.attemptCount), + ); + + await this.certificateOrders.markFailure( + params.orderId, + failureSummary, + retryAfter, + { + artifactId: artifact.id, + artifactVersion: artifact.version, + action: params.action, + operationId: operation.operationId, + previousArtifactId: currentArtifact?.id ?? null, + operationStatus: settledOperation.status, + }, + ); + + throw new ArtifactActivationError(failureSummary); + } + + const activeCertificate = await this.prisma.certificate.findUnique({ + where: { domainsHash: artifact.domainsHash }, + } as any); + const now = new Date(); + + const certificateRecord = activeCertificate + ? await this.prisma.certificate.update({ + where: { domainsHash: artifact.domainsHash } as any, + data: { + domains: artifact.domains, + certPem: artifact.certPem, + ...this.encryptPrivateKeyForCertificate({ + keyPem: artifactPrivateKeyPem, + domainsHash: artifact.domainsHash, + }), + expiresAt: artifact.expiresAt, + issuedAt: artifact.issuedAt, + lastUsedAt: now, + isOrphaned: false, + status: 'active', + failureReason: null, + retryAfter: null, + failureCount: 0, + issuedByNode: this.distributedLock.getInstanceId(), + } as any, + }) + : await this.prisma.certificate.create({ + data: { + domains: artifact.domains, + domainsHash: artifact.domainsHash, + certPem: artifact.certPem, + ...this.encryptPrivateKeyForCertificate({ + keyPem: artifactPrivateKeyPem, + domainsHash: artifact.domainsHash, + }), + expiresAt: artifact.expiresAt, + issuedAt: artifact.issuedAt, + lastUsedAt: now, + isOrphaned: false, + status: 'active', + issuedByNode: this.distributedLock.getInstanceId(), + }, + }); + + await this.prisma.certificateArtifactVersion.updateMany({ + where: { + domainsHash: artifact.domainsHash, + id: { not: artifact.id }, + }, + data: { + isCurrent: false, + }, + }); + + await this.prisma.certificateArtifactVersion.update({ + where: { id: artifact.id }, + data: { + certificateId: certificateRecord.id, + activatedAt: now, + isCurrent: true, + distributionStatus: settledOperation.status, + distributionCompletedAt: completedAt, + metadata: toPrismaNullableJsonValue( + this.mergeMetadata(artifact.metadata, { + latestDistribution: { + operationId: operation.operationId, + action: params.action, + previousArtifactId: currentArtifact?.id ?? null, + completedAt: completedAt.toISOString(), + status: settledOperation.status, + }, + }), + ), + }, + }); + + await this.certificateOrders.completeWithCertificate(params.orderId, { + certificateId: certificateRecord.id, + message: + params.action === 'rollback' + ? `Rolled back certificate activation to artifact version ${artifact.version} after cluster ACKs` + : `Certificate artifact version ${artifact.version} activated across the cluster after node ACKs`, + details: { + artifactId: artifact.id, + artifactVersion: artifact.version, + action: params.action, + operationId: operation.operationId, + previousArtifactId: currentArtifact?.id ?? null, + }, + }); + + return { + certificate: certificateRecord, + operationId: operation.operationId, + artifactId: artifact.id, + artifactVersion: artifact.version, + }; + } + + async ensureCertificate( + domainsInput: string[] | string, + options: { + orderId?: string; + sourceType?: CertificateOrderSourceType; + } = {}, + ): Promise<void> { if (process.env.NODE_ENV === 'development') { this.logger.log( '[Dev] Skipping certificate issuance in development mode.', ); return; } + + const sourceType = options.sourceType ?? 'acme'; const domains = Array.isArray(domainsInput) - ? domainsInput - : parseDomains(domainsInput); + ? normalizeDomains(domainsInput as string[], { allowWildcard: true }) + : parseDomains(domainsInput as string, { allowWildcard: true }); const primaryDomain = domains[0]; - const hash = hashDomains(domains); + const hash = hashDomains(domains, { allowWildcard: true }); const domainsHash = hash; - const domainsStr = joinDomains(domains); + const domainsStr = joinDomains(domains, { allowWildcard: true }); const now = new Date(); const renewBefore = addDays(now, RENEW_BEFORE_DAYS); + const instanceId = this.distributedLock.getInstanceId(); + const acmeStrategy = resolveAcmeStrategy(domains); + + const order = await this.certificateOrders.getOrCreateOrder({ + domains, + sourceType, + requestedByNode: instanceId, + existingOrderId: options.orderId, + metadata: { + flow: 'certificate.ensure', + renewBeforeDays: RENEW_BEFORE_DAYS, + acme: acmeStrategy, + }, + }); this.logger.log( `[Cert] Ensuring certificate for [${domainsStr}] (hash: ${hash})`, @@ -784,10 +1240,11 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown { }); if (certEntry) { + const decryptedKeyPem = this.decryptStoredCertificateKey(certEntry); this.logger.log( `[DB] Found valid certificate (id: ${certEntry.id}, expires: ${certEntry.expiresAt.toISOString()}). Writing to FS.`, ); - this.writeCertToFs(primaryDomain, certEntry.certPem, certEntry.keyPem); + this.writeCertToFs(primaryDomain, certEntry.certPem, decryptedKeyPem); await this.prisma.certificate.update({ where: { id: certEntry.id }, data: { lastUsedAt: now }, @@ -795,6 +1252,14 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown { this.logger.log( `[DB] Updated lastUsedAt for certificate id: ${certEntry.id}`, ); + await this.certificateOrders.completeWithCertificate(order.id, { + certificateId: certEntry.id, + message: 'Reused existing valid certificate from the database', + details: { + reusedExistingCertificate: true, + expiresAt: certEntry.expiresAt.toISOString(), + }, + }); return; } @@ -803,6 +1268,14 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown { if (!rateLimitOk) { const errorMsg = `Rate limit check failed for ${primaryDomain}. Too many certificates issued recently.`; this.logger.error(`[RateLimit] ${errorMsg}`); + const retryAfter = new Date( + Date.now() + this.calculateRetryDelayMs(order.attemptCount), + ); + + await this.certificateOrders.markFailure(order.id, errorMsg, retryAfter, { + rateLimited: true, + sourceType, + }); await this.alertService .sendAlert({ @@ -812,8 +1285,9 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown { metadata: { domain: primaryDomain, allDomains: domainsStr, - instanceId: this.distributedLock.getInstanceId(), + instanceId, timestamp: new Date().toISOString(), + retryAfter: retryAfter.toISOString(), }, }) .catch((alertErr) => @@ -848,93 +1322,79 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown { }); if (recheck) { + const decryptedKeyPem = this.decryptStoredCertificateKey(recheck); this.logger.log( `[Lock] Certificate was created by another node (id: ${recheck.id}). Using it.`, ); - this.writeCertToFs(primaryDomain, recheck.certPem, recheck.keyPem); + this.writeCertToFs(primaryDomain, recheck.certPem, decryptedKeyPem); await this.prisma.certificate.update({ where: { id: recheck.id }, data: { lastUsedAt: now }, }); + await this.certificateOrders.completeWithCertificate(order.id, { + certificateId: recheck.id, + message: + 'Another node completed the order before the local issuance lock executed', + details: { + reusedExistingCertificate: true, + lockName, + }, + }); return recheck; } - // Still need to issue - proceed with certbot + // Still need to issue - proceed with ACME issuance this.logger.log( - `[Certbot] No valid certificate in DB. Running certbot for: [${domainsStr}]`, + `[ACME] No valid certificate in DB. Starting certificate issuance for: [${domainsStr}]`, ); - const domainArgs = domains.map((d) => `-d ${d}`).join(' '); try { - // Use shell script hooks that directly interact with PostgreSQL - // These scripts are copied into the container at /certbot-auth-hook.sh and /certbot-cleanup-hook.sh - const authHookPath = '/certbot-auth-hook.sh'; - const cleanupHookPath = '/certbot-cleanup-hook.sh'; - - // Parse DATABASE_URL to get connection details for psql in the hooks - const dbUrl = process.env.DATABASE_URL || ''; - const dbMatch = dbUrl.match( - /postgresql:\/\/([^:]+):([^@]+)@([^:]+):(\d+)\/([^?]+)/, + await this.certificateOrders.transitionOrder( + order.id, + 'challenge-published', + { + message: + 'Starting ACME HTTP-01 challenge publication and certificate issuance', + details: { + lockName, + sourceType, + acme: acmeStrategy, + }, + data: { + metadata: this.mergeMetadata(order.metadata, { + acme: acmeStrategy, + }), + }, + }, ); - if (!dbMatch) { - const errorMsg = - 'Could not parse DATABASE_URL for certbot hooks. Expected format: postgresql://user:pass@host:port/dbname'; - this.logger.error(`[Certbot] ${errorMsg}`); - - // Send alert for configuration error - await this.alertService - .sendAlert({ - type: 'error', - title: 'Certificate Issuance Configuration Error', - message: errorMsg, - metadata: { - domains: domainsStr, - instanceId: this.distributedLock.getInstanceId(), - timestamp: new Date().toISOString(), - }, - }) - .catch((alertErr) => - this.logger.error( - `[Certbot] Failed to send alert: ${alertErr.message}`, - ), - ); - - throw new Error(errorMsg); + if (!this.acmeService) { + throw new Error('ACME service is not available for certificate issuance'); } - const [, dbUser, dbPassword, dbHost, dbPort, dbName] = dbMatch; - this.logger.log( - `[Certbot] Running command: certbot certonly --manual --preferred-challenges=http --manual-auth-hook=${authHookPath} --manual-cleanup-hook=${cleanupHookPath} --non-interactive --agree-tos -m ${adminEmail} ${domainArgs}`, + `[ACME] Running ${acmeStrategy.challengeType} certificate flow for ${primaryDomain} via provider ${acmeStrategy.provider}`, ); - // Use manual mode with shell hooks so all nodes can serve challenges from database - await exec( - `DB_USER=${dbUser} DB_PASSWORD=${dbPassword} DB_HOST=${dbHost} DB_PORT=${dbPort} DB_NAME=${dbName} certbot certonly --manual --preferred-challenges=http --manual-auth-hook=${authHookPath} --manual-cleanup-hook=${cleanupHookPath} --non-interactive --agree-tos -m ${adminEmail} ${domainArgs}`, - ); + const issuedCertificate = await this.acmeService.obtainCertificate({ + orderId: order.id, + domains, + instanceId, + adminEmail, + }); this.logger.log( - `[Certbot] Successfully obtained/renewed certificate for ${primaryDomain}`, + `[ACME] Successfully obtained/renewed certificate for ${primaryDomain}`, ); - // Read the cert/key files produced by certbot - const certPath = path.join( - this.certDir(primaryDomain), - 'fullchain.pem', - ); - const keyPath = path.join(this.certDir(primaryDomain), 'privkey.pem'); - - // Verify files exist - if (!fs.existsSync(certPath) || !fs.existsSync(keyPath)) { - throw new Error( - `Certificate files not found after certbot execution. Cert: ${fs.existsSync(certPath)}, Key: ${fs.existsSync(keyPath)}`, - ); - } + await this.certificateOrders.transitionOrder(order.id, 'validating', { + message: + 'ACME order completed; validating generated certificate artifacts', + details: { + acme: issuedCertificate.metadata.acme, + }, + }); - this.logger.log(`[FS] Reading certificate file: ${certPath}`); - const certPem = fs.readFileSync(certPath, 'utf8'); - this.logger.log(`[FS] Reading key file: ${keyPath}`); - const keyPem = fs.readFileSync(keyPath, 'utf8'); + const { certPem, keyPem } = issuedCertificate; // Defensive checks: ensure we actually read PEM content before proceeding this.logger.debug( @@ -952,11 +1412,11 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown { if (!certLooksValid || !keyLooksValid) { this.logger.error( - `[DB] Invalid or missing PEM data after certbot for ${primaryDomain} — certLooksValid=${certLooksValid}, keyLooksValid=${keyLooksValid}`, + `[DB] Invalid or missing PEM data after ACME issuance for ${primaryDomain} — certLooksValid=${certLooksValid}, keyLooksValid=${keyLooksValid}`, ); // Throw to trigger the existing failure path (which records failure & schedules retry) throw new Error( - 'Certificate or private key missing/invalid after certbot execution', + 'Certificate or private key missing/invalid after ACME execution', ); } @@ -998,61 +1458,76 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown { `[DB] Certificate validated; expiresAt=${validation.expiresAt}`, ); - this.logger.log(`[DB] Saving certificate to database...`); - const certRecord = await this.prisma.certificate.upsert({ - where: { domainsHash } as any, - update: { - certPem, - keyPem, - expiresAt: validation.expiresAt, - issuedAt: new Date(), - lastUsedAt: now, - isOrphaned: false, - status: 'active', - failureReason: null, - retryAfter: null, - failureCount: 0, - issuedByNode: this.distributedLock.getInstanceId(), - } as any, - create: { - domains: domainsStr, - domainsHash, - certPem, - keyPem, - expiresAt: validation.expiresAt, - issuedAt: new Date(), - lastUsedAt: now, - isOrphaned: false, - status: 'active', - issuedByNode: this.distributedLock.getInstanceId(), - } as any, + await this.certificateOrders.transitionOrder(order.id, 'issued', { + message: + 'Validated certificate and private key produced by ACME flow', + details: { + expiresAt: validation.expiresAt?.toISOString() ?? null, + acme: issuedCertificate.metadata.acme, + }, + }); + + const artifact = await this.certificateOrders.recordArtifact({ + orderId: order.id, + certificateId: null, + domains, + sourceType, + certPem, + keyPem, + issuedAt: issuedCertificate.issuedAt, + expiresAt: + validation.expiresAt ?? issuedCertificate.expiresAt ?? addDays(new Date(), 90), + activatedAt: null, + createdByNode: instanceId, + metadata: { + activation: 'pending-cluster-rollout', + ...issuedCertificate.metadata, + }, }); - this.logger.log( - `[DB] Upsert complete for domainsHash=${domainsHash} id=${certRecord.id}`, - ); - this.logger.log( - `[DB] Certificate saved (id: ${certRecord.id}, expires: ${certRecord.expiresAt.toISOString()}). Writing to FS...`, - ); - this.writeCertToFs(primaryDomain, certPem, keyPem); - return certRecord; + const activation = await this.activateArtifactAcrossCluster({ + artifactId: artifact.id, + orderId: order.id, + action: 'activate', + }); + + return activation.certificate; } catch (err) { // Persist failure with backoff const message = err instanceof Error ? err.message : String(err); const stack = err instanceof Error ? err.stack : undefined; - const failureCountInc = 1; - const nextRetryMs = Math.min( - 1000 * 60 * 60 * 24, - 1000 * 60 * 2 ** Math.min(5, failureCountInc) + - Math.floor(Math.random() * 30000), - ); - const retryAfter = new Date(Date.now() + nextRetryMs); + + if (err instanceof ArtifactActivationError) { + await this.alertService + .sendAlert({ + type: 'error', + title: 'Certificate Activation Failed', + message: `Certificate issuance completed but cluster activation failed for ${domainsStr}: ${message}`, + metadata: { + domains: domainsStr, + error: message, + stack, + instanceId, + }, + }) + .catch((alertErr) => + this.logger.error( + `[ACME] Failed to send alert: ${alertErr.message}`, + ), + ); + + throw err; + } // Avoid overwriting existing certPem/keyPem with empty strings. // If a certificate record already exists for this domainsHash, update failure fields only. const existingCert = await this.prisma.certificate.findUnique({ where: { domainsHash }, } as any); + const nextFailureCount = (existingCert?.failureCount ?? 0) + 1; + const retryAfter = new Date( + Date.now() + this.calculateRetryDelayMs(order.attemptCount), + ); if (existingCert) { await this.prisma.certificate.update({ @@ -1062,7 +1537,7 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown { failureReason: message, retryAfter, failureCount: { increment: 1 }, - issuedByNode: this.distributedLock.getInstanceId(), + issuedByNode: instanceId, } as any, }); } else { @@ -1081,11 +1556,23 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown { failureReason: message, retryAfter, failureCount: 1, - issuedByNode: this.distributedLock.getInstanceId(), + issuedByNode: instanceId, }, }); } + await this.certificateOrders.markFailure( + order.id, + message, + retryAfter, + { + failureCount: nextFailureCount, + lockName, + sourceType, + stack, + }, + ); + await this.alertService .sendAlert({ type: 'error', @@ -1095,13 +1582,13 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown { domains: domainsStr, error: message, stack, - instanceId: this.distributedLock.getInstanceId(), + instanceId, retryAfter: retryAfter.toISOString(), }, }) .catch((alertErr) => this.logger.error( - `[Certbot] Failed to send alert: ${alertErr.message}`, + `[ACME] Failed to send alert: ${alertErr.message}`, ), ); @@ -1130,15 +1617,33 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown { }); if (certEntry) { + const decryptedKeyPem = this.decryptStoredCertificateKey(certEntry); this.logger.log( `[DB] Found certificate created by another node (id: ${certEntry.id}). Using it.`, ); - this.writeCertToFs(primaryDomain, certEntry.certPem, certEntry.keyPem); + this.writeCertToFs(primaryDomain, certEntry.certPem, decryptedKeyPem); + await this.certificateOrders.completeWithCertificate(order.id, { + certificateId: certEntry.id, + message: + 'Used certificate created by another node after local lock contention', + details: { + lockName, + reusedExistingCertificate: true, + }, + }); return; } const errorMsg = `Failed to acquire lock for certificate issuance and no certificate found in DB`; this.logger.error(`[Lock] ${errorMsg}`); + const retryAfter = new Date( + Date.now() + this.calculateRetryDelayMs(order.attemptCount), + ); + + await this.certificateOrders.markFailure(order.id, errorMsg, retryAfter, { + lockName, + sourceType, + }); // Send alert for lock acquisition failure await this.alertService @@ -1149,8 +1654,9 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown { metadata: { domains: domainsStr, lockName, - instanceId: this.distributedLock.getInstanceId(), + instanceId, timestamp: new Date().toISOString(), + retryAfter: retryAfter.toISOString(), }, }) .catch((alertErr) => @@ -1175,8 +1681,14 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown { }, }); const domainGroups = Array.from( - new Set(entries.map((e) => joinDomains(parseDomains(e.domains)))), - ).map((group) => parseDomains(group)); + new Set<string>( + entries.map((e) => + joinDomains(parseDomains(e.domains, { allowWildcard: true }), { + allowWildcard: true, + }), + ), + ), + ).map((group) => parseDomains(group, { allowWildcard: true })); this.logger.log( `[Renewal] Found ${domainGroups.length} unique domain group(s) to renew.`, @@ -1185,34 +1697,35 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown { for (const domains of domainGroups) { try { this.logger.log( - `[Renewal] Ensuring certificate for group: [${joinDomains(domains)}]`, + `[Renewal] Ensuring certificate for group: [${joinDomains(domains, { allowWildcard: true })}]`, ); await this.ensureCertificate(domains); } catch (err) { this.logger.error( - `[Renewal] Error ensuring certificate for domains [${joinDomains(domains)}]: ${err instanceof Error ? err.stack : String(err)}`, + `[Renewal] Error ensuring certificate for domains [${joinDomains(domains, { allowWildcard: true })}]: ${err instanceof Error ? err.stack : String(err)}`, ); } } - // Backoff-aware: also retry failed certs whose retryAfter has passed - const failedCerts = await this.prisma.certificate.findMany({ + // Backoff-aware: retry failed ACME orders whose retry window has opened. + const retryableOrders = await this.prisma.certificateOrder.findMany({ where: { - retryAfter: { lte: retryableAfter }, - isOrphaned: false, + sourceType: 'acme', + status: 'failed', + nextRetryAt: { lte: retryableAfter }, }, - } as any); + orderBy: { nextRetryAt: 'asc' }, + }); - for (const cert of failedCerts) { - const domains = parseDomains(cert.domains); + for (const order of retryableOrders) { try { this.logger.log( - `[Renewal] Retrying failed certificate for [${cert.domains}] (failureCount=${(cert as any).failureCount ?? 0})`, + `[Renewal] Retrying failed certificate order ${order.id} for [${order.domains}] (attempt=${order.attemptCount})`, ); - await this.ensureCertificate(domains); + await this.retryCertificateOrder(order.id); } catch (err) { this.logger.error( - `[Renewal] Retry failed for [${cert.domains}]: ${err instanceof Error ? err.message : String(err)}`, + `[Renewal] Retry failed for order ${order.id} [${order.domains}]: ${err instanceof Error ? err.message : String(err)}`, ); } } @@ -1220,7 +1733,7 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown { // After possible renewals, reload nginx try { this.logger.log('[Renewal] Reloading nginx after cert renewal check...'); - await exec('nginx -s reload'); + await runCommand('nginx', ['-s', 'reload']); this.logger.log('[Renewal] nginx reloaded after cert renewal.'); // Also trigger cluster reload to be safe @@ -1246,57 +1759,81 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown { keyPem: string; chainPem?: string; }) { + const domains = normalizeDomains(dto.domains, { allowWildcard: true }); + const instanceId = this.distributedLock.getInstanceId(); + const order = await this.certificateOrders.getOrCreateOrder({ + domains, + sourceType: 'uploaded', + requestedByNode: instanceId, + metadata: { + flow: 'certificate.upload', + }, + }); this.logger.log( - `[Upload] Uploading custom certificate for domains: [${joinDomains(dto.domains)}]`, + `[Upload] Uploading custom certificate for domains: [${joinDomains(domains, { allowWildcard: true })}]`, ); - const hash = hashDomains(dto.domains); - const domainsStr = joinDomains(dto.domains); - const primaryDomain = dto.domains[0]; // Validate certificate and key match await this.validateCertificateKeyPair(dto.certPem, dto.keyPem); // Extract expiry from cert - const certFile = `/tmp/cert-${Date.now()}.pem`; + const tempDir = this.createTempDirectory('lyttlenginx-upload-cert-'); + const certFile = path.join(tempDir, 'cert.pem'); fs.writeFileSync(certFile, dto.certPem, 'utf8'); try { - const { stdout } = await exec( - `openssl x509 -enddate -noout -in ${certFile}`, - ); - const match = stdout.match(/notAfter=(.*)/); - const expiresAt = match ? new Date(match[1]) : addDays(new Date(), 365); + const expiresAt = await this.getCertificateExpiryFromFile(certFile); // Combine cert with chain if provided const fullChainPem = dto.chainPem ? `${dto.certPem}\n${dto.chainPem}` : dto.certPem; - // Write to filesystem - this.writeCertToFs(primaryDomain, fullChainPem, dto.keyPem); + await this.certificateOrders.transitionOrder(order.id, 'issued', { + message: 'Validated uploaded certificate material', + details: { + expiresAt: expiresAt.toISOString(), + }, + }); - // Save to database - const certRecord = await this.prisma.certificate.create({ - data: { - domains: domainsStr, - domainsHash: hash, - certPem: fullChainPem, - keyPem: dto.keyPem, - expiresAt, - issuedAt: new Date(), - lastUsedAt: new Date(), - isOrphaned: false, + const artifact = await this.certificateOrders.recordArtifact({ + orderId: order.id, + certificateId: null, + domains, + sourceType: 'uploaded', + certPem: fullChainPem, + keyPem: dto.keyPem, + issuedAt: new Date(), + expiresAt, + activatedAt: null, + createdByNode: instanceId, + metadata: { + activation: 'pending-cluster-rollout', }, }); + const activation = await this.activateArtifactAcrossCluster({ + artifactId: artifact.id, + orderId: order.id, + action: 'activate', + }); + this.logger.log( - `[Upload] Successfully uploaded certificate (id: ${certRecord.id})`, + `[Upload] Successfully uploaded certificate (id: ${activation.certificate.id})`, ); - return certRecord; + return activation.certificate; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (!(error instanceof ArtifactActivationError)) { + await this.certificateOrders.markFailure(order.id, message, null, { + sourceType: 'uploaded', + }); + } + throw error; } finally { // Clean up temp file try { - fs.unlinkSync(certFile); - } catch (e) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } catch { // Ignore } } @@ -1305,58 +1842,105 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown { /** * Generate a self-signed certificate for testing/development */ - async generateSelfSignedCertificate(domains: string[]) { + async generateSelfSignedCertificate( + domains: string[], + options: { orderId?: string } = {}, + ) { + const normalizedDomains = normalizeDomains(domains, { + allowWildcard: true, + }); + const instanceId = this.distributedLock.getInstanceId(); + const order = await this.certificateOrders.getOrCreateOrder({ + domains: normalizedDomains, + sourceType: 'self-signed', + requestedByNode: instanceId, + existingOrderId: options.orderId, + metadata: { + flow: 'certificate.generate-self-signed', + }, + }); this.logger.log( - `[Self-Signed] Generating self-signed certificate for domains: [${joinDomains(domains)}]`, + `[Self-Signed] Generating self-signed certificate for domains: [${joinDomains(normalizedDomains, { allowWildcard: true })}]`, ); - const hash = hashDomains(domains); - const domainsStr = joinDomains(domains); - const primaryDomain = domains[0]; + const primaryDomain = normalizedDomains[0]; // Generate self-signed certificate using openssl - const keyFile = `/tmp/key-${Date.now()}.pem`; - const certFile = `/tmp/cert-${Date.now()}.pem`; + const tempDir = this.createTempDirectory('lyttlenginx-self-signed-'); + const keyFile = path.join(tempDir, 'key.pem'); + const certFile = path.join(tempDir, 'cert.pem'); try { // Generate private key - await exec(`openssl genrsa -out ${keyFile} 2048`); + await runCommand('openssl', ['genrsa', '-out', keyFile, '2048']); // Generate certificate - const sanList = domains.map((d) => `DNS:${d}`).join(','); - await exec( - `openssl req -new -x509 -key ${keyFile} -out ${certFile} -days 365 -subj "/CN=${primaryDomain}" -addext "subjectAltName=${sanList}"`, - ); + const sanList = normalizedDomains.map((d) => `DNS:${d}`).join(','); + await runCommand('openssl', [ + 'req', + '-new', + '-x509', + '-key', + keyFile, + '-out', + certFile, + '-days', + '365', + '-subj', + `/CN=${primaryDomain}`, + '-addext', + `subjectAltName=${sanList}`, + ]); const certPem = fs.readFileSync(certFile, 'utf8'); const keyPem = fs.readFileSync(keyFile, 'utf8'); - // Write to filesystem - this.writeCertToFs(primaryDomain, certPem, keyPem); + await this.certificateOrders.transitionOrder(order.id, 'issued', { + message: 'Generated self-signed certificate material successfully', + details: { + primaryDomain, + }, + }); - // Save to database - const certRecord = await this.prisma.certificate.create({ - data: { - domains: domainsStr, - domainsHash: hash, - certPem, - keyPem, - expiresAt: addDays(new Date(), 365), - issuedAt: new Date(), - lastUsedAt: new Date(), - isOrphaned: false, + + const artifact = await this.certificateOrders.recordArtifact({ + orderId: order.id, + certificateId: null, + domains: normalizedDomains, + sourceType: 'self-signed', + certPem, + keyPem, + issuedAt: new Date(), + expiresAt: addDays(new Date(), 365), + activatedAt: null, + createdByNode: instanceId, + metadata: { + activation: 'pending-cluster-rollout', }, }); + const activation = await this.activateArtifactAcrossCluster({ + artifactId: artifact.id, + orderId: order.id, + action: 'activate', + }); + this.logger.log( - `[Self-Signed] Successfully generated certificate (id: ${certRecord.id})`, + `[Self-Signed] Successfully generated certificate (id: ${activation.certificate.id})`, ); - return certRecord; + return activation.certificate; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (!(error instanceof ArtifactActivationError)) { + await this.certificateOrders.markFailure(order.id, message, null, { + sourceType: 'self-signed', + }); + } + throw error; } finally { // Clean up temp files try { - fs.unlinkSync(keyFile); - fs.unlinkSync(certFile); - } catch (e) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } catch { // Ignore } } @@ -1386,7 +1970,7 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown { return { id: cert.id, - domains: parseDomains(cert.domains), + domains: parseDomains(cert.domains, { allowWildcard: true }), expiresAt: cert.expiresAt, issuedAt: cert.issuedAt, lastUsedAt: cert.lastUsedAt, @@ -1424,7 +2008,7 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown { return { id: cert.id, - domains: parseDomains(cert.domains), + domains: parseDomains(cert.domains, { allowWildcard: true }), expiresAt: cert.expiresAt, issuedAt: cert.issuedAt, lastUsedAt: cert.lastUsedAt, @@ -1437,6 +2021,63 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown { }; } + async listCertificateOrders(limit?: number) { + return this.certificateOrders.listOrders(limit); + } + + async listAcmeChallenges(options: { status?: string; limit?: number } = {}) { + return this.acmeService + ? this.acmeService.listChallenges(options) + : { count: 0, challenges: [] }; + } + + async getCertificateOrder(orderId: string) { + return this.certificateOrders.getOrder(orderId); + } + + async retryCertificateOrder(orderId: string) { + const order = await this.certificateOrders.validateRetryableOrder(orderId); + const latestArtifact = await this.certificateOrders.getLatestArtifactForOrder( + orderId, + ); + + if (order.sourceType === 'uploaded' || order.sourceType === 'imported') { + if (!latestArtifact) { + throw new Error( + `Certificate order ${orderId} cannot be retried automatically for source type ${order.sourceType}`, + ); + } + } + + await this.certificateOrders.resumeOrder(orderId, { + reason: 'Manual operator retry requested', + force: true, + }); + + if (latestArtifact) { + await this.activateArtifactAcrossCluster({ + artifactId: latestArtifact.id, + orderId, + action: 'activate', + }); + + return this.certificateOrders.getOrder(orderId); + } + + const domains = parseDomains(order.domains, { allowWildcard: true }); + + if (order.sourceType === 'self-signed') { + await this.generateSelfSignedCertificate(domains, { orderId }); + } else { + await this.ensureCertificate(domains, { + orderId, + sourceType: 'acme', + }); + } + + return this.certificateOrders.getOrder(orderId); + } + /** * Analyze a certificate to detect OCSP support and type */ @@ -1447,46 +2088,59 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown { }> { try { // Write cert to temp file for analysis - const tempFile = `/tmp/cert-${Date.now()}.pem`; + const tempDir = this.createTempDirectory('lyttlenginx-cert-analysis-'); + const tempFile = path.join(tempDir, 'cert.pem'); fs.writeFileSync(tempFile, certPem); // Check for OCSP URI let ocspUri = ''; try { - const { stdout } = await exec( - `openssl x509 -in ${tempFile} -noout -ocsp_uri`, - ); + const { stdout } = await runCommand('openssl', [ + 'x509', + '-in', + tempFile, + '-noout', + '-ocsp_uri', + ]); ocspUri = stdout.trim(); - } catch (err) { + } catch { // No OCSP URI } // Check issuer let issuer = 'Unknown'; try { - const { stdout } = await exec( - `openssl x509 -in ${tempFile} -noout -issuer`, - ); + const { stdout } = await runCommand('openssl', [ + 'x509', + '-in', + tempFile, + '-noout', + '-issuer', + ]); issuer = stdout.replace('issuer=', '').trim(); - } catch (err) { + } catch { // Could not get issuer } // Check subject let subject = ''; try { - const { stdout } = await exec( - `openssl x509 -in ${tempFile} -noout -subject`, - ); + const { stdout } = await runCommand('openssl', [ + 'x509', + '-in', + tempFile, + '-noout', + '-subject', + ]); subject = stdout.replace('subject=', '').trim(); - } catch (err) { + } catch { // Could not get subject } // Clean up temp file try { - fs.unlinkSync(tempFile); - } catch (err) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } catch { // Ignore cleanup errors } @@ -1533,12 +2187,72 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown { throw new Error(`Certificate not found: ${id}`); } - const domains = parseDomains(cert.domains); + const domains = parseDomains(cert.domains, { allowWildcard: true }); await this.ensureCertificate(domains); return { message: `Certificate renewal initiated for ${cert.domains}` }; } + async activateCertificateArtifact( + artifactId: string, + operationId?: string, + ) { + return this.activateArtifactLocally(artifactId, operationId); + } + + async rollbackCertificate(id: string) { + const certificate = await this.prisma.certificate.findUnique({ where: { id } }); + + if (!certificate) { + throw new Error(`Certificate not found: ${id}`); + } + + const currentArtifact = await this.certificateOrders.getCurrentArtifactForDomainsHash( + certificate.domainsHash, + ); + + if (!currentArtifact) { + throw new Error( + `No current certificate artifact is recorded for certificate ${id}`, + ); + } + + const rollbackArtifact = + await this.certificateOrders.getRollbackArtifactForDomainsHash( + certificate.domainsHash, + currentArtifact.version, + ); + + if (!rollbackArtifact) { + throw new Error( + `No prior activated artifact version is available to roll back certificate ${id}`, + ); + } + + const rollbackOrderId = rollbackArtifact.orderId ?? currentArtifact.orderId; + + if (!rollbackOrderId) { + throw new Error( + `Rollback artifact ${rollbackArtifact.id} is missing its source order linkage`, + ); + } + + const activation = await this.activateArtifactAcrossCluster({ + artifactId: rollbackArtifact.id, + orderId: rollbackOrderId, + action: 'rollback', + }); + + return { + ...activation.certificate, + orderId: rollbackOrderId, + rollbackFromArtifactId: currentArtifact.id, + rollbackToArtifactId: rollbackArtifact.id, + rollbackToVersion: rollbackArtifact.version, + operationId: activation.operationId, + }; + } + /** * Delete a certificate */ @@ -1550,7 +2264,9 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown { } // Delete from filesystem - const primaryDomain = parseDomains(cert.domains)[0]; + const primaryDomain = parseDomains(cert.domains, { + allowWildcard: true, + })[0]; const dir = this.certDir(primaryDomain); try { if (fs.existsSync(dir)) { @@ -1573,16 +2289,21 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown { * Validate domain ownership for certificate issuance */ async validateDomainForCertificate(domain: string) { - this.logger.log(`[Validate] Validating domain: ${domain}`); + const normalizedDomain = normalizeDomain(domain); + this.logger.log(`[Validate] Validating domain: ${normalizedDomain}`); // This is a placeholder - in production, you'd check DNS, HTTP challenges, etc. // For now, just check if domain resolves const { lookup } = await import('dns/promises'); try { - await lookup(domain); - return { domain, valid: true, message: 'Domain resolves successfully' }; + await lookup(normalizedDomain); + return { + domain: normalizedDomain, + valid: true, + message: 'Domain resolves successfully', + }; } catch (err) { return { - domain, + domain: normalizedDomain, valid: false, message: `Domain does not resolve: ${err instanceof Error ? err.message : String(err)}`, }; @@ -1596,23 +2317,18 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown { certPem: string, keyPem: string, ): Promise<void> { - const certFile = `/tmp/cert-validate-${Date.now()}.pem`; - const keyFile = `/tmp/key-validate-${Date.now()}.pem`; + const tempDir = this.createTempDirectory('lyttlenginx-keypair-validation-'); + const certFile = path.join(tempDir, 'cert.pem'); + const keyFile = path.join(tempDir, 'key.pem'); try { fs.writeFileSync(certFile, certPem, 'utf8'); fs.writeFileSync(keyFile, keyPem, 'utf8'); - // Get modulus from cert - const { stdout: certModulus } = await exec( - `openssl x509 -noout -modulus -in ${certFile}`, - ); - // Get modulus from key - const { stdout: keyModulus } = await exec( - `openssl rsa -noout -modulus -in ${keyFile}`, - ); + const certPublicKey = await this.getCertificatePublicKey(certFile); + const keyPublicKey = await this.getPrivateKeyPublicKey(keyFile); - if (certModulus.trim() !== keyModulus.trim()) { + if (certPublicKey !== keyPublicKey) { throw new Error('Certificate and private key do not match'); } @@ -1620,9 +2336,8 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown { } finally { // Clean up temp files try { - fs.unlinkSync(certFile); - fs.unlinkSync(keyFile); - } catch (e) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } catch { // Ignore } } @@ -1643,11 +2358,11 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown { const results = await Promise.all( certs.map(async (cert) => { const analysis = await this.analyzeCertificate(cert.certPem); - const domains = parseDomains(cert.domains); + const domains = parseDomains(cert.domains, { allowWildcard: true }); return { id: cert.id, - domains: parseDomains(cert.domains), + domains: parseDomains(cert.domains, { allowWildcard: true }), primaryDomain: domains[0], hasOcsp: analysis.hasOcsp, certificateType: analysis.type, @@ -1689,73 +2404,34 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown { */ private async triggerClusterReload() { try { - if (!this.clusterHeartbeat) { - this.logger.warn( - '[Reload] ClusterHeartbeatService not available, skipping broadcast', - ); - return; - } - - const nodes = await this.clusterHeartbeat.getActiveNodes(); - const thisInstanceId = this.distributedLock.getInstanceId(); - - const otherNodes = nodes.filter( - (n) => n.instanceId !== thisInstanceId && n.ipAddress, - ); - - if (otherNodes.length === 0) { - this.logger.log('[Reload] No other nodes to notify'); - return; - } - - this.logger.log( - `[Reload] Broadcasting certificate sync to ${otherNodes.length} other nodes...`, - ); - - // Trigger sync on other nodes using the dedicated sync endpoint - // Fire and forget - don't wait for responses - const port = process.env.PORT || 3000; - - await Promise.allSettled( - otherNodes.map(async (node) => { - try { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 5000); - - // Call the sync endpoint on the other node - const url = `http://${node.ipAddress}:${port}/certificates/sync`; - - const response = await fetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - signal: controller.signal, - }); - - clearTimeout(timeoutId); + const operation = await this.clusterOperations.enqueueBroadcastOperation({ + operationType: 'certificate.sync', + broadcast: true, + remotePath: '/certificates/sync', + initiatedBy: { + requestPath: '/certificates/sync', + }, + metadata: { + source: 'certificate-renewal', + }, + localAction: async () => { + const result = await this.syncCertificates(); - if (response.ok) { - const result = await response.json(); - this.logger.debug( - `[Reload] Synced ${result.syncedCount || 0} cert(s) on ${node.hostname} (${node.ipAddress})`, - ); - } else { - this.logger.warn( - `[Reload] Sync failed on ${node.hostname}: ${response.statusText}`, - ); - } - } catch (e) { - this.logger.debug( - `[Reload] Failed to notify ${node.hostname}: ${e instanceof Error ? e.message : String(e)}`, + if (!result.success) { + const errorSummary = result.errors + .map((entry) => `${entry.domain}: ${entry.error}`) + .join('; '); + throw new Error( + errorSummary || 'Certificate synchronization failed', ); } - }), - ); - // Also trigger immediate sync on this node - this.syncCertificates().catch((err) => - this.logger.error(`[Reload] Local sync failed: ${err.message}`), + return result; + }, + }); + + this.logger.log( + `[Reload] Queued certificate sync cluster operation ${operation.operationId}`, ); } catch (error) { this.logger.warn( diff --git a/src/certificate/dto/acme-challenge.dto.ts b/src/certificate/dto/acme-challenge.dto.ts new file mode 100644 index 0000000..cf087aa --- /dev/null +++ b/src/certificate/dto/acme-challenge.dto.ts @@ -0,0 +1,17 @@ +export class AcmeChallengeInfoDto { + id: string; + orderId: string | null; + token: string; + domain: string; + challengeType: string; + provider: string | null; + status: string; + presentedAt: Date; + cleanedUpAt: Date | null; + finalizedAt: Date | null; + lastServedAt: Date | null; + expiresAt: Date; + metadata: Record<string, unknown> | null; + createdAt: Date; +} + diff --git a/src/certificate/dto/certificate-order.dto.ts b/src/certificate/dto/certificate-order.dto.ts new file mode 100644 index 0000000..f97ce7f --- /dev/null +++ b/src/certificate/dto/certificate-order.dto.ts @@ -0,0 +1,86 @@ +import type { + CertificateOrderSourceType, + CertificateOrderStatus, +} from '../certificate-order.constants'; + +export class CertificateOrderArtifactDto { + id: string; + certificateId: string | null; + version: number; + sourceType: CertificateOrderSourceType; + issuedAt: Date; + expiresAt: Date; + activatedAt: Date | null; + isCurrent: boolean; + distributionStatus: string | null; + distributionOperationId: string | null; + distributionCompletedAt: Date | null; + createdByNode: string | null; + createdAt: Date; +} + +export class CertificateOrderDistributionAckDto { + nodeInstanceId: string; + nodeHostname: string | null; + endpointUrl: string | null; + status: string; + responseStatus: number | null; + errorMessage: string | null; + startedAt: Date | null; + ackedAt: Date | null; + details: Record<string, unknown> | null; +} + +export class CertificateOrderDistributionDto { + artifactId: string; + version: number; + status: string; + operationId: string; + completedAt: Date | null; + acknowledgements: CertificateOrderDistributionAckDto[]; +} + +export class CertificateOrderEventDto { + id: string; + eventType: string; + fromStatus: CertificateOrderStatus | null; + toStatus: CertificateOrderStatus | null; + message: string | null; + attemptNumber: number | null; + retryAt: Date | null; + details: Record<string, unknown> | null; + occurredAt: Date; +} + +export class CertificateOrderSummaryDto { + id: string; + domains: string[]; + primaryDomain: string; + sourceType: CertificateOrderSourceType; + status: CertificateOrderStatus; + attemptCount: number; + retryCount: number; + nextRetryAt: Date | null; + lastError: string | null; + certificateId: string | null; + requestedByNode: string | null; + requestedAt: Date; + startedAt: Date | null; + challengePublishedAt: Date | null; + validatingAt: Date | null; + issuedAt: Date | null; + distributingAt: Date | null; + activatedAt: Date | null; + failedAt: Date | null; + revokedAt: Date | null; + completedAt: Date | null; + metadata: Record<string, unknown> | null; + createdAt: Date; + updatedAt: Date; +} + +export class CertificateOrderDetailDto extends CertificateOrderSummaryDto { + events: CertificateOrderEventDto[]; + artifacts: CertificateOrderArtifactDto[]; + latestDistribution: CertificateOrderDistributionDto | null; +} diff --git a/src/certificate/dto/domain-list.decorator.ts b/src/certificate/dto/domain-list.decorator.ts new file mode 100644 index 0000000..ea8a192 --- /dev/null +++ b/src/certificate/dto/domain-list.decorator.ts @@ -0,0 +1,75 @@ +import { Transform } from 'class-transformer'; +import { + registerDecorator, + ValidationArguments, + ValidationOptions, +} from 'class-validator'; +import { + DomainValidationError, + normalizeDomains, + NormalizeDomainOptions, +} from '../../utils/domain-utils'; + +function normalizeOrThrow( + value: unknown, + options: NormalizeDomainOptions, +): void { + if (!Array.isArray(value)) { + throw new DomainValidationError('Domains must be provided as an array'); + } + + normalizeDomains(value, options); +} + +export function NormalizeDomainList( + options: NormalizeDomainOptions = {}, +): PropertyDecorator { + return Transform(({ value }) => { + if (!Array.isArray(value)) { + return value; + } + + return normalizeDomains(value, options); + }); +} + +export function IsDomainList( + options: NormalizeDomainOptions = {}, + validationOptions?: ValidationOptions, +): PropertyDecorator { + return (target: object, propertyName: string | symbol) => { + registerDecorator({ + name: 'IsDomainList', + target: target.constructor, + propertyName: propertyName.toString(), + options: validationOptions, + constraints: [options], + validator: { + validate(value: unknown, args: ValidationArguments) { + try { + normalizeOrThrow( + value, + (args.constraints[0] as NormalizeDomainOptions | undefined) ?? {}, + ); + return true; + } catch { + return false; + } + }, + defaultMessage(args: ValidationArguments) { + try { + normalizeOrThrow( + args.value, + (args.constraints[0] as NormalizeDomainOptions | undefined) ?? {}, + ); + return `${args.property} contains invalid domain values`; + } catch (error) { + return error instanceof Error + ? error.message + : `${args.property} contains invalid domain values`; + } + }, + }, + }); + }; +} diff --git a/src/certificate/dto/generate-self-signed.dto.ts b/src/certificate/dto/generate-self-signed.dto.ts index a0fd374..6674e85 100644 --- a/src/certificate/dto/generate-self-signed.dto.ts +++ b/src/certificate/dto/generate-self-signed.dto.ts @@ -1,8 +1,13 @@ -import { IsArray, IsString, MinLength } from 'class-validator'; +import { IsArray, IsString } from 'class-validator'; +import { IsDomainList, NormalizeDomainList } from './domain-list.decorator'; export class GenerateSelfSignedDto { @IsArray() @IsString({ each: true }) - @MinLength(1, { each: true, message: 'Domain names cannot be empty' }) + @NormalizeDomainList({ allowWildcard: true }) + @IsDomainList( + { allowWildcard: true }, + { message: 'Each domain must be a valid FQDN or wildcard FQDN' }, + ) domains: string[]; } diff --git a/src/certificate/dto/import-certificates.dto.ts b/src/certificate/dto/import-certificates.dto.ts new file mode 100644 index 0000000..21e8535 --- /dev/null +++ b/src/certificate/dto/import-certificates.dto.ts @@ -0,0 +1,49 @@ +import { + ArrayMinSize, + IsArray, + IsISO8601, + IsString, + Matches, + ValidateNested, +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { IsDomainList, NormalizeDomainList } from './domain-list.decorator'; + +export class ImportCertificateEntryDto { + @IsArray() + @ArrayMinSize(1) + @IsString({ each: true }) + @NormalizeDomainList({ allowWildcard: true }) + @IsDomainList( + { allowWildcard: true }, + { message: 'Each domain must be a valid FQDN or wildcard FQDN' }, + ) + domains: string[]; + + @IsString() + @Matches(/^-----BEGIN CERTIFICATE-----/, { + message: 'Invalid certificate PEM format', + }) + certPem: string; + + @IsString() + @Matches(/^-----BEGIN (RSA |EC )?PRIVATE KEY-----/, { + message: 'Invalid private key PEM format', + }) + keyPem: string; + + @IsISO8601({ strict: true }, { message: 'issuedAt must be an ISO-8601 timestamp' }) + issuedAt: string; + + @IsISO8601({ strict: true }, { message: 'expiresAt must be an ISO-8601 timestamp' }) + expiresAt: string; +} + +export class ImportCertificatesDto { + @IsArray() + @ArrayMinSize(1) + @ValidateNested({ each: true }) + @Type(() => ImportCertificateEntryDto) + certificates: ImportCertificateEntryDto[]; +} + diff --git a/src/certificate/dto/upload-certificate.dto.ts b/src/certificate/dto/upload-certificate.dto.ts index 33b8c7c..e8f2781 100644 --- a/src/certificate/dto/upload-certificate.dto.ts +++ b/src/certificate/dto/upload-certificate.dto.ts @@ -1,15 +1,14 @@ -import { - IsArray, - IsOptional, - IsString, - Matches, - MinLength, -} from 'class-validator'; +import { IsArray, IsOptional, IsString, Matches } from 'class-validator'; +import { IsDomainList, NormalizeDomainList } from './domain-list.decorator'; export class UploadCertificateDto { @IsArray() @IsString({ each: true }) - @MinLength(1, { each: true, message: 'Domain names cannot be empty' }) + @NormalizeDomainList({ allowWildcard: true }) + @IsDomainList( + { allowWildcard: true }, + { message: 'Each domain must be a valid FQDN or wildcard FQDN' }, + ) domains: string[]; // Array of domain names @IsString() diff --git a/src/certificate/private-key-encryption.service.ts b/src/certificate/private-key-encryption.service.ts new file mode 100644 index 0000000..1ef01e1 --- /dev/null +++ b/src/certificate/private-key-encryption.service.ts @@ -0,0 +1,488 @@ +import { Injectable } from '@nestjs/common'; +import * as crypto from 'node:crypto'; +import { PrismaService } from '../prisma/prisma.service'; + +const ENVELOPE_SCHEME = 'lyttle-private-key-envelope/v1'; +const DEVELOPMENT_FALLBACK_MASTER_KEY = + 'lyttle-nginx-development-only-private-key-master-key'; + +type EncryptionContext = { + scope: 'certificate' | 'certificate-artifact'; + domainsHash?: string | null; + version?: number | null; +}; + +type WrappedDataKeyMetadata = { + algorithm: 'aes-256-gcm'; + iv: string; + authTag: string; + aad: string; + ciphertext: string; +}; + +type EncryptedPayloadMetadata = { + algorithm: 'aes-256-gcm'; + iv: string; + authTag: string; + aad: string; +}; + +export type PrivateKeyEncryptionMetadata = { + scheme: typeof ENVELOPE_SCHEME; + secretType: 'tls-private-key'; + encryptedAt: string; + provider: { + type: 'local-master-key'; + keyVersion: string; + keyId: string; + }; + context: Required<EncryptionContext>; + wrappedDataKey: WrappedDataKeyMetadata; + payload: EncryptedPayloadMetadata; +}; + +export type PrivateKeyEncryptionProviderStatus = { + type: 'local-master-key'; + keyVersion: string; + keyId: string; + usesDevelopmentFallback: boolean; +}; + +interface PrivateKeyEncryptionProvider { + readonly type: 'local-master-key'; + readonly keyVersion: string; + readonly keyId: string; + wrapDataKey(dataKey: Buffer): WrappedDataKeyMetadata; + unwrapDataKey(metadata: WrappedDataKeyMetadata): Buffer; +} + +class LocalMasterKeyProvider implements PrivateKeyEncryptionProvider { + readonly type = 'local-master-key' as const; + readonly keyVersion: string; + readonly keyId: string; + private readonly masterKey: Buffer; + + constructor(params: { masterKeyMaterial: string; keyVersion: string }) { + this.keyVersion = params.keyVersion; + this.keyId = `local:${this.keyVersion}`; + this.masterKey = deriveEncryptionKey(params.masterKeyMaterial); + } + + wrapDataKey(dataKey: Buffer): WrappedDataKeyMetadata { + const aad = Buffer.from( + JSON.stringify({ + provider: this.type, + keyVersion: this.keyVersion, + keyId: this.keyId, + }), + 'utf8', + ); + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv('aes-256-gcm', this.masterKey, iv); + cipher.setAAD(aad); + + const ciphertext = Buffer.concat([cipher.update(dataKey), cipher.final()]); + const authTag = cipher.getAuthTag(); + + return { + algorithm: 'aes-256-gcm', + iv: iv.toString('base64'), + authTag: authTag.toString('base64'), + aad: aad.toString('base64'), + ciphertext: ciphertext.toString('base64'), + }; + } + + unwrapDataKey(metadata: WrappedDataKeyMetadata): Buffer { + const decipher = crypto.createDecipheriv( + metadata.algorithm, + this.masterKey, + Buffer.from(metadata.iv, 'base64'), + ); + decipher.setAAD(Buffer.from(metadata.aad, 'base64')); + decipher.setAuthTag(Buffer.from(metadata.authTag, 'base64')); + + return Buffer.concat([ + decipher.update(Buffer.from(metadata.ciphertext, 'base64')), + decipher.final(), + ]); + } +} + +function deriveEncryptionKey(rawSecret: string): Buffer { + const trimmed = rawSecret.trim(); + + if (/^[a-fA-F0-9]{64}$/.test(trimmed)) { + return Buffer.from(trimmed, 'hex'); + } + + const compact = trimmed.replace(/\s+/g, ''); + const decoded = Buffer.from(compact, 'base64'); + if (decoded.length === 32) { + return decoded; + } + + return Buffer.from( + crypto.createHash('sha256').update(trimmed, 'utf8').digest(), + ); +} + +function toStoredContext( + context: EncryptionContext, +): Required<EncryptionContext> { + return { + scope: context.scope, + domainsHash: context.domainsHash ?? null, + version: context.version ?? null, + }; +} + +@Injectable() +export class PrivateKeyEncryptionService { + private readonly provider: PrivateKeyEncryptionProvider; + private readonly usesDevelopmentFallbackMasterKey: boolean; + + constructor(private readonly prisma?: PrismaService) { + const { provider, usesDevelopmentFallbackMasterKey } = + this.createProvider(); + this.provider = provider; + this.usesDevelopmentFallbackMasterKey = usesDevelopmentFallbackMasterKey; + } + + getProviderStatus(): PrivateKeyEncryptionProviderStatus { + return { + type: this.provider.type, + keyVersion: this.provider.keyVersion, + keyId: this.provider.keyId, + usesDevelopmentFallback: this.usesDevelopmentFallbackMasterKey, + }; + } + + encryptPrivateKey( + keyPem: string, + context: EncryptionContext, + ): { keyPem: string; keyEncryption: PrivateKeyEncryptionMetadata | null } { + if (!keyPem.trim()) { + return { + keyPem, + keyEncryption: null, + }; + } + + const storedContext = toStoredContext(context); + const dataKey = crypto.randomBytes(32); + const aad = Buffer.from( + JSON.stringify({ + secretType: 'tls-private-key', + context: storedContext, + }), + 'utf8', + ); + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv('aes-256-gcm', dataKey, iv); + cipher.setAAD(aad); + + const ciphertext = Buffer.concat([ + cipher.update(Buffer.from(keyPem, 'utf8')), + cipher.final(), + ]); + const authTag = cipher.getAuthTag(); + + return { + keyPem: ciphertext.toString('base64'), + keyEncryption: { + scheme: ENVELOPE_SCHEME, + secretType: 'tls-private-key', + encryptedAt: new Date().toISOString(), + provider: { + type: this.provider.type, + keyVersion: this.provider.keyVersion, + keyId: this.provider.keyId, + }, + context: storedContext, + wrappedDataKey: this.provider.wrapDataKey(dataKey), + payload: { + algorithm: 'aes-256-gcm', + iv: iv.toString('base64'), + authTag: authTag.toString('base64'), + aad: aad.toString('base64'), + }, + }, + }; + } + + decryptPrivateKey( + keyPem: string, + metadata: unknown, + expectedContext?: EncryptionContext, + ): string { + if (!keyPem.trim()) { + return keyPem; + } + + if (this.isPlaintextPrivateKey(keyPem)) { + return keyPem; + } + + const envelope = this.parseEncryptionMetadata(metadata); + if (!envelope) { + throw new Error( + 'Encrypted private key material is missing encryption metadata', + ); + } + + if (expectedContext) { + this.assertExpectedContext(envelope.context, expectedContext); + } + + const dataKey = this.provider.unwrapDataKey(envelope.wrappedDataKey); + const decipher = crypto.createDecipheriv( + envelope.payload.algorithm, + dataKey, + Buffer.from(envelope.payload.iv, 'base64'), + ); + decipher.setAAD(Buffer.from(envelope.payload.aad, 'base64')); + decipher.setAuthTag(Buffer.from(envelope.payload.authTag, 'base64')); + + return Buffer.concat([ + decipher.update(Buffer.from(keyPem, 'base64')), + decipher.final(), + ]).toString('utf8'); + } + + ensureEncryptedPrivateKey(params: { + keyPem: string; + metadata: unknown; + context: EncryptionContext; + }): { + keyPem: string; + keyEncryption: PrivateKeyEncryptionMetadata | null; + changed: boolean; + } { + if (!params.keyPem.trim()) { + return { + keyPem: params.keyPem, + keyEncryption: null, + changed: false, + }; + } + + const parsedMetadata = this.parseEncryptionMetadata(params.metadata); + + if (this.isPlaintextPrivateKey(params.keyPem)) { + const encrypted = this.encryptPrivateKey(params.keyPem, params.context); + return { + ...encrypted, + changed: true, + }; + } + + if ( + parsedMetadata && + parsedMetadata.provider.keyVersion === this.provider.keyVersion + ) { + return { + keyPem: params.keyPem, + keyEncryption: parsedMetadata, + changed: false, + }; + } + + const decrypted = this.decryptPrivateKey( + params.keyPem, + parsedMetadata, + params.context, + ); + const encrypted = this.encryptPrivateKey(decrypted, params.context); + + return { + ...encrypted, + changed: true, + }; + } + + async migrateStoredPrivateKeys(): Promise<{ + certificatesUpdated: number; + artifactsUpdated: number; + }> { + if (!this.prisma) { + return { + certificatesUpdated: 0, + artifactsUpdated: 0, + }; + } + + let certificatesUpdated = 0; + let artifactsUpdated = 0; + + const certificates = (await this.prisma.certificate.findMany()) as Array<{ + id: string; + keyPem: string; + keyEncryption?: unknown; + domainsHash: string; + }>; + + for (const certificate of certificates) { + const ensured = this.ensureEncryptedPrivateKey({ + keyPem: certificate.keyPem, + metadata: certificate.keyEncryption ?? null, + context: { + scope: 'certificate', + domainsHash: certificate.domainsHash, + }, + }); + + if (!ensured.changed) { + continue; + } + + await this.prisma.certificate.update({ + where: { id: certificate.id }, + data: { + keyPem: ensured.keyPem, + keyEncryption: ensured.keyEncryption ?? undefined, + } as any, + }); + certificatesUpdated += 1; + } + + const artifactDelegate = (this.prisma as Record<string, any>)[ + 'certificateArtifactVersion' + ] as + | { + findMany?: () => Promise<unknown>; + update?: (input: unknown) => Promise<unknown>; + } + | undefined; + + const artifacts = (await artifactDelegate?.findMany?.()) as + | Array<{ + id: string; + keyPem: string; + keyEncryption?: unknown; + domainsHash: string; + version: number; + }> + | undefined; + + for (const artifact of artifacts ?? []) { + const ensured = this.ensureEncryptedPrivateKey({ + keyPem: artifact.keyPem, + metadata: artifact.keyEncryption ?? null, + context: { + scope: 'certificate-artifact', + domainsHash: artifact.domainsHash, + version: artifact.version, + }, + }); + + if (!ensured.changed) { + continue; + } + + await artifactDelegate?.update?.({ + where: { id: artifact.id }, + data: { + keyPem: ensured.keyPem, + keyEncryption: ensured.keyEncryption ?? undefined, + } as any, + }); + artifactsUpdated += 1; + } + + return { + certificatesUpdated, + artifactsUpdated, + }; + } + + isPlaintextPrivateKey(value: string): boolean { + return /-----BEGIN [A-Z ]*PRIVATE KEY-----/.test(value); + } + + private createProvider(): { + provider: PrivateKeyEncryptionProvider; + usesDevelopmentFallbackMasterKey: boolean; + } { + const configuredProvider = + (process.env['PRIVATE_KEY_ENCRYPTION_PROVIDER'] ?? 'local') + .trim() + .toLowerCase() || 'local'; + + if (configuredProvider !== 'local') { + throw new Error( + `Unsupported PRIVATE_KEY_ENCRYPTION_PROVIDER "${configuredProvider}". The current build ships a local envelope-encryption provider plus an abstraction that can be extended for Vault/KMS/HSM integration.`, + ); + } + + const usesDevelopmentFallbackMasterKey = + !process.env['PRIVATE_KEY_ENCRYPTION_MASTER_KEY']?.trim(); + const masterKeyMaterial = + process.env['PRIVATE_KEY_ENCRYPTION_MASTER_KEY']?.trim() || + this.resolveDevelopmentFallbackMasterKey(); + + if ( + !process.env['PRIVATE_KEY_ENCRYPTION_MASTER_KEY']?.trim() && + process.env['NODE_ENV'] === 'production' + ) { + throw new Error( + 'PRIVATE_KEY_ENCRYPTION_MASTER_KEY must be configured in production to protect stored private keys', + ); + } + + return { + provider: new LocalMasterKeyProvider({ + masterKeyMaterial, + keyVersion: + process.env['PRIVATE_KEY_ENCRYPTION_KEY_VERSION']?.trim() || 'v1', + }), + usesDevelopmentFallbackMasterKey, + }; + } + + private resolveDevelopmentFallbackMasterKey(): string { + return DEVELOPMENT_FALLBACK_MASTER_KEY; + } + + private parseEncryptionMetadata( + metadata: unknown, + ): PrivateKeyEncryptionMetadata | null { + if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) { + return null; + } + + const candidate = metadata as Record<string, unknown>; + if (candidate.scheme !== ENVELOPE_SCHEME) { + return null; + } + + return candidate as PrivateKeyEncryptionMetadata; + } + + private assertExpectedContext( + actual: Required<EncryptionContext>, + expected: EncryptionContext, + ) { + if (actual.scope !== expected.scope) { + throw new Error( + `Encrypted private key context mismatch: expected ${expected.scope}, received ${actual.scope}`, + ); + } + + if ( + expected.domainsHash !== undefined && + actual.domainsHash !== (expected.domainsHash ?? null) + ) { + throw new Error('Encrypted private key domainsHash context mismatch'); + } + + if ( + expected.version !== undefined && + actual.version !== (expected.version ?? null) + ) { + throw new Error( + 'Encrypted private key artifact-version context mismatch', + ); + } + } +} diff --git a/src/certificate/tls-config.service.ts b/src/certificate/tls-config.service.ts index d0a303d..d4376e4 100644 --- a/src/certificate/tls-config.service.ts +++ b/src/certificate/tls-config.service.ts @@ -1,9 +1,9 @@ import { Injectable, Logger } from '@nestjs/common'; -import { exec as execCb } from 'child_process'; -import { promisify } from 'util'; import * as fs from 'fs'; - -const exec = promisify(execCb); +import * as os from 'node:os'; +import * as path from 'node:path'; +import { normalizeDomain } from '../utils/domain-utils'; +import { runCommand } from '../utils/process-utils'; @Injectable() export class TlsConfigService { @@ -11,6 +11,10 @@ export class TlsConfigService { private readonly dhParamPath = '/etc/nginx/ssl/dhparam.pem'; private readonly sslDir = '/etc/nginx/ssl'; + private createTempDirectory(prefix: string): string { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + } + /** * Generate Diffie-Hellman parameters for enhanced security * This can take a long time (minutes), so it's optional @@ -35,7 +39,13 @@ export class TlsConfigService { } try { - await exec(`openssl dhparam -out ${this.dhParamPath} ${bits}`); + await runCommand( + 'openssl', + ['dhparam', '-out', this.dhParamPath, `${bits}`], + { + timeoutMs: 15 * 60 * 1000, + }, + ); this.logger.log(`[DH] Successfully generated DH parameters`); } catch (err) { this.logger.error( @@ -62,6 +72,7 @@ export class TlsConfigService { hsts: boolean; ocspStapling: boolean; } { + normalizeDomain(domain); // You could customize this based on domain or requirements return { protocols: ['TLSv1.2', 'TLSv1.3'], @@ -84,15 +95,30 @@ export class TlsConfigService { cipher?: string; error?: string; }> { - this.logger.log(`[TLS Test] Testing TLS connection to ${domain}:${port}`); + const normalizedDomain = normalizeDomain(domain); + this.logger.log( + `[TLS Test] Testing TLS connection to ${normalizedDomain}:${port}`, + ); try { - const { stdout } = await exec( - `echo | openssl s_client -connect ${domain}:${port} -servername ${domain} 2>/dev/null | grep -E '(Protocol|Cipher)'`, + const { stdout, stderr } = await runCommand( + 'openssl', + [ + 's_client', + '-connect', + `${normalizedDomain}:${port}`, + '-servername', + normalizedDomain, + ], + { + input: '\n', + timeoutMs: 15_000, + }, ); + const combinedOutput = `${stdout}\n${stderr}`; - const protocolMatch = stdout.match(/Protocol\s*:\s*(\S+)/); - const cipherMatch = stdout.match(/Cipher\s*:\s*(\S+)/); + const protocolMatch = combinedOutput.match(/Protocol\s*:\s*(\S+)/); + const cipherMatch = combinedOutput.match(/Cipher\s*:\s*(\S+)/); return { success: true, @@ -118,13 +144,18 @@ export class TlsConfigService { serialNumber: string; subjectAltNames?: string[]; }> { - const certFile = `/tmp/cert-info-${Date.now()}.pem`; + const tempDir = this.createTempDirectory('lyttlenginx-cert-info-'); + const certFile = path.join(tempDir, 'cert.pem'); try { fs.writeFileSync(certFile, certPem, 'utf8'); - const { stdout: textOutput } = await exec( - `openssl x509 -in ${certFile} -text -noout`, - ); + const { stdout: textOutput } = await runCommand('openssl', [ + 'x509', + '-in', + certFile, + '-text', + '-noout', + ]); // Parse subject const subjectMatch = textOutput.match(/Subject: (.*)/); @@ -167,8 +198,8 @@ export class TlsConfigService { }; } finally { try { - fs.unlinkSync(certFile); - } catch (e) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } catch { // Ignore } } @@ -181,12 +212,13 @@ export class TlsConfigService { certPem: string, chainPem?: string, ): Promise<{ valid: boolean; error?: string }> { - const certFile = `/tmp/cert-chain-${Date.now()}.pem`; + const tempDir = this.createTempDirectory('lyttlenginx-cert-chain-'); + const certFile = path.join(tempDir, 'chain.pem'); try { const fullChain = chainPem ? `${certPem}\n${chainPem}` : certPem; fs.writeFileSync(certFile, fullChain, 'utf8'); - await exec(`openssl verify -CAfile ${certFile} ${certFile}`); + await runCommand('openssl', ['verify', '-CAfile', certFile, certFile]); return { valid: true }; } catch (err) { return { @@ -195,21 +227,11 @@ export class TlsConfigService { }; } finally { try { - fs.unlinkSync(certFile); - } catch (e) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } catch { // Ignore } } } - /** - * Ensure certbot webroot directory exists - */ - ensureCertbotWebroot(): void { - const webroot = '/var/www/certbot'; - if (!fs.existsSync(webroot)) { - this.logger.log(`[Certbot] Creating webroot directory: ${webroot}`); - fs.mkdirSync(webroot, { recursive: true, mode: 0o755 }); - } - } } diff --git a/src/certificate/tls.controller.ts b/src/certificate/tls.controller.ts index dc4bb11..d86b0ff 100644 --- a/src/certificate/tls.controller.ts +++ b/src/certificate/tls.controller.ts @@ -11,24 +11,34 @@ import { import { TlsConfigService } from './tls-config.service'; import { CertificatePemDto } from './dto/certificate-pem.dto'; import { ValidateCertChainDto } from './dto/validate-cert-chain.dto'; +import { AuthorizeAdmin } from '../auth/decorators/authorize.decorator'; import { ApiKeyGuard } from '../auth/guards/api-key.guard'; +import { Audit } from '../audit/decorators/audit.decorator'; +import { NormalizedDomainPipe } from '../utils/pipes/normalized-domain.pipe'; @Controller('tls') +@AuthorizeAdmin('viewer') export class TlsController { constructor(private readonly tlsConfigService: TlsConfigService) {} @Get('config/:domain') - async getRecommendedConfig(@Param('domain') domain: string) { + async getRecommendedConfig( + @Param('domain', new NormalizedDomainPipe()) domain: string, + ) { return this.tlsConfigService.getRecommendedTlsConfig(domain); } @Get('test/:domain') - async testTlsConnection(@Param('domain') domain: string) { + async testTlsConnection( + @Param('domain', new NormalizedDomainPipe()) domain: string, + ) { return this.tlsConfigService.testTlsConnection(domain); } @Post('dhparam') @HttpCode(HttpStatus.ACCEPTED) + @AuthorizeAdmin('security-admin') + @Audit({ action: 'tls.dhparam.generate' }) async generateDhParams(@Body() body: { bits?: number }) { // Run in background as this can take a long time this.tlsConfigService.generateDhParams(body.bits || 2048).catch((err) => { @@ -50,6 +60,8 @@ export class TlsController { @Post('certificate/info') @UseGuards(ApiKeyGuard) @HttpCode(HttpStatus.OK) + @AuthorizeAdmin('security-admin') + @Audit({ action: 'tls.certificate.inspect' }) async getCertificateInfo(@Body() dto: CertificatePemDto) { return this.tlsConfigService.getCertificateInfo(dto.certPem); } @@ -57,6 +69,8 @@ export class TlsController { @Post('certificate/validate-chain') @UseGuards(ApiKeyGuard) @HttpCode(HttpStatus.OK) + @AuthorizeAdmin('security-admin') + @Audit({ action: 'tls.certificate.validate-chain' }) async validateChain(@Body() dto: ValidateCertChainDto) { return this.tlsConfigService.validateCertificateChain( dto.certPem, diff --git a/src/distributed-lock/cluster-heartbeat.service.ts b/src/distributed-lock/cluster-heartbeat.service.ts index ba35227..589de60 100644 --- a/src/distributed-lock/cluster-heartbeat.service.ts +++ b/src/distributed-lock/cluster-heartbeat.service.ts @@ -7,7 +7,33 @@ import { import { PrismaService } from '../prisma/prisma.service'; import { DistributedLockService } from './distributed-lock.service'; import * as os from 'os'; -import { getNodeIpAddress } from '../utils/network-utils'; +import { getLocalControlPlaneRegistration } from '../utils/network-utils'; + +type ClusterNodeRecord = { + id: string; + hostname: string; + instanceId: string; + ipAddress: string | null; + isLeader: boolean; + lastHeartbeat: Date; + version: string | null; + status: string; + metadata: unknown; + createdAt: Date; + updatedAt: Date; +}; + +type LeaderLeaseSnapshot = Awaited< + ReturnType<DistributedLockService['getLeaderLeaseSnapshot']> +>; + +type LeaderLeaseState = { + lease: NonNullable<LeaderLeaseSnapshot> | null; + hasActiveLease: boolean; + ownerNode: ClusterNodeRecord | null; + activeLeaderNode: ClusterNodeRecord | null; + issues: string[]; +}; /** * Service to track cluster nodes and their health status @@ -16,9 +42,9 @@ import { getNodeIpAddress } from '../utils/network-utils'; @Injectable() export class ClusterHeartbeatService implements OnModuleInit, OnModuleDestroy { private readonly logger = new Logger(ClusterHeartbeatService.name); - private heartbeatInterval: NodeJS.Timeout | null = null; - private cleanupInterval: NodeJS.Timeout | null = null; - private leaderCheckInterval: NodeJS.Timeout | null = null; + private heartbeatInterval: ReturnType<typeof setInterval> | null = null; + private cleanupInterval: ReturnType<typeof setInterval> | null = null; + private leaderCheckInterval: ReturnType<typeof setInterval> | null = null; private readonly heartbeatIntervalMs = 10000; // 10 seconds private readonly staleThresholdMs = 45000; // 45 seconds private readonly deleteThresholdMs = 3600000; // 1 hour - delete very old stale nodes @@ -127,7 +153,18 @@ export class ClusterHeartbeatService implements OnModuleInit, OnModuleDestroy { private async registerNode() { const instanceId = this.distributedLock.getInstanceId(); const hostname = os.hostname(); - const ipAddress = await getNodeIpAddress(); + const controlPlane = getLocalControlPlaneRegistration(); + const ipAddress = controlPlane.endpoint?.address ?? null; + + if (!controlPlane.endpoint) { + this.logger.warn( + `[Register] Node ${hostname} does not have a valid control-plane endpoint configured: ${controlPlane.issues.join('; ')}`, + ); + } else if (controlPlane.issues.length > 0) { + this.logger.warn( + `[Register] Node ${hostname} control-plane configuration warnings: ${controlPlane.issues.join('; ')}`, + ); + } try { await this.prisma.clusterNode.upsert({ @@ -145,6 +182,7 @@ export class ClusterHeartbeatService implements OnModuleInit, OnModuleDestroy { nodeVersion: process.version, cpus: os.cpus().length, totalMemory: os.totalmem(), + controlPlane: this.serializeControlPlaneRegistration(controlPlane), }, }, update: { @@ -153,6 +191,14 @@ export class ClusterHeartbeatService implements OnModuleInit, OnModuleDestroy { lastHeartbeat: new Date(), status: 'active', version: process.env.npm_package_version || 'unknown', + metadata: { + platform: os.platform(), + arch: os.arch(), + nodeVersion: process.version, + cpus: os.cpus().length, + totalMemory: os.totalmem(), + controlPlane: this.serializeControlPlaneRegistration(controlPlane), + }, }, }); @@ -171,33 +217,14 @@ export class ClusterHeartbeatService implements OnModuleInit, OnModuleDestroy { */ private async sendHeartbeat() { const instanceId = this.distributedLock.getInstanceId(); - const ipAddress = await getNodeIpAddress(); + const controlPlane = getLocalControlPlaneRegistration(); + const ipAddress = controlPlane.endpoint?.address ?? null; try { - const isLeader = await this.distributedLock.isLeader(); - - // If claiming to be leader, verify no other active leaders exist first - if (isLeader) { - const otherLeaders = await this.prisma.clusterNode.findMany({ - where: { - isLeader: true, - instanceId: { not: instanceId }, - status: 'active', - }, - }); - - if (otherLeaders.length > 0) { - this.logger.error( - `[Heartbeat] CRITICAL: Detected ${otherLeaders.length} other leader(s): ${otherLeaders.map((n) => n.hostname).join(', ')}. Releasing leadership.`, - ); - // Release our lock to prevent split-brain - await this.distributedLock.releaseLeaderLock(); - // Fall through to update as non-leader - } - } - - // Re-check leadership status after validation - const finalLeaderStatus = await this.distributedLock.isLeader(); + const leaderState = await this.getLeaderLeaseState(); + const finalLeaderStatus = + leaderState.hasActiveLease && + leaderState.lease?.ownerNodeId === instanceId; await this.prisma.clusterNode.update({ where: { instanceId }, @@ -215,10 +242,15 @@ export class ClusterHeartbeatService implements OnModuleInit, OnModuleDestroy { freeMemory: os.freemem(), uptime: os.uptime(), loadAverage: os.loadavg(), + controlPlane: this.serializeControlPlaneRegistration(controlPlane), }, }, }); + if (finalLeaderStatus || leaderState.issues.length > 0) { + await this.enforceOneLeader(); + } + this.logger.debug(`[Heartbeat] Sent (Leader: ${finalLeaderStatus})`); } catch (error) { this.logger.error( @@ -292,10 +324,16 @@ export class ClusterHeartbeatService implements OnModuleInit, OnModuleDestroy { }, }); - const staleLeaders = staleNodes.filter((n) => n.isLeader); - if (staleLeaders.length > 0) { - this.logger.error( - `[Cleanup] CRITICAL: Removed ${staleLeaders.length} stale LEADER node(s): ${staleLeaders.map((n) => n.hostname).join(', ')}`, + const leaderState = await this.getLeaderLeaseState(); + const staleLeaseOwners = staleNodes.filter( + (node) => + leaderState.hasActiveLease && + leaderState.lease?.ownerNodeId === node.instanceId, + ); + + if (staleLeaseOwners.length > 0) { + this.logger.warn( + `[Cleanup] Active leader lease currently belongs to stale node(s): ${staleLeaseOwners.map((n) => n.hostname).join(', ')}. Waiting for lease expiry before electing a replacement.`, ); } @@ -304,7 +342,7 @@ export class ClusterHeartbeatService implements OnModuleInit, OnModuleDestroy { ); } - // ADDITIONAL SAFETY: Ensure only one leader exists across all nodes + // Reconcile denormalized leader flags from the authoritative lease state. await this.enforceOneLeader(); // Delete very old stale/inactive nodes to prevent database bloat @@ -343,43 +381,25 @@ export class ClusterHeartbeatService implements OnModuleInit, OnModuleDestroy { } /** - * Enforce that only one node can be marked as leader - * If multiple leaders detected, keep the one with the most recent heartbeat + * Reconcile denormalized ClusterNode leader flags from the authoritative lease. + * Leader selection is derived from the lease rather than DB heartbeat recency. */ private async enforceOneLeader() { try { - const leaders = await this.prisma.clusterNode.findMany({ - where: { - isLeader: true, - }, - orderBy: { - lastHeartbeat: 'desc', - }, - }); + const leaderState = await this.getLeaderLeaseState(); + await this.reconcileLeaderFlagsFromLease(leaderState); - if (leaders.length > 1) { - this.logger.error( - `[EnforceLeader] CRITICAL: Found ${leaders.length} leaders! Fixing...`, + if (leaderState.hasActiveLease && leaderState.activeLeaderNode) { + this.logger.debug( + `[EnforceLeader] Reconciled leader flags to lease owner ${leaderState.activeLeaderNode.hostname} (${leaderState.activeLeaderNode.instanceId}) generation ${leaderState.lease?.generation}`, ); - - // Keep the most recent leader (first in the sorted list) - const validLeader = leaders[0]; - const invalidLeaders = leaders.slice(1); - - // Remove leader status from all others - await this.prisma.clusterNode.updateMany({ - where: { - instanceId: { - in: invalidLeaders.map((l) => l.instanceId), - }, - }, - data: { - isLeader: false, - }, - }); - + } else if (leaderState.hasActiveLease) { this.logger.warn( - `[EnforceLeader] Demoted ${invalidLeaders.length} invalid leader(s). Current leader: ${validLeader.hostname} (${validLeader.instanceId})`, + `[EnforceLeader] Lease owner ${leaderState.lease?.ownerNodeId} is not currently active; leader flags were cleared while waiting for lease expiry`, + ); + } else { + this.logger.debug( + '[EnforceLeader] No active leader lease is present; cleared any stale leader flags', ); } } catch (error) { @@ -393,63 +413,95 @@ export class ClusterHeartbeatService implements OnModuleInit, OnModuleDestroy { * Get all active cluster nodes */ async getActiveNodes() { - return this.prisma.clusterNode.findMany({ - where: { status: 'active' }, - orderBy: { lastHeartbeat: 'desc' }, - }); + const [nodes, leaderState] = await Promise.all([ + this.prisma.clusterNode.findMany({ + where: { status: 'active' }, + orderBy: { lastHeartbeat: 'desc' }, + }), + this.getLeaderLeaseState(), + ]); + + return this.applyLeaseLeadership(nodes, leaderState); } /** * Get the current leader node */ async getLeaderNode() { - return this.prisma.clusterNode.findFirst({ - where: { - isLeader: true, - status: 'active', - }, - orderBy: { lastHeartbeat: 'desc' }, - }); + const leaderState = await this.getLeaderLeaseState(); + return leaderState.activeLeaderNode; } /** * Get cluster statistics */ async getClusterStats() { - const [total, active, stale, inactive, leaders] = await Promise.all([ + const [total, active, stale, inactive, leaderState] = await Promise.all([ this.prisma.clusterNode.count(), this.prisma.clusterNode.count({ where: { status: 'active' } }), this.prisma.clusterNode.count({ where: { status: 'stale' } }), this.prisma.clusterNode.count({ where: { status: 'inactive' } }), - this.prisma.clusterNode.findMany({ - where: { isLeader: true }, - select: { - hostname: true, - instanceId: true, - ipAddress: true, - status: true, - lastHeartbeat: true, - }, - }), + this.getLeaderLeaseState(), ]); + const leaders = leaderState.activeLeaderNode + ? [leaderState.activeLeaderNode] + : []; + return { total, active, stale, inactive, - leaders: leaders, + leaders, leaderCount: leaders.length, - hasMultipleLeaders: leaders.length > 1, + hasMultipleLeaders: false, + leadershipIssues: leaderState.issues, + leaderLeaseGeneration: leaderState.lease?.generation ?? null, + leaderLeaseOwnerNodeId: leaderState.lease?.ownerNodeId ?? null, }; } + async getLeaderLeaseState() { + const lease = await this.distributedLock.getLeaderLeaseSnapshot(); + const hasActiveLease = Boolean( + lease && !lease.isExpired && lease.ownerNodeId, + ); + const ownerNode = hasActiveLease + ? await this.prisma.clusterNode.findUnique({ + where: { instanceId: lease!.ownerNodeId! }, + }) + : null; + const activeLeaderNode = + ownerNode && ownerNode.status === 'active' ? ownerNode : null; + const issues: string[] = []; + + if (!lease) { + issues.push('NO_LEASE'); + } else if (lease.isExpired || !lease.ownerNodeId) { + issues.push('LEASE_EXPIRED'); + } + + if (hasActiveLease && !ownerNode) { + issues.push('LEASE_OWNER_MISSING'); + } + + if (ownerNode && ownerNode.status !== 'active') { + issues.push('LEASE_OWNER_NOT_ACTIVE'); + } + + return { + lease, + hasActiveLease, + ownerNode, + activeLeaderNode, + issues, + } satisfies LeaderLeaseState; + } + /** - * CRITICAL: Ensure exactly one leader exists in the cluster - * This method handles: - * 1. No leader exists -> elect one - * 2. Multiple leaders exist -> keep only one - * 3. Stale leader exists -> remove and elect new one + * Ensure the cluster has an active leader lease and reconcile the legacy + * ClusterNode.isLeader flag from that lease for observability. */ async ensureLeaderExists() { try { @@ -463,184 +515,58 @@ export class ClusterHeartbeatService implements OnModuleInit, OnModuleDestroy { return; } - const leaders = activeNodes.filter((n) => n.isLeader); - const staleThreshold = new Date(Date.now() - this.staleThresholdMs); + const leaderState = await this.getLeaderLeaseState(); + const thisInstanceId = this.distributedLock.getInstanceId(); + const localLockStatus = this.distributedLock.getLeaderLockStatus(); - // Case 1: Multiple leaders exist - keep the healthiest one - if (leaders.length > 1) { + if ( + localLockStatus.isLeader && + leaderState.hasActiveLease && + leaderState.lease?.ownerNodeId !== thisInstanceId + ) { this.logger.error( - `[LeaderCheck] CRITICAL: ${leaders.length} leaders detected! Resolving...`, + `[LeaderCheck] Local leader state is stale; releasing local lease tracking because ${leaderState.lease?.ownerNodeId} owns the active leader lease`, ); + await this.distributedLock.releaseLeaderLock(); + } - // Find the best leader (most recent heartbeat, active status) - const validLeader = leaders[0]; // Already sorted by lastHeartbeat desc - const invalidLeaders = leaders.slice(1); - - // Demote invalid leaders in DB - await this.prisma.clusterNode.updateMany({ - where: { - instanceId: { in: invalidLeaders.map((l) => l.instanceId) }, - }, - data: { isLeader: false }, - }); - - // If any of the invalid leaders is THIS instance, release the lock - const thisInstanceId = this.distributedLock.getInstanceId(); - if (invalidLeaders.some((l) => l.instanceId === thisInstanceId)) { - this.logger.warn( - '[LeaderCheck] This instance was an invalid leader - releasing lock', - ); - await this.distributedLock.releaseLeaderLock(); - } - - this.logger.log( - `[LeaderCheck] Resolved split-brain: kept ${validLeader.hostname} as leader`, + if (leaderState.hasActiveLease && leaderState.activeLeaderNode) { + await this.enforceOneLeader(); + this.logger.debug( + `[LeaderCheck] Leader lease is healthy: ${leaderState.activeLeaderNode.hostname} (generation ${leaderState.lease?.generation})`, ); return; } - // Case 2: One leader exists - validate it's healthy - if (leaders.length === 1) { - const leader = leaders[0]; - const thisInstanceId = this.distributedLock.getInstanceId(); - - // Check if leader is stale - if (leader.lastHeartbeat < staleThreshold) { - this.logger.error( - `[LeaderCheck] CRITICAL: Leader ${leader.hostname} is STALE (last heartbeat: ${leader.lastHeartbeat.toISOString()})`, - ); - - // Demote stale leader - await this.prisma.clusterNode.update({ - where: { instanceId: leader.instanceId }, - data: { isLeader: false, status: 'stale' }, - }); - - this.logger.log( - '[LeaderCheck] Demoted stale leader, will elect new one...', - ); - // Fall through to elect new leader - } else { - // Leader is healthy - this.logger.debug( - `[LeaderCheck] Leader ${leader.hostname} is healthy`, - ); - - // If we think we're the leader but DB says someone else is, release our lock - const weAreLeader = await this.distributedLock.isLeader(); - if (weAreLeader && leader.instanceId !== thisInstanceId) { - this.logger.error( - `[LeaderCheck] CRITICAL: Lock mismatch! We hold lock but ${leader.hostname} is DB leader. Releasing lock.`, - ); - await this.distributedLock.releaseLeaderLock(); - } - return; - } - } - - // Case 3: No leader exists - elect one! - this.logger.warn('[LeaderCheck] NO LEADER EXISTS - initiating election'); - - // CRITICAL FIX: Check if lock exists but no DB leader (deadlock state) - const thisInstanceId = this.distributedLock.getInstanceId(); - - // Double-check no DB leader exists (race condition safety) - const currentLeader = await this.prisma.clusterNode.findFirst({ - where: { isLeader: true, status: 'active' }, - }); - - if (currentLeader) { - this.logger.log( - `[LeaderCheck] Leader appeared during check: ${currentLeader.hostname}`, + if (leaderState.hasActiveLease) { + await this.enforceOneLeader(); + this.logger.warn( + `[LeaderCheck] Active leader lease generation ${leaderState.lease?.generation} belongs to ${leaderState.lease?.ownerNodeId}, but that node is not currently active. Waiting for lease expiry before electing a replacement.`, ); - return; // Leader was elected by another node, we're done + return; } - // Try to acquire - this is non-blocking and race-safe + this.logger.warn( + '[LeaderCheck] No active leader lease exists - initiating lease election', + ); + const acquired = await this.distributedLock.tryAcquireLeaderLock(); if (acquired) { - // WE WON THE ELECTION! + const currentLease = await this.getLeaderLeaseState(); + await this.enforceOneLeader(); + this.logger.log( - '[LeaderCheck] ✓ This node successfully became the LEADER', + `[LeaderCheck] ✓ This node successfully became the leader (lease generation ${currentLease.lease?.generation ?? 'unknown'})`, ); - - // Update DB to reflect leadership - await this.prisma.clusterNode.update({ - where: { instanceId: thisInstanceId }, - data: { isLeader: true }, - }); - - // Verify we're the only leader - setTimeout(() => this.enforceOneLeader(), 2000); return; } - // Lock acquisition failed - someone else holds it this.logger.debug( - '[LeaderCheck] Failed to acquire lock - another node holds it', + '[LeaderCheck] Failed to acquire leader lease - another node likely holds it', ); - // DEADLOCK DETECTION: Lock exists but no DB leader after delay - setTimeout(async () => { - try { - const leaderCheck = await this.prisma.clusterNode.findFirst({ - where: { isLeader: true, status: 'active' }, - }); - - if (!leaderCheck) { - this.logger.error( - '[LeaderCheck] DEADLOCK DETECTED: Lock exists but no DB leader! Forcing recovery...', - ); - - // Check if the lock holder is a stale/dead node - const allNodes = await this.prisma.clusterNode.findMany({ - where: { status: { in: ['active', 'stale'] } }, - orderBy: { lastHeartbeat: 'desc' }, - }); - - const staleThreshold = new Date(Date.now() - this.staleThresholdMs); - const healthyNodes = allNodes.filter( - (n) => n.lastHeartbeat >= staleThreshold, - ); - - if (healthyNodes.length > 0) { - // Force release ALL advisory locks (nuclear option for deadlock recovery) - try { - await this.prisma.$executeRaw`SELECT pg_advisory_unlock_all()`; - this.logger.warn( - '[LeaderCheck] Released all advisory locks to break deadlock', - ); - - // Wait for locks to clear, then trigger re-election - setTimeout(() => { - this.ensureLeaderExists().catch((err) => - this.logger.error( - `[LeaderCheck] Re-election failed: ${err.message}`, - ), - ); - }, 1000); - } catch (err) { - this.logger.error( - `[LeaderCheck] Failed to release locks: ${err instanceof Error ? err.message : String(err)}`, - ); - } - } else { - this.logger.error( - '[LeaderCheck] No healthy nodes available for leadership!', - ); - } - } else { - this.logger.debug( - `[LeaderCheck] Leader confirmed: ${leaderCheck.hostname}`, - ); - } - } catch (err) { - this.logger.error( - `[LeaderCheck] Deadlock detection failed: ${err instanceof Error ? err.message : String(err)}`, - ); - } - }, 3000); // 3 second delay to allow leader to update DB + await this.enforceOneLeader(); } catch (error) { this.logger.error( `[LeaderCheck] Failed: ${error instanceof Error ? error.message : String(error)}`, @@ -655,25 +581,29 @@ export class ClusterHeartbeatService implements OnModuleInit, OnModuleDestroy { async tryBecomeLeader(): Promise<boolean> { try { const thisInstanceId = this.distributedLock.getInstanceId(); + const leaderState = await this.getLeaderLeaseState(); - // First check if we already are the leader - const isLeader = await this.distributedLock.isLeader(); - if (isLeader) { + if ( + leaderState.hasActiveLease && + leaderState.lease?.ownerNodeId === thisInstanceId + ) { + await this.enforceOneLeader(); this.logger.log('[TryBecomeLeader] This node is already the leader'); return true; } - // Try to acquire the lock + if (leaderState.hasActiveLease) { + this.logger.log( + `[TryBecomeLeader] ✗ Active leader lease is already owned by ${leaderState.lease?.ownerNodeId}`, + ); + return false; + } + const acquired = await this.distributedLock.acquireLeaderLock(); if (acquired) { this.logger.log('[TryBecomeLeader] ✓ Successfully became the leader'); - - // Update DB - await this.prisma.clusterNode.update({ - where: { instanceId: thisInstanceId }, - data: { isLeader: true }, - }); + await this.enforceOneLeader(); return true; } else { @@ -707,6 +637,76 @@ export class ClusterHeartbeatService implements OnModuleInit, OnModuleDestroy { async manualEnforceLeader() { this.logger.log('[ManualEnforce] Triggered by admin'); await this.enforceOneLeader(); - return { success: true, message: 'Leader enforcement completed' }; + const leaderState = await this.getLeaderLeaseState(); + return { + success: true, + message: leaderState.activeLeaderNode + ? `Lease-backed leader reconciliation completed for ${leaderState.activeLeaderNode.hostname}` + : leaderState.hasActiveLease + ? 'Lease-backed leader reconciliation completed while waiting for lease owner recovery/expiry' + : 'Lease-backed leader reconciliation completed with no active leader lease', + }; + } + + private applyLeaseLeadership( + nodes: ClusterNodeRecord[], + leaderState: LeaderLeaseState, + ): ClusterNodeRecord[] { + const leaderInstanceId = leaderState.hasActiveLease + ? (leaderState.lease?.ownerNodeId ?? null) + : null; + + return nodes.map((node) => ({ + ...node, + isLeader: + leaderInstanceId !== null && + node.status === 'active' && + node.instanceId === leaderInstanceId, + })); + } + + private async reconcileLeaderFlagsFromLease( + leaderState: LeaderLeaseState, + ): Promise<void> { + const leaderInstanceId = leaderState.activeLeaderNode?.instanceId ?? null; + + if (leaderInstanceId) { + await Promise.all([ + this.prisma.clusterNode.updateMany({ + where: { + instanceId: leaderInstanceId, + isLeader: false, + }, + data: { isLeader: true }, + }), + this.prisma.clusterNode.updateMany({ + where: { + isLeader: true, + instanceId: { not: leaderInstanceId }, + }, + data: { isLeader: false }, + }), + ]); + return; + } + + await this.prisma.clusterNode.updateMany({ + where: { isLeader: true }, + data: { isLeader: false }, + }); + } + + private serializeControlPlaneRegistration( + registration: ReturnType<typeof getLocalControlPlaneRegistration>, + ) { + return { + configured: registration.endpoint !== null, + address: registration.endpoint?.address ?? null, + port: registration.endpoint?.port ?? null, + protocol: registration.endpoint?.protocol ?? null, + baseUrl: registration.endpoint?.baseUrl ?? null, + source: registration.endpoint?.source ?? null, + issues: registration.issues, + }; } } diff --git a/src/distributed-lock/cluster-operations.service.ts b/src/distributed-lock/cluster-operations.service.ts new file mode 100644 index 0000000..c6d22fa --- /dev/null +++ b/src/distributed-lock/cluster-operations.service.ts @@ -0,0 +1,834 @@ +import { Injectable, Logger } from '@nestjs/common'; +import * as os from 'node:os'; +import { PrismaService } from '../prisma/prisma.service'; +import { toPrismaNullableJsonValue } from '../prisma/prisma-json.util'; +import { ClusterHeartbeatService } from './cluster-heartbeat.service'; +import { DistributedLockService } from './distributed-lock.service'; +import { AuthIdentity } from '../auth/types/auth-identity'; +import { buildClusterNodeUrl } from '../utils/network-utils'; +import { runWithLogContext } from '../logs/log-context'; + +type ClusterNodeTarget = Awaited< + ReturnType<ClusterHeartbeatService['getActiveNodes']> +>[number]; + +type ClusterOperationRecord = { + id: string; + operationType: string; + scope: string; + status: string; + initiatorNodeId: string | null; + initiatorHostname: string | null; + initiatorActorId: string | null; + initiatorActorType: string | null; + initiatorActorDisplayName: string | null; + correlationId: string | null; + requestPath: string | null; + targetNodeCount: number; + completedNodeCount: number; + successfulNodeCount: number; + failedNodeCount: number; + startedAt: Date | null; + completedAt: Date | null; + lastError: string | null; + metadata: unknown; + createdAt: Date; + updatedAt: Date; +}; + +type ClusterOperationAckRecord = { + id: string; + operationId: string; + nodeInstanceId: string; + nodeHostname: string | null; + endpointUrl: string | null; + status: string; + responseStatus: number | null; + errorMessage: string | null; + startedAt: Date | null; + ackedAt: Date | null; + details: unknown; + createdAt: Date; + updatedAt: Date; +}; + +type ClusterOperationRecordWithAcks = ClusterOperationRecord & { + acknowledgements: ClusterOperationAckRecord[]; +}; + +type ClusterOperationSummaryRecord = ClusterOperationRecord & { + acknowledgements?: ClusterOperationAckRecord[]; +}; + +type OperationTarget = { + instanceId: string; + hostname: string; + ipAddress: string | null; + metadata: unknown; +}; + +type OperationExecutionContext = { + auth?: AuthIdentity; + correlationId?: string; + requestPath?: string; +}; + +type StartClusterOperationOptions<TLocalResult = unknown> = { + operationType: string; + remotePath: string; + remoteMethod?: 'POST'; + remoteQuery?: Record<string, string | undefined>; + broadcast?: boolean; + executionTimeoutMs?: number; + localAction: (operationId: string) => Promise<TLocalResult>; + initiatedBy?: OperationExecutionContext; + metadata?: Record<string, unknown>; +}; + +type WaitForOperationOptions = { + timeoutMs?: number; + pollIntervalMs?: number; +}; + +type ListClusterOperationsOptions = { + limit?: number; + status?: string; + operationType?: string; + operationTypes?: string[]; + targetNodeId?: string; +}; + +export type ClusterOperationAcceptedResponse = { + operationId: string; + operationType: string; + status: string; + scope: string; + targetNodeCount: number; + completedNodeCount: number; + successfulNodeCount: number; + failedNodeCount: number; + createdAt: Date; + startedAt: Date | null; + completedAt: Date | null; + correlationId: string | null; + requestPath: string | null; + operationStatusPath: string; + links: { + self: string; + }; + statusSummary: { + pendingNodeCount: number; + completionRatio: number; + isTerminal: boolean; + isSuccessful: boolean; + }; +}; + +@Injectable() +export class ClusterOperationsService { + private readonly logger = new Logger(ClusterOperationsService.name); + private readonly defaultExecutionTimeoutMs = 5000; + + constructor( + private readonly prisma: PrismaService, + private readonly clusterHeartbeat: ClusterHeartbeatService, + private readonly distributedLock: DistributedLockService, + ) {} + + async enqueueBroadcastOperation<TLocalResult = unknown>( + options: StartClusterOperationOptions<TLocalResult>, + ): Promise<ClusterOperationAcceptedResponse> { + const targets = await this.resolveTargets(options.broadcast ?? true); + const createdOperation = await this.prisma.clusterOperation.create({ + data: { + operationType: options.operationType, + scope: targets.length > 1 ? 'cluster' : 'node', + status: 'pending', + initiatorNodeId: this.distributedLock.getInstanceId(), + initiatorHostname: os.hostname(), + initiatorActorId: options.initiatedBy?.auth?.id, + initiatorActorType: options.initiatedBy?.auth?.actorType, + initiatorActorDisplayName: options.initiatedBy?.auth?.displayName, + correlationId: options.initiatedBy?.correlationId ?? null, + requestPath: options.initiatedBy?.requestPath ?? null, + targetNodeCount: targets.length, + metadata: { + remotePath: options.remotePath, + remoteMethod: options.remoteMethod ?? 'POST', + broadcast: options.broadcast ?? true, + ...(options.metadata ?? {}), + }, + acknowledgements: { + create: targets.map((target) => ({ + nodeInstanceId: target.instanceId, + nodeHostname: target.hostname, + status: 'pending', + endpointUrl: + target.instanceId === this.distributedLock.getInstanceId() + ? null + : buildClusterNodeUrl(target, options.remotePath, { + ...(options.remoteQuery ?? {}), + operationId: '__pending__', + }), + })), + }, + }, + }); + + await Promise.all( + targets + .filter((target) => target.instanceId !== this.distributedLock.getInstanceId()) + .map((target) => + this.prisma.clusterOperationAck.update({ + where: { + operationId_nodeInstanceId: { + operationId: createdOperation.id, + nodeInstanceId: target.instanceId, + }, + }, + data: { + endpointUrl: buildClusterNodeUrl(target, options.remotePath, { + ...(options.remoteQuery ?? {}), + operationId: createdOperation.id, + }), + }, + }), + ), + ); + + void runWithLogContext( + { + correlationId: options.initiatedBy?.correlationId, + operationId: createdOperation.id, + }, + () => this.executeOperation(createdOperation.id, targets, options), + ).catch(async (error) => { + const message = error instanceof Error ? error.message : String(error); + this.logger.error( + `[Operation] Execution failed for ${createdOperation.id}: ${message}`, + ); + await this.prisma.clusterOperation.update({ + where: { id: createdOperation.id }, + data: { + status: 'failed', + completedAt: new Date(), + lastError: this.truncate(message), + }, + }); + }); + + const operation = await this.prisma.clusterOperation.findUniqueOrThrow({ + where: { id: createdOperation.id }, + }); + + return this.toAcceptedResponse(operation); + } + + async listOperations(limitOrOptions: number | ListClusterOperationsOptions = 20) { + const options = + typeof limitOrOptions === 'number' + ? ({ limit: limitOrOptions } satisfies ListClusterOperationsOptions) + : limitOrOptions; + const take = this.normalizeListLimit(options.limit ?? 20); + const operationTypes = this.normalizeOperationTypes(options); + const targetNodeId = options.targetNodeId?.trim() || undefined; + const operations = (await this.prisma.clusterOperation.findMany({ + take, + orderBy: { createdAt: 'desc' }, + where: { + ...(options.status ? { status: options.status } : {}), + ...(operationTypes.length === 1 + ? { operationType: operationTypes[0] } + : operationTypes.length > 1 + ? { operationType: { in: operationTypes } } + : {}), + ...(targetNodeId + ? { + acknowledgements: { + some: { + nodeInstanceId: targetNodeId, + }, + }, + } + : {}), + }, + ...(targetNodeId + ? { + include: { + acknowledgements: { + where: { nodeInstanceId: targetNodeId }, + orderBy: [{ nodeHostname: 'asc' }, { nodeInstanceId: 'asc' }], + }, + }, + } + : {}), + })) as ClusterOperationSummaryRecord[]; + + const filteredOperations = targetNodeId + ? operations.filter((operation) => (operation.acknowledgements?.length ?? 0) > 0) + : operations; + + return { + count: filteredOperations.length, + filters: { + limit: take, + status: options.status ?? null, + operationTypes, + targetNodeId: targetNodeId ?? null, + }, + operations: filteredOperations.map((operation) => + this.toSummary(operation, operation.acknowledgements?.[0] ?? null), + ), + }; + } + + async getOperation(operationId: string) { + const operation = await this.prisma.clusterOperation.findUnique({ + where: { id: operationId }, + include: { + acknowledgements: { + orderBy: [{ nodeHostname: 'asc' }, { nodeInstanceId: 'asc' }], + }, + }, + }); + + if (!operation) { + return null; + } + + return this.toDetailedOperation(operation); + } + + async waitForOperationToSettle( + operationId: string, + options: WaitForOperationOptions = {}, + ) { + const timeoutMs = options.timeoutMs ?? 30000; + const pollIntervalMs = options.pollIntervalMs ?? 250; + const deadline = Date.now() + timeoutMs; + + while (Date.now() <= deadline) { + const operation = await this.getOperation(operationId); + + if (!operation) { + throw new Error(`Cluster operation not found: ${operationId}`); + } + + if (!['pending', 'running'].includes(operation.status)) { + return operation; + } + + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); + } + + throw new Error( + `Timed out waiting for cluster operation ${operationId} to settle after ${timeoutMs}ms`, + ); + } + + private async executeOperation<TLocalResult>( + operationId: string, + targets: OperationTarget[], + options: StartClusterOperationOptions<TLocalResult>, + ) { + await this.prisma.clusterOperation.update({ + where: { id: operationId }, + data: { + status: 'running', + startedAt: new Date(), + }, + }); + + const localInstanceId = this.distributedLock.getInstanceId(); + const localTarget = + targets.find((target) => target.instanceId === localInstanceId) ?? + this.buildFallbackLocalTarget(); + const remoteTargets = targets.filter( + (target) => target.instanceId !== localTarget.instanceId, + ); + + await this.runLocalTarget(operationId, localTarget, options.localAction); + + if (remoteTargets.length > 0) { + const apiKey = process.env.API_KEY?.split(',')[0]?.trim(); + + if (!apiKey) { + await Promise.all( + remoteTargets.map((target) => + this.markAckFailed( + operationId, + target.instanceId, + 'No API key is configured for authenticated peer requests', + ), + ), + ); + } else { + await Promise.all( + remoteTargets.map((target) => + this.runRemoteTarget(operationId, target, options, apiKey), + ), + ); + } + } + + await this.refreshOperation(operationId); + } + + private async runLocalTarget<TLocalResult>( + operationId: string, + target: OperationTarget, + localAction: (operationId: string) => Promise<TLocalResult>, + ) { + await this.markAckStarted(operationId, target.instanceId); + + try { + const result = await localAction(operationId); + await this.markAckSucceeded(operationId, target.instanceId, 200, null, result); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + await this.markAckFailed(operationId, target.instanceId, message); + } + } + + private async runRemoteTarget<TLocalResult>( + operationId: string, + target: OperationTarget, + options: StartClusterOperationOptions<TLocalResult>, + apiKey: string, + ) { + const url = buildClusterNodeUrl(target, options.remotePath, { + ...(options.remoteQuery ?? {}), + operationId, + }); + + if (!url) { + await this.markAckFailed( + operationId, + target.instanceId, + 'Target node does not have a valid control-plane endpoint', + ); + return; + } + + await this.markAckStarted(operationId, target.instanceId, url); + + const controller = new AbortController(); + const timeoutId = setTimeout( + () => controller.abort(), + options.executionTimeoutMs ?? this.defaultExecutionTimeoutMs, + ); + + try { + const response = await fetch(url, { + method: options.remoteMethod ?? 'POST', + headers: { + 'X-API-Key': apiKey, + ...(options.initiatedBy?.correlationId + ? { 'X-Correlation-Id': options.initiatedBy.correlationId } + : {}), + }, + signal: controller.signal, + }); + + if (!response.ok) { + await this.markAckFailed( + operationId, + target.instanceId, + await this.readRemoteError(response), + response.status, + url, + ); + return; + } + + const details = await this.readRemoteSuccess(response); + + await this.markAckSucceeded( + operationId, + target.instanceId, + response.status, + url, + details, + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + await this.markAckFailed( + operationId, + target.instanceId, + message, + null, + url, + ); + } finally { + clearTimeout(timeoutId); + } + } + + private async resolveTargets(includeRemoteNodes: boolean) { + const localInstanceId = this.distributedLock.getInstanceId(); + const activeNodes = await this.clusterHeartbeat + .getActiveNodes() + .catch((error) => { + this.logger.warn( + `[Operation] Failed to read active cluster nodes, falling back to the local node only: ${error instanceof Error ? error.message : String(error)}`, + ); + return [] as ClusterNodeTarget[]; + }); + + const targets = new Map<string, OperationTarget>(); + + for (const node of activeNodes) { + if (node.instanceId === localInstanceId || includeRemoteNodes) { + targets.set(node.instanceId, { + instanceId: node.instanceId, + hostname: node.hostname, + ipAddress: node.ipAddress, + metadata: node.metadata, + }); + } + } + + if (!targets.has(localInstanceId)) { + const localTarget = this.buildFallbackLocalTarget(); + targets.set(localTarget.instanceId, localTarget); + } + + if (!includeRemoteNodes) { + return [targets.get(localInstanceId)!]; + } + + return Array.from(targets.values()); + } + + private buildFallbackLocalTarget(): OperationTarget { + return { + instanceId: this.distributedLock.getInstanceId(), + hostname: os.hostname(), + ipAddress: null, + metadata: null, + }; + } + + private async markAckStarted( + operationId: string, + nodeInstanceId: string, + endpointUrl?: string | null, + ) { + await this.prisma.clusterOperationAck.update({ + where: { + operationId_nodeInstanceId: { + operationId, + nodeInstanceId, + }, + }, + data: { + status: 'running', + startedAt: new Date(), + ...(endpointUrl === undefined ? {} : { endpointUrl }), + }, + }); + } + + private async markAckSucceeded( + operationId: string, + nodeInstanceId: string, + responseStatus: number, + endpointUrl?: string | null, + details?: unknown, + ) { + await this.prisma.clusterOperationAck.update({ + where: { + operationId_nodeInstanceId: { + operationId, + nodeInstanceId, + }, + }, + data: { + status: 'succeeded', + responseStatus, + errorMessage: null, + ackedAt: new Date(), + details: + details === undefined + ? undefined + : this.normalizeAckDetails(details), + ...(endpointUrl === undefined ? {} : { endpointUrl }), + }, + }); + } + + private async markAckFailed( + operationId: string, + nodeInstanceId: string, + errorMessage: string, + responseStatus?: number | null, + endpointUrl?: string | null, + ) { + await this.prisma.clusterOperationAck.update({ + where: { + operationId_nodeInstanceId: { + operationId, + nodeInstanceId, + }, + }, + data: { + status: 'failed', + responseStatus: responseStatus ?? null, + errorMessage: this.truncate(errorMessage), + ackedAt: new Date(), + ...(endpointUrl === undefined ? {} : { endpointUrl }), + }, + }); + } + + private async refreshOperation(operationId: string) { + const acknowledgements = await this.prisma.clusterOperationAck.findMany({ + where: { operationId }, + select: { + status: true, + errorMessage: true, + }, + }); + + const completedNodeCount = acknowledgements.filter((ack) => + ['succeeded', 'failed'].includes(ack.status), + ).length; + const successfulNodeCount = acknowledgements.filter( + (ack) => ack.status === 'succeeded', + ).length; + const failedNodeCount = acknowledgements.filter( + (ack) => ack.status === 'failed', + ).length; + const latestError = acknowledgements.find((ack) => ack.errorMessage)?.errorMessage; + const status = this.calculateOperationStatus( + acknowledgements.length, + completedNodeCount, + successfulNodeCount, + failedNodeCount, + ); + + await this.prisma.clusterOperation.update({ + where: { id: operationId }, + data: { + status, + completedNodeCount, + successfulNodeCount, + failedNodeCount, + completedAt: + completedNodeCount === acknowledgements.length ? new Date() : null, + lastError: latestError ?? null, + }, + }); + } + + private calculateOperationStatus( + targetNodeCount: number, + completedNodeCount: number, + successfulNodeCount: number, + failedNodeCount: number, + ) { + if (targetNodeCount === 0) { + return 'succeeded'; + } + + if (completedNodeCount === 0) { + return 'pending'; + } + + if (completedNodeCount < targetNodeCount) { + return 'running'; + } + + if (failedNodeCount === 0) { + return 'succeeded'; + } + + if (successfulNodeCount === 0) { + return 'failed'; + } + + return 'partially_failed'; + } + + private async readRemoteError(response: Response) { + try { + const contentType = response.headers.get('content-type') ?? ''; + if (contentType.includes('application/json')) { + const payload = (await response.json()) as Record<string, unknown>; + const message = payload['message']; + if (typeof message === 'string' && message.trim()) { + return this.truncate(message); + } + return this.truncate(JSON.stringify(payload)); + } + + const text = await response.text(); + return this.truncate(text || response.statusText); + } catch (error) { + return this.truncate( + error instanceof Error ? error.message : response.statusText, + ); + } + } + + private async readRemoteSuccess(response: Response) { + try { + const contentType = response.headers.get('content-type') ?? ''; + if (contentType.includes('application/json')) { + return (await response.json()) as Record<string, unknown>; + } + + const text = await response.text(); + return text.trim().length > 0 ? { message: text } : null; + } catch (error) { + return { + message: error instanceof Error ? error.message : response.statusText, + }; + } + } + + private normalizeAckDetails(details: unknown) { + if (details === undefined) { + return undefined; + } + + return toPrismaNullableJsonValue(details); + } + + private toAcceptedResponse( + operation: ClusterOperationRecord, + ): ClusterOperationAcceptedResponse { + return { + operationId: operation.id, + operationType: operation.operationType, + status: operation.status, + scope: operation.scope, + targetNodeCount: operation.targetNodeCount, + completedNodeCount: operation.completedNodeCount, + successfulNodeCount: operation.successfulNodeCount, + failedNodeCount: operation.failedNodeCount, + createdAt: operation.createdAt, + startedAt: operation.startedAt, + completedAt: operation.completedAt, + correlationId: operation.correlationId, + requestPath: operation.requestPath, + operationStatusPath: `/cluster/operations/${operation.id}`, + links: { + self: `/cluster/operations/${operation.id}`, + }, + statusSummary: this.buildStatusSummary(operation), + }; + } + + private toSummary( + operation: ClusterOperationRecord, + nodeAcknowledgement?: ClusterOperationAckRecord | null, + ) { + return { + operationId: operation.id, + operationType: operation.operationType, + scope: operation.scope, + status: operation.status, + targetNodeCount: operation.targetNodeCount, + completedNodeCount: operation.completedNodeCount, + successfulNodeCount: operation.successfulNodeCount, + failedNodeCount: operation.failedNodeCount, + initiatorNodeId: operation.initiatorNodeId, + initiatorHostname: operation.initiatorHostname, + correlationId: operation.correlationId, + requestPath: operation.requestPath, + createdAt: operation.createdAt, + startedAt: operation.startedAt, + completedAt: operation.completedAt, + lastError: operation.lastError, + operationStatusPath: `/cluster/operations/${operation.id}`, + links: { + self: `/cluster/operations/${operation.id}`, + }, + statusSummary: this.buildStatusSummary(operation), + nodeAcknowledgement: nodeAcknowledgement + ? { + nodeInstanceId: nodeAcknowledgement.nodeInstanceId, + nodeHostname: nodeAcknowledgement.nodeHostname, + endpointUrl: nodeAcknowledgement.endpointUrl, + status: nodeAcknowledgement.status, + responseStatus: nodeAcknowledgement.responseStatus, + errorMessage: nodeAcknowledgement.errorMessage, + startedAt: nodeAcknowledgement.startedAt, + ackedAt: nodeAcknowledgement.ackedAt, + details: nodeAcknowledgement.details, + } + : null, + }; + } + + private toDetailedOperation(operation: ClusterOperationRecordWithAcks) { + return { + ...this.toSummary(operation), + metadata: operation.metadata, + acknowledgements: operation.acknowledgements.map((ack) => ({ + nodeInstanceId: ack.nodeInstanceId, + nodeHostname: ack.nodeHostname, + endpointUrl: ack.endpointUrl, + status: ack.status, + responseStatus: ack.responseStatus, + errorMessage: ack.errorMessage, + startedAt: ack.startedAt, + ackedAt: ack.ackedAt, + details: ack.details, + createdAt: ack.createdAt, + updatedAt: ack.updatedAt, + })), + }; + } + + private buildStatusSummary(operation: Pick< + ClusterOperationRecord, + | 'status' + | 'targetNodeCount' + | 'completedNodeCount' + | 'successfulNodeCount' + | 'failedNodeCount' + >) { + const pendingNodeCount = Math.max( + operation.targetNodeCount - operation.completedNodeCount, + 0, + ); + + return { + pendingNodeCount, + completionRatio: + operation.targetNodeCount > 0 + ? Number( + (operation.completedNodeCount / operation.targetNodeCount).toFixed(4), + ) + : 1, + isTerminal: !['pending', 'running'].includes(operation.status), + isSuccessful: operation.status === 'succeeded', + }; + } + + private normalizeOperationTypes(options: ListClusterOperationsOptions) { + const values = [ + ...(Array.isArray(options.operationTypes) ? options.operationTypes : []), + ...(options.operationType ? [options.operationType] : []), + ] + .map((value) => value.trim()) + .filter((value, index, array) => value.length > 0 && array.indexOf(value) === index); + + return values; + } + + private normalizeListLimit(limit: number) { + if (!Number.isFinite(limit) || limit <= 0) { + return 20; + } + + return Math.min(Math.floor(limit), 100); + } + + private truncate(value: string, maxLength = 500) { + return value.length > maxLength ? `${value.slice(0, maxLength - 1)}…` : value; + } +} + diff --git a/src/distributed-lock/cluster.controller.ts b/src/distributed-lock/cluster.controller.ts index 4cc288d..ca522f9 100644 --- a/src/distributed-lock/cluster.controller.ts +++ b/src/distributed-lock/cluster.controller.ts @@ -1,88 +1,195 @@ import { Controller, Get, + HttpStatus, Logger, + Param, Post, Query, + Req, + Res, UseGuards, } from '@nestjs/common'; +import * as os from 'os'; +import { Response } from 'express'; import { ClusterHeartbeatService } from './cluster-heartbeat.service'; +import { ClusterOperationsService } from './cluster-operations.service'; import { DistributedLockService } from './distributed-lock.service'; import { ReloaderService } from '../reloader/reloader.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { + AuthorizeAdmin, + AuthorizeInternalNodeOrAdmin, +} from '../auth/decorators/authorize.decorator'; import { ApiKeyGuard } from '../auth/guards/api-key.guard'; +import { + getClusterNodeControlPlaneEndpoint, +} from '../utils/network-utils'; +import { Audit } from '../audit/decorators/audit.decorator'; +import { AuthenticatedRequest } from '../auth/interfaces/authenticated-request.interface'; +import { parseDomains } from '../utils/domain-utils'; @Controller('cluster') @UseGuards(ApiKeyGuard) +@AuthorizeAdmin('viewer') export class ClusterController { private readonly logger = new Logger(ClusterController.name); constructor( private readonly clusterHeartbeat: ClusterHeartbeatService, + private readonly clusterOperations: ClusterOperationsService, private readonly distributedLock: DistributedLockService, private readonly reloader: ReloaderService, + private readonly prisma: PrismaService, ) {} + @Get('status') + async getStatus(@Query('operationLimit') operationLimit?: string) { + const [stats, leaderStatus, nodes, operations] = await Promise.all([ + this.clusterHeartbeat.getClusterStats(), + this.getLeaderStatus(), + this.getNodes(), + this.clusterOperations.listOperations({ + limit: this.parseLimit(operationLimit, 10), + }), + ]); + + return { + status: leaderStatus.status === 'healthy' ? 'ok' : 'attention', + generatedAt: new Date().toISOString(), + cluster: stats, + leader: leaderStatus, + nodes, + operations, + links: { + nodes: '/cluster/nodes', + leader: '/cluster/leader', + lease: '/cluster/lease', + operations: '/cluster/operations', + }, + }; + } + /** * Trigger a reload on this node and optionally broadcast to others */ @Post('reload') - async reload(@Query('broadcast') broadcast: string) { - const shouldBroadcast = broadcast !== 'false'; + @AuthorizeInternalNodeOrAdmin('operator') + @Audit({ action: 'cluster.reload' }) + async reload( + @Query('broadcast') broadcast: string, + @Query('operationId') operationId: string | undefined, + @Req() request: AuthenticatedRequest, + @Res({ passthrough: true }) response: Response, + ) { + if (operationId) { + const localResult = await this.reloader.reloadConfig(); - this.logger.log(`[Reload] Triggered. Broadcast: ${shouldBroadcast}`); + if (!localResult.ok) { + response.status(HttpStatus.INTERNAL_SERVER_ERROR); + return { + operationId, + status: 'failed', + node: this.getLocalNodeInfo(), + error: localResult.error ?? 'Reload failed', + }; + } - // Reload local - const result = await this.reloader.reloadConfig(); + return { + operationId, + status: 'succeeded', + node: this.getLocalNodeInfo(), + runtime: await this.safeReadLocalRuntimeReleaseStatus(), + }; + } - if (shouldBroadcast) { - // Get other active nodes - const nodes = await this.clusterHeartbeat.getActiveNodes(); - const thisInstanceId = this.distributedLock.getInstanceId(); + const shouldBroadcast = broadcast !== 'false'; + this.logger.log(`[Reload] Queued. Broadcast: ${shouldBroadcast}`); - const otherNodes = nodes.filter( - (n) => n.instanceId !== thisInstanceId && n.ipAddress, - ); + const operation = await this.clusterOperations.enqueueBroadcastOperation({ + operationType: 'cluster.reload', + broadcast: shouldBroadcast, + remotePath: '/cluster/reload', + remoteQuery: { broadcast: 'false' }, + initiatedBy: { + auth: request.auth, + correlationId: request.auditContext?.correlationId, + requestPath: request.originalUrl ?? request.url ?? '/cluster/reload', + }, + metadata: { + broadcast: shouldBroadcast, + }, + localAction: async () => { + const localResult = await this.reloader.reloadConfig(); + if (!localResult.ok) { + throw new Error(localResult.error ?? 'Reload failed'); + } + return { + ...localResult, + runtime: await this.safeReadLocalRuntimeReleaseStatus(), + }; + }, + }); - this.logger.log( - `[Reload] Broadcasting to ${otherNodes.length} other nodes...`, - ); + response.status(HttpStatus.ACCEPTED); + return operation; + } - // Get API key from environment for inter-node communication - const apiKey = process.env.API_KEY?.split(',')[0]; // Use first API key + @Get('operations') + async getOperations( + @Query('limit') limit?: string, + @Query('status') status?: string, + @Query('type') operationType?: string, + @Query('nodeId') nodeId?: string, + ) { + const resolvedNode = nodeId ? await this.findNodeRecord(nodeId) : null; - // Fire and forget broadcast - otherNodes.forEach(async (node) => { - try { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 5000); + if (nodeId && !resolvedNode) { + return { + count: 0, + filters: { + requestedNodeId: nodeId, + resolvedNodeId: null, + found: false, + status: status ?? null, + operationTypes: operationType ? [operationType] : [], + }, + operations: [], + }; + } - // Assuming default port 3000 if not specified in env - // In a real scenario, we might want to store the port in ClusterNode metadata - const port = process.env.PORT || 3000; - const url = `http://${node.ipAddress}:${port}/cluster/reload?broadcast=false`; + const operations = await this.clusterOperations.listOperations({ + limit: this.parseLimit(limit, 20), + status, + operationType, + targetNodeId: resolvedNode?.instanceId, + }); - this.logger.debug(`[Reload] Calling ${url}`); + return { + ...operations, + filters: { + ...operations.filters, + requestedNodeId: nodeId ?? null, + resolvedNodeId: resolvedNode?.instanceId ?? null, + }, + }; + } - await fetch(url, { - method: 'POST', - headers: { - 'X-API-Key': apiKey || '', - }, - signal: controller.signal, - }); + @Get('operations/:operationId') + async getOperation(@Param('operationId') operationId: string) { + const operation = await this.clusterOperations.getOperation(operationId); - clearTimeout(timeoutId); - } catch (e) { - this.logger.error( - `[Reload] Failed to broadcast to ${node.hostname} (${node.ipAddress}): ${e.message}`, - ); - } - }); + if (!operation) { + return { + operationId, + found: false, + message: 'Cluster operation not found', + }; } return { - local: result, - broadcast: shouldBroadcast, + found: true, + ...operation, }; } @@ -90,21 +197,86 @@ export class ClusterController { * Get all active cluster nodes */ @Get('nodes') - async getNodes() { - const nodes = await this.clusterHeartbeat.getActiveNodes(); + async getNodes(@Query('includeInactive') includeInactive?: string) { + const nodes = await this.listNodes(includeInactive === 'true'); return { count: nodes.length, - nodes: nodes.map((node) => ({ - id: node.id, - hostname: node.hostname, - instanceId: node.instanceId, - ipAddress: node.ipAddress, - isLeader: node.isLeader, - status: node.status, - lastHeartbeat: node.lastHeartbeat, - version: node.version, - metadata: node.metadata, - })), + includeInactive: includeInactive === 'true', + nodes: nodes.map((node) => this.toNodeSummary(node)), + }; + } + + @Get('nodes/:nodeId/config') + async getNodeConfig(@Param('nodeId') nodeId: string) { + const node = await this.findNodeRecord(nodeId); + + if (!node) { + return { + found: false, + nodeId, + message: 'Cluster node not found', + }; + } + + return { + found: true, + node: this.toNodeSummary(node), + config: await this.buildNodeConfigState(node), + }; + } + + @Get('nodes/:nodeId/certificates') + async getNodeCertificates(@Param('nodeId') nodeId: string) { + const node = await this.findNodeRecord(nodeId); + + if (!node) { + return { + found: false, + nodeId, + message: 'Cluster node not found', + }; + } + + return { + found: true, + node: this.toNodeSummary(node), + certificates: await this.buildNodeCertificateState(node), + }; + } + + @Get('nodes/:nodeId') + async getNode(@Param('nodeId') nodeId: string) { + const node = await this.findNodeRecord(nodeId); + + if (!node) { + return { + found: false, + nodeId, + message: 'Cluster node not found', + }; + } + + const [config, certificates, operations] = await Promise.all([ + this.buildNodeConfigState(node), + this.buildNodeCertificateState(node), + this.clusterOperations.listOperations({ + limit: 10, + targetNodeId: node.instanceId, + }), + ]); + + return { + found: true, + node: this.toNodeSummary(node), + config, + certificates, + operations, + links: { + self: `/cluster/nodes/${node.instanceId}`, + config: `/cluster/nodes/${node.instanceId}/config`, + certificates: `/cluster/nodes/${node.instanceId}/certificates`, + operations: `/cluster/operations?nodeId=${encodeURIComponent(node.instanceId)}`, + }, }; } @@ -131,6 +303,7 @@ export class ClusterController { hostname: leader.hostname, instanceId: leader.instanceId, ipAddress: leader.ipAddress, + controlPlane: getClusterNodeControlPlaneEndpoint(leader), lastHeartbeat: leader.lastHeartbeat, version: leader.version, metadata: leader.metadata, @@ -143,23 +316,73 @@ export class ClusterController { */ @Get('leader/status') async getLeaderStatus() { - const dbLeader = await this.clusterHeartbeat.getLeaderNode(); + const leaderState = await this.clusterHeartbeat.getLeaderLeaseState(); + const dbLeader = leaderState.activeLeaderNode; const lockStatus = this.distributedLock.getLeaderLockStatus(); - const allLeaders = await this.clusterHeartbeat - .getClusterStats() - .then((stats) => stats.leaders); + const lease = leaderState.lease; + const allLeaders = dbLeader ? [dbLeader] : []; + const hasActiveLease = leaderState.hasActiveLease; + const dbMatchesLease = Boolean( + dbLeader && hasActiveLease && lease?.ownerNodeId === dbLeader.instanceId, + ); + const issues = [ + ...leaderState.issues, + !hasActiveLease && 'NO_ACTIVE_LEASE', + hasActiveLease && !dbLeader && 'LEASE_WITHOUT_ACTIVE_NODE', + hasActiveLease && dbLeader && !dbMatchesLease && 'LEASE_DB_MISMATCH', + lockStatus.isLeader && + lease && + lockStatus.generation !== lease.generation && + 'LOCAL_LEASE_STALE', + lockStatus.isLeader && + lease?.ownerNodeId && + lockStatus.ownerNodeId !== lease.ownerNodeId && + 'LOCAL_OWNER_MISMATCH', + ].filter(Boolean); const isConsistent = - allLeaders.length === 1 && - (!dbLeader || - (lockStatus.isLeader && dbLeader.instanceId === lockStatus.instanceId)); + issues.length === 0 && hasActiveLease && dbMatchesLease; return { - status: isConsistent ? 'healthy' : 'inconsistent', + status: isConsistent + ? 'healthy' + : hasActiveLease + ? 'degraded' + : 'no-leader', + lease: lease + ? { + leaseName: lease.leaseName, + ownerNodeId: lease.ownerNodeId, + ownerHostname: lease.ownerHostname, + generation: lease.generation, + fencingToken: lease.fencingToken, + ttlSeconds: lease.ttlSeconds, + acquiredAt: lease.acquiredAt, + renewedAt: lease.renewedAt, + expiresAt: lease.expiresAt, + isExpired: lease.isExpired, + isHeldByThisInstance: lease.isHeldByThisInstance, + } + : null, lockHolder: lockStatus.isLeader ? { - instanceId: lockStatus.instanceId, + instanceId: lockStatus.ownerNodeId ?? lockStatus.instanceId, heldForMs: lockStatus.heldForMs, + generation: lockStatus.generation, + fencingToken: lockStatus.fencingToken, + expiresAt: lockStatus.expiresAt, + } + : null, + leaseOwnerRecord: leaderState.ownerNode + ? { + hostname: leaderState.ownerNode.hostname, + instanceId: leaderState.ownerNode.instanceId, + ipAddress: leaderState.ownerNode.ipAddress, + controlPlane: getClusterNodeControlPlaneEndpoint( + leaderState.ownerNode, + ), + status: leaderState.ownerNode.status, + lastHeartbeat: leaderState.ownerNode.lastHeartbeat, } : null, dbLeader: dbLeader @@ -167,6 +390,7 @@ export class ClusterController { hostname: dbLeader.hostname, instanceId: dbLeader.instanceId, ipAddress: dbLeader.ipAddress, + controlPlane: getClusterNodeControlPlaneEndpoint(dbLeader), lastHeartbeat: dbLeader.lastHeartbeat, status: dbLeader.status, } @@ -175,19 +399,23 @@ export class ClusterController { hostname: l.hostname, instanceId: l.instanceId, ipAddress: l.ipAddress, + controlPlane: getClusterNodeControlPlaneEndpoint(l), status: l.status, lastHeartbeat: l.lastHeartbeat, })), - issues: [ - allLeaders.length === 0 && 'NO_LEADER', - allLeaders.length > 1 && 'MULTIPLE_LEADERS', - lockStatus.isLeader && !dbLeader && 'LOCK_WITHOUT_DB_ENTRY', - !lockStatus.isLeader && dbLeader && 'DB_ENTRY_WITHOUT_LOCK', - lockStatus.isLeader && - dbLeader && - dbLeader.instanceId !== lockStatus.instanceId && - 'LOCK_DB_MISMATCH', - ].filter(Boolean), + issues, + }; + } + + /** + * Get the current leader lease and fencing-token state. + */ + @Get('lease') + async getLease() { + const lease = await this.distributedLock.getLeaderLeaseSnapshot(); + return { + lease, + local: this.distributedLock.getLeaderLockStatus(), }; } @@ -195,6 +423,8 @@ export class ClusterController { * Manually trigger cleanup of stale nodes */ @Get('admin/cleanup') + @AuthorizeAdmin('platform-admin') + @Audit({ action: 'cluster.cleanup' }) async manualCleanup() { return this.clusterHeartbeat.manualCleanup(); } @@ -203,6 +433,8 @@ export class ClusterController { * Manually enforce single leader (fixes split-brain) */ @Get('admin/enforce-leader') + @AuthorizeAdmin('platform-admin') + @Audit({ action: 'cluster.enforce-leader' }) async manualEnforceLeader() { return this.clusterHeartbeat.manualEnforceLeader(); } @@ -211,6 +443,8 @@ export class ClusterController { * Check and ensure leader exists (auto-elects if needed) */ @Get('admin/ensure-leader') + @AuthorizeAdmin('platform-admin') + @Audit({ action: 'cluster.ensure-leader' }) async ensureLeader() { try { await this.clusterHeartbeat.ensureLeaderExists(); @@ -240,6 +474,8 @@ export class ClusterController { * Try to make this node the leader */ @Get('admin/become-leader') + @AuthorizeAdmin('platform-admin') + @Audit({ action: 'cluster.become-leader' }) async tryBecomeLeader() { try { const success = await this.clusterHeartbeat.tryBecomeLeader(); @@ -256,4 +492,193 @@ export class ClusterController { }; } } + + private getLocalNodeInfo() { + return { + instanceId: this.distributedLock.getInstanceId(), + hostname: os.hostname(), + }; + } + + private async listNodes(includeInactive: boolean) { + if (!includeInactive) { + return this.clusterHeartbeat.getActiveNodes(); + } + + return this.prisma.clusterNode.findMany({ + orderBy: [{ status: 'asc' }, { lastHeartbeat: 'desc' }], + }); + } + + private async findNodeRecord(nodeId: string) { + const trimmed = nodeId.trim(); + + if (!trimmed) { + return null; + } + + return this.prisma.clusterNode.findFirst({ + where: { + OR: [ + { id: trimmed }, + { instanceId: trimmed }, + { hostname: trimmed }, + ], + }, + }); + } + + private toNodeSummary(node: { + id: string; + hostname: string; + instanceId: string; + ipAddress: string | null; + isLeader: boolean; + status: string; + lastHeartbeat: Date; + version: string | null; + metadata: unknown; + }) { + return { + id: node.id, + hostname: node.hostname, + instanceId: node.instanceId, + ipAddress: node.ipAddress, + controlPlane: getClusterNodeControlPlaneEndpoint(node), + isLeader: node.isLeader, + status: node.status, + lastHeartbeat: node.lastHeartbeat, + version: node.version, + metadata: node.metadata, + isLocal: node.instanceId === this.distributedLock.getInstanceId(), + }; + } + + private async buildNodeConfigState(node: { + instanceId: string; + hostname: string; + }) { + const operations = await this.clusterOperations.listOperations({ + limit: 5, + targetNodeId: node.instanceId, + operationType: 'cluster.reload', + }); + const latestReload = operations.operations[0] ?? null; + + return { + nodeInstanceId: node.instanceId, + source: + node.instanceId === this.distributedLock.getInstanceId() + ? ['operation-journal', 'local-runtime'] + : ['operation-journal'], + latestReload, + runtime: + node.instanceId === this.distributedLock.getInstanceId() + ? await this.safeReadLocalRuntimeReleaseStatus() + : null, + }; + } + + private async buildNodeCertificateState(node: { + instanceId: string; + }) { + const operations = await this.clusterOperations.listOperations({ + limit: 10, + targetNodeId: node.instanceId, + operationTypes: [ + 'certificate.activate', + 'certificate.rollback', + 'certificate.sync', + ], + }); + const latestActivation = + operations.operations.find((operation) => + ['certificate.activate', 'certificate.rollback'].includes( + operation.operationType, + ), + ) ?? null; + const latestSync = + operations.operations.find( + (operation) => operation.operationType === 'certificate.sync', + ) ?? null; + + return { + nodeInstanceId: node.instanceId, + source: + node.instanceId === this.distributedLock.getInstanceId() + ? ['operation-journal', 'cluster-database'] + : ['operation-journal'], + latestActivation, + latestSync, + activeCertificates: + node.instanceId === this.distributedLock.getInstanceId() + ? await this.readLocalCertificateInventory() + : null, + }; + } + + private async safeReadLocalRuntimeReleaseStatus() { + try { + return await this.reloader.getRuntimeReleaseStatus(); + } catch (error) { + return { + readError: error instanceof Error ? error.message : String(error), + }; + } + } + + private async readLocalCertificateInventory() { + try { + const [count, certificates] = await Promise.all([ + this.prisma.certificate.count(), + this.prisma.certificate.findMany({ + take: 5, + orderBy: { expiresAt: 'asc' }, + }), + ]); + + const now = Date.now(); + + return { + count, + nextExpiryAt: certificates[0]?.expiresAt ?? null, + certificates: certificates.map((certificate) => { + const daysUntilExpiry = Math.ceil( + (certificate.expiresAt.getTime() - now) / (1000 * 60 * 60 * 24), + ); + + return { + id: certificate.id, + domains: parseDomains(certificate.domains, { allowWildcard: true }), + expiresAt: certificate.expiresAt, + issuedAt: certificate.issuedAt, + lastUsedAt: certificate.lastUsedAt, + status: + daysUntilExpiry < 0 + ? 'expired' + : daysUntilExpiry <= 30 + ? 'expiring_soon' + : 'valid', + daysUntilExpiry, + }; + }), + }; + } catch (error) { + return { + count: 0, + nextExpiryAt: null, + certificates: [], + readError: error instanceof Error ? error.message : String(error), + }; + } + } + + private parseLimit(value: string | undefined, fallback: number) { + if (!value) { + return fallback; + } + + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; + } } diff --git a/src/distributed-lock/distributed-lock.module.ts b/src/distributed-lock/distributed-lock.module.ts index 51f5bef..b869d2e 100644 --- a/src/distributed-lock/distributed-lock.module.ts +++ b/src/distributed-lock/distributed-lock.module.ts @@ -1,6 +1,7 @@ import { Global, Module } from '@nestjs/common'; import { DistributedLockService } from './distributed-lock.service'; import { ClusterHeartbeatService } from './cluster-heartbeat.service'; +import { ClusterOperationsService } from './cluster-operations.service'; import { ClusterController } from './cluster.controller'; import { PrismaModule } from '../prisma/prisma.module'; import { ReloaderModule } from '../reloader/reloader.module'; @@ -10,7 +11,15 @@ import { AuthModule } from '../auth/auth.module'; @Module({ imports: [PrismaModule, ReloaderModule, AuthModule], controllers: [ClusterController], - providers: [DistributedLockService, ClusterHeartbeatService], - exports: [DistributedLockService, ClusterHeartbeatService], + providers: [ + DistributedLockService, + ClusterHeartbeatService, + ClusterOperationsService, + ], + exports: [ + DistributedLockService, + ClusterHeartbeatService, + ClusterOperationsService, + ], }) export class DistributedLockModule {} diff --git a/src/distributed-lock/distributed-lock.service.ts b/src/distributed-lock/distributed-lock.service.ts index 53b2f19..6ac8726 100644 --- a/src/distributed-lock/distributed-lock.service.ts +++ b/src/distributed-lock/distributed-lock.service.ts @@ -1,15 +1,64 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common'; +import { randomUUID } from 'node:crypto'; +import * as os from 'os'; import { PrismaService } from '../prisma/prisma.service'; +const LEADER_LEASE_NAME = 'cluster:leader'; +const DEFAULT_LEASE_TTL_SECONDS = 30; +const MIN_LEASE_TTL_SECONDS = 5; +const MIN_RENEW_INTERVAL_MS = 1000; + +type ClusterLeaseRecord = { + id: string; + leaseName: string; + ownerNodeId: string | null; + ownerHostname: string | null; + generation: number; + ttlSeconds: number; + acquiredAt: Date; + renewedAt: Date; + expiresAt: Date; + metadata: unknown; + createdAt: Date; + updatedAt: Date; +}; + +type LeaseSnapshot = { + leaseName: string; + ownerNodeId: string | null; + ownerHostname: string | null; + generation: number; + ttlSeconds: number; + acquiredAt: Date; + renewedAt: Date; + expiresAt: Date; + isExpired: boolean; + isHeldByThisInstance: boolean; + fencingToken: number; +}; + +type AcquireLeaseOptions = { + ttlSeconds?: number; + autoRenew?: boolean; + renewIntervalMs?: number; +}; + +type HeldLease = LeaseSnapshot & { + autoRenew: boolean; + renewIntervalMs: number; + renewalTimer: ReturnType<typeof setInterval> | null; +}; + /** * Distributed lock service using PostgreSQL advisory locks * Ensures only one node in the cluster can perform critical operations */ @Injectable() -export class DistributedLockService { +export class DistributedLockService implements OnModuleDestroy { private readonly logger = new Logger(DistributedLockService.name); private readonly instanceId: string; private heldLocks = new Map<string, { lockId: number; acquiredAt: number }>(); + private heldLeases = new Map<string, HeldLease>(); constructor(private prisma: PrismaService) { // Generate unique instance ID for this container @@ -17,14 +66,15 @@ export class DistributedLockService { this.logger.log(`[Init] Instance ID: ${this.instanceId}`); } + async onModuleDestroy() { + await this.releaseAllLocks(); + } + /** * Acquire an advisory lock with timeout * Returns true if lock was acquired, false otherwise */ - async tryAcquireLock( - lockName: string, - timeoutMs: number = 5000, - ): Promise<boolean> { + async tryAcquireLock(lockName: string): Promise<boolean> { const lockId = this.stringToLockId(lockName); const startTime = Date.now(); @@ -103,11 +153,11 @@ export class DistributedLockService { maxRetries?: number; } = {}, ): Promise<T | null> { - const { timeoutMs = 5000, retryDelayMs = 1000, maxRetries = 3 } = options; + const { retryDelayMs = 1000, maxRetries = 3 } = options; let retries = 0; while (retries < maxRetries) { - const acquired = await this.tryAcquireLock(lockName, timeoutMs); + const acquired = await this.tryAcquireLock(lockName); if (acquired) { try { @@ -138,11 +188,7 @@ export class DistributedLockService { * Returns true if we are holding the leader lock */ async isLeader(): Promise<boolean> { - const lockName = 'cluster:leader'; - - // Simply check if we have the leader lock in our held locks - // Don't try to acquire it - that would release it immediately! - return this.heldLocks.has(lockName); + return this.hasActiveHeldLease(LEADER_LEASE_NAME); } /** @@ -151,8 +197,10 @@ export class DistributedLockService { * Does NOT wait or retry */ async tryAcquireLeaderLock(): Promise<boolean> { - const lockName = 'cluster:leader'; - return this.tryAcquireLock(lockName, 0); + const lease = await this.acquireLease(LEADER_LEASE_NAME, { + autoRenew: true, + }); + return lease !== null; } /** @@ -161,12 +209,14 @@ export class DistributedLockService { * CRITICAL: Uses PostgreSQL advisory lock for true distributed locking */ async acquireLeaderLock(): Promise<boolean> { - const lockName = 'cluster:leader'; - const acquired = await this.tryAcquireLock(lockName, 5000); + const lease = await this.acquireLease(LEADER_LEASE_NAME, { + autoRenew: true, + }); + const acquired = lease !== null; if (acquired) { this.logger.log( - '[LeaderLock] Successfully acquired leader lock - this node is now the leader', + `[LeaderLock] Successfully acquired leader lease generation ${lease?.generation} - this node is now the leader`, ); } @@ -177,7 +227,289 @@ export class DistributedLockService { * Release leader lock */ async releaseLeaderLock(): Promise<void> { - await this.releaseLock('cluster:leader'); + await this.releaseLease(LEADER_LEASE_NAME); + } + + async acquireLease( + leaseName: string, + options: AcquireLeaseOptions = {}, + ): Promise<LeaseSnapshot | null> { + const ttlSeconds = this.resolveLeaseTtlSeconds(options.ttlSeconds); + + try { + const result = await this.prisma.$queryRaw<ClusterLeaseRecord[]>` + INSERT INTO "ClusterLease" ( + "id", + "leaseName", + "ownerNodeId", + "ownerHostname", + "generation", + "ttlSeconds", + "acquiredAt", + "renewedAt", + "expiresAt" + ) + VALUES ( + ${randomUUID()}, + ${leaseName}, + ${this.instanceId}, + ${os.hostname()}, + 1, + ${ttlSeconds}, + NOW(), + NOW(), + NOW() + ${ttlSeconds} * interval '1 second' + ) + ON CONFLICT ("leaseName") DO UPDATE + SET + "ownerNodeId" = EXCLUDED."ownerNodeId", + "ownerHostname" = EXCLUDED."ownerHostname", + "generation" = CASE + WHEN "ClusterLease"."ownerNodeId" = EXCLUDED."ownerNodeId" + THEN "ClusterLease"."generation" + ELSE "ClusterLease"."generation" + 1 + END, + "ttlSeconds" = EXCLUDED."ttlSeconds", + "acquiredAt" = CASE + WHEN "ClusterLease"."ownerNodeId" = EXCLUDED."ownerNodeId" + THEN "ClusterLease"."acquiredAt" + ELSE NOW() + END, + "renewedAt" = NOW(), + "expiresAt" = NOW() + ${ttlSeconds} * interval '1 second', + "updatedAt" = NOW() + WHERE + "ClusterLease"."expiresAt" <= NOW() + OR "ClusterLease"."ownerNodeId" IS NULL + OR "ClusterLease"."ownerNodeId" = EXCLUDED."ownerNodeId" + RETURNING + "id", + "leaseName", + "ownerNodeId", + "ownerHostname", + "generation", + "ttlSeconds", + "acquiredAt", + "renewedAt", + "expiresAt", + "metadata", + "createdAt", + "updatedAt" + `; + + const row = result[0]; + if (!row) { + this.logger.debug( + `[Lease] Failed to acquire lease "${leaseName}" - held by another active node`, + ); + return null; + } + + const snapshot = this.toLeaseSnapshot(row); + this.trackHeldLease(leaseName, snapshot, options); + this.logger.log( + `[Lease] Acquired lease "${leaseName}" with generation ${snapshot.generation} (ttl ${snapshot.ttlSeconds}s)`, + ); + return snapshot; + } catch (error) { + this.logger.error( + `[Lease] Error acquiring lease "${leaseName}": ${error instanceof Error ? error.message : String(error)}`, + ); + return null; + } + } + + async renewLease( + leaseName: string, + options: Pick<AcquireLeaseOptions, 'ttlSeconds'> = {}, + ): Promise<LeaseSnapshot | null> { + const heldLease = this.heldLeases.get(leaseName); + if (!heldLease) { + this.logger.warn( + `[Lease] Attempted to renew lease "${leaseName}" that is not held locally`, + ); + return null; + } + + const ttlSeconds = this.resolveLeaseTtlSeconds( + options.ttlSeconds ?? heldLease.ttlSeconds, + ); + + try { + const result = await this.prisma.$queryRaw<ClusterLeaseRecord[]>` + UPDATE "ClusterLease" + SET + "ttlSeconds" = ${ttlSeconds}, + "renewedAt" = NOW(), + "expiresAt" = NOW() + ${ttlSeconds} * interval '1 second', + "updatedAt" = NOW() + WHERE + "leaseName" = ${leaseName} + AND "ownerNodeId" = ${this.instanceId} + AND "generation" = ${heldLease.generation} + RETURNING + "id", + "leaseName", + "ownerNodeId", + "ownerHostname", + "generation", + "ttlSeconds", + "acquiredAt", + "renewedAt", + "expiresAt", + "metadata", + "createdAt", + "updatedAt" + `; + + const row = result[0]; + if (!row) { + this.logger.warn( + `[Lease] Lost lease "${leaseName}" while attempting renewal for generation ${heldLease.generation}`, + ); + this.untrackHeldLease(leaseName); + return null; + } + + const snapshot = this.toLeaseSnapshot(row); + this.trackHeldLease(leaseName, snapshot, { + autoRenew: heldLease.autoRenew, + renewIntervalMs: heldLease.renewIntervalMs, + }); + this.logger.debug( + `[Lease] Renewed lease "${leaseName}" generation ${snapshot.generation} until ${snapshot.expiresAt.toISOString()}`, + ); + return snapshot; + } catch (error) { + this.logger.error( + `[Lease] Error renewing lease "${leaseName}": ${error instanceof Error ? error.message : String(error)}`, + ); + return null; + } + } + + async releaseLease(leaseName: string): Promise<boolean> { + const heldLease = this.heldLeases.get(leaseName); + if (!heldLease) { + this.logger.warn( + `[Lease] Attempted to release lease "${leaseName}" that is not held locally`, + ); + return false; + } + + try { + const result = await this.prisma.$queryRaw<ClusterLeaseRecord[]>` + UPDATE "ClusterLease" + SET + "ownerNodeId" = NULL, + "ownerHostname" = NULL, + "renewedAt" = NOW(), + "expiresAt" = NOW(), + "updatedAt" = NOW() + WHERE + "leaseName" = ${leaseName} + AND "ownerNodeId" = ${this.instanceId} + AND "generation" = ${heldLease.generation} + RETURNING + "id", + "leaseName", + "ownerNodeId", + "ownerHostname", + "generation", + "ttlSeconds", + "acquiredAt", + "renewedAt", + "expiresAt", + "metadata", + "createdAt", + "updatedAt" + `; + + const released = result.length > 0; + this.untrackHeldLease(leaseName); + + if (released) { + this.logger.log( + `[Lease] Released lease "${leaseName}" generation ${heldLease.generation}`, + ); + } else { + this.logger.warn( + `[Lease] Lease "${leaseName}" was no longer owned by this node during release`, + ); + } + + return released; + } catch (error) { + this.logger.error( + `[Lease] Error releasing lease "${leaseName}": ${error instanceof Error ? error.message : String(error)}`, + ); + this.untrackHeldLease(leaseName); + return false; + } + } + + async getLeaseSnapshot(leaseName: string): Promise<LeaseSnapshot | null> { + try { + const result = await this.prisma.$queryRaw<ClusterLeaseRecord[]>` + SELECT + "id", + "leaseName", + "ownerNodeId", + "ownerHostname", + "generation", + "ttlSeconds", + "acquiredAt", + "renewedAt", + "expiresAt", + "metadata", + "createdAt", + "updatedAt" + FROM "ClusterLease" + WHERE "leaseName" = ${leaseName} + LIMIT 1 + `; + + const lease = result[0] ?? null; + + return lease ? this.toLeaseSnapshot(lease) : null; + } catch (error) { + this.logger.error( + `[Lease] Error reading lease "${leaseName}": ${error instanceof Error ? error.message : String(error)}`, + ); + return null; + } + } + + async getLeaderLeaseSnapshot(): Promise<LeaseSnapshot | null> { + return this.getLeaseSnapshot(LEADER_LEASE_NAME); + } + + async validateLeaseFenceToken( + leaseName: string, + expectedGeneration: number, + ownerNodeId: string = this.instanceId, + ): Promise<boolean> { + const lease = await this.getLeaseSnapshot(leaseName); + if (!lease) { + return false; + } + + return ( + !lease.isExpired && + lease.ownerNodeId === ownerNodeId && + lease.generation === expectedGeneration + ); + } + + async validateLeaderFenceToken( + expectedGeneration: number, + ownerNodeId?: string, + ): Promise<boolean> { + return this.validateLeaseFenceToken( + LEADER_LEASE_NAME, + expectedGeneration, + ownerNodeId, + ); } /** @@ -186,13 +518,18 @@ export class DistributedLockService { */ async releaseAllLocks(): Promise<void> { const locks = Array.from(this.heldLocks.keys()); + const leases = Array.from(this.heldLeases.keys()); this.logger.log( - `[Shutdown] Releasing ${locks.length} held lock(s): ${locks.join(', ')}`, + `[Shutdown] Releasing ${locks.length} advisory lock(s) and ${leases.length} lease(s)`, ); for (const lockName of locks) { await this.releaseLock(lockName); } + + for (const leaseName of leases) { + await this.releaseLease(leaseName); + } } /** @@ -237,14 +574,134 @@ export class DistributedLockService { isLeader: boolean; heldForMs: number | null; instanceId: string; + ownerNodeId: string | null; + generation: number | null; + fencingToken: number | null; + expiresAt: Date | null; } { - const lockName = 'cluster:leader'; - const lockInfo = this.heldLocks.get(lockName); + const lease = this.getActiveHeldLease(LEADER_LEASE_NAME); return { - isLeader: lockInfo !== undefined, - heldForMs: lockInfo ? Date.now() - lockInfo.acquiredAt : null, + isLeader: lease !== null, + heldForMs: lease ? Date.now() - lease.acquiredAt.getTime() : null, instanceId: this.instanceId, + ownerNodeId: lease?.ownerNodeId ?? null, + generation: lease?.generation ?? null, + fencingToken: lease?.fencingToken ?? null, + expiresAt: lease?.expiresAt ?? null, }; } + + private resolveLeaseTtlSeconds(ttlSeconds?: number): number { + const configured = Number.parseInt( + String(ttlSeconds ?? process.env.CLUSTER_LEASE_TTL_SECONDS ?? ''), + 10, + ); + + if (Number.isInteger(configured) && configured >= MIN_LEASE_TTL_SECONDS) { + return configured; + } + + return DEFAULT_LEASE_TTL_SECONDS; + } + + private resolveRenewIntervalMs( + ttlSeconds: number, + renewIntervalMs?: number, + ): number { + const configured = Number.parseInt( + String( + renewIntervalMs ?? process.env.CLUSTER_LEASE_RENEW_INTERVAL_MS ?? '', + ), + 10, + ); + + if (Number.isInteger(configured) && configured >= MIN_RENEW_INTERVAL_MS) { + return configured; + } + + return Math.max(MIN_RENEW_INTERVAL_MS, Math.floor((ttlSeconds * 1000) / 3)); + } + + private toLeaseSnapshot(lease: ClusterLeaseRecord): LeaseSnapshot { + return { + leaseName: lease.leaseName, + ownerNodeId: lease.ownerNodeId, + ownerHostname: lease.ownerHostname, + generation: lease.generation, + ttlSeconds: lease.ttlSeconds, + acquiredAt: new Date(lease.acquiredAt), + renewedAt: new Date(lease.renewedAt), + expiresAt: new Date(lease.expiresAt), + isExpired: new Date(lease.expiresAt).getTime() <= Date.now(), + isHeldByThisInstance: lease.ownerNodeId === this.instanceId, + fencingToken: lease.generation, + }; + } + + private trackHeldLease( + leaseName: string, + snapshot: LeaseSnapshot, + options: AcquireLeaseOptions, + ) { + const existing = this.heldLeases.get(leaseName); + if (existing?.renewalTimer) { + clearInterval(existing.renewalTimer); + } + + const autoRenew = options.autoRenew ?? existing?.autoRenew ?? false; + const renewIntervalMs = this.resolveRenewIntervalMs( + snapshot.ttlSeconds, + options.renewIntervalMs ?? existing?.renewIntervalMs, + ); + + const heldLease: HeldLease = { + ...snapshot, + autoRenew, + renewIntervalMs, + renewalTimer: null, + }; + + if (autoRenew) { + heldLease.renewalTimer = setInterval(() => { + this.renewLease(leaseName).catch((error) => + this.logger.error( + `[Lease] Auto-renew failed for "${leaseName}": ${error instanceof Error ? error.message : String(error)}`, + ), + ); + }, renewIntervalMs); + } + + this.heldLeases.set(leaseName, heldLease); + } + + private untrackHeldLease(leaseName: string) { + const heldLease = this.heldLeases.get(leaseName); + if (heldLease?.renewalTimer) { + clearInterval(heldLease.renewalTimer); + } + + this.heldLeases.delete(leaseName); + } + + private getActiveHeldLease(leaseName: string): HeldLease | null { + const heldLease = this.heldLeases.get(leaseName); + if (!heldLease) { + return null; + } + + if (heldLease.expiresAt.getTime() <= Date.now()) { + this.logger.warn( + `[Lease] Local lease "${leaseName}" expired before release/renewal completed`, + ); + this.untrackHeldLease(leaseName); + return null; + } + + return heldLease; + } + + private hasActiveHeldLease(leaseName: string): boolean { + return this.getActiveHeldLease(leaseName) !== null; + } } diff --git a/src/filters/global-exception.filter.ts b/src/filters/global-exception.filter.ts index 4c69b4b..db37af8 100644 --- a/src/filters/global-exception.filter.ts +++ b/src/filters/global-exception.filter.ts @@ -4,19 +4,24 @@ import { ExceptionFilter, HttpException, HttpStatus, - Logger, } from '@nestjs/common'; import { Response } from 'express'; import { CertificateError } from '../certificate/errors/certificate.errors'; +import { LogsService } from '../logs/logs.service'; +import { AuthenticatedRequest } from '../auth/interfaces/authenticated-request.interface'; @Catch() export class GlobalExceptionFilter implements ExceptionFilter { - private readonly logger = new Logger(GlobalExceptionFilter.name); + private readonly logsService: LogsService; + + constructor(logsService: LogsService) { + this.logsService = logsService; + } catch(exception: unknown, host: ArgumentsHost) { const ctx = host.switchToHttp(); const response = ctx.getResponse<Response>(); - const request = ctx.getRequest(); + const request = ctx.getRequest<AuthenticatedRequest>(); let status = HttpStatus.INTERNAL_SERVER_ERROR; let message = 'Internal server error'; @@ -41,10 +46,16 @@ export class GlobalExceptionFilter implements ExceptionFilter { details = { stack: exception.stack }; } - // Log the error - this.logger.error( - `${request.method} ${request.url} - ${status} - ${message}`, - exception instanceof Error ? exception.stack : String(exception), + this.logsService.error( + { + message: `${request.method ?? 'UNKNOWN'} ${request.url ?? '/'} failed with HTTP ${status}`, + event: 'http.request.error', + statusCode: status, + errorCode: code, + details, + }, + exception instanceof Error ? exception.stack : undefined, + GlobalExceptionFilter.name, ); response.status(status).json({ diff --git a/src/health/health.controller.ts b/src/health/health.controller.ts index 1e5dca7..10137a4 100644 --- a/src/health/health.controller.ts +++ b/src/health/health.controller.ts @@ -1,21 +1,65 @@ -import { Controller, Get, HttpCode, HttpStatus } from '@nestjs/common'; +import { Controller, Get, HttpStatus, Res } from '@nestjs/common'; +import { Response } from 'express'; import { HealthService } from './health.service'; +import { Public } from '../auth/decorators/public.decorator'; @Controller() +@Public() export class HealthController { - constructor(private readonly healthService: HealthService) {} + private readonly probeService: HealthService; - // Liveness probe - @Get('health') - @HttpCode(HttpStatus.OK) + constructor(service: HealthService) { + this.probeService = service; + } + + @Get('health/live') live() { - return this.healthService.live(); + return this.probeService.live(); + } + + @Get('health') + healthAlias() { + return this.probeService.live(); + } + + @Get('health/startup') + async startup(@Res({ passthrough: true }) response: Response) { + const report = await this.probeService.startup(); + this.applyProbeStatus(response, report.status); + return report; + } + + @Get('health/ready') + async readiness(@Res({ passthrough: true }) response: Response) { + const report = await this.probeService.ready(); + this.applyProbeStatus(response, report.status); + return report; + } + + @Get('health/dependencies') + async dependencies(@Res({ passthrough: true }) response: Response) { + const report = await this.probeService.dependencies(); + this.applyProbeStatus(response, report.status); + return report; + } + + @Get('health/deep') + async deep(@Res({ passthrough: true }) response: Response) { + const report = await this.probeService.deep(); + this.applyProbeStatus(response, report.status); + return report; } - // Readiness probe (checks nginx + config) @Get('ready') - @HttpCode(HttpStatus.OK) - ready() { - return this.healthService.ready(); + async readyAlias(@Res({ passthrough: true }) response: Response) { + const report = await this.probeService.ready(); + this.applyProbeStatus(response, report.status); + return report; + } + + private applyProbeStatus(response: Response, status: string) { + response.status( + status === 'ok' ? HttpStatus.OK : HttpStatus.SERVICE_UNAVAILABLE, + ); } } \ No newline at end of file diff --git a/src/health/health.module.ts b/src/health/health.module.ts index 4c616be..bfad8e7 100644 --- a/src/health/health.module.ts +++ b/src/health/health.module.ts @@ -1,8 +1,10 @@ import { Module } from '@nestjs/common'; import { HealthController } from './health.controller'; import { HealthService } from './health.service'; +import { PrismaModule } from '../prisma/prisma.module'; @Module({ + imports: [PrismaModule], controllers: [HealthController], providers: [HealthService], exports: [HealthService], diff --git a/src/health/health.service.ts b/src/health/health.service.ts index 9973bac..3d1fb5c 100644 --- a/src/health/health.service.ts +++ b/src/health/health.service.ts @@ -1,55 +1,446 @@ -import { Injectable } from '@nestjs/common'; -import { access } from 'fs/promises'; +import { Injectable, Logger } from '@nestjs/common'; +import * as fsPromises from 'node:fs/promises'; +import * as process from 'node:process'; +import { PrismaService } from '../prisma/prisma.service'; @Injectable() export class HealthService { + private readonly logger = new Logger(HealthService.name); + private readonly startedAt = new Date(); + private readonly configApplyMaxAgeMs = this.getThresholdFromEnv( + 'HEALTH_CONFIG_APPLY_MAX_AGE_MS', + 15 * 60 * 1000, + ); + private readonly certificateSyncMaxAgeMs = this.getThresholdFromEnv( + 'HEALTH_CERTIFICATE_SYNC_MAX_AGE_MS', + 15 * 60 * 1000, + ); + private configApplyState = this.createOperationState(); + private certificateSyncState = this.createOperationState(); + + constructor(private readonly prisma: PrismaService) {} + async live() { return { status: 'ok', + probe: 'liveness', + uptime: process.uptime(), + startedAt: this.startedAt.toISOString(), + timestamp: new Date().toISOString(), + }; + } + + async startup() { + const checks = [ + this.checkInitializationState('config_apply', this.configApplyState), + this.checkInitializationState( + 'certificate_sync', + this.certificateSyncState, + ), + ]; + + const healthy = checks.every((check) => check.status === 'ok'); + + return { + status: healthy ? 'ok' : 'starting', + probe: 'startup', + checks, uptime: process.uptime(), + startedAt: this.startedAt.toISOString(), timestamp: new Date().toISOString(), }; } async ready() { - const checks: Record<string, string>[] = []; - let critical = true; + const checks: HealthCheckResult[] = await this.buildDependencyChecks(); + return this.createDependencyReport('readiness', checks); + } + + async dependencies() { + const checks: HealthCheckResult[] = await this.buildDependencyChecks(); + return this.createDependencyReport('dependencies', checks); + } + + async deep() { + const live = await this.live(); + const startup = await this.startup(); + const dependencyChecks: HealthCheckResult[] = + await this.buildDependencyChecks(); + const readiness = this.createDependencyReport('readiness', dependencyChecks); + const dependencies = this.createDependencyReport( + 'dependencies', + dependencyChecks, + ); + const healthy = + live.status === 'ok' && + startup.status === 'ok' && + readiness.status === 'ok' && + dependencies.status === 'ok'; + + return { + status: healthy ? 'ok' : 'error', + probe: 'deep', + live, + startup, + readiness, + dependencies, + uptime: process.uptime(), + startedAt: this.startedAt.toISOString(), + timestamp: new Date().toISOString(), + }; + } + + recordConfigApplySuccess(details?: string) { + this.configApplyState = this.markOperationSuccess( + this.configApplyState, + details, + ); + } + + recordConfigApplyFailure(error: string) { + this.configApplyState = this.markOperationFailure( + this.configApplyState, + error, + ); + } + + recordCertificateSyncSuccess(details?: string) { + this.certificateSyncState = this.markOperationSuccess( + this.certificateSyncState, + details, + ); + } + + recordCertificateSyncFailure(error: string) { + this.certificateSyncState = this.markOperationFailure( + this.certificateSyncState, + error, + ); + } + + getOperationalDependencyState() { + return { + configApply: this.createOperationSnapshot( + 'config_apply', + this.configApplyState, + this.configApplyMaxAgeMs, + ), + certificateSync: this.createOperationSnapshot( + 'certificate_sync', + this.certificateSyncState, + this.certificateSyncMaxAgeMs, + ), + }; + } + + private createOperationState(): OperationState { + return { + lastAttemptAt: null, + lastSuccessAt: null, + lastError: null, + details: null, + }; + } + + private markOperationSuccess( + state: OperationState, + details?: string, + ): OperationState { + const now = new Date(); + + return { + lastAttemptAt: now, + lastSuccessAt: now, + lastError: null, + details: details ?? state.details, + }; + } + + private markOperationFailure(state: OperationState, error: string): OperationState { + return { + ...state, + lastAttemptAt: new Date(), + lastError: error, + }; + } + + private checkInitializationState( + name: string, + state: OperationState, + ): HealthCheckResult { + if (!state.lastSuccessAt) { + return this.createCheckResult( + name, + 'error', + 'awaiting first successful completion', + state, + ); + } + + return this.createCheckResult(name, 'ok', 'initialized', state); + } + + private checkOperationFreshness( + name: string, + state: OperationState, + maxAgeMs: number, + ): HealthCheckResult { + if (!state.lastSuccessAt) { + return this.createCheckResult( + name, + 'error', + 'no successful completion recorded yet', + state, + ); + } + + if ( + state.lastAttemptAt && + state.lastAttemptAt.getTime() >= state.lastSuccessAt.getTime() && + state.lastError + ) { + return this.createCheckResult( + name, + 'error', + `latest attempt failed: ${state.lastError}`, + state, + ); + } + + const ageMs = Date.now() - state.lastSuccessAt.getTime(); + if (ageMs > maxAgeMs) { + return this.createCheckResult( + name, + 'error', + `last successful completion is stale (${ageMs}ms old, max ${maxAgeMs}ms)`, + state, + ); + } + + return this.createCheckResult(name, 'ok', 'fresh', state, ageMs, maxAgeMs); + } + + private async buildDependencyChecks(): Promise<HealthCheckResult[]> { + return [ + await this.checkDatabaseConnectivity(), + await this.checkNginxMasterProcess(), + this.checkOperationFreshness( + 'config_apply', + this.configApplyState, + this.configApplyMaxAgeMs, + ), + this.checkOperationFreshness( + 'certificate_sync', + this.certificateSyncState, + this.certificateSyncMaxAgeMs, + ), + ]; + } + + private async checkDatabaseConnectivity(): Promise<HealthCheckResult> { + const startedAt = Date.now(); - // 1) Check local nginx HTTP response (short timeout) - try { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 1500); - const res = await fetch('http://127.0.0.1:80/', { - signal: controller.signal, - }); - clearTimeout(timeout); - checks.push({ nginx_http: res.ok ? 'ok' : `status:${res.status}` }); - } catch (err) { - // Nginx might not be fully ready yet, but don't fail the health check - const errMsg = String(err).substring(0, 50); - checks.push({ nginx_http: `starting:${errMsg}` }); - critical = false; // Don't require nginx for readiness - } - - // 2) Ensure nginx config file exists (critical check) try { - await access('/etc/nginx/nginx.conf'); - checks.push({ nginx_conf: 'ok' }); - } catch { - checks.push({ nginx_conf: 'missing' }); + const prismaClient = this.prisma as PrismaService & { + $queryRawUnsafe?: (query: string) => Promise<unknown>; + }; + + if (typeof prismaClient.$queryRawUnsafe !== 'function') { + throw new Error('Prisma raw query API is unavailable'); + } + + await prismaClient.$queryRawUnsafe('SELECT 1'); + const latencyMs = Date.now() - startedAt; + + return { + name: 'database', + status: 'ok', + details: 'query succeeded', + latencyMs, + checkedAt: new Date().toISOString(), + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.logger.warn(`Database readiness check failed: ${message}`); + + return { + name: 'database', + status: 'error', + details: message, + latencyMs: Date.now() - startedAt, + checkedAt: new Date().toISOString(), + }; } + } - // Consider healthy if app is running (config exists) - // Nginx http check is informational but not critical during startup - const configOk = checks.some((c) => c.nginx_conf === 'ok'); - const healthy = - configOk && - (critical ? checks.every((c) => Object.values(c)[0] === 'ok') : true); + private async checkNginxMasterProcess(): Promise<HealthCheckResult> { + const pidFileCandidates = ['/run/nginx.pid', '/var/run/nginx.pid']; + + for (const pidFile of pidFileCandidates) { + try { + const pid = await this.readPidFromFile(pidFile); + process.kill(pid, 0); + + return { + name: 'nginx_master', + status: 'ok', + details: `master process responding via ${pidFile}`, + pid, + checkedAt: new Date().toISOString(), + }; + } catch (error) { + const code = (error as NodeJS.ErrnoException)?.code; + if (code === 'ENOENT') { + continue; + } + + const message = error instanceof Error ? error.message : String(error); + return { + name: 'nginx_master', + status: 'error', + details: message, + checkedAt: new Date().toISOString(), + }; + } + } + + return { + name: 'nginx_master', + status: 'error', + details: 'nginx pid file not found', + checkedAt: new Date().toISOString(), + }; + } + + private async readPidFromFile(pidFile: string): Promise<number> { + await fsPromises.access(pidFile); + const rawPid = await fsPromises.readFile(pidFile, 'utf8'); + const pid = Number.parseInt(rawPid.trim(), 10); + + if (!Number.isInteger(pid) || pid <= 0) { + throw new Error(`invalid nginx pid in ${pidFile}`); + } + + return pid; + } + private createCheckResult( + name: string, + status: HealthCheckStatus, + details: string, + state: OperationState, + ageMs?: number, + maxAgeMs?: number, + ): HealthCheckResult { return { - status: healthy ? 'ok' : 'degraded', + name, + status, + details, + checkedAt: new Date().toISOString(), + lastAttemptAt: state.lastAttemptAt?.toISOString() ?? null, + lastSuccessAt: state.lastSuccessAt?.toISOString() ?? null, + lastError: state.lastError, + operationAgeMs: ageMs, + maxAgeMs, + stateDetails: state.details, + }; + } + + private createDependencyReport( + probe: 'readiness' | 'dependencies', + checks: HealthCheckResult[], + ) { + const healthy = checks.every((check) => check.status === 'ok'); + const summary = this.summarizeChecks(checks); + + return { + status: healthy ? 'ok' : 'error', + probe, checks, + summary, + thresholds: { + configApplyMaxAgeMs: this.configApplyMaxAgeMs, + certificateSyncMaxAgeMs: this.certificateSyncMaxAgeMs, + }, + uptime: process.uptime(), + startedAt: this.startedAt.toISOString(), timestamp: new Date().toISOString(), }; } + + private summarizeChecks(checks: HealthCheckResult[]) { + const ok = checks.filter((check) => check.status === 'ok').length; + const error = checks.filter((check) => check.status === 'error').length; + + return { + total: checks.length, + ok, + error, + }; + } + + private createOperationSnapshot( + name: string, + state: OperationState, + maxAgeMs: number, + ) { + const freshness = this.checkOperationFreshness(name, state, maxAgeMs); + const ageMs = state.lastSuccessAt + ? Date.now() - state.lastSuccessAt.getTime() + : null; + + return { + name, + status: freshness.status, + details: freshness.details, + lastAttemptAt: state.lastAttemptAt?.toISOString() ?? null, + lastSuccessAt: state.lastSuccessAt?.toISOString() ?? null, + lastError: state.lastError, + stateDetails: state.details, + ageMs, + maxAgeMs, + }; + } + + private getThresholdFromEnv(name: string, fallback: number): number { + const rawValue = process.env[name]; + if (!rawValue) { + return fallback; + } + + const parsedValue = Number.parseInt(rawValue, 10); + if (!Number.isFinite(parsedValue) || parsedValue <= 0) { + this.logger.warn( + `Ignoring invalid ${name} value ${rawValue}; using fallback ${fallback}`, + ); + return fallback; + } + + return parsedValue; + } } + +type HealthCheckStatus = 'ok' | 'error'; + +type OperationState = { + lastAttemptAt: Date | null; + lastSuccessAt: Date | null; + lastError: string | null; + details: string | null; +}; + +type HealthCheckResult = { + name: string; + status: HealthCheckStatus; + details: string; + checkedAt: string; + latencyMs?: number; + pid?: number; + lastAttemptAt?: string | null; + lastSuccessAt?: string | null; + lastError?: string | null; + operationAgeMs?: number; + maxAgeMs?: number; + stateDetails?: string | null; +}; + diff --git a/src/logs/log-context.ts b/src/logs/log-context.ts new file mode 100644 index 0000000..bc8f696 --- /dev/null +++ b/src/logs/log-context.ts @@ -0,0 +1,28 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; +import { AuthenticatedRequest } from '../auth/interfaces/authenticated-request.interface'; + +export type LogContext = { + request?: AuthenticatedRequest; + correlationId?: string; + operationId?: string; +}; + +const storage = new AsyncLocalStorage<LogContext>(); + +export function getLogContext() { + return storage.getStore(); +} + +export function runWithLogContext<T>( + context: Partial<LogContext>, + callback: () => T, +) { + return storage.run({ ...(storage.getStore() ?? {}), ...context }, callback); +} + +export function assignLogContext(context: Partial<LogContext>) { + const current = storage.getStore(); + if (current) { + Object.assign(current, context); + } +} diff --git a/src/logs/logs.controller.ts b/src/logs/logs.controller.ts index 83a16ff..9b15922 100644 --- a/src/logs/logs.controller.ts +++ b/src/logs/logs.controller.ts @@ -6,16 +6,35 @@ import { UseGuards, } from '@nestjs/common'; import { LogsService } from './logs.service'; +import { AuthorizeAdmin } from '../auth/decorators/authorize.decorator'; import { ApiKeyGuard } from '../auth/guards/api-key.guard'; +import { Audit } from '../audit/decorators/audit.decorator'; @Controller('logs') +@AuthorizeAdmin('operator') export class LogsController { - constructor(private readonly logsService: LogsService) {} + private readonly logsService: LogsService; + + constructor(logsService: LogsService) { + this.logsService = logsService; + } @Get() @UseGuards(ApiKeyGuard) + @Audit({ action: 'logs.read' }) getLogs(@Query('count', ParseIntPipe) count = 100) { - const logs = this.logsService.getLastLogs(count); - return { logs }; + const entries = this.logsService.getLastLogs(count); + const logs = + typeof this.logsService.getLastLogLines === 'function' + ? this.logsService.getLastLogLines(count) + : entries.map((entry) => + typeof entry === 'string' ? entry : JSON.stringify(entry), + ); + + return { + count: entries.length, + logs, + entries, + }; } } diff --git a/src/logs/logs.service.ts b/src/logs/logs.service.ts index e4012a2..04a5976 100644 --- a/src/logs/logs.service.ts +++ b/src/logs/logs.service.ts @@ -1,59 +1,432 @@ import { Injectable, LoggerService } from '@nestjs/common'; -import * as fs from 'fs'; -import * as path from 'path'; +import { ensureAuditContext, getRequestIpAddress, getRequestPath } from '../audit/audit-context'; +import { AuthIdentity } from '../auth/types/auth-identity'; +import { AuthenticatedRequest } from '../auth/interfaces/authenticated-request.interface'; +import { getLogContext, runWithLogContext } from './log-context'; -const LOG_FILE_PATH = - process.env.NGINX_LOG_FILE_PATH || - path.resolve(process.cwd(), 'nginx-app.log'); -const MAX_LOG_LINES = 1000; +const MAX_LOG_ENTRIES = Number.parseInt( + process.env['LOG_BUFFER_SIZE'] ?? '1000', + 10, +); +const REDACTED_VALUE = '[REDACTED]'; + +type OperationalLogLevel = 'log' | 'error' | 'warn' | 'debug' | 'verbose' | 'fatal'; + +export interface StructuredOperationalLogEntry { + timestamp: string; + stream: 'operational'; + level: OperationalLogLevel; + message: string; + source?: string; + event?: string; + nodeId: string; + pid: number; + correlationId?: string; + requestId?: string; + operationId?: string; + actor?: { + id: string; + subject: string; + actorType: string; + displayName: string; + authMethod: string; + roles: string[]; + nodeId?: string; + }; + request?: { + method?: string; + path?: string; + ipAddress?: string; + statusCode?: number; + durationMs?: number; + }; + data?: unknown; + trace?: string; +} + +type BufferedLogEntry = { + entry: StructuredOperationalLogEntry; + line: string; +}; @Injectable() export class LogsService implements LoggerService { - private buffer: string[] = []; + private readonly buffer: BufferedLogEntry[] = []; + private readonly nodeId = + process.env['CLUSTER_NODE_ID']?.trim() || + process.env['HOSTNAME']?.trim() || + 'unknown-node'; + + log(message: unknown, ...optionalParams: unknown[]) { + this.writeFromLoggerCall('log', message, optionalParams); + } + + error(message: unknown, ...optionalParams: unknown[]) { + this.writeFromLoggerCall('error', message, optionalParams); + } + + warn(message: unknown, ...optionalParams: unknown[]) { + this.writeFromLoggerCall('warn', message, optionalParams); + } + + debug(message: unknown, ...optionalParams: unknown[]) { + this.writeFromLoggerCall('debug', message, optionalParams); + } + + verbose(message: unknown, ...optionalParams: unknown[]) { + this.writeFromLoggerCall('verbose', message, optionalParams); + } + + fatal(message: unknown, ...optionalParams: unknown[]) { + this.writeFromLoggerCall('fatal', message, optionalParams); + } + + runWithContext<T>( + context: Parameters<typeof runWithLogContext>[0], + callback: () => T, + ) { + return runWithLogContext(context, callback); + } + + bindRequestContext( + request: AuthenticatedRequest, + response: { + statusCode?: number; + once: (event: 'finish' | 'close', listener: () => void) => void; + removeListener?: (event: 'finish' | 'close', listener: () => void) => void; + setHeader?: (name: string, value: string) => void; + writableEnded?: boolean; + }, + next: () => void, + ) { + const auditContext = ensureAuditContext(request, response); + const operationId = this.extractRequestOperationId(request); + const startedAt = Date.now(); + + return runWithLogContext( + { + request, + correlationId: auditContext.correlationId, + operationId, + }, + () => { + let settled = false; + const finalize = (event: 'completed' | 'closed') => { + if (settled) { + return; + } + + settled = true; + response.removeListener?.('finish', onFinish); + response.removeListener?.('close', onClose); + + this.recordRequestLifecycleEvent( + event, + request, + response.statusCode ?? 200, + Date.now() - startedAt, + operationId, + ); + }; + const onFinish = () => finalize('completed'); + const onClose = () => { + if (!response.writableEnded) { + finalize('closed'); + } + }; + + response.once('finish', onFinish); + response.once('close', onClose); + + next(); + }, + ); + } + + getLastLogs(count: number): StructuredOperationalLogEntry[] { + return this.buffer.slice(-count).map(({ entry }) => ({ ...entry })); + } + + getLastLogLines(count: number): string[] { + return this.buffer.slice(-count).map(({ line }) => line); + } + + private writeFromLoggerCall( + level: OperationalLogLevel, + message: unknown, + optionalParams: unknown[], + ) { + const payload = this.normalizeLoggerCall(level, message, optionalParams); + this.writeEntry( + this.createEntry(level, payload.message, { + source: payload.source, + event: payload.event, + data: payload.data, + trace: payload.trace, + }), + ); + } + + private recordRequestLifecycleEvent( + lifecycle: 'completed' | 'closed', + request: AuthenticatedRequest, + statusCode: number, + durationMs: number, + operationId?: string, + ) { + this.writeEntry( + this.createEntry('log', `HTTP request ${lifecycle}`, { + source: 'http.request', + event: `http.request.${lifecycle}`, + request, + operationId, + statusCode, + durationMs, + }), + ); + } + + private createEntry( + level: OperationalLogLevel, + message: string, + options: { + source?: string; + event?: string; + data?: unknown; + trace?: string; + request?: AuthenticatedRequest; + operationId?: string; + statusCode?: number; + durationMs?: number; + } = {}, + ): StructuredOperationalLogEntry { + const context = getLogContext(); + const request = options.request ?? context?.request; + const correlationId = + request?.auditContext?.correlationId ?? context?.correlationId; + const operationId = + options.operationId ?? context?.operationId ?? this.extractRequestOperationId(request); + + return { + timestamp: new Date().toISOString(), + stream: 'operational', + level, + message, + ...(options.source ? { source: options.source } : {}), + ...(options.event ? { event: options.event } : {}), + nodeId: this.nodeId, + pid: process.pid, + ...(correlationId + ? { + correlationId, + requestId: correlationId, + } + : {}), + ...(operationId ? { operationId } : {}), + ...(request + ? { + actor: this.serializeActor(request.auth), + request: { + method: request.method, + path: getRequestPath(request), + ipAddress: getRequestIpAddress(request), + ...(options.statusCode !== undefined + ? { statusCode: options.statusCode } + : {}), + ...(options.durationMs !== undefined + ? { durationMs: options.durationMs } + : {}), + }, + } + : {}), + ...(options.data !== undefined + ? { data: this.redactValue(options.data) } + : {}), + ...(options.trace ? { trace: this.redactTrace(options.trace) } : {}), + }; + } + + private writeEntry(entry: StructuredOperationalLogEntry) { + const line = JSON.stringify(entry); + this.buffer.push({ entry, line }); + if (this.buffer.length > MAX_LOG_ENTRIES) { + this.buffer.shift(); + } - constructor() { - this.readLogFile(); + const target = entry.level === 'error' || entry.level === 'fatal' + ? process.stderr + : process.stdout; + target.write(line + '\n'); } - log(message: string) { - this.appendLog(`[LOG] ${message}`); - process.stdout.write(`[LOG] ${message}\n`); + private normalizeLoggerCall( + level: OperationalLogLevel, + message: unknown, + optionalParams: unknown[], + ) { + const params = [...optionalParams]; + let source: string | undefined; + let trace: string | undefined; + let event: string | undefined; + + const lastParam = params.length > 0 ? params[params.length - 1] : undefined; + if (typeof lastParam === 'string' && !this.looksLikeTrace(lastParam)) { + source = lastParam; + params.pop(); + } + + if (level === 'error' || level === 'fatal') { + const firstParam = params[0]; + if (typeof firstParam === 'string' && this.looksLikeTrace(firstParam)) { + trace = firstParam; + params.shift(); + } + } + + let messageText: string; + let data: unknown = params.length === 0 ? undefined : params.length === 1 ? params[0] : params; + + if (message instanceof Error) { + messageText = message.message; + trace ??= message.stack; + data = { + name: message.name, + ...(data !== undefined ? { details: data } : {}), + }; + } else if (typeof message === 'string') { + messageText = message; + } else if (typeof message === 'object' && message !== null) { + const record = message as Record<string, unknown>; + const structuredMessage = + typeof record['message'] === 'string' ? record['message'] : undefined; + const structuredEvent = + typeof record['event'] === 'string' ? record['event'] : undefined; + messageText = structuredMessage || structuredEvent || 'Structured log event'; + event = structuredEvent; + data = data === undefined ? record : { message: record, details: data }; + } else { + messageText = String(message); + } + + return { + message: messageText, + source, + event, + data, + trace, + }; } - error(message: string, trace?: string) { - this.appendLog(`[ERROR] ${message}${trace ? ' - ' + trace : ''}`); - process.stderr.write(`[ERROR] ${message}${trace ? ' - ' + trace : ''}\n`); + private serializeActor(actor: AuthIdentity | undefined) { + if (!actor) { + return undefined; + } + + return { + id: actor.id, + subject: actor.subject, + actorType: actor.actorType, + displayName: actor.displayName, + authMethod: actor.authMethod, + roles: [...actor.roles], + ...(actor.nodeId ? { nodeId: actor.nodeId } : {}), + }; } - warn(message: string) { - this.appendLog(`[WARN] ${message}`); - process.stdout.write(`[WARN] ${message}\n`); + private redactValue(value: unknown, key?: string, seen = new WeakSet<object>()): unknown { + if (this.isSensitiveKey(key) || this.isSensitiveString(value)) { + return REDACTED_VALUE; + } + + if ( + value === null || + value === undefined || + typeof value === 'number' || + typeof value === 'boolean' + ) { + return value; + } + + if (typeof value === 'string') { + return value.length > 4000 ? `${value.slice(0, 3997)}...` : value; + } + + if (typeof value === 'bigint') { + return value.toString(); + } + + if (value instanceof Date) { + return value.toISOString(); + } + + if (Buffer.isBuffer(value)) { + return `<Buffer length=${value.length}>`; + } + + if (Array.isArray(value)) { + return value.map((entry) => this.redactValue(entry, key, seen)); + } + + if (typeof value === 'object') { + if (seen.has(value)) { + return '[Circular]'; + } + + seen.add(value); + + if (value instanceof Error) { + return { + name: value.name, + message: value.message, + ...(value.stack ? { stack: this.redactTrace(value.stack) } : {}), + }; + } + + return Object.fromEntries( + Object.entries(value).map(([entryKey, entryValue]) => [ + entryKey, + this.redactValue(entryValue, entryKey, seen), + ]), + ); + } + + return String(value); } - debug(message: string) { - this.appendLog(`[DEBUG] ${message}`); - process.stdout.write(`[DEBUG] ${message}\n`); + private redactTrace(trace: string) { + return this.isSensitiveString(trace) ? REDACTED_VALUE : trace; } - verbose(message: string) { - this.appendLog(`[VERBOSE] ${message}`); - process.stdout.write(`[VERBOSE] ${message}\n`); + private isSensitiveKey(key: string | undefined) { + if (!key) { + return false; + } + + return /(^|[-_.])(authorization|api[-_]?key|token|secret|password|passphrase|cookie|private[-_]?key|keypem|client[-_]?secret|master[-_]?key|encryption[-_]?key)([-_.]|$)/i.test( + key, + ); } - private appendLog(logLine: string) { - const entry = `${new Date().toISOString()} ${logLine}`; - this.buffer.push(entry); - if (this.buffer.length > MAX_LOG_LINES) this.buffer.shift(); - fs.appendFileSync(LOG_FILE_PATH, entry + '\n'); + private isSensitiveString(value: unknown) { + if (typeof value !== 'string') { + return false; + } + + return ( + /-----BEGIN(?: [A-Z0-9]+)* PRIVATE KEY-----/.test(value) || + /^Bearer\s+.+/i.test(value) || + /^ApiKey\s+.+/i.test(value) + ); } - private readLogFile() { - if (!fs.existsSync(LOG_FILE_PATH)) return; - const lines = fs.readFileSync(LOG_FILE_PATH, 'utf8').trim().split('\n'); - this.buffer = lines.slice(-MAX_LOG_LINES); + private looksLikeTrace(value: string) { + return value.includes('\n') || value.includes(' at '); } - getLastLogs(count: number): string[] { - return this.buffer.slice(-count); + private extractRequestOperationId(request?: AuthenticatedRequest) { + const candidate = request?.query?.['operationId']; + return typeof candidate === 'string' && candidate.trim() + ? candidate.trim() + : undefined; } } diff --git a/src/main.ts b/src/main.ts index 0a2963c..a6413a9 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,16 +1,25 @@ import { NestFactory } from '@nestjs/core'; import { ValidationPipe } from '@nestjs/common'; +import { Response } from 'express'; import { AppModule } from './app.module'; import { LogsService } from './logs/logs.service'; import { GlobalExceptionFilter } from './filters/global-exception.filter'; +import { AuthenticatedRequest } from './auth/interfaces/authenticated-request.interface'; async function bootstrap() { - const app = await NestFactory.create(AppModule); + const app = await NestFactory.create(AppModule, { bufferLogs: true }); const logsService = app.get(LogsService); - app.useLogger(logsService); // <- This makes all Nest logs go through your service + app.useLogger(logsService); + app.use( + ( + request: AuthenticatedRequest, + response: Response, + next: () => void, + ) => logsService.bindRequestContext(request, response, next), + ); // Enable global exception filter - app.useGlobalFilters(new GlobalExceptionFilter()); + app.useGlobalFilters(new GlobalExceptionFilter(logsService)); // Enable global validation app.useGlobalPipes( @@ -24,7 +33,7 @@ async function bootstrap() { }), ); - await app.listen(process.env.PORT ?? 3000); + await app.listen(process.env['PORT'] ?? 3000); } void bootstrap(); diff --git a/src/metrics/metrics.controller.ts b/src/metrics/metrics.controller.ts index 7719756..b978ccc 100644 --- a/src/metrics/metrics.controller.ts +++ b/src/metrics/metrics.controller.ts @@ -1,35 +1,21 @@ import { Controller, Get, Header } from '@nestjs/common'; import { MetricsService } from './metrics.service'; +import { Public } from '../auth/decorators/public.decorator'; @Controller('metrics') +@Public() export class MetricsController { constructor(private readonly metricsService: MetricsService) {} @Get() @Header('Content-Type', 'text/plain; version=0.0.4') async getPrometheusMetrics(): Promise<string> { - const [certificates, proxies] = await Promise.all([ - this.metricsService.getCertificateMetrics(), - this.metricsService.getProxyMetrics(), - ]); - - return this.metricsService.formatPrometheusMetrics({ - certificates, - proxies, - }); + const metrics = await this.metricsService.getAllMetrics(); + return this.metricsService.formatPrometheusMetrics(metrics); } @Get('json') async getJsonMetrics() { - const [certificates, proxies] = await Promise.all([ - this.metricsService.getCertificateMetrics(), - this.metricsService.getProxyMetrics(), - ]); - - return { - timestamp: new Date().toISOString(), - certificates, - proxies, - }; + return this.metricsService.getAllMetrics(); } } diff --git a/src/metrics/metrics.module.ts b/src/metrics/metrics.module.ts index 2ec8fa4..43c5dc8 100644 --- a/src/metrics/metrics.module.ts +++ b/src/metrics/metrics.module.ts @@ -2,9 +2,10 @@ import { Module } from '@nestjs/common'; import { MetricsService } from './metrics.service'; import { MetricsController } from './metrics.controller'; import { PrismaModule } from '../prisma/prisma.module'; +import { HealthModule } from '../health/health.module'; @Module({ - imports: [PrismaModule], + imports: [PrismaModule, HealthModule], controllers: [MetricsController], providers: [MetricsService], exports: [MetricsService], diff --git a/src/metrics/metrics.service.ts b/src/metrics/metrics.service.ts index cec5155..029a7ed 100644 --- a/src/metrics/metrics.service.ts +++ b/src/metrics/metrics.service.ts @@ -1,31 +1,110 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; +import * as fsPromises from 'node:fs/promises'; +import * as process from 'node:process'; +import { HealthService } from '../health/health.service'; import { PrismaService } from '../prisma/prisma.service'; +const BACKUP_FILENAME_PATTERN = + /^[A-Za-z0-9][A-Za-z0-9._-]*\.(?:lyttlebackup|zip)$/; +const LEADER_LEASE_NAME = 'cluster:leader'; +const TERMINAL_CERTIFICATE_ORDER_STATUSES = new Set([ + 'activated', + 'failed', + 'revoked', +]); + @Injectable() export class MetricsService { - constructor(private prisma: PrismaService) {} + private readonly logger = new Logger(MetricsService.name); + private readonly backupDir = process.env['BACKUP_DIR'] || '/tmp/cert-backups'; + private readonly clusterOperationStaleMaxAgeMs = this.getThresholdFromEnv( + 'METRICS_CLUSTER_OPERATION_STALE_MAX_AGE_MS', + 15 * 60 * 1000, + ); + private readonly clusterOperationFailureWindowMs = this.getThresholdFromEnv( + 'METRICS_CLUSTER_OPERATION_FAILURE_WINDOW_MS', + 60 * 60 * 1000, + ); + private readonly certificateOrderStaleMaxAgeMs = this.getThresholdFromEnv( + 'METRICS_CERTIFICATE_ORDER_STALE_MAX_AGE_MS', + 30 * 60 * 1000, + ); + private readonly backupMaxAgeMs = this.getThresholdFromEnv( + 'METRICS_BACKUP_MAX_AGE_MS', + 24 * 60 * 60 * 1000, + ); + + constructor( + private readonly prisma: PrismaService, + private readonly healthService: HealthService, + ) {} + + async getAllMetrics() { + const sections = [ + ['certificates', () => this.getCertificateMetrics()], + ['proxies', () => this.getProxyMetrics()], + ['health', () => this.getHealthMetrics()], + ['leases', () => this.getLeaseMetrics()], + ['clusterOperations', () => this.getClusterOperationMetrics()], + ['certificateOrders', () => this.getCertificateOrderMetrics()], + ['backups', () => this.getBackupMetrics()], + ] as const; + + const settled = await Promise.allSettled( + sections.map(async ([section, loader]) => [section, await loader()] as const), + ); + + const metrics = { + timestamp: new Date().toISOString(), + collection: { + sections: {} as Record<string, 'ok' | 'error'>, + errors: [] as Array<{ section: string; message: string }>, + }, + certificates: this.getEmptyCertificateMetrics(), + proxies: this.getEmptyProxyMetrics(), + health: this.getEmptyHealthMetrics(), + leases: this.getEmptyLeaseMetrics(), + clusterOperations: this.getEmptyClusterOperationMetrics(), + certificateOrders: this.getEmptyCertificateOrderMetrics(), + backups: this.getEmptyBackupMetrics(), + }; + + for (const [index, result] of settled.entries()) { + if (result.status === 'fulfilled') { + const [section, value] = result.value; + metrics.collection.sections[section] = 'ok'; + (metrics as Record<string, unknown>)[section] = value; + continue; + } + + const reason = result.reason instanceof Error ? result.reason.message : String(result.reason); + const section = sections[index]?.[0] ?? 'unknown'; + metrics.collection.sections[section] = 'error'; + metrics.collection.errors.push({ section, message: reason }); + this.logger.warn(`Failed to collect ${section} metrics: ${reason}`); + } + + for (const [section] of sections) { + if (!metrics.collection.sections[section]) { + metrics.collection.sections[section] = 'error'; + } + } + + return metrics; + } async getCertificateMetrics() { const certificates = await this.prisma.certificate.findMany({ where: { isOrphaned: false }, }); - const now = new Date(); - const metrics = { - total: certificates.length, - valid: 0, - expiringSoon: 0, - expired: 0, - avgDaysUntilExpiry: 0, - oldestExpiry: null as Date | null, - newestExpiry: null as Date | null, - }; - + const now = Date.now(); + const metrics = this.getEmptyCertificateMetrics(); let totalDays = 0; certificates.forEach((cert) => { const daysUntilExpiry = Math.ceil( - (cert.expiresAt.getTime() - now.getTime()) / (1000 * 60 * 60 * 24), + (cert.expiresAt.getTime() - now) / (1000 * 60 * 60 * 24), ); totalDays += daysUntilExpiry; @@ -46,6 +125,7 @@ export class MetricsService { } }); + metrics.total = certificates.length; metrics.avgDaysUntilExpiry = certificates.length > 0 ? Math.round(totalDays / certificates.length) : 0; @@ -56,45 +136,866 @@ export class MetricsService { const entries = await this.prisma.proxyEntry.findMany(); return { total: entries.length, - withSsl: entries.filter((e) => e.ssl).length, - withoutSsl: entries.filter((e) => !e.ssl).length, - proxies: entries.filter((e) => e.type === 'PROXY').length, - redirects: entries.filter((e) => e.type === 'REDIRECT').length, + withSsl: entries.filter((entry) => entry.ssl).length, + withoutSsl: entries.filter((entry) => !entry.ssl).length, + proxies: entries.filter((entry) => entry.type === 'PROXY').length, + redirects: entries.filter((entry) => entry.type === 'REDIRECT').length, + }; + } + + async getHealthMetrics() { + const dependencyReport = await this.healthService.dependencies(); + const operationalState = this.healthService.getOperationalDependencyState(); + const dependencyChecks = Object.fromEntries( + dependencyReport.checks.map((check) => [check.name, check]), + ); + + return { + status: dependencyReport.status, + summary: dependencyReport.summary, + thresholds: dependencyReport.thresholds, + checks: dependencyReport.checks, + database: dependencyChecks['database'] ?? null, + nginxMaster: dependencyChecks['nginx_master'] ?? null, + operations: operationalState, + }; + } + + async getLeaseMetrics() { + const leases = await this.prisma.clusterLease.findMany({ + orderBy: [{ leaseName: 'asc' }], + }); + const now = Date.now(); + const leaderLease = leases.find((lease) => lease.leaseName === LEADER_LEASE_NAME) ?? null; + const active = leases.filter((lease) => lease.expiresAt.getTime() > now).length; + const expired = leases.length - active; + + return { + total: leases.length, + active, + expired, + leader: leaderLease + ? { + present: true, + ownerNodeId: leaderLease.ownerNodeId, + ownerHostname: leaderLease.ownerHostname, + generation: leaderLease.generation, + ttlSeconds: leaderLease.ttlSeconds, + secondsRemaining: Math.max( + 0, + Math.round((leaderLease.expiresAt.getTime() - now) / 1000), + ), + isExpired: leaderLease.expiresAt.getTime() <= now, + } + : { + present: false, + ownerNodeId: null, + ownerHostname: null, + generation: 0, + ttlSeconds: 0, + secondsRemaining: 0, + isExpired: true, + }, + }; + } + + async getClusterOperationMetrics() { + const [operations, acknowledgements] = await Promise.all([ + this.prisma.clusterOperation.findMany({ + orderBy: [{ createdAt: 'desc' }], + }), + this.prisma.clusterOperationAck.findMany(), + ]); + + const now = Date.now(); + const activeOperations = operations.filter((operation) => + ['pending', 'running'].includes(operation.status), + ); + const staleOperations = activeOperations.filter( + (operation) => now - operation.createdAt.getTime() > this.clusterOperationStaleMaxAgeMs, + ); + const oldestActive = activeOperations.reduce<number | null>((oldest, operation) => { + const ageSeconds = Math.max(0, Math.round((now - operation.createdAt.getTime()) / 1000)); + return oldest === null ? ageSeconds : Math.max(oldest, ageSeconds); + }, null); + const recentFailures = operations.filter( + (operation) => + ['failed', 'partially_failed'].includes(operation.status) && + now - operation.createdAt.getTime() <= this.clusterOperationFailureWindowMs, + ); + + return { + total: operations.length, + byStatus: this.countBy( + operations as Array<Record<string, unknown>>, + (operation) => operation['status'] as string | null | undefined, + ), + byType: this.countBy( + operations as Array<Record<string, unknown>>, + (operation) => operation['operationType'] as string | null | undefined, + ), + active: { + total: activeOperations.length, + stale: staleOperations.length, + oldestAgeSeconds: oldestActive ?? 0, + staleThresholdMs: this.clusterOperationStaleMaxAgeMs, + }, + recentFailures: { + total: recentFailures.length, + windowMs: this.clusterOperationFailureWindowMs, + }, + acknowledgements: { + total: acknowledgements.length, + byStatus: this.countBy( + acknowledgements as Array<Record<string, unknown>>, + (ack) => ack['status'] as string | null | undefined, + ), + }, + }; + } + + async getCertificateOrderMetrics() { + const orders = await this.prisma.certificateOrder.findMany({ + orderBy: [{ createdAt: 'desc' }], + }); + + const now = Date.now(); + const activeOrders = orders.filter( + (order) => !TERMINAL_CERTIFICATE_ORDER_STATUSES.has(order.status), + ); + const staleOrders = activeOrders.filter( + (order) => now - order.updatedAt.getTime() > this.certificateOrderStaleMaxAgeMs, + ); + const retryScheduled = orders.filter( + (order) => order.nextRetryAt && order.nextRetryAt.getTime() > now, + ).length; + const retryDue = orders.filter( + (order) => order.nextRetryAt && order.nextRetryAt.getTime() <= now, + ).length; + const oldestActiveAgeSeconds = activeOrders.reduce<number | null>((oldest, order) => { + const ageSeconds = Math.max(0, Math.round((now - order.updatedAt.getTime()) / 1000)); + return oldest === null ? ageSeconds : Math.max(oldest, ageSeconds); + }, null); + + return { + total: orders.length, + active: activeOrders.length, + stale: staleOrders.length, + staleThresholdMs: this.certificateOrderStaleMaxAgeMs, + oldestActiveAgeSeconds: oldestActiveAgeSeconds ?? 0, + retryScheduled, + retryDue, + byStatus: this.countBy( + orders as Array<Record<string, unknown>>, + (order) => order['status'] as string | null | undefined, + ), }; } - formatPrometheusMetrics(data: { certificates: any; proxies: any }): string { - const lines = [ - '# HELP lyttle_certificates_total Total number of certificates', - '# TYPE lyttle_certificates_total gauge', - `lyttle_certificates_total ${data.certificates.total}`, - '', - '# HELP lyttle_certificates_valid Number of valid certificates', - '# TYPE lyttle_certificates_valid gauge', - `lyttle_certificates_valid ${data.certificates.valid}`, - '', - '# HELP lyttle_certificates_expiring_soon Number of certificates expiring soon', - '# TYPE lyttle_certificates_expiring_soon gauge', - `lyttle_certificates_expiring_soon ${data.certificates.expiringSoon}`, - '', - '# HELP lyttle_certificates_expired Number of expired certificates', - '# TYPE lyttle_certificates_expired gauge', - `lyttle_certificates_expired ${data.certificates.expired}`, - '', - '# HELP lyttle_certificates_avg_days_until_expiry Average days until certificate expiry', - '# TYPE lyttle_certificates_avg_days_until_expiry gauge', - `lyttle_certificates_avg_days_until_expiry ${data.certificates.avgDaysUntilExpiry}`, - '', - '# HELP lyttle_proxy_entries_total Total number of proxy entries', - '# TYPE lyttle_proxy_entries_total gauge', - `lyttle_proxy_entries_total ${data.proxies.total}`, - '', - '# HELP lyttle_proxy_entries_ssl Number of proxy entries with SSL', - '# TYPE lyttle_proxy_entries_ssl gauge', - `lyttle_proxy_entries_ssl ${data.proxies.withSsl}`, - '', - ]; + async getBackupMetrics() { + let files: Array<{ filename: string; size: number; createdAt: Date }> = []; + + try { + const entries = await fsPromises.readdir(this.backupDir); + files = ( + await Promise.all( + entries + .filter((entry) => BACKUP_FILENAME_PATTERN.test(entry)) + .map(async (filename) => { + const stats = await fsPromises.stat(`${this.backupDir}/${filename}`); + return { + filename, + size: stats.size, + createdAt: stats.birthtime, + }; + }), + ) + ).sort((left, right) => right.createdAt.getTime() - left.createdAt.getTime()); + } catch (error) { + const code = (error as NodeJS.ErrnoException)?.code; + if (code !== 'ENOENT') { + throw error; + } + } + + const latest = files[0] ?? null; + const latestAgeSeconds = latest + ? Math.max(0, Math.round((Date.now() - latest.createdAt.getTime()) / 1000)) + : 0; + + return { + total: files.length, + totalSizeBytes: files.reduce((sum, file) => sum + file.size, 0), + latest: latest + ? { + filename: latest.filename, + createdAt: latest.createdAt.toISOString(), + sizeBytes: latest.size, + ageSeconds: latestAgeSeconds, + } + : null, + maxAgeMs: this.backupMaxAgeMs, + freshnessStatus: + latest !== null && latest.createdAt.getTime() >= Date.now() - this.backupMaxAgeMs ? 1 : 0, + }; + } + + formatPrometheusMetrics(data: Awaited<ReturnType<MetricsService['getAllMetrics']>>): string { + const lines: string[] = []; + const emitted = new Set<string>(); + + for (const [section, status] of Object.entries(data.collection.sections)) { + this.appendGauge( + lines, + emitted, + 'lyttle_metrics_collection_status', + 'Whether collecting a metrics section succeeded', + status === 'ok' ? 1 : 0, + { section }, + ); + } + this.appendGauge( + lines, + emitted, + 'lyttle_metrics_collection_errors_total', + 'Number of metrics collection sections that failed', + data.collection.errors.length, + ); + + this.appendGauge( + lines, + emitted, + 'lyttle_certificates_total', + 'Total number of non-orphaned certificates', + data.certificates.total, + ); + this.appendGauge( + lines, + emitted, + 'lyttle_certificates_valid', + 'Number of valid certificates', + data.certificates.valid, + ); + this.appendGauge( + lines, + emitted, + 'lyttle_certificates_expiring_soon', + 'Number of certificates expiring within 30 days', + data.certificates.expiringSoon, + ); + this.appendGauge( + lines, + emitted, + 'lyttle_certificates_expired', + 'Number of expired certificates', + data.certificates.expired, + ); + this.appendGauge( + lines, + emitted, + 'lyttle_certificates_avg_days_until_expiry', + 'Average number of days until certificate expiry', + data.certificates.avgDaysUntilExpiry, + ); + this.appendGauge( + lines, + emitted, + 'lyttle_certificates_oldest_expiry_timestamp_seconds', + 'Unix timestamp for the certificate expiring soonest', + this.toUnixTimestampSeconds(data.certificates.oldestExpiry), + ); + this.appendGauge( + lines, + emitted, + 'lyttle_certificates_newest_expiry_timestamp_seconds', + 'Unix timestamp for the certificate expiring latest', + this.toUnixTimestampSeconds(data.certificates.newestExpiry), + ); + + this.appendGauge( + lines, + emitted, + 'lyttle_proxy_entries_total', + 'Total number of proxy entries', + data.proxies.total, + ); + this.appendGauge( + lines, + emitted, + 'lyttle_proxy_entries_ssl', + 'Number of proxy entries with SSL enabled', + data.proxies.withSsl, + ); + this.appendGauge( + lines, + emitted, + 'lyttle_proxy_entries_without_ssl', + 'Number of proxy entries without SSL enabled', + data.proxies.withoutSsl, + ); + this.appendGauge( + lines, + emitted, + 'lyttle_proxy_entries_by_type_total', + 'Number of proxy entries by type', + data.proxies.proxies, + { type: 'proxy' }, + ); + this.appendGauge( + lines, + emitted, + 'lyttle_proxy_entries_by_type_total', + 'Number of proxy entries by type', + data.proxies.redirects, + { type: 'redirect' }, + ); + + for (const check of data.health.checks) { + this.appendGauge( + lines, + emitted, + 'lyttle_health_dependency_status', + 'Dependency status where 1 indicates healthy and 0 indicates unhealthy', + check.status === 'ok' ? 1 : 0, + { name: check.name }, + ); + if (typeof check.latencyMs === 'number') { + this.appendGauge( + lines, + emitted, + 'lyttle_health_dependency_latency_ms', + 'Dependency check latency in milliseconds when available', + check.latencyMs, + { name: check.name }, + ); + } + } + this.appendGauge( + lines, + emitted, + 'lyttle_db_connectivity_status', + 'Database connectivity health where 1 indicates healthy', + data.health.database?.status === 'ok' ? 1 : 0, + ); + this.appendGauge( + lines, + emitted, + 'lyttle_db_query_duration_ms', + 'Database health-check query duration in milliseconds', + data.health.database?.latencyMs ?? 0, + ); + this.appendGauge( + lines, + emitted, + 'lyttle_nginx_master_process_status', + 'NGINX master-process health where 1 indicates healthy', + data.health.nginxMaster?.status === 'ok' ? 1 : 0, + ); + this.appendOperationalStateMetrics( + lines, + emitted, + 'config_apply', + 'Configuration apply state', + data.health.operations.configApply, + ); + this.appendOperationalStateMetrics( + lines, + emitted, + 'certificate_sync', + 'Certificate sync state', + data.health.operations.certificateSync, + ); + + this.appendGauge( + lines, + emitted, + 'lyttle_cluster_leases_total', + 'Total number of cluster leases', + data.leases.total, + ); + this.appendGauge( + lines, + emitted, + 'lyttle_cluster_leases_active', + 'Number of active cluster leases', + data.leases.active, + ); + this.appendGauge( + lines, + emitted, + 'lyttle_cluster_leases_expired', + 'Number of expired cluster leases still present in the database', + data.leases.expired, + ); + this.appendGauge( + lines, + emitted, + 'lyttle_cluster_leader_present', + 'Whether a leader lease record is present', + data.leases.leader.present ? 1 : 0, + ); + this.appendGauge( + lines, + emitted, + 'lyttle_cluster_leader_lease_seconds_remaining', + 'Seconds remaining before the leader lease expires', + data.leases.leader.secondsRemaining, + ); + this.appendGauge( + lines, + emitted, + 'lyttle_cluster_leader_lease_generation', + 'Current leader lease generation / fencing token', + data.leases.leader.generation, + ); + this.appendGauge( + lines, + emitted, + 'lyttle_cluster_leader_lease_expired', + 'Whether the current leader lease is expired', + data.leases.leader.isExpired ? 1 : 0, + ); + + this.appendStatusSeries( + lines, + emitted, + 'lyttle_cluster_operations_total', + 'Number of cluster operations by status', + data.clusterOperations.byStatus, + ); + this.appendStatusSeries( + lines, + emitted, + 'lyttle_cluster_operation_acks_total', + 'Number of cluster operation acknowledgements by status', + data.clusterOperations.acknowledgements.byStatus, + ); + this.appendStatusSeries( + lines, + emitted, + 'lyttle_cluster_operations_by_type_total', + 'Number of cluster operations by operation type', + data.clusterOperations.byType, + 'operation_type', + ); + this.appendGauge( + lines, + emitted, + 'lyttle_cluster_operations_active_total', + 'Number of active cluster operations', + data.clusterOperations.active.total, + ); + this.appendGauge( + lines, + emitted, + 'lyttle_cluster_operations_stale_total', + 'Number of active cluster operations older than the configured stale threshold', + data.clusterOperations.active.stale, + ); + this.appendGauge( + lines, + emitted, + 'lyttle_cluster_operations_oldest_active_age_seconds', + 'Age in seconds of the oldest active cluster operation', + data.clusterOperations.active.oldestAgeSeconds, + ); + this.appendGauge( + lines, + emitted, + 'lyttle_cluster_operations_stale_threshold_seconds', + 'Stale threshold for active cluster operations in seconds', + Math.round(data.clusterOperations.active.staleThresholdMs / 1000), + ); + this.appendGauge( + lines, + emitted, + 'lyttle_cluster_operations_recent_failures_total', + 'Number of failed or partially failed cluster operations within the configured failure window', + data.clusterOperations.recentFailures.total, + ); + this.appendGauge( + lines, + emitted, + 'lyttle_cluster_operations_failure_window_seconds', + 'Observation window for recent cluster operation failure counts in seconds', + Math.round(data.clusterOperations.recentFailures.windowMs / 1000), + ); + + this.appendStatusSeries( + lines, + emitted, + 'lyttle_certificate_orders_total', + 'Number of certificate orders by lifecycle status', + data.certificateOrders.byStatus, + ); + this.appendGauge( + lines, + emitted, + 'lyttle_certificate_orders_active_total', + 'Number of non-terminal certificate orders', + data.certificateOrders.active, + ); + this.appendGauge( + lines, + emitted, + 'lyttle_certificate_orders_retry_scheduled_total', + 'Number of certificate orders scheduled to retry in the future', + data.certificateOrders.retryScheduled, + ); + this.appendGauge( + lines, + emitted, + 'lyttle_certificate_orders_retry_due_total', + 'Number of certificate orders whose retry time is due now', + data.certificateOrders.retryDue, + ); + this.appendGauge( + lines, + emitted, + 'lyttle_certificate_orders_stale_total', + 'Number of active certificate orders older than the configured stale threshold', + data.certificateOrders.stale, + ); + this.appendGauge( + lines, + emitted, + 'lyttle_certificate_orders_oldest_active_age_seconds', + 'Age in seconds of the oldest active certificate order', + data.certificateOrders.oldestActiveAgeSeconds, + ); + this.appendGauge( + lines, + emitted, + 'lyttle_certificate_orders_stale_threshold_seconds', + 'Stale threshold for active certificate orders in seconds', + Math.round(data.certificateOrders.staleThresholdMs / 1000), + ); + + this.appendGauge( + lines, + emitted, + 'lyttle_backups_total', + 'Total number of backup artifacts present on disk', + data.backups.total, + ); + this.appendGauge( + lines, + emitted, + 'lyttle_backups_total_size_bytes', + 'Combined size in bytes of all backup artifacts present on disk', + data.backups.totalSizeBytes, + ); + this.appendGauge( + lines, + emitted, + 'lyttle_backup_latest_age_seconds', + 'Age in seconds of the most recent backup artifact', + data.backups.latest?.ageSeconds ?? 0, + ); + this.appendGauge( + lines, + emitted, + 'lyttle_backup_latest_size_bytes', + 'Size in bytes of the most recent backup artifact', + data.backups.latest?.sizeBytes ?? 0, + ); + this.appendGauge( + lines, + emitted, + 'lyttle_backup_freshness_status', + 'Whether a recent backup exists within the configured freshness window', + data.backups.freshnessStatus, + ); + this.appendGauge( + lines, + emitted, + 'lyttle_backup_max_age_seconds', + 'Maximum acceptable age for the latest backup before it is considered stale', + Math.round(data.backups.maxAgeMs / 1000), + ); return lines.join('\n'); } + + private appendOperationalStateMetrics( + lines: string[], + emitted: Set<string>, + operationName: string, + helpPrefix: string, + state: { + status: string; + ageMs: number | null; + maxAgeMs: number; + lastSuccessAt: string | null; + lastAttemptAt: string | null; + lastError: string | null; + }, + ) { + this.appendGauge( + lines, + emitted, + `lyttle_${operationName}_status`, + `${helpPrefix} health where 1 indicates healthy`, + state.status === 'ok' ? 1 : 0, + ); + this.appendGauge( + lines, + emitted, + `lyttle_${operationName}_age_seconds`, + `${helpPrefix} age in seconds since the last successful completion`, + state.ageMs === null ? 0 : Math.round(state.ageMs / 1000), + ); + this.appendGauge( + lines, + emitted, + `lyttle_${operationName}_max_age_seconds`, + `${helpPrefix} maximum acceptable freshness age in seconds`, + Math.round(state.maxAgeMs / 1000), + ); + this.appendGauge( + lines, + emitted, + `lyttle_${operationName}_last_success_timestamp_seconds`, + `${helpPrefix} last successful completion time as a Unix timestamp`, + this.toUnixTimestampSeconds(state.lastSuccessAt), + ); + this.appendGauge( + lines, + emitted, + `lyttle_${operationName}_last_attempt_timestamp_seconds`, + `${helpPrefix} last attempted completion time as a Unix timestamp`, + this.toUnixTimestampSeconds(state.lastAttemptAt), + ); + this.appendGauge( + lines, + emitted, + `lyttle_${operationName}_last_error_status`, + `${helpPrefix} latest attempt error indicator where 1 means an error is present`, + state.lastError ? 1 : 0, + ); + } + + private appendStatusSeries( + lines: string[], + emitted: Set<string>, + metricName: string, + help: string, + values: Record<string, number>, + labelName = 'status', + ) { + const entries: Array<[string, number]> = + Object.keys(values).length > 0 + ? Object.entries(values).map(([key, value]) => [key, Number(value)]) + : [['none', 0]]; + for (const [labelValue, value] of entries) { + this.appendGauge(lines, emitted, metricName, help, value, { + [labelName]: labelValue, + }); + } + } + + private appendGauge( + lines: string[], + emitted: Set<string>, + name: string, + help: string, + value: number, + labels?: Record<string, string>, + ) { + if (!emitted.has(name)) { + lines.push(`# HELP ${name} ${help}`); + lines.push(`# TYPE ${name} gauge`); + emitted.add(name); + } + + lines.push(`${name}${this.formatLabels(labels)} ${this.normalizeMetricValue(value)}`); + lines.push(''); + } + + private formatLabels(labels?: Record<string, string>) { + if (!labels || Object.keys(labels).length === 0) { + return ''; + } + + const rendered = Object.entries(labels) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, value]) => `${key}="${this.escapeLabelValue(value)}"`) + .join(','); + + return `{${rendered}}`; + } + + private escapeLabelValue(value: string) { + return value.replace(/\\/g, '\\\\').replace(/\n/g, '\\n').replace(/"/g, '\\"'); + } + + private normalizeMetricValue(value: number) { + if (!Number.isFinite(value)) { + return 0; + } + + return value; + } + + private toUnixTimestampSeconds(value: Date | string | null | undefined) { + if (!value) { + return 0; + } + + const date = value instanceof Date ? value : new Date(value); + const timestamp = date.getTime(); + return Number.isFinite(timestamp) ? Math.floor(timestamp / 1000) : 0; + } + + private countBy<T>(items: T[], getKey: (item: T) => string | null | undefined) { + return items.reduce<Record<string, number>>((counts, item) => { + const key = getKey(item)?.trim(); + const normalizedKey = key && key.length > 0 ? key : 'unknown'; + const nextCount = (counts[normalizedKey] ?? 0) + 1; + counts[normalizedKey] = nextCount; + return counts; + }, {}); + } + + private getThresholdFromEnv(name: string, fallback: number) { + const rawValue = process.env[name]; + if (!rawValue) { + return fallback; + } + + const parsedValue = Number.parseInt(rawValue, 10); + if (!Number.isFinite(parsedValue) || parsedValue <= 0) { + this.logger.warn( + `Ignoring invalid ${name} value ${rawValue}; using fallback ${fallback}`, + ); + return fallback; + } + + return parsedValue; + } + + private getEmptyCertificateMetrics() { + return { + total: 0, + valid: 0, + expiringSoon: 0, + expired: 0, + avgDaysUntilExpiry: 0, + oldestExpiry: null as Date | null, + newestExpiry: null as Date | null, + }; + } + + private getEmptyProxyMetrics() { + return { + total: 0, + withSsl: 0, + withoutSsl: 0, + proxies: 0, + redirects: 0, + }; + } + + private getEmptyHealthMetrics() { + return { + status: 'error', + summary: { + total: 0, + ok: 0, + error: 0, + }, + thresholds: { + configApplyMaxAgeMs: 0, + certificateSyncMaxAgeMs: 0, + }, + checks: [] as any[], + database: null as any, + nginxMaster: null as any, + operations: { + configApply: { + status: 'error', + ageMs: null as number | null, + maxAgeMs: 0, + lastSuccessAt: null as string | null, + lastAttemptAt: null as string | null, + lastError: null as string | null, + }, + certificateSync: { + status: 'error', + ageMs: null as number | null, + maxAgeMs: 0, + lastSuccessAt: null as string | null, + lastAttemptAt: null as string | null, + lastError: null as string | null, + }, + }, + }; + } + + private getEmptyLeaseMetrics() { + return { + total: 0, + active: 0, + expired: 0, + leader: { + present: false, + ownerNodeId: null as string | null, + ownerHostname: null as string | null, + generation: 0, + ttlSeconds: 0, + secondsRemaining: 0, + isExpired: true, + }, + }; + } + + private getEmptyClusterOperationMetrics() { + return { + total: 0, + byStatus: {} as Record<string, number>, + byType: {} as Record<string, number>, + active: { + total: 0, + stale: 0, + oldestAgeSeconds: 0, + staleThresholdMs: this.clusterOperationStaleMaxAgeMs, + }, + recentFailures: { + total: 0, + windowMs: this.clusterOperationFailureWindowMs, + }, + acknowledgements: { + total: 0, + byStatus: {} as Record<string, number>, + }, + }; + } + + private getEmptyCertificateOrderMetrics() { + return { + total: 0, + active: 0, + stale: 0, + staleThresholdMs: this.certificateOrderStaleMaxAgeMs, + oldestActiveAgeSeconds: 0, + retryScheduled: 0, + retryDue: 0, + byStatus: {} as Record<string, number>, + }; + } + + private getEmptyBackupMetrics() { + return { + total: 0, + totalSizeBytes: 0, + latest: null as { + filename: string; + createdAt: string; + sizeBytes: number; + ageSeconds: number; + } | null, + maxAgeMs: this.backupMaxAgeMs, + freshnessStatus: 0, + }; + } } diff --git a/src/nginx/nginx-custom-code.ts b/src/nginx/nginx-custom-code.ts new file mode 100644 index 0000000..f39b7a1 --- /dev/null +++ b/src/nginx/nginx-custom-code.ts @@ -0,0 +1,564 @@ +import { posix as pathPosix } from 'path'; + +type NginxCustomStatement = + | { + kind: 'directive'; + name: string; + args: string[]; + } + | { + kind: 'block'; + name: string; + args: string[]; + children: NginxCustomStatement[]; + }; + +const SERVER_DIRECTIVE_ALLOWLIST = new Set([ + 'add_header', + 'client_max_body_size', + 'expires', +]); + +const LOCATION_DIRECTIVE_ALLOWLIST = new Set([ + 'add_header', + 'alias', + 'autoindex', + 'client_max_body_size', + 'default_type', + 'expires', + 'index', + 'return', + 'root', + 'try_files', +]); + +const DEFAULT_ALLOWED_PATH_PREFIXES = [ + '/etc/nginx/custom', + '/etc/nginx/html', + '/srv/www', + '/usr/share/nginx/html', + '/var/www', +]; + +export class NginxCustomCodeValidationError extends Error { + constructor(message: string) { + super(`Invalid nginx_custom_code: ${message}`); + this.name = 'NginxCustomCodeValidationError'; + } +} + +export function sanitizeNginxCustomCode( + fragment: string | null | undefined, +): string { + const statements = parseValidatedCustomCode(fragment); + if (statements.length === 0) { + return ''; + } + + return `${renderStatements(statements, ' ').trimEnd()}\n`; +} + +export function extractManagedPathsFromCustomCode( + fragment: string | null | undefined, +): string[] { + const statements = parseValidatedCustomCode(fragment); + const paths = new Set<string>(); + collectManagedPaths(statements, paths); + return [...paths]; +} + +function parseValidatedCustomCode( + fragment: string | null | undefined, +): NginxCustomStatement[] { + const normalized = normalizeFragment(fragment); + if (!normalized) { + return []; + } + + assertNoDisallowedControlCharacters(normalized); + + const tokens = tokenize(normalized); + const { statements, index } = parseStatements(tokens, 0); + if (index !== tokens.length) { + throw new NginxCustomCodeValidationError( + 'unexpected trailing tokens in custom fragment', + ); + } + + validateStatements(statements, 'server'); + return statements; +} + +function normalizeFragment(fragment: string | null | undefined): string { + return (fragment ?? '').replace(/\r\n?/g, '\n').trim(); +} + +function assertNoDisallowedControlCharacters(fragment: string): void { + for (const character of fragment) { + const code = character.charCodeAt(0); + const isAllowedWhitespace = character === '\n' || character === '\t'; + if (code < 32 && !isAllowedWhitespace) { + throw new NginxCustomCodeValidationError( + 'control characters are not allowed in custom fragments', + ); + } + } +} + +function tokenize(fragment: string): string[] { + const tokens: string[] = []; + let current = ''; + let quote: '"' | "'" | null = null; + let escaped = false; + + const pushCurrent = () => { + if (current) { + tokens.push(current); + current = ''; + } + }; + + for (let index = 0; index < fragment.length; index += 1) { + const character = fragment[index]; + + if (quote) { + current += character; + if (escaped) { + escaped = false; + continue; + } + + if (character === '\\') { + escaped = true; + continue; + } + + if (character === quote) { + quote = null; + } + continue; + } + + if (character === '#' && current.length === 0) { + while (index < fragment.length && fragment[index] !== '\n') { + index += 1; + } + continue; + } + + if (character === '"' || character === "'") { + current += character; + quote = character; + continue; + } + + if (/\s/.test(character)) { + pushCurrent(); + continue; + } + + if (character === '{' || character === '}' || character === ';') { + pushCurrent(); + tokens.push(character); + continue; + } + + current += character; + } + + if (quote) { + throw new NginxCustomCodeValidationError( + 'unterminated quoted string in custom fragment', + ); + } + + pushCurrent(); + return tokens; +} + +function parseStatements( + tokens: string[], + startIndex: number, +): { statements: NginxCustomStatement[]; index: number } { + const statements: NginxCustomStatement[] = []; + let index = startIndex; + + while (index < tokens.length && tokens[index] !== '}') { + const headTokens: string[] = []; + + while ( + index < tokens.length && + !['{', '}', ';'].includes(tokens[index]) + ) { + headTokens.push(tokens[index]); + index += 1; + } + + if (headTokens.length === 0) { + throw new NginxCustomCodeValidationError( + 'empty or malformed statement in custom fragment', + ); + } + + const delimiter = tokens[index]; + if (delimiter === ';') { + statements.push({ + kind: 'directive', + name: headTokens[0], + args: headTokens.slice(1), + }); + index += 1; + continue; + } + + if (delimiter === '{') { + const blockName = headTokens[0]; + const childResult = parseStatements(tokens, index + 1); + if (tokens[childResult.index] !== '}') { + throw new NginxCustomCodeValidationError( + `block "${blockName}" is missing a closing brace`, + ); + } + + statements.push({ + kind: 'block', + name: blockName, + args: headTokens.slice(1), + children: childResult.statements, + }); + index = childResult.index + 1; + continue; + } + + throw new NginxCustomCodeValidationError( + `statement "${headTokens.join(' ')}" must end with ";" or contain a block`, + ); + } + + return { statements, index }; +} + +function validateStatements( + statements: NginxCustomStatement[], + context: 'server' | 'location', +): void { + for (const statement of statements) { + if (statement.kind === 'block') { + validateBlock(statement, context); + continue; + } + + validateDirective(statement, context); + } +} + +function validateBlock( + statement: Extract<NginxCustomStatement, { kind: 'block' }>, + context: 'server' | 'location', +): void { + const name = statement.name.toLowerCase(); + if (context !== 'server' || name !== 'location') { + throw new NginxCustomCodeValidationError( + `block directive "${statement.name}" is not allowed in ${context}-level custom fragments`, + ); + } + + validateLocationSelector(statement.args); + validateStatements(statement.children, 'location'); +} + +function validateDirective( + statement: Extract<NginxCustomStatement, { kind: 'directive' }>, + context: 'server' | 'location', +): void { + const name = statement.name.toLowerCase(); + const allowlist = + context === 'server' + ? SERVER_DIRECTIVE_ALLOWLIST + : LOCATION_DIRECTIVE_ALLOWLIST; + + if (!allowlist.has(name)) { + throw new NginxCustomCodeValidationError( + `directive "${statement.name}" is not allowed in ${context}-level custom fragments`, + ); + } + + switch (name) { + case 'add_header': + validateAddHeader(statement.args); + return; + case 'alias': + case 'root': + validateManagedPathDirective(statement.name, statement.args); + return; + case 'autoindex': + validateSingleEnumArg(statement.name, statement.args, ['on', 'off']); + return; + case 'client_max_body_size': + validateClientMaxBodySize(statement.args); + return; + case 'default_type': + validateDefaultType(statement.args); + return; + case 'expires': + validateExpires(statement.args); + return; + case 'index': + validateIndex(statement.args); + return; + case 'return': + validateReturn(statement.args); + return; + case 'try_files': + validateTryFiles(statement.args); + return; + default: + throw new NginxCustomCodeValidationError( + `directive "${statement.name}" is not supported`, + ); + } +} + +function validateLocationSelector(args: string[]): void { + if (args.length === 0 || args.length > 2) { + throw new NginxCustomCodeValidationError( + 'location blocks must use a path and may optionally use only the "=" or "^~" modifiers', + ); + } + + const [maybeModifier, maybePath] = args; + const modifier = args.length === 2 ? maybeModifier : null; + const locationPath = args.length === 2 ? maybePath : maybeModifier; + + if (modifier && !['=', '^~'].includes(modifier)) { + throw new NginxCustomCodeValidationError( + 'location blocks may only use the "=" or "^~" modifiers', + ); + } + + if (!locationPath.startsWith('/')) { + throw new NginxCustomCodeValidationError( + 'location paths must start with "/"', + ); + } + + if (/[{};$]/.test(locationPath) || locationPath.includes('..')) { + throw new NginxCustomCodeValidationError( + `location path "${locationPath}" is not allowed`, + ); + } +} + +function validateAddHeader(args: string[]): void { + if (args.length < 2 || args.length > 3) { + throw new NginxCustomCodeValidationError( + 'add_header requires a header name, a value, and optionally the "always" flag', + ); + } + + if (!/^[A-Za-z0-9-]+$/.test(args[0])) { + throw new NginxCustomCodeValidationError( + `header name "${args[0]}" is not allowed`, + ); + } + + if (args.length === 3 && args[2] !== 'always') { + throw new NginxCustomCodeValidationError( + 'add_header only supports the optional "always" flag as the third argument', + ); + } +} + +function validateManagedPathDirective(name: string, args: string[]): void { + if (args.length !== 1) { + throw new NginxCustomCodeValidationError( + `${name} requires exactly one absolute path argument`, + ); + } + + const normalizedPath = normalizeManagedPath(args[0]); + const allowedPrefixes = getAllowedPathPrefixes(); + const isAllowed = allowedPrefixes.some((prefix) => + normalizedPath === prefix || normalizedPath.startsWith(`${prefix}/`), + ); + + if (!isAllowed) { + throw new NginxCustomCodeValidationError( + `${name} path "${normalizedPath}" must stay within one of: ${allowedPrefixes.join(', ')}`, + ); + } +} + +function validateSingleEnumArg( + name: string, + args: string[], + allowedValues: string[], +): void { + if (args.length !== 1 || !allowedValues.includes(args[0])) { + throw new NginxCustomCodeValidationError( + `${name} must be one of: ${allowedValues.join(', ')}`, + ); + } +} + +function validateClientMaxBodySize(args: string[]): void { + if (args.length !== 1 || !/^(0|\d+[kKmMgG]?)$/.test(args[0])) { + throw new NginxCustomCodeValidationError( + 'client_max_body_size must be 0 or a size such as 10m', + ); + } +} + +function validateDefaultType(args: string[]): void { + if (args.length !== 1 || !/^[A-Za-z0-9.+-]+\/[A-Za-z0-9.+-]+$/.test(args[0])) { + throw new NginxCustomCodeValidationError( + 'default_type must be a single MIME type such as text/plain', + ); + } +} + +function validateExpires(args: string[]): void { + if ( + args.length !== 1 || + !/^(off|epoch|max|modified|@?[A-Za-z0-9_:+-]+)$/.test(args[0]) + ) { + throw new NginxCustomCodeValidationError( + 'expires must be a single cache-control value such as off, max, or 1h', + ); + } +} + +function validateIndex(args: string[]): void { + if (args.length === 0) { + throw new NginxCustomCodeValidationError( + 'index requires at least one file name', + ); + } + + for (const value of args) { + if (/[{};$]/.test(value) || value.includes('..')) { + throw new NginxCustomCodeValidationError( + `index value "${value}" is not allowed`, + ); + } + } +} + +function validateReturn(args: string[]): void { + if (args.length === 0 || args.length > 2) { + throw new NginxCustomCodeValidationError( + 'return requires one or two arguments', + ); + } + + if (args.length === 2 && !/^\d{3}$/.test(args[0])) { + throw new NginxCustomCodeValidationError( + 'two-argument return statements must start with an HTTP status code', + ); + } +} + +function validateTryFiles(args: string[]): void { + if (args.length < 2) { + throw new NginxCustomCodeValidationError( + 'try_files requires at least two arguments', + ); + } + + for (const value of args) { + if (/[{};]/.test(value)) { + throw new NginxCustomCodeValidationError( + `try_files value "${value}" is not allowed`, + ); + } + } +} + +function normalizeManagedPath(rawPathToken: string): string { + const rawPath = stripWrappingQuotes(rawPathToken); + if (!rawPath.startsWith('/')) { + throw new NginxCustomCodeValidationError( + `managed path "${rawPath}" must be absolute`, + ); + } + + if (/[{};$]/.test(rawPath) || rawPath.includes('..') || rawPath.includes('\\')) { + throw new NginxCustomCodeValidationError( + `managed path "${rawPath}" is not allowed`, + ); + } + + if (/\s/.test(rawPath)) { + throw new NginxCustomCodeValidationError( + `managed path "${rawPath}" must not contain whitespace`, + ); + } + + const normalized = pathPosix.normalize(rawPath); + if (!normalized.startsWith('/')) { + throw new NginxCustomCodeValidationError( + `managed path "${rawPath}" is not allowed`, + ); + } + + return normalized; +} + +function stripWrappingQuotes(value: string): string { + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + return value.slice(1, -1); + } + + return value; +} + +function getAllowedPathPrefixes(): string[] { + const configuredPrefixes = process.env['NGINX_CUSTOM_CODE_ALLOWED_PATH_PREFIXES'] + ?.split(',') + .map((value) => value.trim()) + .filter(Boolean) + .map((value) => pathPosix.normalize(stripWrappingQuotes(value))); + + return configuredPrefixes?.length + ? configuredPrefixes + : DEFAULT_ALLOWED_PATH_PREFIXES; +} + +function collectManagedPaths( + statements: NginxCustomStatement[], + paths: Set<string>, +): void { + for (const statement of statements) { + if (statement.kind === 'block') { + collectManagedPaths(statement.children, paths); + continue; + } + + const name = statement.name.toLowerCase(); + if ((name === 'root' || name === 'alias') && statement.args[0]) { + paths.add(normalizeManagedPath(statement.args[0])); + } + } +} + +function renderStatements( + statements: NginxCustomStatement[], + indent: string, +): string { + return statements + .map((statement) => { + if (statement.kind === 'directive') { + return `${indent}${statement.name} ${statement.args.join(' ')};`; + } + + return `${indent}${statement.name} ${statement.args.join(' ')} {\n${renderStatements( + statement.children, + `${indent} `, + )}\n${indent}}`; + }) + .join('\n'); +} + diff --git a/src/nginx/nginx.service.ts b/src/nginx/nginx.service.ts index 4b61608..defb127 100644 --- a/src/nginx/nginx.service.ts +++ b/src/nginx/nginx.service.ts @@ -1,28 +1,44 @@ import { Injectable } from '@nestjs/common'; import { ProxyEntry, ProxyType } from '@prisma/client'; import * as fs from 'fs'; +import { sanitizeNginxCustomCode } from './nginx-custom-code'; +import { getCertificateStorageName, parseDomains } from '../utils/domain-utils'; + +type GenerateNginxConfigOptions = { + resolved?: boolean; + htmlRoot?: string; + letsEncryptLiveRoot?: string; + acmeProxyPass?: string; +}; @Injectable() export class NginxService { public generateNginxConfig( entries: ProxyEntry[], - resolved: boolean = true, + options: GenerateNginxConfigOptions = {}, ): string { + const resolved = options.resolved ?? true; + const htmlRoot = options.htmlRoot ?? '/etc/nginx/html'; + const letsEncryptLiveRoot = + options.letsEncryptLiveRoot ?? '/etc/letsencrypt/live'; + const acmeProxyPass = options.acmeProxyPass ?? 'http://127.0.0.1:3000'; let config = `# Generated by Lyttle NGINX - DO NOT EDIT\n`; for (const entry of entries) { try { - const domains = entry.domains - .split(';') - .map((d) => d.trim()) - .map((d) => d.replace(/^\*/, '').trim()) - .filter(Boolean); + const domains = parseDomains(entry.domains, { allowWildcard: true }); if (domains.length === 0) continue; const primaryDomain = domains[0]; - - const certPath = `/etc/letsencrypt/live/${primaryDomain}/fullchain.pem`; - const keyPath = `/etc/letsencrypt/live/${primaryDomain}/privkey.pem`; + const certStorageName = getCertificateStorageName(primaryDomain); + const { + proxy_pass_host: proxyPassHost, + nginx_custom_code: customCodeSource, + } = entry; + + const certPath = `${letsEncryptLiveRoot}/${certStorageName}/fullchain.pem`; + const keyPath = `${letsEncryptLiveRoot}/${certStorageName}/privkey.pem`; const hasCert = fs.existsSync(certPath) && fs.existsSync(keyPath); + const customCode = sanitizeNginxCustomCode(customCodeSource); // Use single server block for all entry types (matches pre-TLS behavior) { @@ -37,31 +53,15 @@ export class NginxService { ` : ''; - const server_block = ` -server { - listen 80; - listen [::]:80; - ${sslLines} - server_name ${domains.join(' ')}; - - # Allow ACME challenges - proxy to Node.js app (applies to all entry types) - location /.well-known/acme-challenge/ { - proxy_pass http://127.0.0.1:3000; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } - - ${ - entry.type === ProxyType.REDIRECT - ? `# Redirect all other traffic + const upstreamBlock = + entry.type === ProxyType.REDIRECT + ? `# Redirect all other traffic location / { - return 301 ${entry.proxy_pass_host}; + return 301 ${proxyPassHost}; }` - : `# Proxy traffic to upstream + : `# Proxy traffic to upstream location / { - ${resolved ? `proxy_pass ${entry.proxy_pass_host};` : 'return 503;'} + ${resolved ? `proxy_pass ${proxyPassHost};` : 'return 503;'} proxy_ssl_verify off; proxy_pass_request_headers on; @@ -84,9 +84,26 @@ server { proxy_set_header Connection $connection_upgrade; proxy_read_timeout 86400; - }` + }`; + + const serverBlock = ` +server { + listen 80; + listen [::]:80; + ${sslLines} + server_name ${domains.join(' ')}; + + # Allow ACME challenges - proxy to Node.js app (applies to all entry types) + location /.well-known/acme-challenge/ { + proxy_pass ${acmeProxyPass}; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; } + ${upstreamBlock} + # Ensure upstream 5xx are intercepted so error_page is used proxy_intercept_errors on; @@ -94,28 +111,28 @@ server { error_page 500 /error-5xx.html; location = /error-5xx.html { internal; - root /etc/nginx/html/errors; + root ${htmlRoot}/errors; try_files /5xx.html =200; } error_page 503 /error-broken.html; location = /error-broken.html { internal; - root /etc/nginx/html/errors; + root ${htmlRoot}/errors; try_files /broken.html =200; } error_page 502 504 /error-loading.html; location = /error-loading.html { internal; - root /etc/nginx/html/errors; + root ${htmlRoot}/errors; try_files /loading.html =200; } - - ${entry.nginx_custom_code || ''} + +${customCode} } `; - config += server_block; + config += serverBlock; } } catch (error) { console.error(`Error generating config for entry ${entry.id}:`, error); diff --git a/src/prisma/prisma-json.util.ts b/src/prisma/prisma-json.util.ts new file mode 100644 index 0000000..e7292b2 --- /dev/null +++ b/src/prisma/prisma-json.util.ts @@ -0,0 +1,48 @@ +import { Prisma } from '@prisma/client'; + +export function toPrismaJsonValue(value: unknown): Prisma.InputJsonValue { + if (typeof value === 'string' || typeof value === 'boolean') { + return value; + } + + if (typeof value === 'number') { + return Number.isFinite(value) ? value : String(value); + } + + if (typeof value === 'bigint') { + return value.toString(); + } + + if (value instanceof Date) { + return value.toISOString(); + } + + if (value instanceof Error) { + return { + name: value.name, + message: value.message, + ...(value.stack ? { stack: value.stack } : {}), + } satisfies Prisma.InputJsonObject; + } + + if (Array.isArray(value)) { + return value.map((entry) => toPrismaJsonValue(entry)); + } + + if (value && typeof value === 'object') { + const entries = Object.entries(value).flatMap(([key, entry]) => + entry === undefined ? [] : [[key, toPrismaJsonValue(entry)] as const], + ); + + return Object.fromEntries(entries) as Prisma.InputJsonObject; + } + + return String(value); +} + +export function toPrismaNullableJsonValue( + value: unknown, +): Prisma.NullableJsonNullValueInput | Prisma.InputJsonValue { + return value === null ? Prisma.JsonNull : toPrismaJsonValue(value); +} + diff --git a/src/prisma/prisma.service.ts b/src/prisma/prisma.service.ts index d6a52b1..210e4d6 100644 --- a/src/prisma/prisma.service.ts +++ b/src/prisma/prisma.service.ts @@ -16,26 +16,33 @@ export class PrismaService constructor() { // Configure connection pool to prevent connection leaks // Parse DATABASE_URL and add connection pool parameters if not present - const databaseUrl = process.env.DATABASE_URL || ''; - const url = new URL(databaseUrl); + const databaseUrl = process.env.DATABASE_URL; + if (databaseUrl) { + const url = new URL(databaseUrl); + const configuredPoolSize = Number.parseInt( + url.searchParams.get('pool_size') || '', + 10, + ); + const resolvedConnectionLimit = + Number.isFinite(configuredPoolSize) && configuredPoolSize > 0 + ? String(configuredPoolSize) + : '1'; - // Set connection pool limits if not already configured - if (!url.searchParams.has('connection_limit')) { - url.searchParams.set('connection_limit', '1'); // Max 1 connection per instance - } - if (!url.searchParams.has('pool_timeout')) { - url.searchParams.set('pool_timeout', '10'); // 10 second timeout - } - if (!url.searchParams.has('connect_timeout')) { - url.searchParams.set('connect_timeout', '10'); // 10 second connect timeout + // Set connection pool limits if not already configured + if (!url.searchParams.has('connection_limit')) { + url.searchParams.set('connection_limit', resolvedConnectionLimit); + } + if (!url.searchParams.has('pool_timeout')) { + url.searchParams.set('pool_timeout', '10'); // 10 second timeout + } + if (!url.searchParams.has('connect_timeout')) { + url.searchParams.set('connect_timeout', '10'); // 10 second connect timeout + } + + process.env.DATABASE_URL = url.toString(); } super({ - datasources: { - db: { - url: url.toString(), - }, - }, log: [ { level: 'warn', emit: 'event' }, { level: 'error', emit: 'event' }, diff --git a/src/proxy/dto/create-proxy-entry.dto.ts b/src/proxy/dto/create-proxy-entry.dto.ts new file mode 100644 index 0000000..63b0f13 --- /dev/null +++ b/src/proxy/dto/create-proxy-entry.dto.ts @@ -0,0 +1,36 @@ +import { + IsArray, + IsBoolean, + IsEnum, + IsOptional, + IsString, +} from 'class-validator'; +import { ProxyType } from '@prisma/client'; +import { + IsDomainList, + NormalizeDomainList, +} from '../../certificate/dto/domain-list.decorator'; + +export class CreateProxyEntryDto { + @IsArray() + @IsString({ each: true }) + @NormalizeDomainList({ allowWildcard: true }) + @IsDomainList( + { allowWildcard: true }, + { message: 'Each domain must be a valid FQDN or wildcard FQDN' }, + ) + domains: string[]; + + @IsString() + proxyPassHost: string; + + @IsOptional() + @IsString() + nginxCustomCode?: string; + + @IsEnum(ProxyType) + type: ProxyType; + + @IsBoolean() + ssl: boolean; +} diff --git a/src/proxy/dto/update-proxy-entry.dto.ts b/src/proxy/dto/update-proxy-entry.dto.ts new file mode 100644 index 0000000..18cb05b --- /dev/null +++ b/src/proxy/dto/update-proxy-entry.dto.ts @@ -0,0 +1,40 @@ +import { + IsArray, + IsBoolean, + IsEnum, + IsOptional, + IsString, +} from 'class-validator'; +import { ProxyType } from '@prisma/client'; +import { + IsDomainList, + NormalizeDomainList, +} from '../../certificate/dto/domain-list.decorator'; + +export class UpdateProxyEntryDto { + @IsOptional() + @IsArray() + @IsString({ each: true }) + @NormalizeDomainList({ allowWildcard: true }) + @IsDomainList( + { allowWildcard: true }, + { message: 'Each domain must be a valid FQDN or wildcard FQDN' }, + ) + domains?: string[]; + + @IsOptional() + @IsString() + proxyPassHost?: string; + + @IsOptional() + @IsString() + nginxCustomCode?: string; + + @IsOptional() + @IsEnum(ProxyType) + type?: ProxyType; + + @IsOptional() + @IsBoolean() + ssl?: boolean; +} diff --git a/src/proxy/proxy.controller.ts b/src/proxy/proxy.controller.ts new file mode 100644 index 0000000..67f6d1a --- /dev/null +++ b/src/proxy/proxy.controller.ts @@ -0,0 +1,88 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + ParseIntPipe, + Patch, + Post, +} from '@nestjs/common'; +import { AuthorizeAdmin } from '../auth/decorators/authorize.decorator'; +import { Audit } from '../audit/decorators/audit.decorator'; +import { CreateProxyEntryDto } from './dto/create-proxy-entry.dto'; +import { UpdateProxyEntryDto } from './dto/update-proxy-entry.dto'; +import { ProxyService } from './proxy.service'; + +@Controller('proxies') +@AuthorizeAdmin('viewer') +export class ProxyController { + private readonly proxyService: ProxyService; + + constructor(proxyService: ProxyService) { + this.proxyService = proxyService; + } + + @Get() + async listProxies() { + return this.proxyService.listProxies(); + } + + @Post('validate') + @HttpCode(HttpStatus.OK) + @AuthorizeAdmin('operator') + @Audit({ action: 'proxy.validate-draft' }) + async validateDraftProxy(@Body() dto: CreateProxyEntryDto) { + return this.proxyService.validateDraftProxy(dto); + } + + @Post(':id/validate') + @HttpCode(HttpStatus.OK) + @AuthorizeAdmin('operator') + @Audit({ action: 'proxy.validate' }) + async validateStoredProxy(@Param('id', ParseIntPipe) id: number) { + return this.proxyService.validateStoredProxy(id); + } + + @Post(':id/test-upstream') + @HttpCode(HttpStatus.OK) + @AuthorizeAdmin('operator') + @Audit({ action: 'proxy.test-upstream' }) + async testProxyUpstream(@Param('id', ParseIntPipe) id: number) { + return this.proxyService.testProxyUpstream(id); + } + + @Post() + @HttpCode(HttpStatus.CREATED) + @AuthorizeAdmin('platform-admin') + @Audit({ action: 'proxy.create' }) + async createProxy(@Body() dto: CreateProxyEntryDto) { + return this.proxyService.createProxy(dto); + } + + @Get(':id') + async getProxy(@Param('id', ParseIntPipe) id: number) { + return this.proxyService.getProxy(id); + } + + @Patch(':id') + @HttpCode(HttpStatus.OK) + @AuthorizeAdmin('platform-admin') + @Audit({ action: 'proxy.update' }) + async updateProxy( + @Param('id', ParseIntPipe) id: number, + @Body() dto: UpdateProxyEntryDto, + ) { + return this.proxyService.updateProxy(id, dto); + } + + @Delete(':id') + @HttpCode(HttpStatus.OK) + @AuthorizeAdmin('platform-admin') + @Audit({ action: 'proxy.delete' }) + async deleteProxy(@Param('id', ParseIntPipe) id: number) { + return this.proxyService.deleteProxy(id); + } +} diff --git a/src/proxy/proxy.module.ts b/src/proxy/proxy.module.ts new file mode 100644 index 0000000..a63cda0 --- /dev/null +++ b/src/proxy/proxy.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { NginxModule } from '../nginx/nginx.module'; +import { PrismaModule } from '../prisma/prisma.module'; +import { ProxyController } from './proxy.controller'; +import { ProxyService } from './proxy.service'; + +@Module({ + imports: [PrismaModule, NginxModule], + controllers: [ProxyController], + providers: [ProxyService], + exports: [ProxyService], +}) +export class ProxyModule {} diff --git a/src/proxy/proxy.service.ts b/src/proxy/proxy.service.ts new file mode 100644 index 0000000..7a8749d --- /dev/null +++ b/src/proxy/proxy.service.ts @@ -0,0 +1,397 @@ +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { ProxyEntry, ProxyType } from '@prisma/client'; +import { lookup } from 'node:dns/promises'; +import { sanitizeNginxCustomCode } from '../nginx/nginx-custom-code'; +import { NginxService } from '../nginx/nginx.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { + joinDomains, + normalizeDomains, + parseDomains, +} from '../utils/domain-utils'; +import { CreateProxyEntryDto } from './dto/create-proxy-entry.dto'; +import { UpdateProxyEntryDto } from './dto/update-proxy-entry.dto'; + +type ProxyConfigChangeAction = 'created' | 'updated' | 'deleted'; + +type SerializedProxyEntry = { + id: number; + domains: string[]; + proxyPassHost: string; + nginxCustomCode: string; + type: ProxyType; + ssl: boolean; +}; + +type ProxyValidationResult = { + valid: true; + normalizedEntry: SerializedProxyEntry; + generatedConfigPreview: string; + warnings: string[]; +}; + +@Injectable() +export class ProxyService { + private readonly prisma: PrismaService; + private readonly nginx: NginxService; + + constructor(prisma: PrismaService, nginx: NginxService) { + this.prisma = prisma; + this.nginx = nginx; + } + + async listProxies() { + const proxies = await this.prisma.proxyEntry.findMany({ + orderBy: { id: 'asc' }, + }); + + return { + count: proxies.length, + proxies: proxies.map((entry) => this.serializeProxyEntry(entry)), + }; + } + + async getProxy(id: number) { + return this.serializeProxyEntry(await this.getProxyEntryOrThrow(id)); + } + + async createProxy(dto: CreateProxyEntryDto) { + const validation = await this.validateCandidate(dto); + const created = await this.prisma.proxyEntry.create({ + data: this.toPersistenceInput(validation), + }); + + return { + proxy: this.serializeProxyEntry(created), + validation, + configChange: this.buildConfigChange('created', created.id), + }; + } + + async updateProxy(id: number, dto: UpdateProxyEntryDto) { + const existing = await this.getProxyEntryOrThrow(id); + const candidate: CreateProxyEntryDto = { + domains: + dto.domains ?? parseDomains(existing.domains, { allowWildcard: true }), + proxyPassHost: dto.proxyPassHost ?? existing.proxy_pass_host, + nginxCustomCode: dto.nginxCustomCode ?? existing.nginx_custom_code ?? '', + type: dto.type ?? existing.type, + ssl: dto.ssl ?? existing.ssl, + }; + const validation = await this.validateCandidate(candidate, id); + + const updated = await this.prisma.proxyEntry.update({ + where: { id }, + data: this.toPersistenceInput(validation), + }); + + return { + proxy: this.serializeProxyEntry(updated), + validation, + configChange: this.buildConfigChange('updated', updated.id), + }; + } + + async deleteProxy(id: number) { + const existing = await this.getProxyEntryOrThrow(id); + await this.prisma.proxyEntry.delete({ where: { id } }); + + return { + deleted: true, + proxy: this.serializeProxyEntry(existing), + configChange: this.buildConfigChange('deleted', id), + }; + } + + async validateDraftProxy( + dto: CreateProxyEntryDto, + ): Promise<ProxyValidationResult> { + return this.validateCandidate(dto); + } + + async validateStoredProxy(id: number): Promise<ProxyValidationResult> { + const entry = await this.getProxyEntryOrThrow(id); + return this.validateCandidate( + { + domains: parseDomains(entry.domains, { allowWildcard: true }), + proxyPassHost: entry.proxy_pass_host, + nginxCustomCode: entry.nginx_custom_code ?? '', + type: entry.type, + ssl: entry.ssl, + }, + id, + ); + } + + async testProxyUpstream(id: number) { + const entry = await this.getProxyEntryOrThrow(id); + if (entry.type !== ProxyType.PROXY) { + throw new BadRequestException( + 'Upstream connectivity checks apply only to PROXY entries', + ); + } + + const upstream = new URL(entry.proxy_pass_host); + const hostname = upstream.hostname; + const protocol = upstream.protocol.replace(/:$/, ''); + const port = upstream.port + ? Number.parseInt(upstream.port, 10) + : upstream.protocol === 'https:' + ? 443 + : 80; + + try { + const addresses = await lookup(hostname, { all: true }); + return { + ok: true, + proxyId: entry.id, + proxyPassHost: entry.proxy_pass_host, + hostname, + protocol, + port, + addresses: addresses.map((address) => ({ + address: address.address, + family: address.family, + })), + testedAt: new Date().toISOString(), + }; + } catch (error) { + return { + ok: false, + proxyId: entry.id, + proxyPassHost: entry.proxy_pass_host, + hostname, + protocol, + port, + addresses: [], + testedAt: new Date().toISOString(), + error: + error instanceof Error + ? error.message + : 'Failed to resolve upstream hostname', + }; + } + } + + serializeProxyEntry(entry: ProxyEntry): SerializedProxyEntry { + return { + id: entry.id, + domains: parseDomains(entry.domains, { allowWildcard: true }), + proxyPassHost: entry.proxy_pass_host, + nginxCustomCode: entry.nginx_custom_code ?? '', + type: entry.type, + ssl: entry.ssl, + }; + } + + private async validateCandidate( + candidate: CreateProxyEntryDto, + existingId?: number, + ): Promise<ProxyValidationResult> { + const domains = normalizeDomains(candidate.domains, { + allowWildcard: true, + }); + const type = candidate.type; + const proxyPassHost = this.normalizeProxyPassHost( + type, + candidate.proxyPassHost, + ); + const nginxCustomCode = this.normalizeCustomCode(candidate.nginxCustomCode); + + await this.ensureNoDomainConflicts(domains, existingId); + + const previewEntry = { + id: existingId ?? 0, + domains: joinDomains(domains, { allowWildcard: true }), + proxy_pass_host: proxyPassHost, + nginx_custom_code: nginxCustomCode, + type, + ssl: candidate.ssl, + } as ProxyEntry; + + const generatedConfigPreview = this.nginx.generateNginxConfig( + [previewEntry], + { + resolved: true, + }, + ); + + if (!generatedConfigPreview.trim()) { + throw new BadRequestException( + 'Proxy entry validation produced an empty NGINX config', + ); + } + + const warnings: string[] = []; + if (candidate.ssl && domains.some((domain) => domain.startsWith('*.'))) { + warnings.push( + 'Wildcard proxy domains require pre-provisioned certificate material because the built-in HTTP-01 ACME flow does not issue wildcard certificates.', + ); + } + + return { + valid: true, + normalizedEntry: this.serializeProxyEntry(previewEntry), + generatedConfigPreview, + warnings, + }; + } + + private normalizeCustomCode(value: string | undefined): string | null { + const sanitized = sanitizeNginxCustomCode(value ?? ''); + const normalized = sanitized.trimEnd(); + return normalized.length > 0 ? normalized : null; + } + + private normalizeProxyPassHost(type: ProxyType, value: string): string { + if (typeof value !== 'string' || value.trim().length === 0) { + throw new BadRequestException('proxyPassHost must be a non-empty string'); + } + + const normalized = value.trim(); + if (/[\u0000-\u001f\u007f\s]/.test(normalized)) { + throw new BadRequestException( + 'proxyPassHost cannot contain whitespace or control characters', + ); + } + + if (type === ProxyType.REDIRECT && normalized.startsWith('/')) { + return normalized; + } + + let parsed: globalThis.URL; + try { + parsed = new URL(normalized); + } catch { + throw new BadRequestException( + 'proxyPassHost must be an absolute http(s) URL, or an absolute path for redirects', + ); + } + + if (!['http:', 'https:'].includes(parsed.protocol)) { + throw new BadRequestException( + 'proxyPassHost must use the http or https scheme', + ); + } + + if (!parsed.hostname) { + throw new BadRequestException('proxyPassHost must include a hostname'); + } + + if (parsed.username || parsed.password) { + throw new BadRequestException( + 'proxyPassHost must not include embedded credentials', + ); + } + + if (parsed.hash) { + throw new BadRequestException( + 'proxyPassHost must not include a URL fragment', + ); + } + + return parsed.toString(); + } + + private async ensureNoDomainConflicts( + candidateDomains: string[], + existingId?: number, + ): Promise<void> { + const existingEntries = await this.prisma.proxyEntry.findMany({ + where: existingId ? { id: { not: existingId } } : undefined, + orderBy: { id: 'asc' }, + }); + + for (const entry of existingEntries) { + const existingDomains = parseDomains(entry.domains, { + allowWildcard: true, + }); + const conflictingDomain = this.findConflictingDomain( + candidateDomains, + existingDomains, + ); + + if (conflictingDomain) { + throw new BadRequestException( + `Domain ${JSON.stringify(conflictingDomain)} is already managed by proxy entry ${entry.id}`, + ); + } + } + } + + private findConflictingDomain( + leftDomains: string[], + rightDomains: string[], + ): string | null { + for (const leftDomain of leftDomains) { + for (const rightDomain of rightDomains) { + if (this.domainsConflict(leftDomain, rightDomain)) { + return leftDomain; + } + } + } + + return null; + } + + private domainsConflict(leftDomain: string, rightDomain: string): boolean { + return ( + leftDomain === rightDomain || + this.domainMatchesWildcard(leftDomain, rightDomain) || + this.domainMatchesWildcard(rightDomain, leftDomain) + ); + } + + private domainMatchesWildcard( + domain: string, + wildcardDomain: string, + ): boolean { + if (!wildcardDomain.startsWith('*.')) { + return false; + } + + const wildcardBase = wildcardDomain.slice(2); + return ( + domain.length > wildcardBase.length + 1 && + domain.endsWith(`.${wildcardBase}`) + ); + } + + private toPersistenceInput(validation: ProxyValidationResult) { + return { + domains: joinDomains(validation.normalizedEntry.domains, { + allowWildcard: true, + }), + proxy_pass_host: validation.normalizedEntry.proxyPassHost, + nginx_custom_code: + validation.normalizedEntry.nginxCustomCode.trim().length > 0 + ? validation.normalizedEntry.nginxCustomCode + : null, + type: validation.normalizedEntry.type, + ssl: validation.normalizedEntry.ssl, + }; + } + + private buildConfigChange(action: ProxyConfigChangeAction, proxyId: number) { + return { + scope: 'proxy-entry', + action, + proxyId, + reloadRequired: true, + suggestedOperationEndpoint: '/cluster/reload', + }; + } + + private async getProxyEntryOrThrow(id: number): Promise<ProxyEntry> { + const entry = await this.prisma.proxyEntry.findUnique({ where: { id } }); + if (!entry) { + throw new NotFoundException(`Proxy entry ${id} was not found`); + } + + return entry; + } +} diff --git a/src/reloader/reloader.module.ts b/src/reloader/reloader.module.ts index cc7fea7..fe66224 100644 --- a/src/reloader/reloader.module.ts +++ b/src/reloader/reloader.module.ts @@ -5,9 +5,10 @@ import { NginxModule } from '../nginx/nginx.module'; import { NginxService } from '../nginx/nginx.service'; import { PrismaService } from '../prisma/prisma.service'; import { CertificateModule } from '../certificate/certificate.module'; +import { HealthModule } from '../health/health.module'; @Module({ - imports: [PrismaModule, NginxModule, CertificateModule], + imports: [PrismaModule, NginxModule, CertificateModule, HealthModule], providers: [ReloaderService, NginxService, PrismaService], exports: [ReloaderService], }) diff --git a/src/reloader/reloader.service.ts b/src/reloader/reloader.service.ts index 33ed99f..364154c 100644 --- a/src/reloader/reloader.service.ts +++ b/src/reloader/reloader.service.ts @@ -6,25 +6,40 @@ import { } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { NginxService } from '../nginx/nginx.service'; -import { exec } from 'child_process'; +import { execFile } from 'child_process'; import { + access, copyFile, mkdir, readdir, + readFile, + readlink, rename, rm, stat, + symlink, unlink, writeFile, } from 'fs/promises'; -import { join } from 'path'; +import { basename, dirname, join } from 'path'; import * as fs from 'fs'; import { CertificateService } from '../certificate/certificate.service'; import { TlsConfigService } from '../certificate/tls-config.service'; import { lookup } from 'dns/promises'; // <-- Added - -const NGINX_ETC_DIR = '/etc/nginx'; -const NGINX_SOURCE_DIR = join(process.cwd(), 'nginx'); +import { HealthService } from '../health/health.service'; +import { extractManagedPathsFromCustomCode } from '../nginx/nginx-custom-code'; +import { getCertificateStorageName, parseDomains } from '../utils/domain-utils'; + +const NGINX_ETC_DIR = process.env['NGINX_ETC_DIR'] ?? '/etc/nginx'; +const NGINX_SOURCE_DIR = + process.env['NGINX_SOURCE_DIR'] ?? join(process.cwd(), 'nginx'); +const NGINX_LOG_DIR = process.env['NGINX_LOG_DIR'] ?? '/var/log/nginx'; +const NGINX_RUNTIME_DIR = join(NGINX_ETC_DIR, 'runtime'); +const NGINX_RELEASES_DIR = join(NGINX_RUNTIME_DIR, 'releases'); +const NGINX_CURRENT_RELEASE_LINK = join(NGINX_RUNTIME_DIR, 'current'); +const NGINX_LAST_KNOWN_GOOD_LINK = join(NGINX_RUNTIME_DIR, 'last-known-good'); +const NGINX_RELEASE_METADATA_FILE = 'lyttle-nginx-release.json'; +const RELEASE_RETENTION_COUNT = 5; // Helper function to check if host resolves async function isHostResolvable(host: string): Promise<boolean> { @@ -39,7 +54,7 @@ async function isHostResolvable(host: string): Promise<boolean> { @Injectable() export class ReloaderService implements OnModuleInit, OnModuleDestroy { private readonly logger = new Logger(ReloaderService.name); - private intervalHandle: NodeJS.Timeout | null = null; + private intervalHandle: ReturnType<typeof setInterval> | null = null; // reload interval: every 5 minutes private readonly intervalMs = 5 * 60 * 1000; @@ -49,6 +64,7 @@ export class ReloaderService implements OnModuleInit, OnModuleDestroy { private nginx: NginxService, private certificate: CertificateService, private tlsConfig: TlsConfigService, + private healthService: HealthService, ) {} async onModuleInit() { @@ -89,58 +105,42 @@ export class ReloaderService implements OnModuleInit, OnModuleDestroy { async reloadConfig(): Promise<{ ok: boolean; error?: string }> { try { - this.logger.log('Starting nginx reload process...'); + this.logger.log('Starting staged nginx reload process...'); - // Step 1: Remove all content from /etc/nginx - this.logger.log(`Clearing directory: ${NGINX_ETC_DIR}`); - await this.clearDirectory(NGINX_ETC_DIR, false); + this.logger.log(`Ensuring ${NGINX_LOG_DIR} exists...`); + await mkdir(NGINX_LOG_DIR, { recursive: true }); - // Step 2: Copy full /nginx (project) to /etc/nginx - this.logger.log(`Copying from ${NGINX_SOURCE_DIR} to ${NGINX_ETC_DIR}`); - await this.copyDirectoryRecursive(NGINX_SOURCE_DIR, NGINX_ETC_DIR); + await this.ensureRuntimeLayout(); - this.logger.log('Ensuring /var/log/nginx exists...'); - await mkdir('/var/log/nginx', { recursive: true }); + // Step 1: Fetch proxy entries and ensure any required directories exist + this.logger.log('Fetching proxy entries from database...'); + const entries = await this.prisma.proxyEntry.findMany(); + this.logger.log(`Found ${entries.length} proxy entries.`); - // Step 3: Ensure any directories required by proxy/redirect configs exist this.logger.log( 'Ensuring directories required by proxies/redirects exist...', ); - await this.ensureProxyAndRedirectDirs(); - - // Step 4: Fetch proxy entries - this.logger.log('Fetching proxy entries from database...'); - const entries = await this.prisma.proxyEntry.findMany(); - this.logger.log(`Found ${entries.length} proxy entries.`); + await this.ensureProxyAndRedirectDirs(entries); // ----------- PHASE 1: Generate configs (HTTP only, as no SSL certs exist yet) ----------- - this.logger.log('Phase 1: Generating HTTP-only nginx configs...'); - await this.generateNginxConfs(entries); - - // Validate and reload - this.logger.log('Validating nginx config syntax (nginx -t)...'); - await this.execShell('nginx -t'); - - this.logger.log('Reloading nginx (nginx -s reload)...'); - await this.execShell('nginx -s reload'); + const httpOnlyRelease = await this.deployConfigSnapshot(entries, { + phase: 'http-bootstrap', + details: `http-only rollout for ${entries.length} proxy entr${entries.length === 1 ? 'y' : 'ies'}`, + }); // ----------- PHASE 2: Obtain SSL Certificates ----------- this.logger.log('Phase 2: Ensuring SSL certificates...'); - // Ensure certbot webroot exists - this.tlsConfig.ensureCertbotWebroot(); for (const entry of entries) { try { if (!entry.ssl) continue; - const domains = entry.domains - .split(';') - .map((d) => d.trim()) - .filter(Boolean); + const domains = parseDomains(entry.domains, { allowWildcard: true }); if (domains.length === 0) continue; const primaryDomain = domains[0]; - const certPath = `/etc/letsencrypt/live/${primaryDomain}/fullchain.pem`; - const keyPath = `/etc/letsencrypt/live/${primaryDomain}/privkey.pem`; + const certStorageName = getCertificateStorageName(primaryDomain); + const certPath = `/etc/letsencrypt/live/${certStorageName}/fullchain.pem`; + const keyPath = `/etc/letsencrypt/live/${certStorageName}/privkey.pem`; if (!fs.existsSync(certPath) || !fs.existsSync(keyPath)) { this.logger.log( `Certificate missing for ${primaryDomain}, calling ensureCertificate`, @@ -160,18 +160,16 @@ export class ReloaderService implements OnModuleInit, OnModuleDestroy { } // ----------- PHASE 3: Re-generate configs (now with SSL where possible) ----------- - this.logger.log('Phase 3: Generating SSL-enabled nginx configs...'); - await this.generateNginxConfs(entries); - - // Validate and reload - this.logger.log('Validating nginx config syntax (nginx -t)...'); - await this.execShell('nginx -t'); - - this.logger.log('Reloading nginx (nginx -s reload)...'); - await this.execShell('nginx -s reload'); + const sslRelease = await this.deployConfigSnapshot(entries, { + phase: 'ssl-activation', + details: `ssl-aware rollout for ${entries.length} proxy entr${entries.length === 1 ? 'y' : 'ies'}`, + }); this.logger.log( - 'Nginx config and directories replaced/reloaded successfully.', + 'Nginx config staged, validated, and activated successfully.', + ); + this.healthService.recordConfigApplySuccess( + `activated release ${sslRelease.releaseId} (${sslRelease.phase}); previous release ${sslRelease.previousReleaseId ?? httpOnlyRelease.previousReleaseId ?? 'none'}`, ); return { ok: true }; } catch (error: any) { @@ -180,16 +178,46 @@ export class ReloaderService implements OnModuleInit, OnModuleDestroy { error instanceof Error ? error.stack : String(error), JSON.stringify({ error, time: new Date().toISOString() }), ); + this.healthService.recordConfigApplyFailure( + error.message || String(error), + ); return { ok: false, error: error.message || String(error) }; } } + async getRuntimeReleaseStatus() { + const [currentReleasePath, lastKnownGoodPath] = await Promise.all([ + this.getSymlinkTarget(NGINX_CURRENT_RELEASE_LINK), + this.getSymlinkTarget(NGINX_LAST_KNOWN_GOOD_LINK), + ]); + const [currentRelease, lastKnownGoodRelease] = await Promise.all([ + this.readReleaseMetadata(currentReleasePath), + this.readReleaseMetadata(lastKnownGoodPath), + ]); + + return { + runtimeDir: NGINX_RUNTIME_DIR, + releasesDir: NGINX_RELEASES_DIR, + currentReleaseId: currentReleasePath ? basename(currentReleasePath) : null, + currentReleasePath, + currentRelease, + lastKnownGoodReleaseId: lastKnownGoodPath ? basename(lastKnownGoodPath) : null, + lastKnownGoodPath, + lastKnownGoodRelease, + }; + } + // Helper: Generate nginx confs for all entries (auto SSL detection) - private async generateNginxConfs(entries: any[]): Promise<void> { - const confdDir = join(NGINX_ETC_DIR, 'conf.d'); + private async generateNginxConfs( + entries: any[], + releasePath: string, + ): Promise<void> { + const confdDir = join(releasePath, 'conf.d'); this.logger.log(`Ensuring conf.d directory exists at: ${confdDir}`); await mkdir(confdDir, { recursive: true }); + await this.rewriteBundledDefaultConfig(releasePath); + for (const entry of entries) { try { // Extract upstream host from entry.proxy_pass_host (handle http[s]://host[:port]) @@ -202,13 +230,16 @@ export class ReloaderService implements OnModuleInit, OnModuleDestroy { } // Check if upstream host resolves (skip for REDIRECT type) - let resolved = false + let resolved = false; if (entry.type !== 'REDIRECT') { resolved = await isHostResolvable(upstreamHost); } this.logger.log(`Generating nginx config for entry id=${entry.id}`); - const entryConfig = this.nginx.generateNginxConfig([entry], resolved); + const entryConfig = this.nginx.generateNginxConfig([entry], { + resolved, + htmlRoot: join(releasePath, 'html'), + }); const entryFilename = join(confdDir, `${entry.id}.conf.tmp`); this.logger.log(`Writing temp config file: ${entryFilename}`); await writeFile(entryFilename, entryConfig, { encoding: 'utf-8' }); @@ -224,6 +255,7 @@ export class ReloaderService implements OnModuleInit, OnModuleDestroy { time: new Date().toISOString(), }), ); + throw error; } } } @@ -309,42 +341,22 @@ export class ReloaderService implements OnModuleInit, OnModuleDestroy { } // Ensure directories required by proxies/redirects exist - private async ensureProxyAndRedirectDirs(): Promise<void> { + private async ensureProxyAndRedirectDirs(entries: any[]): Promise<void> { this.logger.log( 'Parsing proxy/redirect configs for required directories...', ); - const entries = await this.prisma.proxyEntry.findMany(); const dirs = new Set<string>(); for (const entry of entries) { try { if (entry.nginx_custom_code) { - const rootMatches = entry.nginx_custom_code.match(/root\s+([^;]+);/g); - const aliasMatches = - entry.nginx_custom_code.match(/alias\s+([^;]+);/g); - if (rootMatches) { - rootMatches.forEach((m) => { - const path = m - .replace(/root\s+/, '') - .replace(/;/, '') - .trim(); - this.logger.log( - `Found root directive, will ensure directory: ${path}`, - ); - dirs.add(path); - }); - } - if (aliasMatches) { - aliasMatches.forEach((m) => { - const path = m - .replace(/alias\s+/, '') - .replace(/;/, '') - .trim(); - this.logger.log( - `Found alias directive, will ensure directory: ${path}`, - ); - dirs.add(path); - }); + for (const path of extractManagedPathsFromCustomCode( + entry.nginx_custom_code, + )) { + this.logger.log( + `Found validated managed-path directive, will ensure directory: ${path}`, + ); + dirs.add(path); } } } catch (error: any) { @@ -356,6 +368,7 @@ export class ReloaderService implements OnModuleInit, OnModuleDestroy { time: new Date().toISOString(), }), ); + throw error; } } @@ -369,20 +382,376 @@ export class ReloaderService implements OnModuleInit, OnModuleDestroy { } } - private execShell(cmd: string): Promise<void> { - this.logger.log(`Executing shell command: ${cmd}`); + private async ensureRuntimeLayout(): Promise<void> { + await mkdir(NGINX_RELEASES_DIR, { recursive: true }); + + let currentReleasePath = await this.getSymlinkTarget( + NGINX_CURRENT_RELEASE_LINK, + ); + if (!currentReleasePath) { + currentReleasePath = await this.createBootstrapRelease(); + await this.updateSymlinkAtomically( + NGINX_CURRENT_RELEASE_LINK, + currentReleasePath, + ); + } + + const lastKnownGoodPath = await this.getSymlinkTarget( + NGINX_LAST_KNOWN_GOOD_LINK, + ); + if (!lastKnownGoodPath) { + await this.updateSymlinkAtomically( + NGINX_LAST_KNOWN_GOOD_LINK, + currentReleasePath, + ); + } + } + + private async createBootstrapRelease(): Promise<string> { + const bootstrapReleasePath = join(NGINX_RELEASES_DIR, 'bootstrap'); + await rm(bootstrapReleasePath, { recursive: true, force: true }); + await this.copyDirectoryRecursive(NGINX_SOURCE_DIR, bootstrapReleasePath); + await this.rewriteBundledDefaultConfig(bootstrapReleasePath); + await this.writeReleaseMetadata(bootstrapReleasePath, { + releaseId: 'bootstrap', + phase: 'bootstrap', + status: 'bootstrap', + createdAt: new Date().toISOString(), + sourceDirectory: NGINX_SOURCE_DIR, + appliedNode: process.env['HOSTNAME'] ?? 'unknown', + details: 'bootstrap runtime release created for nginx startup', + }); + return bootstrapReleasePath; + } + + private async deployConfigSnapshot( + entries: any[], + options: { + phase: string; + details: string; + }, + ): Promise<ActivatedRelease> { + const releaseId = this.createReleaseId(options.phase); + const releasePath = join(NGINX_RELEASES_DIR, releaseId); + const createdAt = new Date().toISOString(); + const previousReleasePath = await this.getSymlinkTarget( + NGINX_CURRENT_RELEASE_LINK, + ); + const previousReleaseId = previousReleasePath + ? basename(previousReleasePath) + : null; + + this.logger.log( + `Creating staged nginx release ${releaseId} at ${releasePath}`, + ); + await this.copyDirectoryRecursive(NGINX_SOURCE_DIR, releasePath); + await this.generateNginxConfs(entries, releasePath); + + const validationConfigPath = await this.writeValidationConfig(releasePath); + const validationCommand = [ + 'nginx', + '-t', + '-c', + validationConfigPath, + ] as const; + const validationOutput = await this.execCommand( + validationCommand[0], + validationCommand.slice(1), + ); + + await this.writeReleaseMetadata(releasePath, { + releaseId, + phase: options.phase, + status: 'validated', + createdAt, + sourceDirectory: NGINX_SOURCE_DIR, + appliedNode: process.env['HOSTNAME'] ?? 'unknown', + details: options.details, + previousReleaseId, + validation: { + command: validationCommand.join(' '), + output: validationOutput, + validatedAt: new Date().toISOString(), + }, + }); + + await this.updateSymlinkAtomically(NGINX_CURRENT_RELEASE_LINK, releasePath); + + try { + await this.execCommand('nginx', ['-s', 'reload']); + } catch (error) { + await this.rollbackActivation({ + previousReleasePath, + failedReleasePath: releasePath, + validationOutput, + activationError: error instanceof Error ? error.message : String(error), + }); + } + + await this.updateSymlinkAtomically(NGINX_LAST_KNOWN_GOOD_LINK, releasePath); + await this.writeReleaseMetadata(releasePath, { + releaseId, + phase: options.phase, + status: 'active', + createdAt, + activatedAt: new Date().toISOString(), + sourceDirectory: NGINX_SOURCE_DIR, + appliedNode: process.env['HOSTNAME'] ?? 'unknown', + details: options.details, + previousReleaseId, + validation: { + command: validationCommand.join(' '), + output: validationOutput, + }, + }); + await this.pruneOldReleases([releasePath, previousReleasePath]); + + return { + releaseId, + releasePath, + previousReleaseId, + phase: options.phase, + validationOutput, + }; + } + + private async rollbackActivation(options: { + previousReleasePath: string | null; + failedReleasePath: string; + validationOutput: string; + activationError: string; + }): Promise<never> { + const failedReleaseId = basename(options.failedReleasePath); + const previousReleaseId = options.previousReleasePath + ? basename(options.previousReleasePath) + : null; + + if (!options.previousReleasePath) { + throw new Error( + `Activation of release ${failedReleaseId} failed with no previous release to restore: ${options.activationError}`, + ); + } + + this.logger.warn( + `Activation of release ${failedReleaseId} failed; rolling back to ${previousReleaseId}`, + ); + await this.updateSymlinkAtomically( + NGINX_CURRENT_RELEASE_LINK, + options.previousReleasePath, + ); + + try { + await this.execCommand('nginx', ['-s', 'reload']); + } catch (rollbackError) { + throw new Error( + `Activation of release ${failedReleaseId} failed (${options.activationError}) and rollback to ${previousReleaseId} also failed: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`, + ); + } + + await this.writeReleaseMetadata(options.failedReleasePath, { + releaseId: failedReleaseId, + phase: 'rollback', + status: 'rolled_back', + createdAt: new Date().toISOString(), + sourceDirectory: NGINX_SOURCE_DIR, + appliedNode: process.env['HOSTNAME'] ?? 'unknown', + previousReleaseId, + validation: { + output: options.validationOutput, + }, + rollback: { + rolledBackToReleaseId: previousReleaseId, + rolledBackAt: new Date().toISOString(), + activationError: options.activationError, + }, + }); + + throw new Error( + `Activation of release ${failedReleaseId} failed and was rolled back to ${previousReleaseId}: ${options.activationError}`, + ); + } + + private async writeValidationConfig(releasePath: string): Promise<string> { + const validationConfigPath = join(releasePath, '.validation-nginx.conf'); + const bundledConfig = await readFile( + join(releasePath, 'nginx.conf'), + 'utf8', + ); + const validationConfig = bundledConfig + .replace( + /^pid\s+.*;$/m, + `pid ${join(releasePath, '.validation-nginx.pid')};`, + ) + .replaceAll('/etc/nginx/mime.types', join(releasePath, 'mime.types')) + .replaceAll( + '/etc/nginx/runtime/current/conf.d/*.conf', + `${join(releasePath, 'conf.d')}/*.conf`, + ) + .replaceAll('/etc/nginx/html', join(releasePath, 'html')); + + await writeFile(validationConfigPath, validationConfig, 'utf8'); + return validationConfigPath; + } + + private async rewriteBundledDefaultConfig( + releasePath: string, + ): Promise<void> { + const defaultConfPath = join(releasePath, 'conf.d', 'default.conf'); + if (!(await this.pathExists(defaultConfPath))) { + return; + } + + const rewritten = (await readFile(defaultConfPath, 'utf8')) + .replaceAll('/etc/nginx/html', join(releasePath, 'html')) + .replaceAll('/errors/50x.html', '/errors/5xx.html'); + await writeFile(defaultConfPath, rewritten, 'utf8'); + } + + private async writeReleaseMetadata( + releasePath: string, + metadata: Record<string, unknown>, + ): Promise<void> { + await writeFile( + join(releasePath, NGINX_RELEASE_METADATA_FILE), + `${JSON.stringify(metadata, null, 2)}\n`, + 'utf8', + ); + } + + private async readReleaseMetadata(releasePath: string | null) { + if (!releasePath) { + return null; + } + + try { + const metadata = await readFile( + join(releasePath, NGINX_RELEASE_METADATA_FILE), + 'utf8', + ); + + return JSON.parse(metadata) as Record<string, unknown>; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code ?? ''; + if (code === 'ENOENT') { + return null; + } + + this.logger.warn( + `Failed to read nginx release metadata for ${releasePath}: ${error instanceof Error ? error.message : String(error)}`, + ); + + return { + releasePath, + readError: error instanceof Error ? error.message : String(error), + }; + } + } + + private async updateSymlinkAtomically( + linkPath: string, + targetPath: string, + ): Promise<void> { + await mkdir(dirname(linkPath), { recursive: true }); + const tempLinkPath = `${linkPath}.tmp-${Date.now()}`; + await rm(tempLinkPath, { force: true }); + await symlink(targetPath, tempLinkPath); + await rename(tempLinkPath, linkPath); + } + + private async getSymlinkTarget(linkPath: string): Promise<string | null> { + try { + return await readlink(linkPath); + } catch (error) { + if ( + ['ENOENT', 'EINVAL'].includes( + (error as NodeJS.ErrnoException).code ?? '', + ) + ) { + return null; + } + throw error; + } + } + + private createReleaseId(phase: string): string { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const normalizedPhase = phase.replace(/[^a-z0-9-]+/gi, '-').toLowerCase(); + return `${timestamp}-${normalizedPhase}`; + } + + private async pruneOldReleases( + retainedReleasePaths: Array<string | null>, + ): Promise<void> { + const lastKnownGoodPath = await this.getSymlinkTarget( + NGINX_LAST_KNOWN_GOOD_LINK, + ); + const protectedPaths = new Set( + [...retainedReleasePaths, lastKnownGoodPath] + .filter((value): value is string => Boolean(value)) + .map((value) => value), + ); + + const releaseNames = await readdir(NGINX_RELEASES_DIR); + const releases = await Promise.all( + releaseNames.map(async (releaseName) => { + const releasePath = join(NGINX_RELEASES_DIR, releaseName); + const releaseStat = await stat(releasePath); + return { + releaseName, + releasePath, + modifiedAt: releaseStat.mtimeMs, + }; + }), + ); + + const staleReleases = releases + .filter( + (release) => + release.releaseName !== 'bootstrap' && + !protectedPaths.has(release.releasePath), + ) + .sort((left, right) => right.modifiedAt - left.modifiedAt) + .slice(RELEASE_RETENTION_COUNT); + + await Promise.all( + staleReleases.map(async (release) => { + this.logger.log(`Pruning stale nginx release ${release.releaseName}`); + await rm(release.releasePath, { recursive: true, force: true }); + }), + ); + } + + private async pathExists(path: string): Promise<boolean> { + try { + await access(path); + return true; + } catch { + return false; + } + } + + private execCommand(command: string, args: string[]): Promise<string> { + this.logger.log(`Executing command: ${command} ${args.join(' ')}`); return new Promise((resolve, reject) => { - exec(cmd, (err, stdout, stderr) => { + execFile(command, args, (err, stdout, stderr) => { if (err) { this.logger.error( - `Command failed: ${cmd}\nSTDOUT: ${stdout}\nSTDERR: ${stderr}`, + `Command failed: ${command} ${args.join(' ')}\nSTDOUT: ${stdout}\nSTDERR: ${stderr}`, ); return reject(new Error(stderr || stdout || err.message)); } if (stdout) this.logger.log(`Command stdout: ${stdout}`); if (stderr) this.logger.log(`Command stderr: ${stderr}`); - resolve(); + resolve([stdout, stderr].filter(Boolean).join('\n').trim()); }); }); } } + +type ActivatedRelease = { + releaseId: string; + releasePath: string; + previousReleaseId: string | null; + phase: string; + validationOutput: string; +}; diff --git a/src/security/dto/rotate-api-key.dto.ts b/src/security/dto/rotate-api-key.dto.ts new file mode 100644 index 0000000..adba364 --- /dev/null +++ b/src/security/dto/rotate-api-key.dto.ts @@ -0,0 +1,28 @@ +import { + IsBoolean, + IsNotEmpty, + IsOptional, + IsString, + MaxLength, +} from 'class-validator'; + +export class RotateApiKeyDto { + @IsString() + @IsNotEmpty() + @MaxLength(512) + nextApiKey!: string; + + @IsOptional() + @IsString() + @MaxLength(512) + reason?: string; + + @IsOptional() + @IsBoolean() + issueBridgeToken?: boolean; + + @IsOptional() + @IsString() + @MaxLength(128) + retireApiKeyId?: string; +} diff --git a/src/security/dto/rotate-internal-certs.dto.ts b/src/security/dto/rotate-internal-certs.dto.ts new file mode 100644 index 0000000..b0ad9b1 --- /dev/null +++ b/src/security/dto/rotate-internal-certs.dto.ts @@ -0,0 +1,13 @@ +import { IsOptional, IsString, MaxLength } from 'class-validator'; + +export class RotateInternalCertsDto { + @IsOptional() + @IsString() + @MaxLength(512) + reason?: string; + + @IsOptional() + @IsString() + @MaxLength(256) + maintenanceWindow?: string; +} diff --git a/src/security/dto/rotate-private-key-encryption.dto.ts b/src/security/dto/rotate-private-key-encryption.dto.ts new file mode 100644 index 0000000..7fe265e --- /dev/null +++ b/src/security/dto/rotate-private-key-encryption.dto.ts @@ -0,0 +1,23 @@ +import { + IsBoolean, + IsNotEmpty, + IsOptional, + IsString, + MaxLength, +} from 'class-validator'; + +export class RotatePrivateKeyEncryptionDto { + @IsString() + @IsNotEmpty() + @MaxLength(128) + confirmKeyVersion!: string; + + @IsOptional() + @IsBoolean() + dryRun?: boolean; + + @IsOptional() + @IsString() + @MaxLength(512) + reason?: string; +} diff --git a/src/security/security.controller.ts b/src/security/security.controller.ts new file mode 100644 index 0000000..0c8dfa6 --- /dev/null +++ b/src/security/security.controller.ts @@ -0,0 +1,79 @@ +import { + Body, + Controller, + Get, + HttpCode, + HttpStatus, + Post, + UseGuards, +} from '@nestjs/common'; +import { CurrentIdentity } from '../auth/decorators/current-identity.decorator'; +import { AuthorizeAdmin } from '../auth/decorators/authorize.decorator'; +import { ApiKeyGuard } from '../auth/guards/api-key.guard'; +import { AuthIdentity } from '../auth/types/auth-identity'; +import { Audit } from '../audit/decorators/audit.decorator'; +import { RotateApiKeyDto } from './dto/rotate-api-key.dto'; +import { RotateInternalCertsDto } from './dto/rotate-internal-certs.dto'; +import { RotatePrivateKeyEncryptionDto } from './dto/rotate-private-key-encryption.dto'; +import { SecurityService } from './security.service'; + +@Controller('security') +@UseGuards(ApiKeyGuard) +@AuthorizeAdmin('security-admin') +export class SecurityController { + private readonly securityService: SecurityService; + + constructor(securityService: SecurityService) { + this.securityService = securityService; + } + + @Get('status') + @Audit({ action: 'security.status.review' }) + getStatus(@CurrentIdentity() identity: AuthIdentity) { + return this.securityService.getSecurityStatus(identity); + } + + @Get('policy') + @Audit({ action: 'security.policy.review' }) + getPolicy() { + return this.securityService.getSecurityPolicy(); + } + + @Get('secrets/health') + @Audit({ action: 'security.secrets.review' }) + getSecretsHealth() { + return this.securityService.getSecretsHealth(); + } + + @Get('access-review') + @Audit({ action: 'security.access-review' }) + getAccessReview(@CurrentIdentity() identity: AuthIdentity) { + return this.securityService.getAccessReview(identity); + } + + @Post('rotate/api-key') + @HttpCode(HttpStatus.OK) + @AuthorizeAdmin('platform-admin') + @Audit({ action: 'security.api-key.rotate.plan' }) + planApiKeyRotation( + @Body() dto: RotateApiKeyDto, + @CurrentIdentity() identity: AuthIdentity, + ) { + return this.securityService.planApiKeyRotation(identity, dto); + } + + @Post('rotate/private-key-encryption') + @HttpCode(HttpStatus.OK) + @Audit({ action: 'security.private-key-encryption.rotate' }) + rotatePrivateKeyEncryption(@Body() dto: RotatePrivateKeyEncryptionDto) { + return this.securityService.rotatePrivateKeyEncryption(dto); + } + + @Post('rotate/internal-certs') + @HttpCode(HttpStatus.OK) + @AuthorizeAdmin('platform-admin') + @Audit({ action: 'security.internal-certs.rotate' }) + rotateInternalCertificates(@Body() dto: RotateInternalCertsDto) { + return this.securityService.getInternalCertificateRotationHook(dto); + } +} diff --git a/src/security/security.module.ts b/src/security/security.module.ts new file mode 100644 index 0000000..41e9871 --- /dev/null +++ b/src/security/security.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { CertificateModule } from '../certificate/certificate.module'; +import { SecurityController } from './security.controller'; +import { SecurityService } from './security.service'; + +@Module({ + imports: [AuthModule, CertificateModule], + controllers: [SecurityController], + providers: [SecurityService], +}) +export class SecurityModule {} diff --git a/src/security/security.service.ts b/src/security/security.service.ts new file mode 100644 index 0000000..8749099 --- /dev/null +++ b/src/security/security.service.ts @@ -0,0 +1,563 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import * as fs from 'node:fs'; +import { AuthService } from '../auth/auth.service'; +import { + AuthIdentity, + ConfiguredApiKeySummary, +} from '../auth/types/auth-identity'; +import { resolveEffectiveRoles } from '../auth/types/auth-role'; +import { PrivateKeyEncryptionService } from '../certificate/private-key-encryption.service'; +import { RotateApiKeyDto } from './dto/rotate-api-key.dto'; +import { RotateInternalCertsDto } from './dto/rotate-internal-certs.dto'; +import { RotatePrivateKeyEncryptionDto } from './dto/rotate-private-key-encryption.dto'; + +@Injectable() +export class SecurityService { + private readonly authService: AuthService; + private readonly privateKeyEncryption: PrivateKeyEncryptionService; + + constructor( + authService: AuthService, + privateKeyEncryption: PrivateKeyEncryptionService, + ) { + this.authService = authService; + this.privateKeyEncryption = privateKeyEncryption; + } + + getSecurityStatus(identity: AuthIdentity) { + const authCapabilities = this.authService.getAuthCapabilities(); + const configuredApiKeys = this.authService.listConfiguredApiKeys(); + const privateKeyProvider = this.privateKeyEncryption.getProviderStatus(); + const secrets = this.getSecretsHealth(); + const clusterProtocol = + process.env['CLUSTER_CONTROL_PROTOCOL']?.trim().toLowerCase() || 'http'; + + return { + generatedAt: new Date().toISOString(), + actor: this.presentIdentity(identity), + auth: { + ...authCapabilities, + configuredApiKeys, + legacyApiKeyBridgeEnabled: + authCapabilities.apiKeyConfigured && + authCapabilities.tokenExchangeEnabled, + }, + secrets, + privateKeyEncryption: { + ...privateKeyProvider, + rotateEndpoint: '/security/rotate/private-key-encryption', + providerReady: true, + }, + backupProtection: { + configured: + Boolean(process.env['BACKUP_ENCRYPTION_KEY']?.trim()) || + process.env['NODE_ENV'] !== 'production', + keyVersion: + process.env['BACKUP_ENCRYPTION_KEY_VERSION']?.trim() || 'v1', + usesDevelopmentFallback: + !process.env['BACKUP_ENCRYPTION_KEY']?.trim() && + process.env['NODE_ENV'] !== 'production', + }, + interNodeSecurity: { + currentMode: 'authenticated-http', + clusterControlProtocol: clusterProtocol, + mtlsEnabled: false, + internalNodeIdentityModel: 'jwt-or-api-key-bridge', + rotateEndpoint: '/security/rotate/internal-certs', + notes: [ + 'Inter-node requests are authenticated but not yet mTLS-protected.', + 'The internal certificate rotation endpoint is a forward-compatible contract for future node PKI work.', + ], + }, + breakGlass: { + rawCertificateExport: { + endpoint: 'GET /certificates/backup/export/:id', + requiredRole: 'platform-admin', + audited: true, + }, + apiKeyRotation: { + endpoint: 'POST /security/rotate/api-key', + requiredRole: 'platform-admin', + hotReloadSupported: false, + overlapRotationRecommended: authCapabilities.apiKeyConfigured, + }, + }, + }; + } + + getSecurityPolicy() { + return { + generatedAt: new Date().toISOString(), + roles: { + viewer: { + description: + 'Read-only visibility into cluster, certificate, and auth state.', + }, + operator: { + description: + 'Operational actions such as reloads, renewals, and upstream diagnostics.', + }, + 'security-admin': { + description: + 'Security maintenance such as certificate/key lifecycle, encrypted backups, imports/restores, and security review APIs.', + }, + 'platform-admin': { + description: + 'Full control-plane access including cluster leadership maintenance and break-glass flows.', + }, + 'internal-node': { + description: 'Reserved for trusted inter-node control-plane traffic.', + }, + }, + publicEndpoints: [ + 'GET /health/live', + 'GET /health/startup', + 'GET /health/ready', + 'GET /metrics', + 'GET /metrics/json', + 'GET /.well-known/acme-challenge/:token', + ], + breakGlassFlows: [ + { + endpoint: 'GET /certificates/backup/export/:id', + requiredRole: 'platform-admin', + notes: + 'Returns decrypted PEM material and must remain tightly controlled and audited.', + }, + ], + rotationFlows: [ + { + endpoint: 'POST /security/rotate/api-key', + requiredRole: 'platform-admin', + behavior: + 'Validates a new API key candidate and returns a manual overlap rotation plan.', + }, + { + endpoint: 'POST /security/rotate/private-key-encryption', + requiredRole: 'security-admin', + behavior: + 'Re-encrypts stored certificate private keys with the currently configured provider/key version after operators rotate the master key outside the app.', + }, + { + endpoint: 'POST /security/rotate/internal-certs', + requiredRole: 'platform-admin', + behavior: + 'Reserves the future internal-mTLS certificate rotation contract and reports current prerequisites until node PKI is implemented.', + }, + ], + expectations: [ + 'Secrets should be injected at runtime via Docker Swarm secrets or an external secret manager.', + 'API keys remain a temporary compatibility bridge; operators should prefer short-lived bearer tokens.', + 'Inter-node mTLS is not active yet, so the internal certificate rotation endpoint is currently a documented hook rather than a live PKI workflow.', + ], + }; + } + + getSecretsHealth() { + const privateKeyProvider = this.privateKeyEncryption.getProviderStatus(); + const backupUsesDevelopmentFallback = + !process.env['BACKUP_ENCRYPTION_KEY']?.trim() && + process.env['NODE_ENV'] !== 'production'; + const checks = [ + this.buildSecretCheck({ + name: 'API_KEY', + configured: this.authService.getAuthCapabilities().apiKeyConfigured, + requiredInProduction: true, + details: { + configuredKeyCount: this.authService.listConfiguredApiKeys().length, + }, + }), + this.buildSecretCheck({ + name: 'AUTH_JWT_SECRET_OR_PUBLIC_KEY', + configured: this.authService.canVerifyBearerTokens(), + requiredInProduction: false, + details: { + issuer: process.env['AUTH_JWT_ISSUER']?.trim() || 'lyttle-nginx', + audience: + process.env['AUTH_JWT_AUDIENCE']?.trim() || 'lyttle-nginx-admin', + tokenExchangeEnabled: this.authService.canIssueAccessTokens(), + }, + }), + this.buildSecretCheck({ + name: 'PRIVATE_KEY_ENCRYPTION_MASTER_KEY', + configured: + Boolean(process.env['PRIVATE_KEY_ENCRYPTION_MASTER_KEY']?.trim()) || + process.env['NODE_ENV'] !== 'production', + requiredInProduction: true, + usingDevelopmentFallback: privateKeyProvider.usesDevelopmentFallback, + details: { + provider: privateKeyProvider.type, + keyVersion: privateKeyProvider.keyVersion, + }, + }), + this.buildSecretCheck({ + name: 'BACKUP_ENCRYPTION_KEY', + configured: + Boolean(process.env['BACKUP_ENCRYPTION_KEY']?.trim()) || + process.env['NODE_ENV'] !== 'production', + requiredInProduction: true, + usingDevelopmentFallback: backupUsesDevelopmentFallback, + details: { + keyVersion: + process.env['BACKUP_ENCRYPTION_KEY_VERSION']?.trim() || 'v1', + }, + }), + this.buildSecretCheck({ + name: 'ACME_ACCOUNT_PRIVATE_KEY_PATH', + configured: Boolean( + process.env['ACME_ACCOUNT_PRIVATE_KEY_PATH']?.trim(), + ), + requiredInProduction: true, + details: { + path: process.env['ACME_ACCOUNT_PRIVATE_KEY_PATH']?.trim() || null, + existsOnDisk: this.safePathExists( + process.env['ACME_ACCOUNT_PRIVATE_KEY_PATH']?.trim(), + ), + }, + }), + ]; + + const production = process.env['NODE_ENV'] === 'production'; + const blockingIssues = checks.filter( + (check) => check.requiredInProduction && production && !check.configured, + ).length; + const warnings = checks.filter( + (check) => check.usingDevelopmentFallback, + ).length; + + return { + generatedAt: new Date().toISOString(), + status: + blockingIssues > 0 ? 'unhealthy' : warnings > 0 ? 'degraded' : 'ok', + summary: { + total: checks.length, + configured: checks.filter((check) => check.configured).length, + blockingIssues, + warnings, + environment: process.env['NODE_ENV'] || 'development', + }, + checks, + }; + } + + getAccessReview(identity: AuthIdentity) { + const effectiveRoles = resolveEffectiveRoles(identity); + const accessibleCapabilities = { + reviewSecurityStatus: this.hasAnyRole(effectiveRoles, ['security-admin']), + reviewSecurityPolicy: this.hasAnyRole(effectiveRoles, ['security-admin']), + reviewSecretsHealth: this.hasAnyRole(effectiveRoles, ['security-admin']), + reviewAuditTrail: this.hasAnyRole(effectiveRoles, ['security-admin']), + planApiKeyRotation: this.hasAnyRole(effectiveRoles, ['platform-admin']), + rotatePrivateKeyEncryption: this.hasAnyRole(effectiveRoles, [ + 'security-admin', + ]), + rotateInternalCerts: this.hasAnyRole(effectiveRoles, ['platform-admin']), + useBreakGlassCertificateExport: this.hasAnyRole(effectiveRoles, [ + 'platform-admin', + ]), + }; + + return { + generatedAt: new Date().toISOString(), + actor: this.presentIdentity(identity), + effectiveRoles, + accessibleCapabilities, + highRiskFlows: [ + { + name: 'raw-certificate-export', + endpoint: 'GET /certificates/backup/export/:id', + allowed: accessibleCapabilities.useBreakGlassCertificateExport, + }, + { + name: 'api-key-rotation-plan', + endpoint: 'POST /security/rotate/api-key', + allowed: accessibleCapabilities.planApiKeyRotation, + }, + { + name: 'internal-cert-rotation-hook', + endpoint: 'POST /security/rotate/internal-certs', + allowed: accessibleCapabilities.rotateInternalCerts, + }, + ], + recommendations: [ + this.authService.getAuthCapabilities().apiKeyConfigured + ? 'Prefer bearer tokens for routine automation and keep API keys only as a compatibility bridge.' + : 'Legacy API keys are disabled; keep bearer-token verification available for admin clients.', + accessibleCapabilities.rotatePrivateKeyEncryption + ? 'After changing PRIVATE_KEY_ENCRYPTION_KEY_VERSION, run POST /security/rotate/private-key-encryption to re-encrypt stored key material.' + : 'Use a security-admin or platform-admin identity when performing encryption-key maintenance.', + 'Treat decrypted certificate export as a break-glass action and review the audit trail after each use.', + ], + links: { + status: '/security/status', + policy: '/security/policy', + secretsHealth: '/security/secrets/health', + audit: '/audit', + }, + }; + } + + planApiKeyRotation(identity: AuthIdentity, dto: RotateApiKeyDto) { + const configuredApiKeys = this.authService.listConfiguredApiKeys(); + const retireTarget = dto.retireApiKeyId?.trim(); + const retireMatch = retireTarget + ? (configuredApiKeys.find((entry) => entry.apiKeyId === retireTarget) ?? + null) + : null; + const candidate = this.validateApiKeyCandidate( + dto.nextApiKey, + configuredApiKeys, + ); + + return { + generatedAt: new Date().toISOString(), + requestedBy: this.presentIdentity(identity), + status: candidate.valid ? 'ready' : 'rejected', + strategy: + configuredApiKeys.length > 0 + ? 'manual-overlap-env-rotation' + : 'manual-bootstrap-env-rotation', + current: { + configuredKeyCount: configuredApiKeys.length, + configuredApiKeys, + retireTarget: retireTarget + ? { + requestedApiKeyId: retireTarget, + found: Boolean(retireMatch), + apiKey: retireMatch, + } + : null, + }, + candidate, + migrationBridge: this.buildBridgeToken( + identity, + dto.issueBridgeToken === true, + ), + recommendedSteps: [ + 'Add the candidate API key to your secret store alongside the currently active API_KEY value(s).', + 'Redeploy or restart every node so the updated API_KEY set is loaded consistently across the cluster.', + 'Verify the new key against /auth/status and /security/status before retiring the old key.', + 'Prefer exchanging long-lived API-key automation for short-lived bearer tokens when AUTH_JWT_SECRET is configured.', + retireTarget + ? `After validation, remove the retired API key ${retireTarget} from the injected API_KEY list and redeploy again.` + : 'After validation, remove any superseded API keys from the injected API_KEY list and redeploy again.', + ], + notes: [ + 'The application does not hot-reload API keys today; rotation remains a secret-store plus redeploy workflow.', + 'This endpoint never persists or returns raw API key material. It only validates the candidate and returns a safe rotation plan.', + dto.reason?.trim() ? `Operator reason: ${dto.reason.trim()}` : null, + ].filter(Boolean), + }; + } + + async rotatePrivateKeyEncryption(dto: RotatePrivateKeyEncryptionDto) { + const provider = this.privateKeyEncryption.getProviderStatus(); + const confirmedVersion = dto.confirmKeyVersion.trim(); + + if (confirmedVersion !== provider.keyVersion) { + throw new BadRequestException( + `confirmKeyVersion must match the active PRIVATE_KEY_ENCRYPTION_KEY_VERSION (${provider.keyVersion})`, + ); + } + + if (dto.dryRun) { + return { + generatedAt: new Date().toISOString(), + status: 'dry-run', + provider, + message: + 'Dry run only. No database rows were changed. Set PRIVATE_KEY_ENCRYPTION_KEY_VERSION to the intended target version, then repeat this request with dryRun=false to re-encrypt stored private keys.', + warnings: provider.usesDevelopmentFallback + ? [ + 'The development fallback master key is active. Configure PRIVATE_KEY_ENCRYPTION_MASTER_KEY before running a production rotation.', + ] + : [], + reason: dto.reason?.trim() || null, + }; + } + + const result = await this.privateKeyEncryption.migrateStoredPrivateKeys(); + + return { + generatedAt: new Date().toISOString(), + status: 'completed', + provider, + certificatesUpdated: result.certificatesUpdated, + artifactsUpdated: result.artifactsUpdated, + message: + 'Stored certificate private keys were re-encrypted with the active provider configuration.', + reason: dto.reason?.trim() || null, + }; + } + + getInternalCertificateRotationHook(dto: RotateInternalCertsDto) { + return { + generatedAt: new Date().toISOString(), + supported: false, + status: 'not-configured', + currentTransport: { + protocol: + process.env['CLUSTER_CONTROL_PROTOCOL']?.trim().toLowerCase() || + 'http', + mtlsEnabled: false, + actorIdentity: 'jwt-or-api-key-bridge', + }, + message: + 'Internal node mTLS certificates are not active yet. This endpoint reserves the future rotation contract and documents the prerequisites for that workflow.', + requestedMaintenanceWindow: dto.maintenanceWindow?.trim() || null, + reason: dto.reason?.trim() || null, + prerequisites: [ + 'Enable per-node internal TLS identities for control-plane traffic.', + 'Distribute a cluster trust bundle/CA to all nodes.', + 'Convert internal HTTP requests to mTLS-authenticated transport.', + 'Back the rotation with a durable cluster operation once internal PKI exists.', + ], + futureContract: { + endpoint: 'POST /security/rotate/internal-certs', + expectedResponse: + '202 Accepted with an operationId once internal PKI is implemented.', + trackingEndpoints: [ + '/security/status', + '/cluster/operations/:operationId', + ], + }, + }; + } + + private buildSecretCheck(params: { + name: string; + configured: boolean; + requiredInProduction: boolean; + usingDevelopmentFallback?: boolean; + details?: Record<string, unknown>; + }) { + return { + name: params.name, + configured: params.configured, + requiredInProduction: params.requiredInProduction, + usingDevelopmentFallback: params.usingDevelopmentFallback ?? false, + status: !params.configured + ? 'missing' + : params.usingDevelopmentFallback + ? 'degraded' + : 'ok', + details: params.details ?? {}, + }; + } + + private validateApiKeyCandidate( + candidate: string, + configuredApiKeys: ConfiguredApiKeySummary[], + ) { + const normalized = candidate.trim(); + const errors: string[] = []; + const warnings: string[] = []; + + if (!normalized) { + errors.push('A non-empty nextApiKey value is required.'); + } + if (normalized.length > 0 && normalized.length < 24) { + errors.push('API keys should be at least 24 characters long.'); + } + if (/[\s,]/.test(normalized)) { + errors.push( + 'API keys must not contain whitespace or commas because API_KEY uses comma-separated values.', + ); + } + if (/[^\x21-\x7E]/.test(normalized)) { + errors.push('API keys must contain only printable ASCII characters.'); + } + if (normalized && this.authService.validateApiKey(normalized)) { + errors.push('The candidate API key is already configured.'); + } + + const characterClasses = [/[a-z]/, /[A-Z]/, /[0-9]/, /[^A-Za-z0-9]/].filter( + (pattern) => pattern.test(normalized), + ).length; + if (normalized && characterClasses < 3) { + warnings.push( + 'Use a mix of upper-case, lower-case, numeric, and symbol characters for stronger entropy.', + ); + } + if (configuredApiKeys.length >= 2) { + warnings.push( + 'Multiple API keys are already configured. Keep the overlap window short and remove retired keys promptly after validation.', + ); + } + + return { + valid: errors.length === 0, + fingerprint: normalized + ? this.authService.fingerprintApiKey(normalized) + : null, + maskedPreview: normalized + ? `${normalized.slice(0, 4)}…${normalized.slice(-4)}` + : null, + length: normalized.length, + warnings, + errors, + }; + } + + private buildBridgeToken(identity: AuthIdentity, shouldIssue: boolean) { + if (!shouldIssue) { + return { + issued: false, + reason: 'issueBridgeToken was not requested.', + }; + } + + if (!this.authService.canIssueAccessTokens()) { + return { + issued: false, + reason: + 'AUTH_JWT_SECRET is not configured, so the application cannot mint bridge bearer tokens.', + }; + } + + return { + issued: true, + token: this.authService.issueAccessToken(identity), + notes: [ + 'Use this bridge token to validate bearer-token auth before retiring legacy API keys.', + 'The token carries the current caller identity and expires according to AUTH_ACCESS_TOKEN_TTL_SECONDS.', + ], + }; + } + + private hasAnyRole(effectiveRoles: string[], requiredRoles: string[]) { + return requiredRoles.some((role) => effectiveRoles.includes(role)); + } + + private presentIdentity(identity: AuthIdentity) { + return { + id: identity.id, + subject: identity.subject, + actorType: identity.actorType, + authMethod: identity.authMethod, + displayName: identity.displayName, + roles: [...identity.roles], + effectiveRoles: resolveEffectiveRoles(identity), + scopes: [...identity.scopes], + audience: [...identity.audience], + issuer: identity.issuer, + apiKeyId: identity.apiKeyId, + nodeId: identity.nodeId, + tokenId: identity.tokenId, + issuedAt: identity.issuedAt, + expiresAt: identity.expiresAt, + }; + } + + private safePathExists(candidatePath: string | undefined) { + if (!candidatePath) { + return false; + } + + try { + return fs.existsSync(candidatePath); + } catch { + return false; + } + } +} diff --git a/src/utils/domain-utils.ts b/src/utils/domain-utils.ts index 5001254..f88b782 100644 --- a/src/utils/domain-utils.ts +++ b/src/utils/domain-utils.ts @@ -1,26 +1,229 @@ +import { createHash } from 'node:crypto'; +import { isIP } from 'node:net'; +import { domainToASCII } from 'node:url'; + /** * Utilities for handling domain lists stored as ';'-joined strings. */ -/** Parse a semicolon-joined domain string into an array of trimmed domains */ -export function parseDomains(domainsString: string): string[] { - return domainsString - .split(';') - .map(d => d.trim()) - .filter(Boolean); +const MAX_DOMAIN_LENGTH = 253; +const MAX_LABEL_LENGTH = 63; +const DOMAIN_LABEL_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/; + +export type NormalizeDomainOptions = { + allowWildcard?: boolean; +}; + +export class DomainValidationError extends Error { + constructor(message: string) { + super(message); + this.name = 'DomainValidationError'; + } +} + +function toSafeErrorMessage(value: unknown): string { + return typeof value === 'string' && value.trim().length > 0 + ? JSON.stringify(value.trim()) + : 'the provided value'; +} + +function ensureLabelRules(domain: string): void { + const labels = domain.split('.'); + + if (labels.length < 2) { + throw new DomainValidationError( + 'Domains must be fully-qualified and include at least one dot', + ); + } + + for (const label of labels) { + if (label.length === 0) { + throw new DomainValidationError('Domains cannot contain empty labels'); + } + + if (label.length > MAX_LABEL_LENGTH) { + throw new DomainValidationError( + `Domain labels cannot exceed ${MAX_LABEL_LENGTH} characters`, + ); + } + + if (!DOMAIN_LABEL_PATTERN.test(label)) { + throw new DomainValidationError( + `Invalid domain label ${JSON.stringify(label)}`, + ); + } + } +} + +/** Strictly normalize and validate a single domain name. */ +export function normalizeDomain( + value: string, + options: NormalizeDomainOptions = {}, +): string { + if (typeof value !== 'string') { + throw new DomainValidationError('Domain names must be strings'); + } + + let domain = value.trim().toLowerCase(); + if (!domain) { + throw new DomainValidationError('Domain names cannot be empty'); + } + + if (/[\u0000-\u001f\u007f\s/\\]/.test(domain)) { + throw new DomainValidationError( + `Invalid domain ${toSafeErrorMessage(value)}: whitespace, control characters, and path separators are not allowed`, + ); + } + + if (domain.endsWith('.')) { + domain = domain.slice(0, -1); + } + + if (!domain) { + throw new DomainValidationError('Domain names cannot be empty'); + } + + const wildcard = domain.startsWith('*.'); + if (wildcard) { + if (!options.allowWildcard) { + throw new DomainValidationError( + `Wildcard domains are not allowed here: ${toSafeErrorMessage(value)}`, + ); + } + + domain = domain.slice(2); + } + + if (domain.includes('*')) { + throw new DomainValidationError( + `Wildcards are only allowed as the left-most label: ${toSafeErrorMessage(value)}`, + ); + } + + if (domain.includes('..')) { + throw new DomainValidationError( + `Invalid domain ${toSafeErrorMessage(value)}: consecutive dots are not allowed`, + ); + } + + const asciiDomain = domainToASCII(domain); + if (!asciiDomain) { + throw new DomainValidationError( + `Invalid domain ${toSafeErrorMessage(value)}: could not convert to ASCII`, + ); + } + + if (isIP(asciiDomain) !== 0) { + throw new DomainValidationError( + `IP addresses are not valid certificate domains: ${toSafeErrorMessage(value)}`, + ); + } + + if (asciiDomain.length > MAX_DOMAIN_LENGTH) { + throw new DomainValidationError( + `Domains cannot exceed ${MAX_DOMAIN_LENGTH} characters`, + ); + } + + if (!/^[a-z0-9.-]+$/.test(asciiDomain)) { + throw new DomainValidationError( + `Invalid domain ${toSafeErrorMessage(value)}: unsupported characters detected`, + ); + } + + ensureLabelRules(asciiDomain); + + return wildcard ? `*.${asciiDomain}` : asciiDomain; +} + +/** Normalize and deduplicate a domain list while preserving the first-seen order. */ +export function normalizeDomains( + domains: string[], + options: NormalizeDomainOptions = {}, +): string[] { + if (!Array.isArray(domains)) { + throw new DomainValidationError('Domain input must be an array of strings'); + } + + const normalized: string[] = []; + const seen = new Set<string>(); + + for (const domain of domains) { + const normalizedDomain = normalizeDomain(domain, options); + if (seen.has(normalizedDomain)) { + continue; + } + + normalized.push(normalizedDomain); + seen.add(normalizedDomain); + } + + if (normalized.length === 0) { + throw new DomainValidationError('At least one domain is required'); + } + + return normalized; +} + +/** Parse a semicolon-joined domain string into a validated array of normalized domains. */ +export function parseDomains( + domainsString: string, + options: NormalizeDomainOptions = {}, +): string[] { + if (typeof domainsString !== 'string') { + throw new DomainValidationError('Domain input must be a string'); + } + + return normalizeDomains( + domainsString + .split(';') + .map((domain) => domain.trim()) + .filter(Boolean), + options, + ); } /** Join an array of domains into a semicolon-joined string (no trailing ;) */ -export function joinDomains(domains: string[]): string { - return domains.map(d => d.trim()).filter(Boolean).join(';'); +export function joinDomains( + domains: string[], + options: NormalizeDomainOptions = {}, +): string { + return normalizeDomains(domains, options).join(';'); } -/** Consistent hash for a set of domains (used for cert uniqueness) */ -export function hashDomains(domains: string[] | string): string { - const arr = Array.isArray(domains) ? domains : parseDomains(domains); - const sorted = [...arr].sort(); - return require('crypto') - .createHash('sha256') - .update(JSON.stringify(sorted)) +/** Consistent hash for a normalized set of domains (used for cert uniqueness). */ +export function hashDomains( + domains: string[] | string, + options: NormalizeDomainOptions = {}, +): string { + const normalizedDomains = Array.isArray(domains) + ? normalizeDomains(domains as string[], options) + : parseDomains(domains as string, options); + + return createHash('sha256') + .update(JSON.stringify([...normalizedDomains].sort())) .digest('hex'); -} \ No newline at end of file +} + +/** Build a safe, deterministic certificate storage directory name from a primary domain. */ +export function getCertificateStorageName(domain: string): string { + const normalized = normalizeDomain(domain, { allowWildcard: true }); + const wildcard = normalized.startsWith('*.'); + const baseDomain = wildcard ? normalized.slice(2) : normalized; + const safeLabel = baseDomain + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 40); + const hash = createHash('sha256') + .update(normalized) + .digest('hex') + .slice(0, 16); + + return `cert-${wildcard ? 'wild-' : ''}${safeLabel || 'domain'}-${hash}`; +} + +export function containsWildcardDomain(domains: string[]): boolean { + return normalizeDomains(domains, { allowWildcard: true }).some((domain) => + domain.startsWith('*.'), + ); +} diff --git a/src/utils/network-utils.ts b/src/utils/network-utils.ts index c6f7d43..faf4f17 100644 --- a/src/utils/network-utils.ts +++ b/src/utils/network-utils.ts @@ -1,141 +1,389 @@ import * as os from 'os'; +import { isIP } from 'node:net'; +import { URL } from 'node:url'; + +const DEFAULT_CLUSTER_CONTROL_PROTOCOL = 'http'; +const DEFAULT_CLUSTER_CONTROL_PORT = 3000; + +export type ControlPlaneEndpoint = { + address: string; + port: number; + protocol: 'http' | 'https'; + baseUrl: string; + source: string; +}; + +export type ControlPlaneResolution = { + endpoint: ControlPlaneEndpoint | null; + issues: string[]; +}; + +type ClusterNodeLike = { + ipAddress?: string | null; + metadata?: unknown; +}; /** - * Get the public IP address of this node using external services - * Tries multiple services for redundancy + * Resolve the control-plane endpoint that this node should advertise to peers. + * The current design intentionally avoids public-IP discovery and instead relies + * on explicit cluster configuration. */ -export async function getPublicIpAddress(): Promise<string | null> { - const services = [ - 'https://api.ipify.org', - 'https://api64.ipify.org', - 'https://icanhazip.com', - 'https://ifconfig.me/ip', - ]; - - for (const service of services) { - try { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 3000); // 3 second timeout - - const response = await fetch(service, { - signal: controller.signal, - }); - - clearTimeout(timeoutId); - - if (response.ok) { - const ip = (await response.text()).trim(); - // Basic validation to ensure it looks like an IPv4 address - if (/^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$/.test(ip)) { - // Additional validation: check that each octet is 0-255 - const octets = ip.split('.').map(Number); - if (octets.every((octet) => octet >= 0 && octet <= 255)) { - return ip; - } - } - } - } catch (error) { - // Try next service if this one fails - } +export function getLocalControlPlaneRegistration( + env: NodeJS.ProcessEnv = process.env, +): ControlPlaneResolution { + const allowHostnameFallback = env['NODE_ENV'] !== 'production'; + const allowLoopback = env['NODE_ENV'] !== 'production'; + const configuredUrl = firstDefinedString( + env['CLUSTER_CONTROL_URL'], + env['CONTROL_PLANE_URL'], + ); + + if (configuredUrl) { + return createEndpointFromUrl(configuredUrl, { + source: 'CLUSTER_CONTROL_URL', + allowLoopback, + }); } - return null; + const issues: string[] = []; + const configuredAddress = firstDefinedString( + env['CLUSTER_CONTROL_ADDRESS'], + env['CONTROL_PLANE_ADDRESS'], + ); + + const address = + configuredAddress ?? + (allowHostnameFallback + ? firstDefinedString(env['HOSTNAME'], os.hostname()) + : null); + + if (!address) { + return { + endpoint: null, + issues: [ + 'No control-plane address configured. Set CLUSTER_CONTROL_URL or CLUSTER_CONTROL_ADDRESS.', + ], + }; + } + + if (!configuredAddress) { + issues.push( + `CLUSTER_CONTROL_ADDRESS is not set; falling back to hostname "${address}"`, + ); + } + + const configuredPort = firstDefinedString( + env['CLUSTER_CONTROL_PORT'], + env['CONTROL_PLANE_PORT'], + ); + + if (!configuredPort) { + issues.push( + `CLUSTER_CONTROL_PORT is not set; defaulting to ${DEFAULT_CLUSTER_CONTROL_PORT}`, + ); + } + + return createEndpointFromParts( + { + address, + port: configuredPort ?? String(DEFAULT_CLUSTER_CONTROL_PORT), + protocol: + firstDefinedString( + env['CLUSTER_CONTROL_PROTOCOL'], + env['CONTROL_PLANE_PROTOCOL'], + ) ?? DEFAULT_CLUSTER_CONTROL_PROTOCOL, + source: configuredAddress ? 'CLUSTER_CONTROL_ADDRESS' : 'HOSTNAME', + }, + { allowLoopback, issues }, + ); } -/** - * Get the primary IP address of this node - * Prioritizes public IP, then non-internal IPv4 addresses, falls back to internal addresses - * PRODUCTION-GRADE: Multiple fallbacks and comprehensive error handling - */ export async function getNodeIpAddress(): Promise<string | null> { - // Try to get public IP first (with multiple services for redundancy) - const publicIp = await getPublicIpAddress(); - if (publicIp) { - console.log(`[Network] Using public IP: ${publicIp}`); - return publicIp; + return getLocalControlPlaneRegistration().endpoint?.address ?? null; +} + +export function getClusterNodeControlPlaneEndpoint( + node: ClusterNodeLike, +): ControlPlaneEndpoint | null { + const controlPlaneMetadata = getControlPlaneMetadata(node.metadata); + const configuredUrl = firstDefinedString( + asNonEmptyString(controlPlaneMetadata?.baseUrl), + asNonEmptyString(controlPlaneMetadata?.url), + ); + + if (configuredUrl) { + return createEndpointFromUrl(configuredUrl, { + source: 'cluster-node.metadata.controlPlane.baseUrl', + allowLoopback: false, + }).endpoint; } - console.log( - '[Network] Public IP detection failed, using local network interfaces', + const address = firstDefinedString( + asNonEmptyString(controlPlaneMetadata?.address), + node.ipAddress ?? undefined, ); + const port = getMetadataPort(controlPlaneMetadata); - try { - const interfaces = os.networkInterfaces(); - const addresses: string[] = []; - - // Collect all IPv4 addresses - for (const name of Object.keys(interfaces)) { - const iface = interfaces[name]; - if (!iface) continue; - - for (const addr of iface) { - // Only consider IPv4 addresses - if (addr.family === 'IPv4' && !addr.internal) { - addresses.push(addr.address); - console.log(`[Network] Found interface ${name}: ${addr.address}`); - } + if (!address || !port) { + return null; + } + + return createEndpointFromParts( + { + address, + port, + protocol: + asNonEmptyString(controlPlaneMetadata?.protocol) ?? + DEFAULT_CLUSTER_CONTROL_PROTOCOL, + source: 'cluster-node.metadata.controlPlane', + }, + { allowLoopback: false, issues: [] }, + ).endpoint; +} + +export function buildClusterNodeUrl( + node: ClusterNodeLike, + pathname: string, + query?: Record<string, string | undefined>, +): string | null { + const endpoint = getClusterNodeControlPlaneEndpoint(node); + if (!endpoint) { + return null; + } + + const url = new URL(endpoint.baseUrl); + url.pathname = pathname.startsWith('/') ? pathname : `/${pathname}`; + + if (query) { + for (const [key, value] of Object.entries(query)) { + if (value !== undefined) { + url.searchParams.set(key, value); } } + } - // Filter and prioritize addresses - const externalAddresses = addresses.filter((addr) => !isInternalIp(addr)); - const internalAddresses = addresses.filter( - (addr) => isInternalIp(addr) && addr !== '127.0.0.1', - ); + return url.toString(); +} - // Prefer external addresses, then internal (non-localhost) - if (externalAddresses.length > 0) { - console.log(`[Network] Using external address: ${externalAddresses[0]}`); - return externalAddresses[0]; - } +/** + * Check whether an advertised node address is actually reachable from peers. + * Private RFC1918 ranges are intentionally allowed because the cluster should + * prefer internal networking, but loopback/unspecified addresses are rejected. + */ +export function isReachableIp(address: string): boolean { + return isReachableAddress(address, { allowLoopback: false }); +} - if (internalAddresses.length > 0) { - console.log(`[Network] Using internal address: ${internalAddresses[0]}`); - return internalAddresses[0]; - } +function createEndpointFromUrl( + rawUrl: string, + options: { source: string; allowLoopback: boolean }, +): ControlPlaneResolution { + const issues: string[] = []; + let parsed: URL; - // Last resort: use localhost - console.warn( - '[Network] No network interfaces found, falling back to localhost', - ); - return '127.0.0.1'; + try { + parsed = new URL(rawUrl); } catch (error) { - console.error( - `[Network] Failed to get node IP address: ${error instanceof Error ? error.message : String(error)}`, + return { + endpoint: null, + issues: [ + `Invalid control-plane URL "${rawUrl}": ${error instanceof Error ? error.message : String(error)}`, + ], + }; + } + + if (parsed.pathname !== '/' || parsed.search || parsed.hash) { + return { + endpoint: null, + issues: [ + 'CLUSTER_CONTROL_URL must be an origin only and must not include a path, query string, or hash fragment.', + ], + }; + } + + if (!parsed.port) { + return { + endpoint: null, + issues: ['CLUSTER_CONTROL_URL must include an explicit port.'], + }; + } + + return createEndpointFromParts( + { + address: parsed.hostname, + port: parsed.port, + protocol: parsed.protocol.replace(/:$/, ''), + source: options.source, + }, + { allowLoopback: options.allowLoopback, issues }, + ); +} + +function createEndpointFromParts( + parts: { + address: string; + port: string; + protocol: string; + source: string; + }, + options: { allowLoopback: boolean; issues: string[] }, +): ControlPlaneResolution { + const issues = [...options.issues]; + const normalizedAddress = normalizeAddress(parts.address); + const normalizedProtocol = parts.protocol.trim().toLowerCase(); + const parsedPort = Number.parseInt(parts.port, 10); + + if (!normalizedAddress) { + issues.push('Cluster control-plane address is empty.'); + return { endpoint: null, issues }; + } + + if (!looksLikeHostOrIp(normalizedAddress)) { + issues.push(`Invalid cluster control-plane address "${parts.address}".`); + return { endpoint: null, issues }; + } + + if (normalizedProtocol !== 'http' && normalizedProtocol !== 'https') { + issues.push( + `Invalid cluster control-plane protocol "${parts.protocol}". Expected http or https.`, ); + return { endpoint: null, issues }; + } + + if (!Number.isInteger(parsedPort) || parsedPort <= 0 || parsedPort > 65535) { + issues.push(`Invalid cluster control-plane port "${parts.port}".`); + return { endpoint: null, issues }; + } + + if (!isReachableAddress(normalizedAddress, options)) { + issues.push( + `Cluster control-plane address "${normalizedAddress}" is not routable from peer nodes.`, + ); + return { endpoint: null, issues }; + } + + return { + endpoint: { + address: normalizedAddress, + port: parsedPort, + protocol: normalizedProtocol, + baseUrl: `${normalizedProtocol}://${formatAddressForUrl(normalizedAddress)}:${parsedPort}`, + source: parts.source, + }, + issues, + }; +} + +function getControlPlaneMetadata( + metadata: unknown, +): Record<string, unknown> | null { + if (!isRecord(metadata)) { return null; } + + const controlPlane = metadata['controlPlane']; + return isRecord(controlPlane) ? controlPlane : null; } -/** - * Check if an IP address is internal/private - */ -function isInternalIp(ip: string): boolean { - const parts = ip.split('.').map(Number); +function getMetadataPort( + metadata: Record<string, unknown> | null, +): string | null { + const port = metadata?.port; - if (parts.length !== 4) return false; + if (typeof port === 'number' && Number.isInteger(port)) { + return String(port); + } - // 127.x.x.x (loopback) - if (parts[0] === 127) return true; + if (typeof port === 'string' && port.trim()) { + return port.trim(); + } + + return null; +} - // 10.x.x.x (private) - if (parts[0] === 10) return true; +function firstDefinedString( + ...values: Array<string | null | undefined> +): string | null { + for (const value of values) { + if (typeof value === 'string' && value.trim()) { + return value.trim(); + } + } - // 172.16.x.x - 172.31.x.x (private) - if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true; + return null; +} - // 192.168.x.x (private) - if (parts[0] === 192 && parts[1] === 168) return true; +function asNonEmptyString(value: unknown): string | null { + return typeof value === 'string' && value.trim() ? value.trim() : null; +} - // 169.254.x.x (link-local) - return parts[0] === 169 && parts[1] === 254; +function normalizeAddress(address: string): string { + return address.trim().replace(/^\[/, '').replace(/]$/, ''); } -/** - * Check if IP address is reachable (public or internal network) - */ -export function isReachableIp(ip: string): boolean { - if (!ip) return false; - if (ip === '127.0.0.1') return false; // Localhost is not reachable from other nodes +function formatAddressForUrl(address: string): string { + return isIP(address) === 6 ? `[${address}]` : address; +} + +function looksLikeHostOrIp(address: string): boolean { + if (isIP(address) !== 0) { + return true; + } + + if (/\s|[/?#]/.test(address)) { + return false; + } + + if (address.startsWith('.') || address.endsWith('.') || address.includes('..')) { + return false; + } + + return /^[a-zA-Z0-9.-]+$/.test(address); +} + +function isReachableAddress( + address: string, + options: { allowLoopback: boolean }, +): boolean { + const normalizedAddress = normalizeAddress(address).toLowerCase(); + + if (!normalizedAddress) { + return false; + } + + if ( + normalizedAddress === '0.0.0.0' || + normalizedAddress === '::' || + normalizedAddress === '::0' + ) { + return false; + } + + if (isLoopbackAddress(normalizedAddress)) { + return options.allowLoopback; + } + return true; } + +function isLoopbackAddress(address: string): boolean { + if (address === 'localhost' || address === 'ip6-localhost') { + return true; + } + + if (isIP(address) === 4) { + const octets = address.split('.').map((segment) => Number.parseInt(segment, 10)); + return octets.length === 4 && octets[0] === 127; + } + + if (isIP(address) === 6) { + return address === '::1'; + } + + return false; +} + +function isRecord(value: unknown): value is Record<string, unknown> { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + diff --git a/src/utils/pipes/normalized-domain.pipe.ts b/src/utils/pipes/normalized-domain.pipe.ts new file mode 100644 index 0000000..a33c999 --- /dev/null +++ b/src/utils/pipes/normalized-domain.pipe.ts @@ -0,0 +1,23 @@ +import { BadRequestException, Injectable, PipeTransform } from '@nestjs/common'; +import { + DomainValidationError, + normalizeDomain, + NormalizeDomainOptions, +} from '../domain-utils'; + +@Injectable() +export class NormalizedDomainPipe implements PipeTransform<string, string> { + constructor(private readonly options: NormalizeDomainOptions = {}) {} + + transform(value: string): string { + try { + return normalizeDomain(value, this.options); + } catch (error) { + if (error instanceof DomainValidationError) { + throw new BadRequestException(error.message); + } + + throw error; + } + } +} diff --git a/src/utils/process-utils.ts b/src/utils/process-utils.ts new file mode 100644 index 0000000..2a42328 --- /dev/null +++ b/src/utils/process-utils.ts @@ -0,0 +1,84 @@ +import { execFile } from 'node:child_process'; +import * as process from 'node:process'; + +export type RunCommandOptions = { + cwd?: string; + env?: NodeJS.ProcessEnv; + input?: string; + timeoutMs?: number; + maxBuffer?: number; + allowNonZeroExit?: boolean; +}; + +export class ProcessExecutionError extends Error { + constructor( + message: string, + readonly command: string, + readonly args: string[], + readonly stdout: string, + readonly stderr: string, + readonly exitCode?: number | null, + ) { + super(message); + this.name = 'ProcessExecutionError'; + } +} + +export async function runCommand( + command: string, + args: string[], + options: RunCommandOptions = {}, +): Promise<{ stdout: string; stderr: string }> { + return new Promise((resolve, reject) => { + const child = execFile( + command, + args, + { + cwd: options.cwd, + env: { + ...process.env, + ...options.env, + }, + timeout: options.timeoutMs ?? 30_000, + maxBuffer: options.maxBuffer ?? 10 * 1024 * 1024, + windowsHide: true, + }, + (error, stdout, stderr) => { + const normalizedStdout = stdout ?? ''; + const normalizedStderr = stderr ?? ''; + + if (error && !options.allowNonZeroExit) { + const combinedOutput = [ + normalizedStderr.trim(), + normalizedStdout.trim(), + ] + .filter(Boolean) + .join('\n'); + reject( + new ProcessExecutionError( + combinedOutput + ? `${command} ${args.join(' ')} failed: ${combinedOutput}` + : `${command} ${args.join(' ')} failed`, + command, + args, + normalizedStdout, + normalizedStderr, + typeof (error as NodeJS.ErrnoException & { code?: unknown }) + .code === 'number' + ? ((error as NodeJS.ErrnoException & { code?: number }).code ?? + null) + : null, + ), + ); + return; + } + + resolve({ stdout: normalizedStdout, stderr: normalizedStderr }); + }, + ); + + if (options.input !== undefined) { + child.stdin?.end(options.input); + } + }); +} diff --git a/test/chaos/chaos-fault-injection.test.js b/test/chaos/chaos-fault-injection.test.js new file mode 100644 index 0000000..42fde5a --- /dev/null +++ b/test/chaos/chaos-fault-injection.test.js @@ -0,0 +1,1731 @@ +require('reflect-metadata'); + +const { after, afterEach, before, beforeEach, describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const childProcess = require('node:child_process'); +const fs = require('node:fs'); +const fsPromises = require('node:fs/promises'); +const Module = require('node:module'); +const os = require('node:os'); +const path = require('node:path'); +const { spawn } = require('node:child_process'); + +const repoRoot = path.resolve(__dirname, '../..'); +const entrypointPath = path.join(repoRoot, 'docker-entrypoint.sh'); +const reloaderModulePath = path.join(repoRoot, 'src/reloader/reloader.service.ts'); +const nginxServiceModulePath = path.join(repoRoot, 'src/nginx/nginx.service.ts'); +const certificateServicePath = path.join(repoRoot, 'src/certificate/certificate.service.ts'); +const certificateOrderServicePath = path.join( + repoRoot, + 'src/certificate/certificate-order.service.ts', +); + +class PrismaService {} + +const originalModuleLoad = Module._load; +Module._load = function loadWithPrismaStub(request, parent, isMain) { + if (request === '../prisma/prisma.service') { + return { PrismaService }; + } + + return originalModuleLoad.call(this, request, parent, isMain); +}; + +const { HealthService } = require('../../src/health/health.service'); + +Module._load = originalModuleLoad; + +const { + ClusterHeartbeatService, +} = require('../../src/distributed-lock/cluster-heartbeat.service'); +const { + ClusterOperationsService, +} = require('../../src/distributed-lock/cluster-operations.service'); + +const originalAccess = fsPromises.access; +const originalReadFile = fsPromises.readFile; +const originalKill = process.kill; +const originalFetch = global.fetch; +const originalAdminEmail = process.env.ADMIN_EMAIL; +const originalApiKey = process.env.API_KEY; +const originalConfigMaxAge = process.env.HEALTH_CONFIG_APPLY_MAX_AGE_MS; +const originalCertSyncMaxAge = process.env.HEALTH_CERTIFICATE_SYNC_MAX_AGE_MS; +const originalExecFile = childProcess.execFile; + +const reloaderEnvKeys = [ + 'NGINX_ETC_DIR', + 'NGINX_SOURCE_DIR', + 'NGINX_LOG_DIR', + 'ADMIN_EMAIL', +]; +const originalReloaderEnv = Object.fromEntries( + reloaderEnvKeys.map((key) => [key, process.env[key]]), +); + +function stubHealthyNginx(pid = 4242) { + fsPromises.access = async () => undefined; + fsPromises.readFile = async () => `${pid}\n`; + process.kill = (targetPid, signal) => { + assert.equal(targetPid, pid); + assert.equal(signal, 0); + return true; + }; +} + +function restoreProcessAndFs() { + fsPromises.access = originalAccess; + fsPromises.readFile = originalReadFile; + process.kill = originalKill; +} + +function createNode(overrides = {}) { + const now = new Date(); + + return { + id: overrides.id ?? `id-${overrides.instanceId ?? 'node-a'}`, + hostname: overrides.hostname ?? overrides.instanceId ?? 'node-a', + instanceId: overrides.instanceId ?? 'node-a', + ipAddress: overrides.ipAddress ?? '10.0.0.1', + isLeader: overrides.isLeader ?? false, + lastHeartbeat: overrides.lastHeartbeat ?? now, + version: overrides.version ?? '0.0.1', + status: overrides.status ?? 'active', + metadata: overrides.metadata ?? {}, + createdAt: overrides.createdAt ?? now, + updatedAt: overrides.updatedAt ?? now, + }; +} + +function createLease({ + ownerNodeId, + generation = 1, + ttlSeconds = 30, + expiresAt, +} = {}) { + const now = new Date(); + const effectiveExpiresAt = + expiresAt ?? new Date(now.getTime() + ttlSeconds * 1000); + + return { + leaseName: 'cluster:leader', + ownerNodeId: ownerNodeId ?? null, + ownerHostname: ownerNodeId ?? null, + generation, + ttlSeconds, + acquiredAt: now, + renewedAt: now, + expiresAt: effectiveExpiresAt, + isExpired: + ownerNodeId == null || effectiveExpiresAt.getTime() <= now.getTime(), + isHeldByThisInstance: false, + fencingToken: generation, + }; +} + +function createClusterNodePrismaMock(initialNodes) { + const state = { + nodes: initialNodes.map((node) => ({ ...node })), + }; + + function matchesWhere(node, where = {}) { + return Object.entries(where).every(([key, expected]) => { + const actual = node[key]; + + if (expected && typeof expected === 'object' && !Array.isArray(expected)) { + if (Object.prototype.hasOwnProperty.call(expected, 'lt')) { + return actual < expected.lt; + } + + if (Object.prototype.hasOwnProperty.call(expected, 'not')) { + return actual !== expected.not; + } + + if (Object.prototype.hasOwnProperty.call(expected, 'in')) { + return expected.in.includes(actual); + } + } + + return actual === expected; + }); + } + + function sortNodes(nodes, orderBy) { + if (!orderBy) { + return nodes; + } + + const [key, direction] = Object.entries(orderBy)[0]; + return nodes.slice().sort((left, right) => { + const leftValue = left[key]; + const rightValue = right[key]; + + if (leftValue < rightValue) { + return direction === 'desc' ? 1 : -1; + } + + if (leftValue > rightValue) { + return direction === 'desc' ? -1 : 1; + } + + return 0; + }); + } + + function selectFields(node, select) { + if (!select) { + return { ...node }; + } + + return Object.fromEntries( + Object.entries(select) + .filter(([, include]) => include) + .map(([key]) => [key, node[key]]), + ); + } + + return { + state, + clusterNode: { + async findMany({ where, orderBy, select } = {}) { + const filtered = state.nodes.filter((node) => matchesWhere(node, where)); + return sortNodes(filtered, orderBy).map((node) => selectFields(node, select)); + }, + async findUnique({ where }) { + const [key, value] = Object.entries(where)[0]; + const node = state.nodes.find((entry) => entry[key] === value); + return node ? { ...node } : null; + }, + async update({ where, data }) { + const [key, value] = Object.entries(where)[0]; + const index = state.nodes.findIndex((node) => node[key] === value); + + if (index < 0) { + throw new Error(`Node not found for ${key}=${value}`); + } + + state.nodes[index] = { + ...state.nodes[index], + ...data, + updatedAt: new Date(), + }; + + return { ...state.nodes[index] }; + }, + async updateMany({ where, data }) { + let count = 0; + state.nodes = state.nodes.map((node) => { + if (!matchesWhere(node, where)) { + return node; + } + + count += 1; + return { + ...node, + ...data, + updatedAt: new Date(), + }; + }); + + return { count }; + }, + async deleteMany({ where }) { + const before = state.nodes.length; + state.nodes = state.nodes.filter((node) => !matchesWhere(node, where)); + return { count: before - state.nodes.length }; + }, + async count({ where } = {}) { + return state.nodes.filter((node) => matchesWhere(node, where)).length; + }, + }, + }; +} + +function createMutableDistributedLockMock({ + instanceId = 'node-a', + lease = null, + tryAcquire = false, + acquire = false, +} = {}) { + let currentLease = lease; + const calls = { + tryAcquireLeaderLock: 0, + acquireLeaderLock: 0, + releaseLeaderLock: 0, + }; + + function snapshot() { + if (!currentLease) { + return null; + } + + const now = new Date(); + const isExpired = + !currentLease.ownerNodeId || currentLease.expiresAt.getTime() <= now.getTime(); + + return { + ...currentLease, + isExpired, + isHeldByThisInstance: currentLease.ownerNodeId === instanceId && !isExpired, + fencingToken: currentLease.generation, + }; + } + + function assignLeaseToInstance() { + currentLease = createLease({ + ownerNodeId: instanceId, + generation: currentLease?.generation ? currentLease.generation + 1 : 1, + }); + } + + return { + calls, + setLease(nextLease) { + currentLease = nextLease; + }, + getInstanceId() { + return instanceId; + }, + async isLeader() { + const leaderLease = snapshot(); + return Boolean( + leaderLease && + !leaderLease.isExpired && + leaderLease.ownerNodeId === instanceId, + ); + }, + getLeaderLockStatus() { + const leaderLease = snapshot(); + return { + isLeader: Boolean( + leaderLease && + !leaderLease.isExpired && + leaderLease.ownerNodeId === instanceId, + ), + instanceId, + ownerNodeId: leaderLease?.ownerNodeId ?? null, + generation: leaderLease?.generation ?? null, + fencingToken: leaderLease?.generation ?? null, + expiresAt: leaderLease?.expiresAt ?? null, + heldForMs: leaderLease ? 1000 : null, + }; + }, + async getLeaderLeaseSnapshot() { + return snapshot(); + }, + async tryAcquireLeaderLock() { + calls.tryAcquireLeaderLock += 1; + if (!tryAcquire) { + return false; + } + + assignLeaseToInstance(); + return true; + }, + async acquireLeaderLock() { + calls.acquireLeaderLock += 1; + if (!acquire) { + return false; + } + + assignLeaseToInstance(); + return true; + }, + async releaseLeaderLock() { + calls.releaseLeaderLock += 1; + if (!currentLease || currentLease.ownerNodeId !== instanceId) { + return false; + } + + currentLease = createLease({ + ownerNodeId: null, + generation: currentLease.generation, + expiresAt: new Date(Date.now() - 1000), + }); + return true; + }, + }; +} + +function createOperationTarget(overrides = {}) { + const now = new Date(); + + return { + id: overrides.id ?? `id-${overrides.instanceId ?? 'node-a'}`, + hostname: overrides.hostname ?? overrides.instanceId ?? 'node-a', + instanceId: overrides.instanceId ?? 'node-a', + ipAddress: overrides.ipAddress ?? '10.0.0.1', + isLeader: overrides.isLeader ?? false, + lastHeartbeat: overrides.lastHeartbeat ?? now, + version: overrides.version ?? '0.0.1', + status: overrides.status ?? 'active', + metadata: + overrides.metadata ?? + { + controlPlane: { + baseUrl: `http://${overrides.hostname ?? overrides.instanceId ?? 'node-a'}.internal:3000`, + address: `${overrides.hostname ?? overrides.instanceId ?? 'node-a'}.internal`, + port: 3000, + protocol: 'http', + }, + }, + createdAt: overrides.createdAt ?? now, + updatedAt: overrides.updatedAt ?? now, + }; +} + +function createClusterOperationPrismaMock() { + const state = { + operationSequence: 0, + ackSequence: 0, + operations: [], + acknowledgements: [], + }; + + function clone(value) { + return JSON.parse(JSON.stringify(value)); + } + + function getOperation(operationId) { + return state.operations.find((operation) => operation.id === operationId) ?? null; + } + + function getAcknowledgements(operationId) { + return state.acknowledgements.filter((ack) => ack.operationId === operationId); + } + + return { + state, + clusterOperation: { + async create({ data }) { + const now = new Date(); + state.operationSequence += 1; + const operation = { + id: `operation-${state.operationSequence}`, + operationType: data.operationType, + scope: data.scope ?? 'cluster', + status: data.status ?? 'pending', + initiatorNodeId: data.initiatorNodeId ?? null, + initiatorHostname: data.initiatorHostname ?? null, + initiatorActorId: data.initiatorActorId ?? null, + initiatorActorType: data.initiatorActorType ?? null, + initiatorActorDisplayName: data.initiatorActorDisplayName ?? null, + correlationId: data.correlationId ?? null, + requestPath: data.requestPath ?? null, + targetNodeCount: data.targetNodeCount ?? 0, + completedNodeCount: data.completedNodeCount ?? 0, + successfulNodeCount: data.successfulNodeCount ?? 0, + failedNodeCount: data.failedNodeCount ?? 0, + startedAt: data.startedAt ?? null, + completedAt: data.completedAt ?? null, + lastError: data.lastError ?? null, + metadata: data.metadata ?? null, + createdAt: now, + updatedAt: now, + }; + + state.operations.push(operation); + + for (const ackData of data.acknowledgements?.create ?? []) { + state.ackSequence += 1; + state.acknowledgements.push({ + id: `ack-${state.ackSequence}`, + operationId: operation.id, + nodeInstanceId: ackData.nodeInstanceId, + nodeHostname: ackData.nodeHostname ?? null, + endpointUrl: ackData.endpointUrl ?? null, + status: ackData.status ?? 'pending', + responseStatus: ackData.responseStatus ?? null, + errorMessage: ackData.errorMessage ?? null, + startedAt: ackData.startedAt ?? null, + ackedAt: ackData.ackedAt ?? null, + details: ackData.details ?? null, + createdAt: now, + updatedAt: now, + }); + } + + return clone(operation); + }, + async update({ where, data }) { + const operation = getOperation(where.id); + if (!operation) { + throw new Error(`Operation not found: ${where.id}`); + } + + Object.assign(operation, data, { updatedAt: new Date() }); + return clone(operation); + }, + async findUniqueOrThrow({ where }) { + const operation = getOperation(where.id); + if (!operation) { + throw new Error(`Operation not found: ${where.id}`); + } + + return clone(operation); + }, + async findUnique({ where, include }) { + const operation = getOperation(where.id); + if (!operation) { + return null; + } + + if (!include?.acknowledgements) { + return clone(operation); + } + + const acknowledgements = getAcknowledgements(where.id) + .slice() + .sort((left, right) => + `${left.nodeHostname ?? ''}:${left.nodeInstanceId}`.localeCompare( + `${right.nodeHostname ?? ''}:${right.nodeInstanceId}`, + ), + ); + + return { + ...clone(operation), + acknowledgements: clone(acknowledgements), + }; + }, + async findMany({ take } = {}) { + return state.operations + .slice() + .sort((left, right) => right.createdAt - left.createdAt) + .slice(0, take ?? state.operations.length) + .map(clone); + }, + }, + clusterOperationAck: { + async update({ where, data }) { + const ack = state.acknowledgements.find( + (entry) => + entry.operationId === where.operationId_nodeInstanceId.operationId && + entry.nodeInstanceId === where.operationId_nodeInstanceId.nodeInstanceId, + ); + + if (!ack) { + throw new Error( + `Acknowledgement not found for ${where.operationId_nodeInstanceId.operationId}/${where.operationId_nodeInstanceId.nodeInstanceId}`, + ); + } + + Object.assign(ack, data, { updatedAt: new Date() }); + return clone(ack); + }, + async findMany({ where, select } = {}) { + const acknowledgements = state.acknowledgements.filter((ack) => { + if (!where?.operationId) { + return true; + } + return ack.operationId === where.operationId; + }); + + if (!select) { + return acknowledgements.map(clone); + } + + return acknowledgements.map((ack) => { + const selected = {}; + for (const [key, enabled] of Object.entries(select)) { + if (enabled) { + selected[key] = ack[key]; + } + } + return selected; + }); + }, + }, + }; +} + +async function waitForOperationToSettle(service, operationId) { + for (let attempt = 0; attempt < 20; attempt += 1) { + const operation = await service.getOperation(operationId); + if (operation && !['pending', 'running'].includes(operation.status)) { + return operation; + } + + await new Promise((resolve) => setTimeout(resolve, 0)); + } + + throw new Error(`Operation ${operationId} did not settle in time`); +} + +function restoreReloaderEnv() { + for (const key of reloaderEnvKeys) { + if (originalReloaderEnv[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = originalReloaderEnv[key]; + } + } +} + +function loadReloaderWithPaths(paths) { + process.env.NGINX_ETC_DIR = paths.nginxEtcDir; + process.env.NGINX_SOURCE_DIR = paths.nginxSourceDir; + process.env.NGINX_LOG_DIR = paths.nginxLogDir; + process.env.ADMIN_EMAIL = process.env.ADMIN_EMAIL ?? 'chaos-suite@example.test'; + + delete require.cache[require.resolve(reloaderModulePath)]; + delete require.cache[require.resolve(nginxServiceModulePath)]; + + const { ReloaderService } = require(reloaderModulePath); + const { NginxService } = require(nginxServiceModulePath); + + restoreReloaderEnv(); + + return { ReloaderService, NginxService }; +} + +function createProxyEntry(overrides = {}) { + return { + id: overrides.id ?? 'proxy-1', + domains: overrides.domains ?? 'example.test', + proxy_pass_host: overrides.proxy_pass_host ?? 'http://localhost:8080', + ssl: overrides.ssl ?? false, + type: overrides.type ?? 'PROXY', + nginx_custom_code: overrides.nginx_custom_code ?? '', + }; +} + +async function writeFile(filePath, content) { + await fsPromises.mkdir(path.dirname(filePath), { recursive: true }); + await fsPromises.writeFile(filePath, content, 'utf8'); +} + +async function createNginxSourceTree(rootDir) { + await writeFile( + path.join(rootDir, 'nginx.conf'), + `pid /run/nginx.pid; + +events { + worker_connections 64; +} + +http { + include /etc/nginx/mime.types; + root /etc/nginx/html; + include /etc/nginx/runtime/current/conf.d/*.conf; +} +`, + ); + await writeFile(path.join(rootDir, 'mime.types'), 'types { text/html html; }\n'); + await writeFile( + path.join(rootDir, 'conf.d/default.conf'), + `server { + listen 80 default_server; + root /etc/nginx/html; + error_page 500 501 502 503 504 505 506 507 508 510 511 /errors/50x.html; +} +`, + ); + await writeFile( + path.join(rootDir, 'html/index.html'), + '<html lang="en">ok</html>\n', + ); + await writeFile( + path.join(rootDir, 'html/errors/5xx.html'), + '<html lang="en">5xx</html>\n', + ); + await writeFile( + path.join(rootDir, 'html/errors/broken.html'), + '<html lang="en">broken</html>\n', + ); + await writeFile( + path.join(rootDir, 'html/errors/loading.html'), + '<html lang="en">loading</html>\n', + ); +} + +async function createReloaderHarness(overrides = {}) { + const tempDir = await fsPromises.mkdtemp( + path.join(os.tmpdir(), 'lyttlenginx-chaos-reloader-'), + ); + const nginxEtcDir = path.join(tempDir, 'etc-nginx'); + const nginxSourceDir = path.join(tempDir, 'source-nginx'); + const nginxLogDir = path.join(tempDir, 'var-log-nginx'); + await createNginxSourceTree(nginxSourceDir); + + const { ReloaderService, NginxService } = loadReloaderWithPaths({ + nginxEtcDir, + nginxSourceDir, + nginxLogDir, + }); + + const entries = overrides.entries ?? [createProxyEntry()]; + const health = { + successes: [], + failures: [], + recordConfigApplySuccess(message) { + this.successes.push(message); + }, + recordConfigApplyFailure(message) { + this.failures.push(message); + }, + }; + + const service = new ReloaderService( + { + proxyEntry: { + async findMany() { + return entries; + }, + }, + }, + new NginxService(), + { + async ensureCertificate() { + return undefined; + }, + }, + { + ensureCertbotWebroot() {}, + }, + health, + ); + + return { + tempDir, + nginxEtcDir, + health, + service, + cleanup: async () => { + restoreReloaderEnv(); + await fsPromises.rm(tempDir, { recursive: true, force: true }); + }, + }; +} + +async function writeExecutable(filePath, content) { + await fsPromises.writeFile(filePath, content, { mode: 0o755 }); + await fsPromises.chmod(filePath, 0o755); +} + +function collectProcessResult(child) { + let stdout = ''; + let stderr = ''; + + child.stdout.on('data', (chunk) => { + stdout += chunk.toString(); + }); + + child.stderr.on('data', (chunk) => { + stderr += chunk.toString(); + }); + + return new Promise((resolve, reject) => { + child.once('error', reject); + child.once('close', (code, signal) => resolve({ code, signal, stdout, stderr })); + }); +} + +async function readLog(logPath) { + try { + return await fsPromises.readFile(logPath, 'utf8'); + } catch (error) { + if (error && error.code === 'ENOENT') { + return ''; + } + + throw error; + } +} + +async function waitForResult(resultPromise, timeoutMs = 10000) { + return Promise.race([ + resultPromise, + new Promise((_, reject) => { + setTimeout( + () => reject(new Error(`Timed out waiting for entrypoint to exit after ${timeoutMs}ms`)), + timeoutMs, + ); + }), + ]); +} + +function stopIfRunning(child, signal = 'SIGKILL') { + if (child.exitCode === null && child.signalCode === null) { + child.kill(signal); + } +} + +async function createEntrypointHarness(envOverrides = {}) { + const tempDir = await fsPromises.mkdtemp( + path.join(os.tmpdir(), 'lyttlenginx-chaos-entrypoint-'), + ); + const binDir = path.join(tempDir, 'bin'); + const logPath = path.join(tempDir, 'process.log'); + await fsPromises.mkdir(binDir, { recursive: true }); + + await writeExecutable( + path.join(binDir, 'node'), + `#!/usr/bin/env bash +set -euo pipefail + +if [ "\${1:-}" = "--version" ]; then + echo "v24.16.0-test" + exit 0 +fi + +if [ "\${1:-}" = "dist/main.js" ]; then + echo "node:start" >> "$ENTRYPOINT_TEST_LOG" + trap 'echo "node:term" >> "$ENTRYPOINT_TEST_LOG"; exit 0' TERM INT QUIT + + while true; do + sleep 1 + done +fi + +echo "unexpected node args: $*" >&2 +exit 9 +`, + ); + + await writeExecutable( + path.join(binDir, 'npm'), + `#!/usr/bin/env bash +set -euo pipefail + +if [ "\${1:-}" = "--version" ]; then + echo "11.15.0-test" + exit 0 +fi + +echo "unexpected npm args: $*" >&2 +exit 10 +`, + ); + + await writeExecutable( + path.join(binDir, 'npx'), + `#!/usr/bin/env bash +set -euo pipefail + +if [ "\${1:-}" = "prisma" ] && [ "\${2:-}" = "generate" ]; then + exit 0 +fi + +if [ "\${1:-}" = "prisma" ] && [ "\${2:-}" = "migrate" ] && [ "\${3:-}" = "deploy" ]; then + exit 0 +fi + +echo "unexpected npx args: $*" >&2 +exit 11 +`, + ); + + await writeExecutable( + path.join(binDir, 'nginx'), + `#!/usr/bin/env bash +set -euo pipefail + +if [ "\${1:-}" = "-t" ]; then + echo "nginx:test-ok" >> "$ENTRYPOINT_TEST_LOG" + exit 0 +fi + +if [ "\${1:-}" = "-g" ] && [ "\${2:-}" = "daemon off;" ]; then + echo "nginx:start" >> "$ENTRYPOINT_TEST_LOG" + trap 'echo "nginx:quit" >> "$ENTRYPOINT_TEST_LOG"; exit 0' TERM INT QUIT + + if [ "\${FAKE_NGINX_MODE:-steady}" = "crash" ]; then + sleep "\${FAKE_NGINX_CRASH_DELAY_SECONDS:-1}" + echo "nginx:crash" >> "$ENTRYPOINT_TEST_LOG" + exit "\${FAKE_NGINX_EXIT_CODE:-23}" + fi + + while true; do + sleep 1 + done +fi + +if [ "\${1:-}" = "-s" ] && [ "\${2:-}" = "quit" ]; then + echo "nginx:quit-command" >> "$ENTRYPOINT_TEST_LOG" + exit 0 +fi + +echo "unexpected nginx args: $*" >&2 +exit 12 +`, + ); + + await writeExecutable( + path.join(binDir, 'nc'), + `#!/usr/bin/env bash +set -euo pipefail +exit 0 +`, + ); + + const child = spawn('/bin/bash', [entrypointPath], { + cwd: repoRoot, + env: { + ...process.env, + PATH: `${binDir}:${process.env.PATH}`, + DATABASE_URL: 'postgresql://user:pass@db:5432/lyttlenginx', + HOSTNAME: 'chaos-suite-test-host', + ENTRYPOINT_TEST_LOG: logPath, + SERVICE_STARTUP_GRACE_SECONDS: '1', + NODE_SHUTDOWN_TIMEOUT_SECONDS: '1', + NGINX_SHUTDOWN_TIMEOUT_SECONDS: '1', + DB_CONNECT_RETRY_DELAY_SECONDS: '1', + MIGRATION_RETRY_DELAY_SECONDS: '1', + ...envOverrides, + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + return { + child, + logPath, + resultPromise: collectProcessResult(child), + cleanup: async () => { + stopIfRunning(child); + await fsPromises.rm(tempDir, { recursive: true, force: true }); + }, + }; +} + +function resetCertificateModules() { + delete require.cache[require.resolve(certificateServicePath)]; + delete require.cache[require.resolve(certificateOrderServicePath)]; +} + +function installExecFileStub(handler) { + const calls = []; + + childProcess.execFile = (command, args, options, callback) => { + if (typeof options === 'function') { + callback = options; + options = {}; + } + + calls.push({ command, args: [...args], options: { ...options } }); + + Promise.resolve() + .then(() => handler({ command, args, options, calls })) + .then((result) => callback?.(null, result?.stdout ?? '', result?.stderr ?? '')) + .catch((error) => callback?.(error, '', error?.stderr ?? '')); + + return { + stdin: { + end() {}, + }, + }; + }; + + return calls; +} + +function restoreExecFile() { + childProcess.execFile = originalExecFile; +} + +function clone(value) { + return structuredClone(value); +} + +function applyDataPatch(record, data) { + for (const [key, value] of Object.entries(data)) { + if ( + value && + typeof value === 'object' && + !Array.isArray(value) && + Object.keys(value).length === 1 && + Object.prototype.hasOwnProperty.call(value, 'increment') + ) { + record[key] = (record[key] ?? 0) + value.increment; + continue; + } + + record[key] = value; + } +} + +function createCertificatePrismaMock(clusterState) { + const state = { + certificateSequence: 0, + orderSequence: 0, + eventSequence: 0, + artifactSequence: 0, + certificates: [], + orders: [], + events: [], + artifacts: [], + }; + + function getCertificate(where) { + if (where.id) { + return state.certificates.find((entry) => entry.id === where.id) ?? null; + } + + if (where.domainsHash) { + return ( + state.certificates.find((entry) => entry.domainsHash === where.domainsHash) ?? + null + ); + } + + return null; + } + + function getOrderById(id) { + return state.orders.find((entry) => entry.id === id) ?? null; + } + + function matchesArtifactWhere(artifact, where = {}) { + if (typeof where.id === 'string' && artifact.id !== where.id) { + return false; + } + if (where.orderId && artifact.orderId !== where.orderId) { + return false; + } + if (where.domainsHash && artifact.domainsHash !== where.domainsHash) { + return false; + } + if (where.isCurrent !== undefined && artifact.isCurrent !== where.isCurrent) { + return false; + } + if (where.version?.lt !== undefined && !(artifact.version < where.version.lt)) { + return false; + } + if (where.activatedAt?.not === null && artifact.activatedAt === null) { + return false; + } + if (where.id?.not && artifact.id === where.id.not) { + return false; + } + return true; + } + + return { + state, + certificate: { + async create({ data }) { + state.certificateSequence += 1; + const now = new Date(); + const certificate = { + id: `cert-${state.certificateSequence}`, + createdAt: now, + updatedAt: now, + failureReason: data.failureReason ?? null, + retryAfter: data.retryAfter ?? null, + failureCount: data.failureCount ?? 0, + issuedByNode: data.issuedByNode ?? null, + ...data, + }; + state.certificates.push(certificate); + return clone(certificate); + }, + async findFirst() { + return null; + }, + async findUnique({ where }) { + const certificate = getCertificate(where); + return certificate ? clone(certificate) : null; + }, + async update({ where, data }) { + const certificate = getCertificate(where); + if (!certificate) { + throw new Error(`Certificate not found for update: ${JSON.stringify(where)}`); + } + applyDataPatch(certificate, data); + certificate.updatedAt = new Date(); + return clone(certificate); + }, + async findMany() { + return state.certificates.map(clone); + }, + async count() { + return 0; + }, + async delete() { + return undefined; + }, + }, + certificateOrder: { + async create({ data }) { + state.orderSequence += 1; + const now = new Date(); + const order = { + id: `order-${state.orderSequence}`, + sourceType: data.sourceType ?? 'acme', + status: data.status ?? 'requested', + attemptCount: data.attemptCount ?? 1, + retryCount: data.retryCount ?? 0, + nextRetryAt: data.nextRetryAt ?? null, + lastError: data.lastError ?? null, + requestedByNode: data.requestedByNode ?? null, + certificateId: data.certificateId ?? null, + requestedAt: data.requestedAt ?? now, + startedAt: data.startedAt ?? null, + challengePublishedAt: data.challengePublishedAt ?? null, + validatingAt: data.validatingAt ?? null, + issuedAt: data.issuedAt ?? null, + distributingAt: data.distributingAt ?? null, + activatedAt: data.activatedAt ?? null, + failedAt: data.failedAt ?? null, + revokedAt: data.revokedAt ?? null, + completedAt: data.completedAt ?? null, + metadata: data.metadata ?? null, + createdAt: now, + updatedAt: now, + ...data, + }; + state.orders.push(order); + return clone(order); + }, + async findFirst({ where, orderBy } = {}) { + const results = state.orders.filter((order) => { + if (where?.domainsHash && order.domainsHash !== where.domainsHash) { + return false; + } + if (typeof where?.status === 'string' && order.status !== where.status) { + return false; + } + if (where?.status?.in && !where.status.in.includes(order.status)) { + return false; + } + if (where?.sourceType && order.sourceType !== where.sourceType) { + return false; + } + if ( + where?.nextRetryAt?.lte && + !(order.nextRetryAt && order.nextRetryAt <= where.nextRetryAt.lte) + ) { + return false; + } + return true; + }); + if (orderBy?.createdAt === 'desc') { + results.sort((left, right) => right.createdAt - left.createdAt); + } + return results[0] ? clone(results[0]) : null; + }, + async findUnique({ where, include, select } = {}) { + const order = getOrderById(where.id); + if (!order) { + return null; + } + if (select) { + const selected = {}; + for (const [key, enabled] of Object.entries(select)) { + if (enabled) { + selected[key] = order[key]; + } + } + return clone(selected); + } + if (!include) { + return clone(order); + } + const record = clone(order); + if (include.events) { + record.events = state.events + .filter((entry) => entry.orderId === order.id) + .sort((left, right) => right.occurredAt - left.occurredAt) + .map(clone); + } + if (include.artifacts) { + record.artifacts = state.artifacts + .filter((entry) => entry.orderId === order.id) + .sort((left, right) => right.version - left.version) + .map(clone); + } + return record; + }, + async update({ where, data }) { + const order = getOrderById(where.id); + if (!order) { + throw new Error(`Order not found for update: ${where.id}`); + } + applyDataPatch(order, data); + order.updatedAt = new Date(); + return clone(order); + }, + async findMany({ where, take } = {}) { + return state.orders + .filter((order) => { + if (where?.sourceType && order.sourceType !== where.sourceType) { + return false; + } + if (where?.status && order.status !== where.status) { + return false; + } + if ( + where?.nextRetryAt?.lte && + !(order.nextRetryAt && order.nextRetryAt <= where.nextRetryAt.lte) + ) { + return false; + } + return true; + }) + .sort((left, right) => right.createdAt - left.createdAt) + .slice(0, take ?? state.orders.length) + .map(clone); + }, + }, + certificateOrderEvent: { + async create({ data }) { + state.eventSequence += 1; + const event = { + id: `event-${state.eventSequence}`, + occurredAt: data.occurredAt ?? new Date(), + details: data.details ?? null, + ...data, + }; + state.events.push(event); + return clone(event); + }, + }, + certificateArtifactVersion: { + async findFirst({ where, orderBy } = {}) { + const results = state.artifacts.filter((artifact) => + matchesArtifactWhere(artifact, where), + ); + if (orderBy?.version === 'desc') { + results.sort((left, right) => right.version - left.version); + } + return results[0] ? clone(results[0]) : null; + }, + async findUnique({ where }) { + const artifact = state.artifacts.find((entry) => entry.id === where.id) ?? null; + return artifact ? clone(artifact) : null; + }, + async create({ data }) { + state.artifactSequence += 1; + const artifact = { + id: `artifact-${state.artifactSequence}`, + createdAt: new Date(), + activatedAt: data.activatedAt ?? null, + isCurrent: data.isCurrent ?? false, + distributionStatus: data.distributionStatus ?? null, + distributionOperationId: data.distributionOperationId ?? null, + distributionCompletedAt: data.distributionCompletedAt ?? null, + metadata: data.metadata ?? null, + ...data, + }; + state.artifacts.push(artifact); + return clone(artifact); + }, + async update({ where, data }) { + const artifact = state.artifacts.find((entry) => entry.id === where.id) ?? null; + if (!artifact) { + throw new Error(`Artifact not found for update: ${where.id}`); + } + applyDataPatch(artifact, data); + return clone(artifact); + }, + async updateMany({ where, data }) { + const matches = state.artifacts.filter((artifact) => + matchesArtifactWhere(artifact, where), + ); + for (const artifact of matches) { + applyDataPatch(artifact, data); + } + return { count: matches.length }; + }, + }, + clusterOperation: { + async findUnique({ where }) { + const operation = + clusterState.operations.find((entry) => entry.id === where.id) ?? null; + return operation ? clone(operation) : null; + }, + }, + proxyEntry: { + async findMany() { + return []; + }, + }, + }; +} + +function createCertificateHarness() { + process.env.ADMIN_EMAIL = 'chaos-suite@example.test'; + resetCertificateModules(); + const { CertificateOrderService } = require(certificateOrderServicePath); + const { CertificateService } = require(certificateServicePath); + + const clusterState = { + sequence: 0, + mode: 'succeeded', + operations: [], + }; + const prisma = createCertificatePrismaMock(clusterState); + const orderService = new CertificateOrderService(prisma); + const alertService = { + sendAlert: async () => undefined, + }; + const clusterOperations = { + async enqueueBroadcastOperation(options) { + clusterState.sequence += 1; + const operationId = `op-${clusterState.sequence}`; + await options.localAction(operationId); + const operation = + clusterState.mode === 'succeeded' + ? { + id: operationId, + status: 'succeeded', + completedAt: new Date(), + acknowledgements: [ + { + nodeInstanceId: 'node-1', + nodeHostname: 'node-1', + endpointUrl: null, + status: 'succeeded', + responseStatus: 200, + errorMessage: null, + startedAt: new Date(), + ackedAt: new Date(), + details: { status: 'activated' }, + }, + { + nodeInstanceId: 'node-2', + nodeHostname: 'node-2', + endpointUrl: + 'http://node-2.internal:3000/certificates/artifacts/activate', + status: 'succeeded', + responseStatus: 200, + errorMessage: null, + startedAt: new Date(), + ackedAt: new Date(), + details: { status: 'activated' }, + }, + ], + } + : { + id: operationId, + status: 'partially_failed', + completedAt: new Date(), + acknowledgements: [ + { + nodeInstanceId: 'node-1', + nodeHostname: 'node-1', + endpointUrl: null, + status: 'succeeded', + responseStatus: 200, + errorMessage: null, + startedAt: new Date(), + ackedAt: new Date(), + details: { status: 'activated' }, + }, + { + nodeInstanceId: 'node-2', + nodeHostname: 'node-2', + endpointUrl: + 'http://node-2.internal:3000/certificates/artifacts/activate', + status: 'failed', + responseStatus: 500, + errorMessage: 'node-2 failed activation', + startedAt: new Date(), + ackedAt: new Date(), + details: { status: 'failed' }, + }, + ], + }; + clusterState.operations.push(operation); + return { operationId }; + }, + async waitForOperationToSettle(operationId) { + const operation = + clusterState.operations.find((entry) => entry.id === operationId) ?? null; + if (!operation) { + throw new Error(`Unknown operation: ${operationId}`); + } + return clone(operation); + }, + }; + const distributedLock = { + getInstanceId: () => 'node-1', + withLock: async (_name, fn) => fn(), + acquireLeaderLock: async () => false, + releaseLeaderLock: async () => undefined, + releaseAllLocks: async () => undefined, + isLeader: async () => false, + }; + const healthService = { + recordCertificateSyncFailure() {}, + recordCertificateSyncSuccess() {}, + }; + const service = new CertificateService( + prisma, + alertService, + orderService, + clusterOperations, + distributedLock, + healthService, + null, + undefined, + ); + + return { prisma, orderService, service, clusterState }; +} + +before(() => { + process.env.HEALTH_CONFIG_APPLY_MAX_AGE_MS = '60000'; + process.env.HEALTH_CERTIFICATE_SYNC_MAX_AGE_MS = '60000'; + process.env.ADMIN_EMAIL = process.env.ADMIN_EMAIL ?? 'chaos-suite@example.test'; +}); + +after(() => { + restoreProcessAndFs(); + restoreReloaderEnv(); + restoreExecFile(); + global.fetch = originalFetch; + + if (originalAdminEmail === undefined) { + delete process.env.ADMIN_EMAIL; + } else { + process.env.ADMIN_EMAIL = originalAdminEmail; + } + + if (originalApiKey === undefined) { + delete process.env.API_KEY; + } else { + process.env.API_KEY = originalApiKey; + } + + if (originalConfigMaxAge === undefined) { + delete process.env.HEALTH_CONFIG_APPLY_MAX_AGE_MS; + } else { + process.env.HEALTH_CONFIG_APPLY_MAX_AGE_MS = originalConfigMaxAge; + } + + if (originalCertSyncMaxAge === undefined) { + delete process.env.HEALTH_CERTIFICATE_SYNC_MAX_AGE_MS; + } else { + process.env.HEALTH_CERTIFICATE_SYNC_MAX_AGE_MS = originalCertSyncMaxAge; + } +}); + +afterEach(() => { + restoreProcessAndFs(); + restoreReloaderEnv(); + restoreExecFile(); + global.fetch = originalFetch; + resetCertificateModules(); + + if (originalApiKey === undefined) { + delete process.env.API_KEY; + } else { + process.env.API_KEY = originalApiKey; + } + + process.env.ADMIN_EMAIL = 'chaos-suite@example.test'; +}); + +beforeEach(() => { + process.env.ADMIN_EMAIL = 'chaos-suite@example.test'; +}); + +describe('chaos and fault-injection validation', () => { + it('fails readiness decisively during a simulated database outage while keeping other dependencies visible', async () => { + stubHealthyNginx(7272); + + const service = new HealthService({ + $queryRawUnsafe: async () => { + throw new Error('connect ECONNREFUSED postgres:5432'); + }, + }); + + service.recordConfigApplySuccess('release active'); + service.recordCertificateSyncSuccess('sync ok'); + + const readiness = await service.ready(); + + assert.equal(readiness.status, 'error'); + assert.equal( + readiness.checks.find((check) => check.name === 'database')?.status, + 'error', + ); + assert.match( + readiness.checks.find((check) => check.name === 'database')?.details ?? '', + /ECONNREFUSED postgres:5432/, + ); + assert.equal( + readiness.checks.find((check) => check.name === 'nginx_master')?.status, + 'ok', + ); + assert.equal( + readiness.checks.find((check) => check.name === 'config_apply')?.status, + 'ok', + ); + assert.equal( + readiness.checks.find((check) => check.name === 'certificate_sync')?.status, + 'ok', + ); + }); + + it('waits for a crashed leader lease to expire before electing a replacement and then recovers leadership', async () => { + const prisma = createClusterNodePrismaMock([ + createNode({ instanceId: 'node-a', hostname: 'node-a', isLeader: false }), + createNode({ + instanceId: 'node-b', + hostname: 'node-b', + status: 'stale', + isLeader: true, + lastHeartbeat: new Date(Date.now() - 2 * 60 * 1000), + }), + ]); + const distributedLock = createMutableDistributedLockMock({ + instanceId: 'node-a', + lease: createLease({ + ownerNodeId: 'node-b', + generation: 7, + expiresAt: new Date(Date.now() + 30_000), + }), + tryAcquire: true, + }); + const service = new ClusterHeartbeatService(prisma, distributedLock); + + await service.ensureLeaderExists(); + + assert.equal(distributedLock.calls.tryAcquireLeaderLock, 0); + assert.equal( + prisma.state.nodes.find((node) => node.instanceId === 'node-a')?.isLeader, + false, + ); + + const stalledStats = await service.getClusterStats(); + assert.deepEqual(stalledStats.leadershipIssues, ['LEASE_OWNER_NOT_ACTIVE']); + + distributedLock.setLease( + createLease({ + ownerNodeId: 'node-b', + generation: 7, + expiresAt: new Date(Date.now() - 1000), + }), + ); + + await service.ensureLeaderExists(); + + const recoveredLeader = await service.getLeaderNode(); + const recoveredStats = await service.getClusterStats(); + + assert.equal(distributedLock.calls.tryAcquireLeaderLock, 1); + assert.equal(recoveredLeader?.instanceId, 'node-a'); + assert.equal( + prisma.state.nodes.find((node) => node.instanceId === 'node-a')?.isLeader, + true, + ); + assert.equal(recoveredStats.leaderCount, 1); + assert.deepEqual(recoveredStats.leadershipIssues, []); + assert.equal(recoveredStats.leaderLeaseOwnerNodeId, 'node-a'); + }); + + it('forces the container supervisor to exit non-zero when the NGINX master crashes', async () => { + const harness = await createEntrypointHarness({ + FAKE_NGINX_MODE: 'crash', + FAKE_NGINX_EXIT_CODE: '23', + FAKE_NGINX_CRASH_DELAY_SECONDS: '1', + }); + + try { + const result = await waitForResult(harness.resultPromise, 10000); + const log = await readLog(harness.logPath); + + assert.equal(result.code, 23); + assert.equal(result.signal, null); + assert.match(log, /nginx:start/); + assert.match(log, /node:start/); + assert.match(log, /nginx:crash/); + assert.match(log, /node:term/); + assert.match(result.stderr, /NGINX exited unexpectedly with code 23/); + assert.match(result.stdout, /Container supervision finished with exit code 23/); + } finally { + await harness.cleanup(); + } + }); + + it('rolls staged config activation back to the last-known-good release after an injected reload failure', async () => { + const harness = await createReloaderHarness(); + let reloadAttempts = 0; + + harness.service.execCommand = async (command, args) => { + if (command === 'nginx' && args[0] === '-t') { + return 'syntax ok'; + } + if (command === 'nginx' && args[0] === '-s' && args[1] === 'reload') { + reloadAttempts += 1; + if (reloadAttempts === 2) { + throw new Error('reload failed after activation'); + } + return 'reload ok'; + } + throw new Error(`Unexpected command: ${command} ${args.join(' ')}`); + }; + + try { + const result = await harness.service.reloadConfig(); + const currentReleasePath = await fsPromises.readlink( + path.join(harness.nginxEtcDir, 'runtime/current'), + ); + const lastKnownGoodPath = await fsPromises.readlink( + path.join(harness.nginxEtcDir, 'runtime/last-known-good'), + ); + const releaseNames = await fsPromises.readdir( + path.join(harness.nginxEtcDir, 'runtime/releases'), + ); + const rolledBackReleaseName = releaseNames.find( + (releaseName) => + releaseName !== 'bootstrap' && + releaseName !== path.basename(currentReleasePath), + ); + const rolledBackMetadata = JSON.parse( + await fsPromises.readFile( + path.join( + harness.nginxEtcDir, + 'runtime/releases', + rolledBackReleaseName, + 'lyttle-nginx-release.json', + ), + 'utf8', + ), + ); + + assert.equal(result.ok, false); + assert.equal(currentReleasePath, lastKnownGoodPath); + assert.equal(reloadAttempts, 3); + assert.equal(rolledBackMetadata.status, 'rolled_back'); + assert.equal( + rolledBackMetadata.rollback.rolledBackToReleaseId, + path.basename(currentReleasePath), + ); + assert.match(result.error ?? '', /rolled back to/); + assert.equal(harness.health.successes.length, 0); + assert.equal(harness.health.failures.length, 1); + } finally { + await harness.cleanup(); + } + }); + + it('records node-communication failures as partial cluster-operation failures with per-node ACK detail', async () => { + process.env.API_KEY = 'chaos-suite-peer-key'; + global.fetch = async () => { + throw new Error('connect ETIMEDOUT node-b.internal:3000'); + }; + + const prisma = createClusterOperationPrismaMock(); + const service = new ClusterOperationsService( + prisma, + { + async getActiveNodes() { + return [ + createOperationTarget({ instanceId: 'node-a', hostname: 'node-a' }), + createOperationTarget({ instanceId: 'node-b', hostname: 'node-b' }), + ]; + }, + }, + { + getInstanceId() { + return 'node-a'; + }, + }, + ); + + const accepted = await service.enqueueBroadcastOperation({ + operationType: 'cluster.reload', + remotePath: '/cluster/reload', + remoteQuery: { broadcast: 'false' }, + executionTimeoutMs: 50, + localAction: async () => ({ ok: true }), + initiatedBy: { + correlationId: 'corr-chaos-1', + requestPath: '/cluster/reload?broadcast=true', + }, + }); + + const operation = await waitForOperationToSettle(service, accepted.operationId); + const remoteAck = operation.acknowledgements.find( + (ack) => ack.nodeInstanceId === 'node-b', + ); + + assert.equal(operation.status, 'partially_failed'); + assert.equal(operation.successfulNodeCount, 1); + assert.equal(operation.failedNodeCount, 1); + assert.equal(remoteAck?.status, 'failed'); + assert.match(remoteAck?.errorMessage ?? '', /ETIMEDOUT node-b\.internal:3000/); + assert.match(remoteAck?.endpointUrl ?? '', /\/cluster\/reload\?broadcast=false&operationId=/); + }); + + it('keeps the previously active certificate artifact current when a new cluster activation partially fails', async () => { + let certificateSequence = 0; + installExecFileStub(async ({ command, args }) => { + if (command === 'nginx') { + return { stdout: '' }; + } + + assert.equal(command, 'openssl'); + + if (args[0] === 'genrsa') { + fs.writeFileSync( + args[2], + `-----BEGIN PRIVATE KEY-----\nkey-${certificateSequence + 1}\n-----END PRIVATE KEY-----\n`, + ); + return { stdout: '' }; + } + + if (args[0] === 'req') { + certificateSequence += 1; + const outIndex = args.indexOf('-out'); + fs.writeFileSync( + args[outIndex + 1], + `-----BEGIN CERTIFICATE-----\nversion-${certificateSequence}\n-----END CERTIFICATE-----\n`, + ); + return { stdout: '' }; + } + + if (args[0] === 'x509' && args.includes('-pubkey')) { + return { stdout: 'unit-test-public-key\n' }; + } + + if (args[0] === 'pkey') { + return { stdout: 'unit-test-public-key\n' }; + } + + if (args[0] === 'x509' && args.includes('-enddate')) { + return { stdout: 'notAfter=Jan 1 00:00:00 2035 GMT\n' }; + } + + if (args[0] === 'x509' && args.includes('-text')) { + return { + stdout: `Certificate Data\nX509v3 Subject Alternative Name:\n DNS:example.com\n`, + }; + } + + throw new Error(`Unexpected command: ${args.join(' ')}`); + }); + + const { prisma, orderService, service, clusterState } = createCertificateHarness(); + service.writeCertToFs = () => undefined; + + const initialCertificate = await service.generateSelfSignedCertificate(['example.com']); + + clusterState.mode = 'partially_failed'; + + await assert.rejects( + () => service.generateSelfSignedCertificate(['example.com']), + /node-2 failed activation/, + ); + + const failedOrders = await orderService.listOrders(); + const failedOrder = await orderService.getOrder(failedOrders.orders[0].id); + const currentArtifacts = prisma.state.artifacts.filter((artifact) => artifact.isCurrent); + const latestArtifact = prisma.state.artifacts.reduce((latest, artifact) => + !latest || artifact.version > latest.version ? artifact : latest, + null); + const certificateRecord = prisma.state.certificates[0]; + + assert.equal(initialCertificate.id, certificateRecord.id); + assert.equal(failedOrder.status, 'failed'); + assert.equal(currentArtifacts.length, 1); + assert.equal(currentArtifacts[0].version, 1); + assert.equal(latestArtifact.version, 2); + assert.equal(latestArtifact.distributionStatus, 'partially_failed'); + assert.equal(latestArtifact.isCurrent, false); + assert.match(certificateRecord.certPem, /version-1/); + assert.equal(certificateSequence, 2); + }); +}); + diff --git a/test/e2e/audit-logging.test.js b/test/e2e/audit-logging.test.js new file mode 100644 index 0000000..914b04c --- /dev/null +++ b/test/e2e/audit-logging.test.js @@ -0,0 +1,508 @@ +require('reflect-metadata'); + +const { after, before, describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const { createHmac } = require('node:crypto'); +const Module = require('node:module'); +const request = require('supertest'); +const { APP_GUARD, APP_INTERCEPTOR } = require('@nestjs/core'); +const { Test } = require('@nestjs/testing'); + +const { AuthService } = require('../../src/auth/auth.service'); +const { ApiKeyGuard } = require('../../src/auth/guards/api-key.guard'); +const { + AuthorizationGuard, +} = require('../../src/auth/guards/authorization.guard'); +const { AuditInterceptor } = require('../../src/audit/audit.interceptor'); +const { AuditController } = require('../../src/audit/audit.controller'); +const { AuditService } = require('../../src/audit/audit.service'); + +class CertificateService {} +class CertificateBackupService {} +class ClusterHeartbeatService {} +class ClusterOperationsService {} +class DistributedLockService {} +class PrismaService {} +class ReloaderService {} + +const originalLoad = Module._load; +const moduleStubs = new Map([ + ['./certificate.service', { CertificateService }], + ['./certificate-backup.service', { CertificateBackupService }], + ['./cluster-heartbeat.service', { ClusterHeartbeatService }], + ['./cluster-operations.service', { ClusterOperationsService }], + ['./distributed-lock.service', { DistributedLockService }], + ['../prisma/prisma.service', { PrismaService }], + ['./reloader/reloader.service', { ReloaderService }], + ['../reloader/reloader.service', { ReloaderService }], +]); + +Module._load = function loadWithStubs(requestName, parent, isMain) { + if (moduleStubs.has(requestName)) { + return moduleStubs.get(requestName); + } + + return originalLoad.call(this, requestName, parent, isMain); +}; + +const { + CertificateController, +} = require('../../src/certificate/certificate.controller'); +const { BackupController } = require('../../src/certificate/backup.controller'); +const { + ClusterController, +} = require('../../src/distributed-lock/cluster.controller'); + +Module._load = originalLoad; + +function signHs256Token(payload, secret) { + const header = { alg: 'HS256', typ: 'JWT' }; + const encodedHeader = Buffer.from(JSON.stringify(header)).toString( + 'base64url', + ); + const encodedPayload = Buffer.from(JSON.stringify(payload)).toString( + 'base64url', + ); + const signingInput = `${encodedHeader}.${encodedPayload}`; + const signature = createHmac('sha256', secret) + .update(signingInput) + .digest('base64url'); + return `${signingInput}.${signature}`; +} + +function buildAdminToken(role, subject = `${role}-user`) { + const now = Math.floor(Date.now() / 1000); + return signHs256Token( + { + sub: subject, + iss: 'lyttle-nginx.test', + aud: 'lyttle-nginx-admin', + iat: now, + nbf: now, + exp: now + 300, + actor_type: 'admin', + roles: [role], + scope: 'admin:full', + name: subject, + }, + 'audit-logging-super-secret', + ); +} + +function createAuditServiceMock() { + return { + events: [], + async recordEvent(event) { + this.events.push({ + id: `event-${this.events.length + 1}`, + occurredAt: new Date().toISOString(), + ...event, + }); + }, + async listEvents(options) { + return this.events + .slice() + .reverse() + .filter((event) => { + if (options.action && !event.action.includes(options.action)) { + return false; + } + if ( + options.actorSubject && + event.actor?.subject && + !event.actor.subject.includes(options.actorSubject) + ) { + return false; + } + if ( + options.correlationId && + event.correlationId !== options.correlationId + ) { + return false; + } + if (options.outcome && event.outcome !== options.outcome) { + return false; + } + return true; + }) + .slice(0, options.limit); + }, + }; +} + +function flushAuditWrites() { + return new Promise((resolve) => setImmediate(resolve)); +} + +describe('audit logging', () => { + const originalEnv = { + API_KEY: process.env.API_KEY, + AUTH_JWT_SECRET: process.env.AUTH_JWT_SECRET, + AUTH_JWT_ISSUER: process.env.AUTH_JWT_ISSUER, + AUTH_JWT_AUDIENCE: process.env.AUTH_JWT_AUDIENCE, + AUTH_DEFAULT_ADMIN_ROLES: process.env.AUTH_DEFAULT_ADMIN_ROLES, + AUTH_DEFAULT_ADMIN_SCOPES: process.env.AUTH_DEFAULT_ADMIN_SCOPES, + }; + + let app; + let httpServer; + let auditServiceMock; + + const certificateServiceMock = { + async listCertificates() { + return []; + }, + async getCertificateInfo(id) { + return { id, domains: ['example.com'] }; + }, + async uploadCertificate(dto) { + if (dto.domains.includes('broken.example.com')) { + throw new Error('certificate upload failed'); + } + + return { + id: 'cert-uploaded-1', + domains: dto.domains, + }; + }, + async generateSelfSignedCertificate(domains) { + return { id: 'cert-self-signed-1', domains }; + }, + async renewCertificateById(id) { + return { id, renewed: true }; + }, + async renewAllCertificates() { + return undefined; + }, + async deleteCertificate() { + return undefined; + }, + async validateDomainForCertificate(domain) { + return { domain, valid: true }; + }, + async checkAllCertificatesOcspSupport() { + return { supported: true }; + }, + async syncCertificates() { + return { synced: true }; + }, + }; + + const backupServiceMock = { + async createBackup() { + return { filename: 'backup.lyttlebackup' }; + }, + async listBackups() { + return []; + }, + async getBackupStream() { + return undefined; + }, + async verifyBackup(filename) { + return { verified: true, filename }; + }, + async restoreBackup() { + return { imported: 0, skipped: 0, errors: 0 }; + }, + async deleteBackup() { + return undefined; + }, + async importCertificates() { + return { imported: 0, skipped: 0, errors: 0 }; + }, + async exportCertificate(id) { + return { id, exported: true }; + }, + }; + + const clusterHeartbeatServiceMock = { + async getActiveNodes() { + return []; + }, + async getClusterStats() { + return { total: 0, active: 0, leaders: [] }; + }, + async getLeaderNode() { + return null; + }, + async manualCleanup() { + return { cleaned: 0 }; + }, + async manualEnforceLeader() { + return { enforced: true }; + }, + async ensureLeaderExists() { + return undefined; + }, + async tryBecomeLeader() { + return true; + }, + }; + + const distributedLockServiceMock = { + getInstanceId() { + return 'node-1'; + }, + getLeaderLockStatus() { + return { + isLeader: true, + instanceId: 'node-1', + heldForMs: 1000, + }; + }, + }; + + const clusterOperationsServiceMock = { + async enqueueBroadcastOperation() { + return { + operationId: 'operation-1', + status: 'pending', + scope: 'cluster', + operationType: 'cluster.reload', + targetNodeCount: 1, + completedNodeCount: 0, + successfulNodeCount: 0, + failedNodeCount: 0, + createdAt: new Date('2026-05-24T00:00:00Z').toISOString(), + startedAt: null, + completedAt: null, + correlationId: null, + requestPath: '/cluster/reload', + operationStatusPath: '/cluster/operations/operation-1', + }; + }, + async listOperations() { + return { count: 0, operations: [] }; + }, + async getOperation() { + return null; + }, + }; + + const reloaderServiceMock = { + async reloadConfig() { + return { ok: true }; + }, + async getRuntimeReleaseStatus() { + return { + currentReleaseId: 'release-1', + currentRelease: { status: 'active' }, + lastKnownGoodReleaseId: 'release-1', + lastKnownGoodRelease: { status: 'active' }, + }; + }, + }; + + const prismaServiceMock = { + clusterNode: { + async findMany() { + return []; + }, + async findFirst() { + return null; + }, + }, + certificate: { + async count() { + return 0; + }, + async findMany() { + return []; + }, + }, + }; + + before(async () => { + process.env.API_KEY = 'audit-logging-legacy-key'; + process.env.AUTH_JWT_SECRET = 'audit-logging-super-secret'; + process.env.AUTH_JWT_ISSUER = 'lyttle-nginx.test'; + process.env.AUTH_JWT_AUDIENCE = 'lyttle-nginx-admin'; + process.env.AUTH_DEFAULT_ADMIN_ROLES = 'platform-admin'; + process.env.AUTH_DEFAULT_ADMIN_SCOPES = 'admin:full'; + + auditServiceMock = createAuditServiceMock(); + + const moduleRef = await Test.createTestingModule({ + controllers: [ + AuditController, + BackupController, + CertificateController, + ClusterController, + ], + providers: [ + AuthService, + { + provide: APP_GUARD, + useClass: ApiKeyGuard, + }, + { + provide: APP_GUARD, + useClass: AuthorizationGuard, + }, + { + provide: APP_INTERCEPTOR, + useClass: AuditInterceptor, + }, + { + provide: AuditService, + useValue: auditServiceMock, + }, + { + provide: CertificateService, + useValue: certificateServiceMock, + }, + { + provide: CertificateBackupService, + useValue: backupServiceMock, + }, + { + provide: ClusterHeartbeatService, + useValue: clusterHeartbeatServiceMock, + }, + { + provide: ClusterOperationsService, + useValue: clusterOperationsServiceMock, + }, + { + provide: DistributedLockService, + useValue: distributedLockServiceMock, + }, + { + provide: ReloaderService, + useValue: reloaderServiceMock, + }, + { + provide: PrismaService, + useValue: prismaServiceMock, + }, + ], + }).compile(); + + app = moduleRef.createNestApplication(); + await app.init(); + httpServer = app.getHttpServer(); + }); + + after(async () => { + await app?.close(); + + for (const [key, value] of Object.entries(originalEnv)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + }); + + it('records successful mutating certificate actions with actor, target, and correlation id', async () => { + const securityAdminToken = buildAdminToken('security-admin'); + + const response = await request(httpServer) + .post('/certificates/upload') + .set('Authorization', `Bearer ${securityAdminToken}`) + .send({ + domains: ['example.com', 'www.example.com'], + certPem: '-----BEGIN CERTIFICATE-----\nMIIB\n', + keyPem: '-----BEGIN PRIVATE KEY-----\nMIIB\n', + }) + .expect(201); + + await flushAuditWrites(); + + const uploadEvent = auditServiceMock.events.at(-1); + assert.equal(uploadEvent.action, 'certificate.upload'); + assert.equal(uploadEvent.outcome, 'success'); + assert.equal(uploadEvent.responseStatus, 201); + assert.equal(uploadEvent.actor.subject, 'security-admin-user'); + assert.equal(uploadEvent.target.type, 'certificate'); + assert.equal(uploadEvent.target.id, 'cert-uploaded-1'); + assert.equal(uploadEvent.target.label, 'example.com,www.example.com'); + assert.match(uploadEvent.correlationId, /.+/); + assert.equal( + response.headers['x-correlation-id'], + uploadEvent.correlationId, + ); + }); + + it('records controller-level failures for protected mutating routes', async () => { + const securityAdminToken = buildAdminToken( + 'security-admin', + 'security-admin-b', + ); + + await request(httpServer) + .post('/certificates/upload') + .set('Authorization', `Bearer ${securityAdminToken}`) + .send({ + domains: ['broken.example.com'], + certPem: '-----BEGIN CERTIFICATE-----\nMIIB\n', + keyPem: '-----BEGIN PRIVATE KEY-----\nMIIB\n', + }) + .expect(400); + + await flushAuditWrites(); + + const failureEvent = auditServiceMock.events.at(-1); + assert.equal(failureEvent.action, 'certificate.upload'); + assert.equal(failureEvent.outcome, 'failure'); + assert.equal(failureEvent.responseStatus, 400); + assert.match(failureEvent.errorMessage, /failed/i); + assert.equal(failureEvent.actor.subject, 'security-admin-b'); + }); + + it('records denied privileged attempts before controller execution', async () => { + const operatorToken = buildAdminToken('operator'); + + await request(httpServer) + .get('/certificates/backup/export/cert-1') + .set('Authorization', `Bearer ${operatorToken}`) + .expect(403); + + await flushAuditWrites(); + + const deniedEvent = auditServiceMock.events.at(-1); + assert.equal(deniedEvent.action, 'certificate.export'); + assert.equal(deniedEvent.outcome, 'denied'); + assert.equal(deniedEvent.responseStatus, 403); + assert.equal(deniedEvent.actor.subject, 'operator-user'); + assert.match(deniedEvent.errorMessage, /insufficient permissions/i); + }); + + it('records unauthenticated protected mutations as denied audit events', async () => { + await request(httpServer) + .post('/certificates/upload') + .send({ + domains: ['unauth.example.com'], + certPem: '-----BEGIN CERTIFICATE-----\nMIIB\n', + keyPem: '-----BEGIN PRIVATE KEY-----\nMIIB\n', + }) + .expect(401); + + await flushAuditWrites(); + + const deniedEvent = auditServiceMock.events.at(-1); + assert.equal(deniedEvent.action, 'certificate.upload'); + assert.equal(deniedEvent.outcome, 'denied'); + assert.equal(deniedEvent.responseStatus, 401); + assert.equal(deniedEvent.actor, undefined); + assert.match(deniedEvent.errorMessage, /credentials are required/i); + }); + + it('exposes recent audit events through the security-admin review endpoint', async () => { + const securityAdminToken = buildAdminToken( + 'security-admin', + 'auditor-user', + ); + + const response = await request(httpServer) + .get('/audit?limit=2&action=certificate.upload') + .set('Authorization', `Bearer ${securityAdminToken}`) + .expect(200); + + assert.equal(response.body.count, 2); + assert.equal(response.body.events.length, 2); + assert.equal(response.body.events[0].action, 'certificate.upload'); + assert.equal(response.body.events[1].action, 'certificate.upload'); + }); +}); diff --git a/test/e2e/auth-foundation.test.js b/test/e2e/auth-foundation.test.js new file mode 100644 index 0000000..a56319c --- /dev/null +++ b/test/e2e/auth-foundation.test.js @@ -0,0 +1,187 @@ +require('reflect-metadata'); + +const { after, before, describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const { createHmac } = require('node:crypto'); +const request = require('supertest'); +const { APP_GUARD } = require('@nestjs/core'); +const { Test } = require('@nestjs/testing'); + +const { AuthController } = require('../../src/auth/auth.controller'); +const { AuthService } = require('../../src/auth/auth.service'); +const { ApiKeyGuard } = require('../../src/auth/guards/api-key.guard'); +const { + AuthorizationGuard, +} = require('../../src/auth/guards/authorization.guard'); + +function signHs256Token(payload, secret) { + const header = { alg: 'HS256', typ: 'JWT' }; + const encodedHeader = Buffer.from(JSON.stringify(header)).toString('base64url'); + const encodedPayload = Buffer.from(JSON.stringify(payload)).toString('base64url'); + const signingInput = `${encodedHeader}.${encodedPayload}`; + const signature = createHmac('sha256', secret) + .update(signingInput) + .digest('base64url'); + return `${signingInput}.${signature}`; +} + +describe('auth foundation', () => { + const originalEnv = { + API_KEY: process.env.API_KEY, + AUTH_JWT_SECRET: process.env.AUTH_JWT_SECRET, + AUTH_JWT_ISSUER: process.env.AUTH_JWT_ISSUER, + AUTH_JWT_AUDIENCE: process.env.AUTH_JWT_AUDIENCE, + AUTH_DEFAULT_ADMIN_ROLES: process.env.AUTH_DEFAULT_ADMIN_ROLES, + AUTH_DEFAULT_ADMIN_SCOPES: process.env.AUTH_DEFAULT_ADMIN_SCOPES, + AUTH_ACCESS_TOKEN_TTL_SECONDS: process.env.AUTH_ACCESS_TOKEN_TTL_SECONDS, + }; + + let app; + let httpServer; + + before(async () => { + process.env.API_KEY = 'auth-foundation-legacy-key'; + process.env.AUTH_JWT_SECRET = 'auth-foundation-super-secret'; + process.env.AUTH_JWT_ISSUER = 'lyttle-nginx.test'; + process.env.AUTH_JWT_AUDIENCE = 'lyttle-nginx-admin'; + process.env.AUTH_DEFAULT_ADMIN_ROLES = 'platform-admin,operator'; + process.env.AUTH_DEFAULT_ADMIN_SCOPES = 'admin:full,cluster:read'; + process.env.AUTH_ACCESS_TOKEN_TTL_SECONDS = '600'; + + const moduleRef = await Test.createTestingModule({ + controllers: [AuthController], + providers: [ + AuthService, + { + provide: APP_GUARD, + useClass: ApiKeyGuard, + }, + { + provide: APP_GUARD, + useClass: AuthorizationGuard, + }, + ], + }).compile(); + + app = moduleRef.createNestApplication(); + await app.init(); + httpServer = app.getHttpServer(); + }); + + after(async () => { + await app?.close(); + + for (const [key, value] of Object.entries(originalEnv)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + }); + + it('attaches a structured admin identity for legacy API-key requests', async () => { + await request(httpServer) + .get('/auth/me') + .set('X-API-Key', 'auth-foundation-legacy-key') + .expect(200) + .expect(({ body }) => { + assert.equal(body.authenticated, true); + assert.equal(body.identity.authMethod, 'api-key'); + assert.equal(body.identity.actorType, 'admin'); + assert.equal(body.identity.displayName, 'legacy-api-key-1'); + assert.deepEqual(body.identity.roles, ['platform-admin', 'operator']); + assert.deepEqual(body.identity.scopes, ['admin:full', 'cluster:read']); + assert.match(body.identity.apiKeyId, /^1-[a-f0-9]{12}$/); + }); + }); + + it('exposes auth capabilities and supports API-key to bearer token exchange', async () => { + const tokenResponse = await request(httpServer) + .get('/auth/info') + .set('X-API-Key', 'auth-foundation-legacy-key') + .expect(200) + .expect(({ body }) => { + assert.equal(body.authEnabled, true); + assert.deepEqual(body.methods, ['api-key', 'bearer-token']); + assert.equal(body.tokenExchangeEnabled, true); + assert.deepEqual(body.supportedBearerAlgorithms, ['HS256']); + }); + + assert.ok(tokenResponse.body); + + const exchange = await request(httpServer) + .post('/auth/token') + .set('X-API-Key', 'auth-foundation-legacy-key') + .expect(200); + + assert.equal(exchange.body.tokenType, 'Bearer'); + assert.equal(exchange.body.actor.actorType, 'admin'); + assert.deepEqual(exchange.body.actor.roles, ['platform-admin', 'operator']); + + await request(httpServer) + .get('/auth/status') + .set('Authorization', `Bearer ${exchange.body.accessToken}`) + .expect(200) + .expect(({ body }) => { + assert.equal(body.authenticated, true); + assert.equal(body.identity.authMethod, 'bearer-token'); + assert.equal(body.identity.actorType, 'admin'); + assert.equal(body.identity.issuer, 'lyttle-nginx.test'); + assert.deepEqual(body.identity.audience, ['lyttle-nginx-admin']); + }); + }); + + it('accepts internal-node bearer identities and rejects expired bearer tokens', async () => { + const now = Math.floor(Date.now() / 1000); + const internalNodeToken = signHs256Token( + { + sub: 'node-a', + iss: 'lyttle-nginx.test', + aud: 'lyttle-nginx-admin', + iat: now, + nbf: now, + exp: now + 300, + actor_type: 'internal-node', + node_id: 'node-a', + scope: 'cluster:internal node:sync', + roles: ['internal-node'], + name: 'swarm-node-a', + }, + 'auth-foundation-super-secret', + ); + + await request(httpServer) + .get('/auth/me') + .set('Authorization', `Bearer ${internalNodeToken}`) + .expect(200) + .expect(({ body }) => { + assert.equal(body.identity.authMethod, 'bearer-token'); + assert.equal(body.identity.actorType, 'internal-node'); + assert.equal(body.identity.nodeId, 'node-a'); + assert.deepEqual(body.identity.roles, ['internal-node']); + assert.deepEqual(body.identity.scopes, ['cluster:internal', 'node:sync']); + }); + + const expiredToken = signHs256Token( + { + sub: 'operator-1', + iss: 'lyttle-nginx.test', + aud: 'lyttle-nginx-admin', + iat: now - 120, + nbf: now - 120, + exp: now - 60, + actor_type: 'admin', + }, + 'auth-foundation-super-secret', + ); + + await request(httpServer) + .get('/auth/me') + .set('Authorization', `Bearer ${expiredToken}`) + .expect(401) + .expect(({ body }) => { + assert.match(body.message, /expired/i); + }); + }); +}); diff --git a/test/e2e/auth-lockdown.test.js b/test/e2e/auth-lockdown.test.js new file mode 100644 index 0000000..9635e6a --- /dev/null +++ b/test/e2e/auth-lockdown.test.js @@ -0,0 +1,450 @@ +require('reflect-metadata'); + +const { after, before, describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const Module = require('node:module'); +const request = require('supertest'); +const { ValidationPipe } = require('@nestjs/common'); +const { APP_GUARD } = require('@nestjs/core'); +const { Test } = require('@nestjs/testing'); + +const { AuthController } = require('../../src/auth/auth.controller'); +const { AuthService } = require('../../src/auth/auth.service'); +const { ApiKeyGuard } = require('../../src/auth/guards/api-key.guard'); +const { + AuthorizationGuard, +} = require('../../src/auth/guards/authorization.guard'); + +class CertificateService {} +class AcmeService {} +class TlsConfigService {} +class CertificateBackupService {} +class HealthService {} +class MetricsService {} +class PrismaService {} +class ReloaderService {} + +const originalLoad = Module._load; +const moduleStubs = new Map([ + ['./certificate.service', { CertificateService }], + ['./acme.service', { AcmeService }], + ['./certificate-backup.service', { CertificateBackupService }], + ['./tls-config.service', { TlsConfigService }], + ['./health.service', { HealthService }], + ['./metrics.service', { MetricsService }], + ['../prisma/prisma.service', { PrismaService }], + ['./reloader/reloader.service', { ReloaderService }], +]); + +Module._load = function loadWithStubs(request, parent, isMain) { + if (moduleStubs.has(request)) { + return moduleStubs.get(request); + } + + return originalLoad.call(this, request, parent, isMain); +}; + +const { AppController } = require('../../src/app.controller'); +const { AcmeController } = require('../../src/certificate/acme.controller'); +const { BackupController } = require('../../src/certificate/backup.controller'); +const { CertificateController } = require('../../src/certificate/certificate.controller'); +const { TlsController } = require('../../src/certificate/tls.controller'); +const { HealthController } = require('../../src/health/health.controller'); +const { MetricsController } = require('../../src/metrics/metrics.controller'); + +Module._load = originalLoad; + +const testApiKey = 'auth-lockdown-test-key'; +const originalApiKey = process.env.API_KEY; +const originalNodeEnv = process.env.NODE_ENV; + +const certificateServiceMock = { + listCertificates: async () => [], + getCertificateInfo: async (id) => ({ id, domains: ['example.com'] }), + uploadCertificate: async (dto) => ({ + id: 'cert-1', + domains: dto.domains, + }), + generateSelfSignedCertificate: async (domains) => ({ + id: 'cert-2', + domains, + }), + renewCertificateById: async (id) => ({ id, renewed: true }), + renewAllCertificates: async () => undefined, + deleteCertificate: async () => undefined, + validateDomainForCertificate: async (domain) => ({ domain, valid: true }), + checkAllCertificatesOcspSupport: async () => ({ supported: true }), + syncCertificates: async () => ({ synced: true }), +}; + +const acmeServiceMock = { + getPresentedHttpChallenge: async () => ({ status: 'missing' }), + markChallengeServed: async () => undefined, +}; + +const tlsConfigServiceMock = { + getRecommendedTlsConfig: async (domain) => ({ domain, config: 'recommended' }), + testTlsConnection: async (domain) => ({ domain, ok: true }), + generateDhParams: async () => undefined, + dhParamsExist: () => false, + getCertificateInfo: async () => ({ subject: 'CN=example.com' }), + validateCertificateChain: async () => ({ valid: true }), +}; + +const backupServiceMock = { + createBackup: async () => ({ filename: 'backup.zip' }), + listBackups: async () => [], + getBackupStream: async () => undefined, + deleteBackup: async () => undefined, + importCertificates: async () => ({ imported: 0 }), + exportCertificate: async (id) => ({ id }), +}; + +const reloaderServiceMock = { + reloadConfig: async () => ({ ok: true }), +}; + +const healthServiceMock = { + live: async () => ({ status: 'ok' }), + ready: async () => ({ status: 'ok' }), +}; + +const metricsServiceMock = { + getAllMetrics: async () => ({ + timestamp: new Date().toISOString(), + collection: { + sections: { + certificates: 'ok', + proxies: 'ok', + health: 'ok', + leases: 'ok', + clusterOperations: 'ok', + certificateOrders: 'ok', + backups: 'ok', + }, + errors: [], + }, + certificates: { + total: 0, + valid: 0, + expiringSoon: 0, + expired: 0, + avgDaysUntilExpiry: 0, + oldestExpiry: null, + newestExpiry: null, + }, + proxies: { + total: 0, + withSsl: 0, + withoutSsl: 0, + proxies: 0, + redirects: 0, + }, + health: { + status: 'ok', + summary: { total: 0, ok: 0, error: 0 }, + thresholds: { + configApplyMaxAgeMs: 0, + certificateSyncMaxAgeMs: 0, + }, + checks: [], + database: null, + nginxMaster: null, + operations: { + configApply: { + status: 'ok', + ageMs: 0, + maxAgeMs: 0, + lastSuccessAt: null, + lastAttemptAt: null, + lastError: null, + }, + certificateSync: { + status: 'ok', + ageMs: 0, + maxAgeMs: 0, + lastSuccessAt: null, + lastAttemptAt: null, + lastError: null, + }, + }, + }, + leases: { + total: 0, + active: 0, + expired: 0, + leader: { + present: false, + ownerNodeId: null, + ownerHostname: null, + generation: 0, + ttlSeconds: 0, + secondsRemaining: 0, + isExpired: true, + }, + }, + clusterOperations: { + total: 0, + byStatus: {}, + byType: {}, + active: { + total: 0, + stale: 0, + oldestAgeSeconds: 0, + staleThresholdMs: 0, + }, + recentFailures: { + total: 0, + windowMs: 0, + }, + acknowledgements: { + total: 0, + byStatus: {}, + }, + }, + certificateOrders: { + total: 0, + active: 0, + stale: 0, + staleThresholdMs: 0, + oldestActiveAgeSeconds: 0, + retryScheduled: 0, + retryDue: 0, + byStatus: {}, + }, + backups: { + total: 0, + totalSizeBytes: 0, + latest: null, + maxAgeMs: 0, + freshnessStatus: 0, + }, + }), + formatPrometheusMetrics: () => 'lyttle_certificates_total 0', +}; + +const prismaServiceMock = { + acmeChallenge: { + findUnique: async () => null, + delete: async () => undefined, + }, +}; + +describe('endpoint lockdown', () => { + let app; + let httpServer; + + before(async () => { + process.env.API_KEY = testApiKey; + process.env.NODE_ENV = 'test'; + + const moduleRef = await Test.createTestingModule({ + controllers: [ + AppController, + AuthController, + AcmeController, + BackupController, + CertificateController, + TlsController, + HealthController, + MetricsController, + ], + providers: [ + AuthService, + { + provide: APP_GUARD, + useClass: ApiKeyGuard, + }, + { + provide: APP_GUARD, + useClass: AuthorizationGuard, + }, + { + provide: CertificateService, + useValue: certificateServiceMock, + }, + { + provide: AcmeService, + useValue: acmeServiceMock, + }, + { + provide: TlsConfigService, + useValue: tlsConfigServiceMock, + }, + { + provide: CertificateBackupService, + useValue: backupServiceMock, + }, + { + provide: ReloaderService, + useValue: reloaderServiceMock, + }, + { + provide: HealthService, + useValue: healthServiceMock, + }, + { + provide: MetricsService, + useValue: metricsServiceMock, + }, + { + provide: PrismaService, + useValue: prismaServiceMock, + }, + ], + }).compile(); + + app = moduleRef.createNestApplication(); + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + transformOptions: { + enableImplicitConversion: true, + }, + }), + ); + + await app.init(); + httpServer = app.getHttpServer(); + }); + + after(async () => { + await app?.close(); + + if (originalApiKey === undefined) { + delete process.env.API_KEY; + } else { + process.env.API_KEY = originalApiKey; + } + + if (originalNodeEnv === undefined) { + delete process.env.NODE_ENV; + } else { + process.env.NODE_ENV = originalNodeEnv; + } + }); + + it('rejects unauthenticated mutating endpoints with 401', async () => { + const cases = [ + { + method: 'post', + url: '/certificates/upload', + body: { + domains: ['example.com'], + certPem: '-----BEGIN CERTIFICATE-----\nMIIB\n', + keyPem: '-----BEGIN PRIVATE KEY-----\nMIIB\n', + }, + }, + { + method: 'post', + url: '/certificates/generate-self-signed', + body: { domains: ['example.com'] }, + }, + { + method: 'post', + url: '/certificates/sync', + }, + { + method: 'post', + url: '/tls/dhparam', + body: { bits: 1024 }, + }, + { + method: 'post', + url: '/reload', + }, + ]; + + for (const testCase of cases) { + let req = request(httpServer)[testCase.method](testCase.url); + if (testCase.body) { + req = req.send(testCase.body); + } + + await req.expect(401); + } + }); + + it('keeps the explicit public allowlist reachable without authentication', async () => { + await request(httpServer) + .get('/health') + .expect(200) + .expect(({ body }) => assert.equal(body.status, 'ok')); + + await request(httpServer) + .get('/ready') + .expect(200) + .expect(({ body }) => assert.equal(body.status, 'ok')); + + await request(httpServer) + .get('/metrics') + .expect(200) + .expect('Content-Type', /text\/plain/) + .expect((response) => { + assert.match(response.text, /lyttle_certificates_total 0/); + }); + + await request(httpServer) + .get('/metrics/json') + .expect(200) + .expect(({ body }) => assert.equal(body.certificates.total, 0)); + + await request(httpServer) + .get('/.well-known/acme-challenge/test-token') + .expect(404); + }); + + it('protects non-public admin reads by default', async () => { + await request(httpServer).get('/certificates').expect(401); + await request(httpServer).get('/auth/info').expect(401); + }); + + it('rejects invalid API keys on protected endpoints', async () => { + await request(httpServer) + .post('/certificates/sync') + .set('X-API-Key', 'wrong-key') + .expect(401); + + await request(httpServer) + .get('/certificates') + .set('Authorization', 'ApiKey wrong-key') + .expect(401); + }); + + it('allows authenticated admin requests to reach their handlers', async () => { + await request(httpServer) + .get('/certificates') + .set('X-API-Key', testApiKey) + .expect(200) + .expect(({ body }) => assert.deepEqual(body, [])); + + await request(httpServer) + .get('/auth/status') + .set('X-API-Key', testApiKey) + .expect(200) + .expect(({ body }) => assert.equal(body.authenticated, true)); + + await request(httpServer) + .post('/certificates/upload') + .set('Authorization', `ApiKey ${testApiKey}`) + .send({ + domains: ['example.com'], + certPem: '-----BEGIN CERTIFICATE-----\nMIIB\n', + keyPem: '-----BEGIN PRIVATE KEY-----\nMIIB\n', + }) + .expect(201) + .expect(({ body }) => assert.deepEqual(body.domains, ['example.com'])); + + await request(httpServer) + .post('/tls/dhparam') + .set('X-API-Key', testApiKey) + .send({ bits: 1024 }) + .expect(202) + .expect(({ body }) => { + assert.match(body.message, /started in background/i); + }); + }); +}); + diff --git a/test/e2e/cluster-admin-apis.test.js b/test/e2e/cluster-admin-apis.test.js new file mode 100644 index 0000000..a1a5a91 --- /dev/null +++ b/test/e2e/cluster-admin-apis.test.js @@ -0,0 +1,578 @@ +require('reflect-metadata'); + +process.env.ADMIN_EMAIL ??= 'cluster-admin@example.com'; + +const { after, before, describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const { createHmac } = require('node:crypto'); +const request = require('supertest'); +const { APP_GUARD } = require('@nestjs/core'); +const { Test } = require('@nestjs/testing'); + +const { AuthService } = require('../../src/auth/auth.service'); +const { ApiKeyGuard } = require('../../src/auth/guards/api-key.guard'); +const { + AuthorizationGuard, +} = require('../../src/auth/guards/authorization.guard'); +const { + ClusterController, +} = require('../../src/distributed-lock/cluster.controller'); +const { + ClusterHeartbeatService, +} = require('../../src/distributed-lock/cluster-heartbeat.service'); +const { + ClusterOperationsService, +} = require('../../src/distributed-lock/cluster-operations.service'); +const { + DistributedLockService, +} = require('../../src/distributed-lock/distributed-lock.service'); +const { PrismaService } = require('../../src/prisma/prisma.service'); +const { ReloaderService } = require('../../src/reloader/reloader.service'); + +function signHs256Token(payload, secret) { + const header = { alg: 'HS256', typ: 'JWT' }; + const encodedHeader = Buffer.from(JSON.stringify(header)).toString('base64url'); + const encodedPayload = Buffer.from(JSON.stringify(payload)).toString('base64url'); + const signingInput = `${encodedHeader}.${encodedPayload}`; + const signature = createHmac('sha256', secret) + .update(signingInput) + .digest('base64url'); + return `${signingInput}.${signature}`; +} + +function buildAdminToken(role) { + const now = Math.floor(Date.now() / 1000); + return signHs256Token( + { + sub: `${role}-user`, + iss: 'lyttle-nginx.test', + aud: 'lyttle-nginx-admin', + iat: now, + nbf: now, + exp: now + 300, + actor_type: 'admin', + roles: [role], + scope: 'admin:full cluster:read', + name: `${role}-user`, + }, + 'cluster-admin-super-secret', + ); +} + +function createNode(overrides = {}) { + const now = new Date('2026-05-26T12:00:00Z'); + return { + id: overrides.id ?? `node-record-${overrides.instanceId ?? 'node-a'}`, + hostname: overrides.hostname ?? overrides.instanceId ?? 'node-a', + instanceId: overrides.instanceId ?? 'node-a', + ipAddress: overrides.ipAddress ?? '10.0.0.10', + isLeader: overrides.isLeader ?? false, + lastHeartbeat: overrides.lastHeartbeat ?? now, + version: overrides.version ?? '0.0.1', + status: overrides.status ?? 'active', + metadata: + overrides.metadata ?? + { + controlPlane: { + baseUrl: `http://${overrides.hostname ?? overrides.instanceId ?? 'node-a'}.internal:3000`, + address: `${overrides.hostname ?? overrides.instanceId ?? 'node-a'}.internal`, + port: 3000, + protocol: 'http', + }, + }, + }; +} + +function createOperation({ + operationId, + operationType, + status, + nodeInstanceId, + nodeHostname, + ackStatus, + ackedAt, + details, + createdAt, +}) { + return { + operationId, + operationType, + scope: 'cluster', + status, + targetNodeCount: 1, + completedNodeCount: ackStatus === 'pending' ? 0 : 1, + successfulNodeCount: ackStatus === 'succeeded' ? 1 : 0, + failedNodeCount: ackStatus === 'failed' ? 1 : 0, + initiatorNodeId: 'node-a', + initiatorHostname: 'node-a', + correlationId: `corr-${operationId}`, + requestPath: `/${operationType.replace('.', '/')}`, + createdAt, + startedAt: createdAt, + completedAt: ackStatus === 'pending' ? null : ackedAt, + lastError: ackStatus === 'failed' ? 'operation failed' : null, + operationStatusPath: `/cluster/operations/${operationId}`, + links: { + self: `/cluster/operations/${operationId}`, + }, + statusSummary: { + pendingNodeCount: ackStatus === 'pending' ? 1 : 0, + completionRatio: ackStatus === 'pending' ? 0 : 1, + isTerminal: ackStatus !== 'pending', + isSuccessful: ackStatus === 'succeeded', + }, + nodeAcknowledgement: { + nodeInstanceId, + nodeHostname, + endpointUrl: `http://${nodeHostname}.internal:3000/internal`, + status: ackStatus, + responseStatus: ackStatus === 'failed' ? 500 : 200, + errorMessage: ackStatus === 'failed' ? 'operation failed' : null, + startedAt: createdAt, + ackedAt, + details: details ?? null, + }, + }; +} + +describe('cluster operations and node-status admin APIs', () => { + const originalEnv = { + API_KEY: process.env.API_KEY, + AUTH_JWT_SECRET: process.env.AUTH_JWT_SECRET, + AUTH_JWT_ISSUER: process.env.AUTH_JWT_ISSUER, + AUTH_JWT_AUDIENCE: process.env.AUTH_JWT_AUDIENCE, + }; + + let app; + let httpServer; + let nodes; + let operations; + let certificates; + + const prismaMock = { + clusterNode: { + async findMany() { + return [...nodes]; + }, + async findFirst({ where }) { + const candidates = where?.OR ?? []; + return ( + nodes.find((node) => + candidates.some( + (candidate) => + candidate.id === node.id || + candidate.instanceId === node.instanceId || + candidate.hostname === node.hostname, + ), + ) ?? null + ); + }, + }, + certificate: { + async count() { + return certificates.length; + }, + async findMany() { + return [...certificates].sort( + (left, right) => left.expiresAt.getTime() - right.expiresAt.getTime(), + ); + }, + }, + }; + + const clusterHeartbeatMock = { + async getActiveNodes() { + return nodes.filter((node) => node.status === 'active'); + }, + async getClusterStats() { + const active = nodes.filter((node) => node.status === 'active').length; + const stale = nodes.filter((node) => node.status === 'stale').length; + const inactive = nodes.filter((node) => node.status === 'inactive').length; + return { + total: nodes.length, + active, + stale, + inactive, + leaders: nodes.filter((node) => node.isLeader), + leaderCount: nodes.filter((node) => node.isLeader).length, + hasMultipleLeaders: false, + leadershipIssues: [], + leaderLeaseGeneration: 7, + leaderLeaseOwnerNodeId: 'node-a', + }; + }, + async getLeaderNode() { + return nodes.find((node) => node.instanceId === 'node-a') ?? null; + }, + async getLeaderLeaseState() { + const ownerNode = nodes.find((node) => node.instanceId === 'node-a') ?? null; + return { + lease: { + leaseName: 'cluster:leader', + ownerNodeId: 'node-a', + ownerHostname: 'node-a', + generation: 7, + fencingToken: 7, + ttlSeconds: 30, + acquiredAt: new Date('2026-05-26T11:58:00Z'), + renewedAt: new Date('2026-05-26T11:59:30Z'), + expiresAt: new Date('2026-05-26T12:00:00Z'), + isExpired: false, + isHeldByThisInstance: true, + }, + hasActiveLease: true, + ownerNode, + activeLeaderNode: ownerNode, + issues: [], + }; + }, + async manualCleanup() { + return { success: true }; + }, + async manualEnforceLeader() { + return { success: true }; + }, + async ensureLeaderExists() { + return undefined; + }, + async tryBecomeLeader() { + return true; + }, + }; + + const clusterOperationsMock = { + async listOperations(options = {}) { + const normalized = typeof options === 'number' ? { limit: options } : options; + const operationTypes = [ + ...(normalized.operationTypes ?? []), + ...(normalized.operationType ? [normalized.operationType] : []), + ].filter(Boolean); + + let filtered = [...operations]; + + if (normalized.status) { + filtered = filtered.filter((operation) => operation.status === normalized.status); + } + if (operationTypes.length > 0) { + filtered = filtered.filter((operation) => + operationTypes.includes(operation.operationType), + ); + } + if (normalized.targetNodeId) { + filtered = filtered.filter( + (operation) => + operation.nodeAcknowledgement?.nodeInstanceId === normalized.targetNodeId, + ); + } + + const limit = normalized.limit ?? 20; + filtered = filtered.slice(0, limit); + + return { + count: filtered.length, + filters: { + limit, + status: normalized.status ?? null, + operationTypes, + targetNodeId: normalized.targetNodeId ?? null, + }, + operations: filtered, + }; + }, + async getOperation(operationId) { + return operations.find((operation) => operation.operationId === operationId) ?? null; + }, + async enqueueBroadcastOperation() { + throw new Error('not used in cluster admin API tests'); + }, + }; + + const distributedLockMock = { + getInstanceId() { + return 'node-a'; + }, + getLeaderLockStatus() { + return { + isLeader: true, + heldForMs: 1000, + instanceId: 'node-a', + ownerNodeId: 'node-a', + generation: 7, + fencingToken: 7, + expiresAt: new Date('2026-05-26T12:00:00Z'), + }; + }, + async getLeaderLeaseSnapshot() { + return { + leaseName: 'cluster:leader', + ownerNodeId: 'node-a', + ownerHostname: 'node-a', + generation: 7, + ttlSeconds: 30, + acquiredAt: new Date('2026-05-26T11:58:00Z'), + renewedAt: new Date('2026-05-26T11:59:30Z'), + expiresAt: new Date('2026-05-26T12:00:00Z'), + isExpired: false, + isHeldByThisInstance: true, + fencingToken: 7, + }; + }, + }; + + const reloaderMock = { + async reloadConfig() { + return { ok: true }; + }, + async getRuntimeReleaseStatus() { + return { + runtimeDir: '/etc/nginx/runtime', + releasesDir: '/etc/nginx/runtime/releases', + currentReleaseId: '2026-05-26T12-00-00-000Z-ssl-activation', + currentReleasePath: + '/etc/nginx/runtime/releases/2026-05-26T12-00-00-000Z-ssl-activation', + currentRelease: { + releaseId: '2026-05-26T12-00-00-000Z-ssl-activation', + status: 'active', + }, + lastKnownGoodReleaseId: '2026-05-26T12-00-00-000Z-ssl-activation', + lastKnownGoodPath: + '/etc/nginx/runtime/releases/2026-05-26T12-00-00-000Z-ssl-activation', + lastKnownGoodRelease: { + releaseId: '2026-05-26T12-00-00-000Z-ssl-activation', + status: 'active', + }, + }; + }, + }; + + before(async () => { + process.env.API_KEY = 'cluster-admin-legacy-key'; + process.env.AUTH_JWT_SECRET = 'cluster-admin-super-secret'; + process.env.AUTH_JWT_ISSUER = 'lyttle-nginx.test'; + process.env.AUTH_JWT_AUDIENCE = 'lyttle-nginx-admin'; + + nodes = [ + createNode({ instanceId: 'node-a', hostname: 'node-a', isLeader: true }), + createNode({ + id: 'node-record-node-b', + instanceId: 'node-b', + hostname: 'node-b', + status: 'stale', + isLeader: false, + ipAddress: '10.0.0.11', + }), + ]; + + operations = [ + createOperation({ + operationId: 'operation-reload-a', + operationType: 'cluster.reload', + status: 'succeeded', + nodeInstanceId: 'node-a', + nodeHostname: 'node-a', + ackStatus: 'succeeded', + createdAt: new Date('2026-05-26T12:00:00Z'), + ackedAt: new Date('2026-05-26T12:00:05Z'), + details: { currentReleaseId: '2026-05-26T12-00-00-000Z-ssl-activation' }, + }), + createOperation({ + operationId: 'operation-sync-a', + operationType: 'certificate.sync', + status: 'succeeded', + nodeInstanceId: 'node-a', + nodeHostname: 'node-a', + ackStatus: 'succeeded', + createdAt: new Date('2026-05-26T12:05:00Z'), + ackedAt: new Date('2026-05-26T12:05:02Z'), + details: { syncedCount: 2 }, + }), + createOperation({ + operationId: 'operation-activate-a', + operationType: 'certificate.activate', + status: 'succeeded', + nodeInstanceId: 'node-a', + nodeHostname: 'node-a', + ackStatus: 'succeeded', + createdAt: new Date('2026-05-26T12:10:00Z'), + ackedAt: new Date('2026-05-26T12:10:04Z'), + details: { artifactId: 'artifact-1', artifactVersion: 3 }, + }), + createOperation({ + operationId: 'operation-reload-b', + operationType: 'cluster.reload', + status: 'failed', + nodeInstanceId: 'node-b', + nodeHostname: 'node-b', + ackStatus: 'failed', + createdAt: new Date('2026-05-26T12:15:00Z'), + ackedAt: new Date('2026-05-26T12:15:03Z'), + details: { error: 'nginx -t failed' }, + }), + ]; + + const now = Date.now(); + + certificates = [ + { + id: 'cert-1', + domains: 'api.example.com;www.api.example.com', + expiresAt: new Date(now + 7 * 24 * 60 * 60 * 1000), + issuedAt: new Date(now - 30 * 24 * 60 * 60 * 1000), + lastUsedAt: new Date(now - 10 * 60 * 1000), + }, + { + id: 'cert-2', + domains: 'admin.example.com', + expiresAt: new Date(now + 45 * 24 * 60 * 60 * 1000), + issuedAt: new Date(now - 35 * 24 * 60 * 60 * 1000), + lastUsedAt: new Date(now - 9 * 60 * 1000), + }, + ]; + + const moduleRef = await Test.createTestingModule({ + controllers: [ClusterController], + providers: [ + AuthService, + { + provide: APP_GUARD, + useClass: ApiKeyGuard, + }, + { + provide: APP_GUARD, + useClass: AuthorizationGuard, + }, + { + provide: ClusterHeartbeatService, + useValue: clusterHeartbeatMock, + }, + { + provide: ClusterOperationsService, + useValue: clusterOperationsMock, + }, + { + provide: DistributedLockService, + useValue: distributedLockMock, + }, + { + provide: ReloaderService, + useValue: reloaderMock, + }, + { + provide: PrismaService, + useValue: prismaMock, + }, + ], + }).compile(); + + app = moduleRef.createNestApplication(); + await app.init(); + httpServer = app.getHttpServer(); + }); + + after(async () => { + await app?.close(); + + for (const [key, value] of Object.entries(originalEnv)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + }); + + it('lets viewers inspect the aggregated cluster status overview', async () => { + const viewerToken = buildAdminToken('viewer'); + + const response = await request(httpServer) + .get('/cluster/status') + .set('Authorization', `Bearer ${viewerToken}`) + .expect(200); + + assert.equal(response.body.status, 'ok'); + assert.equal(response.body.cluster.total, 2); + assert.equal(response.body.cluster.active, 1); + assert.equal(response.body.nodes.count, 1); + assert.equal(response.body.operations.count, 4); + assert.equal(response.body.leader.status, 'healthy'); + assert.equal(response.body.links.operations, '/cluster/operations'); + }); + + it('supports inactive-node listing and node-filtered operation inspection', async () => { + const viewerToken = buildAdminToken('viewer'); + + const nodesResponse = await request(httpServer) + .get('/cluster/nodes?includeInactive=true') + .set('Authorization', `Bearer ${viewerToken}`) + .expect(200); + + assert.equal(nodesResponse.body.count, 2); + assert.equal(nodesResponse.body.includeInactive, true); + assert.deepEqual( + nodesResponse.body.nodes.map((node) => node.instanceId), + ['node-a', 'node-b'], + ); + + const operationsResponse = await request(httpServer) + .get('/cluster/operations?nodeId=node-a&type=cluster.reload') + .set('Authorization', `Bearer ${viewerToken}`) + .expect(200); + + assert.equal(operationsResponse.body.count, 1); + assert.equal(operationsResponse.body.filters.requestedNodeId, 'node-a'); + assert.equal(operationsResponse.body.filters.resolvedNodeId, 'node-a'); + assert.equal( + operationsResponse.body.operations[0].nodeAcknowledgement.nodeInstanceId, + 'node-a', + ); + }); + + it('returns detailed local config and certificate state for a node', async () => { + const viewerToken = buildAdminToken('viewer'); + + const nodeResponse = await request(httpServer) + .get('/cluster/nodes/node-a') + .set('Authorization', `Bearer ${viewerToken}`) + .expect(200); + + assert.equal(nodeResponse.body.found, true); + assert.equal(nodeResponse.body.node.instanceId, 'node-a'); + assert.equal(nodeResponse.body.config.runtime.currentReleaseId, '2026-05-26T12-00-00-000Z-ssl-activation'); + assert.equal(nodeResponse.body.certificates.activeCertificates.count, 2); + assert.equal(nodeResponse.body.certificates.latestActivation.operationType, 'certificate.activate'); + assert.equal(nodeResponse.body.operations.count, 3); + + const configResponse = await request(httpServer) + .get('/cluster/nodes/node-a/config') + .set('Authorization', `Bearer ${viewerToken}`) + .expect(200); + + assert.equal(configResponse.body.config.latestReload.operationId, 'operation-reload-a'); + assert.equal(configResponse.body.config.runtime.currentRelease.status, 'active'); + + const certificateResponse = await request(httpServer) + .get('/cluster/nodes/node-a/certificates') + .set('Authorization', `Bearer ${viewerToken}`) + .expect(200); + + assert.equal(certificateResponse.body.certificates.latestSync.operationType, 'certificate.sync'); + assert.equal(certificateResponse.body.certificates.activeCertificates.certificates.length, 2); + assert.equal( + certificateResponse.body.certificates.activeCertificates.certificates[0].status, + 'expiring_soon', + ); + }); + + it('returns a stable not-found contract for unknown nodes', async () => { + const viewerToken = buildAdminToken('viewer'); + + const response = await request(httpServer) + .get('/cluster/nodes/missing-node') + .set('Authorization', `Bearer ${viewerToken}`) + .expect(200); + + assert.equal(response.body.found, false); + assert.match(response.body.message, /not found/i); + }); +}); + diff --git a/test/e2e/proxy-management-api.test.js b/test/e2e/proxy-management-api.test.js new file mode 100644 index 0000000..ebe47d7 --- /dev/null +++ b/test/e2e/proxy-management-api.test.js @@ -0,0 +1,333 @@ +require('reflect-metadata'); + +const { after, before, beforeEach, describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const { createHmac } = require('node:crypto'); +const request = require('supertest'); +const { ValidationPipe } = require('@nestjs/common'); +const { APP_GUARD } = require('@nestjs/core'); +const { Test } = require('@nestjs/testing'); + +const { AuthService } = require('../../src/auth/auth.service'); +const { ApiKeyGuard } = require('../../src/auth/guards/api-key.guard'); +const { + AuthorizationGuard, +} = require('../../src/auth/guards/authorization.guard'); +const { NginxService } = require('../../src/nginx/nginx.service'); +const { PrismaService } = require('../../src/prisma/prisma.service'); +const { ProxyController } = require('../../src/proxy/proxy.controller'); +const { ProxyService } = require('../../src/proxy/proxy.service'); + +function signHs256Token(payload, secret) { + const header = { alg: 'HS256', typ: 'JWT' }; + const encodedHeader = Buffer.from(JSON.stringify(header)).toString('base64url'); + const encodedPayload = Buffer.from(JSON.stringify(payload)).toString('base64url'); + const signingInput = `${encodedHeader}.${encodedPayload}`; + const signature = createHmac('sha256', secret) + .update(signingInput) + .digest('base64url'); + return `${signingInput}.${signature}`; +} + +function buildAdminToken(role) { + const now = Math.floor(Date.now() / 1000); + return signHs256Token( + { + sub: `${role}-user`, + iss: 'lyttle-nginx.test', + aud: 'lyttle-nginx-admin', + iat: now, + nbf: now, + exp: now + 300, + actor_type: 'admin', + roles: [role], + scope: 'admin:full', + name: `${role}-user`, + }, + 'proxy-management-super-secret', + ); +} + +function createEntry(overrides = {}) { + return { + id: overrides.id ?? 1, + domains: overrides.domains ?? 'app.example.com', + proxy_pass_host: + overrides.proxy_pass_host ?? 'http://upstream.internal:8080/', + nginx_custom_code: overrides.nginx_custom_code ?? null, + type: overrides.type ?? 'PROXY', + ssl: overrides.ssl ?? false, + }; +} + +describe('proxy management API', () => { + const originalEnv = { + API_KEY: process.env.API_KEY, + AUTH_JWT_SECRET: process.env.AUTH_JWT_SECRET, + AUTH_JWT_ISSUER: process.env.AUTH_JWT_ISSUER, + AUTH_JWT_AUDIENCE: process.env.AUTH_JWT_AUDIENCE, + }; + + let app; + let httpServer; + let proxyEntries; + let nextId; + + const prismaMock = { + proxyEntry: { + async findMany(args = {}) { + let entries = [...proxyEntries]; + const excludedId = args.where?.id?.not; + if (excludedId !== undefined) { + entries = entries.filter((entry) => entry.id !== excludedId); + } + entries.sort((left, right) => left.id - right.id); + return entries.map((entry) => ({ ...entry })); + }, + async findUnique({ where }) { + const entry = proxyEntries.find((candidate) => candidate.id === where.id); + return entry ? { ...entry } : null; + }, + async create({ data }) { + const entry = { id: nextId++, ...data }; + proxyEntries.push(entry); + return { ...entry }; + }, + async update({ where, data }) { + const index = proxyEntries.findIndex((entry) => entry.id === where.id); + if (index === -1) { + throw new Error('missing proxy entry'); + } + proxyEntries[index] = { ...proxyEntries[index], ...data }; + return { ...proxyEntries[index] }; + }, + async delete({ where }) { + const index = proxyEntries.findIndex((entry) => entry.id === where.id); + if (index === -1) { + throw new Error('missing proxy entry'); + } + const [deleted] = proxyEntries.splice(index, 1); + return { ...deleted }; + }, + }, + }; + + before(async () => { + process.env.API_KEY = 'proxy-management-legacy-key'; + process.env.AUTH_JWT_SECRET = 'proxy-management-super-secret'; + process.env.AUTH_JWT_ISSUER = 'lyttle-nginx.test'; + process.env.AUTH_JWT_AUDIENCE = 'lyttle-nginx-admin'; + + const moduleRef = await Test.createTestingModule({ + controllers: [ProxyController], + providers: [ + AuthService, + ProxyService, + NginxService, + { + provide: PrismaService, + useValue: prismaMock, + }, + { + provide: APP_GUARD, + useClass: ApiKeyGuard, + }, + { + provide: APP_GUARD, + useClass: AuthorizationGuard, + }, + ], + }).compile(); + + app = moduleRef.createNestApplication(); + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + transformOptions: { + enableImplicitConversion: true, + }, + }), + ); + await app.init(); + httpServer = app.getHttpServer(); + }); + + beforeEach(() => { + proxyEntries = [createEntry()]; + nextId = 2; + }); + + after(async () => { + if (app) { + await app.close(); + } + + for (const [key, value] of Object.entries(originalEnv)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + }); + + it('allows viewers to inspect proxies but blocks writes', async () => { + const viewerToken = buildAdminToken('viewer'); + + const listResponse = await request(httpServer) + .get('/proxies') + .set('Authorization', `Bearer ${viewerToken}`) + .expect(200); + + assert.equal(listResponse.body.count, 1); + assert.deepEqual(listResponse.body.proxies[0].domains, ['app.example.com']); + + await request(httpServer) + .post('/proxies') + .set('Authorization', `Bearer ${viewerToken}`) + .send({ + domains: ['new.example.com'], + proxyPassHost: 'http://localhost:8080', + type: 'PROXY', + ssl: false, + }) + .expect(403); + }); + + it('creates proxies for platform-admins and returns a reload hint', async () => { + const platformAdminToken = buildAdminToken('platform-admin'); + + const response = await request(httpServer) + .post('/proxies') + .set('Authorization', `Bearer ${platformAdminToken}`) + .send({ + domains: ['api.example.com', 'www.api.example.com'], + proxyPassHost: 'https://backend.internal:8443/app', + type: 'PROXY', + ssl: true, + nginxCustomCode: 'add_header X-Managed-By "session-21" always;', + }) + .expect(201); + + assert.equal(response.body.proxy.id, 2); + assert.equal(response.body.proxy.proxyPassHost, 'https://backend.internal:8443/app'); + assert.equal(response.body.configChange.reloadRequired, true); + assert.equal( + response.body.configChange.suggestedOperationEndpoint, + '/cluster/reload', + ); + assert.match(response.body.validation.generatedConfigPreview, /server_name api.example.com www.api.example.com;/); + assert.match(response.body.validation.generatedConfigPreview, /proxy_pass https:\/\/backend.internal:8443\/app;/); + assert.equal(proxyEntries.length, 2); + }); + + it('rejects invalid proxy targets and overlapping domain ownership before persistence', async () => { + const platformAdminToken = buildAdminToken('platform-admin'); + + const invalidTarget = await request(httpServer) + .post('/proxies') + .set('Authorization', `Bearer ${platformAdminToken}`) + .send({ + domains: ['invalid.example.com'], + proxyPassHost: 'ftp://backend.internal/resource', + type: 'PROXY', + ssl: false, + }) + .expect(400); + + assert.match(invalidTarget.body.message, /http or https scheme/i); + assert.equal(proxyEntries.length, 1); + + const conflictingDomain = await request(httpServer) + .post('/proxies') + .set('Authorization', `Bearer ${platformAdminToken}`) + .send({ + domains: ['app.example.com'], + proxyPassHost: 'http://localhost:8080', + type: 'PROXY', + ssl: false, + }) + .expect(400); + + assert.match(conflictingDomain.body.message, /already managed by proxy entry 1/i); + assert.equal(proxyEntries.length, 1); + }); + + it('lets operators validate draft and stored proxy entries without mutating state', async () => { + const operatorToken = buildAdminToken('operator'); + + const draftValidation = await request(httpServer) + .post('/proxies/validate') + .set('Authorization', `Bearer ${operatorToken}`) + .send({ + domains: ['*.preview.example.com'], + proxyPassHost: '/maintenance', + type: 'REDIRECT', + ssl: true, + }) + .expect(200); + + assert.equal(draftValidation.body.valid, true); + assert.equal(draftValidation.body.normalizedEntry.type, 'REDIRECT'); + assert.equal(draftValidation.body.normalizedEntry.proxyPassHost, '/maintenance'); + assert.equal(draftValidation.body.warnings.length, 1); + assert.match(draftValidation.body.warnings[0], /wildcard proxy domains/i); + assert.equal(proxyEntries.length, 1); + + const storedValidation = await request(httpServer) + .post('/proxies/1/validate') + .set('Authorization', `Bearer ${operatorToken}`) + .expect(200); + + assert.equal(storedValidation.body.valid, true); + assert.equal(storedValidation.body.normalizedEntry.id, 1); + }); + + it('updates proxies and can resolve upstream hostnames for operator diagnostics', async () => { + const platformAdminToken = buildAdminToken('platform-admin'); + const operatorToken = buildAdminToken('operator'); + + const updateResponse = await request(httpServer) + .patch('/proxies/1') + .set('Authorization', `Bearer ${platformAdminToken}`) + .send({ + proxyPassHost: 'http://localhost:3000/service', + nginxCustomCode: 'client_max_body_size 20m;', + }) + .expect(200); + + assert.equal(updateResponse.body.proxy.proxyPassHost, 'http://localhost:3000/service'); + assert.match(updateResponse.body.validation.generatedConfigPreview, /client_max_body_size 20m;/); + + const upstreamResponse = await request(httpServer) + .post('/proxies/1/test-upstream') + .set('Authorization', `Bearer ${operatorToken}`) + .expect(200); + + assert.equal(upstreamResponse.body.ok, true); + assert.equal(upstreamResponse.body.hostname, 'localhost'); + assert.ok(upstreamResponse.body.addresses.length >= 1); + }); + + it('deletes proxies only for platform-admins', async () => { + const operatorToken = buildAdminToken('operator'); + const platformAdminToken = buildAdminToken('platform-admin'); + + await request(httpServer) + .delete('/proxies/1') + .set('Authorization', `Bearer ${operatorToken}`) + .expect(403); + + const deleteResponse = await request(httpServer) + .delete('/proxies/1') + .set('Authorization', `Bearer ${platformAdminToken}`) + .expect(200); + + assert.equal(deleteResponse.body.deleted, true); + assert.equal(deleteResponse.body.configChange.action, 'deleted'); + assert.equal(proxyEntries.length, 0); + }); +}); + diff --git a/test/e2e/rbac-authorization.test.js b/test/e2e/rbac-authorization.test.js new file mode 100644 index 0000000..482f08d --- /dev/null +++ b/test/e2e/rbac-authorization.test.js @@ -0,0 +1,568 @@ +require('reflect-metadata'); + +const { after, before, describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const { createHmac } = require('node:crypto'); +const Module = require('node:module'); +const request = require('supertest'); +const { APP_GUARD } = require('@nestjs/core'); +const { Test } = require('@nestjs/testing'); + +const { AuthController } = require('../../src/auth/auth.controller'); +const { AuthService } = require('../../src/auth/auth.service'); +const { ApiKeyGuard } = require('../../src/auth/guards/api-key.guard'); +const { + AuthorizationGuard, +} = require('../../src/auth/guards/authorization.guard'); + +class CertificateService {} +class TlsConfigService {} +class CertificateBackupService {} +class ClusterHeartbeatService {} +class ClusterOperationsService {} +class DistributedLockService {} +class PrismaService {} +class ReloaderService {} +class LogsService {} + +const originalLoad = Module._load; +const moduleStubs = new Map([ + ['./certificate.service', { CertificateService }], + ['./certificate-backup.service', { CertificateBackupService }], + ['./tls-config.service', { TlsConfigService }], + ['./cluster-heartbeat.service', { ClusterHeartbeatService }], + ['./cluster-operations.service', { ClusterOperationsService }], + ['./distributed-lock.service', { DistributedLockService }], + ['../prisma/prisma.service', { PrismaService }], + ['./reloader/reloader.service', { ReloaderService }], + ['../reloader/reloader.service', { ReloaderService }], + ['./logs.service', { LogsService }], +]); + +Module._load = function loadWithStubs(requestName, parent, isMain) { + if (moduleStubs.has(requestName)) { + return moduleStubs.get(requestName); + } + + return originalLoad.call(this, requestName, parent, isMain); +}; + +const { AppController } = require('../../src/app.controller'); +const { CertificateController } = require('../../src/certificate/certificate.controller'); +const { BackupController } = require('../../src/certificate/backup.controller'); +const { TlsController } = require('../../src/certificate/tls.controller'); +const { ClusterController } = require('../../src/distributed-lock/cluster.controller'); +const { LogsController } = require('../../src/logs/logs.controller'); + +Module._load = originalLoad; + +function signHs256Token(payload, secret) { + const header = { alg: 'HS256', typ: 'JWT' }; + const encodedHeader = Buffer.from(JSON.stringify(header)).toString('base64url'); + const encodedPayload = Buffer.from(JSON.stringify(payload)).toString('base64url'); + const signingInput = `${encodedHeader}.${encodedPayload}`; + const signature = createHmac('sha256', secret) + .update(signingInput) + .digest('base64url'); + return `${signingInput}.${signature}`; +} + +function buildAdminToken(role) { + const now = Math.floor(Date.now() / 1000); + return signHs256Token( + { + sub: `${role}-user`, + iss: 'lyttle-nginx.test', + aud: 'lyttle-nginx-admin', + iat: now, + nbf: now, + exp: now + 300, + actor_type: 'admin', + roles: [role], + scope: 'admin:full', + name: `${role}-user`, + }, + 'rbac-authorization-super-secret', + ); +} + +function buildInternalNodeToken() { + const now = Math.floor(Date.now() / 1000); + return signHs256Token( + { + sub: 'node-a', + iss: 'lyttle-nginx.test', + aud: 'lyttle-nginx-admin', + iat: now, + nbf: now, + exp: now + 300, + actor_type: 'internal-node', + node_id: 'node-a', + scope: 'cluster:internal node:sync', + name: 'swarm-node-a', + }, + 'rbac-authorization-super-secret', + ); +} + +describe('RBAC authorization policies', () => { + const originalEnv = { + API_KEY: process.env.API_KEY, + AUTH_JWT_SECRET: process.env.AUTH_JWT_SECRET, + AUTH_JWT_ISSUER: process.env.AUTH_JWT_ISSUER, + AUTH_JWT_AUDIENCE: process.env.AUTH_JWT_AUDIENCE, + AUTH_DEFAULT_ADMIN_ROLES: process.env.AUTH_DEFAULT_ADMIN_ROLES, + AUTH_DEFAULT_ADMIN_SCOPES: process.env.AUTH_DEFAULT_ADMIN_SCOPES, + }; + + let app; + let httpServer; + + const certificateServiceMock = { + listCertificates: async () => [], + getCertificateInfo: async (id) => ({ id, domains: ['example.com'] }), + uploadCertificate: async (dto) => ({ id: 'cert-1', domains: dto.domains }), + generateSelfSignedCertificate: async (domains) => ({ id: 'cert-2', domains }), + renewCertificateById: async (id) => ({ id, renewed: true }), + renewAllCertificates: async () => undefined, + deleteCertificate: async () => undefined, + validateDomainForCertificate: async (domain) => ({ domain, valid: true }), + checkAllCertificatesOcspSupport: async () => ({ supported: true }), + syncCertificates: async () => ({ synced: true }), + }; + + const tlsConfigServiceMock = { + getRecommendedTlsConfig: async (domain) => ({ domain, config: 'recommended' }), + testTlsConnection: async (domain) => ({ domain, ok: true }), + generateDhParams: async () => undefined, + dhParamsExist: () => true, + getCertificateInfo: async () => ({ subject: 'CN=example.com' }), + validateCertificateChain: async () => ({ valid: true }), + }; + + const backupServiceMock = { + createBackup: async () => ({ filename: 'backup.zip' }), + listBackups: async () => [], + getBackupStream: async () => undefined, + deleteBackup: async () => undefined, + importCertificates: async () => ({ imported: 0 }), + exportCertificate: async (id) => ({ id, exported: true }), + }; + + const clusterHeartbeatServiceMock = { + getActiveNodes: async () => [ + { + id: 'node-1', + hostname: 'node-1', + instanceId: 'node-1', + ipAddress: '10.0.0.1', + controlPlaneAddress: 'node-1.internal', + controlPlanePort: 3000, + controlPlaneProtocol: 'http', + isLeader: true, + status: 'active', + lastHeartbeat: new Date('2026-05-24T00:00:00Z').toISOString(), + version: '0.0.1', + metadata: {}, + }, + ], + getClusterStats: async () => ({ + total: 1, + active: 1, + leaders: [ + { + hostname: 'node-1', + instanceId: 'node-1', + ipAddress: '10.0.0.1', + controlPlaneAddress: 'node-1.internal', + controlPlanePort: 3000, + controlPlaneProtocol: 'http', + status: 'active', + lastHeartbeat: new Date('2026-05-24T00:00:00Z').toISOString(), + metadata: {}, + }, + ], + }), + getLeaderNode: async () => ({ + id: 'node-1', + hostname: 'node-1', + instanceId: 'node-1', + ipAddress: '10.0.0.1', + controlPlaneAddress: 'node-1.internal', + controlPlanePort: 3000, + controlPlaneProtocol: 'http', + status: 'active', + lastHeartbeat: new Date('2026-05-24T00:00:00Z').toISOString(), + version: '0.0.1', + metadata: {}, + }), + manualCleanup: async () => ({ cleaned: 0 }), + manualEnforceLeader: async () => ({ enforced: true }), + ensureLeaderExists: async () => undefined, + tryBecomeLeader: async () => true, + }; + + const distributedLockServiceMock = { + getInstanceId: () => 'node-1', + getLeaderLockStatus: () => ({ + isLeader: true, + instanceId: 'node-1', + heldForMs: 1000, + }), + }; + + const clusterOperationsServiceMock = { + enqueueBroadcastOperation: async () => ({ + operationId: 'operation-1', + status: 'pending', + scope: 'cluster', + operationType: 'cluster.reload', + targetNodeCount: 1, + completedNodeCount: 0, + successfulNodeCount: 0, + failedNodeCount: 0, + createdAt: new Date('2026-05-24T00:00:00Z').toISOString(), + startedAt: null, + completedAt: null, + correlationId: null, + requestPath: '/cluster/reload', + operationStatusPath: '/cluster/operations/operation-1', + }), + listOperations: async () => ({ count: 0, operations: [] }), + getOperation: async () => null, + }; + + const reloaderServiceMock = { + reloadConfig: async () => ({ ok: true }), + getRuntimeReleaseStatus: async () => ({ + currentReleaseId: 'release-1', + currentRelease: { status: 'active' }, + lastKnownGoodReleaseId: 'release-1', + lastKnownGoodRelease: { status: 'active' }, + }), + }; + + const prismaServiceMock = { + clusterNode: { + findMany: async () => [], + findFirst: async () => null, + }, + certificate: { + count: async () => 0, + findMany: async () => [], + }, + }; + + const logsServiceMock = { + getLastLogs: (count) => Array.from({ length: count }, (_, index) => `log-${index + 1}`), + }; + + before(async () => { + process.env.API_KEY = 'rbac-authorization-legacy-key'; + process.env.AUTH_JWT_SECRET = 'rbac-authorization-super-secret'; + process.env.AUTH_JWT_ISSUER = 'lyttle-nginx.test'; + process.env.AUTH_JWT_AUDIENCE = 'lyttle-nginx-admin'; + process.env.AUTH_DEFAULT_ADMIN_ROLES = 'platform-admin'; + process.env.AUTH_DEFAULT_ADMIN_SCOPES = 'admin:full cluster:read'; + + const moduleRef = await Test.createTestingModule({ + controllers: [ + AppController, + AuthController, + BackupController, + CertificateController, + TlsController, + ClusterController, + LogsController, + ], + providers: [ + AuthService, + { + provide: APP_GUARD, + useClass: ApiKeyGuard, + }, + { + provide: APP_GUARD, + useClass: AuthorizationGuard, + }, + { + provide: CertificateService, + useValue: certificateServiceMock, + }, + { + provide: TlsConfigService, + useValue: tlsConfigServiceMock, + }, + { + provide: CertificateBackupService, + useValue: backupServiceMock, + }, + { + provide: ClusterHeartbeatService, + useValue: clusterHeartbeatServiceMock, + }, + { + provide: ClusterOperationsService, + useValue: clusterOperationsServiceMock, + }, + { + provide: DistributedLockService, + useValue: distributedLockServiceMock, + }, + { + provide: ReloaderService, + useValue: reloaderServiceMock, + }, + { + provide: PrismaService, + useValue: prismaServiceMock, + }, + { + provide: LogsService, + useValue: logsServiceMock, + }, + ], + }).compile(); + + app = moduleRef.createNestApplication(); + await app.init(); + httpServer = app.getHttpServer(); + }); + + after(async () => { + await app?.close(); + + for (const [key, value] of Object.entries(originalEnv)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + }); + + it('allows viewer reads but blocks operator and security-admin actions', async () => { + const viewerToken = buildAdminToken('viewer'); + + await request(httpServer) + .get('/certificates') + .set('Authorization', `Bearer ${viewerToken}`) + .expect(200) + .expect(({ body }) => assert.deepEqual(body, [])); + + await request(httpServer) + .get('/cluster/nodes') + .set('Authorization', `Bearer ${viewerToken}`) + .expect(200) + .expect(({ body }) => assert.equal(body.count, 1)); + + await request(httpServer) + .post('/reload') + .set('Authorization', `Bearer ${viewerToken}`) + .expect(403); + + await request(httpServer) + .post('/certificates/upload') + .set('Authorization', `Bearer ${viewerToken}`) + .send({ + domains: ['example.com'], + certPem: '-----BEGIN CERTIFICATE-----\nMIIB\n', + keyPem: '-----BEGIN PRIVATE KEY-----\nMIIB\n', + }) + .expect(403); + + await request(httpServer) + .get('/certificates/backup') + .set('Authorization', `Bearer ${viewerToken}`) + .expect(403); + }); + + it('allows operator actions but blocks security-admin and platform-admin endpoints', async () => { + const operatorToken = buildAdminToken('operator'); + + await request(httpServer) + .post('/reload') + .set('Authorization', `Bearer ${operatorToken}`) + .expect(200) + .expect(({ body }) => assert.equal(body.success, true)); + + await request(httpServer) + .post('/certificates/renew-all') + .set('Authorization', `Bearer ${operatorToken}`) + .expect(200) + .expect(({ body }) => { + assert.match(body.message, /initiated/i); + }); + + await request(httpServer) + .get('/logs?count=2') + .set('Authorization', `Bearer ${operatorToken}`) + .expect(200) + .expect(({ body }) => assert.deepEqual(body.logs, ['log-1', 'log-2'])); + + await request(httpServer) + .get('/certificates/backup') + .set('Authorization', `Bearer ${operatorToken}`) + .expect(403); + + await request(httpServer) + .get('/cluster/admin/become-leader') + .set('Authorization', `Bearer ${operatorToken}`) + .expect(403); + }); + + it('allows security-admin certificate and backup operations but blocks platform actions', async () => { + const securityAdminToken = buildAdminToken('security-admin'); + + await request(httpServer) + .post('/certificates/upload') + .set('Authorization', `Bearer ${securityAdminToken}`) + .send({ + domains: ['example.com'], + certPem: '-----BEGIN CERTIFICATE-----\nMIIB\n', + keyPem: '-----BEGIN PRIVATE KEY-----\nMIIB\n', + }) + .expect(201) + .expect(({ body }) => assert.deepEqual(body.domains, ['example.com'])); + + await request(httpServer) + .get('/certificates/backup') + .set('Authorization', `Bearer ${securityAdminToken}`) + .expect(200) + .expect(({ body }) => assert.deepEqual(body, [])); + + await request(httpServer) + .post('/tls/dhparam') + .set('Authorization', `Bearer ${securityAdminToken}`) + .send({ bits: 1024 }) + .expect(202) + .expect(({ body }) => { + assert.match(body.message, /started in background/i); + }); + + await request(httpServer) + .get('/certificates/backup/export/cert-1') + .set('Authorization', `Bearer ${securityAdminToken}`) + .expect(403); + + await request(httpServer) + .post('/reload') + .set('Authorization', `Bearer ${securityAdminToken}`) + .expect(403); + + await request(httpServer) + .get('/cluster/admin/become-leader') + .set('Authorization', `Bearer ${securityAdminToken}`) + .expect(403); + }); + + it('allows platform-admin to inherit viewer, operator, and security-admin permissions', async () => { + const platformAdminToken = buildAdminToken('platform-admin'); + + await request(httpServer) + .get('/certificates') + .set('Authorization', `Bearer ${platformAdminToken}`) + .expect(200); + + await request(httpServer) + .post('/reload') + .set('Authorization', `Bearer ${platformAdminToken}`) + .expect(200); + + await request(httpServer) + .post('/certificates/upload') + .set('Authorization', `Bearer ${platformAdminToken}`) + .send({ + domains: ['example.com'], + certPem: '-----BEGIN CERTIFICATE-----\nMIIB\n', + keyPem: '-----BEGIN PRIVATE KEY-----\nMIIB\n', + }) + .expect(201); + + await request(httpServer) + .get('/certificates/backup') + .set('Authorization', `Bearer ${platformAdminToken}`) + .expect(200); + + await request(httpServer) + .get('/certificates/backup/export/cert-1') + .set('Authorization', `Bearer ${platformAdminToken}`) + .expect(200); + + await request(httpServer) + .get('/cluster/admin/become-leader') + .set('Authorization', `Bearer ${platformAdminToken}`) + .expect(200) + .expect(({ body }) => assert.equal(body.success, true)); + + await request(httpServer) + .post('/certificates/sync') + .set('Authorization', `Bearer ${platformAdminToken}`) + .expect(200) + .expect(({ body }) => assert.equal(body.synced, true)); + }); + + it('allows internal-node identities on internal routes only', async () => { + const internalNodeToken = buildInternalNodeToken(); + + await request(httpServer) + .get('/auth/me') + .set('Authorization', `Bearer ${internalNodeToken}`) + .expect(200) + .expect(({ body }) => { + assert.equal(body.identity.actorType, 'internal-node'); + assert.equal(body.identity.nodeId, 'node-a'); + }); + + await request(httpServer) + .post('/certificates/sync') + .set('Authorization', `Bearer ${internalNodeToken}`) + .expect(200) + .expect(({ body }) => assert.equal(body.synced, true)); + + await request(httpServer) + .get('/auth/info') + .set('Authorization', `Bearer ${internalNodeToken}`) + .expect(403); + + await request(httpServer) + .get('/certificates') + .set('Authorization', `Bearer ${internalNodeToken}`) + .expect(403); + + await request(httpServer) + .get('/cluster/admin/become-leader') + .set('Authorization', `Bearer ${internalNodeToken}`) + .expect(403); + }); + + it('keeps legacy API keys compatible and exposes the RBAC role catalog', async () => { + await request(httpServer) + .get('/auth/info') + .set('X-API-Key', 'rbac-authorization-legacy-key') + .expect(200) + .expect(({ body }) => { + assert.deepEqual(body.supportedRoles, [ + 'viewer', + 'operator', + 'security-admin', + 'platform-admin', + 'internal-node', + ]); + assert.deepEqual(body.roleHierarchy['platform-admin'], [ + 'viewer', + 'operator', + 'security-admin', + ]); + assert.deepEqual(body.defaultAdminRoles, ['platform-admin']); + assert.deepEqual(body.defaultAdminScopes, ['admin:full cluster:read']); + }); + + await request(httpServer) + .get('/cluster/admin/ensure-leader') + .set('X-API-Key', 'rbac-authorization-legacy-key') + .expect(200) + .expect(({ body }) => assert.equal(body.success, true)); + }); +}); + diff --git a/test/e2e/security-admin-apis.test.js b/test/e2e/security-admin-apis.test.js new file mode 100644 index 0000000..8a7687e --- /dev/null +++ b/test/e2e/security-admin-apis.test.js @@ -0,0 +1,376 @@ +require('reflect-metadata'); + +const { after, before, describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const { createHmac } = require('node:crypto'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const request = require('supertest'); +const { APP_GUARD, APP_INTERCEPTOR } = require('@nestjs/core'); +const { Test } = require('@nestjs/testing'); + +const { AuthService } = require('../../src/auth/auth.service'); +const { ApiKeyGuard } = require('../../src/auth/guards/api-key.guard'); +const { + AuthorizationGuard, +} = require('../../src/auth/guards/authorization.guard'); +const { AuditInterceptor } = require('../../src/audit/audit.interceptor'); +const { AuditService } = require('../../src/audit/audit.service'); +const { + PrivateKeyEncryptionService, +} = require('../../src/certificate/private-key-encryption.service'); +const { PrismaService } = require('../../src/prisma/prisma.service'); +const { SecurityController } = require('../../src/security/security.controller'); +const { SecurityService } = require('../../src/security/security.service'); + +const KEY_PEM = `-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgKxTl2EOJ4qdIDZSD +gHgdj3PXIYhR1gU9hxnOndxEC6ahRANCAATdYQav8MryIyIfh4NV0/1gC+78Kt6J +cq9RgigzG5fsmKUx2zhRf9pjHUH5HDlxbwJTWuLScZaT0PQKiKW1I31A +-----END PRIVATE KEY----- +`; + +function signHs256Token(payload, secret) { + const header = { alg: 'HS256', typ: 'JWT' }; + const encodedHeader = Buffer.from(JSON.stringify(header)).toString('base64url'); + const encodedPayload = Buffer.from(JSON.stringify(payload)).toString('base64url'); + const signingInput = `${encodedHeader}.${encodedPayload}`; + const signature = createHmac('sha256', secret) + .update(signingInput) + .digest('base64url'); + return `${signingInput}.${signature}`; +} + +function buildAdminToken(role, subject = `${role}-user`) { + const now = Math.floor(Date.now() / 1000); + return signHs256Token( + { + sub: subject, + iss: 'lyttle-nginx.test', + aud: 'lyttle-nginx-admin', + iat: now, + nbf: now, + exp: now + 300, + actor_type: 'admin', + roles: [role], + scope: 'admin:full security:admin', + name: subject, + }, + 'security-admin-super-secret', + ); +} + +function clone(value) { + return structuredClone(value); +} + +function createAuditServiceMock() { + return { + events: [], + async recordEvent(event) { + this.events.push({ + id: `event-${this.events.length + 1}`, + occurredAt: new Date().toISOString(), + ...event, + }); + }, + }; +} + +function flushAuditWrites() { + return new Promise((resolve) => setImmediate(resolve)); +} + +describe('security administration APIs', () => { + const originalEnv = { + NODE_ENV: process.env.NODE_ENV, + API_KEY: process.env.API_KEY, + AUTH_JWT_SECRET: process.env.AUTH_JWT_SECRET, + AUTH_JWT_ISSUER: process.env.AUTH_JWT_ISSUER, + AUTH_JWT_AUDIENCE: process.env.AUTH_JWT_AUDIENCE, + AUTH_DEFAULT_ADMIN_ROLES: process.env.AUTH_DEFAULT_ADMIN_ROLES, + AUTH_DEFAULT_ADMIN_SCOPES: process.env.AUTH_DEFAULT_ADMIN_SCOPES, + PRIVATE_KEY_ENCRYPTION_PROVIDER: process.env.PRIVATE_KEY_ENCRYPTION_PROVIDER, + PRIVATE_KEY_ENCRYPTION_MASTER_KEY: + process.env.PRIVATE_KEY_ENCRYPTION_MASTER_KEY, + PRIVATE_KEY_ENCRYPTION_KEY_VERSION: + process.env.PRIVATE_KEY_ENCRYPTION_KEY_VERSION, + BACKUP_ENCRYPTION_KEY: process.env.BACKUP_ENCRYPTION_KEY, + BACKUP_ENCRYPTION_KEY_VERSION: process.env.BACKUP_ENCRYPTION_KEY_VERSION, + ACME_ACCOUNT_PRIVATE_KEY_PATH: process.env.ACME_ACCOUNT_PRIVATE_KEY_PATH, + CLUSTER_CONTROL_PROTOCOL: process.env.CLUSTER_CONTROL_PROTOCOL, + }; + + let app; + let httpServer; + let acmeTempDir; + let auditServiceMock; + let prismaState; + + before(async () => { + acmeTempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lyttlenginx-security-admin-')); + const acmeAccountPath = path.join(acmeTempDir, 'account.pem'); + fs.writeFileSync(acmeAccountPath, 'test-account-key', 'utf8'); + + process.env.NODE_ENV = 'test'; + process.env.API_KEY = 'security-admin-existing-admin-key'; + process.env.AUTH_JWT_SECRET = 'security-admin-super-secret'; + process.env.AUTH_JWT_ISSUER = 'lyttle-nginx.test'; + process.env.AUTH_JWT_AUDIENCE = 'lyttle-nginx-admin'; + process.env.AUTH_DEFAULT_ADMIN_ROLES = 'platform-admin'; + process.env.AUTH_DEFAULT_ADMIN_SCOPES = 'admin:full security:admin'; + process.env.PRIVATE_KEY_ENCRYPTION_PROVIDER = 'local'; + process.env.PRIVATE_KEY_ENCRYPTION_MASTER_KEY = + 'security-admin-private-key-master-key'; + process.env.PRIVATE_KEY_ENCRYPTION_KEY_VERSION = 'security-admin-v2'; + process.env.BACKUP_ENCRYPTION_KEY = 'security-admin-backup-key'; + process.env.BACKUP_ENCRYPTION_KEY_VERSION = 'security-admin-backup-v1'; + process.env.ACME_ACCOUNT_PRIVATE_KEY_PATH = acmeAccountPath; + process.env.CLUSTER_CONTROL_PROTOCOL = 'http'; + + prismaState = { + certificates: [ + { + id: 'cert-1', + keyPem: KEY_PEM, + keyEncryption: null, + domainsHash: 'domains-hash-cert-1', + }, + ], + artifacts: [ + { + id: 'artifact-1', + keyPem: KEY_PEM, + keyEncryption: null, + domainsHash: 'domains-hash-cert-1', + version: 1, + }, + ], + }; + + const prismaMock = { + certificate: { + async findMany() { + return prismaState.certificates.map(clone); + }, + async update({ where, data }) { + const index = prismaState.certificates.findIndex( + (certificate) => certificate.id === where.id, + ); + prismaState.certificates[index] = { + ...prismaState.certificates[index], + ...data, + }; + return clone(prismaState.certificates[index]); + }, + }, + certificateArtifactVersion: { + async findMany() { + return prismaState.artifacts.map(clone); + }, + async update({ where, data }) { + const index = prismaState.artifacts.findIndex( + (artifact) => artifact.id === where.id, + ); + prismaState.artifacts[index] = { + ...prismaState.artifacts[index], + ...data, + }; + return clone(prismaState.artifacts[index]); + }, + }, + }; + + auditServiceMock = createAuditServiceMock(); + + const moduleRef = await Test.createTestingModule({ + controllers: [SecurityController], + providers: [ + AuthService, + SecurityService, + PrivateKeyEncryptionService, + { + provide: APP_GUARD, + useClass: ApiKeyGuard, + }, + { + provide: APP_GUARD, + useClass: AuthorizationGuard, + }, + { + provide: APP_INTERCEPTOR, + useClass: AuditInterceptor, + }, + { + provide: AuditService, + useValue: auditServiceMock, + }, + { + provide: PrismaService, + useValue: prismaMock, + }, + ], + }).compile(); + + app = moduleRef.createNestApplication(); + await app.init(); + httpServer = app.getHttpServer(); + }); + + after(async () => { + await app?.close(); + fs.rmSync(acmeTempDir, { recursive: true, force: true }); + + for (const [key, value] of Object.entries(originalEnv)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + }); + + it('lets security-admin review security posture, policy, and access capabilities', async () => { + const securityAdminToken = buildAdminToken('security-admin'); + + const statusResponse = await request(httpServer) + .get('/security/status') + .set('Authorization', `Bearer ${securityAdminToken}`) + .expect(200); + + assert.equal(statusResponse.body.auth.apiKeyConfigured, true); + assert.equal(statusResponse.body.auth.apiKeyCount, 1); + assert.equal(statusResponse.body.secrets.status, 'ok'); + assert.equal(statusResponse.body.privateKeyEncryption.keyVersion, 'security-admin-v2'); + assert.equal(statusResponse.body.interNodeSecurity.mtlsEnabled, false); + assert.equal( + statusResponse.body.breakGlass.rawCertificateExport.requiredRole, + 'platform-admin', + ); + + const policyResponse = await request(httpServer) + .get('/security/policy') + .set('Authorization', `Bearer ${securityAdminToken}`) + .expect(200); + + assert.equal(policyResponse.body.rotationFlows.length, 3); + assert.equal(policyResponse.body.breakGlassFlows[0].endpoint, 'GET /certificates/backup/export/:id'); + + const accessReviewResponse = await request(httpServer) + .get('/security/access-review') + .set('Authorization', `Bearer ${securityAdminToken}`) + .expect(200); + + assert.equal(accessReviewResponse.body.actor.subject, 'security-admin-user'); + assert.equal(accessReviewResponse.body.accessibleCapabilities.rotatePrivateKeyEncryption, true); + assert.equal(accessReviewResponse.body.accessibleCapabilities.planApiKeyRotation, false); + }); + + it('lets platform-admin plan API key rotation and request a bridge bearer token', async () => { + const platformAdminToken = buildAdminToken('platform-admin', 'platform-admin-a'); + + const response = await request(httpServer) + .post('/security/rotate/api-key') + .set('Authorization', `Bearer ${platformAdminToken}`) + .send({ + nextApiKey: 'NextRotationKey-SecurityAdmin-!A9xYz321', + retireApiKeyId: 'missing-api-key-id', + issueBridgeToken: true, + reason: 'quarterly credential rotation', + }) + .expect(200); + + assert.equal(response.body.status, 'ready'); + assert.equal(response.body.strategy, 'manual-overlap-env-rotation'); + assert.equal(response.body.current.configuredKeyCount, 1); + assert.equal(response.body.candidate.valid, true); + assert.equal(response.body.candidate.maskedPreview, 'Next…z321'); + assert.equal(response.body.migrationBridge.issued, true); + assert.match(response.body.migrationBridge.token.accessToken, /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/); + assert.equal( + response.body.current.retireTarget.requestedApiKeyId, + 'missing-api-key-id', + ); + assert.equal(response.body.current.retireTarget.found, false); + }); + + it('re-encrypts stored private keys on demand and exposes the deferred internal-cert rotation hook', async () => { + const securityAdminToken = buildAdminToken('security-admin', 'security-admin-b'); + const platformAdminToken = buildAdminToken('platform-admin', 'platform-admin-b'); + + const dryRunResponse = await request(httpServer) + .post('/security/rotate/private-key-encryption') + .set('Authorization', `Bearer ${securityAdminToken}`) + .send({ + confirmKeyVersion: 'security-admin-v2', + dryRun: true, + reason: 'preflight', + }) + .expect(200); + + assert.equal(dryRunResponse.body.status, 'dry-run'); + assert.equal(dryRunResponse.body.provider.keyVersion, 'security-admin-v2'); + + const rotateResponse = await request(httpServer) + .post('/security/rotate/private-key-encryption') + .set('Authorization', `Bearer ${securityAdminToken}`) + .send({ + confirmKeyVersion: 'security-admin-v2', + reason: 'activate rotated master key version', + }) + .expect(200); + + assert.equal(rotateResponse.body.status, 'completed'); + assert.equal(rotateResponse.body.certificatesUpdated, 1); + assert.equal(rotateResponse.body.artifactsUpdated, 1); + assert.doesNotMatch(prismaState.certificates[0].keyPem, /BEGIN PRIVATE KEY/); + assert.doesNotMatch(prismaState.artifacts[0].keyPem, /BEGIN PRIVATE KEY/); + + const internalCertResponse = await request(httpServer) + .post('/security/rotate/internal-certs') + .set('Authorization', `Bearer ${platformAdminToken}`) + .send({ + reason: 'prepare future node PKI rotation contract', + maintenanceWindow: '2026-05-28T23:00:00Z/2026-05-29T00:00:00Z', + }) + .expect(200); + + assert.equal(internalCertResponse.body.supported, false); + assert.equal(internalCertResponse.body.status, 'not-configured'); + assert.equal(internalCertResponse.body.currentTransport.mtlsEnabled, false); + assert.equal(internalCertResponse.body.futureContract.endpoint, 'POST /security/rotate/internal-certs'); + }); + + it('denies lower-privilege actors and audits both successful and denied security administration calls', async () => { + const operatorToken = buildAdminToken('operator', 'operator-user'); + const securityAdminToken = buildAdminToken('security-admin', 'security-admin-c'); + + await request(httpServer) + .get('/security/status') + .set('Authorization', `Bearer ${operatorToken}`) + .expect(403); + + await flushAuditWrites(); + + const deniedEvent = auditServiceMock.events.at(-1); + assert.equal(deniedEvent.action, 'security.status.review'); + assert.equal(deniedEvent.outcome, 'denied'); + assert.equal(deniedEvent.responseStatus, 403); + assert.equal(deniedEvent.actor.subject, 'operator-user'); + + await request(httpServer) + .post('/security/rotate/private-key-encryption') + .set('Authorization', `Bearer ${securityAdminToken}`) + .send({ confirmKeyVersion: 'security-admin-v2' }) + .expect(200); + + await flushAuditWrites(); + + const successEvent = auditServiceMock.events.at(-1); + assert.equal(successEvent.action, 'security.private-key-encryption.rotate'); + assert.equal(successEvent.outcome, 'success'); + assert.equal(successEvent.actor.subject, 'security-admin-c'); + }); +}); + diff --git a/test/integration/acme-strategy-hardening.test.js b/test/integration/acme-strategy-hardening.test.js new file mode 100644 index 0000000..bac32f2 --- /dev/null +++ b/test/integration/acme-strategy-hardening.test.js @@ -0,0 +1,311 @@ +require('reflect-metadata'); + +const { afterEach, beforeEach, describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const repoRoot = path.resolve(__dirname, '../..'); +const acmeStrategyPath = path.join( + repoRoot, + 'src/certificate/acme-strategy.ts', +); +const certificateServicePath = path.join( + repoRoot, + 'src/certificate/certificate.service.ts', +); +const acmeControllerPath = path.join( + repoRoot, + 'src/certificate/acme.controller.ts', +); + +const originalAdminEmail = process.env.ADMIN_EMAIL; + +function resetModules() { + delete require.cache[require.resolve(acmeStrategyPath)]; + delete require.cache[require.resolve(certificateServicePath)]; + delete require.cache[require.resolve(acmeControllerPath)]; +} + +function createCertificateService(prisma, acmeService = undefined) { + process.env.ADMIN_EMAIL = 'acme-strategy@example.test'; + resetModules(); + const { CertificateService } = require(certificateServicePath); + + return new CertificateService( + prisma, + { sendAlert: async () => undefined }, + { + listOrders: async () => ({ count: 0, orders: [] }), + getOrder: async () => ({ id: 'order-1' }), + getArtifact: async () => null, + getCurrentArtifactForDomainsHash: async () => null, + getRollbackArtifactForDomainsHash: async () => null, + transitionOrder: async () => undefined, + markFailure: async () => undefined, + completeWithCertificate: async () => undefined, + getOrCreateOrder: async () => ({ id: 'order-1', attemptCount: 1, metadata: null }), + recordArtifact: async () => ({ id: 'artifact-1', version: 1 }), + validateRetryableOrder: async () => ({ id: 'order-1', domains: 'example.com', sourceType: 'acme' }), + resumeOrder: async () => undefined, + getLatestArtifactForOrder: async () => null, + }, + { + enqueueBroadcastOperation: async () => ({ operationId: 'op-1' }), + waitForOperationToSettle: async () => ({ + status: 'succeeded', + completedAt: new Date(), + acknowledgements: [], + }), + }, + { + getInstanceId: () => 'node-1', + withLock: async (_name, fn) => fn(), + acquireLeaderLock: async () => false, + releaseLeaderLock: async () => undefined, + releaseAllLocks: async () => undefined, + isLeader: async () => false, + }, + { + recordCertificateSyncFailure() {}, + recordCertificateSyncSuccess() {}, + }, + null, + acmeService, + ); +} + +function createResponseCapture() { + return { + statusCode: null, + contentTypeValue: null, + body: null, + status(code) { + this.statusCode = code; + return this; + }, + contentType(value) { + this.contentTypeValue = value; + return this; + }, + send(value) { + this.body = value; + return this; + }, + }; +} + +beforeEach(() => { + process.env.ADMIN_EMAIL = 'acme-strategy@example.test'; + resetModules(); +}); + +afterEach(() => { + resetModules(); + if (originalAdminEmail === undefined) { + delete process.env.ADMIN_EMAIL; + } else { + process.env.ADMIN_EMAIL = originalAdminEmail; + } +}); + +describe('ACME strategy hardening', () => { + it('uses Nest-managed ACME strategy helpers and no longer relies on shell hook arguments', () => { + const { + resolveAcmeStrategy, + getAcmeDirectoryUrl, + getAcmeAccountKeyPath, + } = require(acmeStrategyPath); + + const strategy = resolveAcmeStrategy(['example.com', 'www.example.com'], { + ACME_CHALLENGE_STRATEGY: 'http-01', + ACME_HTTP01_PROPAGATION_SECONDS: '7', + LETSENCRYPT_STAGING: 'true', + ACME_ACCOUNT_PRIVATE_KEY_PATH: '/var/lib/lyttle/acme/account.pem', + }); + + const strategySource = fs.readFileSync(acmeStrategyPath, 'utf8'); + const certificateSource = fs.readFileSync(certificateServicePath, 'utf8'); + + assert.deepEqual(strategy, { + requestedStrategy: 'http-01', + challengeType: 'http-01', + provider: 'database-http-01', + wildcard: false, + sharedChallengeStore: true, + propagationSeconds: 7, + challengeStore: 'database-http', + visibleInChallengeApi: true, + }); + assert.equal( + getAcmeDirectoryUrl({ LETSENCRYPT_STAGING: 'true' }), + 'https://acme-staging-v02.api.letsencrypt.org/directory', + ); + assert.equal( + getAcmeAccountKeyPath({ + ACME_ACCOUNT_PRIVATE_KEY_PATH: '/var/lib/lyttle/acme/account.pem', + }), + '/var/lib/lyttle/acme/account.pem', + ); + assert.doesNotMatch( + strategySource, + /manual-auth-hook|manual-cleanup-hook|certbot-auth-hook|certbot-cleanup-hook/, + ); + assert.doesNotMatch( + certificateSource, + /runCommand\('certbot'\)|manual-auth-hook|manual-cleanup-hook/, + ); + }); + + it('keeps production ACME issuance on the shared HTTP-01 flow and rejects wildcard or DNS-TXT-dependent strategies', () => { + const { resolveAcmeStrategy } = require(acmeStrategyPath); + + const strategy = resolveAcmeStrategy(['example.com', 'www.example.com'], { + ACME_CHALLENGE_STRATEGY: 'auto', + ACME_HTTP01_PROPAGATION_SECONDS: '5', + }); + + assert.deepEqual(strategy, { + requestedStrategy: 'auto', + challengeType: 'http-01', + provider: 'database-http-01', + wildcard: false, + sharedChallengeStore: true, + propagationSeconds: 5, + challengeStore: 'database-http', + visibleInChallengeApi: true, + }); + assert.throws( + () => + resolveAcmeStrategy(['*.example.com', 'example.com'], { + ACME_CHALLENGE_STRATEGY: 'auto', + }), + /would require DNS TXT record changes/i, + ); + assert.throws( + () => + resolveAcmeStrategy(['example.com'], { + ACME_CHALLENGE_STRATEGY: 'dns-01', + }), + /not supported.*DNS TXT record changes/i, + ); + }); + + it('lists recent ACME challenges through the certificate service by delegating to the Nest ACME service', async () => { + let receivedOptions = null; + const acmeService = { + listChallenges: async (options) => { + receivedOptions = options; + return { + count: 1, + challenges: [ + { + id: 'challenge-1', + orderId: 'order-1', + token: 'token-1', + domain: 'example.com', + challengeType: 'http-01', + provider: 'database-http-01', + status: 'presented', + presentedAt: new Date('2026-05-24T15:30:00.000Z'), + cleanedUpAt: null, + finalizedAt: null, + lastServedAt: new Date('2026-05-24T15:31:00.000Z'), + expiresAt: new Date('2026-05-24T16:30:00.000Z'), + metadata: { providerState: 'published' }, + createdAt: new Date('2026-05-24T15:30:00.000Z'), + }, + ], + }; + }, + }; + + const service = createCertificateService( + { + certificate: { + findMany: async () => [], + findUnique: async () => null, + }, + certificateOrder: { + findMany: async () => [], + findUnique: async () => null, + }, + certificateArtifactVersion: { + update: async () => undefined, + updateMany: async () => ({ count: 0 }), + }, + proxyEntry: { + findMany: async () => [], + }, + }, + acmeService, + ); + + const result = await service.listAcmeChallenges({ + status: 'presented', + limit: 10, + }); + + assert.deepEqual(receivedOptions, { + status: 'presented', + limit: 10, + }); + assert.equal(result.count, 1); + assert.equal(result.challenges[0].id, 'challenge-1'); + assert.equal(result.challenges[0].token, 'token-1'); + assert.equal(result.challenges[0].status, 'presented'); + assert.deepEqual(result.challenges[0].metadata, { + providerState: 'published', + }); + }); + + it('serves only active HTTP-01 challenges and marks served challenges through the Nest ACME service', async () => { + resetModules(); + const { AcmeController } = require(acmeControllerPath); + + const calls = []; + const controller = new AcmeController({ + getPresentedHttpChallenge: async (token) => { + if (token === 'token-active') { + return { + status: 'found', + challenge: { + id: 'challenge-active', + token, + keyAuth: 'key-auth-active', + domain: 'example.com', + expiresAt: new Date(Date.now() + 60_000), + }, + }; + } + + if (token === 'token-expired') { + return { status: 'expired' }; + } + + return { status: 'missing' }; + }, + markChallengeServed: async (challengeId) => { + calls.push(challengeId); + }, + }); + + const okResponse = createResponseCapture(); + await controller.getChallenge('token-active', okResponse); + assert.equal(okResponse.statusCode, 200); + assert.equal(okResponse.contentTypeValue, 'text/plain'); + assert.equal(okResponse.body, 'key-auth-active'); + + const expiredResponse = createResponseCapture(); + await controller.getChallenge('token-expired', expiredResponse); + assert.equal(expiredResponse.statusCode, 404); + assert.equal(expiredResponse.body, 'Challenge expired'); + + const missingResponse = createResponseCapture(); + await controller.getChallenge('token-missing', missingResponse); + assert.equal(missingResponse.statusCode, 404); + assert.equal(missingResponse.body, 'Challenge not found'); + + assert.deepEqual(calls, ['challenge-active']); + }); +}); diff --git a/test/integration/backup-hardening.test.js b/test/integration/backup-hardening.test.js new file mode 100644 index 0000000..1c888ac --- /dev/null +++ b/test/integration/backup-hardening.test.js @@ -0,0 +1,300 @@ +require('reflect-metadata'); + +const { afterEach, beforeEach, describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const repoRoot = path.resolve(__dirname, '../..'); +const encryptionServicePath = path.join( + repoRoot, + 'src/certificate/private-key-encryption.service.ts', +); +const backupServicePath = path.join( + repoRoot, + 'src/certificate/certificate-backup.service.ts', +); +const domainUtilsPath = path.join(repoRoot, 'src/utils/domain-utils.ts'); + +const CERT_PEM = `-----BEGIN CERTIFICATE----- +MIIBqzCCAVCgAwIBAgIUXj2axSEIzp9WQkQnsHhPNeuGmm4wCgYIKoZIzj0EAwIw +FjEUMBIGA1UEAwwLZXhhbXBsZS5jb20wHhcNMjYwNTI2MTgzNjUzWhcNMzYwNTIz +MTgzNjUzWjAWMRQwEgYDVQQDDAtleGFtcGxlLmNvbTBZMBMGByqGSM49AgEGCCqG +SM49AwEHA0IABN1hBq/wyvIjIh+Hg1XT/WAL7vwq3olyr1GCKDMbl+yYpTHbOFF/ +2mMdQfkcOXFvAlNa4tJxlpPQ9AqIpbUjfUCjfDB6MB0GA1UdDgQWBBQTeY9QpI1O +tUGKc6LZpmhJ98f7CDAfBgNVHSMEGDAWgBQTeY9QpI1OtUGKc6LZpmhJ98f7CDAP +BgNVHRMBAf8EBTADAQH/MCcGA1UdEQQgMB6CC2V4YW1wbGUuY29tgg93d3cuZXhh +bXBsZS5jb20wCgYIKoZIzj0EAwIDSQAwRgIhAIk6kq5Fdvyw2EDkfqUtYrM1IHAG +aKUyOFucZhk0VqmIAiEAzaGSXKVBsKMoA+4EHXTZ+gJV2IhFVDfTTCivqzBMiB0= +-----END CERTIFICATE----- +`; + +const KEY_PEM = `-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgKxTl2EOJ4qdIDZSD +gHgdj3PXIYhR1gU9hxnOndxEC6ahRANCAATdYQav8MryIyIfh4NV0/1gC+78Kt6J +cq9RgigzG5fsmKUx2zhRf9pjHUH5HDlxbwJTWuLScZaT0PQKiKW1I31A +-----END PRIVATE KEY----- +`; + +const OTHER_KEY_PEM = `-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgo+4VXi5sivNgJ9W7 +gmzg8DhmGUzIpzlynUQR/CeNfJ+hRANCAAT7sur+do08NANrfUs5oOiwD4UrbCoa +gpZeLbN4K8ukBD8okY9+xJZ9zxJZCmlZ5wWTkRHijKt04SW/4JMEHdzm +-----END PRIVATE KEY----- +`; + +const CERT_ISSUED_AT = '2026-05-26T18:36:53.000Z'; +const CERT_EXPIRES_AT = '2036-05-23T18:36:53.000Z'; + +const originalEnv = { + NODE_ENV: process.env.NODE_ENV, + BACKUP_DIR: process.env.BACKUP_DIR, + BACKUP_ENCRYPTION_KEY: process.env.BACKUP_ENCRYPTION_KEY, + BACKUP_ENCRYPTION_KEY_VERSION: process.env.BACKUP_ENCRYPTION_KEY_VERSION, + PRIVATE_KEY_ENCRYPTION_PROVIDER: process.env.PRIVATE_KEY_ENCRYPTION_PROVIDER, + PRIVATE_KEY_ENCRYPTION_MASTER_KEY: + process.env.PRIVATE_KEY_ENCRYPTION_MASTER_KEY, + PRIVATE_KEY_ENCRYPTION_KEY_VERSION: + process.env.PRIVATE_KEY_ENCRYPTION_KEY_VERSION, +}; + +let tempDir; + +function resetModules() { + delete require.cache[require.resolve(encryptionServicePath)]; + delete require.cache[require.resolve(backupServicePath)]; + delete require.cache[require.resolve(domainUtilsPath)]; +} + +function clone(value) { + return structuredClone(value); +} + +function createPrismaMock() { + const state = { + certificates: [], + certificateSequence: 0, + }; + + return { + state, + certificate: { + async findMany() { + return state.certificates.map(clone); + }, + async findFirst({ where } = {}) { + const certificate = + state.certificates.find((entry) => { + if (where?.domains && entry.domains !== where.domains) { + return false; + } + if (where?.domainsHash && entry.domainsHash !== where.domainsHash) { + return false; + } + return true; + }) ?? null; + return certificate ? clone(certificate) : null; + }, + async findUnique({ where }) { + const certificate = + state.certificates.find((entry) => { + if (where?.id) { + return entry.id === where.id; + } + if (where?.domainsHash) { + return entry.domainsHash === where.domainsHash; + } + return false; + }) ?? null; + return certificate ? clone(certificate) : null; + }, + async create({ data }) { + state.certificateSequence += 1; + const now = new Date(); + const certificate = { + id: `cert-${state.certificateSequence}`, + createdAt: now, + updatedAt: now, + ...data, + }; + state.certificates.push(certificate); + return clone(certificate); + }, + }, + }; +} + +async function seedEncryptedCertificate(prisma) { + const { PrivateKeyEncryptionService } = require(encryptionServicePath); + const { hashDomains, joinDomains, normalizeDomains } = require(domainUtilsPath); + const encryptionService = new PrivateKeyEncryptionService(prisma); + const domains = normalizeDomains(['example.com', 'www.example.com'], { + allowWildcard: true, + }); + const domainsHash = hashDomains(domains, { allowWildcard: true }); + const encryptedKey = encryptionService.encryptPrivateKey(KEY_PEM, { + scope: 'certificate', + domainsHash, + }); + + prisma.state.certificates.push({ + id: 'cert-seeded', + domains: joinDomains(domains, { allowWildcard: true }), + domainsHash, + certPem: CERT_PEM, + keyPem: encryptedKey.keyPem, + keyEncryption: encryptedKey.keyEncryption, + issuedAt: new Date(CERT_ISSUED_AT), + expiresAt: new Date(CERT_EXPIRES_AT), + lastUsedAt: new Date(CERT_ISSUED_AT), + isOrphaned: false, + status: 'active', + createdAt: new Date(CERT_ISSUED_AT), + updatedAt: new Date(CERT_ISSUED_AT), + }); + + return encryptionService; +} + +beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lyttlenginx-backup-hardening-')); + process.env.NODE_ENV = 'test'; + process.env.BACKUP_DIR = tempDir; + process.env.BACKUP_ENCRYPTION_KEY = 'backup-hardening-test-backup-encryption-key'; + process.env.BACKUP_ENCRYPTION_KEY_VERSION = 'backup-v1'; + process.env.PRIVATE_KEY_ENCRYPTION_PROVIDER = 'local'; + process.env.PRIVATE_KEY_ENCRYPTION_MASTER_KEY = + 'backup-hardening-test-private-key-master-key'; + process.env.PRIVATE_KEY_ENCRYPTION_KEY_VERSION = 'backup-private-key-v1'; + resetModules(); +}); + +afterEach(() => { + resetModules(); + fs.rmSync(tempDir, { recursive: true, force: true }); + + for (const [key, value] of Object.entries(originalEnv)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } +}); + +describe('backup, export, import, and restore hardening', () => { + it('creates encrypted backups that can be verified and restored without re-exposing plaintext at rest', async () => { + const prisma = createPrismaMock(); + const encryptionService = await seedEncryptedCertificate(prisma); + const { CertificateBackupService } = require(backupServicePath); + const backupService = new CertificateBackupService(prisma, encryptionService); + + const backup = await backupService.createBackup(); + assert.match(backup.filename, /\.lyttlebackup$/); + + const rawBackupFile = fs.readFileSync(backup.path, 'utf8'); + assert.doesNotMatch(rawBackupFile, /BEGIN PRIVATE KEY/); + assert.doesNotMatch(rawBackupFile, /BEGIN CERTIFICATE/); + + const verification = await backupService.verifyBackup(backup.filename); + assert.deepEqual( + { + verified: verification.verified, + certificateCount: verification.certificateCount, + entryCount: verification.entryCount, + keyVersion: verification.keyVersion, + }, + { + verified: true, + certificateCount: 1, + entryCount: 4, + keyVersion: 'backup-v1', + }, + ); + + const restorePrisma = createPrismaMock(); + const { PrivateKeyEncryptionService } = require(encryptionServicePath); + const restoreEncryptionService = new PrivateKeyEncryptionService(restorePrisma); + const restoreService = new CertificateBackupService( + restorePrisma, + restoreEncryptionService, + ); + + const restored = await restoreService.restoreBackup(backup.filename); + assert.deepEqual( + { + imported: restored.imported, + skipped: restored.skipped, + errors: restored.errors, + verifiedBackup: restored.verifiedBackup.verified, + }, + { + imported: 1, + skipped: 0, + errors: 0, + verifiedBackup: true, + }, + ); + assert.equal(restorePrisma.state.certificates.length, 1); + assert.doesNotMatch( + restorePrisma.state.certificates[0].keyPem, + /BEGIN PRIVATE KEY/, + ); + + const exported = await restoreService.exportCertificate( + restorePrisma.state.certificates[0].id, + ); + assert.equal(exported.keyPem, KEY_PEM); + assert.deepEqual(exported.domains, ['example.com', 'www.example.com']); + }); + + it('rejects tampered encrypted backups before restore can accept any data', async () => { + const prisma = createPrismaMock(); + const encryptionService = await seedEncryptedCertificate(prisma); + const { CertificateBackupService } = require(backupServicePath); + const backupService = new CertificateBackupService(prisma, encryptionService); + + const backup = await backupService.createBackup(); + const envelope = JSON.parse(fs.readFileSync(backup.path, 'utf8')); + envelope.manifest.entries[0].sha256 = '0'.repeat(64); + fs.writeFileSync(backup.path, JSON.stringify(envelope, null, 2), 'utf8'); + + await assert.rejects( + () => backupService.verifyBackup(backup.filename), + /signature verification failed/i, + ); + await assert.rejects( + () => backupService.restoreBackup(backup.filename), + /signature verification failed/i, + ); + }); + + it('rejects invalid direct imports and does not persist mismatched certificate material', async () => { + const prisma = createPrismaMock(); + const { PrivateKeyEncryptionService } = require(encryptionServicePath); + const { CertificateBackupService } = require(backupServicePath); + const backupService = new CertificateBackupService( + prisma, + new PrivateKeyEncryptionService(prisma), + ); + + const result = await backupService.importCertificates([ + { + domains: ['example.com', 'www.example.com'], + certPem: CERT_PEM, + keyPem: OTHER_KEY_PEM, + issuedAt: CERT_ISSUED_AT, + expiresAt: CERT_EXPIRES_AT, + }, + ]); + + assert.deepEqual(result, { + imported: 0, + skipped: 0, + errors: 1, + }); + assert.equal(prisma.state.certificates.length, 0); + }); +}); + diff --git a/test/integration/certificate-distribution-and-rollback.test.js b/test/integration/certificate-distribution-and-rollback.test.js new file mode 100644 index 0000000..3b007d8 --- /dev/null +++ b/test/integration/certificate-distribution-and-rollback.test.js @@ -0,0 +1,656 @@ +require('reflect-metadata'); + +const { afterEach, beforeEach, describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const childProcess = require('node:child_process'); + +const repoRoot = path.resolve(__dirname, '../..'); +const certificateServicePath = path.join( + repoRoot, + 'src/certificate/certificate.service.ts', +); +const certificateOrderServicePath = path.join( + repoRoot, + 'src/certificate/certificate-order.service.ts', +); + +const originalAdminEmail = process.env.ADMIN_EMAIL; +const originalExecFile = childProcess.execFile; + +function resetModules() { + delete require.cache[require.resolve(certificateServicePath)]; + delete require.cache[require.resolve(certificateOrderServicePath)]; +} + +function installExecFileStub(handler) { + const calls = []; + + childProcess.execFile = (command, args, options, callback) => { + if (typeof options === 'function') { + callback = options; + options = {}; + } + + calls.push({ command, args: [...args], options: { ...options } }); + + Promise.resolve() + .then(() => handler({ command, args, options, calls })) + .then((result) => callback?.(null, result?.stdout ?? '', result?.stderr ?? '')) + .catch((error) => callback?.(error, '', error?.stderr ?? '')); + + return { + stdin: { + end() {}, + }, + }; + }; + + return calls; +} + +function restoreExecFile() { + childProcess.execFile = originalExecFile; +} + +function clone(value) { + return structuredClone(value); +} + +function applyDataPatch(record, data) { + for (const [key, value] of Object.entries(data)) { + if ( + value && + typeof value === 'object' && + !Array.isArray(value) && + Object.keys(value).length === 1 && + Object.prototype.hasOwnProperty.call(value, 'increment') + ) { + record[key] = (record[key] ?? 0) + value.increment; + continue; + } + + record[key] = value; + } +} + +function createPrismaMock(clusterState) { + const state = { + certificateSequence: 0, + orderSequence: 0, + eventSequence: 0, + artifactSequence: 0, + certificates: [], + orders: [], + events: [], + artifacts: [], + }; + + function getCertificate(where) { + if (where.id) { + return state.certificates.find((entry) => entry.id === where.id) ?? null; + } + + if (where.domainsHash) { + return ( + state.certificates.find((entry) => entry.domainsHash === where.domainsHash) ?? + null + ); + } + + return null; + } + + function getOrderById(id) { + return state.orders.find((entry) => entry.id === id) ?? null; + } + + function matchesArtifactWhere(artifact, where = {}) { + if (typeof where.id === 'string' && artifact.id !== where.id) { + return false; + } + if (where.orderId && artifact.orderId !== where.orderId) { + return false; + } + if (where.domainsHash && artifact.domainsHash !== where.domainsHash) { + return false; + } + if (where.isCurrent !== undefined && artifact.isCurrent !== where.isCurrent) { + return false; + } + if (where.version?.lt !== undefined && !(artifact.version < where.version.lt)) { + return false; + } + if (where.activatedAt?.not === null && artifact.activatedAt === null) { + return false; + } + if (where.id?.not && artifact.id === where.id.not) { + return false; + } + return true; + } + + return { + state, + certificate: { + async create({ data }) { + state.certificateSequence += 1; + const now = new Date(); + const certificate = { + id: `cert-${state.certificateSequence}`, + createdAt: now, + updatedAt: now, + failureReason: data.failureReason ?? null, + retryAfter: data.retryAfter ?? null, + failureCount: data.failureCount ?? 0, + issuedByNode: data.issuedByNode ?? null, + ...data, + }; + state.certificates.push(certificate); + return clone(certificate); + }, + async findFirst() { + return null; + }, + async findUnique({ where }) { + const certificate = getCertificate(where); + return certificate ? clone(certificate) : null; + }, + async update({ where, data }) { + const certificate = getCertificate(where); + if (!certificate) { + throw new Error(`Certificate not found for update: ${JSON.stringify(where)}`); + } + applyDataPatch(certificate, data); + certificate.updatedAt = new Date(); + return clone(certificate); + }, + async findMany() { + return state.certificates.map(clone); + }, + async count() { + return 0; + }, + async delete() { + return undefined; + }, + }, + certificateOrder: { + async create({ data }) { + state.orderSequence += 1; + const now = new Date(); + const order = { + id: `order-${state.orderSequence}`, + sourceType: data.sourceType ?? 'acme', + status: data.status ?? 'requested', + attemptCount: data.attemptCount ?? 1, + retryCount: data.retryCount ?? 0, + nextRetryAt: data.nextRetryAt ?? null, + lastError: data.lastError ?? null, + requestedByNode: data.requestedByNode ?? null, + certificateId: data.certificateId ?? null, + requestedAt: data.requestedAt ?? now, + startedAt: data.startedAt ?? null, + challengePublishedAt: data.challengePublishedAt ?? null, + validatingAt: data.validatingAt ?? null, + issuedAt: data.issuedAt ?? null, + distributingAt: data.distributingAt ?? null, + activatedAt: data.activatedAt ?? null, + failedAt: data.failedAt ?? null, + revokedAt: data.revokedAt ?? null, + completedAt: data.completedAt ?? null, + metadata: data.metadata ?? null, + createdAt: now, + updatedAt: now, + ...data, + }; + state.orders.push(order); + return clone(order); + }, + async findFirst({ where, orderBy } = {}) { + const results = state.orders.filter((order) => { + if (where?.domainsHash && order.domainsHash !== where.domainsHash) { + return false; + } + if (typeof where?.status === 'string' && order.status !== where.status) { + return false; + } + if (where?.status?.in && !where.status.in.includes(order.status)) { + return false; + } + if (where?.sourceType && order.sourceType !== where.sourceType) { + return false; + } + if (where?.nextRetryAt?.lte && !(order.nextRetryAt && order.nextRetryAt <= where.nextRetryAt.lte)) { + return false; + } + return true; + }); + if (orderBy?.createdAt === 'desc') { + results.sort((left, right) => right.createdAt - left.createdAt); + } + return results[0] ? clone(results[0]) : null; + }, + async findUnique({ where, include, select } = {}) { + const order = getOrderById(where.id); + if (!order) { + return null; + } + if (select) { + const selected = {}; + for (const [key, enabled] of Object.entries(select)) { + if (enabled) { + selected[key] = order[key]; + } + } + return clone(selected); + } + if (!include) { + return clone(order); + } + const record = clone(order); + if (include.events) { + record.events = state.events + .filter((entry) => entry.orderId === order.id) + .sort((left, right) => right.occurredAt - left.occurredAt) + .map(clone); + } + if (include.artifacts) { + record.artifacts = state.artifacts + .filter((entry) => entry.orderId === order.id) + .sort((left, right) => right.version - left.version) + .map(clone); + } + return record; + }, + async update({ where, data }) { + const order = getOrderById(where.id); + if (!order) { + throw new Error(`Order not found for update: ${where.id}`); + } + applyDataPatch(order, data); + order.updatedAt = new Date(); + return clone(order); + }, + async findMany({ where, take } = {}) { + return state.orders + .filter((order) => { + if (where?.sourceType && order.sourceType !== where.sourceType) { + return false; + } + if (where?.status && order.status !== where.status) { + return false; + } + if (where?.nextRetryAt?.lte && !(order.nextRetryAt && order.nextRetryAt <= where.nextRetryAt.lte)) { + return false; + } + return true; + }) + .sort((left, right) => right.createdAt - left.createdAt) + .slice(0, take ?? state.orders.length) + .map(clone); + }, + }, + certificateOrderEvent: { + async create({ data }) { + state.eventSequence += 1; + const event = { + id: `event-${state.eventSequence}`, + occurredAt: data.occurredAt ?? new Date(), + details: data.details ?? null, + ...data, + }; + state.events.push(event); + return clone(event); + }, + }, + certificateArtifactVersion: { + async findFirst({ where, orderBy } = {}) { + const results = state.artifacts.filter((artifact) => + matchesArtifactWhere(artifact, where), + ); + if (orderBy?.version === 'desc') { + results.sort((left, right) => right.version - left.version); + } + return results[0] ? clone(results[0]) : null; + }, + async findUnique({ where }) { + const artifact = state.artifacts.find((entry) => entry.id === where.id) ?? null; + return artifact ? clone(artifact) : null; + }, + async create({ data }) { + state.artifactSequence += 1; + const artifact = { + id: `artifact-${state.artifactSequence}`, + createdAt: new Date(), + activatedAt: data.activatedAt ?? null, + isCurrent: data.isCurrent ?? false, + distributionStatus: data.distributionStatus ?? null, + distributionOperationId: data.distributionOperationId ?? null, + distributionCompletedAt: data.distributionCompletedAt ?? null, + metadata: data.metadata ?? null, + ...data, + }; + state.artifacts.push(artifact); + return clone(artifact); + }, + async update({ where, data }) { + const artifact = state.artifacts.find((entry) => entry.id === where.id) ?? null; + if (!artifact) { + throw new Error(`Artifact not found for update: ${where.id}`); + } + applyDataPatch(artifact, data); + return clone(artifact); + }, + async updateMany({ where, data }) { + const matches = state.artifacts.filter((artifact) => + matchesArtifactWhere(artifact, where), + ); + for (const artifact of matches) { + applyDataPatch(artifact, data); + } + return { count: matches.length }; + }, + }, + clusterOperation: { + async findUnique({ where }) { + const operation = + clusterState.operations.find((entry) => entry.id === where.id) ?? null; + return operation ? clone(operation) : null; + }, + }, + proxyEntry: { + async findMany() { + return []; + }, + }, + }; +} + +function createHarness() { + process.env.ADMIN_EMAIL = 'certificate-distribution@example.test'; + resetModules(); + const { CertificateOrderService } = require(certificateOrderServicePath); + const { CertificateService } = require(certificateServicePath); + + const clusterState = { + sequence: 0, + mode: 'succeeded', + operations: [], + }; + const prisma = createPrismaMock(clusterState); + const orderService = new CertificateOrderService(prisma); + const alertService = { + sendAlert: async () => undefined, + }; + const clusterOperations = { + async enqueueBroadcastOperation(options) { + clusterState.sequence += 1; + const operationId = `op-${clusterState.sequence}`; + await options.localAction(operationId); + const operation = + clusterState.mode === 'succeeded' + ? { + id: operationId, + status: 'succeeded', + completedAt: new Date(), + acknowledgements: [ + { + nodeInstanceId: 'node-1', + nodeHostname: 'node-1', + endpointUrl: null, + status: 'succeeded', + responseStatus: 200, + errorMessage: null, + startedAt: new Date(), + ackedAt: new Date(), + details: { status: 'activated' }, + }, + { + nodeInstanceId: 'node-2', + nodeHostname: 'node-2', + endpointUrl: 'http://node-2.internal:3000/certificates/artifacts/activate', + status: 'succeeded', + responseStatus: 200, + errorMessage: null, + startedAt: new Date(), + ackedAt: new Date(), + details: { status: 'activated' }, + }, + ], + } + : { + id: operationId, + status: 'partially_failed', + completedAt: new Date(), + acknowledgements: [ + { + nodeInstanceId: 'node-1', + nodeHostname: 'node-1', + endpointUrl: null, + status: 'succeeded', + responseStatus: 200, + errorMessage: null, + startedAt: new Date(), + ackedAt: new Date(), + details: { status: 'activated' }, + }, + { + nodeInstanceId: 'node-2', + nodeHostname: 'node-2', + endpointUrl: 'http://node-2.internal:3000/certificates/artifacts/activate', + status: 'failed', + responseStatus: 500, + errorMessage: 'node-2 failed activation', + startedAt: new Date(), + ackedAt: new Date(), + details: { status: 'failed' }, + }, + ], + }; + clusterState.operations.push(operation); + return { operationId }; + }, + async waitForOperationToSettle(operationId) { + const operation = + clusterState.operations.find((entry) => entry.id === operationId) ?? null; + if (!operation) { + throw new Error(`Unknown operation: ${operationId}`); + } + return clone(operation); + }, + }; + const distributedLock = { + getInstanceId: () => 'node-1', + withLock: async (_name, fn) => fn(), + acquireLeaderLock: async () => false, + releaseLeaderLock: async () => undefined, + releaseAllLocks: async () => undefined, + isLeader: async () => false, + }; + const healthService = { + recordCertificateSyncFailure() {}, + recordCertificateSyncSuccess() {}, + }; + const service = new CertificateService( + prisma, + alertService, + orderService, + clusterOperations, + distributedLock, + healthService, + null, + undefined, + ); + + return { prisma, orderService, service, clusterState }; +} + +beforeEach(() => { + process.env.ADMIN_EMAIL = 'certificate-distribution@example.test'; + restoreExecFile(); + resetModules(); +}); + +afterEach(() => { + restoreExecFile(); + resetModules(); + + if (originalAdminEmail === undefined) { + delete process.env.ADMIN_EMAIL; + } else { + process.env.ADMIN_EMAIL = originalAdminEmail; + } +}); + +describe('cluster certificate distribution and activation', () => { + it('retries failed distribution by reusing the stored artifact instead of reissuing certificate material', async () => { + let certificateSequence = 0; + const calls = installExecFileStub(async ({ command, args }) => { + if (command === 'nginx') { + return { stdout: '' }; + } + + assert.equal(command, 'openssl'); + + if (args[0] === 'genrsa') { + fs.writeFileSync( + args[2], + '-----BEGIN PRIVATE KEY-----\nunit-test\n-----END PRIVATE KEY-----\n', + ); + return { stdout: '' }; + } + + if (args[0] === 'req') { + certificateSequence += 1; + const outIndex = args.indexOf('-out'); + fs.writeFileSync( + args[outIndex + 1], + `-----BEGIN CERTIFICATE-----\nversion-${certificateSequence}\n-----END CERTIFICATE-----\n`, + ); + return { stdout: '' }; + } + + if (args[0] === 'x509' && args.includes('-pubkey')) { + return { stdout: 'unit-test-public-key\n' }; + } + + if (args[0] === 'pkey') { + return { stdout: 'unit-test-public-key\n' }; + } + + if (args[0] === 'x509' && args.includes('-enddate')) { + return { stdout: 'notAfter=Jan 1 00:00:00 2035 GMT\n' }; + } + + if (args[0] === 'x509' && args.includes('-text')) { + return { + stdout: `Certificate Data\nX509v3 Subject Alternative Name:\n DNS:example.com\n`, + }; + } + + throw new Error(`Unexpected command: ${args.join(' ')}`); + }); + + const { orderService, service, clusterState } = createHarness(); + clusterState.mode = 'partially_failed'; + service.writeCertToFs = () => undefined; + + await assert.rejects( + () => service.generateSelfSignedCertificate(['Example.com']), + /node-2 failed activation/, + ); + + const failedList = await orderService.listOrders(); + const failedOrder = await orderService.getOrder(failedList.orders[0].id); + + assert.equal(failedOrder.status, 'failed'); + assert.equal(failedOrder.artifacts.length, 1); + assert.equal(failedOrder.artifacts[0].distributionStatus, 'partially_failed'); + assert.equal(failedOrder.latestDistribution?.acknowledgements.length, 2); + assert.equal(certificateSequence, 1); + + clusterState.mode = 'succeeded'; + const retriedOrder = await service.retryCertificateOrder(failedOrder.id); + + assert.equal(retriedOrder.status, 'activated'); + assert.equal(retriedOrder.artifacts.length, 1); + assert.equal(retriedOrder.artifacts[0].isCurrent, true); + assert.equal(retriedOrder.artifacts[0].distributionStatus, 'succeeded'); + assert.equal(retriedOrder.latestDistribution?.status, 'succeeded'); + assert.equal(certificateSequence, 1); + assert.equal( + calls.filter((call) => call.command === 'openssl' && call.args[0] === 'req').length, + 1, + ); + }); + + it('supports rolling back to the prior activated artifact version', async () => { + let certificateSequence = 0; + installExecFileStub(async ({ command, args }) => { + if (command === 'nginx') { + return { stdout: '' }; + } + + assert.equal(command, 'openssl'); + + if (args[0] === 'genrsa') { + fs.writeFileSync( + args[2], + `-----BEGIN PRIVATE KEY-----\nkey-${certificateSequence + 1}\n-----END PRIVATE KEY-----\n`, + ); + return { stdout: '' }; + } + + if (args[0] === 'req') { + certificateSequence += 1; + const outIndex = args.indexOf('-out'); + fs.writeFileSync( + args[outIndex + 1], + `-----BEGIN CERTIFICATE-----\nversion-${certificateSequence}\n-----END CERTIFICATE-----\n`, + ); + return { stdout: '' }; + } + + if (args[0] === 'x509' && args.includes('-pubkey')) { + return { stdout: 'unit-test-public-key\n' }; + } + + if (args[0] === 'pkey') { + return { stdout: 'unit-test-public-key\n' }; + } + + if (args[0] === 'x509' && args.includes('-enddate')) { + return { stdout: 'notAfter=Jan 1 00:00:00 2035 GMT\n' }; + } + + if (args[0] === 'x509' && args.includes('-text')) { + return { + stdout: `Certificate Data\nX509v3 Subject Alternative Name:\n DNS:example.com\n`, + }; + } + + throw new Error(`Unexpected command: ${args.join(' ')}`); + }); + + const { prisma, orderService, service } = createHarness(); + service.writeCertToFs = () => undefined; + + const firstCertificate = await service.generateSelfSignedCertificate(['example.com']); + const secondCertificate = await service.generateSelfSignedCertificate(['example.com']); + const rolledBack = await service.rollbackCertificate(secondCertificate.id); + const rollbackOrder = await orderService.getOrder(rolledBack.orderId); + const currentArtifacts = prisma.state.artifacts.filter((artifact) => artifact.isCurrent); + const certificateRecord = prisma.state.certificates[0]; + + assert.equal(firstCertificate.id, secondCertificate.id); + assert.equal(rolledBack.rollbackToVersion, 1); + assert.equal(rollbackOrder.status, 'activated'); + assert.equal(rollbackOrder.latestDistribution?.status, 'succeeded'); + assert.equal(currentArtifacts.length, 1); + assert.equal(currentArtifacts[0].version, 1); + assert.match(certificateRecord.certPem, /version-1/); + }); +}); + diff --git a/test/integration/certificate-order-state-machine.test.js b/test/integration/certificate-order-state-machine.test.js new file mode 100644 index 0000000..d1da0e2 --- /dev/null +++ b/test/integration/certificate-order-state-machine.test.js @@ -0,0 +1,668 @@ +require('reflect-metadata'); + +const { afterEach, beforeEach, describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const childProcess = require('node:child_process'); + +const repoRoot = path.resolve(__dirname, '../..'); +const certificateServicePath = path.join( + repoRoot, + 'src/certificate/certificate.service.ts', +); +const certificateOrderServicePath = path.join( + repoRoot, + 'src/certificate/certificate-order.service.ts', +); +const domainUtilsPath = path.join(repoRoot, 'src/utils/domain-utils.ts'); + +const originalAdminEmail = process.env.ADMIN_EMAIL; +const originalDatabaseUrl = process.env.DATABASE_URL; +const originalExecFile = childProcess.execFile; + +function resetModules() { + delete require.cache[require.resolve(certificateServicePath)]; + delete require.cache[require.resolve(certificateOrderServicePath)]; + delete require.cache[require.resolve(domainUtilsPath)]; +} + +function installExecFileStub(handler) { + childProcess.execFile = (command, args, options, callback) => { + if (typeof options === 'function') { + callback = options; + options = {}; + } + + const stdin = { + end() {}, + }; + + Promise.resolve() + .then(() => handler({ command, args, options })) + .then((result) => callback?.(null, result?.stdout ?? '', result?.stderr ?? '')) + .catch((error) => callback?.(error, '', error?.stderr ?? '')); + + return { stdin }; + }; +} + +function restoreExecFile() { + childProcess.execFile = originalExecFile; +} + +function clone(value) { + return structuredClone(value); +} + +function applyDataPatch(record, data) { + for (const [key, value] of Object.entries(data)) { + if ( + value && + typeof value === 'object' && + !Array.isArray(value) && + Object.keys(value).length === 1 && + Object.prototype.hasOwnProperty.call(value, 'increment') + ) { + record[key] = (record[key] ?? 0) + value.increment; + continue; + } + + record[key] = value; + } +} + +function createPrismaMock(clusterState) { + const state = { + certificateSequence: 0, + orderSequence: 0, + eventSequence: 0, + artifactSequence: 0, + certificates: [], + orders: [], + events: [], + artifacts: [], + }; + + function getCertificateById(id) { + return state.certificates.find((certificate) => certificate.id === id) ?? null; + } + + function getOrderById(id) { + return state.orders.find((order) => order.id === id) ?? null; + } + + function matchesCertificateWhere(certificate, where = {}) { + if (where.id && certificate.id !== where.id) { + return false; + } + if (where.domainsHash && certificate.domainsHash !== where.domainsHash) { + return false; + } + if (where.isOrphaned !== undefined && certificate.isOrphaned !== where.isOrphaned) { + return false; + } + if (where.domains?.contains && !certificate.domains.includes(where.domains.contains)) { + return false; + } + if (where.expiresAt?.gt && !(certificate.expiresAt > where.expiresAt.gt)) { + return false; + } + if (where.issuedAt?.gte && !(certificate.issuedAt >= where.issuedAt.gte)) { + return false; + } + if (where.retryAfter?.lte && !(certificate.retryAfter && certificate.retryAfter <= where.retryAfter.lte)) { + return false; + } + if (where.status && certificate.status !== where.status) { + return false; + } + return true; + } + + function matchesOrderWhere(order, where = {}) { + if (where.id && order.id !== where.id) { + return false; + } + if (where.domainsHash && order.domainsHash !== where.domainsHash) { + return false; + } + if (where.sourceType && order.sourceType !== where.sourceType) { + return false; + } + if (typeof where.status === 'string' && order.status !== where.status) { + return false; + } + if (where.status?.in && !where.status.in.includes(order.status)) { + return false; + } + if (where.nextRetryAt?.lte && !(order.nextRetryAt && order.nextRetryAt <= where.nextRetryAt.lte)) { + return false; + } + return true; + } + + return { + state, + certificate: { + async create({ data }) { + state.certificateSequence += 1; + const now = new Date(); + const certificate = { + id: `cert-${state.certificateSequence}`, + status: data.status ?? 'active', + failureReason: data.failureReason ?? null, + retryAfter: data.retryAfter ?? null, + failureCount: data.failureCount ?? 0, + issuedByNode: data.issuedByNode ?? null, + createdAt: data.createdAt ?? now, + updatedAt: data.updatedAt ?? now, + ...data, + }; + state.certificates.push(certificate); + return clone(certificate); + }, + async findFirst({ where, orderBy } = {}) { + const results = state.certificates.filter((certificate) => + matchesCertificateWhere(certificate, where), + ); + if (orderBy?.expiresAt === 'desc') { + results.sort((left, right) => right.expiresAt - left.expiresAt); + } + return results[0] ? clone(results[0]) : null; + }, + async findUnique({ where }) { + const certificate = where.id + ? getCertificateById(where.id) + : state.certificates.find( + (entry) => entry.domainsHash === where.domainsHash, + ) ?? null; + return certificate ? clone(certificate) : null; + }, + async update({ where, data }) { + const certificate = where.id + ? getCertificateById(where.id) + : state.certificates.find( + (entry) => entry.domainsHash === where.domainsHash, + ) ?? null; + if (!certificate) { + throw new Error(`Certificate not found for update: ${JSON.stringify(where)}`); + } + applyDataPatch(certificate, data); + certificate.updatedAt = new Date(); + return clone(certificate); + }, + async upsert({ where, update, create }) { + const existing = state.certificates.find( + (certificate) => certificate.domainsHash === where.domainsHash, + ); + if (existing) { + applyDataPatch(existing, update); + existing.updatedAt = new Date(); + return clone(existing); + } + return this.create({ data: create }); + }, + async findMany({ where, orderBy } = {}) { + const results = state.certificates.filter((certificate) => + matchesCertificateWhere(certificate, where), + ); + if (orderBy?.expiresAt === 'asc') { + results.sort((left, right) => left.expiresAt - right.expiresAt); + } + return results.map(clone); + }, + async count({ where } = {}) { + return state.certificates.filter((certificate) => + matchesCertificateWhere(certificate, where), + ).length; + }, + async delete() { + return undefined; + }, + }, + certificateOrder: { + async create({ data }) { + state.orderSequence += 1; + const now = new Date(); + const order = { + id: `order-${state.orderSequence}`, + sourceType: data.sourceType ?? 'acme', + status: data.status ?? 'requested', + attemptCount: data.attemptCount ?? 1, + retryCount: data.retryCount ?? 0, + nextRetryAt: data.nextRetryAt ?? null, + lastError: data.lastError ?? null, + requestedByNode: data.requestedByNode ?? null, + certificateId: data.certificateId ?? null, + requestedAt: data.requestedAt ?? now, + startedAt: data.startedAt ?? null, + challengePublishedAt: data.challengePublishedAt ?? null, + validatingAt: data.validatingAt ?? null, + issuedAt: data.issuedAt ?? null, + distributingAt: data.distributingAt ?? null, + activatedAt: data.activatedAt ?? null, + failedAt: data.failedAt ?? null, + revokedAt: data.revokedAt ?? null, + completedAt: data.completedAt ?? null, + metadata: data.metadata ?? null, + createdAt: now, + updatedAt: now, + ...data, + }; + state.orders.push(order); + return clone(order); + }, + async findFirst({ where, orderBy } = {}) { + const results = state.orders.filter((order) => matchesOrderWhere(order, where)); + if (orderBy?.createdAt === 'desc') { + results.sort((left, right) => right.createdAt - left.createdAt); + } + return results[0] ? clone(results[0]) : null; + }, + async findUnique({ where, include, select } = {}) { + const order = getOrderById(where.id); + if (!order) { + return null; + } + if (select) { + const record = {}; + for (const [key, enabled] of Object.entries(select)) { + if (enabled) { + record[key] = order[key]; + } + } + return clone(record); + } + if (!include) { + return clone(order); + } + const record = clone(order); + if (include.events) { + record.events = state.events + .filter((event) => event.orderId === order.id) + .sort((left, right) => right.occurredAt - left.occurredAt) + .map(clone); + } + if (include.artifacts) { + record.artifacts = state.artifacts + .filter((artifact) => artifact.orderId === order.id) + .sort((left, right) => right.version - left.version) + .map(clone); + } + return record; + }, + async update({ where, data }) { + const order = getOrderById(where.id); + if (!order) { + throw new Error(`Certificate order not found: ${where.id}`); + } + applyDataPatch(order, data); + order.updatedAt = new Date(); + return clone(order); + }, + async findMany({ where, take } = {}) { + return state.orders + .filter((order) => matchesOrderWhere(order, where)) + .sort((left, right) => right.createdAt - left.createdAt) + .slice(0, take ?? state.orders.length) + .map(clone); + }, + }, + certificateOrderEvent: { + async create({ data }) { + state.eventSequence += 1; + const event = { + id: `event-${state.eventSequence}`, + ...data, + details: data.details ?? null, + occurredAt: data.occurredAt ?? new Date(), + }; + state.events.push(event); + return clone(event); + }, + }, + certificateArtifactVersion: { + async findFirst({ where, orderBy } = {}) { + const results = state.artifacts.filter((artifact) => { + if (where?.domainsHash && artifact.domainsHash !== where.domainsHash) { + return false; + } + if (where?.orderId && artifact.orderId !== where.orderId) { + return false; + } + if (where?.isCurrent !== undefined && artifact.isCurrent !== where.isCurrent) { + return false; + } + if (where?.version?.lt !== undefined && !(artifact.version < where.version.lt)) { + return false; + } + if (where?.activatedAt?.not === null && artifact.activatedAt === null) { + return false; + } + return true; + }); + if (orderBy?.version === 'desc') { + results.sort((left, right) => right.version - left.version); + } + return results[0] ? clone(results[0]) : null; + }, + async findUnique({ where }) { + const artifact = state.artifacts.find((entry) => entry.id === where.id) ?? null; + return artifact ? clone(artifact) : null; + }, + async create({ data }) { + state.artifactSequence += 1; + const artifact = { + id: `artifact-${state.artifactSequence}`, + createdAt: new Date(), + activatedAt: data.activatedAt ?? null, + isCurrent: data.isCurrent ?? false, + distributionStatus: data.distributionStatus ?? null, + distributionOperationId: data.distributionOperationId ?? null, + distributionCompletedAt: data.distributionCompletedAt ?? null, + metadata: data.metadata ?? null, + ...data, + }; + state.artifacts.push(artifact); + return clone(artifact); + }, + async update({ where, data }) { + const artifact = state.artifacts.find((entry) => entry.id === where.id) ?? null; + if (!artifact) { + throw new Error(`Artifact not found for update: ${where.id}`); + } + applyDataPatch(artifact, data); + return clone(artifact); + }, + async updateMany({ where, data }) { + const matches = state.artifacts.filter((artifact) => { + if (where?.domainsHash && artifact.domainsHash !== where.domainsHash) { + return false; + } + if (where?.id?.not && artifact.id === where.id.not) { + return false; + } + return true; + }); + for (const artifact of matches) { + applyDataPatch(artifact, data); + } + return { count: matches.length }; + }, + }, + proxyEntry: { + async findMany() { + return []; + }, + }, + clusterOperation: { + async findUnique({ where }) { + const operation = + clusterState.operations.find((entry) => entry.id === where.id) ?? null; + return operation ? clone(operation) : null; + }, + }, + }; +} + +function createHarness(acmeService = undefined) { + process.env.ADMIN_EMAIL = 'certificate-order@example.test'; + resetModules(); + const { CertificateOrderService } = require(certificateOrderServicePath); + const { CertificateService } = require(certificateServicePath); + + const clusterState = { + operations: [], + }; + const prisma = createPrismaMock(clusterState); + const alertService = { + sendAlert: async () => undefined, + }; + const orderService = new CertificateOrderService(prisma); + const clusterOperations = { + enqueueBroadcastOperation: async (options) => { + await options.localAction('op-1'); + clusterState.operations.push({ + id: 'op-1', + status: 'succeeded', + completedAt: new Date(), + acknowledgements: [ + { + nodeInstanceId: 'node-1', + nodeHostname: 'node-1', + endpointUrl: null, + status: 'succeeded', + responseStatus: 200, + errorMessage: null, + startedAt: new Date(), + ackedAt: new Date(), + details: { status: 'activated' }, + }, + ], + }); + return { operationId: 'op-1' }; + }, + waitForOperationToSettle: async () => ({ + id: 'op-1', + status: 'succeeded', + completedAt: new Date(), + acknowledgements: [ + { + nodeHostname: 'node-1', + nodeInstanceId: 'node-1', + status: 'succeeded', + errorMessage: null, + }, + ], + }), + }; + const distributedLock = { + getInstanceId: () => 'node-1', + withLock: async (_name, fn) => fn(), + acquireLeaderLock: async () => false, + releaseLeaderLock: async () => undefined, + releaseAllLocks: async () => undefined, + isLeader: async () => false, + }; + const healthService = { + recordCertificateSyncFailure() {}, + recordCertificateSyncSuccess() {}, + }; + const service = new CertificateService( + prisma, + alertService, + orderService, + clusterOperations, + distributedLock, + healthService, + null, + acmeService, + ); + + service.writeCertToFs = () => undefined; + + return { prisma, service, orderService }; +} + +beforeEach(() => { + process.env.ADMIN_EMAIL = 'certificate-order@example.test'; + delete process.env.NODE_ENV; + restoreExecFile(); + resetModules(); +}); + +afterEach(() => { + restoreExecFile(); + resetModules(); + + if (originalAdminEmail === undefined) { + delete process.env.ADMIN_EMAIL; + } else { + process.env.ADMIN_EMAIL = originalAdminEmail; + } + + if (originalDatabaseUrl === undefined) { + delete process.env.DATABASE_URL; + } else { + process.env.DATABASE_URL = originalDatabaseUrl; + } +}); + +describe('certificate order state machine', () => { + it('records self-signed certificate workflows as durable orders with artifact history', async () => { + installExecFileStub(async ({ command, args }) => { + if (command === 'nginx') { + return { stdout: '' }; + } + + assert.equal(command, 'openssl'); + + if (args[0] === 'genrsa') { + fs.writeFileSync( + args[2], + '-----BEGIN PRIVATE KEY-----\nunit-test\n-----END PRIVATE KEY-----\n', + ); + return { stdout: '' }; + } + + if (args[0] === 'req') { + const outIndex = args.indexOf('-out'); + fs.writeFileSync( + args[outIndex + 1], + '-----BEGIN CERTIFICATE-----\nunit-test\n-----END CERTIFICATE-----\n', + ); + return { stdout: '' }; + } + + if (args[0] === 'x509' && args.includes('-pubkey')) { + return { stdout: 'unit-test-public-key\n' }; + } + + if (args[0] === 'pkey') { + return { stdout: 'unit-test-public-key\n' }; + } + + if (args[0] === 'x509' && args.includes('-enddate')) { + return { stdout: 'notAfter=Jan 1 00:00:00 2035 GMT\n' }; + } + + if (args[0] === 'x509' && args.includes('-text')) { + return { + stdout: + 'Certificate Data\nX509v3 Subject Alternative Name:\n DNS:example.com\n', + }; + } + + throw new Error(`Unexpected command: ${args.join(' ')}`); + }); + + const { service, orderService } = createHarness(); + const record = await service.generateSelfSignedCertificate(['Example.com']); + const orders = await orderService.listOrders(); + const detail = await orderService.getOrder(orders.orders[0].id); + + assert.equal(record.domains, 'example.com'); + assert.equal(orders.count, 1); + assert.equal(orders.orders[0].status, 'activated'); + assert.equal(orders.orders[0].sourceType, 'self-signed'); + assert.equal(detail.artifacts.length, 1); + assert.equal(detail.artifacts[0].certificateId, record.id); + assert.equal(detail.artifacts[0].version, 1); + assert.equal(detail.artifacts[0].sourceType, 'self-signed'); + assert.equal(Object.hasOwn(detail.artifacts[0], 'keyPem'), false); + assert.equal(detail.events.length, 5); + assert.equal( + detail.events.filter((event) => event.eventType === 'state-transition') + .length, + 3, + ); + assert.equal( + detail.events.some((event) => event.eventType === 'artifact-created'), + true, + ); + assert.equal( + detail.events.some((event) => event.eventType === 'created'), + true, + ); + assert.deepEqual( + detail.events + .filter((event) => event.toStatus) + .map((event) => event.toStatus) + .sort(), + ['activated', 'distributing', 'issued', 'requested'].sort(), + ); + }); + + it('persists failure history and resumes the same ACME order on manual retry', async () => { + const { service, orderService, prisma } = createHarness({ + obtainCertificate: async () => { + throw new Error('Simulated ACME issuance failure'); + }, + listChallenges: async () => ({ count: 0, challenges: [] }), + }); + const { hashDomains } = require(domainUtilsPath); + const domains = ['example.com']; + const domainsHash = hashDomains(domains, { allowWildcard: true }); + + await assert.rejects( + () => service.ensureCertificate(domains), + /Simulated ACME issuance failure/, + ); + + const firstList = await orderService.listOrders(); + const failedOrder = await orderService.getOrder(firstList.orders[0].id); + + assert.equal(failedOrder.status, 'failed'); + assert.equal(failedOrder.retryCount, 0); + assert.equal(failedOrder.attemptCount, 1); + assert.ok(failedOrder.nextRetryAt instanceof Date); + assert.match(failedOrder.lastError ?? '', /Simulated ACME issuance failure/); + assert.equal( + failedOrder.events.some((event) => event.eventType === 'retry-scheduled'), + true, + ); + + const futureExpiry = new Date(Date.now() + 45 * 24 * 60 * 60 * 1000); + prisma.state.certificates.push({ + id: 'cert-existing', + domains: 'example.com', + domainsHash, + certPem: '-----BEGIN CERTIFICATE-----\nexisting\n-----END CERTIFICATE-----\n', + keyPem: '-----BEGIN PRIVATE KEY-----\nexisting\n-----END PRIVATE KEY-----\n', + expiresAt: futureExpiry, + issuedAt: new Date(), + lastUsedAt: new Date(), + isOrphaned: false, + createdAt: new Date(), + updatedAt: new Date(), + status: 'active', + failureReason: null, + retryAfter: null, + failureCount: 0, + issuedByNode: 'node-2', + }); + + const retriedOrder = await service.retryCertificateOrder(failedOrder.id); + + assert.equal(retriedOrder.id, failedOrder.id); + assert.equal(retriedOrder.status, 'activated'); + assert.equal(retriedOrder.retryCount, 1); + assert.equal(retriedOrder.attemptCount, 2); + assert.equal(retriedOrder.certificateId, 'cert-existing'); + assert.equal(retriedOrder.lastError, null); + assert.equal( + retriedOrder.events.some((event) => event.eventType === 'retry-requested'), + true, + ); + assert.equal( + retriedOrder.events.some( + (event) => + event.toStatus === 'activated' && + /Reused existing valid certificate/i.test(event.message ?? ''), + ), + true, + ); + }); +}); + diff --git a/test/integration/cluster-operations.test.js b/test/integration/cluster-operations.test.js new file mode 100644 index 0000000..e740684 --- /dev/null +++ b/test/integration/cluster-operations.test.js @@ -0,0 +1,341 @@ +require('reflect-metadata'); + +const { afterEach, describe, it } = require('node:test'); +const assert = require('node:assert/strict'); + +const { + ClusterOperationsService, +} = require('../../src/distributed-lock/cluster-operations.service'); + +function createNode(overrides = {}) { + const now = new Date(); + + return { + id: overrides.id ?? `id-${overrides.instanceId ?? 'node-a'}`, + hostname: overrides.hostname ?? overrides.instanceId ?? 'node-a', + instanceId: overrides.instanceId ?? 'node-a', + ipAddress: overrides.ipAddress ?? '10.0.0.1', + isLeader: overrides.isLeader ?? false, + lastHeartbeat: overrides.lastHeartbeat ?? now, + version: overrides.version ?? '0.0.1', + status: overrides.status ?? 'active', + metadata: + overrides.metadata ?? + { + controlPlane: { + baseUrl: `http://${overrides.hostname ?? overrides.instanceId ?? 'node-a'}.internal:3000`, + address: `${overrides.hostname ?? overrides.instanceId ?? 'node-a'}.internal`, + port: 3000, + protocol: 'http', + }, + }, + createdAt: overrides.createdAt ?? now, + updatedAt: overrides.updatedAt ?? now, + }; +} + +function createPrismaMock() { + const state = { + operationSequence: 0, + ackSequence: 0, + operations: [], + acknowledgements: [], + }; + + function clone(value) { + return JSON.parse(JSON.stringify(value)); + } + + function getOperation(operationId) { + return state.operations.find((operation) => operation.id === operationId) ?? null; + } + + function getAcknowledgements(operationId) { + return state.acknowledgements.filter((ack) => ack.operationId === operationId); + } + + return { + state, + clusterOperation: { + async create({ data }) { + const now = new Date(); + state.operationSequence += 1; + const operation = { + id: `operation-${state.operationSequence}`, + operationType: data.operationType, + scope: data.scope ?? 'cluster', + status: data.status ?? 'pending', + initiatorNodeId: data.initiatorNodeId ?? null, + initiatorHostname: data.initiatorHostname ?? null, + initiatorActorId: data.initiatorActorId ?? null, + initiatorActorType: data.initiatorActorType ?? null, + initiatorActorDisplayName: data.initiatorActorDisplayName ?? null, + correlationId: data.correlationId ?? null, + requestPath: data.requestPath ?? null, + targetNodeCount: data.targetNodeCount ?? 0, + completedNodeCount: data.completedNodeCount ?? 0, + successfulNodeCount: data.successfulNodeCount ?? 0, + failedNodeCount: data.failedNodeCount ?? 0, + startedAt: data.startedAt ?? null, + completedAt: data.completedAt ?? null, + lastError: data.lastError ?? null, + metadata: data.metadata ?? null, + createdAt: now, + updatedAt: now, + }; + + state.operations.push(operation); + + for (const ackData of data.acknowledgements?.create ?? []) { + state.ackSequence += 1; + state.acknowledgements.push({ + id: `ack-${state.ackSequence}`, + operationId: operation.id, + nodeInstanceId: ackData.nodeInstanceId, + nodeHostname: ackData.nodeHostname ?? null, + endpointUrl: ackData.endpointUrl ?? null, + status: ackData.status ?? 'pending', + responseStatus: ackData.responseStatus ?? null, + errorMessage: ackData.errorMessage ?? null, + startedAt: ackData.startedAt ?? null, + ackedAt: ackData.ackedAt ?? null, + details: ackData.details ?? null, + createdAt: now, + updatedAt: now, + }); + } + + return clone(operation); + }, + async update({ where, data }) { + const operation = getOperation(where.id); + if (!operation) { + throw new Error(`Operation not found: ${where.id}`); + } + + Object.assign(operation, data, { updatedAt: new Date() }); + return clone(operation); + }, + async findUniqueOrThrow({ where }) { + const operation = getOperation(where.id); + if (!operation) { + throw new Error(`Operation not found: ${where.id}`); + } + return clone(operation); + }, + async findUnique({ where, include }) { + const operation = getOperation(where.id); + if (!operation) { + return null; + } + + if (!include?.acknowledgements) { + return clone(operation); + } + + const acknowledgements = getAcknowledgements(where.id) + .slice() + .sort((left, right) => + `${left.nodeHostname ?? ''}:${left.nodeInstanceId}`.localeCompare( + `${right.nodeHostname ?? ''}:${right.nodeInstanceId}`, + ), + ); + + return { + ...clone(operation), + acknowledgements: clone(acknowledgements), + }; + }, + async findMany({ take } = {}) { + return state.operations + .slice() + .sort((left, right) => right.createdAt - left.createdAt) + .slice(0, take ?? state.operations.length) + .map(clone); + }, + }, + clusterOperationAck: { + async update({ where, data }) { + const ack = state.acknowledgements.find( + (entry) => + entry.operationId === where.operationId_nodeInstanceId.operationId && + entry.nodeInstanceId === where.operationId_nodeInstanceId.nodeInstanceId, + ); + + if (!ack) { + throw new Error( + `Acknowledgement not found for ${where.operationId_nodeInstanceId.operationId}/${where.operationId_nodeInstanceId.nodeInstanceId}`, + ); + } + + Object.assign(ack, data, { updatedAt: new Date() }); + return clone(ack); + }, + async findMany({ where, select } = {}) { + const acknowledgements = state.acknowledgements.filter((ack) => { + if (!where?.operationId) { + return true; + } + return ack.operationId === where.operationId; + }); + + if (!select) { + return acknowledgements.map(clone); + } + + return acknowledgements.map((ack) => { + const selected = {}; + for (const [key, enabled] of Object.entries(select)) { + if (enabled) { + selected[key] = ack[key]; + } + } + return selected; + }); + }, + }, + }; +} + +async function waitForOperationToSettle(service, operationId) { + for (let attempt = 0; attempt < 20; attempt += 1) { + const operation = await service.getOperation(operationId); + if (operation && !['pending', 'running'].includes(operation.status)) { + return operation; + } + + await new Promise((resolve) => setTimeout(resolve, 0)); + } + + throw new Error(`Operation ${operationId} did not settle in time`); +} + +describe('cluster operations and per-node acknowledgements', () => { + const originalFetch = global.fetch; + const originalApiKey = process.env.API_KEY; + + afterEach(() => { + global.fetch = originalFetch; + + if (originalApiKey === undefined) { + delete process.env.API_KEY; + } else { + process.env.API_KEY = originalApiKey; + } + }); + + it('tracks successful local and remote acknowledgements for a cluster-wide reload operation', async () => { + process.env.API_KEY = 'cluster-operations-peer-key'; + const fetchCalls = []; + global.fetch = async (url, options) => { + fetchCalls.push({ url, options }); + return new Response(JSON.stringify({ status: 'succeeded' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }; + + const prisma = createPrismaMock(); + const service = new ClusterOperationsService( + prisma, + { + async getActiveNodes() { + return [ + createNode({ instanceId: 'node-a', hostname: 'node-a' }), + createNode({ instanceId: 'node-b', hostname: 'node-b' }), + ]; + }, + }, + { + getInstanceId() { + return 'node-a'; + }, + }, + ); + + let localExecutions = 0; + const accepted = await service.enqueueBroadcastOperation({ + operationType: 'cluster.reload', + remotePath: '/cluster/reload', + remoteQuery: { broadcast: 'false' }, + initiatedBy: { + correlationId: 'corr-123', + requestPath: '/cluster/reload?broadcast=true', + }, + localAction: async () => { + localExecutions += 1; + return { ok: true }; + }, + }); + + const operation = await waitForOperationToSettle( + service, + accepted.operationId, + ); + const operationsList = await service.listOperations(); + + assert.ok(['pending', 'running'].includes(accepted.status)); + assert.equal(localExecutions, 1); + assert.equal(fetchCalls.length, 1); + assert.match(fetchCalls[0].url, /\/cluster\/reload\?broadcast=false&operationId=/); + assert.equal(fetchCalls[0].options.headers['X-API-Key'], 'cluster-operations-peer-key'); + + assert.equal(operation.status, 'succeeded'); + assert.equal(operation.targetNodeCount, 2); + assert.equal(operation.successfulNodeCount, 2); + assert.equal(operation.failedNodeCount, 0); + assert.equal(operation.acknowledgements.length, 2); + assert.deepEqual( + operation.acknowledgements.map((ack) => ack.status), + ['succeeded', 'succeeded'], + ); + assert.equal(operationsList.count, 1); + assert.equal(operationsList.operations[0].operationId, accepted.operationId); + }); + + it('records remote acknowledgement failures when peer authentication is unavailable', async () => { + delete process.env.API_KEY; + global.fetch = async () => { + throw new Error('fetch should not be called when API_KEY is missing'); + }; + + const prisma = createPrismaMock(); + const service = new ClusterOperationsService( + prisma, + { + async getActiveNodes() { + return [ + createNode({ instanceId: 'node-a', hostname: 'node-a' }), + createNode({ instanceId: 'node-b', hostname: 'node-b' }), + ]; + }, + }, + { + getInstanceId() { + return 'node-a'; + }, + }, + ); + + const accepted = await service.enqueueBroadcastOperation({ + operationType: 'certificate.sync', + remotePath: '/certificates/sync', + localAction: async () => ({ success: true }), + }); + + const operation = await waitForOperationToSettle( + service, + accepted.operationId, + ); + const remoteAck = operation.acknowledgements.find( + (ack) => ack.nodeInstanceId === 'node-b', + ); + + assert.equal(operation.status, 'partially_failed'); + assert.equal(operation.successfulNodeCount, 1); + assert.equal(operation.failedNodeCount, 1); + assert.equal(remoteAck?.status, 'failed'); + assert.match(remoteAck?.errorMessage ?? '', /No API key is configured/); + }); +}); + diff --git a/test/run-suite.js b/test/run-suite.js new file mode 100644 index 0000000..951de99 --- /dev/null +++ b/test/run-suite.js @@ -0,0 +1,158 @@ +#!/usr/bin/env node + +const { spawnSync } = require('node:child_process'); +const fs = require('node:fs'); +const path = require('node:path'); +const { BASELINE_PILLARS, getSuiteDefinitions } = require('./test-harness.config'); + +const repoRoot = path.resolve(__dirname, '..'); +const suiteDefinitions = getSuiteDefinitions(); +const validSuites = Object.keys(suiteDefinitions); +const args = process.argv.slice(2); + +const options = { + coverage: false, + list: false, +}; +const requestedSuites = []; + +for (const arg of args) { + if (arg === '--coverage') { + options.coverage = true; + continue; + } + + if (arg === '--list') { + options.list = true; + continue; + } + + requestedSuites.push(arg); +} + +if (options.list) { + console.log('Available test suites:\n'); + for (const [suiteName, suite] of Object.entries(suiteDefinitions)) { + console.log( + `- ${suiteName}: ${suite.description} (${suite.files.length} file${suite.files.length === 1 ? '' : 's'})`, + ); + } + + console.log('\nBaseline coverage pillars:\n'); + for (const pillar of BASELINE_PILLARS) { + console.log(`- ${pillar.name}`); + for (const file of pillar.coverage) { + console.log(` - ${file}`); + } + } + + process.exit(0); +} + +const suitesToRun = requestedSuites.length > 0 ? requestedSuites : ['all']; +const invalidSuites = suitesToRun.filter((suite) => !validSuites.includes(suite)); +if (invalidSuites.length > 0) { + console.error( + `Unknown test suite(s): ${invalidSuites.join(', ')}. Valid suites: ${validSuites.join(', ')}`, + ); + process.exit(1); +} + +const classifiedFiles = new Set( + suiteDefinitions.all.files.map((file) => normalizeRelativePath(file)), +); +const discoveredFiles = discoverTestFiles(path.join(repoRoot, 'test')); +const unclassifiedFiles = discoveredFiles.filter((file) => !classifiedFiles.has(file)); +const missingFiles = [...classifiedFiles].filter((file) => !discoveredFiles.includes(file)); + +if (unclassifiedFiles.length > 0 || missingFiles.length > 0) { + if (unclassifiedFiles.length > 0) { + console.error('Unclassified test files found in the repository:'); + for (const file of unclassifiedFiles) { + console.error(`- ${file}`); + } + } + + if (missingFiles.length > 0) { + console.error('Classified test files missing from disk:'); + for (const file of missingFiles) { + console.error(`- ${file}`); + } + } + + process.exit(1); +} + +const filesToRun = unique( + suitesToRun.flatMap((suite) => suiteDefinitions[suite].files), +).map((file) => path.join(repoRoot, file)); + +if (filesToRun.length === 0) { + console.error('No test files selected for execution.'); + process.exit(1); +} + +console.log( + `Running ${suitesToRun.join(', ')} suite${suitesToRun.length === 1 ? '' : 's'} (${filesToRun.length} file${filesToRun.length === 1 ? '' : 's'})${options.coverage ? ' with coverage' : ''}...`, +); + +const childArgs = []; +if (options.coverage) { + childArgs.push('--experimental-test-coverage'); + childArgs.push('--test-concurrency=1'); +} +childArgs.push( + '--test', + '-r', + 'ts-node/register/transpile-only', + '-r', + path.join(repoRoot, 'test/setup/register.js'), + ...filesToRun, +); + +const result = spawnSync(process.execPath, childArgs, { + cwd: repoRoot, + stdio: 'inherit', + env: { + ...process.env, + TS_NODE_PROJECT: + process.env.TS_NODE_PROJECT ?? path.join(repoRoot, 'tsconfig.test.json'), + }, +}); + +if (typeof result.status === 'number') { + process.exit(result.status); +} + +process.exit(1); + +function discoverTestFiles(rootDir) { + const results = []; + + walk(rootDir); + return results.sort(); + + function walk(currentDir) { + const entries = fs.readdirSync(currentDir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(currentDir, entry.name); + if (entry.isDirectory()) { + walk(fullPath); + continue; + } + + if (entry.isFile() && entry.name.endsWith('.test.js')) { + results.push(normalizeRelativePath(path.relative(repoRoot, fullPath))); + } + } + } +} + +function normalizeRelativePath(relativePath) { + return relativePath.split(path.sep).join('/'); +} + +function unique(values) { + return [...new Set(values)]; +} + diff --git a/test/setup/register.js b/test/setup/register.js new file mode 100644 index 0000000..844d94f --- /dev/null +++ b/test/setup/register.js @@ -0,0 +1,4 @@ +require('reflect-metadata'); + +process.env.NODE_ENV ??= 'test'; + diff --git a/test/test-harness.config.js b/test/test-harness.config.js new file mode 100644 index 0000000..313e6b4 --- /dev/null +++ b/test/test-harness.config.js @@ -0,0 +1,130 @@ +const UNIT_TEST_FILES = [ + 'test/unit/health-semantics.test.js', + 'test/unit/entrypoint-recovery.test.js', + 'test/unit/inter-node-addressing.test.js', + 'test/unit/lease-backed-heartbeat.test.js', + 'test/unit/transactional-nginx-rollout.test.js', + 'test/unit/nginx-custom-code-guardrails.test.js', + 'test/unit/domain-validation-and-safe-process.test.js', + 'test/unit/private-key-encryption-at-rest.test.js', + 'test/unit/structured-logging.test.js', + 'test/unit/metrics-and-alerting.test.js', + 'test/unit/auth-service-unit.test.js', + 'test/unit/release-gating.test.js', +]; + +const INTEGRATION_TEST_FILES = [ + 'test/integration/cluster-operations.test.js', + 'test/integration/certificate-order-state-machine.test.js', + 'test/integration/certificate-distribution-and-rollback.test.js', + 'test/integration/acme-strategy-hardening.test.js', + 'test/integration/backup-hardening.test.js', +]; + +const E2E_TEST_FILES = [ + 'test/e2e/auth-lockdown.test.js', + 'test/e2e/auth-foundation.test.js', + 'test/e2e/rbac-authorization.test.js', + 'test/e2e/audit-logging.test.js', + 'test/e2e/proxy-management-api.test.js', + 'test/e2e/cluster-admin-apis.test.js', + 'test/e2e/security-admin-apis.test.js', +]; + +const CHAOS_TEST_FILES = [ + 'test/chaos/chaos-fault-injection.test.js', +]; + +const BASE_SUITE_DEFINITIONS = { + unit: { + description: + 'Fast isolated checks for health semantics, leases, config generation, crypto helpers, and logging/metrics primitives.', + files: UNIT_TEST_FILES, + }, + integration: { + description: + 'Workflow-level checks for cluster operations, certificate orders, artifact rollout, ACME orchestration, and backup flows.', + files: INTEGRATION_TEST_FILES, + }, + e2e: { + description: + 'Nest application and controller surface checks for auth, RBAC, audit, proxy, cluster-admin, and security-admin APIs.', + files: E2E_TEST_FILES, + }, + chaos: { + description: + 'Deterministic fault-injection checks for DB outages, lease recovery, NGINX crashes, rollback safety, node comms failures, and partial certificate activation failures.', + files: CHAOS_TEST_FILES, + }, +}; + +const BASELINE_PILLARS = [ + { + name: 'auth', + coverage: [ + 'test/unit/auth-service-unit.test.js', + 'test/e2e/auth-lockdown.test.js', + 'test/e2e/auth-foundation.test.js', + 'test/e2e/rbac-authorization.test.js', + 'test/e2e/security-admin-apis.test.js', + ], + }, + { + name: 'health', + coverage: [ + 'test/unit/health-semantics.test.js', + 'test/unit/metrics-and-alerting.test.js', + ], + }, + { + name: 'leases', + coverage: [ + 'test/unit/lease-backed-heartbeat.test.js', + 'test/integration/cluster-operations.test.js', + ], + }, + { + name: 'config-generation', + coverage: [ + 'test/unit/transactional-nginx-rollout.test.js', + 'test/unit/nginx-custom-code-guardrails.test.js', + ], + }, + { + name: 'certificate-order-transitions', + coverage: [ + 'test/integration/certificate-order-state-machine.test.js', + 'test/integration/certificate-distribution-and-rollback.test.js', + 'test/integration/acme-strategy-hardening.test.js', + ], + }, + { + name: 'fault-injection', + coverage: ['test/chaos/chaos-fault-injection.test.js'], + }, +]; + +function unique(values) { + return [...new Set(values)]; +} + +function getSuiteDefinitions() { + const allFiles = unique( + Object.values(BASE_SUITE_DEFINITIONS).flatMap((suite) => suite.files), + ); + + return { + ...BASE_SUITE_DEFINITIONS, + all: { + description: + 'All classified repository suites spanning unit, integration, e2e, and chaos coverage pillars.', + files: allFiles, + }, + }; +} + +module.exports = { + BASELINE_PILLARS, + getSuiteDefinitions, +}; + diff --git a/test/unit/auth-service-unit.test.js b/test/unit/auth-service-unit.test.js new file mode 100644 index 0000000..68d419d --- /dev/null +++ b/test/unit/auth-service-unit.test.js @@ -0,0 +1,147 @@ +const { afterEach, describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const { createHmac } = require('node:crypto'); +const path = require('node:path'); + +const repoRoot = path.resolve(__dirname, '../..'); +const authServiceModulePath = path.join(repoRoot, 'src/auth/auth.service.ts'); + +const managedEnvKeys = [ + 'API_KEY', + 'AUTH_JWT_SECRET', + 'AUTH_JWT_ISSUER', + 'AUTH_JWT_AUDIENCE', + 'AUTH_DEFAULT_ADMIN_ROLES', + 'AUTH_DEFAULT_ADMIN_SCOPES', + 'AUTH_ACCESS_TOKEN_TTL_SECONDS', + 'AUTH_JWT_ALLOWED_ALGS', +]; +const originalEnv = Object.fromEntries( + managedEnvKeys.map((key) => [key, process.env[key]]), +); + +function restoreEnv() { + for (const [key, value] of Object.entries(originalEnv)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } +} + +function resetAuthServiceModule() { + delete require.cache[require.resolve(authServiceModulePath)]; +} + +function createAuthService() { + resetAuthServiceModule(); + const { AuthService } = require(authServiceModulePath); + return new AuthService(); +} + +function signHs256Token(payload, secret) { + const header = { alg: 'HS256', typ: 'JWT' }; + const encodedHeader = Buffer.from(JSON.stringify(header)).toString('base64url'); + const encodedPayload = Buffer.from(JSON.stringify(payload)).toString('base64url'); + const signingInput = `${encodedHeader}.${encodedPayload}`; + const signature = createHmac('sha256', secret) + .update(signingInput) + .digest('base64url'); + return `${signingInput}.${signature}`; +} + +afterEach(() => { + restoreEnv(); + resetAuthServiceModule(); +}); + +describe('auth service unit harness baseline', () => { + it('round-trips configured API-key identities through local bearer-token issuance', () => { + process.env.API_KEY = 'alpha-key,beta-key'; + process.env.AUTH_JWT_SECRET = 'auth-service-unit-secret'; + process.env.AUTH_JWT_ISSUER = 'auth-service-unit.test'; + process.env.AUTH_JWT_AUDIENCE = 'auth-service-unit-admin'; + process.env.AUTH_DEFAULT_ADMIN_ROLES = 'platform-admin,operator'; + process.env.AUTH_DEFAULT_ADMIN_SCOPES = 'admin:full,cluster:read'; + process.env.AUTH_ACCESS_TOKEN_TTL_SECONDS = '300'; + + const service = createAuthService(); + const configuredKeys = service.listConfiguredApiKeys(); + const identity = service.authenticateApiKey(' beta-key '); + + assert.equal(service.isAuthEnabled(), true); + assert.equal(configuredKeys.length, 2); + assert.match(configuredKeys[0].fingerprint, /^[a-f0-9]{16}$/); + assert.ok(identity); + assert.equal(identity.authMethod, 'api-key'); + assert.equal(identity.actorType, 'admin'); + assert.deepEqual(identity.roles, ['platform-admin', 'operator']); + assert.deepEqual(identity.scopes, ['admin:full', 'cluster:read']); + + identity.roles.push('mutated-locally'); + const freshIdentity = service.authenticateApiKey('beta-key'); + assert.deepEqual(freshIdentity.roles, ['platform-admin', 'operator']); + + const issued = service.issueAccessToken(freshIdentity); + const resolved = service.authenticateBearerToken(issued.accessToken); + + assert.equal(resolved.authMethod, 'bearer-token'); + assert.equal(resolved.actorType, 'admin'); + assert.equal(resolved.issuer, 'auth-service-unit.test'); + assert.deepEqual(resolved.audience, ['auth-service-unit-admin']); + assert.deepEqual(resolved.roles, ['platform-admin', 'operator']); + assert.deepEqual(resolved.scopes, ['admin:full', 'cluster:read']); + assert.equal(service.authenticateApiKey('missing-key'), null); + }); + + it('rejects bearer tokens with the wrong audience or an unsupported algorithm', () => { + process.env.AUTH_JWT_SECRET = 'auth-service-unit-secret'; + process.env.AUTH_JWT_ISSUER = 'auth-service-unit.test'; + process.env.AUTH_JWT_AUDIENCE = 'auth-service-unit-admin'; + process.env.AUTH_JWT_ALLOWED_ALGS = 'HS256'; + + const service = createAuthService(); + const now = Math.floor(Date.now() / 1000); + + const wrongAudienceToken = signHs256Token( + { + sub: 'operator-1', + iss: 'auth-service-unit.test', + aud: 'different-audience', + iat: now, + nbf: now, + exp: now + 120, + roles: ['operator'], + scope: 'cluster:read', + }, + 'auth-service-unit-secret', + ); + + assert.throws( + () => service.authenticateBearerToken(wrongAudienceToken), + /audience is invalid/i, + ); + + const unsupportedHeader = Buffer.from( + JSON.stringify({ alg: 'none', typ: 'JWT' }), + ).toString('base64url'); + const unsupportedPayload = Buffer.from( + JSON.stringify({ + sub: 'operator-2', + iss: 'auth-service-unit.test', + aud: 'auth-service-unit-admin', + iat: now, + nbf: now, + exp: now + 120, + }), + ).toString('base64url'); + const unsupportedToken = `${unsupportedHeader}.${unsupportedPayload}.unsigned`; + + assert.throws( + () => service.authenticateBearerToken(unsupportedToken), + /unsupported bearer token algorithm/i, + ); + }); +}); + diff --git a/test/unit/domain-validation-and-safe-process.test.js b/test/unit/domain-validation-and-safe-process.test.js new file mode 100644 index 0000000..849cc6a --- /dev/null +++ b/test/unit/domain-validation-and-safe-process.test.js @@ -0,0 +1,430 @@ +require('reflect-metadata'); + +const { afterEach, beforeEach, describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const repoRoot = path.resolve(__dirname, '../..'); +const childProcess = require('node:child_process'); + +const domainUtilsPath = path.join(repoRoot, 'src/utils/domain-utils.ts'); +const processUtilsPath = path.join(repoRoot, 'src/utils/process-utils.ts'); +const certificateServicePath = path.join( + repoRoot, + 'src/certificate/certificate.service.ts', +); +const tlsConfigServicePath = path.join( + repoRoot, + 'src/certificate/tls-config.service.ts', +); +const nginxServicePath = path.join(repoRoot, 'src/nginx/nginx.service.ts'); + +const originalAdminEmail = process.env.ADMIN_EMAIL; +const originalExecFile = childProcess.execFile; + +function resetModules() { + delete require.cache[require.resolve(domainUtilsPath)]; + delete require.cache[require.resolve(processUtilsPath)]; + delete require.cache[require.resolve(certificateServicePath)]; + delete require.cache[require.resolve(tlsConfigServicePath)]; + delete require.cache[require.resolve(nginxServicePath)]; +} + +function installExecFileStub(handler) { + const calls = []; + childProcess.execFile = (command, args, options, callback) => { + if (typeof options === 'function') { + callback = options; + options = {}; + } + + calls.push({ command, args: [...args], options: { ...options } }); + + const stdin = { + end() {}, + }; + + Promise.resolve() + .then(() => handler({ command, args, options })) + .then((result) => { + callback?.(null, result?.stdout ?? '', result?.stderr ?? ''); + }) + .catch((error) => { + callback?.(error, '', error?.stderr ?? ''); + }); + + return { stdin }; + }; + + return calls; +} + +function restoreExecFile() { + childProcess.execFile = originalExecFile; +} + +function loadDomainUtils() { + resetModules(); + return require(domainUtilsPath); +} + +function createCertificateService() { + process.env.ADMIN_EMAIL = 'domain-validation@example.test'; + resetModules(); + const { CertificateService } = require(certificateServicePath); + + const state = { + artifact: null, + }; + + const prisma = { + certificate: { + create: async ({ data }) => ({ id: 'cert-1', ...data }), + findFirst: async () => null, + update: async () => undefined, + upsert: async ({ create }) => ({ id: 'cert-1', ...create }), + findUnique: async () => null, + findMany: async () => [], + count: async () => 0, + delete: async () => undefined, + }, + certificateOrder: { + findUnique: async () => ({ id: 'order-1', attemptCount: 1 }), + findMany: async () => [], + }, + certificateArtifactVersion: { + update: async ({ data }) => { + state.artifact = { + ...(state.artifact ?? {}), + ...data, + }; + return state.artifact; + }, + updateMany: async () => ({ count: state.artifact ? 1 : 0 }), + }, + proxyEntry: { + findMany: async () => [], + }, + }; + const alertService = { + sendAlert: async () => undefined, + }; + const certificateOrders = { + getOrCreateOrder: async () => ({ id: 'order-1', attemptCount: 1 }), + transitionOrder: async () => undefined, + markFailure: async () => undefined, + recordArtifact: async (params) => { + state.artifact = { + id: 'artifact-1', + version: 1, + domains: params.domains.join(';'), + domainsHash: 'artifact-hash', + certPem: params.certPem, + keyPem: params.keyPem, + expiresAt: params.expiresAt, + issuedAt: params.issuedAt, + activatedAt: params.activatedAt ?? null, + isCurrent: false, + distributionStatus: null, + distributionOperationId: null, + distributionCompletedAt: null, + createdByNode: params.createdByNode ?? null, + certificateId: params.certificateId ?? null, + metadata: params.metadata ?? null, + orderId: params.orderId, + }; + return { id: state.artifact.id, version: state.artifact.version }; + }, + getArtifact: async () => state.artifact, + getCurrentArtifactForDomainsHash: async () => null, + getLatestArtifactForOrder: async () => state.artifact, + getRollbackArtifactForDomainsHash: async () => null, + validateRetryableOrder: async () => ({ id: 'order-1', sourceType: 'self-signed' }), + resumeOrder: async () => ({ id: 'order-1' }), + completeWithCertificate: async () => undefined, + getOrder: async () => ({ id: 'order-1', status: 'activated' }), + }; + const clusterOperations = { + enqueueBroadcastOperation: async (options) => { + await options.localAction('op-1'); + return { operationId: 'op-1' }; + }, + waitForOperationToSettle: async () => ({ + status: 'succeeded', + completedAt: new Date(), + acknowledgements: [ + { + nodeHostname: 'node-1', + nodeInstanceId: 'node-1', + status: 'succeeded', + errorMessage: null, + }, + ], + }), + }; + const distributedLock = { + getInstanceId: () => 'node-1', + withLock: async (_name, fn) => fn(), + acquireLeaderLock: async () => false, + releaseLeaderLock: async () => undefined, + releaseAllLocks: async () => undefined, + isLeader: async () => false, + }; + const healthService = { + recordCertificateSyncFailure() {}, + recordCertificateSyncSuccess() {}, + }; + + return new CertificateService( + prisma, + alertService, + certificateOrders, + clusterOperations, + distributedLock, + healthService, + null, + undefined, + ); +} + +beforeEach(() => { + process.env.ADMIN_EMAIL = 'domain-validation@example.test'; + restoreExecFile(); + resetModules(); +}); + +afterEach(() => { + restoreExecFile(); + resetModules(); + if (originalAdminEmail === undefined) { + delete process.env.ADMIN_EMAIL; + } else { + process.env.ADMIN_EMAIL = originalAdminEmail; + } +}); + +describe('strict domain validation and safe process execution', () => { + it('normalizes Unicode domains to lowercase ASCII and derives safe certificate storage names', () => { + const { normalizeDomain, normalizeDomains, getCertificateStorageName } = + loadDomainUtils(); + + assert.equal(normalizeDomain(' Exämple.COM. '), 'xn--exmple-cua.com'); + assert.deepEqual(normalizeDomains(['Example.com', ' exämple.com. ']), [ + 'example.com', + 'xn--exmple-cua.com', + ]); + + const storageName = getCertificateStorageName('*.Exämple.com'); + assert.match(storageName, /^cert-wild-[a-z0-9-]+-[a-f0-9]{16}$/); + assert.doesNotMatch(storageName, /[*/\\\s]/); + }); + + it('rejects malformed, local-only, and path-like domains early', () => { + const { normalizeDomain } = loadDomainUtils(); + + assert.throws(() => normalizeDomain('localhost'), /fully-qualified/i); + assert.throws(() => normalizeDomain('127.0.0.1'), /IP addresses/i); + assert.throws( + () => normalizeDomain('bad/domain.example'), + /path separators/i, + ); + assert.throws( + () => normalizeDomain('bad_domain.example'), + /unsupported characters detected/i, + ); + assert.throws( + () => normalizeDomain('*.example.com', { allowWildcard: false }), + /wildcard domains are not allowed/i, + ); + }); + + it('parses, joins, hashes, and detects wildcard domain sets consistently', () => { + const { + containsWildcardDomain, + hashDomains, + joinDomains, + parseDomains, + normalizeDomains, + } = loadDomainUtils(); + + assert.deepEqual(parseDomains(' example.com ; *.Example.com ', { allowWildcard: true }), [ + 'example.com', + '*.example.com', + ]); + assert.equal(joinDomains(['Example.com', 'example.com']), 'example.com'); + assert.equal( + hashDomains(['*.example.com', 'example.com'], { allowWildcard: true }), + hashDomains('example.com;*.example.com', { allowWildcard: true }), + ); + assert.equal(containsWildcardDomain(['example.com', '*.example.com']), true); + assert.throws(() => normalizeDomains([]), /at least one domain is required/i); + assert.throws(() => parseDomains(42), /domain input must be a string/i); + }); + + it('uses argument-array OpenSSL execution for TLS connection tests', async () => { + const calls = installExecFileStub(async ({ command, args }) => { + assert.equal(command, 'openssl'); + assert.deepEqual(args.slice(0, 2), ['s_client', '-connect']); + return { + stdout: 'Protocol : TLSv1.3\nCipher : TLS_AES_256_GCM_SHA384\n', + }; + }); + + resetModules(); + const { TlsConfigService } = require(tlsConfigServicePath); + const service = new TlsConfigService(); + const result = await service.testTlsConnection('Exämple.com'); + + assert.equal(result.success, true); + assert.equal(result.protocol, 'TLSv1.3'); + assert.equal(result.cipher, 'TLS_AES_256_GCM_SHA384'); + assert.equal(calls.length, 1); + assert.deepEqual(calls[0].args, [ + 's_client', + '-connect', + 'xn--exmple-cua.com:443', + '-servername', + 'xn--exmple-cua.com', + ]); + }); + + it('rejects wildcard ACME issuance because the hardened flow does not require DNS TXT changes', async () => { + resetModules(); + const { resolveAcmeStrategy } = require( + path.join(repoRoot, 'src/certificate/acme-strategy.ts'), + ); + + assert.throws( + () => + resolveAcmeStrategy(['*.example.com', 'example.com'], { + ACME_CHALLENGE_STRATEGY: 'auto', + }), + /would require DNS TXT record changes/i, + ); + }); + + it('generates self-signed certificates through safe OpenSSL args and stores normalized domains', async () => { + const calls = installExecFileStub(async ({ command, args }) => { + if (command === 'nginx') { + return { stdout: '' }; + } + + assert.equal(command, 'openssl'); + + if (args[0] === 'genrsa') { + fs.writeFileSync( + args[2], + '-----BEGIN PRIVATE KEY-----\nunit-test\n-----END PRIVATE KEY-----\n', + ); + return { stdout: '' }; + } + + if (args[0] === 'req') { + const outIndex = args.indexOf('-out'); + fs.writeFileSync( + args[outIndex + 1], + '-----BEGIN CERTIFICATE-----\nunit-test\n-----END CERTIFICATE-----\n', + ); + return { stdout: '' }; + } + + if (args[0] === 'x509' && args.includes('-pubkey')) { + return { stdout: 'unit-test-public-key\n' }; + } + + if (args[0] === 'pkey') { + return { stdout: 'unit-test-public-key\n' }; + } + + if (args[0] === 'x509' && args.includes('-enddate')) { + return { stdout: 'notAfter=Jan 1 00:00:00 2035 GMT\n' }; + } + + if (args[0] === 'x509' && args.includes('-text')) { + return { + stdout: + 'Certificate Data\nX509v3 Subject Alternative Name:\n DNS:xn--exmple-cua.com\n', + }; + } + + throw new Error(`Unexpected command: ${args.join(' ')}`); + }); + + const service = createCertificateService(); + let writtenCertificate = null; + service.writeCertToFs = (primaryDomain, certPem, keyPem) => { + writtenCertificate = { primaryDomain, certPem, keyPem }; + }; + + const record = await service.generateSelfSignedCertificate([ + ' Exämple.com ', + ]); + const generationCalls = calls.filter( + (call) => + call.command === 'openssl' && + (call.args[0] === 'genrsa' || call.args[0] === 'req'), + ); + + assert.equal(record.domains, 'xn--exmple-cua.com'); + assert.equal(writtenCertificate.primaryDomain, 'xn--exmple-cua.com'); + assert.equal(generationCalls.length, 2); + assert.deepEqual(generationCalls[0].args, [ + 'genrsa', + '-out', + generationCalls[0].args[2], + '2048', + ]); + assert.deepEqual(generationCalls[1].args.slice(0, 8), [ + 'req', + '-new', + '-x509', + '-key', + generationCalls[0].args[2], + '-out', + generationCalls[1].args[6], + '-days', + ]); + assert.equal( + generationCalls[1].args[generationCalls[1].args.indexOf('-subj') + 1], + '/CN=xn--exmple-cua.com', + ); + assert.equal( + generationCalls[1].args[generationCalls[1].args.indexOf('-addext') + 1], + 'subjectAltName=DNS:xn--exmple-cua.com', + ); + }); + + it('renders wildcard server_name values while using safe certificate storage directories', () => { + const { NginxService } = require(nginxServicePath); + const { getCertificateStorageName } = require(domainUtilsPath); + const service = new NginxService(); + const storageName = getCertificateStorageName('*.example.com'); + + const existsSync = fs.existsSync; + fs.existsSync = (filePath) => + typeof filePath === 'string' && filePath.includes(storageName); + + try { + const config = service.generateNginxConfig([ + { + id: 'proxy-1', + domains: '*.example.com;example.com', + proxy_pass_host: 'http://upstream.internal:8080', + ssl: true, + type: 'PROXY', + nginx_custom_code: '', + }, + ]); + + assert.match(config, /server_name \*\.example\.com example\.com;/); + assert.match( + config, + new RegExp(`/etc/letsencrypt/live/${storageName}/fullchain\\.pem`), + ); + assert.doesNotMatch(config, /\/etc\/letsencrypt\/live\/\*\.example\.com/); + } finally { + fs.existsSync = existsSync; + } + }); +}); diff --git a/test/unit/entrypoint-recovery.test.js b/test/unit/entrypoint-recovery.test.js new file mode 100644 index 0000000..98797c4 --- /dev/null +++ b/test/unit/entrypoint-recovery.test.js @@ -0,0 +1,324 @@ +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs/promises'); +const path = require('node:path'); +const os = require('node:os'); +const { spawn } = require('node:child_process'); + +const repoRoot = path.resolve(__dirname, '../..'); +const entrypointPath = path.join(repoRoot, 'docker-entrypoint.sh'); + +async function writeExecutable(filePath, content) { + await fs.writeFile(filePath, content, { mode: 0o755 }); + await fs.chmod(filePath, 0o755); +} + +function collectProcessResult(child) { + let stdout = ''; + let stderr = ''; + + child.stdout.on('data', (chunk) => { + stdout += chunk.toString(); + }); + + child.stderr.on('data', (chunk) => { + stderr += chunk.toString(); + }); + + return new Promise((resolve, reject) => { + child.once('error', reject); + child.once('close', (code, signal) => resolve({ code, signal, stdout, stderr })); + }); +} + +async function readLog(logPath) { + try { + return await fs.readFile(logPath, 'utf8'); + } catch (error) { + if (error && error.code === 'ENOENT') { + return ''; + } + + throw error; + } +} + +async function waitForLogEntries(logPath, expectedEntries, timeoutMs = 10000) { + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + const log = await readLog(logPath); + if (expectedEntries.every((entry) => log.includes(entry))) { + return log; + } + + await new Promise((resolve) => setTimeout(resolve, 50)); + } + + throw new Error( + `Timed out waiting for log entries: ${expectedEntries.join(', ')}\nCurrent log:\n${await readLog(logPath)}`, + ); +} + +async function waitForResult(processResultPromise, timeoutMs = 10000) { + return Promise.race([ + processResultPromise, + new Promise((_, reject) => { + setTimeout(() => reject(new Error(`Timed out waiting for entrypoint to exit after ${timeoutMs}ms`)), timeoutMs); + }), + ]); +} + +function stopIfRunning(child, signal = 'SIGKILL') { + if (child.exitCode === null && child.signalCode === null) { + child.kill(signal); + } +} + +async function createHarness(envOverrides = {}) { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'lyttlenginx-entrypoint-recovery-')); + const binDir = path.join(tempDir, 'bin'); + const logPath = path.join(tempDir, 'process.log'); + const nginxRuntimeDir = path.join(tempDir, 'nginx-runtime'); + const nginxSourceDir = path.join(tempDir, 'nginx-source'); + await fs.mkdir(binDir, { recursive: true }); + await fs.mkdir(path.join(nginxSourceDir, 'conf.d'), { recursive: true }); + await fs.mkdir(path.join(nginxSourceDir, 'html', 'errors'), { recursive: true }); + await fs.writeFile(path.join(nginxSourceDir, 'conf.d', 'default.conf'), 'server { listen 80; }\n'); + await fs.writeFile(path.join(nginxSourceDir, 'html', 'index.html'), '<html><body>ok</body></html>\n'); + await fs.writeFile(path.join(nginxSourceDir, 'html', 'errors', '5xx.html'), '<html><body>error</body></html>\n'); + await fs.writeFile(path.join(nginxSourceDir, 'mime.types'), 'types {}\n'); + await fs.writeFile(path.join(nginxSourceDir, 'nginx.conf'), 'events {}\nhttp { include mime.types; }\n'); + + await writeExecutable( + path.join(binDir, 'node'), + `#!/usr/bin/env bash +set -euo pipefail + +if [ "\${1:-}" = "--version" ]; then + echo "v24.16.0-test" + exit 0 +fi + +if [ "\${1:-}" = "dist/main.js" ]; then + echo "node:start" >> "$ENTRYPOINT_TEST_LOG" + trap 'echo "node:term" >> "$ENTRYPOINT_TEST_LOG"; exit 0' TERM INT QUIT + + if [ "\${FAKE_NODE_MODE:-steady}" = "crash" ]; then + sleep "\${FAKE_NODE_CRASH_DELAY_SECONDS:-1}" + echo "node:crash" >> "$ENTRYPOINT_TEST_LOG" + exit "\${FAKE_NODE_EXIT_CODE:-37}" + fi + + while true; do + sleep 1 + done +fi + +echo "unexpected node args: $*" >&2 +exit 9 +`, + ); + + await writeExecutable( + path.join(binDir, 'npm'), + `#!/usr/bin/env bash +set -euo pipefail + +if [ "\${1:-}" = "--version" ]; then + echo "11.15.0-test" + exit 0 +fi + +echo "unexpected npm args: $*" >&2 +exit 10 +`, + ); + + await writeExecutable( + path.join(binDir, 'npx'), + `#!/usr/bin/env bash +set -euo pipefail + +if [ "\${1:-}" = "prisma" ] && [ "\${2:-}" = "generate" ]; then + echo "npx:prisma-generate" >> "$ENTRYPOINT_TEST_LOG" + exit 0 +fi + +if [ "\${1:-}" = "prisma" ] && [ "\${2:-}" = "migrate" ] && [ "\${3:-}" = "deploy" ]; then + echo "npx:prisma-migrate-deploy" >> "$ENTRYPOINT_TEST_LOG" + exit 0 +fi + +echo "unexpected npx args: $*" >&2 +exit 11 +`, + ); + + await writeExecutable( + path.join(binDir, 'nginx'), + `#!/usr/bin/env bash +set -euo pipefail + +if [ "\${1:-}" = "-t" ]; then + echo "nginx:test-ok" >> "$ENTRYPOINT_TEST_LOG" + exit 0 +fi + +if [ "\${1:-}" = "-g" ] && [ "\${2:-}" = "daemon off;" ]; then + echo "nginx:start" >> "$ENTRYPOINT_TEST_LOG" + trap 'echo "nginx:quit" >> "$ENTRYPOINT_TEST_LOG"; exit 0' TERM INT QUIT + + if [ "\${FAKE_NGINX_MODE:-steady}" = "crash" ]; then + sleep "\${FAKE_NGINX_CRASH_DELAY_SECONDS:-1}" + echo "nginx:crash" >> "$ENTRYPOINT_TEST_LOG" + exit "\${FAKE_NGINX_EXIT_CODE:-23}" + fi + + while true; do + sleep 1 + done +fi + +if [ "\${1:-}" = "-s" ] && [ "\${2:-}" = "quit" ]; then + echo "nginx:quit-command" >> "$ENTRYPOINT_TEST_LOG" + exit 0 +fi + +echo "unexpected nginx args: $*" >&2 +exit 12 +`, + ); + + await writeExecutable( + path.join(binDir, 'nc'), + `#!/usr/bin/env bash +set -euo pipefail +exit 0 +`, + ); + + const child = spawn('/bin/bash', [entrypointPath], { + cwd: repoRoot, + env: { + ...process.env, + PATH: `${binDir}:${process.env.PATH}`, + DATABASE_URL: 'postgresql://user:pass@db:5432/lyttlenginx', + HOSTNAME: 'entrypoint-recovery-test-host', + ENTRYPOINT_TEST_LOG: logPath, + SERVICE_STARTUP_GRACE_SECONDS: '1', + NODE_SHUTDOWN_TIMEOUT_SECONDS: '1', + NGINX_SHUTDOWN_TIMEOUT_SECONDS: '1', + DB_CONNECT_RETRY_DELAY_SECONDS: '1', + MIGRATION_RETRY_DELAY_SECONDS: '1', + NGINX_RUNTIME_DIR: nginxRuntimeDir, + NGINX_BUNDLED_SOURCE_DIR: nginxSourceDir, + ...envOverrides, + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + return { + child, + logPath, + resultPromise: collectProcessResult(child), + cleanup: async () => { + stopIfRunning(child); + await fs.rm(tempDir, { recursive: true, force: true }); + }, + }; +} + +describe('entrypoint recovery behavior', () => { + it('exits non-zero and tears down NGINX when the Node process crashes', async () => { + const harness = await createHarness({ + FAKE_NODE_MODE: 'crash', + FAKE_NODE_EXIT_CODE: '37', + FAKE_NODE_CRASH_DELAY_SECONDS: '2', + }); + + try { + const result = await waitForResult(harness.resultPromise, 10000); + const log = await readLog(harness.logPath); + + assert.equal(result.code, 37); + assert.equal(result.signal, null); + assert.match(log, /nginx:start/); + assert.match(log, /node:start/); + assert.match(log, /node:crash/); + assert.match(log, /nginx:quit/); + assert.match(result.stderr, /Node\.js application exited unexpectedly with code 37/); + assert.match(result.stdout, /Container supervision finished with exit code 37/); + } finally { + await harness.cleanup(); + } + }); + + it('exits non-zero and tears down Node.js when the NGINX master crashes', async () => { + const harness = await createHarness({ + FAKE_NGINX_MODE: 'crash', + FAKE_NGINX_EXIT_CODE: '23', + FAKE_NGINX_CRASH_DELAY_SECONDS: '2', + }); + + try { + const result = await waitForResult(harness.resultPromise, 10000); + const log = await readLog(harness.logPath); + + assert.equal(result.code, 23); + assert.equal(result.signal, null); + assert.match(log, /nginx:start/); + assert.match(log, /node:start/); + assert.match(log, /nginx:crash/); + assert.match(log, /node:term/); + assert.match(result.stderr, /NGINX exited unexpectedly with code 23/); + assert.match(result.stdout, /Container supervision finished with exit code 23/); + } finally { + await harness.cleanup(); + } + }); + + it('shuts both supervised processes down cleanly on SIGTERM', async () => { + const harness = await createHarness(); + + try { + await waitForLogEntries(harness.logPath, ['nginx:start', 'node:start'], 10000); + harness.child.kill('SIGTERM'); + + const result = await waitForResult(harness.resultPromise, 10000); + const log = await readLog(harness.logPath); + + assert.equal(result.code, 0); + assert.equal(result.signal, null); + assert.match(log, /node:term/); + assert.ok( + /nginx:quit/.test(log) || + /Stopping NGINX \(PID: .*\) with SIGTERM/.test(result.stdout), + `expected either a graceful fake-nginx quit log or an explicit supervisor shutdown attempt\nlog:\n${log}\nstdout:\n${result.stdout}`, + ); + assert.match(result.stdout, /Received SIGTERM/); + assert.match(result.stdout, /Container supervision finished with exit code 0/); + } finally { + await harness.cleanup(); + } + }); + + it('keeps the manifests on restart-friendly policies with no wedged failure mode', async () => { + const entrypoint = await fs.readFile(path.join(repoRoot, 'docker-entrypoint.sh'), 'utf8'); + const compose = await fs.readFile(path.join(repoRoot, 'docker-compose.yml'), 'utf8'); + const swarm = await fs.readFile(path.join(repoRoot, 'docker-compose.swarm.yml'), 'utf8'); + + assert.doesNotMatch(entrypoint, /sleep infinity/); + assert.doesNotMatch(entrypoint, /restart\.state/); + assert.match(entrypoint, /wait -n -p exited_pid/); + + assert.match(compose, /restart:\s+unless-stopped/); + assert.match(compose, /stop_grace_period:\s+45s/); + assert.doesNotMatch(compose, /network_mode:\s+host/); + + assert.match(swarm, /condition:\s+any/); + assert.match(swarm, /stop_grace_period:\s+45s/); + assert.doesNotMatch(swarm, /max_attempts:/); + }); +}); + diff --git a/test/unit/health-semantics.test.js b/test/unit/health-semantics.test.js new file mode 100644 index 0000000..82deafc --- /dev/null +++ b/test/unit/health-semantics.test.js @@ -0,0 +1,249 @@ +require('reflect-metadata'); + +const { after, before, describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const request = require('supertest'); +const Module = require('node:module'); +const fsPromises = require('node:fs/promises'); +const { Test } = require('@nestjs/testing'); + +class PrismaService {} + +const originalLoad = Module._load; +Module._load = function loadWithPrismaStub(request, parent, isMain) { + if (request === '../prisma/prisma.service') { + return { PrismaService }; + } + + return originalLoad.call(this, request, parent, isMain); +}; + +const { HealthController } = require('../../src/health/health.controller'); +const { HealthService } = require('../../src/health/health.service'); + +Module._load = originalLoad; + +const originalAccess = fsPromises.access; +const originalReadFile = fsPromises.readFile; +const originalKill = process.kill; +const originalConfigMaxAge = process.env.HEALTH_CONFIG_APPLY_MAX_AGE_MS; +const originalCertSyncMaxAge = process.env.HEALTH_CERTIFICATE_SYNC_MAX_AGE_MS; + +function stubHealthyNginx(pid = 4242) { + fsPromises.access = async () => undefined; + fsPromises.readFile = async () => `${pid}\n`; + process.kill = (targetPid, signal) => { + assert.equal(targetPid, pid); + assert.equal(signal, 0); + return true; + }; +} + +function restoreProcessAndFs() { + fsPromises.access = originalAccess; + fsPromises.readFile = originalReadFile; + process.kill = originalKill; +} + +describe('health service semantics', () => { + before(() => { + process.env.HEALTH_CONFIG_APPLY_MAX_AGE_MS = '60000'; + process.env.HEALTH_CERTIFICATE_SYNC_MAX_AGE_MS = '60000'; + }); + + after(() => { + restoreProcessAndFs(); + + if (originalConfigMaxAge === undefined) { + delete process.env.HEALTH_CONFIG_APPLY_MAX_AGE_MS; + } else { + process.env.HEALTH_CONFIG_APPLY_MAX_AGE_MS = originalConfigMaxAge; + } + + if (originalCertSyncMaxAge === undefined) { + delete process.env.HEALTH_CERTIFICATE_SYNC_MAX_AGE_MS; + } else { + process.env.HEALTH_CERTIFICATE_SYNC_MAX_AGE_MS = originalCertSyncMaxAge; + } + }); + + it('keeps startup and readiness unhealthy until initialization milestones succeed', async () => { + stubHealthyNginx(); + + const prisma = { + $queryRawUnsafe: async () => [{ '?column?': 1 }], + }; + const service = new HealthService(prisma); + + const liveReport = await service.live(); + assert.equal(liveReport.status, 'ok'); + assert.equal(liveReport.probe, 'liveness'); + + const startupReport = await service.startup(); + assert.equal(startupReport.status, 'starting'); + assert.deepEqual( + startupReport.checks.map((check) => check.name), + ['config_apply', 'certificate_sync'], + ); + + const readyReport = await service.ready(); + assert.equal(readyReport.status, 'error'); + assert.equal( + readyReport.checks.find((check) => check.name === 'database').status, + 'ok', + ); + assert.equal( + readyReport.checks.find((check) => check.name === 'nginx_master').status, + 'ok', + ); + assert.equal( + readyReport.checks.find((check) => check.name === 'config_apply').status, + 'error', + ); + assert.equal( + readyReport.checks.find((check) => check.name === 'certificate_sync').status, + 'error', + ); + }); + + it('reports startup and readiness healthy after successful config apply and certificate sync', async () => { + stubHealthyNginx(5252); + + const prisma = { + $queryRawUnsafe: async () => [{ '?column?': 1 }], + }; + const service = new HealthService(prisma); + + service.recordConfigApplySuccess('reload completed'); + service.recordCertificateSyncSuccess('sync completed'); + + const startupReport = await service.startup(); + assert.equal(startupReport.status, 'ok'); + + const readyReport = await service.ready(); + assert.equal(readyReport.status, 'ok'); + assert.equal( + readyReport.checks.find((check) => check.name === 'config_apply').status, + 'ok', + ); + assert.equal( + readyReport.checks.find((check) => check.name === 'certificate_sync').status, + 'ok', + ); + }); + + it('fails readiness when the latest successful state is superseded by a failed attempt', async () => { + stubHealthyNginx(6262); + + const prisma = { + $queryRawUnsafe: async () => [{ '?column?': 1 }], + }; + const service = new HealthService(prisma); + + service.recordConfigApplySuccess('reload completed'); + service.recordCertificateSyncSuccess('sync completed'); + service.recordCertificateSyncFailure('sync job failed on latest attempt'); + + const startupReport = await service.startup(); + assert.equal(startupReport.status, 'ok'); + + const readyReport = await service.ready(); + assert.equal(readyReport.status, 'error'); + assert.match( + readyReport.checks.find((check) => check.name === 'certificate_sync').details, + /latest attempt failed/i, + ); + }); +}); + +describe('health controller probes', () => { + let app; + let httpServer; + let startupStatus = 'starting'; + let readyStatus = 'error'; + + const healthServiceMock = { + live: async () => ({ status: 'ok', probe: 'liveness' }), + startup: async () => ({ status: startupStatus, probe: 'startup' }), + ready: async () => ({ status: readyStatus, probe: 'readiness' }), + }; + + before(async () => { + const moduleRef = await Test.createTestingModule({ + controllers: [HealthController], + providers: [ + { + provide: HealthService, + useValue: healthServiceMock, + }, + ], + }).compile(); + + app = moduleRef.createNestApplication(); + await app.init(); + httpServer = app.getHttpServer(); + }); + + after(async () => { + await app?.close(); + }); + + it('keeps the liveness endpoint and legacy alias on HTTP 200', async () => { + await request(httpServer) + .get('/health/live') + .expect(200) + .expect(({ body }) => { + assert.equal(body.status, 'ok'); + assert.equal(body.probe, 'liveness'); + }); + + await request(httpServer) + .get('/health') + .expect(200) + .expect(({ body }) => { + assert.equal(body.status, 'ok'); + assert.equal(body.probe, 'liveness'); + }); + }); + + it('returns 503 for startup and readiness while the node is not yet safe to receive traffic', async () => { + startupStatus = 'starting'; + readyStatus = 'error'; + + await request(httpServer) + .get('/health/startup') + .expect(503) + .expect(({ body }) => assert.equal(body.status, 'starting')); + + await request(httpServer) + .get('/health/ready') + .expect(503) + .expect(({ body }) => assert.equal(body.status, 'error')); + + await request(httpServer) + .get('/ready') + .expect(503) + .expect(({ body }) => assert.equal(body.status, 'error')); + }); + + it('returns 200 from startup and readiness once dependencies are healthy', async () => { + startupStatus = 'ok'; + readyStatus = 'ok'; + + await request(httpServer) + .get('/health/startup') + .expect(200) + .expect(({ body }) => assert.equal(body.status, 'ok')); + + await request(httpServer) + .get('/health/ready') + .expect(200) + .expect(({ body }) => assert.equal(body.status, 'ok')); + + await request(httpServer) + .get('/ready') + .expect(200) + .expect(({ body }) => assert.equal(body.status, 'ok')); + }); +}); + diff --git a/test/unit/inter-node-addressing.test.js b/test/unit/inter-node-addressing.test.js new file mode 100644 index 0000000..39cbd34 --- /dev/null +++ b/test/unit/inter-node-addressing.test.js @@ -0,0 +1,152 @@ +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs/promises'); +const path = require('node:path'); + +const repoRoot = path.resolve(__dirname, '../..'); +const { + buildClusterNodeUrl, + getClusterNodeControlPlaneEndpoint, + getLocalControlPlaneRegistration, +} = require('../../src/utils/network-utils'); + +describe('control-plane endpoint resolution', () => { + it('builds the local advertised endpoint from explicit address/port configuration', () => { + const registration = getLocalControlPlaneRegistration({ + NODE_ENV: 'production', + CLUSTER_CONTROL_ADDRESS: 'node-a.internal', + CLUSTER_CONTROL_PORT: '3003', + CLUSTER_CONTROL_PROTOCOL: 'https', + }); + + assert.deepEqual(registration.issues, []); + assert.deepEqual(registration.endpoint, { + address: 'node-a.internal', + port: 3003, + protocol: 'https', + baseUrl: 'https://node-a.internal:3003', + source: 'CLUSTER_CONTROL_ADDRESS', + }); + }); + + it('accepts a full explicit control-plane URL when it includes a port', () => { + const registration = getLocalControlPlaneRegistration({ + NODE_ENV: 'production', + CLUSTER_CONTROL_URL: 'http://node-b.internal:3100', + }); + + assert.deepEqual(registration.issues, []); + assert.equal(registration.endpoint?.baseUrl, 'http://node-b.internal:3100'); + assert.equal(registration.endpoint?.address, 'node-b.internal'); + assert.equal(registration.endpoint?.port, 3100); + assert.equal(registration.endpoint?.protocol, 'http'); + }); + + it('rejects loopback control-plane addresses in production', () => { + const registration = getLocalControlPlaneRegistration({ + NODE_ENV: 'production', + CLUSTER_CONTROL_ADDRESS: '127.0.0.1', + CLUSTER_CONTROL_PORT: '3003', + }); + + assert.equal(registration.endpoint, null); + assert.match(registration.issues.join('\n'), /not routable from peer nodes/i); + }); + + it('builds peer request URLs from registered node metadata instead of implicit ports', () => { + const node = { + ipAddress: 'stale-address-that-should-not-be-used', + metadata: { + controlPlane: { + address: 'node-c.internal', + port: 3200, + protocol: 'http', + baseUrl: 'http://node-c.internal:3200', + }, + }, + }; + + const endpoint = getClusterNodeControlPlaneEndpoint(node); + const url = buildClusterNodeUrl(node, '/cluster/reload', { + broadcast: 'false', + }); + + assert.deepEqual(endpoint, { + address: 'node-c.internal', + port: 3200, + protocol: 'http', + baseUrl: 'http://node-c.internal:3200', + source: 'cluster-node.metadata.controlPlane.baseUrl', + }); + assert.equal(url, 'http://node-c.internal:3200/cluster/reload?broadcast=false'); + }); + + it('rejects registered peer endpoints that are still loopback-only', () => { + const url = buildClusterNodeUrl( + { + metadata: { + controlPlane: { + baseUrl: 'http://localhost:3003', + }, + }, + }, + '/certificates/sync', + ); + + assert.equal(url, null); + }); +}); + +describe('source and manifest regressions', () => { + it('removes public-IP discovery and direct PORT-based peer URL assumptions from the codebase', async () => { + const networkUtils = await fs.readFile( + path.join(repoRoot, 'src/utils/network-utils.ts'), + 'utf8', + ); + const clusterOperationsService = await fs.readFile( + path.join(repoRoot, 'src/distributed-lock/cluster-operations.service.ts'), + 'utf8', + ); + const certificateService = await fs.readFile( + path.join(repoRoot, 'src/certificate/certificate.service.ts'), + 'utf8', + ); + + assert.doesNotMatch(networkUtils, /ipify|icanhazip|ifconfig\.me/i); + assert.match(clusterOperationsService, /buildClusterNodeUrl/); + assert.doesNotMatch(clusterOperationsService, /process\.env\.PORT\s*\|\|\s*3000/); + assert.doesNotMatch(certificateService, /process\.env\.PORT\s*\|\|\s*3000/); + assert.doesNotMatch(clusterOperationsService, /http:\/\/\$\{node\.ipAddress\}/); + assert.doesNotMatch(certificateService, /http:\/\/\$\{node\.ipAddress\}/); + }); + + it('keeps deployment configuration explicit about advertised control-plane address and peer-facing port', async () => { + const envExample = await fs.readFile(path.join(repoRoot, '.env.example'), 'utf8'); + const compose = await fs.readFile( + path.join(repoRoot, 'docker-compose.yml'), + 'utf8', + ); + const swarm = await fs.readFile( + path.join(repoRoot, 'docker-compose.swarm.yml'), + 'utf8', + ); + const readme = await fs.readFile(path.join(repoRoot, 'README.md'), 'utf8'); + + assert.match(envExample, /CLUSTER_CONTROL_ADDRESS/); + assert.match(envExample, /CLUSTER_CONTROL_PORT/); + assert.match(envExample, /CLUSTER_CONTROL_URL/); + + assert.match(compose, /CLUSTER_CONTROL_ADDRESS:\s*\$\{CLUSTER_CONTROL_ADDRESS:-app\}/); + assert.match(compose, /CLUSTER_CONTROL_PORT:\s*\$\{CLUSTER_CONTROL_PORT:-3000\}/); + + assert.match(swarm, /CLUSTER_CONTROL_ADDRESS=\{\{\.Node\.Hostname\}\}/); + assert.match(swarm, /CLUSTER_CONTROL_PORT=\$\{CLUSTER_CONTROL_PORT:-3003\}/); + assert.match(swarm, /PORT=\$\{PORT:-3000\}/); + assert.match(swarm, /published:\s*3003/); + + assert.match(readme, /CLUSTER_CONTROL_ADDRESS/); + assert.match(readme, /CLUSTER_CONTROL_PORT/); + assert.match(readme, /do not assume it matches PORT/i); + }); +}); + diff --git a/test/unit/lease-backed-heartbeat.test.js b/test/unit/lease-backed-heartbeat.test.js new file mode 100644 index 0000000..16796a1 --- /dev/null +++ b/test/unit/lease-backed-heartbeat.test.js @@ -0,0 +1,429 @@ +require('reflect-metadata'); + +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); + +const { + ClusterHeartbeatService, +} = require('../../src/distributed-lock/cluster-heartbeat.service'); + +function createNode(overrides = {}) { + const now = new Date(); + + return { + id: overrides.id ?? `id-${overrides.instanceId ?? 'node-a'}`, + hostname: overrides.hostname ?? overrides.instanceId ?? 'node-a', + instanceId: overrides.instanceId ?? 'node-a', + ipAddress: overrides.ipAddress ?? '10.0.0.1', + isLeader: overrides.isLeader ?? false, + lastHeartbeat: overrides.lastHeartbeat ?? now, + version: overrides.version ?? '0.0.1', + status: overrides.status ?? 'active', + metadata: overrides.metadata ?? {}, + createdAt: overrides.createdAt ?? now, + updatedAt: overrides.updatedAt ?? now, + }; +} + +function createLease({ + ownerNodeId, + generation = 1, + ttlSeconds = 30, + expiresAt, +} = {}) { + const now = new Date(); + const effectiveExpiresAt = + expiresAt ?? new Date(now.getTime() + ttlSeconds * 1000); + + return { + leaseName: 'cluster:leader', + ownerNodeId: ownerNodeId ?? null, + ownerHostname: ownerNodeId ?? null, + generation, + ttlSeconds, + acquiredAt: now, + renewedAt: now, + expiresAt: effectiveExpiresAt, + isExpired: + ownerNodeId == null || effectiveExpiresAt.getTime() <= now.getTime(), + isHeldByThisInstance: false, + fencingToken: generation, + }; +} + +function createPrismaMock(initialNodes) { + const state = { + nodes: initialNodes.map((node) => ({ ...node })), + }; + + function matchesWhere(node, where = {}) { + return Object.entries(where).every(([key, expected]) => { + const actual = node[key]; + + if (expected && typeof expected === 'object' && !Array.isArray(expected)) { + if (Object.prototype.hasOwnProperty.call(expected, 'lt')) { + return actual < expected.lt; + } + + if (Object.prototype.hasOwnProperty.call(expected, 'not')) { + return actual !== expected.not; + } + + if (Object.prototype.hasOwnProperty.call(expected, 'in')) { + return expected.in.includes(actual); + } + } + + return actual === expected; + }); + } + + function sortNodes(nodes, orderBy) { + if (!orderBy) { + return nodes; + } + + const [key, direction] = Object.entries(orderBy)[0]; + return nodes.slice().sort((left, right) => { + const leftValue = left[key]; + const rightValue = right[key]; + + if (leftValue < rightValue) { + return direction === 'desc' ? 1 : -1; + } + + if (leftValue > rightValue) { + return direction === 'desc' ? -1 : 1; + } + + return 0; + }); + } + + function selectFields(node, select) { + if (!select) { + return { ...node }; + } + + return Object.fromEntries( + Object.entries(select) + .filter(([, include]) => include) + .map(([key]) => [key, node[key]]), + ); + } + + return { + state, + clusterNode: { + async findMany({ where, orderBy, select } = {}) { + const filtered = state.nodes.filter((node) => matchesWhere(node, where)); + return sortNodes(filtered, orderBy).map((node) => selectFields(node, select)); + }, + async findUnique({ where }) { + const [key, value] = Object.entries(where)[0]; + const node = state.nodes.find((entry) => entry[key] === value); + return node ? { ...node } : null; + }, + async findFirst({ where, orderBy } = {}) { + const filtered = state.nodes.filter((node) => matchesWhere(node, where)); + const [first] = sortNodes(filtered, orderBy); + return first ? { ...first } : null; + }, + async upsert({ where, create, update }) { + const [key, value] = Object.entries(where)[0]; + const index = state.nodes.findIndex((node) => node[key] === value); + + if (index >= 0) { + state.nodes[index] = { + ...state.nodes[index], + ...update, + updatedAt: new Date(), + }; + return { ...state.nodes[index] }; + } + + const node = { + id: create.id ?? `id-${create.instanceId}`, + createdAt: new Date(), + updatedAt: new Date(), + isLeader: false, + ...create, + }; + state.nodes.push(node); + return { ...node }; + }, + async update({ where, data }) { + const [key, value] = Object.entries(where)[0]; + const index = state.nodes.findIndex((node) => node[key] === value); + + if (index < 0) { + throw new Error(`Node not found for ${key}=${value}`); + } + + state.nodes[index] = { + ...state.nodes[index], + ...data, + updatedAt: new Date(), + }; + + return { ...state.nodes[index] }; + }, + async updateMany({ where, data }) { + let count = 0; + state.nodes = state.nodes.map((node) => { + if (!matchesWhere(node, where)) { + return node; + } + + count += 1; + return { + ...node, + ...data, + updatedAt: new Date(), + }; + }); + return { count }; + }, + async deleteMany({ where }) { + const before = state.nodes.length; + state.nodes = state.nodes.filter((node) => !matchesWhere(node, where)); + return { count: before - state.nodes.length }; + }, + async count({ where } = {}) { + return state.nodes.filter((node) => matchesWhere(node, where)).length; + }, + }, + }; +} + +function createDistributedLockMock({ + instanceId = 'node-a', + lease = null, + tryAcquire = false, + acquire = false, + localLockStatus, +} = {}) { + let currentLease = lease; + const calls = { + tryAcquireLeaderLock: 0, + acquireLeaderLock: 0, + releaseLeaderLock: 0, + }; + + function snapshot() { + if (!currentLease) { + return null; + } + + const now = new Date(); + const isExpired = + !currentLease.ownerNodeId || currentLease.expiresAt.getTime() <= now.getTime(); + + return { + ...currentLease, + isExpired, + isHeldByThisInstance: currentLease.ownerNodeId === instanceId && !isExpired, + fencingToken: currentLease.generation, + }; + } + + function assignLeaseToInstance() { + currentLease = createLease({ + ownerNodeId: instanceId, + generation: currentLease?.generation ? currentLease.generation + 1 : 1, + }); + } + + return { + calls, + getInstanceId() { + return instanceId; + }, + async isLeader() { + const leaderLease = snapshot(); + return Boolean( + leaderLease && + !leaderLease.isExpired && + leaderLease.ownerNodeId === instanceId, + ); + }, + getLeaderLockStatus() { + if (typeof localLockStatus === 'function') { + return localLockStatus(snapshot()); + } + + const leaderLease = snapshot(); + return { + isLeader: Boolean( + leaderLease && + !leaderLease.isExpired && + leaderLease.ownerNodeId === instanceId, + ), + instanceId, + ownerNodeId: leaderLease?.ownerNodeId ?? null, + generation: leaderLease?.generation ?? null, + fencingToken: leaderLease?.generation ?? null, + expiresAt: leaderLease?.expiresAt ?? null, + heldForMs: leaderLease ? 1000 : null, + }; + }, + async getLeaderLeaseSnapshot() { + return snapshot(); + }, + async tryAcquireLeaderLock() { + calls.tryAcquireLeaderLock += 1; + if (!tryAcquire) { + return false; + } + + assignLeaseToInstance(); + return true; + }, + async acquireLeaderLock() { + calls.acquireLeaderLock += 1; + if (!acquire) { + return false; + } + + assignLeaseToInstance(); + return true; + }, + async releaseLeaderLock() { + calls.releaseLeaderLock += 1; + if (!currentLease || currentLease.ownerNodeId !== instanceId) { + return false; + } + + currentLease = createLease({ + ownerNodeId: null, + generation: currentLease.generation, + expiresAt: new Date(Date.now() - 1000), + }); + return true; + }, + }; +} + +describe('lease-backed heartbeat and leader flows', () => { + it('derives leader reads from the active lease instead of persisted isLeader flags', async () => { + const prisma = createPrismaMock([ + createNode({ instanceId: 'node-a', hostname: 'node-a', isLeader: false }), + createNode({ instanceId: 'node-b', hostname: 'node-b', isLeader: true }), + ]); + const distributedLock = createDistributedLockMock({ + instanceId: 'node-a', + lease: createLease({ ownerNodeId: 'node-a', generation: 4 }), + }); + const service = new ClusterHeartbeatService(prisma, distributedLock); + + const activeNodes = await service.getActiveNodes(); + const leader = await service.getLeaderNode(); + const stats = await service.getClusterStats(); + const activeNodeById = Object.fromEntries( + activeNodes.map((node) => [node.instanceId, node]), + ); + + assert.equal(activeNodeById['node-a']?.isLeader, true); + assert.equal(activeNodeById['node-b']?.isLeader, false); + assert.equal(leader?.instanceId, 'node-a'); + assert.equal(stats.leaderCount, 1); + assert.deepEqual(stats.leadershipIssues, []); + assert.equal(stats.leaderLeaseGeneration, 4); + }); + + it('waits for lease expiry instead of forcing re-election when the lease owner is no longer active', async () => { + const prisma = createPrismaMock([ + createNode({ instanceId: 'node-a', hostname: 'node-a', isLeader: false }), + createNode({ + instanceId: 'node-b', + hostname: 'node-b', + status: 'stale', + isLeader: true, + lastHeartbeat: new Date(Date.now() - 2 * 60 * 1000), + }), + ]); + const distributedLock = createDistributedLockMock({ + instanceId: 'node-a', + lease: createLease({ ownerNodeId: 'node-b', generation: 7 }), + tryAcquire: true, + }); + const service = new ClusterHeartbeatService(prisma, distributedLock); + + await service.ensureLeaderExists(); + + assert.equal(distributedLock.calls.tryAcquireLeaderLock, 0); + assert.equal( + prisma.state.nodes.find((node) => node.instanceId === 'node-a')?.isLeader, + false, + ); + assert.equal( + prisma.state.nodes.find((node) => node.instanceId === 'node-b')?.isLeader, + false, + ); + + const stats = await service.getClusterStats(); + assert.equal(stats.leaderCount, 0); + assert.deepEqual(stats.leadershipIssues, ['LEASE_OWNER_NOT_ACTIVE']); + }); + + it('elects leadership only by acquiring the leader lease and reconciles stale flags afterwards', async () => { + const prisma = createPrismaMock([ + createNode({ instanceId: 'node-a', hostname: 'node-a', isLeader: false }), + createNode({ instanceId: 'node-b', hostname: 'node-b', isLeader: true }), + ]); + const distributedLock = createDistributedLockMock({ + instanceId: 'node-a', + lease: null, + tryAcquire: true, + }); + const service = new ClusterHeartbeatService(prisma, distributedLock); + + await service.ensureLeaderExists(); + + assert.equal(distributedLock.calls.tryAcquireLeaderLock, 1); + assert.equal( + prisma.state.nodes.find((node) => node.instanceId === 'node-a')?.isLeader, + true, + ); + assert.equal( + prisma.state.nodes.find((node) => node.instanceId === 'node-b')?.isLeader, + false, + ); + + const leader = await service.getLeaderNode(); + assert.equal(leader?.instanceId, 'node-a'); + }); + + it('marks stale nodes stale during cleanup while preserving lease authority until expiry', async () => { + const prisma = createPrismaMock([ + createNode({ instanceId: 'node-a', hostname: 'node-a', isLeader: false }), + createNode({ + instanceId: 'node-b', + hostname: 'node-b', + status: 'active', + isLeader: true, + lastHeartbeat: new Date(Date.now() - 2 * 60 * 1000), + }), + ]); + const distributedLock = createDistributedLockMock({ + instanceId: 'node-a', + lease: createLease({ ownerNodeId: 'node-b', generation: 3 }), + tryAcquire: true, + }); + const service = new ClusterHeartbeatService(prisma, distributedLock); + + const result = await service.manualCleanup(); + + assert.equal(result.success, true); + assert.equal( + prisma.state.nodes.find((node) => node.instanceId === 'node-b')?.status, + 'stale', + ); + assert.equal(distributedLock.calls.tryAcquireLeaderLock, 0); + + const stats = await service.getClusterStats(); + assert.equal(stats.leaderCount, 0); + assert.deepEqual(stats.leadershipIssues, ['LEASE_OWNER_NOT_ACTIVE']); + }); +}); + diff --git a/test/unit/metrics-and-alerting.test.js b/test/unit/metrics-and-alerting.test.js new file mode 100644 index 0000000..00a6b84 --- /dev/null +++ b/test/unit/metrics-and-alerting.test.js @@ -0,0 +1,383 @@ +require('reflect-metadata'); + +const { after, before, describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const request = require('supertest'); +const { Test } = require('@nestjs/testing'); + +const { HealthController } = require('../../src/health/health.controller'); +const { HealthService } = require('../../src/health/health.service'); +const { MetricsService } = require('../../src/metrics/metrics.service'); + +function isoDate(offsetMs = 0) { + return new Date(Date.now() + offsetMs); +} + +describe('metrics expansion', () => { + const originalBackupDir = process.env.BACKUP_DIR; + const originalOperationStale = process.env.METRICS_CLUSTER_OPERATION_STALE_MAX_AGE_MS; + const originalOrderStale = process.env.METRICS_CERTIFICATE_ORDER_STALE_MAX_AGE_MS; + const originalBackupMaxAge = process.env.METRICS_BACKUP_MAX_AGE_MS; + const originalFailureWindow = process.env.METRICS_CLUSTER_OPERATION_FAILURE_WINDOW_MS; + + let backupDir; + + before(() => { + backupDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lyttle-metrics-')); + process.env.BACKUP_DIR = backupDir; + process.env.METRICS_CLUSTER_OPERATION_STALE_MAX_AGE_MS = '600000'; + process.env.METRICS_CERTIFICATE_ORDER_STALE_MAX_AGE_MS = '1800000'; + process.env.METRICS_BACKUP_MAX_AGE_MS = '86400000'; + process.env.METRICS_CLUSTER_OPERATION_FAILURE_WINDOW_MS = '3600000'; + + fs.writeFileSync( + path.join(backupDir, 'certificates-backup-2026-05-28-00-00-00.lyttlebackup'), + 'encrypted-backup-payload', + 'utf8', + ); + }); + + after(() => { + fs.rmSync(backupDir, { recursive: true, force: true }); + + if (originalBackupDir === undefined) { + delete process.env.BACKUP_DIR; + } else { + process.env.BACKUP_DIR = originalBackupDir; + } + + if (originalOperationStale === undefined) { + delete process.env.METRICS_CLUSTER_OPERATION_STALE_MAX_AGE_MS; + } else { + process.env.METRICS_CLUSTER_OPERATION_STALE_MAX_AGE_MS = originalOperationStale; + } + + if (originalOrderStale === undefined) { + delete process.env.METRICS_CERTIFICATE_ORDER_STALE_MAX_AGE_MS; + } else { + process.env.METRICS_CERTIFICATE_ORDER_STALE_MAX_AGE_MS = originalOrderStale; + } + + if (originalBackupMaxAge === undefined) { + delete process.env.METRICS_BACKUP_MAX_AGE_MS; + } else { + process.env.METRICS_BACKUP_MAX_AGE_MS = originalBackupMaxAge; + } + + if (originalFailureWindow === undefined) { + delete process.env.METRICS_CLUSTER_OPERATION_FAILURE_WINDOW_MS; + } else { + process.env.METRICS_CLUSTER_OPERATION_FAILURE_WINDOW_MS = originalFailureWindow; + } + }); + + it('aggregates lease, operation, order, backup, and dependency metrics into JSON and Prometheus formats', async () => { + const prisma = { + certificate: { + findMany: async () => [ + { + expiresAt: isoDate(45 * 24 * 60 * 60 * 1000), + }, + { + expiresAt: isoDate(-2 * 24 * 60 * 60 * 1000), + }, + ], + }, + proxyEntry: { + findMany: async () => [ + { ssl: true, type: 'PROXY' }, + { ssl: false, type: 'REDIRECT' }, + ], + }, + clusterLease: { + findMany: async () => [ + { + leaseName: 'cluster:leader', + ownerNodeId: 'node-a', + ownerHostname: 'node-a-host', + generation: 4, + ttlSeconds: 30, + expiresAt: isoDate(20 * 1000), + }, + { + leaseName: 'cluster:stale', + ownerNodeId: 'node-old', + ownerHostname: 'node-old-host', + generation: 1, + ttlSeconds: 30, + expiresAt: isoDate(-60 * 1000), + }, + ], + }, + clusterOperation: { + findMany: async () => [ + { + status: 'pending', + operationType: 'config.reload', + createdAt: isoDate(-20 * 60 * 1000), + }, + { + status: 'failed', + operationType: 'certificate.activate', + createdAt: isoDate(-10 * 60 * 1000), + }, + { + status: 'succeeded', + operationType: 'config.reload', + createdAt: isoDate(-2 * 60 * 1000), + }, + ], + }, + clusterOperationAck: { + findMany: async () => [ + { status: 'succeeded' }, + { status: 'failed' }, + { status: 'pending' }, + ], + }, + certificateOrder: { + findMany: async () => [ + { + status: 'requested', + updatedAt: isoDate(-40 * 60 * 1000), + nextRetryAt: null, + }, + { + status: 'failed', + updatedAt: isoDate(-5 * 60 * 1000), + nextRetryAt: isoDate(5 * 60 * 1000), + }, + { + status: 'validating', + updatedAt: isoDate(-5 * 60 * 1000), + nextRetryAt: isoDate(-1 * 60 * 1000), + }, + ], + }, + }; + + const healthService = { + dependencies: async () => ({ + status: 'error', + summary: { + total: 4, + ok: 2, + error: 2, + }, + thresholds: { + configApplyMaxAgeMs: 900000, + certificateSyncMaxAgeMs: 900000, + }, + checks: [ + { + name: 'database', + status: 'ok', + latencyMs: 12, + }, + { + name: 'nginx_master', + status: 'error', + }, + { + name: 'config_apply', + status: 'ok', + }, + { + name: 'certificate_sync', + status: 'error', + }, + ], + }), + getOperationalDependencyState: () => ({ + configApply: { + status: 'ok', + ageMs: 30_000, + maxAgeMs: 900_000, + lastSuccessAt: isoDate(-30_000).toISOString(), + lastAttemptAt: isoDate(-20_000).toISOString(), + lastError: null, + }, + certificateSync: { + status: 'error', + ageMs: 1_000_000, + maxAgeMs: 900_000, + lastSuccessAt: isoDate(-1_000_000).toISOString(), + lastAttemptAt: isoDate(-5_000).toISOString(), + lastError: 'peer sync failed', + }, + }), + }; + + const service = new MetricsService(prisma, healthService); + const metrics = await service.getAllMetrics(); + + assert.equal(metrics.collection.errors.length, 0); + assert.equal(metrics.certificates.total, 2); + assert.equal(metrics.certificates.valid, 1); + assert.equal(metrics.certificates.expired, 1); + assert.equal(metrics.proxies.withSsl, 1); + assert.equal(metrics.leases.total, 2); + assert.equal(metrics.leases.active, 1); + assert.equal(metrics.leases.leader.generation, 4); + assert.equal(metrics.clusterOperations.active.total, 1); + assert.equal(metrics.clusterOperations.active.stale, 1); + assert.equal(metrics.clusterOperations.recentFailures.total, 1); + assert.equal(metrics.certificateOrders.active, 2); + assert.equal(metrics.certificateOrders.stale, 1); + assert.equal(metrics.certificateOrders.retryScheduled, 1); + assert.equal(metrics.certificateOrders.retryDue, 1); + assert.equal(metrics.backups.total, 1); + assert.equal(metrics.backups.freshnessStatus, 1); + assert.equal(metrics.health.database.status, 'ok'); + assert.equal(metrics.health.nginxMaster.status, 'error'); + assert.equal(metrics.health.operations.certificateSync.lastError, 'peer sync failed'); + + const prometheus = service.formatPrometheusMetrics(metrics); + assert.match(prometheus, /lyttle_cluster_leader_lease_generation 4/); + assert.match(prometheus, /lyttle_health_dependency_status\{name="nginx_master"\} 0/); + assert.match(prometheus, /lyttle_db_query_duration_ms 12/); + assert.match(prometheus, /lyttle_cluster_operations_total\{status="failed"\} 1/); + assert.match(prometheus, /lyttle_certificate_orders_total\{status="requested"\} 1/); + assert.match(prometheus, /lyttle_backup_freshness_status 1/); + assert.match(prometheus, /lyttle_metrics_collection_status\{section="health"\} 1/); + }); + + it('returns section-level collection failures instead of crashing the entire metrics scrape', async () => { + const prisma = { + certificate: { findMany: async () => [] }, + proxyEntry: { findMany: async () => [] }, + clusterLease: { findMany: async () => [] }, + clusterOperation: { + findMany: async () => { + throw new Error('cluster journal unavailable'); + }, + }, + clusterOperationAck: { findMany: async () => [] }, + certificateOrder: { findMany: async () => [] }, + }; + + const healthService = { + dependencies: async () => ({ + status: 'ok', + summary: { total: 0, ok: 0, error: 0 }, + thresholds: { + configApplyMaxAgeMs: 900000, + certificateSyncMaxAgeMs: 900000, + }, + checks: [], + }), + getOperationalDependencyState: () => ({ + configApply: { + status: 'ok', + ageMs: 0, + maxAgeMs: 900000, + lastSuccessAt: null, + lastAttemptAt: null, + lastError: null, + }, + certificateSync: { + status: 'ok', + ageMs: 0, + maxAgeMs: 900000, + lastSuccessAt: null, + lastAttemptAt: null, + lastError: null, + }, + }), + }; + + const service = new MetricsService(prisma, healthService); + const metrics = await service.getAllMetrics(); + + assert.equal(metrics.collection.sections.clusterOperations, 'error'); + assert.equal(metrics.collection.errors.length, 1); + assert.match(metrics.collection.errors[0].message, /cluster journal unavailable/); + + const prometheus = service.formatPrometheusMetrics(metrics); + assert.match( + prometheus, + /lyttle_metrics_collection_status\{section="clusterOperations"\} 0/, + ); + }); +}); + +describe('health drilldown endpoints', () => { + let app; + let httpServer; + let dependencyStatus = 'error'; + let deepStatus = 'error'; + + const healthServiceMock = { + live: async () => ({ status: 'ok', probe: 'liveness' }), + startup: async () => ({ status: 'ok', probe: 'startup' }), + ready: async () => ({ status: 'ok', probe: 'readiness' }), + dependencies: async () => ({ + status: dependencyStatus, + probe: 'dependencies', + summary: { total: 4, ok: dependencyStatus === 'ok' ? 4 : 2, error: dependencyStatus === 'ok' ? 0 : 2 }, + checks: [], + }), + deep: async () => ({ + status: deepStatus, + probe: 'deep', + live: { status: 'ok' }, + startup: { status: 'ok' }, + readiness: { status: deepStatus }, + dependencies: { status: dependencyStatus }, + }), + }; + + before(async () => { + const moduleRef = await Test.createTestingModule({ + controllers: [HealthController], + providers: [ + { + provide: HealthService, + useValue: healthServiceMock, + }, + ], + }).compile(); + + app = moduleRef.createNestApplication(); + await app.init(); + httpServer = app.getHttpServer(); + }); + + after(async () => { + await app?.close(); + }); + + it('returns HTTP 503 for unhealthy dependency and deep-health drilldowns', async () => { + dependencyStatus = 'error'; + deepStatus = 'error'; + + await request(httpServer) + .get('/health/dependencies') + .expect(503) + .expect(({ body }) => assert.equal(body.status, 'error')); + + await request(httpServer) + .get('/health/deep') + .expect(503) + .expect(({ body }) => assert.equal(body.status, 'error')); + }); + + it('returns HTTP 200 once dependency and deep-health drilldowns are healthy', async () => { + dependencyStatus = 'ok'; + deepStatus = 'ok'; + + await request(httpServer) + .get('/health/dependencies') + .expect(200) + .expect(({ body }) => assert.equal(body.status, 'ok')); + + await request(httpServer) + .get('/health/deep') + .expect(200) + .expect(({ body }) => assert.equal(body.status, 'ok')); + }); +}); + diff --git a/test/unit/nginx-custom-code-guardrails.test.js b/test/unit/nginx-custom-code-guardrails.test.js new file mode 100644 index 0000000..ff87a9b --- /dev/null +++ b/test/unit/nginx-custom-code-guardrails.test.js @@ -0,0 +1,307 @@ +require('reflect-metadata'); + +const { afterEach, describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs/promises'); +const os = require('node:os'); +const path = require('node:path'); + +const repoRoot = path.resolve(__dirname, '../..'); +const customCodeModulePath = path.join( + repoRoot, + 'src/nginx/nginx-custom-code.ts', +); +const nginxServiceModulePath = path.join(repoRoot, 'src/nginx/nginx.service.ts'); +const reloaderModulePath = path.join(repoRoot, 'src/reloader/reloader.service.ts'); + +const envKeys = [ + 'ADMIN_EMAIL', + 'NGINX_CUSTOM_CODE_ALLOWED_PATH_PREFIXES', + 'NGINX_ETC_DIR', + 'NGINX_LOG_DIR', + 'NGINX_SOURCE_DIR', +]; +const originalEnv = Object.fromEntries( + envKeys.map((key) => [key, process.env[key]]), +); + +function restoreEnv() { + for (const key of envKeys) { + if (originalEnv[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = originalEnv[key]; + } + } +} + +function createProxyEntry(overrides = {}) { + return { + id: overrides.id ?? 'proxy-1', + domains: overrides.domains ?? 'example.test', + proxy_pass_host: overrides.proxy_pass_host ?? 'http://localhost:8080', + ssl: overrides.ssl ?? false, + type: overrides.type ?? 'PROXY', + nginx_custom_code: overrides.nginx_custom_code ?? '', + }; +} + +async function writeFile(filePath, content) { + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, content, 'utf8'); +} + +async function createNginxSourceTree(rootDir) { + await writeFile( + path.join(rootDir, 'nginx.conf'), + `pid /run/nginx.pid; + +events { + worker_connections 64; +} + +http { + include /etc/nginx/mime.types; + root /etc/nginx/html; + include /etc/nginx/runtime/current/conf.d/*.conf; +} +`, + ); + await writeFile(path.join(rootDir, 'mime.types'), 'types { text/html html; }\n'); + await writeFile( + path.join(rootDir, 'conf.d/default.conf'), + `server { + listen 80 default_server; + root /etc/nginx/html; + error_page 500 501 502 503 504 505 506 507 508 510 511 /errors/50x.html; +} +`, + ); + await writeFile( + path.join(rootDir, 'html/index.html'), + '<!doctype html><html lang="en">ok</html>\n', + ); + await writeFile( + path.join(rootDir, 'html/errors/5xx.html'), + '<!doctype html><html lang="en">5xx</html>\n', + ); + await writeFile( + path.join(rootDir, 'html/errors/broken.html'), + '<!doctype html><html lang="en">broken</html>\n', + ); + await writeFile( + path.join(rootDir, 'html/errors/loading.html'), + '<!doctype html><html lang="en">loading</html>\n', + ); +} + +function loadCustomCodeModule() { + delete require.cache[require.resolve(customCodeModulePath)]; + return require(customCodeModulePath); +} + +function loadNginxServiceModule() { + delete require.cache[require.resolve(customCodeModulePath)]; + delete require.cache[require.resolve(nginxServiceModulePath)]; + return require(nginxServiceModulePath); +} + +function loadReloaderWithPaths(paths) { + process.env.NGINX_ETC_DIR = paths.nginxEtcDir; + process.env.NGINX_SOURCE_DIR = paths.nginxSourceDir; + process.env.NGINX_LOG_DIR = paths.nginxLogDir; + process.env.ADMIN_EMAIL = process.env.ADMIN_EMAIL ?? 'nginx-custom-code@example.test'; + + delete require.cache[require.resolve(customCodeModulePath)]; + delete require.cache[require.resolve(nginxServiceModulePath)]; + delete require.cache[require.resolve(reloaderModulePath)]; + + const { ReloaderService } = require(reloaderModulePath); + const { NginxService } = require(nginxServiceModulePath); + + restoreEnv(); + + return { ReloaderService, NginxService }; +} + +async function createReloadHarness(entries) { + const tempDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'lyttlenginx-nginx-custom-code-'), + ); + const nginxEtcDir = path.join(tempDir, 'etc-nginx'); + const nginxSourceDir = path.join(tempDir, 'source-nginx'); + const nginxLogDir = path.join(tempDir, 'var-log-nginx'); + await createNginxSourceTree(nginxSourceDir); + + const { ReloaderService, NginxService } = loadReloaderWithPaths({ + nginxEtcDir, + nginxSourceDir, + nginxLogDir, + }); + + const health = { + successes: [], + failures: [], + recordConfigApplySuccess(message) { + this.successes.push(message); + }, + recordConfigApplyFailure(message) { + this.failures.push(message); + }, + }; + + const service = new ReloaderService( + { + proxyEntry: { + async findMany() { + return entries; + }, + }, + }, + new NginxService(), + { + async ensureCertificate() {}, + }, + { + ensureCertbotWebroot() {}, + }, + health, + ); + + return { + health, + service, + tempDir, + cleanup: async () => { + restoreEnv(); + await fs.rm(tempDir, { recursive: true, force: true }); + }, + }; +} + +afterEach(() => { + restoreEnv(); +}); + +describe('nginx_custom_code guardrails', () => { + it('sanitizes allowlisted custom fragments and reuses their managed paths safely', async () => { + const tempDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'lyttlenginx-nginx-custom-code-allowed-'), + ); + const allowedRoot = path.join(tempDir, 'allowed-static'); + process.env.NGINX_CUSTOM_CODE_ALLOWED_PATH_PREFIXES = allowedRoot; + + try { + const { sanitizeNginxCustomCode, extractManagedPathsFromCustomCode } = + loadCustomCodeModule(); + const { NginxService } = loadNginxServiceModule(); + + const fragment = ` + location ^~ /assets/ { + root ${allowedRoot}; + try_files $uri =404; + add_header Cache-Control "public, max-age=3600" always; + } + add_header X-Frame-Options DENY always; + client_max_body_size 10m; + `; + + const sanitized = sanitizeNginxCustomCode(fragment); + const managedPaths = extractManagedPathsFromCustomCode(fragment); + const config = new NginxService().generateNginxConfig([ + createProxyEntry({ nginx_custom_code: fragment }), + ]); + + assert.match(sanitized, new RegExp('^ {2}location \\^~ \/assets\/', 'm')); + assert.match(sanitized, /root .*allowed-static;/); + assert.match(sanitized, /add_header Cache-Control "public, max-age=3600" always;/); + assert.deepEqual(managedPaths, [allowedRoot]); + assert.match(config, /location \^~ \/assets\//); + assert.match(config, /client_max_body_size 10m;/); + assert.doesNotMatch(config, /#.*allowed-static/); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + + it('rejects dangerous directives instead of injecting them into generated server blocks', () => { + const { sanitizeNginxCustomCode } = loadCustomCodeModule(); + + assert.throws( + () => + sanitizeNginxCustomCode(` + location /private/ { + proxy_pass http://attacker.internal; + } + `), + /directive "proxy_pass" is not allowed/i, + ); + }); + + it('rejects managed paths outside the allowlisted prefixes', async () => { + const tempDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'lyttlenginx-nginx-custom-code-prefix-'), + ); + process.env.NGINX_CUSTOM_CODE_ALLOWED_PATH_PREFIXES = path.join( + tempDir, + 'approved-root', + ); + + try { + const { sanitizeNginxCustomCode } = loadCustomCodeModule(); + + assert.throws( + () => + sanitizeNginxCustomCode(` + location /downloads/ { + alias ${path.join(tempDir, 'unapproved-root')}; + } + `), + /must stay within one of/i, + ); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + + it('fails the staged reload before nginx validation when a custom fragment is invalid', async () => { + const tempDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'lyttlenginx-nginx-custom-code-reload-'), + ); + process.env.NGINX_CUSTOM_CODE_ALLOWED_PATH_PREFIXES = path.join( + tempDir, + 'allowed-static', + ); + + const harness = await createReloadHarness([ + createProxyEntry({ + nginx_custom_code: ` + location /private/ { + proxy_pass http://attacker.internal; + } + `, + }), + ]); + const commandCalls = []; + + harness.service.execCommand = async (command, args) => { + commandCalls.push([command, ...args]); + return 'unexpected'; + }; + + try { + const result = await harness.service.reloadConfig(); + + assert.equal(result.ok, false); + assert.match(result.error ?? '', /proxy_pass/); + assert.equal(commandCalls.length, 0); + assert.equal(harness.health.successes.length, 0); + assert.equal(harness.health.failures.length, 1); + assert.match(harness.health.failures[0], /proxy_pass/); + } finally { + await harness.cleanup(); + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); +}); + diff --git a/test/unit/private-key-encryption-at-rest.test.js b/test/unit/private-key-encryption-at-rest.test.js new file mode 100644 index 0000000..c8c8401 --- /dev/null +++ b/test/unit/private-key-encryption-at-rest.test.js @@ -0,0 +1,409 @@ +require('reflect-metadata'); + +const { afterEach, beforeEach, describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); + +const repoRoot = path.resolve(__dirname, '../..'); +const encryptionServicePath = path.join( + repoRoot, + 'src/certificate/private-key-encryption.service.ts', +); +const certificateOrderServicePath = path.join( + repoRoot, + 'src/certificate/certificate-order.service.ts', +); +const certificateBackupServicePath = path.join( + repoRoot, + 'src/certificate/certificate-backup.service.ts', +); + +const originalMasterKey = process.env.PRIVATE_KEY_ENCRYPTION_MASTER_KEY; +const originalKeyVersion = process.env.PRIVATE_KEY_ENCRYPTION_KEY_VERSION; +const originalProvider = process.env.PRIVATE_KEY_ENCRYPTION_PROVIDER; +const originalNodeEnv = process.env.NODE_ENV; + +const VALID_CERT_PEM = `-----BEGIN CERTIFICATE----- +MIIBqzCCAVCgAwIBAgIUXj2axSEIzp9WQkQnsHhPNeuGmm4wCgYIKoZIzj0EAwIw +FjEUMBIGA1UEAwwLZXhhbXBsZS5jb20wHhcNMjYwNTI2MTgzNjUzWhcNMzYwNTIz +MTgzNjUzWjAWMRQwEgYDVQQDDAtleGFtcGxlLmNvbTBZMBMGByqGSM49AgEGCCqG +SM49AwEHA0IABN1hBq/wyvIjIh+Hg1XT/WAL7vwq3olyr1GCKDMbl+yYpTHbOFF/ +2mMdQfkcOXFvAlNa4tJxlpPQ9AqIpbUjfUCjfDB6MB0GA1UdDgQWBBQTeY9QpI1O +tUGKc6LZpmhJ98f7CDAfBgNVHSMEGDAWgBQTeY9QpI1OtUGKc6LZpmhJ98f7CDAP +BgNVHRMBAf8EBTADAQH/MCcGA1UdEQQgMB6CC2V4YW1wbGUuY29tgg93d3cuZXhh +bXBsZS5jb20wCgYIKoZIzj0EAwIDSQAwRgIhAIk6kq5Fdvyw2EDkfqUtYrM1IHAG +aKUyOFucZhk0VqmIAiEAzaGSXKVBsKMoA+4EHXTZ+gJV2IhFVDfTTCivqzBMiB0= +-----END CERTIFICATE----- +`; + +const VALID_KEY_PEM = `-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgKxTl2EOJ4qdIDZSD +gHgdj3PXIYhR1gU9hxnOndxEC6ahRANCAATdYQav8MryIyIfh4NV0/1gC+78Kt6J +cq9RgigzG5fsmKUx2zhRf9pjHUH5HDlxbwJTWuLScZaT0PQKiKW1I31A +-----END PRIVATE KEY----- +`; + +function resetModules() { + delete require.cache[require.resolve(encryptionServicePath)]; + delete require.cache[require.resolve(certificateOrderServicePath)]; + delete require.cache[require.resolve(certificateBackupServicePath)]; +} + +function clone(value) { + return structuredClone(value); +} + +function applyDataPatch(record, data) { + for (const [key, value] of Object.entries(data)) { + if ( + value && + typeof value === 'object' && + !Array.isArray(value) && + Object.keys(value).length === 1 && + Object.prototype.hasOwnProperty.call(value, 'increment') + ) { + record[key] = (record[key] ?? 0) + value.increment; + continue; + } + + record[key] = value; + } +} + +function createPrismaMock() { + const state = { + certificates: [], + artifacts: [], + events: [], + certificateSequence: 0, + artifactSequence: 0, + eventSequence: 0, + }; + + return { + state, + certificate: { + async findMany() { + return state.certificates.map(clone); + }, + async findFirst({ where } = {}) { + const certificate = + state.certificates.find((entry) => { + if (where?.domains && entry.domains !== where.domains) { + return false; + } + if (where?.domainsHash && entry.domainsHash !== where.domainsHash) { + return false; + } + return true; + }) ?? null; + return certificate ? clone(certificate) : null; + }, + async findUnique({ where }) { + const certificate = + state.certificates.find((entry) => { + if (where?.id) { + return entry.id === where.id; + } + if (where?.domainsHash) { + return entry.domainsHash === where.domainsHash; + } + return false; + }) ?? null; + return certificate ? clone(certificate) : null; + }, + async create({ data }) { + state.certificateSequence += 1; + const now = new Date(); + const certificate = { + id: `cert-${state.certificateSequence}`, + createdAt: now, + updatedAt: now, + ...data, + }; + state.certificates.push(certificate); + return clone(certificate); + }, + async update({ where, data }) { + const certificate = + state.certificates.find((entry) => { + if (where?.id) { + return entry.id === where.id; + } + if (where?.domainsHash) { + return entry.domainsHash === where.domainsHash; + } + return false; + }) ?? null; + + if (!certificate) { + throw new Error(`Certificate not found for update: ${JSON.stringify(where)}`); + } + + applyDataPatch(certificate, data); + certificate.updatedAt = new Date(); + return clone(certificate); + }, + }, + certificateArtifactVersion: { + async findMany() { + return state.artifacts.map(clone); + }, + async findFirst({ where, orderBy } = {}) { + const results = state.artifacts.filter((entry) => { + if (where?.domainsHash && entry.domainsHash !== where.domainsHash) { + return false; + } + return true; + }); + if (orderBy?.version === 'desc') { + results.sort((left, right) => right.version - left.version); + } + return results[0] ? clone(results[0]) : null; + }, + async create({ data }) { + state.artifactSequence += 1; + const artifact = { + id: `artifact-${state.artifactSequence}`, + createdAt: new Date(), + ...data, + }; + state.artifacts.push(artifact); + return clone(artifact); + }, + async update({ where, data }) { + const artifact = state.artifacts.find((entry) => entry.id === where.id) ?? null; + if (!artifact) { + throw new Error(`Artifact not found for update: ${where.id}`); + } + applyDataPatch(artifact, data); + return clone(artifact); + }, + async findUnique({ where }) { + const artifact = state.artifacts.find((entry) => entry.id === where.id) ?? null; + return artifact ? clone(artifact) : null; + }, + }, + certificateOrderEvent: { + async create({ data }) { + state.eventSequence += 1; + const event = { + id: `event-${state.eventSequence}`, + occurredAt: new Date(), + ...data, + }; + state.events.push(event); + return clone(event); + }, + }, + }; +} + +beforeEach(() => { + process.env.NODE_ENV = 'test'; + process.env.PRIVATE_KEY_ENCRYPTION_PROVIDER = 'local'; + process.env.PRIVATE_KEY_ENCRYPTION_MASTER_KEY = + 'private-key-encryption-unit-test-master-key-material'; + process.env.PRIVATE_KEY_ENCRYPTION_KEY_VERSION = 'private-key-v1'; + resetModules(); +}); + +afterEach(() => { + resetModules(); + + if (originalMasterKey === undefined) { + delete process.env.PRIVATE_KEY_ENCRYPTION_MASTER_KEY; + } else { + process.env.PRIVATE_KEY_ENCRYPTION_MASTER_KEY = originalMasterKey; + } + + if (originalKeyVersion === undefined) { + delete process.env.PRIVATE_KEY_ENCRYPTION_KEY_VERSION; + } else { + process.env.PRIVATE_KEY_ENCRYPTION_KEY_VERSION = originalKeyVersion; + } + + if (originalProvider === undefined) { + delete process.env.PRIVATE_KEY_ENCRYPTION_PROVIDER; + } else { + process.env.PRIVATE_KEY_ENCRYPTION_PROVIDER = originalProvider; + } + + if (originalNodeEnv === undefined) { + delete process.env.NODE_ENV; + } else { + process.env.NODE_ENV = originalNodeEnv; + } +}); + +describe('private-key encryption at rest', () => { + it('backfills legacy plaintext keys and supports re-encryption when the key version changes', async () => { + const prisma = createPrismaMock(); + prisma.state.certificates.push({ + id: 'cert-legacy', + domains: 'example.com', + domainsHash: 'hash-example', + certPem: '-----BEGIN CERTIFICATE-----\nlegacy\n-----END CERTIFICATE-----\n', + keyPem: '-----BEGIN PRIVATE KEY-----\nlegacy-cert\n-----END PRIVATE KEY-----\n', + keyEncryption: null, + expiresAt: new Date('2035-01-01T00:00:00.000Z'), + issuedAt: new Date('2026-01-01T00:00:00.000Z'), + lastUsedAt: new Date('2026-01-01T00:00:00.000Z'), + isOrphaned: false, + status: 'active', + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), + }); + prisma.state.artifacts.push({ + id: 'artifact-legacy', + orderId: 'order-1', + certificateId: null, + domains: 'example.com', + domainsHash: 'hash-example', + version: 1, + sourceType: 'self-signed', + certPem: '-----BEGIN CERTIFICATE-----\nlegacy\n-----END CERTIFICATE-----\n', + keyPem: '-----BEGIN PRIVATE KEY-----\nlegacy-artifact\n-----END PRIVATE KEY-----\n', + keyEncryption: null, + issuedAt: new Date('2026-01-01T00:00:00.000Z'), + expiresAt: new Date('2035-01-01T00:00:00.000Z'), + createdAt: new Date('2026-01-01T00:00:00.000Z'), + }); + + const { PrivateKeyEncryptionService } = require(encryptionServicePath); + const encryptionService = new PrivateKeyEncryptionService(prisma); + + const migrated = await encryptionService.migrateStoredPrivateKeys(); + + assert.deepEqual(migrated, { + certificatesUpdated: 1, + artifactsUpdated: 1, + }); + assert.doesNotMatch(prisma.state.certificates[0].keyPem, /BEGIN PRIVATE KEY/); + assert.doesNotMatch(prisma.state.artifacts[0].keyPem, /BEGIN PRIVATE KEY/); + assert.equal( + prisma.state.certificates[0].keyEncryption.provider.keyVersion, + 'private-key-v1', + ); + assert.equal( + prisma.state.artifacts[0].keyEncryption.provider.keyVersion, + 'private-key-v1', + ); + assert.match( + encryptionService.decryptPrivateKey( + prisma.state.certificates[0].keyPem, + prisma.state.certificates[0].keyEncryption, + { + scope: 'certificate', + domainsHash: 'hash-example', + }, + ), + /legacy-cert/, + ); + + process.env.PRIVATE_KEY_ENCRYPTION_KEY_VERSION = 'private-key-v2'; + resetModules(); + const { PrivateKeyEncryptionService: RotatingEncryptionService } = require( + encryptionServicePath, + ); + const rotatingService = new RotatingEncryptionService(prisma); + const rotated = await rotatingService.migrateStoredPrivateKeys(); + + assert.deepEqual(rotated, { + certificatesUpdated: 1, + artifactsUpdated: 1, + }); + assert.equal( + prisma.state.certificates[0].keyEncryption.provider.keyVersion, + 'private-key-v2', + ); + assert.equal( + prisma.state.artifacts[0].keyEncryption.provider.keyVersion, + 'private-key-v2', + ); + assert.match( + rotatingService.decryptPrivateKey( + prisma.state.artifacts[0].keyPem, + prisma.state.artifacts[0].keyEncryption, + { + scope: 'certificate-artifact', + domainsHash: 'hash-example', + version: 1, + }, + ), + /legacy-artifact/, + ); + }); + + it('stores new artifact and imported certificate keys encrypted while still exporting decrypted PEMs', async () => { + const prisma = createPrismaMock(); + const { PrivateKeyEncryptionService } = require(encryptionServicePath); + const { CertificateOrderService } = require(certificateOrderServicePath); + const { CertificateBackupService } = require(certificateBackupServicePath); + const encryptionService = new PrivateKeyEncryptionService(prisma); + const orderService = new CertificateOrderService(prisma, encryptionService); + const backupService = new CertificateBackupService(prisma, encryptionService); + const plaintextKey = VALID_KEY_PEM; + + const artifact = await orderService.recordArtifact({ + orderId: 'order-1', + certificateId: null, + domains: ['Example.com'], + sourceType: 'uploaded', + certPem: VALID_CERT_PEM, + keyPem: plaintextKey, + issuedAt: new Date('2026-05-26T18:36:53.000Z'), + expiresAt: new Date('2036-05-23T18:36:53.000Z'), + activatedAt: null, + createdByNode: 'node-1', + metadata: { activation: 'pending' }, + }); + + assert.equal(artifact.version, 1); + assert.equal(prisma.state.artifacts.length, 1); + assert.doesNotMatch(prisma.state.artifacts[0].keyPem, /BEGIN PRIVATE KEY/); + assert.equal( + prisma.state.artifacts[0].keyEncryption.provider.keyVersion, + 'private-key-v1', + ); + assert.equal( + encryptionService.decryptPrivateKey( + prisma.state.artifacts[0].keyPem, + prisma.state.artifacts[0].keyEncryption, + { + scope: 'certificate-artifact', + domainsHash: prisma.state.artifacts[0].domainsHash, + version: 1, + }, + ), + plaintextKey, + ); + + const imported = await backupService.importCertificates([ + { + domains: ['example.com', 'www.example.com'], + certPem: VALID_CERT_PEM, + keyPem: plaintextKey, + expiresAt: new Date('2036-05-23T18:36:53.000Z'), + issuedAt: new Date('2026-05-26T18:36:53.000Z'), + }, + ]); + + assert.deepEqual(imported, { + imported: 1, + skipped: 0, + errors: 0, + }); + assert.equal(prisma.state.certificates.length, 1); + assert.doesNotMatch(prisma.state.certificates[0].keyPem, /BEGIN PRIVATE KEY/); + assert.equal( + prisma.state.certificates[0].keyEncryption.provider.keyVersion, + 'private-key-v1', + ); + + const exported = await backupService.exportCertificate(prisma.state.certificates[0].id); + assert.equal(exported.keyPem, plaintextKey); + assert.deepEqual(exported.domains, ['example.com', 'www.example.com']); + }); +}); + diff --git a/test/unit/release-gating.test.js b/test/unit/release-gating.test.js new file mode 100644 index 0000000..a5e285e --- /dev/null +++ b/test/unit/release-gating.test.js @@ -0,0 +1,87 @@ +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const repoRoot = path.resolve(__dirname, '../..'); +const packageJson = JSON.parse( + fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8'), +); +const workflow = fs.readFileSync( + path.join(repoRoot, '.github/workflows/main.yml'), + 'utf8', +); +const dockerfile = fs.readFileSync(path.join(repoRoot, 'Dockerfile'), 'utf8'); +const coverageGateScript = fs.readFileSync( + path.join(repoRoot, 'scripts/ci/assert-coverage.js'), + 'utf8', +); + +describe('CI/CD release gating', () => { + it('adds explicit CI scripts and targeted overrides for release verification', () => { + assert.equal(typeof packageJson.scripts['lint:ci'], 'string'); + assert.equal(typeof packageJson.scripts['test:coverage:ci'], 'string'); + assert.equal(typeof packageJson.scripts['audit:prod'], 'string'); + assert.equal(typeof packageJson.scripts['verify:ci'], 'string'); + + assert.equal(packageJson.overrides['@hono/node-server'], '1.19.13'); + assert.equal(packageJson.overrides['@prisma/streams-local'].ajv, '8.20.0'); + assert.equal(packageJson.overrides['fast-uri'], '3.1.2'); + }); + + it('keeps a dedicated coverage gate with explicit threshold environment variables', () => { + assert.match(coverageGateScript, /COVERAGE_MIN_LINES/); + assert.match(coverageGateScript, /COVERAGE_MIN_BRANCHES/); + assert.match(coverageGateScript, /COVERAGE_MIN_FUNCTIONS/); + assert.match(coverageGateScript, /all files\\s\*\\\|/); + assert.match(coverageGateScript, /Coverage gate passed\./); + }); + + it('requires lint, typecheck, tests, dependency audit, and container scan before publishing', () => { + assert.match(workflow, /^name: CI and Release Gates$/m); + assert.match(workflow, /pull_request:/); + assert.match(workflow, /workflow_dispatch:/); + + for (const jobName of [ + 'lint:', + 'typecheck:', + 'tests:', + 'build:', + 'dependency-audit:', + 'container-scan:', + 'publish-image:', + ]) { + assert.match(workflow, new RegExp(`^ ${jobName}$`, 'm')); + } + + assert.match(workflow, /Run CI lint gate[\s\S]*npm run lint:ci/); + assert.match(workflow, /Run TypeScript typecheck[\s\S]*npm run typecheck/); + assert.match(workflow, /Run tests with coverage gate[\s\S]*npm run test:coverage:ci/); + assert.match(workflow, /Run production dependency audit[\s\S]*npm run audit:prod/); + assert.match(workflow, /Scan container image with Trivy/); + assert.match(workflow, /FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true'/); + assert.match(workflow, /uses: actions\/checkout@v6\.0\.3/); + assert.match(workflow, /uses: actions\/setup-node@v6\.4\.0/); + assert.match(workflow, /uses: docker\/build-push-action@v6/); + assert.match(workflow, /uses: aquasecurity\/trivy-action@v0\.36\.0/); + assert.match(workflow, /Build container image for scanning[\s\S]*pull: true/); + assert.match(workflow, /Build and push API Docker image[\s\S]*pull: true/); + + assert.match(workflow, /if: github\.event_name == 'push' && github\.ref == 'refs\/heads\/main'/); + assert.match( + workflow, + /needs:\n\s+- lint\n\s+- typecheck\n\s+- tests\n\s+- build\n\s+- dependency-audit\n\s+- container-scan/, + ); + assert.match(workflow, /Build and push API Docker image/); + assert.match(workflow, /ghcr\.io\/\$\{\{ github\.repository_owner }}\/lyttlenginx/); + }); + + it('runs the full verification contract during the Docker build before pruning dev dependencies', () => { + assert.match(dockerfile, /RUN npm ci/); + assert.match(dockerfile, /RUN npm run verify:ci/); + assert.match(dockerfile, /RUN npm prune --omit=dev/); + assert.match(dockerfile, /COPY \. \.\nRUN npm run verify:ci/); + assert.match(dockerfile, /apt-get update && \\\n\s+apt-get dist-upgrade -y && \\\n\s+apt-get install -y --no-install-recommends/); + }); +}); + diff --git a/test/unit/structured-logging.test.js b/test/unit/structured-logging.test.js new file mode 100644 index 0000000..bfd19b9 --- /dev/null +++ b/test/unit/structured-logging.test.js @@ -0,0 +1,157 @@ +require('reflect-metadata'); + +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const { EventEmitter } = require('node:events'); + +const { LogsService } = require('../../src/logs/logs.service'); + +class TestResponse extends EventEmitter { + constructor() { + super(); + this.statusCode = 200; + this.headers = {}; + this.writableEnded = false; + } + + setHeader(name, value) { + this.headers[name] = value; + } + + finish(statusCode = 200) { + this.statusCode = statusCode; + this.writableEnded = true; + this.emit('finish'); + this.emit('close'); + } +} + +function createActor(role = 'operator', subject = `${role}-user`) { + return { + id: `${subject}-id`, + subject, + actorType: 'admin', + authMethod: 'bearer-token', + displayName: subject, + roles: [role], + scopes: ['admin:full'], + audience: ['lyttle-nginx-admin'], + }; +} + +function readLastJsonLogLine(writes) { + const line = [...writes] + .reverse() + .find((entry) => entry.trim().startsWith('{') && entry.trim().endsWith('}')); + assert.ok(line, 'expected at least one JSON log line'); + return JSON.parse(line); +} + +describe('structured operational logging', () => { + it('emits structured JSON logs with request, actor, operation, and redacted secret fields', async () => { + const logsService = new LogsService(); + const request = { + method: 'POST', + originalUrl: '/cluster/reload?broadcast=true', + url: '/cluster/reload?broadcast=true', + headers: { + 'x-correlation-id': 'corr-123', + authorization: 'Bearer super-secret-token', + 'x-forwarded-for': '10.20.30.40', + }, + query: { + operationId: 'operation-123', + }, + auditContext: { + correlationId: 'corr-123', + startedAt: '2026-05-28T00:00:00.000Z', + }, + auth: createActor('platform-admin', 'platform-admin-user'), + }; + + logsService.runWithContext( + { + request, + correlationId: 'corr-123', + operationId: 'operation-123', + }, + () => { + logsService.log( + { + message: 'Queued cluster reload', + event: 'cluster.reload.queued', + apiKey: 'very-secret-api-key', + nested: { + authorization: 'Bearer nested-secret-token', + keyPem: + '-----BEGIN PRIVATE KEY-----\nsecret\n-----END PRIVATE KEY-----', + }, + }, + 'ClusterController', + ); + }, + ); + + const entry = logsService.getLastLogs(1)[0]; + assert.equal(entry.stream, 'operational'); + assert.equal(entry.level, 'log'); + assert.equal(entry.message, 'Queued cluster reload'); + assert.equal(entry.event, 'cluster.reload.queued'); + assert.equal(entry.source, 'ClusterController'); + assert.equal(entry.correlationId, 'corr-123'); + assert.equal(entry.requestId, 'corr-123'); + assert.equal(entry.operationId, 'operation-123'); + assert.equal(entry.request.method, 'POST'); + assert.equal(entry.request.path, '/cluster/reload?broadcast=true'); + assert.equal(entry.request.ipAddress, '10.20.30.40'); + assert.equal(entry.actor.subject, 'platform-admin-user'); + assert.deepEqual(entry.actor.roles, ['platform-admin']); + assert.equal(entry.data.apiKey, '[REDACTED]'); + assert.equal(entry.data.nested.authorization, '[REDACTED]'); + assert.equal(entry.data.nested.keyPem, '[REDACTED]'); + assert.equal(readLastJsonLogLine(logsService.getLastLogLines(1)).event, 'cluster.reload.queued'); + }); + + it('binds request context for lifecycle logging and mirrors the correlation id header', async () => { + const logsService = new LogsService(); + const request = { + method: 'GET', + originalUrl: '/logs?count=1', + url: '/logs?count=1', + headers: { + 'x-request-id': 'req-789', + 'x-forwarded-for': '192.0.2.15', + }, + query: { + count: '1', + }, + }; + const response = new TestResponse(); + + await new Promise((resolve) => { + logsService.bindRequestContext(request, response, () => { + request.auth = createActor('viewer', 'viewer-user'); + setImmediate(() => { + response.finish(202); + resolve(); + }); + }); + }); + + assert.equal(response.headers['X-Correlation-Id'], 'req-789'); + + const entry = logsService.getLastLogs(1)[0]; + assert.equal(entry.event, 'http.request.completed'); + assert.equal(entry.message, 'HTTP request completed'); + assert.equal(entry.correlationId, 'req-789'); + assert.equal(entry.requestId, 'req-789'); + assert.equal(entry.request.method, 'GET'); + assert.equal(entry.request.path, '/logs?count=1'); + assert.equal(entry.request.statusCode, 202); + assert.equal(entry.request.ipAddress, '192.0.2.15'); + assert.equal(entry.actor.subject, 'viewer-user'); + assert.equal(typeof entry.request.durationMs, 'number'); + assert.ok(entry.request.durationMs >= 0); + assert.equal(readLastJsonLogLine(logsService.getLastLogLines(5)).event, 'http.request.completed'); + }); +}); diff --git a/test/unit/transactional-nginx-rollout.test.js b/test/unit/transactional-nginx-rollout.test.js new file mode 100644 index 0000000..0333f0f --- /dev/null +++ b/test/unit/transactional-nginx-rollout.test.js @@ -0,0 +1,321 @@ +require('reflect-metadata'); + +const { afterEach, describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs/promises'); +const os = require('node:os'); +const path = require('node:path'); + +const repoRoot = path.resolve(__dirname, '../..'); +const reloaderModulePath = path.join(repoRoot, 'src/reloader/reloader.service.ts'); +const nginxServiceModulePath = path.join(repoRoot, 'src/nginx/nginx.service.ts'); + +const envKeys = ['NGINX_ETC_DIR', 'NGINX_SOURCE_DIR', 'NGINX_LOG_DIR', 'ADMIN_EMAIL']; +const originalEnv = Object.fromEntries( + envKeys.map((key) => [key, process.env[key]]), +); + +function restoreEnv() { + for (const key of envKeys) { + if (originalEnv[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = originalEnv[key]; + } + } +} + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function loadReloaderWithPaths(paths) { + process.env.NGINX_ETC_DIR = paths.nginxEtcDir; + process.env.NGINX_SOURCE_DIR = paths.nginxSourceDir; + process.env.NGINX_LOG_DIR = paths.nginxLogDir; + process.env.ADMIN_EMAIL = process.env.ADMIN_EMAIL ?? 'nginx-rollout@example.test'; + + delete require.cache[require.resolve(reloaderModulePath)]; + delete require.cache[require.resolve(nginxServiceModulePath)]; + + const { ReloaderService } = require(reloaderModulePath); + const { NginxService } = require(nginxServiceModulePath); + + restoreEnv(); + + return { ReloaderService, NginxService }; +} + +function createProxyEntry(overrides = {}) { + return { + id: overrides.id ?? 'proxy-1', + domains: overrides.domains ?? 'example.test', + proxy_pass_host: overrides.proxy_pass_host ?? 'http://localhost:8080', + ssl: overrides.ssl ?? false, + type: overrides.type ?? 'PROXY', + nginx_custom_code: overrides.nginx_custom_code ?? '', + }; +} + +async function writeFile(filePath, content) { + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, content, 'utf8'); +} + +async function createNginxSourceTree(rootDir) { + await writeFile( + path.join(rootDir, 'nginx.conf'), + `pid /run/nginx.pid; + +events { + worker_connections 64; +} + +http { + include /etc/nginx/mime.types; + root /etc/nginx/html; + include /etc/nginx/runtime/current/conf.d/*.conf; +} +`, + ); + await writeFile(path.join(rootDir, 'mime.types'), 'types { text/html html; }\n'); + await writeFile( + path.join(rootDir, 'conf.d/default.conf'), + `server { + listen 80 default_server; + root /etc/nginx/html; + error_page 500 501 502 503 504 505 506 507 508 510 511 /errors/50x.html; +} +`, + ); + await writeFile(path.join(rootDir, 'html/index.html'), '<html>ok</html>\n'); + await writeFile(path.join(rootDir, 'html/errors/5xx.html'), '<html>5xx</html>\n'); + await writeFile( + path.join(rootDir, 'html/errors/broken.html'), + '<html>broken</html>\n', + ); + await writeFile( + path.join(rootDir, 'html/errors/loading.html'), + '<html>loading</html>\n', + ); +} + +async function createHarness(overrides = {}) { + const tempDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'lyttlenginx-nginx-rollout-'), + ); + const nginxEtcDir = path.join(tempDir, 'etc-nginx'); + const nginxSourceDir = path.join(tempDir, 'source-nginx'); + const nginxLogDir = path.join(tempDir, 'var-log-nginx'); + await createNginxSourceTree(nginxSourceDir); + + const { ReloaderService, NginxService } = loadReloaderWithPaths({ + nginxEtcDir, + nginxSourceDir, + nginxLogDir, + }); + + const entries = overrides.entries ?? [createProxyEntry()]; + const health = { + successes: [], + failures: [], + recordConfigApplySuccess(message) { + this.successes.push(message); + }, + recordConfigApplyFailure(message) { + this.failures.push(message); + }, + }; + + const service = new ReloaderService( + { + proxyEntry: { + async findMany() { + return entries; + }, + }, + }, + new NginxService(), + { + ensuredDomains: [], + async ensureCertificate(domains) { + this.ensuredDomains.push(domains); + }, + }, + { + ensureCertbotWebroot() {}, + }, + health, + ); + + return { + tempDir, + nginxEtcDir, + nginxSourceDir, + health, + service, + cleanup: async () => { + restoreEnv(); + await fs.rm(tempDir, { recursive: true, force: true }); + }, + }; +} + +afterEach(() => { + restoreEnv(); +}); + +describe('transactional NGINX rollout', () => { + it('validates staged releases before atomically promoting the current symlink', async () => { + const harness = await createHarness(); + const commandCalls = []; + + harness.service.execCommand = async (command, args) => { + commandCalls.push([command, ...args]); + if (command === 'nginx' && args[0] === '-t') { + return 'syntax ok'; + } + if (command === 'nginx' && args[0] === '-s' && args[1] === 'reload') { + return 'reload ok'; + } + throw new Error(`Unexpected command: ${command} ${args.join(' ')}`); + }; + + try { + const result = await harness.service.reloadConfig(); + const currentReleasePath = await fs.readlink( + path.join(harness.nginxEtcDir, 'runtime/current'), + ); + const lastKnownGoodPath = await fs.readlink( + path.join(harness.nginxEtcDir, 'runtime/last-known-good'), + ); + const metadata = JSON.parse( + await fs.readFile( + path.join(currentReleasePath, 'lyttle-nginx-release.json'), + 'utf8', + ), + ); + const generatedConfig = await fs.readFile( + path.join(currentReleasePath, 'conf.d/proxy-1.conf'), + 'utf8', + ); + const validationConfig = await fs.readFile( + path.join(currentReleasePath, '.validation-nginx.conf'), + 'utf8', + ); + + assert.equal(result.ok, true); + assert.notEqual(path.basename(currentReleasePath), 'bootstrap'); + assert.equal(currentReleasePath, lastKnownGoodPath); + assert.equal(metadata.status, 'active'); + assert.equal(metadata.phase, 'ssl-activation'); + assert.match(metadata.validation.output, /syntax ok/); + assert.match( + generatedConfig, + new RegExp(escapeRegExp(path.join(currentReleasePath, 'html/errors'))), + ); + assert.match( + validationConfig, + new RegExp(escapeRegExp(path.join(currentReleasePath, 'conf.d'))), + ); + assert.equal( + commandCalls.filter((call) => call[0] === 'nginx' && call[1] === '-t') + .length, + 2, + ); + assert.equal( + commandCalls.filter( + (call) => call[0] === 'nginx' && call[1] === '-s' && call[2] === 'reload', + ).length, + 2, + ); + assert.equal(harness.health.failures.length, 0); + assert.equal(harness.health.successes.length, 1); + assert.match(harness.health.successes[0], new RegExp(path.basename(currentReleasePath))); + } finally { + await harness.cleanup(); + } + }); + + it('rolls back to the prior release when activation reload fails', async () => { + const harness = await createHarness(); + let reloadAttempts = 0; + + harness.service.execCommand = async (command, args) => { + if (command === 'nginx' && args[0] === '-t') { + return 'syntax ok'; + } + if (command === 'nginx' && args[0] === '-s' && args[1] === 'reload') { + reloadAttempts += 1; + if (reloadAttempts === 2) { + throw new Error('reload failed after activation'); + } + return 'reload ok'; + } + throw new Error(`Unexpected command: ${command} ${args.join(' ')}`); + }; + + try { + const result = await harness.service.reloadConfig(); + const currentReleasePath = await fs.readlink( + path.join(harness.nginxEtcDir, 'runtime/current'), + ); + const lastKnownGoodPath = await fs.readlink( + path.join(harness.nginxEtcDir, 'runtime/last-known-good'), + ); + const releaseNames = await fs.readdir( + path.join(harness.nginxEtcDir, 'runtime/releases'), + ); + const rolledBackReleaseName = releaseNames.find( + (releaseName) => + releaseName !== 'bootstrap' && + releaseName !== path.basename(currentReleasePath), + ); + const rolledBackMetadata = JSON.parse( + await fs.readFile( + path.join( + harness.nginxEtcDir, + 'runtime/releases', + rolledBackReleaseName, + 'lyttle-nginx-release.json', + ), + 'utf8', + ), + ); + + assert.equal(result.ok, false); + assert.equal(currentReleasePath, lastKnownGoodPath); + assert.equal(reloadAttempts, 3); + assert.equal(rolledBackMetadata.status, 'rolled_back'); + assert.equal( + rolledBackMetadata.rollback.rolledBackToReleaseId, + path.basename(currentReleasePath), + ); + assert.match(result.error ?? '', /rolled back to/); + assert.equal(harness.health.successes.length, 0); + assert.equal(harness.health.failures.length, 1); + } finally { + await harness.cleanup(); + } + }); + + it('keeps the stable loader and startup bootstrap wired to the managed runtime release path', async () => { + const loaderConfig = await fs.readFile( + path.join(repoRoot, 'nginx/nginx.conf'), + 'utf8', + ); + const entrypoint = await fs.readFile( + path.join(repoRoot, 'docker-entrypoint.sh'), + 'utf8', + ); + + assert.match( + loaderConfig, + /include \/etc\/nginx\/runtime\/current\/conf\.d\/\*\.conf;/, + ); + assert.match(entrypoint, /bootstrap_nginx_runtime_layout\(\)/); + assert.match(entrypoint, /NGINX_RUNTIME_CURRENT_LINK/); + assert.match(entrypoint, /NGINX_RUNTIME_LAST_KNOWN_GOOD_LINK/); + }); +}); + diff --git a/tsconfig.json b/tsconfig.json index 1cfc545..e0e122f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,7 +9,12 @@ "target": "ES2021", "sourceMap": true, "outDir": "./dist", + "rootDir": "./src", "baseUrl": "./", + "paths": { + "@prisma/client": ["node_modules/.prisma/client"] + }, + "ignoreDeprecations": "6.0", "incremental": true, "skipLibCheck": true, "strictNullChecks": false, @@ -18,7 +23,5 @@ "forceConsistentCasingInFileNames": false, "noFallthroughCasesInSwitch": false }, - "include": [ - "src/**/*" - ] + "include": ["src/**/*"] } diff --git a/tsconfig.test.json b/tsconfig.test.json new file mode 100644 index 0000000..f9594f7 --- /dev/null +++ b/tsconfig.test.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "noEmit": true + }, + "include": ["src/**/*", "test/**/*"] +} +