env-master
ENСоздать аккаунт
Разделы документации

SDK для сервисов

@env-master/sdk — то, что подключает сервис. Забирает значения при старте и держит их в памяти процесса, а не в переменных окружения.

npm i @env-master/sdk    # или bun add @env-master/sdk

Пакет опубликован в npm. Вместе с ним ставятся @env-master/shared и @env-master/crypto — они служебные, подключать их напрямую не нужно. Командная строка живёт отдельным пакетом без скоупа: envmaster (см. CLI).

Подключение

import { EnvMasterClient } from "@env-master/sdk";

const em = new EnvMasterClient({
  baseUrl: process.env.ENVMASTER_URL!,
  project: "billing-api",
  environment: "production",
});

await em.loginMachine({
  clientId: process.env.ENVMASTER_CLIENT_ID!,
  clientSecret: process.env.ENVMASTER_CLIENT_SECRET!,
});

const dbPassword = await em.get("DB_PASSWORD");

SDK сам не читает переменные окружения — значения в конструктор и loginMachine передаёте вы. baseUrl принимает адрес как с /api/v1, так и без — суффикс добавляется сам. Транспорт обязан быть HTTPS; http:// принимается только для localhost.

Опции конструктора:

ОпцияПо умолчаниюЧто значит
baseUrl—адрес API, обязательна
project—slug или id проекта, обязательна
environment—slug окружения, обязательна
cacheTtlMs15 минутвремя жизни кэша записей
renewFraction0.75доля TTL access-токена, на которой он продлевается в фоне
fetchFnfetchинъекция транспорта для тестов

Кэш живёт не дольше ближайшего expiresAt среди записей: истёкший секрет не задерживается в памяти.

Аксессор get() — рекомендуемый способ. Запись в process.env тоже возможна (injectIntoEnv()), но требует осознанного вызова и печатает предупреждение: переменные окружения видны через /proc, наследуются дочерними процессами и попадают в crash-дампы.

Как значение доходит до сервиса

Клиент генерирует эфемерную x25519-пару при создании и передаёт публичный ключ при входе. Сервер оборачивает ключ данных окружения под эту публичную часть и отдаёт шифртекст записей — расшифровка происходит уже в вашем процессе (ECIES: x25519 → HKDF-SHA256 → XChaCha20-Poly1305). На этом пути сервер открытого значения не производит вовсе.

Методы

МетодЧто делает
EnvMasterClient.connect(opts)найти учётные данные (файл, обмен токена) и войти
loginMachine(creds)вход и фоновое обновление токена
get(key, { default? })значение ключа, NOT_FOUND если его нет нигде
getVersion(key)значение вместе с номером версии
secrets()все значения как Map
list()записи с метаданными (тип, видимость, версия, expiresAt)
refresh()сбросить кэш и перечитать
getSource(key)откуда взялось значение: сервер, файл или умолчание
checkRequirements(keys?, opts?)отчёт по обязательным ключам, без значений
assertRequirements(keys?, opts?)то же, но падает при REQUIRED_NOT_MET
EnvMasterClient.requiredKeysFromSchemaFile(path)список из env.schema.yml
validateEnvironment(schema)проверить окружение по схеме
getEnvironmentSchema()схема окружения или null
injectIntoEnv({ include? })opt-in запись в process.env, с предупреждением
mask(text)заменить известные значения на ***
dispose()остановить таймер обновления токена

Отдельно от клиента — работа с enrollment-токеном и файлом учётных данных:

ФункцияЧто делает
enroll({ baseUrl, enrollmentToken })обменять одноразовый токен на clientId/clientSecret
readCredentialsFile(path)прочитать файл: учётные данные, токен или «пусто»
writeCredentialsFile(path, creds)записать учётные данные, права 0600
assertCredentialsWritable(path)убедиться, что писать будет куда, — ДО обмена
resolveCredentials(opts)весь порядок целиком; им пользуется connect()

Первый старт по enrollment-токену

const client = await EnvMasterClient.connect({
  baseUrl: process.env.ENVMASTER_URL!,
  project: "billing",
  environment: "prod",
  credentialsFile: "/var/lib/myapp/envmaster.json",
  enrollmentToken: process.env.ENVMASTER_ENROLLMENT_TOKEN,
});

Порядок: готовые учётные данные → файл → обмен токена. Обмен последний, потому что он необратим: токен одноразовый, а clientSecret сервер показывает единожды.

Три правила, которые из этого следуют и которые SDK соблюдает за вас:

Писучесть проверяется до обмена. Иначе однажды получится, что токен потрачен, а сохранить ответ некуда — и сервис заперт навсегда, до выдачи новой identity руками.

Обмен без файла запрещён и требует allowEnrollWithoutFile: true. Учётные данные, никуда не сохранённые, живут до первого перезапуска.

Пути по умолчанию нет. Файл с секретом машины лежит там, где его положили вы. Пустой файл — законное состояние первого запуска и значит «данных пока нет»; испорченный — ошибка, а не пустота.

