diff --git a/.env.example b/.env.example index 2637c39..9b9c38f 100644 --- a/.env.example +++ b/.env.example @@ -11,5 +11,6 @@ PLATFORM_GATEWAY_URL=http://host.docker.internal:3000 PLATFORM_JWT_ISSUER=https://platform.example.invalid PLATFORM_JWT_AUDIENCE=krona-staff PLATFORM_JWKS_URL=https://platform.example.invalid/.well-known/jwks.json -STANDALONE_TENANT_ID=01J00000000000000000000000 +# Set this to the organization's tenant ID from Krona Business Platform. +STANDALONE_TENANT_ID= STANDALONE_ACTOR_ID=01J00000000000000000000001 diff --git a/README.md b/README.md index 9ab3a19..dac7e8b 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ ```bash cp .env.example .env +# Перед следующей командой заполните STANDALONE_TENANT_ID в .env: +# используйте ULID организации из Krona Business Platform, не ID сотрудника. docker compose --profile tools run --rm migrate docker compose up --build app postgres ``` diff --git a/compose.yaml b/compose.yaml index 79f24af..685463b 100644 --- a/compose.yaml +++ b/compose.yaml @@ -13,7 +13,7 @@ services: PLATFORM_JWT_ISSUER: ${PLATFORM_JWT_ISSUER:-} PLATFORM_JWT_AUDIENCE: ${PLATFORM_JWT_AUDIENCE:-} PLATFORM_JWKS_URL: ${PLATFORM_JWKS_URL:-} - STANDALONE_TENANT_ID: ${STANDALONE_TENANT_ID:-01J00000000000000000000000} + STANDALONE_TENANT_ID: ${STANDALONE_TENANT_ID:?Set STANDALONE_TENANT_ID to the platform organization ID} STANDALONE_ACTOR_ID: ${STANDALONE_ACTOR_ID:-01J00000000000000000000001} ports: ['8080:8080'] depends_on: diff --git a/deploy/pilot/setup.sh b/deploy/pilot/setup.sh index d74313d..69c3cca 100644 --- a/deploy/pilot/setup.sh +++ b/deploy/pilot/setup.sh @@ -5,7 +5,20 @@ cd "$(dirname "$0")" umask 077 mkdir -p secrets +validate_tenant() { + : "${STANDALONE_TENANT_ID:?Set STANDALONE_TENANT_ID to the platform organization ID before setup}" + if ! printf '%s\n' "$STANDALONE_TENANT_ID" | LC_ALL=C grep -Eq '^[0-9A-HJKMNP-TV-Z]{26}$'; then + echo 'STANDALONE_TENANT_ID must be a 26-character ULID' >&2 + exit 1 + fi + if [ "$STANDALONE_TENANT_ID" = '01J00000000000000000000000' ]; then + echo 'The development placeholder cannot be used for a pilot organization' >&2 + exit 1 + fi +} + if [ ! -f .env ]; then + validate_tenant migration_password="$(openssl rand -hex 32)" app_password="$(openssl rand -hex 32)" manager_token="$(openssl rand -hex 32)" @@ -17,7 +30,7 @@ if [ ! -f .env ]; then printf 'STANDALONE_MANAGER_TOKEN=%s\n' "$manager_token" printf 'STANDALONE_TERMINAL_TOKEN=%s\n' "$terminal_token" printf 'HTTPS_PFX_PASSPHRASE=%s\n' "$pfx_passphrase" - printf 'STANDALONE_TENANT_ID=01J00000000000000000000000\n' + printf 'STANDALONE_TENANT_ID=%s\n' "$STANDALONE_TENANT_ID" printf 'STANDALONE_ACTOR_ID=01J00000000000000000000001\n' printf 'STANDALONE_TERMINAL_ACTOR_ID=01J00000000000000000000002\n' } > .env @@ -25,6 +38,9 @@ if [ ! -f .env ]; then printf '%s\n' "$terminal_token" > secrets/terminal.token fi +. ./.env +validate_tenant + if ! grep -q '^VAPID_PUBLIC_KEY=' .env; then docker run --rm \ -v "$(cd ../.. && pwd):/app:ro" \ @@ -34,6 +50,7 @@ if ! grep -q '^VAPID_PUBLIC_KEY=' .env; then fi . ./.env +validate_tenant if [ ! -f secrets/staff.pfx ]; then openssl req -x509 -newkey rsa:3072 -sha256 -days 365 -nodes \ -keyout secrets/staff.key -out secrets/staff.crt \ diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 729734f..9cec7c7 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -1,5 +1,7 @@ # Развёртывание +После копирования `.env.example` обязательно заполните `STANDALONE_TENANT_ID` идентификатором организации (ULID) из Krona Business Platform. Значения по умолчанию нет; прежняя заглушка `01J00000000000000000000000` не подходит для Compose или пилота. Для первого запуска `deploy/pilot/setup.sh` передайте этот ID через переменную окружения: установщик проверит его и сохранит в своём `.env`. При повторном запуске проверяется существующий `.env`. Для уже заполненного пилота нельзя просто заменить ID: сначала нужна резервная копия, проверка восстановления и перенос tenant-данных. + Локально скопируйте `.env.example` в `.env` и выполните `docker compose up --build`. Compose последовательно проверит PostgreSQL, создаст или обновит отдельную роль `krona_staff_app` с `NOSUPERUSER NOBYPASSRLS`, выполнит миграции ролью `krona_migrator` и только затем запустит приложение. Runtime-контейнер не получает пароль или URL мигратора. Redis и object storage не включаются до появления реального сценария очередей или файлов. Для обновления старого Docker volume, созданного версией с ролью `krona_staff`, задайте в `.env` `POSTGRES_MIGRATION_USER=krona_staff` и прежний `POSTGRES_MIGRATION_PASSWORD`, затем выполните обычный `docker compose up --build`. Сохраняйте эту роль владельцем миграций для данного volume: автоматического переименования владельца существующей базы нет, а перенос владения требует отдельного DBA-плана с повторной настройкой default privileges. diff --git a/src/config.ts b/src/config.ts index ad74520..139af71 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,5 +1,8 @@ import { z } from 'zod'; +const PLACEHOLDER_TENANT_ID = '01J00000000000000000000000'; +const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1']); + const configSchema = z.object({ NODE_ENV: z.enum(['development', 'test', 'production']).default('development'), PORT: z.coerce.number().int().min(1).max(65535).default(8080), @@ -10,7 +13,10 @@ const configSchema = z.object({ STANDALONE_MANAGER_TOKEN: emptyStringAsUndefined(z.string().min(32).optional()), STANDALONE_TERMINAL_TOKEN: emptyStringAsUndefined(z.string().min(32).optional()), STANDALONE_TERMINAL_ACTOR_ID: emptyStringAsUndefined( - z.string().regex(/^[0-9A-HJKMNP-TV-Z]{26}$/).optional(), + z + .string() + .regex(/^[0-9A-HJKMNP-TV-Z]{26}$/) + .optional(), ), HTTPS_PFX_PATH: emptyStringAsUndefined(z.string().optional()), HTTPS_PFX_PASSPHRASE: emptyStringAsUndefined(z.string().min(16).optional()), @@ -20,7 +26,9 @@ const configSchema = z.object({ PLATFORM_JWT_ISSUER: optionalUrl(), PLATFORM_JWT_AUDIENCE: emptyStringAsUndefined(z.string().optional()), PLATFORM_JWKS_URL: optionalUrl(), - STANDALONE_TENANT_ID: z.string().regex(/^[0-9A-HJKMNP-TV-Z]{26}$/), + STANDALONE_TENANT_ID: z + .string({ error: 'STANDALONE_TENANT_ID is required and must be a ULID' }) + .regex(/^[0-9A-HJKMNP-TV-Z]{26}$/), STANDALONE_ACTOR_ID: z.string().regex(/^[0-9A-HJKMNP-TV-Z]{26}$/), VAPID_PUBLIC_KEY: emptyStringAsUndefined(z.string().optional()), VAPID_PRIVATE_KEY: emptyStringAsUndefined(z.string().optional()), @@ -39,6 +47,14 @@ export type AppConfig = z.infer; export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig { const config = configSchema.parse(env); + if ( + config.STANDALONE_TENANT_ID === PLACEHOLDER_TENANT_ID && + !(config.NODE_ENV === 'development' && LOOPBACK_HOSTS.has(config.HOST.toLowerCase())) + ) { + throw new Error( + 'STANDALONE_TENANT_ID uses the development placeholder, which is allowed only in development on loopback', + ); + } if (Boolean(config.HTTPS_PFX_PATH) !== Boolean(config.HTTPS_PFX_PASSPHRASE)) { throw new Error('HTTPS_PFX_PATH and HTTPS_PFX_PASSPHRASE must be configured together'); } diff --git a/tests/integration/health.test.ts b/tests/integration/health.test.ts index 7012dcd..a4cac2f 100644 --- a/tests/integration/health.test.ts +++ b/tests/integration/health.test.ts @@ -4,7 +4,7 @@ import { loadConfig } from '../../src/config.js'; const config = loadConfig({ DATABASE_URL: 'postgres://localhost/staff', - STANDALONE_TENANT_ID: '01J00000000000000000000000', + STANDALONE_TENANT_ID: '01ARZ3NDEKTSV4RRFFQ69G5FAV', STANDALONE_ACTOR_ID: '01J00000000000000000000001', LOG_LEVEL: 'silent', NODE_ENV: 'test', diff --git a/tests/integration/openapi-runtime.test.ts b/tests/integration/openapi-runtime.test.ts index 6092743..f9aa7ec 100644 --- a/tests/integration/openapi-runtime.test.ts +++ b/tests/integration/openapi-runtime.test.ts @@ -25,7 +25,7 @@ function assertOpenApiDocument(value: unknown): asserts value is OpenApiDocument const config = loadConfig({ DATABASE_URL: 'postgres://localhost/staff', - STANDALONE_TENANT_ID: '01J00000000000000000000000', + STANDALONE_TENANT_ID: '01ARZ3NDEKTSV4RRFFQ69G5FAV', STANDALONE_ACTOR_ID: '01J00000000000000000000001', LOG_LEVEL: 'silent', NODE_ENV: 'test', diff --git a/tests/integration/portal.test.ts b/tests/integration/portal.test.ts index f11045a..dda8db2 100644 --- a/tests/integration/portal.test.ts +++ b/tests/integration/portal.test.ts @@ -4,7 +4,7 @@ import { loadConfig } from '../../src/config.js'; const base = { DATABASE_URL: 'postgres://localhost/staff', - STANDALONE_TENANT_ID: '01J00000000000000000000000', + STANDALONE_TENANT_ID: '01ARZ3NDEKTSV4RRFFQ69G5FAV', STANDALONE_ACTOR_ID: '01J00000000000000000000001', LOG_LEVEL: 'silent', NODE_ENV: 'test', diff --git a/tests/unit/config.test.ts b/tests/unit/config.test.ts index 94ff9df..84e54cc 100644 --- a/tests/unit/config.test.ts +++ b/tests/unit/config.test.ts @@ -3,7 +3,7 @@ import { loadConfig } from '../../src/config.js'; const base = { DATABASE_URL: 'postgres://localhost/staff', - STANDALONE_TENANT_ID: '01J00000000000000000000000', + STANDALONE_TENANT_ID: '01ARZ3NDEKTSV4RRFFQ69G5FAV', STANDALONE_ACTOR_ID: '01J00000000000000000000001', }; @@ -18,6 +18,33 @@ describe('runtime modes', () => { expect(() => loadConfig({ ...base, NODE_ENV: 'production' })).toThrow( /standalone is not allowed/, )); + it('requires a standalone tenant ID', () => { + expect(() => loadConfig({ ...base, STANDALONE_TENANT_ID: undefined })).toThrow( + /STANDALONE_TENANT_ID/, + ); + }); + it('allows the placeholder only in development on loopback', () => { + expect( + loadConfig({ + ...base, + NODE_ENV: 'development', + HOST: '127.0.0.1', + STANDALONE_TENANT_ID: '01J00000000000000000000000', + }).STANDALONE_TENANT_ID, + ).toBe('01J00000000000000000000000'); + }); + it('rejects the placeholder in test mode and development on a non-loopback host', () => { + const placeholder = { + ...base, + STANDALONE_TENANT_ID: '01J00000000000000000000000', + }; + expect(() => loadConfig({ ...placeholder, NODE_ENV: 'test' })).toThrow( + /allowed only in development on loopback/, + ); + expect(() => loadConfig({ ...placeholder, NODE_ENV: 'development', HOST: '0.0.0.0' })).toThrow( + /allowed only in development on loopback/, + ); + }); it('treats empty optional platform variables as absent in compose', () => expect( loadConfig({ @@ -29,12 +56,12 @@ describe('runtime modes', () => { }).PLATFORM_GATEWAY_URL, ).toBeUndefined()); it('requires VAPID keys to be configured together if provided', () => { - expect(() => - loadConfig({ ...base, VAPID_PUBLIC_KEY: 'pubkey' }), - ).toThrow(/VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY must be configured together/); - expect(() => - loadConfig({ ...base, VAPID_PRIVATE_KEY: 'privkey' }), - ).toThrow(/VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY must be configured together/); + expect(() => loadConfig({ ...base, VAPID_PUBLIC_KEY: 'pubkey' })).toThrow( + /VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY must be configured together/, + ); + expect(() => loadConfig({ ...base, VAPID_PRIVATE_KEY: 'privkey' })).toThrow( + /VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY must be configured together/, + ); const config = loadConfig({ ...base, VAPID_PUBLIC_KEY: 'pubkey', @@ -46,4 +73,3 @@ describe('runtime modes', () => { expect(config.VAPID_SUBJECT).toBe('mailto:ops@krona.local'); }); }); -