first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View 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

View File

@@ -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.

View File

@@ -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.

View 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.

View 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.

View 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.

View 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.

View 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.

View File

@@ -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.

View 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.

View 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.

View 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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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`.

View 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.

View File

@@ -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.

View File

@@ -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.

View 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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.