first commit
This commit is contained in:
222
docs/security-stack/security/hibp/README.md
Normal file
222
docs/security-stack/security/hibp/README.md
Normal file
@@ -0,0 +1,222 @@
|
||||
# @suaveplan/hibp - Privacy-preserving Have I Been Pwned v3 client — k-anonymity range API, authenticated breach/paste API, LRU cache, differential-privacy mode
|
||||
|
||||
## Summary
|
||||
|
||||
Privacy-preserving [Have I Been Pwned](https://haveibeenpwned.com/) v3 client.
|
||||
Universal — works in Node, Bun, Deno, browsers, edge workers. The only
|
||||
required runtime primitive is `fetch`.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [breach-client](./src/breach/breach-client.md) — `BreachClient` wraps the four authenticated HIBP v3 endpoints: `/breachedaccount/{email}`, `/breach/{name}`, `/breaches…
|
||||
- [rate-limit-gate](./src/breach/rate-limit-gate.md) — `RateLimitGate` enforces the HIBP v3 client-side rate-limit contract of one request per 1500 ms across the authenticate…
|
||||
- [retry-after](./src/breach/retry-after.md) — `parseRetryAfter` parses the `Retry-After` HTTP header per RFC 9110 §10.2.3 and returns the indicated delay in millisec…
|
||||
- [cache](./src/cache/cache.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - lru — `LruHibpCache<V>` is the default cache for `H…
|
||||
- [cache](./src/cache/cache.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - lru — `LruHibpCache<V>` is the default cache for `H…
|
||||
- [lru](./src/cache/lru.md) — `LruHibpCache<V>` is the default cache for `HibpClient`. It holds up to `LRU_DEFAULT_CAPACITY` entries (10 000) with `L…
|
||||
- [null](./src/cache/null.md) — `NullHibpCache<V>` is the always-miss adapter used by `HibpClient` when the caller explicitly opts out of caching (`cac…
|
||||
- [client](./src/client/client.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - options.schema — `HibpClientOptionsSchema` is the Z…
|
||||
- [client](./src/client/client.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - options.schema — `HibpClientOptionsSchema` is the Z…
|
||||
- [options.schema](./src/client/options.schema.md) — `HibpClientOptionsSchema` is the Zod boundary for the `HibpClient` constructor. It is `.strict()` — unknown keys cause…
|
||||
- [codes](./src/errors/codes.md) — Spec-canonical path for the HIBP error-code registry. This file is a thin re-export of the top-level `src/error-codes.t…
|
||||
- [hibp-error](./src/errors/hibp-error.md) — `HibpError` is the structured error class every failure path inside `@suaveplan/hibp` throws. It carries one of the sev…
|
||||
- [observability](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [metrics](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [privacy](./src/privacy/assert-no-leak.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [assert-no-leak](./src/privacy/assert-no-leak.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [range](./src/range/range.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [constant-time-compare](./src/range/constant-time-compare.md) — `constantTimeEqualsHex` compares two hex suffixes without early-exit on the first mismatching character. It is used by…
|
||||
- [differential-privacy](./src/range/differential-privacy.md) — `padPrefixes` generates the decoy prefixes used by the differential-privacy mode of `checkPassword`. Given the real 5-h…
|
||||
- [range-mode](./src/range/range-mode.md) — Houses the hash-mode dispatch utilities used by `checkPassword`: `hashSha1Prefix` (SHA-1 via `@noble/hashes/sha1`), `ha…
|
||||
- [range-stream-parse](./src/range/range-stream-parse.md) — Streams an HTTP response body line-by-line through a `TextDecoderStream` and a newline framer, yielding `{ suffix, coun…
|
||||
- [range-strict-frame](./src/range/range-strict-frame.md) — `parseRangeLine` is the per-line framer used by `streamParseRangeBody`. It enforces the documented Pwned Passwords line…
|
||||
- [range](./src/range/range.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [breach.schema](./src/schemas/breach.schema.md) — `BreachSchema` is the Zod schema mirroring the HIBP v3 `Breach` payload. It validates every documented field, so the re…
|
||||
- [paste.schema](./src/schemas/paste.schema.md) — `PasteSchema` validates the HIBP v3 `Paste` payload returned by `/pasteaccount/{email}`. Older paste records can omit `…
|
||||
- [pwned-result.schema](./src/schemas/pwned-result.schema.md) — `PwnedResultSchema` validates the runtime shape of every `checkPassword` result. It is the final boundary between the i…
|
||||
- [error-codes](./src/error-codes.md) — Hosts the canonical `HIBP_CODES` registry — exactly seven error codes that classify every failure path inside `@suavepl…
|
||||
<!-- end:auto-docs --> - lru — `LruHibpCache<V>` is the default cache for `H…
|
||||
- [cache](./src/cache/cache.md) — <!-- begin:auto-docs --> _(no submodules)_- lru — `LruHibpCache<V>` is the default cache for `H…
|
||||
- [lru](./src/cache/lru.md) — `LruHibpCache<V>` is the default cache for `HibpClient`. It holds up to `LRU_DEFAULT_CAPACITY` entries (10 000) with `L…
|
||||
- [null](./src/cache/null.md) — `NullHibpCache<V>` is the always-miss adapter used by `HibpClient` when the caller explicitly opts out of caching (`cac…
|
||||
- [client](./src/client/client.md) — <!-- begin:auto-docs --> _(no submodules)_- options.schema — `HibpClientOptionsSchema` is the Z…
|
||||
- [client](./src/client/client.md) — <!-- begin:auto-docs --> _(no submodules)_- options.schema — `HibpClientOptionsSchema` is the Z…
|
||||
- [options.schema](./src/client/options.schema.md) — `HibpClientOptionsSchema` is the Zod boundary for the `HibpClient` constructor. It is `.strict()` — unknown keys cause…
|
||||
- [codes](./src/errors/codes.md) — Spec-canonical path for the HIBP error-code registry. This file is a thin re-export of the top-level `src/error-codes.t…
|
||||
- [hibp-error](./src/errors/hibp-error.md) — `HibpError` is the structured error class every failure path inside `@suaveplan/hibp` throws. It carries one of the sev…
|
||||
- [observability](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [metrics](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [privacy](./src/privacy/assert-no-leak.md) — <!-- begin:auto-docs --> _(no submodules)_- [assert-no-leak](./src/privacy/assert-no-leak.md) — <!-- begin:auto-docs --> _(no submodules)_- [range](./src/range/range.md) — <!-- begin:auto-docs --> _(no submodules)_- [constant-time-compare](./src/range/constant-time-compare.md) — `constantTimeEqualsHex` compares two hex suffixes without early-exit on the first mismatching character. It is used by…
|
||||
- [differential-privacy](./src/range/differential-privacy.md) — `padPrefixes` generates the decoy prefixes used by the differential-privacy mode of `checkPassword`. Given the real 5-h…
|
||||
- [range-mode](./src/range/range-mode.md) — Houses the hash-mode dispatch utilities used by `checkPassword`: `hashSha1Prefix` (SHA-1 via `@noble/hashes/sha1`), `ha…
|
||||
- [range-stream-parse](./src/range/range-stream-parse.md) — Streams an HTTP response body line-by-line through a `TextDecoderStream` and a newline framer, yielding `{ suffix, coun…
|
||||
- [range-strict-frame](./src/range/range-strict-frame.md) — `parseRangeLine` is the per-line framer used by `streamParseRangeBody`. It enforces the documented Pwned Passwords line…
|
||||
- [range](./src/range/range.md) — <!-- begin:auto-docs --> _(no submodules)_- [breach.schema](./src/schemas/breach.schema.md) — `BreachSchema` is the Zod schema mirroring the HIBP v3 `Breach` payload. It validates every documented field, so the re…
|
||||
- [paste.schema](./src/schemas/paste.schema.md) — `PasteSchema` validates the HIBP v3 `Paste` payload returned by `/pasteaccount/{email}`. Older paste records can omit `…
|
||||
- [pwned-result.schema](./src/schemas/pwned-result.schema.md) — `PwnedResultSchema` validates the runtime shape of every `checkPassword` result. It is the final boundary between the i…
|
||||
- [error-codes](./src/error-codes.md) — Hosts the canonical `HIBP_CODES` registry — exactly seven error codes that classify every failure path inside `@suavepl…
|
||||
|
||||
- lru — `LruHibpCache<V>` is the default cache for `H…
|
||||
|
||||
- [cache](./src/cache/cache.md) — <!-- begin:auto-docs --> _(no submodules)_- lru — `LruHibpCache<V>` is the default cache for `H…
|
||||
- [lru](./src/cache/lru.md) — `LruHibpCache<V>` is the default cache for `HibpClient`. It holds up to `LRU_DEFAULT_CAPACITY` entries (10 000) with `L…
|
||||
- [null](./src/cache/null.md) — `NullHibpCache<V>` is the always-miss adapter used by `HibpClient` when the caller explicitly opts out of caching (`cac…
|
||||
- [client](./src/client/client.md) — <!-- begin:auto-docs --> _(no submodules)_- options.schema — `HibpClientOptionsSchema` is the Z…
|
||||
- [client](./src/client/client.md) — <!-- begin:auto-docs --> _(no submodules)_- options.schema — `HibpClientOptionsSchema` is the Z…
|
||||
- [options.schema](./src/client/options.schema.md) — `HibpClientOptionsSchema` is the Zod boundary for the `HibpClient` constructor. It is `.strict()` — unknown keys cause…
|
||||
- [codes](./src/errors/codes.md) — Spec-canonical path for the HIBP error-code registry. This file is a thin re-export of the top-level `src/error-codes.t…
|
||||
- [hibp-error](./src/errors/hibp-error.md) — `HibpError` is the structured error class every failure path inside `@suaveplan/hibp` throws. It carries one of the sev…
|
||||
- [observability](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [metrics](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [privacy](./src/privacy/assert-no-leak.md) — <!-- begin:auto-docs --> _(no submodules)_- [assert-no-leak](./src/privacy/assert-no-leak.md) — <!-- begin:auto-docs --> _(no submodules)_- [range](./src/range/range.md) — <!-- begin:auto-docs --> _(no submodules)_- [constant-time-compare](./src/range/constant-time-compare.md) — `constantTimeEqualsHex` compares two hex suffixes without early-exit on the first mismatching character. It is used by…
|
||||
- [differential-privacy](./src/range/differential-privacy.md) — `padPrefixes` generates the decoy prefixes used by the differential-privacy mode of `checkPassword`. Given the real 5-h…
|
||||
- [range-mode](./src/range/range-mode.md) — Houses the hash-mode dispatch utilities used by `checkPassword`: `hashSha1Prefix` (SHA-1 via `@noble/hashes/sha1`), `ha…
|
||||
- [range-stream-parse](./src/range/range-stream-parse.md) — Streams an HTTP response body line-by-line through a `TextDecoderStream` and a newline framer, yielding `{ suffix, coun…
|
||||
- [range-strict-frame](./src/range/range-strict-frame.md) — `parseRangeLine` is the per-line framer used by `streamParseRangeBody`. It enforces the documented Pwned Passwords line…
|
||||
- [range](./src/range/range.md) — <!-- begin:auto-docs --> _(no submodules)_- [breach.schema](./src/schemas/breach.schema.md) — `BreachSchema` is the Zod schema mirroring the HIBP v3 `Breach` payload. It validates every documented field, so the re…
|
||||
- [paste.schema](./src/schemas/paste.schema.md) — `PasteSchema` validates the HIBP v3 `Paste` payload returned by `/pasteaccount/{email}`. Older paste records can omit `…
|
||||
- [pwned-result.schema](./src/schemas/pwned-result.schema.md) — `PwnedResultSchema` validates the runtime shape of every `checkPassword` result. It is the final boundary between the i…
|
||||
- [error-codes](./src/error-codes.md) — Hosts the canonical `HIBP_CODES` registry — exactly seven error codes that classify every failure path inside `@suavepl…
|
||||
|
||||
- lru — `LruHibpCache<V>` is the default cache for `H…
|
||||
|
||||
- [cache](./src/cache/cache.md) — <!-- begin:auto-docs --> _(no submodules)_- lru — `LruHibpCache<V>` is the default cache for `H…
|
||||
- [lru](./src/cache/lru.md) — `LruHibpCache<V>` is the default cache for `HibpClient`. It holds up to `LRU_DEFAULT_CAPACITY` entries (10 000) with `L…
|
||||
- [null](./src/cache/null.md) — `NullHibpCache<V>` is the always-miss adapter used by `HibpClient` when the caller explicitly opts out of caching (`cac…
|
||||
- [client](./src/client/client.md) — <!-- begin:auto-docs --> _(no submodules)_- options.schema — `HibpClientOptionsSchema` is the Z…
|
||||
- [client](./src/client/client.md) — <!-- begin:auto-docs --> _(no submodules)_- options.schema — `HibpClientOptionsSchema` is the Z…
|
||||
- [options.schema](./src/client/options.schema.md) — `HibpClientOptionsSchema` is the Zod boundary for the `HibpClient` constructor. It is `.strict()` — unknown keys cause…
|
||||
- [codes](./src/errors/codes.md) — Spec-canonical path for the HIBP error-code registry. This file is a thin re-export of the top-level `src/error-codes.t…
|
||||
- [hibp-error](./src/errors/hibp-error.md) — `HibpError` is the structured error class every failure path inside `@suaveplan/hibp` throws. It carries one of the sev…
|
||||
- [observability](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [metrics](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [privacy](./src/privacy/assert-no-leak.md) — <!-- begin:auto-docs --> _(no submodules)_- [assert-no-leak](./src/privacy/assert-no-leak.md) — <!-- begin:auto-docs --> _(no submodules)_- [range](./src/range/range.md) — <!-- begin:auto-docs --> _(no submodules)_- [constant-time-compare](./src/range/constant-time-compare.md) — `constantTimeEqualsHex` compares two hex suffixes without early-exit on the first mismatching character. It is used by…
|
||||
- [differential-privacy](./src/range/differential-privacy.md) — `padPrefixes` generates the decoy prefixes used by the differential-privacy mode of `checkPassword`. Given the real 5-h…
|
||||
- [range-mode](./src/range/range-mode.md) — Houses the hash-mode dispatch utilities used by `checkPassword`: `hashSha1Prefix` (SHA-1 via `@noble/hashes/sha1`), `ha…
|
||||
- [range-stream-parse](./src/range/range-stream-parse.md) — Streams an HTTP response body line-by-line through a `TextDecoderStream` and a newline framer, yielding `{ suffix, coun…
|
||||
- [range-strict-frame](./src/range/range-strict-frame.md) — `parseRangeLine` is the per-line framer used by `streamParseRangeBody`. It enforces the documented Pwned Passwords line…
|
||||
- [range](./src/range/range.md) — <!-- begin:auto-docs --> _(no submodules)_- [breach.schema](./src/schemas/breach.schema.md) — `BreachSchema` is the Zod schema mirroring the HIBP v3 `Breach` payload. It validates every documented field, so the re…
|
||||
- [paste.schema](./src/schemas/paste.schema.md) — `PasteSchema` validates the HIBP v3 `Paste` payload returned by `/pasteaccount/{email}`. Older paste records can omit `…
|
||||
- [pwned-result.schema](./src/schemas/pwned-result.schema.md) — `PwnedResultSchema` validates the runtime shape of every `checkPassword` result. It is the final boundary between the i…
|
||||
- [error-codes](./src/error-codes.md) — Hosts the canonical `HIBP_CODES` registry — exactly seven error codes that classify every failure path inside `@suavepl…
|
||||
- [cache](./src/cache/cache.md) — <!-- begin:auto-docs --> _(no submodules)_- [lru](./src/cache/lru.md) — `LruHibpCache<V>` is the default cache for `HibpClient`. It holds up to `LRU_DEFAULT_CAPACITY` entries (10 000) with `L…
|
||||
- [null](./src/cache/null.md) — `NullHibpCache<V>` is the always-miss adapter used by `HibpClient` when the caller explicitly opts out of caching (`cac…
|
||||
- [client](./src/client/client.md) — <!-- begin:auto-docs --> _(no submodules)_ - [client](./src/client/client.md) — <!-- begin:auto-docs --> _(no submodules)_ - [options.schema](./src/client/options.schema.md) — `HibpClientOptionsSchema` is the Zod boundary for the `HibpClient` constructor. It is `.strict()` — unknown keys cause…
|
||||
- [codes](./src/errors/codes.md) — Spec-canonical path for the HIBP error-code registry. This file is a thin re-export of the top-level `src/error-codes.t…
|
||||
- [hibp-error](./src/errors/hibp-error.md) — `HibpError` is the structured error class every failure path inside `@suaveplan/hibp` throws. It carries one of the sev…
|
||||
- [observability](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_ - [metrics](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [privacy](./src/privacy/assert-no-leak.md) — <!-- begin:auto-docs --> _(no submodules)_ - [assert-no-leak](./src/privacy/assert-no-leak.md) — <!-- begin:auto-docs --> _(no submodules)_- [range](./src/range/range.md) — <!-- begin:auto-docs --> _(no submodules)_ - [constant-time-compare](./src/range/constant-time-compare.md) — `constantTimeEqualsHex` compares two hex suffixes without early-exit on the first mismatching character. It is used by…
|
||||
- [differential-privacy](./src/range/differential-privacy.md) — `padPrefixes` generates the decoy prefixes used by the differential-privacy mode of `checkPassword`. Given the real 5-h…
|
||||
- [range-mode](./src/range/range-mode.md) — Houses the hash-mode dispatch utilities used by `checkPassword`: `hashSha1Prefix` (SHA-1 via `@noble/hashes/sha1`), `ha…
|
||||
- [range-stream-parse](./src/range/range-stream-parse.md) — Streams an HTTP response body line-by-line through a `TextDecoderStream` and a newline framer, yielding `{ suffix, coun…
|
||||
- [range-strict-frame](./src/range/range-strict-frame.md) — `parseRangeLine` is the per-line framer used by `streamParseRangeBody`. It enforces the documented Pwned Passwords line…
|
||||
- [range](./src/range/range.md) — <!-- begin:auto-docs --> _(no submodules)_- [breach.schema](./src/schemas/breach.schema.md) — `BreachSchema` is the Zod schema mirroring the [HIBP v3 `Breach` payload](https://haveibeenpwned.com/API/v3#Breach). It…
|
||||
- [paste.schema](./src/schemas/paste.schema.md) — `PasteSchema` validates the [HIBP v3 `Paste` payload](https://haveibeenpwned.com/API/v3#PastesForAccount) returned by `…
|
||||
- [pwned-result.schema](./src/schemas/pwned-result.schema.md) — `PwnedResultSchema` validates the runtime shape of every `checkPassword` result. It is the final boundary between the i…
|
||||
- [error-codes](./src/error-codes.md) — Hosts the canonical `HIBP_CODES` registry — exactly seven error codes that classify every failure path inside `@suavepl…
|
||||
|
||||
## Privacy contract
|
||||
|
||||
This package treats the password and its SHA-1 digest as secrets that must
|
||||
never leave the calling process.
|
||||
|
||||
- Only the **first five hex characters** of the SHA-1 digest are transmitted
|
||||
to the Pwned Passwords range API ([k-anonymity](https://haveibeenpwned.com/API/v3#PwnedPasswords)).
|
||||
- Every range request carries the mandatory `Add-Padding: true` header so
|
||||
Cloudflare appends decoy lines, defeating response-size correlation
|
||||
attacks.
|
||||
- The full password, the full hash, the API key, and the email are never
|
||||
logged and never appear in any thrown error's serialized form.
|
||||
- An opt-in `assertOutgoingRequestSafe: true` switch wraps every outgoing
|
||||
fetch with a byte-by-byte leak check so callers can catch a regression
|
||||
before it lands in production.
|
||||
|
||||
If any of those invariants ever break, that is a defect, not a feature
|
||||
request.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/hibp
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
### Check a single password against the range API
|
||||
|
||||
```ts
|
||||
import { HibpClient } from "@suaveplan/hibp";
|
||||
|
||||
const client = new HibpClient();
|
||||
const result = await client.checkPassword("hunter2");
|
||||
// { pwned: true, count: 23_597 }
|
||||
```
|
||||
|
||||
### Pull the breach list for an account
|
||||
|
||||
```ts
|
||||
import { HibpClient } from "@suaveplan/hibp";
|
||||
|
||||
const client = new HibpClient({ apiKey: process.env.HIBP_API_KEY! });
|
||||
const breaches = await client.getBreachesForAccount("user@example.com");
|
||||
// Breach[]
|
||||
```
|
||||
|
||||
## Differential-privacy mode
|
||||
|
||||
For deployments that want belt-and-braces defence on top of the standard
|
||||
`Add-Padding` padding, opt in to decoy prefixes. Every `checkPassword` call
|
||||
will dispatch the real 5-hex prefix plus N additional random sibling
|
||||
prefixes; only the real one's response is consulted.
|
||||
|
||||
```ts
|
||||
const client = new HibpClient({
|
||||
differentialPrivacy: { extraPrefixes: 3 },
|
||||
rng: myInjectableRng,
|
||||
});
|
||||
```
|
||||
|
||||
Trade-offs:
|
||||
|
||||
- **Adds N extra round trips per check.** Default off because the
|
||||
`Add-Padding` header already defeats the documented correlation attack
|
||||
for almost every deployment.
|
||||
- **Decoy responses are discarded** but still count against any caching
|
||||
layer's bandwidth budget. Tune `extraPrefixes` to match the operational
|
||||
cost.
|
||||
|
||||
## Modules
|
||||
|
||||
| Path | Purpose |
|
||||
| --- | --- |
|
||||
| [`src/client/client.md`](src/client/client.md) | `HibpClient` façade orchestrating range + breach + cache + observability |
|
||||
| [`src/client/options.schema.md`](src/client/options.schema.md) | Zod boundary for constructor options |
|
||||
| [`src/range/range.md`](src/range/range.md) | k-anonymity range API client |
|
||||
| [`src/range/range-mode.md`](src/range/range-mode.md) | SHA-1 / NTLM mode dispatch |
|
||||
| [`src/range/range-stream-parse.md`](src/range/range-stream-parse.md) | Streaming line parser |
|
||||
| [`src/range/range-strict-frame.md`](src/range/range-strict-frame.md) | Strict-mode line framing |
|
||||
| [`src/range/constant-time-compare.md`](src/range/constant-time-compare.md) | Constant-time suffix compare |
|
||||
| [`src/range/differential-privacy.md`](src/range/differential-privacy.md) | Decoy prefix generator |
|
||||
| [`src/breach/breach-client.md`](src/breach/breach-client.md) | Authenticated breach + paste API client |
|
||||
| [`src/breach/rate-limit-gate.md`](src/breach/rate-limit-gate.md) | 1500 ms client-side rate-limit gate |
|
||||
| [`src/breach/retry-after.md`](src/breach/retry-after.md) | RFC 9110 `Retry-After` parser |
|
||||
| [`src/cache/cache.md`](src/cache/cache.md) | `HibpCacheLike` interface |
|
||||
| [`src/cache/lru.md`](src/cache/lru.md) | Default LRU cache (10 000 / 24 h) |
|
||||
| [`src/cache/null.md`](src/cache/null.md) | Always-miss cache adapter |
|
||||
| [`src/errors/codes.md`](src/errors/codes.md) | Seven-code error registry |
|
||||
| [`src/errors/hibp-error.md`](src/errors/hibp-error.md) | `HibpError` base class |
|
||||
| [`src/error-codes.md`](src/error-codes.md) | Top-level code registration |
|
||||
| [`src/observability/metrics.md`](src/observability/metrics.md) | OTel histograms + counters |
|
||||
| [`src/privacy/assert-no-leak.md`](src/privacy/assert-no-leak.md) | Outgoing-request leak detector |
|
||||
| [`src/schemas/breach.schema.md`](src/schemas/breach.schema.md) | `BreachSchema` Zod schema |
|
||||
| [`src/schemas/paste.schema.md`](src/schemas/paste.schema.md) | `PasteSchema` Zod schema |
|
||||
| [`src/schemas/pwned-result.schema.md`](src/schemas/pwned-result.schema.md) | `PwnedResultSchema` Zod schema |
|
||||
|
||||
## Benchmarks
|
||||
|
||||
| Benchmark | Description |
|
||||
| --- | --- |
|
||||
| `range-cold` | Cold range fetch + parse |
|
||||
| `range-warm-cache` | LRU hit path |
|
||||
| `range-parse-30kib` | Streaming parser throughput on a 30 KiB padded fixture |
|
||||
|
||||
Run with `bun run benchmark` (see `src/__benchmarks__/`).
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
# `breach/breach-client.ts`
|
||||
|
||||
## Purpose
|
||||
|
||||
`BreachClient` wraps the four authenticated HIBP v3 endpoints:
|
||||
`/breachedaccount/{email}`, `/breach/{name}`, `/breaches`, and
|
||||
`/pasteaccount/{email}`. It owns the api-key header, the 1500 ms
|
||||
client-side rate-limit gate, the 429-retry loop with `Retry-After`
|
||||
parsing, and the Zod response validation. The api key is sourced
|
||||
strictly from constructor options — never `process.env`, never a
|
||||
module-scope variable — and never appears in any thrown error's
|
||||
serialized form.
|
||||
|
||||
## Features
|
||||
|
||||
- All four authenticated endpoints with documented URL construction,
|
||||
query parameters, and headers.
|
||||
- Email path segment URL-encoded; spec-conformance tests assert no
|
||||
literal email in any outgoing URL outside `/breachedaccount` and
|
||||
`/pasteaccount`.
|
||||
- 1500 ms rate-limit gate enforced via `RateLimitGate` (injectable
|
||||
clock, injectable sleep).
|
||||
- 429 retry honouring `Retry-After` (delta-seconds or HTTP-date),
|
||||
capped at 60 s; exponential fallback when the header is missing.
|
||||
- HTTP status → typed code mapping: 401/403 → UNAUTHORIZED, 404 →
|
||||
NOT_FOUND (or `[]` for list endpoints), 413 → PAYLOAD_TOO_LARGE, 429
|
||||
→ RATE_LIMITED (after exhaustion), 500+ → NETWORK_ERROR.
|
||||
- AbortSignal threaded into fetch RequestInit and checked before every
|
||||
gate acquisition.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { BreachClient } from "@suaveplan/hibp";
|
||||
|
||||
const client = new BreachClient({ apiKey: "k_test" });
|
||||
const breaches = await client.getBreachesForAccount("user@example.com");
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { BreachClient, RateLimitGate } from "@suaveplan/hibp";
|
||||
|
||||
const gate = new RateLimitGate({ minGapMs: 1500 });
|
||||
const client = new BreachClient({
|
||||
apiKey: process.env.HIBP_API_KEY!,
|
||||
gate,
|
||||
maxAttempts: 5,
|
||||
});
|
||||
|
||||
const adobe = await client.getBreach("Adobe");
|
||||
const adobeBreaches = await client.getAllBreaches({ domain: "adobe.com" });
|
||||
const pastes = await client.getPastesForAccount("user@example.com");
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `BreachClientOptions` — `apiKey`, `fetchFn?`, `clock?`, `gate?`,
|
||||
`logger?`, `maxAttempts?`, `sleep?`.
|
||||
- `BreachCallOptions` — `signal?`.
|
||||
- `GetAllBreachesOptions extends BreachCallOptions` — `domain?`.
|
||||
- `getBreachesForAccount(email, opts?): Promise<Breach[]>`.
|
||||
- `getBreach(name, opts?): Promise<Breach>`.
|
||||
- `getAllBreaches(opts?): Promise<Breach[]>`.
|
||||
- `getPastesForAccount(email, opts?): Promise<Paste[]>`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The client uses a tight retry loop rather than `@suaveplan/retry`
|
||||
directly so the `Retry-After` parsing stays first-class — `retry`'s
|
||||
strategy callbacks don't know about HTTP headers. The 429 exhaustion
|
||||
path throws `HIBP.RATE_LIMITED` rather than the underlying retry
|
||||
error so callers can branch on a single, stable code. JSON-array
|
||||
endpoints share a `fetchJsonArray` helper that validates every entry
|
||||
through the supplied Zod schema; a single bad entry fails the whole
|
||||
response, by design. The 404 fast-path on list endpoints converts a
|
||||
missing-record response to `[]` per the HIBP contract.
|
||||
@@ -0,0 +1,68 @@
|
||||
# `breach/rate-limit-gate.ts`
|
||||
|
||||
## Purpose
|
||||
|
||||
`RateLimitGate` enforces the [HIBP v3 client-side rate-limit
|
||||
contract](https://haveibeenpwned.com/API/v3#RateLimiting) of one
|
||||
request per 1500 ms across the authenticated breach + paste endpoints.
|
||||
It runs against an injectable `Clock` so deterministic tests can advance
|
||||
virtual time without ever sleeping in wall-clock. Every gate-induced
|
||||
wait increments the `hibp.rate_limit_throttled_total` counter so
|
||||
operators can spot saturated workloads in dashboards.
|
||||
|
||||
## Features
|
||||
|
||||
- Default `minGapMs` of 1500 ms — matches `RATE_LIMIT_MIN_GAP_MS`.
|
||||
- Injectable `clock` and `sleep` callbacks for deterministic testing.
|
||||
- `acquire(signal?)` resolves immediately if the gap has elapsed;
|
||||
otherwise awaits the sleep callback for the remaining delay.
|
||||
- `reset()` clears state so test cases share a fresh gate without
|
||||
reconstruction.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { RateLimitGate } from "@suaveplan/hibp";
|
||||
|
||||
const gate = new RateLimitGate();
|
||||
await gate.acquire();
|
||||
// ... breach fetch
|
||||
await gate.acquire();
|
||||
// waits 1500 ms
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { RateLimitGate } from "@suaveplan/hibp";
|
||||
|
||||
let now = 0;
|
||||
const gate = new RateLimitGate({
|
||||
clock: () => now,
|
||||
sleep: async (ms) => {
|
||||
now += ms;
|
||||
},
|
||||
});
|
||||
await gate.acquire();
|
||||
now = 500;
|
||||
await gate.acquire(); // sleeps 1000 in virtual time
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `RATE_LIMIT_MIN_GAP_MS = 1500`.
|
||||
- `new RateLimitGate(options?)` — `minGapMs`, `clock`, `sleep`.
|
||||
- `gate.acquire(signal?): Promise<void>`.
|
||||
- `gate.reset(): void`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The gate is intentionally not concurrent-safe: HIBP's contract is a
|
||||
client-wide 1500 ms gap, and a single gate instance serialises all
|
||||
authenticated calls. Sharing one gate across the entire process is the
|
||||
documented integration pattern. The `signal` argument is checked
|
||||
post-sleep — the gate itself does not throw on abort, leaving that
|
||||
decision to the caller's error-mapping layer. The throttled counter
|
||||
fires only when a wait actually occurred; warming-up calls that fall
|
||||
outside the window do not register, so dashboard graphs reflect true
|
||||
operational pressure rather than benign clock drift.
|
||||
56
docs/security-stack/security/hibp/src/breach/retry-after.md
Normal file
56
docs/security-stack/security/hibp/src/breach/retry-after.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# `breach/retry-after.ts`
|
||||
|
||||
## Purpose
|
||||
|
||||
`parseRetryAfter` parses the `Retry-After` HTTP header per RFC 9110
|
||||
§10.2.3 and returns the indicated delay in milliseconds, capped at
|
||||
`RETRY_AFTER_CAP_MS` (60 000 ms). The breach client calls into it on
|
||||
every HTTP 429 response so the retry loop honours either the
|
||||
delta-seconds form (`"5"`) or the HTTP-date form (`"Wed, 21 Oct 2020
|
||||
07:28:00 GMT"`) without forcing every server team to standardise on one.
|
||||
|
||||
## Features
|
||||
|
||||
- Delta-seconds form: integer-only, multiplied by 1000.
|
||||
- HTTP-date form: parsed via `Date.parse`; clamped to 0 ms if the date
|
||||
is in the past, capped at 60 000 ms otherwise.
|
||||
- Returns `undefined` on missing or unparseable header so the caller
|
||||
falls back to exponential backoff.
|
||||
- Constant `RETRY_AFTER_CAP_MS` exported alongside for callers that need
|
||||
to enforce the same cap elsewhere.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { parseRetryAfter } from "@suaveplan/hibp";
|
||||
|
||||
const parsed = parseRetryAfter("5", Date.now());
|
||||
// { delayMs: 5_000 }
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { parseRetryAfter, RETRY_AFTER_CAP_MS } from "@suaveplan/hibp";
|
||||
|
||||
function effectiveDelay(header: string | null, attempt: number, now: number): number {
|
||||
const parsed = parseRetryAfter(header, now);
|
||||
if (parsed !== undefined) return parsed.delayMs;
|
||||
return Math.min(2 ** attempt * 1000, RETRY_AFTER_CAP_MS);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `RETRY_AFTER_CAP_MS = 60_000`.
|
||||
- `parseRetryAfter(headerValue, now): { delayMs: number } | undefined`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The cap is non-negotiable — the spec says no retry may sleep more than
|
||||
60 seconds, regardless of how generous the server's hint is. Returning
|
||||
`undefined` for unparseable input rather than throwing keeps the breach
|
||||
client's loop simple: the parser is a hint, not an error source. The
|
||||
HTTP-date branch uses the standard library's `Date.parse`, which
|
||||
accepts RFC 1123, RFC 850, and ANSI C `asctime()` formats — all three
|
||||
forms named in RFC 9110.
|
||||
94
docs/security-stack/security/hibp/src/cache/cache.md
vendored
Normal file
94
docs/security-stack/security/hibp/src/cache/cache.md
vendored
Normal file
@@ -0,0 +1,94 @@
|
||||
# `cache/cache.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs --> - lru — `LruHibpCache<V>` is the default cache for `H…
|
||||
- [lru](./lru.md) — `LruHibpCache<V>` is the default cache for `HibpClient`. It holds up to `LRU_DEFAULT_CAPACITY` entries (10 000) with `L…
|
||||
- [null](./null.md) — `NullHibpCache<V>` is the always-miss adapter used by `HibpClient` when the caller explicitly opts out of caching (`cac…
|
||||
- lru — `LruHibpCache<V>` is the default cache for `H…
|
||||
|
||||
- [lru](./lru.md) — `LruHibpCache<V>` is the default cache for `HibpClient`. It holds up to `LRU_DEFAULT_CAPACITY` entries (10 000) with `L…
|
||||
- [null](./null.md) — `NullHibpCache<V>` is the always-miss adapter used by `HibpClient` when the caller explicitly opts out of caching (`cac…
|
||||
|
||||
- lru — `LruHibpCache<V>` is the default cache for `H…
|
||||
|
||||
- [lru](./lru.md) — `LruHibpCache<V>` is the default cache for `HibpClient`. It holds up to `LRU_DEFAULT_CAPACITY` entries (10 000) with `L…
|
||||
- [null](./null.md) — `NullHibpCache<V>` is the always-miss adapter used by `HibpClient` when the caller explicitly opts out of caching (`cac…
|
||||
|
||||
- lru — `LruHibpCache<V>` is the default cache for `H…
|
||||
|
||||
- [lru](./lru.md) — `LruHibpCache<V>` is the default cache for `HibpClient`. It holds up to `LRU_DEFAULT_CAPACITY` entries (10 000) with `L…
|
||||
- [null](./null.md) — `NullHibpCache<V>` is the always-miss adapter used by `HibpClient` when the caller explicitly opts out of caching (`cac…
|
||||
- [lru](./lru.md) — `LruHibpCache<V>` is the default cache for `HibpClient`. It holds up to `LRU_DEFAULT_CAPACITY` entries (10 000) with `L…
|
||||
- [null](./null.md) — `NullHibpCache<V>` is the always-miss adapter used by `HibpClient` when the caller explicitly opts out of caching (`cac…
|
||||
|
||||
## Purpose
|
||||
|
||||
Declares the `HibpCacheLike<V>` interface — the universal contract every
|
||||
cache backend implements. The `HibpClient` orchestrator is parametric in
|
||||
this type, so swapping `LruHibpCache` for a Redis-backed adapter is a
|
||||
constructor option, not a refactor. Living at this layer means downstream
|
||||
storage choices stay isolated from the range-API logic.
|
||||
|
||||
## Features
|
||||
|
||||
- Four-method surface: `get`, `set`, `delete`, `size`.
|
||||
- Generic over value type `V` so a cache of `PwnedResult`, a cache of raw
|
||||
range bodies, and a cache of typed `Breach[]` all use the same shape.
|
||||
- Synchronous semantics — async backends adapt by buffering or by
|
||||
resolving the cache outside the request critical path.
|
||||
- Zero runtime — purely a TypeScript interface.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { HibpCacheLike } from "@suaveplan/hibp";
|
||||
|
||||
class MapCache implements HibpCacheLike<{ pwned: boolean; count: number }> {
|
||||
private store = new Map<string, { pwned: boolean; count: number }>();
|
||||
get(k: string) { return this.store.get(k); }
|
||||
set(k: string, v: { pwned: boolean; count: number }) { this.store.set(k, v); }
|
||||
delete(k: string) { return this.store.delete(k); }
|
||||
get size() { return this.store.size; }
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type { HibpCacheLike } from "@suaveplan/hibp";
|
||||
|
||||
export class CountingCache<V> implements HibpCacheLike<V> {
|
||||
public hits = 0;
|
||||
public misses = 0;
|
||||
public constructor(private inner: HibpCacheLike<V>) {}
|
||||
get(k: string) {
|
||||
const v = this.inner.get(k);
|
||||
if (v === undefined) this.misses++;
|
||||
else this.hits++;
|
||||
return v;
|
||||
}
|
||||
set(k: string, v: V) { this.inner.set(k, v); }
|
||||
delete(k: string) { return this.inner.delete(k); }
|
||||
get size() { return this.inner.size; }
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `HibpCacheLike<V>` — interface with `get`, `set`, `delete`, `size`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The interface is synchronous on purpose: every documented HIBP cache
|
||||
backend is in-memory or microsecond-bound, so the simpler shape is worth
|
||||
it. If a future use case requires an async cache, the `HibpClient` will
|
||||
need a parallel `HibpAsyncCacheLike` rather than mutating this contract
|
||||
and breaking every existing implementation. The `size` field is
|
||||
deliberately a getter rather than a method so adapters wrapping an
|
||||
underlying `Map` can return the map's `size` directly; method-style
|
||||
would force an extra function call per probe. Consumers wiring exotic
|
||||
backends — Redis, IndexedDB, OPFS — typically wrap their async client
|
||||
in a write-through cache that buffers writes and lazily refreshes
|
||||
reads, exposing this synchronous shape to `HibpClient` while preserving
|
||||
durability behind the scenes.
|
||||
65
docs/security-stack/security/hibp/src/cache/lru.md
vendored
Normal file
65
docs/security-stack/security/hibp/src/cache/lru.md
vendored
Normal file
@@ -0,0 +1,65 @@
|
||||
# `cache/lru.ts`
|
||||
|
||||
## Purpose
|
||||
|
||||
`LruHibpCache<V>` is the default cache for `HibpClient`. It holds up to
|
||||
`LRU_DEFAULT_CAPACITY` entries (10 000) with `LRU_DEFAULT_TTL_MS` (24 h)
|
||||
expiry, keyed by the uppercase 5-hex SHA-1 prefix. The TTL clock is the
|
||||
injectable `Clock` from `@suaveplan/types` so tests can advance virtual
|
||||
time without timers. Eviction policy is least-recently-used at capacity
|
||||
plus eager TTL eviction on read.
|
||||
|
||||
## Features
|
||||
|
||||
- O(1) `get`/`set`/`delete` over a `Map<string, CacheEntry<V>>` with
|
||||
recency tracked by insertion order — re-inserting on read bumps the
|
||||
key to the tail.
|
||||
- TTL eviction at access time: stale entries are dropped and reported as
|
||||
cache misses; no background timer is required.
|
||||
- Constructor-time overrides for `capacity`, `ttlMs`, and `clock` so the
|
||||
same class powers production caches, deterministic tests, and
|
||||
experimental tunings.
|
||||
- Key normalisation to uppercase hex on every operation — case-insensitive
|
||||
reads and writes share the same slot.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { LruHibpCache } from "@suaveplan/hibp";
|
||||
|
||||
const cache = new LruHibpCache<{ pwned: boolean; count: number }>();
|
||||
cache.set("F3BBB", { pwned: true, count: 1 });
|
||||
cache.get("f3bbb"); // case-insensitive hit
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { LruHibpCache } from "@suaveplan/hibp";
|
||||
|
||||
let now = 0;
|
||||
const cache = new LruHibpCache<number>({
|
||||
capacity: 50,
|
||||
ttlMs: 5_000,
|
||||
clock: () => now,
|
||||
});
|
||||
cache.set("A", 1);
|
||||
now = 4_999;
|
||||
cache.get("A"); // 1
|
||||
now = 5_001;
|
||||
cache.get("A"); // undefined (TTL-evicted)
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new LruHibpCache<V>(options?)` — `capacity`, `ttlMs`, `clock`.
|
||||
- `LRU_DEFAULT_CAPACITY = 10_000`.
|
||||
- `LRU_DEFAULT_TTL_MS = 24 * 60 * 60 * 1000`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Insertion-order tracking via JavaScript `Map` is the canonical O(1) LRU
|
||||
implementation in pure JS. The class avoids `setInterval`-based eviction
|
||||
because that would require timer cleanup on disposal and add jitter to
|
||||
TTL behaviour; on-read TTL is cheap and deterministic. The capacity loop
|
||||
guards against `capacity: 0` by evicting immediately after the insert.
|
||||
54
docs/security-stack/security/hibp/src/cache/null.md
vendored
Normal file
54
docs/security-stack/security/hibp/src/cache/null.md
vendored
Normal file
@@ -0,0 +1,54 @@
|
||||
# `cache/null.ts`
|
||||
|
||||
## Purpose
|
||||
|
||||
`NullHibpCache<V>` is the always-miss adapter used by `HibpClient` when
|
||||
the caller explicitly opts out of caching (`cache: null`). It satisfies
|
||||
the `HibpCacheLike<V>` shape so the orchestrator code stays branch-free —
|
||||
every cache call goes through the same interface, but the null adapter
|
||||
makes every lookup a miss and every store a no-op.
|
||||
|
||||
## Features
|
||||
|
||||
- `get` always returns `undefined`.
|
||||
- `set` is a no-op.
|
||||
- `delete` always returns `false`.
|
||||
- `size` is always `0`.
|
||||
- Zero allocation per call — instances are stateless beyond the JS class
|
||||
identity.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { HibpClient, NullHibpCache } from "@suaveplan/hibp";
|
||||
|
||||
const client = new HibpClient({ cache: new NullHibpCache() });
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { HibpClient } from "@suaveplan/hibp";
|
||||
|
||||
// Shorthand: passing `null` resolves to NullHibpCache internally.
|
||||
const client = new HibpClient({ cache: null });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new NullHibpCache<V>()` — generic, no options.
|
||||
- All `HibpCacheLike<V>` methods present and conformant.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Using a dedicated class instead of an inline conditional avoids a
|
||||
property branch on every `HibpClient.checkPassword` call. It also keeps
|
||||
the public type system honest: a `HibpCacheLike` is always a real cache,
|
||||
never `undefined`. Callers who want caching disabled make that intention
|
||||
explicit by constructing or asking for `NullHibpCache`. The class is
|
||||
generic so consumers of typed cache values (e.g. `Breach[]` caches
|
||||
layered on top of the package) still get the right type for their
|
||||
`get()` returns when they inject a `NullHibpCache<Breach[]>` instead.
|
||||
Tests benefit too — running `checkPassword` against a `NullHibpCache`
|
||||
guarantees every call exercises the full fetch+parse path, with no
|
||||
hidden cache hits hiding behaviour bugs.
|
||||
97
docs/security-stack/security/hibp/src/client/client.md
Normal file
97
docs/security-stack/security/hibp/src/client/client.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# `client/client.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs --> - options.schema — `HibpClientOptionsSchema` is the Z…
|
||||
- [options.schema](./options.schema.md) — `HibpClientOptionsSchema` is the Zod boundary for the `HibpClient` constructor. It is `.strict()` — unknown keys cause…
|
||||
- options.schema — `HibpClientOptionsSchema` is the Z…
|
||||
|
||||
- [options.schema](./options.schema.md) — `HibpClientOptionsSchema` is the Zod boundary for the `HibpClient` constructor. It is `.strict()` — unknown keys cause…
|
||||
|
||||
- options.schema — `HibpClientOptionsSchema` is the Z…
|
||||
|
||||
- [options.schema](./options.schema.md) — `HibpClientOptionsSchema` is the Zod boundary for the `HibpClient` constructor. It is `.strict()` — unknown keys cause…
|
||||
|
||||
- options.schema — `HibpClientOptionsSchema` is the Z…
|
||||
|
||||
- [options.schema](./options.schema.md) — `HibpClientOptionsSchema` is the Zod boundary for the `HibpClient` constructor. It is `.strict()` — unknown keys cause…
|
||||
- [options.schema](./options.schema.md) — `HibpClientOptionsSchema` is the Zod boundary for the `HibpClient` constructor. It is `.strict()` — unknown keys cause…
|
||||
|
||||
## Purpose
|
||||
|
||||
`HibpClient` is the public façade composing the range client, the
|
||||
breach client, the cache, the rate-limit gate, and the observability
|
||||
surface into one constructor. It is the entry point downstream code
|
||||
imports — `import { HibpClient } from "@suaveplan/hibp"` — and the
|
||||
target of every option flag declared in the
|
||||
[options schema](./options.schema.md). Stateless beyond its cache.
|
||||
|
||||
## Features
|
||||
|
||||
- Single constructor accepts every documented option: `apiKey`,
|
||||
`strict`, `differentialPrivacy`, `assertOutgoingRequestSafe`,
|
||||
`fetch`, `clock`, `rng`, `logger`, `cache`, `ntlmHash`,
|
||||
`maxAttempts`.
|
||||
- Options validated through `HibpClientOptionsSchema` (strict — unknown
|
||||
keys rejected) at construction time.
|
||||
- `cache: null` opts out (resolves to `NullHibpCache`); omitting the
|
||||
field uses `LruHibpCache` defaults; passing a custom adapter accepts
|
||||
any `HibpCacheLike` implementation.
|
||||
- Authenticated methods (`getBreachesForAccount`, `getBreach`,
|
||||
`getAllBreaches`, `getPastesForAccount`) require `apiKey`; calling
|
||||
them without one throws synchronously with a descriptive error.
|
||||
- Range-API method (`checkPassword`) is always available; results are
|
||||
cached by 5-hex prefix.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { HibpClient } from "@suaveplan/hibp";
|
||||
|
||||
const client = new HibpClient();
|
||||
const { pwned } = await client.checkPassword("hunter2");
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { HibpClient } from "@suaveplan/hibp";
|
||||
|
||||
const client = new HibpClient({
|
||||
apiKey: process.env.HIBP_API_KEY!,
|
||||
strict: true,
|
||||
differentialPrivacy: { extraPrefixes: 3 },
|
||||
rng: myInjectableRng,
|
||||
assertOutgoingRequestSafe: true,
|
||||
cache: null,
|
||||
maxAttempts: 5,
|
||||
});
|
||||
|
||||
const breaches = await client.getBreachesForAccount("user@example.com");
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `DifferentialPrivacyOptions` — `{ extraPrefixes: number }`.
|
||||
- `HibpClientOptions` — every documented constructor flag.
|
||||
- `CallOptions` / `BreachOptions` — `{ signal? }` and
|
||||
`{ signal?, domain? }`.
|
||||
- `new HibpClient(options?)`.
|
||||
- `checkPassword(password, { signal?, mode? }?): Promise<PwnedResult>`.
|
||||
- `getBreachesForAccount(email, opts?): Promise<Breach[]>`.
|
||||
- `getBreach(name, opts?): Promise<Breach>`.
|
||||
- `getAllBreaches(opts?): Promise<Breach[]>`.
|
||||
- `getPastesForAccount(email, opts?): Promise<Paste[]>`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The façade keeps the public surface narrow on purpose: the four
|
||||
authenticated methods forward straight to `BreachClient`, the
|
||||
range-API call routes through the cache before delegating to
|
||||
`RangeClient`. Cache keys use the SHA-1 5-hex prefix even when the
|
||||
caller asks for NTLM mode unless an NTLM adapter is supplied, in which
|
||||
case the NTLM 5-hex prefix is the key. This split keeps the default
|
||||
case fast while preserving correctness when the caller configures
|
||||
NTLM. The constructor uses plain mutable record builders instead of
|
||||
typed-readonly objects so `exactOptionalPropertyTypes: true` does not
|
||||
reject conditional property assignment.
|
||||
@@ -0,0 +1,60 @@
|
||||
# `client/options.schema.ts`
|
||||
|
||||
## Purpose
|
||||
|
||||
`HibpClientOptionsSchema` is the Zod boundary for the `HibpClient`
|
||||
constructor. It is `.strict()` — unknown keys cause a synchronous throw
|
||||
at construction time. This catches typos like `Cache: null` (capital C)
|
||||
that would otherwise silently fall through to the default cache, and
|
||||
keeps the public option surface honest: every flag is documented, and
|
||||
every new flag forces an explicit schema update.
|
||||
|
||||
## Features
|
||||
|
||||
- Strict object — every unknown key throws.
|
||||
- `apiKey` constrained to `string` of minimum length 1 (an empty key
|
||||
would silently produce unauthenticated calls).
|
||||
- `differentialPrivacy.extraPrefixes` validated as a non-negative
|
||||
integer.
|
||||
- `maxAttempts` validated as a positive integer.
|
||||
- Runtime callbacks (`fetch`, `clock`, `rng`, `logger`, `cache`,
|
||||
`ntlmHash`) typed as `z.unknown()` because Zod cannot introspect
|
||||
arbitrary function shapes; structural validation happens at usage
|
||||
time inside the relevant sub-client.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { HibpClientOptionsSchema } from "@suaveplan/hibp";
|
||||
|
||||
HibpClientOptionsSchema.parse({ apiKey: "k_test", strict: true });
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { HibpClientOptionsSchema } from "@suaveplan/hibp";
|
||||
|
||||
function validateConfig(raw: unknown): void {
|
||||
const parsed = HibpClientOptionsSchema.parse(raw);
|
||||
if (parsed.differentialPrivacy && parsed.differentialPrivacy.extraPrefixes > 5) {
|
||||
throw new Error("decoy count > 5 is operationally expensive");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `HibpClientOptionsSchema: ZodObject` — strict.
|
||||
- `HibpClientOptionsInput` — `z.infer<typeof HibpClientOptionsSchema>`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The schema validates the *shape* of options rather than their runtime
|
||||
identity — the `fetch` field is allowed to be any value because the
|
||||
real `fetch` is a function and Zod has no way to distinguish a fake one
|
||||
from a real one. Runtime errors from passing a non-function fetch will
|
||||
surface at call time as a typed `HIBP.NETWORK_ERROR` with a helpful
|
||||
cause chain. The boundary is invoked exactly once per `HibpClient`
|
||||
construction so its cost is amortised over the lifetime of the
|
||||
instance.
|
||||
63
docs/security-stack/security/hibp/src/error-codes.md
Normal file
63
docs/security-stack/security/hibp/src/error-codes.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# `error-codes.ts`
|
||||
|
||||
## Purpose
|
||||
|
||||
Hosts the canonical `HIBP_CODES` registry — exactly seven error codes that
|
||||
classify every failure path inside `@suaveplan/hibp`. Lives at the package
|
||||
root (rather than in a nested `errors/` directory) so the
|
||||
`scripts/conformance.ts` scanner can find it and verify every code
|
||||
is actually thrown by at least one production source file.
|
||||
|
||||
## Features
|
||||
|
||||
- Seven stable codes — `RATE_LIMITED`, `UNAUTHORIZED`, `NOT_FOUND`,
|
||||
`MALFORMED_RESPONSE`, `NETWORK_ERROR`, `ABORTED`, `PAYLOAD_TOO_LARGE`.
|
||||
- Each value is the namespaced `HIBP.<KEY>` form, suitable for stable
|
||||
string matching in logs and structured-error registries.
|
||||
- Registered through `defineCodes` from `@suaveplan/error` so the wider
|
||||
registry knows the namespace and can validate it at startup.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { HIBP_CODES, type HibpErrorCode } from "@suaveplan/hibp";
|
||||
|
||||
function isFatal(code: HibpErrorCode): boolean {
|
||||
return code === HIBP_CODES.MALFORMED_RESPONSE;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { HibpError, HIBP_CODES } from "@suaveplan/hibp";
|
||||
|
||||
export function classifyHibpFailure(err: unknown): "retry" | "abort" | "report" {
|
||||
if (!(err instanceof HibpError)) return "report";
|
||||
switch (err.code) {
|
||||
case HIBP_CODES.RATE_LIMITED:
|
||||
case HIBP_CODES.NETWORK_ERROR:
|
||||
return "retry";
|
||||
case HIBP_CODES.ABORTED:
|
||||
return "abort";
|
||||
default:
|
||||
return "report";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `HIBP_CODES` — frozen record. Keys are bare names (`RATE_LIMITED`, …);
|
||||
values are namespaced strings (`"HIBP.RATE_LIMITED"`, …).
|
||||
- `HibpErrorCode` — string-literal union of every registered value.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The seven codes were chosen so each one corresponds to a distinct caller
|
||||
remediation path: rate-limit backoff, credential rotation, resource
|
||||
absence, schema regression, transport failure, user-driven cancellation,
|
||||
and over-sized response. Adding a new code is a breaking change because
|
||||
downstream consumers fan out on `code` literally. Removing a code is
|
||||
likewise breaking and forbidden by the conformance gate, which verifies
|
||||
every registered code is thrown by at least one production path.
|
||||
57
docs/security-stack/security/hibp/src/errors/codes.md
Normal file
57
docs/security-stack/security/hibp/src/errors/codes.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# `errors/codes.ts`
|
||||
|
||||
## Purpose
|
||||
|
||||
Spec-canonical path for the HIBP error-code registry. This file is a thin
|
||||
re-export of the top-level `src/error-codes.ts` module where the
|
||||
`defineCodes("HIBP", [...])` call physically lives. The split exists
|
||||
because the conformance scanner looks for the registry at
|
||||
`src/error-codes.ts`, while the spec documents the consumer-facing import
|
||||
as `@suaveplan/hibp/errors/codes` — keeping both lookups working without
|
||||
duplicating the code list.
|
||||
|
||||
## Features
|
||||
|
||||
- Identical surface to `error-codes.ts`: `HIBP_CODES` and `HibpErrorCode`.
|
||||
- Zero executable code in this file — it is a pure re-export, so any
|
||||
future change to the registry is made in exactly one place.
|
||||
- Allows downstream consumers to import from the spec-canonical subpath
|
||||
without leaking the conformance-driven physical layout.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { HIBP_CODES } from "@suaveplan/hibp/errors/codes";
|
||||
|
||||
if (Object.keys(HIBP_CODES).length !== 7) {
|
||||
throw new Error("HIBP registry corrupted");
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type { HibpErrorCode } from "@suaveplan/hibp/errors/codes";
|
||||
|
||||
interface FailureTelemetry {
|
||||
readonly code: HibpErrorCode;
|
||||
readonly elapsedMs: number;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
Same as `error-codes.ts`:
|
||||
|
||||
- `HIBP_CODES` — frozen record of the seven registered codes.
|
||||
- `HibpErrorCode` — string-literal union.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The re-export form (`export { ... } from "../error-codes.js"`) preserves
|
||||
the same module identity — `HIBP_CODES` imported here is `===` to
|
||||
`HIBP_CODES` imported from the root barrel. Consumers can freely mix and
|
||||
match the two import paths without worrying about duplicate registration
|
||||
or identity mismatches in `Set`-based discrimination. If the canonical
|
||||
location is ever moved, this file is the only one that needs to change
|
||||
to keep the spec-documented import surface stable.
|
||||
69
docs/security-stack/security/hibp/src/errors/hibp-error.md
Normal file
69
docs/security-stack/security/hibp/src/errors/hibp-error.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# `errors/hibp-error.ts`
|
||||
|
||||
## Purpose
|
||||
|
||||
`HibpError` is the structured error class every failure path inside
|
||||
`@suaveplan/hibp` throws. It carries one of the seven registered
|
||||
`HibpErrorCode` values plus optional cause / context / trace-id metadata,
|
||||
and ships a `toJSON()` form that strips sensitive payloads (API keys,
|
||||
emails, passwords, full SHA-1 hashes) before any logger touches it.
|
||||
|
||||
## Features
|
||||
|
||||
- `code` field narrows on the seven-member registry, so callers can fan
|
||||
out on stable enumerated values rather than parse messages.
|
||||
- Inherits trace-id propagation and timestamp from
|
||||
`SuaveplanError`, keeping every error in the project wire-compatible.
|
||||
- `leakClass` field annotates errors thrown by `assert-no-leak.ts` with
|
||||
whether the offending value was a SHA-1, a password, or an email —
|
||||
without ever echoing the leaked bytes.
|
||||
- `toJSON()` scrubs any 40-char hex sequence and any address-shaped
|
||||
substring as a defence-in-depth filter, so even an unexpected interpolation
|
||||
cannot leak a hash or email.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { HIBP_CODES, HibpError } from "@suaveplan/hibp";
|
||||
|
||||
try {
|
||||
/* ... */
|
||||
} catch (err) {
|
||||
if (err instanceof HibpError && err.code === HIBP_CODES.RATE_LIMITED) {
|
||||
// back off
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { HibpError } from "@suaveplan/hibp";
|
||||
|
||||
function logSafely(err: unknown, logger: { error(s: string): void }): void {
|
||||
if (err instanceof HibpError) {
|
||||
logger.error(JSON.stringify(err.toJSON()));
|
||||
} else if (err instanceof Error) {
|
||||
logger.error(err.message);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new HibpError(code, message, options?)` — `options` accepts `cause`,
|
||||
`context`, `traceId`, `clock`, and `leakClass`.
|
||||
- `error.code: HibpErrorCode` — the namespaced code string.
|
||||
- `error.leakClass: "sha1" | "password" | "email" | undefined`.
|
||||
- `error.toJSON()` — returns a stable, redacted serialization safe to
|
||||
hand to any logger.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `toJSON` redaction is intentionally regex-based — strings that happen
|
||||
to resemble a SHA-1 or an email get replaced with `[redacted]` even if
|
||||
they were never deliberate. False positives here cost almost nothing;
|
||||
false negatives would cost the privacy contract. The constructor uses a
|
||||
helper to build the super-options object so that
|
||||
`exactOptionalPropertyTypes: true` doesn't reject `undefined` values
|
||||
that the parent class doesn't accept.
|
||||
@@ -0,0 +1,68 @@
|
||||
# `observability/metrics.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Owns the OpenTelemetry instrument bundle for the package: two histograms
|
||||
(`hibp.range.duration_ms`, `hibp.breach.duration_ms`) and three counters
|
||||
(`hibp.range.outcome_total`, `hibp.errors_total`,
|
||||
`hibp.rate_limit_throttled_total`). Instruments are acquired lazily so
|
||||
the meter resolves to whatever provider is globally registered when the
|
||||
first emission fires; tests can swap providers between cases by calling
|
||||
`resetHibpMetricsCache()`.
|
||||
|
||||
## Features
|
||||
|
||||
- One thin function per metric (`recordRangeDuration`,
|
||||
`recordOutcomeMetric`, `recordErrorMetric`, `recordBreachDuration`,
|
||||
`recordThrottled`) so call sites stay short and analysable.
|
||||
- `getHibpMetrics()` returns the full bundle for consumers that need
|
||||
direct instrument access (custom histograms, etc.).
|
||||
- `resetHibpMetricsCache()` clears the cached bundle so the next call
|
||||
re-acquires under the current `MeterProvider`. Essential for in-memory
|
||||
test providers.
|
||||
- Meter name is `@suaveplan/hibp`, matching the package identifier so
|
||||
attribute filtering on `instrumentationScope.name` works out of the
|
||||
box.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { recordRangeDuration, recordOutcomeMetric } from "@suaveplan/hibp";
|
||||
|
||||
recordRangeDuration(12.5);
|
||||
recordOutcomeMetric("pwned");
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import { resetHibpMetricsCache, getHibpMetrics } from "@suaveplan/hibp";
|
||||
|
||||
metrics.setGlobalMeterProvider(myProvider);
|
||||
resetHibpMetricsCache();
|
||||
const m = getHibpMetrics();
|
||||
m.errors.add(1, { code: "HIBP.RATE_LIMITED" });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `HibpMetrics` — bundle interface with the five instruments.
|
||||
- `getHibpMetrics(): HibpMetrics`.
|
||||
- `resetHibpMetricsCache(): void`.
|
||||
- `recordRangeDuration(ms: number)`.
|
||||
- `recordBreachDuration(ms: number)`.
|
||||
- `recordOutcomeMetric(outcome: "pwned" | "clean" | "error")`.
|
||||
- `recordErrorMetric(code: HibpErrorCode)`.
|
||||
- `recordThrottled()`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The module caches the bundle at module scope to avoid the `getMeter` ->
|
||||
`createHistogram` cycle on every call. The cache is invalidated only by
|
||||
`resetHibpMetricsCache()` because in production the global provider is
|
||||
set once at startup. Counters use low-cardinality string attributes
|
||||
(`outcome`, `code`) so a metrics backend's series count stays bounded.
|
||||
@@ -0,0 +1,62 @@
|
||||
# `privacy/assert-no-leak.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`assertNoLeak` is the runtime sentinel that scans an outgoing `Request`
|
||||
URL and body byte-by-byte for three classes of forbidden content: the
|
||||
full SHA-1 digest of the password being checked, the password itself,
|
||||
and the configured email. A hit on any of the three throws
|
||||
`HibpError(HIBP.MALFORMED_RESPONSE)` carrying a `leakClass` tag that
|
||||
identifies what was leaked — without ever echoing the leaked bytes into
|
||||
the error message.
|
||||
|
||||
## Features
|
||||
|
||||
- Three independent leak classes — `sha1`, `password`, `email` — each
|
||||
optional so callers pay only for the checks they configure.
|
||||
- Whitelists the legitimate 5-hex prefix in the range URL so the
|
||||
k-anonymity protocol does not trip the check.
|
||||
- Treats both the literal email and its URL-encoded form as body leaks,
|
||||
but allows the documented `/api/v3/breachedaccount/{encoded}` and
|
||||
`/api/v3/pasteaccount/{encoded}` URL shapes.
|
||||
- Tolerates requests whose body cannot be cloned (some `Request`
|
||||
subclasses) — failure to read the body is silent rather than fatal.
|
||||
- No allocations beyond what `Request.clone().text()` requires.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { assertNoLeak } from "@suaveplan/hibp";
|
||||
|
||||
await assertNoLeak(req, { password: "hunter2" });
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { assertNoLeak, hashSha1Prefix } from "@suaveplan/hibp";
|
||||
|
||||
const { full } = hashSha1Prefix(password);
|
||||
await assertNoLeak(req, { fullSha1: full, password, email });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `LeakCandidate` — `{ fullSha1?, password?, email? }`.
|
||||
- `LeakClass` — `"sha1" | "password" | "email"`.
|
||||
- `assertNoLeak(request, candidates): Promise<void>` — throws on hit.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The helper is intentionally async even though the URL check is
|
||||
synchronous: reading the body requires `Request.clone().text()`, which
|
||||
returns a `Promise<string>`. The check uses `Array.prototype.includes`
|
||||
on the strings rather than per-byte iteration because JS strings already
|
||||
expose `indexOf` in O(n), and the simpler API has fewer ways to go
|
||||
wrong. The 5-hex prefix is stripped before the SHA-1 search to avoid
|
||||
flagging the legitimate k-anonymity payload. Production deployments opt
|
||||
in via `HibpClient` `assertOutgoingRequestSafe: true`; tests use it
|
||||
unconditionally as a regression sentinel.
|
||||
@@ -0,0 +1,59 @@
|
||||
# `range/constant-time-compare.ts`
|
||||
|
||||
## Purpose
|
||||
|
||||
`constantTimeEqualsHex` compares two hex suffixes without early-exit on
|
||||
the first mismatching character. It is used by the range-API client to
|
||||
verify whether any line in the response matches the input password's
|
||||
35-char suffix, so a timing-side-channel attacker cannot infer which
|
||||
slot the real suffix lands in. The instrumented variant
|
||||
(`constantTimeEqualsHexInstrumented`) returns the byte-XOR count for
|
||||
test assertions.
|
||||
|
||||
## Features
|
||||
|
||||
- Branch-free byte loop using OR-accumulator over XOR pairs.
|
||||
- Touches every byte of the input — no length-based shortcut beyond the
|
||||
initial length-mismatch check.
|
||||
- ASCII-only (uppercase hex) inputs guarantee `charCodeAt` is
|
||||
single-byte, keeping per-character cost predictable.
|
||||
- Test instrumentation exposed without changing the hot path.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { constantTimeEqualsHex } from "@suaveplan/hibp";
|
||||
|
||||
if (constantTimeEqualsHex(line.suffix, target)) {
|
||||
/* matched */
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { constantTimeEqualsHexInstrumented } from "@suaveplan/hibp";
|
||||
|
||||
const { equal, bytesTouched } = constantTimeEqualsHexInstrumented(a, b);
|
||||
if (!equal && bytesTouched !== a.length) {
|
||||
throw new Error("comparator regressed to early-exit");
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `constantTimeEqualsHex(a: string, b: string): boolean`.
|
||||
- `constantTimeEqualsHexInstrumented(a, b): { equal, bytesTouched }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
JavaScript does not expose a true constant-time string compare, but the
|
||||
loop pattern above is the canonical approximation. The OR-accumulator
|
||||
trick converts every byte XOR into a single boolean test at the end,
|
||||
eliminating the data-dependent branch that an `if (a[i] !== b[i])
|
||||
return false` would introduce. The length-mismatch check leaks only the
|
||||
public fact that the two strings differ in length, which is never
|
||||
secret in this protocol. The test suite asserts the wall-clock mean
|
||||
delta between equal and unequal pairs stays within tolerance over
|
||||
10 000 samples; the more rigorous proof is the bytes-touched
|
||||
invariant.
|
||||
@@ -0,0 +1,62 @@
|
||||
# `range/differential-privacy.ts`
|
||||
|
||||
## Purpose
|
||||
|
||||
`padPrefixes` generates the decoy prefixes used by the
|
||||
differential-privacy mode of `checkPassword`. Given the real 5-hex
|
||||
prefix and a positive `n`, it returns an array of `n + 1` prefixes —
|
||||
the real one plus `n` uniformly random siblings drawn from the
|
||||
`[00000, FFFFF]` space, with the real prefix guaranteed never to appear
|
||||
in a decoy slot. The injectable `Rng` keeps the function deterministic
|
||||
under test.
|
||||
|
||||
## Features
|
||||
|
||||
- Always includes the real prefix at index 0, normalised to uppercase.
|
||||
- Decoys are unique within a single call (a `Set` guards against
|
||||
collisions) and never equal to the real prefix.
|
||||
- Uses `Rng.nextIntInRange` when present (unbiased on any range) and
|
||||
falls back to unbiased rejection sampling over `nextUint32` so
|
||||
`Math.random`-style RNGs without range support still produce
|
||||
uniform output.
|
||||
- Returns an immutable array literal, so consumers can iterate without
|
||||
worrying about accidental mutation.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { padPrefixes } from "@suaveplan/hibp";
|
||||
|
||||
const prefixes = padPrefixes("F3BBB", 3, myRng);
|
||||
// length 4, includes "F3BBB", three random siblings
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { padPrefixes } from "@suaveplan/hibp";
|
||||
import type { Rng } from "@suaveplan/types";
|
||||
|
||||
const deterministic: Rng = {
|
||||
nextUint32: () => 0,
|
||||
nextFloat: () => 0,
|
||||
nextIntInRange: (_, max) => max - 1,
|
||||
};
|
||||
|
||||
const prefixes = padPrefixes("AAAAA", 2, deterministic);
|
||||
// Reproducible decoy set for tests.
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `padPrefixes(realPrefix, n, rng): readonly string[]`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The rejection-sampling fallback divides the `[0, 2^32)` space by 16 and
|
||||
discards any draw landing in the residual partial range to keep the
|
||||
distribution uniform — a `% 16` shortcut would bias the low nibbles.
|
||||
The seen-set deduplication keeps the algorithm O(n) on average even
|
||||
under unlikely RNG repeats. The function intentionally throws no
|
||||
typed `HibpError` because input validation (range, RNG presence) is
|
||||
handled one layer up in `RangeClient.checkPassword`.
|
||||
60
docs/security-stack/security/hibp/src/range/range-mode.md
Normal file
60
docs/security-stack/security/hibp/src/range/range-mode.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# `range/range-mode.ts`
|
||||
|
||||
## Purpose
|
||||
|
||||
Houses the hash-mode dispatch utilities used by `checkPassword`:
|
||||
`hashSha1Prefix` (SHA-1 via `@noble/hashes/sha1`), `hashNtlmPrefix`
|
||||
(MD4-over-UTF-16-LE, supplied by an injectable adapter because not every
|
||||
runtime ships an MD4 implementation), and `buildRangeUrl` (assembles the
|
||||
final `/range/<prefix>[?mode=ntlm]` URL). The split between hashing and
|
||||
URL building keeps each function small, testable, and reusable from
|
||||
both the public `checkPassword` path and the privacy-leak verification
|
||||
helpers.
|
||||
|
||||
## Features
|
||||
|
||||
- `hashSha1Prefix(password)` returns `{ prefix, suffix, full }` with the
|
||||
uppercase 40-char digest and its k-anonymity decomposition.
|
||||
- `hashNtlmPrefix(password, adapter)` accepts a caller-supplied adapter,
|
||||
validating that the returned digest is exactly 40 uppercase hex chars.
|
||||
- `buildRangeUrl(prefix, mode)` appends `?mode=ntlm` only when the mode
|
||||
is `"ntlm"` — SHA-1 mode never sends a query string.
|
||||
- `RangeMode` type alias narrows callers to the two valid values.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { buildRangeUrl, hashSha1Prefix } from "@suaveplan/hibp";
|
||||
|
||||
const { prefix } = hashSha1Prefix("hunter2");
|
||||
const url = buildRangeUrl(prefix, "sha1");
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { buildRangeUrl, hashNtlmPrefix } from "@suaveplan/hibp";
|
||||
import { md4 } from "my-ntlm-adapter";
|
||||
|
||||
const { prefix } = hashNtlmPrefix("hunter2", (pw) => md4(pw).toUpperCase());
|
||||
const url = buildRangeUrl(prefix, "ntlm");
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `RangeMode = "sha1" | "ntlm"`.
|
||||
- `NtlmHashAdapter = (password: string) => string`.
|
||||
- `hashSha1Prefix(password): { prefix, suffix, full }`.
|
||||
- `hashNtlmPrefix(password, adapter): { prefix, suffix, full }`.
|
||||
- `buildRangeUrl(prefix, mode): string`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
SHA-1 is sourced from `@noble/hashes/sha1` rather than from
|
||||
`@suaveplan/crypto-hash` because the crypto-hash package deliberately
|
||||
omits SHA-1 (collision-broken for security purposes). The HIBP API
|
||||
requires SHA-1 strictly for k-anonymity bucket selection — never for
|
||||
security — so the direct noble dependency is the right call.
|
||||
`hashNtlmPrefix` validates the adapter output rather than trusting it
|
||||
because a buggy adapter could produce shorter strings and accidentally
|
||||
leak unrelated bytes.
|
||||
@@ -0,0 +1,67 @@
|
||||
# `range/range-stream-parse.ts`
|
||||
|
||||
## Purpose
|
||||
|
||||
Streams an HTTP response body line-by-line through a `TextDecoderStream`
|
||||
and a newline framer, yielding `{ suffix, count }` records as soon as
|
||||
each line crosses a complete byte boundary. The parser deliberately
|
||||
never calls `Response.text()` and never collects the body into a single
|
||||
string buffer, so even a 30 KiB `Add-Padding` response is processed in
|
||||
bounded memory. Mid-flight `AbortSignal` is honoured between chunks.
|
||||
|
||||
## Features
|
||||
|
||||
- Async generator interface — drop in to `for await` loops.
|
||||
- CRLF and LF newlines both work; trailing line without newline is
|
||||
emitted; empty lines are skipped.
|
||||
- Per-line validation delegated to `parseRangeLine` so strict-mode
|
||||
rejection happens at first-fault.
|
||||
- AbortSignal threading: pre-aborted and mid-flight aborts both throw
|
||||
`HIBP.ABORTED` and the underlying reader is released in a `finally`
|
||||
guard.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { streamParseRangeBody } from "@suaveplan/hibp";
|
||||
|
||||
for await (const line of streamParseRangeBody(response.body!, { strict: true })) {
|
||||
if (line.suffix === expected) /* ... */;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { streamParseRangeBody } from "@suaveplan/hibp";
|
||||
|
||||
const ac = new AbortController();
|
||||
const iter = streamParseRangeBody(response.body!, {
|
||||
strict: false,
|
||||
signal: ac.signal,
|
||||
});
|
||||
|
||||
setTimeout(() => ac.abort(), 100);
|
||||
|
||||
try {
|
||||
for await (const _line of iter) { /* ... */ }
|
||||
} catch (err) {
|
||||
if ((err as { code?: string }).code === "HIBP.ABORTED") {
|
||||
// user cancelled
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `streamParseRangeBody(body, { strict, signal? }): AsyncGenerator<RangeLine>`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `TextDecoderStream` typings model the writable side as
|
||||
`BufferSource`, which trips the structural `pipeThrough` constraint, so
|
||||
the parser casts at the boundary. Calls to `Response.text()` are
|
||||
forbidden by the spec — the test suite asserts zero invocations. The
|
||||
generator's `finally` clause releases the underlying reader even when
|
||||
the consumer breaks out early, preventing leaked readable streams when
|
||||
a caller chooses to short-circuit on the first match.
|
||||
@@ -0,0 +1,65 @@
|
||||
# `range/range-strict-frame.ts`
|
||||
|
||||
## Purpose
|
||||
|
||||
`parseRangeLine` is the per-line framer used by `streamParseRangeBody`.
|
||||
It enforces the documented Pwned Passwords line shape (`/^[0-9A-F]{35}:[0-9]+$/`)
|
||||
in strict mode, throwing `HIBP.MALFORMED_RESPONSE` with the offending
|
||||
line index on any deviation. In off-mode it accepts lowercase hex,
|
||||
defaults an unparseable count to `0`, and still rejects structurally
|
||||
broken lines (missing colon, wrong suffix length, non-hex characters).
|
||||
|
||||
## Features
|
||||
|
||||
- Strict mode default — uppercase hex, single colon, decimal count.
|
||||
- Off mode tolerant of lowercase hex and unparseable counts (defaults
|
||||
to `0`), still rejecting non-hex suffixes or wrong lengths.
|
||||
- Throws carry `context.lineIndex` so callers can surface a precise
|
||||
diagnostic.
|
||||
- Pure synchronous function — no allocations beyond the returned
|
||||
`{ suffix, count }` record.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { parseRangeLine } from "@suaveplan/hibp";
|
||||
|
||||
const { suffix, count } = parseRangeLine(rawLine, 0, true);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { HibpError, parseRangeLine } from "@suaveplan/hibp";
|
||||
|
||||
function parseTolerant(lines: string[]): Array<{ suffix: string; count: number }> {
|
||||
const out: Array<{ suffix: string; count: number }> = [];
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
try {
|
||||
out.push(parseRangeLine(lines[i] as string, i, false));
|
||||
} catch (err) {
|
||||
if (err instanceof HibpError) continue;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `RangeLine` — `{ suffix: string; count: number }`.
|
||||
- `parseRangeLine(raw, lineIndex, strict): RangeLine` — throws
|
||||
`HibpError(HIBP.MALFORMED_RESPONSE)` on rejection.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The strict pattern uses a tight regex check before the colon split so
|
||||
mismatches are rejected before any allocation. Off-mode counts default
|
||||
to `0` rather than `NaN` so consumers can rely on integer semantics
|
||||
throughout. The function avoids `try`/`catch` internally — every
|
||||
branch is explicit so a future contributor can reason about the
|
||||
failure paths without tracing exception flow. Strict mode is the
|
||||
default because the documented Pwned Passwords contract is precise;
|
||||
opting out should be a deliberate operational decision, not the
|
||||
silent fallback.
|
||||
77
docs/security-stack/security/hibp/src/range/range.md
Normal file
77
docs/security-stack/security/hibp/src/range/range.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# `range/range.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`RangeClient` is the heart of the package — the k-anonymity Pwned
|
||||
Passwords client. `checkPassword(password, opts?)` hashes the input,
|
||||
slices the 5-hex prefix, dispatches `GET https://api.pwnedpasswords.com/range/<prefix>`
|
||||
with the mandatory `Add-Padding: true` header, scans the streamed
|
||||
response for a matching 35-char suffix, and returns
|
||||
`{ pwned, count }`. It owns the strict / off framing, the NTLM mode
|
||||
opt-in, the differential-privacy decoy fan-out, the AbortSignal
|
||||
threading, and the OTel emission.
|
||||
|
||||
## Features
|
||||
|
||||
- Mandatory `Add-Padding: true` on every fetch — including every
|
||||
differential-privacy decoy.
|
||||
- SHA-1 (default) and NTLM (caller-injected adapter) hash modes.
|
||||
- Constant-time suffix compare on every parsed line.
|
||||
- Streaming line parser (no `Response.text()` call ever).
|
||||
- Differential-privacy decoy fan-out when `extraPrefixes > 0` — real
|
||||
prefix's response is the only one consulted.
|
||||
- AbortSignal threaded into fetch and the streaming parser.
|
||||
- OTel histograms for duration; counters for outcome, errors,
|
||||
throttling.
|
||||
- Optional `assertOutgoingRequestSafe: true` runtime privacy gate.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { RangeClient } from "@suaveplan/hibp";
|
||||
|
||||
const client = new RangeClient();
|
||||
const { pwned, count } = await client.checkPassword("hunter2");
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { RangeClient } from "@suaveplan/hibp";
|
||||
|
||||
const client = new RangeClient({
|
||||
extraPrefixes: 3,
|
||||
rng: myRng,
|
||||
assertOutgoingRequestSafe: true,
|
||||
strict: true,
|
||||
});
|
||||
|
||||
const ac = new AbortController();
|
||||
setTimeout(() => ac.abort(), 5_000);
|
||||
const result = await client.checkPassword("hunter2", { signal: ac.signal });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `CheckPasswordOptions` — `mode?`, `signal?`, `extraPrefixes?`.
|
||||
- `RangeClientOptions` — `fetchFn`, `clock`, `rng`, `strict`,
|
||||
`ntlmHash`, `extraPrefixes`, `assertOutgoingRequestSafe`.
|
||||
- `new RangeClient(options?)`.
|
||||
- `client.checkPassword(password, opts?): Promise<PwnedResult>`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The class is stateless beyond its constructor options — every public
|
||||
method takes the password and a per-call options bag, so a single
|
||||
`RangeClient` instance is safe to share across a process. Error paths
|
||||
funnel through the metrics emission so dashboards see counters even
|
||||
for failures the caller catches and retries. The `assertOutgoingRequestSafe`
|
||||
gate constructs a `Request` to feed into `assertNoLeak`, then re-issues
|
||||
the actual fetch with the same `RequestInit` so the privacy check
|
||||
exercises the real outgoing bytes. The NTLM adapter is required at
|
||||
runtime if `mode: "ntlm"` is ever passed; a missing adapter surfaces as
|
||||
`HIBP.MALFORMED_RESPONSE` because the caller has misconfigured the
|
||||
client rather than the server having misbehaved.
|
||||
@@ -0,0 +1,68 @@
|
||||
# `schemas/breach.schema.ts`
|
||||
|
||||
## Purpose
|
||||
|
||||
`BreachSchema` is the Zod schema mirroring the [HIBP v3 `Breach`
|
||||
payload](https://haveibeenpwned.com/API/v3#Breach). It validates every
|
||||
documented field, so the rest of the package can assume a fully populated
|
||||
`Breach` value anywhere a parsed breach record flows. Defending the
|
||||
boundary at this layer means a server-side schema regression fails fast
|
||||
with a typed `HIBP.MALFORMED_RESPONSE` error rather than silently
|
||||
producing partial objects.
|
||||
|
||||
## Features
|
||||
|
||||
- Strict-required fields: missing keys throw at parse time.
|
||||
- Per-field type narrowing: `PwnCount` is a non-negative integer,
|
||||
`DataClasses` is `string[]`, every `Is*` field is boolean.
|
||||
- Covers `IsStealerLog`, the v3 addition that earlier docs sometimes
|
||||
miss.
|
||||
- Type alias `Breach` is exported alongside the schema so consumer code
|
||||
stays single-source-of-truth.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { BreachSchema } from "@suaveplan/hibp";
|
||||
|
||||
const parsed = BreachSchema.parse(await response.json());
|
||||
console.log(parsed.Name);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { BreachSchema, type Breach } from "@suaveplan/hibp";
|
||||
|
||||
export function partitionBreaches(items: unknown[]): {
|
||||
verified: Breach[];
|
||||
fabricated: Breach[];
|
||||
} {
|
||||
const verified: Breach[] = [];
|
||||
const fabricated: Breach[] = [];
|
||||
for (const item of items) {
|
||||
const b = BreachSchema.parse(item);
|
||||
if (b.IsFabricated) fabricated.push(b);
|
||||
else verified.push(b);
|
||||
}
|
||||
return { verified, fabricated };
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `BreachSchema: ZodObject` — strict v3 breach shape.
|
||||
- `Breach` — `z.infer<typeof BreachSchema>` type alias.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The schema does not call `.passthrough()` deliberately — opting out of
|
||||
silent forward-compat keeps server-side schema regressions visible. If
|
||||
HIBP adds a new field, the integration test in
|
||||
`breach-client.test.ts` will fail loudly and we can decide whether to
|
||||
add it, mark it optional, or reject the response. The `IsStealerLog`
|
||||
field landed in HIBP v3 after the original spec; we include it as
|
||||
required because every breach record observed in production carries it,
|
||||
and treating it as optional would let silent regressions slip through.
|
||||
Consumers concerned about long-tail server inconsistencies can run the
|
||||
schema through `.partial()` themselves before parsing.
|
||||
@@ -0,0 +1,64 @@
|
||||
# `schemas/paste.schema.ts`
|
||||
|
||||
## Purpose
|
||||
|
||||
`PasteSchema` validates the [HIBP v3 `Paste`
|
||||
payload](https://haveibeenpwned.com/API/v3#PastesForAccount) returned by
|
||||
`/pasteaccount/{email}`. Older paste records can omit `Title` and `Date`,
|
||||
so this schema makes them optional while still enforcing structural shape
|
||||
on the required fields. Defending this boundary keeps `getPastesForAccount`
|
||||
honest about its return type.
|
||||
|
||||
## Features
|
||||
|
||||
- Required: `Source`, `Id`, `EmailCount` (non-negative int).
|
||||
- Optional: `Title`, `Date` (older paste records may lack metadata).
|
||||
- Throws `ZodError` on type mismatch; the breach client maps that to
|
||||
`HIBP.MALFORMED_RESPONSE`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { PasteSchema } from "@suaveplan/hibp";
|
||||
|
||||
const paste = PasteSchema.parse({
|
||||
Source: "Pastebin",
|
||||
Id: "ABC",
|
||||
EmailCount: 100,
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { PasteSchema, type Paste } from "@suaveplan/hibp";
|
||||
|
||||
export function summarisePastes(items: unknown[]): {
|
||||
pastes: Paste[];
|
||||
totalEmails: number;
|
||||
} {
|
||||
const pastes = items.map((x) => PasteSchema.parse(x));
|
||||
return {
|
||||
pastes,
|
||||
totalEmails: pastes.reduce((n, p) => n + p.EmailCount, 0),
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `PasteSchema: ZodObject` — v3 paste shape.
|
||||
- `Paste` — `z.infer<typeof PasteSchema>` type alias.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The optional `Title` / `Date` fields are deliberately not defaulted; if
|
||||
they are absent in the upstream payload, they remain `undefined` in the
|
||||
parsed value so callers can branch on presence. We considered defaulting
|
||||
`Date` to `null` for consistency but rejected it as a divergence from the
|
||||
documented contract. The schema rejects unknown nested types eagerly: a
|
||||
server-side rename like `Source` → `source` (lowercase) will surface as a
|
||||
`HIBP.MALFORMED_RESPONSE` from `getPastesForAccount`, which is the
|
||||
correct behaviour — a silent rename would be far worse than a typed
|
||||
throw. Callers can wrap individual `parse` calls in `try` if partial
|
||||
tolerance is desired, but the default policy is fail-fast.
|
||||
@@ -0,0 +1,57 @@
|
||||
# `schemas/pwned-result.schema.ts`
|
||||
|
||||
## Purpose
|
||||
|
||||
`PwnedResultSchema` validates the runtime shape of every `checkPassword`
|
||||
result. It is the final boundary between the internal range-parse loop
|
||||
and the public return type. A negative count or a non-boolean `pwned`
|
||||
flag triggers a typed throw before the value escapes the package, so
|
||||
downstream consumers can trust the parsed value blindly.
|
||||
|
||||
## Features
|
||||
|
||||
- `pwned: boolean` — never undefined, never `null`.
|
||||
- `count: int >= 0` — `Number.isInteger` enforced; negatives rejected.
|
||||
- Coupled with the `PwnedResult` type alias so the schema is the single
|
||||
source of truth for both runtime validation and TypeScript narrowing.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { PwnedResultSchema } from "@suaveplan/hibp";
|
||||
|
||||
const r = PwnedResultSchema.parse({ pwned: true, count: 42 });
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { PwnedResultSchema, type PwnedResult } from "@suaveplan/hibp";
|
||||
|
||||
export function classify(r: PwnedResult): "safe" | "compromised" | "leaked-heavily" {
|
||||
const safe = PwnedResultSchema.parse(r);
|
||||
if (!safe.pwned) return "safe";
|
||||
return safe.count > 1000 ? "leaked-heavily" : "compromised";
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `PwnedResultSchema: ZodObject<{ pwned, count }>`.
|
||||
- `PwnedResult` — `z.infer<typeof PwnedResultSchema>` alias.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The validation is deliberately the very last step in `checkPassword`; it
|
||||
catches any internal logic bug that produces a malformed result before
|
||||
the value reaches user code. The check is cheap enough (two primitive
|
||||
type assertions) to run unconditionally. The schema doubles as
|
||||
documentation: the spec scenarios reference `PwnedResultSchema` directly,
|
||||
so spec readers can verify the runtime contract without reading
|
||||
TypeScript. The `int` modifier ensures fractional counts produced by
|
||||
some upstream API gateways (which occasionally serialize integers as
|
||||
JSON numbers with trailing `.0`) are rejected loudly — silent rounding
|
||||
would mask a server-side regression. Adding fields to the result type
|
||||
is a breaking change because consumers may destructure on `{ pwned,
|
||||
count }`; if a future need arises, prefer a sibling schema with the
|
||||
extra fields.
|
||||
Reference in New Issue
Block a user