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 окружения, обязательна |
cacheTtlMs | 15 минут | время жизни кэша записей |
renewFraction | 0.75 | доля TTL access-токена, на которой он продлевается в фоне |
fetchFn | fetch | инъекция транспорта для тестов |
Кэш живёт не дольше ближайшего 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:***
Более длинные значения заменяются первыми — иначе от вложенной подстроки оставался бы хвост.