Keyring

@keyring-dev/cache

The versioned snapshot, the delta poller, the bounded LRU and the disk snapshot.

Generated from packages/cache/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/cache is on npm under the MIT license: npm install @keyring-dev/cache, on Node 22 or later. Questions: hello@keyringdev.com.

Functions

fromDiskKeys

export function fromDiskKeys(snapshot: DiskSnapshot): KeyPolicy[];

generateNodeId

Identifies one SDK process to the control plane's watermark table, so that revoke?wait=true can answer "revoked on 4/4 servers".

Host and pid make it legible in that answer -- an operator looking at a stalled rollout wants to know which box has not caught up -- and the random suffix keeps two processes on one host, or two hosts that share a name in a container fleet, from colliding onto one watermark and each reporting the other's progress.

Shaped to match the id format the control plane's watermark table expects; anything the hostname contributes that does not fit is dropped rather than escaped.

export function generateNodeId(): string;

toDiskKeys

export function toDiskKeys(policies: Iterable<KeyPolicy>): DiskSnapshot['keys'];

toKeyPolicy

export function toKeyPolicy(wire: WirePolicyKey, workspaceId: string, projectId: string): KeyPolicy;

Classes

DiskSnapshotStore

export class DiskSnapshotStore {
    constructor(options: {
        cacheDir?: string;
        baseUrl: string;
        projectId: string;
        env: EnvFilter;
    });
    get path(): string;
    read(): DiskSnapshot | null;
    write(snapshot: DiskSnapshot): void;
    async writeAsync(snapshot: DiskSnapshot): Promise<void>;
    remove(): void;
}

LruPolicyStore

A bounded, least-recently-used policy cache, built around two rules: cache by tenant rather than by project (see applyUpsert), and bound the resident set hard rather than let it grow unbounded.

A packed representation -- each record as a Uint8Array with an interned scope table, estimated at 582 B -> ~140 B per key -- is deliberately not implemented: that figure is an estimate rather than a measurement, and it is worth doing only if the two rules above prove insufficient on their own.

The insertion order of a JS Map is the LRU list. A get that hits deletes and re-inserts, which moves the entry to the end in O(1); eviction takes the first key the iterator yields, which is the least recently used. No linked list, no second index, nothing else per entry.

export class LruPolicyStore implements PolicyStore {
    constructor(options: LruPolicyStoreOptions = {});
    get(lookupHashKey: string): KeyPolicy | undefined;
    getCached(lookupHashKey: string): CachedPolicy | undefined;
    status(): PolicyStoreStatus;
    stats(): PolicyStoreStats;
    loadSnapshot(policies: Iterable<KeyPolicy>, meta: {
        version: number;
        fetchedAt: number;
        source: PolicyStoreSource;
        scopeComplete: boolean;
        narrowed?: boolean;
    }): void;
    applyUpsert(policy: KeyPolicy, lookupHashKey: string): boolean;
    applyTombstone(lookupHashKey: string): boolean;
    markSynced(version: number, fetchedAt: number): void;
    raiseMaxCachedKeys(next: number): boolean;
    entries(): IterableIterator<KeyPolicy>;
    get size(): number;
}

PolicyAuthFailedError

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 class PolicyAuthFailedError extends Error {
    override readonly name: "PolicyAuthFailedError";
    readonly projectId: string;
    readonly since: number;
    readonly neverServed: boolean;
    constructor(options: {
        projectId: string;
        since: number;
        neverServed: boolean;
    });
}

PolicyBootRefusedError

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 class PolicyBootRefusedError extends Error {
    override 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

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;
    });
}

PolicyClient

The only part of the SDK that touches the network, and it is deliberately not reachable from verify(). Unkey's verifyKey is a network call to Unkey, and no amount of caching in front of that changes the shape. Keyring ships the policy here instead, on a timer, out of band.

export class PolicyClient {
    constructor(options: PolicyClientOptions);
    get tenantIds(): readonly string[];
    addTenants(tenantIds: readonly string[]): readonly string[];
    removeTenants(tenantIds: readonly string[]): void;
    async snapshot(options: {
        cursor?: string;
        maxKeys: number;
    }): Promise<WireSnapshot>;
    async delta(since: number, options: {
        maxKeys: number;
    }): Promise<WireDeltaAnswer | null>;
}

