@setu-ts/secrets-plugin

Secret management for Setu-TS. Registers an ISecretManager under CAPABILITIES.SECRETS, backed by a pluggable provider with a read-through cache.

Providers:

Providerprovider idBackendDependency
EnvProvider (default)envEnvironment, then optional config snapshotnone (every runtime, incl. Cloudflare Workers)
AwsKmsProvideraws-kmsAWS Secrets Manager (KMS-backed encryption)lazy @aws-sdk/client-secrets-manager (or inject)
GcpSecretManagerProvidergcpGCP Secret Managerlazy @google-cloud/secret-manager (or inject)
AzureKeyVaultProviderazureAzure Key Vaultlazy @azure/keyvault-secrets (or inject)
HashiCorpVaultProvidervaultHashiCorp Vault KV v2 over fetchnone (zero-dependency, Workers-compatible)

No cloud SDK is a hard dependency: a provider either receives an injected client facade through options, or lazily imports the SDK at connect() time (AI_GUIDELINES §12.2). Secrets are never logged.

Installation

import { SecretsPlugin } from '@setu-ts/secrets-plugin';

Install the SDK only for the provider you choose (e.g. deno add npm:@aws-sdk/client-secrets-manager). env and vault need nothing.

Usage

import { createApplication } from '@setu-ts/kernel';
import { RuntimePlugin } from '@setu-ts/runtime';
import { SecretsPlugin } from '@setu-ts/secrets-plugin';
import type { ISecretManager } from '@setu-ts/common';

const app = createApplication({
  plugins: [
    RuntimePlugin(),
    // Environment variables (default) — reads e.g. DATABASE_PASSWORD.
    SecretsPlugin(),
  ],
});
await app.start();

const secrets = app.services.get<ISecretManager>('secrets');
const dbPassword = await secrets.get('database/password'); // → env DATABASE_PASSWORD
const exists = await secrets.has('database/password');

Environment secrets and .env

EnvProvider reads IRuntimeServices.env first, then falls back to CAPABILITIES.CONFIG when registered and the environment has no string value for the key. Its key mapping prepends options.prefix (default ''), uppercases, then replaces /, -, and . with _: database/password with prefix APP_ reads APP_DATABASE_PASSWORD from either source. Only string config values are accepted; numbers, booleans and objects are treated as missing, without coercion. Environment strings, including empty strings and ${NAME} references, are returned unchanged.

Register ConfigPlugin({ envFilePath: '.env' }) to make .env-only secrets available. The optional CONFIG dependency orders its provider before secrets regardless of their order in the plugin list, including replacement CONFIG providers. The fallback defaults to true; opt out with SecretsPlugin({ options: { configFallback: false } }). A non-boolean options.configFallback throws at plugin construction for the env provider; other providers ignore it. With no CONFIG capability, reads silently use the environment only.

Config values retain ConfigPlugin’s loading rules:

  • A validation schema must declare the secret key (or pass unknown keys through); an undeclared key stripped by a schema is unavailable and secrets.get() throws “Secret not found”.
  • ${NAME} references in .env-only secrets are expanded by default. For literal references in secrets containing $, use ConfigPlugin({ expandVariables: false }) or supply the secret in the real environment. The environment path always returns its string unchanged.

The fallback also applies to SessionPlugin’s SESSION_SECRET and full-stack starter apps, which always compose SecretsPlugin. A consequence to check: SessionPlugin used to refuse to start when no SESSION_SECRET existed outside a dotenv file; a dotenv file present at runtime now satisfies that check. If a deployment relied on the refusal, keep dotenv files out of production or set configFallback: false. Found values use the normal options.cacheTtl read cache; missing values are never cached. EnvProvider remains read-only and its health is lifecycle-only.

Dotenv files are for local development only. In production, inject variables through the orchestrator or secret store; see Dotenv files never reach an image. For local development, loading the process environment directly is an optional alternative to ConfigPlugin’s fallback:

# Node: tsx passes these Node flags through.
npx tsx --env-file=.env main.ts
# Skip an absent file instead:
npx tsx --env-file-if-exists=.env main.ts
# Deno: load it before the runtime reads the environment.
deno run --allow-env --allow-read --allow-net --env-file=.env main.ts
# Bun loads .env into the process environment automatically.
bun run main.ts

