fix: enforce tenant isolation and runtime contracts

This commit is contained in:
Ochenstarik 2026-09-18 01:34:12 +07:00
parent b88ff9195c
commit 64ac5c0c66
39 changed files with 1410 additions and 953 deletions

View file

@ -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

View file

@ -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
View file

@ -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
View file

@ -0,0 +1,6 @@
node_modules/
.npm-cache/
dist/
coverage/
apps/KronaStaffTerminal/bin/
apps/KronaStaffTerminal/obj/

View file

@ -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

View file

@ -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).

View file

@ -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:

View 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

View file

@ -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

View file

@ -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`.

View file

@ -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 |

View file

@ -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' } },
); );

View file

@ -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
$$;

View 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;

View 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

File diff suppressed because it is too large Load diff

View file

@ -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"
} }
} }

View file

@ -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
}
}
} }
} }

View file

@ -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" }

View file

@ -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');

View file

@ -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)`);

View 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();
}

View file

@ -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;
await client.connect(); if (!connectionString) throw new Error('DATABASE_MIGRATION_URL or DATABASE_URL is required');
try { if (direction === 'down' && process.env.ALLOW_MIGRATION_DOWN !== 'true') {
for (const file of files) { throw new Error(
const sql = await readFile(path.join('migrations', file), 'utf8'); 'Refusing destructive rollback. Set ALLOW_MIGRATION_DOWN=true to roll back the latest migration.',
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; }
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 {
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.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;
}
} }
} finally { await client.end(); }

View file

@ -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',
);
} }
} }

View file

@ -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;

View file

@ -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;
} }

View file

@ -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 }>;
} }

View file

@ -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();
}
} }

View file

@ -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
View file

@ -0,0 +1,2 @@
export const SERVICE_NAME = 'krona-staff';
export const SERVICE_VERSION = '0.1.0';

View file

@ -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();
});
}); });

View 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();
});
});

View 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();
});
});

View file

@ -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/,
));
}); });

View file

@ -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 },

View file

@ -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());
}); });

View file

@ -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`);

View file

@ -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
View file

@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
testTimeout: 15_000,
hookTimeout: 15_000,
},
});