PolicyPoller

Keeps an LruPolicyStore fed from the versioned snapshot and the delta feed, and keeps a copy on disk so a restart of the same process/filesystem is not a cold start.

This is the wedge, in one class. Unkey's verifyKey is a network call to Unkey, and their co-founder's summary of years of latency work is "zero network requests are always faster than one network request." The answer is not a better cache in front of that call -- it is to ship the policy here, on this timer, so the call does not exist.

export class PolicyPoller {
    readonly nodeId: string;
    constructor(options: PolicyPollerOptions);
    get store(): LruPolicyStore;
    get consecutiveFailures(): number;
    get authFailed(): boolean;
    get authFailedSince(): number | null;
    get authFailedRefusingAll(): boolean;
    get tenantIdsRejected(): number;
    get tenantIds(): readonly string[];
    get tenantsConvicted(): number;
    get suspectsTracked(): number;
    get tenantsHeldOut(): number;
    loadFromDisk(): DiskSnapshot | null;
    async refreshOnce(): Promise<PollOutcome>;
    start(): void;
    stop(): void;
    admitTenants(requested: readonly string[]): boolean;
    get snapshotPages(): number;
    get snapshotTruncated(): boolean;
    get autoRaisedMaxCachedKeys(): number | null;
    get maxAutoCachedKeys(): number;
    get scopeTooLarge(): PolicyScopeTooLargeError | null;
    get diskSnapshotScopeMismatch(): PolicySnapshotScopeMismatch | null;
    async flushPersist(): Promise<void>;
}

PolicyRequestError

export class PolicyRequestError extends Error {
    readonly status: number;
    constructor(status: number, message: string);
}

PolicyScopeTooLargeError

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/sdk rethrows 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 ordinary PATCH /v1/projects/:id -- which raises policy_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. And stop()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;
    });
}

PolicySnapshotScopeMismatch

Reported through onError when a disk snapshot was refused because the process that wrote it was running under a different scope than this one.

A notice, like PolicyCacheAutoRaised: the node is not broken, it is cold, and the cold path is one it already has. But an operator staring at a node that keeps cold-starting has no other way to see why -- the file is there, it is valid, it is this project and this environment, and it is still not being read. So the message names both scopes and the file.

export class PolicySnapshotScopeMismatch extends Error {
    override readonly name: "PolicySnapshotScopeMismatch";
    readonly path: string;
    readonly writer: SnapshotScope | undefined;
    readonly reader: SnapshotScope;
    constructor(options: {
        path: string;
        writer: SnapshotScope | undefined;
        reader: SnapshotScope;
    });
}

Interfaces

CachedPolicy

export interface CachedPolicy {
  readonly policy: KeyPolicy;
  /**
   * Epoch ms. Derived rather than stored per record: every successful poll
   * confirms the whole resident set, so a record's freshness is the later of
   * when it was admitted and when the store last synced. Storing an absolute
   * value per record and rewriting all of them on each poll would be O(n) every
   * five seconds for no additional truth.
   */
  readonly cachedUntil: number;
}

DiskSnapshot

export interface DiskSnapshot {
  readonly format: typeof SNAPSHOT_FORMAT;
  readonly workspaceId: string;
  readonly projectId: string;
  readonly env: EnvFilter;
  readonly version: number;
  readonly fetchedAt: number;
  readonly scopeComplete: boolean;
  /**
   * Absent in a file written by an SDK older than this field. That is an
   * *unknown* scope, not a matching one: the file it comes from is exactly the
   * file a mixed-version rollout leaves behind, which is the deployment this
   * check exists for. A tenant-narrowed reader refuses it and comes up cold --
   * one poll's worth of cost, against a poisoned store that never repairs
   * itself. A project-scoped reader loads it but does **not** believe its
   * `scopeComplete`, because such a file may have been written by a node that
   * was `complete: true` while holding one tenant's keys.
   *
   * The field is additive rather than a `SNAPSHOT_FORMAT` bump, deliberately:
   * a bump cold-starts every node in every fleet once, which is the cost this
   * decision exists to avoid. The price of that is a one-directional
   * mixed-version window -- an older SDK ignores this field and keeps
   * poisoning itself until it is replaced -- and `packages/cache/README.md`
   * says so out loud rather than leaving a customer to find it.
   */
  readonly scope?: SnapshotScope;
  readonly keys: ReadonlyArray<{
    keyId: string;
    tenantId: string;
    env: Environment;
    lookupHash: string;
    displayPrefix: string;
    scopes: string[];
    expiresAt: number | null;
    /** Absent in a file written by an SDK from before this field existed. */
    rateLimits?: WireRateLimit[];
  }>;
}

