From 64ac5c0c6652de4cf7944f3493820ebd9044d11b Mon Sep 17 00:00:00 2001 From: Ochenstarik Date: Fri, 18 Sep 2026 01:34:12 +0700 Subject: [PATCH] fix: enforce tenant isolation and runtime contracts --- .env.example | 13 +- .forgejo/workflows/ci.yml | 20 + .gitignore | 2 + .prettierignore | 6 + Dockerfile | 4 +- README.md | 10 +- compose.yaml | 38 +- docker/postgres/init-app-role.sh | 21 + docs/ARCHITECTURE.md | 2 +- docs/DEPLOYMENT.md | 6 +- docs/DOMAIN-BOUNDARIES.md | 22 +- eslint.config.js | 6 +- migrations/0001_infrastructure.up.sql | 53 +- migrations/0002_security_hardening.down.sql | 30 + migrations/0002_security_hardening.up.sql | 68 + package-lock.json | 1417 ++++++++----------- package.json | 8 +- schemas/module.schema.json | 50 +- schemas/settings.schema.schema.json | 18 +- scripts/check-contracts.mjs | 27 +- scripts/check-migrations.mjs | 10 +- scripts/create-app-role.mjs | 25 + scripts/migrate.mjs | 94 +- src/adapters/identity.ts | 4 +- src/app.ts | 119 +- src/config.ts | 15 +- src/core/ports.ts | 6 +- src/infrastructure/database.ts | 29 +- src/server.ts | 26 +- src/version.ts | 2 + tests/integration/health.test.ts | 55 +- tests/integration/openapi-runtime.test.ts | 57 + tests/integration/tenant-isolation.test.ts | 61 + tests/unit/config.test.ts | 18 +- tests/unit/events.test.ts | 3 +- tests/unit/ids.test.ts | 3 +- tests/unit/tenant-sql.test.ts | 5 +- tsconfig.json | 2 +- vitest.config.ts | 8 + 39 files changed, 1410 insertions(+), 953 deletions(-) create mode 100644 .prettierignore create mode 100644 docker/postgres/init-app-role.sh create mode 100644 migrations/0002_security_hardening.down.sql create mode 100644 migrations/0002_security_hardening.up.sql create mode 100644 scripts/create-app-role.mjs create mode 100644 src/version.ts create mode 100644 tests/integration/openapi-runtime.test.ts create mode 100644 tests/integration/tenant-isolation.test.ts create mode 100644 vitest.config.ts diff --git a/.env.example b/.env.example index dac5c2c..a0f046c 100644 --- a/.env.example +++ b/.env.example @@ -2,18 +2,13 @@ NODE_ENV=development PORT=8080 LOG_LEVEL=info STAFF_RUNTIME_MODE=standalone -DATABASE_URL=postgres://krona_staff:krona_staff@postgres:5432/krona_staff -REDIS_URL=redis://redis:6379 +POSTGRES_MIGRATION_PASSWORD=local-migration-only +POSTGRES_APP_PASSWORD=local-app-only +DATABASE_MIGRATION_URL=postgres://krona_migrator:local-migration-only@postgres:5432/krona_staff +DATABASE_URL=postgres://krona_staff_app:local-app-only@postgres:5432/krona_staff PLATFORM_GATEWAY_URL=http://host.docker.internal:3000 PLATFORM_JWT_ISSUER=https://platform.example.invalid PLATFORM_JWT_AUDIENCE=krona-staff PLATFORM_JWKS_URL=https://platform.example.invalid/.well-known/jwks.json STANDALONE_TENANT_ID=01J00000000000000000000000 STANDALONE_ACTOR_ID=01J00000000000000000000001 -OBJECT_STORAGE_DRIVER=s3 -S3_ENDPOINT=http://minio:9000 -S3_REGION=us-east-1 -S3_BUCKET=krona-staff -S3_ACCESS_KEY_ID=change-me -S3_SECRET_ACCESS_KEY=change-me -BACKUP_ENCRYPTION_RECIPIENT=age1replace-with-public-recipient diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 08b72e9..1beabd2 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -6,11 +6,31 @@ on: jobs: verify: runs-on: docker + services: + postgres: + image: postgres:17-alpine@sha256:18cfe3ef5e6815560c98237d6216d1e5119702fb0f3894c8785dd58b8bbe5d73 + env: + POSTGRES_DB: krona_staff + POSTGRES_USER: krona_migrator + POSTGRES_PASSWORD: ci-migration-password + ports: ['5432:5432'] + options: >- + --health-cmd "pg_isready -U krona_migrator -d krona_staff" + --health-interval 5s + --health-timeout 3s + --health-retries 20 + env: + DATABASE_MIGRATION_URL: postgres://krona_migrator:ci-migration-password@postgres:5432/krona_staff + DATABASE_TEST_URL: postgres://krona_staff_app:ci-app-password@postgres:5432/krona_staff + POSTGRES_APP_PASSWORD: ci-app-password steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: { node-version: 22, cache: npm } - run: npm ci + - run: node scripts/create-app-role.mjs + - run: node scripts/migrate.mjs up - run: npm run check + - run: npm run format:check - run: npm audit --audit-level=high - run: docker build -t krona-staff:${{ forgejo.sha }} . diff --git a/.gitignore b/.gitignore index 0cccee8..43a1b3d 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,5 @@ coverage/ .DS_Store backups/ restore-work/ +apps/KronaStaffTerminal/bin/ +apps/KronaStaffTerminal/obj/ diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..897a790 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,6 @@ +node_modules/ +.npm-cache/ +dist/ +coverage/ +apps/KronaStaffTerminal/bin/ +apps/KronaStaffTerminal/obj/ diff --git a/Dockerfile b/Dockerfile index e306168..a7e521a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # syntax=docker/dockerfile:1.7 -FROM node:22-alpine AS build +FROM node:22-alpine@sha256:c610fcdfb1d5b4740dd70c284ed3cb16bb857e0f7166196e36a5501df7a3aa32 AS build WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci @@ -9,7 +9,7 @@ COPY scripts ./scripts COPY migrations ./migrations RUN npm run build -FROM node:22-alpine AS runtime +FROM node:22-alpine@sha256:c610fcdfb1d5b4740dd70c284ed3cb16bb857e0f7166196e36a5501df7a3aa32 AS runtime ENV NODE_ENV=production WORKDIR /app RUN addgroup -S app && adduser -S -G app app diff --git a/README.md b/README.md index abd79d9..28e5501 100644 --- a/README.md +++ b/README.md @@ -9,21 +9,21 @@ ```bash cp .env.example .env docker compose --profile tools run --rm migrate -docker compose up --build app postgres redis +docker compose up --build app postgres ``` -Открыть: shell `http://localhost:8080/`, OpenAPI JSON `/openapi.json`, проверки `/live`, `/ready`, `/version`. +Открыть: shell `http://localhost:8080/`, OpenAPI JSON `/api/v1/staff/openapi.json`, проверки `/api/v1/staff/live`, `/api/v1/staff/ready`, `/api/v1/staff/version`. -Без Docker: Node.js 22+, PostgreSQL 17 и Redis 7; затем `npm ci`, `npm run db:migrate`, `npm run dev`. +Без Docker: Node.js 22+ и PostgreSQL 17; затем `npm ci`, создайте отдельные роли мигратора и приложения, выполните `npm run db:migrate` и `npm run dev`. ## Команды - `npm run check` — lint, typecheck, tests, миграции, контракты и build. -- `npm run db:migrate` / `npm run db:migrate:down` — применить/откатить миграции. +- `npm run db:migrate` — применить ещё не выполненные миграции. Откат удаляет только последнюю миграцию и требует `ALLOW_MIGRATION_DOWN=true`. - `npm run check:contracts` — проверить manifest/settings/OpenAPI. Архитектурные решения: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md), интеграция: [docs/PLATFORM-INTEGRATION.md](docs/PLATFORM-INTEGRATION.md), будущий объём: [docs/ROADMAP.md](docs/ROADMAP.md). ## Биометрический терминал -Windows-приложение для SLK20R находится в [apps/KronaStaffTerminal](apps/KronaStaffTerminal). Оно добавляет сотрудников, регистрирует отпечаток по трём сканам, отмечает первый приход за день и отправляет уведомление в Telegram и/или MAX. Инструкция по запуску и настройке находится в [README терминала](apps/KronaStaffTerminal/README.md). +Windows-приложение для SLK20R находится в [apps/KronaStaffTerminal](apps/KronaStaffTerminal). Оно добавляет сотрудников, регистрирует несколько отпечатков по трём сканам, учитывает приход/уход и отправляет уведомление в Telegram и/или MAX. Инструкция по запуску и настройке находится в [README терминала](apps/KronaStaffTerminal/README.md). diff --git a/compose.yaml b/compose.yaml index a826dd1..4bdf899 100644 --- a/compose.yaml +++ b/compose.yaml @@ -2,34 +2,40 @@ services: app: build: . env_file: .env - ports: ["8080:8080"] + environment: + DATABASE_URL: postgres://krona_staff_app:${POSTGRES_APP_PASSWORD:-local-app-only}@postgres:5432/krona_staff + ports: ['8080:8080'] depends_on: postgres: { condition: service_healthy } - redis: { condition: service_healthy } restart: unless-stopped read_only: true tmpfs: [/tmp] - security_opt: ["no-new-privileges:true"] + security_opt: ['no-new-privileges:true'] migrate: build: { context: ., target: build } env_file: .env - command: ["node", "scripts/migrate.mjs", "up"] + environment: + DATABASE_MIGRATION_URL: postgres://krona_migrator:${POSTGRES_MIGRATION_PASSWORD:-local-migration-only}@postgres:5432/krona_staff + command: ['node', 'scripts/migrate.mjs', 'up'] depends_on: postgres: { condition: service_healthy } - profiles: ["tools"] + profiles: ['tools'] postgres: - image: postgres:17-alpine + image: postgres:17-alpine@sha256:18cfe3ef5e6815560c98237d6216d1e5119702fb0f3894c8785dd58b8bbe5d73 environment: POSTGRES_DB: krona_staff - POSTGRES_USER: krona_staff - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-local-development-only} - volumes: ["postgres-data:/var/lib/postgresql/data"] - healthcheck: { test: ["CMD-SHELL", "pg_isready -U krona_staff -d krona_staff"], interval: 5s, timeout: 3s, retries: 20 } - redis: - image: redis:7-alpine - command: ["redis-server", "--appendonly", "yes"] - volumes: ["redis-data:/data"] - healthcheck: { test: ["CMD", "redis-cli", "ping"], interval: 5s, timeout: 3s, retries: 20 } + POSTGRES_USER: krona_migrator + POSTGRES_PASSWORD: ${POSTGRES_MIGRATION_PASSWORD:-local-migration-only} + POSTGRES_APP_PASSWORD: ${POSTGRES_APP_PASSWORD:-local-app-only} + volumes: + - postgres-data:/var/lib/postgresql/data + - ./docker/postgres/init-app-role.sh:/docker-entrypoint-initdb.d/10-app-role.sh:ro + healthcheck: + { + test: ['CMD-SHELL', 'pg_isready -U krona_migrator -d krona_staff'], + interval: 5s, + timeout: 3s, + retries: 20, + } volumes: postgres-data: - redis-data: diff --git a/docker/postgres/init-app-role.sh b/docker/postgres/init-app-role.sh new file mode 100644 index 0000000..c2ed49b --- /dev/null +++ b/docker/postgres/init-app-role.sh @@ -0,0 +1,21 @@ +#!/bin/sh +set -eu + +if [ -z "${POSTGRES_APP_PASSWORD:-}" ]; then + echo "POSTGRES_APP_PASSWORD is required" >&2 + exit 1 +fi + +psql --set=ON_ERROR_STOP=1 \ + --set=app_password="$POSTGRES_APP_PASSWORD" \ + --username "$POSTGRES_USER" \ + --dbname "$POSTGRES_DB" <<'SQL' +SELECT 'CREATE ROLE krona_staff_app LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOBYPASSRLS' +WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'krona_staff_app') +\gexec +SELECT format( + 'ALTER ROLE krona_staff_app WITH LOGIN PASSWORD %L NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOBYPASSRLS', + :'app_password' +) +\gexec +SQL diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e0e69f2..db2f5bf 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,6 +1,6 @@ # Архитектура -Krona Staff — deployable modular service с hexagonal boundaries. HTTP и workers вызывают application layer; domain contexts зависят только от ports; adapters обеспечивают standalone либо platform integration; PostgreSQL, Redis/BullMQ и object storage находятся на внешнем краю. +Krona Staff — deployable modular service с hexagonal boundaries. HTTP и будущие workers вызывают application layer; domain contexts зависят только от ports; adapters обеспечивают standalone либо platform integration; PostgreSQL находится на внешнем краю. Redis/BullMQ и object storage подключаются только после появления подтверждённых сценариев. ```text HTTP / worker -> application -> bounded-context contracts diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index c2bb055..5624dc6 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -1,9 +1,9 @@ # Развёртывание -Одна команда локально: скопировать `.env.example` в `.env`, затем `docker compose --profile tools run --rm migrate` и `docker compose up --build`. Compose поднимает приложение, PostgreSQL и Redis. Object storage не включён, пока нет файлового сценария; production использует Platform Files или S3-compatible adapter. +Одна команда локально: скопировать `.env.example` в `.env`, затем `docker compose --profile tools run --rm migrate` и `docker compose up --build`. Compose поднимает приложение и PostgreSQL. Контейнер PostgreSQL создаёт отдельную роль `krona_staff_app` с `NOSUPERUSER NOBYPASSRLS`; миграции выполняются только ролью `krona_migrator`. Redis и object storage не включаются до появления реального сценария очередей или файлов. -Production: один stateless app container/process, отдельные PostgreSQL и Redis, TLS/reverse proxy, secret manager, external object storage, централизованные logs/metrics/traces. `/live` проверяет процесс, `/ready` — обязательные зависимости, `/version` — release и runtime mode. Container работает non-root, read-only filesystem. +Production: один stateless app container/process, отдельный PostgreSQL, TLS/reverse proxy, secret manager и централизованные logs/metrics/traces. Приложению выдаётся только роль без `SUPERUSER` и `BYPASSRLS`; миграционный URL хранится отдельно и не передаётся runtime-контейнеру. `/api/v1/staff/live` проверяет процесс, `/api/v1/staff/ready` — обязательные зависимости, `/api/v1/staff/version` — release и runtime mode. Container работает non-root, read-only filesystem. HA сейчас не требуется. Для будущего scale-out: общий PostgreSQL/Redis/S3, несколько API/worker nodes, leader election для scheduler, outbox relay и идемпотентные consumers. Локальные вложения запрещены. Linux и Windows deployment сохраняют паритет на уровне Node/container contract. -Миграции запускаются отдельным one-shot job до rollout. Down migration существует, но удаление данных требует отдельного backup и явного решения оператора. +Миграции запускаются отдельным one-shot job до rollout, учитываются в `schema_migrations` и защищены advisory lock. Down откатывает только последнюю миграцию и требует backup плюс `ALLOW_MIGRATION_DOWN=true`. diff --git a/docs/DOMAIN-BOUNDARIES.md b/docs/DOMAIN-BOUNDARIES.md index 780e494..ce44bf1 100644 --- a/docs/DOMAIN-BOUNDARIES.md +++ b/docs/DOMAIN-BOUNDARIES.md @@ -1,16 +1,16 @@ # Доменные границы -| Контекст | Будущее владение Staff | Сейчас | -|---|---|---| -| Employee Directory | Карточка сотрудника и связь с Core User по nullable reference | Только manifest | -| Employment | Трудовые отношения и их жизненный цикл | Ожидает требований | -| Organization Structure | HR-представление подразделений/позиций | Ожидает решения о source of truth | -| Scheduling | Плановые смены/графики | Ожидает требований | -| Time & Attendance | Факты времени/явки и корректировки | Ожидает требований | -| Leave | Заявки и остатки отпусков/отсутствий | Ожидает требований | -| Payroll | Расчётные входы/результаты и защищённый доступ | Ожидает требований и юрисдикции | -| Documents | HR-документы и ссылки на Platform Files | Ожидает требований | -| Audit/Reporting | HR audit projections и разрешённые отчёты | Базовый audit transport | +| Контекст | Будущее владение Staff | Сейчас | +| ---------------------- | ------------------------------------------------------------- | --------------------------------- | +| Employee Directory | Карточка сотрудника и связь с Core User по nullable reference | Только manifest | +| Employment | Трудовые отношения и их жизненный цикл | Ожидает требований | +| Organization Structure | HR-представление подразделений/позиций | Ожидает решения о source of truth | +| Scheduling | Плановые смены/графики | Ожидает требований | +| Time & Attendance | Факты времени/явки и корректировки | Ожидает требований | +| Leave | Заявки и остатки отпусков/отсутствий | Ожидает требований | +| Payroll | Расчётные входы/результаты и защищённый доступ | Ожидает требований и юрисдикции | +| Documents | HR-документы и ссылки на Platform Files | Ожидает требований | +| Audit/Reporting | HR audit projections и разрешённые отчёты | Базовый audit transport | Core User — учётная запись для входа и прав. Employee — предметная карточка человека в трудовом контексте. Они могут быть связаны, но ни один не является другим: сотрудник может не иметь аккаунта, аккаунт может не быть сотрудником. diff --git a/eslint.config.js b/eslint.config.js index 5cfb763..5c6e7d4 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -5,6 +5,10 @@ export default tseslint.config( { ignores: ['dist/**', 'coverage/**', 'eslint.config.js', 'scripts/**'] }, js.configs.recommended, ...tseslint.configs.recommendedTypeChecked, - { languageOptions: { parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname } } }, + { + languageOptions: { + parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname }, + }, + }, { rules: { '@typescript-eslint/require-await': 'off' } }, ); diff --git a/migrations/0001_infrastructure.up.sql b/migrations/0001_infrastructure.up.sql index 008cbf0..5c8bc1a 100644 --- a/migrations/0001_infrastructure.up.sql +++ b/migrations/0001_infrastructure.up.sql @@ -3,23 +3,27 @@ CREATE TABLE IF NOT EXISTS staff_outbox ( tenant_id VARCHAR(26) NOT NULL CHECK (tenant_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'), event_type TEXT NOT NULL, event_version INTEGER NOT NULL CHECK (event_version > 0), + subject_type TEXT NOT NULL, + subject_id VARCHAR(26) NOT NULL CHECK (subject_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'), + actor_type TEXT NOT NULL CHECK (actor_type IN ('user', 'service')), + actor_id VARCHAR(26) NOT NULL CHECK (actor_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'), payload JSONB NOT NULL, - correlation_id VARCHAR(26) NOT NULL, - causation_id VARCHAR(26), + correlation_id VARCHAR(26) NOT NULL CHECK (correlation_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'), + causation_id VARCHAR(26) CHECK (causation_id IS NULL OR causation_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'), occurred_at TIMESTAMPTZ NOT NULL, published_at TIMESTAMPTZ, attempts INTEGER NOT NULL DEFAULT 0, last_error TEXT ); -CREATE INDEX staff_outbox_pending_idx ON staff_outbox (tenant_id, occurred_at) WHERE published_at IS NULL; +CREATE INDEX IF NOT EXISTS staff_outbox_pending_idx ON staff_outbox (tenant_id, occurred_at) WHERE published_at IS NULL; CREATE TABLE IF NOT EXISTS staff_inbox ( - event_id VARCHAR(26) NOT NULL, - tenant_id VARCHAR(26) NOT NULL, + event_id VARCHAR(26) NOT NULL CHECK (event_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'), + tenant_id VARCHAR(26) NOT NULL CHECK (tenant_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'), event_type TEXT NOT NULL, event_version INTEGER NOT NULL, - correlation_id VARCHAR(26) NOT NULL, - causation_id VARCHAR(26), + correlation_id VARCHAR(26) NOT NULL CHECK (correlation_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'), + causation_id VARCHAR(26) CHECK (causation_id IS NULL OR causation_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'), received_at TIMESTAMPTZ NOT NULL DEFAULT now(), processed_at TIMESTAMPTZ, PRIMARY KEY (tenant_id, event_id) @@ -37,28 +41,49 @@ CREATE TABLE IF NOT EXISTS staff_idempotency_keys ( ); CREATE TABLE IF NOT EXISTS staff_audit_log ( - id VARCHAR(26) PRIMARY KEY, - tenant_id VARCHAR(26) NOT NULL, - actor_id VARCHAR(26) NOT NULL, + id VARCHAR(26) PRIMARY KEY CHECK (id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'), + tenant_id VARCHAR(26) NOT NULL CHECK (tenant_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'), + actor_id VARCHAR(26) NOT NULL CHECK (actor_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'), action TEXT NOT NULL, entity_type TEXT NOT NULL, - entity_id VARCHAR(26), + entity_id VARCHAR(26) CHECK (entity_id IS NULL OR entity_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'), old_values JSONB, new_values JSONB, - correlation_id VARCHAR(26) NOT NULL, + correlation_id VARCHAR(26) NOT NULL CHECK (correlation_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'), created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -CREATE INDEX staff_audit_tenant_time_idx ON staff_audit_log (tenant_id, created_at DESC); +CREATE INDEX IF NOT EXISTS staff_audit_tenant_time_idx ON staff_audit_log (tenant_id, created_at DESC); ALTER TABLE staff_outbox ENABLE ROW LEVEL SECURITY; ALTER TABLE staff_outbox FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS staff_outbox_tenant_isolation ON staff_outbox; CREATE POLICY staff_outbox_tenant_isolation ON staff_outbox USING (tenant_id = current_setting('app.tenant_id', true)) WITH CHECK (tenant_id = current_setting('app.tenant_id', true)); ALTER TABLE staff_inbox ENABLE ROW LEVEL SECURITY; ALTER TABLE staff_inbox FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS staff_inbox_tenant_isolation ON staff_inbox; CREATE POLICY staff_inbox_tenant_isolation ON staff_inbox USING (tenant_id = current_setting('app.tenant_id', true)) WITH CHECK (tenant_id = current_setting('app.tenant_id', true)); ALTER TABLE staff_idempotency_keys ENABLE ROW LEVEL SECURITY; ALTER TABLE staff_idempotency_keys FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS staff_idempotency_tenant_isolation ON staff_idempotency_keys; CREATE POLICY staff_idempotency_tenant_isolation ON staff_idempotency_keys USING (tenant_id = current_setting('app.tenant_id', true)) WITH CHECK (tenant_id = current_setting('app.tenant_id', true)); ALTER TABLE staff_audit_log ENABLE ROW LEVEL SECURITY; ALTER TABLE staff_audit_log FORCE ROW LEVEL SECURITY; -CREATE POLICY staff_audit_tenant_isolation ON staff_audit_log USING (tenant_id = current_setting('app.tenant_id', true)) WITH CHECK (tenant_id = current_setting('app.tenant_id', true)); +DROP POLICY IF EXISTS staff_audit_tenant_isolation ON staff_audit_log; +DROP POLICY IF EXISTS staff_audit_tenant_select ON staff_audit_log; +DROP POLICY IF EXISTS staff_audit_tenant_insert ON staff_audit_log; +CREATE POLICY staff_audit_tenant_select ON staff_audit_log FOR SELECT USING (tenant_id = current_setting('app.tenant_id', true)); +CREATE POLICY staff_audit_tenant_insert ON staff_audit_log FOR INSERT WITH CHECK ( + tenant_id = current_setting('app.tenant_id', true) + AND actor_id = current_setting('app.user_id', true) +); + +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'krona_staff_app') THEN + GRANT USAGE ON SCHEMA public TO krona_staff_app; + GRANT SELECT, INSERT, UPDATE, DELETE ON staff_outbox, staff_inbox, staff_idempotency_keys TO krona_staff_app; + GRANT SELECT, INSERT ON staff_audit_log TO krona_staff_app; + REVOKE UPDATE, DELETE, TRUNCATE ON staff_audit_log FROM krona_staff_app; + END IF; +END +$$; diff --git a/migrations/0002_security_hardening.down.sql b/migrations/0002_security_hardening.down.sql new file mode 100644 index 0000000..026595e --- /dev/null +++ b/migrations/0002_security_hardening.down.sql @@ -0,0 +1,30 @@ +DROP POLICY IF EXISTS staff_audit_tenant_select ON staff_audit_log; +DROP POLICY IF EXISTS staff_audit_tenant_insert ON staff_audit_log; +CREATE POLICY staff_audit_tenant_isolation ON staff_audit_log + USING (tenant_id = current_setting('app.tenant_id', true)) + WITH CHECK (tenant_id = current_setting('app.tenant_id', true)); + +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'krona_staff_app') THEN + GRANT UPDATE, DELETE ON staff_audit_log TO krona_staff_app; + END IF; +END +$$; + +ALTER TABLE staff_inbox DROP CONSTRAINT IF EXISTS staff_inbox_event_id_check; +ALTER TABLE staff_inbox DROP CONSTRAINT IF EXISTS staff_inbox_tenant_id_check; +ALTER TABLE staff_inbox DROP CONSTRAINT IF EXISTS staff_inbox_correlation_id_check; +ALTER TABLE staff_inbox DROP CONSTRAINT IF EXISTS staff_inbox_causation_id_check; +ALTER TABLE staff_idempotency_keys DROP CONSTRAINT IF EXISTS staff_idempotency_keys_tenant_id_check; +ALTER TABLE staff_audit_log DROP CONSTRAINT IF EXISTS staff_audit_log_id_check; +ALTER TABLE staff_audit_log DROP CONSTRAINT IF EXISTS staff_audit_log_tenant_id_check; +ALTER TABLE staff_audit_log DROP CONSTRAINT IF EXISTS staff_audit_log_actor_id_check; +ALTER TABLE staff_audit_log DROP CONSTRAINT IF EXISTS staff_audit_log_entity_id_check; +ALTER TABLE staff_audit_log DROP CONSTRAINT IF EXISTS staff_audit_log_correlation_id_check; +ALTER TABLE staff_outbox DROP CONSTRAINT IF EXISTS staff_outbox_correlation_id_check; +ALTER TABLE staff_outbox DROP CONSTRAINT IF EXISTS staff_outbox_causation_id_check; +ALTER TABLE staff_outbox DROP COLUMN IF EXISTS subject_type; +ALTER TABLE staff_outbox DROP COLUMN IF EXISTS subject_id; +ALTER TABLE staff_outbox DROP COLUMN IF EXISTS actor_type; +ALTER TABLE staff_outbox DROP COLUMN IF EXISTS actor_id; diff --git a/migrations/0002_security_hardening.up.sql b/migrations/0002_security_hardening.up.sql new file mode 100644 index 0000000..8b58a68 --- /dev/null +++ b/migrations/0002_security_hardening.up.sql @@ -0,0 +1,68 @@ +ALTER TABLE staff_outbox ADD COLUMN IF NOT EXISTS subject_type TEXT; +ALTER TABLE staff_outbox ADD COLUMN IF NOT EXISTS subject_id VARCHAR(26); +ALTER TABLE staff_outbox ADD COLUMN IF NOT EXISTS actor_type TEXT; +ALTER TABLE staff_outbox ADD COLUMN IF NOT EXISTS actor_id VARCHAR(26); + +UPDATE staff_outbox +SET subject_type = COALESCE(subject_type, payload #>> '{subject,type}', 'staff.Unknown'), + subject_id = COALESCE(subject_id, payload #>> '{subject,id}', id), + actor_type = COALESCE(actor_type, payload #>> '{actor,type}', 'service'), + actor_id = COALESCE(actor_id, payload #>> '{actor,id}', correlation_id) +WHERE subject_type IS NULL OR subject_id IS NULL OR actor_type IS NULL OR actor_id IS NULL; + +ALTER TABLE staff_outbox ALTER COLUMN subject_type SET NOT NULL; +ALTER TABLE staff_outbox ALTER COLUMN subject_id SET NOT NULL; +ALTER TABLE staff_outbox ALTER COLUMN actor_type SET NOT NULL; +ALTER TABLE staff_outbox ALTER COLUMN actor_id SET NOT NULL; + +ALTER TABLE staff_outbox DROP CONSTRAINT IF EXISTS staff_outbox_subject_id_check; +ALTER TABLE staff_outbox ADD CONSTRAINT staff_outbox_subject_id_check CHECK (subject_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'); +ALTER TABLE staff_outbox DROP CONSTRAINT IF EXISTS staff_outbox_actor_type_check; +ALTER TABLE staff_outbox ADD CONSTRAINT staff_outbox_actor_type_check CHECK (actor_type IN ('user', 'service')); +ALTER TABLE staff_outbox DROP CONSTRAINT IF EXISTS staff_outbox_actor_id_check; +ALTER TABLE staff_outbox ADD CONSTRAINT staff_outbox_actor_id_check CHECK (actor_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'); +ALTER TABLE staff_outbox DROP CONSTRAINT IF EXISTS staff_outbox_correlation_id_check; +ALTER TABLE staff_outbox ADD CONSTRAINT staff_outbox_correlation_id_check CHECK (correlation_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'); +ALTER TABLE staff_outbox DROP CONSTRAINT IF EXISTS staff_outbox_causation_id_check; +ALTER TABLE staff_outbox ADD CONSTRAINT staff_outbox_causation_id_check CHECK (causation_id IS NULL OR causation_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'); + +ALTER TABLE staff_inbox DROP CONSTRAINT IF EXISTS staff_inbox_event_id_check; +ALTER TABLE staff_inbox ADD CONSTRAINT staff_inbox_event_id_check CHECK (event_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'); +ALTER TABLE staff_inbox DROP CONSTRAINT IF EXISTS staff_inbox_tenant_id_check; +ALTER TABLE staff_inbox ADD CONSTRAINT staff_inbox_tenant_id_check CHECK (tenant_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'); +ALTER TABLE staff_inbox DROP CONSTRAINT IF EXISTS staff_inbox_correlation_id_check; +ALTER TABLE staff_inbox ADD CONSTRAINT staff_inbox_correlation_id_check CHECK (correlation_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'); +ALTER TABLE staff_inbox DROP CONSTRAINT IF EXISTS staff_inbox_causation_id_check; +ALTER TABLE staff_inbox ADD CONSTRAINT staff_inbox_causation_id_check CHECK (causation_id IS NULL OR causation_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'); +ALTER TABLE staff_idempotency_keys DROP CONSTRAINT IF EXISTS staff_idempotency_keys_tenant_id_check; +ALTER TABLE staff_idempotency_keys ADD CONSTRAINT staff_idempotency_keys_tenant_id_check CHECK (tenant_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'); +ALTER TABLE staff_audit_log DROP CONSTRAINT IF EXISTS staff_audit_log_id_check; +ALTER TABLE staff_audit_log ADD CONSTRAINT staff_audit_log_id_check CHECK (id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'); +ALTER TABLE staff_audit_log DROP CONSTRAINT IF EXISTS staff_audit_log_tenant_id_check; +ALTER TABLE staff_audit_log ADD CONSTRAINT staff_audit_log_tenant_id_check CHECK (tenant_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'); +ALTER TABLE staff_audit_log DROP CONSTRAINT IF EXISTS staff_audit_log_actor_id_check; +ALTER TABLE staff_audit_log ADD CONSTRAINT staff_audit_log_actor_id_check CHECK (actor_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'); +ALTER TABLE staff_audit_log DROP CONSTRAINT IF EXISTS staff_audit_log_entity_id_check; +ALTER TABLE staff_audit_log ADD CONSTRAINT staff_audit_log_entity_id_check CHECK (entity_id IS NULL OR entity_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'); +ALTER TABLE staff_audit_log DROP CONSTRAINT IF EXISTS staff_audit_log_correlation_id_check; +ALTER TABLE staff_audit_log ADD CONSTRAINT staff_audit_log_correlation_id_check CHECK (correlation_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'); + +DROP POLICY IF EXISTS staff_audit_tenant_isolation ON staff_audit_log; +DROP POLICY IF EXISTS staff_audit_tenant_select ON staff_audit_log; +DROP POLICY IF EXISTS staff_audit_tenant_insert ON staff_audit_log; +CREATE POLICY staff_audit_tenant_select ON staff_audit_log FOR SELECT USING (tenant_id = current_setting('app.tenant_id', true)); +CREATE POLICY staff_audit_tenant_insert ON staff_audit_log FOR INSERT WITH CHECK ( + tenant_id = current_setting('app.tenant_id', true) + AND actor_id = current_setting('app.user_id', true) +); + +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'krona_staff_app') THEN + GRANT USAGE ON SCHEMA public TO krona_staff_app; + GRANT SELECT, INSERT, UPDATE, DELETE ON staff_outbox, staff_inbox, staff_idempotency_keys TO krona_staff_app; + GRANT SELECT, INSERT ON staff_audit_log TO krona_staff_app; + REVOKE UPDATE, DELETE, TRUNCATE ON staff_audit_log FROM krona_staff_app; + END IF; +END +$$; diff --git a/package-lock.json b/package-lock.json index 4e259a2..c646beb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,9 +11,7 @@ "@fastify/helmet": "^13.0.2", "@fastify/sensible": "^6.0.3", "@fastify/swagger": "^9.5.1", - "bullmq": "^5.58.5", "fastify": "^5.6.0", - "ioredis": "^5.7.0", "pg": "^8.16.3", "ulid": "^3.0.1", "yaml": "^2.8.1", @@ -30,7 +28,7 @@ "tsx": "^4.20.5", "typescript": "^5.9.2", "typescript-eslint": "^8.43.0", - "vitest": "^3.2.4" + "vitest": "^5.0.1" }, "engines": { "node": ">=22" @@ -891,11 +889,15 @@ "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@ioredis/commands": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.10.0.tgz", - "integrity": "sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==", - "license": "MIT" + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.6.0", @@ -904,6 +906,17 @@ "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, "node_modules/@lukeed/ms": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz", @@ -913,102 +926,15 @@ "node": ">=8" } }, - "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz", - "integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz", - "integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz", - "integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz", - "integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz", - "integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz", - "integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@napi-rs/lzma-linux-x64-gnu": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", - "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", - "cpu": [ - "x64" - ], + "node_modules/@oxc-project/types": { + "version": "0.150.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.150.0.tgz", + "integrity": "sha512-rDS5/31E9HfPl/CIzGrn0DOlvBbXFseQ5URJ9sYMfstbKLD/c6Gm9vmRzRGDdAXyOIL4zmO37lc9RIwYqVruZw==", "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^22.20 || ^24.12 || >=25" + "peer": true, + "funding": { + "url": "https://github.com/sponsors/oxc-project" } }, "node_modules/@pinojs/redact": { @@ -1032,10 +958,10 @@ "npm": ">=10" } }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.1.tgz", - "integrity": "sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==", + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.9.tgz", + "integrity": "sha512-tNISae1QEf/vkb3xkRcjV5SEdzPE97We5IVaa2Z8jSszQPZ8U60B/YCYpw4QI7VidYsBtKavczXf+DyDs9WGxw==", "cpu": [ "arm" ], @@ -1044,12 +970,16 @@ "optional": true, "os": [ "android" - ] + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.1.tgz", - "integrity": "sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ==", + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.9.tgz", + "integrity": "sha512-YC8YsI30o606GTZi0VyzYlsDKFP8W61i/QzayHDkLbNEz/IShqAmTa+hsJRj13xTHA0H+6fk4b2UmGn+Q/cMlg==", "cpu": [ "arm64" ], @@ -1058,12 +988,16 @@ "optional": true, "os": [ "android" - ] + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.1.tgz", - "integrity": "sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q==", + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.9.tgz", + "integrity": "sha512-IwhlH3qK5urrY8hZiEgGkHKEFN901p/p2bjxCxJlr4GyNnF7wYpUvK+Y43uaRYuC4hpfjzbR3SJC3arX1jGvmw==", "cpu": [ "arm64" ], @@ -1072,12 +1006,16 @@ "optional": true, "os": [ "darwin" - ] + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.1.tgz", - "integrity": "sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg==", + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.9.tgz", + "integrity": "sha512-XxpJfVzFh+jilRxIXUqcfYAYcunIc/XEzIizsOL1fcJee5Sf7H3mH8WlLmfHfluz5amqR88QQo9izKtmMlavAw==", "cpu": [ "x64" ], @@ -1086,26 +1024,16 @@ "optional": true, "os": [ "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.1.tgz", - "integrity": "sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw==", - "cpu": [ - "arm64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.1.tgz", - "integrity": "sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q==", + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.9.tgz", + "integrity": "sha512-kSfvhmgeWyfkbT3p/1s5vSgboogoah2zkm9fX2zjg2hHxSV7T4KhMWRUUaRk4OXNqoD3QAUeRqLcs1aZOK4U1g==", "cpu": [ "x64" ], @@ -1114,46 +1042,34 @@ "optional": true, "os": [ "freebsd" - ] + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.1.tgz", - "integrity": "sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw==", + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.9.tgz", + "integrity": "sha512-1RVzG17pxqbTfYLC352JlLt6kKLG+6Hr30n8DlIJqsnV5luUDd2Qdx9Ayw1Cabfyb1K9k0jXEZ7evxkRoT+uiw==", "cpu": [ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.1.tgz", - "integrity": "sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw==", - "cpu": [ - "arm" ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.1.tgz", - "integrity": "sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg==", + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.9.tgz", + "integrity": "sha512-BXqPvZ2drqVD+/Z8UpKwcs4Mp7grM+eGFku4CAEKrEtcbAsUpzREphK1sogCRZGreVPiMkiiBtw0n3TPteuqvw==", "cpu": [ "arm64" ], @@ -1165,12 +1081,16 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.1.tgz", - "integrity": "sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw==", + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.9.tgz", + "integrity": "sha512-11vWvo8YDwLzukt27J3aYDWU+gg2P7J+ZOmiJ0hkF5BXZDW7pVya7r40MXDy6ya0i9KamoENSVKIugvJNgFXIA==", "cpu": [ "arm64" ], @@ -1182,46 +1102,16 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.1.tgz", - "integrity": "sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.1.tgz", - "integrity": "sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA==", - "cpu": [ - "loong64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.1.tgz", - "integrity": "sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA==", + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.9.tgz", + "integrity": "sha512-a1tijMkdwsIARtc0F39ApURROkf3NwqinI6TOiSSWCTR7dT96dffNvMUtDHnq64wKNTIZOIlzKrFvvFUznJiyw==", "cpu": [ "ppc64" ], @@ -1233,63 +1123,16 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.1.tgz", - "integrity": "sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.1.tgz", - "integrity": "sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w==", - "cpu": [ - "riscv64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.1.tgz", - "integrity": "sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.1.tgz", - "integrity": "sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A==", + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.9.tgz", + "integrity": "sha512-x6SQNdAvv4c3hWqTMaWuawzMX9myaCs/yEmlGsxJzkdClnHW7FbrjQuSiRDhuSYzEYoEMhsaJy9qHG/XNemJPQ==", "cpu": [ "s390x" ], @@ -1301,12 +1144,16 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.1.tgz", - "integrity": "sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w==", + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.9.tgz", + "integrity": "sha512-9s0AZ8BFK5/n7B/TBoa2yJE3gI3KURrbXcPBlsAsvjU4VeJKgE90y1YtNxyEUIcHPQkg6/yfF3qihUrcM/Kf0Q==", "cpu": [ "x64" ], @@ -1318,12 +1165,16 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.1.tgz", - "integrity": "sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow==", + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.9.tgz", + "integrity": "sha512-P7VWAmV+WdJluH7ovnRGoiv2i8To7GAZ+kGzfGup635cyL7SyYl3lSUaA3Gp5THf0n/Co5EyEqb2zbqq+nMOHQ==", "cpu": [ "x64" ], @@ -1335,26 +1186,16 @@ "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.1.tgz", - "integrity": "sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA==", - "cpu": [ - "x64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.1.tgz", - "integrity": "sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw==", + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.9.tgz", + "integrity": "sha512-1qixtsE4BK8h+yS3BfmZ09UhA7O/N4IACva6YBr7EBvCJraByTuRcgOTaiA62Tm0vey3UcKXLOaoGHtYmNGEVg==", "cpu": [ "arm64" ], @@ -1363,12 +1204,16 @@ "optional": true, "os": [ "openharmony" - ] + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.1.tgz", - "integrity": "sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg==", + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.9.tgz", + "integrity": "sha512-ok8IQjcEPs1AKZfuEUznVBrJw+gK4soq+bx8b1X2XoMqVClarc1q5JDmVtWXY1xfr6ZuHTAsPXHTgTrqKTZeww==", "cpu": [ "arm64" ], @@ -1377,26 +1222,16 @@ "optional": true, "os": [ "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.1.tgz", - "integrity": "sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg==", - "cpu": [ - "ia32" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.1.tgz", - "integrity": "sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg==", + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.9.tgz", + "integrity": "sha512-Ip2mXoU0hM0boq3Rf+ekuT653OROSo6aSYcPT1VHE4q52KvyxgFkQgrgb/IEsxOuvQ2fZZbs8khJAyCEPM24/g==", "cpu": [ "x64" ], @@ -1405,21 +1240,19 @@ "optional": true, "os": [ "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.1.tgz", - "integrity": "sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w==", - "cpu": [ - "x64" ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "peer": true }, "node_modules/@types/chai": { "version": "5.2.3", @@ -1757,40 +1590,24 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/@vitest/expect": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", - "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/chai": "^5.2.2", - "@vitest/spy": "3.2.7", - "@vitest/utils": "3.2.7", - "chai": "^5.2.0", - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "node_modules/@vitest/mocker": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", - "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.1.tgz", + "integrity": "sha512-6K1DoBNAPGvuOcSsGA4D6x+5zEEff/KmOOP3uetT2TrGpVfI+HRHRnJJfKi5ib/g1vx8IYHQD8s0pbJz8WQI7Q==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "3.2.7", + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.1", "estree-walker": "^3.0.3", - "magic-string": "^0.30.17" + "magic-string": "^1.2.3" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { "msw": "^2.4.9", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "msw": { @@ -1801,73 +1618,12 @@ } } }, - "node_modules/@vitest/pretty-format": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", - "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", - "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "3.2.7", - "pathe": "^2.0.3", - "strip-literal": "^3.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", - "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "3.2.7", - "magic-string": "^0.30.17", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "node_modules/@vitest/spy": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", - "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.1.tgz", + "integrity": "sha512-rbto/mF/SGERxEgYOek7Xm6B9b+y+mVoo+f4b2LymYO8zM1b7uB5nHuhVMTP2hxdzgxvGiZYGxGIaMvL5y180Q==", "dev": true, "license": "MIT", - "dependencies": { - "tinyspy": "^4.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/utils": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", - "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "3.2.7", - "loupe": "^3.1.4", - "tinyrainbow": "^2.0.0" - }, "funding": { "url": "https://opencollective.com/vitest" } @@ -2030,41 +1786,6 @@ "concat-map": "0.0.1" } }, - "node_modules/bullmq": { - "version": "5.81.4", - "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.81.4.tgz", - "integrity": "sha512-n+WHSzz20KooBGoJyASre6oJNz/p5f1IJRRN2ibD+NWPQaNpNQmDrwYuPO+bsXrQeM1MQzUxXGbdjmyOFKP2xQ==", - "license": "MIT", - "dependencies": { - "cron-parser": "4.9.0", - "ioredis": "5.11.1", - "msgpackr": "2.0.5", - "node-abort-controller": "3.1.1", - "semver": "7.8.5", - "tslib": "2.8.1" - }, - "engines": { - "node": ">=12.22.0" - }, - "peerDependencies": { - "redis": ">=5.0.0" - }, - "peerDependenciesMeta": { - "redis": { - "optional": true - } - } - }, - "node_modules/cac": { - "version": "6.7.14", - "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", - "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -2076,18 +1797,11 @@ } }, "node_modules/chai": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", - "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", "dev": true, "license": "MIT", - "dependencies": { - "assertion-error": "^2.0.1", - "check-error": "^2.1.1", - "deep-eql": "^5.0.1", - "loupe": "^3.1.0", - "pathval": "^2.0.0" - }, "engines": { "node": ">=18" } @@ -2109,25 +1823,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/check-error": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", - "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 16" - } - }, - "node_modules/cluster-key-slot": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.1.tgz", - "integrity": "sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==", - "license": "Apache-2.0", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2181,19 +1876,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/cron-parser": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz", - "integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==", - "deprecated": "v4 is no longer maintained, upgrade to v5", - "license": "MIT", - "dependencies": { - "luxon": "^3.2.1" - }, - "engines": { - "node": ">=12.0.0" - } - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2226,16 +1908,6 @@ } } }, - "node_modules/deep-eql": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", - "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2243,15 +1915,6 @@ "dev": true, "license": "MIT" }, - "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": ">=0.10" - } - }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -2274,16 +1937,17 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, "license": "Apache-2.0", - "optional": true, + "peer": true, "engines": { "node": ">=8" } }, "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.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", "dev": true, "license": "MIT" }, @@ -2892,28 +2556,6 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, - "node_modules/ioredis": { - "version": "5.11.1", - "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.11.1.tgz", - "integrity": "sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==", - "license": "MIT", - "dependencies": { - "@ioredis/commands": "1.10.0", - "cluster-key-slot": "1.1.1", - "debug": "4.4.3", - "denque": "2.1.0", - "redis-errors": "1.2.0", - "redis-parser": "3.0.0", - "standard-as-callback": "2.1.0" - }, - "engines": { - "node": ">=12.22.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/ioredis" - } - }, "node_modules/ipaddr.js": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.5.0.tgz", @@ -2953,13 +2595,6 @@ "dev": true, "license": "ISC" }, - "node_modules/js-tokens": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", - "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", - "dev": true, - "license": "MIT" - }, "node_modules/js-yaml": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", @@ -3116,6 +2751,291 @@ ], "license": "MIT" }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "peer": true, + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -3139,30 +3059,14 @@ "dev": true, "license": "MIT" }, - "node_modules/loupe": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", - "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/luxon": { - "version": "3.7.2", - "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", - "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", - "license": "MIT", - "engines": { - "node": ">=12" - } - }, "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==", + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.1.tgz", + "integrity": "sha512-8lyCu36ErXR0J9uaGKlKQoiLZKmtI63YGLE8G2o9jyRPdr4X47LusSOwgOJOzcVtp81fTAAjxR7BwKz682Jhow==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" + "@jridgewell/sourcemap-codec": "^1.6.0" } }, "node_modules/media-typer": { @@ -3222,41 +3126,10 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, - "node_modules/msgpackr": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-2.0.5.tgz", - "integrity": "sha512-cef05H/dSYpLpqp3sj/qyZh5vhUYCalnaLO7j1yOmpsR0y/XwLVtK7r5gn+U/F7CTEfMowcGhlUQJDLcLf7jcA==", - "license": "MIT", - "optionalDependencies": { - "msgpackr-extract": "^3.0.4" - } - }, - "node_modules/msgpackr-extract": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz", - "integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "dependencies": { - "node-gyp-build-optional-packages": "5.2.2" - }, - "bin": { - "download-msgpackr-prebuilds": "bin/download-prebuilds.js" - }, - "optionalDependencies": { - "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", - "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", - "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", - "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", - "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", - "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" - } - }, "node_modules/nanoid": { - "version": "3.3.18", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", - "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", "dev": true, "funding": [ { @@ -3265,6 +3138,7 @@ } ], "license": "MIT", + "peer": true, "bin": { "nanoid": "bin/nanoid.cjs" }, @@ -3279,25 +3153,18 @@ "dev": true, "license": "MIT" }, - "node_modules/node-abort-controller": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", - "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==", - "license": "MIT" - }, - "node_modules/node-gyp-build-optional-packages": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", - "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], "license": "MIT", - "optional": true, - "dependencies": { - "detect-libc": "^2.0.1" - }, - "bin": { - "node-gyp-build-optional-packages": "bin.js", - "node-gyp-build-optional-packages-optional": "optional.js", - "node-gyp-build-optional-packages-test": "build-test.js" + "engines": { + "node": ">=12.20.0" } }, "node_modules/on-exit-leak-free": { @@ -3398,23 +3265,6 @@ "node": ">=8" } }, - "node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, - "license": "MIT" - }, - "node_modules/pathval": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", - "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.16" - } - }, "node_modules/pg": { "version": "8.23.0", "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz", @@ -3509,7 +3359,8 @@ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "dev": true, - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/picomatch": { "version": "4.0.7", @@ -3581,6 +3432,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.18", "picocolors": "^1.1.1", @@ -3696,27 +3548,6 @@ "node": ">= 12.13.0" } }, - "node_modules/redis-errors": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", - "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/redis-parser": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", - "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", - "license": "MIT", - "dependencies": { - "redis-errors": "^1.0.0" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -3761,50 +3592,39 @@ "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", "license": "MIT" }, - "node_modules/rollup": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.1.tgz", - "integrity": "sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==", + "node_modules/rolldown": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.9.tgz", + "integrity": "sha512-hx/Pv0N1haXRb11qkfnK5MXB/iqr7i0yjWQqmO9uHqZpBgQSqzc8UsSnEpalsh+j1I8qQ2CkXAkJC8Br3dKSlg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "@types/estree": "1.0.9" + "@oxc-project/types": "=0.150.0", + "@rolldown/pluginutils": "^1.0.0" }, "bin": { - "rollup": "dist/bin/rollup" + "rolldown": "bin/cli.mjs" }, "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" + "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@napi-rs/lzma-linux-x64-gnu": "1.5.1", - "@rollup/rollup-android-arm-eabi": "4.63.1", - "@rollup/rollup-android-arm64": "4.63.1", - "@rollup/rollup-darwin-arm64": "4.63.1", - "@rollup/rollup-darwin-x64": "4.63.1", - "@rollup/rollup-freebsd-arm64": "4.63.1", - "@rollup/rollup-freebsd-x64": "4.63.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.63.1", - "@rollup/rollup-linux-arm-musleabihf": "4.63.1", - "@rollup/rollup-linux-arm64-gnu": "4.63.1", - "@rollup/rollup-linux-arm64-musl": "4.63.1", - "@rollup/rollup-linux-loong64-gnu": "4.63.1", - "@rollup/rollup-linux-loong64-musl": "4.63.1", - "@rollup/rollup-linux-ppc64-gnu": "4.63.1", - "@rollup/rollup-linux-ppc64-musl": "4.63.1", - "@rollup/rollup-linux-riscv64-gnu": "4.63.1", - "@rollup/rollup-linux-riscv64-musl": "4.63.1", - "@rollup/rollup-linux-s390x-gnu": "4.63.1", - "@rollup/rollup-linux-x64-gnu": "4.63.1", - "@rollup/rollup-linux-x64-musl": "4.63.1", - "@rollup/rollup-openbsd-x64": "4.63.1", - "@rollup/rollup-openharmony-arm64": "4.63.1", - "@rollup/rollup-win32-arm64-msvc": "4.63.1", - "@rollup/rollup-win32-ia32-msvc": "4.63.1", - "@rollup/rollup-win32-x64-gnu": "4.63.1", - "@rollup/rollup-win32-x64-msvc": "4.63.1", - "fsevents": "~2.3.2" + "@rolldown/binding-android-arm-eabi": "1.2.9", + "@rolldown/binding-android-arm64": "1.2.9", + "@rolldown/binding-darwin-arm64": "1.2.9", + "@rolldown/binding-darwin-x64": "1.2.9", + "@rolldown/binding-freebsd-x64": "1.2.9", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.9", + "@rolldown/binding-linux-arm64-gnu": "1.2.9", + "@rolldown/binding-linux-arm64-musl": "1.2.9", + "@rolldown/binding-linux-ppc64-gnu": "1.2.9", + "@rolldown/binding-linux-s390x-gnu": "1.2.9", + "@rolldown/binding-linux-x64-gnu": "1.2.9", + "@rolldown/binding-linux-x64-musl": "1.2.9", + "@rolldown/binding-openharmony-arm64": "1.2.9", + "@rolldown/binding-win32-arm64-msvc": "1.2.9", + "@rolldown/binding-win32-x64-msvc": "1.2.9" } }, "node_modules/safe-regex2": { @@ -3923,6 +3743,7 @@ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "dev": true, "license": "BSD-3-Clause", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -3943,12 +3764,6 @@ "dev": true, "license": "MIT" }, - "node_modules/standard-as-callback": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", - "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", - "license": "MIT" - }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -3959,9 +3774,9 @@ } }, "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", "dev": true, "license": "MIT" }, @@ -3978,19 +3793,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/strip-literal": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", - "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "js-tokens": "^9.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -4023,18 +3825,24 @@ "license": "MIT" }, "node_modules/tinybench": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz", + "integrity": "sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } }, "node_modules/tinyexec": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", - "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/tinyglobby": { "version": "0.2.17", @@ -4053,36 +3861,6 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tinypool": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", - "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.0.0 || >=20.0.0" - } - }, - "node_modules/tinyrainbow": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", - "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tinyspy": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.6.tgz", - "integrity": "sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/toad-cache": { "version": "3.7.4", "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.4.tgz", @@ -4114,12 +3892,6 @@ "typescript": ">=4.8.4" } }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" - }, "node_modules/tsx": { "version": "4.23.13", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.13.tgz", @@ -4244,18 +4016,18 @@ } }, "node_modules/vite": { - "version": "7.3.6", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", - "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz", + "integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "esbuild": "^0.27.0 || ^0.28.0", - "fdir": "^6.5.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rollup": "^4.43.0", - "tinyglobby": "^0.2.15" + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.6", + "tinyglobby": "^0.2.17" }, "bin": { "vite": "bin/vite.js" @@ -4271,9 +4043,10 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", - "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", @@ -4286,15 +4059,18 @@ "@types/node": { "optional": true }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, "jiti": { "optional": true }, "less": { "optional": true }, - "lightningcss": { - "optional": true - }, "sass": { "optional": true }, @@ -4318,89 +4094,73 @@ } } }, - "node_modules/vite-node": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", - "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cac": "^6.7.14", - "debug": "^4.4.1", - "es-module-lexer": "^1.7.0", - "pathe": "^2.0.3", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" - }, - "bin": { - "vite-node": "vite-node.mjs" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "node_modules/vitest": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", - "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.1.tgz", + "integrity": "sha512-iA95lQbKEkvrtTkdAgnWbXfbipWiiWe/hDl2P5tMi6WFwD76G0NxXAGp/M9EOcYupeGJRr6wppMc7CoA41TQjg==", "dev": true, "license": "MIT", "dependencies": { "@types/chai": "^5.2.2", - "@vitest/expect": "3.2.7", - "@vitest/mocker": "3.2.7", - "@vitest/pretty-format": "^3.2.7", - "@vitest/runner": "3.2.7", - "@vitest/snapshot": "3.2.7", - "@vitest/spy": "3.2.7", - "@vitest/utils": "3.2.7", - "chai": "^5.2.0", - "debug": "^4.4.1", - "expect-type": "^1.2.1", - "magic-string": "^0.30.17", - "pathe": "^2.0.3", - "picomatch": "^4.0.2", - "std-env": "^3.9.0", - "tinybench": "^2.9.0", - "tinyexec": "^0.3.2", - "tinyglobby": "^0.2.14", - "tinypool": "^1.1.1", - "tinyrainbow": "^2.0.0", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", - "vite-node": "3.2.4", + "@vitest/mocker": "5.0.1", + "chai": "^6.2.2", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.2.3", + "obug": "^2.1.4", + "picomatch": "^4.0.7", + "std-env": "^4.2.0", + "tinybench": "6.1.4", + "tinyexec": "1.3.0", + "tinyglobby": "^0.2.17", "why-is-node-running": "^2.3.0" }, "bin": { "vitest": "vitest.mjs" }, "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { "@edge-runtime/vm": "*", - "@types/debug": "^4.1.12", - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.2.7", - "@vitest/ui": "3.2.7", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.1", + "@vitest/browser-preview": "5.0.1", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.1", + "@vitest/coverage-v8": "5.0.1", + "@vitest/ui": "5.0.1", "happy-dom": "*", - "jsdom": "*" + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "@edge-runtime/vm": { "optional": true }, - "@types/debug": { + "@opentelemetry/api": { "optional": true }, "@types/node": { "optional": true }, - "@vitest/browser": { + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { "optional": true }, "@vitest/ui": { @@ -4411,6 +4171,9 @@ }, "jsdom": { "optional": true + }, + "vite": { + "optional": false } } }, diff --git a/package.json b/package.json index 5cab319..a5393f6 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,9 @@ "version": "0.1.0", "private": true, "type": "module", - "engines": { "node": ">=22" }, + "engines": { + "node": ">=22" + }, "scripts": { "dev": "tsx watch src/server.ts", "build": "tsc -p tsconfig.build.json", @@ -25,9 +27,7 @@ "@fastify/helmet": "^13.0.2", "@fastify/sensible": "^6.0.3", "@fastify/swagger": "^9.5.1", - "bullmq": "^5.58.5", "fastify": "^5.6.0", - "ioredis": "^5.7.0", "pg": "^8.16.3", "ulid": "^3.0.1", "yaml": "^2.8.1", @@ -44,6 +44,6 @@ "tsx": "^4.20.5", "typescript": "^5.9.2", "typescript-eslint": "^8.43.0", - "vitest": "^3.2.4" + "vitest": "^5.0.1" } } diff --git a/schemas/module.schema.json b/schemas/module.schema.json index 266f801..54ee549 100644 --- a/schemas/module.schema.json +++ b/schemas/module.schema.json @@ -3,24 +3,62 @@ "$id": "https://krona.example/schemas/module.schema.json", "type": "object", "additionalProperties": false, - "required": ["id", "name", "version", "min_platform_version", "dependencies", "permissions", "entities", "events_emitted", "events_consumed", "settings_schema", "configurable_entities", "ui_extensions"], + "required": [ + "id", + "name", + "version", + "min_platform_version", + "dependencies", + "permissions", + "entities", + "events_emitted", + "events_consumed", + "settings_schema", + "configurable_entities", + "ui_extensions" + ], "properties": { "$schema": { "type": "string" }, "id": { "const": "ru.krona.staff" }, "name": { "type": "string", "minLength": 1 }, "version": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" }, "min_platform_version": { "type": "string" }, - "dependencies": { "type": "array", "contains": { "const": "ru.krona.core" }, "items": { "type": "string" }, "uniqueItems": true }, - "optional_dependencies": { "type": "array", "items": { "type": "string" }, "uniqueItems": true }, - "permissions": { "type": "array", "items": { "type": "string", "pattern": "^staff:[a-z_]+:(read|write)$" }, "uniqueItems": true }, + "dependencies": { + "type": "array", + "contains": { "const": "ru.krona.core" }, + "items": { "type": "string" }, + "uniqueItems": true + }, + "optional_dependencies": { + "type": "array", + "items": { "type": "string" }, + "uniqueItems": true + }, + "permissions": { + "type": "array", + "items": { "type": "string", "pattern": "^staff:[a-z_]+:(read|write)$" }, + "uniqueItems": true + }, "entities": { "type": "array", "items": { "type": "string" }, "uniqueItems": true }, "events_emitted": { "type": "array", "items": { "type": "string" }, "uniqueItems": true }, "events_consumed": { "type": "array", "items": { "type": "string" }, "uniqueItems": true }, "settings_schema": { "type": "string" }, - "configurable_entities": { "type": "array", "items": { "type": "string" }, "uniqueItems": true }, + "configurable_entities": { + "type": "array", + "items": { "type": "string" }, + "uniqueItems": true + }, "promote_targets": { "type": "array", "items": { "type": "object" } }, "api_prefix": { "const": "/api/v1/staff" }, "routes": { "type": "array", "items": { "type": "string" } }, - "ui_extensions": { "type": "array", "items": { "type": "object", "required": ["slot", "component"], "properties": { "slot": { "type": "string" }, "component": { "type": "string" } }, "additionalProperties": false } } + "ui_extensions": { + "type": "array", + "items": { + "type": "object", + "required": ["slot", "component"], + "properties": { "slot": { "type": "string" }, "component": { "type": "string" } }, + "additionalProperties": false + } + } } } diff --git a/schemas/settings.schema.schema.json b/schemas/settings.schema.schema.json index af0fd6a..1d496db 100644 --- a/schemas/settings.schema.schema.json +++ b/schemas/settings.schema.schema.json @@ -22,7 +22,23 @@ "required": ["key", "type", "label"], "properties": { "key": { "type": "string", "pattern": "^[a-z][a-z0-9_]*$" }, - "type": { "enum": ["text", "number", "boolean", "date", "datetime", "money", "select", "multiselect", "relation", "email", "phone", "url", "json"] }, + "type": { + "enum": [ + "text", + "number", + "boolean", + "date", + "datetime", + "money", + "select", + "multiselect", + "relation", + "email", + "phone", + "url", + "json" + ] + }, "label": { "type": "string" }, "required": { "type": "boolean" }, "description": { "type": "string" } diff --git a/scripts/check-contracts.mjs b/scripts/check-contracts.mjs index b76a238..9887b0b 100644 --- a/scripts/check-contracts.mjs +++ b/scripts/check-contracts.mjs @@ -3,16 +3,37 @@ import { spawnSync } from 'node:child_process'; import Ajv from 'ajv/dist/2020.js'; const manifest = JSON.parse(await readFile('module.json', 'utf8')); +const packageManifest = JSON.parse(await readFile('package.json', 'utf8')); +const openapiSource = await readFile('api/openapi.yaml', 'utf8'); +const runtimeVersionSource = await readFile('src/version.ts', 'utf8'); +const openapi = (await import('yaml')).default.parse(openapiSource); const settings = JSON.parse(await readFile('settings.schema.json', 'utf8')); const moduleSchema = JSON.parse(await readFile('schemas/module.schema.json', 'utf8')); const settingsSchema = JSON.parse(await readFile('schemas/settings.schema.schema.json', 'utf8')); const ajv = new Ajv({ allErrors: true }); -for (const [name, value, schema] of [['module.json', manifest, moduleSchema], ['settings.schema.json', settings, settingsSchema]]) { +for (const [name, value, schema] of [ + ['module.json', manifest, moduleSchema], + ['settings.schema.json', settings, settingsSchema], +]) { const validate = ajv.compile(schema); if (!validate(value)) throw new Error(`${name}: ${ajv.errorsText(validate.errors)}`); } -if (manifest.configurable_entities.some((entity) => !manifest.entities.includes(entity))) throw new Error('Configurable entity must be declared'); -const lint = spawnSync(process.execPath, ['node_modules/@redocly/cli/bin/cli.js', 'lint', 'api/openapi.yaml'], { stdio: 'inherit' }); +if (manifest.configurable_entities.some((entity) => !manifest.entities.includes(entity))) + throw new Error('Configurable entity must be declared'); +const runtimeVersion = runtimeVersionSource.match(/SERVICE_VERSION\s*=\s*'([^']+)'/)?.[1]; +const versions = new Set([ + packageManifest.version, + manifest.version, + openapi.info.version, + runtimeVersion, +]); +if (versions.size !== 1 || versions.has(undefined)) + throw new Error('Version mismatch between package, module, OpenAPI and runtime'); +const lint = spawnSync( + process.execPath, + ['node_modules/@redocly/cli/bin/cli.js', 'lint', 'api/openapi.yaml'], + { stdio: 'inherit' }, +); if (lint.error) throw lint.error; if (lint.status !== 0) process.exit(lint.status ?? 1); console.log('module manifest, settings schema and OpenAPI contract validated'); diff --git a/scripts/check-migrations.mjs b/scripts/check-migrations.mjs index e822748..8d556a4 100644 --- a/scripts/check-migrations.mjs +++ b/scripts/check-migrations.mjs @@ -2,11 +2,15 @@ import { readFile, readdir } from 'node:fs/promises'; const files = await readdir('migrations'); const ups = files.filter((f) => f.endsWith('.up.sql')).map((f) => f.replace('.up.sql', '')); -const downs = new Set(files.filter((f) => f.endsWith('.down.sql')).map((f) => f.replace('.down.sql', ''))); -for (const migration of ups) if (!downs.has(migration)) throw new Error(`Missing down migration for ${migration}`); +const downs = new Set( + files.filter((f) => f.endsWith('.down.sql')).map((f) => f.replace('.down.sql', '')), +); +for (const migration of ups) + if (!downs.has(migration)) throw new Error(`Missing down migration for ${migration}`); for (const file of files.filter((f) => f.endsWith('.sql'))) { const sql = await readFile(`migrations/${file}`, 'utf8'); if (/\bUUID\b/i.test(sql)) throw new Error(`${file}: UUID is forbidden; use ULID VARCHAR(26)`); - if (/\b(DECIMAL|NUMERIC|REAL|DOUBLE PRECISION)\b/i.test(sql)) throw new Error(`${file}: unsafe numeric money type detected`); + if (/\b(DECIMAL|NUMERIC|REAL|DOUBLE PRECISION)\b/i.test(sql)) + throw new Error(`${file}: unsafe numeric money type detected`); } console.log(`validated ${ups.length} reversible migration(s)`); diff --git a/scripts/create-app-role.mjs b/scripts/create-app-role.mjs new file mode 100644 index 0000000..99c09f0 --- /dev/null +++ b/scripts/create-app-role.mjs @@ -0,0 +1,25 @@ +import pg from 'pg'; + +const connectionString = process.env.DATABASE_MIGRATION_URL; +const password = process.env.POSTGRES_APP_PASSWORD; +if (!connectionString || !password) { + throw new Error('DATABASE_MIGRATION_URL and POSTGRES_APP_PASSWORD are required'); +} + +const client = new pg.Client({ connectionString, application_name: 'krona-staff-role-setup' }); +await client.connect(); +try { + const exists = await client.query("SELECT 1 FROM pg_roles WHERE rolname = 'krona_staff_app'"); + if (exists.rowCount === 0) { + await client.query( + 'CREATE ROLE krona_staff_app LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOBYPASSRLS', + ); + } + const statement = await client.query( + "SELECT format('ALTER ROLE krona_staff_app WITH LOGIN PASSWORD %L NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOBYPASSRLS', $1) AS sql", + [password], + ); + await client.query(statement.rows[0].sql); +} finally { + await client.end(); +} diff --git a/scripts/migrate.mjs b/scripts/migrate.mjs index 2eaf8c2..2edf1c3 100644 --- a/scripts/migrate.mjs +++ b/scripts/migrate.mjs @@ -2,18 +2,88 @@ import { readFile, readdir } from 'node:fs/promises'; import path from 'node:path'; import pg from 'pg'; -const direction = process.argv[2] === 'down' ? 'down' : 'up'; -const connectionString = process.env.DATABASE_URL; -if (!connectionString) throw new Error('DATABASE_URL is required'); -const files = (await readdir('migrations')).filter((name) => name.endsWith(`.${direction}.sql`)).sort(); -if (direction === 'down') files.reverse(); -const client = new pg.Client({ connectionString }); +const direction = process.argv[2]; +if (direction !== 'up' && direction !== 'down') { + throw new Error('Usage: node scripts/migrate.mjs '); +} + +const connectionString = process.env.DATABASE_MIGRATION_URL ?? process.env.DATABASE_URL; +if (!connectionString) throw new Error('DATABASE_MIGRATION_URL or DATABASE_URL is required'); +if (direction === 'down' && process.env.ALLOW_MIGRATION_DOWN !== 'true') { + throw new Error( + 'Refusing destructive rollback. Set ALLOW_MIGRATION_DOWN=true to roll back the latest migration.', + ); +} + +const migrationsDirectory = path.resolve('migrations'); +const files = (await readdir(migrationsDirectory)) + .filter((name) => name.endsWith('.up.sql')) + .sort(); +const client = new pg.Client({ connectionString, application_name: 'krona-staff-migrator' }); await client.connect(); + +const advisoryLockId = 7_347_821_009; try { - for (const file of files) { - const sql = await readFile(path.join('migrations', file), 'utf8'); - await client.query('BEGIN'); - try { await client.query(sql); await client.query('COMMIT'); console.log(`applied ${file}`); } - catch (error) { await client.query('ROLLBACK'); throw error; } + await client.query('SELECT pg_advisory_lock($1)', [advisoryLockId]); + await client.query(` + CREATE TABLE IF NOT EXISTS schema_migrations ( + version TEXT PRIMARY KEY, + applied_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + `); + + if (direction === 'up') { + const appliedResult = await client.query('SELECT version FROM schema_migrations'); + const applied = new Set(appliedResult.rows.map((row) => String(row.version))); + for (const file of files) { + const version = file.replace(/\.up\.sql$/, ''); + if (applied.has(version)) continue; + const sql = await readFile(path.join(migrationsDirectory, file), 'utf8'); + await applyTransaction(async () => { + await client.query(sql); + await client.query('INSERT INTO schema_migrations (version) VALUES ($1)', [version]); + }); + console.log(`applied ${file}`); + } + } else { + const requestedVersion = process.argv[3]; + const latestResult = await client.query( + 'SELECT version FROM schema_migrations ORDER BY version DESC LIMIT 1', + ); + const latestVersion = latestResult.rows[0]?.version; + if (!latestVersion) { + console.log('no migration to roll back'); + } else { + if (requestedVersion && requestedVersion !== latestVersion) { + throw new Error( + `Only the latest migration can be rolled back. Latest is ${latestVersion}.`, + ); + } + const downFile = `${latestVersion}.down.sql`; + const sql = await readFile(path.join(migrationsDirectory, downFile), 'utf8'); + await applyTransaction(async () => { + await client.query(sql); + await client.query('DELETE FROM schema_migrations WHERE version = $1', [latestVersion]); + }); + console.log(`rolled back ${downFile}`); + } } -} finally { await client.end(); } +} finally { + await client.query('SELECT pg_advisory_unlock($1)', [advisoryLockId]).catch(() => undefined); + await client.end(); +} + +async function applyTransaction(run) { + await client.query('BEGIN'); + try { + await run(); + await client.query('COMMIT'); + } catch (error) { + try { + await client.query('ROLLBACK'); + } catch (rollbackError) { + throw new AggregateError([error, rollbackError], 'Migration failed and rollback also failed'); + } + throw error; + } +} diff --git a/src/adapters/identity.ts b/src/adapters/identity.ts index 76240cb..8b5bb92 100644 --- a/src/adapters/identity.ts +++ b/src/adapters/identity.ts @@ -16,6 +16,8 @@ export class StandaloneIdentityAdapter implements IdentityPort { export class PlatformIdentityAdapter implements IdentityPort { async resolve(): Promise { - throw new Error('Platform JWT adapter is intentionally pending the Core gateway/JWT integration contract'); + throw new Error( + 'Platform JWT adapter is intentionally pending the Core gateway/JWT integration contract', + ); } } diff --git a/src/app.ts b/src/app.ts index 9e282a8..ad31435 100644 --- a/src/app.ts +++ b/src/app.ts @@ -5,20 +5,121 @@ import swagger from '@fastify/swagger'; import type { AppConfig } from './config.js'; import { Database } from './infrastructure/database.js'; import { newUlid } from './core/ids.js'; +import { SERVICE_NAME, SERVICE_VERSION } from './version.js'; -export interface BuildAppOptions { config: AppConfig; database?: Database } +export interface BuildAppOptions { + config: AppConfig; + database?: Database; +} -export async function buildApp({ config, database = new Database(config.DATABASE_URL) }: BuildAppOptions) { - const app = Fastify({ logger: { level: config.LOG_LEVEL }, genReqId: (request) => request.headers['x-correlation-id']?.toString() ?? newUlid() }); +const apiPrefix = '/api/v1/staff'; +const ulidPattern = /^[0-9A-HJKMNP-TV-Z]{26}$/; + +export async function buildApp({ + config, + database = new Database(config.DATABASE_URL), +}: BuildAppOptions) { + const app = Fastify({ + logger: { level: config.LOG_LEVEL }, + genReqId: (request) => { + const supplied = request.headers['x-correlation-id']?.toString(); + return supplied && ulidPattern.test(supplied) ? supplied : newUlid(); + }, + }); await app.register(helmet); await app.register(sensible); - await app.register(swagger, { openapi: { info: { title: 'Krona Staff API', version: '0.1.0' }, servers: [{ url: '/api/v1/staff' }] } }); + app.addHook('onRequest', async (request, reply) => { + reply.header('x-correlation-id', request.id); + }); + await app.register(swagger, { + openapi: { + info: { title: 'Krona Staff API', version: SERVICE_VERSION }, + servers: [{ url: apiPrefix }], + }, + }); - app.get('/live', { schema: { tags: ['Operations'], response: { 200: { type: 'object', required: ['status'], properties: { status: { type: 'string', const: 'ok' } } } } } }, async () => ({ status: 'ok' })); - app.get('/ready', { schema: { tags: ['Operations'], response: { 200: { type: 'object', required: ['status'], properties: { status: { type: 'string', const: 'ready' } } } } } }, async () => { await database.ping(); return { status: 'ready' }; }); - app.get('/version', { schema: { tags: ['Operations'], response: { 200: { type: 'object', required: ['name', 'version', 'mode'], properties: { name: { type: 'string' }, version: { type: 'string' }, mode: { type: 'string' } } } } } }, async () => ({ name: 'krona-staff', version: '0.1.0', mode: config.STAFF_RUNTIME_MODE })); - app.get('/openapi.json', async () => app.swagger()); - app.get('/', async (_request, reply) => reply.type('text/html').send('Krona Staff

