Files
gitmost/apps/server/src/integrations/environment/environment.validation.ts
T
agent_coder d3a049d176 feat(api-key): core-модуль выдачи и валидации api-ключей + kill-switch
Форк не несёт EE-модуль ee/api-key, поэтому api_key-токен сегодня отвергается на
любом /api/*. Вводим core-модуль:

- ApiKeyRepo (kysely): insert/findById/findByCreator/findAllInWorkspace/
  softDelete/touchLastUsed, фильтр deleted_at IS NULL.
- ApiKeyService.create — mint-then-insert: сперва uuid7 id, затем чеканка JWT
  без exp, затем строка (сбой чеканки → строки нет; потерянный ответ →
  осиротевшая строка видна в list и самолечится). Токен отдаётся один раз, в
  таблице не хранится.
- ApiKeyService.validate — единый валидатор для jwt.strategy И /mcp. Владелец
  истины о сроке/отзыве — строка api_keys, не JWT. Любой определённый негатив
  (нет строки / expired / disabled / workspace mismatch / kill-switch off) →
  единый bare-401 (анти-enumeration); инфра-ошибка пробрасывается (→5xx), не
  маскируется. Без кэша — немедленная ревокация. last_used_at троттлится 1ч,
  fire-and-forget.
- REST create/list/revoke: create — self + UserThrottlerGuard; list/revoke —
  admin (CASL Manage на API) видит/отзывает все ключи воркспейса, член — только
  свои. api_key-принципал отвергается на всех трёх (токен не управляет
  токенами — GitHub-PAT-семантика). Аудит API_KEY_CREATED/DELETED + структурный
  лог с apiKeyId и IP.
- jwt.strategy: прямой вызов ApiKeyService.validate вместо динамического
  EE-require; штампует req.raw.authType='api_key' и apiKeyId для гварда выше.
- Kill-switch API_KEYS_ENABLED: дефолт ON (деплой без переменной не убивает
  агентов), строгий парс @IsIn(['true','false']) (=0/=off падают на boot, а не
  молча значат «включено»), boot-лог состояния. off → validate deny и
  эндпоинты 404.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 00:57:15 +03:00

236 lines
5.8 KiB
TypeScript

import {
IsIn,
IsNotEmpty,
IsNotIn,
IsNumberString,
IsOptional,
IsString,
IsUrl,
MinLength,
ValidateIf,
validateSync,
} from 'class-validator';
import { plainToInstance } from 'class-transformer';
import { IsISO6391 } from '../../common/validators/is-iso6391';
export class EnvironmentVariables {
@IsNotEmpty()
@IsUrl(
{
protocols: ['postgres', 'postgresql'],
require_tld: false,
allow_underscores: true,
},
{ message: 'DATABASE_URL must be a valid postgres connection string' },
)
DATABASE_URL: string;
@IsNotEmpty()
@IsUrl(
{
protocols: ['redis', 'rediss'],
require_tld: false,
allow_underscores: true,
},
{ message: 'REDIS_URL must be a valid redis connection string' },
)
REDIS_URL: string;
@IsOptional()
@IsUrl({ protocols: ['http', 'https'], require_tld: false })
APP_URL: string;
@IsNotEmpty()
@MinLength(32)
@IsNotIn(['REPLACE_WITH_LONG_SECRET'])
APP_SECRET: string;
@IsOptional()
@IsIn(['smtp', 'postmark'])
MAIL_DRIVER: string;
@IsOptional()
@IsIn(['local', 's3', 'azure'])
STORAGE_DRIVER: string;
@IsOptional()
@ValidateIf((obj) => obj.COLLAB_URL != '' && obj.COLLAB_URL != null)
@IsUrl({ protocols: ['http', 'https'], require_tld: false })
COLLAB_URL: string;
@IsOptional()
CLOUD: boolean;
@IsOptional()
@IsUrl(
{ protocols: [], require_tld: true },
{
message:
'SUBDOMAIN_HOST must be a valid FQDN domain without the http protocol. e.g example.com',
},
)
@ValidateIf((obj) => obj.CLOUD === 'true'.toLowerCase())
SUBDOMAIN_HOST: string;
@IsOptional()
@IsIn(['database', 'typesense'])
@IsString()
SEARCH_DRIVER: string;
@IsOptional()
@IsUrl(
{
protocols: ['http', 'https'],
require_tld: false,
allow_underscores: true,
},
{
message:
'TYPESENSE_URL must be a valid typesense url e.g http://localhost:8108',
},
)
@ValidateIf((obj) => obj.SEARCH_DRIVER === 'typesense')
TYPESENSE_URL: string;
@ValidateIf((obj) => obj.SEARCH_DRIVER === 'typesense')
@IsNotEmpty()
@IsString()
TYPESENSE_API_KEY: string;
@IsOptional()
@ValidateIf((obj) => obj.SEARCH_DRIVER === 'typesense')
@IsISO6391()
@IsString()
TYPESENSE_LOCALE: string;
// Agent API-key kill-switch. Optional (absent -> default ON). STRICT: only the
// literals 'true'/'false' are accepted, so `=0`/`=off`/`=False` fail at boot
// instead of being silently read as "enabled" — the switch must actually flip
// when an operator flips it. See EnvironmentService.isApiKeysEnabled.
@IsOptional()
@IsIn(['true', 'false'])
@IsString()
API_KEYS_ENABLED: string;
@IsOptional()
@ValidateIf((obj) => obj.AI_DRIVER)
@IsIn(['openai', 'openai-compatible', 'gemini', 'ollama'])
@IsString()
AI_DRIVER: string;
@IsOptional()
@IsString()
AI_EMBEDDING_MODEL: string;
@ValidateIf((obj) => obj.AI_EMBEDDING_DIMENSION)
@IsIn(['768', '1024', '1536', '2000', '3072'])
@IsString()
AI_EMBEDDING_DIMENSION: string;
@IsOptional()
@ValidateIf((obj) => obj.AI_EMBEDDING_SUPPORTS_MRL)
@IsIn(['true', 'false'])
@IsString()
AI_EMBEDDING_SUPPORTS_MRL: string;
@ValidateIf((obj) => obj.AI_DRIVER)
@IsString()
@IsNotEmpty()
AI_COMPLETION_MODEL: string;
@IsOptional()
@ValidateIf(
(obj) =>
obj.AI_DRIVER && ['openai', 'openai-compatible'].includes(obj.AI_DRIVER),
)
@IsString()
@IsNotEmpty()
OPENAI_API_KEY: string;
@IsOptional()
@ValidateIf(
(obj) =>
obj.AI_DRIVER === 'openai-compatible' ||
(obj.AI_DRIVER === 'openai' && obj.OPENAI_API_URL),
)
@IsUrl({ protocols: ['http', 'https'], require_tld: false })
OPENAI_API_URL: string;
@ValidateIf((obj) => obj.AI_DRIVER && obj.AI_DRIVER === 'gemini')
@IsString()
@IsNotEmpty()
GEMINI_API_KEY: string;
@ValidateIf((obj) => obj.AI_DRIVER && obj.AI_DRIVER === 'ollama')
@IsUrl({ protocols: ['http', 'https'], require_tld: false })
OLLAMA_API_URL: string;
@IsOptional()
@IsIn(['postgres', 'clickhouse'])
@IsString()
EVENT_STORE_DRIVER: string;
@ValidateIf((obj) => obj.EVENT_STORE_DRIVER === 'clickhouse')
@IsNotEmpty()
@IsUrl(
{ protocols: ['http', 'https'], require_tld: false },
{
message:
'CLICKHOUSE_URL must be a valid URL e.g http://user:password@localhost:8123/docmost',
},
)
CLICKHOUSE_URL: string;
// --- Blob sandbox (in-RAM ephemeral blob transfer; see SandboxModule) ---
@IsOptional()
@ValidateIf((obj) => obj.SANDBOX_PUBLIC_URL != '' && obj.SANDBOX_PUBLIC_URL != null)
@IsUrl(
{ protocols: ['http', 'https'], require_tld: false },
{
message:
'SANDBOX_PUBLIC_URL must be a valid http(s) URL reachable by the external blob consumer',
},
)
SANDBOX_PUBLIC_URL: string;
@IsOptional()
@IsNumberString({}, { message: 'SANDBOX_TTL_MS must be an integer (milliseconds)' })
SANDBOX_TTL_MS: string;
@IsOptional()
@IsNumberString({}, { message: 'SANDBOX_MAX_BYTES must be an integer (bytes)' })
SANDBOX_MAX_BYTES: string;
@IsOptional()
@IsNumberString({}, { message: 'SANDBOX_MAX_IMAGE_BYTES must be an integer (bytes)' })
SANDBOX_MAX_IMAGE_BYTES: string;
@IsOptional()
@IsNumberString({}, { message: 'SANDBOX_MAX_TOTAL_BYTES must be an integer (bytes)' })
SANDBOX_MAX_TOTAL_BYTES: string;
}
export function validate(config: Record<string, any>) {
const validatedConfig = plainToInstance(EnvironmentVariables, config);
const errors = validateSync(validatedConfig);
if (errors.length > 0) {
console.error(
'The Environment variables has failed the following validations:',
);
errors.map((error) => {
console.error(JSON.stringify(error.constraints));
});
console.error(
'Please fix the environment variables and try again. Exiting program...',
);
process.exit(1);
}
return validatedConfig;
}