@keyring/sdk
The runtime every adapter wraps: the decision, the limits, idempotency and telemetry.
packages/sdk/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.npm install @keyring/sdkFunctions
assertEnv
Throws unless the current context is expected. The cheap version of report
section 5.2's third enforcement layer, for a function that must never run
against live data by accident.
export function assertEnv(expected: Environment): void;assertResourcesUsable
export function assertResourcesUsable(resources: ResourceMap): void;attachRateLimit
The one field of req.keyring that is not known when the context is built.
Written here rather than by each adapter so that two adapters cannot disagree
about the shape -- packages/sdk/README.md's table is a cross-language
contract, and a field one adapter sets and another forgets is the worst kind
of difference between them.
export function attachRateLimit(context: KeyringContextBase, states: readonly RateLimitState[] | null, degraded: boolean): void;buildContext
export function buildContext<R extends ResourceMap>(key: DecidedKey, degraded: boolean, resources: R): KeyringContext<R>;bunyanRedactSerializers
Re-exported from @keyring/core.
export function bunyanRedactSerializers(options?: RedactOptions): Record<string, (input: unknown) => unknown>;bunyanRedactStream
Re-exported from @keyring/core.
export function bunyanRedactStream(target: WritableLike, options?: RedactOptions): NodeJS.WritableStream;canonicalBody
export function canonicalBody(body: unknown): string;canonicalJson
Deterministic JSON: object keys sorted, everything else as JSON.stringify
would have it. Two SDKs must agree on this byte for byte, so it is here and
not in an adapter.
export function canonicalJson(value: unknown): string;currentEnv
The environment of the request this code is running under, or null outside one.
Null is deliberately not 'live'. A background worker that starts its own
context has genuinely not been told, and defaulting to live is how a test
receipt gets emailed to a real customer; the caller has to decide what "not
in a request" means for it.
export function currentEnv(): Environment | null;currentScope
export function currentScope(): EnvScope | null;decide
The whole per-request policy decision, with no HTTP and no framework in it.
Everything about whether the key is valid comes from @keyring/core's
verify(); this function only decides what an unknown_key means, which is
the one question the cache cannot answer on its own. Report section 6.4 is a
policy over week 3's freshness contract (isAuthoritativeMiss,
stalenessMs) and deliberately not a second notion of freshness: an adapter
that re-derived "fresh" from cachedUntil, or from its own clock, would
drift from the predicate the store actually implements.
Synchronous, like everything else on this path. There is no await between
a request arriving and a decision, which is the property the entire
architecture rests on (report section 1.1).
export function decide(token: string | null, options: DecideOptions): Decision;defaultTransient
export function defaultTransient(status: number, headers: Readonly<Record<string, string>>): boolean;isErrorEvent
Report section 6.3: 4xx, 5xx and rate-limited events are never sampled. The predicate is here rather than inline so the Python and Go ports have one sentence to port.
export function isErrorEvent(status: number, degraded: boolean): boolean;isTransientStatus
Which statuses finish() will even ask the predicate about.
A response that succeeded, or that the customer's own handler refused, is an
attempt that happened; releasing the record for it re-executes the operation
on the retry. Only a server-side failure can mean "this never ran". The gate
is here rather than inside the predicate so it holds for a customer's own
transient too -- the sentence every doc writes is about 5xx, and a custom
predicate should not be able to make that sentence false.
export function isTransientStatus(status: number): boolean;pinoRedact
Re-exported from @keyring/core.
export function pinoRedact(options?: RedactOptions): PinoRedactHooks;rateLimitHeaders
Re-exported from @keyring/core.
export function rateLimitHeaders(states: readonly RateLimitState[], options: RateLimitHeaderOptions = {}): Record<string, string>;redact
Re-exported from @keyring/core.
export function redact(value: unknown, options: RedactOptions = {}): unknown;redactString
Re-exported from @keyring/core.
export function redactString(value: string, replacement: string = DEFAULT_REPLACEMENT): string;replayableHeaders
export function replayableHeaders(headers: Readonly<Record<string, string>>): Record<string, string>;requestFingerprint
SHA-256(method ‖ path ‖ sorted query ‖ canonical body).
Report section 4.3: the route and the body, never the headers -- a retry
legitimately carries a different User-Agent, Date or trace header, and
fingerprinting those would turn every legitimate retry into a 422.
The body is canonicalised rather than hashed raw, because by the time a
middleware can see it, it has been through the framework's JSON parser and
the byte order of the original is gone. Object keys are sorted recursively,
so {"a":1,"b":2} and {"b":2,"a":1} are the same request -- which is what
a caller retrying from a different serialiser means. Arrays keep their order,
because in a request body an array's order is content.
export function requestFingerprint(input: {
method: string;
path: string;
body: unknown;
}): string;runInEnv
Report section 5.3, level 3: "the place where every hand-rolled test mode actually leaks".
Levels 1 and 2 solve the handler. They do nothing for the receipt email sent
six frames down, for the job enqueued and run later, or for the webhook the
handler fires -- all of which are code that has no request in scope and every
reason to behave differently in test mode. AsyncLocalStorage is what
carries the environment there without threading a parameter through every
signature in between.
Module-level rather than per-Keyring: two Keyring instances in one process
would otherwise give currentEnv() two answers depending on which module the
caller imported, and the callers this exists for are exactly the ones that
import neither.
export function runInEnv<T>(scope: EnvScope, fn: () => T): T;scopeHash
SHA-256(project ‖ env ‖ tenant ‖ key), computed here so the customer's
caller's own Idempotency-Key never reaches us.
The scope is per (project, env, tenant) -- report section 4.5's deliberate
deviation from the brief's per-key scope, and assumption A6. Per-key scoping
breaks rotation-with-overlap: the old key writes the record, the retry
arrives on the new key, and the customer's customer is charged twice by the
feature that exists to stop exactly that.
env is in it, so a kr_test_ key can never replay a live response and a
live key can never replay a test one -- which is the test-mode boundary
applied to a store that would otherwise be the easiest place to cross it.
export function scopeHash(input: {
projectId: string;
env: Environment;
tenantId: string | null;
idempotencyKey: string;
}): string;winstonRedact
Re-exported from @keyring/core.
export function winstonRedact(options?: RedactOptions): WinstonTransformFunction;Classes
ControlPlaneError
export class ControlPlaneError extends Error {
constructor(readonly url: string, readonly status: number);
}EventShipper
The only thing in the SDK that sends anything, and it is deliberately unable to affect a request. Report section 6.3, in order:
fixed ring buffer, drop oldest, count the drops, warn once a minute;
flush every 1,000 ms or 1,000 events, gzipped, fire-and-forget, 2 s timeout;
one retry with jitter, then drop -- we are not a log shipper;
flush on SIGTERM and beforeExit with a 3 s cap;
sample non-error events above maxEventsPerSecond, never sample errors.
export class EventShipper {
constructor(options: EventShipperOptions);
start(): void;
record(event: UsageEvent): void;
async flush(): Promise<void>;
async close(): Promise<void>;
stats(): ShipperStats;
}HttpRateLimitTransport
The counters are central, and the SDK reaches them through the control plane rather than through Redis directly.
Report section 1.2 draws the SDK talking to COORD (Redis) for leases. This
ships the same placement -- central, exact, one op per request -- over the
credential the SDK already holds, for three reasons that all point the same
way:
- We would otherwise have to give a customer's process Redis credentials. Every other rule in this codebase says the workspace comes from the authenticated principal and never from something the caller supplies; a shared Redis reached by the caller inverts that completely.
@keyring/sdkwould gain a Redis client. These packages are MIT and ship into someone else's process; the dependency budget is a feature.- It is the same failure surface the SDK already has. The policy poller and the telemetry shipper already speak HTTP to this base URL with this key, so an outage, a proxy, a firewall rule and a status page are one thing rather than two.
The cost is one hop of our own on top of the Redis hop. That cost lands only on requests whose key actually has limits, and the alternative placements that avoid it -- lease and static-split -- are report section 3.3's v1.1, not v1.
export class HttpRateLimitTransport implements RateLimitTransport {
constructor(options: HttpTransportOptions);
async check(request: RateLimitCheckRequest): Promise<RateLimitWire>;
}Idempotency
Report section 4's protocol, on the SDK side, and framework-free.
The adapters call begin() before the handler and finish() after it. Every
decision -- which methods are covered, what a missing header means, when a
failure is stored and when it is released, when the lease is refreshed -- is
here, because an adapter is translation and three ports have to agree.
export class Idempotency {
constructor(options: IdempotencyOptions);
covers(method: string): boolean;
async begin(request: IdempotencyRequest): Promise<IdempotencyOutcome>;
async finish(claim: IdempotencyClaim, response: {
status: number;
headers: Readonly<Record<string, string>>;
body: Uint8Array | string | null;
}): Promise<'stored' | 'released' | 'lost' | 'failed'>;
heartbeat(claim: IdempotencyClaim): () => void;
stats();
}Keyring
What an adapter is. Every framework-specific file in this repo is a
translation of one HTTP shape into handle() and of one Decision back into
a response -- report section 6.5's "40 to 80 lines each", which is the
property that makes the Python and Go ports tractable.
Nothing on this path awaits. handle() is synchronous from the token to the
decision, because an await here would make a network call on the hot path
expressible and the entire architecture rests on it not being.
export class Keyring<R extends ResourceMap = ResourceMap> {
readonly nodeId: string;
constructor(options: KeyringOptions<R> = {});
get store(): PolicyStore;
get policyStore(): LruPolicyStore | null;
start(): this;
async refresh(): Promise<void>;
admitTenants(tenantIds: readonly string[]): boolean;
async close(): Promise<void>;
routeRule(method: string, path: string, route?: string | null): RouteRule | undefined;
handle(facts: RequestFacts, override?: RouteRule): RequestOutcome<R>;
async limit(outcome: RequestOutcome<R>, cost = 1): Promise<RateLimitDecision>;
async beginIdempotent(context: KeyringContext<R>, request: Omit<IdempotencyRequest, 'projectId' | 'env' | 'tenantId' | 'verified'>): Promise<IdempotencyOutcome>;
get idempotency(): Idempotency | null;
runInRequest<T>(context: KeyringContext<R>, fn: () => T): T;
record(event: UsageEvent): void;
eventFor(facts: RequestFacts, outcome: RequestOutcome<R>, status: number, durationMs: number, reason?: string | null): UsageEvent | null;
stats();
}OnDemandFill
Report section 6.1's lazy fill, deferred from week 3 because it needs the
miss to be observable to the adapter -- which PolicyStore.status() now
makes it.
Two properties, and both are the point:
The current request never waits. schedule() returns immediately and
the fetch is answered later or not at all. Report section 1.1's diagram
still draws a "miss/stale -> single remote verify" edge; week 1 cut it,
because that branch is precisely what puts a network round trip on a cold
cache, which is the thing the whole architecture exists not to do. The
request is answered by the configured onUnavailable mode from what is
known now; the fetch only makes the next request better.
A burst of unknown keys is not a burst of requests to us. A scan of a million invented keys would otherwise be a million policy fetches, from every node in the fleet at once -- a customer's attacker aiming our own control plane at us. One in flight at a time, a floor between attempts, and a hard ceiling per minute.
export class OnDemandFill {
constructor(refresh: () => Promise<unknown>, options: OnDemandFillOptions = {});
schedule(): void;
stats(): FillStats;
}PolicyCacheAutoRaised
Re-exported from @keyring/cache.
Reported through onError when the snapshot walk raised maxCachedKeys by
itself. It is a notice and not a failure -- the poll it came from succeeded
-- and it is on the error channel because that is the SDK's one operator
channel. An auto-raise that no one can see is the silent behaviour change
this whole decision exists to avoid; stats().policyCache.autoRaisedTo
carries the same fact for a scrape.
export class PolicyCacheAutoRaised extends Error {
override readonly name: "PolicyCacheAutoRaised";
readonly projectId: string;
readonly from: number;
readonly to: number;
constructor(options: {
projectId: string;
from: number;
to: number;
});
}PolicyScopeTooLargeError
Re-exported from @keyring/cache.
The project has more live keys than this node will hold, and no tenantIds
narrows it.
Two different events, told apart by refusedToStart, because "refuse to
start" and "kill a process that is answering requests" are different actions
with different blast radii and the captain only answered the first.
- Cold (
refusedToStart: true): the node has never served. Thrown out of the snapshot walk, so the store is never loaded and the poller stops;@keyring/sdkrethrows it out of a microtask and the deploy dies. That is the captain's decision, faithfully. - Warm (
refusedToStart: false): the node is already serving. Recorded and reported on every poll, and nothing is stopped. Throwing here made one ordinaryPATCH /v1/projects/:id-- which raisespolicy_change_floor, so every node re-seeds on its next poll -- take a whole fleet down inside 31 ms, taking the customer's own API with it. Andstop()ping such a node is worse than not stopping it: it freezes the store, so no revocation ever reaches that node again.
The message names what an operator needs and a generic error would not: how
many keys were walked, what the ceiling is, what it costs to raise it, and --
tenantIds being the answer that reads like a fix and is not -- what
narrowing would cost, in the same sentence that offers it.
export class PolicyScopeTooLargeError extends Error {
override readonly name: "PolicyScopeTooLargeError";
readonly projectId: string;
readonly keysWalked: number;
readonly ceiling: number;
readonly refusedToStart: boolean;
constructor(options: {
projectId: string;
keysWalked: number;
ceiling: number;
refusedToStart: boolean;
});
}RateLimiter
The rate-limit half of the hot path, and the only part of it that awaits.
It lives here rather than in an adapter for the reason report section 6.5 gives: an adapter is 40-80 lines of translation, and everything an adapter would be tempted to reimplement -- the modes, the timeout, the header spellings, the subject resolution -- is what the Python and Go ports would then have to reimplement too, differently.
applies() is synchronous and is what keeps the network call off the
requests that do not need one: a key with no limits never reaches check()
at all, which is most keys and, for a customer who rate-limits one route, most
requests.
export class RateLimiter {
constructor(options: RateLimiterOptions);
applicableRules(rules: readonly RateLimitRule[], key: {
keyId: string | null;
tenantId: string | null;
}): readonly RateLimitRule[];
async check(request: RateLimitCheckRequest): Promise<RateLimitDecision>;
stats();
}RingBuffer
export class RingBuffer<T> {
constructor(capacity: number = DEFAULT_BUFFER_SIZE);
push(item: T): boolean;
drain(limit: number = this.#capacity): T[];
unshift(items: readonly T[]): void;
stats(): RingBufferStats;
get size(): number;
get dropped(): number;
get occupancy(): number;
}RouteTable
'POST /v1/payments', 'GET /v1/health', '/v1/internal/*'.
The method is optional and the only wildcard is a trailing /*. A general
pattern language here would be a second router living beside the host
framework's, disagreeing with it about exactly the routes a customer cared
enough to configure -- and the failure would be silent, because a rule that
matches nothing looks identical to a rule that was never needed. So the
shapes are few, and unmatchedRules reports the ones that never fired.
export class RouteTable {
constructor(rules: RouteRules = {});
find(method: string, path: string): RouteRule | undefined;
unmatchedRules(): string[];
get size(): number;
}Sampler
Report section 6.3: above maxEventsPerSecond, sample non-error events at a
rate that keeps the buffer under half -- and keep 100 % of 4xx, 5xx and
rate-limited events, because the customer debugs errors, not successes.
The rate is recorded in the batch so the dashboard extrapolates counts instead of quietly under-reporting a customer's busiest minute, which is the minute they are looking at.
export class Sampler {
constructor(options: SamplerOptions = {});
shouldKeep(alwaysKeep: boolean, occupancy: number): boolean;
get rate(): number;
get sampledOut(): number;
}Interfaces
AllowDecision
export interface AllowDecision {
readonly outcome: 'allow';
readonly key: DecidedKey;
/** True whenever the decision was made from stale or incomplete data. */
readonly degraded: boolean;
/** For the request log; never for the caller. */
readonly reason: DenialReason | null;
/**
* The store held no record for a key it could not prove absent. The only
* thing this is allowed to cause is an on-demand fill for the *next* request
* (see `fill.ts`); report section 1.1 still draws a "miss -> single remote
* verify" edge and week 1 cut it deliberately, because that branch is what
* puts a round trip on a cold cache.
*/
readonly fill: boolean;
}DecidedKey
The identity an adapter puts on the request. verified: false is the
fail-open case: the environment and the display prefix are read out of the
key itself -- both are in the key material, parsed locally, with no cache and
no network involved -- and everything the policy would have said is
unknown.
export interface DecidedKey {
readonly keyId: string | null;
readonly workspaceId: string | null;
readonly projectId: string | null;
readonly tenantId: string | null;
readonly env: Environment;
readonly scopes: readonly string[];
readonly displayPrefix: string;
readonly expiresAt: number | null;
/**
* The limits the policy says apply, already resolved against the project
* default by the control plane. Empty on the fail-open branch for the same
* reason `scopes` is: there is no policy record, so there is nothing to
* enforce, and inventing a limit would be as wrong as inventing a scope.
*/
readonly rateLimits: readonly RateLimitRule[];
/**
* False when the key was admitted without a policy record -- the
* `stale-then-open` branch. A handler that cares (and a scoped one should)
* reads this rather than inferring it from empty scopes.
*/
readonly verified: boolean;
}DecideOptions
export interface DecideOptions {
readonly store: PolicyStore;
readonly mode: UnavailableMode;
readonly maxStalenessMs?: number;
readonly requiredScopes?: readonly string[];
/** Set only when a node serves a single environment. Unset routes on the key. */
readonly env?: Environment;
readonly now?: number;
/** Seconds advertised on a 503. Defaults to the staleness budget. */
readonly retryAfterSeconds?: number;
}DenyDecision
export interface DenyDecision {
readonly outcome: 'deny';
readonly status: 401 | 403 | 503;
readonly code: PublicErrorCode | 'unavailable';
readonly message: string;
readonly reason: DenialReason | 'missing_credential' | 'policy_unavailable';
readonly degraded: boolean;
readonly displayPrefix: string | null;
/**
* The environment out of the key's own hashed material, parsed locally, and
* `null` only when there is no key to read it from -- no credential at all,
* or one too malformed to parse. The request log routes a denial on this: a
* `kr_test_` key refused is a test-mode event, and recording it as live is
* both the wrong retention window and the wrong dashboard.
*/
readonly env: Environment | null;
readonly missingScopes?: readonly string[];
/** Seconds, for the `retry-after` header a 503 carries. */
readonly retryAfterSeconds?: number;
/** As on `AllowDecision`. */
readonly fill: boolean;
}EnvScope
export interface EnvScope {
readonly env: Environment;
readonly tenantId: string | null;
readonly projectId: string | null;
readonly keyId: string | null;
readonly degraded: boolean;
}EventShipperOptions
export interface EventShipperOptions extends SamplerOptions {
readonly baseUrl: string;
readonly secretKey: string;
readonly nodeId: string;
readonly sdk: { readonly name: string; readonly version: string };
readonly bufferSize?: number;
readonly flushIntervalMs?: number;
readonly flushEvents?: number;
readonly requestTimeoutMs?: number;
readonly shutdownTimeoutMs?: number;
readonly fetch?: ShipperFetch;
readonly onWarn?: (message: string, detail?: unknown) => void;
/** Off in tests, and for a customer who owns their own signal handling. */
readonly handleSignals?: boolean;
}FillStats
export interface FillStats {
readonly requested: number;
readonly started: number;
readonly coalesced: number;
readonly throttled: number;
readonly failed: number;
readonly inFlight: boolean;
}IdempotencyClaim
export interface IdempotencyClaim {
readonly scopeHash: string;
readonly env: Environment;
readonly lockToken: string;
readonly lockMs: number;
readonly maxBodyBytes: number;
/** True when a crashed holder's lock was taken over: at-least-once. */
readonly stolen: boolean;
}IdempotencyOptions
export interface IdempotencyOptions {
readonly baseUrl: string;
readonly secretKey: string;
readonly mode?: IdempotencyUnavailableMode;
readonly ttlMs?: number;
readonly timeoutMs?: number;
readonly methods?: readonly string[];
readonly retryAfterSeconds?: number;
/**
* Report section 4.4's one deviation from Stripe: a 5xx that means "my
* database was failing over" releases the record so the retry executes,
* instead of being stored and replayed forever. A 5xx that means "this
* operation was attempted and failed" is stored, like Stripe stores it.
*
* The default is the report's: `Retry-After` present, or status 503.
*
* `finish()` consults this only for `status >= 500`, whatever it returns: a
* 2xx or a 4xx is an attempt that happened, and releasing the record for one
* re-executes the operation on the retry.
*/
readonly transient?: (
status: number,
headers: Readonly<Record<string, string>>,
) => boolean;
readonly fetch?: ControlPlaneFetch;
readonly onError?: (error: unknown) => void;
readonly now?: () => number;
}IdempotencyRequest
export interface IdempotencyRequest {
readonly method: string;
readonly path: string;
readonly body: unknown;
readonly idempotencyKey: string | null;
readonly projectId: string;
readonly env: Environment;
readonly tenantId: string | null;
/**
* `KeyringContext.verified`. False on the fail-open branch, and load-bearing:
* the scope is `(project, env, tenant)` and an unverified decision knows
* none of the three -- `decide()` returns `projectId: null` and
* `tenantId: null` for every key it admits without a policy record. Sharing
* one namespace across them is a cross-tenant replay, so `begin()` refuses
* instead. Required rather than optional: a port that forgets it should not
* compile.
*/
readonly verified: boolean;
}IngestBatch
export interface IngestBatch {
readonly object: 'ingest_batch';
readonly protocol_version: number;
readonly node_id: string;
readonly sdk: { readonly name: string; readonly version: string };
/**
* The sampling rate in force for the events in this batch, so the dashboard
* can extrapolate counts rather than under-reporting them (report section
* 6.3). `events_sampled_out` is the exact figure the rate approximates and is
* carried too, because an extrapolation is a worse answer than a count when
* the count is free.
*/
readonly sample_rate: number;
readonly events_sampled_out: number;
/** Cumulative, so a gap in a dashboard is attributable rather than mysterious. */
readonly events_dropped: number;
readonly events: readonly UsageEvent[];
}KeyringContextBase
Everything an adapter puts on req.keyring before the host's resources.
export interface KeyringContextBase {
readonly keyId: string | null;
readonly workspaceId: string | null;
readonly projectId: string | null;
readonly tenantId: string | null;
readonly env: Environment;
readonly scopes: readonly string[];
readonly displayPrefix: string;
readonly expiresAt: number | null;
/**
* Report section 6.4. True whenever the decision was made from stale or
* incomplete policy -- not only when a never-seen key was let through. The
* customer can log it, alert on it, or downgrade behaviour themselves.
*/
readonly degraded: boolean;
/** False when the key was admitted without a policy record (fail-open). */
readonly verified: boolean;
/**
* What the limits said about this request, or `null` when no limit applied to
* this key -- which is the common case -- or when the counter store could not
* be reached and `onUnavailable` let the request through anyway. `degraded`
* is set in that second case, so `rateLimit === null && degraded` is
* distinguishable from "this key has no limits".
*
* Filled after `handle()`, because it is the one thing on this object that
* costs a network call. Everything else here was decided synchronously.
*/
readonly rateLimit: readonly RateLimitState[] | null;
has(scope: string): boolean;
/**
* Report section 5.2's third enforcement layer, at the call site. Compares
* against *this request's* environment rather than the ambient one, so it
* still means something in a handler that never entered `runInEnv`.
*/
assertEnv(expected: Environment): void;
}KeyringOptions
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;
/** Defaults to `KEYRING_BASE_URL`, then to the EU control plane. */
readonly baseUrl?: string;
/**
* Report section 5.3: 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;
/** Report section 6.1, fix 1: the tenants this node serves. */
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,
* ~31 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;
/**
* Report section 3: 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;
/** Report section 3.6: the pre-08 header triple. On, because clients read it. */
readonly legacyRateLimitHeaders?: boolean;
readonly rateLimitTimeoutMs?: number;
/**
* Report section 4. `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[];
/** Report section 5.3, level 2. Resolved per request from the key's env. */
readonly resources?: R;
readonly cacheDir?: string;
readonly persist?: boolean;
readonly nodeId?: string;
/** Report section 6.3. 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;
}OnDemandFillOptions
export interface OnDemandFillOptions {
/** Floor between two miss-triggered refreshes. A burst becomes one fetch. */
readonly minIntervalMs?: number;
/** Ceiling over a rolling minute, whatever the burst pattern. */
readonly maxPerMinute?: number;
readonly now?: () => number;
readonly onError?: (error: unknown) => void;
}RateLimitCheckRequest
export interface RateLimitCheckRequest {
readonly projectId: string;
readonly env: Environment;
readonly keyId: string | null;
readonly tenantId: string | null;
readonly rules: readonly RateLimitRule[];
readonly cost: number;
}RateLimitDecision
export interface RateLimitDecision {
/** False only when a limit refused. An unreachable store never lands here. */
readonly allowed: boolean;
/** Empty when no limits applied, or when the store could not be reached. */
readonly states: readonly RateLimitState[];
readonly limitedId: string | null;
readonly retryAfterSeconds: number | null;
/** True when the verdict is the `onUnavailable` policy's, not the store's. */
readonly degraded: boolean;
/** Headers to put on the response, both spellings. */
readonly headers: Readonly<Record<string, string>>;
/** 429 when a limit refused, 503 when the store was unreachable in `closed`. */
readonly status: 429 | 503 | null;
/** For the vendor's request log; never for the caller. */
readonly reason: string | null;
}RateLimitHeaderOptions
Re-exported from @keyring/core.
Report section 3.6, and both spellings ship.
The current IETF draft (draft-ietf-httpapi-ratelimit-headers-11) is RFC 9651
structured fields: RateLimit-Policy is a dictionary of the configured
limits and RateLimit a dictionary of what is left. That is the spelling a
new client should read.
The legacy RateLimit-Limit / -Remaining / -Reset triple is the pre-08
draft, and it is what every client library actually in production parses. It
ships by default for that reason, and it is a config flag rather than a
permanent fixture because it will eventually be right to turn it off.
The triple can carry only one policy, so it carries the most constrained one -- the policy that refused, if one did, and otherwise the one with the least remaining. Reporting the first configured limit instead would tell a caller they have 9,000 requests left in the day while the per-second limit is the one refusing them.
export interface RateLimitHeaderOptions {
/** Report section 3.6: on by default. */
readonly legacy?: boolean;
/** The policy that refused, when one did. */
readonly limitedId?: string | null;
readonly retryAfterSeconds?: number | null;
}RateLimitTransport
export interface RateLimitTransport {
check(request: RateLimitCheckRequest): Promise<RateLimitWire>;
}RateLimitWire
export interface RateLimitWire {
readonly allowed: boolean;
readonly limited_policy: string | null;
readonly retry_after_seconds: number | null;
readonly policies: ReadonlyArray<{
id: string;
limit: number;
window_ms: number;
remaining: number;
reset_ms: number;
}>;
}RedactOptions
Re-exported from @keyring/core.
export interface RedactOptions {
readonly replacement?: string;
readonly maxDepth?: number;
}RequestFacts
export interface RequestFacts {
readonly method: string;
readonly path: string;
readonly token: string | null;
/** The framework's route pattern, when it has one. Matched before `path`. */
readonly route?: string | null;
readonly requestId?: string | null;
}RequestOutcome
export interface RequestOutcome<R extends ResourceMap = ResourceMap> {
readonly decision: Decision;
readonly rule: RouteRule | undefined;
/** Undefined when the route is configured `skip: true`. */
readonly context: KeyringContext<R> | undefined;
}RingBufferStats
export interface RingBufferStats {
readonly size: number;
readonly capacity: number;
readonly dropped: number;
}RouteRule
export interface RouteRule {
/** Report section 6.4: per route, because a payment is not a health check. */
readonly onUnavailable?: UnavailableMode;
/** No verification, no `req.keyring`, no event. */
readonly skip?: boolean;
readonly scopes?: readonly string[];
readonly maxStalenessMs?: number;
}SamplerOptions
export interface SamplerOptions {
readonly maxEventsPerSecond?: number;
readonly now?: () => number;
/** 0 to 1; above this the sampler tightens further. Report section 6.3. */
readonly targetOccupancy?: number;
}ShipperStats
export interface ShipperStats {
readonly buffered: number;
readonly dropped: number;
readonly sampledOut: number;
readonly sampleRate: number;
readonly batchesSent: number;
readonly batchesFailed: number;
readonly eventsSent: number;
/**
* Events the control plane refused (a non-429 4xx) and the shipper therefore
* never retried. Separate from `dropped`, which is buffer pressure: this one
* is a protocol or payload disagreement, and it is the number that makes a
* batch the server will never take visible in a scrape rather than only in an
* `onWarn` callback most integrators never wire.
*/
readonly eventsRejected: number;
readonly rateLimitedUntil: number | null;
}StoredResponse
export interface StoredResponse {
readonly status: number;
readonly headers: Readonly<Record<string, string>>;
readonly body: string | null;
readonly bodyStored: boolean;
}UsageEvent
One request, as it reaches the vendor's request log, and the cross-language definition of what goes on the wire -- so the numbers here are the ones a Python or Go port will size against.
379-405 bytes per event on the wire, measured on this shape in both directions of id cardinality. Report section 10.4's 246 B is the Postgres row, not the wire: the difference is the JSON envelope, and quoting the storage figure for the wire understates a batch by about 40%.
The ring buffer is sized against neither -- it holds objects, not bytes, and 10,000 buffered events measure 2.68 MiB of V8 heap (281 B/event), which is section 6.3's "~2.5 MB".
denial_reason is here and never in a response body. Report section 8.1:
every 401 looks identical to the caller, and the reason travels separately
so the vendor's own log can say which key was revoked and which never
existed.
export interface UsageEvent {
readonly ts: string;
readonly project_id: string | null;
readonly env: Environment;
readonly tenant_id: string | null;
readonly key_id: string | null;
readonly display_prefix: string | null;
readonly method: string;
readonly path: string;
readonly route: string | null;
readonly status: number;
readonly duration_ms: number;
readonly degraded: boolean;
readonly denial_reason: string | null;
readonly request_id: string | null;
}Types
ControlPlaneFetch
export type ControlPlaneFetch = (
input: string,
init: {
method: string;
headers: Record<string, string>;
body: string;
signal: AbortSignal;
},
) => Promise<{ status: number; json(): Promise<unknown> }>;Decision
export type Decision = AllowDecision | DenyDecision;IdempotencyOutcome
export type IdempotencyOutcome =
/** Run the handler, then call `complete` or `release`. */
| { readonly kind: 'execute'; readonly claim: IdempotencyClaim }
/** Write this response verbatim, plus `Idempotent-Replayed: true`. */
| { readonly kind: 'replay'; readonly response: StoredResponse }
/** 422: same key, different request. */
| { readonly kind: 'conflict' }
/** 409 + `Retry-After`: a duplicate is executing right now. */
| { readonly kind: 'in_progress'; readonly retryAfterSeconds: number }
/** 409: the first response was over the cap and was not stored. */
| { readonly kind: 'not_replayable' }
/** No `Idempotency-Key`, or the route is not covered. Nothing to do. */
| { readonly kind: 'skip' }
/** The store could not be reached and the mode is `closed`. */
| { readonly kind: 'unavailable'; readonly retryAfterSeconds: number }
/** The store could not be reached and the mode is `open`. */
| { readonly kind: 'degraded' };IdempotencyUnavailableMode
What happens to a request carrying an Idempotency-Key when the record store
cannot be reached.
closed is the default here, and open is the default for rate limiting.
That asymmetry is the whole decision, and it is deliberate.
A rate limit not enforced during an outage admits excess traffic. An idempotency record not written during an outage executes a payment twice -- and the request in front of us has explicitly asked for exactly-once by carrying the header. Refusing it with 503 is not us imposing our outage on a customer who did not ask; it is us declining to promise something we cannot deliver, to a caller who is holding a retry loop and will ask again.
The blast radius is bounded by construction: a request with no
Idempotency-Key never reaches this code, so a Redis outage refuses only the
mutating requests whose clients were built to retry them.
open exists for a customer whose handlers are already idempotent on their
own side and who would rather serve.
export type IdempotencyUnavailableMode = 'closed' | 'open';KeyringContext
export type KeyringContext<R extends ResourceMap = ResourceMap> =
KeyringContextBase & ResolvedResources<R>;RateLimitUnavailableMode
What happens to a request when the counters cannot be reached.
Deliberately the same two words weeks 3 and 4 already gave the policy path, and deliberately not the same default.
open(the default). The request is served,degradedis set, and noRateLimitheader is emitted -- because we do not know the numbers, and inventing them is worse than omitting them. This matches the policy path'sstale-then-openin spirit and in consequence: our outage is not the customer's outage. The cost is that a limit is not enforced while we are down, which is the same cost the customer already accepts on the policy path, and much smaller: a rate limit protects against excess, not against an intruder.closed. The request is refused with 503 andRetry-After. For a route where admitting unmetered traffic is the expensive failure -- an LLM call, an outbound SMS, anything the customer pays per unit for.
There is no stale-then-open here, because there is no stale state to serve
from: a counter is a shared number, not a cached record, and a local one is a
different limiter (report section 3.2's static-split, measured at +12 %
overshoot and -6 % under-delivery, which v1 does not ship).
export type RateLimitUnavailableMode = 'open' | 'closed';ResolvedResources
export type ResolvedResources<R extends ResourceMap> = {
readonly [K in keyof R]: R[K]['live'] | R[K]['test'];
};ResourceMap
Report section 5.3, level 2, and the one the docs lead with: declare the
live and test halves of a dependency once, and the middleware resolves the
right one per request. It turns "remember to check env" from a discipline
into a wiring decision made in one place, which is the difference between a
test-mode boundary that holds and one that holds until someone is in a hurry.
export type ResourceMap = Readonly<
Record<string, { readonly live: unknown; readonly test: unknown }>
>;RouteRules
export type RouteRules = Readonly<Record<string, RouteRule>>;ShipperFetch
export type ShipperFetch = (
input: string,
init: {
method: string;
headers: Record<string, string>;
body: Uint8Array;
signal: AbortSignal;
},
) => Promise<{ status: number; headers: { get(name: string): string | null } }>;UnavailableMode
Report section 6.4's three modes.
stale-then-open is the default. The cache is complete for the project,
not a partial memo, so a key absent from a fresh complete snapshot is a key
that does not exist -- and that miss is a 401 in every mode, here included.
What the fail-open branch admits is therefore a function of what the store
can prove, which is why complete is honoured rather than assumed: a store
that has evicted under maxCachedKeys, or that was filled from a
tenant-narrowed snapshot, says complete: false, and every one of its misses
lands in the degraded branch instead of the 401 one. Only for a store that is
complete and merely stale is the bound "keys minted inside the staleness
window"; for one that is incomplete or was never loaded it is every
well-formed key, for as long as that lasts. README.md states it per store
state, and that table is the one an integrator decides on.
export type UnavailableMode =
'stale-then-open' | 'stale-then-closed' | 'closed';Constants
DEFAULT_BASE_URL
EU only, EUR only. Not a deployment option (report section 1.2).
const DEFAULT_BASE_URL: "https://api.eu.keyring.dev";DEFAULT_BUFFER_SIZE
Report section 6.3's single most important backpressure decision: an unbounded queue in a library that cannot flush turns the customer's memory pressure into an OOM they blame on us.
Fixed capacity, drop oldest when full, count the drops, never grow and never block the request. Dropping the oldest rather than the newest is deliberate: during an incident the events a customer needs are the ones being produced now, not the ones from before it started.
const DEFAULT_BUFFER_SIZE: 10000;DEFAULT_FILL_MAX_PER_MINUTE
const DEFAULT_FILL_MAX_PER_MINUTE: 12;DEFAULT_FILL_MIN_INTERVAL_MS
const DEFAULT_FILL_MIN_INTERVAL_MS: 1000;DEFAULT_FLUSH_EVENTS
const DEFAULT_FLUSH_EVENTS: 1000;DEFAULT_FLUSH_INTERVAL_MS
Report section 6.3, every one of them.
const DEFAULT_FLUSH_INTERVAL_MS: 1000;DEFAULT_IDEMPOTENCY_TIMEOUT_MS
const DEFAULT_IDEMPOTENCY_TIMEOUT_MS: 1000;DEFAULT_IDEMPOTENCY_UNAVAILABLE_MODE
const DEFAULT_IDEMPOTENCY_UNAVAILABLE_MODE: IdempotencyUnavailableMode;DEFAULT_IDEMPOTENT_METHODS
Report section 4.1's two deliberate deviations from Stripe are in here.
const DEFAULT_IDEMPOTENT_METHODS: readonly string[];DEFAULT_MAX_AUTO_CACHED_KEYS
Re-exported from @keyring/cache.
How far one node will raise its own maxCachedKeys to finish a snapshot
walk, before it refuses to start (cold) or alarms and keeps serving (warm).
The captain settled the large-project fail-open call on option b with c as
an automatic first step: raise the bound automatically to here, and only
then insist the operator raise it further and pay for it. What was rejected
matters as much as what was chosen. Leaving the walk silently truncated makes
the largest customers carry a permanent complete: false, which under
stale-then-open admits any well-formed key, unverified, with
no tenant, no scopes and no scope check, for the life of the process; the
key's checksum is CRC-32, so forging a well-formed key costs an attacker
nothing. Defaulting a truncated store to stale-then-closed instead would
close that hole by rejecting legitimate keys for ever: a key outside the
cached slice can never arrive, because there is no per-key remote lookup and
the async refresh re-walks into the same wall.
The bound the captain approved is a memory cost, not a key count, and
this number is the key count that delivers it. He approved "50,000 keys at
31.2 MiB". The 31.2 came from bench/policy-memory.mjs, which built its wire
records as JS object literals: all 50,000 of its keys shared one
'read:orders' string object and one set of rate_limits ids, and
JSON.parse -- the path PolicyClient actually takes -- allocates a string
per occurrence. Re-measured on the wire path by bench/policy-heap.mjs, a
key costs 855 B at three scopes and the two-rule set migration 0008
bounds, so 50,000 of them is 40.3 MiB and not 31.2. 38,000 x 855 B is
31.0 MiB, which is the number that was approved.
What that buys and what it does not, measured on the same run:
| shape | B/key | steady at 38,000 |
|---|---|---|
| 1 scope, no limits | 519 | 18.8 MiB |
| 3 scopes, 2 rules | 855 | 31.0 MiB |
| 8 scopes, 5 rules | 1,271 | 46.0 MiB |
A re-seed costs another ~1.4x of that while the page in flight is still
uncollected, and one PATCH /v1/projects/:id makes a whole fleet re-seed at
once -- so eight Node workers on the 2 GB container DEFAULT_MAX_CACHED_KEYS
is written against spend 248 MiB steady at this ceiling and ~340 MiB through
a simultaneous re-seed. At 50,000 the same fleet spends 322 MiB and ~450 MiB.
Bounding on measured bytes rather than on a key count would be the better
invariant and is deliberately not this change: it moves the configuration
surface, which is the captain's call and not one he was asked.
const DEFAULT_MAX_AUTO_CACHED_KEYS: 38000;DEFAULT_MAX_EVENTS_PER_SECOND
Report section 6.3's default.
const DEFAULT_MAX_EVENTS_PER_SECOND: 5000;DEFAULT_RATE_LIMIT_TIMEOUT_MS
How long the SDK waits for a verdict before its mode decides instead.
const DEFAULT_RATE_LIMIT_TIMEOUT_MS: 500;DEFAULT_RATE_LIMIT_UNAVAILABLE_MODE
const DEFAULT_RATE_LIMIT_UNAVAILABLE_MODE: RateLimitUnavailableMode;DEFAULT_REPLACEMENT
Re-exported from @keyring/core.
const DEFAULT_REPLACEMENT: "[redacted]";DEFAULT_REQUEST_TIMEOUT_MS
const DEFAULT_REQUEST_TIMEOUT_MS: 2000;DEFAULT_SHUTDOWN_TIMEOUT_MS
const DEFAULT_SHUTDOWN_TIMEOUT_MS: 3000;DEFAULT_UNAVAILABLE_MODE
const DEFAULT_UNAVAILABLE_MODE: UnavailableMode;DROP_WARN_INTERVAL_MS
"log once per minute at WARN".
const DROP_WARN_INTERVAL_MS: 60000;MAX_IDEMPOTENCY_KEY_LENGTH
Report section 4.1: Stripe's bound, and the one every client already obeys.
const MAX_IDEMPOTENCY_KEY_LENGTH: 255;NOT_LIMITED
const NOT_LIMITED: RateLimitDecision;PROTOCOL_VERSION
Report section 6.6: one integer, shared by every SDK, N and N-1 on the server.
const PROTOCOL_VERSION: 1;RESERVED_RESOURCE_NAMES
Names a resource may not take. A resources: { env: ... } would otherwise
shadow the single field the whole test-mode story is read from, and it would
do it silently.
const RESERVED_RESOURCE_NAMES: ReadonlySet<string>;SDK_VERSION
const SDK_VERSION: "0.1.0";