Enrollment — бутстрап МАШИНЫ, а не процесса. Пять реплик с одним токеном выиграет одна, остальные получат ENROLLMENT_USED — и это правильно: на сервере это тревога о компрометации. Для нескольких реплик выпускайте несколько токенов или используйте universal.

Если ключа нет: умолчания и запасной файл

Не всякий ключ обязан лежать на сервере. Локальная разработка, переходный период, значение, которое незачем централизовать, — для этого есть два запасных слоя.

const em = new EnvMasterClient({
  baseUrl: process.env.ENVMASTER_URL!,
  project: "billing-api",
  environment: "production",
  fallbackFiles: [".env.local", ".env"],   // раньше в списке — выше приоритет
  defaults: { LOG_LEVEL: "info" },
  required: ["DB_PASSWORD", "STRIPE_KEY"], // без них не запускаться
  strict: true,
});

Порядок один и не настраивается: сервер → файлы по порядку → умолчания. Сервер всегда первый. Обратный порядок означал бы, что после ротации на сервере сервис продолжит работать на устаревшем локальном значении, — и централизованное хранилище перестаёт быть источником правды.

Пустая строка — это значение, а не отсутствие. Ключ, который на сервере обнулили осознанно, не подменяется файлом или умолчанием: иначе явное действие администратора молча игнорируется. Пустоту умеет ловить проверка (см. ниже), а не резолвер.

Секрет из файла не берётся. Ключ, который сервер знает как secret, из dotenv-файла не подставляется: иначе защиту чужого значения можно было бы понизить, подложив .env. Снимается явным allowSecretsFromFile: true.

Права файла хуже 0600 — предупреждение в лог, не ошибка: в контейнерах и на Windows биты прав значат не то же самое.

Сервер недоступен целиком — это другое

Отсутствие ключа и отсутствие сервера — разные события, и по умолчанию второе остаётся ошибкой. Молчаливый переход на локальные файлы при мёртвом сервере означает, что прод поедет на значениях разработчика, ротация и отзыв доступа перестанут действовать, а журнал доступа увидит пустоту.

Если офлайн всё же нужен, он включается отдельно и обвешан предохранителями:

onServerUnavailable: "use-fallback",  // по умолчанию "fail"
requiredSource: "any",                // по умолчанию "server"

Пока requiredSource остаётся "server", обязательные ключи принимаются только с сервера — даже в офлайне приложение с заданным required не стартует на локальных значениях. Каждое срабатывание офлайна пишет предупреждение в лог, а офлайн-значения кэшируются на минуту, а не на полный TTL: восстановившийся сервер должен быстро вернуть себе роль источника.

Проверка обязательных ключей

await em.assertRequirements();          // упасть, если чего-то нет
const report = await em.checkRequirements();  // или получить отчёт

Список берётся из первого доступного источника, без слияния: аргумент метода → опция required → локальный env.schema.yml (schemaFile) → схема с сервера. Расхождение локального списка с серверной схемой — предупреждение с перечнем (schemaDrift), а не ошибка: это два разных контракта, «что нужно приложению» и «каким оператор хочет видеть окружение».

В отчёте нет значений. У типов просто нет поля со значением, и в текст сообщений они тоже не попадают: отчёт задуман так, чтобы его можно было печатать в лог целиком.

interface ResolutionReport {
  environment: string;
  ok: boolean;              // нет ни одной проблемы со severity "error"
  requiredCount: number;
  serverAvailable: boolean;
  issues: ResolutionIssue[]; // key, status, severity, message
  schemaDrift?: string[];
  checkedAt: string;
}

Статусы: ok, missing, empty, fallback, default, blocked-kind (секрет, который запрещено брать из файла), unused-default (умолчание, перекрытое источником выше, — признак протухшей заглушки в коде).

Провенанс по умолчанию информационный: ключ, пришедший из файла, не роняет проверку, но виден в отчёте. В CI это ужесточается одним параметром:

await em.assertRequirements(undefined, { acceptFallback: "none", strictEmpty: true });

getSource(key) отвечает на тот же вопрос про один ключ: server, fallback-file с путём или default.

Ошибки

Все ошибки — EnvMasterError с полем code: это либо код ответа API (UNAUTHENTICATED, FORBIDDEN, NOT_FOUND, …), либо один из трёх собственных: NETWORK_ERROR, DECRYPTION_ERROR, NOT_LOGGED_IN, REQUIRED_NOT_MET. Текст ошибки санитизируется: тело ответа сервера наружу не утекает.

На 401 и TOKEN_EXPIRED клиент один раз перелогинивается и повторяет запрос — перезапускать процесс из-за протухшего токена не нужно.

Ротация без перезапуска

Токен обновляется на 75% TTL, кэш записей живёт 15 минут. Чтобы среагировать на новое значение, сравнивайте версию:

const { value, version } = await em.getVersion("DB_PASSWORD");
if (version !== lastSeen) await reconnectPool(value);

Логи

logger.info(em.mask(`connecting as ${user}:${password}`));
// connecting as billing:***

Более длинные значения заменяются первыми — иначе от вложенной подстроки оставался бы хвост.