Krona Staff

Архитектурная основа модуля. Предметные функции ожидают требований владельца.

')); + await app.register( + async (api) => { + api.get( + '/live', + { + schema: { + tags: ['Operations'], + response: { + 200: { + type: 'object', + required: ['status'], + properties: { status: { type: 'string', const: 'ok' } }, + }, + }, + }, + }, + async () => ({ status: 'ok' }), + ); + api.get( + '/ready', + { + schema: { + tags: ['Operations'], + response: { + 200: { + type: 'object', + required: ['status'], + properties: { status: { type: 'string', const: 'ready' } }, + }, + 503: { + type: 'object', + required: ['status'], + properties: { status: { type: 'string', const: 'unavailable' } }, + }, + }, + }, + }, + async (_request, reply) => { + try { + await database.ping(); + return { status: 'ready' }; + } catch (error) { + api.log.error({ err: error }, 'readiness dependency check failed'); + return reply.code(503).send({ status: 'unavailable' }); + } + }, + ); + api.get( + '/version', + { + schema: { + tags: ['Operations'], + response: { + 200: { + type: 'object', + required: ['name', 'version', 'mode'], + properties: { + name: { type: 'string' }, + version: { type: 'string' }, + mode: { type: 'string' }, + }, + }, + }, + }, + }, + async () => ({ + name: SERVICE_NAME, + version: SERVICE_VERSION, + mode: config.STAFF_RUNTIME_MODE, + }), + ); + api.get('/openapi.json', async () => app.swagger()); + }, + { prefix: apiPrefix }, + ); + app.get('/', async (_request, reply) => + reply + .type('text/html') + .send( + `Krona Staff