LruPolicyStoreOptions

export interface LruPolicyStoreOptions {
  /** `= 640 bytes x maxCachedKeys`, so 10,000 ~ 6.1 MiB. */
  readonly maxCachedKeys?: number;
  readonly cachedTtlMs?: number;
  readonly now?: () => number;
}

PolicyClientOptions

export interface PolicyClientOptions {
  readonly baseUrl: string;
  /** A `krsk_` vendor secret key. Its environment decides what the feed shows. */
  readonly secretKey: string;
  readonly projectId: string;
  readonly env?: EnvFilter;
  /** Narrows the snapshot to these tenants, done server-side. */
  readonly tenantIds?: readonly string[];
  readonly nodeId?: string;
  readonly requestTimeoutMs?: number;
  readonly fetch?: FetchLike;
}

PolicyPollerOptions

export interface PolicyPollerOptions extends LruPolicyStoreOptions {
  readonly baseUrl: string;
  readonly secretKey: string;
  readonly projectId: string;
  readonly env?: EnvFilter;
  readonly tenantIds?: readonly string[];
  readonly store?: LruPolicyStore;
  /**
   * How far the snapshot walk may raise `maxCachedKeys` on its own.
   * `DEFAULT_MAX_AUTO_CACHED_KEYS` unless set, and never below `maxCachedKeys`
   * -- an operator who picked a larger bound already chose it.
   *
   * Setting it *equal* to `maxCachedKeys` turns the automatic step off, which
   * leaves the two honest answers to a project that does not fit and not the
   * silent third one: a node that has never served refuses to start, and one
   * that is already serving alarms on every poll and keeps serving. Raising
   * the bound is the fix either way; `tenantIds` lets the node run without
   * closing the hole, because a narrowed store is never `complete` either.
   */
  readonly maxAutoCachedKeys?: number;
  readonly policyRefreshMs?: number;
  readonly pollJitter?: number;
  readonly requestTimeoutMs?: number;
  readonly nodeId?: string;
  /** A day-0 requirement. Set false to opt out entirely. */
  readonly persist?: boolean;
  readonly cacheDir?: string;
  readonly fetch?: FetchLike;
  readonly now?: () => number;
  readonly onError?: (error: unknown) => void;
  readonly onPoll?: (outcome: PollOutcome) => void;
}

PolicyStoreStats

export interface PolicyStoreStats {
  readonly size: number;
  /** The bound in force now, which `raiseMaxCachedKeys` may have widened. */
  readonly maxCachedKeys: number;
  /** The bound this store was constructed with, whatever it is now. */
  readonly configuredMaxCachedKeys: number;
  readonly hits: number;
  readonly misses: number;
  /**
   * The rule is "bound it hard and say so": an SDK that silently grows
   * unbounded inside a customer's process is how you get uninstalled -- and
   * one that silently *evicts* is how a fail-open default stops being safe,
   * because the argument for it is that the cache is complete for the project.
   */
  readonly evictions: number;
  /** Evictions per second since the store was created. */
  readonly evictionRate: number;
  /** Upserts dropped because the key was not resident. See `applyUpsert`. */
  readonly skippedUpserts: number;
}

PollOutcome

export interface PollOutcome {
  readonly kind: PollKind;
  readonly version: number | null;
  /** How many cached records the poll added, replaced or dropped. */
  readonly applied: number;
  readonly error?: unknown;
}

WireDelta

export interface WireDelta {
  readonly object: 'policy_delta';
  readonly workspace_id: string;
  readonly project_id: string;
  readonly env: EnvFilter;
  readonly since: number;
  readonly version: number;
  readonly project_deleted: boolean;
  readonly generated_at: string;
  readonly changes: WireChange[];
}

WirePolicyKey

One key as GET /v1/policy/snapshot sends it.

export interface WirePolicyKey {
  readonly key_id: string;
  readonly tenant_id: string;
  readonly env: Environment;
  /** base64 SHA-256 of the whole key: the cache index, and not a secret. */
  readonly lookup_hash: string;
  readonly display_prefix: string;
  readonly scopes: string[];
  readonly expires_at: string | null;
  /**
   * The key's effective limits, resolved against the project default by the
   * control plane. Absent on a snapshot from a control plane that predates
   * this field, which reads as "no limits" -- the same answer that plane
   * would have given.
   */
  readonly rate_limits?: WireRateLimit[];
}

WireSnapshot

export interface WireSnapshot {
  readonly object: 'policy_snapshot';
  readonly workspace_id: string;
  readonly project_id: string;
  readonly env: EnvFilter;
  readonly version: number;
  readonly project_deleted: boolean;
  readonly generated_at: string;
  readonly keys: WirePolicyKey[];
  /**
   * The token that fetches the next page of this snapshot, or `null`/absent for
   * the last one.
   *
   * Optional on this side and not on the control plane's, because a snapshot
   * also arrives from disk and from a control plane older than the paging
   * protocol. Absent is read as "there is no next page", which is safe in both
   * directions: an old control plane never truncates -- it refuses or it sends
   * everything -- and a truncation is only ever something this client asked for
   * by sending `max_keys`.
   */
  readonly next_cursor?: string | null;
}

Types

EnvFilter

null is "both environments", which is what a live credential gets.

export type EnvFilter = Environment | null;

FetchLike

export type FetchLike = (
  input: string,
  init?: {
    method?: string;
    headers?: Record<string, string>;
    signal?: AbortSignal;
  },
) => Promise<{
  status: number;
  headers: { get(name: string): string | null };
  text(): Promise<string>;
}>;

PollKind

export type PollKind = 'snapshot' | 'delta' | 'unchanged' | 'error';

SnapshotScope

The scope of the process that wrote the file: the whole project, or a specific tenant filter.

Nothing recorded it before, so PolicyPoller.loadFromDisk built half the loaded state from the file (scopeComplete) and half from this node's options (narrowed). Two differently-scoped processes sharing one cacheDir -- several workers in one container is the ordinary shape of that -- then reached complete: true together with narrowed: true, which no network walk can produce: applyUpsert skips every non-resident delta upsert while status() still says the store can prove a miss, so a key minted for a tenant that node serves is answered 401 for the life of the process. Recording the writer's scope is what lets the loader refuse.

The filter is normalised -- deduplicated and sorted -- so the comparison is about the set and not about the order an integrator listed it in, and the empty filter is spelled project rather than tenants: [] so there is exactly one representation of "the whole project".

The rejected alternative is putting tenantIds into the filename fingerprint, so differently-scoped processes never share a file at all. It makes the mismatch invisible rather than diagnosable, it multiplies files in a shared directory, and a node whose filter widens at runtime would write under a name its own restart no longer looks for.

export type SnapshotScope =
  | { readonly kind: 'project' }
  | { readonly kind: 'tenants'; readonly tenantIds: readonly string[] };

WireChange

export type WireChange =
  | ({ readonly change: 'upsert' } & WirePolicyKey)
  | {
      readonly change: 'tombstone';
      readonly key_id: string;
      readonly env: Environment;
      readonly lookup_hash: string;
    };

WireDeltaAnswer

A delta request can come back as a snapshot. That is not an error: it is the control plane saying the caller's since is not one it can answer completely (pruned, ahead of the current version, or so far behind that the delta would cost more than a re-seed). A partial delta would be a silently wrong cache, so the protocol has no way to express one.

export type WireDeltaAnswer = WireDelta | WireSnapshot;

Constants

AUTH_FAILED_REPORT_INTERVAL_MS

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_CACHE_DIR

os.tmpdir() is the default and it is world-writable on every Unix. That means the directory is the exposure, not the file: another local user can pre-create /tmp/keyring and own it, and then every 0600 file we write lands somewhere they control.

So the directory is created 0700 and, if it already exists, checked before anything is written to it: a symlink, a non-directory, or permissions that let group or other write to it are all refused. Refusing means the SDK runs without a disk cache and says so -- degraded, and loudly, rather than silently writing the customer's policy where someone else can read or replace it.

A customer sharing a host with untrusted local users should set cacheDir to a private path they own. The README says so.

const DEFAULT_CACHE_DIR: string;

DEFAULT_CACHED_TTL_MS

Belt-and-braces bound: every cached record also carries cachedUntil = now + 60 s. It is not the invalidation mechanism -- the delta feed is, at 5 s -- it is the bound on how long a node that has stopped being able to reach us keeps serving before the configured fail-open/fail-closed policy takes over.

const DEFAULT_CACHED_TTL_MS: 60000;

DEFAULT_MAX_AUTO_CACHED_KEYS

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:

shapeB/keysteady at 38,000
1 scope, no limits53419.4 MiB
3 scopes, 2 rules87031.5 MiB
8 scopes, 5 rules1,28646.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_CACHED_KEYS

The default the memory formula is written against. The measurement is what matters, not the key count on its own: 582-679 bytes per cached key in the V8 heap means 1 M keys is 555 MiB inside someone else's process, and a customer running eight Node workers on a 2 GB container cannot afford that.

const DEFAULT_MAX_CACHED_KEYS: 10000;

DEFAULT_POLICY_REFRESH_MS

5 s: fast enough to meet the "<= 5 seconds (p100)" propagation commitment quoted to customers, without paying for it in feed load -- polling every 1 s instead would be five times the requests against the same feed, spread across every node in a fleet.

const DEFAULT_POLICY_REFRESH_MS: 5000;

DEFAULT_POLL_JITTER

10,000 nodes on an unjittered 5 s poll is 10,000 requests arriving in the same millisecond every five seconds, because they all started when the same deploy rolled out. The feed is sized for 2,000 req/s spread evenly; in lockstep it is a 10,000-request spike against our own delta feed twelve times a minute.

The jitter is subtracted, never added: the delay is drawn from [interval * (1 - jitter), interval]. Adding it would spread the herd just as well and would push worst-case propagation past the interval, and "<= 5 seconds (p100)" is a number we quote to customers.

const DEFAULT_POLL_JITTER: 0.2;

MAX_SNAPSHOT_PAGES

How many pages of one snapshot this node will walk before it stops and says the store is incomplete.

The real bound is maxCachedKeys: everything past it would be evicted on load anyway, so paging beyond it is bandwidth spent to throw the result away. This is the second bound, and it is about a control plane that misbehaves rather than one that is large -- a page that answers with a cursor and no keys would otherwise be an infinite loop inside the customer's process, which is not a failure mode an SDK gets to have.

const MAX_SNAPSHOT_PAGES: 512;

SNAPSHOT_FORMAT

The cold start is the genuinely dangerous case: a restarted process with no cache to be stale from. Without a snapshot on disk, a customer restarting the same process/filesystem during a Keyring outage goes down -- the worst possible first impression -- so this is a day-0 requirement rather than a nice-to-have. It does not cover a fresh container in an immutable-container rollout, which has no file to hydrate regardless; see packages/cache/README.md's "Immutable containers and rollouts".

What lands on disk, and why that is acceptable:

  • lookup_hash is the unpeppered SHA-256 of the key. That is not a concession: it has to be computable inside the customer's process, which is what makes verification local, and the authoritative peppered hash never leaves the control plane. A key carries 192 bits of entropy, so a hash is not invertible and there is no dictionary to attack -- the same argument that lets api_key.lookup_hash be unpeppered in our own database. (The pepper cannot substitute for that entropy -- it adds server-side defense in depth, it does not extend the key's 192 bits.)
  • key ids, tenant ids, display prefixes, scopes and expiries. Metadata, and already in the customer's own heap and their own request logs.
  • never a plaintext key, never the pepper, never the peppered hash. None of the three exists in this process at all.

So the file adds no class of secret the process was not already holding; what it adds is a persistence window, and that is what the permissions below are for.

const SNAPSHOT_FORMAT: "keyring.policy-snapshot.v1";

On this page