fix: enforce tenant isolation and runtime contracts
This commit is contained in:
parent
b88ff9195c
commit
64ac5c0c66
39 changed files with 1410 additions and 953 deletions
13
.env.example
13
.env.example
|
|
@ -2,18 +2,13 @@ NODE_ENV=development
|
||||||
PORT=8080
|
PORT=8080
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
STAFF_RUNTIME_MODE=standalone
|
STAFF_RUNTIME_MODE=standalone
|
||||||
DATABASE_URL=postgres://krona_staff:krona_staff@postgres:5432/krona_staff
|
POSTGRES_MIGRATION_PASSWORD=local-migration-only
|
||||||
REDIS_URL=redis://redis:6379
|
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_GATEWAY_URL=http://host.docker.internal:3000
|
||||||
PLATFORM_JWT_ISSUER=https://platform.example.invalid
|
PLATFORM_JWT_ISSUER=https://platform.example.invalid
|
||||||
PLATFORM_JWT_AUDIENCE=krona-staff
|
PLATFORM_JWT_AUDIENCE=krona-staff
|
||||||
PLATFORM_JWKS_URL=https://platform.example.invalid/.well-known/jwks.json
|
PLATFORM_JWKS_URL=https://platform.example.invalid/.well-known/jwks.json
|
||||||
STANDALONE_TENANT_ID=01J00000000000000000000000
|
STANDALONE_TENANT_ID=01J00000000000000000000000
|
||||||
STANDALONE_ACTOR_ID=01J00000000000000000000001
|
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
|
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,31 @@ on:
|
||||||
jobs:
|
jobs:
|
||||||
verify:
|
verify:
|
||||||
runs-on: docker
|
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:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with: { node-version: 22, cache: npm }
|
with: { node-version: 22, cache: npm }
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
|
- run: node scripts/create-app-role.mjs
|
||||||
|
- run: node scripts/migrate.mjs up
|
||||||
- run: npm run check
|
- run: npm run check
|
||||||
|
- run: npm run format:check
|
||||||
- run: npm audit --audit-level=high
|
- run: npm audit --audit-level=high
|
||||||
- run: docker build -t krona-staff:${{ forgejo.sha }} .
|
- run: docker build -t krona-staff:${{ forgejo.sha }} .
|
||||||
|
|
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -9,3 +9,5 @@ coverage/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
backups/
|
backups/
|
||||||
restore-work/
|
restore-work/
|
||||||
|
apps/KronaStaffTerminal/bin/
|
||||||
|
apps/KronaStaffTerminal/obj/
|
||||||
|
|
|
||||||
6
.prettierignore
Normal file
6
.prettierignore
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
node_modules/
|
||||||
|
.npm-cache/
|
||||||
|
dist/
|
||||||
|
coverage/
|
||||||
|
apps/KronaStaffTerminal/bin/
|
||||||
|
apps/KronaStaffTerminal/obj/
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
# syntax=docker/dockerfile:1.7
|
# syntax=docker/dockerfile:1.7
|
||||||
FROM node:22-alpine AS build
|
FROM node:22-alpine@sha256:c610fcdfb1d5b4740dd70c284ed3cb16bb857e0f7166196e36a5501df7a3aa32 AS build
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|
@ -9,7 +9,7 @@ COPY scripts ./scripts
|
||||||
COPY migrations ./migrations
|
COPY migrations ./migrations
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
FROM node:22-alpine AS runtime
|
FROM node:22-alpine@sha256:c610fcdfb1d5b4740dd70c284ed3cb16bb857e0f7166196e36a5501df7a3aa32 AS runtime
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN addgroup -S app && adduser -S -G app app
|
RUN addgroup -S app && adduser -S -G app app
|
||||||
|
|
|
||||||
10
README.md
10
README.md
|
|
@ -9,21 +9,21 @@
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
docker compose --profile tools run --rm migrate
|
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 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.
|
- `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).
|
Архитектурные решения: [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).
|
||||||
|
|
|
||||||
38
compose.yaml
38
compose.yaml
|
|
@ -2,34 +2,40 @@ services:
|
||||||
app:
|
app:
|
||||||
build: .
|
build: .
|
||||||
env_file: .env
|
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:
|
depends_on:
|
||||||
postgres: { condition: service_healthy }
|
postgres: { condition: service_healthy }
|
||||||
redis: { condition: service_healthy }
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
read_only: true
|
read_only: true
|
||||||
tmpfs: [/tmp]
|
tmpfs: [/tmp]
|
||||||
security_opt: ["no-new-privileges:true"]
|
security_opt: ['no-new-privileges:true']
|
||||||
migrate:
|
migrate:
|
||||||
build: { context: ., target: build }
|
build: { context: ., target: build }
|
||||||
env_file: .env
|
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:
|
depends_on:
|
||||||
postgres: { condition: service_healthy }
|
postgres: { condition: service_healthy }
|
||||||
profiles: ["tools"]
|
profiles: ['tools']
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:17-alpine
|
image: postgres:17-alpine@sha256:18cfe3ef5e6815560c98237d6216d1e5119702fb0f3894c8785dd58b8bbe5d73
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: krona_staff
|
POSTGRES_DB: krona_staff
|
||||||
POSTGRES_USER: krona_staff
|
POSTGRES_USER: krona_migrator
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-local-development-only}
|
POSTGRES_PASSWORD: ${POSTGRES_MIGRATION_PASSWORD:-local-migration-only}
|
||||||
volumes: ["postgres-data:/var/lib/postgresql/data"]
|
POSTGRES_APP_PASSWORD: ${POSTGRES_APP_PASSWORD:-local-app-only}
|
||||||
healthcheck: { test: ["CMD-SHELL", "pg_isready -U krona_staff -d krona_staff"], interval: 5s, timeout: 3s, retries: 20 }
|
volumes:
|
||||||
redis:
|
- postgres-data:/var/lib/postgresql/data
|
||||||
image: redis:7-alpine
|
- ./docker/postgres/init-app-role.sh:/docker-entrypoint-initdb.d/10-app-role.sh:ro
|
||||||
command: ["redis-server", "--appendonly", "yes"]
|
healthcheck:
|
||||||
volumes: ["redis-data:/data"]
|
{
|
||||||
healthcheck: { test: ["CMD", "redis-cli", "ping"], interval: 5s, timeout: 3s, retries: 20 }
|
test: ['CMD-SHELL', 'pg_isready -U krona_migrator -d krona_staff'],
|
||||||
|
interval: 5s,
|
||||||
|
timeout: 3s,
|
||||||
|
retries: 20,
|
||||||
|
}
|
||||||
volumes:
|
volumes:
|
||||||
postgres-data:
|
postgres-data:
|
||||||
redis-data:
|
|
||||||
|
|
|
||||||
21
docker/postgres/init-app-role.sh
Normal file
21
docker/postgres/init-app-role.sh
Normal file
|
|
@ -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
|
||||||
|
|
@ -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
|
```text
|
||||||
HTTP / worker -> application -> bounded-context contracts
|
HTTP / worker -> application -> bounded-context contracts
|
||||||
|
|
|
||||||
|
|
@ -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.
|
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`.
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# Доменные границы
|
# Доменные границы
|
||||||
|
|
||||||
| Контекст | Будущее владение Staff | Сейчас |
|
| Контекст | Будущее владение Staff | Сейчас |
|
||||||
|---|---|---|
|
| ---------------------- | ------------------------------------------------------------- | --------------------------------- |
|
||||||
| Employee Directory | Карточка сотрудника и связь с Core User по nullable reference | Только manifest |
|
| Employee Directory | Карточка сотрудника и связь с Core User по nullable reference | Только manifest |
|
||||||
| Employment | Трудовые отношения и их жизненный цикл | Ожидает требований |
|
| Employment | Трудовые отношения и их жизненный цикл | Ожидает требований |
|
||||||
| Organization Structure | HR-представление подразделений/позиций | Ожидает решения о source of truth |
|
| Organization Structure | HR-представление подразделений/позиций | Ожидает решения о source of truth |
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,10 @@ export default tseslint.config(
|
||||||
{ ignores: ['dist/**', 'coverage/**', 'eslint.config.js', 'scripts/**'] },
|
{ ignores: ['dist/**', 'coverage/**', 'eslint.config.js', 'scripts/**'] },
|
||||||
js.configs.recommended,
|
js.configs.recommended,
|
||||||
...tseslint.configs.recommendedTypeChecked,
|
...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' } },
|
{ rules: { '@typescript-eslint/require-await': 'off' } },
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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}$'),
|
tenant_id VARCHAR(26) NOT NULL CHECK (tenant_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
|
||||||
event_type TEXT NOT NULL,
|
event_type TEXT NOT NULL,
|
||||||
event_version INTEGER NOT NULL CHECK (event_version > 0),
|
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,
|
payload JSONB NOT NULL,
|
||||||
correlation_id VARCHAR(26) NOT NULL,
|
correlation_id VARCHAR(26) NOT NULL CHECK (correlation_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
|
||||||
causation_id VARCHAR(26),
|
causation_id VARCHAR(26) CHECK (causation_id IS NULL OR causation_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
|
||||||
occurred_at TIMESTAMPTZ NOT NULL,
|
occurred_at TIMESTAMPTZ NOT NULL,
|
||||||
published_at TIMESTAMPTZ,
|
published_at TIMESTAMPTZ,
|
||||||
attempts INTEGER NOT NULL DEFAULT 0,
|
attempts INTEGER NOT NULL DEFAULT 0,
|
||||||
last_error TEXT
|
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 (
|
CREATE TABLE IF NOT EXISTS staff_inbox (
|
||||||
event_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,
|
tenant_id VARCHAR(26) NOT NULL CHECK (tenant_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
|
||||||
event_type TEXT NOT NULL,
|
event_type TEXT NOT NULL,
|
||||||
event_version INTEGER NOT NULL,
|
event_version INTEGER NOT NULL,
|
||||||
correlation_id VARCHAR(26) NOT NULL,
|
correlation_id VARCHAR(26) NOT NULL CHECK (correlation_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
|
||||||
causation_id VARCHAR(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(),
|
received_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
processed_at TIMESTAMPTZ,
|
processed_at TIMESTAMPTZ,
|
||||||
PRIMARY KEY (tenant_id, event_id)
|
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 (
|
CREATE TABLE IF NOT EXISTS staff_audit_log (
|
||||||
id VARCHAR(26) PRIMARY KEY,
|
id VARCHAR(26) PRIMARY KEY CHECK (id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
|
||||||
tenant_id VARCHAR(26) NOT NULL,
|
tenant_id VARCHAR(26) NOT NULL CHECK (tenant_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
|
||||||
actor_id VARCHAR(26) NOT NULL,
|
actor_id VARCHAR(26) NOT NULL CHECK (actor_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
|
||||||
action TEXT NOT NULL,
|
action TEXT NOT NULL,
|
||||||
entity_type 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,
|
old_values JSONB,
|
||||||
new_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()
|
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 ENABLE ROW LEVEL SECURITY;
|
||||||
ALTER TABLE staff_outbox FORCE 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));
|
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 ENABLE ROW LEVEL SECURITY;
|
||||||
ALTER TABLE staff_inbox FORCE 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));
|
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 ENABLE ROW LEVEL SECURITY;
|
||||||
ALTER TABLE staff_idempotency_keys FORCE 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));
|
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 ENABLE ROW LEVEL SECURITY;
|
||||||
ALTER TABLE staff_audit_log FORCE 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
|
||||||
|
$$;
|
||||||
|
|
|
||||||
30
migrations/0002_security_hardening.down.sql
Normal file
30
migrations/0002_security_hardening.down.sql
Normal file
|
|
@ -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;
|
||||||
68
migrations/0002_security_hardening.up.sql
Normal file
68
migrations/0002_security_hardening.up.sql
Normal file
|
|
@ -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
|
||||||
|
$$;
|
||||||
1417
package-lock.json
generated
1417
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -3,7 +3,9 @@
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": { "node": ">=22" },
|
"engines": {
|
||||||
|
"node": ">=22"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx watch src/server.ts",
|
"dev": "tsx watch src/server.ts",
|
||||||
"build": "tsc -p tsconfig.build.json",
|
"build": "tsc -p tsconfig.build.json",
|
||||||
|
|
@ -25,9 +27,7 @@
|
||||||
"@fastify/helmet": "^13.0.2",
|
"@fastify/helmet": "^13.0.2",
|
||||||
"@fastify/sensible": "^6.0.3",
|
"@fastify/sensible": "^6.0.3",
|
||||||
"@fastify/swagger": "^9.5.1",
|
"@fastify/swagger": "^9.5.1",
|
||||||
"bullmq": "^5.58.5",
|
|
||||||
"fastify": "^5.6.0",
|
"fastify": "^5.6.0",
|
||||||
"ioredis": "^5.7.0",
|
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
"ulid": "^3.0.1",
|
"ulid": "^3.0.1",
|
||||||
"yaml": "^2.8.1",
|
"yaml": "^2.8.1",
|
||||||
|
|
@ -44,6 +44,6 @@
|
||||||
"tsx": "^4.20.5",
|
"tsx": "^4.20.5",
|
||||||
"typescript": "^5.9.2",
|
"typescript": "^5.9.2",
|
||||||
"typescript-eslint": "^8.43.0",
|
"typescript-eslint": "^8.43.0",
|
||||||
"vitest": "^3.2.4"
|
"vitest": "^5.0.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,24 +3,62 @@
|
||||||
"$id": "https://krona.example/schemas/module.schema.json",
|
"$id": "https://krona.example/schemas/module.schema.json",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"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": {
|
"properties": {
|
||||||
"$schema": { "type": "string" },
|
"$schema": { "type": "string" },
|
||||||
"id": { "const": "ru.krona.staff" },
|
"id": { "const": "ru.krona.staff" },
|
||||||
"name": { "type": "string", "minLength": 1 },
|
"name": { "type": "string", "minLength": 1 },
|
||||||
"version": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" },
|
"version": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" },
|
||||||
"min_platform_version": { "type": "string" },
|
"min_platform_version": { "type": "string" },
|
||||||
"dependencies": { "type": "array", "contains": { "const": "ru.krona.core" }, "items": { "type": "string" }, "uniqueItems": true },
|
"dependencies": {
|
||||||
"optional_dependencies": { "type": "array", "items": { "type": "string" }, "uniqueItems": true },
|
"type": "array",
|
||||||
"permissions": { "type": "array", "items": { "type": "string", "pattern": "^staff:[a-z_]+:(read|write)$" }, "uniqueItems": true },
|
"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 },
|
"entities": { "type": "array", "items": { "type": "string" }, "uniqueItems": true },
|
||||||
"events_emitted": { "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 },
|
"events_consumed": { "type": "array", "items": { "type": "string" }, "uniqueItems": true },
|
||||||
"settings_schema": { "type": "string" },
|
"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" } },
|
"promote_targets": { "type": "array", "items": { "type": "object" } },
|
||||||
"api_prefix": { "const": "/api/v1/staff" },
|
"api_prefix": { "const": "/api/v1/staff" },
|
||||||
"routes": { "type": "array", "items": { "type": "string" } },
|
"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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,23 @@
|
||||||
"required": ["key", "type", "label"],
|
"required": ["key", "type", "label"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"key": { "type": "string", "pattern": "^[a-z][a-z0-9_]*$" },
|
"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" },
|
"label": { "type": "string" },
|
||||||
"required": { "type": "boolean" },
|
"required": { "type": "boolean" },
|
||||||
"description": { "type": "string" }
|
"description": { "type": "string" }
|
||||||
|
|
|
||||||
|
|
@ -3,16 +3,37 @@ import { spawnSync } from 'node:child_process';
|
||||||
import Ajv from 'ajv/dist/2020.js';
|
import Ajv from 'ajv/dist/2020.js';
|
||||||
|
|
||||||
const manifest = JSON.parse(await readFile('module.json', 'utf8'));
|
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 settings = JSON.parse(await readFile('settings.schema.json', 'utf8'));
|
||||||
const moduleSchema = JSON.parse(await readFile('schemas/module.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 settingsSchema = JSON.parse(await readFile('schemas/settings.schema.schema.json', 'utf8'));
|
||||||
const ajv = new Ajv({ allErrors: true });
|
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);
|
const validate = ajv.compile(schema);
|
||||||
if (!validate(value)) throw new Error(`${name}: ${ajv.errorsText(validate.errors)}`);
|
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');
|
if (manifest.configurable_entities.some((entity) => !manifest.entities.includes(entity)))
|
||||||
const lint = spawnSync(process.execPath, ['node_modules/@redocly/cli/bin/cli.js', 'lint', 'api/openapi.yaml'], { stdio: 'inherit' });
|
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.error) throw lint.error;
|
||||||
if (lint.status !== 0) process.exit(lint.status ?? 1);
|
if (lint.status !== 0) process.exit(lint.status ?? 1);
|
||||||
console.log('module manifest, settings schema and OpenAPI contract validated');
|
console.log('module manifest, settings schema and OpenAPI contract validated');
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,15 @@ import { readFile, readdir } from 'node:fs/promises';
|
||||||
|
|
||||||
const files = await readdir('migrations');
|
const files = await readdir('migrations');
|
||||||
const ups = files.filter((f) => f.endsWith('.up.sql')).map((f) => f.replace('.up.sql', ''));
|
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', '')));
|
const downs = new Set(
|
||||||
for (const migration of ups) if (!downs.has(migration)) throw new Error(`Missing down migration for ${migration}`);
|
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'))) {
|
for (const file of files.filter((f) => f.endsWith('.sql'))) {
|
||||||
const sql = await readFile(`migrations/${file}`, 'utf8');
|
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 (/\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)`);
|
console.log(`validated ${ups.length} reversible migration(s)`);
|
||||||
|
|
|
||||||
25
scripts/create-app-role.mjs
Normal file
25
scripts/create-app-role.mjs
Normal file
|
|
@ -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();
|
||||||
|
}
|
||||||
|
|
@ -2,18 +2,88 @@ import { readFile, readdir } from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import pg from 'pg';
|
import pg from 'pg';
|
||||||
|
|
||||||
const direction = process.argv[2] === 'down' ? 'down' : 'up';
|
const direction = process.argv[2];
|
||||||
const connectionString = process.env.DATABASE_URL;
|
if (direction !== 'up' && direction !== 'down') {
|
||||||
if (!connectionString) throw new Error('DATABASE_URL is required');
|
throw new Error('Usage: node scripts/migrate.mjs <up|down>');
|
||||||
const files = (await readdir('migrations')).filter((name) => name.endsWith(`.${direction}.sql`)).sort();
|
}
|
||||||
if (direction === 'down') files.reverse();
|
|
||||||
const client = new pg.Client({ connectionString });
|
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();
|
await client.connect();
|
||||||
|
|
||||||
|
const advisoryLockId = 7_347_821_009;
|
||||||
try {
|
try {
|
||||||
|
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) {
|
for (const file of files) {
|
||||||
const sql = await readFile(path.join('migrations', file), 'utf8');
|
const version = file.replace(/\.up\.sql$/, '');
|
||||||
await client.query('BEGIN');
|
if (applied.has(version)) continue;
|
||||||
try { await client.query(sql); await client.query('COMMIT'); console.log(`applied ${file}`); }
|
const sql = await readFile(path.join(migrationsDirectory, file), 'utf8');
|
||||||
catch (error) { await client.query('ROLLBACK'); throw error; }
|
await applyTransaction(async () => {
|
||||||
|
await client.query(sql);
|
||||||
|
await client.query('INSERT INTO schema_migrations (version) VALUES ($1)', [version]);
|
||||||
|
});
|
||||||
|
console.log(`applied ${file}`);
|
||||||
}
|
}
|
||||||
} finally { await client.end(); }
|
} 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.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@ export class StandaloneIdentityAdapter implements IdentityPort {
|
||||||
|
|
||||||
export class PlatformIdentityAdapter implements IdentityPort {
|
export class PlatformIdentityAdapter implements IdentityPort {
|
||||||
async resolve(): Promise<never> {
|
async resolve(): Promise<never> {
|
||||||
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',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
119
src/app.ts
119
src/app.ts
|
|
@ -5,20 +5,121 @@ import swagger from '@fastify/swagger';
|
||||||
import type { AppConfig } from './config.js';
|
import type { AppConfig } from './config.js';
|
||||||
import { Database } from './infrastructure/database.js';
|
import { Database } from './infrastructure/database.js';
|
||||||
import { newUlid } from './core/ids.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 apiPrefix = '/api/v1/staff';
|
||||||
const app = Fastify({ logger: { level: config.LOG_LEVEL }, genReqId: (request) => request.headers['x-correlation-id']?.toString() ?? newUlid() });
|
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(helmet);
|
||||||
await app.register(sensible);
|
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' }));
|
await app.register(
|
||||||
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' }; });
|
async (api) => {
|
||||||
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 }));
|
api.get(
|
||||||
app.get('/openapi.json', async () => app.swagger());
|
'/live',
|
||||||
app.get('/', async (_request, reply) => reply.type('text/html').send('<!doctype html><html lang="ru"><meta charset="utf-8"><title>Krona Staff</title><body><main><h1>Krona Staff</h1><p>Архитектурная основа модуля. Предметные функции ожидают требований владельца.</p><nav><a href="/openapi.json">OpenAPI</a> · <a href="/live">Live</a> · <a href="/ready">Ready</a> · <a href="/version">Version</a></nav></main></body></html>'));
|
{
|
||||||
|
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(
|
||||||
|
`<!doctype html><html lang="ru"><meta charset="utf-8"><title>Krona Staff</title><body><main><h1>Krona Staff</h1><p>Архитектурная основа модуля. Предметные функции ожидают требований владельца.</p><nav><a href="${apiPrefix}/openapi.json">OpenAPI</a> · <a href="${apiPrefix}/live">Live</a> · <a href="${apiPrefix}/ready">Ready</a> · <a href="${apiPrefix}/version">Version</a></nav></main></body></html>`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
app.addHook('onClose', async () => database.close());
|
app.addHook('onClose', async () => database.close());
|
||||||
return app;
|
return app;
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ const configSchema = z.object({
|
||||||
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace', 'silent']).default('info'),
|
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace', 'silent']).default('info'),
|
||||||
STAFF_RUNTIME_MODE: z.enum(['standalone', 'platform']).default('standalone'),
|
STAFF_RUNTIME_MODE: z.enum(['standalone', 'platform']).default('standalone'),
|
||||||
DATABASE_URL: z.string().url(),
|
DATABASE_URL: z.string().url(),
|
||||||
REDIS_URL: z.string().url(),
|
REDIS_URL: z.string().url().optional(),
|
||||||
PLATFORM_GATEWAY_URL: z.string().url().optional(),
|
PLATFORM_GATEWAY_URL: z.string().url().optional(),
|
||||||
PLATFORM_JWT_ISSUER: z.string().url().optional(),
|
PLATFORM_JWT_ISSUER: z.string().url().optional(),
|
||||||
PLATFORM_JWT_AUDIENCE: z.string().optional(),
|
PLATFORM_JWT_AUDIENCE: z.string().optional(),
|
||||||
|
|
@ -19,9 +19,18 @@ export type AppConfig = z.infer<typeof configSchema>;
|
||||||
|
|
||||||
export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
|
export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
|
||||||
const config = configSchema.parse(env);
|
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') {
|
if (config.STAFF_RUNTIME_MODE === 'platform') {
|
||||||
const required = ['PLATFORM_GATEWAY_URL', 'PLATFORM_JWT_ISSUER', 'PLATFORM_JWT_AUDIENCE', 'PLATFORM_JWKS_URL'] as const;
|
const required = [
|
||||||
for (const key of required) if (!config[key]) throw new Error(`${key} is required in platform mode`);
|
'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;
|
return config;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,11 @@ export interface SettingsPort {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FileStoragePort {
|
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 }>;
|
createDownload(tenantId: Ulid, objectKey: string): Promise<{ downloadUrl: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,24 +7,43 @@ export class Database {
|
||||||
readonly pool: pg.Pool;
|
readonly pool: pg.Pool;
|
||||||
constructor(connectionString: string) {
|
constructor(connectionString: string) {
|
||||||
this.pool = new Pool({ connectionString, max: 10, application_name: 'krona-staff' });
|
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<void> {
|
async ping(): Promise<void> {
|
||||||
await this.pool.query('SELECT 1');
|
await this.pool.query('SELECT 1');
|
||||||
}
|
}
|
||||||
async tenantTransaction<T>(tenantId: Ulid, actorId: Ulid, run: (client: pg.PoolClient) => Promise<T>): Promise<T> {
|
async tenantTransaction<T>(
|
||||||
|
tenantId: Ulid,
|
||||||
|
actorId: Ulid,
|
||||||
|
run: (client: pg.PoolClient) => Promise<T>,
|
||||||
|
): Promise<T> {
|
||||||
const client = await this.pool.connect();
|
const client = await this.pool.connect();
|
||||||
|
let released = false;
|
||||||
try {
|
try {
|
||||||
await client.query('BEGIN');
|
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);
|
const result = await run(client);
|
||||||
await client.query('COMMIT');
|
await client.query('COMMIT');
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
try {
|
||||||
await client.query('ROLLBACK');
|
await client.query('ROLLBACK');
|
||||||
|
} catch (rollbackError) {
|
||||||
|
console.error('PostgreSQL rollback failed', rollbackError);
|
||||||
|
}
|
||||||
|
client.release(error instanceof Error ? error : true);
|
||||||
|
released = true;
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
client.release();
|
if (!released) client.release();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async close(): Promise<void> { await this.pool.end(); }
|
async close(): Promise<void> {
|
||||||
|
await this.pool.end();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,28 @@ import { loadConfig } from './config.js';
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
const app = await buildApp({ config });
|
const app = await buildApp({ config });
|
||||||
|
|
||||||
const shutdown = () => {
|
let shuttingDown = false;
|
||||||
void app.close().then(() => process.exit(0));
|
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('SIGTERM', () => shutdown('SIGTERM'));
|
||||||
process.on('SIGINT', shutdown);
|
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||||
|
|
||||||
await app.listen({ host: '0.0.0.0', port: config.PORT });
|
await app.listen({ host: '0.0.0.0', port: config.PORT });
|
||||||
|
|
|
||||||
2
src/version.ts
Normal file
2
src/version.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export const SERVICE_NAME = 'krona-staff';
|
||||||
|
export const SERVICE_VERSION = '0.1.0';
|
||||||
|
|
@ -2,16 +2,61 @@ import { describe, expect, it, vi } from 'vitest';
|
||||||
import { buildApp } from '../../src/app.js';
|
import { buildApp } from '../../src/app.js';
|
||||||
import { loadConfig } from '../../src/config.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 config = loadConfig({
|
||||||
const database = { ping: vi.fn(async () => undefined), close: vi.fn(async () => undefined) } as never;
|
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', () => {
|
describe('operational endpoints', () => {
|
||||||
it('reports liveness, readiness and version without domain APIs', async () => {
|
it('reports liveness, readiness and version without domain APIs', async () => {
|
||||||
const app = await buildApp({ config, database });
|
const app = await buildApp({ config, database });
|
||||||
expect((await app.inject('/live')).statusCode).toBe(200);
|
expect((await app.inject('/api/v1/staff/live')).statusCode).toBe(200);
|
||||||
expect((await app.inject('/ready')).statusCode).toBe(200);
|
expect((await app.inject('/api/v1/staff/ready')).statusCode).toBe(200);
|
||||||
expect((await app.inject('/version')).json()).toMatchObject({ name: 'krona-staff', mode: 'standalone' });
|
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);
|
expect((await app.inject('/api/v1/staff/payroll')).statusCode).toBe(404);
|
||||||
await app.close();
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
57
tests/integration/openapi-runtime.test.ts
Normal file
57
tests/integration/openapi-runtime.test.ts
Normal file
|
|
@ -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<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
61
tests/integration/tenant-isolation.test.ts
Normal file
61
tests/integration/tenant-isolation.test.ts
Normal file
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,9 +1,21 @@
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { loadConfig } from '../../src/config.js';
|
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', () => {
|
describe('runtime modes', () => {
|
||||||
it('allows the compatible standalone adapter', () => expect(loadConfig(base).STAFF_RUNTIME_MODE).toBe('standalone'));
|
it('allows the compatible standalone adapter', () =>
|
||||||
it('fails closed when platform contracts are missing', () => expect(() => loadConfig({ ...base, STAFF_RUNTIME_MODE: 'platform' })).toThrow(/PLATFORM_GATEWAY_URL/));
|
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/,
|
||||||
|
));
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,8 @@ describe('event envelope', () => {
|
||||||
it('retains tenant and trace identity in the platform shape', () => {
|
it('retains tenant and trace identity in the platform shape', () => {
|
||||||
const event: PlatformEvent<{ marker: string }> = {
|
const event: PlatformEvent<{ marker: string }> = {
|
||||||
id: '01J00000000000000000000002' as never,
|
id: '01J00000000000000000000002' as never,
|
||||||
type: 'staff.test.marker', version: 1,
|
type: 'staff.test.marker',
|
||||||
|
version: 1,
|
||||||
tenant_id: '01J00000000000000000000000' as never,
|
tenant_id: '01J00000000000000000000000' as never,
|
||||||
occurred_at: '2026-09-10T00:00:00.000Z',
|
occurred_at: '2026-09-10T00:00:00.000Z',
|
||||||
subject: { type: 'staff.Test', id: '01J00000000000000000000003' as never },
|
subject: { type: 'staff.Test', id: '01J00000000000000000000003' as never },
|
||||||
|
|
|
||||||
|
|
@ -3,5 +3,6 @@ import { newUlid, parseUlid, ULID_PATTERN } from '../../src/core/ids.js';
|
||||||
|
|
||||||
describe('platform identifiers', () => {
|
describe('platform identifiers', () => {
|
||||||
it('generates canonical sortable ULIDs', () => expect(newUlid()).toMatch(ULID_PATTERN));
|
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());
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,10 @@ describe('tenant isolation migration', () => {
|
||||||
const sql = readFileSync('migrations/0001_infrastructure.up.sql', 'utf8');
|
const sql = readFileSync('migrations/0001_infrastructure.up.sql', 'utf8');
|
||||||
const tables = ['staff_outbox', 'staff_inbox', 'staff_idempotency_keys', 'staff_audit_log'];
|
const tables = ['staff_outbox', 'staff_inbox', 'staff_idempotency_keys', 'staff_audit_log'];
|
||||||
it.each(tables)('%s is tenant scoped and protected by RLS', (table) => {
|
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(create).toContain('tenant_id VARCHAR(26) NOT NULL');
|
||||||
expect(sql).toContain(`ALTER TABLE ${table} ENABLE ROW LEVEL SECURITY`);
|
expect(sql).toContain(`ALTER TABLE ${table} ENABLE ROW LEVEL SECURITY`);
|
||||||
expect(sql).toContain(`ALTER TABLE ${table} FORCE ROW LEVEL SECURITY`);
|
expect(sql).toContain(`ALTER TABLE ${table} FORCE ROW LEVEL SECURITY`);
|
||||||
|
|
|
||||||
|
|
@ -11,5 +11,5 @@
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"types": ["node"]
|
"types": ["node"]
|
||||||
},
|
},
|
||||||
"include": ["src", "tests", "scripts"]
|
"include": ["src", "tests", "scripts", "vitest.config.ts"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
8
vitest.config.ts
Normal file
8
vitest.config.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
testTimeout: 15_000,
|
||||||
|
hookTimeout: 15_000,
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue