@keyring-dev/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.@keyring-dev/sdk is on npm under the MIT license: npm install @keyring-dev/sdk, on Node 22 or later. Questions: hello@keyringdev.com.The interfaces and types are on their own page, which keeps each page small enough to load quickly.
Functions
assertEnv
Throws unless the current context is expected. A cheap defense-in-depth
check, 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-dev/core.
export declare function bunyanRedactSerializers(options?: RedactOptions): Record<string, (input: unknown) => unknown>;bunyanRedactStream
Re-exported from @keyring-dev/core.
export declare 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-dev/core's
verify(); this function only decides what an unknown_key means, which is
the one question the cache cannot answer on its own. This is a policy layer
over the store's own 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.
export function decide(token: string | null, options: DecideOptions): Decision;defaultTransient
export function defaultTransient(status: number, headers: Readonly<Record<string, string>>): boolean;isErrorEvent
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-dev/core.
export declare function pinoRedact(options?: RedactOptions): PinoRedactHooks;rateLimitHeaders
Re-exported from @keyring-dev/core.
export declare function rateLimitHeaders(states: readonly RateLimitState[], options?: RateLimitHeaderOptions): Record<string, string>;redact
Re-exported from @keyring-dev/core.
export declare function redact(value: unknown, options?: RedactOptions): unknown;redactString
Re-exported from @keyring-dev/core.
export declare function redactString(value: string, replacement?: string): string;replayableHeaders
export function replayableHeaders(headers: Readonly<Record<string, string>>): Record<string, string>;requestFingerprint
SHA-256(method ‖ path ‖ sorted query ‖ canonical body).
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
The place where every hand-rolled test mode actually leaks.
req.keyring and the declared resource map both 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) -- a deliberate deviation from
per-key scoping. 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-dev/core.
export declare 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. 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.
An earlier design had the SDK talking to a Redis-backed coordinator directly 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-dev/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 left for a later version, not this one.
export class HttpRateLimitTransport implements RateLimitTransport {
constructor(options: HttpTransportOptions);
async check(request: RateLimitCheckRequest): Promise<RateLimitWire>;
}Idempotency
The idempotency 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 -- "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
The lazy fill: it needs the miss to be observable to the adapter, which
PolicyStore.status() 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. A synchronous "miss/stale ->
single remote verify" fallback was deliberately not built, 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;
}PolicyAuthFailedError
Re-exported from @keyring-dev/cache.
Reported through onError when the policy poll is refused with 401 --
distinct from an ordinary failed poll, which already reaches onError as
the raw PolicyRequestError on every occurrence.
GitHub secret scanning revokes a leaked
krsk_live_ key at once, and that key is usually the vendor's own
KEYRING_SECRET_KEY -- so this is the shape a self-inflicted revoke takes
on every node in the fleet, all at once. A 401 is not "the control plane is
slow or unreachable", which is what stale-then-open is defensible against: it is "this credential no
longer authenticates", and that is not evidence that any other key is
fine to admit. So the node stops treating a miss as inconclusive while this
holds -- see authFailed on PolicyPoller and DecideOptions.authFailed
in @keyring-dev/sdk -- and keeps enforcing whatever it already knew: a hit
still verifies from the last snapshot, only a miss loses the benefit of the
doubt. Traffic is not refused wholesale, and this notice is what says why a
store that looks merely stale is instead denying every unknown key.
export declare class PolicyAuthFailedError extends Error {
readonly name: "PolicyAuthFailedError";
readonly projectId: string;
readonly since: number;
readonly neverServed: boolean;
constructor(options: {
projectId: string;
since: number;
neverServed: boolean;
});
}PolicyBootRefusedError
Re-exported from @keyring-dev/cache.
Reported through onError when a cold node -- one that has never loaded
a snapshot, from disk or from the network -- gets a 400, 403 or 404 from
the policy poll, instead of an ordinary failed poll.
Pre-existing and easy to miss: a 400, 403 or 404 at boot is deterministic
-- assertEnvAllowed answers 403 for a krsk_test_ naming env: 'live',
a project outside the key's workspace answers 404, and a malformed request
answers 400 -- and none of those clears on its own the way an outage does,
so treating it as an ordinary failed poll left a cold node in "Never
loaded" admitting every well-formed key for as long as the condition
lasts, which for a misconfiguration is the life of the deploy.
The fix: any non-transient 4xx before the first successful poll enters the
same authFailed state a 401 does -- see authFailed on PolicyPoller --
deliberately scoped to the cold refusal only, not to a general non-401
policy. A warm node's
ordinary failed poll on one of these statuses is unchanged and does not
reach here: #refresh's catch only constructs this error while the store
has never loaded a snapshot, the same condition PolicyAuthFailedError's
own neverServed reads.
A 404 -- and, via #rollBackWidening, a 400 -- is not always the
misconfiguration above. An unowned tenant_id fails the whole snapshot
with a 404 (the control plane's own rule for a tenant widening it does
not recognise), and #rollBackWidening treats a 400 the same way; both
are the status a refused tenant widening produces, on the very poll this
class fires from. That cause is not a misconfiguration and does not need
one fixed: the rollback removes the offending id from the filter on this
same refusal, so the next poll re-seeds on a filter that works, with no
operator action. This class
cannot tell the two causes apart from the status code alone, so the
message names both rather than asserting the misconfiguration is the only
one -- measured: a cold node whose first widening names an unowned tenant
refuses all traffic for exactly one poll, then recovers on its own. A
statically configured tenantIds naming an unowned tenant is the
exception: there is no pending widening to roll back, so that node stays
refused for the life of the deploy -- a real fail-closed-outage trade for
that one case, deliberately left as-is rather than carved out of this
state.
export declare class PolicyBootRefusedError extends Error {
readonly name: "PolicyBootRefusedError";
readonly projectId: string;
readonly since: number;
readonly status: 400 | 403 | 404;
constructor(options: {
projectId: string;
since: number;
status: 400 | 403 | 404;
});
}PolicyCacheAutoRaised
Re-exported from @keyring-dev/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 declare class PolicyCacheAutoRaised extends Error {
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-dev/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 only the first was in scope for this
decision.
- 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-dev/sdkrethrows it out of a microtask and the deploy dies. That is the deliberate outcome for this case. - 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 declare class PolicyScopeTooLargeError extends Error {
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 because 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
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;
}Constants
AUTH_FAILED_REPORT_INTERVAL_MS
Re-exported from @keyring-dev/cache.
How often the loud auth-failed notice repeats while the state continues, once it has already fired on entry.
Every failed poll already reaches onError with the raw PolicyRequestError
(unchanged); this is a second, distinct notice on top of that, so it must
not repeat on the same 5 s cadence or a sustained 401 turns into a
duplicate line every poll. It is not the recovery bound -- there is none, by
design: only a poll that authenticates clears the state.
const AUTH_FAILED_REPORT_INTERVAL_MS: 60000;DEFAULT_BUFFER_SIZE
The single most important backpressure decision here: 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
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
Two deliberate deviations from Stripe's covered-methods list are in here.
const DEFAULT_IDEMPOTENT_METHODS: readonly string[];DEFAULT_MAX_AUTO_CACHED_KEYS
Re-exported from @keyring-dev/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).
For a project too large to cache, the policy is: 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 here is a memory cost, not a key count, and this number is
the key count that delivers it. "50,000 keys at 31.2 MiB" was the first
estimate, built from wire records as JS object literals: all 50,000 of
those 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 rather than sharing one.
Re-measured on the wire path, a key costs 870 B at three scopes and
the two-rule set the control plane's rate-limit configuration allows, so
50,000 of them is 41.5 MiB and not 31.2. 38,000 x 870 B is 31.5 MiB,
which is the number chosen.
What that buys and what it does not, measured on the same run:
| shape | B/key | steady at 38,000 |
|---|---|---|
| 1 scope, no limits | 534 | 19.4 MiB |
| 3 scopes, 2 rules | 870 | 31.5 MiB |
| 8 scopes, 5 rules | 1,286 | 46.6 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 done here: it would change the configuration
surface, which the bound above leaves alone.
const DEFAULT_MAX_AUTO_CACHED_KEYS: 38000;DEFAULT_MAX_EVENTS_PER_SECOND
Default cap on the telemetry event rate.
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-dev/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
Stripe's bound, and the one every client already obeys.
const MAX_IDEMPOTENCY_KEY_LENGTH: 255;NOT_LIMITED
const NOT_LIMITED: RateLimitDecision;PROTOCOL_VERSION
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.2";