Keep .env and local secret files out of version control.

Workers have no ambient process environment: only string entries of the env record passed to RuntimePlugin({ env }) reach runtime.env; without it the map is empty. Pass the platform’s bindings record so deployed secrets and local development bindings reach EnvProvider. Provisioning a secret alone does not pass that record to the runtime:

import { env } from 'cloudflare:workers';
import { createApplication } from '@setu-ts/kernel';
import { RuntimePlugin } from '@setu-ts/runtime';
import { SecretsPlugin } from '@setu-ts/secrets-plugin';

const app = createApplication({ plugins: [RuntimePlugin({ env }), SecretsPlugin()] });
await app.start();
export default { fetch: (request: Request): Promise<Response> => app.fetch(request) };

On Workers, ConfigPlugin normally sees the same string bindings as the environment, so the fallback adds no keys; envFilePath requires filesystem support and cannot load files there. An injected ConfigPlugin({ instance }) or another IConfig provider can supply additional keys, including the snapshot used by createFullStackAppFromConfig.

HashiCorp Vault

SecretsPlugin({
  provider: 'vault',
  options: {
    address: 'https://vault.example.com',
    token: vaultToken,
    mount: 'secret', // KV v2 mount (default)
  },
});

Vault secrets store the string under the value field of the KV item; rotate writes a new version.

Every Vault request is bounded by options.requestTimeoutMs (default 5000, 0 disables). A request that fails on the network or does not answer in time — the response body included, so a server that sends headers and then stalls counts as not answering — rejects with SecretProviderUnavailableError, which carries a 503 Service Unavailable status hint, so an application running errorHandler answers an outage with a retryable 503 instead of a masked 500. The transport error is kept as cause for the log and never reaches the response body. A Vault that answers with an HTTP error or a body that is not JSON is reachable, so it keeps the existing handling (a plain Error; an unparseable body is one too): 404 reads as null, any other error status rejects with a plain Error. A value outside 0–2147483647 is refused with a RangeError when the application starts.

