@keyring-dev/express
Express and Connect middleware.
packages/express/src/index.ts and the declarations it exports, by packages/docs/tools/generate-reference.mjs. Every signature below is printed from the source; the prose is the source's own doc comment.@keyring-dev/express is on npm under the MIT license: npm install @keyring-dev/express, on Node 22 or later. Questions: hello@keyringdev.com.Functions
keyring
app.use(keyring()) -- the reference adapter, and
everything in it is translation. The decision, the three onUnavailable
modes, the rate-limit modes and header spellings, the per-environment
resources, the AsyncLocalStorage scope and the telemetry ring buffer all
live in @keyring-dev/sdk, which is what keeps this file to the size an
adapter should be and what makes the Python and Go ports a week each
rather than a month.
Where to mount it, and why it matters.
- Before your routes, and before any body parser. Verification needs the
Authorizationheader and nothing else, so making it wait for a body is paying parse cost on requests that are about to be rejected -- and it is a denial-of-service shape: an unauthenticated caller gets your JSON parser to run on a 5 MB body before anyone checks their key. Rate limiting needs no body either, so it is here too, which means a caller over their limit is refused before your parser runs. - After
express.static, or the middleware verifies your favicon. - Beside, not instead of, your own authentication. Keyring answers "which tenant of yours is calling, in which environment"; it does not answer "which of your staff is calling". Mount both, in whichever order matches which one owns the route.
- Your error handler stays last. This middleware only ever calls
next()or writes its own 401/403/429/503, and never throws into yours.
Idempotency is the one thing that cannot be here, because it has to hash a
body: keyringIdempotency() is a second mount, after your body parser.
export function keyring<R extends ResourceMap = ResourceMap>(source: Keyring<R> | KeyringOptions<R>): KeyringExpressMiddleware;keyringIdempotency
app.use(express.json(), keyringIdempotency(mw.keyring)).
A second mount, and it has to be, because the fingerprint covers the body
and keyring() deliberately runs before any body parser. Mount this one
after the parser and before your routes. A request with no Idempotency-Key
passes straight through and costs nothing.
The response is buffered so it can be stored, which is the one real cost:
only for requests that carry the header, and capped by the control plane's
max_body_bytes (256 KB by default).
export function keyringIdempotency(instance: Keyring<ResourceMap>): (req: ExpressLikeRequest, res: ExpressLikeResponse, next: (error?: unknown) => void) => void;Interfaces
KeyringExpressMiddleware
export interface KeyringExpressMiddleware {
(
req: ExpressLikeRequest,
res: ExpressLikeResponse,
next: (error?: unknown) => void,
): void;
readonly keyring: Keyring<ResourceMap>;
}KeyringOptions
Re-exported from @keyring-dev/sdk.
export interface KeyringOptions<R extends ResourceMap = ResourceMap> {
/**
* A `krsk_` vendor secret key. Never a tenant's `kr_` key.
* Defaults to `KEYRING_SECRET_KEY`.
*/
readonly secretKey?: string;
/** Defaults to `KEYRING_PROJECT_ID`. */
readonly projectId?: string;
/**
* The control plane's URL. No default: pass it, or set `KEYRING_BASE_URL`.
*/
readonly baseUrl?: string;
/**
* Unset means one endpoint serves both environments and routes on the key,
* which is the shape the docs lead with. Set it only for a deployment that
* serves one environment and should refuse the other outright.
*/
readonly env?: Environment;
/** The tenants this node serves, so the cache is narrowed by tenant rather than by project. */
readonly tenantIds?: readonly string[];
readonly maxCachedKeys?: number;
/**
* How far the SDK may raise `maxCachedKeys` by itself before it refuses to
* serve a project that does not fit. `DEFAULT_MAX_AUTO_CACHED_KEYS` (38,000,
* ~32 MiB of heap on a three-scope, two-rule key; ~46 MiB on a heavier one)
* unless set, and never below `maxCachedKeys`.
*/
readonly maxAutoCachedKeys?: number;
/**
* What to do when a node that has **never served** finds the project has more
* live keys than it can cache and no `tenantIds` narrows it.
*
* The default throws it, which crashes the process at boot with the actionable
* message -- that is what "refuse to start" means, and it is deliberate. Such
* a node can never hold a complete cache, and an incomplete cache under the
* `stale-then-open` default admits *any* well-formed key, unverified and
* unscoped, for the life of the process. A deploy that cannot verify keys
* should not take traffic.
*
* It is **not** called on a node that is already serving when its project
* outgrows the cache. That one keeps serving and keeps polling, reports the
* same error through `onError` on every poll, and reports
* `stats().policyCache.scopeTooLarge` -- the readiness predicate to drain it
* on. Killing it instead is a fleet-wide outage of your own API one vendor
* `PATCH` away, and stopping its poller is worse still: the node then never
* learns another revocation.
*
* Set this to handle the boot case yourself -- to page, to fail a readiness
* probe, to exit with your own code. A no-op leaves the node serving on the
* store it has, which at boot is an empty one: every well-formed key is
* admitted unverified until the first successful poll, and there will not be
* one. The fail-open table in the README says what that admits.
*/
readonly onScopeTooLarge?: (error: PolicyScopeTooLargeError) => void;
readonly policyRefreshMs?: number;
readonly maxStalenessMs?: number;
readonly onUnavailable?: UnavailableMode;
/**
* What a request does when the *counter store* cannot be reached, which is
* a different question from what it does when the *policy* cannot be.
* `open` by default; see `limits/limiter.ts` for why, and why idempotency's
* default is the opposite.
*/
readonly rateLimitOnUnavailable?: RateLimitUnavailableMode;
/** The legacy pre-standard header triple. On, because clients read it. */
readonly legacyRateLimitHeaders?: boolean;
readonly rateLimitTimeoutMs?: number;
/**
* `false` disables the feature; an object configures it. Enabled by
* default -- a mutating request with no `Idempotency-Key` costs nothing,
* and the header is how a caller opts in.
*/
readonly idempotency?: boolean | Omit<IdempotencyOptions, 'baseUrl' | 'secretKey' | 'fetch' | 'onError'>;
readonly routes?: RouteRules;
readonly scopes?: readonly string[];
/** Resolved per request from the key's env. */
readonly resources?: R;
readonly cacheDir?: string;
readonly persist?: boolean;
readonly nodeId?: string;
/** Set false to send nothing at all. */
readonly telemetry?: boolean;
readonly bufferSize?: number;
readonly flushIntervalMs?: number;
readonly flushEvents?: number;
readonly maxEventsPerSecond?: number;
readonly handleSignals?: boolean;
readonly lazyFill?: boolean | OnDemandFillOptions;
readonly sdkName?: string;
readonly fetch?: FetchLike & ShipperFetch & ControlPlaneFetch;
readonly onWarn?: (message: string, detail?: unknown) => void;
/** Injectable so a test can exercise the environment defaults. */
readonly processEnv?: Record<string, string | undefined>;
readonly onError?: (error: unknown) => void;
/** Escape hatch for tests: a store to use instead of the polled cache. */
readonly store?: PolicyStore;
readonly now?: () => number;
}Types
KeyringContext
Re-exported from @keyring-dev/sdk.
export type KeyringContext<R extends ResourceMap = ResourceMap> = KeyringContextBase & ResolvedResources<R>;