From 93eccfe60926ccaea40c22ea0d124fbd88b2ac6d Mon Sep 17 00:00:00 2001
From: Stualyttle <27898452+Stualyttle@users.noreply.github.com.>
Date: Sun, 24 May 2026 13:49:34 +0200
Subject: [PATCH 01/43] feat: add architecture decisions log and implementation
status tracker
---
ARCHITECTURE_DECISIONS.md | 142 ++++++++++++++++
IMPLEMENTATION_STATUS.md | 344 ++++++++++++++++++++++++++++++++++++++
README.md | 152 ++++++++++-------
package.json | 8 +-
4 files changed, 587 insertions(+), 59 deletions(-)
create mode 100644 ARCHITECTURE_DECISIONS.md
create mode 100644 IMPLEMENTATION_STATUS.md
diff --git a/ARCHITECTURE_DECISIONS.md b/ARCHITECTURE_DECISIONS.md
new file mode 100644
index 0000000..efaeabc
--- /dev/null
+++ b/ARCHITECTURE_DECISIONS.md
@@ -0,0 +1,142 @@
+# Architecture Decisions Log
+
+Last updated: 2026-05-24
+
+This file records repository-level architectural and delivery decisions so future implementation sessions can build on explicit, reviewable choices.
+
+## How to use this log
+
+- add a new ADR-style entry when a decision changes architecture, delivery shape, verification policy, or operator expectations
+- link each decision to the session that introduced it
+- mark superseded decisions explicitly instead of deleting history
+
+---
+
+## Decision index
+
+| ID | Title | Status | Session | Date |
+| --- | --- | --- | --- | --- |
+| ADR-001 | Production-readiness source of truth | accepted | Session 1 | 2026-05-24 |
+| ADR-002 | Session-based delivery model | accepted | Session 1 | 2026-05-24 |
+| ADR-003 | Standard repository verification contract | accepted | Session 1 | 2026-05-24 |
+| ADR-004 | Deployment mode expectations | accepted | Session 1 | 2026-05-24 |
+
+---
+
+## ADR-001 — Production-readiness source of truth
+
+- Status: accepted
+- Session: Session 1 — Delivery scaffolding and progress tracking
+- Date: 2026-05-24
+
+### Context
+
+The repository README historically advertised production readiness, but the current source review in `PRODUCTION_READINESS_ASSESSMENT.md` documents multiple P0/P1 blockers. Future sessions need a single, honest baseline for what “done” means.
+
+### Decision
+
+Treat the following documents as the canonical delivery set:
+
+1. `PRODUCTION_READINESS_ASSESSMENT.md` — current gap analysis and risk inventory
+2. `IMPLEMENTATION_PLAN_BY_SESSION.md` — ordered remediation roadmap
+3. `IMPLEMENTATION_STATUS.md` — live progress tracker for shipped work
+4. `ARCHITECTURE_DECISIONS.md` — durable log of cross-session decisions
+
+### Consequences
+
+- README and other operator docs must not contradict the assessment or status tracker
+- future sessions should update the status tracker and ADR log when scope materially changes
+- production-readiness claims must be backed by completed roadmap work, not historical feature inventory alone
+
+---
+
+## ADR-002 — Session-based delivery model
+
+- Status: accepted
+- Session: Session 1 — Delivery scaffolding and progress tracking
+- Date: 2026-05-24
+
+### Context
+
+The remediation roadmap spans security, cluster coordination, certificate lifecycle, recovery, testing, and CI/CD. Shipping those changes safely requires small, reviewable increments.
+
+### Decision
+
+Use the session structure from `IMPLEMENTATION_PLAN_BY_SESSION.md` as the default delivery unit. Each session should be:
+
+- focused
+- testable or otherwise verifiable within current repo constraints
+- low-risk
+- reviewable
+- documented before close-out
+
+### Consequences
+
+- implementation work should avoid unrelated refactors
+- each completed session should update both status and documentation for the touched area
+- acceptance criteria should remain explicit enough to verify before moving on
+
+---
+
+## ADR-003 — Standard repository verification contract
+
+- Status: accepted
+- Session: Session 1 — Delivery scaffolding and progress tracking
+- Date: 2026-05-24
+
+### Context
+
+The repository previously had partial scripts, but there was no normalized verification contract covering linting, type-checking, builds, and an explicit placeholder for tests.
+
+### Decision
+
+Adopt the following standard commands in `package.json`:
+
+- `npm run lint`
+- `npm run lint:fix`
+- `npm run format`
+- `npm run format:check`
+- `npm run typecheck`
+- `npm run build`
+- `npm run test`
+- `npm run verify`
+
+For now, `npm run test` is an explicit placeholder until Session 26 adds the automated test harness.
+
+### Consequences
+
+- contributors have a consistent pre-review command set immediately
+- CI/CD work in Session 28 can build on a stable script contract
+- Session 26 is responsible for replacing the test placeholder with real automated coverage
+
+---
+
+## ADR-004 — Deployment mode expectations
+
+- Status: accepted
+- Session: Session 1 — Delivery scaffolding and progress tracking
+- Date: 2026-05-24
+
+### Context
+
+The repository supports several deployment shapes, but they are not equally mature. The README previously blurred the lines between local evaluation, single-node use, and the intended Swarm target architecture.
+
+### Decision
+
+Document three explicit deployment expectations:
+
+1. Local development
+ - intended for coding, manual verification, and exploratory testing
+2. Single-node Compose
+ - intended for operator evaluation, demos, and non-HA environments
+ - not yet positioned as hardened production
+3. Docker Swarm global mode
+ - remains the target production architecture
+ - should be treated as controlled testing only until P0/P1 roadmap sessions are complete
+
+### Consequences
+
+- operator documentation must distinguish current support level from target architecture
+- deployment examples should prefer clarity over overclaiming readiness
+- later sessions should tighten each mode's docs and manifests without erasing the current warning state
+
diff --git a/IMPLEMENTATION_STATUS.md b/IMPLEMENTATION_STATUS.md
new file mode 100644
index 0000000..dbbad83
--- /dev/null
+++ b/IMPLEMENTATION_STATUS.md
@@ -0,0 +1,344 @@
+# LyttleNGINX Implementation Status
+
+Last updated: 2026-05-24
+
+This file is the working delivery tracker for the roadmap in `IMPLEMENTATION_PLAN_BY_SESSION.md`.
+Use it as the single place to record what has shipped, what is in progress, and what remains.
+
+## Current summary
+
+- Overall status: in progress
+- Current phase: Phase 0 — Delivery setup and guardrails
+- Most recently completed session: Session 1 — Delivery scaffolding and progress tracking
+- Next recommended session from the roadmap: Session 3 — Lock down public mutating endpoints
+- Readiness reference: `PRODUCTION_READINESS_ASSESSMENT.md`
+- Architecture decision log: `ARCHITECTURE_DECISIONS.md`
+
+---
+
+## Phase 0 — Delivery setup and guardrails
+
+## Session 1 — Delivery scaffolding and progress tracking
+- Status: done
+- Objective: create a consistent delivery scaffold for future sessions
+- Files touched:
+ - `package.json`
+ - `README.md`
+ - `IMPLEMENTATION_STATUS.md`
+ - `ARCHITECTURE_DECISIONS.md`
+- Tests added/updated:
+ - none yet; Session 26 remains the dedicated test-harness milestone
+- Risks:
+ - verification scripts are now defined, but this workspace session cannot execute Node/npm commands because the current environment does not have `node` or `npm` installed
+ - the `test` script is intentionally a documented placeholder until the automated test harness is introduced
+- Follow-up sessions:
+ - Session 3 — lock down public mutating endpoints
+ - Session 4 — fix health, readiness, liveness, and startup semantics
+ - Session 2 — dependency hygiene and secret-handling cleanup
+- Notes:
+ - added a single status tracker for the roadmap
+ - added an architecture decision log for major repo-level choices
+ - normalized common verification commands around `lint`, `typecheck`, `build`, and `verify`
+ - documented current expectations for local, single-node, and swarm deployment modes in `README.md`
+
+## Session 2 — Dependency hygiene and secret-handling cleanup
+- Status: not started
+- Objective: remove immediate supply-chain and secret risks
+- Files touched: none yet
+- Tests added/updated: none yet
+- Risks: vulnerable dependencies and secret-handling guidance remain open until this session ships
+- Follow-up sessions: Session 28
+- Notes: direct dependency upgrades and secret-handling docs are still pending
+
+---
+
+## Phase 1 — Emergency hardening and correctness fixes
+
+## Session 3 — Lock down public mutating endpoints
+- Status: not started
+- Objective: require auth on all mutating endpoints and define the public allowlist
+- Files touched: none yet
+- Tests added/updated: none yet
+- Risks: public write endpoints remain a P0 issue until addressed
+- Follow-up sessions: Session 7, Session 8, Session 26
+- Notes: recommended as the next implementation session in the roadmap
+
+## Session 4 — Fix health, readiness, liveness, and startup semantics
+- Status: not started
+- Objective: make health signaling reliable for orchestration and recovery
+- Files touched: none yet
+- Tests added/updated: none yet
+- Risks: current readiness semantics can mask unhealthy states
+- Follow-up sessions: Session 5, Session 25, Session 27
+- Notes: should include `healthcheck.sh` alignment
+
+## Session 5 — Fix container and process auto-recovery behavior
+- Status: not started
+- Objective: ensure process failure leads to correct container recovery
+- Files touched: none yet
+- Tests added/updated: none yet
+- Risks: current recovery behavior can wedge the service instead of restarting it
+- Follow-up sessions: Session 27
+- Notes: entrypoint supervision and restart policies remain open
+
+## Session 6 — Fix inter-node addressing and Swarm communication model
+- Status: not started
+- Objective: use explicit internal control-plane addressing instead of fragile discovery assumptions
+- Files touched: none yet
+- Tests added/updated: none yet
+- Risks: incorrect address and port selection can break node-to-node coordination
+- Follow-up sessions: Session 10, Session 11, Session 12
+- Notes: public IP discovery must be removed from cluster comms
+
+---
+
+## Phase 2 — Security model foundation
+
+## Session 7 — Introduce a real auth foundation
+- Status: not started
+- Objective: move from shared API keys toward a real identity model
+- Files touched: none yet
+- Tests added/updated: none yet
+- Risks: current auth is insufficient for production admin workflows
+- Follow-up sessions: Session 8, Session 9, Session 23
+- Notes: request identity should become part of request context
+
+## Session 8 — Add RBAC and authorization policies
+- Status: not started
+- Objective: make endpoint permissions explicit and enforceable
+- Files touched: none yet
+- Tests added/updated: none yet
+- Risks: sensitive actions are not yet constrained by role
+- Follow-up sessions: Session 9, Session 21, Session 22, Session 23
+- Notes: permission matrix should become testable and documented
+
+## Session 9 — Add audit logging for privileged and mutating operations
+- Status: not started
+- Objective: make sensitive actions attributable and reviewable
+- Files touched: none yet
+- Tests added/updated: none yet
+- Risks: privileged actions are not yet durably auditable
+- Follow-up sessions: Session 20, Session 23, Session 24
+- Notes: requires schema and service changes
+
+---
+
+## Phase 3 — Cluster coordination redesign
+
+## Session 10 — Add lease-based coordination primitives
+- Status: not started
+- Objective: replace advisory-lock-centric leadership with durable leases and fencing tokens
+- Files touched: none yet
+- Tests added/updated: none yet
+- Risks: current leader coordination can diverge under failure
+- Follow-up sessions: Session 11, Session 12, Session 27
+- Notes: central coordination semantics remain to be implemented
+
+## Session 11 — Move heartbeat and leader flows onto leases
+- Status: not started
+- Objective: align membership and leadership around the new lease model
+- Files touched: none yet
+- Tests added/updated: none yet
+- Risks: split-brain recovery logic remains fragile until lease-backed
+- Follow-up sessions: Session 22, Session 27
+- Notes: should simplify stale-node handling
+
+## Session 12 — Add cluster operations and per-node ACK tracking
+- Status: not started
+- Objective: represent cluster-wide mutations as tracked async operations
+- Files touched: none yet
+- Tests added/updated: none yet
+- Risks: cluster-wide actions still return local-only success today
+- Follow-up sessions: Session 17, Session 22, Session 25
+- Notes: operation IDs and node ACK state remain open
+
+---
+
+## Phase 4 — Configuration rollout and rollback safety
+
+## Session 13 — Implement staged NGINX config generation and atomic activation
+- Status: not started
+- Objective: make config rollout transactional and rollbackable
+- Files touched: none yet
+- Tests added/updated: none yet
+- Risks: current config deployment is destructive
+- Follow-up sessions: Session 14, Session 27
+- Notes: last-known-good config handling is still pending
+
+## Session 14 — Restrict or redesign `nginx_custom_code`
+- Status: not started
+- Objective: remove arbitrary config injection risk while preserving advanced extensibility
+- Files touched: none yet
+- Tests added/updated: none yet
+- Risks: raw config injection remains unsafe
+- Follow-up sessions: Session 21, Session 29
+- Notes: should introduce validation and tighter authorization
+
+---
+
+## Phase 5 — Certificate lifecycle redesign
+
+## Session 15 — Add strict domain validation and safe process execution
+- Status: not started
+- Objective: reject malformed domains early and stop using unsafe shell interpolation
+- Files touched: none yet
+- Tests added/updated: none yet
+- Risks: command injection and unsafe path derivation remain open
+- Follow-up sessions: Session 16, Session 18, Session 20, Session 26
+- Notes: domain normalization rules need to become explicit
+
+## Session 16 — Add certificate order state machine
+- Status: not started
+- Objective: model certificate issuance as a durable workflow
+- Files touched: none yet
+- Tests added/updated: none yet
+- Risks: certificate lifecycle remains weakly modeled and hard to resume safely
+- Follow-up sessions: Session 17, Session 18, Session 25
+- Notes: retry and history persistence are still pending
+
+## Session 17 — Rework cluster certificate distribution and activation
+- Status: not started
+- Objective: separate issuance from activation and track per-node rollout state
+- Files touched: none yet
+- Tests added/updated: none yet
+- Risks: cluster cert activation remains local-first instead of ACK-driven
+- Follow-up sessions: Session 22, Session 27
+- Notes: rollback to prior artifact version remains open
+
+## Session 18 — Harden the ACME strategy for clustered production
+- Status: not started
+- Objective: make challenge handling explicit, durable, and cluster-safe
+- Files touched: none yet
+- Tests added/updated: none yet
+- Risks: current issuance coordination is still fragile in global mode
+- Follow-up sessions: Session 29
+- Notes: DNS-01 or a hardened HTTP-01 workflow must be decided
+
+---
+
+## Phase 6 — Secrets, backups, and data protection
+
+## Session 19 — Encrypt private key material at rest
+- Status: not started
+- Objective: protect private keys in storage with application-layer encryption
+- Files touched: none yet
+- Tests added/updated: none yet
+- Risks: private keys remain plaintext in the database today
+- Follow-up sessions: Session 20, Session 23
+- Notes: KMS/Vault integration design remains open
+
+## Session 20 — Harden backup, export, import, and restore flows
+- Status: not started
+- Objective: make backup and recovery flows encrypted, validated, and authorized
+- Files touched: none yet
+- Tests added/updated: none yet
+- Risks: backups and restore flows are not yet production-grade
+- Follow-up sessions: Session 27, Session 29
+- Notes: integrity manifests and stronger validation remain pending
+
+---
+
+## Phase 7 — Operational API expansion
+
+## Session 21 — Add proxy management API
+- Status: not started
+- Objective: manage proxy config through authenticated API endpoints instead of direct DB mutation
+- Files touched: none yet
+- Tests added/updated: none yet
+- Risks: proxy management is still a major operational gap
+- Follow-up sessions: Session 13, Session 14, Session 26
+- Notes: validation endpoints should ship with CRUD APIs
+
+## Session 22 — Add cluster operations and node-status admin APIs
+- Status: not started
+- Objective: expose cluster coordination state through operator-facing APIs
+- Files touched: none yet
+- Tests added/updated: none yet
+- Risks: operators cannot yet inspect convergence or operation state through the API
+- Follow-up sessions: Session 24, Session 25, Session 26
+- Notes: async operation response contracts remain to be defined
+
+## Session 23 — Add security administration APIs
+- Status: not started
+- Objective: support operational security maintenance safely and audibly
+- Files touched: none yet
+- Tests added/updated: none yet
+- Risks: key rotation and security maintenance flows are not yet explicit
+- Follow-up sessions: Session 29
+- Notes: break-glass documentation should land here or alongside it
+
+---
+
+## Phase 8 — Logging, metrics, and SRE readiness
+
+## Session 24 — Replace ad hoc logging with structured operational and audit logging
+- Status: not started
+- Objective: make logs machine-parseable, traceable, and safe for centralized collection
+- Files touched: none yet
+- Tests added/updated: none yet
+- Risks: current logging is not production-grade and may expose sensitive data
+- Follow-up sessions: Session 25, Session 28
+- Notes: request IDs and redaction remain open
+
+## Session 25 — Expand metrics and alerting
+- Status: not started
+- Objective: add observability for leases, config apply, cert orders, backups, and DB health
+- Files touched: none yet
+- Tests added/updated: none yet
+- Risks: major failure modes are still under-instrumented
+- Follow-up sessions: Session 27, Session 29
+- Notes: should align with the health endpoint redesign
+
+---
+
+## Phase 9 — Test harness and release gates
+
+## Session 26 — Add automated test harness and baseline coverage
+- Status: not started
+- Objective: create unit, integration, and e2e test foundations for critical flows
+- Files touched: none yet
+- Tests added/updated: none yet
+- Risks: the repository still lacks automated regression protection
+- Follow-up sessions: Session 27, Session 28
+- Notes: this session will replace the current `test` placeholder with a real harness
+
+## Session 27 — Add chaos and fault-injection validation
+- Status: not started
+- Objective: prove recovery and coordination claims with reproducible failure tests
+- Files touched: none yet
+- Tests added/updated: none yet
+- Risks: auto-recovery claims remain unproven without fault-injection coverage
+- Follow-up sessions: Session 30
+- Notes: should cover DB outage, leader crash, NGINX crash, bad config, and node comms failure
+
+## Session 28 — Upgrade CI/CD and release gating
+- Status: not started
+- Objective: block insecure or broken releases from shipping
+- Files touched: none yet
+- Tests added/updated: none yet
+- Risks: current CI only builds and pushes an image
+- Follow-up sessions: Session 30
+- Notes: lint, typecheck, tests, vulnerability scans, and image gating remain open
+
+---
+
+## Phase 10 — Documentation and final validation
+
+## Session 29 — Reconcile README, architecture docs, and runbooks with reality
+- Status: not started
+- Objective: make operator-facing documentation accurate and practical
+- Files touched: none yet
+- Tests added/updated: none yet
+- Risks: docs still overstate readiness in several areas
+- Follow-up sessions: Session 30
+- Notes: runbooks for leader failure, rollback, restore, issuance failure, and credential rotation remain open
+
+## Session 30 — Final production-readiness validation pass
+- Status: not started
+- Objective: reconcile the assessment, roadmap, and shipped implementation into a final go-live checklist
+- Files touched: none yet
+- Tests added/updated: none yet
+- Risks: production-readiness cannot be claimed until every assessment item is accounted for
+- Follow-up sessions: none
+- Notes: final checklist and deferment register remain to be created
+
diff --git a/README.md b/README.md
index bfd4ed3..40aff57 100644
--- a/README.md
+++ b/README.md
@@ -1,16 +1,52 @@
# 🔒 LyttleNGINX
-
-
-
+
+
+
-**Enterprise-grade NGINX proxy management with automated SSL/TLS certificate management, real-time monitoring, and comprehensive backup solutions.**
+**NGINX proxy management control plane with certificate automation, monitoring primitives, and an active production-hardening roadmap.**
Built with [NestJS](https://nestjs.com/) • Powered by [PostgreSQL](https://www.postgresql.org/) • Secured by [Let's Encrypt](https://letsencrypt.org/)
+> Current state: this repository is under active implementation and hardening. It should **not** currently be treated as production-ready for single-node or Docker Swarm deployment. The current delivery baseline is tracked in `PRODUCTION_READINESS_ASSESSMENT.md`, `IMPLEMENTATION_PLAN_BY_SESSION.md`, `IMPLEMENTATION_STATUS.md`, and `ARCHITECTURE_DECISIONS.md`.
+
+---
+
+## 📍 Current Delivery Status
+
+- **Roadmap status:** Phase 0 in progress
+- **Completed in Session 1:** delivery scaffolding, status tracking, architecture decision logging, and normalized verification scripts
+- **Next recommended implementation session:** Session 3 — lock down public mutating endpoints
+- **Canonical planning and status docs:**
+ - [`PRODUCTION_READINESS_ASSESSMENT.md`](PRODUCTION_READINESS_ASSESSMENT.md)
+ - [`IMPLEMENTATION_PLAN_BY_SESSION.md`](IMPLEMENTATION_PLAN_BY_SESSION.md)
+ - [`IMPLEMENTATION_STATUS.md`](IMPLEMENTATION_STATUS.md)
+ - [`ARCHITECTURE_DECISIONS.md`](ARCHITECTURE_DECISIONS.md)
+
+## 🧭 Deployment Mode Expectations
+
+| Mode | Use it for | Current expectation |
+| --- | --- | --- |
+| Local development | coding, manual verification, exploratory testing | best-supported workflow today |
+| Single-node Compose | demos, operator evaluation, non-HA environments | usable for local/single-node evaluation, **not** yet positioned as hardened production |
+| Docker Swarm global mode | target cluster architecture | roadmap target only; use for controlled testing until P0/P1 hardening sessions are complete |
+
+## ✅ Repository Verification Commands
+
+Run these commands once a local Node/npm toolchain is available:
+
+```bash
+npm run lint
+npm run typecheck
+npm run build
+npm run verify
+```
+
+`npm run test` is currently an explicit placeholder until Session 26 adds the automated test harness.
+
---
## 🌟 Features
@@ -84,7 +120,7 @@ Built with [NestJS](https://nestjs.com/) • Powered by [PostgreSQL](https://www
- **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
+- **Docker Support** - Evaluation manifests with hardening work tracked in the roadmap
- **TypeScript** - Fully typed codebase
---
@@ -92,6 +128,9 @@ Built with [NestJS](https://nestjs.com/) • Powered by [PostgreSQL](https://www
## 📋 Table of Contents
- [Quick Start](#-quick-start)
+- [Current Delivery Status](#-current-delivery-status)
+- [Deployment Mode Expectations](#-deployment-mode-expectations)
+- [Repository Verification Commands](#-repository-verification-commands)
- [Installation](#-installation)
- [Configuration](#-configuration)
- [API Documentation](#-api-documentation)
@@ -109,7 +148,7 @@ Built with [NestJS](https://nestjs.com/) • Powered by [PostgreSQL](https://www
### Prerequisites
-- Node.js 22.19.0 or higher
+- Node.js 24.11.1 or higher
- PostgreSQL 12 or higher
- Docker & Docker Compose (optional)
@@ -137,7 +176,7 @@ 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).
+> **Note:** Connection pooling limits are automatically applied (10 connections per instance). Until dedicated database-operations docs are added, use `PRODUCTION_READINESS_ASSESSMENT.md` and `IMPLEMENTATION_PLAN_BY_SESSION.md` as the current references for production-hardening work.
### 3. Setup Database
@@ -340,7 +379,7 @@ npm run prisma:migrate
| GET | `/ready` | Readiness check | Public |
| POST | `/health/reload` | Reload NGINX config | Public |
-**📖 Complete API documentation:** [API_REFERENCE_ENHANCED.md](API_REFERENCE_ENHANCED.md)
+**📖 API documentation status:** a refreshed API reference is still pending; for now, use the controller source under `src/`, `IMPLEMENTATION_STATUS.md`, and `PRODUCTION_READINESS_ASSESSMENT.md` as the current references.
---
@@ -539,9 +578,9 @@ curl http://localhost:3000/certificates/backup/$FILENAME -o /backups/$FILENAME
## 🐳 Docker Deployment
-### Docker Swarm (Recommended for Production) 🆕
+### Docker Swarm (Roadmap Target / Controlled Testing Only)
-Deploy LyttleNGINX in **global mode** across your Docker Swarm cluster with built-in distributed coordination:
+Deploy LyttleNGINX in **global mode** across your Docker Swarm cluster only for controlled testing while the hardening roadmap is still in progress:
```bash
# Quick deployment with script
@@ -551,13 +590,11 @@ Deploy LyttleNGINX in **global mode** across your Docker Swarm cluster with buil
docker stack deploy -c docker-compose.swarm.yml lyttlenginx
```
-**Key Features:**
+**Current caveats:**
-- ✅ Runs on every node (global mode)
-- ✅ Distributed locking prevents certificate conflicts
-- ✅ Automatic leader election for renewals
-- ✅ Zero-downtime rolling updates
-- ✅ Automatic failure recovery
+- global mode is the intended target architecture
+- the current assessment still identifies P0/P1 blockers in auth, health semantics, recovery behavior, cluster comms, and config rollout
+- use the swarm manifest for evaluation and development feedback, not as a final production deployment contract yet
**View cluster status:**
@@ -572,11 +609,13 @@ docker service logs lyttlenginx_lyttlenginx 2>&1 | grep "LEADER"
docker service ps lyttlenginx_lyttlenginx
```
-**📖 Complete guide:** [DEPLOYMENT_GUIDE.md](DEPLOYMENT_GUIDE.md)
+**📖 Current deployment references:** [`docker-compose.swarm.yml`](docker-compose.swarm.yml), [`PRODUCTION_READINESS_ASSESSMENT.md`](PRODUCTION_READINESS_ASSESSMENT.md), and [`IMPLEMENTATION_PLAN_BY_SESSION.md`](IMPLEMENTATION_PLAN_BY_SESSION.md)
---
-### Docker Compose (Single Instance)
+### Docker Compose (Single-node Evaluation)
+
+Use `docker-compose.yml` for local or single-node evaluation only. It is not yet the documented high-availability production path.
**docker-compose.yml:**
@@ -632,7 +671,7 @@ docker-compose logs -f app
docker-compose ps
```
-**📖 More Docker examples:** [DOCKER_COMPOSE_EXAMPLES.md](DOCKER_COMPOSE_EXAMPLES.md)
+**📖 Current single-node references:** [`docker-compose.yml`](docker-compose.yml) and [`IMPLEMENTATION_STATUS.md`](IMPLEMENTATION_STATUS.md)
---
@@ -658,18 +697,30 @@ LyttleNGINX/
│ └── utils/ # Utility functions
├── nginx/ # NGINX configuration templates
├── prisma/ # Database schema and migrations
-└── docs/ # Documentation files
+├── IMPLEMENTATION_STATUS.md # Session-by-session delivery tracker
+└── ARCHITECTURE_DECISIONS.md # Architecture decision log
```
### Available Scripts
```bash
+# Repository verification
+npm run lint # Run ESLint without modifying files
+npm run lint:fix # Run ESLint and apply safe fixes
+npm run typecheck # Run TypeScript type-checking
+npm run build # Build application
+npm run verify # Lint + typecheck + build
+npm run test # Placeholder until Session 26 adds the test harness
+
# Development
npm run start:dev # Start with hot reload
npm run start:debug # Start with debugger
-# Production
-npm run build # Build application
+# Formatting
+npm run format # Format with Prettier
+npm run format:check # Check formatting without changing files
+
+# Runtime
npm run start:prod # Start production server
# Database
@@ -681,10 +732,6 @@ 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
@@ -715,46 +762,35 @@ npm run format # Format with Prettier
### Testing
```bash
-# Unit tests (when implemented)
+# Placeholder until Session 26
npm run test
-
-# E2E tests (when implemented)
-npm run test:e2e
-
-# Test coverage (when implemented)
-npm run test:cov
```
+At the time of Session 1, the repository does **not** yet have the automated unit/integration/e2e harness required by the implementation plan. That work is explicitly tracked in Session 26.
+
---
## 📖 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
+1. **[README.md](README.md)** - current project overview and operator notes
+2. **[PRODUCTION_READINESS_ASSESSMENT.md](PRODUCTION_READINESS_ASSESSMENT.md)** - current gap analysis and remediation priorities
+3. **[IMPLEMENTATION_PLAN_BY_SESSION.md](IMPLEMENTATION_PLAN_BY_SESSION.md)** - session-by-session delivery roadmap
+4. **[IMPLEMENTATION_STATUS.md](IMPLEMENTATION_STATUS.md)** - live progress tracker for shipped work
+5. **[ARCHITECTURE_DECISIONS.md](ARCHITECTURE_DECISIONS.md)** - architecture decision log
+6. **[docker-compose.yml](docker-compose.yml)** - single-node evaluation manifest
+7. **[docker-compose.swarm.yml](docker-compose.swarm.yml)** - swarm evaluation manifest and target architecture reference
+8. **[.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
+- **Readiness and risk register** → [PRODUCTION_READINESS_ASSESSMENT.md](PRODUCTION_READINESS_ASSESSMENT.md)
+- **Implementation roadmap** → [IMPLEMENTATION_PLAN_BY_SESSION.md](IMPLEMENTATION_PLAN_BY_SESSION.md)
+- **Delivery progress** → [IMPLEMENTATION_STATUS.md](IMPLEMENTATION_STATUS.md)
+- **Architecture decisions** → [ARCHITECTURE_DECISIONS.md](ARCHITECTURE_DECISIONS.md)
+- **Environment setup** → [.env.example](.env.example)
+- **Deployment manifests** → [docker-compose.yml](docker-compose.yml), [docker-compose.swarm.yml](docker-compose.swarm.yml)
---
@@ -841,8 +877,8 @@ docker-compose logs -f app
For issues and questions:
-1. Check [TLS_DOCUMENTATION.md](TLS_DOCUMENTATION.md) troubleshooting section
-2. Review [API_EXAMPLES.md](API_EXAMPLES.md) for usage examples
+1. Check [`PRODUCTION_READINESS_ASSESSMENT.md`](PRODUCTION_READINESS_ASSESSMENT.md) for known gaps and risks
+2. Review [`IMPLEMENTATION_STATUS.md`](IMPLEMENTATION_STATUS.md) for the latest shipped scope
3. Check application logs: `docker-compose logs -f app`
---
@@ -865,7 +901,7 @@ For issues and questions:
| 🛡️ TLS 1.3 | ✅ | Modern protocols only |
| 🔒 OCSP Stapling | ✅ | Enhanced performance |
| 📈 Monitoring | ✅ | Daily health checks |
-| 🐳 Docker | ✅ | Production-ready |
+| 🐳 Docker | ⚠️ | Evaluation-ready, hardening in progress |
---
@@ -877,7 +913,7 @@ For issues and questions:
- **Services:** 17+
- **Controllers:** 9
- **Modules:** 11+
-- **Build Status:** ✅ Passing
+- **Verification Status:** Scripts normalized in Session 1; full execution requires a local Node/npm toolchain
---
diff --git a/package.json b/package.json
index accf6e2..7be4523 100644
--- a/package.json
+++ b/package.json
@@ -6,8 +6,12 @@
"private": true,
"license": "UNLICENSED",
"scripts": {
+ "clean": "rm -rf dist coverage",
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
+ "format:check": "prettier --check \"src/**/*.ts\" \"test/**/*.ts\"",
+ "typecheck": "tsc --noEmit -p tsconfig.json",
+ "test": "node -e \"console.log('No automated tests are configured yet. Session 26 will add the test harness.')\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
@@ -17,9 +21,11 @@
"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: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 build",
"update": "npm update --save"
},
"volta": {
From fb54f393dfd999089f89e10ca1b1a6957f140bf6 Mon Sep 17 00:00:00 2001
From: Stualyttle <27898452+Stualyttle@users.noreply.github.com.>
Date: Sun, 24 May 2026 14:02:28 +0200
Subject: [PATCH 02/43] feat: improve secret handling guidance and update
environment configuration
---
.env.example | 33 +++++++++-----
.gitignore | 20 ++++++--
ARCHITECTURE_DECISIONS.md | 29 ++++++++++++
IMPLEMENTATION_STATUS.md | 36 ++++++++++++---
README.md | 41 +++++++++++++----
package-lock.json | 96 +++++++++++++++++++--------------------
package.json | 10 ++--
7 files changed, 182 insertions(+), 83 deletions(-)
diff --git a/.env.example b/.env.example
index 2ecb17e..c320bda 100644
--- a/.env.example
+++ b/.env.example
@@ -1,9 +1,13 @@
# 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?connection_limit=20&pool_timeout=10&connect_timeout=10"
# ===============================================
# ADMIN CONFIGURATION (REQUIRED FOR PRODUCTION)
@@ -13,12 +17,13 @@ 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_KEY=""
# ===============================================
# APPLICATION SETTINGS
@@ -48,7 +53,7 @@ SMTP_HOST=""
SMTP_PORT="587"
SMTP_SECURE="false"
SMTP_USER=""
-SMTP_PASS=""
+SMTP_PASS=""
ALERT_EMAIL=""
ALERT_FROM_EMAIL=""
@@ -56,10 +61,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 +72,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/.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/ARCHITECTURE_DECISIONS.md b/ARCHITECTURE_DECISIONS.md
index efaeabc..dcbe0e8 100644
--- a/ARCHITECTURE_DECISIONS.md
+++ b/ARCHITECTURE_DECISIONS.md
@@ -20,6 +20,7 @@ This file records repository-level architectural and delivery decisions so futur
| ADR-002 | Session-based delivery model | accepted | Session 1 | 2026-05-24 |
| ADR-003 | Standard repository verification contract | accepted | Session 1 | 2026-05-24 |
| ADR-004 | Deployment mode expectations | accepted | Session 1 | 2026-05-24 |
+| ADR-005 | Secret material stays out of git | accepted | Session 2 | 2026-05-24 |
---
@@ -140,3 +141,31 @@ Document three explicit deployment expectations:
- deployment examples should prefer clarity over overclaiming readiness
- later sessions should tighten each mode's docs and manifests without erasing the current warning state
+---
+
+## ADR-005 — Secret material stays out of git
+
+- Status: accepted
+- Session: Session 2 — Dependency hygiene and secret-handling cleanup
+- Date: 2026-05-24
+
+### Context
+
+The production-readiness assessment identified secret handling as an immediate risk area. The repository already tracked only `.env.example`, but the ignore rules and operator docs did not clearly define which files and artifacts must stay out of version control or where production secrets should come from.
+
+### Decision
+
+Adopt the following repository policy:
+
+1. `.env.example` remains the only env template intended for git tracking.
+2. Live env overrides such as `.env`, `.env.local`, `.env.production`, and other `.env.*` files must remain untracked.
+3. Generated certificate/key material and backup artifacts must also remain untracked.
+4. Production secret sources should be Docker Swarm secrets or an external secret manager such as Vault or a cloud secret-management service.
+5. Published examples and docs must use placeholders, not live-looking credentials.
+
+### Consequences
+
+- future delivery sessions should preserve the distinction between safe examples and runtime secret injection
+- operators have an explicit default for where production secrets should live even before first-class secret-provider integration lands
+- later sessions can add `_FILE` support or deeper secret-provider integrations without changing the repository rule that sensitive material must stay out of git
+
diff --git a/IMPLEMENTATION_STATUS.md b/IMPLEMENTATION_STATUS.md
index dbbad83..7389a60 100644
--- a/IMPLEMENTATION_STATUS.md
+++ b/IMPLEMENTATION_STATUS.md
@@ -9,7 +9,7 @@ Use it as the single place to record what has shipped, what is in progress, and
- Overall status: in progress
- Current phase: Phase 0 — Delivery setup and guardrails
-- Most recently completed session: Session 1 — Delivery scaffolding and progress tracking
+- Most recently completed session: Session 2 — Dependency hygiene and secret-handling cleanup
- Next recommended session from the roadmap: Session 3 — Lock down public mutating endpoints
- Readiness reference: `PRODUCTION_READINESS_ASSESSMENT.md`
- Architecture decision log: `ARCHITECTURE_DECISIONS.md`
@@ -42,13 +42,35 @@ Use it as the single place to record what has shipped, what is in progress, and
- documented current expectations for local, single-node, and swarm deployment modes in `README.md`
## Session 2 — Dependency hygiene and secret-handling cleanup
-- Status: not started
+- Status: done
- Objective: remove immediate supply-chain and secret risks
-- Files touched: none yet
-- Tests added/updated: none yet
-- Risks: vulnerable dependencies and secret-handling guidance remain open until this session ships
-- Follow-up sessions: Session 28
-- Notes: direct dependency upgrades and secret-handling docs are still pending
+- Files touched:
+ - `package.json`
+ - `package-lock.json`
+ - `.gitignore`
+ - `.env.example`
+ - `README.md`
+ - `ARCHITECTURE_DECISIONS.md`
+ - `IMPLEMENTATION_STATUS.md`
+- Tests added/updated:
+ - no automated tests yet; Session 26 remains the dedicated test-harness milestone
+ - validated direct npm CVE fixes against the upgraded target versions
+ - verified via git that only `.env.example` is tracked among env files
+- Risks:
+ - the application still relies on direct environment-variable injection; first-class `*_FILE` or secret-provider integration remains future work
+ - broader automated dependency scanning and release gating remain deferred to Session 28
+ - lockfile regeneration could not be performed with npm in this workspace, so the lockfile was updated manually using npm registry metadata for the patched versions
+- Follow-up sessions:
+ - Session 3 — lock down public mutating endpoints
+ - Session 19 — encrypt private key material at rest
+ - Session 20 — harden backup, export, import, and restore flows
+ - Session 23 — add security administration APIs
+ - Session 28 — upgrade CI/CD and release gating
+- Notes:
+ - upgraded the direct dependency versions called out in the assessment (`@nestjs/core`-aligned Nest packages and `nodemailer`)
+ - tightened `.gitignore` so live env files, generated key material, and backup artifacts stay out of git while keeping `.env.example` tracked
+ - rewrote `.env.example` to use safe placeholders and explicit runtime-injection guidance
+ - documented Docker Swarm secrets and external secret-manager expectations in `README.md`
---
diff --git a/README.md b/README.md
index 40aff57..3ebf963 100644
--- a/README.md
+++ b/README.md
@@ -4,6 +4,7 @@
+
@@ -18,7 +19,7 @@ Built with [NestJS](https://nestjs.com/) • Powered by [PostgreSQL](https://www
## 📍 Current Delivery Status
- **Roadmap status:** Phase 0 in progress
-- **Completed in Session 1:** delivery scaffolding, status tracking, architecture decision logging, and normalized verification scripts
+- **Completed in Sessions 1-2:** delivery scaffolding, status tracking, dependency hygiene for known direct CVEs, and secret-handling cleanup for publishable env examples
- **Next recommended implementation session:** Session 3 — lock down public mutating endpoints
- **Canonical planning and status docs:**
- [`PRODUCTION_READINESS_ASSESSMENT.md`](PRODUCTION_READINESS_ASSESSMENT.md)
@@ -167,11 +168,13 @@ cp .env.example .env
nano .env
```
+Use `.env` only for local evaluation. Do **not** commit `.env`, `.env.local`, `.env.production`, or any other live secret file.
+
**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
+DATABASE_URL=postgresql://user:@localhost:5432/lyttlenginx?connection_limit=10&pool_timeout=10&connect_timeout=10
ADMIN_EMAIL=admin@example.com
NODE_ENV=production
```
@@ -268,11 +271,29 @@ docker-compose logs -f app
#### Required
```bash
-DATABASE_URL=postgresql://user:pass@host:5432/db
+DATABASE_URL=postgresql://user:@host:5432/db
ADMIN_EMAIL=admin@example.com # For Let's Encrypt
NODE_ENV=production # production | development
```
+### Secret handling policy
+
+The repository is intentionally configured so that only `.env.example` is tracked. Real runtime secrets must stay outside git.
+
+- **Local/single-node evaluation:** keep secrets in an untracked `.env` file on the operator workstation.
+- **Docker Swarm:** store sensitive values as Swarm secrets and inject them at deploy time instead of hard-coding them into compose files or committed env files.
+- **External secret stores:** Vault, AWS Secrets Manager, GCP Secret Manager, Azure Key Vault, or equivalent are preferred for long-lived production credentials.
+- **Current implementation note:** the app still consumes environment variables directly. Until first-class `*_FILE` support or a dedicated secret-provider integration is added, have the deployment system materialize secret values at runtime rather than committing them.
+
+Treat at least the following as secret material:
+
+- `DATABASE_URL`
+- `API_KEY`
+- `SMTP_PASS`
+- `SLACK_WEBHOOK_URL`
+- `DISCORD_WEBHOOK_URL`
+- certificate private keys, backup archives, and exported PEM material
+
#### TLS Configuration
```bash
@@ -288,15 +309,15 @@ 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
+SMTP_USER=alerts@example.com
+SMTP_PASS=
# 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/...
+SLACK_WEBHOOK_URL=
+DISCORD_WEBHOOK_URL=
```
#### Backup Configuration
@@ -582,6 +603,8 @@ curl http://localhost:3000/certificates/backup/$FILENAME -o /backups/$FILENAME
Deploy LyttleNGINX in **global mode** across your Docker Swarm cluster only for controlled testing while the hardening roadmap is still in progress:
+> **Secret handling for Swarm:** keep live credentials out of committed env files. Store them as Swarm secrets or in an external secret manager, then inject them at deployment time. Session 2 cleaned up the tracked examples and documentation, but runtime secret-file ingestion is still future work.
+
```bash
# Quick deployment with script
./deploy-swarm.sh
@@ -658,7 +681,7 @@ volumes:
```bash
# Create .env file
-echo "POSTGRES_PASSWORD=your-secure-password" > .env
+echo "POSTGRES_PASSWORD=" > .env
echo "ADMIN_EMAIL=admin@example.com" >> .env
# Start services
@@ -671,6 +694,8 @@ docker-compose logs -f app
docker-compose ps
```
+For any environment beyond local/single-node evaluation, prefer Swarm secrets or an external secret store over checked-in or shared `.env` files.
+
**📖 Current single-node references:** [`docker-compose.yml`](docker-compose.yml) and [`IMPLEMENTATION_STATUS.md`](IMPLEMENTATION_STATUS.md)
---
diff --git a/package-lock.json b/package-lock.json
index e5b3631..5a328e6 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,10 +9,10 @@
"version": "0.0.1",
"license": "UNLICENSED",
"dependencies": {
- "@nestjs/common": "^11.1.9",
- "@nestjs/core": "^11.1.9",
+ "@nestjs/common": "^11.1.18",
+ "@nestjs/core": "^11.1.18",
"@nestjs/mapped-types": "^2.1.0",
- "@nestjs/platform-express": "^11.1.9",
+ "@nestjs/platform-express": "^11.1.18",
"@nestjs/schedule": "^6.0.1",
"@nestjs/terminus": "^11.0.0",
"@nestjs/throttler": "^6.4.0",
@@ -24,7 +24,7 @@
"class-transformer": "^0.5.1",
"class-validator": "^0.14.2",
"date-fns": "^4.1.0",
- "nodemailer": "^7.0.10",
+ "nodemailer": "^8.0.5",
"prisma": "^6.19.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2"
@@ -32,7 +32,7 @@
"devDependencies": {
"@nestjs/cli": "^11.0.12",
"@nestjs/schematics": "^11.0.9",
- "@nestjs/testing": "^11.1.9",
+ "@nestjs/testing": "^11.1.18",
"@types/express": "^5.0.5",
"@types/jest": "^30.0.0",
"@types/node": "^24.10.1",
@@ -3025,13 +3025,13 @@
}
},
"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.18",
+ "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.18.tgz",
+ "integrity": "sha512-0sLq8Z+TIjLnz1Tqp0C/x9BpLbqpt1qEu0VcH4/fkE0y3F5JxhfK1AdKQ/SPbKhKgwqVDoY4gS8GQr2G6ujaWg==",
"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,9 +3057,9 @@
}
},
"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.18",
+ "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.1.18.tgz",
+ "integrity": "sha512-wR3DtGyk/LUAiPtbXDuWJJwVkWElKBY0sqnTzf9d4uM3+X18FRZhK7WFc47czsIGOdWuRsMeLYV+1Z9dO4zDEQ==",
"hasInstallScript": true,
"license": "MIT",
"peer": true,
@@ -3067,7 +3067,7 @@
"@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"
},
@@ -3119,16 +3119,16 @@
}
},
"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.18",
+ "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.18.tgz",
+ "integrity": "sha512-s6GdHMTa3qx0fJewR74Xa30ysPHfBEqxIwZ7BGSTLoAEQ1vTP24urNl+b6+s49NFLEIOyeNho5fN/9/I17QlOw==",
"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": {
@@ -3298,9 +3298,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.18",
+ "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-11.1.18.tgz",
+ "integrity": "sha512-frzwNlpBgtAzI3hp/qo57DZoRO4RMTH1wST3QUYEhRTHyfPkLpzkWz3jV/mhApXjD0yT56Ptlzn6zuYPLh87Lw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -6857,9 +6857,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",
@@ -7983,18 +7983,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",
@@ -8213,14 +8214,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": {
@@ -10657,18 +10658,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"
@@ -10895,9 +10893,9 @@
"license": "MIT"
},
"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.5",
+ "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.5.tgz",
+ "integrity": "sha512-0PF8Yb1yZuQfQbq+5/pZJrtF6WQcjTd5/S4JOHs9PGFxuTqoB/icwuB44pOdURHJbRKX1PPoJZtY7R4VUoCC8w==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
@@ -11272,9 +11270,9 @@
}
},
"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",
diff --git a/package.json b/package.json
index 7be4523..b91f17e 100644
--- a/package.json
+++ b/package.json
@@ -35,10 +35,10 @@
"node": "24.11.1"
},
"dependencies": {
- "@nestjs/common": "^11.1.9",
- "@nestjs/core": "^11.1.9",
+ "@nestjs/common": "^11.1.18",
+ "@nestjs/core": "^11.1.18",
"@nestjs/mapped-types": "^2.1.0",
- "@nestjs/platform-express": "^11.1.9",
+ "@nestjs/platform-express": "^11.1.18",
"@nestjs/schedule": "^6.0.1",
"@nestjs/terminus": "^11.0.0",
"@nestjs/throttler": "^6.4.0",
@@ -50,7 +50,7 @@
"class-transformer": "^0.5.1",
"class-validator": "^0.14.2",
"date-fns": "^4.1.0",
- "nodemailer": "^7.0.10",
+ "nodemailer": "^8.0.5",
"prisma": "^6.19.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2"
@@ -58,7 +58,7 @@
"devDependencies": {
"@nestjs/cli": "^11.0.12",
"@nestjs/schematics": "^11.0.9",
- "@nestjs/testing": "^11.1.9",
+ "@nestjs/testing": "^11.1.18",
"@types/express": "^5.0.5",
"@types/jest": "^30.0.0",
"@types/node": "^24.10.1",
From 70cc8221cf2405278dad2b639934aab1a82a26c1 Mon Sep 17 00:00:00 2001
From: Stualyttle <27898452+Stualyttle@users.noreply.github.com.>
Date: Sun, 24 May 2026 14:19:54 +0200
Subject: [PATCH 03/43] feat: update Node.js version to 24.16.0 and refresh npm
packages
---
Dockerfile | 8 +-
IMPLEMENTATION_STATUS.md | 3 +-
README.md | 14 +-
package-lock.json | 5687 ++++++++++++--------------------------
package.json | 67 +-
5 files changed, 1813 insertions(+), 3966 deletions(-)
diff --git a/Dockerfile b/Dockerfile
index 8bc3bec..ca82602 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
@@ -20,14 +20,14 @@ RUN npm prune --production && \
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 install -y --no-install-recommends \
curl \
@@ -41,8 +41,6 @@ RUN apt-get update && \
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/*
diff --git a/IMPLEMENTATION_STATUS.md b/IMPLEMENTATION_STATUS.md
index 7389a60..ce73805 100644
--- a/IMPLEMENTATION_STATUS.md
+++ b/IMPLEMENTATION_STATUS.md
@@ -9,7 +9,7 @@ Use it as the single place to record what has shipped, what is in progress, and
- Overall status: in progress
- Current phase: Phase 0 — Delivery setup and guardrails
-- Most recently completed session: Session 2 — Dependency hygiene and secret-handling cleanup
+- Most recently completed session: Session 2 — Dependency hygiene and secret-handling cleanup (followed by 2026-05-24 dependency/toolchain refresh)
- Next recommended session from the roadmap: Session 3 — Lock down public mutating endpoints
- Readiness reference: `PRODUCTION_READINESS_ASSESSMENT.md`
- Architecture decision log: `ARCHITECTURE_DECISIONS.md`
@@ -68,6 +68,7 @@ Use it as the single place to record what has shipped, what is in progress, and
- Session 28 — upgrade CI/CD and release gating
- Notes:
- upgraded the direct dependency versions called out in the assessment (`@nestjs/core`-aligned Nest packages and `nodemailer`)
+ - follow-up maintenance on 2026-05-24 refreshed the full npm package set to the latest available versions, regenerated `package-lock.json`, and pinned the repo toolchain to Node `24.16.0` and npm `11.15.0`
- tightened `.gitignore` so live env files, generated key material, and backup artifacts stay out of git while keeping `.env.example` tracked
- rewrote `.env.example` to use safe placeholders and explicit runtime-injection guidance
- documented Docker Swarm secrets and external secret-manager expectations in `README.md`
diff --git a/README.md b/README.md
index 3ebf963..71e19f4 100644
--- a/README.md
+++ b/README.md
@@ -19,7 +19,7 @@ Built with [NestJS](https://nestjs.com/) • Powered by [PostgreSQL](https://www
## 📍 Current Delivery Status
- **Roadmap status:** Phase 0 in progress
-- **Completed in Sessions 1-2:** delivery scaffolding, status tracking, dependency hygiene for known direct CVEs, and secret-handling cleanup for publishable env examples
+- **Completed in Sessions 1-2 plus follow-up maintenance:** delivery scaffolding, status tracking, dependency hygiene for known direct CVEs, secret-handling cleanup for publishable env examples, and a full npm/toolchain refresh to the latest available package set on 2026-05-24
- **Next recommended implementation session:** Session 3 — lock down public mutating endpoints
- **Canonical planning and status docs:**
- [`PRODUCTION_READINESS_ASSESSMENT.md`](PRODUCTION_READINESS_ASSESSMENT.md)
@@ -46,6 +46,13 @@ npm run build
npm run verify
```
+Current repo toolchain target:
+
+```bash
+node -v # expected: v24.16.0
+npm -v # expected: 11.15.0
+```
+
`npm run test` is currently an explicit placeholder until Session 26 adds the automated test harness.
---
@@ -149,7 +156,8 @@ npm run verify
### Prerequisites
-- Node.js 24.11.1 or higher
+- Node.js 24.16.0
+- npm 11.15.0
- PostgreSQL 12 or higher
- Docker & Docker Compose (optional)
@@ -647,7 +655,7 @@ version: '3.8'
services:
postgres:
- image: postgres:16-alpine
+ image: postgres:18.4-alpine
environment:
POSTGRES_DB: lyttlenginx
POSTGRES_USER: lyttlenginx
diff --git a/package-lock.json b/package-lock.json
index 5a328e6..e1a35a5 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,63 +9,63 @@
"version": "0.0.1",
"license": "UNLICENSED",
"dependencies": {
- "@nestjs/common": "^11.1.18",
- "@nestjs/core": "^11.1.18",
- "@nestjs/mapped-types": "^2.1.0",
- "@nestjs/platform-express": "^11.1.18",
- "@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",
+ "archiver": "^8.0.0",
"class-transformer": "^0.5.1",
- "class-validator": "^0.14.2",
- "date-fns": "^4.1.0",
- "nodemailer": "^8.0.5",
- "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.18",
- "@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"
}
},
"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 +83,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 +111,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 +130,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 +192,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 +213,7 @@
"integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=6.9.0"
}
@@ -945,6 +256,7 @@
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"dev": true,
"license": "ISC",
+ "peer": true,
"bin": {
"semver": "bin/semver.js"
}
@@ -953,8 +265,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 +285,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 +303,7 @@
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"dev": true,
"license": "ISC",
+ "peer": true,
"bin": {
"semver": "bin/semver.js"
}
@@ -997,8 +312,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 +325,7 @@
"integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/traverse": "^7.27.1",
"@babel/types": "^7.27.1"
@@ -1023,6 +340,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 +359,7 @@
"integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=6.9.0"
}
@@ -1049,8 +368,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 +379,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 +391,7 @@
"integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=6.9.0"
}
@@ -1081,6 +402,7 @@
"integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/template": "^7.27.2",
"@babel/types": "^7.28.4"
@@ -1093,8 +415,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 +434,7 @@
"integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/helper-plugin-utils": "^7.8.0"
},
@@ -1124,6 +448,7 @@
"integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/helper-plugin-utils": "^7.8.0"
},
@@ -1137,6 +462,7 @@
"integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/helper-plugin-utils": "^7.12.13"
},
@@ -1150,6 +476,7 @@
"integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/helper-plugin-utils": "^7.14.5"
},
@@ -1166,6 +493,7 @@
"integrity": "sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/helper-plugin-utils": "^7.27.1"
},
@@ -1182,6 +510,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 +524,7 @@
"integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/helper-plugin-utils": "^7.8.0"
},
@@ -1208,6 +538,7 @@
"integrity": "sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/helper-plugin-utils": "^7.27.1"
},
@@ -1224,6 +555,7 @@
"integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/helper-plugin-utils": "^7.10.4"
},
@@ -1237,6 +569,7 @@
"integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/helper-plugin-utils": "^7.8.0"
},
@@ -1250,6 +583,7 @@
"integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/helper-plugin-utils": "^7.10.4"
},
@@ -1263,6 +597,7 @@
"integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/helper-plugin-utils": "^7.8.0"
},
@@ -1276,6 +611,7 @@
"integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/helper-plugin-utils": "^7.8.0"
},
@@ -1289,6 +625,7 @@
"integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/helper-plugin-utils": "^7.8.0"
},
@@ -1302,6 +639,7 @@
"integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/helper-plugin-utils": "^7.14.5"
},
@@ -1318,6 +656,7 @@
"integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/helper-plugin-utils": "^7.14.5"
},
@@ -1334,6 +673,7 @@
"integrity": "sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/helper-plugin-utils": "^7.27.1"
},
@@ -1348,8 +688,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 +704,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 +724,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 +740,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 +753,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 +800,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 +834,7 @@
"dev": true,
"license": "MIT",
"optional": true,
+ "peer": true,
"dependencies": {
"@emnapi/wasi-threads": "1.1.0",
"tslib": "^2.4.0"
@@ -1463,6 +847,7 @@
"dev": true,
"license": "MIT",
"optional": true,
+ "peer": true,
"dependencies": {
"tslib": "^2.4.0"
}
@@ -1474,14 +859,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 +894,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.11",
+ "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz",
+ "integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.14.1"
+ },
+ "peerDependencies": {
+ "hono": "^4"
}
},
"node_modules/@humanfs/core": {
@@ -2093,34 +1411,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 +1434,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 +1448,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 +1475,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 +1496,7 @@
"integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==",
"dev": true,
"license": "ISC",
+ "peer": true,
"dependencies": {
"camelcase": "^5.3.1",
"find-up": "^4.1.0",
@@ -2208,6 +1514,7 @@
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"sprintf-js": "~1.0.2"
}
@@ -2218,6 +1525,7 @@
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=6"
}
@@ -2228,6 +1536,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 +1551,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 +1566,7 @@
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"p-locate": "^4.1.0"
},
@@ -2269,6 +1580,7 @@
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"p-try": "^2.0.0"
},
@@ -2285,6 +1597,7 @@
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"p-limit": "^2.2.0"
},
@@ -2298,6 +1611,7 @@
"integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=8"
}
@@ -2308,6 +1622,7 @@
"integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=8"
}
@@ -2318,6 +1633,7 @@
"integrity": "sha512-+O1ifRjkvYIkBqASKWgLxrpEhQAAE7hY77ALLUufSk5717KfOShg6IbqLmdsLMPdUiFvA2kTs0R7YZy+l0IzZQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@jest/types": "30.2.0",
"@types/node": "*",
@@ -2336,6 +1652,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 +1711,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 +1728,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 +1756,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 +1785,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 +1816,7 @@
"integrity": "sha512-DRyW6baWPqKMa9CzeiBjHwjd8XeAyco2Vt8XbcLFjiwCOEKOvy82GJ8QQnJE9ofsxCMPjH4MfH8fCWIHHDKpAQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@bcoe/v8-coverage": "^0.2.3",
"@jest/console": "30.2.0",
@@ -2538,6 +1860,7 @@
"integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
"dev": true,
"license": "ISC",
+ "peer": true,
"dependencies": {
"foreground-child": "^3.1.0",
"jackspeak": "^3.1.2",
@@ -2559,6 +1882,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 +1898,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 +1907,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 +1938,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 +1955,7 @@
"integrity": "sha512-MIRWMUUR3sdbP36oyNyhbThLHyJ2eEDClPCiHVbrYAe5g3CHRArIVpBw7cdSB5fr+ofSfIb2Tnsw8iEHL0PYQg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@jridgewell/trace-mapping": "^0.3.25",
"callsites": "^3.1.0",
@@ -2643,6 +1971,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 +1988,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 +2005,7 @@
"integrity": "sha512-XsauDV82o5qXbhalKxD7p4TZYYdwcaEXC77PPD2HixEFF+6YGppjrAAQurTl2ECWcEomHBMMNS9AH3kcCFx8jA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/core": "^7.27.4",
"@jest/types": "30.2.0",
@@ -2719,7 +2050,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 +2063,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 +2073,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 +2094,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 +2123,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 +2154,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 +2170,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,146 +2182,25 @@
}
}
},
- "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.18",
- "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.18.tgz",
- "integrity": "sha512-0sLq8Z+TIjLnz1Tqp0C/x9BpLbqpt1qEu0VcH4/fkE0y3F5JxhfK1AdKQ/SPbKhKgwqVDoY4gS8GQr2G6ujaWg==",
+ "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.3.4",
"iterare": "1.2.1",
@@ -3057,12 +2228,11 @@
}
},
"node_modules/@nestjs/core": {
- "version": "11.1.18",
- "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.1.18.tgz",
- "integrity": "sha512-wR3DtGyk/LUAiPtbXDuWJJwVkWElKBY0sqnTzf9d4uM3+X18FRZhK7WFc47czsIGOdWuRsMeLYV+1Z9dO4zDEQ==",
+ "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",
@@ -3099,14 +2269,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,11 +2289,10 @@
}
},
"node_modules/@nestjs/platform-express": {
- "version": "11.1.18",
- "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.18.tgz",
- "integrity": "sha512-s6GdHMTa3qx0fJewR74Xa30ysPHfBEqxIwZ7BGSTLoAEQ1vTP24urNl+b6+s49NFLEIOyeNho5fN/9/I17QlOw==",
+ "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.6",
"express": "5.2.1",
@@ -3141,12 +2310,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 +2323,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 +2416,9 @@
}
},
"node_modules/@nestjs/testing": {
- "version": "11.1.18",
- "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-11.1.18.tgz",
- "integrity": "sha512-frzwNlpBgtAzI3hp/qo57DZoRO4RMTH1wST3QUYEhRTHyfPkLpzkWz3jV/mhApXjD0yT56Ptlzn6zuYPLh87Lw==",
+ "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 +2444,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 +2467,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 +2484,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"
}
@@ -3426,8 +2507,10 @@
"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": ">=14"
}
@@ -3446,18 +2529,19 @@
}
},
"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,
+ "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",
- "peer": true,
+ "dependencies": {
+ "@prisma/client-runtime-utils": "7.8.0"
+ },
"engines": {
- "node": ">=18.18"
+ "node": "^20.19 || ^22.12 || >=24.0"
},
"peerDependencies": {
"prisma": "*",
- "typescript": ">=5.1.0"
+ "typescript": ">=5.4.0"
},
"peerDependenciesMeta": {
"prisma": {
@@ -3468,737 +2552,376 @@
}
}
},
+ "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/@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==",
+ "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": {
- "c12": "3.1.0",
+ "c12": "3.3.4",
"deepmerge-ts": "7.1.5",
- "effect": "3.18.4",
+ "effect": "3.20.0",
"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==",
+ "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/@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"
- }
- },
- "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==",
- "license": "MIT",
- "dependencies": {
- "@promster/metrics": "15.4.2",
- "merge-options": "3.0.4",
- "tslib": "2.8.1"
- },
- "engines": {
- "node": ">=20",
- "npm": ">=8"
- }
- },
- "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==",
- "license": "MIT",
+ "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": {
- "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"
+ "@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/@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==",
+ "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": "Unlicense",
- "optional": true,
- "dependencies": {
- "@mapbox/node-pre-gyp": "^1.0.10",
- "depcheck": "^1.4.3",
- "nan": "^2.17.0"
- },
- "engines": {
- "node": ">=6"
- }
- },
- "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",
- "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"
- }
- },
- "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",
- "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"
- }
- },
- "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",
- "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"
- }
- },
- "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",
- "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"
- }
- },
- "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",
- "dependencies": {
- "@smithy/types": "^4.9.0",
- "tslib": "^2.6.2"
- },
- "engines": {
- "node": ">=18.0.0"
- }
- },
- "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",
- "dependencies": {
- "tslib": "^2.6.2"
- },
- "engines": {
- "node": ">=18.0.0"
- }
- },
- "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",
- "dependencies": {
- "@smithy/protocol-http": "^5.3.5",
- "@smithy/types": "^4.9.0",
- "tslib": "^2.6.2"
- },
- "engines": {
- "node": ">=18.0.0"
- }
- },
- "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",
- "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"
- }
- },
- "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",
- "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"
- },
- "engines": {
- "node": ">=18.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"
- },
- "engines": {
- "node": ">=18.0.0"
- }
- },
- "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"
- },
- "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"
- },
- "engines": {
- "node": ">=18.0.0"
- }
- },
- "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==",
- "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"
- },
- "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"
- },
- "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/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"
- },
- "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/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==",
+ "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": {
- "@smithy/types": "^4.9.0",
- "@smithy/util-uri-escape": "^4.2.0",
- "tslib": "^2.6.2"
- },
- "engines": {
- "node": ">=18.0.0"
+ "@prisma/debug": "7.8.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==",
- "license": "Apache-2.0",
- "dependencies": {
- "@smithy/types": "^4.9.0",
- "tslib": "^2.6.2"
- },
- "engines": {
- "node": ">=18.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==",
+ "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/types": "^4.9.0"
- },
- "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/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==",
+ "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/types": "^4.9.0",
- "tslib": "^2.6.2"
- },
- "engines": {
- "node": ">=18.0.0"
+ "@prisma/debug": "7.8.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/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": {
- "@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.2.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/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/@smithy/types": {
- "version": "4.9.0",
- "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.9.0.tgz",
- "integrity": "sha512-MvUbdnXDTwykR8cB1WZvNNwqoWVaTRA0RLlLmf/cIFNMM2cKWz01X4Ly6SMC4Kks30r8tT3Cty0jmeWfiuyHTA==",
- "license": "Apache-2.0",
- "dependencies": {
- "tslib": "^2.6.2"
- },
- "engines": {
- "node": ">=18.0.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/@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/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": {
- "@smithy/querystring-parser": "^4.2.5",
- "@smithy/types": "^4.9.0",
- "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-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/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/util-buffer-from": "^4.2.0",
- "@smithy/util-utf8": "^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_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==",
- "license": "Apache-2.0",
- "dependencies": {
- "tslib": "^2.6.2"
+ "node": "^20.19 || ^22.12 || >=24.0",
+ "pnpm": "8"
},
- "engines": {
- "node": ">=18.0.0"
+ "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-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==",
- "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-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==",
- "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/is-array-buffer": "^4.2.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_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",
- "dependencies": {
- "tslib": "^2.6.2"
+ "node": ">=20",
+ "npm": ">=8"
},
- "engines": {
- "node": ">=18.0.0"
+ "peerDependencies": {
+ "prom-client": "13.x.x || 14.x || 15.x"
}
},
- "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",
- "dependencies": {
- "@smithy/property-provider": "^4.2.5",
- "@smithy/smithy-client": "^4.9.8",
- "@smithy/types": "^4.9.0",
- "tslib": "^2.6.2"
- },
- "engines": {
- "node": ">=18.0.0"
- }
+ "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/@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/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 +2972,7 @@
"dev": true,
"license": "MIT",
"optional": true,
+ "peer": true,
"dependencies": {
"tslib": "^2.4.0"
}
@@ -4268,6 +2992,7 @@
"integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/parser": "^7.20.7",
"@babel/types": "^7.20.7",
@@ -4282,6 +3007,7 @@
"integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/types": "^7.0.0"
}
@@ -4292,6 +3018,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 +3030,7 @@
"integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/types": "^7.28.2"
}
@@ -4341,7 +3069,6 @@
"integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@types/estree": "*",
"@types/json-schema": "*"
@@ -4358,6 +3085,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 +3100,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 +3189,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 +3221,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 +3251,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 +3282,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 +3316,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 +3339,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 +3365,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 +3388,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 +3410,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 +3423,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 +3447,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 +3466,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 +3490,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 +3552,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 +3575,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 +3592,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 +3607,8 @@
"optional": true,
"os": [
"android"
- ]
+ ],
+ "peer": true
},
"node_modules/@unrs/resolver-binding-android-arm64": {
"version": "1.11.1",
@@ -4875,7 +3622,8 @@
"optional": true,
"os": [
"android"
- ]
+ ],
+ "peer": true
},
"node_modules/@unrs/resolver-binding-darwin-arm64": {
"version": "1.11.1",
@@ -4889,7 +3637,8 @@
"optional": true,
"os": [
"darwin"
- ]
+ ],
+ "peer": true
},
"node_modules/@unrs/resolver-binding-darwin-x64": {
"version": "1.11.1",
@@ -4903,7 +3652,8 @@
"optional": true,
"os": [
"darwin"
- ]
+ ],
+ "peer": true
},
"node_modules/@unrs/resolver-binding-freebsd-x64": {
"version": "1.11.1",
@@ -4917,7 +3667,8 @@
"optional": true,
"os": [
"freebsd"
- ]
+ ],
+ "peer": true
},
"node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": {
"version": "1.11.1",
@@ -4931,7 +3682,8 @@
"optional": true,
"os": [
"linux"
- ]
+ ],
+ "peer": true
},
"node_modules/@unrs/resolver-binding-linux-arm-musleabihf": {
"version": "1.11.1",
@@ -4945,7 +3697,8 @@
"optional": true,
"os": [
"linux"
- ]
+ ],
+ "peer": true
},
"node_modules/@unrs/resolver-binding-linux-arm64-gnu": {
"version": "1.11.1",
@@ -4959,7 +3712,8 @@
"optional": true,
"os": [
"linux"
- ]
+ ],
+ "peer": true
},
"node_modules/@unrs/resolver-binding-linux-arm64-musl": {
"version": "1.11.1",
@@ -4973,7 +3727,8 @@
"optional": true,
"os": [
"linux"
- ]
+ ],
+ "peer": true
},
"node_modules/@unrs/resolver-binding-linux-ppc64-gnu": {
"version": "1.11.1",
@@ -4987,7 +3742,8 @@
"optional": true,
"os": [
"linux"
- ]
+ ],
+ "peer": true
},
"node_modules/@unrs/resolver-binding-linux-riscv64-gnu": {
"version": "1.11.1",
@@ -5001,7 +3757,8 @@
"optional": true,
"os": [
"linux"
- ]
+ ],
+ "peer": true
},
"node_modules/@unrs/resolver-binding-linux-riscv64-musl": {
"version": "1.11.1",
@@ -5015,7 +3772,8 @@
"optional": true,
"os": [
"linux"
- ]
+ ],
+ "peer": true
},
"node_modules/@unrs/resolver-binding-linux-s390x-gnu": {
"version": "1.11.1",
@@ -5029,7 +3787,8 @@
"optional": true,
"os": [
"linux"
- ]
+ ],
+ "peer": true
},
"node_modules/@unrs/resolver-binding-linux-x64-gnu": {
"version": "1.11.1",
@@ -5043,7 +3802,8 @@
"optional": true,
"os": [
"linux"
- ]
+ ],
+ "peer": true
},
"node_modules/@unrs/resolver-binding-linux-x64-musl": {
"version": "1.11.1",
@@ -5057,7 +3817,8 @@
"optional": true,
"os": [
"linux"
- ]
+ ],
+ "peer": true
},
"node_modules/@unrs/resolver-binding-wasm32-wasi": {
"version": "1.11.1",
@@ -5069,6 +3830,7 @@
"dev": true,
"license": "MIT",
"optional": true,
+ "peer": true,
"dependencies": {
"@napi-rs/wasm-runtime": "^0.2.11"
},
@@ -5088,7 +3850,8 @@
"optional": true,
"os": [
"win32"
- ]
+ ],
+ "peer": true
},
"node_modules/@unrs/resolver-binding-win32-ia32-msvc": {
"version": "1.11.1",
@@ -5102,7 +3865,8 @@
"optional": true,
"os": [
"win32"
- ]
+ ],
+ "peer": true
},
"node_modules/@unrs/resolver-binding-win32-x64-msvc": {
"version": "1.11.1",
@@ -5116,78 +3880,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 +4058,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",
@@ -5397,12 +4084,11 @@
}
},
"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"
},
@@ -5436,36 +4122,21 @@
"node_modules/acorn-walk": {
"version": "8.3.4",
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz",
- "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "acorn": "^8.11.0"
- },
- "engines": {
- "node": ">=0.4.0"
- }
- },
- "node_modules/agent-base": {
- "version": "6.0.2",
- "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
- "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
+ "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==",
+ "dev": true,
"license": "MIT",
- "optional": true,
"dependencies": {
- "debug": "4"
+ "acorn": "^8.11.0"
},
"engines": {
- "node": ">= 6.0.0"
+ "node": ">=0.4.0"
}
},
"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,
"license": "MIT",
- "peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@@ -5533,6 +4204,7 @@
"integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"type-fest": "^0.21.3"
},
@@ -5549,6 +4221,7 @@
"integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==",
"dev": true,
"license": "(MIT OR CC0-1.0)",
+ "peer": true,
"engines": {
"node": ">=10"
},
@@ -5560,7 +4233,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 +4274,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 +4289,7 @@
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=8.6"
},
@@ -5626,134 +4303,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 +4349,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 +4356,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",
@@ -5827,6 +4376,15 @@
"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/b4a": {
"version": "1.7.3",
"resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.3.tgz",
@@ -5847,6 +4405,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 +4428,7 @@
"integrity": "sha512-D8Z6Qm8jCvVXtIRkBnqNHX0zJ37rQcFJ9u8WOS6tkYOsRdHBzypCstaxWiu5ZIlqQtviRYbgnRLSoCEvjqcqbA==",
"dev": true,
"license": "BSD-3-Clause",
+ "peer": true,
"workspaces": [
"test/babel-8"
],
@@ -5889,6 +4449,7 @@
"integrity": "sha512-ftzhzSGMUnOzcCXd6WHdBGMyuwy15Wnn0iyyWGKgBDLxf9/s5ABuraCSpBX2uG0jUg4rqJnxsLc5+oYBqoxVaA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@types/babel__core": "^7.20.5"
},
@@ -5902,6 +4463,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 +4491,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 +4507,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 +4545,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 +4598,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 +4685,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 +4696,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 +4706,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 +4725,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 +4758,7 @@
"integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==",
"dev": true,
"license": "Apache-2.0",
+ "peer": true,
"dependencies": {
"node-int64": "^0.4.0"
}
@@ -6262,26 +4824,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 +4851,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 +4908,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 +4931,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 +4973,7 @@
"integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=10"
}
@@ -6403,6 +4985,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 +5010,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 +5022,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 +5048,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 +5143,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 +5159,7 @@
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=8"
}
@@ -6593,6 +5170,7 @@
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"ansi-regex": "^5.0.1"
},
@@ -6606,6 +5184,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 +5213,7 @@
"integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"iojs": ">= 1.0.0",
"node": ">= 0.12.0"
@@ -6644,7 +5224,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,16 +5245,6 @@
"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",
@@ -6698,14 +5269,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 +5293,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 +5357,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 +5371,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 +5398,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",
@@ -6909,16 +5485,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,9 +5505,9 @@
"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",
@@ -6939,6 +5515,10 @@
},
"engines": {
"node": ">=18.x"
+ },
+ "funding": {
+ "type": "ko-fi",
+ "url": "https://ko-fi.com/intcreator"
}
},
"node_modules/cross-spawn": {
@@ -6955,10 +5535,17 @@
"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.1.0",
- "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
- "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
+ "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": {
"type": "github",
@@ -6988,6 +5575,7 @@
"integrity": "sha512-HGFtf8yhuhGhqO07SV79tRp+br4MnbdjeVxotpn1QBl30pcLLCQjX5b2295ll0fv8RKDKsmWYrl05usHM9CewQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"peerDependencies": {
"babel-plugin-macros": "^3.1.0"
},
@@ -7037,9 +5625,9 @@
}
},
"node_modules/defu": {
- "version": "6.1.4",
- "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz",
- "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==",
+ "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": {
@@ -7052,234 +5640,13 @@
"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"
- },
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/isaacs"
- }
- },
- "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==",
- "license": "MIT",
- "optional": true,
- "engines": {
- "node": ">=8.6"
- },
- "funding": {
- "url": "https://github.com/sponsors/jonschlinkert"
- }
- },
- "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==",
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "picomatch": "^2.2.1"
- },
- "engines": {
- "node": ">=8.10.0"
- }
- },
- "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==",
- "license": "MIT",
- "optional": true,
- "engines": {
- "node": ">=8"
- }
- },
- "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==",
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "ansi-regex": "^5.0.1"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "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==",
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "ansi-styles": "^4.0.0",
- "string-width": "^4.1.0",
- "strip-ansi": "^6.0.0"
- },
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
- }
- },
- "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==",
- "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_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 +5658,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 +5697,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 +5726,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 +5737,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 +5747,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 +5759,7 @@
"integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=12"
},
@@ -7448,37 +5792,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 +5846,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"
},
@@ -7537,11 +5880,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 +5920,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 +5953,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 +5961,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 +5981,6 @@
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
"dev": true,
"license": "MIT",
- "peer": true,
"bin": {
"eslint-config-prettier": "bin/cli.js"
},
@@ -7645,14 +5992,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 +6023,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 +6055,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 +6071,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 +6125,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 +6175,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 +6186,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 +6221,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 +6273,7 @@
"integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"cross-spawn": "^7.0.3",
"get-stream": "^6.0.0",
@@ -7939,7 +6297,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 +6306,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",
@@ -8073,7 +6420,6 @@
"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": {
@@ -8089,36 +6435,6 @@
"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==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "is-glob": "^4.0.1"
- },
- "engines": {
- "node": ">= 6"
- }
- },
"node_modules/fast-json-stable-stringify": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
@@ -8143,7 +6459,6 @@
"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,
"funding": [
{
"type": "github",
@@ -8156,49 +6471,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",
@@ -8235,7 +6535,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"
@@ -8278,22 +6578,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",
@@ -8474,39 +6758,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",
@@ -8518,8 +6769,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",
@@ -8532,6 +6784,7 @@
"os": [
"darwin"
],
+ "peer": true,
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
@@ -8545,56 +6798,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": {
@@ -8603,6 +6813,7 @@
"integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=6.9.0"
}
@@ -8611,8 +6822,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.*"
}
@@ -8647,10 +6859,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",
@@ -8670,6 +6889,7 @@
"integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=10"
},
@@ -8678,41 +6898,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"
@@ -8738,78 +6944,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": {
@@ -8830,17 +7001,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": {
@@ -8906,13 +7082,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",
@@ -8925,17 +7094,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": {
@@ -8943,7 +7108,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",
@@ -8965,19 +7131,11 @@
"url": "https://opencollective.com/express"
}
},
- "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"
- },
- "engines": {
- "node": ">= 6"
- }
+ "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/human-signals": {
"version": "2.1.0",
@@ -8985,6 +7143,7 @@
"integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==",
"dev": true,
"license": "Apache-2.0",
+ "peer": true,
"engines": {
"node": ">=10.17.0"
}
@@ -9039,7 +7198,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",
@@ -9058,6 +7217,7 @@
"integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"pkg-dir": "^4.2.0",
"resolve-cwd": "^3.0.0"
@@ -9087,8 +7247,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"
@@ -9100,13 +7261,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",
@@ -9120,30 +7274,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"
@@ -9164,6 +7302,7 @@
"integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=6"
}
@@ -9172,7 +7311,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"
@@ -9195,7 +7334,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"
@@ -9216,11 +7355,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"
},
@@ -9241,16 +7388,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",
@@ -9269,6 +7406,7 @@
"integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
"dev": true,
"license": "BSD-3-Clause",
+ "peer": true,
"engines": {
"node": ">=8"
}
@@ -9279,6 +7417,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",
@@ -9296,6 +7435,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",
@@ -9311,6 +7451,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",
@@ -9326,6 +7467,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"
@@ -9343,22 +7485,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",
@@ -9393,6 +7519,7 @@
"integrity": "sha512-L8lR1ChrRnSdfeOvTrwZMlnWV8G/LLjQ0nG9MBclwWZidA2N5FviRki0Bvh20WRMOX31/JYvzdqTJrk5oBdydQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"execa": "^5.1.1",
"jest-util": "30.2.0",
@@ -9408,6 +7535,7 @@
"integrity": "sha512-Fh0096NC3ZkFx05EP2OXCxJAREVxj1BcW/i6EWqqymcgYKWjyyDpral3fMxVcHXg6oZM7iULer9wGRFvfpl+Tg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@jest/environment": "30.2.0",
"@jest/expect": "30.2.0",
@@ -9440,6 +7568,7 @@
"integrity": "sha512-Os9ukIvADX/A9sLt6Zse3+nmHtHaE6hqOsjQtNiugFTbKRHYIYtZXNGNK9NChseXy7djFPjndX1tL0sCTlfpAA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@jest/core": "30.2.0",
"@jest/test-result": "30.2.0",
@@ -9473,6 +7602,7 @@
"integrity": "sha512-g4WkyzFQVWHtu6uqGmQR4CQxz/CH3yDSlhzXMWzNjDx843gYjReZnMRanjRCq5XZFuQrGDxgUaiYWE8BRfVckA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/core": "^7.27.4",
"@jest/get-type": "30.1.0",
@@ -9525,6 +7655,7 @@
"integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
"dev": true,
"license": "ISC",
+ "peer": true,
"dependencies": {
"foreground-child": "^3.1.0",
"jackspeak": "^3.1.2",
@@ -9546,6 +7677,7 @@
"integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
"dev": true,
"license": "BlueOak-1.0.0",
+ "peer": true,
"dependencies": {
"@isaacs/cliui": "^8.0.2"
},
@@ -9561,7 +7693,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",
@@ -9569,6 +7702,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"
@@ -9602,6 +7736,7 @@
"integrity": "sha512-tR/FFgZKS1CXluOQzZvNH3+0z9jXr3ldGSD8bhyuxvlVUwbeLOGynkunvlTMxchC5urrKndYiwCFC0DLVjpOCA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"detect-newline": "^3.1.0"
},
@@ -9615,6 +7750,7 @@
"integrity": "sha512-lpWlJlM7bCUf1mfmuqTA8+j2lNURW9eNafOy99knBM01i5CQeY5UH1vZjgT9071nDJac1M4XsbyI44oNOdhlDQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@jest/get-type": "30.1.0",
"@jest/types": "30.2.0",
@@ -9632,6 +7768,7 @@
"integrity": "sha512-ElU8v92QJ9UrYsKrxDIKCxu6PfNj4Hdcktcn0JX12zqNdqWHB0N+hwOnnBBXvjLd2vApZtuLUGs1QSY+MsXoNA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@jest/environment": "30.2.0",
"@jest/fake-timers": "30.2.0",
@@ -9651,6 +7788,7 @@
"integrity": "sha512-sQA/jCb9kNt+neM0anSj6eZhLZUIhQgwDt7cPGjumgLM4rXsfb9kpnlacmvZz3Q5tb80nS+oG/if+NBKrHC+Xw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@jest/types": "30.2.0",
"@types/node": "*",
@@ -9676,6 +7814,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"
@@ -9742,6 +7881,7 @@
"integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=6"
},
@@ -9770,6 +7910,7 @@
"integrity": "sha512-TCrHSxPlx3tBY3hWNtRQKbtgLhsXa1WmbJEqBlTBrGafd5fiQFByy2GNCEoGR+Tns8d15GaL9cxEzKOO3GEb2A==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"chalk": "^4.1.2",
"graceful-fs": "^4.2.11",
@@ -9790,6 +7931,7 @@
"integrity": "sha512-xTOIGug/0RmIe3mmCqCT95yO0vj6JURrn1TKWlNbhiAefJRWINNPgwVkrVgt/YaerPzY3iItufd80v3lOrFJ2w==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"jest-regex-util": "30.0.1",
"jest-snapshot": "30.2.0"
@@ -9804,6 +7946,7 @@
"integrity": "sha512-PqvZ2B2XEyPEbclp+gV6KO/F1FIFSbIwewRgmROCMBo/aZ6J1w8Qypoj2pEOcg3G2HzLlaP6VUtvwCI8dM3oqQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@jest/console": "30.2.0",
"@jest/environment": "30.2.0",
@@ -9838,6 +7981,7 @@
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"dev": true,
"license": "BSD-3-Clause",
+ "peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -9848,6 +7992,7 @@
"integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"buffer-from": "^1.0.0",
"source-map": "^0.6.0"
@@ -9859,6 +8004,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",
@@ -9893,6 +8039,7 @@
"integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
"dev": true,
"license": "ISC",
+ "peer": true,
"dependencies": {
"foreground-child": "^3.1.0",
"jackspeak": "^3.1.2",
@@ -9914,6 +8061,7 @@
"integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
"dev": true,
"license": "BlueOak-1.0.0",
+ "peer": true,
"dependencies": {
"@isaacs/cliui": "^8.0.2"
},
@@ -9929,7 +8077,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",
@@ -9937,6 +8086,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"
@@ -9954,6 +8104,7 @@
"integrity": "sha512-5WEtTy2jXPFypadKNpbNkZ72puZCa6UjSr/7djeecHWOu7iYhSXSnHScT8wBz3Rn8Ena5d5RYRcsyKIeqG1IyA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/core": "^7.27.4",
"@babel/generator": "^7.27.5",
@@ -10005,6 +8156,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",
@@ -10023,6 +8175,7 @@
"integrity": "sha512-PYxa28dxJ9g777pGm/7PrbnMeA0Jr7osHP9bS7eJy9DuAjMgdGtxgf0uKMyoIsTWAkIbUW5hSDdJ3urmgXBqxg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@jest/test-result": "30.2.0",
"@jest/types": "30.2.0",
@@ -10043,6 +8196,7 @@
"integrity": "sha512-0Q4Uk8WF7BUwqXHuAjc23vmopWJw5WH7w2tqBoUOZpOjW/ZnR44GXXd1r82RvnmI2GZge3ivrYXk/BE2+VtW2g==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@types/node": "*",
"@ungap/structured-clone": "^1.3.0",
@@ -10060,6 +8214,7 @@
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"has-flag": "^4.0.0"
},
@@ -10083,7 +8238,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": {
@@ -10103,8 +8258,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"
},
@@ -10123,14 +8279,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": {
@@ -10144,7 +8299,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"
@@ -10231,6 +8386,7 @@
"integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=6"
}
@@ -10259,7 +8415,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": {
@@ -10315,27 +8471,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",
@@ -10353,16 +8498,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",
@@ -10388,6 +8555,7 @@
"integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"semver": "^7.5.3"
},
@@ -10411,6 +8579,7 @@
"integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==",
"dev": true,
"license": "BSD-3-Clause",
+ "peer": true,
"dependencies": {
"tmpl": "1.0.5"
}
@@ -10477,16 +8646,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",
@@ -10501,7 +8660,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",
@@ -10515,7 +8674,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"
@@ -10576,7 +8735,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"
},
@@ -10591,66 +8752,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",
@@ -10715,50 +8832,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",
@@ -10769,30 +8842,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": {
@@ -10801,6 +8880,7 @@
"integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"bin": {
"napi-postinstall": "lib/cli.js"
},
@@ -10851,72 +8931,33 @@
"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"
- },
- "engines": {
- "node": "4.x || >=6.0.0"
- },
- "peerDependencies": {
- "encoding": "^0.1.0"
- },
- "peerDependenciesMeta": {
- "encoding": {
- "optional": true
- }
- }
- },
- "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": "8.0.5",
- "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.5.tgz",
- "integrity": "sha512-0PF8Yb1yZuQfQbq+5/pZJrtF6WQcjTd5/S4JOHs9PGFxuTqoB/icwuB44pOdURHJbRKX1PPoJZtY7R4VUoCC8w==",
+ "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",
@@ -10932,6 +8973,7 @@
"integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"path-key": "^3.0.0"
},
@@ -10939,39 +8981,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",
@@ -11036,12 +9045,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",
@@ -11145,6 +9148,7 @@
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=6"
}
@@ -11153,13 +9157,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"
@@ -11172,7 +9178,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",
@@ -11187,16 +9193,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",
@@ -11220,8 +9216,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"
}
@@ -11235,17 +9232,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": {
@@ -11253,18 +9243,18 @@
"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"
}
@@ -11283,7 +9273,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"
@@ -11296,22 +9286,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": {
@@ -11327,6 +9317,7 @@
"integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">= 6"
}
@@ -11337,6 +9328,7 @@
"integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"find-up": "^4.0.0"
},
@@ -11350,6 +9342,7 @@
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
@@ -11364,6 +9357,7 @@
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"p-locate": "^4.1.0"
},
@@ -11377,6 +9371,7 @@
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"p-try": "^2.0.0"
},
@@ -11393,6 +9388,7 @@
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"p-limit": "^2.2.0"
},
@@ -11401,26 +9397,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",
@@ -11431,33 +9417,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": {
@@ -11471,12 +9441,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"
},
@@ -11488,9 +9457,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": {
@@ -11529,26 +9498,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
}
@@ -11583,24 +9559,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",
@@ -11639,12 +9614,13 @@
"url": "https://opencollective.com/fast-check"
}
],
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"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"
@@ -11656,37 +9632,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",
@@ -11712,13 +9657,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": {
@@ -11769,30 +9737,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"
@@ -11806,15 +9805,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"
}
@@ -11823,46 +9831,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"
},
@@ -11876,29 +9856,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"
@@ -11907,96 +9874,31 @@
"node_modules/restore-cursor": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz",
- "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "onetime": "^5.1.0",
- "signal-exit": "^3.0.2"
- },
- "engines": {
- "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,
+ "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==",
+ "dev": true,
+ "license": "MIT",
"dependencies": {
- "brace-expansion": "^1.1.7"
+ "onetime": "^5.1.0",
+ "signal-exit": "^3.0.2"
},
"engines": {
- "node": "*"
+ "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/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": ">= 4"
}
},
"node_modules/router": {
@@ -12015,36 +9917,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"
}
@@ -12075,6 +9952,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",
@@ -12100,7 +9984,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",
@@ -12130,10 +10013,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"
@@ -12142,13 +10025,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",
@@ -12171,15 +10047,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",
@@ -12196,13 +10067,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",
@@ -12334,16 +10198,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",
@@ -12369,8 +10223,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",
@@ -12404,6 +10268,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",
@@ -12438,6 +10308,7 @@
"integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"char-regex": "^1.0.2",
"strip-ansi": "^6.0.0"
@@ -12452,6 +10323,7 @@
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=8"
}
@@ -12462,6 +10334,7 @@
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"ansi-regex": "^5.0.1"
},
@@ -12488,7 +10361,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",
@@ -12502,7 +10377,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"
}
@@ -12511,7 +10388,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"
},
@@ -12544,7 +10423,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"
},
@@ -12560,7 +10441,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"
},
@@ -12572,7 +10455,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"
}
@@ -12583,6 +10468,7 @@
"integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=8"
}
@@ -12593,6 +10479,7 @@
"integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=6"
}
@@ -12603,6 +10490,7 @@
"integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=8"
},
@@ -12610,18 +10498,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",
@@ -12639,9 +10515,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": {
@@ -12649,25 +10525,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"
@@ -12685,19 +10562,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",
@@ -12709,9 +10573,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": {
@@ -12725,9 +10589,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": {
@@ -12738,24 +10602,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",
@@ -12767,49 +10613,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": {
@@ -12826,16 +10643,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": {
@@ -12849,12 +10665,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
}
@@ -12942,6 +10785,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",
@@ -12957,6 +10801,7 @@
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
@@ -12969,6 +10814,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",
@@ -12990,6 +10836,7 @@
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dev": true,
"license": "ISC",
+ "peer": true,
"dependencies": {
"brace-expansion": "^1.1.7"
},
@@ -13006,13 +10853,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": {
@@ -13020,13 +10875,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"
@@ -13062,17 +10918,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": {
@@ -13083,19 +10932,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"
},
@@ -13112,7 +10961,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": {
@@ -13149,9 +10998,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": {
@@ -13175,7 +11024,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",
@@ -13280,6 +11128,7 @@
"integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=4"
}
@@ -13317,12 +11166,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"
@@ -13370,9 +11218,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": {
@@ -13401,6 +11249,7 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"napi-postinstall": "^0.3.0"
},
@@ -13430,9 +11279,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": [
{
@@ -13498,6 +11347,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",
@@ -13507,6 +11357,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",
@@ -13531,14 +11395,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": {
@@ -13559,20 +11424,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",
@@ -13580,12 +11437,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",
@@ -13596,9 +11453,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"
@@ -13627,9 +11484,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": {
@@ -13721,17 +11578,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",
@@ -13747,16 +11593,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",
@@ -13806,7 +11642,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",
@@ -13823,7 +11661,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"
}
@@ -13832,7 +11672,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"
},
@@ -13875,6 +11717,7 @@
"integrity": "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==",
"dev": true,
"license": "ISC",
+ "peer": true,
"dependencies": {
"imurmurhash": "^0.1.4",
"signal-exit": "^4.0.1"
@@ -13883,21 +11726,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"
}
@@ -13907,17 +11742,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",
@@ -13925,6 +11751,7 @@
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"cliui": "^8.0.1",
"escalade": "^3.1.1",
@@ -13984,18 +11811,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 b91f17e..2f29b5d 100644
--- a/package.json
+++ b/package.json
@@ -5,6 +5,7 @@
"author": "lyttle-development",
"private": true,
"license": "UNLICENSED",
+ "packageManager": "npm@11.15.0",
"scripts": {
"clean": "rm -rf dist coverage",
"build": "nest build",
@@ -29,52 +30,54 @@
"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.18",
- "@nestjs/core": "^11.1.18",
- "@nestjs/mapped-types": "^2.1.0",
- "@nestjs/platform-express": "^11.1.18",
- "@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",
+ "archiver": "^8.0.0",
"class-transformer": "^0.5.1",
- "class-validator": "^0.14.2",
- "date-fns": "^4.1.0",
- "nodemailer": "^8.0.5",
- "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.18",
- "@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"
}
}
From 078b049b17d4722eb69d83308728f9445911b3b0 Mon Sep 17 00:00:00 2001
From: Stualyttle <27898452+Stualyttle@users.noreply.github.com.>
Date: Sun, 24 May 2026 14:38:46 +0200
Subject: [PATCH 04/43] feat: implement authenticated-by-default control-plane
API with public allowlist
---
ARCHITECTURE_DECISIONS.md | 34 +++
IMPLEMENTATION_STATUS.md | 45 +++-
README.md | 118 +++++----
package.json | 2 +-
src/auth/auth.module.ts | 10 +-
src/auth/auth.service.ts | 29 +-
src/auth/decorators/public.decorator.ts | 5 +
src/auth/guards/api-key.guard.ts | 39 ++-
src/certificate/acme.controller.ts | 2 +
src/health/health.controller.ts | 2 +
src/metrics/metrics.controller.ts | 2 +
test/session3/auth-lockdown.test.js | 336 ++++++++++++++++++++++++
tsconfig.json | 2 +
tsconfig.test.json | 9 +
14 files changed, 566 insertions(+), 69 deletions(-)
create mode 100644 src/auth/decorators/public.decorator.ts
create mode 100644 test/session3/auth-lockdown.test.js
create mode 100644 tsconfig.test.json
diff --git a/ARCHITECTURE_DECISIONS.md b/ARCHITECTURE_DECISIONS.md
index dcbe0e8..3e671db 100644
--- a/ARCHITECTURE_DECISIONS.md
+++ b/ARCHITECTURE_DECISIONS.md
@@ -21,6 +21,7 @@ This file records repository-level architectural and delivery decisions so futur
| ADR-003 | Standard repository verification contract | accepted | Session 1 | 2026-05-24 |
| ADR-004 | Deployment mode expectations | accepted | Session 1 | 2026-05-24 |
| ADR-005 | Secret material stays out of git | accepted | Session 2 | 2026-05-24 |
+| ADR-006 | Authenticated-by-default control-plane API | accepted | Session 3 | 2026-05-24 |
---
@@ -169,3 +170,36 @@ Adopt the following repository policy:
- operators have an explicit default for where production secrets should live even before first-class secret-provider integration lands
- later sessions can add `_FILE` support or deeper secret-provider integrations without changing the repository rule that sensitive material must stay out of git
+---
+
+## ADR-006 — Authenticated-by-default control-plane API
+
+- Status: accepted
+- Session: Session 3 — Lock down public mutating endpoints
+- Date: 2026-05-24
+
+### Context
+
+The production-readiness assessment identified several P0 write endpoints that were publicly reachable, including certificate upload, self-signed certificate generation, certificate sync, and DH parameter generation. The repository also relied on per-endpoint guard annotations, which made it easy to miss new routes and accidentally expose control-plane functionality.
+
+### Decision
+
+Adopt an authenticated-by-default API policy for the NestJS control plane:
+
+1. register the API-key guard globally so admin and internal-control endpoints require authentication unless explicitly marked otherwise
+2. keep a small, explicit public allowlist using a `@Public()` decorator
+3. limit the current public allowlist to:
+ - `GET /health`
+ - `GET /ready`
+ - `GET /metrics`
+ - `GET /metrics/json`
+ - `GET /.well-known/acme-challenge/:token`
+4. remove the previous development-mode bypass that made protected endpoints public when `API_KEY` was unset
+
+### Consequences
+
+- new endpoints are protected by default, which reduces the chance of future accidental public mutations
+- operators must configure `API_KEY` even in local evaluation when they need to access admin endpoints
+- public observability and ACME flows remain available, but all other current API surfaces should be treated as admin/control-plane endpoints
+- Session 7 and Session 8 can build on this deny-by-default posture when introducing richer identity and RBAC models
+
diff --git a/IMPLEMENTATION_STATUS.md b/IMPLEMENTATION_STATUS.md
index ce73805..17255c6 100644
--- a/IMPLEMENTATION_STATUS.md
+++ b/IMPLEMENTATION_STATUS.md
@@ -8,9 +8,9 @@ Use it as the single place to record what has shipped, what is in progress, and
## Current summary
- Overall status: in progress
-- Current phase: Phase 0 — Delivery setup and guardrails
-- Most recently completed session: Session 2 — Dependency hygiene and secret-handling cleanup (followed by 2026-05-24 dependency/toolchain refresh)
-- Next recommended session from the roadmap: Session 3 — Lock down public mutating endpoints
+- Current phase: Phase 1 — Emergency hardening and correctness fixes
+- Most recently completed session: Session 3 — Lock down public mutating endpoints
+- Next recommended session from the roadmap: Session 4 — Fix health, readiness, liveness, and startup semantics
- Readiness reference: `PRODUCTION_READINESS_ASSESSMENT.md`
- Architecture decision log: `ARCHITECTURE_DECISIONS.md`
@@ -78,13 +78,40 @@ Use it as the single place to record what has shipped, what is in progress, and
## Phase 1 — Emergency hardening and correctness fixes
## Session 3 — Lock down public mutating endpoints
-- Status: not started
+- Status: done
- Objective: require auth on all mutating endpoints and define the public allowlist
-- Files touched: none yet
-- Tests added/updated: none yet
-- Risks: public write endpoints remain a P0 issue until addressed
-- Follow-up sessions: Session 7, Session 8, Session 26
-- Notes: recommended as the next implementation session in the roadmap
+- Files touched:
+ - `package.json`
+ - `README.md`
+ - `IMPLEMENTATION_STATUS.md`
+ - `ARCHITECTURE_DECISIONS.md`
+ - `tsconfig.json`
+ - `tsconfig.test.json`
+ - `src/auth/auth.module.ts`
+ - `src/auth/auth.service.ts`
+ - `src/auth/decorators/public.decorator.ts`
+ - `src/auth/guards/api-key.guard.ts`
+ - `src/certificate/acme.controller.ts`
+ - `src/health/health.controller.ts`
+ - `src/metrics/metrics.controller.ts`
+ - `test/session3/auth-lockdown.test.js`
+- Tests added/updated:
+ - added `test/session3/auth-lockdown.test.js` to prove unauthenticated writes are rejected and explicit public probe endpoints remain reachable
+ - wired `npm run test` to execute the Session 3 regression checks with Node's built-in test runner
+ - verified the focused access-control test suite passes locally
+- Risks:
+ - authentication is still API-key based only; Session 7 and Session 8 remain responsible for real identity and RBAC
+ - internal node-to-node traffic still shares the same auth mechanism and is not yet mTLS-protected
+ - health/readiness semantics are still shallow until Session 4 lands
+- Follow-up sessions:
+ - Session 4 — fix health, readiness, liveness, and startup semantics
+ - Session 7 — introduce a real auth foundation
+ - Session 8 — add RBAC and authorization policies
+ - Session 26 — expand automated test coverage beyond the current focused regression set
+- Notes:
+ - admin and internal-control endpoints are now authenticated by default through a global guard
+ - the explicit public allowlist is currently limited to health probes, metrics, and the ACME challenge endpoint
+ - removed the previous development-mode auth bypass so write endpoints no longer become public when `API_KEY` is unset
## Session 4 — Fix health, readiness, liveness, and startup semantics
- Status: not started
diff --git a/README.md b/README.md
index 71e19f4..4ce794e 100644
--- a/README.md
+++ b/README.md
@@ -5,6 +5,7 @@
+
@@ -18,9 +19,9 @@ Built with [NestJS](https://nestjs.com/) • Powered by [PostgreSQL](https://www
## 📍 Current Delivery Status
-- **Roadmap status:** Phase 0 in progress
-- **Completed in Sessions 1-2 plus follow-up maintenance:** delivery scaffolding, status tracking, dependency hygiene for known direct CVEs, secret-handling cleanup for publishable env examples, and a full npm/toolchain refresh to the latest available package set on 2026-05-24
-- **Next recommended implementation session:** Session 3 — lock down public mutating endpoints
+- **Roadmap status:** Phase 1 in progress
+- **Completed in Sessions 1-3 plus follow-up maintenance:** delivery scaffolding, status tracking, dependency hygiene for known direct CVEs, secret-handling cleanup for publishable env examples, a full npm/toolchain refresh to the latest available package set on 2026-05-24, and Session 3 endpoint lockdown with authenticated-by-default admin APIs plus a targeted regression test pass
+- **Next recommended implementation session:** Session 4 — fix health, readiness, liveness, and startup semantics
- **Canonical planning and status docs:**
- [`PRODUCTION_READINESS_ASSESSMENT.md`](PRODUCTION_READINESS_ASSESSMENT.md)
- [`IMPLEMENTATION_PLAN_BY_SESSION.md`](IMPLEMENTATION_PLAN_BY_SESSION.md)
@@ -53,7 +54,7 @@ node -v # expected: v24.16.0
npm -v # expected: 11.15.0
```
-`npm run test` is currently an explicit placeholder until Session 26 adds the automated test harness.
+`npm run test` now runs the focused Session 3 regression tests for API access control. Session 26 still remains the planned milestone for broad unit/integration/e2e harness expansion.
---
@@ -212,8 +213,9 @@ curl http://localhost:3000/ready
# View metrics
curl http://localhost:3000/metrics/json
-# List certificates
-curl http://localhost:3000/certificates
+# List certificates (admin API key required)
+curl http://localhost:3000/certificates \
+ -H "X-API-Key: $API_KEY"
```
**🎉 You're ready to go!**
@@ -351,54 +353,69 @@ npm run prisma:migrate
## 📚 API Documentation
+### Session 3 access policy
+
+As of Session 3, the control-plane API is **authenticated by default**.
+
+The current explicit public allowlist is limited to:
+
+- `GET /health`
+- `GET /ready`
+- `GET /metrics`
+- `GET /metrics/json`
+- `GET /.well-known/acme-challenge/:token`
+
+All other endpoints should be treated as admin or internal control-plane endpoints and require `X-API-Key` (or `Authorization: ApiKey `).
+
### 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 |
+| Method | Endpoint | Description | Auth |
+|--------|--------------------------------------|-----------------------------------|----------|
+| GET | `/certificates` | List all certificates with status | Required |
+| GET | `/certificates/:id` | Get certificate details | Required |
+| POST | `/certificates/upload` | Upload custom certificate | Required |
+| POST | `/certificates/generate-self-signed` | Generate self-signed cert | Required |
+| POST | `/certificates/renew/:id` | Renew specific certificate | Required |
+| POST | `/certificates/renew-all` | Renew all certificates | Required |
+| DELETE | `/certificates/:id` | Delete certificate | Required |
+| GET | `/certificates/validate/:domain` | Validate domain | Required |
+| POST | `/certificates/sync` | Trigger certificate sync | Required |
### 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 |
+| Method | Endpoint | Description | Auth |
+|--------|-----------------------------------|---------------------|----------|
+| POST | `/certificates/backup` | Create backup | Required |
+| GET | `/certificates/backup` | List backups | Required |
+| GET | `/certificates/backup/:filename` | Download backup | Required |
+| DELETE | `/certificates/backup/:filename` | Delete backup | Required |
+| POST | `/certificates/backup/import` | Import certificates | Required |
+| GET | `/certificates/backup/export/:id` | Export certificate | Required |
### Metrics Endpoints
-| Method | Endpoint | Description |
-|--------|-----------------|--------------------|
-| GET | `/metrics` | Prometheus metrics |
-| GET | `/metrics/json` | JSON metrics |
+| Method | Endpoint | Description | Auth |
+|--------|-----------------|--------------------|--------|
+| GET | `/metrics` | Prometheus metrics | Public |
+| GET | `/metrics/json` | JSON metrics | Public |
### 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 |
+| Method | Endpoint | Description | Auth |
+|--------|-----------------------------------|---------------------|----------|
+| GET | `/tls/config/:domain` | Get TLS config | Required |
+| GET | `/tls/test/:domain` | Test TLS connection | Required |
+| POST | `/tls/dhparam` | Generate DH params | Required |
+| GET | `/tls/dhparam/status` | Check DH params | Required |
+| POST | `/tls/certificate/info` | Parse certificate | Required |
+| POST | `/tls/certificate/validate-chain` | Validate chain | Required |
### Authentication Endpoints
| Method | Endpoint | Description | Auth |
|--------|----------------|-----------------------------|----------|
| GET | `/auth/status` | Check authentication status | Required |
-| GET | `/auth/info` | Get auth configuration info | Public |
+| GET | `/auth/info` | Get auth configuration info | Required |
### Health Endpoints
@@ -406,7 +423,13 @@ npm run prisma:migrate
|--------|------------------|---------------------|--------|
| GET | `/health` | Health check | Public |
| GET | `/ready` | Readiness check | Public |
-| POST | `/health/reload` | Reload NGINX config | Public |
+| POST | `/reload` | Reload NGINX config | Required |
+
+### ACME Challenge Endpoint
+
+| Method | Endpoint | Description | Auth |
+|--------|--------------------------------------|---------------------------|--------|
+| GET | `/.well-known/acme-challenge/:token` | Serve ACME challenge data | Public |
**📖 API documentation status:** a refreshed API reference is still pending; for now, use the controller source under `src/`, `IMPLEMENTATION_STATUS.md`, and `PRODUCTION_READINESS_ASSESSMENT.md` as the current references.
@@ -436,6 +459,7 @@ The system will:
```bash
curl -X POST http://localhost:3000/certificates/upload \
+ -H "X-API-Key: $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"domains": ["example.com", "www.example.com"],
@@ -451,6 +475,7 @@ Perfect for development and testing:
```bash
curl -X POST http://localhost:3000/certificates/generate-self-signed \
+ -H "X-API-Key: $API_KEY" \
-H "Content-Type: application/json" \
-d '{"domains": ["localhost", "*.localhost"]}'
```
@@ -465,7 +490,8 @@ Certificates have three statuses:
```bash
# Check certificate status
-curl http://localhost:3000/certificates | jq '.[] | {domain: .domains[0], status, days: .daysUntilExpiry}'
+curl http://localhost:3000/certificates \
+ -H "X-API-Key: $API_KEY" | jq '.[] | {domain: .domains[0], status, days: .daysUntilExpiry}'
```
---
@@ -549,7 +575,8 @@ The monitoring service runs **daily at 9 AM** automatically.
### Create Backup
```bash
-curl -X POST http://localhost:3000/certificates/backup
+curl -X POST http://localhost:3000/certificates/backup \
+ -H "X-API-Key: $API_KEY"
```
Creates a ZIP file containing:
@@ -562,13 +589,15 @@ Creates a ZIP file containing:
### List Backups
```bash
-curl http://localhost:3000/certificates/backup
+curl http://localhost:3000/certificates/backup \
+ -H "X-API-Key: $API_KEY"
```
### Download Backup
```bash
curl http://localhost:3000/certificates/backup/certificates-backup-2025-11-22.zip \
+ -H "X-API-Key: $API_KEY" \
--output backup.zip
```
@@ -580,6 +609,7 @@ unzip backup.zip
# Import certificates
curl -X POST http://localhost:3000/certificates/backup/import \
+ -H "X-API-Key: $API_KEY" \
-H "Content-Type: application/json" \
-d @certificates.json
```
@@ -592,9 +622,9 @@ curl -X POST http://localhost:3000/certificates/backup/import \
#!/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
+curl -X POST http://localhost:3000/certificates/backup -H "X-API-Key: $API_KEY"
+FILENAME=$(curl -s http://localhost:3000/certificates/backup -H "X-API-Key: $API_KEY" | jq -r '.[0].filename')
+curl http://localhost:3000/certificates/backup/$FILENAME -H "X-API-Key: $API_KEY" -o /backups/$FILENAME
```
**Add to crontab:**
diff --git a/package.json b/package.json
index 2f29b5d..ef88ece 100644
--- a/package.json
+++ b/package.json
@@ -12,7 +12,7 @@
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"format:check": "prettier --check \"src/**/*.ts\" \"test/**/*.ts\"",
"typecheck": "tsc --noEmit -p tsconfig.json",
- "test": "node -e \"console.log('No automated tests are configured yet. Session 26 will add the test harness.')\"",
+ "test": "TS_NODE_PROJECT=tsconfig.test.json node --test -r ts-node/register/transpile-only test/**/*.test.js",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts
index a1b6b71..fc4bde3 100644
--- a/src/auth/auth.module.ts
+++ b/src/auth/auth.module.ts
@@ -1,11 +1,19 @@
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';
@Module({
imports: [],
controllers: [AuthController],
- providers: [AuthService],
+ providers: [
+ AuthService,
+ {
+ provide: APP_GUARD,
+ useClass: ApiKeyGuard,
+ },
+ ],
exports: [AuthService],
})
export class AuthModule {}
diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts
index e00f60f..ca5c345 100644
--- a/src/auth/auth.service.ts
+++ b/src/auth/auth.service.ts
@@ -1,4 +1,5 @@
import { Injectable } from '@nestjs/common';
+import { timingSafeEqual } from 'crypto';
@Injectable()
export class AuthService {
@@ -7,7 +8,10 @@ export class AuthService {
constructor() {
// Load API keys from environment (comma-separated)
- const apiKeys = process.env.API_KEY?.split(',').filter(Boolean) || [];
+ const apiKeys =
+ process.env.API_KEY?.split(',')
+ .map((apiKey) => apiKey.trim())
+ .filter(Boolean) || [];
this.validApiKeys = new Set(apiKeys);
}
@@ -15,15 +19,24 @@ export class AuthService {
* Validate API key
*/
validateApiKey(apiKey: string): boolean {
- // If no API keys configured, allow access (development mode)
- if (
- this.validApiKeys.size === 0 &&
- process.env.NODE_ENV === 'development'
- ) {
- return true;
+ const normalizedApiKey = apiKey.trim();
+ if (!normalizedApiKey || this.validApiKeys.size === 0) {
+ return false;
}
- return this.validApiKeys.has(apiKey);
+ const provided = Buffer.from(normalizedApiKey);
+
+ for (const validApiKey of this.validApiKeys) {
+ const candidate = Buffer.from(validApiKey);
+ if (
+ provided.length === candidate.length &&
+ timingSafeEqual(provided, candidate)
+ ) {
+ return true;
+ }
+ }
+
+ return false;
}
/**
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..ca823a3 100644
--- a/src/auth/guards/api-key.guard.ts
+++ b/src/auth/guards/api-key.guard.ts
@@ -4,22 +4,29 @@ import {
Injectable,
UnauthorizedException,
} from '@nestjs/common';
+import { Reflector } from '@nestjs/core';
import { AuthService } from '../auth.service';
+import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
@Injectable()
export class ApiKeyGuard implements CanActivate {
- constructor(private authService: AuthService) {}
+ constructor(
+ private readonly authService: AuthService,
+ private readonly reflector: Reflector,
+ ) {}
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(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 apiKey = this.extractApiKey(request.headers);
if (!apiKey) {
throw new UnauthorizedException('API key is required');
@@ -31,4 +38,24 @@ export class ApiKeyGuard implements CanActivate {
return true;
}
+
+ private extractApiKey(headers: Record) {
+ 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/certificate/acme.controller.ts b/src/certificate/acme.controller.ts
index bc5c276..1540016 100644
--- a/src/certificate/acme.controller.ts
+++ b/src/certificate/acme.controller.ts
@@ -8,8 +8,10 @@ import {
} from '@nestjs/common';
import { Response } from 'express';
import { PrismaService } from '../prisma/prisma.service';
+import { Public } from '../auth/decorators/public.decorator';
@Controller('.well-known/acme-challenge')
+@Public()
export class AcmeController {
private readonly logger = new Logger(AcmeController.name);
diff --git a/src/health/health.controller.ts b/src/health/health.controller.ts
index 1e5dca7..00dc976 100644
--- a/src/health/health.controller.ts
+++ b/src/health/health.controller.ts
@@ -1,7 +1,9 @@
import { Controller, Get, HttpCode, HttpStatus } from '@nestjs/common';
import { HealthService } from './health.service';
+import { Public } from '../auth/decorators/public.decorator';
@Controller()
+@Public()
export class HealthController {
constructor(private readonly healthService: HealthService) {}
diff --git a/src/metrics/metrics.controller.ts b/src/metrics/metrics.controller.ts
index 7719756..52c6c37 100644
--- a/src/metrics/metrics.controller.ts
+++ b/src/metrics/metrics.controller.ts
@@ -1,7 +1,9 @@
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) {}
diff --git a/test/session3/auth-lockdown.test.js b/test/session3/auth-lockdown.test.js
new file mode 100644
index 0000000..ecbeec3
--- /dev/null
+++ b/test/session3/auth-lockdown.test.js
@@ -0,0 +1,336 @@
+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');
+
+class CertificateService {}
+class TlsConfigService {}
+class CertificateBackupService {}
+class HealthService {}
+class MetricsService {}
+class PrismaService {}
+class ReloaderService {}
+
+const originalLoad = Module._load;
+const moduleStubs = new Map([
+ ['./certificate.service', { CertificateService }],
+ ['./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 = 'session3-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 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 = {
+ getCertificateMetrics: async () => ({
+ total: 0,
+ valid: 0,
+ expiringSoon: 0,
+ expired: 0,
+ avgDaysUntilExpiry: 0,
+ }),
+ getProxyMetrics: async () => ({
+ total: 0,
+ withSsl: 0,
+ withoutSsl: 0,
+ proxies: 0,
+ redirects: 0,
+ }),
+ formatPrometheusMetrics: () => 'lyttle_certificates_total 0',
+};
+
+const prismaServiceMock = {
+ acmeChallenge: {
+ findUnique: async () => null,
+ delete: async () => undefined,
+ },
+};
+
+describe('Session 3 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: CertificateService,
+ useValue: certificateServiceMock,
+ },
+ {
+ 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/tsconfig.json b/tsconfig.json
index 1cfc545..5e3574d 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -9,7 +9,9 @@
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
+ "rootDir": "./src",
"baseUrl": "./",
+ "ignoreDeprecations": "6.0",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": false,
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/**/*"]
+}
+
From 025c84f3c23c4a1bca373316b5149307cc04a255 Mon Sep 17 00:00:00 2001
From: Stualyttle <27898452+Stualyttle@users.noreply.github.com.>
Date: Sun, 24 May 2026 14:48:14 +0200
Subject: [PATCH 05/43] feat: enhance health monitoring with explicit probe
endpoints and dependency checks
---
ARCHITECTURE_DECISIONS.md | 35 +++
IMPLEMENTATION_STATUS.md | 39 ++-
README.md | 34 ++-
healthcheck.sh | 22 +-
src/certificate/certificate.module.ts | 3 +-
src/certificate/certificate.service.ts | 17 ++
src/health/health.controller.ts | 51 +++-
src/health/health.module.ts | 2 +
src/health/health.service.ts | 364 ++++++++++++++++++++++---
src/reloader/reloader.module.ts | 3 +-
src/reloader/reloader.service.ts | 8 +
test/session4/health-semantics.test.js | 249 +++++++++++++++++
12 files changed, 755 insertions(+), 72 deletions(-)
create mode 100644 test/session4/health-semantics.test.js
diff --git a/ARCHITECTURE_DECISIONS.md b/ARCHITECTURE_DECISIONS.md
index 3e671db..5050c72 100644
--- a/ARCHITECTURE_DECISIONS.md
+++ b/ARCHITECTURE_DECISIONS.md
@@ -22,6 +22,7 @@ This file records repository-level architectural and delivery decisions so futur
| ADR-004 | Deployment mode expectations | accepted | Session 1 | 2026-05-24 |
| ADR-005 | Secret material stays out of git | accepted | Session 2 | 2026-05-24 |
| ADR-006 | Authenticated-by-default control-plane API | accepted | Session 3 | 2026-05-24 |
+| ADR-007 | Explicit probe endpoints with dependency-aware readiness | accepted | Session 4 | 2026-05-24 |
---
@@ -203,3 +204,37 @@ Adopt an authenticated-by-default API policy for the NestJS control plane:
- public observability and ACME flows remain available, but all other current API surfaces should be treated as admin/control-plane endpoints
- Session 7 and Session 8 can build on this deny-by-default posture when introducing richer identity and RBAC models
+---
+
+## ADR-007 — Explicit probe endpoints with dependency-aware readiness
+
+- Status: accepted
+- Session: Session 4 — Fix health, readiness, liveness, and startup semantics
+- Date: 2026-05-24
+
+### Context
+
+The previous health model exposed only `/health` and `/ready`, always returned HTTP 200 for readiness, and did not validate the database or recent control-plane convergence. That allowed orchestration layers to keep routing traffic to nodes that had lost PostgreSQL connectivity, had stale config/certificate state, or had not finished startup.
+
+### Decision
+
+Adopt explicit probe semantics for the NestJS control plane:
+
+1. expose `GET /health/live` for liveness
+2. expose `GET /health/startup` for startup completion
+3. expose `GET /health/ready` for readiness
+4. keep `GET /health` and `GET /ready` as temporary compatibility aliases for liveness and readiness respectively
+5. make readiness return HTTP 503 when any critical dependency check fails
+6. require readiness to validate at least:
+ - PostgreSQL connectivity
+ - NGINX master-process health
+ - a recent successful config apply
+ - a recent successful certificate sync
+7. update the container healthcheck script to inspect both the readiness HTTP status and the JSON body
+
+### Consequences
+
+- orchestrators can now distinguish “process alive” from “safe to receive traffic”
+- the service stays unready after restart until config apply and certificate sync have completed successfully at least once
+- later sessions should persist and broaden health signals beyond the current in-memory freshness tracking, especially for cluster-wide convergence and richer observability
+
diff --git a/IMPLEMENTATION_STATUS.md b/IMPLEMENTATION_STATUS.md
index 17255c6..e518ded 100644
--- a/IMPLEMENTATION_STATUS.md
+++ b/IMPLEMENTATION_STATUS.md
@@ -9,8 +9,8 @@ Use it as the single place to record what has shipped, what is in progress, and
- Overall status: in progress
- Current phase: Phase 1 — Emergency hardening and correctness fixes
-- Most recently completed session: Session 3 — Lock down public mutating endpoints
-- Next recommended session from the roadmap: Session 4 — Fix health, readiness, liveness, and startup semantics
+- Most recently completed session: Session 4 — Fix health, readiness, liveness, and startup semantics
+- Next recommended session from the roadmap: Session 5 — Fix container and process auto-recovery behavior
- Readiness reference: `PRODUCTION_READINESS_ASSESSMENT.md`
- Architecture decision log: `ARCHITECTURE_DECISIONS.md`
@@ -114,13 +114,36 @@ Use it as the single place to record what has shipped, what is in progress, and
- removed the previous development-mode auth bypass so write endpoints no longer become public when `API_KEY` is unset
## Session 4 — Fix health, readiness, liveness, and startup semantics
-- Status: not started
+- Status: done
- Objective: make health signaling reliable for orchestration and recovery
-- Files touched: none yet
-- Tests added/updated: none yet
-- Risks: current readiness semantics can mask unhealthy states
-- Follow-up sessions: Session 5, Session 25, Session 27
-- Notes: should include `healthcheck.sh` alignment
+- Files touched:
+ - `README.md`
+ - `IMPLEMENTATION_STATUS.md`
+ - `ARCHITECTURE_DECISIONS.md`
+ - `healthcheck.sh`
+ - `src/health/health.controller.ts`
+ - `src/health/health.module.ts`
+ - `src/health/health.service.ts`
+ - `src/reloader/reloader.module.ts`
+ - `src/reloader/reloader.service.ts`
+ - `src/certificate/certificate.module.ts`
+ - `src/certificate/certificate.service.ts`
+ - `test/session4/health-semantics.test.js`
+- Tests added/updated:
+ - added `test/session4/health-semantics.test.js` to verify explicit liveness/startup/readiness routes and their HTTP status semantics
+ - validated the focused Session 4 test suite locally with the repository test runner
+- Risks:
+ - readiness still relies on in-memory timestamps for config apply and certificate sync, so state is reset on process restart until the first successful post-boot operations complete
+ - cluster communication health and deeper dependency reporting remain future work for Session 6 and Session 25
+- Follow-up sessions:
+ - Session 5 — fix container and process auto-recovery behavior
+ - Session 6 — fix inter-node addressing and Swarm communication model
+ - Session 25 — expand metrics and alerting
+ - Session 27 — add chaos and fault-injection validation
+- Notes:
+ - added explicit `GET /health/live`, `GET /health/startup`, and `GET /health/ready` endpoints while keeping `/health` and `/ready` as compatibility aliases
+ - readiness now returns non-200 when PostgreSQL, NGINX master health, recent config apply, or recent certificate sync checks fail
+ - `healthcheck.sh` now inspects both the readiness HTTP status and the JSON body instead of trusting status code alone
## Session 5 — Fix container and process auto-recovery behavior
- Status: not started
diff --git a/README.md b/README.md
index 4ce794e..b6c8a7f 100644
--- a/README.md
+++ b/README.md
@@ -353,20 +353,33 @@ npm run prisma:migrate
## 📚 API Documentation
-### Session 3 access policy
+### Session 3/4 access policy
-As of Session 3, the control-plane API is **authenticated by default**.
+As of Sessions 3 and 4, the control-plane API is **authenticated by default**, with a small public probe allowlist.
The current explicit public allowlist is limited to:
-- `GET /health`
-- `GET /ready`
+- `GET /health/live`
+- `GET /health/startup`
+- `GET /health/ready`
- `GET /metrics`
- `GET /metrics/json`
- `GET /.well-known/acme-challenge/:token`
+Legacy compatibility aliases currently remain available for:
+
+- `GET /health` → liveness
+- `GET /ready` → readiness
+
All other endpoints should be treated as admin or internal control-plane endpoints and require `X-API-Key` (or `Authorization: ApiKey `).
+Readiness now returns **HTTP 503** when critical dependencies are unhealthy. The readiness body also reports the status of:
+
+- PostgreSQL connectivity
+- NGINX master-process health
+- last successful config apply
+- last successful certificate sync
+
### Certificate Endpoints
| Method | Endpoint | Description | Auth |
@@ -419,11 +432,14 @@ All other endpoints should be treated as admin or internal control-plane endpoin
### Health Endpoints
-| Method | Endpoint | Description | Auth |
-|--------|------------------|---------------------|--------|
-| GET | `/health` | Health check | Public |
-| GET | `/ready` | Readiness check | Public |
-| POST | `/reload` | Reload NGINX config | Required |
+| Method | Endpoint | Description | Auth |
+|--------|-------------------|--------------------------------------------------------------|----------|
+| GET | `/health/live` | Liveness probe; returns 200 while the process is alive | Public |
+| GET | `/health/startup` | Startup probe; returns 503 until first config apply + cert sync succeed | Public |
+| GET | `/health/ready` | Readiness probe; returns 503 when DB/NGINX/config/cert state is unhealthy | Public |
+| GET | `/health` | Legacy alias for `/health/live` | Public |
+| GET | `/ready` | Legacy alias for `/health/ready` | Public |
+| POST | `/reload` | Reload NGINX config | Required |
### ACME Challenge Endpoint
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/src/certificate/certificate.module.ts b/src/certificate/certificate.module.ts
index 09fc5eb..767ba66 100644
--- a/src/certificate/certificate.module.ts
+++ b/src/certificate/certificate.module.ts
@@ -12,9 +12,10 @@ 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: [
CertificateController,
TlsController,
diff --git a/src/certificate/certificate.service.ts b/src/certificate/certificate.service.ts
index dd1f386..bba4a83 100644
--- a/src/certificate/certificate.service.ts
+++ b/src/certificate/certificate.service.ts
@@ -16,6 +16,7 @@ import { addDays } from 'date-fns';
import { hashDomains, joinDomains, parseDomains } from '../utils/domain-utils';
import { AlertService } from '../alert/alert.service';
import { DistributedLockService } from '../distributed-lock/distributed-lock.service';
+import { HealthService } from '../health/health.service';
const exec = promisify(execCb);
@@ -42,6 +43,7 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown {
private prisma: PrismaService,
private alertService: AlertService,
private distributedLock: DistributedLockService,
+ private healthService: HealthService,
@Inject(
forwardRef(
() =>
@@ -197,6 +199,10 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown {
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 +246,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
diff --git a/src/health/health.controller.ts b/src/health/health.controller.ts
index 00dc976..a8c94a0 100644
--- a/src/health/health.controller.ts
+++ b/src/health/health.controller.ts
@@ -1,23 +1,54 @@
-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();
+ response.status(
+ report.status === 'ok' ? HttpStatus.OK : HttpStatus.SERVICE_UNAVAILABLE,
+ );
+
+ return report;
+ }
+
+ @Get('health/ready')
+ async readiness(@Res({ passthrough: true }) response: Response) {
+ const report = await this.probeService.ready();
+ response.status(
+ report.status === 'ok' ? HttpStatus.OK : HttpStatus.SERVICE_UNAVAILABLE,
+ );
+
+ 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();
+ response.status(
+ report.status === 'ok' ? HttpStatus.OK : HttpStatus.SERVICE_UNAVAILABLE,
+ );
+
+ return report;
}
}
\ 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..92de9b5 100644
--- a/src/health/health.service.ts
+++ b/src/health/health.service.ts
@@ -1,55 +1,351 @@
-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[] = [];
- let critical = true;
+ const checks = [
+ await this.checkDatabaseConnectivity(),
+ await this.checkNginxMasterProcess(),
+ this.checkOperationFreshness(
+ 'config_apply',
+ this.configApplyState,
+ this.configApplyMaxAgeMs,
+ ),
+ this.checkOperationFreshness(
+ 'certificate_sync',
+ this.certificateSyncState,
+ this.certificateSyncMaxAgeMs,
+ ),
+ ];
+
+ const healthy = checks.every((check) => check.status === 'ok');
+
+ return {
+ status: healthy ? 'ok' : 'error',
+ probe: 'readiness',
+ checks,
+ thresholds: {
+ configApplyMaxAgeMs: this.configApplyMaxAgeMs,
+ certificateSyncMaxAgeMs: this.certificateSyncMaxAgeMs,
+ },
+ 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,
+ );
+ }
+
+ 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 checkDatabaseConnectivity(): Promise {
+ 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;
+ };
+
+ 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(),
+ };
+ }
+ }
+
+ private async checkNginxMasterProcess(): Promise {
+ 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(),
+ };
+ }
}
- // 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);
+ return {
+ name: 'nginx_master',
+ status: 'error',
+ details: 'nginx pid file not found',
+ checkedAt: new Date().toISOString(),
+ };
+ }
+
+ private async readPidFromFile(pidFile: string): Promise {
+ 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',
- checks,
- timestamp: new Date().toISOString(),
+ 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 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/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..3b95d2c 100644
--- a/src/reloader/reloader.service.ts
+++ b/src/reloader/reloader.service.ts
@@ -22,6 +22,7 @@ import * as fs from 'fs';
import { CertificateService } from '../certificate/certificate.service';
import { TlsConfigService } from '../certificate/tls-config.service';
import { lookup } from 'dns/promises'; // <-- Added
+import { HealthService } from '../health/health.service';
const NGINX_ETC_DIR = '/etc/nginx';
const NGINX_SOURCE_DIR = join(process.cwd(), 'nginx');
@@ -49,6 +50,7 @@ export class ReloaderService implements OnModuleInit, OnModuleDestroy {
private nginx: NginxService,
private certificate: CertificateService,
private tlsConfig: TlsConfigService,
+ private healthService: HealthService,
) {}
async onModuleInit() {
@@ -173,6 +175,9 @@ export class ReloaderService implements OnModuleInit, OnModuleDestroy {
this.logger.log(
'Nginx config and directories replaced/reloaded successfully.',
);
+ this.healthService.recordConfigApplySuccess(
+ `reloaded ${entries.length} proxy entr${entries.length === 1 ? 'y' : 'ies'}`,
+ );
return { ok: true };
} catch (error: any) {
this.logger.error(
@@ -180,6 +185,9 @@ 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) };
}
}
diff --git a/test/session4/health-semantics.test.js b/test/session4/health-semantics.test.js
new file mode 100644
index 0000000..05bc91b
--- /dev/null
+++ b/test/session4/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('Session 4 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('Session 4 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'));
+ });
+});
+
From 98567481eafff96b4697a4b5ce39bf4223c067b3 Mon Sep 17 00:00:00 2001
From: Stualyttle <27898452+Stualyttle@users.noreply.github.com.>
Date: Sun, 24 May 2026 15:05:30 +0200
Subject: [PATCH 06/43] feat: implement fail-fast supervision for container
processes and update deployment policies
---
ARCHITECTURE_DECISIONS.md | 38 +++
IMPLEMENTATION_STATUS.md | 34 ++-
README.md | 20 +-
docker-compose.swarm.yml | 9 +-
docker-compose.yml | 6 +-
docker-entrypoint.sh | 290 +++++++++++---------
test/session5/entrypoint-recovery.test.js | 309 ++++++++++++++++++++++
7 files changed, 560 insertions(+), 146 deletions(-)
create mode 100644 test/session5/entrypoint-recovery.test.js
diff --git a/ARCHITECTURE_DECISIONS.md b/ARCHITECTURE_DECISIONS.md
index 5050c72..126fb5a 100644
--- a/ARCHITECTURE_DECISIONS.md
+++ b/ARCHITECTURE_DECISIONS.md
@@ -23,6 +23,7 @@ This file records repository-level architectural and delivery decisions so futur
| ADR-005 | Secret material stays out of git | accepted | Session 2 | 2026-05-24 |
| ADR-006 | Authenticated-by-default control-plane API | accepted | Session 3 | 2026-05-24 |
| ADR-007 | Explicit probe endpoints with dependency-aware readiness | accepted | Session 4 | 2026-05-24 |
+| ADR-008 | Fail-fast container supervision and restart-friendly deployment policies | accepted | Session 5 | 2026-05-24 |
---
@@ -238,3 +239,40 @@ Adopt explicit probe semantics for the NestJS control plane:
- the service stays unready after restart until config apply and certificate sync have completed successfully at least once
- later sessions should persist and broaden health signals beyond the current in-memory freshness tracking, especially for cluster-wide convergence and richer observability
+---
+
+## ADR-008 — Fail-fast container supervision and restart-friendly deployment policies
+
+- Status: accepted
+- Session: Session 5 — Fix container and process auto-recovery behavior
+- Date: 2026-05-24
+
+### Context
+
+The previous container startup flow tried to recover crashes inside `docker-entrypoint.sh`, but it did so unsafely:
+
+- failure paths ended with `exit 0`, which could suppress orchestrator restarts
+- repeated failures eventually triggered `sleep infinity`, leaving the service permanently wedged
+- `nginx` daemonized away from the supervising shell, which made process-state tracking fragile
+- the Swarm manifest limited restart attempts to three failures, which was too aggressive for the intended self-healing edge runtime
+
+### Decision
+
+Adopt a fail-fast supervision model for the current single-container runtime:
+
+1. run the NestJS process and the NGINX master as directly supervised child processes
+2. start NGINX in foreground mode (`daemon off;`) so the entrypoint can track its actual lifecycle
+3. treat any unexpected child-process exit as a container failure and exit non-zero so Docker or Swarm can restart the container
+4. reserve exit code `0` for intentional shutdowns initiated by container stop signals
+5. remove restart-state bookkeeping, in-container retry suppression, and all `sleep infinity` wedged states
+6. configure deployment manifests to prefer continued recovery:
+ - Compose uses `restart: unless-stopped`
+ - Swarm uses `restart_policy.condition: any` without a hard `max_attempts` cap
+ - both manifests allow a grace window for orderly child-process shutdown
+
+### Consequences
+
+- Node or NGINX crashes now translate into observable container failures instead of hidden partial outages
+- graceful container stops still terminate both supervised processes cleanly without turning normal shutdown into a failure
+- the current architecture still relies on a shell-based two-process container, so later sessions may still choose to split the control plane from the edge runtime or adopt a dedicated supervisor
+
diff --git a/IMPLEMENTATION_STATUS.md b/IMPLEMENTATION_STATUS.md
index e518ded..33e04e3 100644
--- a/IMPLEMENTATION_STATUS.md
+++ b/IMPLEMENTATION_STATUS.md
@@ -9,8 +9,8 @@ Use it as the single place to record what has shipped, what is in progress, and
- Overall status: in progress
- Current phase: Phase 1 — Emergency hardening and correctness fixes
-- Most recently completed session: Session 4 — Fix health, readiness, liveness, and startup semantics
-- Next recommended session from the roadmap: Session 5 — Fix container and process auto-recovery behavior
+- Most recently completed session: Session 5 — Fix container and process auto-recovery behavior
+- Next recommended session from the roadmap: Session 6 — Fix inter-node address/port model
- Readiness reference: `PRODUCTION_READINESS_ASSESSMENT.md`
- Architecture decision log: `ARCHITECTURE_DECISIONS.md`
@@ -146,13 +146,31 @@ Use it as the single place to record what has shipped, what is in progress, and
- `healthcheck.sh` now inspects both the readiness HTTP status and the JSON body instead of trusting status code alone
## Session 5 — Fix container and process auto-recovery behavior
-- Status: not started
+- Status: done
- Objective: ensure process failure leads to correct container recovery
-- Files touched: none yet
-- Tests added/updated: none yet
-- Risks: current recovery behavior can wedge the service instead of restarting it
-- Follow-up sessions: Session 27
-- Notes: entrypoint supervision and restart policies remain open
+- Files touched:
+ - `docker-entrypoint.sh`
+ - `docker-compose.yml`
+ - `docker-compose.swarm.yml`
+ - `README.md`
+ - `IMPLEMENTATION_STATUS.md`
+ - `ARCHITECTURE_DECISIONS.md`
+ - `test/session5/entrypoint-recovery.test.js`
+- Tests added/updated:
+ - added `test/session5/entrypoint-recovery.test.js` to verify fail-fast supervision for Node and NGINX crashes, graceful signal handling, and restart-policy regressions in the deployment manifests
+ - validated the focused Session 5 recovery suite locally with the repository test runner
+- Risks:
+ - Node and NGINX still share a single container, so this remains shell-based supervision rather than a split control-plane/dataplane architecture or a dedicated supervisor
+ - restart behavior now depends on Docker or Swarm policy after container exit; deeper fault-injection proof remains planned for Session 27
+- Follow-up sessions:
+ - Session 6 — fix inter-node addressing and Swarm communication model
+ - Session 13 — implement staged NGINX config generation and atomic activation
+ - Session 27 — add chaos and fault-injection validation
+- Notes:
+ - removed restart-state tracking, `sleep infinity`, and all failure paths that masked crashes with `exit 0`
+ - NGINX now runs as a supervised foreground child, and the entrypoint exits non-zero when either supervised process dies unexpectedly
+ - the single-node Compose manifest now uses a valid port-mapping configuration instead of the previous conflicting host-network example
+ - the Swarm manifest now favors continued recovery by restarting on any exit without a hard `max_attempts` cap and by allowing a graceful stop window
## Session 6 — Fix inter-node addressing and Swarm communication model
- Status: not started
diff --git a/README.md b/README.md
index b6c8a7f..b183338 100644
--- a/README.md
+++ b/README.md
@@ -6,6 +6,8 @@
+
+
@@ -20,8 +22,8 @@ Built with [NestJS](https://nestjs.com/) • Powered by [PostgreSQL](https://www
## 📍 Current Delivery Status
- **Roadmap status:** Phase 1 in progress
-- **Completed in Sessions 1-3 plus follow-up maintenance:** delivery scaffolding, status tracking, dependency hygiene for known direct CVEs, secret-handling cleanup for publishable env examples, a full npm/toolchain refresh to the latest available package set on 2026-05-24, and Session 3 endpoint lockdown with authenticated-by-default admin APIs plus a targeted regression test pass
-- **Next recommended implementation session:** Session 4 — fix health, readiness, liveness, and startup semantics
+- **Completed in Sessions 1-5 plus follow-up maintenance:** delivery scaffolding, status tracking, dependency hygiene for known direct CVEs, secret-handling cleanup for publishable env examples, a full npm/toolchain refresh to the latest available package set on 2026-05-24, authenticated-by-default admin APIs, dependency-aware health semantics, and fail-fast container supervision with restart-friendly Docker manifests
+- **Next recommended implementation session:** Session 6 — fix inter-node addressing and Swarm communication model
- **Canonical planning and status docs:**
- [`PRODUCTION_READINESS_ASSESSMENT.md`](PRODUCTION_READINESS_ASSESSMENT.md)
- [`IMPLEMENTATION_PLAN_BY_SESSION.md`](IMPLEMENTATION_PLAN_BY_SESSION.md)
@@ -54,7 +56,7 @@ node -v # expected: v24.16.0
npm -v # expected: 11.15.0
```
-`npm run test` now runs the focused Session 3 regression tests for API access control. Session 26 still remains the planned milestone for broad unit/integration/e2e harness expansion.
+`npm run test` now runs the focused Session 3-5 regression tests for API access control, health semantics, and container-supervision behavior. Session 26 still remains the planned milestone for broad unit/integration/e2e harness expansion.
---
@@ -670,9 +672,11 @@ docker stack deploy -c docker-compose.swarm.yml lyttlenginx
**Current caveats:**
- global mode is the intended target architecture
-- the current assessment still identifies P0/P1 blockers in auth, health semantics, recovery behavior, cluster comms, and config rollout
+- the current assessment still identifies P0/P1 blockers in auth depth, cluster comms, certificate lifecycle hardening, and transactional config rollout even after the completed health and auto-recovery sessions
- use the swarm manifest for evaluation and development feedback, not as a final production deployment contract yet
+The current container model is intentionally **fail-fast**: if either the NestJS control-plane process or the foreground NGINX master exits unexpectedly, the container exits non-zero so Docker or Swarm can restart it instead of leaving the node wedged.
+
**View cluster status:**
```bash
@@ -721,8 +725,12 @@ services:
- letsencrypt-data:/etc/letsencrypt
- certbot-webroot:/var/www/certbot
- nginx-ssl:/etc/nginx/ssl
- network_mode: host
+ ports:
+ - 80:80
+ - 443:443
+ - 3003:3000
restart: unless-stopped
+ stop_grace_period: 45s
volumes:
postgres-data:
@@ -789,7 +797,7 @@ npm run lint:fix # Run ESLint and apply safe fixes
npm run typecheck # Run TypeScript type-checking
npm run build # Build application
npm run verify # Lint + typecheck + build
-npm run test # Placeholder until Session 26 adds the test harness
+npm run test # Focused regression suites for shipped sessions (Session 26 will expand the harness)
# Development
npm run start:dev # Start with hot reload
diff --git a/docker-compose.swarm.yml b/docker-compose.swarm.yml
index 8bba0c2..a5a5d57 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"
@@ -84,6 +83,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..0740a7a 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -22,14 +22,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..a7fb572 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,16 @@ 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}"
+
log_info() {
echo -e "${BLUE}[ENTRYPOINT] INFO: $1${NC}"
}
@@ -24,99 +34,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"
+
+ 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
- # 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
+ 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 +159,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,32 +188,32 @@ 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
-
- 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 "Prisma migrations completed"
log_success "Prerequisites verified"
@@ -210,13 +229,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 +260,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 +278,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,15 +322,13 @@ 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
diff --git a/test/session5/entrypoint-recovery.test.js b/test/session5/entrypoint-recovery.test.js
new file mode 100644
index 0000000..9369332
--- /dev/null
+++ b/test/session5/entrypoint-recovery.test.js
@@ -0,0 +1,309 @@
+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-session5-'));
+ const binDir = path.join(tempDir, 'bin');
+ const logPath = path.join(tempDir, 'process.log');
+ await fs.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
+
+ 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: 'session5-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 fs.rm(tempDir, { recursive: true, force: true });
+ },
+ };
+}
+
+describe('Session 5 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: '1',
+ });
+
+ 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: '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('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.match(log, /nginx:quit/);
+ 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:/);
+ });
+});
+
From 0c20d058d7ae20aab44d6c0acc504a583d2d57a0 Mon Sep 17 00:00:00 2001
From: Stualyttle <27898452+Stualyttle@users.noreply.github.com.>
Date: Sun, 24 May 2026 15:14:22 +0200
Subject: [PATCH 07/43] feat: implement explicit control-plane endpoint
configuration for inter-node communication
---
.env.example | 20 +
ARCHITECTURE_DECISIONS.md | 35 ++
IMPLEMENTATION_STATUS.md | 42 +-
README.md | 18 +-
docker-compose.swarm.yml | 4 +
docker-compose.yml | 4 +
prisma/schema.prisma | 2 +-
src/certificate/certificate.service.ts | 55 ++-
.../cluster-heartbeat.service.ts | 42 +-
src/distributed-lock/cluster.controller.ts | 93 ++--
src/utils/network-utils.ts | 460 ++++++++++++++----
test/session6/inter-node-addressing.test.js | 153 ++++++
12 files changed, 762 insertions(+), 166 deletions(-)
create mode 100644 test/session6/inter-node-addressing.test.js
diff --git a/.env.example b/.env.example
index c320bda..8586de7 100644
--- a/.env.example
+++ b/.env.example
@@ -31,6 +31,26 @@ API_KEY=""
NODE_ENV="production"
PORT="3000"
+# ===============================================
+# 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"
+
# ===============================================
# CERTIFICATE MANAGEMENT SETTINGS
# ===============================================
diff --git a/ARCHITECTURE_DECISIONS.md b/ARCHITECTURE_DECISIONS.md
index 126fb5a..5213735 100644
--- a/ARCHITECTURE_DECISIONS.md
+++ b/ARCHITECTURE_DECISIONS.md
@@ -24,6 +24,7 @@ This file records repository-level architectural and delivery decisions so futur
| ADR-006 | Authenticated-by-default control-plane API | accepted | Session 3 | 2026-05-24 |
| ADR-007 | Explicit probe endpoints with dependency-aware readiness | accepted | Session 4 | 2026-05-24 |
| ADR-008 | Fail-fast container supervision and restart-friendly deployment policies | accepted | Session 5 | 2026-05-24 |
+| ADR-009 | Explicit advertised control-plane endpoints for cluster communication | accepted | Session 6 | 2026-05-24 |
---
@@ -276,3 +277,37 @@ Adopt a fail-fast supervision model for the current single-container runtime:
- graceful container stops still terminate both supervised processes cleanly without turning normal shutdown into a failure
- the current architecture still relies on a shell-based two-process container, so later sessions may still choose to split the control plane from the edge runtime or adopt a dedicated supervisor
+---
+
+## ADR-009 — Explicit advertised control-plane endpoints for cluster communication
+
+- Status: accepted
+- Session: Session 6 — Fix inter-node addressing and Swarm communication model
+- Date: 2026-05-24
+
+### Context
+
+The production-readiness assessment identified two related cluster-communication flaws:
+
+- nodes tried to discover their identity through third-party public-IP services
+- inter-node requests assumed `process.env.PORT` matched the peer-facing reachable port in Swarm
+
+That made peer communication fragile, leaked topology information to public services, and broke the distinction between the container's internal listen port and the port other nodes must actually call.
+
+### Decision
+
+Adopt an explicit advertised control-plane endpoint model:
+
+1. each node advertises a routable control-plane address using `CLUSTER_CONTROL_ADDRESS` or `CLUSTER_CONTROL_URL`
+2. each node advertises a peer-facing control-plane port using `CLUSTER_CONTROL_PORT`
+3. `PORT` remains the local NestJS listen port and must not be reused implicitly for peer URL construction
+4. cluster registration stores the advertised control-plane endpoint in node metadata
+5. inter-node reload and certificate-sync broadcasts build their URLs from the registered endpoint, not from public-IP discovery or hard-coded port assumptions
+6. loopback or otherwise non-routable advertised endpoints are rejected for peer use in production-mode registration
+
+### Consequences
+
+- Swarm and other clustered deployments must now provide explicit control-plane addressing configuration
+- operators can inspect the registered peer endpoint for each node through cluster metadata instead of inferring behavior from logs or container internals
+- inter-node communication remains HTTP + API-key authenticated for now; Session 7, Session 8, and later security work still need to introduce stronger node identity and transport security
+
diff --git a/IMPLEMENTATION_STATUS.md b/IMPLEMENTATION_STATUS.md
index 33e04e3..adc1386 100644
--- a/IMPLEMENTATION_STATUS.md
+++ b/IMPLEMENTATION_STATUS.md
@@ -9,8 +9,8 @@ Use it as the single place to record what has shipped, what is in progress, and
- Overall status: in progress
- Current phase: Phase 1 — Emergency hardening and correctness fixes
-- Most recently completed session: Session 5 — Fix container and process auto-recovery behavior
-- Next recommended session from the roadmap: Session 6 — Fix inter-node address/port model
+- Most recently completed session: Session 6 — Fix inter-node address/port model
+- Next recommended session from the roadmap: Session 7 — Introduce a real auth foundation
- Readiness reference: `PRODUCTION_READINESS_ASSESSMENT.md`
- Architecture decision log: `ARCHITECTURE_DECISIONS.md`
@@ -173,13 +173,39 @@ Use it as the single place to record what has shipped, what is in progress, and
- the Swarm manifest now favors continued recovery by restarting on any exit without a hard `max_attempts` cap and by allowing a graceful stop window
## Session 6 — Fix inter-node addressing and Swarm communication model
-- Status: not started
+- Status: done
- Objective: use explicit internal control-plane addressing instead of fragile discovery assumptions
-- Files touched: none yet
-- Tests added/updated: none yet
-- Risks: incorrect address and port selection can break node-to-node coordination
-- Follow-up sessions: Session 10, Session 11, Session 12
-- Notes: public IP discovery must be removed from cluster comms
+- Files touched:
+ - `src/utils/network-utils.ts`
+ - `src/distributed-lock/cluster-heartbeat.service.ts`
+ - `src/distributed-lock/cluster.controller.ts`
+ - `src/certificate/certificate.service.ts`
+ - `prisma/schema.prisma`
+ - `docker-compose.yml`
+ - `docker-compose.swarm.yml`
+ - `.env.example`
+ - `README.md`
+ - `ARCHITECTURE_DECISIONS.md`
+ - `IMPLEMENTATION_STATUS.md`
+ - `test/session6/inter-node-addressing.test.js`
+- Tests added/updated:
+ - added `test/session6/inter-node-addressing.test.js` to cover explicit control-plane endpoint parsing, peer URL construction, and manifest/documentation regressions
+ - verified the focused Session 6 regression suite locally with the repository test runner
+ - verified type-checking for the edited TypeScript files after migrating peer URL construction away from `process.env.PORT`
+- Risks:
+ - the current peer transport is still plain HTTP with shared API keys; mTLS and per-node identity remain future work
+ - Swarm hostname reachability is environment-specific, so operators must override `CLUSTER_CONTROL_ADDRESS` where node hostnames are not resolvable or routable on the internal network
+ - cluster-wide operations are still best-effort broadcasts without durable ACK tracking until Sessions 10-12
+- Follow-up sessions:
+ - Session 7 — introduce a real auth foundation
+ - Session 10 — add lease-based coordination primitives
+ - Session 11 — move heartbeat and leader flows onto leases
+ - Session 12 — add cluster operations and per-node ACK tracking
+- Notes:
+ - removed all public-IP discovery from `src/utils/network-utils.ts` and replaced it with explicit `CLUSTER_CONTROL_ADDRESS` / `CLUSTER_CONTROL_PORT` or `CLUSTER_CONTROL_URL` registration
+ - node heartbeats now persist the advertised control-plane endpoint in cluster metadata so operators and peer workflows can inspect what each node registered
+ - inter-node reload and certificate-sync broadcasts now build URLs from the registered endpoint instead of assuming the peer-facing port matches the local `PORT`
+ - authenticated peer requests now attach the configured API key to certificate sync broadcasts so the Session 3 lockdown does not silently break cluster propagation
---
diff --git a/README.md b/README.md
index b183338..d3a489f 100644
--- a/README.md
+++ b/README.md
@@ -286,8 +286,14 @@ docker-compose logs -f app
DATABASE_URL=postgresql://user:@host:5432/db
ADMIN_EMAIL=admin@example.com # For Let's Encrypt
NODE_ENV=production # production | development
+PORT=3000 # Internal NestJS listen port inside the container
+CLUSTER_CONTROL_ADDRESS=node-a.internal
+CLUSTER_CONTROL_PORT=3003 # Peer-facing control-plane port; do not assume it matches PORT
+CLUSTER_CONTROL_PROTOCOL=http
```
+For Session 6 and later, inter-node traffic should be configured from these explicit control-plane settings rather than from public-IP discovery or implicit `PORT` assumptions. If you prefer a single variable, you can provide `CLUSTER_CONTROL_URL=http://node-a.internal:3003` instead of the address/port pair.
+
### Secret handling policy
The repository is intentionally configured so that only `.env.example` is tracked. Real runtime secrets must stay outside git.
@@ -672,11 +678,21 @@ docker stack deploy -c docker-compose.swarm.yml lyttlenginx
**Current caveats:**
- global mode is the intended target architecture
-- the current assessment still identifies P0/P1 blockers in auth depth, cluster comms, certificate lifecycle hardening, and transactional config rollout even after the completed health and auto-recovery sessions
+- Session 6 replaces public-IP autodiscovery with explicit `CLUSTER_CONTROL_ADDRESS` / `CLUSTER_CONTROL_PORT` registration, but inter-node auth is still API-key based and mTLS remains future work
+- the current assessment still identifies remaining P0/P1 blockers in auth depth, certificate lifecycle hardening, and transactional config rollout even after the completed health, auto-recovery, and inter-node addressing sessions
- use the swarm manifest for evaluation and development feedback, not as a final production deployment contract yet
The current container model is intentionally **fail-fast**: if either the NestJS control-plane process or the foreground NGINX master exits unexpectedly, the container exits non-zero so Docker or Swarm can restart it instead of leaving the node wedged.
+The current Swarm manifest now treats peer communication as an **advertised control-plane endpoint** problem:
+
+- `PORT` remains the internal NestJS listen port inside the container (`3000` by default)
+- `CLUSTER_CONTROL_PORT` is the peer-facing port other nodes should call (`3003` in the current host-published Swarm example)
+- `CLUSTER_CONTROL_ADDRESS` is the routable hostname/address peers should use for this node (`{{.Node.Hostname}}` in the current example)
+- nodes register that advertised endpoint in the database and cluster broadcasts now use that registration instead of building URLs from discovered public IPs
+
+If your Swarm nodes are not mutually reachable by node hostname, override `CLUSTER_CONTROL_ADDRESS` with a routable internal DNS name or address per node before treating cluster operations as healthy.
+
**View cluster status:**
```bash
diff --git a/docker-compose.swarm.yml b/docker-compose.swarm.yml
index a5a5d57..ea7b43a 100644
--- a/docker-compose.swarm.yml
+++ b/docker-compose.swarm.yml
@@ -45,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}
diff --git a/docker-compose.yml b/docker-compose.yml
index 0740a7a..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}
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index ab195fd..722ae53 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -48,7 +48,7 @@ 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?
diff --git a/src/certificate/certificate.service.ts b/src/certificate/certificate.service.ts
index bba4a83..78b3f68 100644
--- a/src/certificate/certificate.service.ts
+++ b/src/certificate/certificate.service.ts
@@ -14,6 +14,7 @@ import * as fs from 'fs';
import * as path from 'path';
import { addDays } from 'date-fns';
import { hashDomains, joinDomains, parseDomains } from '../utils/domain-utils';
+import { buildClusterNodeUrl } from '../utils/network-utils';
import { AlertService } from '../alert/alert.service';
import { DistributedLockService } from '../distributed-lock/distributed-lock.service';
import { HealthService } from '../health/health.service';
@@ -1716,9 +1717,7 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown {
const nodes = await this.clusterHeartbeat.getActiveNodes();
const thisInstanceId = this.distributedLock.getInstanceId();
- const otherNodes = nodes.filter(
- (n) => n.instanceId !== thisInstanceId && n.ipAddress,
- );
+ const otherNodes = nodes.filter((n) => n.instanceId !== thisInstanceId);
if (otherNodes.length === 0) {
this.logger.log('[Reload] No other nodes to notify');
@@ -1729,33 +1728,53 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown {
`[Reload] Broadcasting certificate sync to ${otherNodes.length} other nodes...`,
);
+ const apiKey = process.env.API_KEY?.split(',')[0]?.trim();
+ if (!apiKey) {
+ this.logger.warn(
+ '[Reload] Skipping remote certificate sync because no API key is configured for authenticated peer requests',
+ );
+ this.syncCertificates().catch((err) =>
+ this.logger.error(`[Reload] Local sync failed: ${err.message}`),
+ );
+ return;
+ }
+
// 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) => {
+ const url = buildClusterNodeUrl(node, '/certificates/sync');
+
+ if (!url) {
+ this.logger.warn(
+ `[Reload] Skipping ${node.hostname} (${node.instanceId}) because it does not have a valid registered control-plane endpoint`,
+ );
+ return;
+ }
+
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 response = await (async () => {
+ try {
+ return await fetch(url, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-API-Key': apiKey,
+ },
+ signal: controller.signal,
+ });
+ } finally {
+ clearTimeout(timeoutId);
+ }
+ })();
if (response.ok) {
const result = await response.json();
this.logger.debug(
- `[Reload] Synced ${result.syncedCount || 0} cert(s) on ${node.hostname} (${node.ipAddress})`,
+ `[Reload] Synced ${result.syncedCount || 0} cert(s) on ${node.hostname} (${node.instanceId})`,
);
} else {
this.logger.warn(
diff --git a/src/distributed-lock/cluster-heartbeat.service.ts b/src/distributed-lock/cluster-heartbeat.service.ts
index ba35227..424fa06 100644
--- a/src/distributed-lock/cluster-heartbeat.service.ts
+++ b/src/distributed-lock/cluster-heartbeat.service.ts
@@ -7,7 +7,7 @@ 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';
/**
* Service to track cluster nodes and their health status
@@ -127,7 +127,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 +156,7 @@ export class ClusterHeartbeatService implements OnModuleInit, OnModuleDestroy {
nodeVersion: process.version,
cpus: os.cpus().length,
totalMemory: os.totalmem(),
+ controlPlane: this.serializeControlPlaneRegistration(controlPlane),
},
},
update: {
@@ -153,6 +165,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,7 +191,8 @@ 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();
@@ -215,6 +236,7 @@ export class ClusterHeartbeatService implements OnModuleInit, OnModuleDestroy {
freeMemory: os.freemem(),
uptime: os.uptime(),
loadAverage: os.loadavg(),
+ controlPlane: this.serializeControlPlaneRegistration(controlPlane),
},
},
});
@@ -709,4 +731,18 @@ export class ClusterHeartbeatService implements OnModuleInit, OnModuleDestroy {
await this.enforceOneLeader();
return { success: true, message: 'Leader enforcement completed' };
}
+
+ private serializeControlPlaneRegistration(
+ registration: ReturnType,
+ ) {
+ 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.controller.ts b/src/distributed-lock/cluster.controller.ts
index 4cc288d..ffbdb68 100644
--- a/src/distributed-lock/cluster.controller.ts
+++ b/src/distributed-lock/cluster.controller.ts
@@ -10,6 +10,10 @@ import { ClusterHeartbeatService } from './cluster-heartbeat.service';
import { DistributedLockService } from './distributed-lock.service';
import { ReloaderService } from '../reloader/reloader.service';
import { ApiKeyGuard } from '../auth/guards/api-key.guard';
+import {
+ buildClusterNodeUrl,
+ getClusterNodeControlPlaneEndpoint,
+} from '../utils/network-utils';
@Controller('cluster')
@UseGuards(ApiKeyGuard)
@@ -28,6 +32,12 @@ export class ClusterController {
@Post('reload')
async reload(@Query('broadcast') broadcast: string) {
const shouldBroadcast = broadcast !== 'false';
+ const broadcastSummary = {
+ requested: shouldBroadcast,
+ attemptedNodes: 0,
+ skippedNodes: [] as string[],
+ authenticated: false,
+ };
this.logger.log(`[Reload] Triggered. Broadcast: ${shouldBroadcast}`);
@@ -39,50 +49,71 @@ export class ClusterController {
const nodes = await this.clusterHeartbeat.getActiveNodes();
const thisInstanceId = this.distributedLock.getInstanceId();
- const otherNodes = nodes.filter(
- (n) => n.instanceId !== thisInstanceId && n.ipAddress,
- );
+ const otherNodes = nodes.filter((n) => n.instanceId !== thisInstanceId);
this.logger.log(
`[Reload] Broadcasting to ${otherNodes.length} other nodes...`,
);
// Get API key from environment for inter-node communication
- const apiKey = process.env.API_KEY?.split(',')[0]; // Use first API key
+ const apiKey = process.env.API_KEY?.split(',')[0]?.trim();
- // Fire and forget broadcast
- otherNodes.forEach(async (node) => {
- try {
- const controller = new AbortController();
- const timeoutId = setTimeout(() => controller.abort(), 5000);
+ if (!apiKey) {
+ this.logger.warn(
+ '[Reload] Skipping inter-node broadcast because no API key is configured for authenticated peer requests',
+ );
+ broadcastSummary.skippedNodes = otherNodes.map(
+ (node) => node.hostname ?? node.instanceId,
+ );
+ } else {
+ broadcastSummary.authenticated = true;
- // 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`;
+ await Promise.allSettled(
+ otherNodes.map(async (node) => {
+ const url = buildClusterNodeUrl(node, '/cluster/reload', {
+ broadcast: 'false',
+ });
- this.logger.debug(`[Reload] Calling ${url}`);
+ if (!url) {
+ const nodeLabel = node.hostname ?? node.instanceId;
+ broadcastSummary.skippedNodes.push(nodeLabel);
+ this.logger.warn(
+ `[Reload] Skipping ${nodeLabel} because it does not have a valid registered control-plane endpoint`,
+ );
+ return;
+ }
+
+ try {
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(), 5000);
- await fetch(url, {
- method: 'POST',
- headers: {
- 'X-API-Key': apiKey || '',
- },
- signal: controller.signal,
- });
+ broadcastSummary.attemptedNodes += 1;
+ this.logger.debug(`[Reload] Calling ${url}`);
- clearTimeout(timeoutId);
- } catch (e) {
- this.logger.error(
- `[Reload] Failed to broadcast to ${node.hostname} (${node.ipAddress}): ${e.message}`,
- );
- }
- });
+ try {
+ await fetch(url, {
+ method: 'POST',
+ headers: {
+ 'X-API-Key': apiKey,
+ },
+ signal: controller.signal,
+ });
+ } finally {
+ clearTimeout(timeoutId);
+ }
+ } catch (error) {
+ this.logger.error(
+ `[Reload] Failed to broadcast to ${node.hostname} (${node.instanceId}): ${error instanceof Error ? error.message : String(error)}`,
+ );
+ }
+ }),
+ );
+ }
}
return {
local: result,
- broadcast: shouldBroadcast,
+ broadcast: broadcastSummary,
};
}
@@ -99,6 +130,7 @@ export class ClusterController {
hostname: node.hostname,
instanceId: node.instanceId,
ipAddress: node.ipAddress,
+ controlPlane: getClusterNodeControlPlaneEndpoint(node),
isLeader: node.isLeader,
status: node.status,
lastHeartbeat: node.lastHeartbeat,
@@ -131,6 +163,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,
@@ -167,6 +200,7 @@ export class ClusterController {
hostname: dbLeader.hostname,
instanceId: dbLeader.instanceId,
ipAddress: dbLeader.ipAddress,
+ controlPlane: getClusterNodeControlPlaneEndpoint(dbLeader),
lastHeartbeat: dbLeader.lastHeartbeat,
status: dbLeader.status,
}
@@ -175,6 +209,7 @@ export class ClusterController {
hostname: l.hostname,
instanceId: l.instanceId,
ipAddress: l.ipAddress,
+ controlPlane: getClusterNodeControlPlaneEndpoint(l),
status: l.status,
lastHeartbeat: l.lastHeartbeat,
})),
diff --git a/src/utils/network-utils.ts b/src/utils/network-utils.ts
index c6f7d43..9009f92 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.
+ * Session 6 intentionally avoids any public-IP discovery and instead relies on
+ * explicit cluster configuration.
*/
-export async function getPublicIpAddress(): Promise {
- 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 {
- // 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 | 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 | 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 | 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 {
+ 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 {
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
+}
+
diff --git a/test/session6/inter-node-addressing.test.js b/test/session6/inter-node-addressing.test.js
new file mode 100644
index 0000000..b5ca5f7
--- /dev/null
+++ b/test/session6/inter-node-addressing.test.js
@@ -0,0 +1,153 @@
+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('Session 6 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('Session 6 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 clusterController = await fs.readFile(
+ path.join(repoRoot, 'src/distributed-lock/cluster.controller.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(clusterController, /buildClusterNodeUrl/);
+ assert.match(certificateService, /buildClusterNodeUrl/);
+ assert.doesNotMatch(clusterController, /process\.env\.PORT\s*\|\|\s*3000/);
+ assert.doesNotMatch(certificateService, /process\.env\.PORT\s*\|\|\s*3000/);
+ assert.doesNotMatch(clusterController, /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);
+ });
+});
+
From f1609254e19dacccc38cdf4478c9050260c3f904 Mon Sep 17 00:00:00 2001
From: Stualyttle <27898452+Stualyttle@users.noreply.github.com.>
Date: Sun, 24 May 2026 15:27:25 +0200
Subject: [PATCH 08/43] feat: implement identity-aware authentication
foundation with bearer-token support and legacy API-key compatibility
---
.env.example | 17 +
ARCHITECTURE_DECISIONS.md | 41 ++
IMPLEMENTATION_STATUS.md | 41 +-
README.md | 75 ++-
src/auth/auth.controller.ts | 64 ++-
src/auth/auth.service.ts | 465 +++++++++++++++++-
.../decorators/current-identity.decorator.ts | 9 +
src/auth/guards/api-key.guard.ts | 49 +-
.../authenticated-request.interface.ts | 7 +
src/auth/types/auth-identity.ts | 32 ++
test/session7/auth-foundation.test.js | 180 +++++++
11 files changed, 924 insertions(+), 56 deletions(-)
create mode 100644 src/auth/decorators/current-identity.decorator.ts
create mode 100644 src/auth/interfaces/authenticated-request.interface.ts
create mode 100644 src/auth/types/auth-identity.ts
create mode 100644 test/session7/auth-foundation.test.js
diff --git a/.env.example b/.env.example
index 8586de7..f66b3a2 100644
--- a/.env.example
+++ b/.env.example
@@ -23,8 +23,25 @@ ADMIN_EMAIL="admin@example.com"
# openssl rand -base64 32
# API_KEY supports multiple comma-separated keys.
# Keep the real value in a Swarm secret or external secret manager.
+# Session 7 keeps API keys as a temporary compatibility bridge, but bearer tokens
+# should become 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 until Session 8 adds RBAC.
+AUTH_DEFAULT_ADMIN_ROLES="platform-admin"
+AUTH_DEFAULT_ADMIN_SCOPES="admin:full"
+
# ===============================================
# APPLICATION SETTINGS
# ===============================================
diff --git a/ARCHITECTURE_DECISIONS.md b/ARCHITECTURE_DECISIONS.md
index 5213735..65705eb 100644
--- a/ARCHITECTURE_DECISIONS.md
+++ b/ARCHITECTURE_DECISIONS.md
@@ -25,6 +25,7 @@ This file records repository-level architectural and delivery decisions so futur
| ADR-007 | Explicit probe endpoints with dependency-aware readiness | accepted | Session 4 | 2026-05-24 |
| ADR-008 | Fail-fast container supervision and restart-friendly deployment policies | accepted | Session 5 | 2026-05-24 |
| ADR-009 | Explicit advertised control-plane endpoints for cluster communication | accepted | Session 6 | 2026-05-24 |
+| ADR-010 | Identity-aware auth foundation with bearer-token support and legacy API-key compatibility | accepted | Session 7 | 2026-05-24 |
---
@@ -311,3 +312,43 @@ Adopt an explicit advertised control-plane endpoint model:
- operators can inspect the registered peer endpoint for each node through cluster metadata instead of inferring behavior from logs or container internals
- inter-node communication remains HTTP + API-key authenticated for now; Session 7, Session 8, and later security work still need to introduce stronger node identity and transport security
+---
+
+## ADR-010 — Identity-aware auth foundation with bearer-token support and legacy API-key compatibility
+
+- Status: accepted
+- Session: Session 7 — Introduce a real auth foundation
+- Date: 2026-05-24
+
+### Context
+
+The production-readiness assessment identified the current shared API-key model as too weak for real production administration because it lacked request identity, richer claim semantics, and a credible bridge toward RBAC, audit logging, and stronger internal node authentication.
+
+Session 3 already moved the API surface to authenticated-by-default, but the code still treated auth as a yes/no API-key check rather than an actor model.
+
+### Decision
+
+Adopt an identity-aware authentication foundation with the following properties:
+
+1. keep the API authenticated by default using the existing global guard posture
+2. allow that guard to resolve either:
+ - legacy API keys (`X-API-Key` or `Authorization: ApiKey ...`)
+ - JWT bearer tokens (`Authorization: Bearer ...`)
+3. attach a structured identity object to each authenticated request, including:
+ - subject
+ - actor type (`admin` or `internal-node`)
+ - auth method
+ - roles
+ - scopes
+ - issuer/audience metadata
+4. support short-lived locally issued HS256 bearer tokens via `AUTH_JWT_SECRET`
+5. support verification of externally issued bearer tokens through standard issuer/audience claims and optional `AUTH_JWT_PUBLIC_KEY` for RS256 verification
+6. preserve temporary legacy API-key compatibility and allow API-key-authenticated clients to exchange for a bearer token via `POST /auth/token`
+7. introduce identity inspection endpoints (`GET /auth/me`, enriched `GET /auth/status`, and richer `GET /auth/info`) so operators and future tests can observe the resolved actor context
+
+### Consequences
+
+- the codebase now has a real request identity object that later sessions can use for RBAC decisions, audit logging, and internal-node policy separation
+- operators can begin migrating clients from raw API keys to short-lived bearer tokens without breaking existing integrations immediately
+- bearer-token support is intentionally a foundation, not a full security model: Session 8 still needs RBAC, Session 9 still needs audit logging, and later sessions still need stronger internal transport security such as mTLS
+
diff --git a/IMPLEMENTATION_STATUS.md b/IMPLEMENTATION_STATUS.md
index adc1386..1541b1a 100644
--- a/IMPLEMENTATION_STATUS.md
+++ b/IMPLEMENTATION_STATUS.md
@@ -8,9 +8,9 @@ Use it as the single place to record what has shipped, what is in progress, and
## Current summary
- Overall status: in progress
-- Current phase: Phase 1 — Emergency hardening and correctness fixes
-- Most recently completed session: Session 6 — Fix inter-node address/port model
-- Next recommended session from the roadmap: Session 7 — Introduce a real auth foundation
+- Current phase: Phase 2 — Security model foundation
+- Most recently completed session: Session 7 — Introduce a real auth foundation
+- Next recommended session from the roadmap: Session 8 — Add RBAC and authorization policies
- Readiness reference: `PRODUCTION_READINESS_ASSESSMENT.md`
- Architecture decision log: `ARCHITECTURE_DECISIONS.md`
@@ -212,13 +212,36 @@ Use it as the single place to record what has shipped, what is in progress, and
## Phase 2 — Security model foundation
## Session 7 — Introduce a real auth foundation
-- Status: not started
+- Status: done
- Objective: move from shared API keys toward a real identity model
-- Files touched: none yet
-- Tests added/updated: none yet
-- Risks: current auth is insufficient for production admin workflows
-- Follow-up sessions: Session 8, Session 9, Session 23
-- Notes: request identity should become part of request context
+- Files touched:
+ - `src/auth/auth.controller.ts`
+ - `src/auth/auth.service.ts`
+ - `src/auth/guards/api-key.guard.ts`
+ - `src/auth/decorators/current-identity.decorator.ts`
+ - `src/auth/interfaces/authenticated-request.interface.ts`
+ - `src/auth/types/auth-identity.ts`
+ - `.env.example`
+ - `README.md`
+ - `ARCHITECTURE_DECISIONS.md`
+ - `IMPLEMENTATION_STATUS.md`
+ - `test/session7/auth-foundation.test.js`
+- Tests added/updated:
+ - added `test/session7/auth-foundation.test.js` to verify structured request identity, bearer-token auth, legacy API-key token exchange, and expired-token rejection
+ - validated the focused Session 7 auth suite locally with the repository test runner
+ - validated TypeScript type-checking for the edited auth files after introducing bearer-token verification and request identity types
+- Risks:
+ - bearer-token support is now in place, but endpoint-level authorization policy remains coarse until Session 8 introduces RBAC
+ - inter-node traffic can now carry an `internal-node` identity model, but the transport is still plain HTTP and not yet mTLS-protected
+ - local token issuance currently uses HS256 shared-secret signing; external IdP/JWKS integration remains future work if operators need full OIDC federation
+- Follow-up sessions:
+ - Session 8 — add RBAC and authorization policies
+ - Session 9 — add audit logging for privileged and mutating operations
+ - Session 23 — add security administration APIs
+- Notes:
+ - the global auth guard now resolves either legacy API keys or JWT bearer tokens and attaches a structured identity object to `request.auth`
+ - the identity model distinguishes `admin` and `internal-node` actors and carries roles/scopes so later RBAC and audit work can build on stable request context
+ - `POST /auth/token` now provides a migration bridge from API-key clients to short-lived bearer tokens when `AUTH_JWT_SECRET` is configured
## Session 8 — Add RBAC and authorization policies
- Status: not started
diff --git a/README.md b/README.md
index d3a489f..622ac6c 100644
--- a/README.md
+++ b/README.md
@@ -8,6 +8,8 @@
+
+
@@ -21,9 +23,9 @@ Built with [NestJS](https://nestjs.com/) • Powered by [PostgreSQL](https://www
## 📍 Current Delivery Status
-- **Roadmap status:** Phase 1 in progress
-- **Completed in Sessions 1-5 plus follow-up maintenance:** delivery scaffolding, status tracking, dependency hygiene for known direct CVEs, secret-handling cleanup for publishable env examples, a full npm/toolchain refresh to the latest available package set on 2026-05-24, authenticated-by-default admin APIs, dependency-aware health semantics, and fail-fast container supervision with restart-friendly Docker manifests
-- **Next recommended implementation session:** Session 6 — fix inter-node addressing and Swarm communication model
+- **Roadmap status:** Phase 2 in progress
+- **Completed in Sessions 1-7 plus follow-up maintenance:** delivery scaffolding, dependency hygiene, authenticated-by-default admin APIs, dependency-aware health semantics, fail-fast container supervision, explicit inter-node control-plane addressing, and an identity-aware auth foundation with JWT bearer-token support plus temporary legacy API-key compatibility
+- **Next recommended implementation session:** Session 8 — add RBAC and authorization policies
- **Canonical planning and status docs:**
- [`PRODUCTION_READINESS_ASSESSMENT.md`](PRODUCTION_READINESS_ASSESSMENT.md)
- [`IMPLEMENTATION_PLAN_BY_SESSION.md`](IMPLEMENTATION_PLAN_BY_SESSION.md)
@@ -56,7 +58,7 @@ node -v # expected: v24.16.0
npm -v # expected: 11.15.0
```
-`npm run test` now runs the focused Session 3-5 regression tests for API access control, health semantics, and container-supervision behavior. Session 26 still remains the planned milestone for broad unit/integration/e2e harness expansion.
+`npm run test` now runs the focused Session 3-7 regression tests for API access control, health semantics, container-supervision behavior, inter-node addressing, and the new identity-aware auth foundation. Session 26 still remains the planned milestone for broad unit/integration/e2e harness expansion.
---
@@ -361,9 +363,9 @@ npm run prisma:migrate
## 📚 API Documentation
-### Session 3/4 access policy
+### Session 3-7 access policy
-As of Sessions 3 and 4, the control-plane API is **authenticated by default**, with a small public probe allowlist.
+As of Sessions 3-7, the control-plane API is **authenticated by default**, with a small public probe allowlist and an identity-aware authentication layer.
The current explicit public allowlist is limited to:
@@ -379,7 +381,27 @@ Legacy compatibility aliases currently remain available for:
- `GET /health` → liveness
- `GET /ready` → readiness
-All other endpoints should be treated as admin or internal control-plane endpoints and require `X-API-Key` (or `Authorization: ApiKey `).
+All other endpoints should be treated as admin or internal control-plane endpoints and require one of:
+
+- `Authorization: Bearer `
+- `X-API-Key: `
+- `Authorization: ApiKey `
+
+Session 7 introduces a JWT bearer-token foundation that attaches actor identity to each authenticated request. Legacy API keys remain supported temporarily as a migration bridge and can be exchanged for short-lived bearer tokens via `POST /auth/token` when `AUTH_JWT_SECRET` is configured.
+
+Current identity model:
+
+- `admin` actors for operator/API clients
+- `internal-node` actors for trusted inter-node calls
+
+The resolved identity now carries:
+
+- subject
+- actor type
+- auth method
+- roles
+- scopes
+- issuer/audience metadata
Readiness now returns **HTTP 503** when critical dependencies are unhealthy. The readiness body also reports the status of:
@@ -433,10 +455,36 @@ Readiness now returns **HTTP 503** when critical dependencies are unhealthy. The
### Authentication Endpoints
-| Method | Endpoint | Description | Auth |
-|--------|----------------|-----------------------------|----------|
-| GET | `/auth/status` | Check authentication status | Required |
-| GET | `/auth/info` | Get auth configuration info | Required |
+| Method | Endpoint | Description | Auth |
+|--------|----------------|-------------------------------------------------------------------|----------|
+| GET | `/auth/status` | Check authentication status and return the resolved actor identity | Required |
+| GET | `/auth/info` | Get auth capability/configuration info | Required |
+| GET | `/auth/me` | Inspect the current request identity | Required |
+| POST | `/auth/token` | Exchange a legacy API-key-authenticated request for a bearer token | Required |
+
+### Auth configuration
+
+Session 7 adds a JWT/OIDC-compatible claim foundation. The most relevant auth env vars are:
+
+- `API_KEY` — temporary legacy compatibility credentials
+- `AUTH_JWT_SECRET` — enables locally signed HS256 bearer tokens and `/auth/token`
+- `AUTH_JWT_PUBLIC_KEY` — optional RS256 verification key for externally issued bearer tokens
+- `AUTH_JWT_ISSUER` — expected token issuer and local token issuer
+- `AUTH_JWT_AUDIENCE` — expected token audience and local token audience
+- `AUTH_DEFAULT_ADMIN_ROLES` / `AUTH_DEFAULT_ADMIN_SCOPES` — bridge claims applied to legacy API keys until Session 8 lands
+
+Example migration flow:
+
+```bash
+# Exchange a legacy API key for a short-lived bearer token
+JWT=$(curl -s http://localhost:3000/auth/token \
+ -X POST \
+ -H "X-API-Key: $API_KEY" | jq -r '.accessToken')
+
+# Use the bearer token for subsequent admin requests
+curl http://localhost:3000/auth/me \
+ -H "Authorization: Bearer $JWT"
+```
### Health Endpoints
@@ -678,8 +726,9 @@ docker stack deploy -c docker-compose.swarm.yml lyttlenginx
**Current caveats:**
- global mode is the intended target architecture
-- Session 6 replaces public-IP autodiscovery with explicit `CLUSTER_CONTROL_ADDRESS` / `CLUSTER_CONTROL_PORT` registration, but inter-node auth is still API-key based and mTLS remains future work
-- the current assessment still identifies remaining P0/P1 blockers in auth depth, certificate lifecycle hardening, and transactional config rollout even after the completed health, auto-recovery, and inter-node addressing sessions
+- Session 6 replaces public-IP autodiscovery with explicit `CLUSTER_CONTROL_ADDRESS` / `CLUSTER_CONTROL_PORT` registration
+- Session 7 adds request identity plus bearer-token support, but internal-node traffic is still plain HTTP and mTLS remains future work
+- the current assessment still identifies remaining P0/P1 blockers in RBAC depth, certificate lifecycle hardening, internal-traffic security, and transactional config rollout even after the completed health, auto-recovery, inter-node addressing, and auth-foundation sessions
- use the swarm manifest for evaluation and development feedback, not as a final production deployment contract yet
The current container model is intentionally **fail-fast**: if either the NestJS control-plane process or the foreground NGINX master exits unexpectedly, the container exits non-zero so Docker or Swarm can restart it instead of leaving the node wedged.
diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts
index 55c5ea6..0b7ba25 100644
--- a/src/auth/auth.controller.ts
+++ b/src/auth/auth.controller.ts
@@ -1,26 +1,76 @@
-import { Controller, Get, UseGuards } from '@nestjs/common';
+import {
+ BadRequestException,
+ Controller,
+ Get,
+ HttpCode,
+ HttpStatus,
+ Post,
+ UseGuards,
+} from '@nestjs/common';
import { AuthService } from './auth.service';
import { ApiKeyGuard } from './guards/api-key.guard';
+import { CurrentIdentity } from './decorators/current-identity.decorator';
+import { AuthIdentity } from './types/auth-identity';
@Controller('auth')
export class AuthController {
- constructor(private authService: AuthService) {}
+ private readonly authService: AuthService;
+
+ constructor(authService: AuthService) {
+ this.authService = authService;
+ }
@Get('status')
@UseGuards(ApiKeyGuard)
- async getStatus() {
+ 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')
+ async getCurrentIdentity(@CurrentIdentity() identity: AuthIdentity) {
+ return {
+ authenticated: true,
+ identity: this.presentIdentity(identity),
+ };
+ }
+
+ @Post('token')
+ @HttpCode(HttpStatus.OK)
+ 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.service.ts b/src/auth/auth.service.ts
index ca5c345..0c3c44c 100644
--- a/src/auth/auth.service.ts
+++ b/src/auth/auth.service.ts
@@ -1,48 +1,475 @@
-import { Injectable } from '@nestjs/common';
-import { timingSafeEqual } from 'crypto';
+import {
+ Injectable,
+ ServiceUnavailableException,
+ UnauthorizedException,
+} from '@nestjs/common';
+import {
+ createHmac,
+ createPublicKey,
+ KeyObject,
+ randomUUID,
+ timingSafeEqual,
+ verify,
+} from 'crypto';
+import { AuthCapabilities, AuthIdentity, ActorType } from './types/auth-identity';
@Injectable()
export class AuthService {
- // In production, these should come from a database
- private readonly validApiKeys: Set;
+ private readonly validApiKeys: Map;
+ 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(',')
+ process.env['API_KEY']?.split(',')
.map((apiKey) => apiKey.trim())
.filter(Boolean) || [];
- this.validApiKeys = new Set(apiKeys);
+ 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;
+ }),
+ );
}
- /**
- * Validate API key
- */
- validateApiKey(apiKey: string): boolean {
+ authenticateApiKey(apiKey: string): AuthIdentity | null {
const normalizedApiKey = apiKey.trim();
if (!normalizedApiKey || this.validApiKeys.size === 0) {
- return false;
+ return null;
}
const provided = Buffer.from(normalizedApiKey);
- for (const validApiKey of this.validApiKeys) {
+ for (const [validApiKey, identity] of this.validApiKeys.entries()) {
const candidate = Buffer.from(validApiKey);
if (
provided.length === candidate.length &&
timingSafeEqual(provided, candidate)
) {
- return true;
+ return { ...identity };
}
}
- return false;
+ return null;
+ }
+
+ validateApiKey(apiKey: string): boolean {
+ return this.authenticateApiKey(apiKey) !== null;
}
- /**
- * Check if authentication is enabled
- */
isAuthEnabled(): boolean {
- return this.validApiKeys.size > 0;
+ 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,
+ bearerTokenVerificationConfigured: this.canVerifyBearerTokens(),
+ tokenExchangeEnabled: this.canIssueAccessTokens(),
+ issuer: this.jwtIssuer,
+ audience: this.jwtAudience,
+ supportedBearerAlgorithms: this.getSupportedBearerAlgorithms(),
+ };
+ }
+
+ 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>(
+ headerSegment,
+ 'Invalid bearer token header',
+ );
+ const payload = this.parseJsonSegment>(
+ 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) {
+ 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.jwtAudience && audience.length > 0 && !audience.includes(this.jwtAudience)) {
+ throw new UnauthorizedException('Bearer token audience is invalid');
+ }
+ }
+
+ private resolveActorType(payload: Record): ActorType {
+ const actorTypeClaim = this.readStringClaim(payload['actor_type']);
+ if (actorTypeClaim === 'admin' || actorTypeClaim === 'internal-node') {
+ return actorTypeClaim;
+ }
+
+ const scopes = this.extractScopes(payload);
+ if (
+ this.readStringClaim(payload['node_id']) ||
+ scopes.includes('cluster:internal') ||
+ scopes.includes('node:sync')
+ ) {
+ return 'internal-node';
+ }
+
+ return 'admin';
+ }
+
+ private extractRoles(payload: Record): string[] {
+ const roles = new Set();
+
+ 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[] {
+ const scopes = new Set();
+
+ 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 | undefined {
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
+ return undefined;
+ }
+ return value as Record;
+ }
+
+ 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(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 {
+ 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/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();
+ return request.auth;
+ },
+);
diff --git a/src/auth/guards/api-key.guard.ts b/src/auth/guards/api-key.guard.ts
index ca823a3..cd2d06e 100644
--- a/src/auth/guards/api-key.guard.ts
+++ b/src/auth/guards/api-key.guard.ts
@@ -7,13 +7,17 @@ import {
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';
@Injectable()
export class ApiKeyGuard implements CanActivate {
- constructor(
- private readonly authService: AuthService,
- private readonly reflector: Reflector,
- ) {}
+ private readonly authService: AuthService;
+ private readonly reflector: Reflector;
+
+ constructor(authService: AuthService, reflector: Reflector) {
+ this.authService = authService;
+ this.reflector = reflector;
+ }
canActivate(context: ExecutionContext): boolean {
const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [
@@ -25,20 +29,49 @@ export class ApiKeyGuard implements CanActivate {
return true;
}
- const request = context.switchToHttp().getRequest();
- const apiKey = this.extractApiKey(request.headers);
+ const request = context.switchToHttp().getRequest();
+ const bearerToken = this.extractBearerToken(request.headers);
+
+ if (bearerToken) {
+ const identity = this.authService.authenticateBearerToken(bearerToken);
+ request.auth = identity;
+ request.user = identity;
+ return true;
+ }
+ const apiKey = this.extractApiKey(request.headers);
if (!apiKey) {
- throw new UnauthorizedException('API key is required');
+ throw new UnauthorizedException('Authentication credentials are required');
}
- if (!this.authService.validateApiKey(apiKey)) {
+ const identity = this.authService.authenticateApiKey(apiKey);
+ if (!identity) {
throw new UnauthorizedException('Invalid API key');
}
+ request.auth = identity;
+ request.user = identity;
+
return true;
}
+ private extractBearerToken(
+ headers: Record,
+ ) {
+ 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) {
const headerApiKey = headers['x-api-key'];
if (typeof headerApiKey === 'string' && headerApiKey.trim()) {
diff --git a/src/auth/interfaces/authenticated-request.interface.ts b/src/auth/interfaces/authenticated-request.interface.ts
new file mode 100644
index 0000000..9b0db4a
--- /dev/null
+++ b/src/auth/interfaces/authenticated-request.interface.ts
@@ -0,0 +1,7 @@
+import { AuthIdentity } from '../types/auth-identity';
+
+export interface AuthenticatedRequest {
+ headers: Record;
+ auth?: AuthIdentity;
+ user?: AuthIdentity;
+}
diff --git a/src/auth/types/auth-identity.ts b/src/auth/types/auth-identity.ts
new file mode 100644
index 0000000..e9bad0d
--- /dev/null
+++ b/src/auth/types/auth-identity.ts
@@ -0,0 +1,32 @@
+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;
+}
+
+export interface AuthCapabilities {
+ authEnabled: boolean;
+ methods: AuthMethod[];
+ apiKeyConfigured: boolean;
+ bearerTokenVerificationConfigured: boolean;
+ tokenExchangeEnabled: boolean;
+ issuer?: string;
+ audience?: string;
+ supportedBearerAlgorithms: string[];
+}
diff --git a/test/session7/auth-foundation.test.js b/test/session7/auth-foundation.test.js
new file mode 100644
index 0000000..a5c1194
--- /dev/null
+++ b/test/session7/auth-foundation.test.js
@@ -0,0 +1,180 @@
+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');
+
+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('Session 7 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 = 'session7-legacy-key';
+ process.env.AUTH_JWT_SECRET = 'session7-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,
+ },
+ ],
+ }).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', 'session7-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', 'session7-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', 'session7-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',
+ },
+ 'session7-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',
+ },
+ 'session7-super-secret',
+ );
+
+ await request(httpServer)
+ .get('/auth/me')
+ .set('Authorization', `Bearer ${expiredToken}`)
+ .expect(401)
+ .expect(({ body }) => {
+ assert.match(body.message, /expired/i);
+ });
+ });
+});
From e3093d4357dc398bebabf098b2fee2643232ec28 Mon Sep 17 00:00:00 2001
From: Stualyttle <27898452+Stualyttle@users.noreply.github.com.>
Date: Sun, 24 May 2026 15:39:32 +0200
Subject: [PATCH 09/43] feat: implement explicit RBAC policies with a global
authorization guard
---
.env.example | 3 +-
ARCHITECTURE_DECISIONS.md | 47 ++
IMPLEMENTATION_STATUS.md | 52 ++-
README.md | 116 +++--
src/app.controller.ts | 12 +-
src/auth/auth.controller.ts | 5 +
src/auth/auth.module.ts | 5 +
src/auth/auth.service.ts | 5 +
src/auth/decorators/authorize.decorator.ts | 27 ++
src/auth/guards/authorization.guard.ts | 69 +++
src/auth/types/auth-identity.ts | 7 +
src/auth/types/auth-role.ts | 52 +++
src/auth/types/authorization-policy.ts | 8 +
src/certificate/backup.controller.ts | 2 +
src/certificate/certificate.controller.ts | 11 +
src/certificate/certificate.module.ts | 2 +-
src/certificate/tls.controller.ts | 5 +
src/distributed-lock/cluster.controller.ts | 7 +
src/logs/logs.controller.ts | 2 +
test/session3/auth-lockdown.test.js | 7 +
test/session7/auth-foundation.test.js | 7 +
test/session8/rbac-authorization.test.js | 508 +++++++++++++++++++++
22 files changed, 904 insertions(+), 55 deletions(-)
create mode 100644 src/auth/decorators/authorize.decorator.ts
create mode 100644 src/auth/guards/authorization.guard.ts
create mode 100644 src/auth/types/auth-role.ts
create mode 100644 src/auth/types/authorization-policy.ts
create mode 100644 test/session8/rbac-authorization.test.js
diff --git a/.env.example b/.env.example
index f66b3a2..8fd59dd 100644
--- a/.env.example
+++ b/.env.example
@@ -38,7 +38,8 @@ 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 until Session 8 adds RBAC.
+# 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"
diff --git a/ARCHITECTURE_DECISIONS.md b/ARCHITECTURE_DECISIONS.md
index 65705eb..37801f9 100644
--- a/ARCHITECTURE_DECISIONS.md
+++ b/ARCHITECTURE_DECISIONS.md
@@ -26,6 +26,7 @@ This file records repository-level architectural and delivery decisions so futur
| ADR-008 | Fail-fast container supervision and restart-friendly deployment policies | accepted | Session 5 | 2026-05-24 |
| ADR-009 | Explicit advertised control-plane endpoints for cluster communication | accepted | Session 6 | 2026-05-24 |
| ADR-010 | Identity-aware auth foundation with bearer-token support and legacy API-key compatibility | accepted | Session 7 | 2026-05-24 |
+| ADR-011 | Explicit RBAC policies with a global authorization guard | accepted | Session 8 | 2026-05-24 |
---
@@ -352,3 +353,49 @@ Adopt an identity-aware authentication foundation with the following properties:
- operators can begin migrating clients from raw API keys to short-lived bearer tokens without breaking existing integrations immediately
- bearer-token support is intentionally a foundation, not a full security model: Session 8 still needs RBAC, Session 9 still needs audit logging, and later sessions still need stronger internal transport security such as mTLS
+---
+
+## ADR-011 — Explicit RBAC policies with a global authorization guard
+
+- Status: accepted
+- Session: Session 8 — Add RBAC and authorization policies
+- Date: 2026-05-24
+
+### Context
+
+Session 7 introduced stable request identity, but the API still treated most authenticated actors equivalently. The production-readiness assessment explicitly called for RBAC roles such as `viewer`, `operator`, `security-admin`, `platform-admin`, and `internal-node`, plus an explicit permission matrix for all endpoints.
+
+Without a second authorization layer, any authenticated caller with a valid credential could still reach sensitive backup, cluster-maintenance, certificate, and TLS-hardening routes.
+
+### Decision
+
+Adopt an explicit RBAC model enforced by a global authorization guard:
+
+1. keep the existing authenticated-by-default posture from Session 3 and identity resolution from Session 7
+2. add a second global guard that evaluates authorization metadata on every non-public route
+3. require protected routes to declare an explicit authorization policy; missing policy metadata is treated as a configuration error and denied
+4. define the initial RBAC catalog as:
+ - `viewer`
+ - `operator`
+ - `security-admin`
+ - `platform-admin`
+ - `internal-node`
+5. adopt the following initial hierarchy:
+ - `platform-admin` implies `security-admin`, `operator`, and `viewer`
+ - `security-admin` implies `viewer`
+ - `operator` implies `viewer`
+ - `internal-node` remains separate from admin roles
+6. map the current API surface explicitly, including:
+ - read-only inspection endpoints → `viewer`
+ - runtime operational actions such as reload and renew → `operator`
+ - certificate/key, backup, export/import, and TLS hardening actions → `security-admin`
+ - cluster leader-management and break-glass maintenance endpoints → `platform-admin`
+ - internal certificate sync → `internal-node` or `platform-admin` for operator fallback during migration
+7. preserve temporary legacy API-key compatibility by continuing to map configured API keys to default admin roles from env configuration
+
+### Consequences
+
+- the current endpoint permission matrix is now explicit in code, enforceable at runtime, and regression-tested
+- later sessions can add audit logging, service accounts, and finer-grained operational APIs without first reworking the basic authorization model again
+- legacy API keys remain a compatibility bridge rather than a destination state; future security work should continue pushing operators toward short-lived bearer identities and narrower role assignment
+
diff --git a/IMPLEMENTATION_STATUS.md b/IMPLEMENTATION_STATUS.md
index 1541b1a..f51333a 100644
--- a/IMPLEMENTATION_STATUS.md
+++ b/IMPLEMENTATION_STATUS.md
@@ -9,8 +9,8 @@ Use it as the single place to record what has shipped, what is in progress, and
- Overall status: in progress
- Current phase: Phase 2 — Security model foundation
-- Most recently completed session: Session 7 — Introduce a real auth foundation
-- Next recommended session from the roadmap: Session 8 — Add RBAC and authorization policies
+- Most recently completed session: Session 8 — Add RBAC and authorization policies
+- Next recommended session from the roadmap: Session 9 — Add audit logging for privileged and mutating operations
- Readiness reference: `PRODUCTION_READINESS_ASSESSMENT.md`
- Architecture decision log: `ARCHITECTURE_DECISIONS.md`
@@ -244,13 +244,49 @@ Use it as the single place to record what has shipped, what is in progress, and
- `POST /auth/token` now provides a migration bridge from API-key clients to short-lived bearer tokens when `AUTH_JWT_SECRET` is configured
## Session 8 — Add RBAC and authorization policies
-- Status: not started
+- Status: done
- Objective: make endpoint permissions explicit and enforceable
-- Files touched: none yet
-- Tests added/updated: none yet
-- Risks: sensitive actions are not yet constrained by role
-- Follow-up sessions: Session 9, Session 21, Session 22, Session 23
-- Notes: permission matrix should become testable and documented
+- Files touched:
+ - `src/auth/auth.module.ts`
+ - `src/auth/auth.service.ts`
+ - `src/auth/auth.controller.ts`
+ - `src/auth/decorators/authorize.decorator.ts`
+ - `src/auth/guards/authorization.guard.ts`
+ - `src/auth/types/auth-identity.ts`
+ - `src/auth/types/auth-role.ts`
+ - `src/auth/types/authorization-policy.ts`
+ - `src/app.controller.ts`
+ - `src/logs/logs.controller.ts`
+ - `src/certificate/certificate.controller.ts`
+ - `src/certificate/backup.controller.ts`
+ - `src/certificate/tls.controller.ts`
+ - `src/certificate/certificate.module.ts`
+ - `src/distributed-lock/cluster.controller.ts`
+ - `.env.example`
+ - `README.md`
+ - `ARCHITECTURE_DECISIONS.md`
+ - `IMPLEMENTATION_STATUS.md`
+ - `test/session3/auth-lockdown.test.js`
+ - `test/session7/auth-foundation.test.js`
+ - `test/session8/rbac-authorization.test.js`
+- Tests added/updated:
+ - added `test/session8/rbac-authorization.test.js` to verify viewer/operator/security-admin/platform-admin/internal-node authorization behavior and role hierarchy
+ - updated the Session 3 and Session 7 auth regression harnesses to include the new global authorization guard
+ - verified `npm test` passes with the full Session 3-8 regression suite after the RBAC changes landed
+- Risks:
+ - RBAC is now explicit for the current route surface, but it still relies on bearer-token/API-key claims rather than centralized user/service-account persistence
+ - legacy API keys still map to broad admin roles for migration compatibility; Session 23 should narrow rotation and break-glass workflows further
+ - internal-node traffic now has a distinct authorization path, but transport security remains HTTP until later mTLS work
+- Follow-up sessions:
+ - Session 9 — add audit logging for privileged and mutating operations
+ - Session 21 — add proxy management API
+ - Session 22 — add cluster operations and node-status admin APIs
+ - Session 23 — add security administration APIs
+- Notes:
+ - added a second global guard so protected endpoints must now have an explicit authorization policy in addition to authentication
+ - defined the initial role catalog and hierarchy: `viewer`, `operator`, `security-admin`, `platform-admin`, and `internal-node`
+ - mapped the current API surface to explicit role requirements and documented that matrix in `README.md`
+ - fixed controller registration order so `/certificates/backup` resolves to the intended backup controller instead of being shadowed by `/certificates/:id`
## Session 9 — Add audit logging for privileged and mutating operations
- Status: not started
diff --git a/README.md b/README.md
index 622ac6c..eba7e2e 100644
--- a/README.md
+++ b/README.md
@@ -10,6 +10,7 @@
+
@@ -24,8 +25,8 @@ Built with [NestJS](https://nestjs.com/) • Powered by [PostgreSQL](https://www
## 📍 Current Delivery Status
- **Roadmap status:** Phase 2 in progress
-- **Completed in Sessions 1-7 plus follow-up maintenance:** delivery scaffolding, dependency hygiene, authenticated-by-default admin APIs, dependency-aware health semantics, fail-fast container supervision, explicit inter-node control-plane addressing, and an identity-aware auth foundation with JWT bearer-token support plus temporary legacy API-key compatibility
-- **Next recommended implementation session:** Session 8 — add RBAC and authorization policies
+- **Completed in Sessions 1-8 plus follow-up maintenance:** delivery scaffolding, dependency hygiene, authenticated-by-default admin APIs, dependency-aware health semantics, fail-fast container supervision, explicit inter-node control-plane addressing, an identity-aware auth foundation, and explicit RBAC authorization policies across the current API surface
+- **Next recommended implementation session:** Session 9 — add audit logging for privileged and mutating operations
- **Canonical planning and status docs:**
- [`PRODUCTION_READINESS_ASSESSMENT.md`](PRODUCTION_READINESS_ASSESSMENT.md)
- [`IMPLEMENTATION_PLAN_BY_SESSION.md`](IMPLEMENTATION_PLAN_BY_SESSION.md)
@@ -58,7 +59,7 @@ node -v # expected: v24.16.0
npm -v # expected: 11.15.0
```
-`npm run test` now runs the focused Session 3-7 regression tests for API access control, health semantics, container-supervision behavior, inter-node addressing, and the new identity-aware auth foundation. Session 26 still remains the planned milestone for broad unit/integration/e2e harness expansion.
+`npm run test` now runs the focused Session 3-8 regression tests for API access control, health semantics, container-supervision behavior, inter-node addressing, the identity-aware auth foundation, and RBAC authorization policy enforcement. Session 26 still remains the planned milestone for broad unit/integration/e2e harness expansion.
---
@@ -363,9 +364,9 @@ npm run prisma:migrate
## 📚 API Documentation
-### Session 3-7 access policy
+### Session 3-8 access policy
-As of Sessions 3-7, the control-plane API is **authenticated by default**, with a small public probe allowlist and an identity-aware authentication layer.
+As of Sessions 3-8, the control-plane API is **authenticated by default**, with a small public probe allowlist, an identity-aware authentication layer, and explicit RBAC authorization policies on protected endpoints.
The current explicit public allowlist is limited to:
@@ -387,13 +388,28 @@ All other endpoints should be treated as admin or internal control-plane endpoin
- `X-API-Key: `
- `Authorization: ApiKey `
-Session 7 introduces a JWT bearer-token foundation that attaches actor identity to each authenticated request. Legacy API keys remain supported temporarily as a migration bridge and can be exchanged for short-lived bearer tokens via `POST /auth/token` when `AUTH_JWT_SECRET` is configured.
+Session 7 introduced a JWT bearer-token foundation that attaches actor identity to each authenticated request. Session 8 builds on that with explicit RBAC policies. Legacy API keys remain supported temporarily as a migration bridge and can be exchanged for short-lived bearer tokens via `POST /auth/token` when `AUTH_JWT_SECRET` is configured.
Current identity model:
- `admin` actors for operator/API clients
- `internal-node` actors for trusted inter-node calls
+Current RBAC roles:
+
+- `viewer` — read-only admin visibility into certificates, cluster state, TLS recommendations, and auth configuration
+- `operator` — operational actions such as config reloads, certificate renewals, and log access
+- `security-admin` — certificate/key lifecycle, backup, export/import, and TLS hardening actions
+- `platform-admin` — full administrative access, including cluster-leadership and break-glass maintenance flows
+- `internal-node` — reserved for trusted inter-node identities; currently used for internal certificate sync and future cluster control-plane policies
+
+Role hierarchy:
+
+- `platform-admin` ⟶ `security-admin`, `operator`, `viewer`
+- `security-admin` ⟶ `viewer`
+- `operator` ⟶ `viewer`
+- `internal-node` is separate and does not inherit admin roles
+
The resolved identity now carries:
- subject
@@ -410,30 +426,42 @@ Readiness now returns **HTTP 503** when critical dependencies are unhealthy. The
- last successful config apply
- last successful certificate sync
+### Current authorization matrix
+
+| Role / actor | Intended scope |
+| --- | --- |
+| Public | health probes, metrics, ACME challenge serving |
+| `viewer` | read-only admin inspection endpoints |
+| `operator` | runtime operations such as reload, renew, and log inspection |
+| `security-admin` | certificate/key management, backup/export/import, TLS hardening |
+| `platform-admin` | full admin access including cluster maintenance endpoints |
+| `internal-node` | internal certificate sync and future node-only control-plane actions |
+
### Certificate Endpoints
-| Method | Endpoint | Description | Auth |
-|--------|--------------------------------------|-----------------------------------|----------|
-| GET | `/certificates` | List all certificates with status | Required |
-| GET | `/certificates/:id` | Get certificate details | Required |
-| POST | `/certificates/upload` | Upload custom certificate | Required |
-| POST | `/certificates/generate-self-signed` | Generate self-signed cert | Required |
-| POST | `/certificates/renew/:id` | Renew specific certificate | Required |
-| POST | `/certificates/renew-all` | Renew all certificates | Required |
-| DELETE | `/certificates/:id` | Delete certificate | Required |
-| GET | `/certificates/validate/:domain` | Validate domain | Required |
-| POST | `/certificates/sync` | Trigger certificate sync | Required |
+| Method | Endpoint | Description | Required role / actor |
+|--------|--------------------------------------|-----------------------------------|-----------------------|
+| GET | `/certificates` | List all certificates with status | `viewer` |
+| GET | `/certificates/:id` | Get certificate details | `viewer` |
+| POST | `/certificates/upload` | Upload custom certificate | `security-admin` |
+| POST | `/certificates/generate-self-signed` | Generate self-signed cert | `security-admin` |
+| POST | `/certificates/renew/:id` | Renew specific certificate | `operator` |
+| POST | `/certificates/renew-all` | Renew all certificates | `operator` |
+| DELETE | `/certificates/:id` | Delete certificate | `security-admin` |
+| GET | `/certificates/validate/:domain` | Validate domain | `viewer` |
+| GET | `/certificates/health/ocsp-check` | Inspect OCSP support status | `viewer` |
+| POST | `/certificates/sync` | Trigger certificate sync | `internal-node` or `platform-admin` |
### Backup Endpoints
-| Method | Endpoint | Description | Auth |
-|--------|-----------------------------------|---------------------|----------|
-| POST | `/certificates/backup` | Create backup | Required |
-| GET | `/certificates/backup` | List backups | Required |
-| GET | `/certificates/backup/:filename` | Download backup | Required |
-| DELETE | `/certificates/backup/:filename` | Delete backup | Required |
-| POST | `/certificates/backup/import` | Import certificates | Required |
-| GET | `/certificates/backup/export/:id` | Export certificate | Required |
+| Method | Endpoint | Description | Required role |
+|--------|-----------------------------------|---------------------|---------------|
+| POST | `/certificates/backup` | Create backup | `security-admin` |
+| GET | `/certificates/backup` | List backups | `security-admin` |
+| GET | `/certificates/backup/:filename` | Download backup | `security-admin` |
+| DELETE | `/certificates/backup/:filename` | Delete backup | `security-admin` |
+| POST | `/certificates/backup/import` | Import certificates | `security-admin` |
+| GET | `/certificates/backup/export/:id` | Export certificate | `security-admin` |
### Metrics Endpoints
@@ -444,34 +472,34 @@ Readiness now returns **HTTP 503** when critical dependencies are unhealthy. The
### TLS Configuration Endpoints
-| Method | Endpoint | Description | Auth |
-|--------|-----------------------------------|---------------------|----------|
-| GET | `/tls/config/:domain` | Get TLS config | Required |
-| GET | `/tls/test/:domain` | Test TLS connection | Required |
-| POST | `/tls/dhparam` | Generate DH params | Required |
-| GET | `/tls/dhparam/status` | Check DH params | Required |
-| POST | `/tls/certificate/info` | Parse certificate | Required |
-| POST | `/tls/certificate/validate-chain` | Validate chain | Required |
+| Method | Endpoint | Description | Required role |
+|--------|-----------------------------------|---------------------|---------------|
+| GET | `/tls/config/:domain` | Get TLS config | `viewer` |
+| GET | `/tls/test/:domain` | Test TLS connection | `viewer` |
+| POST | `/tls/dhparam` | Generate DH params | `security-admin` |
+| GET | `/tls/dhparam/status` | Check DH params | `viewer` |
+| POST | `/tls/certificate/info` | Parse certificate | `security-admin` |
+| POST | `/tls/certificate/validate-chain` | Validate chain | `security-admin` |
### Authentication Endpoints
-| Method | Endpoint | Description | Auth |
-|--------|----------------|-------------------------------------------------------------------|----------|
-| GET | `/auth/status` | Check authentication status and return the resolved actor identity | Required |
-| GET | `/auth/info` | Get auth capability/configuration info | Required |
-| GET | `/auth/me` | Inspect the current request identity | Required |
-| POST | `/auth/token` | Exchange a legacy API-key-authenticated request for a bearer token | Required |
+| Method | Endpoint | Description | Required role / actor |
+|--------|----------------|-------------------------------------------------------------------|-----------------------|
+| GET | `/auth/status` | Check authentication status and return the resolved actor identity | any authenticated `admin` or `internal-node` |
+| GET | `/auth/info` | Get auth capability/configuration info | `viewer` |
+| GET | `/auth/me` | Inspect the current request identity | any authenticated `admin` or `internal-node` |
+| POST | `/auth/token` | Exchange a legacy API-key-authenticated request for a bearer token | any authenticated `admin` |
### Auth configuration
-Session 7 adds a JWT/OIDC-compatible claim foundation. The most relevant auth env vars are:
+Sessions 7-8 add a JWT/OIDC-compatible claim foundation plus RBAC. The most relevant auth env vars are:
- `API_KEY` — temporary legacy compatibility credentials
- `AUTH_JWT_SECRET` — enables locally signed HS256 bearer tokens and `/auth/token`
- `AUTH_JWT_PUBLIC_KEY` — optional RS256 verification key for externally issued bearer tokens
- `AUTH_JWT_ISSUER` — expected token issuer and local token issuer
- `AUTH_JWT_AUDIENCE` — expected token audience and local token audience
-- `AUTH_DEFAULT_ADMIN_ROLES` / `AUTH_DEFAULT_ADMIN_SCOPES` — bridge claims applied to legacy API keys until Session 8 lands
+- `AUTH_DEFAULT_ADMIN_ROLES` / `AUTH_DEFAULT_ADMIN_SCOPES` — bridge claims applied to legacy API keys during the migration from shared keys to bearer-token identities
Example migration flow:
@@ -495,7 +523,7 @@ curl http://localhost:3000/auth/me \
| GET | `/health/ready` | Readiness probe; returns 503 when DB/NGINX/config/cert state is unhealthy | Public |
| GET | `/health` | Legacy alias for `/health/live` | Public |
| GET | `/ready` | Legacy alias for `/health/ready` | Public |
-| POST | `/reload` | Reload NGINX config | Required |
+| POST | `/reload` | Reload NGINX config | `operator` |
### ACME Challenge Endpoint
@@ -727,8 +755,8 @@ docker stack deploy -c docker-compose.swarm.yml lyttlenginx
- global mode is the intended target architecture
- Session 6 replaces public-IP autodiscovery with explicit `CLUSTER_CONTROL_ADDRESS` / `CLUSTER_CONTROL_PORT` registration
-- Session 7 adds request identity plus bearer-token support, but internal-node traffic is still plain HTTP and mTLS remains future work
-- the current assessment still identifies remaining P0/P1 blockers in RBAC depth, certificate lifecycle hardening, internal-traffic security, and transactional config rollout even after the completed health, auto-recovery, inter-node addressing, and auth-foundation sessions
+- Sessions 7-8 add request identity plus explicit RBAC, but internal-node traffic is still plain HTTP and mTLS remains future work
+- the current assessment still identifies remaining P0/P1 blockers in audit logging, certificate lifecycle hardening, internal-traffic security, and transactional config rollout even after the completed health, auto-recovery, inter-node addressing, auth-foundation, and RBAC sessions
- use the swarm manifest for evaluation and development feedback, not as a final production deployment contract yet
The current container model is intentionally **fail-fast**: if either the NestJS control-plane process or the foreground NGINX master exits unexpectedly, the container exits non-zero so Docker or Swarm can restart it instead of leaving the node wedged.
diff --git a/src/app.controller.ts b/src/app.controller.ts
index 03a9eaf..d0d6331 100644
--- a/src/app.controller.ts
+++ b/src/app.controller.ts
@@ -1,6 +1,14 @@
-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';
@Controller()
@@ -8,7 +16,9 @@ export class AppController {
constructor(private reloader: ReloaderService) {}
@Post('reload')
+ @HttpCode(HttpStatus.OK)
@UseGuards(ApiKeyGuard)
+ @AuthorizeAdmin('operator')
async reload(@Res() res: Response) {
const result = await this.reloader.reloadConfig();
if (result.ok) {
diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts
index 0b7ba25..51b8233 100644
--- a/src/auth/auth.controller.ts
+++ b/src/auth/auth.controller.ts
@@ -8,11 +8,13 @@ import {
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';
@Controller('auth')
+@AuthorizeAdmin('viewer')
export class AuthController {
private readonly authService: AuthService;
@@ -22,6 +24,7 @@ export class AuthController {
@Get('status')
@UseGuards(ApiKeyGuard)
+ @Authorize({ actorTypes: ['admin'] }, { actorTypes: ['internal-node'] })
async getStatus(@CurrentIdentity() identity: AuthIdentity) {
return {
authenticated: true,
@@ -36,6 +39,7 @@ export class AuthController {
}
@Get('me')
+ @Authorize({ actorTypes: ['admin'] }, { actorTypes: ['internal-node'] })
async getCurrentIdentity(@CurrentIdentity() identity: AuthIdentity) {
return {
authenticated: true,
@@ -45,6 +49,7 @@ export class AuthController {
@Post('token')
@HttpCode(HttpStatus.OK)
+ @Authorize({ actorTypes: ['admin'] })
async exchangeLegacyApiKey(@CurrentIdentity() identity: AuthIdentity) {
if (identity.authMethod !== 'api-key') {
throw new BadRequestException(
diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts
index fc4bde3..cff5a15 100644
--- a/src/auth/auth.module.ts
+++ b/src/auth/auth.module.ts
@@ -3,6 +3,7 @@ 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';
@Module({
imports: [],
@@ -13,6 +14,10 @@ import { ApiKeyGuard } from './guards/api-key.guard';
provide: APP_GUARD,
useClass: ApiKeyGuard,
},
+ {
+ provide: APP_GUARD,
+ useClass: AuthorizationGuard,
+ },
],
exports: [AuthService],
})
diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts
index 0c3c44c..92f3ea1 100644
--- a/src/auth/auth.service.ts
+++ b/src/auth/auth.service.ts
@@ -12,6 +12,7 @@ import {
verify,
} from 'crypto';
import { AuthCapabilities, AuthIdentity, ActorType } from './types/auth-identity';
+import { AUTH_ROLES, AUTH_ROLE_HIERARCHY } from './types/auth-role';
@Injectable()
export class AuthService {
@@ -121,6 +122,10 @@ export class AuthService {
issuer: this.jwtIssuer,
audience: this.jwtAudience,
supportedBearerAlgorithms: this.getSupportedBearerAlgorithms(),
+ supportedRoles: [...AUTH_ROLES],
+ roleHierarchy: { ...AUTH_ROLE_HIERARCHY },
+ defaultAdminRoles: [...this.defaultAdminRoles],
+ defaultAdminScopes: [...this.defaultAdminScopes],
};
}
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/guards/authorization.guard.ts b/src/auth/guards/authorization.guard.ts
new file mode 100644
index 0000000..31fd273
--- /dev/null
+++ b/src/auth/guards/authorization.guard.ts
@@ -0,0 +1,69 @@
+import {
+ CanActivate,
+ ExecutionContext,
+ ForbiddenException,
+ Injectable,
+} 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';
+
+@Injectable()
+export class AuthorizationGuard implements CanActivate {
+ constructor(private readonly reflector: Reflector) {}
+
+ canActivate(context: ExecutionContext): boolean {
+ const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [
+ context.getHandler(),
+ context.getClass(),
+ ]);
+
+ if (isPublic) {
+ return true;
+ }
+
+ const policy = this.reflector.getAllAndOverride(
+ 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();
+ const identity = request.auth;
+
+ if (!identity) {
+ throw new ForbiddenException('Authenticated identity is required');
+ }
+
+ const effectiveRoles = new Set(resolveEffectiveRoles(identity));
+ const allowed = policy.some((rule) => this.matchesRule(rule, identity, effectiveRoles));
+
+ if (!allowed) {
+ throw new ForbiddenException('Insufficient permissions for this endpoint');
+ }
+
+ return true;
+ }
+
+ private matchesRule(
+ rule: AuthorizationRule,
+ identity: NonNullable,
+ effectiveRoles: ReadonlySet,
+ ): 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/types/auth-identity.ts b/src/auth/types/auth-identity.ts
index e9bad0d..7b2b74f 100644
--- a/src/auth/types/auth-identity.ts
+++ b/src/auth/types/auth-identity.ts
@@ -1,7 +1,10 @@
+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;
@@ -29,4 +32,8 @@ export interface AuthCapabilities {
issuer?: string;
audience?: string;
supportedBearerAlgorithms: string[];
+ supportedRoles: AuthRole[];
+ roleHierarchy: Record;
+ 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 = {
+ 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): AuthRole[] {
+ const expanded = new Set();
+
+ 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/backup.controller.ts b/src/certificate/backup.controller.ts
index fbb8613..032a069 100644
--- a/src/certificate/backup.controller.ts
+++ b/src/certificate/backup.controller.ts
@@ -13,9 +13,11 @@ 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';
@Controller('certificates/backup')
+@AuthorizeAdmin('security-admin')
export class BackupController {
constructor(private readonly backupService: CertificateBackupService) {}
diff --git a/src/certificate/certificate.controller.ts b/src/certificate/certificate.controller.ts
index ddf90db..9d757cf 100644
--- a/src/certificate/certificate.controller.ts
+++ b/src/certificate/certificate.controller.ts
@@ -14,9 +14,14 @@ 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 {
+ AuthorizeAdmin,
+ AuthorizeInternalNodeOrAdmin,
+} from '../auth/decorators/authorize.decorator';
import { ApiKeyGuard } from '../auth/guards/api-key.guard';
@Controller('certificates')
+@AuthorizeAdmin('viewer')
export class CertificateController {
constructor(private readonly certificateService: CertificateService) {}
@@ -32,6 +37,7 @@ export class CertificateController {
@Post('upload')
@HttpCode(HttpStatus.CREATED)
+ @AuthorizeAdmin('security-admin')
async uploadCertificate(@Body() dto: UploadCertificateDto) {
try {
return await this.certificateService.uploadCertificate(dto);
@@ -44,6 +50,7 @@ export class CertificateController {
@Post('generate-self-signed')
@HttpCode(HttpStatus.CREATED)
+ @AuthorizeAdmin('security-admin')
async generateSelfSigned(@Body() dto: GenerateSelfSignedDto) {
try {
return await this.certificateService.generateSelfSignedCertificate(
@@ -61,6 +68,7 @@ export class CertificateController {
@Post('renew/:id')
@UseGuards(ApiKeyGuard)
@HttpCode(HttpStatus.OK)
+ @AuthorizeAdmin('operator')
async renewCertificate(@Param('id') id: string) {
return this.certificateService.renewCertificateById(id);
}
@@ -68,6 +76,7 @@ export class CertificateController {
@Post('renew-all')
@UseGuards(ApiKeyGuard)
@HttpCode(HttpStatus.OK)
+ @AuthorizeAdmin('operator')
async renewAllCertificates() {
await this.certificateService.renewAllCertificates();
return { message: 'Certificate renewal process initiated' };
@@ -76,6 +85,7 @@ export class CertificateController {
@Delete(':id')
@UseGuards(ApiKeyGuard)
@HttpCode(HttpStatus.NO_CONTENT)
+ @AuthorizeAdmin('security-admin')
async deleteCertificate(@Param('id') id: string) {
await this.certificateService.deleteCertificate(id);
}
@@ -97,6 +107,7 @@ export class CertificateController {
*/
@Post('sync')
@HttpCode(HttpStatus.OK)
+ @AuthorizeInternalNodeOrAdmin('platform-admin')
async syncCertificates() {
return this.certificateService.syncCertificates();
}
diff --git a/src/certificate/certificate.module.ts b/src/certificate/certificate.module.ts
index 767ba66..3ad65d1 100644
--- a/src/certificate/certificate.module.ts
+++ b/src/certificate/certificate.module.ts
@@ -17,9 +17,9 @@ import { HealthModule } from '../health/health.module';
@Module({
imports: [ScheduleModule.forRoot(), AlertModule, AuthModule, HealthModule],
controllers: [
+ BackupController,
CertificateController,
TlsController,
- BackupController,
AcmeController,
],
providers: [
diff --git a/src/certificate/tls.controller.ts b/src/certificate/tls.controller.ts
index dc4bb11..4be5778 100644
--- a/src/certificate/tls.controller.ts
+++ b/src/certificate/tls.controller.ts
@@ -11,9 +11,11 @@ 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';
@Controller('tls')
+@AuthorizeAdmin('viewer')
export class TlsController {
constructor(private readonly tlsConfigService: TlsConfigService) {}
@@ -29,6 +31,7 @@ export class TlsController {
@Post('dhparam')
@HttpCode(HttpStatus.ACCEPTED)
+ @AuthorizeAdmin('security-admin')
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 +53,7 @@ export class TlsController {
@Post('certificate/info')
@UseGuards(ApiKeyGuard)
@HttpCode(HttpStatus.OK)
+ @AuthorizeAdmin('security-admin')
async getCertificateInfo(@Body() dto: CertificatePemDto) {
return this.tlsConfigService.getCertificateInfo(dto.certPem);
}
@@ -57,6 +61,7 @@ export class TlsController {
@Post('certificate/validate-chain')
@UseGuards(ApiKeyGuard)
@HttpCode(HttpStatus.OK)
+ @AuthorizeAdmin('security-admin')
async validateChain(@Body() dto: ValidateCertChainDto) {
return this.tlsConfigService.validateCertificateChain(
dto.certPem,
diff --git a/src/distributed-lock/cluster.controller.ts b/src/distributed-lock/cluster.controller.ts
index ffbdb68..d73a2d7 100644
--- a/src/distributed-lock/cluster.controller.ts
+++ b/src/distributed-lock/cluster.controller.ts
@@ -9,6 +9,7 @@ import {
import { ClusterHeartbeatService } from './cluster-heartbeat.service';
import { DistributedLockService } from './distributed-lock.service';
import { ReloaderService } from '../reloader/reloader.service';
+import { AuthorizeAdmin } from '../auth/decorators/authorize.decorator';
import { ApiKeyGuard } from '../auth/guards/api-key.guard';
import {
buildClusterNodeUrl,
@@ -17,6 +18,7 @@ import {
@Controller('cluster')
@UseGuards(ApiKeyGuard)
+@AuthorizeAdmin('viewer')
export class ClusterController {
private readonly logger = new Logger(ClusterController.name);
@@ -30,6 +32,7 @@ export class ClusterController {
* Trigger a reload on this node and optionally broadcast to others
*/
@Post('reload')
+ @AuthorizeAdmin('operator')
async reload(@Query('broadcast') broadcast: string) {
const shouldBroadcast = broadcast !== 'false';
const broadcastSummary = {
@@ -230,6 +233,7 @@ export class ClusterController {
* Manually trigger cleanup of stale nodes
*/
@Get('admin/cleanup')
+ @AuthorizeAdmin('platform-admin')
async manualCleanup() {
return this.clusterHeartbeat.manualCleanup();
}
@@ -238,6 +242,7 @@ export class ClusterController {
* Manually enforce single leader (fixes split-brain)
*/
@Get('admin/enforce-leader')
+ @AuthorizeAdmin('platform-admin')
async manualEnforceLeader() {
return this.clusterHeartbeat.manualEnforceLeader();
}
@@ -246,6 +251,7 @@ export class ClusterController {
* Check and ensure leader exists (auto-elects if needed)
*/
@Get('admin/ensure-leader')
+ @AuthorizeAdmin('platform-admin')
async ensureLeader() {
try {
await this.clusterHeartbeat.ensureLeaderExists();
@@ -275,6 +281,7 @@ export class ClusterController {
* Try to make this node the leader
*/
@Get('admin/become-leader')
+ @AuthorizeAdmin('platform-admin')
async tryBecomeLeader() {
try {
const success = await this.clusterHeartbeat.tryBecomeLeader();
diff --git a/src/logs/logs.controller.ts b/src/logs/logs.controller.ts
index 83a16ff..0cd3de9 100644
--- a/src/logs/logs.controller.ts
+++ b/src/logs/logs.controller.ts
@@ -6,9 +6,11 @@ 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';
@Controller('logs')
+@AuthorizeAdmin('operator')
export class LogsController {
constructor(private readonly logsService: LogsService) {}
diff --git a/test/session3/auth-lockdown.test.js b/test/session3/auth-lockdown.test.js
index ecbeec3..8b5877e 100644
--- a/test/session3/auth-lockdown.test.js
+++ b/test/session3/auth-lockdown.test.js
@@ -11,6 +11,9 @@ 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 {}
@@ -149,6 +152,10 @@ describe('Session 3 endpoint lockdown', () => {
provide: APP_GUARD,
useClass: ApiKeyGuard,
},
+ {
+ provide: APP_GUARD,
+ useClass: AuthorizationGuard,
+ },
{
provide: CertificateService,
useValue: certificateServiceMock,
diff --git a/test/session7/auth-foundation.test.js b/test/session7/auth-foundation.test.js
index a5c1194..965f265 100644
--- a/test/session7/auth-foundation.test.js
+++ b/test/session7/auth-foundation.test.js
@@ -10,6 +10,9 @@ 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' };
@@ -53,6 +56,10 @@ describe('Session 7 auth foundation', () => {
provide: APP_GUARD,
useClass: ApiKeyGuard,
},
+ {
+ provide: APP_GUARD,
+ useClass: AuthorizationGuard,
+ },
],
}).compile();
diff --git a/test/session8/rbac-authorization.test.js b/test/session8/rbac-authorization.test.js
new file mode 100644
index 0000000..113a650
--- /dev/null
+++ b/test/session8/rbac-authorization.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 } = 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 DistributedLockService {}
+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 }],
+ ['./distributed-lock.service', { DistributedLockService }],
+ ['./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`,
+ },
+ 'session8-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',
+ },
+ 'session8-super-secret',
+ );
+}
+
+describe('Session 8 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 reloaderServiceMock = {
+ reloadConfig: async () => ({ ok: true }),
+ };
+
+ const logsServiceMock = {
+ getLastLogs: (count) => Array.from({ length: count }, (_, index) => `log-${index + 1}`),
+ };
+
+ before(async () => {
+ process.env.API_KEY = 'session8-legacy-key';
+ process.env.AUTH_JWT_SECRET = 'session8-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: DistributedLockService,
+ useValue: distributedLockServiceMock,
+ },
+ {
+ provide: ReloaderService,
+ useValue: reloaderServiceMock,
+ },
+ {
+ 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)
+ .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('/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', 'session8-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', 'session8-legacy-key')
+ .expect(200)
+ .expect(({ body }) => assert.equal(body.success, true));
+ });
+});
+
From 939d67522d8fd5c3bd2f8f98777039d6269b2ff5 Mon Sep 17 00:00:00 2001
From: Stualyttle <27898452+Stualyttle@users.noreply.github.com.>
Date: Sun, 24 May 2026 15:57:42 +0200
Subject: [PATCH 10/43] feat: implement durable audit logging for privileged
and mutating operations
---
.env.example | 2 +-
ARCHITECTURE_DECISIONS.md | 62 ++-
IMPLEMENTATION_STATUS.md | 93 +++-
README.md | 161 ++++---
eslint.config.js | 34 ++
prisma.config.ts | 14 +
.../migration.sql | 34 ++
prisma/schema.prisma | 33 +-
src/app.controller.ts | 2 +
src/app.module.ts | 2 +
src/audit/audit-context.ts | 79 ++++
src/audit/audit-record.utils.ts | 171 ++++++++
src/audit/audit.controller.ts | 45 ++
src/audit/audit.interceptor.ts | 121 ++++++
src/audit/audit.module.ts | 20 +
src/audit/audit.service.ts | 169 ++++++++
src/audit/decorators/audit.decorator.ts | 24 ++
src/audit/types/audit.types.ts | 45 ++
src/auth/auth.controller.ts | 2 +
src/auth/auth.module.ts | 3 +-
src/auth/guards/api-key.guard.ts | 88 +++-
src/auth/guards/authorization.guard.ts | 75 +++-
.../authenticated-request.interface.ts | 20 +
src/certificate/backup.controller.ts | 7 +
src/certificate/certificate-backup.service.ts | 2 +-
src/certificate/certificate.controller.ts | 43 ++
src/certificate/certificate.service.ts | 2 +-
src/certificate/tls.controller.ts | 4 +
src/distributed-lock/cluster.controller.ts | 6 +
src/logs/logs.controller.ts | 2 +
src/prisma/prisma.service.ts | 31 +-
test/session9/audit-logging.test.js | 403 ++++++++++++++++++
tsconfig.json | 7 +-
33 files changed, 1677 insertions(+), 129 deletions(-)
create mode 100644 eslint.config.js
create mode 100644 prisma.config.ts
create mode 100644 prisma/migrations/20260524153000_session9_audit_events/migration.sql
create mode 100644 src/audit/audit-context.ts
create mode 100644 src/audit/audit-record.utils.ts
create mode 100644 src/audit/audit.controller.ts
create mode 100644 src/audit/audit.interceptor.ts
create mode 100644 src/audit/audit.module.ts
create mode 100644 src/audit/audit.service.ts
create mode 100644 src/audit/decorators/audit.decorator.ts
create mode 100644 src/audit/types/audit.types.ts
create mode 100644 test/session9/audit-logging.test.js
diff --git a/.env.example b/.env.example
index 8fd59dd..d6ca22c 100644
--- a/.env.example
+++ b/.env.example
@@ -7,7 +7,7 @@
# ===============================================
# DATABASE CONFIGURATION (REQUIRED)
# ===============================================
-DATABASE_URL="postgresql://lyttlenginx:@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
# ===============================================
# ADMIN CONFIGURATION (REQUIRED FOR PRODUCTION)
diff --git a/ARCHITECTURE_DECISIONS.md b/ARCHITECTURE_DECISIONS.md
index 37801f9..528919d 100644
--- a/ARCHITECTURE_DECISIONS.md
+++ b/ARCHITECTURE_DECISIONS.md
@@ -14,19 +14,20 @@ This file records repository-level architectural and delivery decisions so futur
## Decision index
-| ID | Title | Status | Session | Date |
-| --- | --- | --- | --- | --- |
-| ADR-001 | Production-readiness source of truth | accepted | Session 1 | 2026-05-24 |
-| ADR-002 | Session-based delivery model | accepted | Session 1 | 2026-05-24 |
-| ADR-003 | Standard repository verification contract | accepted | Session 1 | 2026-05-24 |
-| ADR-004 | Deployment mode expectations | accepted | Session 1 | 2026-05-24 |
-| ADR-005 | Secret material stays out of git | accepted | Session 2 | 2026-05-24 |
-| ADR-006 | Authenticated-by-default control-plane API | accepted | Session 3 | 2026-05-24 |
-| ADR-007 | Explicit probe endpoints with dependency-aware readiness | accepted | Session 4 | 2026-05-24 |
-| ADR-008 | Fail-fast container supervision and restart-friendly deployment policies | accepted | Session 5 | 2026-05-24 |
-| ADR-009 | Explicit advertised control-plane endpoints for cluster communication | accepted | Session 6 | 2026-05-24 |
+| ID | Title | Status | Session | Date |
+| ------- | ----------------------------------------------------------------------------------------- | -------- | --------- | ---------- |
+| ADR-001 | Production-readiness source of truth | accepted | Session 1 | 2026-05-24 |
+| ADR-002 | Session-based delivery model | accepted | Session 1 | 2026-05-24 |
+| ADR-003 | Standard repository verification contract | accepted | Session 1 | 2026-05-24 |
+| ADR-004 | Deployment mode expectations | accepted | Session 1 | 2026-05-24 |
+| ADR-005 | Secret material stays out of git | accepted | Session 2 | 2026-05-24 |
+| ADR-006 | Authenticated-by-default control-plane API | accepted | Session 3 | 2026-05-24 |
+| ADR-007 | Explicit probe endpoints with dependency-aware readiness | accepted | Session 4 | 2026-05-24 |
+| ADR-008 | Fail-fast container supervision and restart-friendly deployment policies | accepted | Session 5 | 2026-05-24 |
+| ADR-009 | Explicit advertised control-plane endpoints for cluster communication | accepted | Session 6 | 2026-05-24 |
| ADR-010 | Identity-aware auth foundation with bearer-token support and legacy API-key compatibility | accepted | Session 7 | 2026-05-24 |
-| ADR-011 | Explicit RBAC policies with a global authorization guard | accepted | Session 8 | 2026-05-24 |
+| ADR-011 | Explicit RBAC policies with a global authorization guard | accepted | Session 8 | 2026-05-24 |
+| ADR-012 | Durable audit events for privileged and mutating operations | accepted | Session 9 | 2026-05-24 |
---
@@ -399,3 +400,40 @@ Adopt an explicit RBAC model enforced by a global authorization guard:
- later sessions can add audit logging, service accounts, and finer-grained operational APIs without first reworking the basic authorization model again
- legacy API keys remain a compatibility bridge rather than a destination state; future security work should continue pushing operators toward short-lived bearer identities and narrower role assignment
+---
+
+## ADR-012 — Durable audit events for privileged and mutating operations
+
+- Status: accepted
+- Session: Session 9 — Add audit logging for privileged and mutating operations
+- Date: 2026-05-24
+
+### Context
+
+Sessions 7 and 8 introduced stable request identity and explicit RBAC, but the control plane still lacked a durable record of who attempted sensitive operations, what they targeted, whether they succeeded, and how to correlate those actions across logs and client-visible failures.
+
+The production-readiness assessment explicitly called for audit logging of privileged and mutating operations, including failed privileged attempts.
+
+### Decision
+
+Adopt a durable audit-event model with request correlation for the current NestJS control plane:
+
+1. persist audit records in a new Prisma-backed `AuditEvent` table
+2. record at least:
+ - actor identity metadata when available
+ - action name
+ - target identifier or label when available
+ - outcome (`success`, `failure`, or `denied`)
+ - request method/path and HTTP status
+ - correlation ID and timestamp
+3. generate or propagate a correlation ID for audited requests and return it via `X-Correlation-Id`
+4. audit all protected mutating routes by default through a global interceptor
+5. allow explicit `@Audit(...)` metadata on routes that need stable action names or auditing despite using non-mutating HTTP verbs
+6. record denied attempts directly from the authentication and authorization guards so failed privileged requests are not lost before controller execution
+7. expose a minimal privileged audit-review endpoint at `GET /audit`
+
+### Consequences
+
+- successful writes, controller/service failures, and denied privileged attempts are now durably attributable even before the later structured-logging work lands
+- route authors can opt into clearer action naming and target extraction without reworking the global audit pipeline
+- audit storage currently shares the main application database, so later sessions should still harden retention, redaction, export controls, and long-term operational reporting
diff --git a/IMPLEMENTATION_STATUS.md b/IMPLEMENTATION_STATUS.md
index f51333a..8ee78c7 100644
--- a/IMPLEMENTATION_STATUS.md
+++ b/IMPLEMENTATION_STATUS.md
@@ -9,8 +9,8 @@ Use it as the single place to record what has shipped, what is in progress, and
- Overall status: in progress
- Current phase: Phase 2 — Security model foundation
-- Most recently completed session: Session 8 — Add RBAC and authorization policies
-- Next recommended session from the roadmap: Session 9 — Add audit logging for privileged and mutating operations
+- Most recently completed session: Session 9 — Add audit logging for privileged and mutating operations
+- Next recommended session from the roadmap: Session 10 — Add lease-based coordination primitives
- Readiness reference: `PRODUCTION_READINESS_ASSESSMENT.md`
- Architecture decision log: `ARCHITECTURE_DECISIONS.md`
@@ -19,6 +19,7 @@ Use it as the single place to record what has shipped, what is in progress, and
## Phase 0 — Delivery setup and guardrails
## Session 1 — Delivery scaffolding and progress tracking
+
- Status: done
- Objective: create a consistent delivery scaffold for future sessions
- Files touched:
@@ -42,6 +43,7 @@ Use it as the single place to record what has shipped, what is in progress, and
- documented current expectations for local, single-node, and swarm deployment modes in `README.md`
## Session 2 — Dependency hygiene and secret-handling cleanup
+
- Status: done
- Objective: remove immediate supply-chain and secret risks
- Files touched:
@@ -78,6 +80,7 @@ Use it as the single place to record what has shipped, what is in progress, and
## Phase 1 — Emergency hardening and correctness fixes
## Session 3 — Lock down public mutating endpoints
+
- Status: done
- Objective: require auth on all mutating endpoints and define the public allowlist
- Files touched:
@@ -114,6 +117,7 @@ Use it as the single place to record what has shipped, what is in progress, and
- removed the previous development-mode auth bypass so write endpoints no longer become public when `API_KEY` is unset
## Session 4 — Fix health, readiness, liveness, and startup semantics
+
- Status: done
- Objective: make health signaling reliable for orchestration and recovery
- Files touched:
@@ -146,6 +150,7 @@ Use it as the single place to record what has shipped, what is in progress, and
- `healthcheck.sh` now inspects both the readiness HTTP status and the JSON body instead of trusting status code alone
## Session 5 — Fix container and process auto-recovery behavior
+
- Status: done
- Objective: ensure process failure leads to correct container recovery
- Files touched:
@@ -173,6 +178,7 @@ Use it as the single place to record what has shipped, what is in progress, and
- the Swarm manifest now favors continued recovery by restarting on any exit without a hard `max_attempts` cap and by allowing a graceful stop window
## Session 6 — Fix inter-node addressing and Swarm communication model
+
- Status: done
- Objective: use explicit internal control-plane addressing instead of fragile discovery assumptions
- Files touched:
@@ -212,6 +218,7 @@ Use it as the single place to record what has shipped, what is in progress, and
## Phase 2 — Security model foundation
## Session 7 — Introduce a real auth foundation
+
- Status: done
- Objective: move from shared API keys toward a real identity model
- Files touched:
@@ -244,6 +251,7 @@ Use it as the single place to record what has shipped, what is in progress, and
- `POST /auth/token` now provides a migration bridge from API-key clients to short-lived bearer tokens when `AUTH_JWT_SECRET` is configured
## Session 8 — Add RBAC and authorization policies
+
- Status: done
- Objective: make endpoint permissions explicit and enforceable
- Files touched:
@@ -289,19 +297,67 @@ Use it as the single place to record what has shipped, what is in progress, and
- fixed controller registration order so `/certificates/backup` resolves to the intended backup controller instead of being shadowed by `/certificates/:id`
## Session 9 — Add audit logging for privileged and mutating operations
-- Status: not started
+
+- Status: done
- Objective: make sensitive actions attributable and reviewable
-- Files touched: none yet
-- Tests added/updated: none yet
-- Risks: privileged actions are not yet durably auditable
-- Follow-up sessions: Session 20, Session 23, Session 24
-- Notes: requires schema and service changes
+- Files touched:
+ - `prisma.config.ts`
+ - `prisma/schema.prisma`
+ - `prisma/migrations/20260524153000_session9_audit_events/migration.sql`
+ - `src/audit/audit.module.ts`
+ - `src/audit/audit.controller.ts`
+ - `src/audit/audit.interceptor.ts`
+ - `src/audit/audit.service.ts`
+ - `src/audit/audit-context.ts`
+ - `src/audit/audit-record.utils.ts`
+ - `src/audit/decorators/audit.decorator.ts`
+ - `src/audit/types/audit.types.ts`
+ - `src/app.module.ts`
+ - `src/app.controller.ts`
+ - `src/auth/auth.module.ts`
+ - `src/auth/auth.controller.ts`
+ - `src/auth/guards/api-key.guard.ts`
+ - `src/auth/guards/authorization.guard.ts`
+ - `src/auth/interfaces/authenticated-request.interface.ts`
+ - `src/certificate/certificate.controller.ts`
+ - `src/certificate/backup.controller.ts`
+ - `src/certificate/certificate-backup.service.ts`
+ - `src/certificate/tls.controller.ts`
+ - `src/certificate/certificate.service.ts`
+ - `src/distributed-lock/cluster.controller.ts`
+ - `src/logs/logs.controller.ts`
+ - `src/prisma/prisma.service.ts`
+ - `README.md`
+ - `ARCHITECTURE_DECISIONS.md`
+ - `IMPLEMENTATION_STATUS.md`
+ - `test/session9/audit-logging.test.js`
+ - `tsconfig.json`
+- Tests added/updated:
+ - added `test/session9/audit-logging.test.js` to verify successful mutating audit events, denied privileged attempts, controller-level failures, and the new audit review endpoint
+ - re-ran the repository test suite so Sessions 3-9 continue to pass together after the new global interceptor and guard changes
+ - regenerated the Prisma client after adding the `AuditEvent` model so TypeScript and runtime code resolve the new table correctly
+- Risks:
+ - audit persistence currently shares the primary application database, so a database outage can still block durable audit writes until later resilience work lands
+ - audit retention, redaction, export controls, and long-term reporting remain future work for Sessions 20, 23, and 24
+ - audit payloads intentionally avoid request bodies by default, which reduces secret exposure but means some targets are represented by IDs or labels rather than full change diffs
+- Follow-up sessions:
+ - Session 20 — harden backup, export, import, and restore flows
+ - Session 23 — add security administration APIs
+ - Session 24 — replace ad hoc logging with structured operational and audit logging
+ - Session 28 — upgrade CI/CD and release gating
+- Notes:
+ - added a Prisma-backed `AuditEvent` model and migration for durable audit persistence
+ - introduced a global audit interceptor plus explicit `@Audit(...)` route metadata so all protected mutating routes are audited by default and privileged GET-style maintenance actions can opt in explicitly
+ - updated the authentication and authorization guards to record denied attempts before controller execution, closing the gap for failed privileged actions
+ - added `GET /audit` for security-admin review workflows and propagated per-request `X-Correlation-Id` headers for audited requests
+ - aligned the repo with Prisma 7 generation and typing requirements so the new audit-event schema can be generated and type-checked cleanly in this workspace
---
## Phase 3 — Cluster coordination redesign
## Session 10 — Add lease-based coordination primitives
+
- Status: not started
- Objective: replace advisory-lock-centric leadership with durable leases and fencing tokens
- Files touched: none yet
@@ -311,6 +367,7 @@ Use it as the single place to record what has shipped, what is in progress, and
- Notes: central coordination semantics remain to be implemented
## Session 11 — Move heartbeat and leader flows onto leases
+
- Status: not started
- Objective: align membership and leadership around the new lease model
- Files touched: none yet
@@ -320,6 +377,7 @@ Use it as the single place to record what has shipped, what is in progress, and
- Notes: should simplify stale-node handling
## Session 12 — Add cluster operations and per-node ACK tracking
+
- Status: not started
- Objective: represent cluster-wide mutations as tracked async operations
- Files touched: none yet
@@ -333,6 +391,7 @@ Use it as the single place to record what has shipped, what is in progress, and
## Phase 4 — Configuration rollout and rollback safety
## Session 13 — Implement staged NGINX config generation and atomic activation
+
- Status: not started
- Objective: make config rollout transactional and rollbackable
- Files touched: none yet
@@ -342,6 +401,7 @@ Use it as the single place to record what has shipped, what is in progress, and
- Notes: last-known-good config handling is still pending
## Session 14 — Restrict or redesign `nginx_custom_code`
+
- Status: not started
- Objective: remove arbitrary config injection risk while preserving advanced extensibility
- Files touched: none yet
@@ -355,6 +415,7 @@ Use it as the single place to record what has shipped, what is in progress, and
## Phase 5 — Certificate lifecycle redesign
## Session 15 — Add strict domain validation and safe process execution
+
- Status: not started
- Objective: reject malformed domains early and stop using unsafe shell interpolation
- Files touched: none yet
@@ -364,6 +425,7 @@ Use it as the single place to record what has shipped, what is in progress, and
- Notes: domain normalization rules need to become explicit
## Session 16 — Add certificate order state machine
+
- Status: not started
- Objective: model certificate issuance as a durable workflow
- Files touched: none yet
@@ -373,6 +435,7 @@ Use it as the single place to record what has shipped, what is in progress, and
- Notes: retry and history persistence are still pending
## Session 17 — Rework cluster certificate distribution and activation
+
- Status: not started
- Objective: separate issuance from activation and track per-node rollout state
- Files touched: none yet
@@ -382,6 +445,7 @@ Use it as the single place to record what has shipped, what is in progress, and
- Notes: rollback to prior artifact version remains open
## Session 18 — Harden the ACME strategy for clustered production
+
- Status: not started
- Objective: make challenge handling explicit, durable, and cluster-safe
- Files touched: none yet
@@ -395,6 +459,7 @@ Use it as the single place to record what has shipped, what is in progress, and
## Phase 6 — Secrets, backups, and data protection
## Session 19 — Encrypt private key material at rest
+
- Status: not started
- Objective: protect private keys in storage with application-layer encryption
- Files touched: none yet
@@ -404,6 +469,7 @@ Use it as the single place to record what has shipped, what is in progress, and
- Notes: KMS/Vault integration design remains open
## Session 20 — Harden backup, export, import, and restore flows
+
- Status: not started
- Objective: make backup and recovery flows encrypted, validated, and authorized
- Files touched: none yet
@@ -417,6 +483,7 @@ Use it as the single place to record what has shipped, what is in progress, and
## Phase 7 — Operational API expansion
## Session 21 — Add proxy management API
+
- Status: not started
- Objective: manage proxy config through authenticated API endpoints instead of direct DB mutation
- Files touched: none yet
@@ -426,6 +493,7 @@ Use it as the single place to record what has shipped, what is in progress, and
- Notes: validation endpoints should ship with CRUD APIs
## Session 22 — Add cluster operations and node-status admin APIs
+
- Status: not started
- Objective: expose cluster coordination state through operator-facing APIs
- Files touched: none yet
@@ -435,6 +503,7 @@ Use it as the single place to record what has shipped, what is in progress, and
- Notes: async operation response contracts remain to be defined
## Session 23 — Add security administration APIs
+
- Status: not started
- Objective: support operational security maintenance safely and audibly
- Files touched: none yet
@@ -448,6 +517,7 @@ Use it as the single place to record what has shipped, what is in progress, and
## Phase 8 — Logging, metrics, and SRE readiness
## Session 24 — Replace ad hoc logging with structured operational and audit logging
+
- Status: not started
- Objective: make logs machine-parseable, traceable, and safe for centralized collection
- Files touched: none yet
@@ -457,6 +527,7 @@ Use it as the single place to record what has shipped, what is in progress, and
- Notes: request IDs and redaction remain open
## Session 25 — Expand metrics and alerting
+
- Status: not started
- Objective: add observability for leases, config apply, cert orders, backups, and DB health
- Files touched: none yet
@@ -470,6 +541,7 @@ Use it as the single place to record what has shipped, what is in progress, and
## Phase 9 — Test harness and release gates
## Session 26 — Add automated test harness and baseline coverage
+
- Status: not started
- Objective: create unit, integration, and e2e test foundations for critical flows
- Files touched: none yet
@@ -479,6 +551,7 @@ Use it as the single place to record what has shipped, what is in progress, and
- Notes: this session will replace the current `test` placeholder with a real harness
## Session 27 — Add chaos and fault-injection validation
+
- Status: not started
- Objective: prove recovery and coordination claims with reproducible failure tests
- Files touched: none yet
@@ -488,6 +561,7 @@ Use it as the single place to record what has shipped, what is in progress, and
- Notes: should cover DB outage, leader crash, NGINX crash, bad config, and node comms failure
## Session 28 — Upgrade CI/CD and release gating
+
- Status: not started
- Objective: block insecure or broken releases from shipping
- Files touched: none yet
@@ -501,6 +575,7 @@ Use it as the single place to record what has shipped, what is in progress, and
## Phase 10 — Documentation and final validation
## Session 29 — Reconcile README, architecture docs, and runbooks with reality
+
- Status: not started
- Objective: make operator-facing documentation accurate and practical
- Files touched: none yet
@@ -510,6 +585,7 @@ Use it as the single place to record what has shipped, what is in progress, and
- Notes: runbooks for leader failure, rollback, restore, issuance failure, and credential rotation remain open
## Session 30 — Final production-readiness validation pass
+
- Status: not started
- Objective: reconcile the assessment, roadmap, and shipped implementation into a final go-live checklist
- Files touched: none yet
@@ -517,4 +593,3 @@ Use it as the single place to record what has shipped, what is in progress, and
- Risks: production-readiness cannot be claimed until every assessment item is accounted for
- Follow-up sessions: none
- Notes: final checklist and deferment register remain to be created
-
diff --git a/README.md b/README.md
index eba7e2e..e38e324 100644
--- a/README.md
+++ b/README.md
@@ -25,8 +25,8 @@ Built with [NestJS](https://nestjs.com/) • Powered by [PostgreSQL](https://www
## 📍 Current Delivery Status
- **Roadmap status:** Phase 2 in progress
-- **Completed in Sessions 1-8 plus follow-up maintenance:** delivery scaffolding, dependency hygiene, authenticated-by-default admin APIs, dependency-aware health semantics, fail-fast container supervision, explicit inter-node control-plane addressing, an identity-aware auth foundation, and explicit RBAC authorization policies across the current API surface
-- **Next recommended implementation session:** Session 9 — add audit logging for privileged and mutating operations
+- **Completed in Sessions 1-9 plus follow-up maintenance:** delivery scaffolding, dependency hygiene, authenticated-by-default admin APIs, dependency-aware health semantics, fail-fast container supervision, explicit inter-node control-plane addressing, an identity-aware auth foundation, explicit RBAC authorization policies, and durable audit logging for privileged and mutating operations
+- **Next recommended implementation session:** Session 10 — add lease-based coordination primitives
- **Canonical planning and status docs:**
- [`PRODUCTION_READINESS_ASSESSMENT.md`](PRODUCTION_READINESS_ASSESSMENT.md)
- [`IMPLEMENTATION_PLAN_BY_SESSION.md`](IMPLEMENTATION_PLAN_BY_SESSION.md)
@@ -35,11 +35,11 @@ Built with [NestJS](https://nestjs.com/) • Powered by [PostgreSQL](https://www
## 🧭 Deployment Mode Expectations
-| Mode | Use it for | Current expectation |
-| --- | --- | --- |
-| Local development | coding, manual verification, exploratory testing | best-supported workflow today |
-| Single-node Compose | demos, operator evaluation, non-HA environments | usable for local/single-node evaluation, **not** yet positioned as hardened production |
-| Docker Swarm global mode | target cluster architecture | roadmap target only; use for controlled testing until P0/P1 hardening sessions are complete |
+| Mode | Use it for | Current expectation |
+| ------------------------ | ------------------------------------------------ | ------------------------------------------------------------------------------------------- |
+| Local development | coding, manual verification, exploratory testing | best-supported workflow today |
+| Single-node Compose | demos, operator evaluation, non-HA environments | usable for local/single-node evaluation, **not** yet positioned as hardened production |
+| Docker Swarm global mode | target cluster architecture | roadmap target only; use for controlled testing until P0/P1 hardening sessions are complete |
## ✅ Repository Verification Commands
@@ -59,7 +59,7 @@ node -v # expected: v24.16.0
npm -v # expected: 11.15.0
```
-`npm run test` now runs the focused Session 3-8 regression tests for API access control, health semantics, container-supervision behavior, inter-node addressing, the identity-aware auth foundation, and RBAC authorization policy enforcement. Session 26 still remains the planned milestone for broad unit/integration/e2e harness expansion.
+`npm run test` now runs the focused Session 3-9 regression tests for API access control, health semantics, container-supervision behavior, inter-node addressing, the identity-aware auth foundation, RBAC authorization policy enforcement, and audit-logging regressions. Session 26 still remains the planned milestone for broad unit/integration/e2e harness expansion.
---
@@ -364,9 +364,9 @@ npm run prisma:migrate
## 📚 API Documentation
-### Session 3-8 access policy
+### Session 3-9 access policy
-As of Sessions 3-8, the control-plane API is **authenticated by default**, with a small public probe allowlist, an identity-aware authentication layer, and explicit RBAC authorization policies on protected endpoints.
+As of Sessions 3-9, the control-plane API is **authenticated by default**, with a small public probe allowlist, an identity-aware authentication layer, explicit RBAC authorization policies on protected endpoints, and durable audit logging for privileged and mutating operations.
The current explicit public allowlist is limited to:
@@ -388,7 +388,7 @@ All other endpoints should be treated as admin or internal control-plane endpoin
- `X-API-Key: `
- `Authorization: ApiKey `
-Session 7 introduced a JWT bearer-token foundation that attaches actor identity to each authenticated request. Session 8 builds on that with explicit RBAC policies. Legacy API keys remain supported temporarily as a migration bridge and can be exchanged for short-lived bearer tokens via `POST /auth/token` when `AUTH_JWT_SECRET` is configured.
+Session 7 introduced a JWT bearer-token foundation that attaches actor identity to each authenticated request. Session 8 builds on that with explicit RBAC policies. Session 9 adds durable audit logging for privileged and mutating operations, including successful writes, denied privileged attempts, and controller/service failures on protected routes. Legacy API keys remain supported temporarily as a migration bridge and can be exchanged for short-lived bearer tokens via `POST /auth/token` when `AUTH_JWT_SECRET` is configured.
Current identity model:
@@ -419,6 +419,15 @@ The resolved identity now carries:
- scopes
- issuer/audience metadata
+Protected privileged and mutating requests now also receive an `X-Correlation-Id` response header. Audit events persist:
+
+- actor identity
+- action name
+- target identifier or label when available
+- outcome (`success`, `failure`, or `denied`)
+- HTTP status and request path
+- correlation ID and timestamp
+
Readiness now returns **HTTP 503** when critical dependencies are unhealthy. The readiness body also reports the status of:
- PostgreSQL connectivity
@@ -428,34 +437,34 @@ Readiness now returns **HTTP 503** when critical dependencies are unhealthy. The
### Current authorization matrix
-| Role / actor | Intended scope |
-| --- | --- |
-| Public | health probes, metrics, ACME challenge serving |
-| `viewer` | read-only admin inspection endpoints |
-| `operator` | runtime operations such as reload, renew, and log inspection |
-| `security-admin` | certificate/key management, backup/export/import, TLS hardening |
-| `platform-admin` | full admin access including cluster maintenance endpoints |
-| `internal-node` | internal certificate sync and future node-only control-plane actions |
+| Role / actor | Intended scope |
+| ---------------- | -------------------------------------------------------------------- |
+| Public | health probes, metrics, ACME challenge serving |
+| `viewer` | read-only admin inspection endpoints |
+| `operator` | runtime operations such as reload, renew, and log inspection |
+| `security-admin` | certificate/key management, backup/export/import, TLS hardening |
+| `platform-admin` | full admin access including cluster maintenance endpoints |
+| `internal-node` | internal certificate sync and future node-only control-plane actions |
### Certificate Endpoints
-| Method | Endpoint | Description | Required role / actor |
-|--------|--------------------------------------|-----------------------------------|-----------------------|
-| GET | `/certificates` | List all certificates with status | `viewer` |
-| GET | `/certificates/:id` | Get certificate details | `viewer` |
-| POST | `/certificates/upload` | Upload custom certificate | `security-admin` |
-| POST | `/certificates/generate-self-signed` | Generate self-signed cert | `security-admin` |
-| POST | `/certificates/renew/:id` | Renew specific certificate | `operator` |
-| POST | `/certificates/renew-all` | Renew all certificates | `operator` |
-| DELETE | `/certificates/:id` | Delete certificate | `security-admin` |
-| GET | `/certificates/validate/:domain` | Validate domain | `viewer` |
-| GET | `/certificates/health/ocsp-check` | Inspect OCSP support status | `viewer` |
+| Method | Endpoint | Description | Required role / actor |
+| ------ | ------------------------------------ | --------------------------------- | ----------------------------------- |
+| GET | `/certificates` | List all certificates with status | `viewer` |
+| GET | `/certificates/:id` | Get certificate details | `viewer` |
+| POST | `/certificates/upload` | Upload custom certificate | `security-admin` |
+| POST | `/certificates/generate-self-signed` | Generate self-signed cert | `security-admin` |
+| POST | `/certificates/renew/:id` | Renew specific certificate | `operator` |
+| POST | `/certificates/renew-all` | Renew all certificates | `operator` |
+| DELETE | `/certificates/:id` | Delete certificate | `security-admin` |
+| GET | `/certificates/validate/:domain` | Validate domain | `viewer` |
+| GET | `/certificates/health/ocsp-check` | Inspect OCSP support status | `viewer` |
| POST | `/certificates/sync` | Trigger certificate sync | `internal-node` or `platform-admin` |
### Backup Endpoints
-| Method | Endpoint | Description | Required role |
-|--------|-----------------------------------|---------------------|---------------|
+| Method | Endpoint | Description | Required role |
+| ------ | --------------------------------- | ------------------- | ---------------- |
| POST | `/certificates/backup` | Create backup | `security-admin` |
| GET | `/certificates/backup` | List backups | `security-admin` |
| GET | `/certificates/backup/:filename` | Download backup | `security-admin` |
@@ -466,29 +475,36 @@ Readiness now returns **HTTP 503** when critical dependencies are unhealthy. The
### Metrics Endpoints
| Method | Endpoint | Description | Auth |
-|--------|-----------------|--------------------|--------|
+| ------ | --------------- | ------------------ | ------ |
| GET | `/metrics` | Prometheus metrics | Public |
| GET | `/metrics/json` | JSON metrics | Public |
### TLS Configuration Endpoints
-| Method | Endpoint | Description | Required role |
-|--------|-----------------------------------|---------------------|---------------|
-| GET | `/tls/config/:domain` | Get TLS config | `viewer` |
-| GET | `/tls/test/:domain` | Test TLS connection | `viewer` |
+| Method | Endpoint | Description | Required role |
+| ------ | --------------------------------- | ------------------- | ---------------- |
+| GET | `/tls/config/:domain` | Get TLS config | `viewer` |
+| GET | `/tls/test/:domain` | Test TLS connection | `viewer` |
| POST | `/tls/dhparam` | Generate DH params | `security-admin` |
-| GET | `/tls/dhparam/status` | Check DH params | `viewer` |
+| GET | `/tls/dhparam/status` | Check DH params | `viewer` |
| POST | `/tls/certificate/info` | Parse certificate | `security-admin` |
| POST | `/tls/certificate/validate-chain` | Validate chain | `security-admin` |
### Authentication Endpoints
-| Method | Endpoint | Description | Required role / actor |
-|--------|----------------|-------------------------------------------------------------------|-----------------------|
+| Method | Endpoint | Description | Required role / actor |
+| ------ | -------------- | ------------------------------------------------------------------ | -------------------------------------------- |
| GET | `/auth/status` | Check authentication status and return the resolved actor identity | any authenticated `admin` or `internal-node` |
-| GET | `/auth/info` | Get auth capability/configuration info | `viewer` |
-| GET | `/auth/me` | Inspect the current request identity | any authenticated `admin` or `internal-node` |
-| POST | `/auth/token` | Exchange a legacy API-key-authenticated request for a bearer token | any authenticated `admin` |
+| GET | `/auth/info` | Get auth capability/configuration info | `viewer` |
+| POST | `/auth/token` | Exchange a legacy API key identity for a short-lived bearer token | authenticated `admin` |
+
+### Audit Endpoints
+
+| Method | Endpoint | Description | Required role |
+| ------ | ------------- | ------------------------------------------------------------------ | -------------------------------------------- |
+| GET | `/audit` | List recent audit events with optional filters | `security-admin` |
+| GET | `/auth/me` | Inspect the current request identity | any authenticated `admin` or `internal-node` |
+| POST | `/auth/token` | Exchange a legacy API-key-authenticated request for a bearer token | any authenticated `admin` |
### Auth configuration
@@ -516,19 +532,19 @@ curl http://localhost:3000/auth/me \
### Health Endpoints
-| Method | Endpoint | Description | Auth |
-|--------|-------------------|--------------------------------------------------------------|----------|
-| GET | `/health/live` | Liveness probe; returns 200 while the process is alive | Public |
-| GET | `/health/startup` | Startup probe; returns 503 until first config apply + cert sync succeed | Public |
-| GET | `/health/ready` | Readiness probe; returns 503 when DB/NGINX/config/cert state is unhealthy | Public |
-| GET | `/health` | Legacy alias for `/health/live` | Public |
-| GET | `/ready` | Legacy alias for `/health/ready` | Public |
-| POST | `/reload` | Reload NGINX config | `operator` |
+| Method | Endpoint | Description | Auth |
+| ------ | ----------------- | ------------------------------------------------------------------------- | ---------- |
+| GET | `/health/live` | Liveness probe; returns 200 while the process is alive | Public |
+| GET | `/health/startup` | Startup probe; returns 503 until first config apply + cert sync succeed | Public |
+| GET | `/health/ready` | Readiness probe; returns 503 when DB/NGINX/config/cert state is unhealthy | Public |
+| GET | `/health` | Legacy alias for `/health/live` | Public |
+| GET | `/ready` | Legacy alias for `/health/ready` | Public |
+| POST | `/reload` | Reload NGINX config | `operator` |
### ACME Challenge Endpoint
| Method | Endpoint | Description | Auth |
-|--------|--------------------------------------|---------------------------|--------|
+| ------ | ------------------------------------ | ------------------------- | ------ |
| GET | `/.well-known/acme-challenge/:token` | Serve ACME challenge data | Public |
**📖 API documentation status:** a refreshed API reference is still pending; for now, use the controller source under `src/`, `IMPLEMENTATION_STATUS.md`, and `PRODUCTION_READINESS_ASSESSMENT.md` as the current references.
@@ -628,7 +644,7 @@ curl http://localhost:3000/metrics/json
scrape_configs:
- job_name: 'lyttlenginx'
static_configs:
- - targets: [ 'app:3000' ]
+ - targets: ['app:3000']
metrics_path: '/metrics'
scrape_interval: 30s
```
@@ -917,16 +933,19 @@ npm run docker:setup # Setup for Docker
### 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
```
@@ -936,7 +955,7 @@ npm run docker:setup # Setup for Docker
imports: [
// ... existing imports
FeatureModule,
- ]
+ ];
```
### Testing
@@ -1065,23 +1084,23 @@ For issues and questions:
## 🏆 Features at a Glance
-| 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 | ⚠️ | Evaluation-ready, hardening in progress |
+| 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 | ⚠️ | Evaluation-ready, hardening in progress |
---
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/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/schema.prisma b/prisma/schema.prisma
index 722ae53..889e0c7 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 {
@@ -75,4 +74,36 @@ model AcmeChallenge {
@@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/src/app.controller.ts b/src/app.controller.ts
index d0d6331..d33eb2a 100644
--- a/src/app.controller.ts
+++ b/src/app.controller.ts
@@ -10,6 +10,7 @@ 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 {
@@ -19,6 +20,7 @@ export class AppController {
@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..a3a9f5f 100644
--- a/src/app.module.ts
+++ b/src/app.module.ts
@@ -13,9 +13,11 @@ 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';
@Module({
imports: [
+ AuditModule,
AuthModule,
RateLimitModule,
MetricsModule,
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 | 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,
+ '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)
+ : 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 {
+ const request = context.switchToHttp().getRequest();
+ const response = context.switchToHttp().getResponse();
+ const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [
+ context.getHandler(),
+ context.getClass(),
+ ]);
+ const auditOptions = this.reflector.getAllAndOverride(
+ 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;
+ 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;
+ 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 51b8233..a7613b9 100644
--- a/src/auth/auth.controller.ts
+++ b/src/auth/auth.controller.ts
@@ -12,6 +12,7 @@ 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')
@@ -50,6 +51,7 @@ export class AuthController {
@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(
diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts
index cff5a15..e8ee4ad 100644
--- a/src/auth/auth.module.ts
+++ b/src/auth/auth.module.ts
@@ -4,9 +4,10 @@ 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,
diff --git a/src/auth/guards/api-key.guard.ts b/src/auth/guards/api-key.guard.ts
index cd2d06e..56e8082 100644
--- a/src/auth/guards/api-key.guard.ts
+++ b/src/auth/guards/api-key.guard.ts
@@ -2,21 +2,44 @@ 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 {
private readonly authService: AuthService;
private readonly reflector: Reflector;
+ private readonly auditService?: AuditService;
- constructor(authService: AuthService, reflector: Reflector) {
+ constructor(
+ authService: AuthService,
+ reflector: Reflector,
+ @Optional() auditService?: AuditService,
+ ) {
this.authService = authService;
this.reflector = reflector;
+ this.auditService = auditService;
}
canActivate(context: ExecutionContext): boolean {
@@ -30,23 +53,39 @@ export class ApiKeyGuard implements CanActivate {
}
const request = context.switchToHttp().getRequest();
+ const response = context.switchToHttp().getResponse();
+ const auditOptions = this.reflector.getAllAndOverride(
+ AUDIT_OPTIONS_KEY,
+ [context.getHandler(), context.getClass()],
+ );
const bearerToken = this.extractBearerToken(request.headers);
if (bearerToken) {
- const identity = this.authService.authenticateBearerToken(bearerToken);
- request.auth = identity;
- request.user = identity;
- return true;
+ 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('Authentication credentials are required');
+ const error = new UnauthorizedException(
+ 'Authentication credentials are required',
+ );
+ this.recordDeniedAttempt(request, response, auditOptions, error);
+ throw error;
}
const identity = this.authService.authenticateApiKey(apiKey);
if (!identity) {
- throw new UnauthorizedException('Invalid API key');
+ const error = new UnauthorizedException('Invalid API key');
+ this.recordDeniedAttempt(request, response, auditOptions, error);
+ throw error;
}
request.auth = identity;
@@ -55,6 +94,37 @@ export class ApiKeyGuard implements CanActivate {
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,
) {
@@ -72,7 +142,9 @@ export class ApiKeyGuard implements CanActivate {
return token || undefined;
}
- private extractApiKey(headers: Record) {
+ private extractApiKey(
+ headers: Record,
+ ) {
const headerApiKey = headers['x-api-key'];
if (typeof headerApiKey === 'string' && headerApiKey.trim()) {
return headerApiKey.trim();
diff --git a/src/auth/guards/authorization.guard.ts b/src/auth/guards/authorization.guard.ts
index 31fd273..883ad81 100644
--- a/src/auth/guards/authorization.guard.ts
+++ b/src/auth/guards/authorization.guard.ts
@@ -3,6 +3,7 @@ import {
ExecutionContext,
ForbiddenException,
Injectable,
+ Optional,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AUTHORIZATION_POLICY_KEY } from '../decorators/authorize.decorator';
@@ -10,10 +11,29 @@ 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) {}
+ constructor(
+ private readonly reflector: Reflector,
+ @Optional() private readonly auditService?: AuditService,
+ ) {}
canActivate(context: ExecutionContext): boolean {
const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [
@@ -37,22 +57,68 @@ export class AuthorizationGuard implements CanActivate {
}
const request = context.switchToHttp().getRequest();
+ const response = context.switchToHttp().getResponse();
+ const auditOptions = this.reflector.getAllAndOverride(
+ AUDIT_OPTIONS_KEY,
+ [context.getHandler(), context.getClass()],
+ );
const identity = request.auth;
if (!identity) {
- throw new ForbiddenException('Authenticated identity is required');
+ 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));
+ const allowed = policy.some((rule) =>
+ this.matchesRule(rule, identity, effectiveRoles),
+ );
if (!allowed) {
- throw new ForbiddenException('Insufficient permissions for this endpoint');
+ 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,
@@ -66,4 +132,3 @@ export class AuthorizationGuard implements CanActivate {
return actorTypesAllowed && rolesAllowed;
}
}
-
diff --git a/src/auth/interfaces/authenticated-request.interface.ts b/src/auth/interfaces/authenticated-request.interface.ts
index 9b0db4a..4daa75d 100644
--- a/src/auth/interfaces/authenticated-request.interface.ts
+++ b/src/auth/interfaces/authenticated-request.interface.ts
@@ -2,6 +2,26 @@ import { AuthIdentity } from '../types/auth-identity';
export interface AuthenticatedRequest {
headers: Record;
+ method?: string;
+ url?: string;
+ originalUrl?: string;
+ ip?: string;
+ params?: Record;
+ query?: Record;
+ body?: Record;
+ route?: {
+ path?: string;
+ };
+ socket?: {
+ remoteAddress?: string;
+ };
+ connection?: {
+ remoteAddress?: string;
+ };
auth?: AuthIdentity;
user?: AuthIdentity;
+ auditContext?: {
+ correlationId: string;
+ startedAt: string;
+ };
}
diff --git a/src/certificate/backup.controller.ts b/src/certificate/backup.controller.ts
index 032a069..794ab80 100644
--- a/src/certificate/backup.controller.ts
+++ b/src/certificate/backup.controller.ts
@@ -15,6 +15,7 @@ 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';
@Controller('certificates/backup')
@AuthorizeAdmin('security-admin')
@@ -24,6 +25,7 @@ export class BackupController {
@Post()
@UseGuards(ApiKeyGuard)
@HttpCode(HttpStatus.CREATED)
+ @Audit({ action: 'certificate.backup.create' })
async createBackup() {
const result = await this.backupService.createBackup();
return {
@@ -34,12 +36,14 @@ 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,
@@ -55,6 +59,7 @@ export class BackupController {
@Delete(':filename')
@UseGuards(ApiKeyGuard)
@HttpCode(HttpStatus.NO_CONTENT)
+ @Audit({ action: 'certificate.backup.delete' })
async deleteBackup(@Param('filename') filename: string) {
await this.backupService.deleteBackup(filename);
}
@@ -62,12 +67,14 @@ export class BackupController {
@Post('import')
@UseGuards(ApiKeyGuard)
@HttpCode(HttpStatus.OK)
+ @Audit({ action: 'certificate.backup.import' })
async importCertificates(@Body() data: any) {
return this.backupService.importCertificates(data.certificates);
}
@Get('export/:id')
@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..3f61b52 100644
--- a/src/certificate/certificate-backup.service.ts
+++ b/src/certificate/certificate-backup.service.ts
@@ -1,6 +1,6 @@
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
-import * as archiver from 'archiver';
+import archiver from 'archiver';
import * as fs from 'fs';
import * as path from 'path';
import { Readable } from 'stream';
diff --git a/src/certificate/certificate.controller.ts b/src/certificate/certificate.controller.ts
index 9d757cf..e239f44 100644
--- a/src/certificate/certificate.controller.ts
+++ b/src/certificate/certificate.controller.ts
@@ -19,6 +19,7 @@ import {
AuthorizeInternalNodeOrAdmin,
} from '../auth/decorators/authorize.decorator';
import { ApiKeyGuard } from '../auth/guards/api-key.guard';
+import { Audit } from '../audit/decorators/audit.decorator';
@Controller('certificates')
@AuthorizeAdmin('viewer')
@@ -38,6 +39,25 @@ export class CertificateController {
@Post('upload')
@HttpCode(HttpStatus.CREATED)
@AuthorizeAdmin('security-admin')
+ @Audit({
+ action: 'certificate.upload',
+ target: (request, responseBody) => ({
+ type: 'certificate',
+ id:
+ typeof (responseBody as Record | undefined)?.['id'] ===
+ 'string'
+ ? ((responseBody as Record)['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);
@@ -51,6 +71,25 @@ 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 | undefined)?.['id'] ===
+ 'string'
+ ? ((responseBody as Record)['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(
@@ -69,6 +108,7 @@ export class CertificateController {
@UseGuards(ApiKeyGuard)
@HttpCode(HttpStatus.OK)
@AuthorizeAdmin('operator')
+ @Audit({ action: 'certificate.renew' })
async renewCertificate(@Param('id') id: string) {
return this.certificateService.renewCertificateById(id);
}
@@ -77,6 +117,7 @@ export class CertificateController {
@UseGuards(ApiKeyGuard)
@HttpCode(HttpStatus.OK)
@AuthorizeAdmin('operator')
+ @Audit({ action: 'certificate.renew-all' })
async renewAllCertificates() {
await this.certificateService.renewAllCertificates();
return { message: 'Certificate renewal process initiated' };
@@ -86,6 +127,7 @@ export class CertificateController {
@UseGuards(ApiKeyGuard)
@HttpCode(HttpStatus.NO_CONTENT)
@AuthorizeAdmin('security-admin')
+ @Audit({ action: 'certificate.delete' })
async deleteCertificate(@Param('id') id: string) {
await this.certificateService.deleteCertificate(id);
}
@@ -108,6 +150,7 @@ export class CertificateController {
@Post('sync')
@HttpCode(HttpStatus.OK)
@AuthorizeInternalNodeOrAdmin('platform-admin')
+ @Audit({ action: 'certificate.sync' })
async syncCertificates() {
return this.certificateService.syncCertificates();
}
diff --git a/src/certificate/certificate.service.ts b/src/certificate/certificate.service.ts
index 78b3f68..e0553f3 100644
--- a/src/certificate/certificate.service.ts
+++ b/src/certificate/certificate.service.ts
@@ -1193,7 +1193,7 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown {
},
});
const domainGroups = Array.from(
- new Set(entries.map((e) => joinDomains(parseDomains(e.domains)))),
+ new Set(entries.map((e) => joinDomains(parseDomains(e.domains)))),
).map((group) => parseDomains(group));
this.logger.log(
diff --git a/src/certificate/tls.controller.ts b/src/certificate/tls.controller.ts
index 4be5778..ec18651 100644
--- a/src/certificate/tls.controller.ts
+++ b/src/certificate/tls.controller.ts
@@ -13,6 +13,7 @@ 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';
@Controller('tls')
@AuthorizeAdmin('viewer')
@@ -32,6 +33,7 @@ export class TlsController {
@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) => {
@@ -54,6 +56,7 @@ export class TlsController {
@UseGuards(ApiKeyGuard)
@HttpCode(HttpStatus.OK)
@AuthorizeAdmin('security-admin')
+ @Audit({ action: 'tls.certificate.inspect' })
async getCertificateInfo(@Body() dto: CertificatePemDto) {
return this.tlsConfigService.getCertificateInfo(dto.certPem);
}
@@ -62,6 +65,7 @@ export class TlsController {
@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.controller.ts b/src/distributed-lock/cluster.controller.ts
index d73a2d7..8036db8 100644
--- a/src/distributed-lock/cluster.controller.ts
+++ b/src/distributed-lock/cluster.controller.ts
@@ -15,6 +15,7 @@ import {
buildClusterNodeUrl,
getClusterNodeControlPlaneEndpoint,
} from '../utils/network-utils';
+import { Audit } from '../audit/decorators/audit.decorator';
@Controller('cluster')
@UseGuards(ApiKeyGuard)
@@ -33,6 +34,7 @@ export class ClusterController {
*/
@Post('reload')
@AuthorizeAdmin('operator')
+ @Audit({ action: 'cluster.reload' })
async reload(@Query('broadcast') broadcast: string) {
const shouldBroadcast = broadcast !== 'false';
const broadcastSummary = {
@@ -234,6 +236,7 @@ export class ClusterController {
*/
@Get('admin/cleanup')
@AuthorizeAdmin('platform-admin')
+ @Audit({ action: 'cluster.cleanup' })
async manualCleanup() {
return this.clusterHeartbeat.manualCleanup();
}
@@ -243,6 +246,7 @@ export class ClusterController {
*/
@Get('admin/enforce-leader')
@AuthorizeAdmin('platform-admin')
+ @Audit({ action: 'cluster.enforce-leader' })
async manualEnforceLeader() {
return this.clusterHeartbeat.manualEnforceLeader();
}
@@ -252,6 +256,7 @@ export class ClusterController {
*/
@Get('admin/ensure-leader')
@AuthorizeAdmin('platform-admin')
+ @Audit({ action: 'cluster.ensure-leader' })
async ensureLeader() {
try {
await this.clusterHeartbeat.ensureLeaderExists();
@@ -282,6 +287,7 @@ export class ClusterController {
*/
@Get('admin/become-leader')
@AuthorizeAdmin('platform-admin')
+ @Audit({ action: 'cluster.become-leader' })
async tryBecomeLeader() {
try {
const success = await this.clusterHeartbeat.tryBecomeLeader();
diff --git a/src/logs/logs.controller.ts b/src/logs/logs.controller.ts
index 0cd3de9..c787959 100644
--- a/src/logs/logs.controller.ts
+++ b/src/logs/logs.controller.ts
@@ -8,6 +8,7 @@ import {
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')
@@ -16,6 +17,7 @@ export class LogsController {
@Get()
@UseGuards(ApiKeyGuard)
+ @Audit({ action: 'logs.read' })
getLogs(@Query('count', ParseIntPipe) count = 100) {
const logs = this.logsService.getLastLogs(count);
return { logs };
diff --git a/src/prisma/prisma.service.ts b/src/prisma/prisma.service.ts
index d6a52b1..f737298 100644
--- a/src/prisma/prisma.service.ts
+++ b/src/prisma/prisma.service.ts
@@ -16,26 +16,25 @@ 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);
- // 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', '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
+ }
+
+ process.env.DATABASE_URL = url.toString();
}
super({
- datasources: {
- db: {
- url: url.toString(),
- },
- },
log: [
{ level: 'warn', emit: 'event' },
{ level: 'error', emit: 'event' },
diff --git a/test/session9/audit-logging.test.js b/test/session9/audit-logging.test.js
new file mode 100644
index 0000000..5b721a7
--- /dev/null
+++ b/test/session9/audit-logging.test.js
@@ -0,0 +1,403 @@
+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 ClusterHeartbeatService {}
+class DistributedLockService {}
+class ReloaderService {}
+
+const originalLoad = Module._load;
+const moduleStubs = new Map([
+ ['./certificate.service', { CertificateService }],
+ ['./cluster-heartbeat.service', { ClusterHeartbeatService }],
+ ['./distributed-lock.service', { DistributedLockService }],
+ ['./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 {
+ 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,
+ },
+ 'session9-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('Session 9 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 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 reloaderServiceMock = {
+ async reloadConfig() {
+ return { ok: true };
+ },
+ };
+
+ before(async () => {
+ process.env.API_KEY = 'session9-legacy-key';
+ process.env.AUTH_JWT_SECRET = 'session9-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, 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: ClusterHeartbeatService,
+ useValue: clusterHeartbeatServiceMock,
+ },
+ {
+ provide: DistributedLockService,
+ useValue: distributedLockServiceMock,
+ },
+ {
+ provide: ReloaderService,
+ useValue: reloaderServiceMock,
+ },
+ ],
+ }).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('/cluster/admin/become-leader')
+ .set('Authorization', `Bearer ${operatorToken}`)
+ .expect(403);
+
+ await flushAuditWrites();
+
+ const deniedEvent = auditServiceMock.events.at(-1);
+ assert.equal(deniedEvent.action, 'cluster.become-leader');
+ 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/tsconfig.json b/tsconfig.json
index 5e3574d..e0e122f 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -11,6 +11,9 @@
"outDir": "./dist",
"rootDir": "./src",
"baseUrl": "./",
+ "paths": {
+ "@prisma/client": ["node_modules/.prisma/client"]
+ },
"ignoreDeprecations": "6.0",
"incremental": true,
"skipLibCheck": true,
@@ -20,7 +23,5 @@
"forceConsistentCasingInFileNames": false,
"noFallthroughCasesInSwitch": false
},
- "include": [
- "src/**/*"
- ]
+ "include": ["src/**/*"]
}
From 7a3f3554eee92f2897a0c6670430c371ab519bb2 Mon Sep 17 00:00:00 2001
From: Stualyttle <27898452+Stualyttle@users.noreply.github.com.>
Date: Sun, 24 May 2026 15:59:27 +0200
Subject: [PATCH 11/43] feat: enhance database connection configuration with
dynamic pool size handling
---
.env.example | 4 +++-
src/prisma/prisma.service.ts | 10 +++++++++-
2 files changed, 12 insertions(+), 2 deletions(-)
diff --git a/.env.example b/.env.example
index d6ca22c..9cd8a44 100644
--- a/.env.example
+++ b/.env.example
@@ -7,7 +7,9 @@
# ===============================================
# DATABASE CONFIGURATION (REQUIRED)
# ===============================================
-DATABASE_URL="postgresql://lyttlenginx:@localhost:5432/lyttlenginx?connect_timeout=360&pool_timeout=360&pool_size=1&schema=public&sslmode=no-verify
+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)
diff --git a/src/prisma/prisma.service.ts b/src/prisma/prisma.service.ts
index f737298..210e4d6 100644
--- a/src/prisma/prisma.service.ts
+++ b/src/prisma/prisma.service.ts
@@ -19,10 +19,18 @@ export class PrismaService
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
+ url.searchParams.set('connection_limit', resolvedConnectionLimit);
}
if (!url.searchParams.has('pool_timeout')) {
url.searchParams.set('pool_timeout', '10'); // 10 second timeout
From 1a68ec40ef2a514c124089e3801bc2a564107809 Mon Sep 17 00:00:00 2001
From: Stualyttle <27898452+Stualyttle@users.noreply.github.com.>
Date: Sun, 24 May 2026 16:08:42 +0200
Subject: [PATCH 12/43] feat: implement lease-based leader coordination with
generation fencing tokens
---
.env.example | 6 +
ARCHITECTURE_DECISIONS.md | 33 ++
IMPLEMENTATION_STATUS.md | 42 +-
README.md | 16 +-
.../migration.sql | 22 +
prisma/schema.prisma | 19 +
.../cluster-heartbeat.service.ts | 61 +--
src/distributed-lock/cluster.controller.ts | 60 ++-
.../distributed-lock.service.ts | 479 +++++++++++++++++-
9 files changed, 666 insertions(+), 72 deletions(-)
create mode 100644 prisma/migrations/20260524161500_session10_cluster_leases/migration.sql
diff --git a/.env.example b/.env.example
index 9cd8a44..47e5dc4 100644
--- a/.env.example
+++ b/.env.example
@@ -71,6 +71,12 @@ 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"
+
# ===============================================
# CERTIFICATE MANAGEMENT SETTINGS
# ===============================================
diff --git a/ARCHITECTURE_DECISIONS.md b/ARCHITECTURE_DECISIONS.md
index 528919d..6e906bd 100644
--- a/ARCHITECTURE_DECISIONS.md
+++ b/ARCHITECTURE_DECISIONS.md
@@ -28,6 +28,7 @@ This file records repository-level architectural and delivery decisions so futur
| ADR-010 | Identity-aware auth foundation with bearer-token support and legacy API-key compatibility | accepted | Session 7 | 2026-05-24 |
| ADR-011 | Explicit RBAC policies with a global authorization guard | accepted | Session 8 | 2026-05-24 |
| ADR-012 | Durable audit events for privileged and mutating operations | accepted | Session 9 | 2026-05-24 |
+| ADR-013 | Lease-based leader coordination with generation fencing tokens | accepted | Session 10 | 2026-05-24 |
---
@@ -437,3 +438,35 @@ Adopt a durable audit-event model with request correlation for the current NestJ
- successful writes, controller/service failures, and denied privileged attempts are now durably attributable even before the later structured-logging work lands
- route authors can opt into clearer action naming and target extraction without reworking the global audit pipeline
- audit storage currently shares the main application database, so later sessions should still harden retention, redaction, export controls, and long-term operational reporting
+
+---
+
+## ADR-013 — Lease-based leader coordination with generation fencing tokens
+
+- Status: accepted
+- Session: Session 10 — Add lease-based coordination primitives
+- Date: 2026-05-24
+
+### Context
+
+The previous cluster leader model relied on PostgreSQL advisory locks plus `ClusterNode.isLeader` booleans. That left leader intent split across transient session state and durable database state, made recovery paths advisory-lock-specific, and provided no monotonic fencing token that later cluster-wide operations could use to reject stale leaders.
+
+The production-readiness assessment called for a durable lease record with expiration and generation semantics.
+
+### Decision
+
+Adopt a database-backed lease primitive for leader coordination:
+
+1. add a new Prisma-backed `ClusterLease` table with a unique `leaseName`
+2. store the current owner, acquisition/renewal timestamps, TTL, and expiry in that table
+3. maintain a monotonically increasing `generation` value that acts as the leader fencing token
+4. preserve the generation when a lease is released so the next owner observes a higher fencing token
+5. make the leader-acquisition path in `DistributedLockService` use the durable lease primitive with automatic renewal
+6. expose lease state through the cluster API so operators can inspect the active owner and fencing token
+
+### Consequences
+
+- leader identity is now durably visible in the database instead of existing only in a PostgreSQL session lock
+- future cluster-wide writes can carry the leader generation and reject stale actors using the fencing token
+- the codebase remains in a transitional hybrid state until Session 11 finishes moving heartbeat and leader reconciliation fully onto the lease model
+
diff --git a/IMPLEMENTATION_STATUS.md b/IMPLEMENTATION_STATUS.md
index 8ee78c7..46d9ad8 100644
--- a/IMPLEMENTATION_STATUS.md
+++ b/IMPLEMENTATION_STATUS.md
@@ -8,9 +8,9 @@ Use it as the single place to record what has shipped, what is in progress, and
## Current summary
- Overall status: in progress
-- Current phase: Phase 2 — Security model foundation
-- Most recently completed session: Session 9 — Add audit logging for privileged and mutating operations
-- Next recommended session from the roadmap: Session 10 — Add lease-based coordination primitives
+- Current phase: Phase 3 — Cluster coordination redesign
+- Most recently completed session: Session 10 — Add lease-based coordination primitives
+- Next recommended session from the roadmap: Session 11 — Move heartbeat and leader flows onto leases
- Readiness reference: `PRODUCTION_READINESS_ASSESSMENT.md`
- Architecture decision log: `ARCHITECTURE_DECISIONS.md`
@@ -358,13 +358,37 @@ Use it as the single place to record what has shipped, what is in progress, and
## Session 10 — Add lease-based coordination primitives
-- Status: not started
+- Status: done
- Objective: replace advisory-lock-centric leadership with durable leases and fencing tokens
-- Files touched: none yet
-- Tests added/updated: none yet
-- Risks: current leader coordination can diverge under failure
-- Follow-up sessions: Session 11, Session 12, Session 27
-- Notes: central coordination semantics remain to be implemented
+- Files touched:
+ - `prisma/schema.prisma`
+ - `prisma/migrations/20260524161500_session10_cluster_leases/migration.sql`
+ - `src/distributed-lock/distributed-lock.service.ts`
+ - `src/distributed-lock/cluster-heartbeat.service.ts`
+ - `src/distributed-lock/cluster.controller.ts`
+ - `.env.example`
+ - `README.md`
+ - `ARCHITECTURE_DECISIONS.md`
+ - `IMPLEMENTATION_STATUS.md`
+ - `test/session10/lease-coordination.test.js`
+- Tests added/updated:
+ - added `test/session10/lease-coordination.test.js` to cover the new lease schema/migration, leader-lease acquisition state, fencing-token validation, and the new cluster lease inspection endpoint
+ - regenerated the Prisma client after adding `ClusterLease` so the new delegate and schema types resolve correctly in TypeScript
+ - re-ran the repository test suite so Sessions 3-10 continue to pass together after the lease-foundation changes
+- Risks:
+ - the cluster heartbeat service still maintains transitional `ClusterNode.isLeader` reconciliation until Session 11 completes the full lease-backed leader flow
+ - cluster-wide operations do not yet persist operation IDs or per-node ACKs; Session 12 remains responsible for making lease ownership actionable across async workflows
+ - inter-node transport is still authenticated HTTP rather than mTLS, so this session improves coordination correctness but not network trust boundaries
+- Follow-up sessions:
+ - Session 11 — move heartbeat and leader flows onto leases
+ - Session 12 — add cluster operations and per-node ACK tracking
+ - Session 22 — add cluster operation and node-status admin APIs
+ - Session 27 — add chaos and fault-injection validation
+- Notes:
+ - added a durable `ClusterLease` table with owner, TTL, expiry, and generation-based fencing-token state
+ - moved leader acquisition in `DistributedLockService` to lease upsert/renew/release primitives while preserving the existing advisory-lock helper methods for non-leader exclusivity
+ - replaced the advisory-lock-specific deadlock recovery path with lease reconciliation so the transitional cluster heartbeat can reason about durable lease ownership before the full Session 11 refactor lands
+ - exposed `GET /cluster/lease` and enriched `GET /cluster/leader/status` so operators can inspect current lease owner and fencing-token state
## Session 11 — Move heartbeat and leader flows onto leases
diff --git a/README.md b/README.md
index e38e324..a0b036f 100644
--- a/README.md
+++ b/README.md
@@ -101,8 +101,8 @@ npm -v # expected: 11.15.0
### 🌐 Cluster Management
-- **Distributed Locking** - PostgreSQL advisory locks for coordination
-- **Leader Election** - Automatic leader election with fail-over
+- **Lease-based Leader Coordination** - Durable PostgreSQL-backed leader leases with generation-based fencing tokens
+- **Leader Election** - Automatic leader election with fail-over and lease renewal
- **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
@@ -771,8 +771,9 @@ docker stack deploy -c docker-compose.swarm.yml lyttlenginx
- global mode is the intended target architecture
- Session 6 replaces public-IP autodiscovery with explicit `CLUSTER_CONTROL_ADDRESS` / `CLUSTER_CONTROL_PORT` registration
+- Session 10 replaces leader advisory-lock ownership with a durable `ClusterLease` record and generation-based fencing token
- Sessions 7-8 add request identity plus explicit RBAC, but internal-node traffic is still plain HTTP and mTLS remains future work
-- the current assessment still identifies remaining P0/P1 blockers in audit logging, certificate lifecycle hardening, internal-traffic security, and transactional config rollout even after the completed health, auto-recovery, inter-node addressing, auth-foundation, and RBAC sessions
+- the current assessment still identifies remaining P0/P1 blockers in full lease-backed heartbeat reconciliation, cluster ACK tracking, certificate lifecycle hardening, internal-traffic security, and transactional config rollout even after the completed health, auto-recovery, inter-node addressing, auth/RBAC, audit logging, and lease-foundation sessions
- use the swarm manifest for evaluation and development feedback, not as a final production deployment contract yet
The current container model is intentionally **fail-fast**: if either the NestJS control-plane process or the foreground NGINX master exits unexpectedly, the container exits non-zero so Docker or Swarm can restart it instead of leaving the node wedged.
@@ -784,6 +785,12 @@ The current Swarm manifest now treats peer communication as an **advertised cont
- `CLUSTER_CONTROL_ADDRESS` is the routable hostname/address peers should use for this node (`{{.Node.Hostname}}` in the current example)
- nodes register that advertised endpoint in the database and cluster broadcasts now use that registration instead of building URLs from discovered public IPs
+Leader election now uses a durable database lease:
+
+- `CLUSTER_LEASE_TTL_SECONDS` controls how long the leader lease remains valid without renewal (default `30`)
+- `CLUSTER_LEASE_RENEW_INTERVAL_MS` controls how frequently the local leader renews that lease (default: one third of the TTL, minimum `1000ms`)
+- the active lease carries a monotonically increasing generation number that acts as the leader fencing token for later cluster operations
+
If your Swarm nodes are not mutually reachable by node hostname, override `CLUSTER_CONTROL_ADDRESS` with a routable internal DNS name or address per node before treating cluster operations as healthy.
**View cluster status:**
@@ -792,6 +799,9 @@ If your Swarm nodes are not mutually reachable by node hostname, override `CLUST
# See all nodes
curl -H "Authorization: Bearer $JWT" http://localhost:3003/cluster/nodes
+# Inspect the current leader lease and fencing token
+curl -H "Authorization: Bearer $JWT" http://localhost:3003/cluster/lease
+
# View leader
docker service logs lyttlenginx_lyttlenginx 2>&1 | grep "LEADER"
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/schema.prisma b/prisma/schema.prisma
index 889e0c7..cff42be 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -61,6 +61,25 @@ 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 AcmeChallenge {
id String @id @default(uuid())
token String @unique // The challenge token (filename)
diff --git a/src/distributed-lock/cluster-heartbeat.service.ts b/src/distributed-lock/cluster-heartbeat.service.ts
index 424fa06..f647037 100644
--- a/src/distributed-lock/cluster-heartbeat.service.ts
+++ b/src/distributed-lock/cluster-heartbeat.service.ts
@@ -582,9 +582,11 @@ export class ClusterHeartbeatService implements OnModuleInit, OnModuleDestroy {
const acquired = await this.distributedLock.tryAcquireLeaderLock();
if (acquired) {
+ const leaseStatus = this.distributedLock.getLeaderLockStatus();
+
// WE WON THE ELECTION!
this.logger.log(
- '[LeaderCheck] ✓ This node successfully became the LEADER',
+ `[LeaderCheck] ✓ This node successfully became the LEADER (lease generation ${leaseStatus.generation ?? 'unknown'})`,
);
// Update DB to reflect leadership
@@ -603,7 +605,9 @@ export class ClusterHeartbeatService implements OnModuleInit, OnModuleDestroy {
'[LeaderCheck] Failed to acquire lock - another node holds it',
);
- // DEADLOCK DETECTION: Lock exists but no DB leader after delay
+ // Lease reconciliation: a peer may hold the leader lease before the DB
+ // leader flag has been updated. Reconcile from lease state instead of
+ // relying on advisory-lock-specific recovery.
setTimeout(async () => {
try {
const leaderCheck = await this.prisma.clusterNode.findFirst({
@@ -611,46 +615,33 @@ export class ClusterHeartbeatService implements OnModuleInit, OnModuleDestroy {
});
if (!leaderCheck) {
- this.logger.error(
- '[LeaderCheck] DEADLOCK DETECTED: Lock exists but no DB leader! Forcing recovery...',
- );
+ const lease = await this.distributedLock.getLeaderLeaseSnapshot();
- // 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' },
- });
+ if (!lease || lease.isExpired || !lease.ownerNodeId) {
+ this.logger.warn(
+ '[LeaderCheck] No active leader lease is present; retrying election',
+ );
- const staleThreshold = new Date(Date.now() - this.staleThresholdMs);
- const healthyNodes = allNodes.filter(
- (n) => n.lastHeartbeat >= staleThreshold,
- );
+ await this.ensureLeaderExists();
+ } else {
+ const leaseOwner = await this.prisma.clusterNode.findUnique({
+ where: { instanceId: lease.ownerNodeId },
+ });
+
+ if (leaseOwner && leaseOwner.status === 'active') {
+ await this.prisma.clusterNode.update({
+ where: { instanceId: lease.ownerNodeId },
+ data: { isLeader: true },
+ });
- 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',
+ `[LeaderCheck] Reconciled DB leader state from active lease owner ${leaseOwner.hostname} (generation ${lease.generation})`,
);
-
- // 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.warn(
+ `[LeaderCheck] Active leader lease belongs to unknown or inactive owner ${lease.ownerNodeId}; waiting for lease expiry before retry`,
);
}
- } else {
- this.logger.error(
- '[LeaderCheck] No healthy nodes available for leadership!',
- );
}
} else {
this.logger.debug(
diff --git a/src/distributed-lock/cluster.controller.ts b/src/distributed-lock/cluster.controller.ts
index 8036db8..fc75b66 100644
--- a/src/distributed-lock/cluster.controller.ts
+++ b/src/distributed-lock/cluster.controller.ts
@@ -183,21 +183,49 @@ export class ClusterController {
async getLeaderStatus() {
const dbLeader = await this.clusterHeartbeat.getLeaderNode();
const lockStatus = this.distributedLock.getLeaderLockStatus();
+ const lease = await this.distributedLock.getLeaderLeaseSnapshot();
const allLeaders = await this.clusterHeartbeat
.getClusterStats()
.then((stats) => stats.leaders);
+ const hasActiveLease = Boolean(
+ lease && !lease.isExpired && lease.ownerNodeId,
+ );
+ const dbMatchesLease = Boolean(
+ dbLeader && hasActiveLease && lease?.ownerNodeId === dbLeader.instanceId,
+ );
const isConsistent =
allLeaders.length === 1 &&
- (!dbLeader ||
- (lockStatus.isLeader && dbLeader.instanceId === lockStatus.instanceId));
+ hasActiveLease &&
+ dbMatchesLease &&
+ (!lockStatus.isLeader ||
+ (lockStatus.ownerNodeId === lease?.ownerNodeId &&
+ lockStatus.generation === lease?.generation));
return {
status: isConsistent ? 'healthy' : 'inconsistent',
+ 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,
dbLeader: dbLeader
@@ -221,16 +249,34 @@ export class ClusterController {
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',
+ !hasActiveLease && 'NO_ACTIVE_LEASE',
+ lease?.isExpired && 'LEASE_EXPIRED',
+ hasActiveLease && !dbLeader && 'LEASE_WITHOUT_DB_ENTRY',
+ !hasActiveLease && dbLeader && 'DB_ENTRY_WITHOUT_LEASE',
lockStatus.isLeader &&
+ lease &&
+ lockStatus.generation !== lease.generation &&
+ 'LOCAL_LEASE_STALE',
+ hasActiveLease &&
dbLeader &&
- dbLeader.instanceId !== lockStatus.instanceId &&
- 'LOCK_DB_MISMATCH',
+ dbLeader.instanceId !== lease?.ownerNodeId &&
+ 'LEASE_DB_MISMATCH',
].filter(Boolean),
};
}
+ /**
+ * 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(),
+ };
+ }
+
/**
* Manually trigger cleanup of stale nodes
*/
diff --git a/src/distributed-lock/distributed-lock.service.ts b/src/distributed-lock/distributed-lock.service.ts
index 53b2f19..0e7528b 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: NodeJS.Timeout | 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();
+ private heldLeases = new Map();
constructor(private prisma: PrismaService) {
// Generate unique instance ID for this container
@@ -17,6 +66,10 @@ 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
@@ -138,11 +191,7 @@ export class DistributedLockService {
* Returns true if we are holding the leader lock
*/
async isLeader(): Promise {
- 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 +200,10 @@ export class DistributedLockService {
* Does NOT wait or retry
*/
async tryAcquireLeaderLock(): Promise {
- const lockName = 'cluster:leader';
- return this.tryAcquireLock(lockName, 0);
+ const lease = await this.acquireLease(LEADER_LEASE_NAME, {
+ autoRenew: true,
+ });
+ return lease !== null;
}
/**
@@ -161,12 +212,14 @@ export class DistributedLockService {
* CRITICAL: Uses PostgreSQL advisory lock for true distributed locking
*/
async acquireLeaderLock(): Promise {
- 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 +230,269 @@ export class DistributedLockService {
* Release leader lock
*/
async releaseLeaderLock(): Promise {
- await this.releaseLock('cluster:leader');
+ await this.releaseLease(LEADER_LEASE_NAME);
+ }
+
+ async acquireLease(
+ leaseName: string,
+ options: AcquireLeaseOptions = {},
+ ): Promise {
+ const ttlSeconds = this.resolveLeaseTtlSeconds(options.ttlSeconds);
+
+ try {
+ const result = await this.prisma.$queryRaw`
+ 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 = {},
+ ): Promise {
+ 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`
+ 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 {
+ 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`
+ 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 {
+ try {
+ const lease = await this.prisma.clusterLease.findUnique({
+ where: { leaseName },
+ });
+
+ 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 {
+ return this.getLeaseSnapshot(LEADER_LEASE_NAME);
+ }
+
+ async validateLeaseFenceToken(
+ leaseName: string,
+ expectedGeneration: number,
+ ownerNodeId: string = this.instanceId,
+ ): Promise {
+ const lease = await this.getLeaseSnapshot(leaseName);
+ return Boolean(
+ lease &&
+ !lease.isExpired &&
+ lease.ownerNodeId === ownerNodeId &&
+ lease.generation === expectedGeneration,
+ );
+ }
+
+ async validateLeaderFenceToken(
+ expectedGeneration: number,
+ ownerNodeId?: string,
+ ): Promise {
+ return this.validateLeaseFenceToken(
+ LEADER_LEASE_NAME,
+ expectedGeneration,
+ ownerNodeId,
+ );
}
/**
@@ -186,13 +501,18 @@ export class DistributedLockService {
*/
async releaseAllLocks(): Promise {
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 +557,137 @@ 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;
}
}
From 73879bee66636af7f66ecdf458e3852fdf49a3c3 Mon Sep 17 00:00:00 2001
From: Stualyttle <27898452+Stualyttle@users.noreply.github.com.>
Date: Sun, 24 May 2026 18:10:41 +0200
Subject: [PATCH 13/43] feat: implement lease-backed heartbeat and leader
reconciliation
---
ARCHITECTURE_DECISIONS.md | 29 ++
IMPLEMENTATION_STATUS.md | 36 +-
README.md | 13 +-
.../cluster-heartbeat.service.ts | 471 +++++++++---------
src/distributed-lock/cluster.controller.ts | 69 +--
.../distributed-lock.service.ts | 50 +-
test/session11/lease-backed-heartbeat.test.js | 428 ++++++++++++++++
7 files changed, 785 insertions(+), 311 deletions(-)
create mode 100644 test/session11/lease-backed-heartbeat.test.js
diff --git a/ARCHITECTURE_DECISIONS.md b/ARCHITECTURE_DECISIONS.md
index 6e906bd..e23a94b 100644
--- a/ARCHITECTURE_DECISIONS.md
+++ b/ARCHITECTURE_DECISIONS.md
@@ -29,6 +29,7 @@ This file records repository-level architectural and delivery decisions so futur
| ADR-011 | Explicit RBAC policies with a global authorization guard | accepted | Session 8 | 2026-05-24 |
| ADR-012 | Durable audit events for privileged and mutating operations | accepted | Session 9 | 2026-05-24 |
| ADR-013 | Lease-based leader coordination with generation fencing tokens | accepted | Session 10 | 2026-05-24 |
+| ADR-014 | Lease-backed heartbeat and leader reconciliation | accepted | Session 11 | 2026-05-24 |
---
@@ -470,3 +471,31 @@ Adopt a database-backed lease primitive for leader coordination:
- future cluster-wide writes can carry the leader generation and reject stale actors using the fencing token
- the codebase remains in a transitional hybrid state until Session 11 finishes moving heartbeat and leader reconciliation fully onto the lease model
+---
+
+## ADR-014 — Lease-backed heartbeat and leader reconciliation
+
+- Status: accepted
+- Session: Session 11 — Move heartbeat and leader flows onto leases
+- Date: 2026-05-24
+
+### Context
+
+Session 10 introduced durable `ClusterLease` records and generation-based fencing tokens, but the heartbeat service still treated `ClusterNode.isLeader` as an operational source of truth. That left split-brain recovery logic dependent on stale DB flags and heartbeat recency rather than the current leader lease.
+
+### Decision
+
+Adopt the leader lease as the authoritative source of truth for cluster leadership:
+
+1. derive leader reads, cluster stats, and node leader annotations from the active `cluster:leader` lease
+2. treat `ClusterNode.isLeader` as a denormalized observability field only, reconciled from the lease rather than used for leader election
+3. when the lease owner is missing or stale, clear denormalized leader flags and wait for lease expiry instead of force-electing a replacement from heartbeat recency
+4. simplify admin repair flows so “enforce leader” means reconciling DB flags to the active lease, not choosing a winner from multiple DB leaders
+
+### Consequences
+
+- leadership and membership diagnostics now stay aligned with durable lease ownership instead of transient DB flag drift
+- stale-node cleanup no longer performs risky split-brain arbitration; it marks nodes stale and lets lease expiry govern failover timing
+- operator-facing leader status can explicitly report lease-owner-missing or lease-owner-not-active states, which were previously hidden behind generic “multiple leaders” logic
+- later sessions can layer cluster operations and per-node ACKs onto a cleaner lease-backed control-plane model without preserving the old DB-leader election semantics
+
diff --git a/IMPLEMENTATION_STATUS.md b/IMPLEMENTATION_STATUS.md
index 46d9ad8..593c3f0 100644
--- a/IMPLEMENTATION_STATUS.md
+++ b/IMPLEMENTATION_STATUS.md
@@ -9,8 +9,8 @@ Use it as the single place to record what has shipped, what is in progress, and
- Overall status: in progress
- Current phase: Phase 3 — Cluster coordination redesign
-- Most recently completed session: Session 10 — Add lease-based coordination primitives
-- Next recommended session from the roadmap: Session 11 — Move heartbeat and leader flows onto leases
+- Most recently completed session: Session 11 — Move heartbeat and leader flows onto leases
+- Next recommended session from the roadmap: Session 12 — Add cluster operations and per-node ACK tracking
- Readiness reference: `PRODUCTION_READINESS_ASSESSMENT.md`
- Architecture decision log: `ARCHITECTURE_DECISIONS.md`
@@ -392,13 +392,33 @@ Use it as the single place to record what has shipped, what is in progress, and
## Session 11 — Move heartbeat and leader flows onto leases
-- Status: not started
+- Status: done
- Objective: align membership and leadership around the new lease model
-- Files touched: none yet
-- Tests added/updated: none yet
-- Risks: split-brain recovery logic remains fragile until lease-backed
-- Follow-up sessions: Session 22, Session 27
-- Notes: should simplify stale-node handling
+- Files touched:
+ - `src/distributed-lock/distributed-lock.service.ts`
+ - `src/distributed-lock/cluster-heartbeat.service.ts`
+ - `src/distributed-lock/cluster.controller.ts`
+ - `test/session11/lease-backed-heartbeat.test.js`
+ - `README.md`
+ - `ARCHITECTURE_DECISIONS.md`
+ - `IMPLEMENTATION_STATUS.md`
+- Tests added/updated:
+ - added `test/session11/lease-backed-heartbeat.test.js` to verify lease-derived leader reads, stale lease-owner handling, lease-only leader election, and stale-node cleanup behavior
+ - re-ran the focused Session 11 regression suite and the repository test suite so Sessions 3-11 continue to pass together after the lease-backed refactor
+ - validated the edited cluster coordination files with `npm run typecheck` and `npm run build` after removing the remaining generated-client dependency from lease snapshot reads
+- Risks:
+ - `ClusterNode.isLeader` is still retained as a denormalized observability field for compatibility, so later sessions should continue reducing reliance on legacy node flags in any new APIs
+ - cluster-wide mutating workflows still do not persist operation IDs or per-node ACKs; Session 12 remains responsible for making lease ownership actionable across async workflows
+ - inter-node transport is still authenticated HTTP rather than mTLS, so this session improves coordination correctness but not network trust boundaries
+- Follow-up sessions:
+ - Session 12 — add cluster operations and per-node ACK tracking
+ - Session 22 — add cluster operation and node-status admin APIs
+ - Session 24 — replace ad hoc logging with structured operational and audit logging
+ - Session 27 — add chaos and fault-injection validation
+- Notes:
+ - heartbeat updates, leader reads, and cluster stats now derive leadership from the active leader lease instead of choosing a leader from `ClusterNode.isLeader` flags
+ - stale-node cleanup now marks nodes stale and clears denormalized leader flags, but it waits for lease expiry instead of force-electing a replacement from heartbeat recency
+ - the cluster leader status endpoint now surfaces lease-owner-missing and lease-owner-not-active states explicitly so leadership drift is diagnosable without relying on split-brain heuristics
## Session 12 — Add cluster operations and per-node ACK tracking
diff --git a/README.md b/README.md
index a0b036f..9a3c00b 100644
--- a/README.md
+++ b/README.md
@@ -11,6 +11,9 @@
+
+
+
@@ -24,9 +27,9 @@ Built with [NestJS](https://nestjs.com/) • Powered by [PostgreSQL](https://www
## 📍 Current Delivery Status
-- **Roadmap status:** Phase 2 in progress
-- **Completed in Sessions 1-9 plus follow-up maintenance:** delivery scaffolding, dependency hygiene, authenticated-by-default admin APIs, dependency-aware health semantics, fail-fast container supervision, explicit inter-node control-plane addressing, an identity-aware auth foundation, explicit RBAC authorization policies, and durable audit logging for privileged and mutating operations
-- **Next recommended implementation session:** Session 10 — add lease-based coordination primitives
+- **Roadmap status:** Phase 3 in progress
+- **Completed in Sessions 1-11 plus follow-up maintenance:** delivery scaffolding, dependency hygiene, authenticated-by-default admin APIs, dependency-aware health semantics, fail-fast container supervision, explicit inter-node control-plane addressing, an identity-aware auth foundation, explicit RBAC authorization policies, durable audit logging for privileged and mutating operations, durable leader leases, and lease-backed heartbeat/leader reconciliation
+- **Next recommended implementation session:** Session 12 — add cluster operations and per-node ACK tracking
- **Canonical planning and status docs:**
- [`PRODUCTION_READINESS_ASSESSMENT.md`](PRODUCTION_READINESS_ASSESSMENT.md)
- [`IMPLEMENTATION_PLAN_BY_SESSION.md`](IMPLEMENTATION_PLAN_BY_SESSION.md)
@@ -59,7 +62,7 @@ node -v # expected: v24.16.0
npm -v # expected: 11.15.0
```
-`npm run test` now runs the focused Session 3-9 regression tests for API access control, health semantics, container-supervision behavior, inter-node addressing, the identity-aware auth foundation, RBAC authorization policy enforcement, and audit-logging regressions. Session 26 still remains the planned milestone for broad unit/integration/e2e harness expansion.
+`npm run test` now runs the focused Session 3-11 regression tests for API access control, health semantics, container-supervision behavior, inter-node addressing, the identity-aware auth foundation, RBAC authorization policy enforcement, audit-logging regressions, and lease-backed cluster coordination behavior. Session 26 still remains the planned milestone for broad unit/integration/e2e harness expansion.
---
@@ -105,7 +108,7 @@ npm -v # expected: 11.15.0
- **Leader Election** - Automatic leader election with fail-over and lease renewal
- **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
+- **Lease-Backed Leadership Reconciliation** - Heartbeats and leader diagnostics derive authority from the active lease instead of stale DB flags
- **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
diff --git a/src/distributed-lock/cluster-heartbeat.service.ts b/src/distributed-lock/cluster-heartbeat.service.ts
index f647037..589706e 100644
--- a/src/distributed-lock/cluster-heartbeat.service.ts
+++ b/src/distributed-lock/cluster-heartbeat.service.ts
@@ -9,6 +9,32 @@ import { DistributedLockService } from './distributed-lock.service';
import * as os from 'os';
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
+>;
+
+type LeaderLeaseState = {
+ lease: NonNullable | null;
+ hasActiveLease: boolean;
+ ownerNode: ClusterNodeRecord | null;
+ activeLeaderNode: ClusterNodeRecord | null;
+ issues: string[];
+};
+
/**
* Service to track cluster nodes and their health status
* Helps with monitoring and debugging in distributed deployments
@@ -16,9 +42,9 @@ import { getLocalControlPlaneRegistration } 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 | null = null;
+ private cleanupInterval: ReturnType | null = null;
+ private leaderCheckInterval: ReturnType | 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
@@ -195,30 +221,10 @@ export class ClusterHeartbeatService implements OnModuleInit, OnModuleDestroy {
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 },
@@ -241,6 +247,10 @@ export class ClusterHeartbeatService implements OnModuleInit, OnModuleDestroy {
},
});
+ if (finalLeaderStatus || leaderState.issues.length > 0) {
+ await this.enforceOneLeader();
+ }
+
this.logger.debug(`[Heartbeat] Sent (Leader: ${finalLeaderStatus})`);
} catch (error) {
this.logger.error(
@@ -314,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.`,
);
}
@@ -326,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
@@ -365,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.
+ * Session 11 deliberately stops picking a leader from 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) {
@@ -415,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 {
@@ -485,175 +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) {
- const leaseStatus = this.distributedLock.getLeaderLockStatus();
+ const currentLease = await this.getLeaderLeaseState();
+ await this.enforceOneLeader();
- // WE WON THE ELECTION!
this.logger.log(
- `[LeaderCheck] ✓ This node successfully became the LEADER (lease generation ${leaseStatus.generation ?? 'unknown'})`,
+ `[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',
);
- // Lease reconciliation: a peer may hold the leader lease before the DB
- // leader flag has been updated. Reconcile from lease state instead of
- // relying on advisory-lock-specific recovery.
- setTimeout(async () => {
- try {
- const leaderCheck = await this.prisma.clusterNode.findFirst({
- where: { isLeader: true, status: 'active' },
- });
-
- if (!leaderCheck) {
- const lease = await this.distributedLock.getLeaderLeaseSnapshot();
-
- if (!lease || lease.isExpired || !lease.ownerNodeId) {
- this.logger.warn(
- '[LeaderCheck] No active leader lease is present; retrying election',
- );
-
- await this.ensureLeaderExists();
- } else {
- const leaseOwner = await this.prisma.clusterNode.findUnique({
- where: { instanceId: lease.ownerNodeId },
- });
-
- if (leaseOwner && leaseOwner.status === 'active') {
- await this.prisma.clusterNode.update({
- where: { instanceId: lease.ownerNodeId },
- data: { isLeader: true },
- });
-
- this.logger.warn(
- `[LeaderCheck] Reconciled DB leader state from active lease owner ${leaseOwner.hostname} (generation ${lease.generation})`,
- );
- } else {
- this.logger.warn(
- `[LeaderCheck] Active leader lease belongs to unknown or inactive owner ${lease.ownerNodeId}; waiting for lease expiry before retry`,
- );
- }
- }
- } 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)}`,
@@ -668,25 +581,29 @@ export class ClusterHeartbeatService implements OnModuleInit, OnModuleDestroy {
async tryBecomeLeader(): Promise {
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 {
@@ -720,7 +637,63 @@ 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 {
+ 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(
diff --git a/src/distributed-lock/cluster.controller.ts b/src/distributed-lock/cluster.controller.ts
index fc75b66..55c5e8c 100644
--- a/src/distributed-lock/cluster.controller.ts
+++ b/src/distributed-lock/cluster.controller.ts
@@ -181,29 +181,39 @@ 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 lease = await this.distributedLock.getLeaderLeaseSnapshot();
- const allLeaders = await this.clusterHeartbeat
- .getClusterStats()
- .then((stats) => stats.leaders);
+ const lease = leaderState.lease;
+ const allLeaders = dbLeader ? [dbLeader] : [];
- const hasActiveLease = Boolean(
- lease && !lease.isExpired && lease.ownerNodeId,
- );
+ 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 &&
- hasActiveLease &&
- dbMatchesLease &&
- (!lockStatus.isLeader ||
- (lockStatus.ownerNodeId === lease?.ownerNodeId &&
- lockStatus.generation === lease?.generation));
+ issues.length === 0 && hasActiveLease && dbMatchesLease;
return {
- status: isConsistent ? 'healthy' : 'inconsistent',
+ status: isConsistent
+ ? 'healthy'
+ : hasActiveLease
+ ? 'degraded'
+ : 'no-leader',
lease: lease
? {
leaseName: lease.leaseName,
@@ -228,6 +238,18 @@ export class ClusterController {
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
? {
hostname: dbLeader.hostname,
@@ -246,22 +268,7 @@ export class ClusterController {
status: l.status,
lastHeartbeat: l.lastHeartbeat,
})),
- issues: [
- allLeaders.length === 0 && 'NO_LEADER',
- allLeaders.length > 1 && 'MULTIPLE_LEADERS',
- !hasActiveLease && 'NO_ACTIVE_LEASE',
- lease?.isExpired && 'LEASE_EXPIRED',
- hasActiveLease && !dbLeader && 'LEASE_WITHOUT_DB_ENTRY',
- !hasActiveLease && dbLeader && 'DB_ENTRY_WITHOUT_LEASE',
- lockStatus.isLeader &&
- lease &&
- lockStatus.generation !== lease.generation &&
- 'LOCAL_LEASE_STALE',
- hasActiveLease &&
- dbLeader &&
- dbLeader.instanceId !== lease?.ownerNodeId &&
- 'LEASE_DB_MISMATCH',
- ].filter(Boolean),
+ issues,
};
}
diff --git a/src/distributed-lock/distributed-lock.service.ts b/src/distributed-lock/distributed-lock.service.ts
index 0e7528b..50a8b5e 100644
--- a/src/distributed-lock/distributed-lock.service.ts
+++ b/src/distributed-lock/distributed-lock.service.ts
@@ -46,7 +46,7 @@ type AcquireLeaseOptions = {
type HeldLease = LeaseSnapshot & {
autoRenew: boolean;
renewIntervalMs: number;
- renewalTimer: NodeJS.Timeout | null;
+ renewalTimer: ReturnType | null;
};
/**
@@ -74,10 +74,7 @@ export class DistributedLockService implements OnModuleDestroy {
* Acquire an advisory lock with timeout
* Returns true if lock was acquired, false otherwise
*/
- async tryAcquireLock(
- lockName: string,
- timeoutMs: number = 5000,
- ): Promise {
+ async tryAcquireLock(lockName: string): Promise {
const lockId = this.stringToLockId(lockName);
const startTime = Date.now();
@@ -160,7 +157,7 @@ export class DistributedLockService implements OnModuleDestroy {
let retries = 0;
while (retries < maxRetries) {
- const acquired = await this.tryAcquireLock(lockName, timeoutMs);
+ const acquired = await this.tryAcquireLock(lockName);
if (acquired) {
try {
@@ -453,9 +450,26 @@ export class DistributedLockService implements OnModuleDestroy {
async getLeaseSnapshot(leaseName: string): Promise {
try {
- const lease = await this.prisma.clusterLease.findUnique({
- where: { leaseName },
- });
+ const result = await this.prisma.$queryRaw`
+ 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) {
@@ -476,11 +490,14 @@ export class DistributedLockService implements OnModuleDestroy {
ownerNodeId: string = this.instanceId,
): Promise {
const lease = await this.getLeaseSnapshot(leaseName);
- return Boolean(
- lease &&
- !lease.isExpired &&
- lease.ownerNodeId === ownerNodeId &&
- lease.generation === expectedGeneration,
+ if (!lease) {
+ return false;
+ }
+
+ return (
+ !lease.isExpired &&
+ lease.ownerNodeId === ownerNodeId &&
+ lease.generation === expectedGeneration
);
}
@@ -603,10 +620,7 @@ export class DistributedLockService implements OnModuleDestroy {
return configured;
}
- return Math.max(
- MIN_RENEW_INTERVAL_MS,
- Math.floor((ttlSeconds * 1000) / 3),
- );
+ return Math.max(MIN_RENEW_INTERVAL_MS, Math.floor((ttlSeconds * 1000) / 3));
}
private toLeaseSnapshot(lease: ClusterLeaseRecord): LeaseSnapshot {
diff --git a/test/session11/lease-backed-heartbeat.test.js b/test/session11/lease-backed-heartbeat.test.js
new file mode 100644
index 0000000..fe32323
--- /dev/null
+++ b/test/session11/lease-backed-heartbeat.test.js
@@ -0,0 +1,428 @@
+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('Session 11 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();
+
+ assert.equal(activeNodes[0].instanceId, 'node-a');
+ assert.equal(activeNodes[0].isLeader, true);
+ assert.equal(activeNodes[1].instanceId, 'node-b');
+ assert.equal(activeNodes[1].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']);
+ });
+});
+
From 55b66b75f99efb05515483459fd3505928ee55b2 Mon Sep 17 00:00:00 2001
From: Stualyttle <27898452+Stualyttle@users.noreply.github.com.>
Date: Sun, 24 May 2026 18:24:07 +0200
Subject: [PATCH 14/43] feat: implement durable cluster operation journal with
per-node acknowledgements
---
ARCHITECTURE_DECISIONS.md | 30 +
IMPLEMENTATION_STATUS.md | 45 +-
README.md | 17 +
.../migration.sql | 57 ++
prisma/schema.prisma | 50 ++
src/certificate/certificate.controller.ts | 23 +-
src/certificate/certificate.service.ts | 106 +--
.../cluster-operations.service.ts | 637 ++++++++++++++++++
src/distributed-lock/cluster.controller.ts | 162 ++---
.../distributed-lock.module.ts | 13 +-
test/session12/cluster-operations.test.js | 341 ++++++++++
test/session6/inter-node-addressing.test.js | 11 +-
test/session8/rbac-authorization.test.js | 27 +
test/session9/audit-logging.test.js | 33 +
14 files changed, 1377 insertions(+), 175 deletions(-)
create mode 100644 prisma/migrations/20260524182000_session12_cluster_operations/migration.sql
create mode 100644 src/distributed-lock/cluster-operations.service.ts
create mode 100644 test/session12/cluster-operations.test.js
diff --git a/ARCHITECTURE_DECISIONS.md b/ARCHITECTURE_DECISIONS.md
index e23a94b..16bc32c 100644
--- a/ARCHITECTURE_DECISIONS.md
+++ b/ARCHITECTURE_DECISIONS.md
@@ -30,6 +30,7 @@ This file records repository-level architectural and delivery decisions so futur
| ADR-012 | Durable audit events for privileged and mutating operations | accepted | Session 9 | 2026-05-24 |
| ADR-013 | Lease-based leader coordination with generation fencing tokens | accepted | Session 10 | 2026-05-24 |
| ADR-014 | Lease-backed heartbeat and leader reconciliation | accepted | Session 11 | 2026-05-24 |
+| ADR-015 | Durable cluster operation journal with per-node acknowledgements | accepted | Session 12 | 2026-05-24 |
---
@@ -499,3 +500,32 @@ Adopt the leader lease as the authoritative source of truth for cluster leadersh
- operator-facing leader status can explicitly report lease-owner-missing or lease-owner-not-active states, which were previously hidden behind generic “multiple leaders” logic
- later sessions can layer cluster operations and per-node ACKs onto a cleaner lease-backed control-plane model without preserving the old DB-leader election semantics
+---
+
+## ADR-015 — Durable cluster operation journal with per-node acknowledgements
+
+- Status: accepted
+- Session: Session 12 — Add cluster operations and per-node ACK tracking
+- Date: 2026-05-24
+
+### Context
+
+Sessions 10 and 11 established lease-backed leader coordination, but cluster-wide mutations still returned local-only success. That meant operators could not tell whether a reload or sync had actually converged across the cluster, and later certificate/config rollout work had no durable operation record to build on.
+
+### Decision
+
+Represent cluster-wide mutations as durable operations with explicit per-node acknowledgement state:
+
+1. add new Prisma-backed `ClusterOperation` and `ClusterOperationAck` tables to persist operation intent, status, and per-node outcomes
+2. introduce a `ClusterOperationsService` that creates operation records before execution, seeds ACK rows for all targeted nodes, runs local work, and issues authenticated peer calls to collect ACKs
+3. change cluster-wide reload requests to return `202 Accepted` with an operation ID instead of a best-effort synchronous broadcast result
+4. expose operation inspection endpoints so operators can query current status and per-node ACK details
+5. route cluster-triggered certificate sync broadcasts through the same operation journal so later certificate activation work can reuse a consistent contract
+
+### Consequences
+
+- cluster-wide mutations now have durable, queryable status instead of collapsing into local-node success responses
+- operators can inspect which nodes succeeded or failed for a given operation before later Session 22 API expansion work lands
+- future sessions can layer desired-state versions, certificate activation ACK policies, and richer operational metrics onto the shared operation journal instead of inventing separate tracking paths
+- operation execution is still initiated in-process on the requesting node, so restart-resume durability and stronger internal transport guarantees remain future work
+
diff --git a/IMPLEMENTATION_STATUS.md b/IMPLEMENTATION_STATUS.md
index 593c3f0..f01667f 100644
--- a/IMPLEMENTATION_STATUS.md
+++ b/IMPLEMENTATION_STATUS.md
@@ -9,8 +9,8 @@ Use it as the single place to record what has shipped, what is in progress, and
- Overall status: in progress
- Current phase: Phase 3 — Cluster coordination redesign
-- Most recently completed session: Session 11 — Move heartbeat and leader flows onto leases
-- Next recommended session from the roadmap: Session 12 — Add cluster operations and per-node ACK tracking
+- Most recently completed session: Session 12 — Add cluster operations and per-node ACK tracking
+- Next recommended session from the roadmap: Session 13 — Implement staged NGINX config generation and atomic activation
- Readiness reference: `PRODUCTION_READINESS_ASSESSMENT.md`
- Architecture decision log: `ARCHITECTURE_DECISIONS.md`
@@ -422,13 +422,42 @@ Use it as the single place to record what has shipped, what is in progress, and
## Session 12 — Add cluster operations and per-node ACK tracking
-- Status: not started
+- Status: done
- Objective: represent cluster-wide mutations as tracked async operations
-- Files touched: none yet
-- Tests added/updated: none yet
-- Risks: cluster-wide actions still return local-only success today
-- Follow-up sessions: Session 17, Session 22, Session 25
-- Notes: operation IDs and node ACK state remain open
+- Files touched:
+ - `prisma/schema.prisma`
+ - `prisma/migrations/20260524182000_session12_cluster_operations/migration.sql`
+ - `src/distributed-lock/cluster-operations.service.ts`
+ - `src/distributed-lock/cluster.controller.ts`
+ - `src/distributed-lock/distributed-lock.module.ts`
+ - `src/certificate/certificate.controller.ts`
+ - `src/certificate/certificate.service.ts`
+ - `README.md`
+ - `ARCHITECTURE_DECISIONS.md`
+ - `IMPLEMENTATION_STATUS.md`
+ - `test/session6/inter-node-addressing.test.js`
+ - `test/session8/rbac-authorization.test.js`
+ - `test/session9/audit-logging.test.js`
+ - `test/session12/cluster-operations.test.js`
+- Tests added/updated:
+ - added `test/session12/cluster-operations.test.js` to verify successful cluster-wide operation tracking and remote ACK failure handling when peer auth is unavailable
+ - updated the Session 6 regression to assert peer URL construction now lives in the new cluster-operations layer instead of the controller directly
+ - updated Session 8 and Session 9 controller harnesses so `ClusterController` still instantiates with the new cluster-operations dependency
+ - re-ran `npm test`, `npm run typecheck`, and `npm run build` after regenerating the Prisma client for the new operation models
+- Risks:
+ - cluster operations are now durable and queryable, but execution is still process-local in-memory orchestration, so restart-resume semantics remain future work
+ - remote node ACKs still rely on authenticated HTTP responses rather than mTLS-backed service identity or a separate ACK callback channel
+ - only the current reload and certificate-sync workflows use the operation journal so far; additional cluster-wide mutations still need to adopt the same contract in later sessions
+- Follow-up sessions:
+ - Session 13 — implement staged NGINX config generation and atomic activation
+ - Session 17 — rework cluster certificate distribution and activation
+ - Session 22 — add cluster operations and node-status admin APIs
+ - Session 25 — expand metrics and alerting
+- Notes:
+ - added durable `ClusterOperation` and `ClusterOperationAck` tables so cluster-wide mutations can be tracked independently from lease state
+ - introduced `ClusterOperationsService` to create operation records, pre-register per-node ACK rows, execute local and remote work, and summarize final success/partial-failure state
+ - changed `POST /cluster/reload` to return `202 Accepted` with an operation ID and added `GET /cluster/operations` plus `GET /cluster/operations/:operationId` for inspection
+ - updated cluster-triggered certificate sync broadcasts to use the same operation journal, and the per-node sync endpoint now returns HTTP 500 for tracked failures so remote ACK state is accurate
---
diff --git a/README.md b/README.md
index 9a3c00b..1c72910 100644
--- a/README.md
+++ b/README.md
@@ -775,6 +775,7 @@ docker stack deploy -c docker-compose.swarm.yml lyttlenginx
- global mode is the intended target architecture
- Session 6 replaces public-IP autodiscovery with explicit `CLUSTER_CONTROL_ADDRESS` / `CLUSTER_CONTROL_PORT` registration
- Session 10 replaces leader advisory-lock ownership with a durable `ClusterLease` record and generation-based fencing token
+- Session 12 adds durable `ClusterOperation` and `ClusterOperationAck` tracking so cluster-wide reload/sync flows return operation IDs and per-node convergence state
- Sessions 7-8 add request identity plus explicit RBAC, but internal-node traffic is still plain HTTP and mTLS remains future work
- the current assessment still identifies remaining P0/P1 blockers in full lease-backed heartbeat reconciliation, cluster ACK tracking, certificate lifecycle hardening, internal-traffic security, and transactional config rollout even after the completed health, auto-recovery, inter-node addressing, auth/RBAC, audit logging, and lease-foundation sessions
- use the swarm manifest for evaluation and development feedback, not as a final production deployment contract yet
@@ -794,6 +795,13 @@ Leader election now uses a durable database lease:
- `CLUSTER_LEASE_RENEW_INTERVAL_MS` controls how frequently the local leader renews that lease (default: one third of the TTL, minimum `1000ms`)
- the active lease carries a monotonically increasing generation number that acts as the leader fencing token for later cluster operations
+Cluster-wide mutations now use a durable operation journal:
+
+- `POST /cluster/reload` now returns `202 Accepted` with an operation ID instead of only reporting local-node success
+- `GET /cluster/operations` returns recent cluster operations and summary counts
+- `GET /cluster/operations/:operationId` returns per-node acknowledgement state for a specific operation
+- internal certificate-sync broadcasts also use the same operation journal so later certificate activation work can build on a shared ACK model
+
If your Swarm nodes are not mutually reachable by node hostname, override `CLUSTER_CONTROL_ADDRESS` with a routable internal DNS name or address per node before treating cluster operations as healthy.
**View cluster status:**
@@ -805,6 +813,15 @@ curl -H "Authorization: Bearer $JWT" http://localhost:3003/cluster/nodes
# Inspect the current leader lease and fencing token
curl -H "Authorization: Bearer $JWT" http://localhost:3003/cluster/lease
+# Queue a cluster-wide reload and capture the returned operation id
+curl -X POST -H "Authorization: Bearer $JWT" http://localhost:3003/cluster/reload
+
+# Inspect recent cluster operations
+curl -H "Authorization: Bearer $JWT" http://localhost:3003/cluster/operations
+
+# Inspect one operation and its per-node acknowledgements
+curl -H "Authorization: Bearer $JWT" http://localhost:3003/cluster/operations/
+
# View leader
docker service logs lyttlenginx_lyttlenginx 2>&1 | grep "LEADER"
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/schema.prisma b/prisma/schema.prisma
index cff42be..c199a65 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -80,6 +80,56 @@ model ClusterLease {
@@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)
diff --git a/src/certificate/certificate.controller.ts b/src/certificate/certificate.controller.ts
index e239f44..bd23a95 100644
--- a/src/certificate/certificate.controller.ts
+++ b/src/certificate/certificate.controller.ts
@@ -8,8 +8,11 @@ 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';
@@ -151,7 +154,23 @@ export class CertificateController {
@HttpCode(HttpStatus.OK)
@AuthorizeInternalNodeOrAdmin('platform-admin')
@Audit({ action: 'certificate.sync' })
- async syncCertificates() {
- return this.certificateService.syncCertificates();
+ 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.service.ts b/src/certificate/certificate.service.ts
index e0553f3..30b742e 100644
--- a/src/certificate/certificate.service.ts
+++ b/src/certificate/certificate.service.ts
@@ -14,8 +14,8 @@ import * as fs from 'fs';
import * as path from 'path';
import { addDays } from 'date-fns';
import { hashDomains, joinDomains, parseDomains } from '../utils/domain-utils';
-import { buildClusterNodeUrl } from '../utils/network-utils';
import { AlertService } from '../alert/alert.service';
+import { ClusterOperationsService } from '../distributed-lock/cluster-operations.service';
import { DistributedLockService } from '../distributed-lock/distributed-lock.service';
import { HealthService } from '../health/health.service';
@@ -43,6 +43,7 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown {
constructor(
private prisma: PrismaService,
private alertService: AlertService,
+ private clusterOperations: ClusterOperationsService,
private distributedLock: DistributedLockService,
private healthService: HealthService,
@Inject(
@@ -1707,91 +1708,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);
-
- 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...`,
- );
-
- const apiKey = process.env.API_KEY?.split(',')[0]?.trim();
- if (!apiKey) {
- this.logger.warn(
- '[Reload] Skipping remote certificate sync because no API key is configured for authenticated peer requests',
- );
- this.syncCertificates().catch((err) =>
- this.logger.error(`[Reload] Local sync failed: ${err.message}`),
- );
- return;
- }
-
- // Trigger sync on other nodes using the dedicated sync endpoint
- // Fire and forget - don't wait for responses
- await Promise.allSettled(
- otherNodes.map(async (node) => {
- const url = buildClusterNodeUrl(node, '/certificates/sync');
+ 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 (!url) {
- this.logger.warn(
- `[Reload] Skipping ${node.hostname} (${node.instanceId}) because it does not have a valid registered control-plane endpoint`,
+ if (!result.success) {
+ const errorSummary = result.errors
+ .map((entry) => `${entry.domain}: ${entry.error}`)
+ .join('; ');
+ throw new Error(
+ errorSummary || 'Certificate synchronization failed',
);
- return;
}
- try {
- const controller = new AbortController();
- const timeoutId = setTimeout(() => controller.abort(), 5000);
-
- const response = await (async () => {
- try {
- return await fetch(url, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- 'X-API-Key': apiKey,
- },
- signal: controller.signal,
- });
- } finally {
- clearTimeout(timeoutId);
- }
- })();
-
- if (response.ok) {
- const result = await response.json();
- this.logger.debug(
- `[Reload] Synced ${result.syncedCount || 0} cert(s) on ${node.hostname} (${node.instanceId})`,
- );
- } 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)}`,
- );
- }
- }),
- );
+ return result;
+ },
+ });
- // Also trigger immediate sync on this node
- this.syncCertificates().catch((err) =>
- this.logger.error(`[Reload] Local sync failed: ${err.message}`),
+ this.logger.log(
+ `[Reload] Queued certificate sync cluster operation ${operation.operationId}`,
);
} catch (error) {
this.logger.warn(
diff --git a/src/distributed-lock/cluster-operations.service.ts b/src/distributed-lock/cluster-operations.service.ts
new file mode 100644
index 0000000..72bda34
--- /dev/null
+++ b/src/distributed-lock/cluster-operations.service.ts
@@ -0,0 +1,637 @@
+import { Injectable, Logger } from '@nestjs/common';
+import * as os from 'node:os';
+import { PrismaService } from '../prisma/prisma.service';
+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';
+
+type ClusterNodeTarget = Awaited<
+ ReturnType
+>[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 OperationTarget = {
+ instanceId: string;
+ hostname: string;
+ ipAddress: string | null;
+ metadata: unknown;
+};
+
+type OperationExecutionContext = {
+ auth?: AuthIdentity;
+ correlationId?: string;
+ requestPath?: string;
+};
+
+type StartClusterOperationOptions = {
+ operationType: string;
+ remotePath: string;
+ remoteMethod?: 'POST';
+ remoteQuery?: Record;
+ broadcast?: boolean;
+ executionTimeoutMs?: number;
+ localAction: (operationId: string) => Promise;
+ initiatedBy?: OperationExecutionContext;
+ metadata?: Record;
+};
+
+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;
+};
+
+@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(
+ options: StartClusterOperationOptions,
+ ): Promise {
+ 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 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(limit = 20) {
+ const take = this.normalizeListLimit(limit);
+ const operations = await this.prisma.clusterOperation.findMany({
+ take,
+ orderBy: { createdAt: 'desc' },
+ });
+
+ return {
+ count: operations.length,
+ operations: operations.map((operation) => this.toSummary(operation)),
+ };
+ }
+
+ 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);
+ }
+
+ private async executeOperation(
+ operationId: string,
+ targets: OperationTarget[],
+ options: StartClusterOperationOptions,
+ ) {
+ 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(
+ operationId: string,
+ target: OperationTarget,
+ localAction: (operationId: string) => Promise,
+ ) {
+ await this.markAckStarted(operationId, target.instanceId);
+
+ try {
+ await localAction(operationId);
+ await this.markAckSucceeded(operationId, target.instanceId, 200);
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ await this.markAckFailed(operationId, target.instanceId, message);
+ }
+ }
+
+ private async runRemoteTarget(
+ operationId: string,
+ target: OperationTarget,
+ options: StartClusterOperationOptions,
+ 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;
+ }
+
+ await this.markAckSucceeded(
+ operationId,
+ target.instanceId,
+ response.status,
+ url,
+ );
+ } 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();
+
+ 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,
+ ) {
+ await this.prisma.clusterOperationAck.update({
+ where: {
+ operationId_nodeInstanceId: {
+ operationId,
+ nodeInstanceId,
+ },
+ },
+ data: {
+ status: 'succeeded',
+ responseStatus,
+ errorMessage: null,
+ ackedAt: new Date(),
+ ...(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;
+ 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 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}`,
+ };
+ }
+
+ private toSummary(operation: ClusterOperationRecord) {
+ 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}`,
+ };
+ }
+
+ 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 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 55c5e8c..9442a90 100644
--- a/src/distributed-lock/cluster.controller.ts
+++ b/src/distributed-lock/cluster.controller.ts
@@ -1,21 +1,31 @@
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 { AuthorizeAdmin } from '../auth/decorators/authorize.decorator';
+import {
+ AuthorizeAdmin,
+ AuthorizeInternalNodeOrAdmin,
+} from '../auth/decorators/authorize.decorator';
import { ApiKeyGuard } from '../auth/guards/api-key.guard';
import {
- buildClusterNodeUrl,
getClusterNodeControlPlaneEndpoint,
} from '../utils/network-utils';
import { Audit } from '../audit/decorators/audit.decorator';
+import { AuthenticatedRequest } from '../auth/interfaces/authenticated-request.interface';
@Controller('cluster')
@UseGuards(ApiKeyGuard)
@@ -25,6 +35,7 @@ export class ClusterController {
constructor(
private readonly clusterHeartbeat: ClusterHeartbeatService,
+ private readonly clusterOperations: ClusterOperationsService,
private readonly distributedLock: DistributedLockService,
private readonly reloader: ReloaderService,
) {}
@@ -33,92 +44,84 @@ export class ClusterController {
* Trigger a reload on this node and optionally broadcast to others
*/
@Post('reload')
- @AuthorizeAdmin('operator')
+ @AuthorizeInternalNodeOrAdmin('operator')
@Audit({ action: 'cluster.reload' })
- async reload(@Query('broadcast') broadcast: string) {
- const shouldBroadcast = broadcast !== 'false';
- const broadcastSummary = {
- requested: shouldBroadcast,
- attemptedNodes: 0,
- skippedNodes: [] as string[],
- authenticated: false,
- };
-
- this.logger.log(`[Reload] Triggered. Broadcast: ${shouldBroadcast}`);
-
- // Reload local
- const result = await this.reloader.reloadConfig();
-
- if (shouldBroadcast) {
- // Get other active nodes
- const nodes = await this.clusterHeartbeat.getActiveNodes();
- const thisInstanceId = this.distributedLock.getInstanceId();
-
- const otherNodes = nodes.filter((n) => n.instanceId !== thisInstanceId);
+ 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] Broadcasting to ${otherNodes.length} other nodes...`,
- );
+ if (!localResult.ok) {
+ response.status(HttpStatus.INTERNAL_SERVER_ERROR);
+ return {
+ operationId,
+ status: 'failed',
+ node: this.getLocalNodeInfo(),
+ error: localResult.error ?? 'Reload failed',
+ };
+ }
- // Get API key from environment for inter-node communication
- const apiKey = process.env.API_KEY?.split(',')[0]?.trim();
+ return {
+ operationId,
+ status: 'succeeded',
+ node: this.getLocalNodeInfo(),
+ };
+ }
- if (!apiKey) {
- this.logger.warn(
- '[Reload] Skipping inter-node broadcast because no API key is configured for authenticated peer requests',
- );
- broadcastSummary.skippedNodes = otherNodes.map(
- (node) => node.hostname ?? node.instanceId,
- );
- } else {
- broadcastSummary.authenticated = true;
+ const shouldBroadcast = broadcast !== 'false';
+ this.logger.log(`[Reload] Queued. Broadcast: ${shouldBroadcast}`);
- await Promise.allSettled(
- otherNodes.map(async (node) => {
- const url = buildClusterNodeUrl(node, '/cluster/reload', {
- broadcast: 'false',
- });
+ 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;
+ },
+ });
- if (!url) {
- const nodeLabel = node.hostname ?? node.instanceId;
- broadcastSummary.skippedNodes.push(nodeLabel);
- this.logger.warn(
- `[Reload] Skipping ${nodeLabel} because it does not have a valid registered control-plane endpoint`,
- );
- return;
- }
+ response.status(HttpStatus.ACCEPTED);
+ return operation;
+ }
- try {
- const controller = new AbortController();
- const timeoutId = setTimeout(() => controller.abort(), 5000);
+ @Get('operations')
+ async getOperations(@Query('limit') limit?: string) {
+ const parsedLimit = limit ? Number.parseInt(limit, 10) : undefined;
+ return this.clusterOperations.listOperations(parsedLimit);
+ }
- broadcastSummary.attemptedNodes += 1;
- this.logger.debug(`[Reload] Calling ${url}`);
+ @Get('operations/:operationId')
+ async getOperation(@Param('operationId') operationId: string) {
+ const operation = await this.clusterOperations.getOperation(operationId);
- try {
- await fetch(url, {
- method: 'POST',
- headers: {
- 'X-API-Key': apiKey,
- },
- signal: controller.signal,
- });
- } finally {
- clearTimeout(timeoutId);
- }
- } catch (error) {
- this.logger.error(
- `[Reload] Failed to broadcast to ${node.hostname} (${node.instanceId}): ${error instanceof Error ? error.message : String(error)}`,
- );
- }
- }),
- );
- }
+ if (!operation) {
+ return {
+ operationId,
+ found: false,
+ message: 'Cluster operation not found',
+ };
}
return {
- local: result,
- broadcast: broadcastSummary,
+ found: true,
+ ...operation,
};
}
@@ -357,4 +360,11 @@ export class ClusterController {
};
}
}
+
+ private getLocalNodeInfo() {
+ return {
+ instanceId: this.distributedLock.getInstanceId(),
+ hostname: os.hostname(),
+ };
+ }
}
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/test/session12/cluster-operations.test.js b/test/session12/cluster-operations.test.js
new file mode 100644
index 0000000..206fc4a
--- /dev/null
+++ b/test/session12/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('Session 12 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 = 'session12-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'], 'session12-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/session6/inter-node-addressing.test.js b/test/session6/inter-node-addressing.test.js
index b5ca5f7..d3ff721 100644
--- a/test/session6/inter-node-addressing.test.js
+++ b/test/session6/inter-node-addressing.test.js
@@ -103,8 +103,8 @@ describe('Session 6 source and manifest regressions', () => {
path.join(repoRoot, 'src/utils/network-utils.ts'),
'utf8',
);
- const clusterController = await fs.readFile(
- path.join(repoRoot, 'src/distributed-lock/cluster.controller.ts'),
+ const clusterOperationsService = await fs.readFile(
+ path.join(repoRoot, 'src/distributed-lock/cluster-operations.service.ts'),
'utf8',
);
const certificateService = await fs.readFile(
@@ -113,11 +113,10 @@ describe('Session 6 source and manifest regressions', () => {
);
assert.doesNotMatch(networkUtils, /ipify|icanhazip|ifconfig\.me/i);
- assert.match(clusterController, /buildClusterNodeUrl/);
- assert.match(certificateService, /buildClusterNodeUrl/);
- assert.doesNotMatch(clusterController, /process\.env\.PORT\s*\|\|\s*3000/);
+ assert.match(clusterOperationsService, /buildClusterNodeUrl/);
+ assert.doesNotMatch(clusterOperationsService, /process\.env\.PORT\s*\|\|\s*3000/);
assert.doesNotMatch(certificateService, /process\.env\.PORT\s*\|\|\s*3000/);
- assert.doesNotMatch(clusterController, /http:\/\/\$\{node\.ipAddress\}/);
+ assert.doesNotMatch(clusterOperationsService, /http:\/\/\$\{node\.ipAddress\}/);
assert.doesNotMatch(certificateService, /http:\/\/\$\{node\.ipAddress\}/);
});
diff --git a/test/session8/rbac-authorization.test.js b/test/session8/rbac-authorization.test.js
index 113a650..d9e350e 100644
--- a/test/session8/rbac-authorization.test.js
+++ b/test/session8/rbac-authorization.test.js
@@ -19,6 +19,7 @@ class CertificateService {}
class TlsConfigService {}
class CertificateBackupService {}
class ClusterHeartbeatService {}
+class ClusterOperationsService {}
class DistributedLockService {}
class ReloaderService {}
class LogsService {}
@@ -29,6 +30,7 @@ const moduleStubs = new Map([
['./certificate-backup.service', { CertificateBackupService }],
['./tls-config.service', { TlsConfigService }],
['./cluster-heartbeat.service', { ClusterHeartbeatService }],
+ ['./cluster-operations.service', { ClusterOperationsService }],
['./distributed-lock.service', { DistributedLockService }],
['./reloader/reloader.service', { ReloaderService }],
['../reloader/reloader.service', { ReloaderService }],
@@ -207,6 +209,27 @@ describe('Session 8 RBAC authorization policies', () => {
}),
};
+ 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 }),
};
@@ -259,6 +282,10 @@ describe('Session 8 RBAC authorization policies', () => {
provide: ClusterHeartbeatService,
useValue: clusterHeartbeatServiceMock,
},
+ {
+ provide: ClusterOperationsService,
+ useValue: clusterOperationsServiceMock,
+ },
{
provide: DistributedLockService,
useValue: distributedLockServiceMock,
diff --git a/test/session9/audit-logging.test.js b/test/session9/audit-logging.test.js
index 5b721a7..c0305e7 100644
--- a/test/session9/audit-logging.test.js
+++ b/test/session9/audit-logging.test.js
@@ -19,6 +19,7 @@ const { AuditService } = require('../../src/audit/audit.service');
class CertificateService {}
class ClusterHeartbeatService {}
+class ClusterOperationsService {}
class DistributedLockService {}
class ReloaderService {}
@@ -26,6 +27,7 @@ const originalLoad = Module._load;
const moduleStubs = new Map([
['./certificate.service', { CertificateService }],
['./cluster-heartbeat.service', { ClusterHeartbeatService }],
+ ['./cluster-operations.service', { ClusterOperationsService }],
['./distributed-lock.service', { DistributedLockService }],
['./reloader/reloader.service', { ReloaderService }],
['../reloader/reloader.service', { ReloaderService }],
@@ -218,6 +220,33 @@ describe('Session 9 audit logging', () => {
},
};
+ 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 };
@@ -262,6 +291,10 @@ describe('Session 9 audit logging', () => {
provide: ClusterHeartbeatService,
useValue: clusterHeartbeatServiceMock,
},
+ {
+ provide: ClusterOperationsService,
+ useValue: clusterOperationsServiceMock,
+ },
{
provide: DistributedLockService,
useValue: distributedLockServiceMock,
From df5206cac768ef640893c0e7cba984c8799d9405 Mon Sep 17 00:00:00 2001
From: Stualyttle <27898452+Stualyttle@users.noreply.github.com.>
Date: Sun, 24 May 2026 18:37:37 +0200
Subject: [PATCH 15/43] feat: implement staged NGINX runtime releases with
atomic activation and rollback
---
ARCHITECTURE_DECISIONS.md | 32 ++
IMPLEMENTATION_STATUS.md | 40 +-
README.md | 18 +-
docker-entrypoint.sh | 55 +++
nginx/conf.d/default.conf | 2 +-
nginx/nginx.conf | 4 +-
src/nginx/nginx.service.ts | 26 +-
src/reloader/reloader.service.ts | 422 +++++++++++++++---
.../transactional-nginx-rollout.test.js | 321 +++++++++++++
9 files changed, 845 insertions(+), 75 deletions(-)
create mode 100644 test/session13/transactional-nginx-rollout.test.js
diff --git a/ARCHITECTURE_DECISIONS.md b/ARCHITECTURE_DECISIONS.md
index 16bc32c..9491002 100644
--- a/ARCHITECTURE_DECISIONS.md
+++ b/ARCHITECTURE_DECISIONS.md
@@ -31,6 +31,7 @@ This file records repository-level architectural and delivery decisions so futur
| ADR-013 | Lease-based leader coordination with generation fencing tokens | accepted | Session 10 | 2026-05-24 |
| ADR-014 | Lease-backed heartbeat and leader reconciliation | accepted | Session 11 | 2026-05-24 |
| ADR-015 | Durable cluster operation journal with per-node acknowledgements | accepted | Session 12 | 2026-05-24 |
+| ADR-016 | Staged NGINX runtime releases with atomic activation and rollback | accepted | Session 13 | 2026-05-24 |
---
@@ -529,3 +530,34 @@ Represent cluster-wide mutations as durable operations with explicit per-node ac
- future sessions can layer desired-state versions, certificate activation ACK policies, and richer operational metrics onto the shared operation journal instead of inventing separate tracking paths
- operation execution is still initiated in-process on the requesting node, so restart-resume durability and stronger internal transport guarantees remain future work
+---
+
+## ADR-016 — Staged NGINX runtime releases with atomic activation and rollback
+
+- Status: accepted
+- Session: Session 13 — Implement staged NGINX config generation and atomic activation
+- Date: 2026-05-24
+
+### Context
+
+The previous NGINX reload flow cleared `/etc/nginx` in place, recopied repository assets directly into the live directory, regenerated configs there, and only then ran `nginx -t`. That meant a failed copy, partial write, or invalid generated config could leave a node with a broken or incomplete live NGINX tree before validation had even occurred.
+
+### Decision
+
+Adopt a staged runtime-release model for NGINX virtual-host configuration:
+
+1. keep `/etc/nginx/nginx.conf` as a stable loader that includes `/etc/nginx/runtime/current/conf.d/*.conf`
+2. create a full staged release under `/etc/nginx/runtime/releases/` for every reload attempt
+3. validate the staged release with a release-specific `nginx -t -c /.validation-nginx.conf` before activation
+4. activate a validated release by atomically swapping the `current` symlink to the new release
+5. preserve a `last-known-good` symlink and automatically roll back to the prior release if `nginx -s reload` fails after activation
+6. record release metadata on disk in `lyttle-nginx-release.json`, including release phase, apply node, validation output, and rollback context when applicable
+7. bootstrap a runtime `current` and `last-known-good` release at container startup so the stable loader is usable before the first dynamic reload occurs
+
+### Consequences
+
+- invalid generated configs are rejected before they become the active NGINX release
+- live config activation no longer depends on destructive in-place mutation of `/etc/nginx`
+- operators have a filesystem-local rollback point and release metadata trail even before later sessions add richer config-version APIs or database-backed apply history
+- later sessions should build on this model for metrics, operator inspection APIs, certificate artifact activation, and stronger controls around advanced custom NGINX fragments
+
diff --git a/IMPLEMENTATION_STATUS.md b/IMPLEMENTATION_STATUS.md
index f01667f..d83c2b9 100644
--- a/IMPLEMENTATION_STATUS.md
+++ b/IMPLEMENTATION_STATUS.md
@@ -8,9 +8,9 @@ Use it as the single place to record what has shipped, what is in progress, and
## Current summary
- Overall status: in progress
-- Current phase: Phase 3 — Cluster coordination redesign
-- Most recently completed session: Session 12 — Add cluster operations and per-node ACK tracking
-- Next recommended session from the roadmap: Session 13 — Implement staged NGINX config generation and atomic activation
+- Current phase: Phase 4 — Configuration rollout and rollback safety
+- Most recently completed session: Session 13 — Implement staged NGINX config generation and atomic activation
+- Next recommended session from the roadmap: Session 14 — Restrict or redesign `nginx_custom_code`
- Readiness reference: `PRODUCTION_READINESS_ASSESSMENT.md`
- Architecture decision log: `ARCHITECTURE_DECISIONS.md`
@@ -465,13 +465,35 @@ Use it as the single place to record what has shipped, what is in progress, and
## Session 13 — Implement staged NGINX config generation and atomic activation
-- Status: not started
+- Status: done
- Objective: make config rollout transactional and rollbackable
-- Files touched: none yet
-- Tests added/updated: none yet
-- Risks: current config deployment is destructive
-- Follow-up sessions: Session 14, Session 27
-- Notes: last-known-good config handling is still pending
+- Files touched:
+ - `src/reloader/reloader.service.ts`
+ - `src/nginx/nginx.service.ts`
+ - `nginx/nginx.conf`
+ - `nginx/conf.d/default.conf`
+ - `docker-entrypoint.sh`
+ - `README.md`
+ - `ARCHITECTURE_DECISIONS.md`
+ - `IMPLEMENTATION_STATUS.md`
+ - `test/session13/transactional-nginx-rollout.test.js`
+- Tests added/updated:
+ - added `test/session13/transactional-nginx-rollout.test.js` to verify staged release validation, atomic `current` symlink activation, rollback to the prior release on reload failure, and the bootstrap runtime layout wiring
+ - re-ran `npm test`, `npm run typecheck`, and `npm run build` after the staged rollout changes landed
+- Risks:
+ - the stable loader currently swaps only the managed virtual-host release path; broader NGINX extensibility such as `nginx_custom_code` still remains unsafe until Session 14 narrows or redesigns that surface
+ - release metadata is currently filesystem-backed inside the container/runtime volume rather than persisted in PostgreSQL, so operators must retain the runtime state directory to preserve local rollout history across node replacement
+ - certificate and TLS asset paths are still live-system paths outside the staged release tree, so later certificate-hardening sessions must keep config rollout and certificate artifact activation coordinated
+- Follow-up sessions:
+ - Session 14 — restrict or redesign `nginx_custom_code`
+ - Session 17 — rework cluster certificate distribution and activation
+ - Session 25 — expand metrics and alerting
+ - Session 27 — add chaos and fault-injection validation
+- Notes:
+ - reloader deployments now create full staged releases under `/etc/nginx/runtime/releases/` instead of clearing `/etc/nginx` in place
+ - each staged release is validated with a release-specific `nginx -t` config before activation, and activation now swaps the `current` release symlink atomically
+ - the runtime keeps both `current` and `last-known-good` symlinks, and failed reloads automatically roll back to the previous release before returning an error
+ - each release writes `lyttle-nginx-release.json` metadata with phase, node, validation output, and rollback details so apply state is inspectable on disk
## Session 14 — Restrict or redesign `nginx_custom_code`
diff --git a/README.md b/README.md
index 1c72910..56940d0 100644
--- a/README.md
+++ b/README.md
@@ -14,6 +14,8 @@
+
+
@@ -27,9 +29,9 @@ Built with [NestJS](https://nestjs.com/) • Powered by [PostgreSQL](https://www
## 📍 Current Delivery Status
-- **Roadmap status:** Phase 3 in progress
-- **Completed in Sessions 1-11 plus follow-up maintenance:** delivery scaffolding, dependency hygiene, authenticated-by-default admin APIs, dependency-aware health semantics, fail-fast container supervision, explicit inter-node control-plane addressing, an identity-aware auth foundation, explicit RBAC authorization policies, durable audit logging for privileged and mutating operations, durable leader leases, and lease-backed heartbeat/leader reconciliation
-- **Next recommended implementation session:** Session 12 — add cluster operations and per-node ACK tracking
+- **Roadmap status:** Phase 4 in progress
+- **Completed in Sessions 1-13 plus follow-up maintenance:** delivery scaffolding, dependency hygiene, authenticated-by-default admin APIs, dependency-aware health semantics, fail-fast container supervision, explicit inter-node control-plane addressing, an identity-aware auth foundation, explicit RBAC authorization policies, durable audit logging for privileged and mutating operations, durable leader leases, lease-backed heartbeat/leader reconciliation, durable cluster operation journaling with per-node ACK tracking, and staged NGINX release activation with rollback-safe config deployment
+- **Next recommended implementation session:** Session 14 — restrict or redesign `nginx_custom_code`
- **Canonical planning and status docs:**
- [`PRODUCTION_READINESS_ASSESSMENT.md`](PRODUCTION_READINESS_ASSESSMENT.md)
- [`IMPLEMENTATION_PLAN_BY_SESSION.md`](IMPLEMENTATION_PLAN_BY_SESSION.md)
@@ -62,7 +64,7 @@ node -v # expected: v24.16.0
npm -v # expected: 11.15.0
```
-`npm run test` now runs the focused Session 3-11 regression tests for API access control, health semantics, container-supervision behavior, inter-node addressing, the identity-aware auth foundation, RBAC authorization policy enforcement, audit-logging regressions, and lease-backed cluster coordination behavior. Session 26 still remains the planned milestone for broad unit/integration/e2e harness expansion.
+`npm run test` now runs the focused Session 3-13 regression tests for API access control, health semantics, container-supervision behavior, inter-node addressing, the identity-aware auth foundation, RBAC authorization policy enforcement, audit-logging regressions, lease-backed cluster coordination behavior, cluster-operation journaling, and transactional NGINX rollout behavior. Session 26 still remains the planned milestone for broad unit/integration/e2e harness expansion.
---
@@ -802,6 +804,14 @@ Cluster-wide mutations now use a durable operation journal:
- `GET /cluster/operations/:operationId` returns per-node acknowledgement state for a specific operation
- internal certificate-sync broadcasts also use the same operation journal so later certificate activation work can build on a shared ACK model
+Transactional config activation now uses a managed runtime release layout:
+
+- the stable loader at `/etc/nginx/nginx.conf` reads virtual-host configs from `/etc/nginx/runtime/current/conf.d/*.conf`
+- each reload stages a full config snapshot under `/etc/nginx/runtime/releases/`
+- the staged snapshot is validated with `nginx -t -c /.validation-nginx.conf` before activation
+- activation swaps the `current` symlink atomically, preserves a `last-known-good` symlink, and rolls back automatically if `nginx -s reload` fails
+- each staged release records metadata in `lyttle-nginx-release.json`, including validation output, apply node, phase, and rollback context when relevant
+
If your Swarm nodes are not mutually reachable by node hostname, override `CLUSTER_CONTROL_ADDRESS` with a routable internal DNS name or address per node before treating cluster operations as healthy.
**View cluster status:**
diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh
index a7fb572..25b32c6 100644
--- a/docker-entrypoint.sh
+++ b/docker-entrypoint.sh
@@ -17,6 +17,11 @@ 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}"
@@ -220,6 +225,53 @@ verify_prerequisites() {
}
# 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..."
@@ -333,6 +385,9 @@ fi
# 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/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/src/nginx/nginx.service.ts b/src/nginx/nginx.service.ts
index 4b61608..2ddd86d 100644
--- a/src/nginx/nginx.service.ts
+++ b/src/nginx/nginx.service.ts
@@ -2,12 +2,24 @@ import { Injectable } from '@nestjs/common';
import { ProxyEntry, ProxyType } from '@prisma/client';
import * as fs from 'fs';
+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) {
@@ -20,8 +32,8 @@ export class NginxService {
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 certPath = `${letsEncryptLiveRoot}/${primaryDomain}/fullchain.pem`;
+ const keyPath = `${letsEncryptLiveRoot}/${primaryDomain}/privkey.pem`;
const hasCert = fs.existsSync(certPath) && fs.existsSync(keyPath);
// Use single server block for all entry types (matches pre-TLS behavior)
@@ -46,7 +58,7 @@ server {
# 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_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;
@@ -94,21 +106,21 @@ 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;
}
diff --git a/src/reloader/reloader.service.ts b/src/reloader/reloader.service.ts
index 3b95d2c..1164757 100644
--- a/src/reloader/reloader.service.ts
+++ b/src/reloader/reloader.service.ts
@@ -6,26 +6,41 @@ 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
import { HealthService } from '../health/health.service';
-const NGINX_ETC_DIR = '/etc/nginx';
-const NGINX_SOURCE_DIR = join(process.cwd(), 'nginx');
+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 {
@@ -40,7 +55,7 @@ async function isHostResolvable(host: string): Promise {
@Injectable()
export class ReloaderService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(ReloaderService.name);
- private intervalHandle: NodeJS.Timeout | null = null;
+ private intervalHandle: ReturnType | null = null;
// reload interval: every 5 minutes
private readonly intervalMs = 5 * 60 * 1000;
@@ -91,40 +106,28 @@ 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...');
@@ -162,21 +165,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(
- `reloaded ${entries.length} proxy entr${entries.length === 1 ? 'y' : 'ies'}`,
+ `activated release ${sslRelease.releaseId} (${sslRelease.phase}); previous release ${sslRelease.previousReleaseId ?? httpOnlyRelease.previousReleaseId ?? 'none'}`,
);
return { ok: true };
} catch (error: any) {
@@ -193,11 +191,16 @@ export class ReloaderService implements OnModuleInit, OnModuleDestroy {
}
// Helper: Generate nginx confs for all entries (auto SSL detection)
- private async generateNginxConfs(entries: any[]): Promise {
- const confdDir = join(NGINX_ETC_DIR, 'conf.d');
+ private async generateNginxConfs(
+ entries: any[],
+ releasePath: string,
+ ): Promise {
+ 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])
@@ -210,13 +213,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' });
@@ -317,11 +323,10 @@ export class ReloaderService implements OnModuleInit, OnModuleDestroy {
}
// Ensure directories required by proxies/redirects exist
- private async ensureProxyAndRedirectDirs(): Promise {
+ private async ensureProxyAndRedirectDirs(entries: any[]): Promise {
this.logger.log(
'Parsing proxy/redirect configs for required directories...',
);
- const entries = await this.prisma.proxyEntry.findMany();
const dirs = new Set();
for (const entry of entries) {
@@ -377,20 +382,333 @@ export class ReloaderService implements OnModuleInit, OnModuleDestroy {
}
}
- private execShell(cmd: string): Promise {
- this.logger.log(`Executing shell command: ${cmd}`);
+ private async ensureRuntimeLayout(): Promise {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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,
+ ): Promise {
+ await writeFile(
+ join(releasePath, NGINX_RELEASE_METADATA_FILE),
+ `${JSON.stringify(metadata, null, 2)}\n`,
+ 'utf8',
+ );
+ }
+
+ private async updateSymlinkAtomically(
+ linkPath: string,
+ targetPath: string,
+ ): Promise {
+ 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 {
+ 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,
+ ): Promise {
+ 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 {
+ try {
+ await access(path);
+ return true;
+ } catch {
+ return false;
+ }
+ }
+
+ private execCommand(command: string, args: string[]): Promise {
+ 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/test/session13/transactional-nginx-rollout.test.js b/test/session13/transactional-nginx-rollout.test.js
new file mode 100644
index 0000000..a72311a
--- /dev/null
+++ b/test/session13/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 ?? 'session13@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'), 'ok\n');
+ await writeFile(path.join(rootDir, 'html/errors/5xx.html'), '5xx\n');
+ await writeFile(
+ path.join(rootDir, 'html/errors/broken.html'),
+ 'broken\n',
+ );
+ await writeFile(
+ path.join(rootDir, 'html/errors/loading.html'),
+ 'loading\n',
+ );
+}
+
+async function createHarness(overrides = {}) {
+ const tempDir = await fs.mkdtemp(
+ path.join(os.tmpdir(), 'lyttlenginx-session13-'),
+ );
+ 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('Session 13 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/);
+ });
+});
+
From d2b35705619691a1b625cede3a74cd392b071e78 Mon Sep 17 00:00:00 2001
From: Stualyttle <27898452+Stualyttle@users.noreply.github.com.>
Date: Sun, 24 May 2026 18:45:41 +0200
Subject: [PATCH 16/43] feat: implement validated allowlisted nginx_custom_code
fragments with strict directive controls
---
.env.example | 5 +
ARCHITECTURE_DECISIONS.md | 30 +
IMPLEMENTATION_STATUS.md | 34 +-
README.md | 26 +-
src/nginx/nginx-custom-code.ts | 564 ++++++++++++++++++
src/nginx/nginx.service.ts | 57 +-
src/reloader/reloader.service.ts | 36 +-
.../nginx-custom-code-guardrails.test.js | 307 ++++++++++
8 files changed, 995 insertions(+), 64 deletions(-)
create mode 100644 src/nginx/nginx-custom-code.ts
create mode 100644 test/session14/nginx-custom-code-guardrails.test.js
diff --git a/.env.example b/.env.example
index 47e5dc4..e9c8be3 100644
--- a/.env.example
+++ b/.env.example
@@ -51,6 +51,11 @@ AUTH_DEFAULT_ADMIN_SCOPES="admin:full"
NODE_ENV="production"
PORT="3000"
+# Restrict `nginx_custom_code` root/alias directives to approved static-content
+# prefixes. Session 14 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
# ===============================================
diff --git a/ARCHITECTURE_DECISIONS.md b/ARCHITECTURE_DECISIONS.md
index 9491002..cae396c 100644
--- a/ARCHITECTURE_DECISIONS.md
+++ b/ARCHITECTURE_DECISIONS.md
@@ -32,6 +32,7 @@ This file records repository-level architectural and delivery decisions so futur
| ADR-014 | Lease-backed heartbeat and leader reconciliation | accepted | Session 11 | 2026-05-24 |
| ADR-015 | Durable cluster operation journal with per-node acknowledgements | accepted | Session 12 | 2026-05-24 |
| ADR-016 | Staged NGINX runtime releases with atomic activation and rollback | accepted | Session 13 | 2026-05-24 |
+| ADR-017 | Validated allowlisted `nginx_custom_code` fragments | accepted | Session 14 | 2026-05-24 |
---
@@ -561,3 +562,32 @@ Adopt a staged runtime-release model for NGINX virtual-host configuration:
- operators have a filesystem-local rollback point and release metadata trail even before later sessions add richer config-version APIs or database-backed apply history
- later sessions should build on this model for metrics, operator inspection APIs, certificate artifact activation, and stronger controls around advanced custom NGINX fragments
+---
+
+## ADR-017 — Validated allowlisted `nginx_custom_code` fragments
+
+- Status: accepted
+- Session: Session 14 — Restrict or redesign `nginx_custom_code`
+- Date: 2026-05-24
+
+### Context
+
+Even after Session 13 made NGINX rollout transactional, `ProxyEntry.nginx_custom_code` still flowed straight into generated server blocks as raw text. That left the system open to arbitrary directive injection, unsafe filesystem path materialization through `root` and `alias`, and accidental or malicious syntax that could bypass the intent of the managed proxy template.
+
+### Decision
+
+Keep `nginx_custom_code` as a narrow extensibility surface for now, but replace raw injection with a validated fragment model:
+
+1. parse custom fragments before rollout instead of copying them verbatim into generated config
+2. allow only a small server-level directive set (`add_header`, `client_max_body_size`, `expires`) plus `location` blocks
+3. allow only reviewed static-content and response-shaping directives inside custom `location` blocks (`root`, `alias`, `try_files`, `index`, `return`, `default_type`, `autoindex`, `add_header`, `expires`, `client_max_body_size`)
+4. reject dangerous directives and structures such as nested `server`/`if` blocks, regex locations, `proxy_pass`, `include`, and other arbitrary config escapes
+5. require `root` and `alias` paths to stay under an operator-configured allowlist via `NGINX_CUSTOM_CODE_ALLOWED_PATH_PREFIXES`
+6. fail the staged reload immediately when a fragment is invalid so unsafe config never reaches `nginx -t`, activation, or directory creation side effects
+
+### Consequences
+
+- advanced per-proxy static-file and response-header customization remains possible without preserving arbitrary server-block injection
+- the reloader now treats invalid custom fragments as rollout failures instead of silently ignoring or partially applying them
+- future proxy-management APIs in Session 21 should keep building on this validated fragment contract, or replace it with a stricter structured model if operators need richer NGINX extensibility later
+
diff --git a/IMPLEMENTATION_STATUS.md b/IMPLEMENTATION_STATUS.md
index d83c2b9..ddfd92e 100644
--- a/IMPLEMENTATION_STATUS.md
+++ b/IMPLEMENTATION_STATUS.md
@@ -9,8 +9,8 @@ Use it as the single place to record what has shipped, what is in progress, and
- Overall status: in progress
- Current phase: Phase 4 — Configuration rollout and rollback safety
-- Most recently completed session: Session 13 — Implement staged NGINX config generation and atomic activation
-- Next recommended session from the roadmap: Session 14 — Restrict or redesign `nginx_custom_code`
+- Most recently completed session: Session 14 — Restrict or redesign `nginx_custom_code`
+- Next recommended session from the roadmap: Session 15 — Add strict domain validation and safe process execution
- Readiness reference: `PRODUCTION_READINESS_ASSESSMENT.md`
- Architecture decision log: `ARCHITECTURE_DECISIONS.md`
@@ -497,13 +497,31 @@ Use it as the single place to record what has shipped, what is in progress, and
## Session 14 — Restrict or redesign `nginx_custom_code`
-- Status: not started
+- Status: done
- Objective: remove arbitrary config injection risk while preserving advanced extensibility
-- Files touched: none yet
-- Tests added/updated: none yet
-- Risks: raw config injection remains unsafe
-- Follow-up sessions: Session 21, Session 29
-- Notes: should introduce validation and tighter authorization
+- Files touched:
+ - `src/nginx/nginx-custom-code.ts`
+ - `src/nginx/nginx.service.ts`
+ - `src/reloader/reloader.service.ts`
+ - `.env.example`
+ - `README.md`
+ - `ARCHITECTURE_DECISIONS.md`
+ - `IMPLEMENTATION_STATUS.md`
+ - `test/session14/nginx-custom-code-guardrails.test.js`
+- Tests added/updated:
+ - added `test/session14/nginx-custom-code-guardrails.test.js` to verify allowlisted custom fragments render successfully, dangerous directives are rejected, path-prefix enforcement works, and invalid fragments fail reloads before activation
+ - re-ran `npm test`, `npm run typecheck`, and `npm run build` after landing the guarded fragment parser so Sessions 3-14 continue to pass together
+- Risks:
+ - `nginx_custom_code` is now constrained to a narrow allowlist, so operators who relied on arbitrary directives will need to move to the reviewed fragment subset or wait for a future structured extensibility model
+ - proxy management still occurs outside a dedicated API surface today; Session 21 remains responsible for shipping authenticated CRUD and validation workflows around proxy state instead of direct database mutation
+- Follow-up sessions:
+ - Session 15 — add strict domain validation and safe process execution
+ - Session 21 — add proxy management API
+ - Session 29 — reconcile README, architecture docs, and runbooks with reality
+- Notes:
+ - raw `nginx_custom_code` injection has been replaced with a validated fragment parser that only accepts reviewed server-level directives and `location` blocks
+ - `root` and `alias` paths now must stay within `NGINX_CUSTOM_CODE_ALLOWED_PATH_PREFIXES`, and directory creation derives from the validated fragment AST instead of regex extraction from raw text
+ - invalid custom fragments now fail the staged reload immediately, preventing unsafe or unsupported config from reaching `nginx -t`, activation, or side-effectful directory creation
---
diff --git a/README.md b/README.md
index 56940d0..2206f89 100644
--- a/README.md
+++ b/README.md
@@ -16,6 +16,7 @@
+
@@ -30,8 +31,8 @@ Built with [NestJS](https://nestjs.com/) • Powered by [PostgreSQL](https://www
## 📍 Current Delivery Status
- **Roadmap status:** Phase 4 in progress
-- **Completed in Sessions 1-13 plus follow-up maintenance:** delivery scaffolding, dependency hygiene, authenticated-by-default admin APIs, dependency-aware health semantics, fail-fast container supervision, explicit inter-node control-plane addressing, an identity-aware auth foundation, explicit RBAC authorization policies, durable audit logging for privileged and mutating operations, durable leader leases, lease-backed heartbeat/leader reconciliation, durable cluster operation journaling with per-node ACK tracking, and staged NGINX release activation with rollback-safe config deployment
-- **Next recommended implementation session:** Session 14 — restrict or redesign `nginx_custom_code`
+- **Completed in Sessions 1-14 plus follow-up maintenance:** delivery scaffolding, dependency hygiene, authenticated-by-default admin APIs, dependency-aware health semantics, fail-fast container supervision, explicit inter-node control-plane addressing, an identity-aware auth foundation, explicit RBAC authorization policies, durable audit logging for privileged and mutating operations, durable leader leases, lease-backed heartbeat/leader reconciliation, durable cluster operation journaling with per-node ACK tracking, staged NGINX release activation with rollback-safe config deployment, and validated allowlisted custom NGINX fragments
+- **Next recommended implementation session:** Session 15 — add strict domain validation and safe process execution
- **Canonical planning and status docs:**
- [`PRODUCTION_READINESS_ASSESSMENT.md`](PRODUCTION_READINESS_ASSESSMENT.md)
- [`IMPLEMENTATION_PLAN_BY_SESSION.md`](IMPLEMENTATION_PLAN_BY_SESSION.md)
@@ -64,7 +65,7 @@ node -v # expected: v24.16.0
npm -v # expected: 11.15.0
```
-`npm run test` now runs the focused Session 3-13 regression tests for API access control, health semantics, container-supervision behavior, inter-node addressing, the identity-aware auth foundation, RBAC authorization policy enforcement, audit-logging regressions, lease-backed cluster coordination behavior, cluster-operation journaling, and transactional NGINX rollout behavior. Session 26 still remains the planned milestone for broad unit/integration/e2e harness expansion.
+`npm run test` now runs the focused Session 3-14 regression tests for API access control, health semantics, container-supervision behavior, inter-node addressing, the identity-aware auth foundation, RBAC authorization policy enforcement, audit-logging regressions, lease-backed cluster coordination behavior, cluster-operation journaling, transactional NGINX rollout behavior, and guarded `nginx_custom_code` validation. Session 26 still remains the planned milestone for broad unit/integration/e2e harness expansion.
---
@@ -85,7 +86,7 @@ npm -v # expected: 11.15.0
- **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
+- **Guarded Custom NGINX Fragments** - Allowlisted server/location directives validated before rollout
- **WebSocket Support** - Full WebSocket proxying capability
### 📊 Monitoring & Observability
@@ -200,6 +201,23 @@ NODE_ENV=production
> **Note:** Connection pooling limits are automatically applied (10 connections per instance). Until dedicated database-operations docs are added, use `PRODUCTION_READINESS_ASSESSMENT.md` and `IMPLEMENTATION_PLAN_BY_SESSION.md` as the current references for production-hardening work.
+### Custom NGINX fragment guardrails
+
+Session 14 narrows `ProxyEntry.nginx_custom_code` from raw server-block injection to a validated fragment model:
+
+- only reviewed server-level directives (`add_header`, `client_max_body_size`, `expires`) and `location` blocks are accepted
+- inside custom `location` blocks, only a small allowlist of static-content and response-shaping directives is accepted
+- dangerous directives such as `proxy_pass`, `include`, nested `server`/`if` blocks, certificate directives, and regex locations are rejected before rollout
+- `root` and `alias` paths must stay under operator-approved prefixes configured with `NGINX_CUSTOM_CODE_ALLOWED_PATH_PREFIXES`
+
+Example:
+
+```bash
+NGINX_CUSTOM_CODE_ALLOWED_PATH_PREFIXES="/var/www,/srv/www,/etc/nginx/custom"
+```
+
+If a fragment violates these rules, the staged reload fails before `nginx -t` or activation. This keeps advanced per-proxy static-file behavior available without preserving arbitrary config injection as a casual escape hatch.
+
### 3. Setup Database
```bash
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();
+ 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,
+ 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,
+ 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,
+): 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 2ddd86d..78ddc52 100644
--- a/src/nginx/nginx.service.ts
+++ b/src/nginx/nginx.service.ts
@@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common';
import { ProxyEntry, ProxyType } from '@prisma/client';
import * as fs from 'fs';
+import { sanitizeNginxCustomCode } from './nginx-custom-code';
type GenerateNginxConfigOptions = {
resolved?: boolean;
@@ -31,10 +32,13 @@ export class NginxService {
.filter(Boolean);
if (domains.length === 0) continue;
const primaryDomain = domains[0];
+ const { proxy_pass_host: proxyPassHost, nginx_custom_code: customCodeSource } =
+ entry;
const certPath = `${letsEncryptLiveRoot}/${primaryDomain}/fullchain.pem`;
const keyPath = `${letsEncryptLiveRoot}/${primaryDomain}/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)
{
@@ -49,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 ${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;
- }
-
- ${
- 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;
@@ -96,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;
@@ -123,11 +128,11 @@ server {
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/reloader/reloader.service.ts b/src/reloader/reloader.service.ts
index 1164757..e3797a1 100644
--- a/src/reloader/reloader.service.ts
+++ b/src/reloader/reloader.service.ts
@@ -27,6 +27,7 @@ import { CertificateService } from '../certificate/certificate.service';
import { TlsConfigService } from '../certificate/tls-config.service';
import { lookup } from 'dns/promises'; // <-- Added
import { HealthService } from '../health/health.service';
+import { extractManagedPathsFromCustomCode } from '../nginx/nginx-custom-code';
const NGINX_ETC_DIR = process.env['NGINX_ETC_DIR'] ?? '/etc/nginx';
const NGINX_SOURCE_DIR =
@@ -238,6 +239,7 @@ export class ReloaderService implements OnModuleInit, OnModuleDestroy {
time: new Date().toISOString(),
}),
);
+ throw error;
}
}
}
@@ -332,32 +334,13 @@ export class ReloaderService implements OnModuleInit, OnModuleDestroy {
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) {
@@ -369,6 +352,7 @@ export class ReloaderService implements OnModuleInit, OnModuleDestroy {
time: new Date().toISOString(),
}),
);
+ throw error;
}
}
diff --git a/test/session14/nginx-custom-code-guardrails.test.js b/test/session14/nginx-custom-code-guardrails.test.js
new file mode 100644
index 0000000..a7ad918
--- /dev/null
+++ b/test/session14/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'),
+ 'ok\n',
+ );
+ await writeFile(
+ path.join(rootDir, 'html/errors/5xx.html'),
+ '5xx\n',
+ );
+ await writeFile(
+ path.join(rootDir, 'html/errors/broken.html'),
+ 'broken\n',
+ );
+ await writeFile(
+ path.join(rootDir, 'html/errors/loading.html'),
+ 'loading\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 ?? 'session14@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-session14-'),
+ );
+ 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('Session 14 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-session14-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-session14-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-session14-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 });
+ }
+ });
+});
+
From a447e86577750aa6b4570b8cf0e4d8ff7b94b079 Mon Sep 17 00:00:00 2001
From: Stualyttle <27898452+Stualyttle@users.noreply.github.com.>
Date: Sun, 24 May 2026 19:04:03 +0200
Subject: [PATCH 17/43] feat: implement strict domain validation and safe
process execution for certificates
---
ARCHITECTURE_DECISIONS.md | 62 ++-
IMPLEMENTATION_STATUS.md | 53 ++-
README.md | 20 +-
src/certificate/certificate-backup.service.ts | 35 +-
.../certificate-cleanup.service.ts | 6 +-
.../certificate-monitor.service.ts | 4 +-
src/certificate/certificate.controller.ts | 5 +-
src/certificate/certificate.service.ts | 360 +++++++++++-------
src/certificate/dto/domain-list.decorator.ts | 75 ++++
.../dto/generate-self-signed.dto.ts | 9 +-
src/certificate/dto/upload-certificate.dto.ts | 15 +-
src/certificate/tls-config.service.ts | 72 +++-
src/certificate/tls.controller.ts | 9 +-
src/nginx/nginx.service.ts | 20 +-
src/reloader/reloader.service.ts | 48 ++-
src/utils/domain-utils.ts | 235 +++++++++++-
src/utils/pipes/normalized-domain.pipe.ts | 23 ++
src/utils/process-utils.ts | 84 ++++
...domain-validation-and-safe-process.test.js | 303 +++++++++++++++
19 files changed, 1184 insertions(+), 254 deletions(-)
create mode 100644 src/certificate/dto/domain-list.decorator.ts
create mode 100644 src/utils/pipes/normalized-domain.pipe.ts
create mode 100644 src/utils/process-utils.ts
create mode 100644 test/session15/domain-validation-and-safe-process.test.js
diff --git a/ARCHITECTURE_DECISIONS.md b/ARCHITECTURE_DECISIONS.md
index cae396c..f9ff7e3 100644
--- a/ARCHITECTURE_DECISIONS.md
+++ b/ARCHITECTURE_DECISIONS.md
@@ -14,25 +14,26 @@ This file records repository-level architectural and delivery decisions so futur
## Decision index
-| ID | Title | Status | Session | Date |
-| ------- | ----------------------------------------------------------------------------------------- | -------- | --------- | ---------- |
-| ADR-001 | Production-readiness source of truth | accepted | Session 1 | 2026-05-24 |
-| ADR-002 | Session-based delivery model | accepted | Session 1 | 2026-05-24 |
-| ADR-003 | Standard repository verification contract | accepted | Session 1 | 2026-05-24 |
-| ADR-004 | Deployment mode expectations | accepted | Session 1 | 2026-05-24 |
-| ADR-005 | Secret material stays out of git | accepted | Session 2 | 2026-05-24 |
-| ADR-006 | Authenticated-by-default control-plane API | accepted | Session 3 | 2026-05-24 |
-| ADR-007 | Explicit probe endpoints with dependency-aware readiness | accepted | Session 4 | 2026-05-24 |
-| ADR-008 | Fail-fast container supervision and restart-friendly deployment policies | accepted | Session 5 | 2026-05-24 |
-| ADR-009 | Explicit advertised control-plane endpoints for cluster communication | accepted | Session 6 | 2026-05-24 |
-| ADR-010 | Identity-aware auth foundation with bearer-token support and legacy API-key compatibility | accepted | Session 7 | 2026-05-24 |
-| ADR-011 | Explicit RBAC policies with a global authorization guard | accepted | Session 8 | 2026-05-24 |
-| ADR-012 | Durable audit events for privileged and mutating operations | accepted | Session 9 | 2026-05-24 |
+| ID | Title | Status | Session | Date |
+| ------- | ----------------------------------------------------------------------------------------- | -------- | ---------- | ---------- |
+| ADR-001 | Production-readiness source of truth | accepted | Session 1 | 2026-05-24 |
+| ADR-002 | Session-based delivery model | accepted | Session 1 | 2026-05-24 |
+| ADR-003 | Standard repository verification contract | accepted | Session 1 | 2026-05-24 |
+| ADR-004 | Deployment mode expectations | accepted | Session 1 | 2026-05-24 |
+| ADR-005 | Secret material stays out of git | accepted | Session 2 | 2026-05-24 |
+| ADR-006 | Authenticated-by-default control-plane API | accepted | Session 3 | 2026-05-24 |
+| ADR-007 | Explicit probe endpoints with dependency-aware readiness | accepted | Session 4 | 2026-05-24 |
+| ADR-008 | Fail-fast container supervision and restart-friendly deployment policies | accepted | Session 5 | 2026-05-24 |
+| ADR-009 | Explicit advertised control-plane endpoints for cluster communication | accepted | Session 6 | 2026-05-24 |
+| ADR-010 | Identity-aware auth foundation with bearer-token support and legacy API-key compatibility | accepted | Session 7 | 2026-05-24 |
+| ADR-011 | Explicit RBAC policies with a global authorization guard | accepted | Session 8 | 2026-05-24 |
+| ADR-012 | Durable audit events for privileged and mutating operations | accepted | Session 9 | 2026-05-24 |
| ADR-013 | Lease-based leader coordination with generation fencing tokens | accepted | Session 10 | 2026-05-24 |
| ADR-014 | Lease-backed heartbeat and leader reconciliation | accepted | Session 11 | 2026-05-24 |
| ADR-015 | Durable cluster operation journal with per-node acknowledgements | accepted | Session 12 | 2026-05-24 |
| ADR-016 | Staged NGINX runtime releases with atomic activation and rollback | accepted | Session 13 | 2026-05-24 |
| ADR-017 | Validated allowlisted `nginx_custom_code` fragments | accepted | Session 14 | 2026-05-24 |
+| ADR-018 | Strict normalized certificate domains and argument-array process execution | accepted | Session 15 | 2026-05-24 |
---
@@ -591,3 +592,36 @@ Keep `nginx_custom_code` as a narrow extensibility surface for now, but replace
- the reloader now treats invalid custom fragments as rollout failures instead of silently ignoring or partially applying them
- future proxy-management APIs in Session 21 should keep building on this validated fragment contract, or replace it with a stricter structured model if operators need richer NGINX extensibility later
+---
+
+## ADR-018 — Strict normalized certificate domains and argument-array process execution
+
+- Status: accepted
+- Session: Session 15 — Add strict domain validation and safe process execution
+- Date: 2026-05-24
+
+### Context
+
+The production-readiness assessment identified two closely related certificate risks:
+
+1. domain input was weakly validated, which left room for malformed FQDNs, wildcard/path confusion, and unsafe filesystem path derivation
+2. certificate and TLS workflows still built OpenSSL and certbot commands through shell-interpolated strings, which increased command-injection risk and made argument handling brittle
+
+These problems affected certificate issuance, upload, self-signed generation, TLS inspection helpers, generated NGINX certificate paths, and backup archive naming.
+
+### Decision
+
+Adopt the following certificate-domain and certificate-process policy:
+
+1. normalize certificate-related domains to lowercase ASCII/punycode before use
+2. accept only fully-qualified domains; reject local-only names, IPs, path separators, whitespace, and control characters early
+3. accept wildcard domains only in the left-most `*.` form, and explicitly reject wildcard issuance in the current built-in ACME flow until DNS-01 support exists
+4. derive certificate storage paths from deterministic safe storage identifiers instead of raw domain strings
+5. execute OpenSSL, certbot, and NGINX helper commands in the certificate/TLS path through `execFile`-style argument arrays rather than shell-interpolated command strings
+
+### Consequences
+
+- certificate uploads, self-signed generation, TLS inspection, and route-param-based domain workflows now fail fast on malformed input before they reach filesystem or subprocess boundaries
+- wildcard certificate issuance remains intentionally unavailable in the current HTTP-01 flow, which makes the limitation explicit until Session 18 lands a wildcard-safe ACME strategy
+- generated NGINX certificate paths, local certificate directories, and backup archive paths no longer depend on raw domain text, reducing path-confusion risk
+- future certificate lifecycle work should build on the shared normalized-domain utilities and safe process helper instead of reintroducing ad hoc validation or shell command construction
diff --git a/IMPLEMENTATION_STATUS.md b/IMPLEMENTATION_STATUS.md
index ddfd92e..40c7a47 100644
--- a/IMPLEMENTATION_STATUS.md
+++ b/IMPLEMENTATION_STATUS.md
@@ -8,9 +8,9 @@ Use it as the single place to record what has shipped, what is in progress, and
## Current summary
- Overall status: in progress
-- Current phase: Phase 4 — Configuration rollout and rollback safety
-- Most recently completed session: Session 14 — Restrict or redesign `nginx_custom_code`
-- Next recommended session from the roadmap: Session 15 — Add strict domain validation and safe process execution
+- Current phase: Phase 5 — Certificate lifecycle redesign
+- Most recently completed session: Session 15 — Add strict domain validation and safe process execution
+- Next recommended session from the roadmap: Session 16 — Add certificate order state machine
- Readiness reference: `PRODUCTION_READINESS_ASSESSMENT.md`
- Architecture decision log: `ARCHITECTURE_DECISIONS.md`
@@ -529,13 +529,48 @@ Use it as the single place to record what has shipped, what is in progress, and
## Session 15 — Add strict domain validation and safe process execution
-- Status: not started
+- Status: done
- Objective: reject malformed domains early and stop using unsafe shell interpolation
-- Files touched: none yet
-- Tests added/updated: none yet
-- Risks: command injection and unsafe path derivation remain open
-- Follow-up sessions: Session 16, Session 18, Session 20, Session 26
-- Notes: domain normalization rules need to become explicit
+- Files touched:
+ - `src/utils/domain-utils.ts`
+ - `src/utils/process-utils.ts`
+ - `src/utils/pipes/normalized-domain.pipe.ts`
+ - `src/certificate/dto/domain-list.decorator.ts`
+ - `src/certificate/dto/generate-self-signed.dto.ts`
+ - `src/certificate/dto/upload-certificate.dto.ts`
+ - `src/certificate/certificate.controller.ts`
+ - `src/certificate/tls.controller.ts`
+ - `src/certificate/certificate.service.ts`
+ - `src/certificate/tls-config.service.ts`
+ - `src/certificate/certificate-backup.service.ts`
+ - `src/certificate/certificate-monitor.service.ts`
+ - `src/certificate/certificate-cleanup.service.ts`
+ - `src/nginx/nginx.service.ts`
+ - `src/reloader/reloader.service.ts`
+ - `README.md`
+ - `ARCHITECTURE_DECISIONS.md`
+ - `IMPLEMENTATION_STATUS.md`
+ - `test/session15/domain-validation-and-safe-process.test.js`
+- Tests added/updated:
+ - added `test/session15/domain-validation-and-safe-process.test.js` to verify strict FQDN normalization, malformed-domain rejection, wildcard HTTP-01 issuance rejection, safe OpenSSL argument execution, and safe certificate storage names in generated NGINX config
+ - ran `npm run typecheck`, `npm test`, and `npm run build` successfully after landing the domain-validation and process-execution changes
+ - ran `npm run lint`; the repo-wide lint command still reports pre-existing unrelated formatting debt in untouched files, but the Session 15 files were cleaned up and do not block typecheck/build/test verification
+- Risks:
+ - wildcard issuance remains intentionally blocked until Session 18 introduces DNS-01 or another wildcard-safe ACME strategy
+ - certificate lifecycle durability, retry journaling, and resumable issuance state are still pending Session 16 and Session 17
+ - private keys and backup artifacts are still not encrypted at rest; Session 19 and Session 20 remain responsible for that hardening
+- Follow-up sessions:
+ - Session 16 — add certificate order state machine
+ - Session 17 — rework cluster certificate distribution and activation
+ - Session 18 — harden the ACME strategy for clustered production
+ - Session 19 — encrypt private key material at rest
+ - Session 20 — harden backup, export, import, and restore flows
+ - Session 26 — expand automated regression coverage further
+- Notes:
+ - domain handling now enforces lowercase normalized FQDNs with punycode conversion, explicit wildcard rules, and early rejection of path separators, whitespace, control characters, and IP addresses
+ - certificate storage directories now derive from safe deterministic storage IDs instead of raw domain strings, preventing wildcard/path characters from leaking into filesystem layout or generated NGINX certificate paths
+ - OpenSSL, certbot, and NGINX invocations in the certificate and TLS flows now use argument arrays via a shared `execFile`-based helper instead of shell-interpolated command strings
+ - route params and certificate DTO domain arrays are validated and normalized before the certificate services run, reducing the chance of malformed input reaching DNS lookups, certbot, OpenSSL, or archive generation
## Session 16 — Add certificate order state machine
diff --git a/README.md b/README.md
index 2206f89..e732e9e 100644
--- a/README.md
+++ b/README.md
@@ -30,9 +30,9 @@ Built with [NestJS](https://nestjs.com/) • Powered by [PostgreSQL](https://www
## 📍 Current Delivery Status
-- **Roadmap status:** Phase 4 in progress
-- **Completed in Sessions 1-14 plus follow-up maintenance:** delivery scaffolding, dependency hygiene, authenticated-by-default admin APIs, dependency-aware health semantics, fail-fast container supervision, explicit inter-node control-plane addressing, an identity-aware auth foundation, explicit RBAC authorization policies, durable audit logging for privileged and mutating operations, durable leader leases, lease-backed heartbeat/leader reconciliation, durable cluster operation journaling with per-node ACK tracking, staged NGINX release activation with rollback-safe config deployment, and validated allowlisted custom NGINX fragments
-- **Next recommended implementation session:** Session 15 — add strict domain validation and safe process execution
+- **Roadmap status:** Phase 5 in progress
+- **Completed in Sessions 1-15 plus follow-up maintenance:** delivery scaffolding, dependency hygiene, authenticated-by-default admin APIs, dependency-aware health semantics, fail-fast container supervision, explicit inter-node control-plane addressing, an identity-aware auth foundation, explicit RBAC authorization policies, durable audit logging for privileged and mutating operations, durable leader leases, lease-backed heartbeat/leader reconciliation, durable cluster operation journaling with per-node ACK tracking, staged NGINX release activation with rollback-safe config deployment, validated allowlisted custom NGINX fragments, and strict certificate-domain validation with safe process execution
+- **Next recommended implementation session:** Session 16 — add certificate order state machine
- **Canonical planning and status docs:**
- [`PRODUCTION_READINESS_ASSESSMENT.md`](PRODUCTION_READINESS_ASSESSMENT.md)
- [`IMPLEMENTATION_PLAN_BY_SESSION.md`](IMPLEMENTATION_PLAN_BY_SESSION.md)
@@ -594,6 +594,14 @@ The system will:
3. Update config with HTTPS + HTTP→HTTPS redirect
4. Auto-renew when within `RENEW_BEFORE_DAYS` threshold
+Certificate domain input is now normalized and validated strictly before any filesystem or process interaction:
+
+- domains must be fully-qualified (`example.com`, not `localhost`)
+- internationalized domains are normalized to ASCII/punycode
+- wildcard domains must use the left-most `*.` form
+- path separators, whitespace, control characters, and shell metacharacter tricks are rejected early
+- wildcard issuance is currently rejected for the built-in ACME flow because the current implementation is still HTTP-01-based and DNS-01 support has not landed yet
+
### Upload Custom Certificate
```bash
@@ -616,7 +624,7 @@ Perfect for development and testing:
curl -X POST http://localhost:3000/certificates/generate-self-signed \
-H "X-API-Key: $API_KEY" \
-H "Content-Type: application/json" \
- -d '{"domains": ["localhost", "*.localhost"]}'
+ -d '{"domains": ["example.test", "*.example.test"]}'
```
### Certificate Status
@@ -721,8 +729,8 @@ 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
+- `certs/{certificate-storage-id}/fullchain.pem` - Certificate files
+- `certs/{certificate-storage-id}/privkey.pem` - Private keys
- `metadata.json` - Backup metadata
### List Backups
diff --git a/src/certificate/certificate-backup.service.ts b/src/certificate/certificate-backup.service.ts
index 3f61b52..8d8db1f 100644
--- a/src/certificate/certificate-backup.service.ts
+++ b/src/certificate/certificate-backup.service.ts
@@ -4,6 +4,13 @@ import archiver from 'archiver';
import * as fs from 'fs';
import * as path from 'path';
import { Readable } from 'stream';
+import {
+ getCertificateStorageName,
+ hashDomains,
+ joinDomains,
+ normalizeDomains,
+ parseDomains,
+} from '../utils/domain-utils';
@Injectable()
export class CertificateBackupService {
@@ -51,8 +58,8 @@ export class CertificateBackupService {
// Add individual certificate files
certificates.forEach((cert) => {
- const domains = cert.domains.split(';')[0].trim();
- const prefix = `certs/${domains}`;
+ const domains = parseDomains(cert.domains, { allowWildcard: true });
+ const prefix = `certs/${getCertificateStorageName(domains[0])}`;
archive.append(cert.certPem, { name: `${prefix}/fullchain.pem` });
archive.append(cert.keyPem, { name: `${prefix}/privkey.pem` });
@@ -87,7 +94,7 @@ export class CertificateBackupService {
return {
certPem: cert.certPem,
keyPem: cert.keyPem,
- domains: cert.domains.split(';').map((d) => d.trim()),
+ domains: parseDomains(cert.domains, { allowWildcard: true }),
};
}
@@ -106,15 +113,21 @@ export class CertificateBackupService {
for (const cert of data) {
try {
+ const normalizedDomains = normalizeDomains(cert.domains, {
+ allowWildcard: true,
+ });
+
// 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,7 +137,9 @@ export class CertificateBackupService {
await this.prisma.certificate.create({
data: {
domains: domainsStr,
- domainsHash: this.hashDomains(cert.domains),
+ domainsHash: hashDomains(normalizedDomains, {
+ allowWildcard: true,
+ }),
certPem: cert.certPem,
keyPem: cert.keyPem,
expiresAt: new Date(cert.expiresAt),
@@ -134,7 +149,7 @@ export class CertificateBackupService {
},
});
- this.logger.log(`[Import] Imported cert: ${cert.domains[0]}`);
+ this.logger.log(`[Import] Imported cert: ${normalizedDomains[0]}`);
results.imported++;
} catch (error) {
this.logger.error(
@@ -190,10 +205,4 @@ export class CertificateBackupService {
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');
- }
}
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.controller.ts b/src/certificate/certificate.controller.ts
index bd23a95..62ecaa5 100644
--- a/src/certificate/certificate.controller.ts
+++ b/src/certificate/certificate.controller.ts
@@ -23,6 +23,7 @@ import {
} 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')
@@ -136,7 +137,9 @@ export class CertificateController {
}
@Get('validate/:domain')
- async validateDomain(@Param('domain') domain: string) {
+ async validateDomain(
+ @Param('domain', new NormalizedDomainPipe()) domain: string,
+ ) {
return this.certificateService.validateDomainForCertificate(domain);
}
diff --git a/src/certificate/certificate.service.ts b/src/certificate/certificate.service.ts
index 30b742e..a5788ff 100644
--- a/src/certificate/certificate.service.ts
+++ b/src/certificate/certificate.service.ts
@@ -6,20 +6,27 @@ 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 {
+ containsWildcardDomain,
+ getCertificateStorageName,
+ hashDomains,
+ joinDomains,
+ normalizeDomain,
+ normalizeDomains,
+ parseDomains,
+} from '../utils/domain-utils';
import { AlertService } from '../alert/alert.service';
+import { ClusterHeartbeatService } from '../distributed-lock/cluster-heartbeat.service';
import { ClusterOperationsService } from '../distributed-lock/cluster-operations.service';
import { DistributedLockService } from '../distributed-lock/distributed-lock.service';
import { HealthService } from '../health/health.service';
-
-const exec = promisify(execCb);
+import { runCommand } from '../utils/process-utils';
// Validate ADMIN_EMAIL is set (required by Let's Encrypt)
const adminEmail = process.env.ADMIN_EMAIL;
@@ -46,14 +53,8 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown {
private clusterOperations: ClusterOperationsService,
private distributedLock: DistributedLockService,
private healthService: HealthService,
- @Inject(
- forwardRef(
- () =>
- require('../distributed-lock/cluster-heartbeat.service')
- .ClusterHeartbeatService,
- ),
- )
- private clusterHeartbeat: any,
+ @Inject(forwardRef(() => ClusterHeartbeatService))
+ private clusterHeartbeat: ClusterHeartbeatService | null,
) {}
async onModuleInit() {
@@ -113,7 +114,7 @@ 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];
// Quick validation check
@@ -194,8 +195,8 @@ 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 =
@@ -467,7 +468,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 {
+ 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 {
+ const { stdout } = await runCommand('openssl', [
+ 'x509',
+ '-in',
+ certPath,
+ '-noout',
+ '-pubkey',
+ ]);
+ return stdout.trim();
+ }
+
+ private async getPrivateKeyPublicKey(keyPath: string): Promise {
+ const { stdout } = await runCommand('openssl', [
+ 'pkey',
+ '-in',
+ keyPath,
+ '-pubout',
+ ]);
+ return stdout.trim();
+ }
+
+ private assertWildcardIssuanceSupported(domains: string[]): void {
+ if (!containsWildcardDomain(domains)) {
+ return;
+ }
+
+ throw new Error(
+ 'Wildcard certificate issuance requires DNS-01 and is not supported by the current HTTP-01 ACME flow',
+ );
}
/**
@@ -549,7 +597,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'];
@@ -589,8 +641,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');
@@ -600,36 +651,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) {
- return {
- valid: false,
- error: `Certificate validation failed: ${certCheck.stderr}`,
- };
- }
-
- // 4. Validate private key
- const keyCheck = await exec(
- `openssl rsa -in ${tempKeyPath} -check -noout 2>&1`,
- );
- if (keyCheck.stderr && !keyCheck.stderr.includes('ok')) {
+ const certCheck = await runCommand('openssl', [
+ 'x509',
+ '-in',
+ tempCertPath,
+ '-noout',
+ '-text',
+ ]);
+ if (!certCheck.stdout.trim()) {
return {
valid: false,
- error: `Private key validation failed: ${keyCheck.stderr}`,
+ error:
+ 'Certificate validation failed: no certificate data returned',
};
}
- // 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`,
- );
+ // 4. Validate private key and 5. Verify cert and key match
+ const certPublicKey = await this.getCertificatePublicKey(tempCertPath);
+ const keyPublicKey = await this.getPrivateKeyPublicKey(tempKeyPath);
- if (certModulus.stdout.trim() !== keyModulus.stdout.trim()) {
+ if (certPublicKey !== keyPublicKey) {
return {
valid: false,
error: 'Certificate and private key do not match',
@@ -637,9 +678,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' };
@@ -661,11 +706,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:', '')) || [];
@@ -776,12 +818,13 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown {
return;
}
const domains = Array.isArray(domainsInput)
- ? domainsInput
- : parseDomains(domainsInput);
+ ? normalizeDomains(domainsInput as string[], { allowWildcard: true })
+ : parseDomains(domainsInput as string, { allowWildcard: true });
+ this.assertWildcardIssuanceSupported(domains);
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);
@@ -882,7 +925,6 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown {
this.logger.log(
`[Certbot] No valid certificate in DB. Running certbot for: [${domainsStr}]`,
);
- const domainArgs = domains.map((d) => `-d ${d}`).join(' ');
try {
// Use shell script hooks that directly interact with PostgreSQL
@@ -923,15 +965,36 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown {
}
const [, dbUser, dbPassword, dbHost, dbPort, dbName] = dbMatch;
+ const certbotArgs = [
+ 'certonly',
+ '--manual',
+ '--preferred-challenges=http',
+ `--manual-auth-hook=${authHookPath}`,
+ `--manual-cleanup-hook=${cleanupHookPath}`,
+ '--non-interactive',
+ '--agree-tos',
+ '--cert-name',
+ getCertificateStorageName(primaryDomain),
+ '-m',
+ adminEmail,
+ ...domains.flatMap((domain) => ['-d', domain]),
+ ];
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}`,
+ `[Certbot] Running command: certbot ${certbotArgs.join(' ')}`,
);
// 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}`,
- );
+ await runCommand('certbot', certbotArgs, {
+ env: {
+ DB_USER: dbUser,
+ DB_PASSWORD: dbPassword,
+ DB_HOST: dbHost,
+ DB_PORT: dbPort,
+ DB_NAME: dbName,
+ },
+ timeoutMs: 10 * 60 * 1000,
+ });
this.logger.log(
`[Certbot] Successfully obtained/renewed certificate for ${primaryDomain}`,
);
@@ -1194,8 +1257,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(
+ 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.`,
@@ -1204,12 +1273,12 @@ 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)}`,
);
}
}
@@ -1223,7 +1292,7 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown {
} as any);
for (const cert of failedCerts) {
- const domains = parseDomains(cert.domains);
+ const domains = parseDomains(cert.domains, { allowWildcard: true });
try {
this.logger.log(
`[Renewal] Retrying failed certificate for [${cert.domains}] (failureCount=${(cert as any).failureCount ?? 0})`,
@@ -1239,7 +1308,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
@@ -1265,25 +1334,23 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown {
keyPem: string;
chainPem?: string;
}) {
+ const domains = normalizeDomains(dto.domains, { allowWildcard: true });
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];
+ const hash = hashDomains(domains, { allowWildcard: true });
+ const domainsStr = joinDomains(domains, { allowWildcard: true });
+ const primaryDomain = 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
@@ -1314,8 +1381,8 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown {
} finally {
// Clean up temp file
try {
- fs.unlinkSync(certFile);
- } catch (e) {
+ fs.rmSync(tempDir, { recursive: true, force: true });
+ } catch {
// Ignore
}
}
@@ -1325,26 +1392,44 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown {
* Generate a self-signed certificate for testing/development
*/
async generateSelfSignedCertificate(domains: string[]) {
+ const normalizedDomains = normalizeDomains(domains, {
+ allowWildcard: true,
+ });
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 hash = hashDomains(normalizedDomains, { allowWildcard: true });
+ const domainsStr = joinDomains(normalizedDomains, {
+ allowWildcard: true,
+ });
+ 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');
@@ -1373,9 +1458,8 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown {
} finally {
// Clean up temp files
try {
- fs.unlinkSync(keyFile);
- fs.unlinkSync(certFile);
- } catch (e) {
+ fs.rmSync(tempDir, { recursive: true, force: true });
+ } catch {
// Ignore
}
}
@@ -1405,7 +1489,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,
@@ -1443,7 +1527,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,
@@ -1466,46 +1550,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
}
@@ -1552,7 +1649,7 @@ 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}` };
@@ -1569,7 +1666,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)) {
@@ -1592,16 +1691,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)}`,
};
@@ -1615,23 +1719,18 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown {
certPem: string,
keyPem: string,
): Promise {
- 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');
}
@@ -1639,9 +1738,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
}
}
@@ -1662,11 +1760,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,
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/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/tls-config.service.ts b/src/certificate/tls-config.service.ts
index d0a303d..d20358d 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,8 +227,8 @@ export class TlsConfigService {
};
} finally {
try {
- fs.unlinkSync(certFile);
- } catch (e) {
+ fs.rmSync(tempDir, { recursive: true, force: true });
+ } catch {
// Ignore
}
}
diff --git a/src/certificate/tls.controller.ts b/src/certificate/tls.controller.ts
index ec18651..d86b0ff 100644
--- a/src/certificate/tls.controller.ts
+++ b/src/certificate/tls.controller.ts
@@ -14,6 +14,7 @@ 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')
@@ -21,12 +22,16 @@ 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);
}
diff --git a/src/nginx/nginx.service.ts b/src/nginx/nginx.service.ts
index 78ddc52..defb127 100644
--- a/src/nginx/nginx.service.ts
+++ b/src/nginx/nginx.service.ts
@@ -2,6 +2,7 @@ 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;
@@ -25,18 +26,17 @@ export class NginxService {
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 { proxy_pass_host: proxyPassHost, nginx_custom_code: customCodeSource } =
- entry;
-
- const certPath = `${letsEncryptLiveRoot}/${primaryDomain}/fullchain.pem`;
- const keyPath = `${letsEncryptLiveRoot}/${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);
diff --git a/src/reloader/reloader.service.ts b/src/reloader/reloader.service.ts
index e3797a1..d25ec8c 100644
--- a/src/reloader/reloader.service.ts
+++ b/src/reloader/reloader.service.ts
@@ -28,6 +28,7 @@ import { TlsConfigService } from '../certificate/tls-config.service';
import { lookup } from 'dns/promises'; // <-- Added
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 =
@@ -36,10 +37,7 @@ 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_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;
@@ -139,14 +137,12 @@ export class ReloaderService implements OnModuleInit, OnModuleDestroy {
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`,
@@ -369,7 +365,9 @@ export class ReloaderService implements OnModuleInit, OnModuleDestroy {
private async ensureRuntimeLayout(): Promise {
await mkdir(NGINX_RELEASES_DIR, { recursive: true });
- let currentReleasePath = await this.getSymlinkTarget(NGINX_CURRENT_RELEASE_LINK);
+ let currentReleasePath = await this.getSymlinkTarget(
+ NGINX_CURRENT_RELEASE_LINK,
+ );
if (!currentReleasePath) {
currentReleasePath = await this.createBootstrapRelease();
await this.updateSymlinkAtomically(
@@ -466,8 +464,7 @@ export class ReloaderService implements OnModuleInit, OnModuleDestroy {
previousReleasePath,
failedReleasePath: releasePath,
validationOutput,
- activationError:
- error instanceof Error ? error.message : String(error),
+ activationError: error instanceof Error ? error.message : String(error),
});
}
@@ -556,9 +553,15 @@ export class ReloaderService implements OnModuleInit, OnModuleDestroy {
private async writeValidationConfig(releasePath: string): Promise {
const validationConfigPath = join(releasePath, '.validation-nginx.conf');
- const bundledConfig = await readFile(join(releasePath, 'nginx.conf'), 'utf8');
+ const bundledConfig = await readFile(
+ join(releasePath, 'nginx.conf'),
+ 'utf8',
+ );
const validationConfig = bundledConfig
- .replace(/^pid\s+.*;$/m, `pid ${join(releasePath, '.validation-nginx.pid')};`)
+ .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',
@@ -570,7 +573,9 @@ export class ReloaderService implements OnModuleInit, OnModuleDestroy {
return validationConfigPath;
}
- private async rewriteBundledDefaultConfig(releasePath: string): Promise {
+ private async rewriteBundledDefaultConfig(
+ releasePath: string,
+ ): Promise {
const defaultConfPath = join(releasePath, 'conf.d', 'default.conf');
if (!(await this.pathExists(defaultConfPath))) {
return;
@@ -608,7 +613,11 @@ export class ReloaderService implements OnModuleInit, OnModuleDestroy {
try {
return await readlink(linkPath);
} catch (error) {
- if (['ENOENT', 'EINVAL'].includes((error as NodeJS.ErrnoException).code ?? '')) {
+ if (
+ ['ENOENT', 'EINVAL'].includes(
+ (error as NodeJS.ErrnoException).code ?? '',
+ )
+ ) {
return null;
}
throw error;
@@ -624,7 +633,9 @@ export class ReloaderService implements OnModuleInit, OnModuleDestroy {
private async pruneOldReleases(
retainedReleasePaths: Array,
): Promise {
- const lastKnownGoodPath = await this.getSymlinkTarget(NGINX_LAST_KNOWN_GOOD_LINK);
+ const lastKnownGoodPath = await this.getSymlinkTarget(
+ NGINX_LAST_KNOWN_GOOD_LINK,
+ );
const protectedPaths = new Set(
[...retainedReleasePaths, lastKnownGoodPath]
.filter((value): value is string => Boolean(value))
@@ -695,4 +706,3 @@ type ActivatedRelease = {
phase: string;
validationOutput: string;
};
-
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();
+
+ 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/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 {
+ 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/session15/domain-validation-and-safe-process.test.js b/test/session15/domain-validation-and-safe-process.test.js
new file mode 100644
index 0000000..0a02232
--- /dev/null
+++ b/test/session15/domain-validation-and-safe-process.test.js
@@ -0,0 +1,303 @@
+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 = 'session15@example.test';
+ resetModules();
+ const { CertificateService } = require(certificateServicePath);
+
+ 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,
+ },
+ proxyEntry: {
+ findMany: async () => [],
+ },
+ };
+ const alertService = {
+ sendAlert: async () => undefined,
+ };
+ const clusterOperations = {
+ enqueueBroadcastOperation: async () => ({ operationId: 'op-1' }),
+ };
+ 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,
+ clusterOperations,
+ distributedLock,
+ healthService,
+ null,
+ );
+}
+
+beforeEach(() => {
+ process.env.ADMIN_EMAIL = 'session15@example.test';
+ restoreExecFile();
+ resetModules();
+});
+
+afterEach(() => {
+ restoreExecFile();
+ resetModules();
+ if (originalAdminEmail === undefined) {
+ delete process.env.ADMIN_EMAIL;
+ } else {
+ process.env.ADMIN_EMAIL = originalAdminEmail;
+ }
+});
+
+describe('Session 15 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('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 until DNS-01 support exists', async () => {
+ const service = createCertificateService();
+
+ await assert.rejects(
+ () => service.ensureCertificate(['*.example.com', 'example.com']),
+ /DNS-01/i,
+ );
+ });
+
+ it('generates self-signed certificates through safe OpenSSL args and stores normalized domains', async () => {
+ const calls = installExecFileStub(async ({ command, args }) => {
+ 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: '' };
+ }
+
+ 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 ',
+ ]);
+
+ assert.equal(record.domains, 'xn--exmple-cua.com');
+ assert.equal(writtenCertificate.primaryDomain, 'xn--exmple-cua.com');
+ assert.equal(calls.length, 2);
+ assert.deepEqual(calls[0].args, [
+ 'genrsa',
+ '-out',
+ calls[0].args[2],
+ '2048',
+ ]);
+ assert.deepEqual(calls[1].args.slice(0, 8), [
+ 'req',
+ '-new',
+ '-x509',
+ '-key',
+ calls[0].args[2],
+ '-out',
+ calls[1].args[6],
+ '-days',
+ ]);
+ assert.equal(
+ calls[1].args[calls[1].args.indexOf('-subj') + 1],
+ '/CN=xn--exmple-cua.com',
+ );
+ assert.equal(
+ calls[1].args[calls[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;
+ }
+ });
+});
From 383eaa0006f1073e24f6f89b81b67c597d70c0a4 Mon Sep 17 00:00:00 2001
From: Stualyttle <27898452+Stualyttle@users.noreply.github.com.>
Date: Sun, 24 May 2026 19:16:53 +0200
Subject: [PATCH 18/43] feat: implement durable certificate order state machine
with artifact history and retryable workflows
---
ARCHITECTURE_DECISIONS.md | 43 ++
IMPLEMENTATION_STATUS.md | 43 +-
README.md | 50 +-
.../migration.sql | 97 +++
prisma/schema.prisma | 79 +++
.../certificate-order.constants.ts | 40 ++
src/certificate/certificate-order.service.ts | 550 ++++++++++++++++++
src/certificate/certificate.controller.ts | 56 +-
src/certificate/certificate.module.ts | 2 +
src/certificate/certificate.service.ts | 349 ++++++++++-
src/certificate/dto/certificate-order.dto.ts | 59 ++
...domain-validation-and-safe-process.test.js | 8 +
.../certificate-order-state-machine.test.js | 544 +++++++++++++++++
13 files changed, 1879 insertions(+), 41 deletions(-)
create mode 100644 prisma/migrations/20260524191500_session16_certificate_orders/migration.sql
create mode 100644 src/certificate/certificate-order.constants.ts
create mode 100644 src/certificate/certificate-order.service.ts
create mode 100644 src/certificate/dto/certificate-order.dto.ts
create mode 100644 test/session16/certificate-order-state-machine.test.js
diff --git a/ARCHITECTURE_DECISIONS.md b/ARCHITECTURE_DECISIONS.md
index f9ff7e3..4fd5259 100644
--- a/ARCHITECTURE_DECISIONS.md
+++ b/ARCHITECTURE_DECISIONS.md
@@ -34,6 +34,7 @@ This file records repository-level architectural and delivery decisions so futur
| ADR-016 | Staged NGINX runtime releases with atomic activation and rollback | accepted | Session 13 | 2026-05-24 |
| ADR-017 | Validated allowlisted `nginx_custom_code` fragments | accepted | Session 14 | 2026-05-24 |
| ADR-018 | Strict normalized certificate domains and argument-array process execution | accepted | Session 15 | 2026-05-24 |
+| ADR-019 | Durable certificate orders with artifact history and retryable lifecycle state | accepted | Session 16 | 2026-05-24 |
---
@@ -625,3 +626,45 @@ Adopt the following certificate-domain and certificate-process policy:
- wildcard certificate issuance remains intentionally unavailable in the current HTTP-01 flow, which makes the limitation explicit until Session 18 lands a wildcard-safe ACME strategy
- generated NGINX certificate paths, local certificate directories, and backup archive paths no longer depend on raw domain text, reducing path-confusion risk
- future certificate lifecycle work should build on the shared normalized-domain utilities and safe process helper instead of reintroducing ad hoc validation or shell command construction
+
+---
+
+## ADR-019 — Durable certificate orders with artifact history and retryable lifecycle state
+
+- Status: accepted
+- Session: Session 16 — Add certificate order state machine
+- Date: 2026-05-24
+
+### Context
+
+The production-readiness assessment called out that certificate issuance was still modeled mostly as transient process execution plus the final `Certificate` row. That left the system with weak visibility into mid-flight state, poor retry history, and no durable artifact-version trail for later activation/rollback work.
+
+### Decision
+
+Adopt a certificate-order lifecycle model with three persistent record types:
+
+1. `CertificateOrder`
+ - captures the workflow intent, normalized domains, source type, current state, attempt counters, retry schedule, and final linked certificate
+2. `CertificateOrderEvent`
+ - records state transitions and retry/backoff history for each order without relying on ephemeral logs
+3. `CertificateArtifactVersion`
+ - stores versioned certificate artifacts so future distribution and rollback work can build on stable artifact history instead of only the latest active certificate row
+
+The initial Session 16 state vocabulary is:
+
+- `requested`
+- `challenge-published`
+- `validating`
+- `issued`
+- `distributing`
+- `activated`
+- `failed`
+- `revoked`
+
+### Consequences
+
+- ACME issuance, uploaded certificates, and self-signed certificates now create durable workflow records that can be queried through dedicated order APIs instead of being inferred from logs alone
+- failed ACME orders now persist retry/backoff history and can be resumed safely through an explicit retry path rather than only by hoping a later scheduled renewal happens to recreate context
+- certificate artifact metadata is now versioned, which gives Session 17 a concrete foundation for separating issuance from cluster-wide activation and for adding rollback-aware distribution state
+- artifact history currently stores the same PEM material as the existing certificate table, so Session 19 must build encryption-at-rest on top of this new lifecycle model before it is suitable for hardened production key storage
+
diff --git a/IMPLEMENTATION_STATUS.md b/IMPLEMENTATION_STATUS.md
index 40c7a47..792e3ad 100644
--- a/IMPLEMENTATION_STATUS.md
+++ b/IMPLEMENTATION_STATUS.md
@@ -9,8 +9,8 @@ Use it as the single place to record what has shipped, what is in progress, and
- Overall status: in progress
- Current phase: Phase 5 — Certificate lifecycle redesign
-- Most recently completed session: Session 15 — Add strict domain validation and safe process execution
-- Next recommended session from the roadmap: Session 16 — Add certificate order state machine
+- Most recently completed session: Session 16 — Add certificate order state machine
+- Next recommended session from the roadmap: Session 17 — Rework cluster certificate distribution and activation
- Readiness reference: `PRODUCTION_READINESS_ASSESSMENT.md`
- Architecture decision log: `ARCHITECTURE_DECISIONS.md`
@@ -574,13 +574,40 @@ Use it as the single place to record what has shipped, what is in progress, and
## Session 16 — Add certificate order state machine
-- Status: not started
+- Status: done
- Objective: model certificate issuance as a durable workflow
-- Files touched: none yet
-- Tests added/updated: none yet
-- Risks: certificate lifecycle remains weakly modeled and hard to resume safely
-- Follow-up sessions: Session 17, Session 18, Session 25
-- Notes: retry and history persistence are still pending
+- Files touched:
+ - `prisma/schema.prisma`
+ - `prisma/migrations/20260524191500_session16_certificate_orders/migration.sql`
+ - `src/certificate/certificate-order.constants.ts`
+ - `src/certificate/certificate-order.service.ts`
+ - `src/certificate/dto/certificate-order.dto.ts`
+ - `src/certificate/certificate.service.ts`
+ - `src/certificate/certificate.controller.ts`
+ - `src/certificate/certificate.module.ts`
+ - `README.md`
+ - `ARCHITECTURE_DECISIONS.md`
+ - `IMPLEMENTATION_STATUS.md`
+ - `test/session15/domain-validation-and-safe-process.test.js`
+ - `test/session16/certificate-order-state-machine.test.js`
+- Tests added/updated:
+ - added `test/session16/certificate-order-state-machine.test.js` to verify durable self-signed order history, artifact-version tracking, failed ACME order persistence, and manual retry/resume behavior
+ - updated the Session 15 certificate-service harness for the new order-service dependency
+ - ran `npm run prisma:generate`, `npm run typecheck`, the focused Session 16 regression, the full `npm test` suite, and `npm run build` successfully after landing the new workflow models and APIs
+- Risks:
+ - order activation is still local-first and immediate; Session 17 remains responsible for separating issuance from cluster-wide distribution and ACK-driven activation
+ - uploaded/imported orders are queryable but not automatically retryable because replaying those workflows safely still requires operator-supplied certificate material and later restore hardening
+ - certificate artifact versions currently store plaintext PEMs alongside the existing certificate table, so Session 19 remains responsible for encryption-at-rest before this history is suitable for production key storage
+- Follow-up sessions:
+ - Session 17 — rework cluster certificate distribution and activation
+ - Session 18 — harden the ACME strategy for clustered production
+ - Session 19 — encrypt private key material at rest
+ - Session 25 — expand metrics and alerting
+- Notes:
+ - added durable `CertificateOrder`, `CertificateOrderEvent`, and `CertificateArtifactVersion` models so certificate workflows now persist their lifecycle, retry/backoff history, and artifact-version metadata
+ - ACME issuance, uploaded certificates, and self-signed certificates now create order records and transition through explicit lifecycle states instead of relying only on ephemeral logs and the final `Certificate` row
+ - failed ACME issuance now records per-order retry scheduling, and operators can inspect or manually retry orders through the new `GET /certificates/orders`, `GET /certificates/orders/:id`, and `POST /certificates/orders/:id/retry` APIs
+ - order read APIs intentionally omit raw PEM payloads even though artifact history is now persisted, keeping lifecycle observability separate from private-key export flows
## Session 17 — Rework cluster certificate distribution and activation
diff --git a/README.md b/README.md
index e732e9e..a8df926 100644
--- a/README.md
+++ b/README.md
@@ -17,6 +17,8 @@
+
+
@@ -31,8 +33,8 @@ Built with [NestJS](https://nestjs.com/) • Powered by [PostgreSQL](https://www
## 📍 Current Delivery Status
- **Roadmap status:** Phase 5 in progress
-- **Completed in Sessions 1-15 plus follow-up maintenance:** delivery scaffolding, dependency hygiene, authenticated-by-default admin APIs, dependency-aware health semantics, fail-fast container supervision, explicit inter-node control-plane addressing, an identity-aware auth foundation, explicit RBAC authorization policies, durable audit logging for privileged and mutating operations, durable leader leases, lease-backed heartbeat/leader reconciliation, durable cluster operation journaling with per-node ACK tracking, staged NGINX release activation with rollback-safe config deployment, validated allowlisted custom NGINX fragments, and strict certificate-domain validation with safe process execution
-- **Next recommended implementation session:** Session 16 — add certificate order state machine
+- **Completed in Sessions 1-16 plus follow-up maintenance:** delivery scaffolding, dependency hygiene, authenticated-by-default admin APIs, dependency-aware health semantics, fail-fast container supervision, explicit inter-node control-plane addressing, an identity-aware auth foundation, explicit RBAC authorization policies, durable audit logging for privileged and mutating operations, durable leader leases, lease-backed heartbeat/leader reconciliation, durable cluster operation journaling with per-node ACK tracking, staged NGINX release activation with rollback-safe config deployment, validated allowlisted custom NGINX fragments, strict certificate-domain validation with safe process execution, and durable certificate-order state tracking with artifact history and retryable workflows
+- **Next recommended implementation session:** Session 17 — rework cluster certificate distribution and activation
- **Canonical planning and status docs:**
- [`PRODUCTION_READINESS_ASSESSMENT.md`](PRODUCTION_READINESS_ASSESSMENT.md)
- [`IMPLEMENTATION_PLAN_BY_SESSION.md`](IMPLEMENTATION_PLAN_BY_SESSION.md)
@@ -65,7 +67,7 @@ node -v # expected: v24.16.0
npm -v # expected: 11.15.0
```
-`npm run test` now runs the focused Session 3-14 regression tests for API access control, health semantics, container-supervision behavior, inter-node addressing, the identity-aware auth foundation, RBAC authorization policy enforcement, audit-logging regressions, lease-backed cluster coordination behavior, cluster-operation journaling, transactional NGINX rollout behavior, and guarded `nginx_custom_code` validation. Session 26 still remains the planned milestone for broad unit/integration/e2e harness expansion.
+`npm run test` now runs the focused Session 3-16 regression tests for API access control, health semantics, container-supervision behavior, inter-node addressing, the identity-aware auth foundation, RBAC authorization policy enforcement, audit-logging regressions, lease-backed cluster coordination behavior, cluster-operation journaling, transactional NGINX rollout behavior, guarded `nginx_custom_code` validation, strict domain/process safety, and durable certificate-order lifecycle tracking. Session 26 still remains the planned milestone for broad unit/integration/e2e harness expansion.
---
@@ -602,6 +604,48 @@ Certificate domain input is now normalized and validated strictly before any fil
- path separators, whitespace, control characters, and shell metacharacter tricks are rejected early
- wildcard issuance is currently rejected for the built-in ACME flow because the current implementation is still HTTP-01-based and DNS-01 support has not landed yet
+### Certificate order state machine
+
+Session 16 adds a durable certificate-order workflow so issuance is no longer just an ephemeral `certbot` run plus a best-effort database write.
+
+Current order states are:
+
+- `requested`
+- `challenge-published`
+- `validating`
+- `issued`
+- `distributing`
+- `activated`
+- `failed`
+- `revoked`
+
+Each order now stores:
+
+- normalized domains and primary domain
+- source type (`acme`, `uploaded`, `self-signed`, `imported`)
+- attempt and retry counters
+- next retry time for failed ACME orders
+- per-order event history
+- certificate artifact versions linked to the order
+
+The order read APIs intentionally return metadata and history only; they do **not** expose raw certificate private keys.
+
+```bash
+# List recent certificate orders
+curl http://localhost:3000/certificates/orders \
+ -H "Authorization: Bearer $ADMIN_TOKEN"
+
+# Inspect one order, its state transitions, and artifact-version history
+curl http://localhost:3000/certificates/orders/ \
+ -H "Authorization: Bearer $ADMIN_TOKEN"
+
+# Retry a failed ACME or self-signed order
+curl -X POST http://localhost:3000/certificates/orders//retry \
+ -H "Authorization: Bearer $ADMIN_TOKEN"
+```
+
+> Current limitation: activation is still local-first today. Session 17 remains responsible for separating issuance from cluster-wide distribution/activation ACKs.
+
### Upload Custom Certificate
```bash
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/schema.prisma b/prisma/schema.prisma
index c199a65..493b39e 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -37,12 +37,91 @@ 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
+ issuedAt DateTime
+ expiresAt DateTime
+ activatedAt 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])
+}
+
model ClusterNode {
id String @id @default(uuid())
hostname String
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..4dbcdd2
--- /dev/null
+++ b/src/certificate/certificate-order.service.ts
@@ -0,0 +1,550 @@
+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';
+
+const STATUS_TIMESTAMP_FIELDS: Partial> =
+ {
+ 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 | 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 | null;
+ occurredAt: Date;
+};
+
+type CertificateArtifactRecord = {
+ id: string;
+ certificateId: string | null;
+ version: number;
+ sourceType: CertificateOrderSourceType;
+ issuedAt: Date;
+ expiresAt: Date;
+ activatedAt: Date | null;
+ createdByNode: string | null;
+ createdAt: Date;
+};
+
+@Injectable()
+export class CertificateOrderService {
+ private readonly logger = new Logger(CertificateOrderService.name);
+
+ constructor(private readonly prisma: PrismaService) {}
+
+ async getOrCreateOrder(params: {
+ domains: string[];
+ sourceType: CertificateOrderSourceType;
+ requestedByNode?: string | null;
+ existingOrderId?: string;
+ metadata?: Record;
+ }): Promise {
+ 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,
+ },
+ })) 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 {
+ 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 | null;
+ lastError?: string | null;
+ nextRetryAt?: Date | null;
+ data?: Record;
+ } = {},
+ ): Promise {
+ 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 = {
+ ...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 | null = null,
+ ): Promise {
+ 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 | null;
+ },
+ ): Promise {
+ 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;
+ domains: string[];
+ sourceType: CertificateOrderSourceType;
+ certPem: string;
+ keyPem: string;
+ issuedAt: Date;
+ expiresAt: Date;
+ activatedAt?: Date | null;
+ createdByNode?: string | null;
+ metadata?: Record | 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 artifact = (await this.prisma.certificateArtifactVersion.create({
+ data: {
+ orderId: params.orderId,
+ certificateId: params.certificateId,
+ domains: joinDomains(domains, { allowWildcard: true }),
+ domainsHash,
+ version,
+ sourceType: params.sourceType,
+ certPem: params.certPem,
+ keyPem: params.keyPem,
+ issuedAt: params.issuedAt,
+ expiresAt: params.expiresAt,
+ activatedAt: params.activatedAt ?? null,
+ createdByNode: params.createdByNode ?? null,
+ metadata: params.metadata ?? undefined,
+ },
+ })) 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 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 {
+ 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}`);
+ }
+
+ return this.toDetailDto(order);
+ }
+
+ async validateRetryableOrder(
+ orderId: string,
+ ): Promise {
+ 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 | null;
+ },
+ ): Promise {
+ 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,
+ },
+ });
+ }
+
+ 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,
+ createdAt: order.createdAt,
+ updatedAt: order.updatedAt,
+ };
+ }
+
+ private toDetailDto(
+ order: CertificateOrderRecord & {
+ events: CertificateOrderEventRecord[];
+ artifacts: CertificateArtifactRecord[];
+ },
+ ): 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,
+ createdByNode: artifact.createdByNode,
+ createdAt: artifact.createdAt,
+ })),
+ };
+ }
+}
diff --git a/src/certificate/certificate.controller.ts b/src/certificate/certificate.controller.ts
index 62ecaa5..a31656e 100644
--- a/src/certificate/certificate.controller.ts
+++ b/src/certificate/certificate.controller.ts
@@ -17,6 +17,10 @@ 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 {
+ CertificateOrderDetailDto,
+ CertificateOrderSummaryDto,
+} from './dto/certificate-order.dto';
import {
AuthorizeAdmin,
AuthorizeInternalNodeOrAdmin,
@@ -35,9 +39,32 @@ export class CertificateController {
return this.certificateService.listCertificates();
}
- @Get(':id')
- async getCertificate(@Param('id') id: string): Promise {
- 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 {
+ return this.certificateService.getCertificateOrder(id);
}
@Post('upload')
@@ -108,6 +135,24 @@ export class CertificateController {
}
}
+ @Post('orders/:id/retry')
+ @HttpCode(HttpStatus.OK)
+ @AuthorizeAdmin('operator')
+ @Audit({ action: 'certificate.order.retry' })
+ async retryCertificateOrder(
+ @Param('id') id: string,
+ ): Promise {
+ try {
+ return await this.certificateService.retryCertificateOrder(id);
+ } catch (error) {
+ throw new BadRequestException(
+ error instanceof Error
+ ? error.message
+ : 'Failed to retry certificate order',
+ );
+ }
+ }
+
@Post('renew/:id')
@UseGuards(ApiKeyGuard)
@HttpCode(HttpStatus.OK)
@@ -148,6 +193,11 @@ export class CertificateController {
return this.certificateService.checkAllCertificatesOcspSupport();
}
+ @Get(':id')
+ async getCertificate(@Param('id') id: string): Promise {
+ 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
diff --git a/src/certificate/certificate.module.ts b/src/certificate/certificate.module.ts
index 3ad65d1..a3c890d 100644
--- a/src/certificate/certificate.module.ts
+++ b/src/certificate/certificate.module.ts
@@ -7,6 +7,7 @@ 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 { TlsConfigService } from './tls-config.service';
import { PrismaService } from '../prisma/prisma.service';
import { ScheduleModule } from '@nestjs/schedule';
@@ -24,6 +25,7 @@ import { HealthModule } from '../health/health.module';
],
providers: [
CertificateService,
+ CertificateOrderService,
CertificateCleanupService,
CertificateMonitorService,
CertificateBackupService,
diff --git a/src/certificate/certificate.service.ts b/src/certificate/certificate.service.ts
index a5788ff..f527023 100644
--- a/src/certificate/certificate.service.ts
+++ b/src/certificate/certificate.service.ts
@@ -22,6 +22,8 @@ import {
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';
@@ -50,6 +52,7 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown {
constructor(
private prisma: PrismaService,
private alertService: AlertService,
+ private certificateOrders: CertificateOrderService,
private clusterOperations: ClusterOperationsService,
private distributedLock: DistributedLockService,
private healthService: HealthService,
@@ -518,6 +521,16 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown {
);
}
+ 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),
+ );
+ }
+
/**
* Check Let's Encrypt rate limits before certificate issuance
* Let's Encrypt limits: 50 certificates per registered domain per week
@@ -810,13 +823,21 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown {
}
}
- async ensureCertificate(domainsInput: string[] | string): Promise {
+ async ensureCertificate(
+ domainsInput: string[] | string,
+ options: {
+ orderId?: string;
+ sourceType?: CertificateOrderSourceType;
+ } = {},
+ ): Promise {
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)
? normalizeDomains(domainsInput as string[], { allowWildcard: true })
: parseDomains(domainsInput as string, { allowWildcard: true });
@@ -827,6 +848,18 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown {
const domainsStr = joinDomains(domains, { allowWildcard: true });
const now = new Date();
const renewBefore = addDays(now, RENEW_BEFORE_DAYS);
+ const instanceId = this.distributedLock.getInstanceId();
+
+ const order = await this.certificateOrders.getOrCreateOrder({
+ domains,
+ sourceType,
+ requestedByNode: instanceId,
+ existingOrderId: options.orderId,
+ metadata: {
+ flow: 'certificate.ensure',
+ renewBeforeDays: RENEW_BEFORE_DAYS,
+ },
+ });
this.logger.log(
`[Cert] Ensuring certificate for [${domainsStr}] (hash: ${hash})`,
@@ -857,6 +890,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;
}
@@ -865,6 +906,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({
@@ -874,8 +923,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) =>
@@ -918,6 +968,15 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown {
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;
}
@@ -927,6 +986,19 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown {
);
try {
+ await this.certificateOrders.transitionOrder(
+ order.id,
+ 'challenge-published',
+ {
+ message:
+ 'Starting ACME manual challenge publication and certificate issuance',
+ details: {
+ lockName,
+ sourceType,
+ },
+ },
+ );
+
// 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';
@@ -951,7 +1023,7 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown {
message: errorMsg,
metadata: {
domains: domainsStr,
- instanceId: this.distributedLock.getInstanceId(),
+ instanceId,
timestamp: new Date().toISOString(),
},
})
@@ -999,6 +1071,11 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown {
`[Certbot] Successfully obtained/renewed certificate for ${primaryDomain}`,
);
+ await this.certificateOrders.transitionOrder(order.id, 'validating', {
+ message:
+ 'ACME order completed; validating generated certificate artifacts',
+ });
+
// Read the cert/key files produced by certbot
const certPath = path.join(
this.certDir(primaryDomain),
@@ -1080,6 +1157,14 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown {
`[DB] Certificate validated; expiresAt=${validation.expiresAt}`,
);
+ await this.certificateOrders.transitionOrder(order.id, 'issued', {
+ message:
+ 'Validated certificate and private key produced by ACME flow',
+ details: {
+ expiresAt: validation.expiresAt?.toISOString() ?? null,
+ },
+ });
+
this.logger.log(`[DB] Saving certificate to database...`);
const certRecord = await this.prisma.certificate.upsert({
where: { domainsHash } as any,
@@ -1094,7 +1179,7 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown {
failureReason: null,
retryAfter: null,
failureCount: 0,
- issuedByNode: this.distributedLock.getInstanceId(),
+ issuedByNode: instanceId,
} as any,
create: {
domains: domainsStr,
@@ -1106,7 +1191,7 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown {
lastUsedAt: now,
isOrphaned: false,
status: 'active',
- issuedByNode: this.distributedLock.getInstanceId(),
+ issuedByNode: instanceId,
} as any,
});
@@ -1116,25 +1201,59 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown {
this.logger.log(
`[DB] Certificate saved (id: ${certRecord.id}, expires: ${certRecord.expiresAt.toISOString()}). Writing to FS...`,
);
+ await this.certificateOrders.transitionOrder(
+ order.id,
+ 'distributing',
+ {
+ message:
+ 'Persisted certificate in the database and preparing local activation',
+ details: {
+ certificateId: certRecord.id,
+ },
+ },
+ );
this.writeCertToFs(primaryDomain, certPem, keyPem);
+
+ await this.certificateOrders.recordArtifact({
+ orderId: order.id,
+ certificateId: certRecord.id,
+ domains,
+ sourceType,
+ certPem,
+ keyPem,
+ issuedAt: certRecord.issuedAt,
+ expiresAt: certRecord.expiresAt,
+ activatedAt: new Date(),
+ createdByNode: instanceId,
+ metadata: {
+ activation: 'local-filesystem',
+ },
+ });
+
+ await this.certificateOrders.completeWithCertificate(order.id, {
+ certificateId: certRecord.id,
+ message:
+ 'Certificate activated on the local node after ACME issuance',
+ details: {
+ primaryDomain,
+ lockName,
+ },
+ });
return certRecord;
} 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);
// 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({
@@ -1144,7 +1263,7 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown {
failureReason: message,
retryAfter,
failureCount: { increment: 1 },
- issuedByNode: this.distributedLock.getInstanceId(),
+ issuedByNode: instanceId,
} as any,
});
} else {
@@ -1163,11 +1282,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',
@@ -1177,7 +1308,7 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown {
domains: domainsStr,
error: message,
stack,
- instanceId: this.distributedLock.getInstanceId(),
+ instanceId,
retryAfter: retryAfter.toISOString(),
},
})
@@ -1216,11 +1347,28 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown {
`[DB] Found certificate created by another node (id: ${certEntry.id}). Using it.`,
);
this.writeCertToFs(primaryDomain, certEntry.certPem, certEntry.keyPem);
+ 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
@@ -1231,8 +1379,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) =>
@@ -1283,24 +1432,26 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown {
}
}
- // 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, { allowWildcard: true });
+ for (const order of retryableOrders) {
+ const domains = parseDomains(order.domains, { allowWildcard: true });
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.ensureCertificate(domains, { orderId: 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)}`,
);
}
}
@@ -1335,6 +1486,15 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown {
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(domains, { allowWildcard: true })}]`,
);
@@ -1357,6 +1517,13 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown {
? `${dto.certPem}\n${dto.chainPem}`
: dto.certPem;
+ await this.certificateOrders.transitionOrder(order.id, 'issued', {
+ message: 'Validated uploaded certificate material',
+ details: {
+ expiresAt: expiresAt.toISOString(),
+ },
+ });
+
// Write to filesystem
this.writeCertToFs(primaryDomain, fullChainPem, dto.keyPem);
@@ -1371,6 +1538,36 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown {
issuedAt: new Date(),
lastUsedAt: new Date(),
isOrphaned: false,
+ status: 'active',
+ issuedByNode: instanceId,
+ },
+ });
+
+ await this.certificateOrders.transitionOrder(order.id, 'distributing', {
+ message: 'Persisted uploaded certificate and activated it locally',
+ details: {
+ certificateId: certRecord.id,
+ },
+ });
+
+ await this.certificateOrders.recordArtifact({
+ orderId: order.id,
+ certificateId: certRecord.id,
+ domains,
+ sourceType: 'uploaded',
+ certPem: fullChainPem,
+ keyPem: dto.keyPem,
+ issuedAt: certRecord.issuedAt,
+ expiresAt: certRecord.expiresAt,
+ activatedAt: new Date(),
+ createdByNode: instanceId,
+ });
+
+ await this.certificateOrders.completeWithCertificate(order.id, {
+ certificateId: certRecord.id,
+ message: 'Uploaded certificate activated on the local node',
+ details: {
+ primaryDomain,
},
});
@@ -1378,6 +1575,12 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown {
`[Upload] Successfully uploaded certificate (id: ${certRecord.id})`,
);
return certRecord;
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ await this.certificateOrders.markFailure(order.id, message, null, {
+ sourceType: 'uploaded',
+ });
+ throw error;
} finally {
// Clean up temp file
try {
@@ -1391,10 +1594,23 @@ 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(normalizedDomains, { allowWildcard: true })}]`,
);
@@ -1434,6 +1650,13 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown {
const certPem = fs.readFileSync(certFile, 'utf8');
const keyPem = fs.readFileSync(keyFile, 'utf8');
+ await this.certificateOrders.transitionOrder(order.id, 'issued', {
+ message: 'Generated self-signed certificate material successfully',
+ details: {
+ primaryDomain,
+ },
+ });
+
// Write to filesystem
this.writeCertToFs(primaryDomain, certPem, keyPem);
@@ -1448,6 +1671,36 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown {
issuedAt: new Date(),
lastUsedAt: new Date(),
isOrphaned: false,
+ status: 'active',
+ issuedByNode: instanceId,
+ },
+ });
+
+ await this.certificateOrders.transitionOrder(order.id, 'distributing', {
+ message: 'Persisted self-signed certificate and activated it locally',
+ details: {
+ certificateId: certRecord.id,
+ },
+ });
+
+ await this.certificateOrders.recordArtifact({
+ orderId: order.id,
+ certificateId: certRecord.id,
+ domains: normalizedDomains,
+ sourceType: 'self-signed',
+ certPem,
+ keyPem,
+ issuedAt: certRecord.issuedAt,
+ expiresAt: certRecord.expiresAt,
+ activatedAt: new Date(),
+ createdByNode: instanceId,
+ });
+
+ await this.certificateOrders.completeWithCertificate(order.id, {
+ certificateId: certRecord.id,
+ message: 'Self-signed certificate activated on the local node',
+ details: {
+ primaryDomain,
},
});
@@ -1455,6 +1708,12 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown {
`[Self-Signed] Successfully generated certificate (id: ${certRecord.id})`,
);
return certRecord;
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ await this.certificateOrders.markFailure(order.id, message, null, {
+ sourceType: 'self-signed',
+ });
+ throw error;
} finally {
// Clean up temp files
try {
@@ -1540,6 +1799,42 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown {
};
}
+ async listCertificateOrders(limit?: number) {
+ return this.certificateOrders.listOrders(limit);
+ }
+
+ async getCertificateOrder(orderId: string) {
+ return this.certificateOrders.getOrder(orderId);
+ }
+
+ async retryCertificateOrder(orderId: string) {
+ const order = await this.certificateOrders.validateRetryableOrder(orderId);
+
+ if (order.sourceType === 'uploaded' || order.sourceType === 'imported') {
+ 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,
+ });
+
+ 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
*/
diff --git a/src/certificate/dto/certificate-order.dto.ts b/src/certificate/dto/certificate-order.dto.ts
new file mode 100644
index 0000000..ceb14f9
--- /dev/null
+++ b/src/certificate/dto/certificate-order.dto.ts
@@ -0,0 +1,59 @@
+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;
+ createdByNode: string | null;
+ createdAt: Date;
+}
+
+export class CertificateOrderEventDto {
+ id: string;
+ eventType: string;
+ fromStatus: CertificateOrderStatus | null;
+ toStatus: CertificateOrderStatus | null;
+ message: string | null;
+ attemptNumber: number | null;
+ retryAt: Date | null;
+ details: Record | 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;
+ createdAt: Date;
+ updatedAt: Date;
+}
+
+export class CertificateOrderDetailDto extends CertificateOrderSummaryDto {
+ events: CertificateOrderEventDto[];
+ artifacts: CertificateOrderArtifactDto[];
+}
diff --git a/test/session15/domain-validation-and-safe-process.test.js b/test/session15/domain-validation-and-safe-process.test.js
index 0a02232..fbf98c9 100644
--- a/test/session15/domain-validation-and-safe-process.test.js
+++ b/test/session15/domain-validation-and-safe-process.test.js
@@ -92,6 +92,13 @@ function createCertificateService() {
const alertService = {
sendAlert: async () => undefined,
};
+ const certificateOrders = {
+ getOrCreateOrder: async () => ({ id: 'order-1', attemptCount: 1 }),
+ transitionOrder: async () => undefined,
+ markFailure: async () => undefined,
+ recordArtifact: async () => ({ id: 'artifact-1', version: 1 }),
+ completeWithCertificate: async () => undefined,
+ };
const clusterOperations = {
enqueueBroadcastOperation: async () => ({ operationId: 'op-1' }),
};
@@ -111,6 +118,7 @@ function createCertificateService() {
return new CertificateService(
prisma,
alertService,
+ certificateOrders,
clusterOperations,
distributedLock,
healthService,
diff --git a/test/session16/certificate-order-state-machine.test.js b/test/session16/certificate-order-state-machine.test.js
new file mode 100644
index 0000000..70e9a8d
--- /dev/null
+++ b/test/session16/certificate-order-state-machine.test.js
@@ -0,0 +1,544 @@
+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() {
+ 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 } = {}) {
+ const order = getOrderById(where.id);
+ if (!order) {
+ return null;
+ }
+ 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;
+ }
+ 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(),
+ activatedAt: data.activatedAt ?? null,
+ metadata: data.metadata ?? null,
+ ...data,
+ };
+ state.artifacts.push(artifact);
+ return clone(artifact);
+ },
+ },
+ proxyEntry: {
+ async findMany() {
+ return [];
+ },
+ },
+ };
+}
+
+function createHarness() {
+ process.env.ADMIN_EMAIL = 'session16@example.test';
+ resetModules();
+ const { CertificateOrderService } = require(certificateOrderServicePath);
+ const { CertificateService } = require(certificateServicePath);
+
+ const prisma = createPrismaMock();
+ const alertService = {
+ sendAlert: async () => undefined,
+ };
+ const orderService = new CertificateOrderService(prisma);
+ const clusterOperations = {
+ enqueueBroadcastOperation: async () => ({ operationId: 'op-1' }),
+ };
+ 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,
+ );
+
+ service.writeCertToFs = () => undefined;
+
+ return { prisma, service, orderService };
+}
+
+beforeEach(() => {
+ process.env.ADMIN_EMAIL = 'session16@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('Session 16 certificate order state machine', () => {
+ it('records self-signed certificate workflows as durable orders with artifact history', async () => {
+ installExecFileStub(async ({ command, args }) => {
+ 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: '' };
+ }
+
+ 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 () => {
+ process.env.DATABASE_URL = 'not-a-postgresql-url';
+ const { service, orderService, prisma } = createHarness();
+ const { hashDomains } = require(domainUtilsPath);
+ const domains = ['example.com'];
+ const domainsHash = hashDomains(domains, { allowWildcard: true });
+
+ await assert.rejects(
+ () => service.ensureCertificate(domains),
+ /Could not parse DATABASE_URL/,
+ );
+
+ 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 ?? '', /Could not parse DATABASE_URL/);
+ 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,
+ );
+ });
+});
+
From 2435f9fe1474bdaa16207a644c17bfc0fb822133 Mon Sep 17 00:00:00 2001
From: Stualyttle <27898452+Stualyttle@users.noreply.github.com.>
Date: Sun, 24 May 2026 19:32:23 +0200
Subject: [PATCH 19/43] feat: implement ACK-backed certificate artifact
activation with rollback support
---
ARCHITECTURE_DECISIONS.md | 31 +
IMPLEMENTATION_STATUS.md | 43 +-
README.md | 9 +-
.../migration.sql | 9 +
prisma/schema.prisma | 5 +
src/certificate/certificate-order.service.ts | 141 +++-
src/certificate/certificate.controller.ts | 54 ++
src/certificate/certificate.service.ts | 608 +++++++++++-----
src/certificate/dto/certificate-order.dto.ts | 27 +
.../cluster-operations.service.ts | 78 ++-
...domain-validation-and-safe-process.test.js | 109 ++-
.../certificate-order-state-machine.test.js | 127 +++-
...tificate-distribution-and-rollback.test.js | 655 ++++++++++++++++++
13 files changed, 1709 insertions(+), 187 deletions(-)
create mode 100644 prisma/migrations/20260524200000_session17_certificate_distribution_activation/migration.sql
create mode 100644 test/session17/certificate-distribution-and-rollback.test.js
diff --git a/ARCHITECTURE_DECISIONS.md b/ARCHITECTURE_DECISIONS.md
index 4fd5259..405837f 100644
--- a/ARCHITECTURE_DECISIONS.md
+++ b/ARCHITECTURE_DECISIONS.md
@@ -35,6 +35,7 @@ This file records repository-level architectural and delivery decisions so futur
| ADR-017 | Validated allowlisted `nginx_custom_code` fragments | accepted | Session 14 | 2026-05-24 |
| ADR-018 | Strict normalized certificate domains and argument-array process execution | accepted | Session 15 | 2026-05-24 |
| ADR-019 | Durable certificate orders with artifact history and retryable lifecycle state | accepted | Session 16 | 2026-05-24 |
+| ADR-020 | ACK-backed certificate artifact activation with rollback to prior versions | accepted | Session 17 | 2026-05-24 |
---
@@ -668,3 +669,33 @@ The initial Session 16 state vocabulary is:
- certificate artifact metadata is now versioned, which gives Session 17 a concrete foundation for separating issuance from cluster-wide activation and for adding rollback-aware distribution state
- artifact history currently stores the same PEM material as the existing certificate table, so Session 19 must build encryption-at-rest on top of this new lifecycle model before it is suitable for hardened production key storage
+---
+
+## ADR-020 — ACK-backed certificate artifact activation with rollback to prior versions
+
+- Status: accepted
+- Session: Session 17 — Rework cluster certificate distribution and activation
+- Date: 2026-05-24
+
+### Context
+
+Session 16 introduced durable certificate orders and artifact history, but issuance still looked complete as soon as the leader had PEM material locally. Remote nodes could still be out of date, and there was no explicit artifact-level activation contract or rollback path to a previously known-good version.
+
+### Decision
+
+Separate issuance from activation and make activation an ACK-backed cluster operation:
+
+1. issuance, upload, and self-signed generation now store a `CertificateArtifactVersion` first instead of immediately treating the artifact as the active cluster certificate
+2. activating an artifact now runs through the cluster-operation journal so every node returns an explicit success/failure acknowledgement
+3. the live `Certificate` row is updated only after the artifact activation operation succeeds across the cluster
+4. artifact records now persist rollout metadata (`isCurrent`, `distributionStatus`, `distributionOperationId`, `distributionCompletedAt`) so operators can inspect which version is active and what the latest rollout did
+5. failed activation retries should reuse the stored artifact instead of reissuing certificate material
+6. rollback should reactivate the prior successful artifact version rather than mutating raw certificate state in place
+
+### Consequences
+
+- a certificate order now becomes `activated` only after the cluster rollout succeeds, not merely after local issuance succeeds
+- per-node distribution status is inspectable through the linked cluster operation and exposed on order detail responses
+- failed activations leave the previously active certificate row unchanged, preserving a safe rollback target and letting later reconciliation flows restore drifted nodes from the durable active state
+- later sessions can build richer activation policies, operator APIs, and certificate-distribution observability on top of artifact-level rollout state instead of inferring activation from local filesystem writes alone
+
diff --git a/IMPLEMENTATION_STATUS.md b/IMPLEMENTATION_STATUS.md
index 792e3ad..c116bb6 100644
--- a/IMPLEMENTATION_STATUS.md
+++ b/IMPLEMENTATION_STATUS.md
@@ -9,8 +9,8 @@ Use it as the single place to record what has shipped, what is in progress, and
- Overall status: in progress
- Current phase: Phase 5 — Certificate lifecycle redesign
-- Most recently completed session: Session 16 — Add certificate order state machine
-- Next recommended session from the roadmap: Session 17 — Rework cluster certificate distribution and activation
+- Most recently completed session: Session 17 — Rework cluster certificate distribution and activation
+- Next recommended session from the roadmap: Session 18 — Harden the ACME strategy for clustered production
- Readiness reference: `PRODUCTION_READINESS_ASSESSMENT.md`
- Architecture decision log: `ARCHITECTURE_DECISIONS.md`
@@ -611,13 +611,40 @@ Use it as the single place to record what has shipped, what is in progress, and
## Session 17 — Rework cluster certificate distribution and activation
-- Status: not started
+- Status: done
- Objective: separate issuance from activation and track per-node rollout state
-- Files touched: none yet
-- Tests added/updated: none yet
-- Risks: cluster cert activation remains local-first instead of ACK-driven
-- Follow-up sessions: Session 22, Session 27
-- Notes: rollback to prior artifact version remains open
+- Files touched:
+ - `prisma/schema.prisma`
+ - `prisma/migrations/20260524200000_session17_certificate_distribution_activation/migration.sql`
+ - `src/distributed-lock/cluster-operations.service.ts`
+ - `src/certificate/certificate-order.service.ts`
+ - `src/certificate/dto/certificate-order.dto.ts`
+ - `src/certificate/certificate.service.ts`
+ - `src/certificate/certificate.controller.ts`
+ - `README.md`
+ - `ARCHITECTURE_DECISIONS.md`
+ - `IMPLEMENTATION_STATUS.md`
+ - `test/session15/domain-validation-and-safe-process.test.js`
+ - `test/session16/certificate-order-state-machine.test.js`
+ - `test/session17/certificate-distribution-and-rollback.test.js`
+- Tests added/updated:
+ - added `test/session17/certificate-distribution-and-rollback.test.js` to verify failed rollout retries reuse stored artifacts and rollback reactivates the prior successful artifact version
+ - updated the Session 15 and Session 16 certificate harnesses so they cover the new activation-time certificate validation and NGINX reload steps
+ - ran `npm run prisma:generate`, `npm run typecheck`, the focused Session 15-17 certificate suites, the full `npm test` suite, and `npm run build` successfully after landing the new rollout flow
+- Risks:
+ - cluster artifact activation now waits for node ACKs, but peer transport is still authenticated HTTP rather than mTLS-backed service identity
+ - artifact rollout state is durable, but rollback and retry visibility is still routed through certificate-order detail plus the cluster-operation journal; richer operator-focused cluster/certificate views remain future work for Session 22
+ - activation failure recovery currently relies on retrying the stored artifact and on the existing active certificate row for safe rollback; broader fault-injection proof remains planned for Session 27
+- Follow-up sessions:
+ - Session 18 — harden the ACME strategy for clustered production
+ - Session 22 — add cluster operations and node-status admin APIs
+ - Session 25 — expand metrics and alerting
+ - Session 27 — add chaos and fault-injection validation
+- Notes:
+ - issuance, upload, and self-signed generation now create durable certificate artifacts first and only update the live `Certificate` row after the artifact has been ACKed across the cluster
+ - certificate artifacts now persist rollout metadata (`isCurrent`, `distributionStatus`, `distributionOperationId`, `distributionCompletedAt`) and order detail responses surface the latest distribution ACK summary
+ - failed activation retries now reuse the existing artifact instead of reissuing certificate material, reducing unnecessary issuance churn and preserving rollback targets
+ - added admin rollback support that reactivates the prior successful artifact version through the same cluster operation + ACK path used for forward activation
## Session 18 — Harden the ACME strategy for clustered production
diff --git a/README.md b/README.md
index a8df926..94fa108 100644
--- a/README.md
+++ b/README.md
@@ -19,6 +19,7 @@
+
@@ -33,8 +34,8 @@ Built with [NestJS](https://nestjs.com/) • Powered by [PostgreSQL](https://www
## 📍 Current Delivery Status
- **Roadmap status:** Phase 5 in progress
-- **Completed in Sessions 1-16 plus follow-up maintenance:** delivery scaffolding, dependency hygiene, authenticated-by-default admin APIs, dependency-aware health semantics, fail-fast container supervision, explicit inter-node control-plane addressing, an identity-aware auth foundation, explicit RBAC authorization policies, durable audit logging for privileged and mutating operations, durable leader leases, lease-backed heartbeat/leader reconciliation, durable cluster operation journaling with per-node ACK tracking, staged NGINX release activation with rollback-safe config deployment, validated allowlisted custom NGINX fragments, strict certificate-domain validation with safe process execution, and durable certificate-order state tracking with artifact history and retryable workflows
-- **Next recommended implementation session:** Session 17 — rework cluster certificate distribution and activation
+- **Completed in Sessions 1-17 plus follow-up maintenance:** delivery scaffolding, dependency hygiene, authenticated-by-default admin APIs, dependency-aware health semantics, fail-fast container supervision, explicit inter-node control-plane addressing, an identity-aware auth foundation, explicit RBAC authorization policies, durable audit logging for privileged and mutating operations, durable leader leases, lease-backed heartbeat/leader reconciliation, durable cluster operation journaling with per-node ACK tracking, staged NGINX release activation with rollback-safe config deployment, validated allowlisted custom NGINX fragments, strict certificate-domain validation with safe process execution, durable certificate-order state tracking with artifact history and retryable workflows, and ACK-backed cluster certificate activation with rollback to prior artifact versions
+- **Next recommended implementation session:** Session 18 — harden the ACME strategy for clustered production
- **Canonical planning and status docs:**
- [`PRODUCTION_READINESS_ASSESSMENT.md`](PRODUCTION_READINESS_ASSESSMENT.md)
- [`IMPLEMENTATION_PLAN_BY_SESSION.md`](IMPLEMENTATION_PLAN_BY_SESSION.md)
@@ -67,7 +68,7 @@ node -v # expected: v24.16.0
npm -v # expected: 11.15.0
```
-`npm run test` now runs the focused Session 3-16 regression tests for API access control, health semantics, container-supervision behavior, inter-node addressing, the identity-aware auth foundation, RBAC authorization policy enforcement, audit-logging regressions, lease-backed cluster coordination behavior, cluster-operation journaling, transactional NGINX rollout behavior, guarded `nginx_custom_code` validation, strict domain/process safety, and durable certificate-order lifecycle tracking. Session 26 still remains the planned milestone for broad unit/integration/e2e harness expansion.
+`npm run test` now runs the focused Session 3-17 regression tests for API access control, health semantics, container-supervision behavior, inter-node addressing, the identity-aware auth foundation, RBAC authorization policy enforcement, audit-logging regressions, lease-backed cluster coordination behavior, cluster-operation journaling, transactional NGINX rollout behavior, guarded `nginx_custom_code` validation, strict domain/process safety, durable certificate-order lifecycle tracking, and ACK-backed certificate distribution plus rollback behavior. Session 26 still remains the planned milestone for broad unit/integration/e2e harness expansion.
---
@@ -80,6 +81,8 @@ npm -v # expected: 11.15.0
- **Self-Signed Certificates** - One-click generation for development
- **Certificate Validation** - Automatic cert/key pair validation
- **Multi-Domain Support** - SAN (Subject Alternative Names) support
+- **Cluster-Aware Certificate Activation** - Issuance now rolls out a specific artifact across nodes and waits for tracked ACKs before marking it active
+- **Certificate Rollback** - Operators can roll back to the prior activated artifact version when a rollout must be reverted
- **Certificate Backup/Restore** - Complete backup and recovery solution
### 🚀 NGINX Proxy Management
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/schema.prisma b/prisma/schema.prisma
index 493b39e..36aeebe 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -110,6 +110,10 @@ model CertificateArtifactVersion {
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)
@@ -120,6 +124,7 @@ model CertificateArtifactVersion {
@@index([orderId])
@@index([certificateId, createdAt])
@@index([domainsHash, createdAt])
+ @@index([domainsHash, isCurrent])
}
model ClusterNode {
diff --git a/src/certificate/certificate-order.service.ts b/src/certificate/certificate-order.service.ts
index 4dbcdd2..4f787f9 100644
--- a/src/certificate/certificate-order.service.ts
+++ b/src/certificate/certificate-order.service.ts
@@ -72,16 +72,46 @@ type CertificateOrderEventRecord = {
type CertificateArtifactRecord = {
id: string;
+ orderId: string | null;
certificateId: string | null;
+ domainsHash: string;
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;
};
+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 | 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);
@@ -343,7 +373,7 @@ export class CertificateOrderService {
async recordArtifact(params: {
orderId: string;
- certificateId: string;
+ certificateId?: string | null;
domains: string[];
sourceType: CertificateOrderSourceType;
certPem: string;
@@ -366,7 +396,7 @@ export class CertificateOrderService {
const artifact = (await this.prisma.certificateArtifactVersion.create({
data: {
orderId: params.orderId,
- certificateId: params.certificateId,
+ certificateId: params.certificateId ?? null,
domains: joinDomains(domains, { allowWildcard: true }),
domainsHash,
version,
@@ -396,6 +426,50 @@ export class CertificateOrderService {
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 & {
+ domains: string;
+ domainsHash: string;
+ certPem: string;
+ keyPem: string;
+ metadata: Record | 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[];
@@ -436,7 +510,9 @@ export class CertificateOrderService {
throw new Error(`Certificate order not found: ${orderId}`);
}
- return this.toDetailDto(order);
+ const latestDistribution = await this.getLatestDistribution(order.artifacts);
+
+ return this.toDetailDto(order, latestDistribution);
}
async validateRetryableOrder(
@@ -510,6 +586,7 @@ export class CertificateOrderService {
failedAt: order.failedAt,
revokedAt: order.revokedAt,
completedAt: order.completedAt,
+ metadata: order.metadata,
createdAt: order.createdAt,
updatedAt: order.updatedAt,
};
@@ -520,6 +597,7 @@ export class CertificateOrderService {
events: CertificateOrderEventRecord[];
artifacts: CertificateArtifactRecord[];
},
+ latestDistribution: LatestDistributionRecord | null,
): CertificateOrderDetailDto {
return {
...this.toSummaryDto(order),
@@ -542,9 +620,66 @@ export class CertificateOrderService {
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 {
+ 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 a31656e..34269cb 100644
--- a/src/certificate/certificate.controller.ts
+++ b/src/certificate/certificate.controller.ts
@@ -153,6 +153,20 @@ export class CertificateController {
}
}
+ @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)
@@ -193,6 +207,46 @@ 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 {
return this.certificateService.getCertificateInfo(id);
diff --git a/src/certificate/certificate.service.ts b/src/certificate/certificate.service.ts
index f527023..20633f2 100644
--- a/src/certificate/certificate.service.ts
+++ b/src/certificate/certificate.service.ts
@@ -41,6 +41,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);
@@ -823,6 +830,299 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown {
}
}
+ 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,
+ ): Record {
+ const base =
+ current && typeof current === 'object' && !Array.isArray(current)
+ ? { ...(current as Record) }
+ : {};
+
+ return {
+ ...base,
+ ...patch,
+ };
+ }
+
+ 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 validation = await this.validateCertificate(
+ artifact.certPem,
+ artifact.keyPem,
+ domains,
+ );
+
+ if (!validation.valid) {
+ throw new Error(
+ `Artifact ${artifactId} failed validation before activation: ${validation.error}`,
+ );
+ }
+
+ this.writeCertToFs(primaryDomain, artifact.certPem, artifact.keyPem);
+ 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 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: 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,
+ keyPem: artifact.keyPem,
+ 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,
+ keyPem: artifact.keyPem,
+ 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: 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: {
@@ -1165,86 +1465,56 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown {
},
});
- 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: instanceId,
- } as any,
- create: {
- domains: domainsStr,
- domainsHash,
- certPem,
- keyPem,
- expiresAt: validation.expiresAt,
- issuedAt: new Date(),
- lastUsedAt: now,
- isOrphaned: false,
- status: 'active',
- issuedByNode: instanceId,
- } as any,
- });
-
- 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...`,
- );
- await this.certificateOrders.transitionOrder(
- order.id,
- 'distributing',
- {
- message:
- 'Persisted certificate in the database and preparing local activation',
- details: {
- certificateId: certRecord.id,
- },
- },
- );
- this.writeCertToFs(primaryDomain, certPem, keyPem);
-
- await this.certificateOrders.recordArtifact({
+ const artifact = await this.certificateOrders.recordArtifact({
orderId: order.id,
- certificateId: certRecord.id,
+ certificateId: null,
domains,
sourceType,
certPem,
keyPem,
- issuedAt: certRecord.issuedAt,
- expiresAt: certRecord.expiresAt,
- activatedAt: new Date(),
+ issuedAt: new Date(),
+ expiresAt: validation.expiresAt ?? addDays(new Date(), 90),
+ activatedAt: null,
createdByNode: instanceId,
metadata: {
- activation: 'local-filesystem',
+ activation: 'pending-cluster-rollout',
},
});
- await this.certificateOrders.completeWithCertificate(order.id, {
- certificateId: certRecord.id,
- message:
- 'Certificate activated on the local node after ACME issuance',
- details: {
- primaryDomain,
- lockName,
- },
+ const activation = await this.activateArtifactAcrossCluster({
+ artifactId: artifact.id,
+ orderId: order.id,
+ action: 'activate',
});
- return certRecord;
+
+ 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;
+ 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(
+ `[Certbot] 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({
@@ -1443,12 +1713,11 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown {
});
for (const order of retryableOrders) {
- const domains = parseDomains(order.domains, { allowWildcard: true });
try {
this.logger.log(
`[Renewal] Retrying failed certificate order ${order.id} for [${order.domains}] (attempt=${order.attemptCount})`,
);
- await this.ensureCertificate(domains, { orderId: order.id });
+ await this.retryCertificateOrder(order.id);
} catch (err) {
this.logger.error(
`[Renewal] Retry failed for order ${order.id} [${order.domains}]: ${err instanceof Error ? err.message : String(err)}`,
@@ -1499,8 +1768,6 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown {
`[Upload] Uploading custom certificate for domains: [${joinDomains(domains, { allowWildcard: true })}]`,
);
const hash = hashDomains(domains, { allowWildcard: true });
- const domainsStr = joinDomains(domains, { allowWildcard: true });
- const primaryDomain = domains[0];
// Validate certificate and key match
await this.validateCertificateKeyPair(dto.certPem, dto.keyPem);
@@ -1524,62 +1791,39 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown {
},
});
- // Write to filesystem
- this.writeCertToFs(primaryDomain, fullChainPem, dto.keyPem);
-
- // 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,
- status: 'active',
- issuedByNode: instanceId,
- },
- });
-
- await this.certificateOrders.transitionOrder(order.id, 'distributing', {
- message: 'Persisted uploaded certificate and activated it locally',
- details: {
- certificateId: certRecord.id,
- },
- });
-
- await this.certificateOrders.recordArtifact({
+ const artifact = await this.certificateOrders.recordArtifact({
orderId: order.id,
- certificateId: certRecord.id,
+ certificateId: null,
domains,
sourceType: 'uploaded',
certPem: fullChainPem,
keyPem: dto.keyPem,
- issuedAt: certRecord.issuedAt,
- expiresAt: certRecord.expiresAt,
- activatedAt: new Date(),
+ issuedAt: new Date(),
+ expiresAt,
+ activatedAt: null,
createdByNode: instanceId,
+ metadata: {
+ activation: 'pending-cluster-rollout',
+ },
});
- await this.certificateOrders.completeWithCertificate(order.id, {
- certificateId: certRecord.id,
- message: 'Uploaded certificate activated on the local node',
- details: {
- primaryDomain,
- },
+ 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);
- await this.certificateOrders.markFailure(order.id, message, null, {
- sourceType: 'uploaded',
- });
+ if (!(error instanceof ArtifactActivationError)) {
+ await this.certificateOrders.markFailure(order.id, message, null, {
+ sourceType: 'uploaded',
+ });
+ }
throw error;
} finally {
// Clean up temp file
@@ -1615,9 +1859,6 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown {
`[Self-Signed] Generating self-signed certificate for domains: [${joinDomains(normalizedDomains, { allowWildcard: true })}]`,
);
const hash = hashDomains(normalizedDomains, { allowWildcard: true });
- const domainsStr = joinDomains(normalizedDomains, {
- allowWildcard: true,
- });
const primaryDomain = normalizedDomains[0];
// Generate self-signed certificate using openssl
@@ -1657,62 +1898,40 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown {
},
});
- // Write to filesystem
- this.writeCertToFs(primaryDomain, certPem, keyPem);
-
- // 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,
- status: 'active',
- issuedByNode: instanceId,
- },
- });
-
- await this.certificateOrders.transitionOrder(order.id, 'distributing', {
- message: 'Persisted self-signed certificate and activated it locally',
- details: {
- certificateId: certRecord.id,
- },
- });
- await this.certificateOrders.recordArtifact({
+ const artifact = await this.certificateOrders.recordArtifact({
orderId: order.id,
- certificateId: certRecord.id,
+ certificateId: null,
domains: normalizedDomains,
sourceType: 'self-signed',
certPem,
keyPem,
- issuedAt: certRecord.issuedAt,
- expiresAt: certRecord.expiresAt,
- activatedAt: new Date(),
+ issuedAt: new Date(),
+ expiresAt: addDays(new Date(), 365),
+ activatedAt: null,
createdByNode: instanceId,
+ metadata: {
+ activation: 'pending-cluster-rollout',
+ },
});
- await this.certificateOrders.completeWithCertificate(order.id, {
- certificateId: certRecord.id,
- message: 'Self-signed certificate activated on the local node',
- details: {
- primaryDomain,
- },
+ 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);
- await this.certificateOrders.markFailure(order.id, message, null, {
- sourceType: 'self-signed',
- });
+ if (!(error instanceof ArtifactActivationError)) {
+ await this.certificateOrders.markFailure(order.id, message, null, {
+ sourceType: 'self-signed',
+ });
+ }
throw error;
} finally {
// Clean up temp files
@@ -1809,11 +2028,16 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown {
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') {
- throw new Error(
- `Certificate order ${orderId} cannot be retried automatically for source type ${order.sourceType}`,
- );
+ if (!latestArtifact) {
+ throw new Error(
+ `Certificate order ${orderId} cannot be retried automatically for source type ${order.sourceType}`,
+ );
+ }
}
await this.certificateOrders.resumeOrder(orderId, {
@@ -1821,6 +2045,16 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown {
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') {
@@ -1950,6 +2184,66 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown {
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
*/
diff --git a/src/certificate/dto/certificate-order.dto.ts b/src/certificate/dto/certificate-order.dto.ts
index ceb14f9..f97ce7f 100644
--- a/src/certificate/dto/certificate-order.dto.ts
+++ b/src/certificate/dto/certificate-order.dto.ts
@@ -11,10 +11,35 @@ export class CertificateOrderArtifactDto {
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 | null;
+}
+
+export class CertificateOrderDistributionDto {
+ artifactId: string;
+ version: number;
+ status: string;
+ operationId: string;
+ completedAt: Date | null;
+ acknowledgements: CertificateOrderDistributionAckDto[];
+}
+
export class CertificateOrderEventDto {
id: string;
eventType: string;
@@ -49,6 +74,7 @@ export class CertificateOrderSummaryDto {
failedAt: Date | null;
revokedAt: Date | null;
completedAt: Date | null;
+ metadata: Record | null;
createdAt: Date;
updatedAt: Date;
}
@@ -56,4 +82,5 @@ export class CertificateOrderSummaryDto {
export class CertificateOrderDetailDto extends CertificateOrderSummaryDto {
events: CertificateOrderEventDto[];
artifacts: CertificateOrderArtifactDto[];
+ latestDistribution: CertificateOrderDistributionDto | null;
}
diff --git a/src/distributed-lock/cluster-operations.service.ts b/src/distributed-lock/cluster-operations.service.ts
index 72bda34..87ae04a 100644
--- a/src/distributed-lock/cluster-operations.service.ts
+++ b/src/distributed-lock/cluster-operations.service.ts
@@ -79,6 +79,11 @@ type StartClusterOperationOptions = {
metadata?: Record;
};
+type WaitForOperationOptions = {
+ timeoutMs?: number;
+ pollIntervalMs?: number;
+};
+
export type ClusterOperationAcceptedResponse = {
operationId: string;
operationType: string;
@@ -222,6 +227,33 @@ export class ClusterOperationsService {
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(
operationId: string,
targets: OperationTarget[],
@@ -278,8 +310,8 @@ export class ClusterOperationsService {
await this.markAckStarted(operationId, target.instanceId);
try {
- await localAction(operationId);
- await this.markAckSucceeded(operationId, target.instanceId, 200);
+ 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);
@@ -337,11 +369,14 @@ export class ClusterOperationsService {
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);
@@ -427,6 +462,7 @@ export class ClusterOperationsService {
nodeInstanceId: string,
responseStatus: number,
endpointUrl?: string | null,
+ details?: unknown,
) {
await this.prisma.clusterOperationAck.update({
where: {
@@ -440,6 +476,10 @@ export class ClusterOperationsService {
responseStatus,
errorMessage: null,
ackedAt: new Date(),
+ details:
+ details === undefined
+ ? undefined
+ : this.normalizeAckDetails(details),
...(endpointUrl === undefined ? {} : { endpointUrl }),
},
});
@@ -559,6 +599,40 @@ export class ClusterOperationsService {
}
}
+ private async readRemoteSuccess(response: Response) {
+ try {
+ const contentType = response.headers.get('content-type') ?? '';
+ if (contentType.includes('application/json')) {
+ return (await response.json()) as Record;
+ }
+
+ 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 === null) {
+ return null;
+ }
+
+ if (details === undefined) {
+ return undefined;
+ }
+
+ if (typeof details === 'object') {
+ return details as Record;
+ }
+
+ return {
+ value: details,
+ };
+ }
+
private toAcceptedResponse(
operation: ClusterOperationRecord,
): ClusterOperationAcceptedResponse {
diff --git a/test/session15/domain-validation-and-safe-process.test.js b/test/session15/domain-validation-and-safe-process.test.js
index fbf98c9..73a2ad9 100644
--- a/test/session15/domain-validation-and-safe-process.test.js
+++ b/test/session15/domain-validation-and-safe-process.test.js
@@ -74,6 +74,10 @@ function createCertificateService() {
resetModules();
const { CertificateService } = require(certificateServicePath);
+ const state = {
+ artifact: null,
+ };
+
const prisma = {
certificate: {
create: async ({ data }) => ({ id: 'cert-1', ...data }),
@@ -85,6 +89,20 @@ function createCertificateService() {
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 () => [],
},
@@ -96,11 +114,54 @@ function createCertificateService() {
getOrCreateOrder: async () => ({ id: 'order-1', attemptCount: 1 }),
transitionOrder: async () => undefined,
markFailure: async () => undefined,
- recordArtifact: async () => ({ id: 'artifact-1', version: 1 }),
+ 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 () => ({ operationId: 'op-1' }),
+ 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',
@@ -215,6 +276,10 @@ describe('Session 15 strict domain validation and safe process execution', () =>
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') {
@@ -234,6 +299,25 @@ describe('Session 15 strict domain validation and safe process execution', () =>
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(' ')}`);
});
@@ -246,32 +330,37 @@ describe('Session 15 strict domain validation and safe process execution', () =>
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(calls.length, 2);
- assert.deepEqual(calls[0].args, [
+ assert.equal(generationCalls.length, 2);
+ assert.deepEqual(generationCalls[0].args, [
'genrsa',
'-out',
- calls[0].args[2],
+ generationCalls[0].args[2],
'2048',
]);
- assert.deepEqual(calls[1].args.slice(0, 8), [
+ assert.deepEqual(generationCalls[1].args.slice(0, 8), [
'req',
'-new',
'-x509',
'-key',
- calls[0].args[2],
+ generationCalls[0].args[2],
'-out',
- calls[1].args[6],
+ generationCalls[1].args[6],
'-days',
]);
assert.equal(
- calls[1].args[calls[1].args.indexOf('-subj') + 1],
+ generationCalls[1].args[generationCalls[1].args.indexOf('-subj') + 1],
'/CN=xn--exmple-cua.com',
);
assert.equal(
- calls[1].args[calls[1].args.indexOf('-addext') + 1],
+ generationCalls[1].args[generationCalls[1].args.indexOf('-addext') + 1],
'subjectAltName=DNS:xn--exmple-cua.com',
);
});
diff --git a/test/session16/certificate-order-state-machine.test.js b/test/session16/certificate-order-state-machine.test.js
index 70e9a8d..5bf2723 100644
--- a/test/session16/certificate-order-state-machine.test.js
+++ b/test/session16/certificate-order-state-machine.test.js
@@ -72,7 +72,7 @@ function applyDataPatch(record, data) {
}
}
-function createPrismaMock() {
+function createPrismaMock(clusterState) {
const state = {
certificateSequence: 0,
orderSequence: 0,
@@ -260,11 +260,20 @@ function createPrismaMock() {
}
return results[0] ? clone(results[0]) : null;
},
- async findUnique({ where, include } = {}) {
+ 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);
}
@@ -319,6 +328,18 @@ function createPrismaMock() {
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') {
@@ -326,24 +347,62 @@ function createPrismaMock() {
}
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;
+ },
+ },
};
}
@@ -353,13 +412,50 @@ function createHarness() {
const { CertificateOrderService } = require(certificateOrderServicePath);
const { CertificateService } = require(certificateServicePath);
- const prisma = createPrismaMock();
+ const clusterState = {
+ operations: [],
+ };
+ const prisma = createPrismaMock(clusterState);
const alertService = {
sendAlert: async () => undefined,
};
const orderService = new CertificateOrderService(prisma);
const clusterOperations = {
- enqueueBroadcastOperation: async () => ({ operationId: 'op-1' }),
+ 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',
@@ -415,6 +511,10 @@ afterEach(() => {
describe('Session 16 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') {
@@ -434,6 +534,25 @@ describe('Session 16 certificate order state machine', () => {
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(' ')}`);
});
diff --git a/test/session17/certificate-distribution-and-rollback.test.js b/test/session17/certificate-distribution-and-rollback.test.js
new file mode 100644
index 0000000..8e4e1c6
--- /dev/null
+++ b/test/session17/certificate-distribution-and-rollback.test.js
@@ -0,0 +1,655 @@
+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 = 'session17@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,
+ );
+
+ return { prisma, orderService, service, clusterState };
+}
+
+beforeEach(() => {
+ process.env.ADMIN_EMAIL = 'session17@example.test';
+ restoreExecFile();
+ resetModules();
+});
+
+afterEach(() => {
+ restoreExecFile();
+ resetModules();
+
+ if (originalAdminEmail === undefined) {
+ delete process.env.ADMIN_EMAIL;
+ } else {
+ process.env.ADMIN_EMAIL = originalAdminEmail;
+ }
+});
+
+describe('Session 17 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/);
+ });
+});
+
From bda8ad6288adf8a5edcbbc0b52821e5388513d1d Mon Sep 17 00:00:00 2001
From: Stualyttle <27898452+Stualyttle@users.noreply.github.com.>
Date: Tue, 26 May 2026 19:45:07 +0200
Subject: [PATCH 20/43] feat: implement explicit ACME challenge strategy with
database-backed HTTP-01 and DNS-01 support
---
.env.example | 23 ++
ARCHITECTURE_DECISIONS.md | 35 ++
IMPLEMENTATION_STATUS.md | 48 ++-
README.md | 68 +++-
certbot-auth-hook.sh | 47 ++-
certbot-cleanup-hook.sh | 13 +-
.../migration.sql | 21 ++
prisma/schema.prisma | 23 +-
src/certificate/acme-strategy.ts | 275 +++++++++++++++
src/certificate/acme.controller.ts | 64 +++-
src/certificate/certificate.controller.ts | 26 ++
src/certificate/certificate.service.ts | 226 +++++++++----
src/certificate/dto/acme-challenge.dto.ts | 17 +
...domain-validation-and-safe-process.test.js | 4 +-
.../session18/acme-strategy-hardening.test.js | 320 ++++++++++++++++++
15 files changed, 1104 insertions(+), 106 deletions(-)
create mode 100644 prisma/migrations/20260524213000_session18_acme_strategy_hardening/migration.sql
create mode 100644 src/certificate/acme-strategy.ts
create mode 100644 src/certificate/dto/acme-challenge.dto.ts
create mode 100644 test/session18/acme-strategy-hardening.test.js
diff --git a/.env.example b/.env.example
index e9c8be3..fd078b4 100644
--- a/.env.example
+++ b/.env.example
@@ -95,6 +95,29 @@ ALERT_THRESHOLD_DAYS="14"
# Set to "true" to avoid rate limits during testing
# LETSENCRYPT_STAGING="false"
+# ACME challenge strategy selection.
+# - auto : use built-in HTTP-01 for normal domains, DNS-01 for wildcard orders
+# - http-01: force the built-in database-backed HTTP-01 flow
+# - dns-01 : force DNS-01 using operator-supplied manual hook scripts
+ACME_CHALLENGE_STRATEGY="auto"
+
+# Built-in HTTP-01 configuration.
+# These default hook paths are baked into the container image; override only if you
+# deliberately mount alternate hook implementations.
+ACME_HTTP01_AUTH_HOOK="/certbot-auth-hook.sh"
+ACME_HTTP01_CLEANUP_HOOK="/certbot-cleanup-hook.sh"
+ACME_HTTP01_PROVIDER="database-http-01"
+ACME_HTTP01_PROPAGATION_SECONDS="5"
+
+# DNS-01 configuration.
+# Wildcard orders require DNS-01. Mount provider-specific hook scripts into the
+# container and point these settings at their absolute paths.
+# Example provider label: route53-manual, cloudflare-manual, infoblox-manual
+ACME_DNS_PROVIDER="route53-manual"
+ACME_DNS_AUTH_HOOK="/opt/acme/dns-auth.sh"
+ACME_DNS_CLEANUP_HOOK="/opt/acme/dns-cleanup.sh"
+ACME_DNS_PROPAGATION_SECONDS="45"
+
# ===============================================
# EMAIL/SMTP CONFIGURATION (OPTIONAL - RECOMMENDED)
# ===============================================
diff --git a/ARCHITECTURE_DECISIONS.md b/ARCHITECTURE_DECISIONS.md
index 405837f..ddd6da4 100644
--- a/ARCHITECTURE_DECISIONS.md
+++ b/ARCHITECTURE_DECISIONS.md
@@ -36,6 +36,7 @@ This file records repository-level architectural and delivery decisions so futur
| ADR-018 | Strict normalized certificate domains and argument-array process execution | accepted | Session 15 | 2026-05-24 |
| ADR-019 | Durable certificate orders with artifact history and retryable lifecycle state | accepted | Session 16 | 2026-05-24 |
| ADR-020 | ACK-backed certificate artifact activation with rollback to prior versions | accepted | Session 17 | 2026-05-24 |
+| ADR-021 | Explicit ACME strategy selection with DB-backed HTTP-01 tracking and DNS-01 hook support | accepted | Session 18 | 2026-05-24 |
---
@@ -697,5 +698,39 @@ Separate issuance from activation and make activation an ACK-backed cluster oper
- a certificate order now becomes `activated` only after the cluster rollout succeeds, not merely after local issuance succeeds
- per-node distribution status is inspectable through the linked cluster operation and exposed on order detail responses
- failed activations leave the previously active certificate row unchanged, preserving a safe rollback target and letting later reconciliation flows restore drifted nodes from the durable active state
+
+---
+
+## ADR-021 — Explicit ACME strategy selection with DB-backed HTTP-01 tracking and DNS-01 hook support
+
+- Status: accepted
+- Session: Session 18 — Harden the ACME strategy for clustered production
+- Date: 2026-05-24
+
+### Context
+
+By the end of Session 17, certificate activation and rollback were cluster-aware, but the pre-issuance ACME flow still relied on an implicit HTTP-01-only certbot invocation. That left three gaps called out in the production-readiness assessment:
+
+1. wildcard issuance still had no supported path
+2. challenge publication / cleanup / finalization was not explicit or inspectable enough for operators
+3. the repository had no clear operator-facing contract for when to use shared HTTP challenge serving versus external DNS provider hooks
+
+### Decision
+
+Adopt an explicit ACME strategy layer with the following rules:
+
+1. introduce `ACME_CHALLENGE_STRATEGY` with `auto`, `http-01`, and `dns-01` modes
+2. keep the built-in cluster-safe HTTP-01 path for non-wildcard orders by writing challenge state into PostgreSQL and serving it from every node through `/.well-known/acme-challenge/:token`
+3. preserve HTTP-01 challenge rows through cleanup/finalization so operators can inspect challenge lifecycle state instead of losing it immediately on cleanup
+4. support DNS-01 through operator-supplied manual hook scripts configured by absolute-path env vars (`ACME_DNS_AUTH_HOOK`, `ACME_DNS_CLEANUP_HOOK`) plus an explicit provider label (`ACME_DNS_PROVIDER`)
+5. in `auto` mode, resolve wildcard orders to DNS-01 and non-wildcard orders to the built-in HTTP-01 path
+6. expose recent built-in HTTP-01 challenge records through an authenticated inspection API (`GET /certificates/challenges`)
+
+### Consequences
+
+- wildcard issuance is now supported when DNS-01 hooks are configured, without weakening the existing safe-domain validation rules
+- the built-in HTTP-01 flow remains cluster-safe because any node can serve the shared challenge record, while challenge publication / cleanup / validation state is now queryable afterward
+- DNS-01 provider behavior remains intentionally pluggable: the application defines the hook contract and metadata, while provider-specific DNS mutations stay outside the main codebase until a later provider integration session is justified
+- future certificate, observability, and operator-API sessions should build on this explicit ACME strategy metadata instead of assuming a single hard-coded HTTP-01 flow
- later sessions can build richer activation policies, operator APIs, and certificate-distribution observability on top of artifact-level rollout state instead of inferring activation from local filesystem writes alone
diff --git a/IMPLEMENTATION_STATUS.md b/IMPLEMENTATION_STATUS.md
index c116bb6..d3f849d 100644
--- a/IMPLEMENTATION_STATUS.md
+++ b/IMPLEMENTATION_STATUS.md
@@ -8,9 +8,9 @@ Use it as the single place to record what has shipped, what is in progress, and
## Current summary
- Overall status: in progress
-- Current phase: Phase 5 — Certificate lifecycle redesign
-- Most recently completed session: Session 17 — Rework cluster certificate distribution and activation
-- Next recommended session from the roadmap: Session 18 — Harden the ACME strategy for clustered production
+- Current phase: Phase 6 — Secrets, backups, and data protection
+- Most recently completed session: Session 18 — Harden the ACME strategy for clustered production
+- Next recommended session from the roadmap: Session 19 — Encrypt private key material at rest
- Readiness reference: `PRODUCTION_READINESS_ASSESSMENT.md`
- Architecture decision log: `ARCHITECTURE_DECISIONS.md`
@@ -648,13 +648,43 @@ Use it as the single place to record what has shipped, what is in progress, and
## Session 18 — Harden the ACME strategy for clustered production
-- Status: not started
+- Status: done
- Objective: make challenge handling explicit, durable, and cluster-safe
-- Files touched: none yet
-- Tests added/updated: none yet
-- Risks: current issuance coordination is still fragile in global mode
-- Follow-up sessions: Session 29
-- Notes: DNS-01 or a hardened HTTP-01 workflow must be decided
+- Files touched:
+ - `prisma/schema.prisma`
+ - `prisma/migrations/20260524213000_session18_acme_strategy_hardening/migration.sql`
+ - `certbot-auth-hook.sh`
+ - `certbot-cleanup-hook.sh`
+ - `src/certificate/acme-strategy.ts`
+ - `src/certificate/acme.controller.ts`
+ - `src/certificate/certificate.controller.ts`
+ - `src/certificate/certificate.service.ts`
+ - `src/certificate/dto/acme-challenge.dto.ts`
+ - `.env.example`
+ - `README.md`
+ - `ARCHITECTURE_DECISIONS.md`
+ - `IMPLEMENTATION_STATUS.md`
+ - `test/session15/domain-validation-and-safe-process.test.js`
+ - `test/session18/acme-strategy-hardening.test.js`
+- Tests added/updated:
+ - added `test/session18/acme-strategy-hardening.test.js` to verify built-in HTTP-01 planning, wildcard DNS-01 auto-selection, challenge inspection listing, and challenge-serving lifecycle behavior
+ - updated the Session 15 wildcard regression so it now asserts that wildcard issuance requires DNS-01 hook configuration rather than rejecting all wildcard ACME orders categorically
+ - re-ran the focused Session 15 and Session 18 suites locally after landing the strategy changes
+- Risks:
+ - DNS-01 is now supported through operator-supplied manual hooks, but first-class provider-specific integrations remain external to the app for now
+ - built-in challenge inspection currently covers the DB-backed HTTP-01 flow only; provider-managed DNS-01 challenge detail remains owned by the mounted provider hooks
+ - private keys and artifact history are still stored plaintext until Session 19 lands encryption-at-rest
+- Follow-up sessions:
+ - Session 19 — encrypt private key material at rest
+ - Session 20 — harden backup, export, import, and restore flows
+ - Session 22 — add cluster operations and node-status admin APIs
+ - Session 25 — expand metrics and alerting
+ - Session 29 — reconcile README, architecture docs, and runbooks with reality
+- Notes:
+ - introduced an explicit ACME strategy layer that supports `auto`, `http-01`, and `dns-01` challenge modes instead of assuming one implicit certbot path
+ - the built-in HTTP-01 flow now stores publication, cleanup, expiry, and finalization state in `AcmeChallenge`, and operators can inspect recent challenge rows through `GET /certificates/challenges`
+ - wildcard orders now resolve to DNS-01 automatically when the required DNS hook configuration is present, while non-wildcard orders continue to use the cluster-safe database-backed HTTP-01 publication path by default
+ - certbot hook wiring now carries per-order metadata and propagation tuning through explicit environment variables instead of relying on ad hoc implicit behavior
---
diff --git a/README.md b/README.md
index 94fa108..957b8ad 100644
--- a/README.md
+++ b/README.md
@@ -20,6 +20,7 @@
+
@@ -34,8 +35,8 @@ Built with [NestJS](https://nestjs.com/) • Powered by [PostgreSQL](https://www
## 📍 Current Delivery Status
- **Roadmap status:** Phase 5 in progress
-- **Completed in Sessions 1-17 plus follow-up maintenance:** delivery scaffolding, dependency hygiene, authenticated-by-default admin APIs, dependency-aware health semantics, fail-fast container supervision, explicit inter-node control-plane addressing, an identity-aware auth foundation, explicit RBAC authorization policies, durable audit logging for privileged and mutating operations, durable leader leases, lease-backed heartbeat/leader reconciliation, durable cluster operation journaling with per-node ACK tracking, staged NGINX release activation with rollback-safe config deployment, validated allowlisted custom NGINX fragments, strict certificate-domain validation with safe process execution, durable certificate-order state tracking with artifact history and retryable workflows, and ACK-backed cluster certificate activation with rollback to prior artifact versions
-- **Next recommended implementation session:** Session 18 — harden the ACME strategy for clustered production
+- **Completed in Sessions 1-18 plus follow-up maintenance:** delivery scaffolding, dependency hygiene, authenticated-by-default admin APIs, dependency-aware health semantics, fail-fast container supervision, explicit inter-node control-plane addressing, an identity-aware auth foundation, explicit RBAC authorization policies, durable audit logging for privileged and mutating operations, durable leader leases, lease-backed heartbeat/leader reconciliation, durable cluster operation journaling with per-node ACK tracking, staged NGINX release activation with rollback-safe config deployment, validated allowlisted custom NGINX fragments, strict certificate-domain validation with safe process execution, durable certificate-order state tracking with artifact history and retryable workflows, ACK-backed cluster certificate activation with rollback to prior artifact versions, and an explicit ACME strategy layer with built-in HTTP-01 challenge tracking plus DNS-01 hook support
+- **Next recommended implementation session:** Session 19 — encrypt private key material at rest
- **Canonical planning and status docs:**
- [`PRODUCTION_READINESS_ASSESSMENT.md`](PRODUCTION_READINESS_ASSESSMENT.md)
- [`IMPLEMENTATION_PLAN_BY_SESSION.md`](IMPLEMENTATION_PLAN_BY_SESSION.md)
@@ -68,7 +69,7 @@ node -v # expected: v24.16.0
npm -v # expected: 11.15.0
```
-`npm run test` now runs the focused Session 3-17 regression tests for API access control, health semantics, container-supervision behavior, inter-node addressing, the identity-aware auth foundation, RBAC authorization policy enforcement, audit-logging regressions, lease-backed cluster coordination behavior, cluster-operation journaling, transactional NGINX rollout behavior, guarded `nginx_custom_code` validation, strict domain/process safety, durable certificate-order lifecycle tracking, and ACK-backed certificate distribution plus rollback behavior. Session 26 still remains the planned milestone for broad unit/integration/e2e harness expansion.
+`npm run test` now runs the focused Session 3-18 regression tests for API access control, health semantics, container-supervision behavior, inter-node addressing, the identity-aware auth foundation, RBAC authorization policy enforcement, audit-logging regressions, lease-backed cluster coordination behavior, cluster-operation journaling, transactional NGINX rollout behavior, guarded `nginx_custom_code` validation, strict domain/process safety, durable certificate-order lifecycle tracking, ACK-backed certificate distribution plus rollback behavior, and the hardened ACME strategy. Session 26 still remains the planned milestone for broad unit/integration/e2e harness expansion.
---
@@ -575,6 +576,12 @@ curl http://localhost:3000/auth/me \
| ------ | ------------------------------------ | ------------------------- | ------ |
| GET | `/.well-known/acme-challenge/:token` | Serve ACME challenge data | Public |
+### ACME Challenge Operations
+
+| Method | Endpoint | Description | Auth |
+| ------ | ------------------------- | -------------------------------------------------------------- | -------- |
+| GET | `/certificates/challenges` | Inspect recent ACME challenge publication / cleanup lifecycle | `viewer` |
+
**📖 API documentation status:** a refreshed API reference is still pending; for now, use the controller source under `src/`, `IMPLEMENTATION_STATUS.md`, and `PRODUCTION_READINESS_ASSESSMENT.md` as the current references.
---
@@ -605,7 +612,58 @@ Certificate domain input is now normalized and validated strictly before any fil
- internationalized domains are normalized to ASCII/punycode
- wildcard domains must use the left-most `*.` form
- path separators, whitespace, control characters, and shell metacharacter tricks are rejected early
-- wildcard issuance is currently rejected for the built-in ACME flow because the current implementation is still HTTP-01-based and DNS-01 support has not landed yet
+- wildcard issuance now resolves to DNS-01 automatically when `ACME_CHALLENGE_STRATEGY=auto` and the required DNS hook configuration is present
+
+### ACME strategy selection
+
+Session 18 formalizes ACME challenge handling behind an explicit strategy layer.
+
+The runtime now supports three `ACME_CHALLENGE_STRATEGY` modes:
+
+- `auto` *(default)* — use built-in HTTP-01 for non-wildcard orders and DNS-01 for wildcard orders
+- `http-01` — force the built-in database-backed HTTP-01 flow
+- `dns-01` — force DNS-01 through operator-supplied manual hook scripts
+
+#### Built-in HTTP-01 strategy
+
+The hardened HTTP-01 flow is the default for non-wildcard orders and remains cluster-safe:
+
+1. the issuing node runs `certbot --manual --preferred-challenges=http`
+2. the built-in auth hook writes the challenge token + key authorization into PostgreSQL together with order/strategy metadata
+3. any node can answer `/.well-known/acme-challenge/:token` from the shared database record
+4. the cleanup hook marks the challenge as cleaned up instead of deleting it immediately
+5. the certificate service finalizes the challenge lifecycle as `validated` or `failed`, which makes challenge publication / finalization inspectable after the run
+
+The `GET /certificates/challenges` endpoint exposes recent built-in HTTP-01 challenge records with statuses such as `presented`, `cleaned-up`, `validated`, `failed`, and `expired`.
+
+#### DNS-01 strategy
+
+Wildcard orders and operator-selected DNS flows now use external manual hooks:
+
+- `ACME_DNS_AUTH_HOOK` — absolute path to the DNS challenge publication hook inside the container
+- `ACME_DNS_CLEANUP_HOOK` — absolute path to the DNS challenge cleanup hook inside the container
+- `ACME_DNS_PROVIDER` — operator-facing label for the DNS provider / hook implementation
+- `ACME_DNS_PROPAGATION_SECONDS` — provider-specific propagation wait hint passed to the hook environment
+
+DNS-01 challenge details are managed by the provider hook itself rather than the built-in DB-backed HTTP challenge table, so `GET /certificates/challenges` is intentionally focused on the built-in HTTP-01 publication path.
+
+Example configuration snippets:
+
+```bash
+# Default mixed strategy: HTTP-01 for normal domains, DNS-01 for wildcards
+ACME_CHALLENGE_STRATEGY=auto
+
+# Force the built-in database-backed HTTP-01 flow
+ACME_CHALLENGE_STRATEGY=http-01
+ACME_HTTP01_PROPAGATION_SECONDS=5
+
+# Force DNS-01 through mounted provider hooks
+ACME_CHALLENGE_STRATEGY=dns-01
+ACME_DNS_PROVIDER=route53-manual
+ACME_DNS_AUTH_HOOK=/opt/acme/dns-auth.sh
+ACME_DNS_CLEANUP_HOOK=/opt/acme/dns-cleanup.sh
+ACME_DNS_PROPAGATION_SECONDS=45
+```
### Certificate order state machine
@@ -647,7 +705,7 @@ curl -X POST http://localhost:3000/certificates/orders//retry \
-H "Authorization: Bearer $ADMIN_TOKEN"
```
-> Current limitation: activation is still local-first today. Session 17 remains responsible for separating issuance from cluster-wide distribution/activation ACKs.
+Session 17 already separates issuance from cluster-wide distribution / activation ACKs. Session 18 builds on that by making the pre-issuance challenge strategy explicit and operator-configurable.
### Upload Custom Certificate
diff --git a/certbot-auth-hook.sh b/certbot-auth-hook.sh
index 0817184..088451b 100755
--- a/certbot-auth-hook.sh
+++ b/certbot-auth-hook.sh
@@ -9,6 +9,11 @@ echo "[Auth Hook] Starting authentication hook..."
TOKEN="${CERTBOT_TOKEN:-}"
VALIDATION="${CERTBOT_VALIDATION:-}"
DOMAIN="${CERTBOT_DOMAIN:-}"
+ORDER_ID="${LYTTLE_ACME_ORDER_ID:-}"
+CHALLENGE_TYPE="${LYTTLE_ACME_CHALLENGE_TYPE:-http-01}"
+PROVIDER="${LYTTLE_ACME_PROVIDER:-database-http-01}"
+METADATA_JSON="${LYTTLE_ACME_METADATA_JSON:-}"
+PROPAGATION_SECONDS="${ACME_HTTP01_PROPAGATION_SECONDS:-2}"
if [ -z "$TOKEN" ] || [ -z "$VALIDATION" ] || [ -z "$DOMAIN" ]; then
echo "[Auth Hook] ERROR: Missing required certbot environment variables"
@@ -57,17 +62,53 @@ echo "[Auth Hook] Expiry: $EXPIRES_AT"
TOKEN_ESC="${TOKEN//\'/\'\'}"
VALIDATION_ESC="${VALIDATION//\'/\'\'}"
DOMAIN_ESC="${DOMAIN//\'/\'\'}"
+ORDER_ID_ESC="${ORDER_ID//\'/\'\'}"
+CHALLENGE_TYPE_ESC="${CHALLENGE_TYPE//\'/\'\'}"
+PROVIDER_ESC="${PROVIDER//\'/\'\'}"
+METADATA_JSON_ESC="${METADATA_JSON//\'/\'\'}"
# 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 <&1 <;
+};
+
+function parsePositiveInteger(value: string | undefined, fallback: number): number {
+ const parsed = Number.parseInt(value ?? '', 10);
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
+}
+
+function parseAbsoluteHookPath(value: string | undefined, envName: string): string {
+ if (!value || value.trim().length === 0) {
+ throw new Error(`${envName} must be configured with an absolute hook path`);
+ }
+
+ if (!path.isAbsolute(value)) {
+ throw new Error(`${envName} must be an absolute filesystem path inside the container`);
+ }
+
+ return value;
+}
+
+function normalizeRequestedStrategy(value: string | undefined): AcmeChallengeStrategy {
+ const normalized = value?.trim().toLowerCase() ?? 'auto';
+ 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(', ')}`,
+ );
+}
+
+export function parseDatabaseUrlForCertbotHooks(databaseUrl: string): {
+ username: string;
+ password: string;
+ host: string;
+ port: string;
+ database: string;
+} {
+ if (!databaseUrl || databaseUrl.trim().length === 0) {
+ throw new Error('DATABASE_URL is required for the built-in HTTP-01 ACME hook flow');
+ }
+
+ const parsed = (() => {
+ try {
+ return new URL(databaseUrl);
+ } catch {
+ throw new Error('Could not parse DATABASE_URL for ACME hooks');
+ }
+ })();
+
+ if (!['postgresql:', 'postgres:'].includes(parsed.protocol)) {
+ throw new Error(
+ `DATABASE_URL must use a PostgreSQL scheme for ACME hooks; received ${parsed.protocol}`,
+ );
+ }
+
+ if (!parsed.username || !parsed.password || !parsed.hostname) {
+ throw new Error(
+ 'DATABASE_URL must include username, password, and host for ACME hooks',
+ );
+ }
+
+ const database = parsed.pathname.replace(/^\//, '');
+ if (!database) {
+ throw new Error('DATABASE_URL must include a database name for ACME hooks');
+ }
+
+ return {
+ username: decodeURIComponent(parsed.username),
+ password: decodeURIComponent(parsed.password),
+ host: parsed.hostname,
+ port: parsed.port || '5432',
+ database: decodeURIComponent(database),
+ };
+}
+
+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'],
+ );
+ const challengeType =
+ requestedStrategy === 'auto'
+ ? wildcard
+ ? 'dns-01'
+ : 'http-01'
+ : requestedStrategy;
+
+ if (challengeType === 'http-01' && wildcard) {
+ throw new Error(
+ 'Wildcard certificate issuance requires DNS-01. Set ACME_CHALLENGE_STRATEGY=dns-01 or leave it on auto with DNS hooks configured.',
+ );
+ }
+
+ if (challengeType === 'dns-01') {
+ return {
+ requestedStrategy,
+ challengeType,
+ provider: env['ACME_DNS_PROVIDER']?.trim() || 'manual-dns-hook',
+ wildcard,
+ sharedChallengeStore: false,
+ propagationSeconds: parsePositiveInteger(
+ env['ACME_DNS_PROPAGATION_SECONDS'],
+ 30,
+ ),
+ challengeStore: 'external-dns',
+ visibleInChallengeApi: false,
+ };
+ }
+
+ return {
+ requestedStrategy,
+ challengeType,
+ 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 buildAcmeCertbotPlan(params: {
+ domains: string[];
+ adminEmail: string;
+ orderId: string;
+ instanceId: string;
+ env?: NodeJS.ProcessEnv;
+}): AcmeCertbotPlan {
+ const env = params.env ?? process.env;
+ const domains = normalizeDomains(params.domains, { allowWildcard: true });
+ const primaryDomain = domains[0];
+ const strategy = resolveAcmeStrategy(domains, env);
+
+ const args = [
+ 'certonly',
+ '--manual',
+ `--preferred-challenges=${strategy.challengeType === 'dns-01' ? 'dns' : 'http'}`,
+ '--non-interactive',
+ '--agree-tos',
+ '--manual-public-ip-logging-ok',
+ '--cert-name',
+ getCertificateStorageName(primaryDomain),
+ '-m',
+ params.adminEmail,
+ ...domains.flatMap((domain) => ['-d', domain]),
+ ];
+
+ if ((env['LETSENCRYPT_STAGING'] ?? '').trim().toLowerCase() === 'true') {
+ args.push('--test-cert');
+ }
+
+ const metadata = {
+ requestedStrategy: strategy.requestedStrategy,
+ challengeType: strategy.challengeType,
+ provider: strategy.provider,
+ wildcard: strategy.wildcard,
+ sharedChallengeStore: strategy.sharedChallengeStore,
+ challengeStore: strategy.challengeStore,
+ visibleInChallengeApi: strategy.visibleInChallengeApi,
+ propagationSeconds: strategy.propagationSeconds,
+ } satisfies Record;
+
+ if (strategy.challengeType === 'dns-01') {
+ const authHookPath = parseAbsoluteHookPath(
+ env['ACME_DNS_AUTH_HOOK'],
+ 'ACME_DNS_AUTH_HOOK',
+ );
+ const cleanupHookPath = parseAbsoluteHookPath(
+ env['ACME_DNS_CLEANUP_HOOK'],
+ 'ACME_DNS_CLEANUP_HOOK',
+ );
+
+ args.push(
+ `--manual-auth-hook=${authHookPath}`,
+ `--manual-cleanup-hook=${cleanupHookPath}`,
+ );
+
+ return {
+ requestedStrategy: strategy.requestedStrategy,
+ challengeType: strategy.challengeType,
+ provider: strategy.provider,
+ args,
+ env: {
+ LYTTLE_ACME_ORDER_ID: params.orderId,
+ LYTTLE_ACME_CHALLENGE_TYPE: strategy.challengeType,
+ LYTTLE_ACME_PROVIDER: strategy.provider,
+ LYTTLE_ACME_METADATA_JSON: JSON.stringify({
+ ...metadata,
+ domains,
+ primaryDomain,
+ requestedByNode: params.instanceId,
+ }),
+ ACME_DNS_PROPAGATION_SECONDS: String(strategy.propagationSeconds ?? 30),
+ },
+ metadata,
+ };
+ }
+
+ const authHookPath =
+ env['ACME_HTTP01_AUTH_HOOK']?.trim() || '/certbot-auth-hook.sh';
+ const cleanupHookPath =
+ env['ACME_HTTP01_CLEANUP_HOOK']?.trim() || '/certbot-cleanup-hook.sh';
+
+ if (!path.isAbsolute(authHookPath) || !path.isAbsolute(cleanupHookPath)) {
+ throw new Error(
+ 'ACME_HTTP01_AUTH_HOOK and ACME_HTTP01_CLEANUP_HOOK must be absolute paths inside the container',
+ );
+ }
+
+ const database = parseDatabaseUrlForCertbotHooks(env['DATABASE_URL'] ?? '');
+ args.push(
+ `--manual-auth-hook=${authHookPath}`,
+ `--manual-cleanup-hook=${cleanupHookPath}`,
+ );
+
+ return {
+ requestedStrategy: strategy.requestedStrategy,
+ challengeType: strategy.challengeType,
+ provider: strategy.provider,
+ args,
+ env: {
+ DB_USER: database.username,
+ DB_PASSWORD: database.password,
+ DB_HOST: database.host,
+ DB_PORT: database.port,
+ DB_NAME: database.database,
+ LYTTLE_ACME_ORDER_ID: params.orderId,
+ LYTTLE_ACME_CHALLENGE_TYPE: strategy.challengeType,
+ LYTTLE_ACME_PROVIDER: strategy.provider,
+ LYTTLE_ACME_METADATA_JSON: JSON.stringify({
+ ...metadata,
+ domains,
+ primaryDomain,
+ requestedByNode: params.instanceId,
+ }),
+ ACME_HTTP01_PROPAGATION_SECONDS: String(strategy.propagationSeconds ?? 5),
+ },
+ metadata,
+ };
+}
+
diff --git a/src/certificate/acme.controller.ts b/src/certificate/acme.controller.ts
index 1540016..2976366 100644
--- a/src/certificate/acme.controller.ts
+++ b/src/certificate/acme.controller.ts
@@ -29,11 +29,46 @@ export class AcmeController {
this.logger.log(`[ACME] Challenge request received for token: ${token}`);
try {
+ const acmeChallengeDelegate = (this.prisma as unknown as {
+ acmeChallenge?: {
+ findFirst?: (args: unknown) => Promise<{
+ id: string;
+ token: string;
+ keyAuth: string;
+ domain: string;
+ expiresAt: Date;
+ } | null>;
+ findUnique?: (args: unknown) => Promise<{
+ id: string;
+ token: string;
+ keyAuth: string;
+ domain: string;
+ expiresAt: Date;
+ } | null>;
+ update?: (args: unknown) => Promise;
+ delete?: (args: unknown) => Promise;
+ };
+ }).acmeChallenge;
+
+ if (!acmeChallengeDelegate) {
+ this.logger.error('[ACME] Prisma ACME challenge delegate is unavailable');
+ res.status(HttpStatus.INTERNAL_SERVER_ERROR).send('Internal error');
+ return;
+ }
+
// 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 challenge =
+ typeof acmeChallengeDelegate.findFirst === 'function'
+ ? await acmeChallengeDelegate.findFirst({
+ where: {
+ token,
+ status: 'presented',
+ },
+ })
+ : await acmeChallengeDelegate.findUnique?.({
+ where: { token },
+ });
if (!challenge) {
this.logger.warn(`[ACME] Challenge not found in database: ${token}`);
@@ -48,10 +83,19 @@ export class AcmeController {
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 (typeof acmeChallengeDelegate.update === 'function') {
+ await acmeChallengeDelegate.update({
+ where: { id: challenge.id },
+ data: {
+ status: 'expired',
+ finalizedAt: new Date(),
+ },
+ });
+ } else if (typeof acmeChallengeDelegate.delete === 'function') {
+ await acmeChallengeDelegate.delete({
+ where: { id: challenge.id },
+ });
+ }
res.status(HttpStatus.NOT_FOUND).send('Challenge expired');
return;
}
@@ -60,6 +104,12 @@ export class AcmeController {
this.logger.log(
`[ACME] Returning challenge response for ${challenge.domain} (keyAuth length: ${challenge.keyAuth.length})`,
);
+ await acmeChallengeDelegate.update?.({
+ where: { id: challenge.id },
+ data: {
+ lastServedAt: new Date(),
+ },
+ });
res
.status(HttpStatus.OK)
.contentType('text/plain')
diff --git a/src/certificate/certificate.controller.ts b/src/certificate/certificate.controller.ts
index 34269cb..3be3649 100644
--- a/src/certificate/certificate.controller.ts
+++ b/src/certificate/certificate.controller.ts
@@ -17,6 +17,7 @@ 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,
@@ -67,6 +68,31 @@ export class CertificateController {
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')
diff --git a/src/certificate/certificate.service.ts b/src/certificate/certificate.service.ts
index 20633f2..3d6d944 100644
--- a/src/certificate/certificate.service.ts
+++ b/src/certificate/certificate.service.ts
@@ -13,7 +13,6 @@ import * as fs from 'fs';
import * as path from 'path';
import { addDays } from 'date-fns';
import {
- containsWildcardDomain,
getCertificateStorageName,
hashDomains,
joinDomains,
@@ -29,6 +28,11 @@ import { ClusterOperationsService } from '../distributed-lock/cluster-operations
import { DistributedLockService } from '../distributed-lock/distributed-lock.service';
import { HealthService } from '../health/health.service';
import { runCommand } from '../utils/process-utils';
+import { AcmeChallengeInfoDto } from './dto/acme-challenge.dto';
+import {
+ buildAcmeCertbotPlan,
+ resolveAcmeStrategy,
+} from './acme-strategy';
// Validate ADMIN_EMAIL is set (required by Let's Encrypt)
const adminEmail = process.env.ADMIN_EMAIL;
@@ -48,6 +52,23 @@ class ArtifactActivationError extends Error {
}
}
+type AcmeChallengeRecord = {
+ id: string;
+ orderId: string | null;
+ token: string;
+ domain: string;
+ challengeType: string;
+ provider: string | null;
+ status: string;
+ metadata: Record | null;
+ createdAt: Date;
+ presentedAt: Date;
+ cleanedUpAt: Date | null;
+ finalizedAt: Date | null;
+ lastServedAt: Date | null;
+ expiresAt: Date;
+};
+
@Injectable()
export class CertificateService implements OnModuleInit, OnApplicationShutdown {
private readonly logger = new Logger(CertificateService.name);
@@ -518,13 +539,85 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown {
return stdout.trim();
}
- private assertWildcardIssuanceSupported(domains: string[]): void {
- if (!containsWildcardDomain(domains)) {
+ private getAcmeChallengeDelegate(): {
+ findMany?: (args: unknown) => Promise;
+ update?: (args: unknown) => Promise;
+ } | null {
+ const delegate = (this.prisma as unknown as {
+ acmeChallenge?: {
+ findMany?: (args: unknown) => Promise;
+ update?: (args: unknown) => Promise;
+ };
+ }).acmeChallenge;
+
+ return delegate ?? null;
+ }
+
+ private toAcmeChallengeDto(
+ 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 async finalizeAcmeChallengesForOrder(
+ orderId: string,
+ params: {
+ status: 'validated' | 'failed';
+ error?: string | null;
+ },
+ ): Promise {
+ const delegate = this.getAcmeChallengeDelegate();
+ if (!delegate?.findMany || !delegate.update) {
+ return;
+ }
+
+ const challenges = await delegate.findMany({
+ where: {
+ orderId,
+ status: {
+ in: ['presented', 'cleaned-up'],
+ },
+ },
+ orderBy: [{ presentedAt: 'desc' }, { createdAt: 'desc' }],
+ });
+
+ if (challenges.length === 0) {
return;
}
- throw new Error(
- 'Wildcard certificate issuance requires DNS-01 and is not supported by the current HTTP-01 ACME flow',
+ const finalizedAt = new Date();
+ await Promise.all(
+ challenges.map((challenge) =>
+ delegate.update?.({
+ where: { id: challenge.id },
+ data: {
+ status: params.status,
+ finalizedAt,
+ metadata: this.mergeMetadata(challenge.metadata, {
+ finalization: {
+ status: params.status,
+ error: params.error ?? null,
+ finalizedAt: finalizedAt.toISOString(),
+ },
+ }),
+ },
+ }),
+ ),
);
}
@@ -1141,7 +1234,6 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown {
const domains = Array.isArray(domainsInput)
? normalizeDomains(domainsInput as string[], { allowWildcard: true })
: parseDomains(domainsInput as string, { allowWildcard: true });
- this.assertWildcardIssuanceSupported(domains);
const primaryDomain = domains[0];
const hash = hashDomains(domains, { allowWildcard: true });
const domainsHash = hash;
@@ -1149,6 +1241,7 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown {
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,
@@ -1158,6 +1251,7 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown {
metadata: {
flow: 'certificate.ensure',
renewBeforeDays: RENEW_BEFORE_DAYS,
+ acme: acmeStrategy,
},
});
@@ -1286,6 +1380,13 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown {
);
try {
+ const acmePlan = buildAcmeCertbotPlan({
+ domains,
+ adminEmail,
+ orderId: order.id,
+ instanceId,
+ });
+
await this.certificateOrders.transitionOrder(
order.id,
'challenge-published',
@@ -1295,85 +1396,38 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown {
details: {
lockName,
sourceType,
+ acme: acmePlan.metadata,
+ },
+ data: {
+ metadata: this.mergeMetadata(order.metadata, {
+ acme: acmePlan.metadata,
+ }),
},
},
);
- // 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+)\/([^?]+)/,
- );
-
- 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,
- timestamp: new Date().toISOString(),
- },
- })
- .catch((alertErr) =>
- this.logger.error(
- `[Certbot] Failed to send alert: ${alertErr.message}`,
- ),
- );
-
- throw new Error(errorMsg);
- }
-
- const [, dbUser, dbPassword, dbHost, dbPort, dbName] = dbMatch;
- const certbotArgs = [
- 'certonly',
- '--manual',
- '--preferred-challenges=http',
- `--manual-auth-hook=${authHookPath}`,
- `--manual-cleanup-hook=${cleanupHookPath}`,
- '--non-interactive',
- '--agree-tos',
- '--cert-name',
- getCertificateStorageName(primaryDomain),
- '-m',
- adminEmail,
- ...domains.flatMap((domain) => ['-d', domain]),
- ];
-
this.logger.log(
- `[Certbot] Running command: certbot ${certbotArgs.join(' ')}`,
+ `[Certbot] Running ${acmePlan.challengeType} certificate flow for ${primaryDomain} via provider ${acmePlan.provider}`,
);
- // Use manual mode with shell hooks so all nodes can serve challenges from database
- await runCommand('certbot', certbotArgs, {
- env: {
- DB_USER: dbUser,
- DB_PASSWORD: dbPassword,
- DB_HOST: dbHost,
- DB_PORT: dbPort,
- DB_NAME: dbName,
- },
+ await runCommand('certbot', acmePlan.args, {
+ env: acmePlan.env,
timeoutMs: 10 * 60 * 1000,
});
this.logger.log(
`[Certbot] Successfully obtained/renewed certificate for ${primaryDomain}`,
);
+ await this.finalizeAcmeChallengesForOrder(order.id, {
+ status: 'validated',
+ });
+
await this.certificateOrders.transitionOrder(order.id, 'validating', {
message:
'ACME order completed; validating generated certificate artifacts',
+ details: {
+ acme: acmePlan.metadata,
+ },
});
// Read the cert/key files produced by certbot
@@ -1493,6 +1547,11 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown {
const message = err instanceof Error ? err.message : String(err);
const stack = err instanceof Error ? err.stack : undefined;
+ await this.finalizeAcmeChallengesForOrder(order.id, {
+ status: 'failed',
+ error: message,
+ });
+
if (err instanceof ArtifactActivationError) {
await this.alertService
.sendAlert({
@@ -2022,6 +2081,35 @@ export class CertificateService implements OnModuleInit, OnApplicationShutdown {
return this.certificateOrders.listOrders(limit);
}
+ async listAcmeChallenges(options: { status?: string; limit?: number } = {}) {
+ const delegate = this.getAcmeChallengeDelegate();
+ if (!delegate?.findMany) {
+ return {
+ count: 0,
+ challenges: [] as 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 delegate.findMany({
+ where: normalizedStatus
+ ? {
+ status: normalizedStatus,
+ }
+ : undefined,
+ take,
+ orderBy: [{ presentedAt: 'desc' }, { createdAt: 'desc' }],
+ });
+
+ return {
+ count: challenges.length,
+ challenges: challenges.map((challenge) => this.toAcmeChallengeDto(challenge)),
+ };
+ }
+
async getCertificateOrder(orderId: string) {
return this.certificateOrders.getOrder(orderId);
}
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 | null;
+ createdAt: Date;
+}
+
diff --git a/test/session15/domain-validation-and-safe-process.test.js b/test/session15/domain-validation-and-safe-process.test.js
index 73a2ad9..786c83e 100644
--- a/test/session15/domain-validation-and-safe-process.test.js
+++ b/test/session15/domain-validation-and-safe-process.test.js
@@ -265,12 +265,12 @@ describe('Session 15 strict domain validation and safe process execution', () =>
]);
});
- it('rejects wildcard ACME issuance until DNS-01 support exists', async () => {
+ it('rejects wildcard ACME issuance when DNS-01 hooks are not configured', async () => {
const service = createCertificateService();
await assert.rejects(
() => service.ensureCertificate(['*.example.com', 'example.com']),
- /DNS-01/i,
+ /ACME_DNS_AUTH_HOOK|ACME_DNS_CLEANUP_HOOK/i,
);
});
diff --git a/test/session18/acme-strategy-hardening.test.js b/test/session18/acme-strategy-hardening.test.js
new file mode 100644
index 0000000..75f6a1c
--- /dev/null
+++ b/test/session18/acme-strategy-hardening.test.js
@@ -0,0 +1,320 @@
+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 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) {
+ process.env.ADMIN_EMAIL = 'session18@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,
+ );
+}
+
+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 = 'session18@example.test';
+ resetModules();
+});
+
+afterEach(() => {
+ resetModules();
+ if (originalAdminEmail === undefined) {
+ delete process.env.ADMIN_EMAIL;
+ } else {
+ process.env.ADMIN_EMAIL = originalAdminEmail;
+ }
+});
+
+describe('Session 18 ACME strategy hardening', () => {
+ it('builds the built-in HTTP-01 certbot plan with database-backed challenge publication', () => {
+ const { buildAcmeCertbotPlan } = require(acmeStrategyPath);
+
+ const plan = buildAcmeCertbotPlan({
+ domains: ['example.com', 'www.example.com'],
+ adminEmail: 'admin@example.com',
+ orderId: 'order-http',
+ instanceId: 'node-http',
+ env: {
+ DATABASE_URL:
+ 'postgresql://demo-user:demo-pass@db.internal:5433/lyttlenginx?schema=public',
+ ACME_CHALLENGE_STRATEGY: 'http-01',
+ ACME_HTTP01_PROPAGATION_SECONDS: '7',
+ },
+ });
+
+ assert.equal(plan.challengeType, 'http-01');
+ assert.equal(plan.provider, 'database-http-01');
+ assert.match(
+ plan.args.join(' '),
+ /--preferred-challenges=http .*--manual-auth-hook=\/certbot-auth-hook\.sh .*--manual-cleanup-hook=\/certbot-cleanup-hook\.sh/,
+ );
+ assert.equal(plan.env.DB_USER, 'demo-user');
+ assert.equal(plan.env.DB_PASSWORD, 'demo-pass');
+ assert.equal(plan.env.DB_HOST, 'db.internal');
+ assert.equal(plan.env.DB_PORT, '5433');
+ assert.equal(plan.env.DB_NAME, 'lyttlenginx');
+ assert.equal(plan.env.LYTTLE_ACME_ORDER_ID, 'order-http');
+ assert.equal(plan.env.ACME_HTTP01_PROPAGATION_SECONDS, '7');
+ assert.deepEqual(plan.metadata, {
+ requestedStrategy: 'http-01',
+ challengeType: 'http-01',
+ provider: 'database-http-01',
+ wildcard: false,
+ sharedChallengeStore: true,
+ challengeStore: 'database-http',
+ visibleInChallengeApi: true,
+ propagationSeconds: 7,
+ });
+ });
+
+ it('auto-selects DNS-01 for wildcard domains and requires external DNS hooks', () => {
+ const { buildAcmeCertbotPlan } = require(acmeStrategyPath);
+
+ const plan = buildAcmeCertbotPlan({
+ domains: ['*.example.com', 'example.com'],
+ adminEmail: 'admin@example.com',
+ orderId: 'order-dns',
+ instanceId: 'node-dns',
+ env: {
+ ACME_CHALLENGE_STRATEGY: 'auto',
+ ACME_DNS_PROVIDER: 'route53-manual',
+ ACME_DNS_AUTH_HOOK: '/opt/acme/dns-auth.sh',
+ ACME_DNS_CLEANUP_HOOK: '/opt/acme/dns-cleanup.sh',
+ ACME_DNS_PROPAGATION_SECONDS: '45',
+ LETSENCRYPT_STAGING: 'true',
+ },
+ });
+
+ assert.equal(plan.challengeType, 'dns-01');
+ assert.equal(plan.provider, 'route53-manual');
+ assert.match(
+ plan.args.join(' '),
+ /--preferred-challenges=dns .*--test-cert .*--manual-auth-hook=\/opt\/acme\/dns-auth\.sh .*--manual-cleanup-hook=\/opt\/acme\/dns-cleanup\.sh/,
+ );
+ assert.equal(plan.env.DB_USER, undefined);
+ assert.equal(plan.env.LYTTLE_ACME_ORDER_ID, 'order-dns');
+ assert.equal(plan.env.ACME_DNS_PROPAGATION_SECONDS, '45');
+ assert.deepEqual(plan.metadata, {
+ requestedStrategy: 'auto',
+ challengeType: 'dns-01',
+ provider: 'route53-manual',
+ wildcard: true,
+ sharedChallengeStore: false,
+ challengeStore: 'external-dns',
+ visibleInChallengeApi: false,
+ propagationSeconds: 45,
+ });
+ });
+
+ it('lists recent ACME challenges through the certificate service with status filtering', async () => {
+ const challenges = [
+ {
+ id: 'challenge-1',
+ orderId: 'order-1',
+ token: 'token-1',
+ domain: 'example.com',
+ challengeType: 'http-01',
+ provider: 'database-http-01',
+ status: 'presented',
+ metadata: { providerState: 'published' },
+ createdAt: new Date('2026-05-24T15:30:00.000Z'),
+ 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'),
+ },
+ ];
+
+ let receivedQuery = null;
+ const service = createCertificateService({
+ acmeChallenge: {
+ findMany: async (query) => {
+ receivedQuery = query;
+ return challenges;
+ },
+ },
+ certificate: {
+ findMany: async () => [],
+ findUnique: async () => null,
+ },
+ certificateOrder: {
+ findMany: async () => [],
+ findUnique: async () => null,
+ },
+ certificateArtifactVersion: {
+ update: async () => undefined,
+ updateMany: async () => ({ count: 0 }),
+ },
+ proxyEntry: {
+ findMany: async () => [],
+ },
+ });
+
+ const result = await service.listAcmeChallenges({
+ status: 'presented',
+ limit: 10,
+ });
+
+ assert.deepEqual(receivedQuery.where, { status: 'presented' });
+ assert.equal(receivedQuery.take, 10);
+ assert.deepEqual(receivedQuery.orderBy, [
+ { presentedAt: 'desc' },
+ { createdAt: 'desc' },
+ ]);
+ 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 presented ACME challenges and marks expired ones', async () => {
+ resetModules();
+ const { AcmeController } = require(acmeControllerPath);
+
+ const updates = [];
+ const activeChallenge = {
+ id: 'challenge-active',
+ token: 'token-active',
+ keyAuth: 'key-auth-active',
+ domain: 'example.com',
+ expiresAt: new Date(Date.now() + 60_000),
+ };
+ const expiredChallenge = {
+ id: 'challenge-expired',
+ token: 'token-expired',
+ keyAuth: 'key-auth-expired',
+ domain: 'expired.example.com',
+ expiresAt: new Date(Date.now() - 60_000),
+ };
+
+ const controller = new AcmeController({
+ acmeChallenge: {
+ findFirst: async ({ where }) => {
+ if (where.token === 'token-active' && where.status === 'presented') {
+ return activeChallenge;
+ }
+ if (where.token === 'token-expired' && where.status === 'presented') {
+ return expiredChallenge;
+ }
+ return null;
+ },
+ update: async (payload) => {
+ updates.push(payload);
+ return undefined;
+ },
+ },
+ });
+
+ 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');
+
+ assert.deepEqual(updates[0], {
+ where: { id: 'challenge-active' },
+ data: { lastServedAt: updates[0].data.lastServedAt },
+ });
+ assert.equal(updates[0].data.lastServedAt instanceof Date, true);
+ assert.deepEqual(updates[1], {
+ where: { id: 'challenge-expired' },
+ data: {
+ status: 'expired',
+ finalizedAt: updates[1].data.finalizedAt,
+ },
+ });
+ assert.equal(updates[1].data.finalizedAt instanceof Date, true);
+ });
+});
+
From 7dd4b0db9b77db921fd04736e349c0cfa6739ab4 Mon Sep 17 00:00:00 2001
From: Stualyttle <27898452+Stualyttle@users.noreply.github.com.>
Date: Tue, 26 May 2026 20:04:44 +0200
Subject: [PATCH 21/43] feat: implement Nest-managed ACME strategy with
database-backed HTTP-01 and DNS-01 challenge orchestration
---
.env.example | 28 +-
ARCHITECTURE_DECISIONS.md | 16 +-
Dockerfile | 14 +-
IMPLEMENTATION_STATUS.md | 22 +-
README.md | 39 +-
certbot-auth-hook.sh | 130 -----
certbot-cleanup-hook.sh | 58 ---
package-lock.json | 310 +++++++++++-
package.json | 1 +
src/certificate/acme-strategy.ts | 246 +++------
src/certificate/acme.controller.ts | 83 +--
src/certificate/acme.service.ts | 472 ++++++++++++++++++
src/certificate/certificate.module.ts | 2 +
src/certificate/certificate.service.ts | 212 ++------
src/certificate/tls-config.service.ts | 10 -
src/reloader/reloader.service.ts | 2 -
...domain-validation-and-safe-process.test.js | 19 +-
.../certificate-order-state-machine.test.js | 15 +-
...tificate-distribution-and-rollback.test.js | 1 +
.../session18/acme-strategy-hardening.test.js | 297 +++++------
test/session3/auth-lockdown.test.js | 11 +
21 files changed, 1136 insertions(+), 852 deletions(-)
delete mode 100755 certbot-auth-hook.sh
delete mode 100755 certbot-cleanup-hook.sh
create mode 100644 src/certificate/acme.service.ts
diff --git a/.env.example b/.env.example
index fd078b4..53a9570 100644
--- a/.env.example
+++ b/.env.example
@@ -98,25 +98,31 @@ ALERT_THRESHOLD_DAYS="14"
# ACME challenge strategy selection.
# - auto : use built-in HTTP-01 for normal domains, DNS-01 for wildcard orders
# - http-01: force the built-in database-backed HTTP-01 flow
-# - dns-01 : force DNS-01 using operator-supplied manual hook scripts
+# - dns-01 : force DNS-01 using the NestJS-managed challenge workflow
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.
-# These default hook paths are baked into the container image; override only if you
-# deliberately mount alternate hook implementations.
-ACME_HTTP01_AUTH_HOOK="/certbot-auth-hook.sh"
-ACME_HTTP01_CLEANUP_HOOK="/certbot-cleanup-hook.sh"
ACME_HTTP01_PROVIDER="database-http-01"
ACME_HTTP01_PROPAGATION_SECONDS="5"
+ACME_HTTP01_VERIFICATION_TIMEOUT_MS="30000"
+ACME_HTTP01_POLL_INTERVAL_MS="2000"
# DNS-01 configuration.
-# Wildcard orders require DNS-01. Mount provider-specific hook scripts into the
-# container and point these settings at their absolute paths.
-# Example provider label: route53-manual, cloudflare-manual, infoblox-manual
-ACME_DNS_PROVIDER="route53-manual"
-ACME_DNS_AUTH_HOOK="/opt/acme/dns-auth.sh"
-ACME_DNS_CLEANUP_HOOK="/opt/acme/dns-cleanup.sh"
+# Wildcard orders require DNS-01. The application records the desired TXT record in
+# PostgreSQL and waits for an external DNS workflow to publish it.
+# Example provider labels: manual-dns-nest, route53-api, cloudflare-api
+ACME_DNS_PROVIDER="manual-dns-nest"
ACME_DNS_PROPAGATION_SECONDS="45"
+ACME_DNS_WAIT_TIMEOUT_MS="180000"
+ACME_DNS_POLL_INTERVAL_MS="3000"
# ===============================================
# EMAIL/SMTP CONFIGURATION (OPTIONAL - RECOMMENDED)
diff --git a/ARCHITECTURE_DECISIONS.md b/ARCHITECTURE_DECISIONS.md
index ddd6da4..a571626 100644
--- a/ARCHITECTURE_DECISIONS.md
+++ b/ARCHITECTURE_DECISIONS.md
@@ -701,7 +701,7 @@ Separate issuance from activation and make activation an ACK-backed cluster oper
---
-## ADR-021 — Explicit ACME strategy selection with DB-backed HTTP-01 tracking and DNS-01 hook support
+## ADR-021 — Explicit ACME strategy selection with Nest-managed HTTP-01 and DNS-01 challenge orchestration
- Status: accepted
- Session: Session 18 — Harden the ACME strategy for clustered production
@@ -709,28 +709,28 @@ Separate issuance from activation and make activation an ACK-backed cluster oper
### Context
-By the end of Session 17, certificate activation and rollback were cluster-aware, but the pre-issuance ACME flow still relied on an implicit HTTP-01-only certbot invocation. That left three gaps called out in the production-readiness assessment:
+By the end of Session 17, certificate activation and rollback were cluster-aware, but the pre-issuance ACME flow still relied on external shell-oriented orchestration. That left three gaps called out in the production-readiness assessment:
1. wildcard issuance still had no supported path
2. challenge publication / cleanup / finalization was not explicit or inspectable enough for operators
-3. the repository had no clear operator-facing contract for when to use shared HTTP challenge serving versus external DNS provider hooks
+3. the repository had no clear operator-facing contract for when to use shared HTTP challenge serving versus external DNS provisioning
### Decision
Adopt an explicit ACME strategy layer with the following rules:
1. introduce `ACME_CHALLENGE_STRATEGY` with `auto`, `http-01`, and `dns-01` modes
-2. keep the built-in cluster-safe HTTP-01 path for non-wildcard orders by writing challenge state into PostgreSQL and serving it from every node through `/.well-known/acme-challenge/:token`
+2. keep the built-in cluster-safe HTTP-01 path for non-wildcard orders by writing challenge state into PostgreSQL, serving it from every node through `/.well-known/acme-challenge/:token`, and verifying it in-process from NestJS
3. preserve HTTP-01 challenge rows through cleanup/finalization so operators can inspect challenge lifecycle state instead of losing it immediately on cleanup
-4. support DNS-01 through operator-supplied manual hook scripts configured by absolute-path env vars (`ACME_DNS_AUTH_HOOK`, `ACME_DNS_CLEANUP_HOOK`) plus an explicit provider label (`ACME_DNS_PROVIDER`)
+4. support DNS-01 through the same Nest-managed challenge lifecycle by storing desired TXT record metadata in `AcmeChallenge` and waiting for external DNS provisioning rather than delegating to shell hook scripts
5. in `auto` mode, resolve wildcard orders to DNS-01 and non-wildcard orders to the built-in HTTP-01 path
-6. expose recent built-in HTTP-01 challenge records through an authenticated inspection API (`GET /certificates/challenges`)
+6. expose recent ACME challenge records through an authenticated inspection API (`GET /certificates/challenges`)
### Consequences
-- wildcard issuance is now supported when DNS-01 hooks are configured, without weakening the existing safe-domain validation rules
+- wildcard issuance now uses the in-app DNS-01 workflow, without weakening the existing safe-domain validation rules
- the built-in HTTP-01 flow remains cluster-safe because any node can serve the shared challenge record, while challenge publication / cleanup / validation state is now queryable afterward
-- DNS-01 provider behavior remains intentionally pluggable: the application defines the hook contract and metadata, while provider-specific DNS mutations stay outside the main codebase until a later provider integration session is justified
+- DNS-01 provider behavior remains intentionally pluggable: the application records the desired TXT record and waits for it in-process, while provider-specific DNS mutations can still be integrated later without reintroducing shell hooks
- future certificate, observability, and operator-API sessions should build on this explicit ACME strategy metadata instead of assuming a single hard-coded HTTP-01 flow
- later sessions can build richer activation policies, operator APIs, and certificate-distribution observability on top of artifact-level rollout state instead of inferring activation from local filesystem writes alone
diff --git a/Dockerfile b/Dockerfile
index ca82602..e4f0245 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -36,8 +36,6 @@ RUN apt-get update && \
openssl \
bash \
tini \
- certbot \
- python3-certbot-nginx \
procps \
netcat-openbsd \
postgresql-client && \
@@ -55,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
@@ -72,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
@@ -82,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_STATUS.md b/IMPLEMENTATION_STATUS.md
index d3f849d..d66ca9d 100644
--- a/IMPLEMENTATION_STATUS.md
+++ b/IMPLEMENTATION_STATUS.md
@@ -653,9 +653,9 @@ Use it as the single place to record what has shipped, what is in progress, and
- Files touched:
- `prisma/schema.prisma`
- `prisma/migrations/20260524213000_session18_acme_strategy_hardening/migration.sql`
- - `certbot-auth-hook.sh`
- - `certbot-cleanup-hook.sh`
+ - `Dockerfile`
- `src/certificate/acme-strategy.ts`
+ - `src/certificate/acme.service.ts`
- `src/certificate/acme.controller.ts`
- `src/certificate/certificate.controller.ts`
- `src/certificate/certificate.service.ts`
@@ -667,12 +667,12 @@ Use it as the single place to record what has shipped, what is in progress, and
- `test/session15/domain-validation-and-safe-process.test.js`
- `test/session18/acme-strategy-hardening.test.js`
- Tests added/updated:
- - added `test/session18/acme-strategy-hardening.test.js` to verify built-in HTTP-01 planning, wildcard DNS-01 auto-selection, challenge inspection listing, and challenge-serving lifecycle behavior
- - updated the Session 15 wildcard regression so it now asserts that wildcard issuance requires DNS-01 hook configuration rather than rejecting all wildcard ACME orders categorically
- - re-ran the focused Session 15 and Session 18 suites locally after landing the strategy changes
+ - added `test/session18/acme-strategy-hardening.test.js` to verify Nest-managed ACME strategy resolution, wildcard DNS-01 auto-selection, challenge inspection delegation, and challenge-serving lifecycle behavior
+ - updated the Session 15 wildcard regression so it now asserts wildcard ACME requests resolve onto the DNS-01 strategy without shell hooks
+ - updated the Session 16 and Session 17 harnesses for the new in-app ACME service dependency and re-ran the focused Session 3 / 15 / 16 / 17 / 18 suites locally after the refactor
- Risks:
- - DNS-01 is now supported through operator-supplied manual hooks, but first-class provider-specific integrations remain external to the app for now
- - built-in challenge inspection currently covers the DB-backed HTTP-01 flow only; provider-managed DNS-01 challenge detail remains owned by the mounted provider hooks
+ - DNS-01 now stays inside the NestJS lifecycle, but provider-specific DNS mutation is still external; the app currently waits for external TXT publication rather than managing provider APIs directly
+ - built-in challenge inspection now covers both HTTP-01 and DNS-01 intent/state, but richer operator workflows around DNS challenge approval and long-running issuance remain future work
- private keys and artifact history are still stored plaintext until Session 19 lands encryption-at-rest
- Follow-up sessions:
- Session 19 — encrypt private key material at rest
@@ -681,10 +681,10 @@ Use it as the single place to record what has shipped, what is in progress, and
- Session 25 — expand metrics and alerting
- Session 29 — reconcile README, architecture docs, and runbooks with reality
- Notes:
- - introduced an explicit ACME strategy layer that supports `auto`, `http-01`, and `dns-01` challenge modes instead of assuming one implicit certbot path
- - the built-in HTTP-01 flow now stores publication, cleanup, expiry, and finalization state in `AcmeChallenge`, and operators can inspect recent challenge rows through `GET /certificates/challenges`
- - wildcard orders now resolve to DNS-01 automatically when the required DNS hook configuration is present, while non-wildcard orders continue to use the cluster-safe database-backed HTTP-01 publication path by default
- - certbot hook wiring now carries per-order metadata and propagation tuning through explicit environment variables instead of relying on ad hoc implicit behavior
+ - replaced the prior shell-hook/certbot orchestration path with a Nest-managed `AcmeService` built on `acme-client`, so HTTP-01 and DNS-01 challenge lifecycle work now stays inside the application
+ - the built-in HTTP-01 flow stores publication, cleanup, expiry, and finalization state in `AcmeChallenge`, and operators can inspect recent challenge rows through `GET /certificates/challenges`
+ - wildcard orders now resolve to DNS-01 automatically and record the required TXT record metadata in the database, allowing external DNS automation or operator workflows to satisfy the challenge without OS-specific scripts
+ - the runtime now persists the ACME account key under `ACME_ACCOUNT_PRIVATE_KEY_PATH` instead of depending on external certbot account state and hook scripts
---
diff --git a/README.md b/README.md
index 957b8ad..fcc6471 100644
--- a/README.md
+++ b/README.md
@@ -35,7 +35,7 @@ Built with [NestJS](https://nestjs.com/) • Powered by [PostgreSQL](https://www
## 📍 Current Delivery Status
- **Roadmap status:** Phase 5 in progress
-- **Completed in Sessions 1-18 plus follow-up maintenance:** delivery scaffolding, dependency hygiene, authenticated-by-default admin APIs, dependency-aware health semantics, fail-fast container supervision, explicit inter-node control-plane addressing, an identity-aware auth foundation, explicit RBAC authorization policies, durable audit logging for privileged and mutating operations, durable leader leases, lease-backed heartbeat/leader reconciliation, durable cluster operation journaling with per-node ACK tracking, staged NGINX release activation with rollback-safe config deployment, validated allowlisted custom NGINX fragments, strict certificate-domain validation with safe process execution, durable certificate-order state tracking with artifact history and retryable workflows, ACK-backed cluster certificate activation with rollback to prior artifact versions, and an explicit ACME strategy layer with built-in HTTP-01 challenge tracking plus DNS-01 hook support
+- **Completed in Sessions 1-18 plus follow-up maintenance:** delivery scaffolding, dependency hygiene, authenticated-by-default admin APIs, dependency-aware health semantics, fail-fast container supervision, explicit inter-node control-plane addressing, an identity-aware auth foundation, explicit RBAC authorization policies, durable audit logging for privileged and mutating operations, durable leader leases, lease-backed heartbeat/leader reconciliation, durable cluster operation journaling with per-node ACK tracking, staged NGINX release activation with rollback-safe config deployment, validated allowlisted custom NGINX fragments, strict certificate-domain validation with safe process execution, durable certificate-order state tracking with artifact history and retryable workflows, ACK-backed cluster certificate activation with rollback to prior artifact versions, and an explicit Nest-managed ACME strategy layer with built-in HTTP-01 challenge tracking plus DNS-01 orchestration metadata
- **Next recommended implementation session:** Session 19 — encrypt private key material at rest
- **Canonical planning and status docs:**
- [`PRODUCTION_READINESS_ASSESSMENT.md`](PRODUCTION_READINESS_ASSESSMENT.md)
@@ -622,30 +622,30 @@ The runtime now supports three `ACME_CHALLENGE_STRATEGY` modes:
- `auto` *(default)* — use built-in HTTP-01 for non-wildcard orders and DNS-01 for wildcard orders
- `http-01` — force the built-in database-backed HTTP-01 flow
-- `dns-01` — force DNS-01 through operator-supplied manual hook scripts
+- `dns-01` — force DNS-01 through the in-app NestJS challenge workflow with external DNS provisioning
#### Built-in HTTP-01 strategy
The hardened HTTP-01 flow is the default for non-wildcard orders and remains cluster-safe:
-1. the issuing node runs `certbot --manual --preferred-challenges=http`
-2. the built-in auth hook writes the challenge token + key authorization into PostgreSQL together with order/strategy metadata
+1. the issuing node starts an in-process ACME order through the NestJS `AcmeService`
+2. the HTTP-01 challenge token + key authorization are written into PostgreSQL together with order/strategy metadata
3. any node can answer `/.well-known/acme-challenge/:token` from the shared database record
-4. the cleanup hook marks the challenge as cleaned up instead of deleting it immediately
+4. challenge verification happens in-process before the ACME order is completed
5. the certificate service finalizes the challenge lifecycle as `validated` or `failed`, which makes challenge publication / finalization inspectable after the run
The `GET /certificates/challenges` endpoint exposes recent built-in HTTP-01 challenge records with statuses such as `presented`, `cleaned-up`, `validated`, `failed`, and `expired`.
#### DNS-01 strategy
-Wildcard orders and operator-selected DNS flows now use external manual hooks:
+Wildcard orders and operator-selected DNS flows now stay inside the NestJS control plane as well:
-- `ACME_DNS_AUTH_HOOK` — absolute path to the DNS challenge publication hook inside the container
-- `ACME_DNS_CLEANUP_HOOK` — absolute path to the DNS challenge cleanup hook inside the container
-- `ACME_DNS_PROVIDER` — operator-facing label for the DNS provider / hook implementation
-- `ACME_DNS_PROPAGATION_SECONDS` — provider-specific propagation wait hint passed to the hook environment
+- `ACME_DNS_PROVIDER` — operator-facing label for the external DNS workflow or provider integration
+- `ACME_DNS_PROPAGATION_SECONDS` — initial propagation delay hint before verification begins
+- `ACME_DNS_WAIT_TIMEOUT_MS` — maximum time the app waits for the expected TXT record to become visible
+- `ACME_DNS_POLL_INTERVAL_MS` — polling interval while waiting for the TXT record to appear
-DNS-01 challenge details are managed by the provider hook itself rather than the built-in DB-backed HTTP challenge table, so `GET /certificates/challenges` is intentionally focused on the built-in HTTP-01 publication path.
+For DNS-01, the application stores the desired TXT record name/value in `AcmeChallenge.metadata`, exposes it through `GET /certificates/challenges`, and then verifies the record in-process. This removes the old shell-hook dependency while keeping the workflow explicit and auditable.
Example configuration snippets:
@@ -657,17 +657,20 @@ ACME_CHALLENGE_STRATEGY=auto
ACME_CHALLENGE_STRATEGY=http-01
ACME_HTTP01_PROPAGATION_SECONDS=5
-# Force DNS-01 through mounted provider hooks
+# Force DNS-01 through the in-app flow while an external DNS automation path publishes TXT records
ACME_CHALLENGE_STRATEGY=dns-01
-ACME_DNS_PROVIDER=route53-manual
-ACME_DNS_AUTH_HOOK=/opt/acme/dns-auth.sh
-ACME_DNS_CLEANUP_HOOK=/opt/acme/dns-cleanup.sh
+ACME_DNS_PROVIDER=manual-dns-nest
ACME_DNS_PROPAGATION_SECONDS=45
+ACME_DNS_WAIT_TIMEOUT_MS=180000
+ACME_DNS_POLL_INTERVAL_MS=3000
+
+# Optional explicit ACME account key location
+ACME_ACCOUNT_PRIVATE_KEY_PATH=/app/state/acme/account.pem
```
### Certificate order state machine
-Session 16 adds a durable certificate-order workflow so issuance is no longer just an ephemeral `certbot` run plus a best-effort database write.
+Session 16 adds a durable certificate-order workflow so issuance is no longer just an ephemeral ACME subprocess plus a best-effort database write.
Current order states are:
@@ -1003,7 +1006,6 @@ services:
NODE_ENV: production
volumes:
- letsencrypt-data:/etc/letsencrypt
- - certbot-webroot:/var/www/certbot
- nginx-ssl:/etc/nginx/ssl
ports:
- 80:80
@@ -1015,7 +1017,6 @@ services:
volumes:
postgres-data:
letsencrypt-data:
- certbot-webroot:
nginx-ssl:
```
@@ -1194,7 +1195,7 @@ psql $DATABASE_URL
curl http://localhost:3000/certificates/validate/yourdomain.com
# 2. Check logs
-docker-compose logs app | grep -i certbot
+docker-compose logs app | grep -i acme
# 3. Verify email is set
echo $ADMIN_EMAIL
diff --git a/certbot-auth-hook.sh b/certbot-auth-hook.sh
deleted file mode 100755
index 088451b..0000000
--- a/certbot-auth-hook.sh
+++ /dev/null
@@ -1,130 +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:-}"
-ORDER_ID="${LYTTLE_ACME_ORDER_ID:-}"
-CHALLENGE_TYPE="${LYTTLE_ACME_CHALLENGE_TYPE:-http-01}"
-PROVIDER="${LYTTLE_ACME_PROVIDER:-database-http-01}"
-METADATA_JSON="${LYTTLE_ACME_METADATA_JSON:-}"
-PROPAGATION_SECONDS="${ACME_HTTP01_PROPAGATION_SECONDS:-2}"
-
-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//\'/\'\'}"
-ORDER_ID_ESC="${ORDER_ID//\'/\'\'}"
-CHALLENGE_TYPE_ESC="${CHALLENGE_TYPE//\'/\'\'}"
-PROVIDER_ESC="${PROVIDER//\'/\'\'}"
-METADATA_JSON_ESC="${METADATA_JSON//\'/\'\'}"
-
-# 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 <&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//\'/\'\'}"
-
-# Mark challenge as cleaned up in the database
-echo "[Cleanup Hook] Marking challenge as cleaned up 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 <=20.0.0"
+ }
+ },
"node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
@@ -4083,6 +4242,22 @@
"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.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
@@ -4132,6 +4307,18 @@
"node": ">=0.4.0"
}
},
+ "node_modules/agent-base": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
+ "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 6.0.0"
+ }
+ },
"node_modules/ajv": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
@@ -4363,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",
@@ -4373,7 +4574,6 @@
"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": {
@@ -4385,6 +4585,18 @@
"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",
@@ -5249,7 +5461,6 @@
"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"
@@ -5634,7 +5845,6 @@
"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"
@@ -5868,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",
@@ -6599,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",
@@ -6671,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",
@@ -6688,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"
@@ -6698,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"
@@ -7070,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"
@@ -7137,6 +7362,19 @@
"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",
+ "dependencies": {
+ "agent-base": "6",
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
"node_modules/human-signals": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz",
@@ -8931,6 +9169,15 @@
"lodash": "^4.17.21"
}
},
+ "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": ">= 6.13.0"
+ }
+ },
"node_modules/node-int64": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
@@ -9589,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",
@@ -9617,6 +9873,24 @@
"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.15.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
@@ -11109,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",
diff --git a/package.json b/package.json
index ef88ece..63e3d91 100644
--- a/package.json
+++ b/package.json
@@ -49,6 +49,7 @@
"@promster/express": "^15.5.1",
"@types/archiver": "^7.0.0",
"@types/nodemailer": "^8.0.0",
+ "acme-client": "^5.4.0",
"archiver": "^8.0.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
diff --git a/src/certificate/acme-strategy.ts b/src/certificate/acme-strategy.ts
index 8f6e7bd..d8e283d 100644
--- a/src/certificate/acme-strategy.ts
+++ b/src/certificate/acme-strategy.ts
@@ -1,5 +1,5 @@
import * as path from 'node:path';
-import { containsWildcardDomain, getCertificateStorageName, normalizeDomains } from '../utils/domain-utils';
+import { normalizeDomain, containsWildcardDomain, normalizeDomains } from '../utils/domain-utils';
export const ACME_CHALLENGE_STRATEGIES = ['auto', 'http-01', 'dns-01'] as const;
export type AcmeChallengeStrategy = (typeof ACME_CHALLENGE_STRATEGIES)[number];
@@ -14,37 +14,21 @@ export type ResolvedAcmeStrategy = {
wildcard: boolean;
sharedChallengeStore: boolean;
propagationSeconds: number | null;
- challengeStore: 'database-http' | 'external-dns';
+ challengeStore: 'database-http' | 'database-dns';
visibleInChallengeApi: boolean;
};
-export type AcmeCertbotPlan = {
- requestedStrategy: AcmeChallengeStrategy;
- challengeType: AcmeChallengeType;
- provider: string;
- args: string[];
- env: NodeJS.ProcessEnv;
- metadata: Record