Secret store
Store credentials that the application must read back through secrets:
import { secrets } from "@warlock.js/core";// Also available from "@warlock.js/core/secrets".
await secrets.set("vendor-token", "a-credential-from-your-provider");const token = await secrets.get("vendor-token"); // string | undefinedconst configured = await secrets.has("vendor-token"); // booleanconst removed = await secrets.remove("vendor-token"); // booleanAll four methods are asynchronous. Missing values return undefined; removing a missing value returns false. Empty strings are valid secrets and has() returns true for them. Names are nonempty strings of at most 256 UTF-16 code units, case-sensitive, and never used as filesystem paths.
Storage is prepared on first use. There is no preparation command, connector, setup step or restart after setting a credential. Importing the module does not import a native addon or create files.
App identity
Section titled “App identity”By default, the namespace combines app.appName (or the project directory name) with a SHA-256 hash of the absolute project root. Separate installations therefore have separate credentials even when they share an app name. Processes in the same installation share persistent credentials.
For deployments whose release directory changes, choose a stable, unique namespace in src/config/secrets.ts. Include the deployment environment when production and staging share an OS account:
import type { SecretsConfigurations } from "@warlock.js/core";
export default { namespace: "sample-app-production", // directory: "/srv/sample-app/private-secrets", // optional; defaults to storage/secrets} satisfies SecretsConfigurations;Changing the namespace or encryption key does not migrate existing credentials. Keep the file root outside public and upload directories, beneath a trusted parent owned by the deployment account. Back up encrypted files together with a separately secured copy of the app encryption key.
Backend order
Section titled “Backend order”Inspect the active store without retrieving credential material:
import { secrets, SecretStore, MemorySecretBackend } from "@warlock.js/core/secrets";import type { SecretBackendType } from "@warlock.js/core/secrets";
const active: SecretBackendType = await secrets.backend(); // "keychain" | "file" | "memory"const store = new SecretStore({ namespace: "unit-test", backend: new MemorySecretBackend() });await store.backend(); // "memory"Both secrets.backend() and SecretStore.backend() are asynchronous. They use
core’s existing selection and the same memoized keychain availability probe as
get/set/remove; inspecting an unselected store triggers that lazy selection.
Concurrent inspection and credential operations share one probe. The result stays
stable for that store, even if a selected keychain later fails; failures do not
silently switch it to files. A new facade configuration can select a different store.
secrets.driver takes precedence over SECRETS_DRIVER. With "auto", an available
keychain reports "keychain"; a missing peer or failing initial probe reports
"file". This reports storage selection, not the source of an individual value:
scoped environment-variable lookups do not change it, and there is no
"environment" backend. Reporting "file" does not create files, read credentials
or validate the encryption key or filesystem; later file operations may still fail.
The configured memory driver, secretsFake and explicit MemorySecretBackend
instances report "memory" without a keychain probe. Explicit backends bypass
driver configuration and environment lookup. A custom SecretBackend may declare
readonly type: SecretBackendType to identify its storage; existing injected
backends without this metadata report "memory" as isolated test overrides.
Core’s explicit file/keychain backend instances report their respective types.
Reads use:
- The OS keychain, if available on first use.
- The exact namespace-scoped environment variable.
- An encrypted local file.
The first operation shares one lazy keychain availability probe across concurrent calls. If the optional peer cannot load or the initial probe fails, the store uses environment/files. A selected keychain stays selected for that store’s lifetime: if it later locks or becomes inaccessible, operations throw UNAVAILABLE instead of silently writing a divergent file copy. No vendor error text or cause is retained. Keep backend availability consistent between deployments; credentials are not automatically reconciled across independent stores writing different backends.
OS keychain
Section titled “OS keychain”Install the optional peer when OS storage is wanted:
npm install @napi-rs/keyringCore uses AsyncEntry(service, username, options), getPassword(), setPassword() and deletePassword(). The service is warlock:<namespace>. On Linux it requires linux.store: "secret-service" to avoid the library’s in-memory keyutils fallback, which does not survive reboot. Other platforms use the library’s native store.
These APIs and Linux persistence behavior were verified against the published 2.1.1 declarations and the official README on 2026-10-19. A missing desktop session, locked keychain, or unavailable native module needs no manual preparation step: an initial failure selects the fallback automatically.
Environment (read-only)
Section titled “Environment (read-only)”Use secrets.environmentName(name) to obtain the exact variable name for your configured namespace. It is WARLOCK_SECRET_<namespace UTF-8 hex>_<name UTF-8 hex> with uppercase hexadecimal characters, avoiding collisions between punctuation, case and namespace boundaries.
import { secrets } from "@warlock.js/core/secrets";
const variableName = secrets.environmentName("vendor-token");// Supply that variable through your deployment's environment configuration.Only the exact scoped variable is read; an unscoped VENDOR_TOKEN is not imported. Environment values, including empty strings, cannot be changed or shadowed through this API: both set() and remove() throw READ_ONLY whenever the variable is present, even if a keychain value currently takes precedence. Change the deployment environment to change it.
Encrypted files
Section titled “Encrypted files”File writes require the existing encryption.key configuration: exactly 64 hexadecimal characters (32 bytes), conventionally supplied by APP_ENCRYPTION_KEY. See Encryption. No plaintext fallback or automatically generated key file is created when the key is absent.
The file backend derives a separate 32-byte key per namespace with HKDF-SHA256, using the namespace as salt and warlock:secrets:v1 as the derivation context. It encrypts with AES-256-GCM, a fresh 12-byte IV, and authenticated data binding the namespace and secret name. Core’s general encrypt() helper reads global configuration directly; this backend uses Node crypto so it can derive its key without mutating app encryption settings.
The default root is storage/secrets, with a hashed subdirectory per namespace and a hashed filename per name. Both directories are 0700, files are 0600; existing owned entries have their modes tightened. Symlinks, multiply linked files, non-regular files and entries owned by another user are rejected. Writes use an exclusively created temporary file, sync it, and rename it over the destination. Different keys can be written concurrently; concurrent writes to the same key use the last completed rename. The on-disk payload is a version byte (1), IV, 16-byte authentication tag, then ciphertext.
Successful keychain writes remove an older file copy of the same secret. remove() clears both writable layers so a fallback copy cannot reappear; a failure can leave a partially completed operation, so retry after resolving the error.
Windows file fallback fails with UNSAFE_FILE: Node’s Windows mode handling cannot enforce owner-only ACLs. Use the OS keychain or scoped environment variables there. The file implementation uses Node’s documented crypto APIs and filesystem operations, verified for the Node 22 line.
Errors and logging
Section titled “Errors and logging”Catch SecretStoreError and inspect its typed code:
import { secrets, SecretStoreError } from "@warlock.js/core/secrets";
try { await secrets.set("vendor-token", "replacement-credential");} catch (error) { if (error instanceof SecretStoreError && error.code === "READ_ONLY") { // Tell the operator to update the deployment environment. } else { throw error; }}| Code | Meaning |
|---|---|
INVALID_NAME, INVALID_VALUE, INVALID_NAMESPACE | Invalid API input |
READ_ONLY | A scoped environment variable supplies this secret |
KEY_REQUIRED | File encryption key is missing or malformed |
UNAVAILABLE | Backend, filesystem or optional custom backend failed |
UNSAFE_FILE | Ownership, entry type or permissions cannot be secured |
CORRUPT_FILE | Unsupported/truncated payload, wrong key or authentication failure |
The store never logs values. It registers names, environment keys and values read or submitted with @warlock.js/logger. Every logger instance then masks registered keys and literal values, including interpolated messages, error messages/stacks/causes and channel error reports. Replaced and removed values remain masked for the process lifetime. Custom redaction censors cannot disable this safety floor.
Masking starts when a name/value is encountered and retains credentials in process memory. It does not cover direct console calls, third-party loggers, earlier messages, encoded/transformed variants or credentials never read by the store. Continue to keep credentials out of logs. See Logger redaction.
Tests and separate instances
Section titled “Tests and separate instances”The standard test kit installs secretsFake by default. It replaces the secrets facade with isolated namespace-aware memory storage, bypassing keychain, environment and files. Opt out through tests.fakes: { secrets: false } only when deliberately exercising real storage. Merely setting NODE_ENV=test without installing the test kit does not replace storage.
import { secrets } from "@warlock.js/core";import { secretsFake } from "@warlock.js/core/tests";
secretsFake.clear(); // after setupTest() or explicit secretsFake.install()await secrets.set("vendor-token", "test-credential");await secretsFake.assertHas("vendor-token");await secretsFake.assertStored("vendor-token", "test-credential");await secrets.remove("vendor-token");await secretsFake.assertMissing("vendor-token");Each fake lives in its process; HTTP server and worker memory stores are separate. Fake assertions never include values in their error messages. Use clear() between cases; setup/teardown installs/restores the facade automatically.
For unit tests without framework bootstrap, or multiple explicit app instances, construct a store:
import { MemorySecretBackend, SecretStore } from "@warlock.js/core/secrets";
const memory = new MemorySecretBackend();const first = new SecretStore({ namespace: "first-app", backend: memory });const second = new SecretStore({ namespace: "second-app", backend: memory });await first.set("vendor-token", "test-credential");await second.has("vendor-token"); // falsememory.clear();SecretStoreOptions accepts namespace, optional directory, optional encryptionKey (a 64-hex string or lazy callback), and optional backend. A SecretBackend implements async get(namespace, name), set(namespace, name, value) and remove(namespace, name). Explicit backends bypass all default layers; the fake affects only the shared facade. Custom backend errors are replaced with safe typed errors.
Built-app tests with the memory driver
Section titled “Built-app tests with the memory driver”Run the built server with SECRETS_DRIVER=memory, or select the driver in
src/config/secrets.ts. Explicit config takes precedence over the environment;
"auto" (the default) keeps keychain/environment/file behavior. Unknown driver
names fail with SecretStoreError.code === "INVALID_DRIVER".
import type { SecretsConfigurations } from "@warlock.js/core";
export default { namespace: "browser-test-server", driver: "memory", allowMemoryInProduction: true,} satisfies SecretsConfigurations;allowMemoryInProduction: true is required only when the test server runs with
NODE_ENV=production. Without it, memory selection fails closed with
MEMORY_IN_PRODUCTION; a built bundle does not bypass this check. Set this
option explicitly in the test deployment configuration. SecretStore accepts
the same driver and allowMemoryInProduction options for standalone stores.
The selected memory driver bypasses keychain, scoped environment secrets and
files, and still registers values for log masking. Values are isolated by app
namespace, lost on process exit, and never shared across workers or with a
separate browser-test process. Seed fixtures through application startup or a
test-only endpoint in the server process using the normal secrets.set() API:
import { secrets } from "@warlock.js/core";
await secrets.set("vendor-token", "browser-test-credential");const token = await secrets.get("vendor-token");The normal test harness still installs secretsFake; explicit backend
instances and that fake continue to bypass configured backend selection.