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:
| Option | Default | What it means |
|---|---|---|
baseUrl | — | API address, required |
project | — | project slug or id, required |
environment | — | environment slug, required |
cacheTtlMs | 15 minutes | entry cache lifetime |
renewFraction | 0.75 | fraction of the access token TTL at which it is renewed in the background |
fetchFn | fetch | transport 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
| Method | What 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:
| Function | What 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.