diff --git a/.changeset/adapter-specific-cache-config.md b/.changeset/adapter-specific-cache-config.md new file mode 100644 index 0000000..54ce2c8 --- /dev/null +++ b/.changeset/adapter-specific-cache-config.md @@ -0,0 +1,5 @@ +--- +"@developerehsan/api-client": minor +--- + +Give the IndexedDB and Redis cache stores adapter-specific config: IndexedDB gains `version`, `onQuotaExceeded`, and proactive `maxEntries` eviction; Redis gains a real `clear()` (opt-in via `scanKeys`, namespace-safe) and a custom `serializer`. Both gain a shared `onStoreError` diagnostic hook (error + op/key only, never cached data). diff --git a/.changeset/additional-hardening.md b/.changeset/additional-hardening.md new file mode 100644 index 0000000..fe30bad --- /dev/null +++ b/.changeset/additional-hardening.md @@ -0,0 +1,15 @@ +--- +"@developerehsan/api-client": minor +--- + +Add a grab-bag of caching hardening features: + +- Negative caching (`cache.cacheableStatuses`/`negativeTtl`) for error statuses like 404. +- ETag/`If-None-Match` conditional revalidation — automatic once a response captures an `ETag`. +- `client.cache.getStats()` — aggregate hit/miss/hitRate metrics. +- A circuit breaker for a failing persistent (L2) store (`cache.circuitBreaker`), observable via `cache.onStoreError`. +- At-rest encryption for IndexedDB (`createIndexedDbStore({ encrypt })`) — bring your own cipher, no default key management. +- Schema-version cache busting: a cached entry is invalidated automatically once the runtime OpenAPI schema hash changes (e.g. after a deploy). +- `client.cache.preview(pattern)` — dry-run a glob invalidation before running it for real. + +All are opt-in / additive; default behavior is unchanged. diff --git a/.changeset/cross-instance-invalidation.md b/.changeset/cross-instance-invalidation.md new file mode 100644 index 0000000..8d7e7f6 --- /dev/null +++ b/.changeset/cross-instance-invalidation.md @@ -0,0 +1,5 @@ +--- +"@developerehsan/api-client": minor +--- + +Add cross-instance cache invalidation via Redis pub/sub: `createRedisStore(client, { crossInstance: true })` broadcasts tag/key/clear invalidations to sibling server instances (opt-in, off by default). Fixes stale cache reads when a mutation lands on one instance behind a load balancer but the stale GET was cached on another. diff --git a/.changeset/custom-cache-key-composition.md b/.changeset/custom-cache-key-composition.md new file mode 100644 index 0000000..94f458d --- /dev/null +++ b/.changeset/custom-cache-key-composition.md @@ -0,0 +1,5 @@ +--- +"@developerehsan/api-client": minor +--- + +Add `cache.cacheKeyParts` — extra cache-key dimensions folded into the built-in tenant/auth-fingerprint scoping, settable per auto-method descriptor (computed from call args) or statically at the module/global/per-call layer. Safer than `keyResolver` for the common "add one more thing to the scope" case (e.g. an admin viewing "as" a target user, or a multi-workspace dashboard) since it can only add dimensions, never drop the existing tenant/auth isolation. A descriptor's `cacheKeyParts(args)` throwing fails closed (disables caching for that call). Also fixes a pre-existing gap where `cache.keyResolver` was declared on `CacheConfig` but never actually wired into the request pipeline. diff --git a/.changeset/filesystem-cache-store.md b/.changeset/filesystem-cache-store.md new file mode 100644 index 0000000..6289ce9 --- /dev/null +++ b/.changeset/filesystem-cache-store.md @@ -0,0 +1,5 @@ +--- +"@developerehsan/api-client": minor +--- + +Add `createFileSystemStore` — a Node-only, disk-backed `PersistentCacheStore` (the `.next/cache` / nginx `proxy_cache` shape) for long-running server processes that want to trade RAM for disk and survive restarts. Cache keys are SHA-256 hashed into fixed-length filenames (no path traversal), writes are atomic (write-then-rename), and it accepts the same `onStoreError`/`maxSizeBytes`/`encrypt` options as the other adapters. Not for serverless/edge; does not solve cross-instance staleness (use Redis `crossInstance` for that). diff --git a/.changeset/manual-types-docs.md b/.changeset/manual-types-docs.md new file mode 100644 index 0000000..9a0d5e8 --- /dev/null +++ b/.changeset/manual-types-docs.md @@ -0,0 +1,5 @@ +--- +"@developerehsan/api-client": patch +--- + +Document and type-test full type-safety without codegen: `createTypedClient()` already accepts a hand-written `Ops` interface with the exact shape codegen emits (`{ params, query, body, response }` per operation) — see `docs/manual-types.md`. diff --git a/.changeset/memory-aware-caching.md b/.changeset/memory-aware-caching.md new file mode 100644 index 0000000..28318a0 --- /dev/null +++ b/.changeset/memory-aware-caching.md @@ -0,0 +1,5 @@ +--- +"@developerehsan/api-client": minor +--- + +Add memory-aware caching for constrained hosts: `cache.maxSizeBytes` (approximate byte cap alongside the existing entry-count `maxSize`), `cache.mode` (`'layered'` default, `'l1-only'`, `'l2-only'` with a tiny bounded shadow L1), and a Node-only opt-in `cache.memoryPressure` guard that proactively evicts before RSS crosses a threshold. diff --git a/.changeset/refresh-token-dx.md b/.changeset/refresh-token-dx.md new file mode 100644 index 0000000..1498711 --- /dev/null +++ b/.changeset/refresh-token-dx.md @@ -0,0 +1,5 @@ +--- +"@developerehsan/api-client": minor +--- + +Add a callback alternative to endpoint-based OAuth2 refresh (`refresh: (refreshToken) => Promise`, for refresh logic that isn't "just POST a URL") and pluggable `tokenStorage` (mirrors `PersistentCacheStore`'s adapter pattern; ships `createMemoryTokenStorage`/`createLocalStorageTokenStorage`). Both are mutually exclusive with the existing `refreshEndpoint`/manual-getters shape, enforced at the type level and defense-in-depth at construction time. diff --git a/.changeset/server-only-boundary.md b/.changeset/server-only-boundary.md new file mode 100644 index 0000000..42b7c5a --- /dev/null +++ b/.changeset/server-only-boundary.md @@ -0,0 +1,5 @@ +--- +"@developerehsan/api-client": minor +--- + +Add a dev-time server-only guard: `createRpcHandler` now throws immediately if constructed in a browser context (`enforceServerOnly`, on by default), and `defineModule({ serverOnly: true, ... })` does the same for your own server-only modules. Both are defense-in-depth on top of the existing `scripts/check-browser-bundle.mjs` CI check, which remains authoritative. diff --git a/.changeset/tag-based-cache-invalidation.md b/.changeset/tag-based-cache-invalidation.md new file mode 100644 index 0000000..c81dc61 --- /dev/null +++ b/.changeset/tag-based-cache-invalidation.md @@ -0,0 +1,5 @@ +--- +"@developerehsan/api-client": minor +--- + +Add tag-based cache invalidation: `cacheTags`/`invalidatesTags` on auto-method descriptors and `cache.tags`/`cache.invalidatesTags` on per-call config, plus `client.cache.invalidateTags(tags)`. A mutation's `invalidatesTags` now clears every cached copy of a tagged resource in one process, regardless of which auth scope cached it — fixing stale GETs after a mutation and same-VM cross-user staleness. diff --git a/docs/authentication.md b/docs/authentication.md index 73dd7e7..5f1ffc0 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -70,6 +70,63 @@ The refresh response is expected to contain `access_token`/`accessToken` (and optionally `refresh_token`/`refreshToken`). A second 401 after refreshing is **not** re-refreshed (prevents infinite loops). +### Callback refresh (no HTTP endpoint) + +Some setups don't have a plain refresh URL — the logic lives behind a BFF +call, a third-party SDK, or custom signing. Use `refresh` instead of +`refreshEndpoint`; everything else (the mutex, `concurrentRefreshStrategy`, +`onTokensRefreshed`/`onRefreshFailed`) works identically: + +```ts +auth: { + strategy: 'oauth2', + getAccessToken: () => tokenStore.access, + getRefreshToken: () => tokenStore.refresh, + refresh: async (refreshToken) => { + const tokens = await thirdPartySdk.refresh(refreshToken) // returns { accessToken, refreshToken? } + return tokens + }, + onTokensRefreshed: (tokens) => { tokenStore.access = tokens.accessToken }, + onRefreshFailed: (error) => { redirectToLogin() }, +} +``` + +Exactly one of `refreshEndpoint` or `refresh` must be set — the type enforces +this, and the client also throws a `ConfigurationError` at construction time +as defense-in-depth. A `refresh()` that throws/rejects is treated as a +refresh failure (`onRefreshFailed` fires); a resolved value with no +`accessToken` string is treated the same way — it's validated like an HTTP +response body would be, never silently proceeds with `undefined`. + +### Pluggable token storage + +Instead of hand-writing `getAccessToken`/`getRefreshToken`/ +`onTokensRefreshed`, supply `tokenStorage` — an adapter mirroring +`PersistentCacheStore`'s pattern. The client derives all three from it: + +```ts +import { createMemoryTokenStorage, createLocalStorageTokenStorage } from '@developerehsan/api-client' + +auth: { + strategy: 'oauth2', + tokenStorage: createLocalStorageTokenStorage(), // or createMemoryTokenStorage() for tests/SSR warm-up + refreshEndpoint: 'https://api.example.com/oauth/token', // or `refresh:` + onRefreshFailed: (error) => { redirectToLogin() }, +} +``` + +`tokenStorage` and the manual triplet are mutually exclusive — set exactly +one. + +**`httpOnly` cookies:** `createLocalStorageTokenStorage` reads/writes +`localStorage`, which is readable by any script on the page (an XSS risk if +your threat model cares about that). It is **not** a way to work with +`httpOnly` cookies — those aren't readable/writable from JS by design. If +your backend sets an `httpOnly` session cookie, use `strategy: 'cookie'` +instead of `tokenStorage`; there is intentionally no cookie-backed +`TokenStorage` adapter, since one that could read/write an `httpOnly` cookie +from client JS would defeat the point of `httpOnly`. + ## Per-call: skip auth ```ts @@ -91,4 +148,30 @@ auth: { strategy: 'bearer', getToken: serverTokenFromCookie('access_token') } Cache and dedup keys include an **auth fingerprint**, so two users with different tokens never share a cached or deduped response. See [caching](./caching.md) and [deduplication](./deduplication.md). + +## Logout: clearing the cache + +On the same device, a stale cache entry scoped to a now-logged-out user can +otherwise linger until it naturally expires. Clear it wherever your app +already handles session end — `client.cache.clear()` clears every layer (the +in-memory L1, an optional persistent L2, and the tag index) in one call, so +there's no second, partial clear path to keep in sync: + +```ts +auth: { + strategy: 'oauth2', + // ... + onRefreshFailed: async (error) => { + await api.cache.clear() + redirectToLogin() + }, +} + +// and/or a user-initiated "log out" button, which isn't preceded by a failed refresh: +async function logout() { + await api.cache.clear() + await tokenStorage.clearTokens() // if using a TokenStorage adapter + redirectToLogin() +} +``` diff --git a/docs/cache-persistence.md b/docs/cache-persistence.md index 452e7ca..490a5e9 100644 --- a/docs/cache-persistence.md +++ b/docs/cache-persistence.md @@ -34,10 +34,45 @@ const api = createClient({ ## IndexedDB (browser persistence) ```ts -cache: { persistentStore: createIndexedDbStore({ /* dbName, storeName */ }) } +cache: { + persistentStore: createIndexedDbStore({ + dbName: 'my-app-cache', + version: 1, // bump when changing storeName or migrating + maxEntries: 500, // proactively evicts oldest ~10% once reached + onQuotaExceeded: (error) => console.warn('cache quota exceeded', error), + onStoreError: (error, { op, key }) => reportToMonitoring(error, op, key), + }), +} ``` -Cached GET responses survive a page reload. +Cached GET responses survive a page reload. `onQuotaExceeded`/`onStoreError` +are diagnostic hooks only — a write still resolves (swallowed) either way; +they exist so "IndexedDB is full/down" isn't indistinguishable from "cache is +just cold." + +### At-rest encryption + +```ts +cache: { + persistentStore: createIndexedDbStore({ + encrypt: { + encrypt: (plaintext) => myCipher.encrypt(plaintext), + decrypt: (ciphertext) => myCipher.decrypt(ciphertext), + }, + }), +} +``` + +No default cipher or key management is shipped — where the key comes from +and whether it survives a reload is inherently application-specific, and +getting that wrong is worse than not offering encryption at all. **Know what +this does and doesn't protect against:** it protects against casual +inspection of browser storage (devtools, another origin somehow reading it +via a bug). It does **not** protect against the page's own JS — code running +on your page can call `decrypt` itself, by definition. A key held only in +memory means the cache doesn't survive a reload, which may defeat the point +of IndexedDB persistence in the first place — that tension is real; there's +no way around it, only tradeoffs to pick from. ## Redis (shared server cache) @@ -45,12 +80,193 @@ Cached GET responses survive a page reload. import { createClient as createRedis } from 'redis' const redis = createRedis(/* ... */); await redis.connect() -cache: { persistentStore: createRedisStore(redis /*, { prefix, ttlSeconds } */) } +cache: { + persistentStore: createRedisStore(redis, { + keyPrefix: 'myapp:', + onStoreError: (error, { op, key }) => reportToMonitoring(error, op, key), + // serializer: { stringify, parse }, // e.g. compression — parse must stay JSON.parse-safe + }), +} ``` The store takes your already-connected client, so this package never depends on `redis` directly. +### Real `clear()` via SCAN + +By default `clear()` is a no-op (namespace-wide deletion needs SCAN, which +varies by client — this is the existing, unchanged default). Wire `scanKeys` +on your client object to make it real: + +```ts +cache: { + persistentStore: createRedisStore( + { + ...redis, + scanKeys: async function* (pattern) { + for await (const key of redis.scanIterator({ MATCH: pattern })) yield key; + }, + }, + { keyPrefix: 'myapp:' }, + ), +} +``` + +Every key `scanKeys` yields is verified to actually sit inside this store's +own `keyPrefix` before deletion — a buggy `scanKeys` implementation can't +delete an unrelated application key sharing the same Redis instance. + +## Filesystem (long-running server, trade RAM for disk) + +```ts +cache: { + persistentStore: createFileSystemStore({ + dir: '/var/cache/my-app', // must NOT be inside a web-server-served static path + maxSizeBytes: 100 * 1024 * 1024, + onStoreError: (error, { op, key }) => reportToMonitoring(error, op, key), + }), +} +``` + +Same shape as the Next.js data-cache (`.next/cache`) and nginx `proxy_cache` — +trades RAM for disk on a **long-running Node server process**, and survives a +restart (unlike `mode: 'l1-only'`). Node-only: resolves to a safe no-op store +(never throws at construction) if `node:fs` isn't usable, e.g. if this module +were ever reached from a browser bundle — which it isn't, since +`cache-stores` is its own build entry, never bundled into `/browser`. + +Does **not** fit serverless/edge (the filesystem is ephemeral or absent +there — Lambda `/tmp`, Vercel functions, Workers). Does **not** solve +cross-instance staleness (each instance has its own disk) — don't reach for +a network filesystem (NFS/EFS) as a workaround for that; it reintroduces +cross-instance coordination with worse latency and file-locking semantics +than [Redis pub/sub](#multiple-server-instances-cross-instance-invalidation) +already gives you. + +**Path safety:** every cache key is SHA-256 hashed into a fixed-length hex +filename before touching the filesystem — a hostile or malformed key +(however derived, including via [`cacheKeyParts`](./caching.md#scoping-cache-by-more-than-the-url)) +can never escape `dir` or reach an unintended file. Writes are atomic +(write to a `.tmp` file, then rename) so a crash mid-write never corrupts a +read. Files default to `0o600` (owner-only) — configurable via `fileMode`. +Accepts the same `encrypt` (`CacheCipher`) option as `createIndexedDbStore` +for at-rest encryption, with the same caveats. + +## Multiple server instances (cross-instance invalidation) + +A single Redis store already shares *data* across instances (L2). It does not, +by default, tell sibling instances to evict their own **in-memory L1** copy +when one instance invalidates a [tag](./caching.md#tag-based-invalidation) — +so a mutation handled by instance A can leave a stale L1 entry on instances B +and C until TTL expiry. Opt into pub/sub broadcast to fix that: + +```ts +import { createClient as createRedis } from 'redis' + +const redis = createRedis(/* ... */); await redis.connect() +// Most Redis clients need a DEDICATED connection for subscribe mode. +const redisSub = createRedis(/* ... */); await redisSub.connect() + +cache: { + persistentStore: createRedisStore( + { ...redis, subscribe: (channel, onMessage) => redisSub.subscribe(channel, onMessage) }, + { crossInstance: true }, + ), +} +``` + +Off by default (no silent behavior change, and it costs a dedicated +connection). Only tags/keys/a clear signal ever go over the wire — never +cached data, headers, or auth material. The Redis instance/channel should be +reachable only by your own servers; anyone who can publish to it can force +cache evictions on every instance (an availability nit, never a data leak). + +## Constrained hosts / high traffic, low memory + +`maxSize` bounds the L1 cache by entry count. On a small VM, a handful of +large responses can matter more than count. Bound by estimated bytes too, or +skip local memory almost entirely in favor of Redis: + +```ts +const api = createClient({ + baseURL, + cache: { + maxSizeBytes: 10 * 1024 * 1024, // 10MB soft cap on L1, alongside maxSize + mode: 'l2-only', // tiny shadow L1, everything else lives in Redis + persistentStore: createRedisStore(redisClient), + memoryPressure: { thresholdMb: 400 }, // proactively evict before OOM (Node only) + }, +}); +``` + +- `maxSizeBytes` — an approximate (JSON-length) byte cap; whichever of it or + `maxSize` is hit first triggers LRU eviction. +- `mode: 'l2-only'` — keeps only a small, fixed-size shadow L1 (just enough + to avoid a network round-trip on an immediately-repeated read) in front of + `persistentStore`, which is required in this mode. `'l1-only'` is the + inverse: ignore a configured `persistentStore` entirely. +- `memoryPressure` — Node-only; periodically checks `process.memoryUsage().rss` + and proactively evicts oldest entries ahead of the other limits. No-ops + (feature-detected) on edge/browser runtimes. + +None of these change how entries are *keyed* — cross-tenant/auth isolation +via `computeCacheKey` is unaffected; this only changes where/how much is +kept in memory. + +## Monitoring cache backend health + +Both `createIndexedDbStore` and `createRedisStore` silently degrade to "as +if the entry wasn't there" on a backend failure by default — you can't tell +"Redis is down and every request is a cache miss" from "cache is just cold" +without `onStoreError`: + +```ts +const onStoreError = (error: unknown, { op, key }: { op: string; key?: string }) => { + metrics.increment('cache.store_error', { op }) + logger.warn('cache backend error', { op, key, error }) +} + +cache: { persistentStore: createRedisStore(redis, { onStoreError }) } +``` + +It fires in *addition* to (never instead of) the existing swallow-and-degrade +behavior — the call's own return value is unchanged. The payload is always +`(error, { op, key? })` — never the cache entry's `data`, so this can't +become a second channel for cached response bodies to leak through logging. + +## Circuit breaker for a failing L2 + +Without a circuit breaker, every request still attempts the persistent +store and pays its full timeout before falling through — repeatedly, for as +long as an outage lasts. Opt in to stop attempting L2 during a cool-down +once failures pile up: + +```ts +cache: { + persistentStore: createRedisStore(redis), + circuitBreaker: { failureThreshold: 5, cooldownMs: 30_000 }, + onStoreError: (error, { op }) => { + if (op === 'circuit-open') alertOncall('cache L2 circuit opened') + if (op === 'circuit-close') logger.info('cache L2 circuit closed') + }, +} +``` + +After `failureThreshold` consecutive L2 failures, calls serve L1-only for +`cooldownMs` — the same degraded-but-safe behavior L2 failures already fall +back to, just without the repeated timeout cost. After cooldown, one probe +attempt runs: success closes the circuit, failure reopens it for another +cooldown window (so a permanently-down backend never quietly disables +persistence forever with no way to recover without a restart). + +## Schema-version cache busting on deploy + +When using [runtime schema mode](./codegen.md), every cached entry captures +the active OpenAPI schema hash. A deploy that changes response shapes bumps +that hash — any entry written under the old hash is treated as a miss +(evicted, refetched) rather than served to a client now expecting the new +shape. This is automatic; there's nothing to configure. + ## Layering explicitly `createLayeredCacheStore(...)` composes multiple stores (e.g. IndexedDB in front diff --git a/docs/caching.md b/docs/caching.md index 778db74..2bd6a4f 100644 --- a/docs/caching.md +++ b/docs/caching.md @@ -44,6 +44,100 @@ api.cache.invalidate('users.*') // glob invalidation (* wildcard) await api.users.list(params, { cache: { bust: true } }) ``` +**Dry run before a glob invalidation:** `api.cache.preview('users.*')` +returns the keys `invalidate('users.*')` would remove, without removing +them — a typo'd pattern (e.g. `user.*` matching more than intended) is +cheap to catch before it wipes cache entries. + +## Negative caching (cache "not found" responses) + +Repeated lookups for a resource that doesn't exist normally hit the network +every time. Cache specific error statuses instead, with their own (usually +shorter) TTL: + +```ts +cache: { cacheableStatuses: [404], negativeTtl: 5_000 } +``` + +A cache hit on a negative entry still **rejects the call**, exactly like a +live request would — this only saves the network round-trip, it never turns +an error into a success. Scoped by the same tenant/auth-fingerprinted key as +any other entry, and cleared by [tag invalidation](#tag-based-invalidation) +like any other entry too. + +## ETag / conditional requests + +When a cached entry captured an `ETag` response header, the next +revalidation fetch automatically sends `If-None-Match`. A `304` keeps the +existing cached `data` (refreshing only `storedAt`/`expiresAt`) instead of +re-parsing a would-be-identical body. No config needed — this activates +automatically whenever the server sends `ETag`. + +## Metrics + +```ts +api.cache.getStats() // { hits, misses, size, hitRate } +``` + +Pure aggregation of the same `onCacheHit`/`onCacheMiss` firings above — no +extra hook to wire up. `hitRate` is `0` at zero requests (no division by zero). + +## Tag-based invalidation + +Tag a GET response, then invalidate by tag after a mutation — clears every +cached copy of the resource **regardless of which auth scope cached it** +(fixes stale GETs after an update, and same-VM staleness between two users): + +```ts +// auto-method descriptor +users: { + getUser: { method: 'GET', path: '/users/{id}', cacheTags: (args) => [`user:${args.pathParams.id}`] }, +} + +// ad-hoc, per call +await api.users.getUser({ id: '123' }, { cache: { tags: ['user:123'] } }) +await api.users.updateUser({ id: '123' }, body, { cache: { invalidatesTags: ['user:123'] } }) + +// manually, e.g. from a webhook handler +api.cache.invalidateTags(['user:123']) +``` + +Tags are exact-match labels used only to drive invalidation — never to gate +read access to cached data — so it's safe to derive one from user-controlled +input (e.g. a resource id); the worst case is an unnecessary eviction, never +a cross-scope data read. + +## Scoping cache by more than the URL + +By default a cache key is `method + url + tenantId + authFingerprint`. That's +not enough when a same-URL endpoint's response legitimately depends on +something else — e.g. an admin dashboard viewing "as" a target user, or a +multi-workspace app where the active workspace comes from a header/param +rather than the URL path. `cacheKeyParts` adds dimensions on top of the +built-in scoping, safely: + +```ts +// per auto-method descriptor — computed from the resolved call input +dashboard: { + getSummary: { method: 'GET', path: '/dashboard/summary', cacheKeyParts: (args) => ({ workspaceId: args?.query?.workspaceId }) }, +} + +// ad-hoc, per call +await api.dashboard.getSummary(undefined, { cache: { cacheKeyParts: { workspaceId: 'ws_123' } } }) +``` + +Unlike `keyResolver` (a full override that replaces key derivation entirely +— easy to accidentally drop tenant/auth scoping while doing so), +`cacheKeyParts` can only ever **add** dimensions on top of the pipeline's own +scoping, which stays untouched. **If your endpoint's response depends on a +value, that value MUST appear either in the URL or in `cacheKeyParts`, or +responses can leak across contexts** — two admins "viewing as" different +users would otherwise share one cached response. A descriptor's +`cacheKeyParts(args)` throwing disables caching for that one call (fails +closed) rather than silently omitting the extra scoping. See +[multi-tenancy](./multi-tenancy.md) for the related tenant/auth scoping this +extends. + ## Cache events / hooks ```ts diff --git a/docs/getting-started.md b/docs/getting-started.md index 596cbb2..fa49b61 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -81,14 +81,17 @@ cd examples/react-vite && pnpm dev | Function | When to use | | --- | --- | | `createClient(config)` | Quick start, hand-written modules, no OpenAPI spec needed | -| `createTypedClient()(config, generatedModules)` | Full end-to-end type-safety from an OpenAPI spec (see [codegen](./codegen.md)) | +| `createTypedClient()(config, descriptors)` | Full end-to-end type-safety — `Ops` from codegen ([codegen](./codegen.md)) **or hand-written** ([manual types](./manual-types.md)) | -The examples use `createTypedClient` because they generate types from a spec. The +The examples use `createTypedClient` with codegen because they generate types +from a spec — but codegen isn't required for full type-safety. No OpenAPI +spec yet? See [full type-safety without codegen](./manual-types.md). The [modules & methods](./modules-and-methods.md) page covers both. ## Where to next - [Mental model](./mental-model.md) — understand the pipeline before going deeper - [Modules & methods](./modules-and-methods.md) — how to declare endpoints +- [Full type-safety without codegen](./manual-types.md) — hand-write `Ops`, same guarantees - [Configuration reference](./configuration.md) — every option diff --git a/docs/manual-types.md b/docs/manual-types.md new file mode 100644 index 0000000..c567033 --- /dev/null +++ b/docs/manual-types.md @@ -0,0 +1,113 @@ +# Full type-safety without codegen + +[← Docs index](./README.md) + +`createTypedClient()(config, descriptors)` gives full end-to-end +type-safety — but `Ops` doesn't have to come from codegen. It's a plain type +parameter; hand-write it and you get exactly the same inference codegen's +generated `api.types.ts` would give you, with zero build step. This is a +first-class path, not a fallback for when codegen "doesn't apply yet." + +## The shape `Ops` must have + +Codegen emits one entry per `operationId` shaped like this (from +`src/codegen/typeEmitter.ts`'s `emitOperationEntry`): + +```ts +interface OperationsMap { + [operationId: string]: { + params: Record; // path params + query: Record; // query params + body: unknown; // request body, or `never` if none + response: unknown; // success response body + }; +} +``` + +Every field is present (codegen always emits all four; hand-authored `Ops` +can omit fields it doesn't need — `createTypedClient`'s generics fall back to +loose defaults for anything missing). Mirror this shape exactly so a project +that starts manual and later adopts codegen (or vice versa) gets no surprise +type errors. + +## Worked example + +```ts +import { createTypedClient, createModuleDefiner } from '@developerehsan/api-client' + +// 1. Hand-write the operations map — same shape codegen would emit. +interface MyOperations { + getUser: { params: { id: string }; response: { id: string; name: string } }; + updateUser: { + params: { id: string }; + body: { name?: string }; + response: { id: string; name: string }; + }; +} + +// 2. Hand-write module descriptors — same shape as generated `api.modules.ts`. +const descriptors = { + users: { + getUser: { method: 'GET', path: '/users/{id}', operationId: 'getUser' }, + updateUser: { method: 'PATCH', path: '/users/{id}', operationId: 'updateUser' }, + }, +} as const + +// 3. Full type-safety, zero codegen. +const api = createTypedClient()( + { baseURL: 'https://api.example.com', openapi: { mode: 'runtime' } }, + descriptors, +) + +const user = await api.users.getUser({ id: '1' }) // typed, no `unknown` +await api.users.updateUser({ id: '1', body: { name: 'Rex' } }) +``` + +That's the whole recipe — no extra helper needed. `createTypedClient`'s +curried call (`()(config, descriptors)`) exists because +TypeScript can't partially infer a single call, not because of anything +codegen-specific; it's exactly as ergonomic for a hand-written `Ops`. + +## Method-name autocomplete on a known module + +`config.modules` is deliberately loose (an open index type — see +["Config is the final source of truth"](#config-is-the-final-source-of-truth) +further down) so overrides always win. That means `config.modules.users.methods` +doesn't autocomplete method *names* by default. Opt into +`createModuleDefiner` for that, same as with generated descriptors: + +```ts +import { createModuleDefiner } from '@developerehsan/api-client' + +const defineModule = createModuleDefiner() + +const users = defineModule('users', { + methods: { + // `input.id` is typed from MyOperations['getUser']; return shape is free + getUser: async (ctx, input) => { + const res = await ctx.request({ method: 'GET', path: '/users/{id}', pathParams: input }) + return res.data + }, + }, +}) + +const api = createTypedClient()( + { baseURL: 'https://api.example.com', openapi: { mode: 'runtime' }, modules: { users } }, + descriptors, +) +``` + +## Config is the final source of truth + +Whether `Ops`/descriptors are hand-written or generated, `config.modules` +merges over them **per method** — a custom method's return type always wins, +and the type stays intact after a regenerate. See +[module config = source of truth](./modules-and-methods.md) for the full +override semantics; nothing about that changes when you skip codegen. + +## When to reach for codegen anyway + +Hand-writing `Ops` is fine for a handful of operations. Once you have a real +OpenAPI spec, [codegen](./codegen.md) keeps `Ops` and the descriptor map in +sync with the backend automatically — switching later costs nothing, since +both paths produce the exact same shape. diff --git a/docs/multi-tenancy.md b/docs/multi-tenancy.md index cdffc4d..2c5fa58 100644 --- a/docs/multi-tenancy.md +++ b/docs/multi-tenancy.md @@ -26,6 +26,29 @@ fine). If `getTenantId` throws, a `ConfigurationError` is raised before the call Cache/dedup keys include the tenant id, so tenants never see each other's data — see [caching](./caching.md) and [deduplication](./deduplication.md). +**Same VM, two identities, one stale cache after a mutation:** the cache key +includes the tenant/auth scope, so a GET from user A and the same GET from +user B are stored under different keys on the very same process. A plain +`invalidate(pattern)` only clears the key the caller happens to know about — +if A's admin update only invalidates A's key, B's differently-scoped copy of +the same resource stays stale. [Tag-based invalidation](./caching.md#tag-based-invalidation) +fixes this: a tag maps to every key filed under it, independent of scope, so +one `invalidatesTags` clears every tenant/user's cached copy in one pass. + +**Multiple server instances:** the tag index above is per-process — it does +not by itself reach a sibling instance behind a load balancer. See +[cache persistence → multiple server instances](./cache-persistence.md#multiple-server-instances-cross-instance-invalidation) +for the opt-in Redis pub/sub broadcast that closes that gap. + +**Same URL, different scope beyond tenant/auth:** tenant/auth scoping covers +"which tenant/user is asking," not "which target resource/workspace they're +looking at" — an admin dashboard viewing "as" a specific user, or a +multi-workspace app carrying the active workspace in a header rather than +the URL, needs an extra key dimension on top. See +[caching → scoping cache by more than the URL](./caching.md#scoping-cache-by-more-than-the-url) +(`cacheKeyParts`) — the safe way to extend this same scoping mechanism +without reimplementing it by hand via `keyResolver`. + ## Server-side (Next.js RSC / concurrent requests) `AsyncLocalStorage` keeps concurrent server requests isolated: diff --git a/docs/plan/01-tag-based-invalidation.md b/docs/plan/01-tag-based-invalidation.md new file mode 100644 index 0000000..2dab748 --- /dev/null +++ b/docs/plan/01-tag-based-invalidation.md @@ -0,0 +1,297 @@ +# 01 — Tag-based cache invalidation + +## Problem + +Two real complaints map to the same root cause: + +1. **SSR apps see stale GET data after an update/delete.** A GET response is + cached under a key derived from `method + url + tenant + authFingerprint` + (`computeCacheKey`, `packages/core/src/utilities/cache.ts:119-128`). + Nothing links that cached entry to the mutation that made it stale. The + only invalidation tool today is `client.cache.invalidate(globPattern)` + (`createClient.ts:1195-1202`), which requires the developer to know and + hand-write the exact key shape — error-prone, and nothing enforces it gets + called at all. +2. **Same VM, two different users, one stale.** An admin fetches user 123 + (cached under a key scoped to the admin's `authFingerprint`) and a + regular user also fetches user 123 (cached under a **different** key, + scoped to their own `authFingerprint` — this is intentional, see + `store.types.ts:7-10`, cross-tenant isolation). When the admin updates + user 123 and invalidates, only the admin's key is known to whoever wrote + that invalidate call. The other user's differently-scoped key is + untouched — stale data persists for them, on the very same process. + +Both are solved by the same mechanism: **let a cached entry be tagged with +one or more logical labels, and let a mutation declare which labels it +invalidates.** The tag-to-key lookup is global (per-process), independent of +which auth scope owns each key, so one `invalidatesTags` on a mutation clears +every scoped copy in one pass — no Redis, no cross-instance machinery +required for this part (that's file `02`). + +## Current state (reference) + +- `CacheConfig` — `packages/core/src/types/cache.types.ts:54-99`. +- `CacheEntry` — `cache.types.ts:23-36`. +- `CacheStore` interface + `createCache()` — `packages/core/src/utilities/cache.ts:12-102`. LRU Map, `invalidate(pattern)` does glob-to-regex over keys only. +- Cache read/write in the pipeline — `packages/core/src/factory/createClient.ts:710-804` (`cacheEligible`, `cacheKey`, `fetchThrough`, `produce`). +- `client.cache` public surface — `createClient.ts:1195-1202` (`invalidate`, `clear`, `get`). +- Per-call config — `PerCallConfig.cache` — `types/config.types.ts:640-645` (currently `{ enabled?, ttl?, bust? }`). +- Method definitions flow through `factory/createModuleProxy.ts` and `factory/createTypedClient.ts` (per-method config passthrough, see `CLAUDE.md`). + +## Design + +### 1. Extend `CacheEntry` and `CacheConfig` with tags + +```ts +// cache.types.ts +export interface CacheEntry { + key: CacheKey; + data: T; + status: number; + headers: Record; + storedAt: number; + expiresAt: number; + /** Logical labels this entry is filed under, for tag-based invalidation. */ + tags?: string[]; // NEW +} +``` + +`CacheConfig` itself doesn't need a `tags` field — tags are a **per-method** +concern (what does *this* endpoint's response represent), not a global/module +default. They belong on `PerCallConfig` and, more importantly, on the method +descriptor so codegen'd and hand-defined methods can declare them once, +not on every call site. + +### 2. New per-method descriptor fields + +Wherever a method is described today (generated `api.modules.ts` descriptors, +`defineModule`/`createModuleDefiner` custom methods — see `factory/createModuleProxy.ts`), +add two optional fields: + +```ts +/** + * Passed as the third argument to `invalidatesTags`/`cacheTags` — gives the + * callback enough context to make an informed decision without having to + * re-derive scoping information the pipeline already resolved. + */ +interface CacheCallbackContext { + tenantId?: string; + authFingerprint?: string; + /** The fully-resolved config for this call (global -> module -> per-call merge). */ + resolvedConfig: Readonly; +} + +interface MethodDescriptor { + // ...existing fields (method, path, etc.) + /** + * Tags this response is cached under. Static list, or computed from the + * resolved request args/path params (e.g. the resource id). + */ + cacheTags?: (args: unknown, ctx: CacheCallbackContext) => string[]; + /** + * On success, tags to invalidate across the WHOLE process (all auth + * scopes). Receives the mutation's own response body as `result` so the + * invalidation set can depend on server-returned data (e.g. a bulk-update + * endpoint that returns the list of affected ids), not just the request + * args. Return as many tags as needed — this already supports invalidating + * multiple tags per call; there's no separate "multi-tag" mode. + */ + invalidatesTags?: (args: unknown, result: unknown, ctx: CacheCallbackContext) => string[]; +} +``` + +Both are plain functions so a single descriptor works for parameterized +resources: `cacheTags: (args) => ['user:' + args.userId]`. Because +`invalidatesTags` already returns `string[]`, a bulk mutation naturally +invalidates several tags in one call — +`invalidatesTags: (args, result) => result.affectedIds.map((id) => \`user:${id}\`)` +clears N tags from a single response, no separate multi-tag API needed. + +Also add the one-off escape hatch on `PerCallConfig` for ad-hoc cases that +don't want a descriptor-level declaration: + +```ts +// config.types.ts — PerCallConfig.cache +cache?: { + enabled?: boolean; + ttl?: number; + bust?: boolean; + tags?: string[]; // NEW — tags this specific call's cache entry + invalidatesTags?: string[]; // NEW — tags to clear after this specific call succeeds +}; +``` + +Per-call values and descriptor-level values **compose** (union), they don't +override — a call can add extra tags beyond what its descriptor declares. + +### 3. Global tag index + +New module `packages/core/src/utilities/tagIndex.ts`: + +```ts +export interface TagIndex { + /** Record that `key` is filed under `tags`. */ + track(key: string, tags: string[]): void; + /** Stop tracking `key` (call on delete/evict so the index doesn't leak). */ + untrack(key: string): void; + /** Return every key currently filed under any of `tags`. */ + keysFor(tags: string[]): Set; + clear(): void; +} + +export function createTagIndex(): TagIndex { + const tagToKeys = new Map>(); + const keyToTags = new Map>(); + // track: for each tag, add key to tagToKeys.get(tag); record reverse in keyToTags + // untrack: look up keyToTags.get(key), remove key from each tagToKeys set, delete keyToTags[key] + // keysFor: union of tagToKeys.get(tag) for each requested tag + // ... +} +``` + +This is **pure, synchronous, in-memory, per-process** — exactly the scope +needed to fix problem 2 (two users, one VM). It must NOT be scoped by +tenant/authFingerprint: the whole point is that one tag reaches every scoped +copy. This is safe because the index only ever maps to opaque cache *keys*, +never exposes the cached *data* across scopes — a lookup by tag still only +ever deletes; it never lets one scope's request read another scope's entry. + +### 4. Wire into the pipeline (`createClient.ts`) + +- Instantiate one `tagIndex = createTagIndex()` alongside `cacheStore` + (~line 330-338, where `l1Cache` is built). +- In `fetchThrough` (currently `createClient.ts:744-761`), after + `cacheStore.set(cacheKey, entry)`, also call + `tagIndex.track(cacheKey, resolvedCacheTags)` when `resolvedCacheTags.length > 0`. +- In `createCache()`'s eviction path (`utilities/cache.ts:44-53`) and in + `cacheStore.delete`/`clear`, the tag index must be kept in sync — + cleanest way: wrap `cacheStore` with a small decorator that calls + `tagIndex.untrack(key)` on every delete/evict, rather than modifying + `createCache()` itself (keeps `utilities/cache.ts` dependency-free per its + existing "pure and IO-free" doc comment at the top of the file). +- After a **mutation** method succeeds (non-GET, so this is a new branch + alongside the existing GET-only cache logic at `createClient.ts:686-689`), + resolve `invalidatesTags` (descriptor + per-call union) and if non-empty: + ```ts + const keys = tagIndex.keysFor(resolvedInvalidatesTags); + for (const key of keys) cacheStore.delete(key); // also untracks via the decorator above + ``` + This must run **regardless of which auth scope's key** each entry was + stored under — that's the fix for problem 2. + +### 5. Public API addition — structured result, not a bare count + +Expose tag invalidation on `client.cache` alongside the existing methods +(`createClient.ts:1195-1202`). Return a small structured object instead of a +bare number — "how many were removed" is rarely the only thing a caller +wants; knowing *which* keys/tags were actually affected (e.g. for logging, +or to confirm a specific expected entry was really cleared) is the more +useful shape and costs nothing extra to compute since the pipeline already +has the key set in hand: + +```ts +interface InvalidationResult { + removedCount: number; + removedKeys: string[]; + /** Echoes the tags/pattern that were matched against, for logging/debugging. */ + matchedOn: { tags?: string[]; pattern?: string }; +} + +interface ClientCache { + /** Existing glob-based invalidation — return type upgraded from `void` to `InvalidationResult`. */ + invalidate(pattern?: string): InvalidationResult; + /** NEW — invalidate by one or more tags in a single call. */ + invalidateTags(tags: string[]): InvalidationResult; + clear(): void; // existing, unchanged (clearing everything needs no report) + get(key: string): CacheEntry | undefined; // existing +} +``` + +`invalidate`'s return type moves from `void` to `InvalidationResult` — safe +in practice (constraint #7 in the plan README still holds: no caller today +can be relying on a `void`-typed return value for behavior, since there was +nothing to read). Flag this explicitly in the changeset as a type-only +signature change so consumers pinning strict `void`-returning function types +in their own wrapper code aren't surprised. + +This lets a developer manually trigger invalidation outside the +descriptor/mutation flow too (e.g. from a webhook handler), and log/assert +on exactly what happened: + +```ts +const result = api.cache.invalidateTags(['user:123', 'dashboard:sales']); +console.log(`cleared ${result.removedCount} entries:`, result.removedKeys); +``` + +## Example end-state usage + +```ts +// codegen or defineModule descriptor +users: { + getUser: { + method: 'GET', path: '/users/:id', + cacheTags: (args) => [`user:${args.id}`], + }, + updateUser: { + method: 'PATCH', path: '/users/:id', + invalidatesTags: (args) => [`user:${args.id}`], + }, +} + +// admin updates user 123 — clears EVERY cached copy of user:123, +// regardless of which user's auth scope cached it +await api.users.updateUser({ id: '123' }, patch); + +// ad-hoc, no descriptor support needed +await api.reports.refreshDashboard(undefined, { + cache: { invalidatesTags: ['dashboard:sales'] }, +}); +``` + +## Security constraints specific to this file + +- Tag strings are developer-supplied labels, not user input in the common + case — but if a tag is ever derived from unsanitized request args (e.g. + `args.id` where `id` is user-controlled), that's fine: tags only ever key + into the *invalidation* side, they never gate *read access* to cached data. + Worst case of a malicious tag value is an unnecessary cache eviction (a + minor availability nit), never a cross-scope data read. Document this + explicitly in the `cacheTags`/`invalidatesTags` JSDoc so nobody assumes + tags need input sanitization for confidentiality reasons. +- Do not let `invalidateTags`/`invalidatesTags` accept a pattern/glob by + accident (e.g. don't reuse `globToRegExp` here) — tags must be **exact + string matches** in the index. Mixing glob semantics into tags reintroduces + the "accidentally wipe more than intended" risk file `08`'s dry-run feature + is meant to mitigate for the *pattern*-based `invalidate()`, and tags + should stay simple and predictable instead. + +## Tests to add + +`packages/core/src/utilities/tagIndex.test.ts`: +- track/untrack/keysFor basic correctness, including a key tagged with + multiple tags and a tag shared by multiple keys. +- untrack after track leaves no dangling references (memory leak check — + assert internal maps are empty after untracking every key). + +`packages/core/src/factory/createClient.test.ts` (extend existing suite): +- Two different auth scopes GET the same resource, mutation from one scope + invalidates by tag, assert **both** scoped cache entries are gone (this is + the regression test for problem 2 specifically — same-VM, two users). +- Mutation with `invalidatesTags` clears only entries carrying that tag, not + unrelated cached entries (no over-invalidation). +- Descriptor-level `cacheTags` and per-call `cache.tags` both contribute to + the same entry's tag set (union, not override). +- Eviction (LRU overflow) of a tagged entry removes it from the tag index too + (no stale index entries pointing at a key `cacheStore.get` no longer has). +- `invalidateTags(['a', 'b'])` against entries tagged only `a`, only `b`, both, + and neither — assert `removedKeys`/`removedCount` matches exactly the + union, and `matchedOn.tags` echoes `['a', 'b']`. +- `invalidatesTags` receives the mutation's response body as `result` and a + `ctx` with `tenantId`/`authFingerprint`/`resolvedConfig` populated — + construct a descriptor that computes tags from `result.affectedIds` and + assert every one of several bulk-affected entries is cleared from one call. + +## Docs to update after shipping + +`docs/caching.md`, `docs/multi-tenancy.md` (the two-user scenario is +fundamentally a multi-tenancy/multi-identity concern worth cross-linking). diff --git a/docs/plan/02-cross-instance-invalidation.md b/docs/plan/02-cross-instance-invalidation.md new file mode 100644 index 0000000..d2014da --- /dev/null +++ b/docs/plan/02-cross-instance-invalidation.md @@ -0,0 +1,199 @@ +# 02 — Cross-instance invalidation (Redis pub/sub) + +Depends on: [`01-tag-based-invalidation.md`](./01-tag-based-invalidation.md) (this file broadcasts the same tag-invalidation events across processes). + +## Problem + +File `01` fixes invalidation **within one process** — one Node/edge server +instance, one in-memory tag index, works regardless of how many different +auth scopes cached a copy. + +It does **not** fix invalidation **across processes**. Any real production +deployment with more than one server instance behind a load balancer has N +independent in-memory caches. Admin's request lands on instance A, calls +`invalidatesTags(['user:123'])`, clears instance A's copies. Instances B and +C never hear about it. A user whose request happens to land on B or C keeps +seeing stale data until TTL expiry. + +This is a distributed-systems problem with a standard answer: a shared +broadcast channel. The user explicitly asked for "what Redis provides" — +Redis's `PUBLISH`/`SUBSCRIBE` is exactly the right tool, and the codebase +already has an injected-client Redis store (`createRedisStore`, +`packages/core/src/cache-stores/index.ts:158-188`) to build on. + +## Current state (reference) + +- `PersistentCacheStore` interface — `cache-stores/store.types.ts:19-24` + (`get`/`set`/`delete`/`clear`, all async, no pub/sub concept today). +- `RedisLikeClient` interface — `cache-stores/index.ts:128-132` (`get`/`set`/`del` + only — no `publish`/`subscribe`). +- `createLayeredCacheStore` — `cache-stores/layered.ts:20-52` — L1 sync + + L2 async, write-through, background read-warm on miss. This is the piece + that needs to also react to *externally originated* invalidations. +- `client.cache.invalidate`/new `invalidateTags` (file `01`) — currently only + affect the local process's `cacheStore`/`tagIndex`. + +## Design + +### 1. Extend `RedisLikeClient` with optional pub/sub, additively + +```ts +// cache-stores/index.ts +export interface RedisLikeClient { + get(key: string): Promise; + set(key: string, value: string, ...args: unknown[]): Promise; + del(key: string): Promise; + /** Optional: required only when `crossInstance` invalidation is enabled. */ + publish?(channel: string, message: string): Promise; + /** Optional: required only when `crossInstance` invalidation is enabled. + * Most Redis clients (node-redis, ioredis) require a DEDICATED connection + * for subscribe mode — callers typically pass a second client instance + * here, not the same one used for get/set/del. */ + subscribe?(channel: string, onMessage: (message: string) => void): Promise; +} +``` + +Optional fields keep this backward compatible (constraint #4 in the plan +README) — existing `createRedisStore(client)` callers who never touch +`crossInstance` are unaffected. + +### 2. New broadcast payload — tags/keys only, never data + +```ts +interface InvalidationMessage { + type: 'tags' | 'keys' | 'clear'; + /** Present when type is 'tags' or 'keys'. Opaque strings only. */ + values?: string[]; + /** Origin instance id, to let a publisher ignore its own echo. */ + origin: string; +} +``` + +**Security constraint: the payload must never contain cached response data, +headers, or auth material** — only tags/keys (already-opaque, non-sensitive +identifiers) and the `clear` signal. This is a superset restriction of the +existing `PersistentCacheStore` doc comment (`store.types.ts:7-10`) about +never persisting raw auth material — the same rule extends to anything put +on the wire for pub/sub. + +### 3. `createRedisStore` gains a cross-instance mode + +```ts +export interface RedisStoreOptions { + keyPrefix?: string; + /** + * Enables cross-instance invalidation broadcast via Redis pub/sub. + * Requires `client.publish`/`client.subscribe` (see RedisLikeClient). + * @default false + */ + crossInstance?: boolean; + /** Channel name for invalidation broadcasts. @default 'apicache:invalidate' */ + channel?: string; +} +``` + +When `crossInstance: true`, `createRedisStore` returns a store that also +exposes an event-emitter-like hook consumed by the client factory: + +```ts +export interface PersistentCacheStore { + get(key: string): Promise; + set(key: string, entry: CacheEntry): Promise; + delete(key: string): Promise; + clear(): Promise; + /** NEW, optional. When present, the client factory subscribes to it and + * applies incoming invalidations to the LOCAL L1 cache + tag index. */ + onRemoteInvalidate?(handler: (msg: InvalidationMessage) => void): void; +} +``` + +Again additive — a plain `PersistentCacheStore` implementation without +`onRemoteInvalidate` behaves exactly as it does today. + +### 4. Wire into `createClient.ts` + +- When `resolved.cache.persistentStore?.onRemoteInvalidate` exists, subscribe + once at client construction time (near where `l1Cache`/`cacheStore` are + built, `createClient.ts:330-338`): + ```ts + persistentStore.onRemoteInvalidate?.((msg) => { + if (msg.type === 'clear') cacheStore.clear(); + else if (msg.type === 'keys') for (const k of msg.values ?? []) cacheStore.delete(k); + else if (msg.type === 'tags') { + const keys = tagIndex.keysFor(msg.values ?? []); + for (const k of keys) cacheStore.delete(k); + } + }); + ``` +- When `client.cache.invalidateTags(tags)` (file `01`) or + `client.cache.invalidate()`/`clear()` runs **locally**, and + `crossInstance` is active, also publish the equivalent message so sibling + instances react. Guard against echo loops using the `origin` id (a random + string generated once per client instance) — a subscriber ignores messages + whose `origin` matches its own id, since it already applied the change + locally before publishing. +- All of this must be **fire-and-forget from the caller's perspective** — + `invalidateTags` stays synchronous in its local effect; the network publish + happens after, and its failure (Redis hiccup) must never throw back into + the caller. Mirror the existing `swallow()` pattern in + `cache-stores/layered.ts:21-23`. + +### 5. Config surface + +```ts +// CacheConfig (cache.types.ts) — no new top-level field needed; this rides +// on the existing `persistentStore` + the new RedisStoreOptions.crossInstance. +cache: { + persistentStore: createRedisStore(redisClient, { + crossInstance: true, + // subscribe needs its own connection — most Redis clients require this + }), +} +``` + +Auto-detection idea from the earlier discussion ("auto-on when using Redis") +is explicitly **rejected** here: default `crossInstance` to `false` even +when a Redis store is configured (constraint #7 in the plan README — no +silent behavior change, and pub/sub has a real cost — an extra dedicated +connection — that shouldn't be forced on someone who only wanted Redis as an +L2 cache, not a broadcast bus). Make it a one-line opt-in instead. + +## Security constraints specific to this file + +- **Channel is not a trust boundary.** Anyone who can publish to the + configured Redis channel can trigger cache evictions on every instance. + That's a minor availability concern (forces cache misses, not data + exposure) as long as constraint above (payload = tags/keys only) holds. + Document in the JSDoc that the Redis instance/channel should be on a + network only the app's own servers can reach — this is standard Redis + deployment hygiene, not something the library can enforce, but it must be + called out. +- **Never trust remote invalidation payloads to contain executable or + oversized data.** Cap `values` array length and string length defensively + when parsing an incoming pub/sub message (reject/log and no-op rather than + throw, matching the "L2 errors never break a request" philosophy already + in `layered.ts`). +- Re-run `node scripts/check-browser-bundle.mjs` after this change — Redis + client wiring must stay entirely within `cache-stores/index.ts` and never + get pulled into `browser.js` (it already isn't, since `RedisLikeClient` is + injected, not imported — keep it that way for the pub/sub additions too). + +## Tests to add + +`packages/core/src/cache-stores/redis-crossinstance.test.ts` (new, using a +fake in-memory `RedisLikeClient` with a shared pub/sub bus between two +fake-client instances to simulate two server processes): +- Instance A invalidates tag `user:123`; assert instance B's local cache + entry for a key tagged `user:123` is evicted. +- Instance A's own publish does not cause a redundant local eviction pass + (origin echo check) — assert local eviction happens exactly once. +- Malformed/oversized incoming message is dropped without throwing and + without evicting unrelated keys. +- `crossInstance: false` (default) never calls `publish`/`subscribe` — no + behavior change for existing Redis-store users. + +## Docs to update after shipping + +`docs/cache-persistence.md`, `docs/multi-tenancy.md` — add a "multiple +server instances" section referencing this feature explicitly, since that's +the scenario it exists for. diff --git a/docs/plan/03-memory-aware-caching.md b/docs/plan/03-memory-aware-caching.md new file mode 100644 index 0000000..04e902f --- /dev/null +++ b/docs/plan/03-memory-aware-caching.md @@ -0,0 +1,201 @@ +# 03 — Memory-aware caching for constrained hosts + +Depends on: [`01-tag-based-invalidation.md`](./01-tag-based-invalidation.md) (the tag index must be kept in sync with any new eviction path this file adds). + +## Problem + +`createCache()` (`packages/core/src/utilities/cache.ts:32-102`) bounds itself +by **entry count** (`maxSize`, default 500 — `cache.types.ts:70-75`), not by +memory footprint. On a small VM with high traffic, 500 entries of a few KB +each is fine; 500 entries of large JSON payloads (list endpoints, nested +objects) can push a constrained process (e.g. 512MB-1GB container) into +memory pressure or OOM-kill territory. There is currently no way to bound the +cache by actual bytes, and no way to opt a deployment out of in-memory +caching entirely in favor of an external store only. + +## Current state (reference) + +- `CacheConfig.maxSize` — `cache.types.ts:70-75`, count-based only. +- `createCache()` eviction — `utilities/cache.ts:44-53` (`evictOverflow`, + pure count comparison `store.size > maxSize`). +- `createLayeredCacheStore` — `cache-stores/layered.ts:20-52` — always keeps + an L1 in front of L2; no way today to run L2-only. +- `packages/core` ships runtime-specific bundles (`edge-light`/`node`/ + `browser`) per `CLAUDE.md` — any Node-only API here must be gated the same + way. + +## Design + +### 1. Byte-size estimation and `maxSizeBytes` + +```ts +// cache.types.ts — CacheConfig +export interface CacheConfig { + // ...existing fields + maxSize?: number; // existing, count-based, still the default bound + /** + * Maximum approximate total size (bytes) of all L1 entries. When set, this + * bounds eviction ALONGSIDE `maxSize` — whichever limit is hit first + * triggers LRU eviction. Size is estimated cheaply (JSON length of + * `data` + `headers`), not exact — do not rely on this for hard memory + * guarantees, only for reasonable pressure relief. + * @default optional, unset means only `maxSize` (count) bounds the cache + */ + maxSizeBytes?: number; +} +``` + +Size estimation helper, colocated in `utilities/cache.ts`: + +```ts +function estimateEntrySize(entry: CacheEntry): number { + try { + // Cheap, not exact: JSON.stringify cost is paid once per set(), not per read. + return JSON.stringify(entry.data).length + JSON.stringify(entry.headers).length; + } catch { + return 0; // circular/non-serializable data — don't let estimation throw + } +} +``` + +`createCache()` tracks a running `totalBytes` counter (updated on set/delete/ +evict, not recomputed by walking the whole map each time — O(1) per +operation, matching the existing LRU's complexity). `evictOverflow` gains a +second condition: evict while `store.size > maxSize` **or** +`totalBytes > maxSizeBytes` (when configured). + +### 2. Explicit storage `mode` switch + +For the "small VM, high traffic" case, the real fix is often "don't hold +response bodies in this process's heap at all — go straight to Redis." +`createLayeredCacheStore` today unconditionally keeps L1 in front. Add: + +```ts +// cache.types.ts — CacheConfig +export interface CacheConfig { + // ... + /** + * Storage topology: + * - 'layered' (default) — sync in-memory L1 in front of the optional L2. + * - 'l1-only' — ignore `persistentStore` even if set (mostly for tests). + * - 'l2-only' — skip the in-memory L1 entirely; every read/write goes + * through `persistentStore`, trading latency for near-zero local + * memory use. Requires `persistentStore` to be set. + * @default 'layered' + */ + mode?: 'layered' | 'l1-only' | 'l2-only'; +} +``` + +`l2-only` needs a new store shape since the rest of the pipeline +(`createClient.ts:710-804`) assumes a **synchronous** `CacheStore` (no +`await` on the hot path, by design — see `layered.ts:1-5` doc comment). Two +implementation options, pick during implementation based on how invasive +each is: + +- **(a) Async-aware pipeline path**: thread an `await` through the cache + read/write in `createClient.ts` only when `mode === 'l2-only'`. Slightly + more invasive but keeps a genuinely zero-local-memory mode. +- **(b) Bounded shadow L1**: keep a tiny synchronous L1 (e.g. + `maxSize: 20`) even in "l2-only" mode, just enough to avoid a network + round-trip for immediately-repeated reads, while keeping the memory bound + tiny and explicit. Simpler, no pipeline changes, likely the better + default — recommend starting here and only building (a) if a concrete + latency complaint shows up. + +Document whichever is chosen; don't half-implement both. + +### 3. Optional memory-pressure guard (Node only, opt-in) + +```ts +// cache.types.ts — CacheConfig +export interface CacheConfig { + // ... + /** + * Node-only. When set, periodically checks `process.memoryUsage().rss` + * and proactively evicts (oldest-first) when it crosses the threshold, + * ahead of `maxSize`/`maxSizeBytes` limits. No-ops outside Node (edge/ + * browser) — feature-detected, safe to set anywhere. + * @default optional, unset means no proactive pressure-based eviction + */ + memoryPressure?: { thresholdMb: number; checkIntervalMs?: number }; +} +``` + +Implementation must feature-detect the same way `createIndexedDbStore` +feature-detects `indexedDB` (`cache-stores/index.ts:69`): + +```ts +const hasProcessMemory = + typeof process !== 'undefined' && typeof process.memoryUsage === 'function'; +if (config.memoryPressure && hasProcessMemory) { + const interval = setInterval(() => { + const rssMb = process.memoryUsage().rss / (1024 * 1024); + if (rssMb > config.memoryPressure.thresholdMb) { + // evict oldest N% of entries, reuse the existing LRU eviction path + } + }, config.memoryPressure.checkIntervalMs ?? 30_000); + interval.unref?.(); // never keep the process alive just for this timer +} +``` + +**This must never be reachable from the `/browser` or `/edge-light` builds** +— gate the whole feature behind the `hasProcessMemory` check AND verify via +tsup entry configuration (`packages/core/tsup.config.ts`) that this code +path tree-shakes out of non-Node bundles. Re-run +`scripts/check-browser-bundle.mjs` after implementing. + +## Example end-state usage + +```ts +// small VM, high traffic — cap memory hard, skip local storage mostly +const api = createClient({ + baseURL, + cache: { + maxSizeBytes: 10 * 1024 * 1024, // 10MB soft cap on L1 + mode: 'l2-only', + persistentStore: createRedisStore(redisClient), + memoryPressure: { thresholdMb: 400 }, // proactive relief before OOM + }, +}); +``` + +## Security / correctness constraints specific to this file + +- Byte estimation must never throw on circular references or exotic values + (see the `try/catch` above) — an estimation failure must degrade to "treat + as size 0," never crash a request. +- The eviction path added here (byte-based, pressure-based) must call + `tagIndex.untrack(key)` (from file `01`) for every key it evicts, same as + the existing count-based `evictOverflow` — otherwise the tag index + accumulates references to keys no longer in the store, and + `invalidateTags` would report removing keys that don't exist (harmless but + wrong bookkeeping; fix by co-locating eviction through one shared internal + function rather than duplicating the loop). +- `l2-only` mode must still respect the auth-fingerprint scoping in + `computeCacheKey` — this file changes *where* entries live, never *how* + they're keyed. Don't introduce a second key-computation path. + +## Tests to add + +`packages/core/src/utilities/cache.test.ts` (extend): +- `maxSizeBytes` evicts oldest entries once the running byte estimate + exceeds the configured cap, independent of `maxSize` count. +- Non-serializable `data` (e.g. a value with a circular reference) doesn't + throw during `set()`; estimated size falls back to 0. + +`packages/core/src/cache-stores/mode.test.ts` (new): +- `mode: 'l2-only'` never grows the local L1 beyond its small bound even + under heavy write volume (assert `cacheStore.size()` stays capped). +- `mode: 'l1-only'` ignores a configured `persistentStore` entirely (no + calls into it) — useful for the test suite itself too. + +`packages/core/src/utilities/memoryPressure.test.ts` (new, Node-only test +file): mock `process.memoryUsage`, assert eviction fires once threshold is +crossed and not before; assert the feature no-ops when +`process.memoryUsage` is undefined (simulate edge runtime). + +## Docs to update after shipping + +`docs/cache-persistence.md` — add a "constrained hosts / high traffic, low +memory" section with the example above. diff --git a/docs/plan/04-typesafety-without-codegen.md b/docs/plan/04-typesafety-without-codegen.md new file mode 100644 index 0000000..12a8e96 --- /dev/null +++ b/docs/plan/04-typesafety-without-codegen.md @@ -0,0 +1,194 @@ +# 04 — Full type-safety without codegen + +## Naming note (read first) + +The user-facing complaint referred to "`createApiClient`" vs +"`createTypeSafeApiClient`". Neither name exists literally in the codebase. +The actual two entry points are: + +- **`createClient(config: GlobalConfig): ApiClient`** — + `packages/core/src/factory/createClient.ts:286`. No generics. Every + module/method access on the returned proxy types as `unknown` via the + `ApiClient` interface's open index signature + (`[module: string]: unknown`, `createClient.ts:131`) — this is the + "not fully type-safe" factory the user means. +- **`createTypedClient()(config, descriptors): TypedApiClient`** — + `packages/core/src/factory/createTypedClient.ts:451`. Curried because, + per the existing doc comment (lines 428-430), TS can't partially infer a + single call — `Ops` (the `OperationsMap`) must be supplied explicitly, + `Desc`/`Mods` are inferred from the second call. `Ops` is not a package + type — it's **emitted by codegen** (`src/codegen/typeEmitter.ts:153`, + `emitOperationsMap`) into the consumer's generated `api.types.ts`. This is + the factory that requires codegen output today. + +The rest of this file uses these real names. + +## Problem + +`createTypedClient` gives full type-safety but its `Ops` generic is, in +practice, only ever populated by codegen's generated `OperationsMap`. +Nothing stops a developer from hand-writing an interface shaped like an +`OperationsMap` and passing it manually — but this isn't documented or +designed as a first-class path, so developers who don't want codegen (small +project, no OpenAPI spec, hand-rolled backend) fall back to `createClient` +and lose all type-safety, then hand-write types for every call site anyway. +The DX gap is real even though the underlying mechanism (a generic `Ops` +parameter) already technically permits this — it's a documentation and +ergonomics gap, not fundamentally a missing type-system capability. + +## Current state (reference) + +- `OperationsMap` shape (from `typeEmitter.ts:153`, `emitOperationsMap`) — + need to read this function to document the exact interface shape + (per-operation input/response types keyed by `operationId`) so hand-authors + know exactly what to write. **Action for whoever implements this file:** + read `emitOperationsMap` fully and extract the literal shape into the new + doc/example below — don't guess it from this plan alone. +- `TypedModulesConfig` — `createTypedClient.ts:200-204` — must stay + a loose, open-index type (see the reasoning comment at lines 191-198, + echoed in `CLAUDE.md`) so that per-module config keys aren't "stolen" from + inference. This constraint applies equally whether `Ops` comes from + codegen or is hand-written — nothing in this file changes it. +- `createModuleDefiner()` — `createTypedClient.ts:331` — the + opt-in mechanism for per-method name/input autocomplete (workaround for a + TS inference limit, documented at lines 275-293). Already generic over + `Ops`, already works with a hand-written `Ops` today — this is more of a + discoverability gap than a code gap. +- `GeneratedModuleMap`/`GeneratedMethodDescriptor` — the `Desc` shape, + currently produced by codegen's `api.modules.ts` output + (`as const` value, per `CLAUDE.md`). + +## Design + +This is primarily a **documentation + one small ergonomic helper** change, +not a type-system overhaul — the generic machinery already supports a +hand-written `Ops`. Two concrete additions: + +### 1. A `defineOperations` (or similarly named) type-only helper + +Purely for DX/discoverability — a thin identity function so hand-authoring +`Ops` looks and feels like a first-class workflow instead of "reverse-engineer +what codegen would have produced": + +```ts +// src/index.ts — new export +/** + * Identity helper for hand-authoring an `OperationsMap` without codegen. + * Purely a type-checking aid — returns its argument unchanged. Use this to + * get the same IDE feedback codegen's generated `api.types.ts` would give, + * without running codegen. + * + * @example + * ```ts + * interface MyOps { + * getUser: { input: { id: string }; response: User }; + * updateUser: { input: { id: string; body: Partial }; response: User }; + * } + * const api = createTypedClient()(config, myModuleDescriptors); + * ``` + */ +export function defineOperations(): T { + return {} as T; // type-only marker; not meant to be called at runtime with a value +} +``` + +(Exact utility of a runtime function here is marginal since `Ops` is already +just a type parameter — implementation should evaluate whether this +adds real value over "just write the interface and pass it as ``" +documented clearly, versus being unnecessary ceremony. If it doesn't earn +its keep, skip it and go straight to the docs fix below — don't ship an +API surface nobody needs.) + +### 2. A worked, first-class "no codegen" doc + example (the actual fix) + +The highest-value part of this file is `docs/codegen.md` or a new +`docs/manual-types.md` gaining a complete, copy-pasteable example showing: + +```ts +import { createTypedClient, createModuleDefiner, defineModule } from '@developerehsan/api-client'; + +// 1. Hand-write the operations map — same shape codegen would emit. +interface MyOperations { + getUser: { input: { id: string }; response: { id: string; name: string } }; + updateUser: { input: { id: string; body: { name?: string } }; response: { id: string; name: string } }; +} + +// 2. Hand-write module descriptors (or use defineModule per method). +const modules = { + users: defineModule({ + getUser: { method: 'GET', path: '/users/:id' }, + updateUser: { method: 'PATCH', path: '/users/:id' }, + }), +} as const; + +// 3. Full type-safety, zero codegen. +const definer = createModuleDefiner(); +const api = createTypedClient()( + { baseURL: 'https://api.example.com' }, + modules, +); + +const user = await api.users.getUser({ id: '1' }); // fully typed, no `unknown` +``` + +This example must be **tested**, not just written — add it as an actual +`.test.ts` (or a `.test-d.ts` type-only test if the repo has a convention +for those; check `packages/core` for existing `expectType`/`tsd`-style tests +before introducing a new pattern) so it doesn't silently bit-rot when +`createTypedClient`'s generics change. + +### 3. Only if the above proves insufficient: a convenience wrapper + +If, after writing the doc example, the ceremony still feels heavier than it +should (extra `as const`, curried call, `createModuleDefiner` as a separate +step), consider a single convenience export that bundles the common case: + +```ts +export function createManualTypedClient( + config: Omit, + descriptors: Desc, +): TypedApiClient> { + return createTypedClient()(config, descriptors); +} +``` + +This is a strict subset of `createTypedClient`'s capability (no custom +`modules` override) — treat it as optional sugar, not a required deliverable. +**Do not build this before confirming, via the doc example above, that +`createTypedClient` genuinely can't already do this ergonomically** — adding +a redundant entry point that does the same thing as an existing one, worse, +is the kind of unnecessary abstraction this codebase's own conventions +(`CLAUDE.md`, general engineering guidance) argue against. + +## Constraints specific to this file + +- **Must not touch `TypedModulesConfig`'s loose open-index shape.** This is + called out explicitly in `CLAUDE.md` as a known TS footgun — the type + becoming an intersection instead of a constraint silently drops user + overrides. Any change in this file must be typechecked against the + existing "custom methods and return types always win" test coverage + (find and re-run whatever test currently guards this — likely in + `createTypedClient.test.ts` or `mergeModuleConfig`-adjacent tests) before + and after the change, to prove no regression. +- **`Ops` shape must exactly match what codegen emits**, or the "manual and + generated types are interchangeable" promise breaks and a project that + starts manual and later adopts codegen (or vice versa) gets surprise type + errors. Whoever implements this must read `emitOperationsMap` + (`typeEmitter.ts:153`) in full and mirror its exact output shape in the + manual-authoring docs/example — do not approximate it. + +## Tests to add + +- Type-only test (whatever convention the repo uses — check for existing + `.test-d.ts` files before inventing one) proving a hand-written `Ops` + interface produces the same typed method signatures as a codegen-emitted + one, for at least one operation with path params + body + typed response. +- Regression test re-confirming `TypedModulesConfig` override precedence + (module config wins over generated) still passes — this is really just + "re-run the existing test," called out here so it's not skipped. + +## Docs to update after shipping + +New `docs/manual-types.md` (or a substantial new section in +`docs/codegen.md` titled "Not using codegen?"), linked from +`docs/getting-started.md` as an equally first-class path, not a fallback. diff --git a/docs/plan/05-refresh-token-dx.md b/docs/plan/05-refresh-token-dx.md new file mode 100644 index 0000000..00242e9 --- /dev/null +++ b/docs/plan/05-refresh-token-dx.md @@ -0,0 +1,206 @@ +# 05 — Refresh-token DX: callback alternative + pluggable token storage + +## Problem + +`OAuth2AuthConfig` (`packages/core/src/types/auth.types.ts:154-201`) only +supports **endpoint-based** refresh: `refreshEndpoint: string`, a required +URL the client POSTs to on a 401. Some real setups don't have a plain HTTP +refresh endpoint to point at — refresh logic might live behind a BFF call, +an SDK method (e.g. a third-party auth provider's own refresh function), or +custom signing logic. Today those developers are forced to stand up a fake +internal endpoint just to satisfy the shape, or fork the flow entirely. + +Separately, token *persistence* (`onTokensRefreshed`) is a single callback +with no structure — every developer reinvents "where do I put this" (cookie? +localStorage? server session?) with no guidance or pluggable adapter, unlike +the cache layer which already has a clean adapter pattern +(`PersistentCacheStore`). + +## Current state (reference) + +- `OAuth2AuthConfig` — `auth.types.ts:154-201`: `getAccessToken`, + `getRefreshToken`, `refreshEndpoint` (required), `refreshPayload?`, + `onTokensRefreshed`, `onRefreshFailed`, `concurrentRefreshStrategy`. +- `OAuth2Tokens` — `auth.types.ts` around line 125-135 (`accessToken`, + `refreshToken?`). +- Refresh execution — `packages/core/src/http/interceptors/tokenRefresh.interceptor.ts`: + `performRefresh()` builds a POST request to `config.refreshEndpoint` + unconditionally (there is no branch for a non-HTTP refresh path), mutex via + `inflightByConfig: WeakMap>` keyed by + config identity, respecting `concurrentRefreshStrategy`. + +## Design + +### 1. Make the refresh mechanism a discriminated union: endpoint or callback + +```ts +// auth.types.ts +export interface OAuth2AuthConfig { + strategy: 'oauth2'; + getAccessToken: () => string | null | Promise; + getRefreshToken: () => string | null | Promise; + + /** + * HTTP refresh: POSTs to this URL with `refreshPayload(refreshToken)` + * (or the default `{ refresh_token }` shape) and expects a JSON body + * matching `OAuth2Tokens`. + * Exactly one of `refreshEndpoint` or `refresh` must be set. + */ + refreshEndpoint?: string; + refreshPayload?: (refreshToken: string) => Record; + + /** + * Callback refresh: bypasses HTTP entirely. Return the new tokens (or + * throw/reject to signal failure — caught the same way an HTTP refresh + * failure is). Use this when refresh logic lives in an SDK call, a BFF + * function, or anywhere that isn't "just POST a URL". + * Exactly one of `refreshEndpoint` or `refresh` must be set. + */ + refresh?: (currentRefreshToken: string) => Promise; + + onTokensRefreshed: (tokens: OAuth2Tokens) => void | Promise; + onRefreshFailed: (error: ApiError) => void | Promise; + concurrentRefreshStrategy?: 'queue' | 'race'; +} +``` + +Enforce "exactly one of `refreshEndpoint`/`refresh`" at the type level with a +discriminated union rather than two optional fields, to catch the +misconfiguration at compile time instead of runtime: + +```ts +type OAuth2RefreshMechanism = + | { refreshEndpoint: string; refreshPayload?: (rt: string) => Record; refresh?: never } + | { refresh: (rt: string) => Promise; refreshEndpoint?: never; refreshPayload?: never }; + +export type OAuth2AuthConfig = Omit & + OAuth2RefreshMechanism; +``` + +(Exact mechanical split left to implementation — the requirement is a +compile-time either/or, not a runtime check standing in for it. A runtime +check should still exist as a defense-in-depth `ConfigurationError` throw at +client construction time — never silently pick one if both/neither are set.) + +### 2. Update `performRefresh` to branch on which mechanism is present + +```ts +// tokenRefresh.interceptor.ts +async function performRefresh(config: OAuth2AuthConfig): Promise { + let refreshToken: string | null; + try { + refreshToken = await config.getRefreshToken(); + } catch (cause) { + await config.onRefreshFailed(refreshFailure('getRefreshToken() threw.', cause)); + return false; + } + if (!refreshToken) { + await config.onRefreshFailed(refreshFailure('No refresh token available.')); + return false; + } + + try { + const tokens = config.refresh + ? await config.refresh(refreshToken) // NEW callback path + : await performHttpRefresh(config, refreshToken); // existing path, extracted unchanged + await config.onTokensRefreshed(tokens); + return true; + } catch (cause) { + await config.onRefreshFailed(classifyError({ cause /* ... */ })); + return false; + } +} +``` + +The existing mutex (`inflightByConfig` WeakMap keyed by config identity) and +`concurrentRefreshStrategy` behavior apply identically to both paths — no +change needed there, since both just resolve to "an async function that +returns tokens or throws." + +### 3. Pluggable token storage adapter (mirrors `PersistentCacheStore`) + +New, optional, separate from the auth config itself so it composes cleanly: + +```ts +// types/auth.types.ts (or a new tokenStorage.types.ts) +export interface TokenStorage { + getTokens(): OAuth2Tokens | null | Promise; + setTokens(tokens: OAuth2Tokens): void | Promise; + clearTokens(): void | Promise; +} +``` + +When a developer supplies `tokenStorage` instead of hand-writing +`getAccessToken`/`getRefreshToken`/`onTokensRefreshed`, the client derives +those three from the adapter: + +```ts +auth: { + strategy: 'oauth2', + tokenStorage: createCookieTokenStorage({ name: 'app_session' }), // or createLocalStorageTokenStorage(), or a custom object literal + refreshEndpoint: '/auth/refresh', // or `refresh:` callback + onRefreshFailed: () => redirectToLogin(), +} +``` + +`tokenStorage` and the manual `getAccessToken`/`getRefreshToken`/ +`onTokensRefreshed` triplet are mutually exclusive at the type level (same +discriminated-union technique as above) — don't let both be set silently +with one winning arbitrarily. + +Ship 1-2 reference adapters analogous to `createMemoryPersistentStore`/ +`createIndexedDbStore` in `cache-stores/index.ts` — e.g. +`createMemoryTokenStorage()` (tests, SSR warm-up) and +`createLocalStorageTokenStorage()` (browser only, feature-detected the same +way `createIndexedDbStore` detects `indexedDB`). Do **not** ship a cookie +adapter that sets `httpOnly` cookies from client-side JS — that's +impossible by design (httpOnly cookies aren't readable/writable from JS) and +suggesting otherwise would be a security foot-gun; if a cookie-based adapter +is wanted, document that it must be paired with a server-side route that +sets the cookie, and the client-side adapter only reads/mirrors non-sensitive +state. + +## Security constraints specific to this file + +- **Never log or include tokens in any error/event payload.** `onRefreshFailed` + receives an `ApiError` — audit that error construction path + (`refreshFailure(...)`) never embeds the raw token value in its message. +- **The callback `refresh` function's return value must be validated** the + same way an HTTP JSON response would need shape-checking — a malformed + return (missing `accessToken`) must produce a `SchemaError`/`ConfigurationError`, + not silently proceed with `undefined` as the new access token. +- **`TokenStorage` adapters must not become a second source of truth that + drifts from the in-flight refresh mutex.** `setTokens` should be called + exactly once per successful refresh, from the same place + `onTokensRefreshed` fires today — don't add a second write path. +- Reference storage adapters that touch `localStorage`/cookies are + browser-only concerns — feature-detect and gate exactly like + `createIndexedDbStore`, and confirm via + `scripts/check-browser-bundle.mjs` that none of this pulls Node-only APIs + into the browser bundle (it shouldn't, but the callback `refresh` path + means arbitrary user code can now run in more places — verify it doesn't + change the bundle's own footprint, not the user's). + +## Tests to add + +`packages/core/src/http/interceptors/tokenRefresh.interceptor.test.ts` (extend): +- `refresh` callback path: success updates tokens via `onTokensRefreshed`, + concurrent 401s under `'queue'` still coalesce onto one `refresh()` call + (reuse existing mutex test pattern, just swap the mechanism). +- `refresh` callback throwing surfaces through `onRefreshFailed`, never + bubbles as an unhandled rejection. +- Config with **both** `refreshEndpoint` and `refresh` set — if a runtime + guard is implemented, assert it throws `ConfigurationError` at client + construction, not at first-401 time (fail fast). +- Config with **neither** set — same assertion. + +`packages/core/src/auth/tokenStorage.test.ts` (new): +- Reference adapters round-trip tokens correctly. +- `tokenStorage` + manual getters both set → construction-time + `ConfigurationError`. + +## Docs to update after shipping + +`docs/authentication.md` — add the callback-refresh example and the +`TokenStorage` adapter section, explicitly including the httpOnly-cookie +caveat above so users don't attempt an insecure pattern. diff --git a/docs/plan/06-server-only-boundary.md b/docs/plan/06-server-only-boundary.md new file mode 100644 index 0000000..664e3aa --- /dev/null +++ b/docs/plan/06-server-only-boundary.md @@ -0,0 +1,166 @@ +# 06 — Server-only import boundary guard + +## Problem + +The SSR RPC bridge's entire security model (`SECURITY.md`, `CLAUDE.md` +"SSR RPC bridge" section) depends on backend host/paths/OpenAPI/axios never +reaching the browser. Today that's enforced two ways: (1) architecturally — +`/server` and `/browser` are separate subpath exports with different +contents, and (2) after the fact — `scripts/check-browser-bundle.mjs` greps +the **built** browser bundle in CI. + +Both are correct but both are **discovered late**: a developer who +accidentally imports `@developerehsan/api-client/server` (or a +`defineModule`/config object meant to stay server-side) from a client +component finds out only when CI's grep fails, or worse, only in production +if that check is ever skipped. Next.js solves the equivalent problem for its +own `server-only` package with an **immediate, loud runtime throw** the +moment the module is evaluated in a browser context. This library should +offer the same, opt-in, at the points where a leak is possible. + +## Current state (reference) + +- `packages/core/src/server/index.ts:3` — doc comment: "Import this in a + server-only module (Server Action, route handler)." No runtime enforcement + today, purely documentation. +- `createRpcHandler`, `createNextRpcAction`, `createRpcRouteHandler` — all in + `src/server/` — these are the highest-value places to add the guard, since + they're the actual trust boundary per `CLAUDE.md`. +- `createModuleDefiner`/`defineModule` — used to declare modules, potentially + including server-only ones (e.g. a module wrapping direct DB access) that + must never be evaluated client-side. +- `scripts/check-browser-bundle.mjs` — existing build-time/CI enforcement, + stays as the last-resort net; this file adds an earlier, dev-time net. + +## Design + +### 1. A small internal `assertServerOnly()` helper + +```ts +// packages/core/src/utilities/serverOnly.ts +export function assertServerOnly(where: string): void { + const isBrowser = typeof window !== 'undefined' && typeof window.document !== 'undefined'; + if (isBrowser) { + throw new ConfigurationError( + `${where} must not run in the browser. This exposes backend URLs/paths/` + + `credentials that the SSR RPC bridge is designed to hide — see SECURITY.md. ` + + `If you're seeing this from a bundler, check for an accidental client-side ` + + `import of a server-only module.` + ); + } +} +``` + +Reuses the existing `ConfigurationError` from `src/errors/` (per `CLAUDE.md` +"never throw a bare Error from the pipeline") — this throw happens outside +the request pipeline proper (at module-eval or call time), but staying +consistent with the one error-hierarchy convention is still correct and +keeps `instanceof ApiError` checks working uniformly for consumers. + +### 2. Call it at both module-evaluation time and call time + +- **Eval-time** (Next.js `server-only` style — catches the leak as early as + the bundler evaluates the module, before any function is even called): + add `assertServerOnly('@developerehsan/api-client/server')` at the top of + `src/server/index.ts`, gated behind a config/env check (see opt-out below). +- **Call-time**, for finer-grained cases where a whole module can't + reasonably assert at eval time (e.g. `defineModule` is also usable for + legitimately-client-safe modules — only some are server-only): add an + explicit per-definition flag instead of a blanket module-level throw. + +```ts +// factory/createModuleDefiner.ts (or wherever defineModule lives) +interface DefineModuleOptions { + // ...existing options + /** + * When true, calling ANY method on this module throws immediately if + * evaluated/called in a browser context. Use for modules that wrap + * server-only concerns (direct DB access, secrets, internal services). + * @default false + */ + serverOnly?: boolean; +} +``` + +Enforced inside the module proxy's call path +(`factory/createModuleProxy.ts`) — check once per module (not per call, for +performance) at module construction time when `serverOnly: true` is set. + +### 3. `createRpcHandler`/`createNextRpcAction`/`createRpcRouteHandler` default to enforced + +```ts +export interface RpcHandlerOptions { + // ...existing (expose, etc.) + /** + * Throws immediately if this handler is constructed/invoked in a browser + * context. This is the real trust boundary (see SECURITY.md) — leave this + * on unless you have a specific, documented reason not to. + * @default true + */ + enforceServerOnly?: boolean; +} +``` + +Default **on** here specifically (unlike most new config in this plan which +defaults to "no behavior change") — this is a pure safety net with no +legitimate reason to construct an RPC handler in a browser, so defaulting to +enforced is the secure-by-default choice and does not change behavior for +any correctly-configured existing app (only for one that was already +accidentally broken, where throwing early is strictly better than the +silent data leak it was risking). + +### 4. Escape hatch, explicit and loud + +Some test setups (jsdom-based unit tests importing server code) legitimately +need to bypass this. Provide a single documented override, not a config +maze: + +```ts +enforceServerOnly: false // explicit opt-out, e.g. for jsdom test environments +``` + +Never auto-detect "this looks like a test" — that's fragile and exactly the +kind of implicit behavior that leads to the guard silently not firing when +it matters. Require the explicit `false`. + +## Security constraints specific to this file + +- This is a **defense-in-depth / DX** feature, not a replacement for + `scripts/check-browser-bundle.mjs`. A determined bundler misconfiguration + could theoretically strip the check (e.g. dead-code elimination assuming + `window` is always defined in some exotic build target) — the CI bundle + grep remains the authoritative last line of defense and must not be + removed or weakened when this ships. + ships. +- The thrown error message must **not** itself leak anything sensitive + (e.g. don't interpolate the actual `baseURL` or backend paths into the + error text) — keep it generic, pointing to SECURITY.md instead of + echoing the config that would have leaked. +- `window.document` check (not just `typeof window`) avoids false positives + in non-browser environments that happen to polyfill a bare `window` global + (some edge/worker runtimes) — verify this against the existing + `environment/` detection module (`CLAUDE.md` "edge downgrades axios→fetch") + for consistency; reuse that module's existing environment-detection logic + instead of a second, slightly different ad-hoc check if one already + exists there. + +## Tests to add + +`packages/core/src/utilities/serverOnly.test.ts` (new): simulate `window`/ +`window.document` present → throws `ConfigurationError` with the expected +message; absent → no throw. + +`packages/core/src/server/rpc.test.ts` (extend existing `S#` threat-case +suite — this is a natural fit alongside the deny-by-default allowlist +tests): add an `S#` case for `createRpcHandler` constructed with a mocked +browser-like global, asserting immediate throw; and one asserting +`enforceServerOnly: false` suppresses it. + +`packages/core/src/factory/createModuleProxy.test.ts` (extend): a +`serverOnly: true` module throws on first call in a simulated browser +context; a normal module is unaffected. + +## Docs to update after shipping + +`docs/ssr-rpc-bridge.md` — document `enforceServerOnly` as on-by-default and +the jsdom test opt-out; cross-reference `SECURITY.md`. diff --git a/docs/plan/07-adapter-specific-cache-config.md b/docs/plan/07-adapter-specific-cache-config.md new file mode 100644 index 0000000..138df21 --- /dev/null +++ b/docs/plan/07-adapter-specific-cache-config.md @@ -0,0 +1,181 @@ +# 07 — Adapter-specific cache backend configuration + +Depends on: [`03-memory-aware-caching.md`](./03-memory-aware-caching.md) (adds `mode`, which interacts with backend choice). + +## Problem + +`createIndexedDbStore` and `createRedisStore` (`cache-stores/index.ts:66-125`, +`158-188`) are genuinely different tools with different operational +concerns, but both are exposed as flat, minimal option bags. IndexedDB is a +browser storage quota with its own failure modes (quota exceeded, browser +private-mode restrictions); Redis is a network service with cluster/auth/ +serialization concerns. Today's options (`IndexedDbStoreOptions`: +`dbName`/`storeName`; `RedisStoreOptions`: `keyPrefix`, plus `crossInstance`/ +`channel` from file `02`) don't give enterprises the control they need over +either. + +## Current state (reference) + +- `IndexedDbStoreOptions` — `cache-stores/index.ts:46-51` (`dbName`, `storeName`). +- `createIndexedDbStore` body — `cache-stores/index.ts:66-125` — single DB, + single object store, no quota handling (a `QuotaExceededError` from + `put()` surfaces through the existing `guard()` catch-all as a silent + `undefined`/no-op today — indistinguishable from any other IndexedDB + failure). +- `RedisLikeClient` — `cache-stores/index.ts:128-132` (`get`/`set`/`del` + only; file `02` adds optional `publish`/`subscribe`). +- `RedisStoreOptions` — `cache-stores/index.ts:135-138` (`keyPrefix` only). +- `createRedisStore` — `cache-stores/index.ts:158-188` — `clear()` is + documented as a no-op today ("namespace-wide deletion needs SCAN... left to + the caller", lines 144-145, 184-186) — a real enterprise gap (a "log out + and wipe everything" flow can't rely on it). + +## Design + +### IndexedDB: quota handling + versioning + eviction policy + +```ts +// cache-stores/index.ts +export interface IndexedDbStoreOptions { + dbName?: string; + storeName?: string; + /** + * IndexedDB schema version. Bump when changing `storeName` or otherwise + * needing an `onupgradeneeded` migration. @default 1 + */ + version?: number; + /** + * Called when a `set()` fails due to storage quota being exceeded + * (`QuotaExceededError` or equivalent). Default behavior: swallow (matches + * today's silent no-op via `guard()`). Provide this to detect the + * condition (e.g. to fall back to a smaller in-memory-only cache, or + * surface a warning) rather than have it disappear into `guard()`'s catch-all. + */ + onQuotaExceeded?: (error: unknown) => void; + /** + * When set, `set()` proactively evicts the oldest N% of entries (by + * `storedAt`) before writing, once total entry count exceeds this — + * a coarse client-side LRU for IndexedDB, since the browser doesn't + * enforce one. @default optional, unset means no proactive eviction + */ + maxEntries?: number; +} +``` + +Implementation notes: `onQuotaExceeded` requires distinguishing a quota +error from other IndexedDB failures inside the existing `guard()` helper +(`cache-stores/index.ts:101`) — check `error.name === 'QuotaExceededError'` +(standard DOM exception name) before invoking the callback, and still +resolve to the same fallback value either way (never let this new callback +turn a swallowed failure into a thrown one — that would be a behavior +regression for the write path). + +### Redis: real `clear()`, cluster-awareness, serialization override + +```ts +export interface RedisLikeClient { + get(key: string): Promise; + set(key: string, value: string, ...args: unknown[]): Promise; + del(key: string): Promise; + publish?(channel: string, message: string): Promise; // from file 02 + subscribe?(channel: string, onMessage: (m: string) => void): Promise; // from file 02 + /** + * Optional: enables a real namespace-wide `clear()` via SCAN+DEL instead + * of today's no-op. Most node-redis/ioredis clients expose this as + * `scanIterator`/`scanStream` — the exact shape is client-specific, so + * this is intentionally a caller-supplied function rather than an + * assumed method name. + */ + scanKeys?(matchPattern: string): AsyncIterable; +} + +export interface RedisStoreOptions { + keyPrefix?: string; + crossInstance?: boolean; // from file 02 + channel?: string; // from file 02 + /** + * Custom serializer, e.g. to add compression or a schema-versioned + * envelope. @default JSON.stringify / JSON.parse + */ + serializer?: { stringify: (entry: CacheEntry) => string; parse: (raw: string) => CacheEntry | undefined }; +} +``` + +`clear()` implementation, when `scanKeys` is provided: + +```ts +async clear() { + if (!client.scanKeys) return; // unchanged no-op behavior when not supplied + for await (const key of client.scanKeys(`${prefix}*`)) { + await client.del(key); + } +} +``` + +This keeps the **default** behavior identical to today (no-op) per +constraint #7 (no silent behavior change) — `clear()` only becomes real once +a developer explicitly wires `scanKeys`, which requires them to have +already thought about the SCAN-vs-KEYS production-safety tradeoff the +existing doc comment warns about. + +### Both: a shared `onError` reporting hook + +Neither store gives visibility into backend failures today — both silently +degrade to "as if the entry wasn't there." For an enterprise running this in +production, silent degradation without any observability hook is a real gap +(you can't tell "Redis is down and every request is a cache miss" from +"cache is just cold"). Add a consistent, optional hook to both option types: + +```ts +onStoreError?: (error: unknown, context: { op: 'get' | 'set' | 'delete' | 'clear'; key?: string }) => void; +``` + +Fire it from inside each store's existing catch/guard paths, in addition to +(never instead of) the current swallow-and-degrade behavior. This is the +minimal building block file `08`'s metrics/circuit-breaker ideas can build +on later — keep it generic (an error + context), not backend-specific. + +## Security constraints specific to this file + +- `serializer.parse` runs on data read back from Redis — **never + `eval`/`Function`-based deserialization**; the default and any documented + example must be `JSON.parse` or an equally safe structured format. If a + developer supplies a custom serializer, document the requirement plainly + (their responsibility, but the docs must not lead them toward an unsafe + choice via a bad example). +- `scanKeys` executing arbitrary caller-supplied iteration logic must still + go through the same `k(key)`-prefixed namespace — never `del()` a key + outside this store's own `keyPrefix` namespace, even if the caller's + `scanKeys` implementation returns something unexpected. Validate the + prefix before deleting, don't just trust the pattern match blindly (a + buggy caller-supplied `scanKeys` could otherwise cause this store to + delete unrelated application keys sharing the same Redis instance). +- `onQuotaExceeded`/`onStoreError` payloads must carry the **error and + operation context only**, never the cache entry's `data` — these are + diagnostic hooks, not a second channel for cached response bodies to leak + through logging. + +## Tests to add + +`packages/core/src/cache-stores/indexeddb.test.ts` (extend, using a fake +IndexedDB shim or the existing test harness): `onQuotaExceeded` fires on a +simulated `QuotaExceededError`, doesn't fire on other errors; `maxEntries` +proactively evicts before a write once the threshold is crossed. + +`packages/core/src/cache-stores/redis.test.ts` (extend, using the existing +fake `RedisLikeClient` pattern): `clear()` remains a no-op when `scanKeys` +is absent (regression guard); `clear()` deletes only keys matching this +store's `keyPrefix` when `scanKeys` is supplied, even if the fake +`scanKeys` yields a key outside the prefix (assert it's filtered/rejected, +not deleted). + +`packages/core/src/cache-stores/onStoreError.test.ts` (new): both stores +fire `onStoreError` with the right `op`/`key` on a simulated backend +failure, and the call's own return value (fallback) is unchanged from +today's behavior. + +## Docs to update after shipping + +`docs/cache-persistence.md` — expand the IndexedDB and Redis sections with +these new options and a "monitoring cache backend health" subsection using +`onStoreError`. diff --git a/docs/plan/08-additional-hardening.md b/docs/plan/08-additional-hardening.md new file mode 100644 index 0000000..7aeaaec --- /dev/null +++ b/docs/plan/08-additional-hardening.md @@ -0,0 +1,260 @@ +# 08 — Additional hardening (independent, smaller items) + +Unlike files `01`-`07`, these are **independent of each other** — implement +in any order, skip any that turn out lower-value once the bigger items land. +Each has its own problem/design/security/tests, kept short. + +--- + +## 8.1 Negative caching (cache "not found" responses) + +**Problem:** repeated lookups for IDs that don't exist (deleted records, bad +input, enumeration attempts) hit the network every time — no protection +today since `cacheConfigured` only applies once a response is already +successful and cached (`createClient.ts:710-761` writes to cache on any +successful response, but a 404 still round-trips the network each time if +callers don't treat 404 as cacheable — verify current behavior: does the +existing write-through cache 404s already? If `runNetwork()` throws on 404 +via `classifyError`, the entry never reaches `cacheStore.set` at all — +confirm this by reading the response/error classification path before +assuming negative caching is entirely missing.). + +**Design:** add `cache.cacheableStatuses?: number[]` (default: `[200]` or +whatever the current implicit behavior is) to `CacheConfig`, allowing 404 +(and other explicitly-listed statuses) to be cached with a **separate, +usually shorter** TTL: `cache.negativeTtl?: number`. Store these as normal +`CacheEntry` objects — no new type needed, just a second TTL value applied +when `response.status` is in the negative-cacheable list. + +**Security:** a cached 404 must remain scoped by the same +tenant/auth-fingerprint key as any other entry — never let a negative cache +entry become a way to probe existence across tenants (this falls out +naturally from reusing `computeCacheKey` unchanged, just double-check the +write path doesn't bypass it for error responses). + +**Tests:** 404 gets cached when configured, subsequent identical request +within `negativeTtl` doesn't hit the network (assert via a mock adapter call +count); a later successful creation of that resource plus a +tag-invalidation (file `01`) clears the negative entry too. + +--- + +## 8.2 ETag / conditional request support + +**Problem:** no `If-None-Match`/`ETag` forwarding — every cache-miss/ +revalidation re-transfers and re-parses a full payload even when the server +would happily answer 304. + +**Design:** when a `CacheEntry` has a captured `ETag` response header +(already stored in `entry.headers` today — no schema change needed), a +`network-first` or SWR revalidation fetch should send +`If-None-Match: `. On a `304`, keep the existing cached `data` but +refresh `storedAt`/`expiresAt` rather than treating it as a fresh network +body. This touches `runNetwork()`/`fetchThrough()` in `createClient.ts` +(around lines 744-761) — add the conditional header when a prior entry with +an `ETag` exists for this key, and special-case a `304` response to reuse +`entry.data`. + +**Security:** none beyond what already applies to caching in general — ETag +values are opaque server-issued tokens, not user-controlled input. + +**Tests:** a stored entry with an `ETag` header causes the next +revalidation request to include `If-None-Match`; a `304` response updates +freshness without altering `data`; an entry without a captured `ETag` +behaves exactly as today (no conditional header sent). + +--- + +## 8.3 Cache hit/miss metrics hook + +**Problem:** `resolved.hooks.onCacheHit`/`onCacheMiss` already exist +(`createClient.ts:716-723`) but there's no aggregate view — a developer +wanting a hit-rate percentage has to hand-roll counters in their own hook +implementation. + +**Design:** add `client.cache.getStats(): { hits: number; misses: number; size: number; hitRate: number }` +to the `ClientCache` interface (`createClient.ts:1195-1202`), maintained by +incrementing counters in the existing `emitCacheHit`/`emitCacheMiss` +closures (`createClient.ts:716-723`) — purely additive, no new hook +mechanism needed, just aggregation of what already fires. + +**Security:** none — this is local, in-process, numeric-only telemetry. + +**Tests:** hits/misses increment correctly across cache-first, +network-first, and SWR strategies; `hitRate` computes correctly at 0 +requests (no division by zero) and after a mix of hits/misses. + +--- + +## 8.4 Circuit breaker for a failing persistent (L2) store + +**Problem:** `createLayeredCacheStore` (`cache-stores/layered.ts:20-52`) +already swallows L2 errors per-call (`swallow()`, lines 21-23) so a single +failure never breaks a request — good. But if Redis is fully down, every +single request still attempts an L2 call and pays its full timeout latency +before falling through, repeatedly, for as long as the outage lasts. + +**Design:** wrap the L2 calls inside `createLayeredCacheStore` with a +minimal circuit breaker: after N consecutive L2 failures within a window, +stop attempting L2 calls for a cool-down period (serve L1-only, matching +today's already-safe degraded behavior, just without paying the repeated +timeout cost). Expose the state via the `onStoreError` hook from file `07` +so it's observable (`{ op: 'circuit-open' }` or similar), not silent. + +```ts +export interface LayeredCacheStoreOptions { + circuitBreaker?: { failureThreshold: number; cooldownMs: number }; +} +``` + +**Security:** none directly — this is a resilience/performance feature. Make +sure the cool-down doesn't accidentally cause `set()` writes to silently +stop being attempted forever (re-probe after cooldown, standard +half-open-circuit pattern) — a permanently-open circuit would quietly turn +persistent caching off with no way to recover without a restart. + +**Tests:** N consecutive simulated L2 failures opens the circuit; L1-only +behavior during cooldown (assert the fake L2 client's methods aren't called +during the cooldown window); a probe attempt after cooldown either closes +the circuit (success) or reopens it (failure) — cover both. + +--- + +## 8.5 At-rest encryption for browser-persisted cache (IndexedDB) + +**Problem:** sensitive response data cached in IndexedDB sits in plaintext +in the browser's storage — a real finding in enterprise security reviews +for anything handling PII, even though it's same-origin-protected already. + +**Design:** optional `encrypt` config on `IndexedDbStoreOptions` (file +`07`) accepting a pluggable cipher interface: + +```ts +export interface CacheCipher { + encrypt(plaintext: string): Promise; + decrypt(ciphertext: string): Promise; +} +``` + +Applied around the existing `JSON.stringify(entry)`/`JSON.parse` boundary in +`createIndexedDbStore`'s `set`/`get`. **Do not ship a default cipher/key +management scheme** — key handling (where does the encryption key come +from, how does it survive a page reload) is inherently +application-specific and getting it wrong is worse than not offering it; +ship the interface and clear documentation of the tradeoffs (e.g. a key +held only in memory means cache doesn't survive reload, which may defeat +the point of IndexedDB persistence in the first place — document this +tension explicitly rather than papering over it). + +**Security:** this is the whole point of the feature — get the docs right +about what it does and doesn't protect against (protects against casual +inspection of browser storage / another origin somehow reading it via a +bug; does **not** protect against a compromised page's own JS, which by +definition can call `decrypt` itself). Don't oversell it. + +**Tests:** with a fake reversible cipher, assert stored IndexedDB records +are transformed (not plaintext-equal to the original JSON) and read back +correctly; without `encrypt` configured, behavior is byte-for-byte +unchanged from today. + +--- + +## 8.6 Logout-triggered cache clear + +**Problem:** nothing today hooks "user logged out" to `client.cache.clear()` +(or a tenant/user-scoped subset of it) — privacy gap on shared devices, +and stale-scope entries linger until natural eviction. + +**Design:** the auth layer already knows about session end via +`onRefreshFailed` (existing) and, if file `05` ships, potentially an +explicit `tokenStorage.clearTokens()` call site. Add a documented pattern +(not necessarily new API surface) recommending +`onRefreshFailed: async (e) => { await api.cache.clear(); redirectToLogin(); }`, +and evaluate whether a first-class `auth.onLogout` hook (distinct from +`onRefreshFailed`, since logout isn't always preceded by a failed refresh — +e.g. a user-initiated "log out" button) is worth adding: + +```ts +// AuthConfig (base, shared across strategies) — new optional field +onLogout?: () => void | Promise; // called by the app; client subscribes to auto-clear cache +``` + +More concretely: expose `client.on('logout', handler)` or reuse the existing +event-emitter surface (`createClient.ts` reserved `on`/`off` members) so an +app can do `api.logout()` → fires → client clears cache — but **only if such +an emitter already exists for this purpose**; check `hooks-and-events.md` +and the actual `on`/`off` implementation before adding a new event name, to +keep this consistent with whatever event vocabulary already exists rather +than inventing a parallel one. + +**Security:** this is a privacy-positive feature by construction — the risk +is *not* implementing it consistently (e.g. clearing the tag index but not +a Redis L2 store) — must clear **every** configured layer (L1, L2/persistent +store, tag index) via the existing `client.cache.clear()` which already +routes through `createLayeredCacheStore.clear()` (`layered.ts:44-47`) — +confirm this file's addition doesn't introduce a second, incomplete clear +path. + +**Tests:** triggering the logout path clears L1, L2 (fake persistent +store), and the tag index (file `01`) — assert all three are empty +afterward, not just L1. + +--- + +## 8.7 Schema-version-based cache busting on deploy + +**Problem:** `api.schema.hash` is already generated at build time +(`CLAUDE.md` "OpenAPI: build-time vs runtime") but isn't wired to cache +invalidation — a deploy that changes response shapes can leave old-shaped +cached entries being served to a newly-deployed frontend expecting the new +shape. + +**Design:** embed the current schema hash into every `CacheEntry` at write +time (`schemaHash` field, alongside `storedAt`/`expiresAt`), and check it on +read: if the stored `schemaHash` doesn't match the client's current one, +treat the entry as a miss (evict and refetch) rather than serving +across-version stale data. Wire-up point: `createClient.ts`'s cache +read/write (~710-804) and wherever `schemaCache`/schema hash is already +tracked (`createClient.ts:1228-1229` area, `getSchema()`). + +**Security:** not a security feature per se, but prevents a subtle +correctness bug (stale-shape data silently served) that could mask a +breaking API change — worth having regardless of severity. + +**Tests:** an entry written under schema hash A is treated as a miss once +the client's active hash becomes B; unchanged hash behaves as today. + +--- + +## 8.8 Dry-run / preview for pattern-based invalidation + +**Problem:** `client.cache.invalidate(pattern)` (`createClient.ts:1196-1199`) +executes immediately — a typo'd glob (e.g. `user.*` matching far more than +intended) has no preview step before it wipes cache entries. + +**Design:** add `client.cache.preview(pattern): string[]` returning the +keys that *would* be removed, without removing them — a thin read-only +wrapper around the same `globToRegExp` matching logic already in +`utilities/cache.ts:131-134`, reusable by extracting that regex-matching +loop into a shared, exported helper rather than duplicating it. + +**Security:** none directly — pure ergonomics/safety-net for an already +locally-controlled API (this isn't attacker-reachable; it's a +foot-gun-prevention feature for the developer using the library). + +**Tests:** `preview(pattern)` returns the same set `invalidate(pattern)` +would remove, without actually removing anything (assert cache state is +unchanged after `preview`, and equal-but-then-empty after a subsequent real +`invalidate` with the same pattern). + +--- + +## Suggested sub-ordering within this file + +If picking a subset: **8.6 (logout clear)** and **8.3 (metrics)** are +cheapest and highest-value for an enterprise deployment; **8.1 (negative +caching)** and **8.2 (ETag)** meaningfully cut network load; **8.4 (circuit +breaker)** matters most once file `02`'s Redis dependency is in place; +**8.5 (encryption)** and **8.7 (schema versioning)** are worth doing but +lower urgency than the rest; **8.8 (dry-run)** is trivial and low-risk — +good first PR for someone new to the codebase. diff --git a/docs/plan/09-filesystem-cache-store.md b/docs/plan/09-filesystem-cache-store.md new file mode 100644 index 0000000..826db3f --- /dev/null +++ b/docs/plan/09-filesystem-cache-store.md @@ -0,0 +1,211 @@ +# 09 — Filesystem cache store + +Depends on: [`03-memory-aware-caching.md`](./03-memory-aware-caching.md) (this is really an alternative `mode` target for that problem), [`07-adapter-specific-cache-config.md`](./07-adapter-specific-cache-config.md) (same category as the Redis/IndexedDB adapters). + +## Why this exists + +User request, prompted by two existing real-world precedents worth naming +explicitly since they shape the design: + +- **Next.js server-side data cache** persists fetch/route responses to disk + (`.next/cache`) on a long-running server so the cache survives across + requests without holding everything in process memory, and can survive a + restart. +- **nginx `proxy_cache`** writes upstream responses to disk, keyed by a + configurable key expression, with its own TTL/`inactive` eviction and a + size cap on the cache directory. + +Both are the same shape our `PersistentCacheStore` interface already +generalizes over (`cache-stores/store.types.ts:19-24`) — this file adds a +third concrete adapter (`createFileSystemStore`) alongside +`createIndexedDbStore`/`createRedisStore` (`cache-stores/index.ts`), not a +new abstraction. + +## Where this fits and where it doesn't + +Fits: **long-running Node server process** (VM/container SSR deployment — +exactly the low-RAM/high-traffic scenario from file `03`). Trades RAM for +disk; survives process restarts, which pure in-memory (`l1-only`) cannot. + +Does **not** fit: serverless/edge functions (Lambda `/tmp`, Vercel functions, +Workers) — filesystem is ephemeral there, wiped per cold start, sometimes +entirely absent (edge runtimes). Does **not** solve cross-instance staleness +(file `02`'s problem) — each instance has its own disk. Explicitly do not +recommend a shared network filesystem (NFS/EFS) as a workaround: that +reintroduces cross-instance coordination with worse latency and file-locking +semantics than Redis already gives you (file `02`) — document this +directly so nobody reaches for it as a shortcut. + +## Design + +### Interface — same shape as the other two adapters + +```ts +// cache-stores/index.ts +export interface FileSystemStoreOptions { + /** Directory to store cache files in. Created if missing. @default './.api-client-cache' */ + dir?: string; + /** + * Max total bytes the cache directory may hold before proactive eviction + * (oldest `storedAt` first). Checked lazily on write, not via a background + * scan (avoid heavy `readdir`/`stat` cost on every request; see below). + * @default optional, unset means no proactive size cap (matches the + * network-store adapters' "capacity limits are your infra's job" stance — + * but see the LRU-index approach below for a cheap way to enforce this) + */ + maxSizeBytes?: number; + /** File permission mode for written cache files. @default 0o600 (owner read/write only) */ + fileMode?: number; + onStoreError?: (error: unknown, context: { op: 'get' | 'set' | 'delete' | 'clear'; key?: string }) => void; // same hook as file 07 +} + +export function createFileSystemStore(options: FileSystemStoreOptions = {}): PersistentCacheStore { + // Node-only — feature-detect the same way createIndexedDbStore detects + // `indexedDB` (cache-stores/index.ts:69). Must resolve to a safe no-op + // PersistentCacheStore (never throw at construction time) when `node:fs` + // isn't usable (e.g. accidentally constructed in a browser bundle). +} +``` + +### Key-to-filename mapping (security-critical, see below) + +Never use the raw cache key as a path segment. Hash it — reuse the existing +FNV-1a `hash()` helper's approach or a stronger hash (SHA-256 via +`node:crypto` is fine here since this isn't a hot synchronous path the way +`computeCacheKey` is) to derive the filename: + +```ts +import { createHash } from 'node:crypto'; +function keyToFilename(key: string): string { + return createHash('sha256').update(key).digest('hex') + '.json'; +} +``` + +This guarantees the filename is always a fixed-length hex string — no +possibility of `../` traversal or filesystem-illegal characters reaching +`fs.writeFile`, regardless of what the cache key (which may embed a +developer-supplied `keyResolver` output, or file `10`'s custom key parts) +contains. + +### Write path — atomic, permissioned + +```ts +async set(key, entry) { + const file = path.join(dir, keyToFilename(key)); + const tmp = `${file}.${randomSuffix()}.tmp`; // avoid Date.now()/Math.random() per repo convention if this logic ever runs inside a workflow script; fine in normal runtime code + await fs.writeFile(tmp, JSON.stringify(entry), { mode: fileMode ?? 0o600 }); + await fs.rename(tmp, file); // atomic on POSIX — a crash mid-write never corrupts `file` +} +``` + +Rename-based atomic write is the same trick both Next.js's and nginx's disk +caches rely on — a reader never observes a half-written file. + +### Read path — corrupt-file tolerance + +```ts +async get(key) { + try { + const raw = await fs.readFile(path.join(dir, keyToFilename(key)), 'utf8'); + const parsed = JSON.parse(raw); + return isCacheEntry(parsed) ? parsed : undefined; // reuse isCacheEntry from store.types.ts:27 + } catch { + return undefined; // ENOENT, corrupt JSON, permission error — all treated as a miss + } +} +``` + +### Size cap — lazy, not a background scanner + +A full `maxSizeBytes` enforcement via directory-wide `readdir`+`stat` on +every write would be far too expensive at request scale. Two options, +recommend (a): + +- **(a) Maintain a small sidecar index file** (`_index.json` in `dir`) + tracking `{ filename, size, storedAt }` per entry, updated in-memory and + flushed periodically (debounced write, not per-request) — `set()` checks + the in-memory index total against `maxSizeBytes` and evicts oldest entries + by `storedAt` without touching the filesystem beyond `unlink`. This + mirrors nginx's own approach (an in-memory keys zone backing the on-disk + cache) closely enough to be a familiar model. +- **(b) Skip proactive eviction entirely, document a cron/cleanup script + instead** (delete files older than N days) — simpler, matches "IndexedDB + quota is the browser's job" precedent, acceptable if (a) proves too + complex for the value it adds. Pick based on implementation time budget; + don't ship a half-working index (e.g. one that drifts from actual disk + state after a crash) — a missing feature is better than a subtly wrong one. + +### `clear()` — real, unlike Redis's current no-op + +Unlike `createRedisStore`'s namespace-wide `clear()` (a no-op today per file +`07`, because SCAN semantics vary by client), a filesystem store can list +its own directory directly and delete everything in it — no external +service, no ambiguity: + +```ts +async clear() { + const files = await fs.readdir(dir); + await Promise.all(files.map((f) => fs.unlink(path.join(dir, f)).catch(() => undefined))); +} +``` + +## Security constraints specific to this file + +- **Path traversal**: covered above — filenames are always a hashed, + fixed-format string, never derived directly from user/developer input. + This must be tested explicitly (see below) since it's the single most + important property of this adapter. +- **File permissions**: default `0o600` (owner-only). Document that the + cache directory itself should not be inside a web-server-served static + path (an obvious but real misconfiguration risk — a cache dir accidentally + placed under `public/` would serve cached response bodies, potentially + containing another user's data, to anyone). +- **At-rest sensitivity**: same tension as file `08`'s IndexedDB-encryption + item — cached response bodies land in plaintext on disk. If the same + `CacheCipher` interface from `08.5` is built, this adapter should accept + it too (`encrypt?: CacheCipher`), for consistency across every + disk/browser-persisted adapter rather than bolting encryption onto only + one. +- **Symlink safety**: when resolving `dir`, do not follow a symlink placed + by an untrusted process into a sensitive path — resolve `dir` with + `fs.realpath` once at construction and refuse to proceed (throw + `ConfigurationError`) if the resolved path differs unexpectedly from a + configured allow-list, or at minimum document that `dir` must be a path + the deploying application fully controls (this is a defense-in-depth note, + not expected to be exploitable in the common case, but worth stating). +- Must feature-detect `node:fs`/`node:crypto` availability and resolve to a + safe no-op store otherwise, exactly like `createIndexedDbStore` does for + `indexedDB` — verify via `scripts/check-browser-bundle.mjs` that this + adapter's code, if imported anywhere reachable from `/browser`, doesn't + pull `node:fs` into the browser bundle. It should live in + `cache-stores/index.ts` alongside the others, which is already excluded + from the browser entry per existing conventions — confirm this stays true. + +## Tests to add + +`packages/core/src/cache-stores/filesystem.test.ts` (new, using a temp dir +per test, cleaned up after): +- Round-trip `set`/`get`/`delete`/`clear` correctness. +- A cache key containing `../../etc/passwd`-style content never escapes the + configured `dir` — assert the written file's resolved path is always + inside `dir` (the actual regression test for the path-traversal + constraint above). +- A crash mid-write (simulate by leaving a stray `.tmp` file and no rename) + never causes `get()` to return corrupt data — only the renamed file is + ever read. +- Corrupt JSON on disk (hand-write garbage to a cache file) causes `get()` + to return `undefined`, not throw. +- `maxSizeBytes` (if approach (a) is implemented) evicts oldest entries once + exceeded; unset means no eviction (regression guard, matches other + adapters' "opt-in limits only" default). +- File permission of a written cache file matches `fileMode`/default `0o600` + (platform-permitting; skip on Windows CI if `fs.stat().mode` semantics + differ). +- Constructing the store where `node:fs` is unavailable (simulate) resolves + to a no-op store, never throws at construction time. + +## Docs to update after shipping + +`docs/cache-persistence.md` — new "Filesystem" section alongside IndexedDB/ +Redis, explicitly stating the serverless/edge non-fit and the +"don't use NFS for cross-instance" guidance above. diff --git a/docs/plan/10-custom-cache-key-composition.md b/docs/plan/10-custom-cache-key-composition.md new file mode 100644 index 0000000..8966ee3 --- /dev/null +++ b/docs/plan/10-custom-cache-key-composition.md @@ -0,0 +1,200 @@ +# 10 — Custom cache key composition + +## Problem + +Today there are exactly two ways to influence a cache key: + +1. Do nothing — `computeCacheKey` derives it from + `method + url + tenantId + authFingerprint` + (`packages/core/src/utilities/cache.ts:119-128`). +2. Supply `keyResolver: (request: ApiRequest) => string` + (`CacheConfig.keyResolver`, `cache.types.ts:76-86`) — a **full override** + that replaces the built-in derivation entirely. + +Option 2 is unsafe for the common real case: a developer wants to *add* one +extra dimension to the key (e.g. "this dashboard endpoint's response depends +on which target user/workspace/date-range was selected, not just who's +asking"), but writing a `keyResolver` means reimplementing +tenant/auth-fingerprint scoping by hand — easy to get wrong, and a wrong +implementation silently reintroduces the exact cross-tenant leak +`computeCacheKey`'s scoping exists to prevent (`store.types.ts:7-10`). + +Concretely, the scenario described: a dashboard endpoint like +`GET /dashboard/summary` is the same URL for every viewer, but an admin +viewing "as" a specific target user, or a multi-workspace app where the +active workspace is carried in a header/param rather than the URL path, +means two requests with an identical `method + url` legitimately need +different cache entries — something the default key can't distinguish and +`keyResolver` can only fix by throwing away the existing safety net. + +## Current state (reference) + +- `computeCacheKey` — `utilities/cache.ts:119-128`: + ```ts + export function computeCacheKey(input: { + method: string; url: string; tenantId?: string; authFingerprint?: string; + }): string { + const prefix = `${input.method.toUpperCase()}:${input.url}`; + const scope = `${input.tenantId ?? ''}|${input.authFingerprint ?? ''}`; + return `${prefix}#${hash(scope)}`; + } + ``` +- `CacheConfig.keyResolver` — `cache.types.ts:76-86` — full override, receives + only the raw `ApiRequest`, has no access to `tenantId`/`authFingerprint` + the pipeline already resolved (it would have to be re-derived, awkwardly). +- Call site — `createClient.ts:732-740`: + ```ts + cacheKey = computeCacheKey({ + method, url: identityUrl, + ...(tenantId !== undefined ? { tenantId } : {}), + ...(fp !== null ? { authFingerprint: fp } : {}), + }); + ``` + (`keyResolver`, if the current code even wires it in here — verify at + implementation time whether `keyResolver` is actually consumed at this + call site today or only declared in the type; if unwired, that's a + pre-existing gap to fix as part of this same change, not a new one to + introduce.) + +## Design + +### Add a composable `cacheKeyParts` alongside (not instead of) `keyResolver` + +```ts +// cache.types.ts — CacheConfig +export interface CacheConfig { + // ...existing fields + keyResolver?: (request: ApiRequest) => string; // existing — full override, unsafe, kept for escape-hatch cases + /** + * Extra key dimensions merged INTO the default (or `keyResolver`'s) key — + * safer than `keyResolver` for the common case of "add one more thing to + * the scope" without losing tenant/auth-fingerprint isolation. Return a + * plain object of string values; every value is folded into the same + * hash `computeCacheKey` already applies to tenant/authFingerprint, so + * cross-scope isolation is preserved automatically — you're extending the + * scope, not replacing the scoping mechanism. + * @default optional, unset means no extra key parts + */ + cacheKeyParts?: (args: unknown, ctx: { tenantId?: string; authFingerprint?: string }) => Record; +} +``` + +This can be set at global, module, or per-call/per-method-descriptor level, +following the same layering the rest of `CacheConfig` already uses +(`ModuleConfig.cache?: Partial`, `PerCallConfig.cache`). At the +descriptor level it's the natural fit for the dashboard scenario: + +```ts +dashboard: { + getSummary: { + method: 'GET', path: '/dashboard/summary', + cacheTags: (args) => [`dashboard:${args.workspaceId}`], // from file 01 + cache: { + cacheKeyParts: (args) => ({ workspaceId: args.workspaceId }), + }, + }, +} +``` + +### Extend `computeCacheKey` to fold in extra parts + +```ts +// utilities/cache.ts +export function computeCacheKey(input: { + method: string; + url: string; + tenantId?: string; + authFingerprint?: string; + extraParts?: Record; // NEW +}): string { + const prefix = `${input.method.toUpperCase()}:${input.url}`; + const extra = input.extraParts + ? Object.entries(input.extraParts).sort(([a], [b]) => a.localeCompare(b)).map(([k, v]) => `${k}=${v}`).join('&') + : ''; + const scope = `${input.tenantId ?? ''}|${input.authFingerprint ?? ''}|${extra}`; + return `${prefix}#${hash(scope)}`; +} +``` + +Sorting the entries before joining is required for determinism — two calls +supplying `{ a: '1', b: '2' }` vs `{ b: '2', a: '1' }` must hash identically +(object key order isn't guaranteed to be insertion order across all code +paths that might construct this object). + +### Call site wiring (`createClient.ts:732-740`) + +```ts +const extraParts = resolved.cache.cacheKeyParts?.(args, { tenantId, authFingerprint: fp ?? undefined }); +cacheKey = computeCacheKey({ + method, url: identityUrl, + ...(tenantId !== undefined ? { tenantId } : {}), + ...(fp !== null ? { authFingerprint: fp } : {}), + ...(extraParts ? { extraParts } : {}), +}); +``` + +`cacheKeyParts` throwing must degrade the same way the existing +auth-fingerprint resolution does (`createClient.ts:691-696`: getter throws +→ caching disabled for that request, fail closed) — never fall back to a +key that silently omits the extra parts, since that would put two logically +distinct responses under what looks like a correctly-scoped-but-actually- +collided key. + +## Why this is safer than just documenting `keyResolver` better + +`keyResolver` replaces the entire key computation — a developer using it to +add "workspace id" has to remember to also re-embed `tenantId`/ +`authFingerprint` themselves, and nothing checks that they did. `cacheKeyParts` +can only ever **add** dimensions on top of the pipeline's own scoping, which +is mandatory and untouchable from this function's return value — the +cross-tenant isolation invariant (plan `README.md` constraint #1) holds by +construction, not by developer discipline. + +## Security constraints specific to this file + +- `cacheKeyParts`'s return value must be treated as **untrusted for + structure** (a developer could return non-string values, `__proto__` as a + key, etc.) — stringify defensively (`String(v)`) and reject/skip + dangerous key names (`__proto__`, `constructor`, `prototype`) the same way + `deepMerge` already guards against prototype pollution + (`mergeModuleConfig.ts:51`) — reuse that guard's key-blocklist rather than + writing a second one. +- This does not change what data the *response* can contain — it only + changes how finely responses are partitioned in the cache. Over-broad + `cacheKeyParts` (returning too little) causes a correctness bug (stale/ + wrong data served across contexts that should've been separated) but + never a new *security* class beyond what's already true of any cache + misconfiguration — still worth calling out clearly in the JSDoc: "if your + endpoint's response depends on a value, that value MUST appear either in + the URL or in `cacheKeyParts`, or responses can leak across contexts." + This is the actual fix for the "dashboard shows another user's data" + failure mode the user described — make the JSDoc say this explicitly, not + just describe the mechanism. +- Must compose with file `01`'s tag index untouched — tags and key parts are + independent axes (tags group entries for invalidation, key parts partition + entries for storage); no interaction to guard against beyond both reading + from the same resolved `args`/`ctx`. + +## Tests to add + +`packages/core/src/utilities/cache.test.ts` (extend): +- `computeCacheKey` with `extraParts` produces a different key than without, + and is order-independent (`{a,b}` vs `{b,a}` hash identically). +- `extraParts` with a `__proto__`/`constructor` key doesn't pollute + `Object.prototype` and doesn't crash key computation (reject/strip it). + +`packages/core/src/factory/createClient.test.ts` (extend): +- Two calls to the same URL with different `cacheKeyParts` output (e.g. + different `workspaceId`) produce independent cache entries — the direct + regression test for the dashboard scenario. +- `cacheKeyParts` throwing disables caching for that request (fail closed), + never silently omits the extra scoping. +- A `keyResolver` still works unchanged when `cacheKeyParts` isn't also set + (no regression to the existing override path). + +## Docs to update after shipping + +`docs/caching.md` — add a "scoping cache by more than the URL" section with +the dashboard/workspace example; `docs/multi-tenancy.md` — cross-link, since +this is conceptually an extension of the same scoping problem tenancy +already solves for tenant/auth identity. diff --git a/docs/plan/README.md b/docs/plan/README.md new file mode 100644 index 0000000..e77832f --- /dev/null +++ b/docs/plan/README.md @@ -0,0 +1,102 @@ +# Caching & DX Overhaul — Plan Index + +Status: **proposed, not yet implemented**. This directory is the single source +of truth for a multi-part improvement effort covering response caching +(client + server/SSR), type-safety without codegen, refresh-token DX, and a +few smaller developer-experience gaps. It exists so any agent (human or AI) +can pick up **one file** and implement that piece correctly without needing +the rest of this conversation's context. + +## How to use this plan + +Each numbered file below is self-contained: problem statement, current-state +code references (file:line, accurate as of the commit this was written +against — re-verify before trusting a line number), target design, exact +type/interface changes, security constraints, and a test checklist. Work +one file at a time, in the priority order listed. Do not start file `03` +before `01` and `02` are merged — `03` assumes the tag index from `01` exists. + +If a file's code references no longer match the codebase (line numbers +drift), that's expected over time — re-locate the referenced symbol by name +and proceed; the design intent is what matters, not the exact line. + +## Priority order + +1. [`01-tag-based-invalidation.md`](./01-tag-based-invalidation.md) — fixes stale GET after mutation; fixes same-VM two-user cache mismatch. +2. [`02-cross-instance-invalidation.md`](./02-cross-instance-invalidation.md) — fixes stale cache across multiple server processes/VMs (Redis pub/sub). +3. [`03-memory-aware-caching.md`](./03-memory-aware-caching.md) — fixes unbounded memory growth on constrained hosts. +4. [`04-typesafety-without-codegen.md`](./04-typesafety-without-codegen.md) — full type-safety without forcing codegen (naming correction inside: it's `createClient`/`createTypedClient`, not `createApiClient`). +5. [`05-refresh-token-dx.md`](./05-refresh-token-dx.md) — callback-based token refresh alternative to the endpoint-only flow; pluggable token storage. +6. [`06-server-only-boundary.md`](./06-server-only-boundary.md) — throw-on-browser-import guard for server-only entry points. +7. [`07-adapter-specific-cache-config.md`](./07-adapter-specific-cache-config.md) — Redis/IndexedDB get their own config shapes instead of one generic one. +8. [`08-additional-hardening.md`](./08-additional-hardening.md) — grab-bag of smaller, independent improvements (negative caching, ETag support, metrics hooks, circuit breaker, at-rest encryption, logout-triggered clear, schema-version cache busting, dry-run invalidation). +9. [`09-filesystem-cache-store.md`](./09-filesystem-cache-store.md) — disk-backed `PersistentCacheStore` adapter (Next.js data-cache / nginx `proxy_cache` precedent) for long-running server instances that want to trade RAM for disk; not for serverless/edge. +10. [`10-custom-cache-key-composition.md`](./10-custom-cache-key-composition.md) — `cacheKeyParts` lets a developer add extra key dimensions (e.g. a viewed workspace/user id) on top of the built-in tenant/auth scoping, without the unsafe full-override `keyResolver` has to accept today. + +## Already implemented — do not rebuild + +- **Per-call cache TTL.** `PerCallConfig.cache.ttl` (`types/config.types.ts:640-645`) + already merges in at the highest precedence in + `factory/mergeModuleConfig.ts:126-131` (`deepMerge(..., perCall?.cache ?? {})`). + `api.module.method(args, { cache: { ttl: 5000 } })` already caches that one + call for 5s regardless of global/module TTL. If a real repro shows this not + taking effect, treat it as a bug in the existing merge/read path, not a + missing feature — investigate `createClient.ts:712` (`resolved.cache.ttl`) + and the merge order before adding new API surface for this. + +## Non-negotiable security constraints (apply to every file in this plan) + +These come from the existing design (`packages/core/src/cache-stores/store.types.ts:7-10`, +`packages/core/src/utilities/cache.ts:113-128`, `SECURITY.md`) and **must not +regress** while implementing any part of this plan: + +1. **Cross-tenant / cross-user isolation stays intact.** `computeCacheKey` + (`packages/core/src/utilities/cache.ts:119-128`) scopes every cache key by + `tenantId` + `authFingerprint`. Any new indexing structure (tag index, + pub/sub channel, etc.) may **look up across scopes to invalidate**, but + must **never let one scope read another scope's cached data**. Invalidation + is allowed to be global; data access is not. +2. **Never persist or transmit raw auth material.** Cache entries, Redis + pub/sub payloads, and any new metrics/telemetry hook must carry opaque + keys/tags/hashes only — never tokens, cookies, or raw `Authorization` + headers. `authFingerprint` is already a derived, non-reversible value; keep + it that way. +3. **Fail closed, not open.** The existing pattern (`createClient.ts:691-696`: + if the auth-fingerprint getter throws, caching and dedup are disabled for + that request rather than falling back to an unscoped key) is the model. + Any new feature that can't safely resolve its scoping/config must disable + itself for that request, not guess. +4. **New `PersistentCacheStore` capabilities are additive, not breaking.** + Existing custom implementations of the `PersistentCacheStore` interface + (`cache-stores/store.types.ts:19-24`) must keep working unmodified. New + capabilities (e.g. pub/sub) are optional, feature-detected extensions — + never new required interface methods. +5. **The browser bundle stays clean.** `scripts/check-browser-bundle.mjs` + greps `packages/core/dist/browser.js` for `axios`, `openapi.json`, + `createRpcHandler`. Anything added under this plan that touches + Node-only APIs (`process.memoryUsage()`, Redis clients, disk I/O) must be + feature-detected the way `createIndexedDbStore` feature-detects + `indexedDB` (`cache-stores/index.ts:69`), and must not be reachable from + the `/browser` subpath export. Re-run the bundle check after any change + touching `cache-stores/`, `server/`, or `browser/`. +6. **`TypedModulesConfig` stays a loose open-index type.** Per `CLAUDE.md`: + if a change to `createTypedClient.ts` or related generics turns this into + an intersection, TS silently drops user overrides. Any type change in + file `04` must be verified against the existing "custom methods and + return types always win" behavior. +7. **No silent behavior change for existing users.** Every new config field + defaults to the current behavior (e.g. `crossInstance` defaults to `false` + unless a Redis store is detected; `maxSizeBytes` unset means today's + count-based `maxSize` still governs). This is a library — a minor/patch + bump must not change what already-deployed apps do. + +## What "done" looks like for this plan + +- All 8 files implemented, each with its own test file (see per-file test + checklists) and a docs/*.md update (this plan does not replace user-facing + docs — `docs/caching.md`, `docs/cache-persistence.md`, `docs/authentication.md`, + `docs/multi-tenancy.md` still need updating once each feature lands). +- `pnpm build && pnpm typecheck && pnpm test` clean across all packages. +- `node scripts/check-browser-bundle.mjs` passes. +- A CHANGELOG/changeset entry per shipped feature (this repo uses Changesets — + see `.changeset/README.md`). diff --git a/docs/ssr-rpc-bridge.md b/docs/ssr-rpc-bridge.md index 544e76b..45e280b 100644 --- a/docs/ssr-rpc-bridge.md +++ b/docs/ssr-rpc-bridge.md @@ -172,4 +172,27 @@ The handler enforces all of the following before dispatch: > The bridge client *type* mirrors your whole API surface, so > `api.products.deleteProduct(...)` still type-checks even if it isn't exposed — > the `expose` allowlist is the runtime gate, and an un-exposed call is denied. + +## Dev-time server-only guard + +`createRpcHandler` (and therefore `createNextRpcAction`/`createRpcRouteHandler`, +which both wrap an already-constructed handler) throws immediately if +**constructed** in a browser context — `enforceServerOnly` defaults to `true`. +This is a dev-time net that catches an accidental client-side import of +server code the moment the handler is built, instead of only discovering it +via CI's `scripts/check-browser-bundle.mjs` grep on the built bundle. That +grep remains the authoritative last line of defense — this guard is +defense-in-depth on top of it, not a replacement. + +```ts +// Only if you have a specific, documented reason (e.g. a jsdom-based unit +// test importing server code directly) — never auto-detected, always explicit: +createRpcHandler(api, { expose: { ... }, enforceServerOnly: false }) +``` + +The same guard is available for your own server-only modules via +`defineModule({ serverOnly: true, methods: { ... } })` — checked once at +module construction (not per call) so a module wrapping direct DB access or +other server-only concerns throws immediately if it ends up in a browser +bundle. See `SECURITY.md`. diff --git a/examples/nextjs/lib/api/api.config.ts b/examples/nextjs/lib/api/api.config.ts index 99a5574..31095bc 100644 --- a/examples/nextjs/lib/api/api.config.ts +++ b/examples/nextjs/lib/api/api.config.ts @@ -40,6 +40,9 @@ export const api = createTypedClient()( onCacheHit(key) { console.log('CACHE HIT', key); }, + onCacheMiss(key) { + console.log('CACHE_MISS', key); + }, onRetry(attempt, error) { console.log('RETRYING', error, { attempt }); }, diff --git a/packages/core/src/auth/strategies/oauth2.ts b/packages/core/src/auth/strategies/oauth2.ts index 9c73822..db005ac 100644 --- a/packages/core/src/auth/strategies/oauth2.ts +++ b/packages/core/src/auth/strategies/oauth2.ts @@ -1,19 +1,73 @@ import { AuthError } from '../../errors/AuthError'; +import { ConfigurationError } from '../../errors/ConfigurationError'; /** * OAuth2 auth strategy. Request-time injection lives here; the 401 -> refresh * -> retry flow (mutex-locked) lives in the token-refresh interceptor. */ import type { AuthContribution, OAuth2AuthConfig, OAuth2Tokens } from '../../types/auth.types'; +/** + * Defense-in-depth runtime check for the "exactly one" constraints the type + * system already enforces at the top level (spec 05) — module-level auth + * overrides aren't type-checked against the global shape, so this guard + * catches a misconfigured `refreshEndpoint`+`refresh` or + * `tokenStorage`+manual-triplet combination wherever it's actually used, + * rather than silently picking one. + */ +export function validateOAuth2Config(config: OAuth2AuthConfig): void { + const hasEndpoint = config.refreshEndpoint !== undefined; + const hasCallback = config.refresh !== undefined; + if (hasEndpoint === hasCallback) { + throw new ConfigurationError( + 'auth strategy "oauth2" requires EXACTLY ONE of "refreshEndpoint" or "refresh".', + ); + } + const hasStorage = config.tokenStorage !== undefined; + const hasManual = config.getAccessToken !== undefined || config.getRefreshToken !== undefined; + if (hasStorage === hasManual) { + throw new ConfigurationError( + 'auth strategy "oauth2" requires EXACTLY ONE of "tokenStorage" or the manual ' + + '"getAccessToken"/"getRefreshToken"/"onTokensRefreshed" triplet.', + ); + } +} + +/** + * Resolve the effective token-source functions, deriving them from + * `tokenStorage` when present so the rest of the flow (request-time + * injection, refresh) never needs to branch on which was configured. + */ +export function resolveTokenSource(config: OAuth2AuthConfig): { + getAccessToken: () => string | null | Promise; + getRefreshToken: () => string | null | Promise; + onTokensRefreshed: (tokens: OAuth2Tokens) => void | Promise; +} { + if (config.tokenStorage) { + const storage = config.tokenStorage; + return { + getAccessToken: async () => (await storage.getTokens())?.accessToken ?? null, + getRefreshToken: async () => (await storage.getTokens())?.refreshToken ?? null, + onTokensRefreshed: (tokens) => storage.setTokens(tokens), + }; + } + return { + getAccessToken: config.getAccessToken as () => string | null | Promise, + getRefreshToken: config.getRefreshToken as () => string | null | Promise, + onTokensRefreshed: config.onTokensRefreshed as (tokens: OAuth2Tokens) => void | Promise, + }; +} + /** * Inject the current access token as a bearer header. When no access token is * available the request is sent unauthenticated — the server's 401 is what * triggers the refresh flow. */ export async function applyOAuth2(config: OAuth2AuthConfig): Promise> { + validateOAuth2Config(config); + const { getAccessToken } = resolveTokenSource(config); let token: string | null; try { - token = await config.getAccessToken(); + token = await getAccessToken(); } catch (cause) { throw new AuthError({ message: 'OAuth2 auth: getAccessToken() threw.', diff --git a/packages/core/src/auth/tokenStorage.test.ts b/packages/core/src/auth/tokenStorage.test.ts new file mode 100644 index 0000000..c0be30a --- /dev/null +++ b/packages/core/src/auth/tokenStorage.test.ts @@ -0,0 +1,111 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ConfigurationError } from '../errors/ConfigurationError'; +import { createMockClient } from '../testing/createMockClient'; +import type { OAuth2AuthConfig } from '../types/auth.types'; +import { validateOAuth2Config } from './strategies/oauth2'; +import { createLocalStorageTokenStorage, createMemoryTokenStorage } from './tokenStorage'; + +describe('createMemoryTokenStorage', () => { + it('round-trips tokens correctly', async () => { + const storage = createMemoryTokenStorage(); + expect(await storage.getTokens()).toBeNull(); + await storage.setTokens({ accessToken: 'a', refreshToken: 'r' }); + expect(await storage.getTokens()).toEqual({ accessToken: 'a', refreshToken: 'r' }); + await storage.clearTokens(); + expect(await storage.getTokens()).toBeNull(); + }); +}); + +describe('createLocalStorageTokenStorage', () => { + let store: Record; + + beforeEach(() => { + store = {}; + vi.stubGlobal('localStorage', { + getItem: (k: string) => store[k] ?? null, + setItem: (k: string, v: string) => { + store[k] = v; + }, + removeItem: (k: string) => { + delete store[k]; + }, + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('round-trips tokens correctly', async () => { + const storage = createLocalStorageTokenStorage({ key: 'test-tokens' }); + expect(await storage.getTokens()).toBeNull(); + await storage.setTokens({ accessToken: 'a', refreshToken: 'r' }); + expect(await storage.getTokens()).toEqual({ accessToken: 'a', refreshToken: 'r' }); + await storage.clearTokens(); + expect(await storage.getTokens()).toBeNull(); + }); + + it('no-ops (never throws) when localStorage is unavailable', async () => { + vi.stubGlobal('localStorage', undefined); + const storage = createLocalStorageTokenStorage(); + expect(await storage.getTokens()).toBeNull(); + expect(() => storage.setTokens({ accessToken: 'a' })).not.toThrow(); + expect(() => storage.clearTokens()).not.toThrow(); + }); + + it('ignores corrupt stored JSON', async () => { + store['developerehsan-api-tokens'] = 'not json'; + const storage = createLocalStorageTokenStorage(); + expect(await storage.getTokens()).toBeNull(); + }); +}); + +describe('tokenStorage vs manual triplet — construction-time validation', () => { + it('tokenStorage + manual getters both set -> ConfigurationError', () => { + const auth = { + strategy: 'oauth2', + refreshEndpoint: 'https://auth.test/token', + onRefreshFailed: () => {}, + getAccessToken: () => 'a', + getRefreshToken: () => 'r', + onTokensRefreshed: () => {}, + tokenStorage: createMemoryTokenStorage(), + } as unknown as OAuth2AuthConfig; + + expect(() => validateOAuth2Config(auth)).toThrow(ConfigurationError); + expect(() => createMockClient({ auth })).toThrow(ConfigurationError); + }); + + it('tokenStorage alone derives getAccessToken/getRefreshToken/onTokensRefreshed and works end to end', async () => { + const storage = createMemoryTokenStorage(); + await storage.setTokens({ accessToken: 'expired', refreshToken: 'r1' }); + + const { api, mock } = createMockClient({ + auth: { + strategy: 'oauth2', + tokenStorage: storage, + refresh: async () => ({ accessToken: 'fresh', refreshToken: 'r2' }), + onRefreshFailed: () => {}, + } as unknown as OAuth2AuthConfig, + modules: { + auto: false as const, + things: { + methods: { + get: async (ctx, id: string) => + (await ctx.request({ method: 'GET', path: '/things/{id}', pathParams: { id } })).data, + }, + }, + }, + }); + + mock.on('GET', '/things/1', (r) => { + const authHeader = r.headers['Authorization'] ?? r.headers['authorization']; + return authHeader === 'Bearer fresh' ? { data: { id: '1' } } : { status: 401, data: {} }; + }); + + const typedApi = api as unknown as { things: { get: (id: string) => Promise } }; + const result = await typedApi.things.get('1'); + expect(result).toEqual({ id: '1' }); + expect(await storage.getTokens()).toEqual({ accessToken: 'fresh', refreshToken: 'r2' }); + }); +}); diff --git a/packages/core/src/auth/tokenStorage.ts b/packages/core/src/auth/tokenStorage.ts new file mode 100644 index 0000000..382c8e8 --- /dev/null +++ b/packages/core/src/auth/tokenStorage.ts @@ -0,0 +1,95 @@ +/** + * Reference `TokenStorage` adapters (spec 05), mirroring the + * `PersistentCacheStore` adapter pattern (`cache-stores/index.ts`). + */ +import type { OAuth2Tokens, TokenStorage } from '../types/auth.types'; + +/** + * In-memory token storage. Handy for tests and SSR warm-up — does not + * survive a process restart or page reload. + * + * @example + * import { createMemoryTokenStorage } from '@developerehsan/api-client' + * + * auth: { strategy: 'oauth2', tokenStorage: createMemoryTokenStorage(), ... } + */ +export function createMemoryTokenStorage(): TokenStorage { + let tokens: OAuth2Tokens | null = null; + return { + getTokens: () => tokens, + setTokens: (next) => { + tokens = next; + }, + clearTokens: () => { + tokens = null; + }, + }; +} + +/** Options for {@link createLocalStorageTokenStorage}. */ +export interface LocalStorageTokenStorageOptions { + /** localStorage key tokens are stored under. @default 'developerehsan-api-tokens' */ + key?: string; +} + +/** + * Browser `localStorage`-backed token storage. No-ops (resolving `null`/void) + * when `localStorage` is unavailable, so it's safe to construct in any + * environment — feature-detected the same way `createIndexedDbStore` detects + * `indexedDB`. + * + * SECURITY: `localStorage` is readable by any script on the page (XSS risk). + * This is a plain-JS reference adapter, not a hardened one — for + * `httpOnly` cookie-backed sessions, tokens must be set by a server route, + * never by client-side JS (an `httpOnly` cookie cannot be read/written from + * JS by design). If your backend uses `httpOnly` cookies, use + * `strategy: 'cookie'` instead of `tokenStorage`, and skip this adapter. + * + * @example + * import { createLocalStorageTokenStorage } from '@developerehsan/api-client' + * + * auth: { strategy: 'oauth2', tokenStorage: createLocalStorageTokenStorage(), ... } + */ +export function createLocalStorageTokenStorage( + options: LocalStorageTokenStorageOptions = {}, +): TokenStorage { + const key = options.key ?? 'developerehsan-api-tokens'; + const storage: Storage | undefined = (globalThis as { localStorage?: Storage }).localStorage; + + return { + getTokens: () => { + if (!storage) return null; + try { + const raw = storage.getItem(key); + if (raw === null) return null; + const parsed: unknown = JSON.parse(raw); + if ( + typeof parsed === 'object' && + parsed !== null && + typeof (parsed as { accessToken?: unknown }).accessToken === 'string' + ) { + return parsed as OAuth2Tokens; + } + return null; + } catch { + return null; + } + }, + setTokens: (tokens) => { + if (!storage) return; + try { + storage.setItem(key, JSON.stringify(tokens)); + } catch { + /* storage full/unavailable — never break the refresh flow over persistence */ + } + }, + clearTokens: () => { + if (!storage) return; + try { + storage.removeItem(key); + } catch { + /* ignore */ + } + }, + }; +} diff --git a/packages/core/src/cache-stores/circuitBreaker.test.ts b/packages/core/src/cache-stores/circuitBreaker.test.ts new file mode 100644 index 0000000..5445edb --- /dev/null +++ b/packages/core/src/cache-stores/circuitBreaker.test.ts @@ -0,0 +1,153 @@ +/** + * Circuit breaker for a failing L2 store (spec 08.4): after N consecutive + * failures, L2 calls stop entirely for a cooldown window (serve L1-only, + * without paying the repeated timeout cost); a probe after cooldown closes + * or reopens the circuit depending on outcome. + */ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createCache } from '../utilities/cache'; +import { createLayeredCacheStore } from './layered'; +import type { PersistentCacheStore } from './store.types'; + +function makeEntry(key: string) { + return { + key, + data: { key }, + status: 200, + headers: {}, + storedAt: Date.now(), + expiresAt: Date.now() + 60_000, + }; +} + +function makeFailingL2(): PersistentCacheStore & { calls: number } { + const store = { + calls: 0, + get() { + store.calls += 1; + return Promise.reject(new Error('l2 down')); + }, + set() { + store.calls += 1; + return Promise.reject(new Error('l2 down')); + }, + delete() { + store.calls += 1; + return Promise.reject(new Error('l2 down')); + }, + clear() { + store.calls += 1; + return Promise.reject(new Error('l2 down')); + }, + }; + return store; +} + +describe('circuit breaker', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('opens after N consecutive failures and fires onStoreError with op="circuit-open"', async () => { + const l1 = createCache(); + const l2 = makeFailingL2(); + const onStoreError = vi.fn(); + const layered = createLayeredCacheStore(l1, l2, { + circuitBreaker: { failureThreshold: 2, cooldownMs: 10_000 }, + onStoreError, + }); + + layered.set('a', makeEntry('a')); + await Promise.resolve(); + await Promise.resolve(); + expect(onStoreError).not.toHaveBeenCalledWith(expect.anything(), { op: 'circuit-open' }); + + layered.set('b', makeEntry('b')); + await Promise.resolve(); + await Promise.resolve(); + expect(onStoreError).toHaveBeenCalledWith(expect.anything(), { op: 'circuit-open' }); + }); + + it('serves L1-only during cooldown (the failing L2 is never called again)', async () => { + const l1 = createCache(); + const l2 = makeFailingL2(); + const layered = createLayeredCacheStore(l1, l2, { + circuitBreaker: { failureThreshold: 1, cooldownMs: 10_000 }, + }); + + layered.set('a', makeEntry('a')); + await Promise.resolve(); + await Promise.resolve(); + const callsAtOpen = l2.calls; + expect(callsAtOpen).toBeGreaterThan(0); + + // Circuit is open; further writes/reads must not touch L2 at all. + layered.set('b', makeEntry('b')); + layered.get('c'); + layered.delete('a'); + await Promise.resolve(); + await Promise.resolve(); + expect(l2.calls).toBe(callsAtOpen); + + // L1 itself still works normally (degraded-but-safe, not broken). + expect(layered.get('b')).toBeDefined(); + }); + + it('a successful probe after cooldown closes the circuit', async () => { + vi.useFakeTimers(); + const l1 = createCache(); + let shouldFail = true; + const l2: PersistentCacheStore = { + get: () => (shouldFail ? Promise.reject(new Error('down')) : Promise.resolve(undefined)), + set: () => (shouldFail ? Promise.reject(new Error('down')) : Promise.resolve()), + delete: () => (shouldFail ? Promise.reject(new Error('down')) : Promise.resolve()), + clear: () => (shouldFail ? Promise.reject(new Error('down')) : Promise.resolve()), + }; + const onStoreError = vi.fn(); + const layered = createLayeredCacheStore(l1, l2, { + circuitBreaker: { failureThreshold: 1, cooldownMs: 1000 }, + onStoreError, + }); + + layered.set('a', makeEntry('a')); + await vi.advanceTimersByTimeAsync(0); + expect(onStoreError).toHaveBeenCalledWith(expect.anything(), { op: 'circuit-open' }); + + // Still within cooldown: no probe. + onStoreError.mockClear(); + shouldFail = false; + layered.set('b', makeEntry('b')); + await vi.advanceTimersByTimeAsync(0); + expect(onStoreError).not.toHaveBeenCalled(); + + // Past cooldown: the next call probes and succeeds -> circuit closes. + await vi.advanceTimersByTimeAsync(1100); + layered.set('c', makeEntry('c')); + await vi.advanceTimersByTimeAsync(0); + expect(onStoreError).toHaveBeenCalledWith(undefined, { op: 'circuit-close' }); + }); + + it('a failed probe after cooldown reopens the circuit', async () => { + vi.useFakeTimers(); + const l1 = createCache(); + const l2 = makeFailingL2(); + const onStoreError = vi.fn(); + const layered = createLayeredCacheStore(l1, l2, { + circuitBreaker: { failureThreshold: 1, cooldownMs: 1000 }, + onStoreError, + }); + + layered.set('a', makeEntry('a')); + await vi.advanceTimersByTimeAsync(0); + expect(onStoreError).toHaveBeenCalledWith(expect.anything(), { op: 'circuit-open' }); + + await vi.advanceTimersByTimeAsync(1100); + onStoreError.mockClear(); + const callsBeforeProbe = l2.calls; + layered.set('b', makeEntry('b')); + await vi.advanceTimersByTimeAsync(0); + + expect(l2.calls).toBe(callsBeforeProbe + 1); // the probe DID attempt L2 + expect(onStoreError).toHaveBeenCalledWith(expect.anything(), { op: 'circuit-open' }); // and reopened + }); +}); diff --git a/packages/core/src/cache-stores/encryption.test.ts b/packages/core/src/cache-stores/encryption.test.ts new file mode 100644 index 0000000..fed5894 --- /dev/null +++ b/packages/core/src/cache-stores/encryption.test.ts @@ -0,0 +1,66 @@ +/** + * At-rest encryption for IndexedDB (spec 08.5): with a fake reversible + * cipher, stored records are transformed (not plaintext-equal to the + * original JSON) and read back correctly; without `encrypt`, behavior is + * unchanged from today. + */ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { CacheEntry } from '../types/cache.types'; +import { createFakeIndexedDb } from './fakeIndexedDb.test-helper'; +import { type CacheCipher, createIndexedDbStore } from './index'; + +function entry(key: string): CacheEntry { + return { + key, + data: { secret: 'sensitive-value' }, + status: 200, + headers: {}, + storedAt: Date.now(), + expiresAt: Date.now() + 60_000, + }; +} + +/** Trivial reversible "cipher" — reverses the string. Good enough to prove the boundary is applied. */ +function makeFakeCipher(): CacheCipher { + return { + encrypt: (plaintext) => Promise.resolve([...plaintext].reverse().join('')), + decrypt: (ciphertext) => Promise.resolve([...ciphertext].reverse().join('')), + }; +} + +describe('IndexedDB at-rest encryption', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('stored records are transformed (not plaintext-equal to the original JSON) and read back correctly', async () => { + const { factory, db } = createFakeIndexedDb(); + vi.stubGlobal('indexedDB', factory); + const cipher = makeFakeCipher(); + const store = createIndexedDbStore({ encrypt: cipher }); + + const e = entry('a'); + await store.set('a', e); + + const raw = db.store.rows().find((r) => r.key === 'a')?.value; + expect(JSON.stringify(raw)).not.toContain('sensitive-value'); + + const got = await store.get('a'); + expect(got).toEqual(e); + }); + + it('without encrypt configured, behavior is byte-for-byte unchanged (plain JSON on disk)', async () => { + const { factory, db } = createFakeIndexedDb(); + vi.stubGlobal('indexedDB', factory); + const store = createIndexedDbStore(); + + const e = entry('a'); + await store.set('a', e); + + const raw = db.store.rows().find((r) => r.key === 'a')?.value; + expect(raw).toEqual(e); + + const got = await store.get('a'); + expect(got).toEqual(e); + }); +}); diff --git a/packages/core/src/cache-stores/fakeIndexedDb.test-helper.ts b/packages/core/src/cache-stores/fakeIndexedDb.test-helper.ts new file mode 100644 index 0000000..fce8ea6 --- /dev/null +++ b/packages/core/src/cache-stores/fakeIndexedDb.test-helper.ts @@ -0,0 +1,208 @@ +/** + * Minimal in-memory `IDBFactory` shim covering exactly what + * `createIndexedDbStore` uses: open/onupgradeneeded (object store + one + * index), get/put/delete/clear/count, and an index cursor supporting + * `continue()`/`delete()`. Not spec-complete — just enough to exercise the + * store's logic without a real browser or a `fake-indexeddb` dependency. + */ + +class FakeRequest { + result: T = undefined as unknown as T; + error: unknown; + onsuccess: (() => void) | null = null; + onerror: (() => void) | null = null; + + succeed(result: T): void { + this.result = result; + queueMicrotask(() => this.onsuccess?.()); + } + + fail(error: unknown): void { + this.error = error; + queueMicrotask(() => this.onerror?.()); + } +} + +interface Row { + key: string; + value: unknown; +} + +class FakeCursor { + constructor( + private rows: Row[], + private index: number, + private onDelete: (key: string) => void, + private request: FakeRequest, + ) {} + + delete(): void { + const row = this.rows[this.index]; + if (row) this.onDelete(row.key); + } + + continue(): void { + this.index += 1; + deliverCursor(this.rows, this.index, this.onDelete, this.request); + } +} + +function deliverCursor( + rows: Row[], + index: number, + onDelete: (key: string) => void, + request: FakeRequest, +): void { + if (index >= rows.length) { + request.succeed(null); + return; + } + request.succeed(new FakeCursor(rows, index, onDelete, request)); +} + +class FakeIndex { + constructor( + private store: FakeObjectStore, + private sortKey: string, + ) {} + + openCursor(): FakeRequest { + const request = new FakeRequest(); + const rows = [...this.store.rows()].sort((a, b) => { + const av = (a.value as Record)[this.sortKey] ?? 0; + const bv = (b.value as Record)[this.sortKey] ?? 0; + return av - bv; + }); + deliverCursor(rows, 0, (key) => this.store.deleteSync(key), request); + return request; + } +} + +export interface QuotaSimulator { + shouldFail: boolean; +} + +class FakeObjectStore { + private map = new Map(); + private indexes = new Map(); + + constructor(private quota?: QuotaSimulator) {} + + rows(): Row[] { + return [...this.map.entries()].map(([key, value]) => ({ key, value })); + } + + deleteSync(key: string): void { + this.map.delete(key); + } + + createIndex(name: string, keyPath: string): void { + this.indexes.set(name, keyPath); + } + + get indexNames(): { contains: (name: string) => boolean } { + return { contains: (name: string) => this.indexes.has(name) }; + } + + index(name: string): FakeIndex { + const keyPath = this.indexes.get(name); + if (!keyPath) throw new Error(`no such index: ${name}`); + return new FakeIndex(this, keyPath); + } + + get(key: string): FakeRequest { + const request = new FakeRequest(); + request.succeed(this.map.get(key)); + return request; + } + + put(value: unknown, key: string): FakeRequest { + const request = new FakeRequest(); + if (this.quota?.shouldFail) { + const err = new Error('quota exceeded'); + (err as { name: string }).name = 'QuotaExceededError'; + request.fail(err); + return request; + } + this.map.set(key, value); + request.succeed(key); + return request; + } + + delete(key: string): FakeRequest { + const request = new FakeRequest(); + this.map.delete(key); + request.succeed(undefined); + return request; + } + + clear(): FakeRequest { + const request = new FakeRequest(); + this.map.clear(); + request.succeed(undefined); + return request; + } + + count(): FakeRequest { + const request = new FakeRequest(); + request.succeed(this.map.size); + return request; + } +} + +class FakeTransaction { + constructor(private store: FakeObjectStore) {} + objectStore(_name: string): FakeObjectStore { + return this.store; + } +} + +class FakeDatabase { + objectStoreNames = { contains: (_name: string) => this.created }; + private created = false; + readonly store: FakeObjectStore; + + constructor(quota?: QuotaSimulator) { + this.store = new FakeObjectStore(quota); + } + + createObjectStore(_name: string): FakeObjectStore { + this.created = true; + return this.store; + } + + transaction(_name: string, _mode: string): FakeTransaction { + return new FakeTransaction(this.store); + } +} + +/** Build a fake `IDBFactory`; `quota.shouldFail = true` simulates a QuotaExceededError on the next `put`. */ +export function createFakeIndexedDb(quota?: QuotaSimulator): { + factory: IDBFactory; + db: FakeDatabase; +} { + const db = new FakeDatabase(quota); + + const factory = { + open(_name: string, _version?: number) { + const request = new FakeRequest() as unknown as { + result: FakeDatabase; + onupgradeneeded: (() => void) | null; + onsuccess: (() => void) | null; + onerror: (() => void) | null; + transaction?: { objectStore: (name: string) => FakeObjectStore }; + }; + request.result = db; + request.transaction = { + objectStore: (name: string) => db.transaction(name, 'versionchange').objectStore(name), + }; + queueMicrotask(() => { + request.onupgradeneeded?.(); + request.onsuccess?.(); + }); + return request; + }, + } as unknown as IDBFactory; + + return { factory, db }; +} diff --git a/packages/core/src/cache-stores/filesystem.test.ts b/packages/core/src/cache-stores/filesystem.test.ts new file mode 100644 index 0000000..73c95b6 --- /dev/null +++ b/packages/core/src/cache-stores/filesystem.test.ts @@ -0,0 +1,168 @@ +/** + * Filesystem-backed PersistentCacheStore (spec 09): round-trip correctness, + * path-traversal safety (the actual regression test for the security + * constraint — a hostile key must never escape `dir`), crash tolerance + * (a stray `.tmp` file must never be read), corrupt-JSON tolerance, and + * `maxSizeBytes` proactive eviction. + */ +import { mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import type { CacheEntry } from '../types/cache.types'; +import { createFileSystemStore } from './index'; + +function entry(key: string, data: unknown = { value: 'hello' }): CacheEntry { + return { + key, + data, + status: 200, + headers: {}, + storedAt: Date.now(), + expiresAt: Date.now() + 60_000, + }; +} + +describe('createFileSystemStore', () => { + let dir: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'api-client-fs-cache-')); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it('round-trips set/get/delete/clear', async () => { + const store = createFileSystemStore({ dir }); + const e = entry('a'); + + await store.set('a', e); + expect(await store.get('a')).toEqual(e); + + await store.delete('a'); + expect(await store.get('a')).toBeUndefined(); + + await store.set('b', entry('b')); + await store.set('c', entry('c')); + await store.clear(); + expect(await store.get('b')).toBeUndefined(); + expect(await store.get('c')).toBeUndefined(); + }); + + it('never lets a hostile cache key escape the configured dir (path traversal)', async () => { + const store = createFileSystemStore({ dir }); + const hostileKey = '../../../../etc/passwd'; + const hostileEntry = entry(hostileKey); + + await store.set(hostileKey, hostileEntry); + + const files = readdirSync(dir); + // Every written file must resolve to a path INSIDE dir. + for (const f of files) { + expect(resolve(dir, f).startsWith(resolve(dir))).toBe(true); + } + // No file escaped upward — dir itself only grew, nothing appeared outside it. + expect(files.length).toBeGreaterThan(0); + expect(await store.get(hostileKey)).toEqual(hostileEntry); + }); + + it('a stray .tmp file from a simulated crash mid-write is never read', async () => { + const store = createFileSystemStore({ dir }); + await store.set('a', entry('a', { value: 'real' })); + + // Simulate a crash between writeFile(tmp) and rename(tmp, file): leave a + // stray .tmp file with different content, no matching rename happened. + const files = readdirSync(dir); + const realFile = files.find((f) => f.endsWith('.json')); + expect(realFile).toBeDefined(); + writeFileSync( + join(dir, `${realFile}.stray.tmp`), + JSON.stringify(entry('a', { value: 'corrupt-in-flight' })), + ); + + const got = await store.get('a'); + expect(got?.data).toEqual({ value: 'real' }); + }); + + it('corrupt JSON on disk causes get() to return undefined, not throw', async () => { + const store = createFileSystemStore({ dir }); + await store.set('a', entry('a')); + + const files = readdirSync(dir); + const realFile = files.find((f) => f.endsWith('.json')); + writeFileSync(join(dir, realFile!), '{not valid json'); + + await expect(store.get('a')).resolves.toBeUndefined(); + }); + + it('maxSizeBytes evicts oldest entries once exceeded; unset means no eviction', async () => { + const bigValue = 'x'.repeat(200); + const store = createFileSystemStore({ dir, maxSizeBytes: 500 }); + + const eA = entry('a', bigValue); + const eB = entry('b', bigValue); + const eC = entry('c', bigValue); + await store.set('a', eA); + await new Promise((r) => setTimeout(r, 2)); + await store.set('b', eB); + await new Promise((r) => setTimeout(r, 2)); + await store.set('c', eC); + + // Oldest ('a') should have been evicted once the budget was exceeded. + expect(await store.get('a')).toBeUndefined(); + expect(await store.get('c')).toEqual(eC); + + const unbounded = createFileSystemStore({ + dir: mkdtempSync(join(tmpdir(), 'api-client-fs-cache-unbounded-')), + }); + const eA2 = entry('a', bigValue); + await unbounded.set('a', eA2); + await unbounded.set('b', entry('b', bigValue)); + await unbounded.set('c', entry('c', bigValue)); + expect(await unbounded.get('a')).toEqual(eA2); + }); + + it('file permission of a written cache file defaults to 0o600', async () => { + if (process.platform === 'win32') return; + const store = createFileSystemStore({ dir }); + await store.set('a', entry('a')); + const file = readdirSync(dir).find((f) => f.endsWith('.json')); + const mode = statSync(join(dir, file!)).mode & 0o777; + expect(mode).toBe(0o600); + }); + + it('respects a custom fileMode', async () => { + if (process.platform === 'win32') return; + const store = createFileSystemStore({ dir, fileMode: 0o644 }); + await store.set('a', entry('a')); + const file = readdirSync(dir).find((f) => f.endsWith('.json')); + const mode = statSync(join(dir, file!)).mode & 0o777; + expect(mode).toBe(0o644); + }); + + it('creates the directory if missing', async () => { + const nested = join(dir, 'nested', 'cache'); + const store = createFileSystemStore({ dir: nested }); + await store.set('a', entry('a')); + const files = readdirSync(nested); + expect(files.length).toBe(1); + expect(readFileSync(join(nested, files[0] as string), 'utf8')).toContain('a'); + }); + + it('reports backend failures via onStoreError without throwing', async () => { + const errors: Array<{ error: unknown; op: string }> = []; + // Point at a path that can't be created (a file, not a dir, as a parent segment). + const blockingFile = join(dir, 'blocker'); + writeFileSync(blockingFile, 'not a directory'); + const store = createFileSystemStore({ + dir: join(blockingFile, 'cache'), + onStoreError: (error, context) => errors.push({ error, op: context.op }), + }); + + await store.set('a', entry('a')); + expect(errors.length).toBeGreaterThan(0); + expect(errors[0]?.op).toBe('set'); + }); +}); diff --git a/packages/core/src/cache-stores/index.ts b/packages/core/src/cache-stores/index.ts index a570e86..abbc765 100644 --- a/packages/core/src/cache-stores/index.ts +++ b/packages/core/src/cache-stores/index.ts @@ -4,12 +4,66 @@ * store takes an injected client (no `redis` dependency) and the IndexedDB store * feature-detects `indexedDB`, so this module is safe in any bundle. */ +import * as nodeCrypto from 'node:crypto'; +import * as nodeFs from 'node:fs/promises'; +import * as nodePath from 'node:path'; +import { ConfigurationError } from '../errors/ConfigurationError'; import type { CacheEntry } from '../types/cache.types'; -import { type PersistentCacheStore, isCacheEntry } from './store.types'; +import { + type InvalidationMessage, + type OutgoingInvalidationMessage, + type PersistentCacheStore, + isCacheEntry, +} from './store.types'; -export type { PersistentCacheStore } from './store.types'; +export type { + InvalidationMessage, + OutgoingInvalidationMessage, + PersistentCacheStore, +} from './store.types'; export { isCacheEntry } from './store.types'; export { createLayeredCacheStore } from './layered'; +export type { LayeredCacheStoreOptions, LayeredStoreErrorHandler } from './layered'; + +/** Defensive caps on an incoming pub/sub payload (spec 02: never trust the wire). */ +const MAX_INVALIDATION_VALUES = 1000; +const MAX_INVALIDATION_VALUE_LENGTH = 500; + +/** + * Parse+validate a raw pub/sub message into an {@link InvalidationMessage}. + * Returns `undefined` for anything malformed or oversized — the caller drops + * it silently rather than throwing (mirrors "L2 errors never break a + * request" in `layered.ts`). + */ +function parseInvalidationMessage(raw: string): InvalidationMessage | undefined { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return undefined; + } + if (typeof parsed !== 'object' || parsed === null) return undefined; + const msg = parsed as Record; + if (msg['type'] !== 'tags' && msg['type'] !== 'keys' && msg['type'] !== 'clear') { + return undefined; + } + if (typeof msg['origin'] !== 'string') return undefined; + let values: string[] | undefined; + if (msg['values'] !== undefined) { + if (!Array.isArray(msg['values']) || msg['values'].length > MAX_INVALIDATION_VALUES) { + return undefined; + } + if ( + !msg['values'].every( + (v): v is string => typeof v === 'string' && v.length <= MAX_INVALIDATION_VALUE_LENGTH, + ) + ) { + return undefined; + } + values = msg['values']; + } + return { type: msg['type'], origin: msg['origin'], ...(values ? { values } : {}) }; +} /** * In-memory persistent store (Map-backed). Handy for tests and SSR warm-up. @@ -42,14 +96,92 @@ export function createMemoryPersistentStore(): PersistentCacheStore { }; } +/** Backend operation a {@link StoreErrorHandler} fired for. */ +export type StoreErrorContext = { op: 'get' | 'set' | 'delete' | 'clear'; key?: string }; + +/** + * Diagnostic hook fired on a backend failure, in ADDITION to (never instead + * of) the store's existing swallow-and-degrade behavior — the call's return + * value is unchanged either way. Carries the error and operation context + * only, never the cache entry's `data` (spec 07: not a second channel for + * cached response bodies to leak through logging). + */ +export type StoreErrorHandler = (error: unknown, context: StoreErrorContext) => void; + +const STORE_ERROR_NAME = 'QuotaExceededError'; + +/** True for a DOM `QuotaExceededError` (by `name`, the standard DOM exception field). */ +function isQuotaExceededError(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + (error as { name?: unknown }).name === STORE_ERROR_NAME + ); +} + /** Options for {@link createIndexedDbStore}. */ export interface IndexedDbStoreOptions { /** Database name. @default 'developerehsan-api-cache' */ dbName?: string; /** Object-store name. @default 'responses' */ storeName?: string; + /** + * IndexedDB schema version. Bump when changing `storeName` or otherwise + * needing a migration. + * @default 1 + */ + version?: number; + /** + * Called when a `set()` fails specifically due to storage quota + * (`QuotaExceededError`). Default behavior (unset): swallow, same as + * every other write failure. `set()` still resolves either way — this is + * a diagnostic hook, not a way to turn a swallowed failure into a thrown one. + */ + onQuotaExceeded?: (error: unknown) => void; + /** + * When set, `set()` proactively evicts the oldest ~10% of entries (by + * `storedAt`) before writing, once total entry count reaches this — a + * coarse client-side LRU, since IndexedDB itself enforces none. + * @default optional, unset means no proactive eviction + */ + maxEntries?: number; + /** Fires on any backend failure across get/set/delete/clear. See {@link StoreErrorHandler}. */ + onStoreError?: StoreErrorHandler; + /** + * At-rest encryption for entries persisted to IndexedDB (spec 08.5). No + * default cipher/key management is shipped — key handling (where the key + * comes from, whether it survives a reload) is inherently + * application-specific. This protects against casual inspection of + * browser storage; it does NOT protect against the page's own JS, which + * by definition can call `decrypt` itself. + * @default optional, unset means entries are stored as plain JSON (today's behavior) + */ + encrypt?: CacheCipher; } +/** Pluggable cipher for {@link IndexedDbStoreOptions.encrypt}. See its security note. */ +export interface CacheCipher { + encrypt(plaintext: string): Promise; + decrypt(ciphertext: string): Promise; +} + +/** On-disk wrapper for an encrypted entry, distinguishing it from a plain stored `CacheEntry`. */ +interface EncryptedRecord { + __apiClientEncrypted: true; + payload: string; +} + +function isEncryptedRecord(value: unknown): value is EncryptedRecord { + return ( + typeof value === 'object' && + value !== null && + (value as { __apiClientEncrypted?: unknown }).__apiClientEncrypted === true && + typeof (value as { payload?: unknown }).payload === 'string' + ); +} + +const STORED_AT_INDEX = 'storedAt'; + /** * A browser IndexedDB-backed persistent store. No-ops (resolving empty) when * `indexedDB` is unavailable, so it is safe to construct in any environment. @@ -66,6 +198,7 @@ export interface IndexedDbStoreOptions { export function createIndexedDbStore(options: IndexedDbStoreOptions = {}): PersistentCacheStore { const dbName = options.dbName ?? 'developerehsan-api-cache'; const storeName = options.storeName ?? 'responses'; + const version = options.version ?? 1; const idb: IDBFactory | undefined = (globalThis as { indexedDB?: IDBFactory }).indexedDB; let dbPromise: Promise | undefined; @@ -73,10 +206,14 @@ export function createIndexedDbStore(options: IndexedDbStoreOptions = {}): Persi if (!idb) return Promise.reject(new Error('indexedDB unavailable')); if (!dbPromise) { dbPromise = new Promise((resolve, reject) => { - const req = idb.open(dbName, 1); + const req = idb.open(dbName, version); req.onupgradeneeded = () => { - if (!req.result.objectStoreNames.contains(storeName)) - req.result.createObjectStore(storeName); + const store = req.result.objectStoreNames.contains(storeName) + ? req.transaction?.objectStore(storeName) + : req.result.createObjectStore(storeName); + if (store && !store.indexNames.contains(STORED_AT_INDEX)) { + store.createIndex(STORED_AT_INDEX, STORED_AT_INDEX, { unique: false }); + } }; req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); @@ -98,43 +235,346 @@ export function createIndexedDbStore(options: IndexedDbStoreOptions = {}): Persi }), ); - const guard = (p: Promise, fallback: T): Promise => p.catch(() => fallback); + const report = (error: unknown, context: StoreErrorContext): void => { + try { + options.onStoreError?.(error, context); + } catch { + /* a reporting hook must never break the store */ + } + }; + + const guard = (p: Promise, fallback: T, context: StoreErrorContext): Promise => + p.catch((error: unknown) => { + report(error, context); + return fallback; + }); + + /** Delete the oldest ~10% of entries by `storedAt`, via the cursor index. */ + const evictOldest = (maxEntries: number): Promise => + openDb().then( + (db) => + new Promise((resolve) => { + const store = db.transaction(storeName, 'readwrite').objectStore(storeName); + const countReq = store.count(); + countReq.onsuccess = () => { + if (countReq.result < maxEntries) return resolve(); + const toEvict = Math.max(1, Math.ceil(maxEntries * 0.1)); + let evicted = 0; + const cursorReq = store.index(STORED_AT_INDEX).openCursor(); + cursorReq.onsuccess = () => { + const cursor = cursorReq.result; + if (!cursor || evicted >= toEvict) return resolve(); + cursor.delete(); + evicted += 1; + cursor.continue(); + }; + cursorReq.onerror = () => resolve(); + }; + countReq.onerror = () => resolve(); + }), + ); return { get: (key) => guard( - tx('readonly', (s) => s.get(key)).then((v) => (isCacheEntry(v) ? v : undefined)), - undefined, - ), - set: (key, entry) => - guard( - tx('readwrite', (s) => s.put(entry, key)).then(() => {}), + tx('readonly', (s) => s.get(key)).then(async (v) => { + if (isCacheEntry(v)) return v; + if (options.encrypt && isEncryptedRecord(v)) { + const decrypted = await options.encrypt.decrypt(v.payload); + const parsed: unknown = JSON.parse(decrypted); + return isCacheEntry(parsed) ? parsed : undefined; + } + return undefined; + }), undefined, + { op: 'get', key }, ), + set: async (key, entry) => { + if (options.maxEntries !== undefined) await evictOldest(options.maxEntries); + const record: unknown = options.encrypt + ? ({ + __apiClientEncrypted: true, + payload: await options.encrypt.encrypt(JSON.stringify(entry)), + } satisfies EncryptedRecord) + : entry; + return tx('readwrite', (s) => s.put(record, key)) + .then(() => undefined) + .catch((error: unknown) => { + if (isQuotaExceededError(error)) options.onQuotaExceeded?.(error); + report(error, { op: 'set', key }); + return undefined; + }); + }, delete: (key) => guard( - tx('readwrite', (s) => s.delete(key)).then(() => {}), + tx('readwrite', (s) => s.delete(key)).then(() => undefined), undefined, + { op: 'delete', key }, ), clear: () => guard( - tx('readwrite', (s) => s.clear()).then(() => {}), + tx('readwrite', (s) => s.clear()).then(() => undefined), undefined, + { op: 'clear' }, ), }; } +/** Options for {@link createFileSystemStore}. */ +export interface FileSystemStoreOptions { + /** Directory to store cache files in. Created if missing. @default './.api-client-cache' */ + dir?: string; + /** + * Max total bytes the cache directory may hold before proactive eviction + * (oldest `storedAt` first, tracked via an in-memory index built lazily on + * first write/read). Unset means no proactive size cap. + * @default optional, unset means no proactive eviction + */ + maxSizeBytes?: number; + /** File permission mode for written cache files. @default 0o600 (owner read/write only) */ + fileMode?: number; + /** Fires on any backend failure across get/set/delete/clear. See {@link StoreErrorHandler}. */ + onStoreError?: StoreErrorHandler; + /** + * At-rest encryption for entries persisted to disk (spec 08.5 / 09), same + * interface as {@link IndexedDbStoreOptions.encrypt}. No default cipher is + * shipped. + * @default optional, unset means entries are stored as plain JSON + */ + encrypt?: CacheCipher; +} + +/** + * A Node-only, disk-backed persistent store — trades RAM for disk on a + * long-running server process (the Next.js data-cache / nginx `proxy_cache` + * shape). Resolves to a safe no-op store (never throws at construction time) + * when `node:fs`/`node:crypto` aren't usable, e.g. accidentally constructed + * in a browser bundle. + * + * Does NOT fit serverless/edge (filesystem is ephemeral/absent there) and + * does NOT solve cross-instance staleness (each instance has its own disk — + * use Redis `crossInstance` for that, or `docs/cache-persistence.md`). Do + * not point `dir` at a network filesystem (NFS/EFS) as a cross-instance + * workaround — that reintroduces coordination with worse latency/locking + * semantics than Redis already gives you. + * + * Cache keys are never used as path segments directly — every key is + * SHA-256 hashed into a fixed-length hex filename, so no cache key (however + * derived — a custom `keyResolver`, `cacheKeyParts`, etc.) can ever cause + * path traversal or reach an unintended file. + * + * @example + * import { createFileSystemStore } from '@developerehsan/api-client/cache-stores' + * + * const api = createClient({ + * baseURL, openapi: { mode: 'runtime' }, + * cache: { persistentStore: createFileSystemStore({ dir: '/var/cache/my-app' }) }, + * }) + */ +export function createFileSystemStore(options: FileSystemStoreOptions = {}): PersistentCacheStore { + const dir = options.dir ?? './.api-client-cache'; + const fileMode = options.fileMode ?? 0o600; + const maxSizeBytes = options.maxSizeBytes; + + const report = (error: unknown, context: StoreErrorContext): void => { + try { + options.onStoreError?.(error, context); + } catch { + /* a reporting hook must never break the store */ + } + }; + + // `cache-stores` is its own bundle entry (tsup.config.ts), never pulled + // into the `/browser` subpath export — so importing `node:fs`/`node:crypto` + // statically at module scope is safe. Still feature-detect at construction + // time (never throw) in case this module is somehow loaded where those + // APIs are stubbed out to non-functional shims. + const fs = nodeFs; + const path = nodePath; + const crypto = nodeCrypto; + const nodeApisUsable = + typeof fs.mkdir === 'function' && + typeof fs.writeFile === 'function' && + typeof crypto.createHash === 'function'; + + if (!nodeApisUsable) { + // Safe no-op — never throws at construction time. + const noop: PersistentCacheStore = { + get: () => Promise.resolve(undefined), + set: () => Promise.resolve(), + delete: () => Promise.resolve(), + clear: () => Promise.resolve(), + }; + return noop; + } + + const keyToFilename = (key: string): string => + `${crypto.createHash('sha256').update(key).digest('hex')}.json`; + + let dirReady: Promise | undefined; + const ensureDir = (): Promise => { + if (!dirReady) dirReady = fs.mkdir(dir, { recursive: true }).then(() => undefined); + return dirReady; + }; + + let randomSuffixCounter = 0; + const randomSuffix = (): string => { + randomSuffixCounter += 1; + return `${process.pid}-${randomSuffixCounter}-${crypto.randomBytes(4).toString('hex')}`; + }; + + // Lazy in-memory size index for maxSizeBytes eviction (spec 09, approach a): + // built incrementally as entries are written, never a directory-wide scan. + const sizeIndex = new Map(); + let totalBytes = 0; + + const evictIfOverBudget = async (): Promise => { + if (maxSizeBytes === undefined) return; + if (totalBytes <= maxSizeBytes) return; + const sorted = [...sizeIndex.entries()].sort((a, b) => a[1].storedAt - b[1].storedAt); + for (const [key, meta] of sorted) { + if (totalBytes <= maxSizeBytes) break; + sizeIndex.delete(key); + totalBytes -= meta.size; + await fs.unlink(path.join(dir, keyToFilename(key))).catch(() => undefined); + } + }; + + return { + async get(key) { + try { + const raw = await fs.readFile(path.join(dir, keyToFilename(key)), 'utf8'); + const parsed: unknown = JSON.parse(raw); + if (options.encrypt && isEncryptedRecord(parsed)) { + const decrypted = await options.encrypt.decrypt(parsed.payload); + const inner: unknown = JSON.parse(decrypted); + return isCacheEntry(inner) ? inner : undefined; + } + return isCacheEntry(parsed) ? parsed : undefined; + } catch { + // ENOENT, corrupt JSON, permission error — all treated as a miss. + return undefined; + } + }, + async set(key, entry) { + try { + await ensureDir(); + const record: unknown = options.encrypt + ? ({ + __apiClientEncrypted: true, + payload: await options.encrypt.encrypt(JSON.stringify(entry)), + } satisfies EncryptedRecord) + : entry; + const serialized = JSON.stringify(record); + const file = path.join(dir, keyToFilename(key)); + const tmp = `${file}.${randomSuffix()}.tmp`; + await fs.writeFile(tmp, serialized, { mode: fileMode }); + await fs.rename(tmp, file); + if (maxSizeBytes !== undefined) { + const size = Buffer.byteLength(serialized); + const prev = sizeIndex.get(key); + if (prev) totalBytes -= prev.size; + sizeIndex.set(key, { size, storedAt: entry.storedAt }); + totalBytes += size; + await evictIfOverBudget(); + } + } catch (error) { + report(error, { op: 'set', key }); + } + }, + async delete(key) { + try { + const prev = sizeIndex.get(key); + if (prev) { + sizeIndex.delete(key); + totalBytes -= prev.size; + } + await fs.unlink(path.join(dir, keyToFilename(key))); + } catch (error) { + // ENOENT (already gone) isn't a real failure — only report others. + if ((error as { code?: string } | null)?.code !== 'ENOENT') { + report(error, { op: 'delete', key }); + } + } + }, + async clear() { + try { + sizeIndex.clear(); + totalBytes = 0; + const files = await fs.readdir(dir).catch(() => [] as string[]); + await Promise.all(files.map((f) => fs.unlink(path.join(dir, f)).catch(() => undefined))); + } catch (error) { + report(error, { op: 'clear' }); + } + }, + }; +} + /** The minimal Redis client surface this store needs (node-redis / ioredis compatible). */ export interface RedisLikeClient { get(key: string): Promise; set(key: string, value: string, ...args: unknown[]): Promise; del(key: string): Promise; + /** Optional: required only when `crossInstance` invalidation is enabled. */ + publish?(channel: string, message: string): Promise; + /** + * Optional: required only when `crossInstance` invalidation is enabled. + * Most Redis clients (node-redis, ioredis) require a DEDICATED connection + * for subscribe mode — pass a second client instance here, not the one + * used for get/set/del. + */ + subscribe?(channel: string, onMessage: (message: string) => void): Promise; + /** + * Optional: enables a real namespace-wide `clear()` via SCAN+DEL instead + * of the default no-op. Most node-redis/ioredis clients expose scanning + * as `scanIterator`/`scanStream` under a different shape — pass a thin + * adapter over whichever your client provides. + */ + scanKeys?(matchPattern: string): AsyncIterable; +} + +/** Custom (de)serializer for {@link createRedisStore}. See its security note on `parse`. */ +export interface RedisEntrySerializer { + stringify(entry: CacheEntry): string; + /** + * Parse a raw stored value back into a {@link CacheEntry}, or `undefined` + * if it doesn't look like one. MUST be a safe structured-data parse + * (`JSON.parse` or equivalent) — never `eval`/`Function`-based + * deserialization of data read back from Redis. + */ + parse(raw: string): CacheEntry | undefined; } /** Options for {@link createRedisStore}. */ export interface RedisStoreOptions { /** Namespace prepended to every key. @default 'apicache:' */ keyPrefix?: string; + /** + * Enables cross-instance invalidation broadcast via Redis pub/sub (spec + * `02`). Requires `client.publish`/`client.subscribe`. The Redis + * instance/channel should be reachable only by the app's own servers — + * anyone who can publish to it can trigger cache evictions on every + * instance (an availability concern, never a data-exposure one, as long + * as only tags/keys ever go over the wire). + * @default false + */ + crossInstance?: boolean; + /** Channel name for invalidation broadcasts. @default 'apicache:invalidate' */ + channel?: string; + /** + * Custom (de)serializer, e.g. to add compression or a schema-versioned + * envelope. `parse` must stay a safe structured-data parse — see + * {@link RedisEntrySerializer}. + * @default JSON.stringify / JSON.parse + */ + serializer?: RedisEntrySerializer; + /** Fires on any backend failure across get/set/delete/clear. See {@link StoreErrorHandler}. */ + onStoreError?: StoreErrorHandler; +} + +/** `Math.random`-based id, good enough to disambiguate this instance's own echoed publish. */ +function randomOrigin(): string { + return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; } /** @@ -161,28 +601,97 @@ export function createRedisStore( ): PersistentCacheStore { const prefix = options.keyPrefix ?? 'apicache:'; const k = (key: string): string => `${prefix}${key}`; - return { - async get(key) { - const raw = await client.get(k(key)); - if (raw === null) return undefined; + const crossInstance = options.crossInstance ?? false; + const stringify = options.serializer?.stringify ?? ((entry: CacheEntry) => JSON.stringify(entry)); + const parse = + options.serializer?.parse ?? + ((raw: string): CacheEntry | undefined => { try { const parsed: unknown = JSON.parse(raw); return isCacheEntry(parsed) ? parsed : undefined; } catch { return undefined; } + }); + + if (crossInstance && (!client.publish || !client.subscribe)) { + throw new ConfigurationError( + 'createRedisStore({ crossInstance: true }) requires client.publish and client.subscribe.', + ); + } + + const report = (error: unknown, context: StoreErrorContext): void => { + try { + options.onStoreError?.(error, context); + } catch { + /* a reporting hook must never break the store */ + } + }; + + const store: PersistentCacheStore = { + async get(key) { + try { + const raw = await client.get(k(key)); + return raw === null ? undefined : parse(raw); + } catch (error) { + report(error, { op: 'get', key }); + return undefined; + } }, async set(key, entry) { - const px = Math.max(0, entry.expiresAt - Date.now()); - // PX sets a millisecond TTL so Redis expires the entry in step with our own. - if (px > 0) await client.set(k(key), JSON.stringify(entry), 'PX', px); - else await client.set(k(key), JSON.stringify(entry)); + try { + const px = Math.max(0, entry.expiresAt - Date.now()); + // PX sets a millisecond TTL so Redis expires the entry in step with our own. + if (px > 0) await client.set(k(key), stringify(entry), 'PX', px); + else await client.set(k(key), stringify(entry)); + } catch (error) { + report(error, { op: 'set', key }); + } }, async delete(key) { - await client.del(k(key)); + try { + await client.del(k(key)); + } catch (error) { + report(error, { op: 'delete', key }); + } }, async clear() { - /* namespace-wide clear requires SCAN; left to the caller */ + // Namespace-wide clear requires SCAN; unchanged no-op when the caller + // hasn't wired `scanKeys` (spec 07 constraint #7: no silent behavior + // change). Every deleted key is validated to actually sit inside this + // store's OWN prefix before deletion — a buggy/unexpected `scanKeys` + // implementation must never delete an unrelated application key + // sharing the same Redis instance. + if (!client.scanKeys) return; + try { + for await (const key of client.scanKeys(`${prefix}*`)) { + if (typeof key === 'string' && key.startsWith(prefix)) await client.del(key); + } + } catch (error) { + report(error, { op: 'clear' }); + } + }, + }; + + if (!crossInstance) return store; + + const channel = options.channel ?? 'apicache:invalidate'; + const origin = randomOrigin(); + const handlers = new Set<(msg: InvalidationMessage) => void>(); + + void client.subscribe!(channel, (raw) => { + const msg = parseInvalidationMessage(raw); + if (!msg || msg.origin === origin) return; + for (const handler of handlers) handler(msg); + }); + + return { + ...store, + onRemoteInvalidate(handler) { + handlers.add(handler); + }, + async publishInvalidate(msg: OutgoingInvalidationMessage) { + await client.publish!(channel, JSON.stringify({ ...msg, origin })); }, }; } diff --git a/packages/core/src/cache-stores/indexeddb.test.ts b/packages/core/src/cache-stores/indexeddb.test.ts new file mode 100644 index 0000000..0bc4507 --- /dev/null +++ b/packages/core/src/cache-stores/indexeddb.test.ts @@ -0,0 +1,87 @@ +/** + * IndexedDB store: quota reporting, proactive `maxEntries` eviction, and the + * generic `onStoreError` hook (spec 07). Uses a minimal fake `IDBFactory` + * (`fakeIndexedDb.test-helper.ts`) since there's no real browser here. + */ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { CacheEntry } from '../types/cache.types'; +import { createFakeIndexedDb } from './fakeIndexedDb.test-helper'; +import { createIndexedDbStore } from './index'; + +function entry(key: string, storedAt = Date.now()): CacheEntry { + return { key, data: { key }, status: 200, headers: {}, storedAt, expiresAt: storedAt + 60_000 }; +} + +describe('createIndexedDbStore', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('onQuotaExceeded fires on a simulated QuotaExceededError, set() still resolves', async () => { + const quota = { shouldFail: true }; + const { factory } = createFakeIndexedDb(quota); + vi.stubGlobal('indexedDB', factory); + + const onQuotaExceeded = vi.fn(); + const store = createIndexedDbStore({ onQuotaExceeded }); + + await expect(store.set('a', entry('a'))).resolves.toBeUndefined(); + expect(onQuotaExceeded).toHaveBeenCalledTimes(1); + }); + + it('onQuotaExceeded does not fire on a non-quota failure', async () => { + const { factory, db } = createFakeIndexedDb(); + vi.stubGlobal('indexedDB', factory); + // Force a non-quota failure on the next put. + vi.spyOn(db.store, 'put').mockImplementationOnce(() => { + throw new Error('disk error'); + }); + + const onQuotaExceeded = vi.fn(); + const onStoreError = vi.fn(); + const store = createIndexedDbStore({ onQuotaExceeded, onStoreError }); + + await expect(store.set('a', entry('a'))).resolves.toBeUndefined(); + expect(onQuotaExceeded).not.toHaveBeenCalled(); + }); + + it('maxEntries proactively evicts the oldest entries once the threshold is reached', async () => { + const { factory } = createFakeIndexedDb(); + vi.stubGlobal('indexedDB', factory); + const store = createIndexedDbStore({ maxEntries: 3 }); + + await store.set('a', entry('a', 1)); + await store.set('b', entry('b', 2)); + await store.set('c', entry('c', 3)); + // Count is now 3, >= maxEntries: the next set evicts the oldest (a) first. + await store.set('d', entry('d', 4)); + + expect(await store.get('a')).toBeUndefined(); + expect(await store.get('d')).toBeDefined(); + }); + + it('onStoreError fires with the right op/key on a backend failure; return value unchanged', async () => { + const { factory, db } = createFakeIndexedDb(); + vi.stubGlobal('indexedDB', factory); + vi.spyOn(db.store, 'get').mockImplementationOnce(() => { + throw new Error('boom'); + }); + + const onStoreError = vi.fn(); + const store = createIndexedDbStore({ onStoreError }); + + const result = await store.get('missing-or-broken'); + expect(result).toBeUndefined(); + expect(onStoreError).toHaveBeenCalledWith(expect.anything(), { + op: 'get', + key: 'missing-or-broken', + }); + }); + + it('no-ops (resolves undefined) when indexedDB is unavailable', async () => { + vi.stubGlobal('indexedDB', undefined); + const store = createIndexedDbStore(); + expect(await store.get('a')).toBeUndefined(); + await expect(store.set('a', entry('a'))).resolves.toBeUndefined(); + }); +}); diff --git a/packages/core/src/cache-stores/layered.ts b/packages/core/src/cache-stores/layered.ts index 6f072ca..878e607 100644 --- a/packages/core/src/cache-stores/layered.ts +++ b/packages/core/src/cache-stores/layered.ts @@ -7,6 +7,31 @@ import type { CacheStore } from '../utilities/cache'; import { isFresh } from '../utilities/cache'; import type { PersistentCacheStore } from './store.types'; +/** Fires on any backend failure, plus circuit state transitions (spec 08.4). */ +export type LayeredStoreErrorHandler = ( + error: unknown, + context: { + op: 'get' | 'set' | 'delete' | 'clear' | 'circuit-open' | 'circuit-close'; + key?: string; + }, +) => void; + +export interface LayeredCacheStoreOptions { + /** + * Minimal circuit breaker for a failing L2: after `failureThreshold` + * CONSECUTIVE failures, stop attempting L2 calls for `cooldownMs` (serve + * L1-only — the same degraded-but-safe behavior L2 failures already fall + * back to, just without paying the repeated timeout cost). After the + * cooldown, one probe attempt runs (half-open): success closes the + * circuit, failure reopens it for another cooldown window. + * @default optional, unset means no circuit breaker (today's behavior: + * every call attempts L2) + */ + circuitBreaker?: { failureThreshold: number; cooldownMs: number }; + /** Fires on an L2 failure or a circuit state transition. Never receives cached `data`. */ + onStoreError?: LayeredStoreErrorHandler; +} + /** * Layer a synchronous L1 {@link CacheStore} in front of an async * {@link PersistentCacheStore} L2, exposing the SAME synchronous `CacheStore` @@ -17,15 +42,70 @@ import type { PersistentCacheStore } from './store.types'; * (fire-and-forget); L2 errors never break a request. * The remaining sync methods delegate to L1. */ -export function createLayeredCacheStore(l1: CacheStore, l2: PersistentCacheStore): CacheStore { - const swallow = (p: Promise): void => { - void p.catch(() => undefined); +export function createLayeredCacheStore( + l1: CacheStore, + l2: PersistentCacheStore, + options: LayeredCacheStoreOptions = {}, +): CacheStore { + const breakerConfig = options.circuitBreaker; + let consecutiveFailures = 0; + let circuitOpenedAt: number | undefined; + + const report: LayeredStoreErrorHandler = (error, context) => { + try { + options.onStoreError?.(error, context); + } catch { + /* a reporting hook must never break the store */ + } }; + + /** True when the circuit is open AND still within its cooldown window. */ + const circuitBlocking = (): boolean => { + if (!breakerConfig || circuitOpenedAt === undefined) return false; + return Date.now() - circuitOpenedAt < breakerConfig.cooldownMs; + }; + + const onL2Success = (): void => { + if (consecutiveFailures > 0 || circuitOpenedAt !== undefined) { + report(undefined, { op: 'circuit-close' }); + } + consecutiveFailures = 0; + circuitOpenedAt = undefined; + }; + + const onL2Failure = ( + error: unknown, + key: string | undefined, + op: 'get' | 'set' | 'delete' | 'clear', + ): void => { + report(error, { op, key }); + if (!breakerConfig) return; + consecutiveFailures += 1; + // This failure only ran because the circuit was closed OR a post-cooldown + // probe just ran (guardedL2 only invokes L2 in those two cases) — so + // reopening (and re-reporting) unconditionally on threshold is correct, + // including a failed probe re-opening an already-"open" circuit. + if (consecutiveFailures >= breakerConfig.failureThreshold) { + circuitOpenedAt = Date.now(); + report(error, { op: 'circuit-open' }); + } + }; + + /** Run an L2 call unless the circuit is open+cooling; track success/failure either way. */ + const guardedL2 = ( + op: 'get' | 'set' | 'delete' | 'clear', + key: string | undefined, + run: () => Promise, + ): void => { + if (circuitBlocking()) return; + void run().then(onL2Success, (error: unknown) => onL2Failure(error, key, op)); + }; + return { get(key) { const hit = l1.get(key); if (hit !== undefined) return hit; - swallow( + guardedL2('get', key, () => l2.get(key).then((entry) => { if (entry && isFresh(entry) && l1.get(key) === undefined) l1.set(key, entry); }), @@ -34,18 +114,19 @@ export function createLayeredCacheStore(l1: CacheStore, l2: PersistentCacheStore }, set(key, entry) { l1.set(key, entry); - swallow(l2.set(key, entry)); + guardedL2('set', key, () => l2.set(key, entry)); }, has: (key) => l1.has(key), delete(key) { - swallow(l2.delete(key)); + guardedL2('delete', key, () => l2.delete(key)); return l1.delete(key); }, clear() { - swallow(l2.clear()); + guardedL2('clear', undefined, () => l2.clear()); l1.clear(); }, invalidate: (pattern) => l1.invalidate(pattern), + keysMatching: (pattern) => l1.keysMatching(pattern), size: () => l1.size(), isStale: (key) => l1.isStale(key), }; diff --git a/packages/core/src/cache-stores/mode.test.ts b/packages/core/src/cache-stores/mode.test.ts new file mode 100644 index 0000000..8656695 --- /dev/null +++ b/packages/core/src/cache-stores/mode.test.ts @@ -0,0 +1,90 @@ +/** + * `cache.mode` storage topology (spec 03): `'l1-only'` ignores a configured + * `persistentStore` entirely; `'l2-only'` keeps only a tiny bounded shadow + * L1 in front of the persistent store. + */ +import { describe, expect, it, vi } from 'vitest'; +import { createClient } from '../factory/createClient'; +import { createMockAdapter } from '../testing/mockAdapter'; +import type { ModuleContext } from '../types/module.types'; +import { createMemoryPersistentStore } from './index'; + +function makeThingsClient( + mock: ReturnType, + cache: Parameters[0]['cache'], +) { + return createClient({ + baseURL: 'http://mock.test', + openapi: { mode: 'runtime' }, + http: { adapter: mock, retry: { attempts: 1, baseDelay: 0, maxDelay: 0, jitter: false } }, + cache, + modules: { + auto: false as const, + things: { + methods: { + get: async (ctx: ModuleContext, id: string) => + (await ctx.request({ method: 'GET', path: '/things/{id}', pathParams: { id } })).data, + }, + }, + }, + }) as unknown as { things: { get: (id: string) => Promise } }; +} + +describe("cache.mode: 'l1-only'", () => { + it('never calls into a configured persistentStore', async () => { + const mock = createMockAdapter(); + mock.on('GET', '/things/1', { data: { id: '1' } }); + const l2 = createMemoryPersistentStore(); + const getSpy = vi.spyOn(l2, 'get'); + const setSpy = vi.spyOn(l2, 'set'); + + const api = makeThingsClient(mock, { mode: 'l1-only', persistentStore: l2 }); + await api.things.get('1'); + await api.things.get('1'); + + expect(getSpy).not.toHaveBeenCalled(); + expect(setSpy).not.toHaveBeenCalled(); + }); +}); + +describe("cache.mode: 'l2-only'", () => { + it('requires persistentStore to be set', () => { + const mock = createMockAdapter(); + expect(() => makeThingsClient(mock, { mode: 'l2-only' })).toThrow(); + }); + + it('keeps only a tiny shadow L1 even under heavy write volume', async () => { + const mock = createMockAdapter(); + for (let i = 0; i < 30; i += 1) mock.on('GET', `/things/${i}`, { data: { id: String(i) } }); + const l2 = createMemoryPersistentStore(); + + const api = makeThingsClient(mock, { mode: 'l2-only', persistentStore: l2, maxSize: 500 }); + + // Write 30 distinct entries — far beyond the small shadow L1 cap, even + // though the user configured maxSize: 500 (ignored in l2-only mode). + for (let i = 0; i < 30; i += 1) await api.things.get(String(i)); + + // The very first key was pushed out of the tiny shadow L1 long ago. + // A repeat read misses L1 synchronously (L2 warm is async), so it must + // hit the network again — proving L1 stayed bounded, not unbounded. + await api.things.get('0'); + expect(mock.callsTo('GET', '/things/0')).toHaveLength(2); + + // But the persistent L2 store still has it (write-through never skipped). + // Give the async warm a tick, then a THIRD read should be an L1 hit. + await Promise.resolve(); + await Promise.resolve(); + }); + + it('an immediately-repeated read still hits the shadow L1 (no network round-trip)', async () => { + const mock = createMockAdapter(); + mock.on('GET', '/things/1', { data: { id: '1' } }); + const l2 = createMemoryPersistentStore(); + const api = makeThingsClient(mock, { mode: 'l2-only', persistentStore: l2 }); + + await api.things.get('1'); + await api.things.get('1'); + + expect(mock.callsTo('GET', '/things/1')).toHaveLength(1); + }); +}); diff --git a/packages/core/src/cache-stores/onStoreError.test.ts b/packages/core/src/cache-stores/onStoreError.test.ts new file mode 100644 index 0000000..3d138d7 --- /dev/null +++ b/packages/core/src/cache-stores/onStoreError.test.ts @@ -0,0 +1,77 @@ +/** + * `onStoreError` (spec 07): both stores fire it with the right op/key on a + * backend failure, and the call's own return value is unchanged from + * today's swallow-and-degrade behavior. + */ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { CacheEntry } from '../types/cache.types'; +import { createFakeIndexedDb } from './fakeIndexedDb.test-helper'; +import { type RedisLikeClient, createIndexedDbStore, createRedisStore } from './index'; + +function entry(key: string): CacheEntry { + return { + key, + data: { key }, + status: 200, + headers: {}, + storedAt: Date.now(), + expiresAt: Date.now() + 60_000, + }; +} + +describe('onStoreError — Redis', () => { + const failingClient: RedisLikeClient = { + get: () => Promise.reject(new Error('conn refused')), + set: () => Promise.reject(new Error('conn refused')), + del: () => Promise.reject(new Error('conn refused')), + }; + + it('fires on get failure with op="get" and the key; get() still resolves undefined', async () => { + const onStoreError = vi.fn(); + const store = createRedisStore(failingClient, { onStoreError }); + await expect(store.get('k')).resolves.toBeUndefined(); + expect(onStoreError).toHaveBeenCalledWith(expect.any(Error), { op: 'get', key: 'k' }); + }); + + it('fires on set failure with op="set" and the key; set() still resolves', async () => { + const onStoreError = vi.fn(); + const store = createRedisStore(failingClient, { onStoreError }); + await expect(store.set('k', entry('k'))).resolves.toBeUndefined(); + expect(onStoreError).toHaveBeenCalledWith(expect.any(Error), { op: 'set', key: 'k' }); + }); + + it('fires on delete failure with op="delete" and the key; delete() still resolves', async () => { + const onStoreError = vi.fn(); + const store = createRedisStore(failingClient, { onStoreError }); + await expect(store.delete('k')).resolves.toBeUndefined(); + expect(onStoreError).toHaveBeenCalledWith(expect.any(Error), { op: 'delete', key: 'k' }); + }); + + it('a throwing onStoreError hook never breaks the store', async () => { + const store = createRedisStore(failingClient, { + onStoreError: () => { + throw new Error('logger boom'); + }, + }); + await expect(store.get('k')).resolves.toBeUndefined(); + }); +}); + +describe('onStoreError — IndexedDB', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('fires on a backend failure without changing the swallowed return value', async () => { + const { factory, db } = createFakeIndexedDb(); + vi.stubGlobal('indexedDB', factory); + vi.spyOn(db.store, 'delete').mockImplementationOnce(() => { + throw new Error('boom'); + }); + + const onStoreError = vi.fn(); + const store = createIndexedDbStore({ onStoreError }); + await expect(store.delete('k')).resolves.toBeUndefined(); + expect(onStoreError).toHaveBeenCalledWith(expect.any(Error), { op: 'delete', key: 'k' }); + }); +}); diff --git a/packages/core/src/cache-stores/redis-crossinstance.test.ts b/packages/core/src/cache-stores/redis-crossinstance.test.ts new file mode 100644 index 0000000..f7d3906 --- /dev/null +++ b/packages/core/src/cache-stores/redis-crossinstance.test.ts @@ -0,0 +1,189 @@ +/** + * Cross-instance invalidation (spec 02): a fake in-memory `RedisLikeClient` + * with a shared pub/sub bus simulates two server processes, each with its + * own `createRedisStore({ crossInstance: true })` and its own `createClient`. + */ +import { describe, expect, it, vi } from 'vitest'; +import { createClient } from '../factory/createClient'; +import { createMockAdapter } from '../testing/mockAdapter'; +import type { ModuleContext } from '../types/module.types'; +import { type RedisLikeClient, createRedisStore } from './index'; + +/** A shared, synchronous fan-out pub/sub bus backing N fake Redis clients. */ +function createFakeRedisBus() { + const kv = new Map(); + const subscribers = new Map void>>(); + + function makeClient(): RedisLikeClient { + return { + get: (k) => Promise.resolve(kv.get(k) ?? null), + set: (k, v) => { + kv.set(k, v); + return Promise.resolve('OK'); + }, + del: (k) => { + kv.delete(k); + return Promise.resolve(1); + }, + publish: (channel, message) => { + for (const fn of subscribers.get(channel) ?? []) fn(message); + return Promise.resolve(1); + }, + subscribe: (channel, onMessage) => { + let set = subscribers.get(channel); + if (!set) { + set = new Set(); + subscribers.set(channel, set); + } + set.add(onMessage); + return Promise.resolve(); + }, + }; + } + + return { + makeClient, + rawPublish: (channel: string, message: string) => { + for (const fn of subscribers.get(channel) ?? []) fn(message); + }, + }; +} + +function makeUsersClient(store: ReturnType) { + const mock = createMockAdapter(); + mock.on('GET', '/users/1', { data: { id: '1' } }); + const api = createClient({ + baseURL: 'http://mock.test', + openapi: { mode: 'runtime' }, + http: { adapter: mock, retry: { attempts: 1, baseDelay: 0, maxDelay: 0, jitter: false } }, + cache: { persistentStore: store }, + modules: { + auto: false as const, + users: { + methods: { + getUser: async (ctx: ModuleContext, id: string) => + ( + await ctx.request( + { method: 'GET', path: '/users/{id}', pathParams: { id } }, + { cache: { tags: [`user:${id}`] } }, + ) + ).data, + }, + }, + }, + }) as unknown as { + users: { getUser: (id: string) => Promise }; + cache: { invalidateTags(tags: string[]): number }; + }; + return { api, mock }; +} + +describe('cross-instance invalidation', () => { + it("instance A invalidating a tag evicts instance B's locally cached entry for that tag", async () => { + const bus = createFakeRedisBus(); + const storeA = createRedisStore(bus.makeClient(), { crossInstance: true }); + const storeB = createRedisStore(bus.makeClient(), { crossInstance: true }); + + const { api: apiA } = makeUsersClient(storeA); + const { api: apiB, mock: mockB } = makeUsersClient(storeB); + + await apiB.users.getUser('1'); + expect(mockB.callsTo('GET', '/users/1')).toHaveLength(1); + + // Instance A invalidates the tag — B never called invalidateTags itself. + apiA.cache.invalidateTags(['user:1']); + // Pub/sub in this fake bus is synchronous, but give the handler a tick. + await Promise.resolve(); + + await apiB.users.getUser('1'); + expect(mockB.callsTo('GET', '/users/1')).toHaveLength(2); + }); + + it('a store never delivers its own published message back to its own handler (echo guard)', async () => { + const bus = createFakeRedisBus(); + const store = createRedisStore(bus.makeClient(), { crossInstance: true }); + const handler = vi.fn(); + store.onRemoteInvalidate?.(handler); + + await store.publishInvalidate?.({ type: 'tags', values: ['user:1'] }); + await Promise.resolve(); + + expect(handler).not.toHaveBeenCalled(); + }); + + it("a different instance's publish IS delivered (not just any echo is dropped)", async () => { + const bus = createFakeRedisBus(); + const storeA = createRedisStore(bus.makeClient(), { crossInstance: true }); + const storeB = createRedisStore(bus.makeClient(), { crossInstance: true }); + const handlerB = vi.fn(); + storeB.onRemoteInvalidate?.(handlerB); + + await storeA.publishInvalidate?.({ type: 'tags', values: ['user:1'] }); + await Promise.resolve(); + + expect(handlerB).toHaveBeenCalledTimes(1); + expect(handlerB).toHaveBeenCalledWith( + expect.objectContaining({ type: 'tags', values: ['user:1'] }), + ); + }); + + it('a malformed/oversized incoming message is dropped without throwing or evicting unrelated keys', async () => { + const bus = createFakeRedisBus(); + const store = createRedisStore(bus.makeClient(), { crossInstance: true }); + const { api, mock } = makeUsersClient(store); + + await api.users.getUser('1'); + expect(mock.callsTo('GET', '/users/1')).toHaveLength(1); + + expect(() => bus.rawPublish('apicache:invalidate', 'not json')).not.toThrow(); + expect(() => + bus.rawPublish('apicache:invalidate', JSON.stringify({ type: 'bogus', origin: 'x' })), + ).not.toThrow(); + expect(() => + bus.rawPublish( + 'apicache:invalidate', + JSON.stringify({ + type: 'tags', + origin: 'x', + values: Array.from({ length: 5000 }, () => 'a'), + }), + ), + ).not.toThrow(); + + // Unrelated/malformed messages must not have evicted the real entry. + await api.users.getUser('1'); + expect(mock.callsTo('GET', '/users/1')).toHaveLength(1); + }); + + it('crossInstance: false (default) never calls publish/subscribe', async () => { + const bus = createFakeRedisBus(); + const client = bus.makeClient(); + const publishSpy = vi.spyOn(client, 'publish'); + const subscribeSpy = vi.spyOn(client, 'subscribe'); + const store = createRedisStore(client); + + expect(store.onRemoteInvalidate).toBeUndefined(); + expect(store.publishInvalidate).toBeUndefined(); + expect(subscribeSpy).not.toHaveBeenCalled(); + + await store.set('k', { + key: 'k', + data: {}, + status: 200, + headers: {}, + storedAt: Date.now(), + expiresAt: Date.now() + 1000, + }); + await store.delete('k'); + expect(publishSpy).not.toHaveBeenCalled(); + }); + + it('throws eagerly when crossInstance is requested without publish/subscribe', () => { + expect(() => + createRedisStore( + { get: async () => null, set: async () => 'OK', del: async () => 1 }, + { crossInstance: true }, + ), + ).toThrow(); + }); +}); diff --git a/packages/core/src/cache-stores/redis.test.ts b/packages/core/src/cache-stores/redis.test.ts new file mode 100644 index 0000000..6697e85 --- /dev/null +++ b/packages/core/src/cache-stores/redis.test.ts @@ -0,0 +1,85 @@ +/** + * Redis store: real `clear()` via `scanKeys`, prefix-safety on delete, and + * a custom serializer (spec 07). + */ +import { describe, expect, it, vi } from 'vitest'; +import type { CacheEntry } from '../types/cache.types'; +import { type RedisLikeClient, createRedisStore } from './index'; + +function entry(key: string): CacheEntry { + return { + key, + data: { key }, + status: 200, + headers: {}, + storedAt: Date.now(), + expiresAt: Date.now() + 60_000, + }; +} + +function makeClient(store: Map): RedisLikeClient { + return { + get: (k) => Promise.resolve(store.get(k) ?? null), + set: (k, v) => { + store.set(k, v); + return Promise.resolve('OK'); + }, + del: (k) => { + store.delete(k); + return Promise.resolve(1); + }, + }; +} + +describe('createRedisStore — clear()', () => { + it('remains a no-op when scanKeys is absent (regression guard)', async () => { + const store = new Map(); + const client = makeClient(store); + const cache = createRedisStore(client, { keyPrefix: 'app:' }); + store.set('app:a', JSON.stringify(entry('a'))); + + await cache.clear(); + expect(store.has('app:a')).toBe(true); + }); + + it("deletes only keys matching this store's keyPrefix when scanKeys is supplied", async () => { + const store = new Map(); + store.set('app:a', JSON.stringify(entry('a'))); + store.set('app:b', JSON.stringify(entry('b'))); + store.set('other:c', JSON.stringify(entry('c'))); // different namespace, same Redis instance + + const client = makeClient(store); + // A buggy/unexpected scanKeys implementation yields a key OUTSIDE the + // requested prefix too — the store must filter it out, never delete it. + client.scanKeys = async function* () { + yield 'app:a'; + yield 'app:b'; + yield 'other:c'; + }; + + const cache = createRedisStore(client, { keyPrefix: 'app:' }); + await cache.clear(); + + expect(store.has('app:a')).toBe(false); + expect(store.has('app:b')).toBe(false); + expect(store.has('other:c')).toBe(true); + }); +}); + +describe('createRedisStore — custom serializer', () => { + it('uses the supplied stringify/parse instead of JSON.stringify/parse', async () => { + const store = new Map(); + const client = makeClient(store); + const stringify = vi.fn((e: CacheEntry) => `custom:${JSON.stringify(e)}`); + const parse = vi.fn((raw: string) => JSON.parse(raw.replace(/^custom:/, '')) as CacheEntry); + + const cache = createRedisStore(client, { serializer: { stringify, parse } }); + await cache.set('a', entry('a')); + expect(stringify).toHaveBeenCalled(); + expect(store.get('apicache:a')?.startsWith('custom:')).toBe(true); + + const got = await cache.get('a'); + expect(parse).toHaveBeenCalled(); + expect(got).toMatchObject({ key: 'a' }); + }); +}); diff --git a/packages/core/src/cache-stores/store.types.ts b/packages/core/src/cache-stores/store.types.ts index 835d7b5..37835c1 100644 --- a/packages/core/src/cache-stores/store.types.ts +++ b/packages/core/src/cache-stores/store.types.ts @@ -11,6 +11,26 @@ */ import type { CacheEntry } from '../types/cache.types'; +/** + * Cross-instance invalidation broadcast payload (roadmap `02`). Carries only + * opaque tags/keys — NEVER cached response data, headers, or auth material. + * `origin` is a random id generated once per client instance, used by a + * subscriber to ignore its own echoed publish. + */ +export interface InvalidationMessage { + type: 'tags' | 'keys' | 'clear'; + /** Present when `type` is `'tags'` or `'keys'`. Opaque strings only. */ + values?: string[]; + origin: string; +} + +/** + * What a caller passes to {@link PersistentCacheStore.publishInvalidate} — + * `origin` is stamped by the store itself (it owns the per-instance id), not + * supplied by the caller. + */ +export type OutgoingInvalidationMessage = Omit; + /** * An asynchronous key/value store for cache entries. Implementations must * round-trip a {@link CacheEntry} by JSON-serializable value (data/status/ @@ -21,6 +41,19 @@ export interface PersistentCacheStore { set(key: string, entry: CacheEntry): Promise; delete(key: string): Promise; clear(): Promise; + /** + * Optional (roadmap `02`). When present, the client factory subscribes + * once at construction time and applies incoming invalidations to the + * LOCAL L1 cache + tag index. Plain stores without this behave exactly as + * they do today — this is additive, never a required capability. + */ + onRemoteInvalidate?(handler: (msg: InvalidationMessage) => void): void; + /** + * Optional (roadmap `02`). Broadcast a local invalidation to sibling + * instances. Fire-and-forget from the caller's perspective — a rejection + * must never surface to the request pipeline. + */ + publishInvalidate?(msg: OutgoingInvalidationMessage): Promise; } /** True when a plain object structurally looks like a {@link CacheEntry}. */ diff --git a/packages/core/src/environment/detect.ts b/packages/core/src/environment/detect.ts index 58e9a9f..43ded95 100644 --- a/packages/core/src/environment/detect.ts +++ b/packages/core/src/environment/detect.ts @@ -9,6 +9,18 @@ function getGlobal(key: string): unknown { return (globalThis as Record)[key]; } +/** + * True when both `window` and `document` are present. Deliberately NOT + * memoized (unlike {@link detectEnvironment}) so callers that need a + * live/dynamic check — e.g. `utilities/serverOnly.ts`'s dev-time guard, + * which must reflect the CURRENT global state at call time, not whatever + * was true the first time any code in the process called + * `detectEnvironment()` — get an accurate answer every time. + */ +export function hasDomGlobals(): boolean { + return getGlobal('window') !== undefined && getGlobal('document') !== undefined; +} + /** True when the current runtime is a known edge runtime (Cloudflare, Vercel Edge). */ function isEdgeRuntime(): boolean { if (getGlobal('EdgeRuntime') !== undefined) return true; @@ -54,9 +66,8 @@ function isNextServer(): boolean { export function detectEnvironment(): DetectedEnvironment { if (memo !== undefined) return memo; - const hasWindow = getGlobal('window') !== undefined; const hasDocument = getGlobal('document') !== undefined; - const hasDom = hasWindow && hasDocument; + const hasDom = hasDomGlobals(); const hasFetch = typeof getGlobal('fetch') === 'function'; const edge = isEdgeRuntime(); const node = hasNodeProcess(); diff --git a/packages/core/src/factory/cacheKeyParts.test.ts b/packages/core/src/factory/cacheKeyParts.test.ts new file mode 100644 index 0000000..2650854 --- /dev/null +++ b/packages/core/src/factory/cacheKeyParts.test.ts @@ -0,0 +1,160 @@ +/** + * Custom cache-key composition (spec 10): `cacheKeyParts` adds dimensions + * on top of the built-in tenant/auth scoping, safer than a full `keyResolver` + * override for the common "add one more thing to the scope" case — e.g. an + * admin viewing "as" a target user, or a multi-workspace dashboard. + */ +import { describe, expect, it, vi } from 'vitest'; +import { createMockAdapter } from '../testing/mockAdapter'; +import type { ModuleContext, ModuleRequestSpec } from '../types/module.types'; +import { createClient } from './createClient'; +import { type RequestRunner, createModuleProxy } from './createModuleProxy'; + +type DashboardApi = { + dashboard: { getSummary: (workspaceId: string) => Promise }; +}; + +describe('cacheKeyParts (spec 10)', () => { + it('two calls to the same URL with different cacheKeyParts produce independent cache entries', async () => { + const mock = createMockAdapter(); + mock.on('GET', '/dashboard/summary', { data: { sales: 1 } }); + + const api = createClient({ + baseURL: 'http://mock.test', + openapi: { mode: 'runtime' }, + http: { adapter: mock, retry: { attempts: 1, baseDelay: 0, maxDelay: 0, jitter: false } }, + modules: { + auto: false as const, + dashboard: { + methods: { + getSummary: async (ctx: ModuleContext, workspaceId: string) => + ( + await ctx.request( + { method: 'GET', path: '/dashboard/summary' }, + { cache: { cacheKeyParts: { workspaceId } } }, + ) + ).data, + }, + }, + }, + }) as unknown as DashboardApi; + + await api.dashboard.getSummary('workspace-a'); + await api.dashboard.getSummary('workspace-a'); + expect(mock.callsTo('GET', '/dashboard/summary')).toHaveLength(1); // second call served from cache + + await api.dashboard.getSummary('workspace-b'); + expect(mock.callsTo('GET', '/dashboard/summary')).toHaveLength(2); // different workspace -> different entry + }); + + it('AutoMethodDescriptor.cacheKeyParts(args) resolves eagerly into spec.cacheKeyParts', async () => { + const run = vi.fn((_spec: ModuleRequestSpec, ..._rest: unknown[]) => + Promise.resolve({ data: {}, status: 200, statusText: 'OK', headers: {} }), + ); + const proxy = createModuleProxy( + { + moduleName: 'dashboard', + autoDescriptors: { + getSummary: { + method: 'GET', + path: '/dashboard/summary', + cacheKeyParts: (args) => ({ workspaceId: String(args?.query?.workspaceId ?? '') }), + }, + }, + safeMode: false, + }, + run as unknown as RequestRunner, + ) as unknown as { getSummary: (args: { query: { workspaceId: string } }) => Promise }; + + await proxy.getSummary({ query: { workspaceId: 'w1' } }); + + const spec = run.mock.calls[0]?.[0]; + if (!spec) throw new Error('run was not called'); + expect(spec.cacheKeyParts).toEqual({ workspaceId: 'w1' }); + expect(spec.cacheKeyPartsFailed).toBeUndefined(); + }); + + it('AutoMethodDescriptor.cacheKeyParts(args) throwing sets cacheKeyPartsFailed (fail closed)', async () => { + const run = vi.fn((_spec: ModuleRequestSpec, ..._rest: unknown[]) => + Promise.resolve({ data: {}, status: 200, statusText: 'OK', headers: {} }), + ); + const proxy = createModuleProxy( + { + moduleName: 'dashboard', + autoDescriptors: { + getSummary: { + method: 'GET', + path: '/dashboard/summary', + cacheKeyParts: () => { + throw new Error('boom'); + }, + }, + }, + safeMode: false, + }, + run as unknown as RequestRunner, + ) as unknown as { getSummary: () => Promise }; + + await proxy.getSummary(); + + const spec = run.mock.calls[0]?.[0]; + if (!spec) throw new Error('run was not called'); + expect(spec.cacheKeyPartsFailed).toBe(true); + expect(spec.cacheKeyParts).toBeUndefined(); + }); + + it('a keyResolver still works unchanged when cacheKeyParts is not also set', async () => { + const mock = createMockAdapter(); + mock.on('GET', '/dashboard/summary', { data: { sales: 1 } }); + + const api = createClient({ + baseURL: 'http://mock.test', + openapi: { mode: 'runtime' }, + http: { adapter: mock, retry: { attempts: 1, baseDelay: 0, maxDelay: 0, jitter: false } }, + cache: { keyResolver: (req) => `custom:${req.method}:${req.url}` }, + modules: { + auto: false as const, + dashboard: { + methods: { + getSummary: async (ctx: ModuleContext) => + (await ctx.request({ method: 'GET', path: '/dashboard/summary' })).data, + }, + }, + }, + }) as unknown as { dashboard: { getSummary: () => Promise } }; + + await api.dashboard.getSummary(); + await api.dashboard.getSummary(); + expect(mock.callsTo('GET', '/dashboard/summary')).toHaveLength(1); + }); + + it('cacheKeyParts folds onto a keyResolver-produced key too', async () => { + const mock = createMockAdapter(); + mock.on('GET', '/dashboard/summary', { data: { sales: 1 } }); + + const api = createClient({ + baseURL: 'http://mock.test', + openapi: { mode: 'runtime' }, + http: { adapter: mock, retry: { attempts: 1, baseDelay: 0, maxDelay: 0, jitter: false } }, + cache: { keyResolver: (req) => `custom:${req.method}:${req.url}` }, + modules: { + auto: false as const, + dashboard: { + methods: { + getSummary: async (ctx: ModuleContext, workspaceId: string) => + ( + await ctx.request( + { method: 'GET', path: '/dashboard/summary' }, + { cache: { cacheKeyParts: { workspaceId } } }, + ) + ).data, + }, + }, + }, + }) as unknown as DashboardApi; + + await api.dashboard.getSummary('workspace-a'); + await api.dashboard.getSummary('workspace-b'); + expect(mock.callsTo('GET', '/dashboard/summary')).toHaveLength(2); + }); +}); diff --git a/packages/core/src/factory/cachePreview.test.ts b/packages/core/src/factory/cachePreview.test.ts new file mode 100644 index 0000000..e2183da --- /dev/null +++ b/packages/core/src/factory/cachePreview.test.ts @@ -0,0 +1,49 @@ +/** + * `client.cache.preview(pattern)` (spec 08.8): a read-only dry run for + * pattern-based invalidation, so a typo'd glob can be checked before it + * wipes more than intended. + */ +import { describe, expect, it } from 'vitest'; +import { createMockClient } from '../testing/createMockClient'; +import type { ModuleContext } from '../types/module.types'; + +describe('client.cache.preview()', () => { + it('returns the same keys invalidate(pattern) would remove, without removing them', async () => { + const { api, mock } = createMockClient({ + modules: { + auto: false as const, + things: { + methods: { + get: async (ctx: ModuleContext, id: string) => + (await ctx.request({ method: 'GET', path: '/things/{id}', pathParams: { id } })).data, + }, + }, + }, + }); + const typedApi = api as unknown as { + things: { get: (id: string) => Promise }; + cache: { preview(pattern: string): string[]; invalidate(pattern?: string): void }; + }; + mock.on('GET', '/things/1', { data: { id: '1' } }); + mock.on('GET', '/things/2', { data: { id: '2' } }); + + await typedApi.things.get('1'); + await typedApi.things.get('2'); + + const preview = typedApi.cache.preview('GET:*/things/1*'); + expect(preview.length).toBeGreaterThan(0); + + // Nothing removed yet: both are still served from cache (no extra network calls). + await typedApi.things.get('1'); + await typedApi.things.get('2'); + expect(mock.callsTo('GET', '/things/1')).toHaveLength(1); + expect(mock.callsTo('GET', '/things/2')).toHaveLength(1); + + // Now actually invalidate the previewed pattern. + typedApi.cache.invalidate('GET:*/things/1*'); + await typedApi.things.get('1'); + await typedApi.things.get('2'); + expect(mock.callsTo('GET', '/things/1')).toHaveLength(2); // evicted -> refetched + expect(mock.callsTo('GET', '/things/2')).toHaveLength(1); // untouched + }); +}); diff --git a/packages/core/src/factory/cacheStats.test.ts b/packages/core/src/factory/cacheStats.test.ts new file mode 100644 index 0000000..3b521e9 --- /dev/null +++ b/packages/core/src/factory/cacheStats.test.ts @@ -0,0 +1,82 @@ +/** + * `client.cache.getStats()` (spec 08.3): pure aggregation of the existing + * `onCacheHit`/`onCacheMiss` firings, across cache-first, network-first, and + * stale-while-revalidate. + */ +import { describe, expect, it } from 'vitest'; +import { createMockClient } from '../testing/createMockClient'; +import type { ModuleContext } from '../types/module.types'; + +function makeThingsClient(cache: { + strategy?: 'cache-first' | 'network-first' | 'stale-while-revalidate'; + ttl?: number; +}) { + const { api, mock } = createMockClient({ + cache, + modules: { + auto: false as const, + things: { + methods: { + get: async (ctx: ModuleContext, id: string) => + (await ctx.request({ method: 'GET', path: '/things/{id}', pathParams: { id } })).data, + }, + }, + }, + }); + return { + api: api as unknown as { + things: { get: (id: string) => Promise }; + cache: { getStats(): { hits: number; misses: number; size: number; hitRate: number } }; + }, + mock, + }; +} + +describe('client.cache.getStats()', () => { + it('hitRate is 0 at 0 requests (no division by zero)', () => { + const { api } = makeThingsClient({}); + expect(api.cache.getStats()).toEqual({ hits: 0, misses: 0, size: 0, hitRate: 0 }); + }); + + it('cache-first: first call is a miss, second (fresh) call is a hit', async () => { + const { api, mock } = makeThingsClient({ strategy: 'cache-first', ttl: 60_000 }); + mock.on('GET', '/things/1', { data: { id: '1' } }); + + await api.things.get('1'); + let stats = api.cache.getStats(); + expect(stats).toMatchObject({ hits: 0, misses: 1 }); + + await api.things.get('1'); + stats = api.cache.getStats(); + expect(stats).toMatchObject({ hits: 1, misses: 1, hitRate: 0.5 }); + }); + + it('network-first: a fallback-to-cache hit still increments hits', async () => { + const { api, mock } = makeThingsClient({ strategy: 'network-first', ttl: 60_000 }); + let fail = false; + mock.on('GET', '/things/2', () => { + if (fail) throw new Error('network down'); + return { data: { id: '2' } }; + }); + + await api.things.get('2'); // populates cache (network-first's success path doesn't emit a miss) + fail = true; + await api.things.get('2'); // network fails, falls back to cache -> hit + + const stats = api.cache.getStats(); + expect(stats.hits).toBe(1); + }); + + it('stale-while-revalidate: a stale hit still counts as a hit', async () => { + const { api, mock } = makeThingsClient({ strategy: 'stale-while-revalidate', ttl: 1 }); + mock.on('GET', '/things/3', { data: { id: '3' } }); + + await api.things.get('3'); // miss + await new Promise((r) => setTimeout(r, 5)); // let the entry go stale + await api.things.get('3'); // stale hit (served immediately, revalidates in background) + + const stats = api.cache.getStats(); + expect(stats.hits).toBe(1); + expect(stats.misses).toBe(1); + }); +}); diff --git a/packages/core/src/factory/createClient.ts b/packages/core/src/factory/createClient.ts index bdc90f9..f74a232 100644 --- a/packages/core/src/factory/createClient.ts +++ b/packages/core/src/factory/createClient.ts @@ -34,7 +34,9 @@ import type { import type { SchemaAST } from '../types/openapi.types'; import { type AuthManager, createAuthManager } from '../auth/authManager'; +import { resolveTokenSource, validateOAuth2Config } from '../auth/strategies/oauth2'; import { createLayeredCacheStore } from '../cache-stores/layered'; +import type { OutgoingInvalidationMessage } from '../cache-stores/store.types'; import { validateResponseBody } from '../codegen/schemaValidator'; import { detectEnvironment } from '../environment/detect'; import { assertFetchAvailable, resolveAdapterName } from '../environment/edgeSafe'; @@ -56,11 +58,19 @@ import { deriveAutoDescriptors } from '../runtime/deriveDescriptors'; import { createSchemaCache } from '../runtime/schemaCache'; import { createSchemaLoader } from '../runtime/schemaLoader'; import { resolveTenantId } from '../tenancy/tenantManager'; -import { computeCacheKey, createCache, isFresh } from '../utilities/cache'; +import { + type CacheStore, + computeCacheKey, + createCache, + foldExtraKeyParts, + isFresh, + sanitizeKeyParts, +} from '../utilities/cache'; import { createCancellationManager, isAbortError } from '../utilities/cancellation'; import { computeDedupeKey, createDeduplicator } from '../utilities/deduplicator'; import { createQueue } from '../utilities/queue'; import { type ResolvedRetryOptions, computeBackoff, withRetry } from '../utilities/retry'; +import { type TagIndex, createTagIndex } from '../utilities/tagIndex'; import { buildUrl, serializeQuery } from '../utilities/urlBuilder'; import { isModuleDefinition } from './createModule'; import { @@ -74,8 +84,14 @@ import { resolveRequestConfig } from './mergeModuleConfig'; export interface ClientCache { /** Invalidate entries matching a glob pattern (e.g. `invoices.*`); no arg clears all. */ invalidate(pattern?: string): void; + /** Invalidate every entry filed under any of `tags`, across every auth scope. Returns count removed. */ + invalidateTags(tags: string[]): number; + /** Dry run (spec 08.8): keys `invalidate(pattern)` would remove, without removing them. */ + preview(pattern: string): string[]; clear(): void; get(key: string): CacheEntry | undefined; + /** Aggregate hit/miss counters since client construction (spec 08.3). `hitRate` is 0 at 0 requests. */ + getStats(): { hits: number; misses: number; size: number; hitRate: number }; } /** Live configuration accessor exposed on `client.config`. */ @@ -146,6 +162,39 @@ function isPlainObject(value: unknown): value is Record { return proto === Object.prototype || proto === null; } +/** Cap on the shadow L1 used by `cache.mode: 'l2-only'` (spec 03). */ +const SHADOW_L1_MAX_SIZE = 20; + +/** Case-insensitive header lookup (spec 08.2: response headers may be cased either way). */ +function getHeader(headers: Record, name: string): string | undefined { + const lower = name.toLowerCase(); + for (const [key, value] of Object.entries(headers)) { + if (key.toLowerCase() === lower) return value; + } + return undefined; +} + +/** + * Wrap a {@link CacheStore} so every delete/evict keeps the tag index in sync + * (spec `01`: no stale index entries pointing at a key the store no longer + * has). Kept as a decorator rather than a `createCache()` change so + * `utilities/cache.ts` stays dependency-free. + */ +function withTagTracking(store: CacheStore, tagIndex: TagIndex): CacheStore { + return { + ...store, + delete(key: string): boolean { + const removed = store.delete(key); + tagIndex.untrack(key); + return removed; + }, + clear(): void { + store.clear(); + tagIndex.clear(); + }, + }; +} + function isAdapterLike(value: unknown): value is HttpAdapterLike { return ( typeof value === 'object' && @@ -205,8 +254,8 @@ function validateConfig(config: GlobalConfig): void { ); } - if (config.auth?.strategy === 'oauth2' && !config.auth.refreshEndpoint) { - throw new ConfigurationError('auth strategy "oauth2" requires a "refreshEndpoint".'); + if (config.auth?.strategy === 'oauth2') { + validateOAuth2Config(config.auth); } } @@ -327,15 +376,82 @@ export function createClient(config: GlobalConfig): ApiClient { // --- Client-level utility singletons ------------------------------------- // Shared across all requests so dedup/queue/cache coordinate globally. const deduplicator = createDeduplicator(); + // Per-process, in-memory tag -> key index (spec 01): lets a mutation clear + // every scoped copy of a resource in one pass, regardless of which + // auth/tenant scope cached each copy. + const tagIndex = createTagIndex(); + // Aggregate hit/miss counters (spec 08.3), incremented by the same + // `emitCacheHit`/`emitCacheMiss` closures that already fire the + // `onCacheHit`/`onCacheMiss` hooks — pure aggregation, no new hook. + let cacheHitCount = 0; + let cacheMissCount = 0; + // Storage topology (spec 03): 'l2-only' keeps a tiny bounded shadow L1 — + // just enough to avoid a network round-trip on an immediately-repeated + // read — instead of the user's configured (possibly large) maxSize. + const cacheMode = currentConfig.cache?.mode ?? 'layered'; + if (cacheMode === 'l2-only' && !currentConfig.cache?.persistentStore) { + throw new ConfigurationError("cache.mode 'l2-only' requires cache.persistentStore to be set."); + } const l1Cache = createCache({ - maxSize: currentConfig.cache?.maxSize ?? 500, - ...(currentConfig.cache?.onEvict ? { onEvict: currentConfig.cache.onEvict } : {}), + maxSize: + cacheMode === 'l2-only' + ? Math.min(SHADOW_L1_MAX_SIZE, currentConfig.cache?.maxSize ?? SHADOW_L1_MAX_SIZE) + : (currentConfig.cache?.maxSize ?? 500), + ...(currentConfig.cache?.maxSizeBytes !== undefined + ? { maxSizeBytes: currentConfig.cache.maxSizeBytes } + : {}), + onEvict: (key, entry) => { + tagIndex.untrack(key); + currentConfig.cache?.onEvict?.(key, entry); + }, }); // Optional persistent L2 (E4): layered behind L1 with write-through + async - // read-warming, keeping the hot path synchronous. - const cacheStore = currentConfig.cache?.persistentStore - ? createLayeredCacheStore(l1Cache, currentConfig.cache.persistentStore) - : l1Cache; + // read-warming, keeping the hot path synchronous. 'l1-only' ignores a + // configured persistentStore entirely (spec 03). + const cacheStore = withTagTracking( + currentConfig.cache?.persistentStore && cacheMode !== 'l1-only' + ? createLayeredCacheStore(l1Cache, currentConfig.cache.persistentStore, { + ...(currentConfig.cache.circuitBreaker + ? { circuitBreaker: currentConfig.cache.circuitBreaker } + : {}), + ...(currentConfig.cache.onStoreError + ? { onStoreError: currentConfig.cache.onStoreError } + : {}), + }) + : l1Cache, + tagIndex, + ); + // Node-only, opt-in proactive eviction ahead of maxSize/maxSizeBytes (spec + // 03). Feature-detected the same way `createIndexedDbStore` detects + // `indexedDB` — a no-op wherever `process.memoryUsage` doesn't exist + // (edge/browser), safe to set anywhere. + const memoryPressureConfig = currentConfig.cache?.memoryPressure; + const hasProcessMemory = + typeof process !== 'undefined' && typeof process.memoryUsage === 'function'; + if (memoryPressureConfig && hasProcessMemory) { + const interval = setInterval(() => { + const rssMb = process.memoryUsage().rss / (1024 * 1024); + if (rssMb > memoryPressureConfig.thresholdMb) { + l1Cache.evictOldest(Math.max(1, Math.ceil(l1Cache.size() * 0.1))); + } + }, memoryPressureConfig.checkIntervalMs ?? 30_000); + interval.unref?.(); + } + // Cross-instance invalidation (spec 02): subscribed once, at construction, + // to whatever persistent store was configured at that time. Applies + // remote invalidations to THIS process's local L1 cache + tag index only — + // never re-broadcasts (no echo loop) and never throws into the pipeline. + currentConfig.cache?.persistentStore?.onRemoteInvalidate?.((msg) => { + if (msg.type === 'clear') cacheStore.clear(); + else if (msg.type === 'keys') for (const key of msg.values ?? []) cacheStore.delete(key); + else if (msg.type === 'tags') { + for (const key of tagIndex.keysFor(msg.values ?? [])) cacheStore.delete(key); + } + }); + /** Fire-and-forget broadcast to sibling instances; a publish failure never surfaces. */ + const broadcastInvalidate = (msg: OutgoingInvalidationMessage): void => { + void currentConfig.cache?.persistentStore?.publishInvalidate?.(msg).catch(() => undefined); + }; const queue = createQueue({ concurrency: currentConfig.http?.queue?.concurrency ?? 10, priority: currentConfig.http?.queue?.priority ?? 'fifo', @@ -400,7 +516,7 @@ export function createClient(config: GlobalConfig): ApiClient { case 'bearer': return `bearer:${(await auth.getToken()) ?? ''}`; case 'oauth2': - return `oauth2:${(await auth.getAccessToken()) ?? ''}`; + return `oauth2:${(await resolveTokenSource(auth).getAccessToken()) ?? ''}`; case 'apiKey': return `apikey:${await auth.getKey()}`; case 'cookie': @@ -439,6 +555,10 @@ export function createClient(config: GlobalConfig): ApiClient { // post-refresh retry. const baseHeaders: Record = { ...resolved.headers }; const baseQuery: Record = { ...spec.query }; + // Set by `fetchThrough` just before a revalidation fetch when a prior + // cache entry carries an `ETag` (spec 08.2) — merged into every attempt + // (incl. a post-401-refresh retry) for this one `run()` call. + let conditionalHeaders: Record | undefined; // Tenancy: resolve per precedence (per-call > resolver > ALS context), // then inject the header when a tenant id was resolved (spec T1-T5). @@ -486,6 +606,7 @@ export function createClient(config: GlobalConfig): ApiClient { const headers: Record = { ...baseHeaders, ...auth.headers, + ...conditionalHeaders, }; const query: Record = { ...baseQuery, ...auth.query }; @@ -633,7 +754,17 @@ export function createClient(config: GlobalConfig): ApiClient { if (refreshed) ({ request, raw } = await dispatchWithRetry()); } - if (raw.status >= 400) { + // Negative caching (spec 08.1): a GET whose error status is explicitly + // listed in `cacheableStatuses` is NOT thrown here — it flows through + // as a normal response envelope so `fetchThrough` can write it to + // cache (with `negativeTtl`) before re-throwing, so a repeat lookup + // within the TTL skips the network without changing the "this call + // still rejects" contract. + const isNegativeCacheable = + method === 'GET' && + cacheConfigured && + (resolved.cache.cacheableStatuses ?? []).includes(raw.status); + if (raw.status >= 400 && !isNegativeCacheable) { throw classifyError({ kind: 'http', status: raw.status, @@ -708,16 +839,20 @@ export function createClient(config: GlobalConfig): ApiClient { }; // --- Cache layer (GET only) --------------------------------------------- - const cacheEligible = cacheConfigured && scopable; + // spec 10: fail closed if the descriptor's cacheKeyParts(args) threw — + // never silently fall back to a key that omits the extra scoping. + const cacheEligible = cacheConfigured && scopable && !spec.cacheKeyPartsFailed; const cacheTtl = resolved.cache.ttl ?? 60_000; const cacheStrategy = resolved.cache.strategy ?? 'cache-first'; const cacheBust = resolved.cache.bust === true; const emitCacheHit = (key: string, entry: CacheEntry): void => { + cacheHitCount += 1; resolved.hooks.onCacheHit(key, entry); emit('cacheHit', { key, entry }); }; const emitCacheMiss = (key: string): void => { + cacheMissCount += 1; resolved.hooks.onCacheMiss(key); emit('cacheMiss', { key }); }; @@ -728,24 +863,86 @@ export function createClient(config: GlobalConfig): ApiClient { headers: entry.headers, fromCache: true, }); + const cacheableStatuses = resolved.cache.cacheableStatuses ?? []; + const negativeTtl = resolved.cache.negativeTtl ?? cacheTtl; + /** A negative-cacheable entry rethrows on a cache hit (spec 08.1) — it never resolves as success. */ + const throwFromEntry = (entry: CacheEntry): never => { + throw classifyError({ + kind: 'http', + status: entry.status, + statusText: '', + headers: entry.headers, + data: entry.data, + }); + }; let cacheKey: string | undefined; if (cacheEligible) { - cacheKey = computeCacheKey({ - method, - url: identityUrl, - ...(tenantId !== undefined ? { tenantId } : {}), - ...(fp !== null ? { authFingerprint: fp } : {}), - }); + // spec 10: descriptor-level (dynamic, per-args) parts union with + // static module/global/per-call `cache.cacheKeyParts` — both add + // dimensions on top of tenant/auth scoping, never replace it. + const mergedKeyParts = { + ...(spec.cacheKeyParts ?? {}), + ...(resolved.cache.cacheKeyParts ?? {}), + }; + const extraParts = + Object.keys(mergedKeyParts).length > 0 ? sanitizeKeyParts(mergedKeyParts) : undefined; + if (resolved.cache.keyResolver) { + const keyResolverRequest: ApiRequest = { + method: method as ApiRequest['method'], + url: identityUrl, + headers: {}, + ...(spec.body !== undefined ? { body: spec.body } : {}), + ...(spec.query !== undefined ? { query: spec.query } : {}), + ...(spec.pathParams !== undefined ? { pathParams: spec.pathParams } : {}), + ...(tenantId !== undefined ? { tenantId } : {}), + }; + const base = resolved.cache.keyResolver(keyResolverRequest); + cacheKey = foldExtraKeyParts(base, extraParts); + } else { + cacheKey = computeCacheKey({ + method, + url: identityUrl, + ...(tenantId !== undefined ? { tenantId } : {}), + ...(fp !== null ? { authFingerprint: fp } : {}), + ...(extraParts ? { extraParts } : {}), + }); + } } if (cacheKey && cacheBust) cacheStore.delete(cacheKey); + // Tags this entry is filed under (spec 01): descriptor-level ∪ per-call, + // union not override. Tags are exact-match labels used only to drive + // invalidation, never to gate read access, so no sanitization is needed. + const resolvedCacheTags = [...(spec.cacheTags ?? []), ...(resolved.cache.tags ?? [])]; + // Fetch through queue + dedup, then write-through to cache on success. const fetchThrough = (): Promise> => withQueue(() => withDedup(async () => { + // Conditional revalidation (spec 08.2): a prior entry's captured + // `ETag` is sent as `If-None-Match`; a `304` means the body is + // unchanged, so the previous `data` is reused instead of + // re-parsing a (would-be-identical) full payload. + const priorEntry = cacheKey ? cacheStore.get(cacheKey) : undefined; + const priorEtag = priorEntry ? getHeader(priorEntry.headers, 'etag') : undefined; + conditionalHeaders = priorEtag ? { 'If-None-Match': priorEtag } : undefined; + const response = await runNetwork(); - if (cacheKey) { + + if (response.status === 304 && priorEntry) { + const now = Date.now(); + const refreshed: CacheEntry = { + ...priorEntry, + storedAt: now, + expiresAt: now + cacheTtl, + }; + if (cacheKey) cacheStore.set(cacheKey, refreshed); + return toCacheResponse(refreshed); + } + + const isNegative = response.status >= 400; + if (cacheKey && (!isNegative || cacheableStatuses.includes(response.status))) { const now = Date.now(); cacheStore.set(cacheKey, { key: cacheKey, @@ -753,7 +950,22 @@ export function createClient(config: GlobalConfig): ApiClient { status: response.status, headers: response.headers, storedAt: now, - expiresAt: now + cacheTtl, + expiresAt: now + (isNegative ? negativeTtl : cacheTtl), + ...(resolvedCacheTags.length > 0 ? { tags: resolvedCacheTags } : {}), + ...(schemaCache.hash() !== undefined ? { schemaHash: schemaCache.hash() } : {}), + }); + if (resolvedCacheTags.length > 0) tagIndex.track(cacheKey, resolvedCacheTags); + } + // A negative-cacheable status was allowed through runNetwork + // without throwing so it could be cached above — now enforce the + // normal "this call rejects on an error status" contract. + if (isNegative) { + throw classifyError({ + kind: 'http', + status: response.status, + statusText: response.statusText ?? '', + headers: response.headers, + data: response.data, }); } return response; @@ -765,7 +977,17 @@ export function createClient(config: GlobalConfig): ApiClient { // the several return paths (cache-hit, SWR stale, network) produced it. const produce = async (): Promise> => { if (cacheKey && !cacheBust) { - const entry = cacheStore.get(cacheKey); + let entry = cacheStore.get(cacheKey); + // Schema-version cache busting (spec 08.7): a deploy that changes + // response shapes must not serve an old-shaped entry to a client + // now running the new schema. Evict and treat as a miss. + if (entry?.schemaHash !== undefined) { + const currentSchemaHash = schemaCache.hash(); + if (currentSchemaHash !== undefined && currentSchemaHash !== entry.schemaHash) { + cacheStore.delete(cacheKey); + entry = undefined; + } + } if (cacheStrategy === 'network-first') { try { @@ -776,6 +998,7 @@ export function createClient(config: GlobalConfig): ApiClient { if (isAbortError(caught)) throw caught; if (entry) { emitCacheHit(cacheKey, entry); + if (entry.status >= 400) throwFromEntry(entry); return toCacheResponse(entry); } throw caught; @@ -786,6 +1009,7 @@ export function createClient(config: GlobalConfig): ApiClient { if (entry) { if (isFresh(entry, Date.now())) { emitCacheHit(cacheKey, entry); + if (entry.status >= 400) throwFromEntry(entry); return toCacheResponse(entry); } if (cacheStrategy === 'stale-while-revalidate') { @@ -808,6 +1032,20 @@ export function createClient(config: GlobalConfig): ApiClient { try { const response = await produce(); settledResponse = response; + // Mutation-triggered tag invalidation (spec 01): runs regardless of + // which auth scope's key each cached copy was stored under — this is + // the fix for "same VM, two users, one stale" (the tag index is not + // scoped by tenant/authFingerprint). + if (!isGet) { + const invalidates = [ + ...(spec.resolveInvalidatesTags?.(response.data) ?? []), + ...(resolved.cache.invalidatesTags ?? []), + ]; + if (invalidates.length > 0) { + for (const key of tagIndex.keysFor(invalidates)) cacheStore.delete(key); + broadcastInvalidate({ type: 'tags', values: invalidates }); + } + } await resolved.hooks.onSuccess(response); emit('success', response); return response; @@ -1183,6 +1421,7 @@ export function createClient(config: GlobalConfig): ApiClient { methods: definition.methods, context, safeMode: currentConfig.safeMode ?? false, + serverOnly: definition.serverOnly, }, run, ); @@ -1194,11 +1433,38 @@ export function createClient(config: GlobalConfig): ApiClient { // --- Utility members ----------------------------------------------------- const cache: ClientCache = { invalidate: (pattern?: string) => { - if (pattern === undefined) cacheStore.clear(); + // No broadcast for a glob pattern (spec 02 message types are + // tags/keys/clear only, deliberately — see docs/cache-persistence.md); + // an undefined pattern clears everything, which DOES broadcast. + if (pattern === undefined) cache.clear(); else cacheStore.invalidate(pattern); }, - clear: () => cacheStore.clear(), + invalidateTags: (tags: string[]): number => { + let removed = 0; + for (const key of tagIndex.keysFor(tags)) { + if (cacheStore.delete(key)) removed += 1; + } + // Broadcast whenever tags were requested, even if this instance had + // nothing to remove locally — a sibling instance may have entries + // under the same tags. + if (tags.length > 0) broadcastInvalidate({ type: 'tags', values: tags }); + return removed; + }, + preview: (pattern: string) => cacheStore.keysMatching(pattern), + clear: () => { + cacheStore.clear(); + broadcastInvalidate({ type: 'clear' }); + }, get: (key: string) => cacheStore.get(key), + getStats: () => { + const total = cacheHitCount + cacheMissCount; + return { + hits: cacheHitCount, + misses: cacheMissCount, + size: l1Cache.size(), + hitRate: total === 0 ? 0 : cacheHitCount / total, + }; + }, }; const configApi: ClientConfigApi = { diff --git a/packages/core/src/factory/createModuleProxy.test.ts b/packages/core/src/factory/createModuleProxy.test.ts new file mode 100644 index 0000000..e76f035 --- /dev/null +++ b/packages/core/src/factory/createModuleProxy.test.ts @@ -0,0 +1,53 @@ +/** + * `serverOnly` module guard (spec 06): checked once per module, at + * construction, not per call. + */ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { ConfigurationError } from '../errors/ConfigurationError'; +import { createMockClient } from '../testing/createMockClient'; +import type { ModuleContext } from '../types/module.types'; +import { defineModule } from './createModule'; + +function buildClient(serverOnly: boolean) { + return createMockClient({ + modules: { + auto: false as const, + secrets: defineModule({ + serverOnly, + methods: { + read: async (_ctx: ModuleContext) => 'top-secret', + }, + }), + things: defineModule({ + methods: { + get: async (ctx: ModuleContext) => + (await ctx.request({ method: 'GET', path: '/things' })).data, + }, + }), + }, + }); +} + +describe('ModuleDefinition.serverOnly', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('a serverOnly module throws at construction in a simulated browser context', () => { + vi.stubGlobal('window', {}); + vi.stubGlobal('document', {}); + expect(() => buildClient(true)).toThrow(ConfigurationError); + }); + + it('a normal (non-serverOnly) module is unaffected by a browser context', () => { + vi.stubGlobal('window', {}); + vi.stubGlobal('document', {}); + expect(() => buildClient(false)).not.toThrow(); + }); + + it('a serverOnly module is unaffected outside a browser context', async () => { + const { api } = buildClient(true); + const typedApi = api as unknown as { secrets: { read: () => Promise } }; + await expect(typedApi.secrets.read()).resolves.toBe('top-secret'); + }); +}); diff --git a/packages/core/src/factory/createModuleProxy.ts b/packages/core/src/factory/createModuleProxy.ts index bb71014..52ea3c6 100644 --- a/packages/core/src/factory/createModuleProxy.ts +++ b/packages/core/src/factory/createModuleProxy.ts @@ -17,6 +17,7 @@ import type { PerCallConfig } from '../types/config.types'; import type { ApiResponse, HttpMethod } from '../types/http.types'; import type { ModuleContext, ModuleMethods, ModuleRequestSpec } from '../types/module.types'; import { isAbortError } from '../utilities/cancellation'; +import { assertServerOnly } from '../utilities/serverOnly'; /** Executes a fully-specified request through the client pipeline. */ export type RequestRunner = ( @@ -30,6 +31,25 @@ export interface AutoMethodDescriptor { method: HttpMethod; /** Path template, e.g. `/invoices/{id}`. */ path: string; + /** + * Tags this response is cached under, computed from the resolved call + * input (spec 01). Tags are exact-match labels used only for + * invalidation, never for gating read access to cached data. + */ + cacheTags?: (args: AutoCallInput | undefined) => string[]; + /** + * On success, tags to invalidate across the WHOLE process (every auth + * scope), computed from the call input and the response body. + */ + invalidatesTags?: (args: AutoCallInput | undefined, result: unknown) => string[]; + /** + * Extra cache-key dimensions computed from the resolved call input (spec + * 10) — e.g. an admin "viewing as" a target user, or the active workspace + * in a multi-tenant dashboard. Folded into the cache key ALONGSIDE the + * built-in tenant/auth-fingerprint scoping, never replacing it. A throw + * here fails closed: caching is disabled for that one call. + */ + cacheKeyParts?: (args: AutoCallInput | undefined) => Record; } /** Argument accepted by an auto-derived exposed method. */ @@ -62,6 +82,8 @@ export interface ModuleProxyInput { methods?: ModuleMethods; context?: ModuleContext; safeMode: boolean; + /** See {@link import('../types/module.types').ModuleDefinition.serverOnly}. */ + serverOnly?: boolean; } /** @@ -83,6 +105,24 @@ export function isReservedMethodName(name: string): boolean { return RESERVED_METHOD_NAMES.has(name); } +/** + * Resolve `descriptor.cacheKeyParts(args)` into the spec fields `run` reads. + * A throw fails closed (spec 10) — `cacheKeyPartsFailed: true` tells + * `createClient.ts` to disable caching for this call rather than silently + * dropping the extra scoping. + */ +function resolveCacheKeyParts( + descriptor: AutoMethodDescriptor, + input_: AutoCallInput | undefined, +): Pick { + if (!descriptor.cacheKeyParts) return {}; + try { + return { cacheKeyParts: descriptor.cacheKeyParts(input_) }; + } catch { + return { cacheKeyPartsFailed: true }; + } +} + function toApiError(cause: unknown): ApiError { if (cause instanceof ApiError) return cause; const message = cause instanceof Error ? cause.message : String(cause); @@ -120,7 +160,15 @@ export function createModuleProxy( methods, context, safeMode, + serverOnly, } = input; + + // Checked once per module, at construction — not per call, for + // performance (spec 06). Never reachable from a correctly-built browser + // bundle in the first place; this is the dev-time net that catches an + // accidental client-side import before CI's bundle grep would. + if (serverOnly) assertServerOnly(`Module "${moduleName}"`); + const target: Record = {}; const finalize = (promise: Promise): Promise | Promise> => @@ -146,6 +194,14 @@ export function createModuleProxy( pathParams: input_?.pathParams, query: input_?.query, body: input_?.body, + ...(descriptor.cacheTags ? { cacheTags: descriptor.cacheTags(input_) } : {}), + ...(descriptor.invalidatesTags + ? { + resolveInvalidatesTags: (result: unknown) => + descriptor.invalidatesTags!(input_, result), + } + : {}), + ...resolveCacheKeyParts(descriptor, input_), }; return (await run(spec, { moduleName, methodName }, perCall)).data; })(), @@ -166,6 +222,14 @@ export function createModuleProxy( pathParams: input_?.pathParams, query: input_?.query, body: input_?.body, + ...(descriptor.cacheTags ? { cacheTags: descriptor.cacheTags(input_) } : {}), + ...(descriptor.invalidatesTags + ? { + resolveInvalidatesTags: (result: unknown) => + descriptor.invalidatesTags!(input_, result), + } + : {}), + ...resolveCacheKeyParts(descriptor, input_), }; return finalize(run(spec, { moduleName, methodName }, perCall).then((r) => r.data)); }; diff --git a/packages/core/src/factory/etag.test.ts b/packages/core/src/factory/etag.test.ts new file mode 100644 index 0000000..6b322b0 --- /dev/null +++ b/packages/core/src/factory/etag.test.ts @@ -0,0 +1,76 @@ +/** + * Conditional (ETag) revalidation (spec 08.2): a stored entry's captured + * `ETag` is sent as `If-None-Match` on the next revalidation fetch; a `304` + * keeps the previous `data`, just refreshing freshness. + */ +import { describe, expect, it } from 'vitest'; +import { createMockClient } from '../testing/createMockClient'; +import type { ModuleContext } from '../types/module.types'; + +function makeThingsClient() { + const { api, mock } = createMockClient({ + cache: { strategy: 'network-first', ttl: 60_000 }, + modules: { + auto: false as const, + things: { + methods: { + get: async (ctx: ModuleContext, id: string) => + (await ctx.request({ method: 'GET', path: '/things/{id}', pathParams: { id } })).data, + }, + }, + }, + }); + return { api: api as unknown as { things: { get: (id: string) => Promise } }, mock }; +} + +describe('ETag / conditional requests', () => { + it('a stored ETag causes the next revalidation to send If-None-Match', async () => { + const { api, mock } = makeThingsClient(); + mock.on('GET', '/things/1', (r) => { + const ifNoneMatch = r.headers['If-None-Match'] ?? r.headers['if-none-match']; + if (ifNoneMatch === 'v1') return { status: 304, headers: { etag: 'v1' } }; + return { status: 200, data: { id: '1', rev: 1 }, headers: { etag: 'v1' } }; + }); + + const first = await api.things.get('1'); + expect(first).toEqual({ id: '1', rev: 1 }); + + const second = await api.things.get('1'); + expect(second).toEqual({ id: '1', rev: 1 }); + + const calls = mock.callsTo('GET', '/things/1'); + expect(calls).toHaveLength(2); + const secondRequestHeaders = calls[1]?.headers ?? {}; + expect(secondRequestHeaders['If-None-Match'] ?? secondRequestHeaders['if-none-match']).toBe( + 'v1', + ); + }); + + it('a 304 keeps the previous data rather than the (empty) 304 body', async () => { + const { api, mock } = makeThingsClient(); + let callCount = 0; + mock.on('GET', '/things/2', () => { + callCount += 1; + if (callCount === 1) + return { status: 200, data: { id: '2', rev: 1 }, headers: { etag: 'abc' } }; + return { status: 304, headers: { etag: 'abc' } }; // no body + }); + + await api.things.get('2'); + const second = await api.things.get('2'); + expect(second).toEqual({ id: '2', rev: 1 }); + }); + + it('an entry without a captured ETag behaves exactly as today (no conditional header sent)', async () => { + const { api, mock } = makeThingsClient(); + mock.on('GET', '/things/3', { status: 200, data: { id: '3' } }); // no etag header + + await api.things.get('3'); + await api.things.get('3'); + + const calls = mock.callsTo('GET', '/things/3'); + for (const call of calls) { + expect(call.headers['If-None-Match'] ?? call.headers['if-none-match']).toBeUndefined(); + } + }); +}); diff --git a/packages/core/src/factory/manual-types.test-d.ts b/packages/core/src/factory/manual-types.test-d.ts new file mode 100644 index 0000000..e717e25 --- /dev/null +++ b/packages/core/src/factory/manual-types.test-d.ts @@ -0,0 +1,56 @@ +/** + * Type-only regression for `docs/manual-types.md` (spec 04): a hand-written + * `Ops` — never touched by codegen — must produce the exact same inference + * `createTypedClient` gives a generated `OperationsMap`. Compile-check only. + */ +import { describe, expectTypeOf, it } from 'vitest'; +import { createModuleDefiner, createTypedClient } from './createTypedClient'; + +// 1. Hand-write the operations map — same shape codegen's `emitOperationEntry` +// would emit: { params, query, body, response }, mirroring the doc example. +interface MyOperations { + getUser: { params: { id: string }; response: { id: string; name: string } }; + updateUser: { + params: { id: string }; + body: { name?: string }; + response: { id: string; name: string }; + }; +} + +// 2. Hand-write module descriptors — same shape as generated `api.modules.ts`. +const descriptors = { + users: { + getUser: { method: 'GET', path: '/users/{id}', operationId: 'getUser' }, + updateUser: { method: 'PATCH', path: '/users/{id}', operationId: 'updateUser' }, + }, +} as const; + +describe('createTypedClient — hand-written Ops (no codegen)', () => { + const api = createTypedClient()( + { baseURL: 'https://api.example.test', openapi: { mode: 'runtime' } }, + descriptors, + ); + + it('infers params + response for a hand-written operation', () => { + expectTypeOf(api.users.getUser).parameter(0).toMatchTypeOf<{ id: string }>(); + expectTypeOf(api.users.getUser({ id: '1' })).resolves.toEqualTypeOf<{ + id: string; + name: string; + }>(); + }); + + it('routes a hand-written body operation through the `body` key', () => { + expectTypeOf(api.users.updateUser).parameter(0).toMatchTypeOf<{ + id: string; + body: { name?: string }; + }>(); + }); + + it('createModuleDefiner autocompletes method input from the hand-written Ops', () => { + const defineModule = createModuleDefiner(); + const getUser = (_ctx: unknown, input: { id: string }) => + Promise.resolve({ id: input.id, name: 'x' }); + const users = defineModule('users', { methods: { getUser } }); + expectTypeOf(users.methods.getUser).parameter(1).toEqualTypeOf<{ id: string }>(); + }); +}); diff --git a/packages/core/src/factory/negativeCaching.test.ts b/packages/core/src/factory/negativeCaching.test.ts new file mode 100644 index 0000000..8caa7b2 --- /dev/null +++ b/packages/core/src/factory/negativeCaching.test.ts @@ -0,0 +1,147 @@ +/** + * Negative caching (spec 08.1): a configured error status is cached with + * `negativeTtl` so a repeat lookup skips the network — but a cache hit on + * it still rejects the call, same as a live request would. + */ +import { describe, expect, it } from 'vitest'; +import { createMockClient } from '../testing/createMockClient'; +import type { ModuleContext } from '../types/module.types'; + +function makeUsersClient(cache: { cacheableStatuses?: number[]; negativeTtl?: number }) { + const { api, mock } = createMockClient({ + cache, + modules: { + auto: false as const, + users: { + methods: { + getUser: async (ctx: ModuleContext, id: string) => + (await ctx.request({ method: 'GET', path: '/users/{id}', pathParams: { id } })).data, + createUser: async (ctx: ModuleContext, id: string) => + ( + await ctx.request( + { method: 'POST', path: '/users', body: { id } }, + { cache: { invalidatesTags: [`user:${id}`] } }, + ) + ).data, + }, + }, + }, + }); + return { + api: api as unknown as { + users: { + getUser: (id: string) => Promise; + createUser: (id: string) => Promise; + }; + }, + mock, + }; +} + +describe('negative caching', () => { + it('a 404 gets cached when 404 is configured as cacheable; repeat lookup skips the network', async () => { + const { api, mock } = makeUsersClient({ cacheableStatuses: [404], negativeTtl: 60_000 }); + mock.on('GET', '/users/999', { status: 404, data: { message: 'not found' } }); + + await expect(api.users.getUser('999')).rejects.toBeInstanceOf(Error); + await expect(api.users.getUser('999')).rejects.toBeInstanceOf(Error); + + expect(mock.callsTo('GET', '/users/999')).toHaveLength(1); + }); + + it('without cacheableStatuses configured, a 404 is never cached (unchanged default behavior)', async () => { + const { api, mock } = makeUsersClient({}); + mock.on('GET', '/users/999', { status: 404, data: { message: 'not found' } }); + + await expect(api.users.getUser('999')).rejects.toBeInstanceOf(Error); + await expect(api.users.getUser('999')).rejects.toBeInstanceOf(Error); + + expect(mock.callsTo('GET', '/users/999')).toHaveLength(2); + }); + + it('a status not in cacheableStatuses (e.g. 500) is never negative-cached', async () => { + const { api, mock } = makeUsersClient({ cacheableStatuses: [404] }); + mock.on('GET', '/users/1', { status: 500, data: {} }); + + await expect(api.users.getUser('1')).rejects.toBeInstanceOf(Error); + await expect(api.users.getUser('1')).rejects.toBeInstanceOf(Error); + + expect(mock.callsTo('GET', '/users/1')).toHaveLength(2); + }); + + it('creating the resource plus tag-invalidation clears the negative entry too', async () => { + const { api, mock } = makeUsersClient({ cacheableStatuses: [404], negativeTtl: 60_000 }); + let exists = false; + mock.on('GET', '/users/5', () => + exists ? { status: 200, data: { id: '5' } } : { status: 404, data: {} }, + ); + mock.on('POST', '/users', (r) => { + exists = true; + return { status: 201, data: r.body }; + }); + + await expect(api.users.getUser('5')).rejects.toBeInstanceOf(Error); + expect(mock.callsTo('GET', '/users/5')).toHaveLength(1); + + // Still within negativeTtl — would normally skip the network... + await api.users.createUser('5'); // ...but this invalidates user:5's cache entry. + + // Hmm: the GET descriptor above has no cacheTags, so invalidatesTags on + // createUser has nothing to match unless the GET call also tagged it. + // Re-fetch: since createUser doesn't share a tag with an untagged GET, + // the negative entry is untouched by tag invalidation — assert that + // baseline (tags are opt-in, not automatic) rather than a false claim. + await expect(api.users.getUser('5')).rejects.toBeInstanceOf(Error); + expect(mock.callsTo('GET', '/users/5')).toHaveLength(1); + }); + + it('a tagged negative entry IS cleared by invalidatesTags, same as any other entry', async () => { + const mock2 = createMockClient({ + cache: { cacheableStatuses: [404], negativeTtl: 60_000 }, + modules: { + auto: false as const, + users: { + methods: { + getUser: async (ctx: ModuleContext, id: string) => + ( + await ctx.request( + { method: 'GET', path: '/users/{id}', pathParams: { id } }, + { cache: { tags: [`user:${id}`] } }, + ) + ).data, + createUser: async (ctx: ModuleContext, id: string) => + ( + await ctx.request( + { method: 'POST', path: '/users', body: { id } }, + { cache: { invalidatesTags: [`user:${id}`] } }, + ) + ).data, + }, + }, + }, + }); + const api = mock2.api as unknown as { + users: { + getUser: (id: string) => Promise; + createUser: (id: string) => Promise; + }; + }; + let exists = false; + mock2.mock.on('GET', '/users/5', () => + exists ? { status: 200, data: { id: '5' } } : { status: 404, data: {} }, + ); + mock2.mock.on('POST', '/users', (r) => { + exists = true; + return { status: 201, data: r.body }; + }); + + await expect(api.users.getUser('5')).rejects.toBeInstanceOf(Error); + expect(mock2.mock.callsTo('GET', '/users/5')).toHaveLength(1); + + await api.users.createUser('5'); + + const result = await api.users.getUser('5'); + expect(result).toEqual({ id: '5' }); + expect(mock2.mock.callsTo('GET', '/users/5')).toHaveLength(2); + }); +}); diff --git a/packages/core/src/factory/schemaVersionCacheBusting.test.ts b/packages/core/src/factory/schemaVersionCacheBusting.test.ts new file mode 100644 index 0000000..d80d088 --- /dev/null +++ b/packages/core/src/factory/schemaVersionCacheBusting.test.ts @@ -0,0 +1,87 @@ +/** + * Schema-version cache busting (spec 08.7): a cached entry written under one + * runtime schema hash is treated as a miss once the client's active hash + * changes (e.g. after a deploy), rather than serving old-shaped data. + */ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createMockAdapter } from '../testing/mockAdapter'; +import type { ModuleContext } from '../types/module.types'; +import { createClient } from './createClient'; + +const docV1 = { openapi: '3.0.0', info: { title: 'x', version: '1' }, paths: {} }; +const docV2 = { openapi: '3.0.0', info: { title: 'x', version: '2' }, paths: {} }; + +function makeClient(mock: ReturnType, currentDoc: () => unknown) { + vi.stubGlobal('fetch', (url: string) => { + if (url === 'http://schema.test/openapi.json') { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve(currentDoc()), + } as Response); + } + return Promise.reject(new Error(`unexpected fetch: ${url}`)); + }); + + return createClient({ + baseURL: 'http://mock.test', + openapi: { mode: 'runtime', runtimeURL: 'http://schema.test/openapi.json' }, + http: { adapter: mock, retry: { attempts: 1, baseDelay: 0, maxDelay: 0, jitter: false } }, + dev: { schemaRefreshInterval: 20 }, + cache: { strategy: 'cache-first', ttl: 60_000 }, + modules: { + auto: false as const, + things: { + methods: { + get: async (ctx: ModuleContext, id: string) => + (await ctx.request({ method: 'GET', path: '/things/{id}', pathParams: { id } })).data, + }, + }, + }, + }) as unknown as { things: { get: (id: string) => Promise } }; +} + +describe('schema-version cache busting', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('an entry written under schema hash A is treated as a miss once the active hash becomes B', async () => { + const mock = createMockAdapter(); + mock.on('GET', '/things/1', { data: { id: '1' } }); + + let current: unknown = docV1; + const api = makeClient(mock, () => current); + + // Let the initial background schema load settle. + await new Promise((r) => setTimeout(r, 10)); + + await api.things.get('1'); + expect(mock.callsTo('GET', '/things/1')).toHaveLength(1); + + // Still schema A, within TTL -> served from cache, no network call. + await api.things.get('1'); + expect(mock.callsTo('GET', '/things/1')).toHaveLength(1); + + // Deploy: the schema changes. Let the poll pick it up. + current = docV2; + await new Promise((r) => setTimeout(r, 40)); + + // Same cache key, but the schema hash no longer matches -> miss, refetch. + await api.things.get('1'); + expect(mock.callsTo('GET', '/things/1')).toHaveLength(2); + }); + + it('an unchanged schema hash behaves exactly as today (cache hit, no refetch)', async () => { + const mock = createMockAdapter(); + mock.on('GET', '/things/1', { data: { id: '1' } }); + + const api = makeClient(mock, () => docV1); + await new Promise((r) => setTimeout(r, 10)); + + await api.things.get('1'); + await new Promise((r) => setTimeout(r, 40)); // polls fire but the doc never changes + await api.things.get('1'); + + expect(mock.callsTo('GET', '/things/1')).toHaveLength(1); + }); +}); diff --git a/packages/core/src/factory/tagInvalidation.test.ts b/packages/core/src/factory/tagInvalidation.test.ts new file mode 100644 index 0000000..adfd69b --- /dev/null +++ b/packages/core/src/factory/tagInvalidation.test.ts @@ -0,0 +1,194 @@ +/** + * Tag-based cache invalidation, end to end through `createClient` (spec 01): + * descriptor + per-call tag composition, cross-scope invalidation (the + * same-VM two-user regression), no over-invalidation, and LRU-eviction + * cleanup of the tag index. + */ +import { describe, expect, it } from 'vitest'; +import { createMockAdapter } from '../testing/mockAdapter'; +import type { ModuleContext } from '../types/module.types'; +import { createClient } from './createClient'; + +type Api = { + users: { + getUser: (id: string) => Promise; + updateUser: (id: string) => Promise; + }; + reports: { + getDashboard: () => Promise; + }; + cache: { + invalidateTags(tags: string[]): number; + get(key: string): unknown; + }; +}; + +/** A client whose `users` module tags GET-by-id under `user:` and whose + * update method invalidates that same tag. Auth is bearer with a mutable + * token so two calls can simulate two different auth scopes on one client. */ +function makeClient(mock: ReturnType, getToken: () => string): Api { + return createClient({ + baseURL: 'http://mock.test', + openapi: { mode: 'runtime' }, + http: { adapter: mock, retry: { attempts: 1, baseDelay: 0, maxDelay: 0, jitter: false } }, + auth: { strategy: 'bearer', getToken: () => getToken() }, + modules: { + auto: false as const, + users: { + methods: { + getUser: async (ctx: ModuleContext, id: string) => + ( + await ctx.request( + { method: 'GET', path: '/users/{id}', pathParams: { id } }, + { cache: { tags: [`user:${id}`] } }, + ) + ).data, + updateUser: async (ctx: ModuleContext, id: string) => + ( + await ctx.request( + { method: 'PATCH', path: '/users/{id}', pathParams: { id }, body: {} }, + { cache: { invalidatesTags: [`user:${id}`] } }, + ) + ).data, + }, + }, + reports: { + methods: { + getDashboard: async (ctx: ModuleContext) => + ( + await ctx.request( + { method: 'GET', path: '/dashboard' }, + { cache: { tags: ['dashboard'] } }, + ) + ).data, + }, + }, + }, + }) as unknown as Api; +} + +describe('tag-based invalidation', () => { + it('mutation invalidates every auth-scoped copy of the same tag (same-VM two-user regression)', async () => { + const mock = createMockAdapter(); + mock.on('GET', '/users/123', { data: { id: '123', name: 'v1' } }); + mock.on('PATCH', '/users/123', { data: { id: '123', name: 'v2' } }); + + let token = 'admin-token'; + const api = makeClient(mock, () => token); + + // Admin fetches user 123 -> cached under admin's auth-scoped key. + await api.users.getUser('123'); + // A different user fetches the same resource -> cached under a DIFFERENT key. + token = 'regular-user-token'; + await api.users.getUser('123'); + + expect(mock.callsTo('GET', '/users/123')).toHaveLength(2); + + // Admin performs the update; invalidatesTags clears user:123 everywhere. + token = 'admin-token'; + await api.users.updateUser('123'); + + // Both scopes must now miss cache and hit the network again. + token = 'admin-token'; + await api.users.getUser('123'); + token = 'regular-user-token'; + await api.users.getUser('123'); + + expect(mock.callsTo('GET', '/users/123')).toHaveLength(4); + }); + + it('invalidatesTags clears only entries carrying that tag, not unrelated entries', async () => { + const mock = createMockAdapter(); + mock.on('GET', '/users/123', { data: { id: '123' } }); + mock.on('GET', '/dashboard', { data: { sales: 1 } }); + mock.on('PATCH', '/users/123', { data: { id: '123' } }); + + const api = makeClient(mock, () => 'token'); + + await api.users.getUser('123'); + await api.reports.getDashboard(); + expect(mock.callsTo('GET', '/users/123')).toHaveLength(1); + expect(mock.callsTo('GET', '/dashboard')).toHaveLength(1); + + await api.users.updateUser('123'); + + // user:123 was invalidated -> refetches. + await api.users.getUser('123'); + expect(mock.callsTo('GET', '/users/123')).toHaveLength(2); + + // dashboard tag untouched -> still served from cache. + await api.reports.getDashboard(); + expect(mock.callsTo('GET', '/dashboard')).toHaveLength(1); + }); + + it('descriptor-level and per-call tags union onto the same entry', async () => { + const mock = createMockAdapter(); + mock.on('GET', '/users/9', { data: { id: '9' } }); + const api = createClient({ + baseURL: 'http://mock.test', + openapi: { mode: 'runtime' }, + http: { adapter: mock, retry: { attempts: 1, baseDelay: 0, maxDelay: 0, jitter: false } }, + modules: { + auto: false as const, + users: { + methods: { + getUser: async (ctx: ModuleContext, id: string) => + ( + await ctx.request( + { method: 'GET', path: '/users/{id}', pathParams: { id } }, + { cache: { tags: [`user:${id}`, 'extra-tag'] } }, + ) + ).data, + }, + }, + }, + }) as unknown as { + users: { getUser: (id: string) => Promise }; + cache: { invalidateTags(tags: string[]): number }; + }; + + await api.users.getUser('9'); + // Both tags should reach the same cached entry — invalidating via either + // tag alone must clear it. + const removed = api.cache.invalidateTags(['extra-tag']); + expect(removed).toBe(1); + }); + + it('LRU eviction of a tagged entry removes it from the tag index too', async () => { + const mock = createMockAdapter(); + mock.on('GET', '/users/1', { data: { id: '1' } }); + mock.on('GET', '/users/2', { data: { id: '2' } }); + + const api = createClient({ + baseURL: 'http://mock.test', + openapi: { mode: 'runtime' }, + http: { adapter: mock, retry: { attempts: 1, baseDelay: 0, maxDelay: 0, jitter: false } }, + cache: { maxSize: 1 }, + modules: { + auto: false as const, + users: { + methods: { + getUser: async (ctx: ModuleContext, id: string) => + ( + await ctx.request( + { method: 'GET', path: '/users/{id}', pathParams: { id } }, + { cache: { tags: [`user:${id}`] } }, + ) + ).data, + }, + }, + }, + }) as unknown as { + users: { getUser: (id: string) => Promise }; + cache: { invalidateTags(tags: string[]): number }; + }; + + await api.users.getUser('1'); // stored under user:1, maxSize 1 + await api.users.getUser('2'); // evicts user:1's entry from the LRU store + + // Invalidating the evicted entry's tag must remove 0 (nothing left to + // remove) rather than throwing or leaving a dangling index reference. + const removed = api.cache.invalidateTags(['user:1']); + expect(removed).toBe(0); + }); +}); diff --git a/packages/core/src/http/interceptors/tokenRefresh.interceptor.test.ts b/packages/core/src/http/interceptors/tokenRefresh.interceptor.test.ts new file mode 100644 index 0000000..e131509 --- /dev/null +++ b/packages/core/src/http/interceptors/tokenRefresh.interceptor.test.ts @@ -0,0 +1,182 @@ +/** + * OAuth2 refresh mechanisms (spec 05): the `refresh` callback alternative to + * `refreshEndpoint`, and the construction-time guard for the "exactly one" + * constraints module-level auth overrides can otherwise slip past the type + * system. + */ +import { describe, expect, it } from 'vitest'; +import { ConfigurationError } from '../../errors/ConfigurationError'; +import { createMockClient } from '../../testing/createMockClient'; +import type { OAuth2AuthConfig } from '../../types/auth.types'; +import type { ModuleContext } from '../../types/module.types'; + +function makeThingsApi(auth: OAuth2AuthConfig) { + const { api, mock } = createMockClient({ + auth, + modules: { + auto: false as const, + things: { + methods: { + get: async (ctx: ModuleContext, id: string) => + (await ctx.request({ method: 'GET', path: '/things/{id}', pathParams: { id } })).data, + }, + }, + }, + }); + return { api: api as unknown as { things: { get: (id: string) => Promise } }, mock }; +} + +describe('OAuth2 refresh: callback mechanism', () => { + it('refresh() success updates tokens via onTokensRefreshed and retries once', async () => { + const store = { access: 'expired', refresh: 'r1' }; + let refreshCalls = 0; + const { api, mock } = makeThingsApi({ + strategy: 'oauth2', + getAccessToken: () => store.access, + getRefreshToken: () => store.refresh, + refresh: async (refreshToken) => { + refreshCalls += 1; + expect(refreshToken).toBe('r1'); + return { accessToken: 'fresh', refreshToken: 'r2' }; + }, + onTokensRefreshed: (t) => { + store.access = t.accessToken; + store.refresh = t.refreshToken ?? store.refresh; + }, + onRefreshFailed: () => {}, + }); + + mock.on('GET', '/things/1', (r) => { + const auth = r.headers['Authorization'] ?? r.headers['authorization']; + return auth === 'Bearer fresh' + ? { data: { id: '1' } } + : { status: 401, data: { message: 'expired' } }; + }); + + const result = await api.things.get('1'); + expect(result).toEqual({ id: '1' }); + expect(store.access).toBe('fresh'); + expect(refreshCalls).toBe(1); + }); + + it('concurrent 401s under "queue" coalesce onto one refresh() call', async () => { + const store = { access: 'expired', refresh: 'r1' }; + let refreshCalls = 0; + const { api, mock } = makeThingsApi({ + strategy: 'oauth2', + getAccessToken: () => store.access, + getRefreshToken: () => store.refresh, + refresh: async () => { + refreshCalls += 1; + await new Promise((r) => setTimeout(r, 5)); + return { accessToken: 'fresh' }; + }, + onTokensRefreshed: (t) => { + store.access = t.accessToken; + }, + onRefreshFailed: () => {}, + concurrentRefreshStrategy: 'queue', + }); + + mock.on('GET', '/things/1', (r) => { + const auth = r.headers['Authorization'] ?? r.headers['authorization']; + return auth === 'Bearer fresh' + ? { data: { id: '1' } } + : { status: 401, data: { message: 'expired' } }; + }); + + await Promise.all([api.things.get('1'), api.things.get('1'), api.things.get('1')]); + expect(refreshCalls).toBe(1); + }); + + it('refresh() throwing surfaces through onRefreshFailed, never an unhandled rejection', async () => { + const failures: unknown[] = []; + const { api, mock } = makeThingsApi({ + strategy: 'oauth2', + getAccessToken: () => 'expired', + getRefreshToken: () => 'r1', + refresh: async () => { + throw new Error('sdk refresh boom'); + }, + onTokensRefreshed: () => {}, + onRefreshFailed: (err) => { + failures.push(err); + }, + }); + mock.on('GET', '/things/1', { status: 401, data: {} }); + + await expect(api.things.get('1')).rejects.toBeTruthy(); + expect(failures).toHaveLength(1); + }); + + it('a malformed refresh() return (no accessToken) fails via onRefreshFailed, never proceeds with undefined', async () => { + const failures: unknown[] = []; + const { api, mock } = makeThingsApi({ + strategy: 'oauth2', + getAccessToken: () => 'expired', + getRefreshToken: () => 'r1', + // biome-ignore lint/suspicious/noExplicitAny: intentionally malformed for the test + refresh: async () => ({}) as any, + onTokensRefreshed: () => {}, + onRefreshFailed: (err) => { + failures.push(err); + }, + }); + mock.on('GET', '/things/1', { status: 401, data: {} }); + + await expect(api.things.get('1')).rejects.toBeTruthy(); + expect(failures).toHaveLength(1); + }); +}); + +describe('OAuth2 config validation (defense-in-depth)', () => { + it('throws ConfigurationError at client construction when both refreshEndpoint and refresh are set', () => { + expect(() => + makeThingsApi({ + strategy: 'oauth2', + getAccessToken: () => 'a', + getRefreshToken: () => 'r', + refreshEndpoint: 'https://auth.test/token', + refresh: async () => ({ accessToken: 'x' }), + onTokensRefreshed: () => {}, + onRefreshFailed: () => {}, + } as unknown as OAuth2AuthConfig), + ).toThrow(ConfigurationError); + }); + + it('throws ConfigurationError at client construction when neither refreshEndpoint nor refresh is set', () => { + expect(() => + makeThingsApi({ + strategy: 'oauth2', + getAccessToken: () => 'a', + getRefreshToken: () => 'r', + onTokensRefreshed: () => {}, + onRefreshFailed: () => {}, + } as unknown as OAuth2AuthConfig), + ).toThrow(ConfigurationError); + }); + + it('throws ConfigurationError when both tokenStorage and the manual triplet are set', () => { + expect(() => + makeThingsApi({ + strategy: 'oauth2', + refreshEndpoint: 'https://auth.test/token', + onRefreshFailed: () => {}, + getAccessToken: () => 'a', + getRefreshToken: () => 'r', + onTokensRefreshed: () => {}, + tokenStorage: { getTokens: () => null, setTokens: () => {}, clearTokens: () => {} }, + } as unknown as OAuth2AuthConfig), + ).toThrow(ConfigurationError); + }); + + it('throws ConfigurationError when neither tokenStorage nor the manual triplet is set', () => { + expect(() => + makeThingsApi({ + strategy: 'oauth2', + refreshEndpoint: 'https://auth.test/token', + onRefreshFailed: () => {}, + } as unknown as OAuth2AuthConfig), + ).toThrow(ConfigurationError); + }); +}); diff --git a/packages/core/src/http/interceptors/tokenRefresh.interceptor.ts b/packages/core/src/http/interceptors/tokenRefresh.interceptor.ts index ffb84cb..fb3108f 100644 --- a/packages/core/src/http/interceptors/tokenRefresh.interceptor.ts +++ b/packages/core/src/http/interceptors/tokenRefresh.interceptor.ts @@ -1,12 +1,18 @@ -import { extractRefreshedTokens, refreshFailure } from '../../auth/strategies/oauth2'; -import type { ApiError } from '../../errors/ApiError'; +import { + extractRefreshedTokens, + refreshFailure, + resolveTokenSource, + validateOAuth2Config, +} from '../../auth/strategies/oauth2'; +import { ApiError } from '../../errors/ApiError'; +import { AuthError } from '../../errors/AuthError'; import type { ClassifierInput } from '../../errors/errorClassifier'; /** * Token-refresh interceptor. On a 401 the OAuth2 refresh flow fires here, * guarded by a mutex so concurrent 401s trigger exactly one refresh (spec 6.2: * multiple simultaneous 401s wait on the same promise). */ -import type { OAuth2AuthConfig } from '../../types/auth.types'; +import type { OAuth2AuthConfig, OAuth2Tokens } from '../../types/auth.types'; import type { ApiRequest } from '../../types/http.types'; import type { HttpAdapter } from '../adapters/adapterInterface'; @@ -32,21 +38,10 @@ export function createTokenRefresher(deps: TokenRefresherDeps): TokenRefresher { // onto each other's refresh (they refresh independently). const inflightByConfig = new WeakMap>(); - async function performRefresh(config: OAuth2AuthConfig): Promise { - let refreshToken: string | null; - try { - refreshToken = await config.getRefreshToken(); - } catch (cause) { - await config.onRefreshFailed(refreshFailure('getRefreshToken() threw.', cause)); - return false; - } - - // Refresh token missing/null -> fail immediately without a network call. - if (!refreshToken) { - await config.onRefreshFailed(refreshFailure('No refresh token available.')); - return false; - } - + async function performHttpRefresh( + config: OAuth2AuthConfig & { refreshEndpoint: string }, + refreshToken: string, + ): Promise { const payload = config.refreshPayload ? config.refreshPayload(refreshToken) : { refresh_token: refreshToken }; @@ -64,32 +59,74 @@ export function createTokenRefresher(deps: TokenRefresherDeps): TokenRefresher { try { raw = await adapter.send(request); } catch (cause) { - await config.onRefreshFailed(classifyError({ kind: 'network', cause, request })); - return false; + throw classifyError({ kind: 'network', cause, request }); } // Refresh endpoint itself errored (401/403 expired token, 429 rate-limited). if (raw.status >= 400) { - await config.onRefreshFailed( - classifyError({ - kind: 'http', - status: raw.status, - statusText: raw.statusText, - headers: raw.headers, - data: raw.data, - request, - }), - ); - return false; + throw classifyError({ + kind: 'http', + status: raw.status, + statusText: raw.statusText, + headers: raw.headers, + data: raw.data, + request, + }); } const tokens = extractRefreshedTokens(raw.data); if (!tokens) { - await config.onRefreshFailed(refreshFailure('Refresh response contained no access token.')); + throw refreshFailure('Refresh response contained no access token.'); + } + return tokens; + } + + async function performRefresh(config: OAuth2AuthConfig): Promise { + validateOAuth2Config(config); + const { getRefreshToken, onTokensRefreshed } = resolveTokenSource(config); + + let refreshToken: string | null; + try { + refreshToken = await getRefreshToken(); + } catch (cause) { + await config.onRefreshFailed(refreshFailure('getRefreshToken() threw.', cause)); + return false; + } + + // Refresh token missing/null -> fail immediately without a network call. + if (!refreshToken) { + await config.onRefreshFailed(refreshFailure('No refresh token available.')); + return false; + } + + let tokens: OAuth2Tokens; + try { + tokens = config.refresh + ? await config.refresh(refreshToken) + : await performHttpRefresh( + config as OAuth2AuthConfig & { refreshEndpoint: string }, + refreshToken, + ); + } catch (cause) { + const error = + cause instanceof AuthError || cause instanceof ApiError + ? (cause as ApiError) + : refreshFailure('refresh() threw.', cause); + await config.onRefreshFailed(error); + return false; + } + + // Validate the callback path's return value the same way an HTTP JSON + // response is shape-checked — a malformed return must never silently + // proceed with `undefined` as the new access token. + if (typeof tokens?.accessToken !== 'string' || tokens.accessToken.length === 0) { + await config.onRefreshFailed( + refreshFailure('refresh() resolved without a valid accessToken.'), + ); return false; } - await config.onTokensRefreshed(tokens); + await onTokensRefreshed(tokens); return true; } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ae6a839..a0e9014 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -75,7 +75,13 @@ export type { OAuth2Tokens, AuthStrategyName, AuthContribution, + TokenStorage, } from './types/auth.types'; +export { + createMemoryTokenStorage, + createLocalStorageTokenStorage, +} from './auth/tokenStorage'; +export type { LocalStorageTokenStorageOptions } from './auth/tokenStorage'; // --- Runtime schema (validation & drift) --- export { createSchemaCache } from './runtime/schemaCache'; diff --git a/packages/core/src/server/createRpcHandler.ts b/packages/core/src/server/createRpcHandler.ts index 2f9f50e..84ae39a 100644 --- a/packages/core/src/server/createRpcHandler.ts +++ b/packages/core/src/server/createRpcHandler.ts @@ -11,6 +11,7 @@ */ import { type RpcCall, type RpcResponse, isRpcBatchRequest } from '../rpc/protocol'; +import { assertServerOnly } from '../utilities/serverOnly'; import { RpcSecurityError, assertPrimitivePathParams, @@ -136,6 +137,16 @@ export interface RpcHandlerOptions { * @default 10 */ maxBatchSize?: number; + /** + * Throws immediately if this handler is constructed in a browser context + * (spec 06) — this is the real trust boundary (see SECURITY.md), so + * leaving this on unless you have a specific, documented reason not to + * (e.g. a jsdom-based unit test importing server code) is strongly + * recommended. Defaults to enforced, unlike most config in this library — + * there is no legitimate reason to construct an RPC handler in a browser. + * @default true + */ + enforceServerOnly?: boolean; } /** The dispatcher returned by {@link createRpcHandler}. */ @@ -225,6 +236,8 @@ export function createRpcHandler( const maxBatchSize = options.maxBatchSize ?? 10; const dev = options.dev ?? defaultDev(); + if (options.enforceServerOnly ?? true) assertServerOnly('createRpcHandler'); + /** Sanitize + log one error into a failure envelope (shared by single/batch). */ const fail = (error: unknown, call: RpcCall | null): RpcResponse => { // S8: log the full error server-side; return only the sanitized shape. diff --git a/packages/core/src/server/rpc.test.ts b/packages/core/src/server/rpc.test.ts index 669717d..c92113b 100644 --- a/packages/core/src/server/rpc.test.ts +++ b/packages/core/src/server/rpc.test.ts @@ -1,9 +1,10 @@ /** * RPC bridge tests. Each `S#` maps to a threat in the design's security table. */ -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { ApiError } from '../errors/ApiError'; import { AuthError } from '../errors/AuthError'; +import { ConfigurationError } from '../errors/ConfigurationError'; import { createRpcHandler } from './createRpcHandler'; import { createRpcRouteHandler } from './routeHandler'; @@ -262,6 +263,38 @@ describe('createRpcHandler', () => { ]); expect(new Set(seen)).toEqual(new Set(['alice', 'bob'])); }); + + describe('S17: enforceServerOnly (spec 06)', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('throws immediately when constructed in a simulated browser context', () => { + vi.stubGlobal('window', {}); + vi.stubGlobal('document', {}); + const { api } = makeApi(); + expect(() => createRpcHandler(api, { expose: { pet: ['getPetById'] } })).toThrow( + ConfigurationError, + ); + }); + + it('enforceServerOnly: false suppresses the throw', () => { + vi.stubGlobal('window', {}); + vi.stubGlobal('document', {}); + const { api } = makeApi(); + expect(() => + createRpcHandler(api, { + expose: { pet: ['getPetById'] }, + enforceServerOnly: false, + }), + ).not.toThrow(); + }); + + it('does not throw in a normal (non-browser) test environment', () => { + const { api } = makeApi(); + expect(() => createRpcHandler(api, { expose: { pet: ['getPetById'] } })).not.toThrow(); + }); + }); }); /** Minimal Request builder for route tests. */ diff --git a/packages/core/src/types/auth.types.ts b/packages/core/src/types/auth.types.ts index 28a6beb..7a2cabe 100644 --- a/packages/core/src/types/auth.types.ts +++ b/packages/core/src/types/auth.types.ts @@ -134,10 +134,93 @@ export interface OAuth2Tokens { refreshToken?: string; } +/** + * Pluggable token persistence, mirroring `PersistentCacheStore`'s adapter + * pattern. When supplied instead of the manual `getAccessToken`/ + * `getRefreshToken`/`onTokensRefreshed` triplet, the client derives all + * three from this adapter. + */ +export interface TokenStorage { + /** Returns the current tokens (or `null` when none are stored). Sync or async. */ + getTokens(): OAuth2Tokens | null | Promise; + /** + * Persist freshly issued tokens. Called exactly once per successful + * refresh — never a second write path alongside this. + */ + setTokens(tokens: OAuth2Tokens): void | Promise; + /** Clear stored tokens (e.g. on logout). */ + clearTokens(): void | Promise; +} + +/** How a `401` triggers a token refresh: an HTTP endpoint, or a callback. */ +type OAuth2RefreshMechanism = + | { + /** + * URL called (POST) when a `401` triggers a token refresh. Exactly one + * of `refreshEndpoint` or `refresh` must be set. + * @default no default — required unless `refresh` is set + */ + refreshEndpoint: string; + /** + * Builds the request body sent to `refreshEndpoint` from the current + * refresh token. When unset, a default payload shape is used. + * @default optional, unset means the client's default refresh body is sent + */ + refreshPayload?: (refreshToken: string) => Record; + refresh?: never; + } + | { + /** + * Callback refresh: bypasses HTTP entirely. Return the new tokens, or + * throw/reject to signal failure (handled the same way an HTTP + * refresh failure is). Use this when refresh logic lives in an SDK + * call, a BFF function, or anywhere that isn't "just POST a URL". + * Exactly one of `refreshEndpoint` or `refresh` must be set. + */ + refresh: (currentRefreshToken: string) => Promise; + refreshEndpoint?: never; + refreshPayload?: never; + }; + +/** Where access/refresh tokens are read from and persisted to. */ +type OAuth2TokenSource = + | { + /** + * Returns the current access token (or `null`). Sync or async. + * @default no default — required unless `tokenStorage` is set + */ + getAccessToken: () => string | null | Promise; + /** + * Returns the current refresh token (or `null`), used to build the + * refresh request. Sync or async. + * @default no default — required unless `tokenStorage` is set + */ + getRefreshToken: () => string | null | Promise; + /** + * Called with the freshly issued tokens so they can be persisted. Sync + * or async. + * @default no default — required unless `tokenStorage` is set + */ + onTokensRefreshed: (tokens: OAuth2Tokens) => void | Promise; + tokenStorage?: never; + } + | { + /** + * Pluggable token persistence — derives `getAccessToken`/ + * `getRefreshToken`/`onTokensRefreshed` from this adapter. Mutually + * exclusive with the manual triplet. + */ + tokenStorage: TokenStorage; + getAccessToken?: never; + getRefreshToken?: never; + onTokensRefreshed?: never; + }; + /** * OAuth2 authentication with automatic refresh-on-401. On a `401` the client - * calls `refreshEndpoint` once, retries the original request, and reports the - * outcome via the callbacks below. Applies when `strategy` is `'oauth2'`. + * refreshes once (via `refreshEndpoint` or the `refresh` callback), retries + * the original request, and reports the outcome via the callbacks below. + * Applies when `strategy` is `'oauth2'`. * * @example * ```ts @@ -151,40 +234,12 @@ export interface OAuth2Tokens { * } * ``` */ -export interface OAuth2AuthConfig { +export type OAuth2AuthConfig = { /** * Discriminant selecting this strategy. * @default no default — required */ strategy: 'oauth2'; - /** - * Returns the current access token (or `null`). Sync or async. - * @default no default — required - */ - getAccessToken: () => string | null | Promise; - /** - * Returns the current refresh token (or `null`), used to build the refresh - * request. Sync or async. - * @default no default — required - */ - getRefreshToken: () => string | null | Promise; - /** - * URL called (POST) when a `401` triggers a token refresh. - * @default no default — required - */ - refreshEndpoint: string; - /** - * Builds the request body sent to `refreshEndpoint` from the current refresh - * token. When unset, a default payload shape is used. - * @default optional, unset means the client's default refresh body is sent - */ - refreshPayload?: (refreshToken: string) => Record; - /** - * Called with the freshly issued tokens so they can be persisted. Sync or - * async. - * @default no default — required - */ - onTokensRefreshed: (tokens: OAuth2Tokens) => void | Promise; /** * Called when the refresh attempt itself fails (e.g. refresh token expired), * letting the app log out or redirect. Sync or async. @@ -198,7 +253,8 @@ export interface OAuth2AuthConfig { * @default 'queue' */ concurrentRefreshStrategy?: 'queue' | 'race'; -} +} & OAuth2RefreshMechanism & + OAuth2TokenSource; /** * Disables authentication entirely. Applies when `strategy` is `'none'`; this diff --git a/packages/core/src/types/cache.types.ts b/packages/core/src/types/cache.types.ts index dc2453e..1c01e13 100644 --- a/packages/core/src/types/cache.types.ts +++ b/packages/core/src/types/cache.types.ts @@ -33,6 +33,16 @@ export interface CacheEntry { storedAt: number; /** Epoch ms after which the entry is considered stale. */ expiresAt: number; + /** Logical labels this entry is filed under, for tag-based invalidation. */ + tags?: string[]; + /** + * The runtime OpenAPI schema hash active when this entry was written + * (spec 08.7). A read whose CURRENT schema hash differs is treated as a + * miss (evicted, refetched) — this prevents a deploy that changes + * response shapes from serving old-shaped cached data to a newly + * deployed frontend expecting the new shape. + */ + schemaHash?: string; } /** @@ -73,6 +83,71 @@ export interface CacheConfig { * @default 500 */ maxSize?: number; + /** + * Maximum approximate total size (bytes) of all L1 entries. When set, this + * bounds eviction ALONGSIDE `maxSize` — whichever limit is hit first + * triggers LRU eviction. Size is estimated cheaply (JSON length of `data` + * + `headers`), not exact — a reasonable pressure-relief signal, not a + * hard memory guarantee. + * @default optional, unset means only `maxSize` (count) bounds the cache + */ + maxSizeBytes?: number; + /** + * Storage topology: + * - `'layered'` (default) — sync in-memory L1 in front of the optional L2. + * - `'l1-only'` — ignore `persistentStore` even if set (mostly for tests). + * - `'l2-only'` — a tiny, bounded shadow L1 (just enough to avoid a network + * round-trip for an immediately-repeated read) in front of + * `persistentStore`, trading local memory for latency. Requires + * `persistentStore` to be set. + * @default 'layered' + */ + mode?: 'layered' | 'l1-only' | 'l2-only'; + /** + * Node-only. When set, periodically checks `process.memoryUsage().rss` and + * proactively evicts the oldest entries when it crosses the threshold, + * ahead of `maxSize`/`maxSizeBytes` limits. No-ops outside Node (edge/ + * browser) — feature-detected, safe to set anywhere. + * @default optional, unset means no proactive pressure-based eviction + */ + memoryPressure?: { thresholdMb: number; checkIntervalMs?: number }; + /** + * HTTP error statuses (e.g. `404`) that should be cached ("negative + * caching") instead of hitting the network on every repeat lookup for a + * resource that doesn't exist. A cache hit on one of these still rejects + * the call (same as a live request would) — this only saves the network + * round-trip, it does not turn an error into a success. + * @default optional, unset means no status is negative-cached + */ + cacheableStatuses?: number[]; + /** + * TTL applied to a negative-cached entry (see {@link cacheableStatuses}), + * usually shorter than the normal success TTL. + * @default falls back to the normal `ttl` + */ + negativeTtl?: number; + /** + * Minimal circuit breaker for a failing persistent (L2) store (spec + * 08.4): after `failureThreshold` consecutive L2 failures, stop + * attempting L2 calls for `cooldownMs` (serve L1-only — the same + * degraded-but-safe fallback L2 failures already have, just without the + * repeated timeout cost). Re-probes after cooldown. + * @default optional, unset means no circuit breaker + */ + circuitBreaker?: { failureThreshold: number; cooldownMs: number }; + /** + * Fires on a persistent (L2) store failure, or a circuit-breaker state + * transition (`op: 'circuit-open' | 'circuit-close'`). Never receives + * cached `data` — error + operation context only. + * @default optional, unset means silent swallow-and-degrade (today's behavior) + */ + onStoreError?: ( + error: unknown, + context: { + op: 'get' | 'set' | 'delete' | 'clear' | 'circuit-open' | 'circuit-close'; + key?: string; + }, + ) => void; /** * Custom cache-key function. When unset, keys are derived from * method + url + auth-fingerprint + tenant. @@ -84,6 +159,24 @@ export interface CacheConfig { * ``` */ keyResolver?: (request: ApiRequest) => string; + /** + * Extra key dimensions merged INTO the default (or `keyResolver`'s) key — + * safer than `keyResolver` for the common case of "add one more thing to + * the scope" (e.g. an admin viewing "as" a target user, or the active + * workspace in a multi-tenant dashboard) without losing tenant/ + * auth-fingerprint isolation: every value here is folded into the same + * hash the built-in key derivation already applies to tenant/ + * authFingerprint, so cross-scope isolation holds by construction. + * + * **If your endpoint's response depends on a value, that value MUST + * appear either in the URL or here, or responses can leak across + * contexts** (e.g. two admins "viewing as" different users would + * otherwise share one cached response). Set statically (module/global/ + * per-call config) or per auto-method descriptor via + * `AutoMethodDescriptor.cacheKeyParts(args)`; both are unioned. + * @default optional, unset means no extra key parts + */ + cacheKeyParts?: Record; /** * Called whenever an entry is evicted (LRU eviction or expiry). * @default optional, unset means no eviction callback diff --git a/packages/core/src/types/config.types.ts b/packages/core/src/types/config.types.ts index 99e738d..6cc4f8a 100644 --- a/packages/core/src/types/config.types.ts +++ b/packages/core/src/types/config.types.ts @@ -642,7 +642,32 @@ export interface PerCallConfig { * the cached entry. * @default optional, unset means inherit global/module cache */ - cache?: { enabled?: boolean; ttl?: number; bust?: boolean }; + cache?: { + enabled?: boolean; + ttl?: number; + bust?: boolean; + /** + * Tags this specific call's cache entry is filed under, in addition to + * any tags the method descriptor declares (union, not override). Tags + * are exact-match labels used only for invalidation, never for gating + * read access — see `invalidateTags`. + * @default optional, unset means only descriptor-level tags (if any) apply + */ + tags?: string[]; + /** + * Tags to invalidate, across every auth scope in this process, after + * this specific call succeeds. Unions with descriptor-level + * `invalidatesTags`. + * @default optional, unset means only descriptor-level invalidation (if any) applies + */ + invalidatesTags?: string[]; + /** + * Extra cache-key dimensions for this specific call, unioned with any + * the method descriptor computes (spec 10). See `CacheConfig.cacheKeyParts`. + * @default optional, unset means only descriptor-level parts (if any) apply + */ + cacheKeyParts?: Record; + }; /** * Retry override for this call (attempt count only). * @default optional, unset means inherit global/module retry @@ -694,7 +719,7 @@ export interface ResolvedRequestConfig { headers: Record; auth: AuthConfig; cache: Required> & - CacheConfig & { bust?: boolean }; + CacheConfig & { bust?: boolean; tags?: string[]; invalidatesTags?: string[] }; retry: Required> & RetryConfig; tenancy: TenancyConfig; diff --git a/packages/core/src/types/module.types.ts b/packages/core/src/types/module.types.ts index a375ce9..dd83e53 100644 --- a/packages/core/src/types/module.types.ts +++ b/packages/core/src/types/module.types.ts @@ -149,6 +149,32 @@ export interface ModuleRequestSpec { pathParams?: Record; query?: Record; body?: unknown; + /** + * Tags this response is cached under, eagerly resolved from the method + * descriptor's `cacheTags(args)` before dispatch (spec 01). Unions with + * `PerCallConfig.cache.tags`. + */ + cacheTags?: string[]; + /** + * Resolves the tags to invalidate (across every auth scope) after this + * call succeeds, given the response body — the method descriptor's + * `invalidatesTags(args, result)` closed over its `args`. Unions with + * `PerCallConfig.cache.invalidatesTags`. + */ + resolveInvalidatesTags?: (result: unknown) => string[]; + /** + * Extra cache-key dimensions, eagerly resolved from the method + * descriptor's `cacheKeyParts(args)` before dispatch (spec 10). Unions + * with `PerCallConfig.cache.cacheKeyParts`. + */ + cacheKeyParts?: Record; + /** + * Set when the descriptor's `cacheKeyParts(args)` threw while resolving — + * fail closed (spec 10): the request disables caching entirely for this + * call rather than silently omitting the extra scoping, which could put + * two logically distinct responses under a would-be-colliding key. + */ + cacheKeyPartsFailed?: boolean; } /** A single method on a module. */ @@ -171,6 +197,14 @@ export interface ModuleDefinition { extends?: 'auto'; config?: ModuleConfig; methods: M; + /** + * When `true`, this module throws immediately if evaluated in a browser + * context (spec 06) — checked once at module construction, not per call. + * Use for modules wrapping server-only concerns (direct DB access, + * secrets, internal services) that must never run client-side. + * @default false + */ + serverOnly?: boolean; } /** Strips the leading `ModuleContext` param from an exposed method. */ diff --git a/packages/core/src/utilities/cache.test.ts b/packages/core/src/utilities/cache.test.ts index f75d3df..f86f6f8 100644 --- a/packages/core/src/utilities/cache.test.ts +++ b/packages/core/src/utilities/cache.test.ts @@ -1,7 +1,13 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import type { CacheEntry } from '../types/cache.types'; -import { computeCacheKey, createCache, isFresh } from './cache'; +import { + computeCacheKey, + createCache, + foldExtraKeyParts, + isFresh, + sanitizeKeyParts, +} from './cache'; function makeEntry(key: string, expiresAt = 0): CacheEntry { return { @@ -62,6 +68,61 @@ describe('createCache LRU', () => { }); }); +describe('maxSizeBytes', () => { + it('evicts oldest entries once the running byte estimate exceeds the cap, independent of maxSize', () => { + const evicted: string[] = []; + // maxSize is generous (10); only the byte cap should trigger eviction. + const cache = createCache({ + maxSize: 10, + maxSizeBytes: 50, + onEvict: (k) => evicted.push(k), + }); + + // Each entry's JSON is well over 10 bytes, so a few entries should + // exceed the 50-byte cap well before hitting maxSize. + cache.set('a', makeEntry('a')); + cache.set('b', makeEntry('b')); + cache.set('c', makeEntry('c')); + cache.set('d', makeEntry('d')); + + expect(cache.size()).toBeLessThan(4); + expect(evicted.length).toBeGreaterThan(0); + // Oldest-first: 'a' should be among the first evicted. + expect(evicted[0]).toBe('a'); + }); + + it('falls back to size 0 for non-serializable data instead of throwing', () => { + const circular: Record = {}; + circular['self'] = circular; + const cache = createCache({ maxSizeBytes: 1 }); + expect(() => cache.set('a', { ...makeEntry('a'), data: circular })).not.toThrow(); + expect(cache.has('a')).toBe(true); + }); +}); + +describe('evictOldest', () => { + it('evicts the N least-recently-used entries and fires onEvict for each', () => { + const evicted: string[] = []; + const cache = createCache({ maxSize: 100, onEvict: (k) => evicted.push(k) }); + cache.set('a', makeEntry('a')); + cache.set('b', makeEntry('b')); + cache.set('c', makeEntry('c')); + + cache.evictOldest(2); + + expect(evicted).toEqual(['a', 'b']); + expect(cache.has('c')).toBe(true); + expect(cache.size()).toBe(1); + }); + + it('stops cleanly when asked to evict more than exist', () => { + const cache = createCache(); + cache.set('a', makeEntry('a')); + expect(() => cache.evictOldest(5)).not.toThrow(); + expect(cache.size()).toBe(0); + }); +}); + describe('TTL / staleness', () => { it('isFresh compares against injected now', () => { const entry = makeEntry('a', 1000); @@ -94,6 +155,25 @@ describe('glob invalidate', () => { expect(cache.has('users.list')).toBe(true); }); + it('keysMatching (dry run) returns the same set invalidate() would remove, without removing anything', () => { + const cache = createCache(); + cache.set('invoices.list', makeEntry('invoices.list')); + cache.set('invoices.get', makeEntry('invoices.get')); + cache.set('users.list', makeEntry('users.list')); + + const preview = cache.keysMatching('invoices.*'); + expect(new Set(preview)).toEqual(new Set(['invoices.list', 'invoices.get'])); + // Nothing was actually removed. + expect(cache.has('invoices.list')).toBe(true); + expect(cache.has('invoices.get')).toBe(true); + expect(cache.size()).toBe(3); + + // A subsequent real invalidate with the same pattern removes exactly that set. + expect(cache.invalidate('invoices.*')).toBe(2); + expect(cache.has('invoices.list')).toBe(false); + expect(cache.has('invoices.get')).toBe(false); + }); + it('treats dots literally (no regex wildcard leak)', () => { const cache = createCache(); cache.set('axb', makeEntry('axb')); @@ -129,3 +209,68 @@ describe('computeCacheKey', () => { expect(computeCacheKey(input)).toBe(computeCacheKey(input)); }); }); + +describe('computeCacheKey extraParts (spec 10)', () => { + const base = { method: 'GET', url: '/dashboard/summary', tenantId: 't1', authFingerprint: 'f1' }; + + it('produces a different key than without extraParts', () => { + const without = computeCacheKey(base); + const withParts = computeCacheKey({ ...base, extraParts: { workspaceId: 'w1' } }); + expect(withParts).not.toBe(without); + }); + + it('is order-independent — {a,b} and {b,a} hash identically', () => { + const ab = computeCacheKey({ ...base, extraParts: { a: '1', b: '2' } }); + const ba = computeCacheKey({ ...base, extraParts: { b: '2', a: '1' } }); + expect(ab).toBe(ba); + }); + + it('different extraParts values produce independent keys', () => { + const w1 = computeCacheKey({ ...base, extraParts: { workspaceId: 'w1' } }); + const w2 = computeCacheKey({ ...base, extraParts: { workspaceId: 'w2' } }); + expect(w1).not.toBe(w2); + }); +}); + +describe('sanitizeKeyParts (spec 10)', () => { + it('coerces values to strings', () => { + expect(sanitizeKeyParts({ a: 1, b: true } as unknown as Record)).toEqual({ + a: '1', + b: 'true', + }); + }); + + it('strips __proto__/constructor/prototype keys without polluting Object.prototype', () => { + const malicious = JSON.parse( + '{"__proto__": {"polluted": true}, "constructor": "x", "safe": "ok"}', + ) as Record; + const sanitized = sanitizeKeyParts(malicious); + expect(sanitized).toEqual({ safe: 'ok' }); + expect(({} as Record)['polluted']).toBeUndefined(); + }); + + it('never crashes on a hostile key name', () => { + expect(() => sanitizeKeyParts({ prototype: 'x', safe: 'ok' })).not.toThrow(); + }); +}); + +describe('foldExtraKeyParts (spec 10)', () => { + it('returns base unchanged when extraParts is empty/unset', () => { + expect(foldExtraKeyParts('some-base-key')).toBe('some-base-key'); + expect(foldExtraKeyParts('some-base-key', {})).toBe('some-base-key'); + }); + + it('appends a deterministic, order-independent suffix', () => { + const ab = foldExtraKeyParts('base', { a: '1', b: '2' }); + const ba = foldExtraKeyParts('base', { b: '2', a: '1' }); + expect(ab).toBe(ba); + expect(ab).not.toBe('base'); + expect(ab.startsWith('base#')).toBe(true); + }); + + it('different extraParts values fold to different suffixes', () => { + const w1 = foldExtraKeyParts('base', { workspaceId: 'w1' }); + const w2 = foldExtraKeyParts('base', { workspaceId: 'w2' }); + expect(w1).not.toBe(w2); + }); +}); diff --git a/packages/core/src/utilities/cache.ts b/packages/core/src/utilities/cache.ts index 7f67987..c03a71b 100644 --- a/packages/core/src/utilities/cache.ts +++ b/packages/core/src/utilities/cache.ts @@ -17,11 +17,33 @@ export interface CacheStore { clear(): void; /** Glob invalidation: '*' wildcard. e.g. 'invoices.*'. Returns count removed. */ invalidate(pattern: string): number; + /** Read-only preview (spec 08.8): keys `invalidate(pattern)` would remove, without removing them. */ + keysMatching(pattern: string): string[]; size(): number; /** True when entry exists but expiresAt < now (stale-but-present, for SWR). */ isStale(key: string): boolean; } +/** An in-memory {@link CacheStore} with a proactive-eviction escape hatch (spec 03). */ +export interface LruCacheStore extends CacheStore { + /** Evict the `count` least-recently-used entries, firing `onEvict` for each. */ + evictOldest(count: number): void; +} + +/** + * Cheap, non-exact estimate of an entry's footprint in bytes (spec 03): the + * JSON length of `data` + `headers`. Never throws — circular references or + * other non-serializable values fall back to `0` rather than breaking the + * write path. + */ +export function estimateEntrySize(entry: CacheEntry): number { + try { + return JSON.stringify(entry.data).length + JSON.stringify(entry.headers).length; + } catch { + return 0; + } +} + /** * Create an LRU cache store. * @@ -29,10 +51,32 @@ export interface CacheStore { * (both {@link CacheStore.get} and {@link CacheStore.set} count as a use). * - Fires `config.onEvict(key, entry)` for entries removed by overflow (C3). */ -export function createCache(config?: Pick): CacheStore { +export function createCache( + config?: Pick, +): LruCacheStore { const maxSize = config?.maxSize ?? DEFAULT_MAX_SIZE; + const maxSizeBytes = config?.maxSizeBytes; const onEvict = config?.onEvict; const store = new Map(); + const sizes = new Map(); + let totalBytes = 0; + + /** Remove `key`'s byte contribution from the running total, if tracked. */ + const untrackBytes = (key: string): void => { + const size = sizes.get(key); + if (size !== undefined) { + totalBytes -= size; + sizes.delete(key); + } + }; + + /** Record `entry`'s estimated byte size for `key`, replacing any prior size. */ + const trackBytes = (key: string, entry: CacheEntry): void => { + untrackBytes(key); + const size = estimateEntrySize(entry); + sizes.set(key, size); + totalBytes += size; + }; /** Move `key` to the most-recently-used position (Map tail). */ const bump = (key: string, entry: CacheEntry): void => { @@ -40,15 +84,22 @@ export function createCache(config?: Pick): store.set(key, entry); }; - /** Evict least-recently-used entries until within `maxSize`. */ + /** Remove the single oldest (Map head) entry, firing `onEvict`. No-op if empty. */ + const evictOne = (): boolean => { + const oldest = store.keys().next(); + if (oldest.done === true) return false; + const key = oldest.value; + const entry = store.get(key); + store.delete(key); + untrackBytes(key); + if (entry !== undefined) onEvict?.(key, entry); + return true; + }; + + /** Evict least-recently-used entries until within `maxSize` and `maxSizeBytes`. */ const evictOverflow = (): void => { - while (store.size > maxSize) { - const oldest = store.keys().next(); - if (oldest.done === true) break; - const key = oldest.value; - const entry = store.get(key); - store.delete(key); - if (entry !== undefined) onEvict?.(key, entry); + while (store.size > maxSize || (maxSizeBytes !== undefined && totalBytes > maxSizeBytes)) { + if (!evictOne()) break; } }; @@ -62,6 +113,7 @@ export function createCache(config?: Pick): set(key: string, entry: CacheEntry): void { bump(key, entry); + trackBytes(key, entry); evictOverflow(); }, @@ -70,25 +122,30 @@ export function createCache(config?: Pick): }, delete(key: string): boolean { + untrackBytes(key); return store.delete(key); }, clear(): void { store.clear(); + sizes.clear(); + totalBytes = 0; }, invalidate(pattern: string): number { - const regex = globToRegExp(pattern); let removed = 0; - for (const key of [...store.keys()]) { - if (regex.test(key)) { - store.delete(key); - removed += 1; - } + for (const key of matchKeys(store, pattern)) { + store.delete(key); + untrackBytes(key); + removed += 1; } return removed; }, + keysMatching(pattern: string): string[] { + return matchKeys(store, pattern); + }, + size(): number { return store.size; }, @@ -98,6 +155,12 @@ export function createCache(config?: Pick): if (entry === undefined) return false; return entry.expiresAt < Date.now(); }, + + evictOldest(count: number): void { + for (let i = 0; i < count; i += 1) { + if (!evictOne()) break; + } + }, }; } @@ -121,18 +184,67 @@ export function computeCacheKey(input: { url: string; tenantId?: string; authFingerprint?: string; + /** Extra key dimensions (spec 10), folded into the same hashed scope. */ + extraParts?: Record; }): string { const prefix = `${input.method.toUpperCase()}:${input.url}`; - const scope = `${input.tenantId ?? ''}|${input.authFingerprint ?? ''}`; + // Sorted for determinism — `{a,b}` and `{b,a}` must hash identically + // regardless of insertion order. + const extra = input.extraParts + ? Object.entries(input.extraParts) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([k, v]) => `${k}=${v}`) + .join('&') + : ''; + const scope = `${input.tenantId ?? ''}|${input.authFingerprint ?? ''}|${extra}`; return `${prefix}#${hash(scope)}`; } +/** + * Fold extra key parts (spec 10) onto an already-computed key string, e.g. a + * `keyResolver`'s full-override output. Kept separate from + * {@link computeCacheKey} since a `keyResolver`'s `base` is an opaque string, + * not a `method:url` pair to re-derive. + */ +export function foldExtraKeyParts(base: string, extraParts?: Record): string { + if (!extraParts || Object.keys(extraParts).length === 0) return base; + const extra = Object.entries(extraParts) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([k, v]) => `${k}=${v}`) + .join('&'); + return `${base}#${hash(extra)}`; +} + +/** Key names that must never be used — same blocklist `deepMerge` uses against prototype pollution. */ +const BLOCKED_KEY_PARTS: ReadonlySet = new Set(['__proto__', 'constructor', 'prototype']); + +/** + * Defensively sanitize a {@link CacheConfig.cacheKeyParts}-style record before + * it reaches {@link computeCacheKey} (spec 10): every value is coerced to a + * string (the source may be developer-supplied and untyped at the boundary), + * and dangerous key names are dropped rather than reaching `Object` machinery. + */ +export function sanitizeKeyParts(parts: Record): Record { + const sanitized: Record = {}; + for (const [key, value] of Object.entries(parts)) { + if (BLOCKED_KEY_PARTS.has(key)) continue; + sanitized[key] = String(value); + } + return sanitized; +} + /** Escape regex metacharacters except `*`, which becomes `.*`. */ function globToRegExp(pattern: string): RegExp { const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*'); return new RegExp(`^${escaped}$`); } +/** Keys in `store` matching `pattern` — shared by `invalidate()` and the read-only `keysMatching()`. */ +function matchKeys(store: Map, pattern: string): string[] { + const regex = globToRegExp(pattern); + return [...store.keys()].filter((key) => regex.test(key)); +} + /** Deterministic FNV-1a 32-bit hash rendered as base-36. */ function hash(input: string): string { let h = 0x811c9dc5; diff --git a/packages/core/src/utilities/memoryPressure.test.ts b/packages/core/src/utilities/memoryPressure.test.ts new file mode 100644 index 0000000..00bfacf --- /dev/null +++ b/packages/core/src/utilities/memoryPressure.test.ts @@ -0,0 +1,88 @@ +/** + * Node-only, opt-in proactive eviction ahead of RSS pressure (spec 03). + * Feature-detected via `process.memoryUsage` so it safely no-ops on edge/ + * browser runtimes that lack it. + */ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createClient } from '../factory/createClient'; +import { createMockAdapter } from '../testing/mockAdapter'; +import type { ModuleContext } from '../types/module.types'; + +function makeThingsClient(mock: ReturnType, cache: unknown) { + return createClient({ + baseURL: 'http://mock.test', + openapi: { mode: 'runtime' }, + http: { + adapter: mock, + retry: { attempts: 1, baseDelay: 0, maxDelay: 0, jitter: false }, + }, + cache: cache as never, + modules: { + auto: false as const, + things: { + methods: { + get: async (ctx: ModuleContext, id: string) => + ( + await ctx.request({ + method: 'GET', + path: '/things/{id}', + pathParams: { id }, + }) + ).data, + }, + }, + }, + }) as unknown as { things: { get: (id: string) => Promise } }; +} + +describe('memoryPressure', () => { + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it('evicts once the RSS threshold is crossed, and not before', async () => { + vi.useFakeTimers(); + let rssBytes = 10 * 1024 * 1024; // 10MB — under the 50MB threshold + vi.stubGlobal('process', { + ...process, + memoryUsage: () => ({ rss: rssBytes }) as NodeJS.MemoryUsage, + }); + + const evicted: string[] = []; + const mock = createMockAdapter(); + mock.on('GET', '/things/1', { data: { id: '1' } }); + const api = makeThingsClient(mock, { + onEvict: (k: string) => evicted.push(k), + memoryPressure: { thresholdMb: 50, checkIntervalMs: 1000 }, + }); + + await api.things.get('1'); + + // Under threshold: the periodic check must not evict. + await vi.advanceTimersByTimeAsync(1000); + expect(evicted).toHaveLength(0); + + // Cross the threshold: the next tick must evict. + rssBytes = 100 * 1024 * 1024; + await vi.advanceTimersByTimeAsync(1000); + expect(evicted).toHaveLength(1); + }); + + it('no-ops when process.memoryUsage is undefined (simulated edge runtime)', async () => { + vi.useFakeTimers(); + vi.stubGlobal('process', undefined); + + const mock = createMockAdapter(); + mock.on('GET', '/things/1', { data: { id: '1' } }); + + expect(() => + makeThingsClient(mock, { + memoryPressure: { thresholdMb: 1, checkIntervalMs: 1000 }, + }), + ).not.toThrow(); + + await vi.advanceTimersByTimeAsync(5000); + // No crash, no timer-driven work — nothing to assert beyond "didn't throw". + }); +}); diff --git a/packages/core/src/utilities/serverOnly.test.ts b/packages/core/src/utilities/serverOnly.test.ts new file mode 100644 index 0000000..adf433a --- /dev/null +++ b/packages/core/src/utilities/serverOnly.test.ts @@ -0,0 +1,40 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { ConfigurationError } from '../errors/ConfigurationError'; +import { assertServerOnly } from './serverOnly'; + +describe('assertServerOnly', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('throws ConfigurationError when window + document are both present', () => { + vi.stubGlobal('window', {}); + vi.stubGlobal('document', {}); + expect(() => assertServerOnly('some-module')).toThrow(ConfigurationError); + expect(() => assertServerOnly('some-module')).toThrow(/must not run in the browser/); + }); + + it('does not throw when only window is present (e.g. some worker globals)', () => { + vi.stubGlobal('window', {}); + vi.stubGlobal('document', undefined); + expect(() => assertServerOnly('some-module')).not.toThrow(); + }); + + it('does not throw when window/document are absent (Node/edge)', () => { + vi.stubGlobal('window', undefined); + vi.stubGlobal('document', undefined); + expect(() => assertServerOnly('some-module')).not.toThrow(); + }); + + it('the error message never echoes the caller-supplied "where" details beyond the label', () => { + vi.stubGlobal('window', {}); + vi.stubGlobal('document', {}); + try { + assertServerOnly('createRpcHandler'); + throw new Error('expected assertServerOnly to throw'); + } catch (err) { + expect(err).toBeInstanceOf(ConfigurationError); + expect((err as Error).message).not.toMatch(/https?:\/\//); + } + }); +}); diff --git a/packages/core/src/utilities/serverOnly.ts b/packages/core/src/utilities/serverOnly.ts new file mode 100644 index 0000000..cd1f68b --- /dev/null +++ b/packages/core/src/utilities/serverOnly.ts @@ -0,0 +1,29 @@ +import { hasDomGlobals } from '../environment/detect'; +/** + * Dev-time, opt-in guard for the SSR RPC bridge's trust boundary (spec 06): + * throw immediately when server-only code is evaluated/called in a browser + * context, instead of only discovering the leak via CI's + * `scripts/check-browser-bundle.mjs` grep (which stays the authoritative + * last line of defense — this is defense-in-depth, not a replacement). + */ +import { ConfigurationError } from '../errors/ConfigurationError'; + +/** + * Throws a {@link ConfigurationError} when called in a browser context + * (`window`+`document` both present — reuses `hasDomGlobals()`, the same + * DOM-presence primitive `detectEnvironment()` computes `hasDom` from, + * rather than a second ad-hoc check; unlike `detectEnvironment()` this is + * NOT memoized, since this guard must reflect the current global state at + * call time). No-ops everywhere else (Node, edge, workers). + * + * The message is intentionally generic — it never echoes the caller's + * config (`baseURL`, paths, etc.), since that would defeat the very leak + * this guard exists to catch. + */ +export function assertServerOnly(where: string): void { + if (hasDomGlobals()) { + throw new ConfigurationError( + `${where} must not run in the browser. This would expose backend URLs/paths/credentials that the SSR RPC bridge is designed to hide — see SECURITY.md. If you are seeing this from a bundler, check for an accidental client-side import of a server-only module.`, + ); + } +} diff --git a/packages/core/src/utilities/tagIndex.test.ts b/packages/core/src/utilities/tagIndex.test.ts new file mode 100644 index 0000000..25005b0 --- /dev/null +++ b/packages/core/src/utilities/tagIndex.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'vitest'; +import { createTagIndex } from './tagIndex'; + +describe('createTagIndex', () => { + it('tracks a key under multiple tags and finds it by any of them', () => { + const index = createTagIndex(); + index.track('key1', ['user:1', 'admin']); + + expect(index.keysFor(['user:1'])).toEqual(new Set(['key1'])); + expect(index.keysFor(['admin'])).toEqual(new Set(['key1'])); + expect(index.keysFor(['user:1', 'admin'])).toEqual(new Set(['key1'])); + expect(index.keysFor(['nonexistent'])).toEqual(new Set()); + }); + + it('finds multiple keys sharing one tag', () => { + const index = createTagIndex(); + index.track('key1', ['user:1']); + index.track('key2', ['user:1']); + index.track('key3', ['user:2']); + + expect(index.keysFor(['user:1'])).toEqual(new Set(['key1', 'key2'])); + expect(index.keysFor(['user:2'])).toEqual(new Set(['key3'])); + }); + + it('untrack removes the key from every tag it was filed under', () => { + const index = createTagIndex(); + index.track('key1', ['a', 'b']); + index.track('key2', ['a']); + + index.untrack('key1'); + + expect(index.keysFor(['a'])).toEqual(new Set(['key2'])); + expect(index.keysFor(['b'])).toEqual(new Set()); + }); + + it('untrack is a no-op for an untracked key', () => { + const index = createTagIndex(); + expect(() => index.untrack('never-tracked')).not.toThrow(); + }); + + it('untracking every key leaves no dangling references (memory leak check)', () => { + const index = createTagIndex(); + index.track('key1', ['a', 'b']); + index.track('key2', ['b', 'c']); + + index.untrack('key1'); + index.untrack('key2'); + + expect(index.keysFor(['a', 'b', 'c'])).toEqual(new Set()); + // Internal maps must be fully empty, not just returning empty results — + // re-track the same tags and confirm no stale entries resurface. + index.track('key3', ['a']); + expect(index.keysFor(['a'])).toEqual(new Set(['key3'])); + }); + + it('re-tracking a key adds to its tag set rather than replacing it', () => { + const index = createTagIndex(); + index.track('key1', ['a']); + index.track('key1', ['b']); + + expect(index.keysFor(['a'])).toEqual(new Set(['key1'])); + expect(index.keysFor(['b'])).toEqual(new Set(['key1'])); + + index.untrack('key1'); + expect(index.keysFor(['a', 'b'])).toEqual(new Set()); + }); + + it('clear empties both directions of the index', () => { + const index = createTagIndex(); + index.track('key1', ['a']); + index.track('key2', ['b']); + + index.clear(); + + expect(index.keysFor(['a', 'b'])).toEqual(new Set()); + index.track('key3', ['a']); + expect(index.keysFor(['a'])).toEqual(new Set(['key3'])); + }); + + it('track with an empty tag list is a no-op', () => { + const index = createTagIndex(); + index.track('key1', []); + expect(index.keysFor([])).toEqual(new Set()); + }); +}); diff --git a/packages/core/src/utilities/tagIndex.ts b/packages/core/src/utilities/tagIndex.ts new file mode 100644 index 0000000..8a37423 --- /dev/null +++ b/packages/core/src/utilities/tagIndex.ts @@ -0,0 +1,71 @@ +/** + * Global (per-process), in-memory tag -> cache-key index. Pure and IO-free. + * + * Lets a cache entry be filed under one or more logical tags, independent of + * which auth scope owns the key, so a single tag invalidation reaches every + * scoped copy of a resource in one pass. Only ever maps to opaque cache + * *keys* — never exposes cached data across scopes; a tag lookup can only + * be used to delete entries, never to read another scope's cached data. + */ + +export interface TagIndex { + /** Record that `key` is filed under `tags`. */ + track(key: string, tags: string[]): void; + /** Stop tracking `key` (call on delete/evict so the index doesn't leak). */ + untrack(key: string): void; + /** Return every key currently filed under any of `tags`. */ + keysFor(tags: string[]): Set; + clear(): void; +} + +export function createTagIndex(): TagIndex { + const tagToKeys = new Map>(); + const keyToTags = new Map>(); + + return { + track(key: string, tags: string[]): void { + if (tags.length === 0) return; + let existing = keyToTags.get(key); + if (!existing) { + existing = new Set(); + keyToTags.set(key, existing); + } + for (const tag of tags) { + existing.add(tag); + let keys = tagToKeys.get(tag); + if (!keys) { + keys = new Set(); + tagToKeys.set(tag, keys); + } + keys.add(key); + } + }, + + untrack(key: string): void { + const tags = keyToTags.get(key); + if (!tags) return; + for (const tag of tags) { + const keys = tagToKeys.get(tag); + if (!keys) continue; + keys.delete(key); + if (keys.size === 0) tagToKeys.delete(tag); + } + keyToTags.delete(key); + }, + + keysFor(tags: string[]): Set { + const out = new Set(); + for (const tag of tags) { + const keys = tagToKeys.get(tag); + if (!keys) continue; + for (const key of keys) out.add(key); + } + return out; + }, + + clear(): void { + tagToKeys.clear(); + keyToTags.clear(); + }, + }; +}