env-master
Documentation sections

SDK for services

@env-master/sdk is what a service integrates. It fetches values at startup and keeps them in process memory, not in environment variables.

npm i @env-master/sdk    # or bun add @env-master/sdk

The package is published on npm. It pulls in @env-master/shared and @env-master/crypto — internal packages you never import directly. The command line ships separately and unscoped: envmaster (see CLI).

Connecting

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");

The SDK does not read environment variables itself — you pass the values into the constructor and loginMachine. baseUrl accepts the address with or without /api/v1 — the suffix is added automatically. The transport must be HTTPS; http:// is accepted only for localhost.

Constructor options:

OptionDefaultWhat it means
baseUrl—API address, required
project—project slug or id, required
environment—environment slug, required
cacheTtlMs15 minutesentry cache lifetime
renewFraction0.75fraction of the access token TTL at which it is renewed in the background
fetchFnfetchtransport injection for tests

The cache lives no longer than the nearest expiresAt among the entries: an expired secret does not linger in memory.

The get() accessor is the recommended way. Writing into process.env is also possible (injectIntoEnv()), but requires a deliberate call and prints a warning: environment variables are visible via /proc, inherited by child processes, and end up in crash dumps.

How a value reaches the service

The client generates an ephemeral x25519 pair on creation and passes the public key at login. The server wraps the environment's data key under that public part and returns the entries' ciphertext — decryption happens in your process (ECIES: x25519 → HKDF-SHA256 → XChaCha20-Poly1305). On this path the server never produces the plaintext value at all.

Methods

MethodWhat it does
EnvMasterClient.connect(opts)find credentials (file, token exchange) and log in
loginMachine(creds)login and background token renewal
get(key, { default? })value of a key, NOT_FOUND if there is none
getVersion(key)value together with the version number
secrets()all values as a Map
list()entries with metadata (type, visibility, version, expiresAt)
refresh()drop the cache and re-read
getSource(key)where the value came from: server, file or default
checkRequirements(keys?, opts?)report on required keys, values excluded
assertRequirements(keys?, opts?)the same, but throws REQUIRED_NOT_MET
EnvMasterClient.requiredKeysFromSchemaFile(path)the list from env.schema.yml
validateEnvironment(schema)check the environment against a schema
getEnvironmentSchema()the environment schema or null
injectIntoEnv({ include? })opt-in write into process.env, with a warning
mask(text)replace known values with ***
dispose()stop the token renewal timer

Separate from the client — the enrollment token and the credentials file:

FunctionWhat it does
enroll({ baseUrl, enrollmentToken })exchange a one-time token for clientId/clientSecret
readCredentialsFile(path)read the file: credentials, a token, or "empty"
writeCredentialsFile(path, creds)write credentials, mode 0600
assertCredentialsWritable(path)make sure there will be somewhere to write — BEFORE the exchange
resolveCredentials(opts)the whole order; connect() uses it

First start with an enrollment token

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

The order is: given credentials → file → token exchange. The exchange comes last because it is irreversible: the token is one-time, and the server shows clientSecret once.

Three rules follow from that, and the SDK keeps them for you:

Writability is checked before the exchange. Otherwise one day the token is spent with nowhere to put the answer — and the service is locked out for good, until a new identity is issued by hand.

An exchange without a file is refused and needs allowEnrollWithoutFile: true. Credentials stored nowhere live until the first restart.

There is no default path. The file holding a machine's secret belongs where you put it. An empty file is a legitimate first-run state meaning "no data yet"; a corrupted one is an error, not emptiness.

Enrollment bootstraps a MACHINE, not a process. With five replicas sharing one token exactly one wins and the rest get ENROLLMENT_USED — which is correct: on the server that is a compromise alert. For several replicas issue several tokens or use universal.

When a key is missing: defaults and fallback files

Not every key has to live on the server. Local development, a transition period, a value not worth centralising — that is what the two fallback layers are for.

const em = new EnvMasterClient({
  baseUrl: process.env.ENVMASTER_URL!,
  project: "billing-api",
  environment: "production",
  fallbackFiles: [".env.local", ".env"],   // earlier in the list wins
  defaults: { LOG_LEVEL: "info" },
  required: ["DB_PASSWORD", "STRIPE_KEY"], // do not start without these
  strict: true,
});

The order is fixed and not configurable: server → files in order → defaults. The server always comes first. The reverse would mean that after a rotation on the server the service keeps running on a stale local value, and the central store stops being the source of truth.

An empty string is a value, not an absence. A key deliberately blanked on the server is not replaced by a file or a default — otherwise an explicit administrator action is silently ignored. Emptiness is caught by the check below, not by the resolver.

Secrets are not taken from files. A key the server knows as secret is never served from a dotenv file: otherwise the protection of someone else's value could be lowered by dropping in a .env. Lift it with allowSecretsFromFile: true.

File permissions looser than 0600 produce a warning, not an error: in containers and on Windows those bits do not mean the same thing.

A server that is down is a different matter

A missing key and a missing server are different events, and by default the second stays an error. Silently switching to local files when the server is down means production runs on a developer's values, rotation and revocation stop working, and the access log sees nothing.

If you do need offline, it is enabled separately and fenced:

onServerUnavailable: "use-fallback",  // default "fail"
requiredSource: "any",                // default "server"

While requiredSource stays "server", required keys are accepted only from the server — even offline, an app with required set will not start on local values. Every offline resolution logs a warning, and offline values are cached for a minute rather than the full TTL: a recovered server should reclaim its role quickly.

Checking required keys

await em.assertRequirements();               // fail if something is missing
const report = await em.checkRequirements(); // or take the report

The list comes from the first available source, without merging: the method argument → the required option → a local env.schema.yml (schemaFile) → the server schema. A mismatch between the local list and the server schema is a warning with a diff (schemaDrift), not an error: these are two different contracts — "what the app needs" and "how the operator wants the environment to look".

The report carries no values. The types simply have no value field, and messages do not contain them either: the report is meant to be printed to a log as is.

interface ResolutionReport {
  environment: string;
  ok: boolean;               // no issue with severity "error"
  requiredCount: number;
  serverAvailable: boolean;
  issues: ResolutionIssue[]; // key, status, severity, message
  schemaDrift?: string[];
  checkedAt: string;
}

Statuses: ok, missing, empty, fallback, default, blocked-kind (a secret that may not come from a file), unused-default (a default shadowed by a higher-priority source — a sign of a stale placeholder in the code).

Provenance is informational by default: a key served from a file does not fail the check but is visible in the report. In CI you tighten that with one option:

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

getSource(key) answers the same question for a single key: server, fallback-file with its path, or default.

Errors

All errors are EnvMasterError with a code field: either an API response code (UNAUTHENTICATED, FORBIDDEN, NOT_FOUND, …) or one of three of its own: NETWORK_ERROR, DECRYPTION_ERROR, NOT_LOGGED_IN, REQUIRED_NOT_MET. The error text is sanitized: the server's response body does not leak out.

On 401 and TOKEN_EXPIRED the client logs in again once and retries the request — there is no need to restart the process over an expired token.

Rotation without a restart

The token is renewed at 75% of its TTL; the entry cache lives 15 minutes. To react to a new value, compare versions:

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

Logs

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

Longer values are replaced first — otherwise a nested substring would leave a tail behind.