Since M101a a secret name is checked and encoded before it reaches the URL. Each /-separated segment is percent-encoded, so app db/pass is read from app%20db/pass, and a name with an empty, . or .. segment (../../sys/health, a//b, a/) is refused before any request is sent: it would otherwise address a Vault endpoint outside the mount with the token attached. A name longer than 4096 characters once encoded is refused the same way, as an input error rather than an outage. A read whose response body exceeds 1 MiB is refused with a plain Error and the rest of the body is cancelled; the body is copied into one buffer as it arrives, so the memory a read holds is bounded by that 1 MiB whatever size of chunk the dependency sends. A name quoted in an error message has its C0 and C1 control characters, DEL, and the Unicode line and paragraph separators written as \uXXXX, so a name cannot start a new log line, and only its first 256 characters are quoted.

AWS Secrets Manager (KMS-backed)

SecretsPlugin({
  provider: 'aws-kms',
  options: { region: 'us-east-1' /* or inject `client` */ },
});

“KMS” here denotes AWS Secrets Manager’s KMS-backed encryption: KMS alone cannot store/retrieve named secrets by path, so get/rotate go through Secrets Manager (which encrypts with KMS).

Injecting a client (testing / custom credentials)

Every cloud provider accepts a structural client facade, bypassing the lazy import:

import { SecretsPlugin } from '@setu-ts/secrets-plugin';

// Your own fetchers, shaped to the facade the plugin expects.
const myGet = (id: string): Promise<string | null> => Promise.resolve(null);
const myPut = (id: string, value: string): Promise<void> => Promise.resolve();

SecretsPlugin({
  provider: 'aws-kms',
  options: {
    client: {
      getSecretValue: (id) => myGet(id), // Promise<string | null>
      putSecretValue: (id, v) => myPut(id, v), // Promise<void>
    },
  },
});

Options

OptionProviderDescription
provider—'env' (default), 'aws-kms', 'gcp', 'azure', 'vault'.
options.cacheTtlallRead-cache TTL in seconds; 0 disables. Default 300.
options.prefixenvPrefix prepended to the derived env key.
options.region / accessKeyId / secretAccessKeyaws-kmsAWS client config (ignored when client injected).
options.endpointaws-kmsLocalStack / emulator / private endpoint for the lazy client (ignored when client injected).
options.projectIdgcpGCP project id for resource paths.
options.endpointgcpPrivate/regional endpoint for the lazy client as host or host:port, no scheme; TLS only (ignored when client injected).
options.vaultUrlazureKey Vault URL.
options.address / token / mountvaultVault server address, token, and KV mount.
options.clientaws-kms/gcp/azureInjected client facade (bypasses lazy import).
options.httpvaultInjected fetch-shaped function (defaults to global fetch).
options.requestTimeoutMsvaultBound on one Vault request in ms; 0 disables. Default 5000. An outage rejects with SecretProviderUnavailableError (503).

API

  • SecretsPlugin(options?) — plugin factory.
  • SecretsService — the ISecretManager implementation (provider + read cache).
  • EnvProvider, AwsKmsProvider, GcpSecretManagerProvider, AzureKeyVaultProvider, HashiCorpVaultProvider — provider classes.
  • ReadOnlySecretProviderError — the read-only refusal, answered 501.
  • SecretProviderUnavailableError — a provider that cannot be reached, answered 503.
  • IAwsSecretsClient, IGcpSecretsClient, IAzureSecretsClient, IVaultHttp — structural injection types.

EnvProvider is read-only: rotate() (and provider set) rejects with ReadOnlySecretProviderError, because environment variables cannot be mutated at runtime. The error is branded 501 Not Implemented (X20-2): nothing is wrong with the caller — the configured provider cannot store or rotate secrets, permanently — so an application running errorHandler answers the write attempt with a 501 in its configured format instead of a masked 500. Both public write paths inherit the refusal from the one set throw site, because SecretsService.rotate() delegates to provider.set().

What set() means per provider

ISecretManager exposes one write method — rotate(), which delegates to the provider’s set() — but the method means a different thing on every provider. A bootstrap that reads a secret, finds it absent, and writes a default works on some providers and fails on others, and the failure names not found — which reads as a problem with the secret being written rather than with the API being used. The divergence is documented here rather than removed: making set() mean one thing is the ROADMAP’s X20-3, a contract question deliberately ungrouped from this one.

Providerset() on an existing secretset() on a name that does not existVerified against a real backend
envrejectsrejectsyes — readonly-status.test.ts pins the 501 refusal
vault (KV v2)writes a new versioncreates the secretyes — real HashiCorp Vault (X20)
aws-kmswrites a new versionrefuses — PutSecretValueCommand requires the secret to exist; the provider has no create pathyes — real LocalStack (X20 addendum)
gcpadds a new versionrefuses — addSecretVersion requires the secret container to pre-existno — no local emulator; stated, not claimed
azurewrites a new versioncreates the secretno — no local emulator; stated, not claimed

The second column is the finding: an application that assumes set() creates is correct for vault and azure, and wrong for aws-kms and gcp. On those two, create the secret out of band (or through the provider’s own client facade, which bypasses the plugin’s write path) before the first set().

Exports

ExportKind
SecretsPluginfunction
AwsKmsProviderclass
AzureKeyVaultProviderclass
EnvProviderclass
GcpSecretManagerProviderclass
HashiCorpVaultProviderclass
ReadOnlySecretProviderErrorclass
SecretProviderUnavailableErrorclass
SecretsServiceclass
AwsKmsProviderOptionsinterface
AzureKeyVaultProviderOptionsinterface
GcpSecretManagerProviderOptionsinterface
HashiCorpVaultProviderOptionsinterface
IAwsSecretsClientinterface
IAzureSecretsClientinterface
IGcpSecretsClientinterface
ISecretManagerinterface
SecretsPluginOptionsinterface
SecretsProviderOptionsinterface
SecretsServiceOptionsinterface
IVaultHttptype
SecretsProviderTypetype

Generated from the package barrel by pnpm run docs:exports; pnpm run check:docs fails when it drifts.

Full API

Every export and option is documented in PUBLIC_API.md.

API reference

SecretsPlugin() (@setu-ts/secrets-plugin)

Provides secret management: registers an ISecretManager under CAPABILITIES.SECRETS, backed by a pluggable provider with a monotonic-clock read-through cache. The default provider is 'env' (zero-dependency, every runtime). No cloud SDK is a hard dependency — each cloud provider accepts an injected client facade or lazily imports its SDK (AI_GUIDELINES §12.2). Secret values are never logged.

Registration

import { SecretsPlugin } from '@setu-ts/secrets-plugin';

// Environment variables (default provider)
app.register(SecretsPlugin());

// HashiCorp Vault (KV v2, over fetch — zero dependency)
app.register(
  SecretsPlugin({
    provider: 'vault',
    options: { address: 'https://vault.example.com', token: vaultToken, mount: 'secret' },
  }),
);

// AWS Secrets Manager (KMS-backed)
app.register(SecretsPlugin({ provider: 'aws-kms', options: { region: 'us-east-1' } }));

provider: 'aws-kms' retrieves named secrets from AWS Secrets Manager, which encrypts values with AWS KMS. KMS alone cannot store/retrieve named secrets by path, so get/rotate go through Secrets Manager.

Usage

import { CAPABILITIES } from '@setu-ts/common';
import type { ISecretManager } from '@setu-ts/common';

const secrets = ctx.services.get<ISecretManager>(CAPABILITIES.SECRETS);
const dbPassword = await secrets.get('database/password'); // env: DATABASE_PASSWORD
const exists = await secrets.has('database/password');
await secrets.rotate('database/password', newPassword); // throws for the env provider

Options

OptionProviderDescription
provider—'env' (default), 'aws-kms', 'gcp', 'azure', 'vault'.
options.cacheTtlallRead-cache TTL in seconds; 0 disables. Default 300.
options.prefixenvPrefix prepended to the derived env key.
options.configFallbackenvDefault true: fall back to string values from CAPABILITIES.CONFIG for missing environment keys. false reads env only. Non-booleans throw at plugin construction naming options.configFallback; other providers ignore it. Absent CONFIG is silent.
options.region / accessKeyId / secretAccessKeyaws-kmsAWS client config (ignored when client injected).
options.endpointaws-kmsLocalStack / emulator / private endpoint for the lazy client (ignored when client injected).
options.projectIdgcpGCP project id for resource paths.
options.endpointgcpPrivate/regional endpoint for the lazy client as host or host:port, no scheme; TLS only (ignored when client injected).
options.vaultUrlazureKey Vault URL.
options.address / token / mountvaultVault server address, token, KV mount (default secret).
options.clientaws-kms/gcp/azureInjected structural client facade (bypasses lazy import).
options.httpvaultInjected fetch-shaped function (defaults to global fetch).
options.requestTimeoutMsvaultBound on one Vault request in ms; 0 disables. Default 5000. A value outside 0–2147483647 throws RangeError at startup.

Exports

  • SecretsPlugin(options?) — plugin factory.

  • SecretsService — the ISecretManager implementation (provider + read cache).

  • EnvProvider, AwsKmsProvider, GcpSecretManagerProvider, AzureKeyVaultProvider, HashiCorpVaultProvider — provider classes.

  • SecretProviderUnavailableError — since M101a, a provider that cannot be reached, answered 503 Service Unavailable through a status hint. HashiCorpVaultProvider rejects with it when a request fails on the network or does not answer inside options.requestTimeoutMs — the body read included, so headers followed by silence count as no answer; the transport error is kept as cause for the log and never reaches the response body. A Vault that answers with an HTTP error is reachable and keeps its handling: 404 reads as null, any other error status rejects with a plain Error. provider names the unreachable provider. Since M101a the provider also percent-encodes each /-separated segment of a secret name and refuses an empty, . or .. segment, or a name longer than 4096 characters once encoded, before sending anything; a read body over 1 MiB is refused with a plain Error, with memory held per read bounded by the same 1 MiB; and a name quoted in any secrets-plugin error message has its C0 and C1 control characters, DEL, and U+2028/U+2029 escaped as \uXXXX, cut after 256 characters.

  • ReadOnlySecretProviderError — the read-only refusal, answered 501 Not Implemented (X20-2). Thrown (as a rejection — never a synchronous throw) by EnvProvider.set, the provider’s only write method; SecretsService.rotate() reaches it by delegating to set, so both public write paths answer identically. Nothing is wrong with the caller — the configured provider cannot store or rotate secrets, permanently — which is what 501 states.

    What set() means per provider — one portable method, four meanings. rotate() delegates to the provider’s set(), so the table below describes both public write paths. The divergence is documented rather than removed: making set() mean one thing is the ROADMAP’s X20-3, a contract question deliberately ungrouped.

    Providerset() on an existing secretset() on a name that does not existVerified against a real backend
    envrejectsrejectsyes — readonly-status.test.ts pins the 501 refusal
    vault (KV v2)writes a new versioncreates the secretyes — real HashiCorp Vault (X20)
    aws-kmswrites a new versionrefuses — PutSecretValueCommand requires the secret to exist; the provider has no create pathyes — real LocalStack (X20 addendum)
    gcpadds a new versionrefuses — addSecretVersion requires the secret container to pre-existno — no local emulator; stated, not claimed
    azurewrites a new versioncreates the secretno — no local emulator; stated, not claimed

    A bootstrap that assumes set() creates is correct for vault and azure, and wrong for aws-kms and gcp. On those two, create the secret out of band before the first set().

  • SecretsServiceOptions, SecretsPluginOptions, SecretsProviderType, SecretsProviderOptions, AwsKmsProviderOptions, GcpSecretManagerProviderOptions, AzureKeyVaultProviderOptions, HashiCorpVaultProviderOptions — option types.

  • IAwsSecretsClient, IGcpSecretsClient, IAzureSecretsClient, IVaultHttp — structural injection types.

  • ISecretManager — re-exported from @setu-ts/common (get / has / rotate).

Health status

Since M90b the indicator reports two signals, and no longer conflates them: the provider’s lifecycle (isReady()) and its reachability. Vault is probed with an unauthenticated request to /v1/sys/health — no secret read, no token; EnvProvider reports its lifecycle truth. The AWS, GCP, and Azure providers publish real reachability ONLY when the injected facade exposes the optional isHealthy() member: a cloud SDK facade with no non-mutating probe is reported reachable: 'unknown' rather than reading a secret as a probe — a read is not a health check and would alter the cache and billing profile of the capability. The probe is cached for 5 seconds and bounded at 2 seconds through createCachedProbe.

StatusMeaning
upThe provider is connected and reachable, or cannot be probed (reachable is 'unknown').
downThe provider is not connected, or is connected but unreachable.

data reports { provider, reachable }, where reachable is true, false, or 'unknown'.

Notes

  • EnvProvider is read-only: rotate() and provider set reject. It reads IRuntimeServices.env first, then the config snapshot for a missing key when options.configFallback is enabled (default true). Both paths accept strings only, including empty strings; environment strings are returned unchanged, including ${NAME} references. Non-string config values are missing, never coerced. Without CONFIG it silently reads only env. The optional CONFIG dependency orders even a replacement config provider before secrets.
  • Direct construction accepts new EnvProvider(env, { prefix?, config? }), where config is Pick<IConfig, 'get'> and is consulted only when the environment lacks a string at the derived key. On Node the environment map is live; Deno and Workers use copies. The service’s normal options.cacheTtl applies to found values from either source; misses are never cached. Provider health remains lifecycle-only.
  • ConfigPlugin’s .env-only keys now resolve through this fallback. A validation schema must declare the secret key or pass it through; a stripped key remains missing. ${NAME} references in config-sourced secrets are expanded by default: use ConfigPlugin({ expandVariables: false }) for literal references in secrets containing $, or supply the secret in the real environment.
  • Workers normally build config from the same string bindings as runtime.env, so the fallback adds no keys and envFilePath requires unavailable filesystem support. An injected ConfigPlugin({ instance }) or another IConfig can supply keys the environment lacks, including the snapshot used by createFullStackAppFromConfig.
  • Secret names use provider-specific path syntax; EnvProvider maps a name to an env key by prepending options.prefix, uppercasing and replacing /, -, . with _ (e.g. database/password → DATABASE_PASSWORD). The same key is used for config.

Agent card · TypeDoc API reference