Krona Staff

Архитектурная основа модуля. Предметные функции ожидают требований владельца.

`, + ), + ); app.addHook('onClose', async () => database.close()); return app; diff --git a/src/config.ts b/src/config.ts index 38ca392..a0f7c18 100644 --- a/src/config.ts +++ b/src/config.ts @@ -6,7 +6,7 @@ const configSchema = z.object({ LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace', 'silent']).default('info'), STAFF_RUNTIME_MODE: z.enum(['standalone', 'platform']).default('standalone'), DATABASE_URL: z.string().url(), - REDIS_URL: z.string().url(), + REDIS_URL: z.string().url().optional(), PLATFORM_GATEWAY_URL: z.string().url().optional(), PLATFORM_JWT_ISSUER: z.string().url().optional(), PLATFORM_JWT_AUDIENCE: z.string().optional(), @@ -19,9 +19,18 @@ export type AppConfig = z.infer; export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig { const config = configSchema.parse(env); + if (config.NODE_ENV === 'production' && config.STAFF_RUNTIME_MODE === 'standalone') { + throw new Error('STAFF_RUNTIME_MODE=standalone is not allowed in production'); + } if (config.STAFF_RUNTIME_MODE === 'platform') { - const required = ['PLATFORM_GATEWAY_URL', 'PLATFORM_JWT_ISSUER', 'PLATFORM_JWT_AUDIENCE', 'PLATFORM_JWKS_URL'] as const; - for (const key of required) if (!config[key]) throw new Error(`${key} is required in platform mode`); + const required = [ + 'PLATFORM_GATEWAY_URL', + 'PLATFORM_JWT_ISSUER', + 'PLATFORM_JWT_AUDIENCE', + 'PLATFORM_JWKS_URL', + ] as const; + for (const key of required) + if (!config[key]) throw new Error(`${key} is required in platform mode`); } return config; } diff --git a/src/core/ports.ts b/src/core/ports.ts index 7ccdeca..318cb08 100644 --- a/src/core/ports.ts +++ b/src/core/ports.ts @@ -14,7 +14,11 @@ export interface SettingsPort { } export interface FileStoragePort { - createUpload(tenantId: Ulid, objectKey: string, contentType: string): Promise<{ uploadUrl: string }>; + createUpload( + tenantId: Ulid, + objectKey: string, + contentType: string, + ): Promise<{ uploadUrl: string }>; createDownload(tenantId: Ulid, objectKey: string): Promise<{ downloadUrl: string }>; } diff --git a/src/infrastructure/database.ts b/src/infrastructure/database.ts index 4461b3f..2a564d4 100644 --- a/src/infrastructure/database.ts +++ b/src/infrastructure/database.ts @@ -7,24 +7,43 @@ export class Database { readonly pool: pg.Pool; constructor(connectionString: string) { this.pool = new Pool({ connectionString, max: 10, application_name: 'krona-staff' }); + this.pool.on('error', (error) => { + console.error('Unexpected PostgreSQL pool error', error); + }); } async ping(): Promise { await this.pool.query('SELECT 1'); } - async tenantTransaction(tenantId: Ulid, actorId: Ulid, run: (client: pg.PoolClient) => Promise): Promise { + async tenantTransaction( + tenantId: Ulid, + actorId: Ulid, + run: (client: pg.PoolClient) => Promise, + ): Promise { const client = await this.pool.connect(); + let released = false; try { await client.query('BEGIN'); - await client.query("SELECT set_config('app.tenant_id', $1, true), set_config('app.user_id', $2, true)", [tenantId, actorId]); + await client.query( + "SELECT set_config('app.tenant_id', $1, true), set_config('app.user_id', $2, true)", + [tenantId, actorId], + ); const result = await run(client); await client.query('COMMIT'); return result; } catch (error) { - await client.query('ROLLBACK'); + try { + await client.query('ROLLBACK'); + } catch (rollbackError) { + console.error('PostgreSQL rollback failed', rollbackError); + } + client.release(error instanceof Error ? error : true); + released = true; throw error; } finally { - client.release(); + if (!released) client.release(); } } - async close(): Promise { await this.pool.end(); } + async close(): Promise { + await this.pool.end(); + } } diff --git a/src/server.ts b/src/server.ts index 583290e..05a80a2 100644 --- a/src/server.ts +++ b/src/server.ts @@ -4,10 +4,28 @@ import { loadConfig } from './config.js'; const config = loadConfig(); const app = await buildApp({ config }); -const shutdown = () => { - void app.close().then(() => process.exit(0)); +let shuttingDown = false; +const shutdown = (signal: string) => { + if (shuttingDown) return; + shuttingDown = true; + const timeout = setTimeout(() => { + app.log.error({ signal }, 'graceful shutdown timed out'); + process.exit(1); + }, 10_000); + timeout.unref(); + void app + .close() + .then(() => { + clearTimeout(timeout); + process.exit(0); + }) + .catch((error: unknown) => { + clearTimeout(timeout); + app.log.error({ err: error, signal }, 'graceful shutdown failed'); + process.exit(1); + }); }; -process.on('SIGTERM', shutdown); -process.on('SIGINT', shutdown); +process.on('SIGTERM', () => shutdown('SIGTERM')); +process.on('SIGINT', () => shutdown('SIGINT')); await app.listen({ host: '0.0.0.0', port: config.PORT }); diff --git a/src/version.ts b/src/version.ts new file mode 100644 index 0000000..b694dc0 --- /dev/null +++ b/src/version.ts @@ -0,0 +1,2 @@ +export const SERVICE_NAME = 'krona-staff'; +export const SERVICE_VERSION = '0.1.0'; diff --git a/tests/integration/health.test.ts b/tests/integration/health.test.ts index 926c4e5..aad256c 100644 --- a/tests/integration/health.test.ts +++ b/tests/integration/health.test.ts @@ -2,16 +2,61 @@ import { describe, expect, it, vi } from 'vitest'; import { buildApp } from '../../src/app.js'; import { loadConfig } from '../../src/config.js'; -const config = loadConfig({ DATABASE_URL: 'postgres://localhost/staff', REDIS_URL: 'redis://localhost:6379', STANDALONE_TENANT_ID: '01J00000000000000000000000', STANDALONE_ACTOR_ID: '01J00000000000000000000001', LOG_LEVEL: 'silent', NODE_ENV: 'test' }); -const database = { ping: vi.fn(async () => undefined), close: vi.fn(async () => undefined) } as never; +const config = loadConfig({ + DATABASE_URL: 'postgres://localhost/staff', + STANDALONE_TENANT_ID: '01J00000000000000000000000', + STANDALONE_ACTOR_ID: '01J00000000000000000000001', + LOG_LEVEL: 'silent', + NODE_ENV: 'test', +}); +const database = { + ping: vi.fn(async () => undefined), + close: vi.fn(async () => undefined), +} as never; describe('operational endpoints', () => { it('reports liveness, readiness and version without domain APIs', async () => { const app = await buildApp({ config, database }); - expect((await app.inject('/live')).statusCode).toBe(200); - expect((await app.inject('/ready')).statusCode).toBe(200); - expect((await app.inject('/version')).json()).toMatchObject({ name: 'krona-staff', mode: 'standalone' }); + expect((await app.inject('/api/v1/staff/live')).statusCode).toBe(200); + expect((await app.inject('/api/v1/staff/ready')).statusCode).toBe(200); + expect((await app.inject('/api/v1/staff/version')).json()).toMatchObject({ + name: 'krona-staff', + mode: 'standalone', + }); + expect((await app.inject('/live')).statusCode).toBe(404); expect((await app.inject('/api/v1/staff/payroll')).statusCode).toBe(404); await app.close(); }); + + it('returns 503 without leaking database errors', async () => { + const unavailableDatabase = { + ping: vi.fn(async () => { + throw new Error('password=secret internal driver detail'); + }), + close: vi.fn(async () => undefined), + } as never; + const app = await buildApp({ config, database: unavailableDatabase }); + const response = await app.inject('/api/v1/staff/ready'); + expect(response.statusCode).toBe(503); + expect(response.body).toBe('{"status":"unavailable"}'); + expect(response.body).not.toContain('secret'); + await app.close(); + }); + + it('accepts only ULID correlation identifiers', async () => { + const app = await buildApp({ config, database }); + const valid = '01J00000000000000000000009'; + expect( + (await app.inject({ url: '/api/v1/staff/live', headers: { 'x-correlation-id': valid } })) + .headers['x-correlation-id'], + ).toBe(valid); + const generated = ( + await app.inject({ + url: '/api/v1/staff/live', + headers: { 'x-correlation-id': 'not-a-ulid'.repeat(100) }, + }) + ).headers['x-correlation-id']; + expect(generated).toMatch(/^[0-9A-HJKMNP-TV-Z]{26}$/); + await app.close(); + }); }); diff --git a/tests/integration/openapi-runtime.test.ts b/tests/integration/openapi-runtime.test.ts new file mode 100644 index 0000000..5980174 --- /dev/null +++ b/tests/integration/openapi-runtime.test.ts @@ -0,0 +1,57 @@ +import { readFile } from 'node:fs/promises'; +import { describe, expect, it, vi } from 'vitest'; +import YAML from 'yaml'; +import { buildApp } from '../../src/app.js'; +import { loadConfig } from '../../src/config.js'; + +interface OpenApiDocument { + servers: Array<{ url: string }>; + paths: Record; +} + +function assertOpenApiDocument(value: unknown): asserts value is OpenApiDocument { + if ( + typeof value !== 'object' || + value === null || + !('servers' in value) || + !Array.isArray(value.servers) || + !('paths' in value) || + typeof value.paths !== 'object' || + value.paths === null + ) { + throw new Error('api/openapi.yaml is not an OpenAPI document'); + } +} + +const config = loadConfig({ + DATABASE_URL: 'postgres://localhost/staff', + STANDALONE_TENANT_ID: '01J00000000000000000000000', + STANDALONE_ACTOR_ID: '01J00000000000000000000001', + LOG_LEVEL: 'silent', + NODE_ENV: 'test', +}); + +describe('OpenAPI runtime contract', () => { + it('exposes the documented operational paths under the module prefix', async () => { + const database = { + ping: vi.fn(async () => undefined), + close: vi.fn(async () => undefined), + } as never; + const app = await buildApp({ config, database }); + await app.ready(); + const documented: unknown = YAML.parse(await readFile('api/openapi.yaml', 'utf8')); + assertOpenApiDocument(documented); + const runtime = app.swagger(); + const prefix = documented.servers[0]?.url; + if (!prefix) throw new Error('OpenAPI server prefix is missing'); + const documentedPaths = Object.keys(documented.paths).sort(); + const runtimePaths = Object.keys(runtime.paths ?? {}) + .filter((path) => path !== '/' && path !== '/openapi.json') + .sort(); + expect(runtimePaths).toEqual(documentedPaths); + for (const path of documentedPaths) { + expect((await app.inject(`${prefix}${path}`)).statusCode).not.toBe(404); + } + await app.close(); + }); +}); diff --git a/tests/integration/tenant-isolation.test.ts b/tests/integration/tenant-isolation.test.ts new file mode 100644 index 0000000..40ec9ed --- /dev/null +++ b/tests/integration/tenant-isolation.test.ts @@ -0,0 +1,61 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { Database } from '../../src/infrastructure/database.js'; +import { newUlid, parseUlid } from '../../src/core/ids.js'; + +const connectionString = process.env.DATABASE_TEST_URL; +const describeDatabase = connectionString ? describe : describe.skip; + +describeDatabase('PostgreSQL tenant isolation', () => { + const database = new Database(connectionString!); + const tenantA = parseUlid('01J0000000000000000000000A'); + const tenantB = parseUlid('01J0000000000000000000000B'); + const actorA = parseUlid('01J0000000000000000000001A'); + const actorB = parseUlid('01J0000000000000000000001B'); + const eventId = newUlid(); + + beforeAll(async () => { + const role = await database.pool.query( + 'SELECT rolsuper, rolbypassrls FROM pg_roles WHERE rolname = current_user', + ); + expect(role.rows[0]).toEqual({ rolsuper: false, rolbypassrls: false }); + }); + + afterAll(async () => { + await database.tenantTransaction(tenantA, actorA, async (client) => { + await client.query('DELETE FROM staff_outbox WHERE id = $1', [eventId]); + }); + await database.close(); + }); + + it('prevents reads and writes across two tenants', async () => { + await database.tenantTransaction(tenantA, actorA, async (client) => { + await client.query( + `INSERT INTO staff_outbox + (id, tenant_id, event_type, event_version, subject_type, subject_id, actor_type, actor_id, payload, correlation_id, occurred_at) + VALUES ($1, $2, 'staff.Test', 1, 'staff.Employee', $3, 'user', $4, '{}'::jsonb, $5, now())`, + [eventId, tenantA, newUlid(), actorA, newUlid()], + ); + }); + + const visibleToA = await database.tenantTransaction(tenantA, actorA, (client) => + client.query('SELECT id FROM staff_outbox WHERE id = $1', [eventId]), + ); + expect(visibleToA.rowCount).toBe(1); + + const visibleToB = await database.tenantTransaction(tenantB, actorB, (client) => + client.query('SELECT id FROM staff_outbox WHERE id = $1', [eventId]), + ); + expect(visibleToB.rowCount).toBe(0); + + await expect( + database.tenantTransaction(tenantB, actorB, (client) => + client.query( + `INSERT INTO staff_outbox + (id, tenant_id, event_type, event_version, subject_type, subject_id, actor_type, actor_id, payload, correlation_id, occurred_at) + VALUES ($1, $2, 'staff.Test', 1, 'staff.Employee', $3, 'user', $4, '{}'::jsonb, $5, now())`, + [newUlid(), tenantA, newUlid(), actorB, newUlid()], + ), + ), + ).rejects.toThrow(); + }); +}); diff --git a/tests/unit/config.test.ts b/tests/unit/config.test.ts index c3c4b34..b672f62 100644 --- a/tests/unit/config.test.ts +++ b/tests/unit/config.test.ts @@ -1,9 +1,21 @@ import { describe, expect, it } from 'vitest'; import { loadConfig } from '../../src/config.js'; -const base = { DATABASE_URL: 'postgres://localhost/staff', REDIS_URL: 'redis://localhost:6379', STANDALONE_TENANT_ID: '01J00000000000000000000000', STANDALONE_ACTOR_ID: '01J00000000000000000000001' }; +const base = { + DATABASE_URL: 'postgres://localhost/staff', + STANDALONE_TENANT_ID: '01J00000000000000000000000', + STANDALONE_ACTOR_ID: '01J00000000000000000000001', +}; describe('runtime modes', () => { - it('allows the compatible standalone adapter', () => expect(loadConfig(base).STAFF_RUNTIME_MODE).toBe('standalone')); - it('fails closed when platform contracts are missing', () => expect(() => loadConfig({ ...base, STAFF_RUNTIME_MODE: 'platform' })).toThrow(/PLATFORM_GATEWAY_URL/)); + it('allows the compatible standalone adapter', () => + expect(loadConfig(base).STAFF_RUNTIME_MODE).toBe('standalone')); + it('fails closed when platform contracts are missing', () => + expect(() => loadConfig({ ...base, STAFF_RUNTIME_MODE: 'platform' })).toThrow( + /PLATFORM_GATEWAY_URL/, + )); + it('rejects standalone mode in production', () => + expect(() => loadConfig({ ...base, NODE_ENV: 'production' })).toThrow( + /standalone is not allowed/, + )); }); diff --git a/tests/unit/events.test.ts b/tests/unit/events.test.ts index 82ce617..0fb3a87 100644 --- a/tests/unit/events.test.ts +++ b/tests/unit/events.test.ts @@ -5,7 +5,8 @@ describe('event envelope', () => { it('retains tenant and trace identity in the platform shape', () => { const event: PlatformEvent<{ marker: string }> = { id: '01J00000000000000000000002' as never, - type: 'staff.test.marker', version: 1, + type: 'staff.test.marker', + version: 1, tenant_id: '01J00000000000000000000000' as never, occurred_at: '2026-09-10T00:00:00.000Z', subject: { type: 'staff.Test', id: '01J00000000000000000000003' as never }, diff --git a/tests/unit/ids.test.ts b/tests/unit/ids.test.ts index 6947b16..31010b1 100644 --- a/tests/unit/ids.test.ts +++ b/tests/unit/ids.test.ts @@ -3,5 +3,6 @@ import { newUlid, parseUlid, ULID_PATTERN } from '../../src/core/ids.js'; describe('platform identifiers', () => { it('generates canonical sortable ULIDs', () => expect(newUlid()).toMatch(ULID_PATTERN)); - it('rejects UUID compatibility drift', () => expect(() => parseUlid('550e8400-e29b-41d4-a716-446655440000')).toThrow()); + it('rejects UUID compatibility drift', () => + expect(() => parseUlid('550e8400-e29b-41d4-a716-446655440000')).toThrow()); }); diff --git a/tests/unit/tenant-sql.test.ts b/tests/unit/tenant-sql.test.ts index a86bd49..0a2fdc0 100644 --- a/tests/unit/tenant-sql.test.ts +++ b/tests/unit/tenant-sql.test.ts @@ -5,7 +5,10 @@ describe('tenant isolation migration', () => { const sql = readFileSync('migrations/0001_infrastructure.up.sql', 'utf8'); const tables = ['staff_outbox', 'staff_inbox', 'staff_idempotency_keys', 'staff_audit_log']; it.each(tables)('%s is tenant scoped and protected by RLS', (table) => { - const create = sql.slice(sql.indexOf(`CREATE TABLE IF NOT EXISTS ${table}`), sql.indexOf(';', sql.indexOf(`CREATE TABLE IF NOT EXISTS ${table}`))); + const create = sql.slice( + sql.indexOf(`CREATE TABLE IF NOT EXISTS ${table}`), + sql.indexOf(';', sql.indexOf(`CREATE TABLE IF NOT EXISTS ${table}`)), + ); expect(create).toContain('tenant_id VARCHAR(26) NOT NULL'); expect(sql).toContain(`ALTER TABLE ${table} ENABLE ROW LEVEL SECURITY`); expect(sql).toContain(`ALTER TABLE ${table} FORCE ROW LEVEL SECURITY`); diff --git a/tsconfig.json b/tsconfig.json index c05ecf3..1792070 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,5 +11,5 @@ "skipLibCheck": true, "types": ["node"] }, - "include": ["src", "tests", "scripts"] + "include": ["src", "tests", "scripts", "vitest.config.ts"] } diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..cc02e66 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + testTimeout: 15_000, + hookTimeout: 15_000, + }, +});