# Capability Spec Delta: hibp (enterprise-hibp) **Package**: `@suaveplan/hibp` **Status**: PROPOSED — first delta; becomes the canonical spec on archive. > Every requirement below carries a `**Verified by:**` line at authoring time. (See `openspec/AGENTS.md` Section 13.) ## ADDED Requirements ### Requirement: Range API k-anonymity check The package SHALL export `checkPassword(password, opts?): Promise` which SHA-1s the input password, slices the resulting hex digest to the first 5 uppercase characters, and dispatches `GET https://api.pwnedpasswords.com/range/` to the [Pwned Passwords range API](https://haveibeenpwned.com/API/v3#PwnedPasswords). The function SHALL return `{ pwned: boolean, count: number }` describing whether the input's full 35-char suffix was present in the response and, if present, its breach count. The full SHA-1 hash and the full password SHALL NEVER be transmitted, logged, persisted, included in any thrown error, or referenced by any thrown error's `cause` chain. #### Scenario: prefix is sent, suffix is not - **WHEN** `checkPassword("hunter2")` is called with an injected `fetch` that records every outgoing `Request` - **THEN** the recorded URL ends with `/range/F3BBB`, the URL contains no character past the 5-hex prefix that matches the SHA-1 of `"hunter2"`, and the request body is empty #### Scenario: pwned password returns the documented count - **GIVEN** the range response contains `1B41F11D33EAB4D6E16AAFA77B5BCD4A6BD:42` and the input's suffix matches that line - **WHEN** `checkPassword(password)` is called - **THEN** the result equals `{ pwned: true, count: 42 }` #### Scenario: clean password returns zero count - **GIVEN** the range response contains no line whose suffix matches the input's - **WHEN** `checkPassword(password)` is called - **THEN** the result equals `{ pwned: false, count: 0 }` **Verified by:** `packages/security/hibp/src/range/range.test.ts::"sends only the 5-hex prefix in the URL"` AND `packages/security/hibp/src/range/range.test.ts::"returns pwned with documented count when suffix matches"` AND `packages/security/hibp/src/range/range.test.ts::"returns clean with zero count when no suffix matches"` --- ### Requirement: Mandatory `Add-Padding: true` header on every range request The package SHALL set the `Add-Padding: true` request header on every call to `/range/` regardless of caller configuration. This MUST NOT be configurable off. The header is required by the [Pwned Passwords padding contract](https://haveibeenpwned.com/API/v3#PwnedPasswordsPadding) to defeat response-size correlation attacks (see Cloudflare _Validating Leaked Passwords with k-Anonymity_). #### Scenario: header is set on the default range call - **WHEN** `checkPassword("anything")` is called with an injected `fetch` - **THEN** the recorded `Request.headers.get("Add-Padding")` equals `"true"` #### Scenario: header is set on the NTLM range call - **WHEN** `checkPassword("anything", { mode: "ntlm" })` is called - **THEN** the recorded `Request.headers.get("Add-Padding")` equals `"true"` #### Scenario: header is set when differential-privacy mode is on - **WHEN** `checkPassword("anything", { extraPrefixes: 3 })` is called - **THEN** every one of the 4 recorded requests has `Add-Padding: true` **Verified by:** `packages/security/hibp/src/range/range.test.ts::"sets Add-Padding true on default range call"` AND `packages/security/hibp/src/range/range.test.ts::"sets Add-Padding true on NTLM range call"` AND `packages/security/hibp/src/range/range.test.ts::"sets Add-Padding true on every differential-privacy decoy"` --- ### Requirement: NTLM mode opt-in The package SHALL accept `mode: "sha1" | "ntlm"` on `checkPassword`. When `mode === "ntlm"`, the request URL SHALL append `?mode=ntlm` per the [Pwned Passwords range API](https://haveibeenpwned.com/API/v3#PwnedPasswords) and the password SHALL be NTLM-hashed (MD4 over UTF-16-LE) instead of SHA-1-hashed before slicing the prefix. When `mode` is unset or `"sha1"`, no query string is appended and SHA-1 is used. #### Scenario: NTLM mode appends the documented query string - **WHEN** `checkPassword("anything", { mode: "ntlm" })` is called with an injected `fetch` - **THEN** the recorded URL ends with `/range/?mode=ntlm` #### Scenario: SHA-1 mode does not append a query string - **WHEN** `checkPassword("anything")` (default mode) is called with an injected `fetch` - **THEN** the recorded URL contains no `?` character **Verified by:** `packages/security/hibp/src/range/range-mode.test.ts::"NTLM mode appends ?mode=ntlm"` AND `packages/security/hibp/src/range/range-mode.test.ts::"default SHA-1 mode does not append a query string"` --- ### Requirement: Streaming response parser The package SHALL parse the `/range/` response by streaming over `Response.body` (`ReadableStream`) through a `TextDecoderStream` and a newline framer. The parser SHALL NOT call `Response.text()` and SHALL NOT collect the response into a single string buffer. The parser SHALL emit `{ suffix, count }` records incrementally so that a 30 KiB padded response is processed in bounded memory. #### Scenario: parser does not call Response.text - **GIVEN** a stub `Response` whose `text()` method records calls - **WHEN** `checkPassword(password)` consumes the response - **THEN** `Response.text()` is called zero times #### Scenario: 30 KiB padded response is decoded across multiple chunks - **GIVEN** a `ReadableStream` that emits a 30 KiB body across 8 chunks of varying size with at least one chunk boundary mid-line - **WHEN** the streaming parser processes the stream - **THEN** every documented line is emitted exactly once, in order, with its `count` numerically equal to the documented value **Verified by:** `packages/security/hibp/src/range/range-stream-parse.test.ts::"never calls Response.text"` AND `packages/security/hibp/src/range/range-stream-parse.test.ts::"decodes 30 KiB padded body across 8 chunks"` --- ### Requirement: Constant-time suffix compare The package SHALL compare the input's 35-char suffix against every line in the range response with a constant-time equality check that touches every byte of the 35-char range and SHALL NOT short-circuit on the first mismatching character. The comparator SHALL be available as `constantTimeEqualsHex(a, b)` in `src/range/constant-time-compare.ts`. #### Scenario: equal inputs touch every byte - **GIVEN** an instrumented comparator that records the number of byte XORs performed - **WHEN** two equal 35-char suffixes are compared - **THEN** exactly 35 byte XORs are recorded and the comparator returns `true` #### Scenario: differing inputs touch every byte - **GIVEN** the same instrumented comparator - **WHEN** two suffixes that differ only at index 0 are compared - **THEN** exactly 35 byte XORs are recorded (no early exit) and the comparator returns `false` #### Scenario: differential-timing test on 10 000 samples - **WHEN** the comparator is exercised against 10 000 randomly chosen suffix pairs (half equal, half differing at random indices) - **THEN** the absolute difference between the mean execution time of the equal samples and the mean execution time of the unequal samples is no more than 5 % of the overall mean **Verified by:** `packages/security/hibp/src/range/constant-time-compare.test.ts::"equal inputs touch every byte"` AND `packages/security/hibp/src/range/constant-time-compare.test.ts::"differing inputs touch every byte"` AND `packages/security/hibp/src/range/constant-time-compare.test.ts::"mean execution time delta is within 5 percent over 10000 samples"` --- ### Requirement: Privacy assertion (URL + body byte-by-byte) The package SHALL provide a privacy-leak assertion utility (`src/privacy/assert-no-leak.ts`) that scans a candidate outgoing `Request` URL and body byte-by-byte for (a) the full SHA-1 hex digest, (b) the full password, and (c) the configured email. A hit on any of the three SHALL throw `HibpError(HIBP.MALFORMED_RESPONSE)` carrying which class of leak was detected (without echoing the leaked value into the error message). The utility SHALL be opt-in via `HibpClientOptions.assertOutgoingRequestSafe?: boolean` for production hardening, and SHALL be exercised by the test suite on every public method. #### Scenario: outgoing URL containing the full SHA-1 throws - **GIVEN** the helper is configured with the SHA-1 of `"hunter2"` - **WHEN** the helper inspects a `Request` whose URL contains the full SHA-1 - **THEN** the call throws `HibpError` with code `"HIBP.MALFORMED_RESPONSE"` and a tag identifying the leak class as `"sha1"` #### Scenario: outgoing URL containing only the 5-hex prefix passes - **WHEN** the helper inspects a `Request` whose URL ends with `/range/F3BBB` - **THEN** the call returns without throwing #### Scenario: outgoing body containing the email throws - **GIVEN** the helper is configured with email `"user@example.com"` - **WHEN** the helper inspects a `Request` whose body byte-stream contains those bytes - **THEN** the call throws `HibpError` with code `"HIBP.MALFORMED_RESPONSE"` and a tag identifying the leak class as `"email"` **Verified by:** `packages/security/hibp/src/privacy/assert-no-leak.test.ts::"throws when URL contains the full SHA-1"` AND `packages/security/hibp/src/privacy/assert-no-leak.test.ts::"passes when URL contains only the 5-hex prefix"` AND `packages/security/hibp/src/privacy/assert-no-leak.test.ts::"throws when body contains the email"` AND `packages/security/hibp/src/privacy/range-no-leak.spec-conformance.test.ts::"1000-iteration checkPassword leaks zero bytes past the 5-hex prefix"` --- ### Requirement: `getBreachesForAccount` The package SHALL export `getBreachesForAccount(email, opts?)` which dispatches `GET https://haveibeenpwned.com/api/v3/breachedaccount/{email}?truncateResponse=false&includeUnverified=true` per the [v3 BreachesForAccount endpoint](https://haveibeenpwned.com/API/v3#BreachesForAccount). The email path segment SHALL be `encodeURIComponent`-applied. The email SHALL never be logged and SHALL never appear in any thrown error message. Responses SHALL be Zod-validated through `BreachSchema[]`; mismatches throw `HIBP.MALFORMED_RESPONSE`. #### Scenario: URL is built with documented query parameters - **WHEN** `getBreachesForAccount("user+test@example.com")` is called with an injected `fetch` - **THEN** the recorded URL equals `https://haveibeenpwned.com/api/v3/breachedaccount/user%2Btest%40example.com?truncateResponse=false&includeUnverified=true` #### Scenario: response is Zod-validated - **GIVEN** the response body omits the `Name` field on an entry - **WHEN** `getBreachesForAccount("user@example.com")` is called - **THEN** the call throws `HibpError` with code `"HIBP.MALFORMED_RESPONSE"` #### Scenario: 404 response yields the documented empty result - **GIVEN** the API returns `404` (no breaches for this account, per the v3 contract) - **WHEN** `getBreachesForAccount("clean@example.com")` is called - **THEN** the call resolves to `[]` **Verified by:** `packages/security/hibp/src/breach/breach-client.test.ts::"builds breachedaccount URL with documented query parameters"` AND `packages/security/hibp/src/breach/breach-client.test.ts::"throws MALFORMED_RESPONSE when Breach payload is missing fields"` AND `packages/security/hibp/src/breach/breach-client.test.ts::"resolves to empty array on 404"` --- ### Requirement: `getBreach` and `getAllBreaches` The package SHALL export `getBreach(name): Promise` against [`/breach/{name}`](https://haveibeenpwned.com/API/v3#SingleBreach) and `getAllBreaches(opts?): Promise` against [`/breaches`](https://haveibeenpwned.com/API/v3#AllBreaches), with optional `domain` query parameter on `getAllBreaches`. Both methods SHALL Zod-validate responses through `BreachSchema`. `getBreach` SHALL throw `HibpError(HIBP.NOT_FOUND)` on `404`. #### Scenario: getBreach returns a typed Breach - **WHEN** `getBreach("Adobe")` is called against a recorded fixture - **THEN** the result satisfies `BreachSchema` and `result.Name === "Adobe"` #### Scenario: getBreach throws NOT_FOUND on 404 - **WHEN** `getBreach("DoesNotExist")` is called and the API returns `404` - **THEN** the call throws `HibpError` with code `"HIBP.NOT_FOUND"` #### Scenario: getAllBreaches forwards the domain filter - **WHEN** `getAllBreaches({ domain: "adobe.com" })` is called with an injected `fetch` - **THEN** the recorded URL ends with `/breaches?domain=adobe.com` **Verified by:** `packages/security/hibp/src/breach/breach-client.test.ts::"getBreach returns a Zod-validated Breach"` AND `packages/security/hibp/src/breach/breach-client.test.ts::"getBreach throws NOT_FOUND on 404"` AND `packages/security/hibp/src/breach/breach-client.test.ts::"getAllBreaches forwards domain query parameter"` --- ### Requirement: `getPastesForAccount` The package SHALL export `getPastesForAccount(email): Promise` against [`/pasteaccount/{email}`](https://haveibeenpwned.com/API/v3#PastesForAccount). Email is `encodeURIComponent`-applied. Responses are Zod-validated through `PasteSchema[]`. The email is never logged. #### Scenario: URL encodes the email path segment - **WHEN** `getPastesForAccount("user+test@example.com")` is called with an injected `fetch` - **THEN** the recorded URL ends with `/pasteaccount/user%2Btest%40example.com` #### Scenario: 404 yields empty result - **GIVEN** the API returns `404` - **WHEN** `getPastesForAccount("clean@example.com")` is called - **THEN** the call resolves to `[]` **Verified by:** `packages/security/hibp/src/breach/breach-client.test.ts::"getPastesForAccount URL-encodes the email"` AND `packages/security/hibp/src/breach/breach-client.test.ts::"getPastesForAccount resolves to empty array on 404"` --- ### Requirement: `hibp-api-key` header sourced from constructor option The package SHALL set the `hibp-api-key` request header on every authenticated breach / paste call from `HibpClientOptions.apiKey`. The key SHALL NEVER be read from `process.env` or any module-scope variable, SHALL NEVER be logged, and SHALL NEVER appear in any thrown error's serialized form per the [v3 Authorisation contract](https://haveibeenpwned.com/API/v3#Authorisation). #### Scenario: header is set from constructor option - **GIVEN** a `HibpClient` constructed with `apiKey: "k_test"` - **WHEN** `getBreachesForAccount("user@example.com")` is called - **THEN** the recorded `Request.headers.get("hibp-api-key")` equals `"k_test"` #### Scenario: api key is not present in any thrown error - **GIVEN** the API returns `500` - **WHEN** `getBreachesForAccount("user@example.com")` is called - **THEN** the call throws `HibpError` and `JSON.stringify(error)` does not contain the literal `"k_test"` #### Scenario: api key is not read at module scope - **WHEN** the source file `src/breach/breach-client.ts` is grepped for `process.env` - **THEN** the grep returns zero hits **Verified by:** `packages/security/hibp/src/breach/breach-client.test.ts::"hibp-api-key header is set from constructor option"` AND `packages/security/hibp/src/breach/breach-client.test.ts::"thrown error does not contain the api key"` AND `packages/security/hibp/src/privacy/breach-no-log.spec-conformance.test.ts::"api key never appears in any captured log line"` --- ### Requirement: 1500 ms client-side rate-limit gate The package SHALL serialize calls to the breach + paste endpoints behind a `RateLimitGate` that enforces a minimum 1500 ms gap between consecutive calls per the [v3 RateLimiting contract](https://haveibeenpwned.com/API/v3#RateLimiting). The gate SHALL drive its delay against an injectable `Clock` from `@suaveplan/types`. Range API calls SHALL NOT pass through the gate (the range endpoint is unauthenticated and has no client-gap requirement). Every gate-induced wait SHALL increment the `hibp.rate_limit_throttled_total` counter. #### Scenario: second call within the window blocks until release - **GIVEN** an injectable `Clock` initialised at `t0` - **WHEN** `getBreachesForAccount(a)` resolves at `t0` and `getBreachesForAccount(b)` is called at `t0 + 500 ms` - **THEN** the second call's outgoing fetch is dispatched at `t0 + 1500 ms` #### Scenario: range calls bypass the gate - **GIVEN** the gate state is fully throttled - **WHEN** `checkPassword(password)` is called - **THEN** the range fetch is dispatched immediately without consulting the gate #### Scenario: throttle counter increments on every blocked call - **WHEN** three consecutive `getBreachesForAccount` calls fire within 200 ms of each other - **THEN** `hibp.rate_limit_throttled_total` increments by exactly 2 (the second and third calls) **Verified by:** `packages/security/hibp/src/breach/rate-limit-gate.test.ts::"second call within 1500ms window blocks until release"` AND `packages/security/hibp/src/breach/rate-limit-gate.test.ts::"range calls bypass the gate"` AND `packages/security/hibp/src/breach/rate-limit-gate.test.ts::"throttle counter increments per blocked call"` --- ### Requirement: 429 retry honouring `Retry-After` with exponential backoff capped at 60 s The package SHALL retry breach + paste requests that return HTTP `429` per the [v3 RateLimiting contract](https://haveibeenpwned.com/API/v3#RateLimiting). The retry delay SHALL parse the `Retry-After` response header (delta-seconds form OR HTTP-date form). If the header is missing, exponential backoff via `@suaveplan/retry`'s `RetryPolicy` SHALL drive the delay using an injectable `Rng`. The delay SHALL be hard-capped at 60 000 ms regardless of header value. Once the cap is reached or `RetryPolicy.maxAttempts` is exhausted, `HibpError(HIBP.RATE_LIMITED)` SHALL be thrown. #### Scenario: delta-seconds Retry-After is honoured - **GIVEN** the API returns `429` with `Retry-After: 5` - **WHEN** `getBreachesForAccount("user@example.com")` is called - **THEN** the next attempt is dispatched 5 000 ms later #### Scenario: HTTP-date Retry-After is honoured - **GIVEN** the API returns `429` with `Retry-After: ` - **WHEN** `getBreachesForAccount("user@example.com")` is called against an injectable clock - **THEN** the next attempt is dispatched at the parsed instant ±50 ms #### Scenario: delay is capped at 60 seconds - **GIVEN** the API returns `429` with `Retry-After: 9999` - **WHEN** `getBreachesForAccount("user@example.com")` is called - **THEN** the next attempt is dispatched no later than 60 000 ms after the prior failure #### Scenario: throws RATE_LIMITED on exhaustion - **GIVEN** the API returns `429` for every attempt and `maxAttempts` is exhausted - **WHEN** `getBreachesForAccount("user@example.com")` is called - **THEN** the call throws `HibpError` with code `"HIBP.RATE_LIMITED"` **Verified by:** `packages/security/hibp/src/breach/retry-after.test.ts::"honours delta-seconds Retry-After"` AND `packages/security/hibp/src/breach/retry-after.test.ts::"honours HTTP-date Retry-After"` AND `packages/security/hibp/src/breach/retry-after.test.ts::"caps backoff at 60 seconds"` AND `packages/security/hibp/src/breach/retry-after.test.ts::"throws RATE_LIMITED on exhaustion"` --- ### Requirement: In-memory LRU cache (default 10 000 entries, 24 h TTL) The package SHALL provide a default `LruHibpCache` keyed by uppercase 5-hex SHA-1 prefix that holds up to 10 000 entries with a 24 h TTL. The TTL clock SHALL be the injectable `Clock`. Eviction order on capacity SHALL be least-recently-used. The cache SHALL be the default `HibpClient` cache when no `cache` option is supplied. #### Scenario: capacity eviction at 10 000 entries - **GIVEN** the cache holds 10 000 entries and a new key is inserted - **WHEN** `set(newKey, value)` is called - **THEN** the cache size remains 10 000 and the least-recently-used key is no longer present #### Scenario: TTL eviction at 24 h - **GIVEN** an entry inserted at `t0` against an injectable clock - **WHEN** `get(key)` is called at `t0 + 24h + 1ms` - **THEN** the result is `undefined` #### Scenario: keys are normalised to uppercase hex - **WHEN** `set("abcde", v)` is called and `get("ABCDE")` is then called - **THEN** the second call returns `v` **Verified by:** `packages/security/hibp/src/cache/lru.test.ts::"evicts least-recently-used at capacity"` AND `packages/security/hibp/src/cache/lru.test.ts::"evicts on TTL expiry"` AND `packages/security/hibp/src/cache/lru.test.ts::"normalises keys to uppercase hex"` --- ### Requirement: Pluggable cache adapter The package SHALL accept any `HibpCacheLike` implementation as `HibpClientOptions.cache`. The interface SHALL expose `get`, `set`, `delete`, and `size`. Passing `cache: null` SHALL disable caching (resolves to `NullHibpCache`). Omitting the option SHALL use `LruHibpCache` defaults. #### Scenario: caller-supplied cache is consulted - **GIVEN** a `HibpCacheLike` whose `get` records every key it sees - **WHEN** `checkPassword("hunter2")` is called twice - **THEN** the first call records the prefix as a miss followed by `set`, and the second records the same prefix as a hit #### Scenario: cache: null disables caching - **GIVEN** `HibpClient` constructed with `cache: null` - **WHEN** `checkPassword("hunter2")` is called twice - **THEN** two range fetches are recorded (no second-call hit) **Verified by:** `packages/security/hibp/src/client/client.test.ts::"caller-supplied cache is consulted on hit and miss"` AND `packages/security/hibp/src/client/client.test.ts::"cache null disables caching"` --- ### Requirement: Strict-mode response framing The package SHALL operate in strict response-framing mode by default. Strict mode SHALL reject any range-response line that does not match `/^[0-9A-F]{35}:[0-9]+$/` per the [Pwned Passwords range API](https://haveibeenpwned.com/API/v3#PwnedPasswords) framing contract. Rejection SHALL throw `HibpError(HIBP.MALFORMED_RESPONSE)` carrying the offending line index. Strict mode SHALL be configurable off via `HibpClientOptions.strict: false`; off-mode SHALL accept lowercase hex and SHALL default an unparseable count to `0`. #### Scenario: strict mode rejects lowercase hex - **WHEN** the response contains `1b41f11d33eab4d6e16aafa77b5bcd4a6bd:1` - **THEN** the call throws `HibpError` with code `"HIBP.MALFORMED_RESPONSE"` #### Scenario: strict mode rejects malformed count - **WHEN** the response contains `1B41F11D33EAB4D6E16AAFA77B5BCD4A6BD:notanumber` - **THEN** the call throws `HibpError` with code `"HIBP.MALFORMED_RESPONSE"` #### Scenario: off-mode accepts lowercase hex - **GIVEN** `HibpClient` constructed with `strict: false` - **WHEN** the response contains `1b41f11d33eab4d6e16aafa77b5bcd4a6bd:1` - **THEN** the call resolves with `pwned: true, count: 1` **Verified by:** `packages/security/hibp/src/range/range-strict-frame.test.ts::"strict mode rejects lowercase hex"` AND `packages/security/hibp/src/range/range-strict-frame.test.ts::"strict mode rejects non-numeric count"` AND `packages/security/hibp/src/range/range-strict-frame.test.ts::"off-mode accepts lowercase hex"` --- ### Requirement: Differential-privacy mode (decoy prefixes) The package SHALL accept `HibpClientOptions.differentialPrivacy?: { extraPrefixes: number }`. When `extraPrefixes >= 1`, every `checkPassword` call SHALL submit the real prefix plus N additional random sibling prefixes drawn from an injectable `Rng`. The real prefix's response is the only one consulted for the result; decoy responses are discarded. Default behaviour (`extraPrefixes` absent or `0`) SHALL submit only the real prefix. The `Add-Padding: true` header SHALL be set on every decoy request. #### Scenario: decoy count matches the option - **WHEN** `checkPassword("anything", undefined)` is called against a client with `differentialPrivacy: { extraPrefixes: 3 }` - **THEN** the recorder captures exactly 4 outgoing fetches #### Scenario: real prefix is always present in the dispatched set - **GIVEN** a deterministic `Rng` returning a fixed sequence of decoys - **WHEN** `checkPassword(password)` is called - **THEN** the set of dispatched URLs contains the real `/range/` exactly once #### Scenario: zero extra prefixes is a single dispatch - **WHEN** `checkPassword("anything")` is called against a client with `differentialPrivacy: { extraPrefixes: 0 }` (or option absent) - **THEN** the recorder captures exactly 1 outgoing fetch **Verified by:** `packages/security/hibp/src/range/differential-privacy.test.ts::"dispatches real plus N decoys when extraPrefixes is N"` AND `packages/security/hibp/src/range/differential-privacy.test.ts::"real prefix is always present in the dispatched set"` AND `packages/security/hibp/src/range/differential-privacy.test.ts::"zero extra prefixes is a single dispatch"` --- ### Requirement: Error registry — seven HIBP codes The package SHALL export an error code registry via `defineCodes("HIBP", [...])` containing exactly: `HIBP.RATE_LIMITED`, `HIBP.UNAUTHORIZED`, `HIBP.NOT_FOUND`, `HIBP.MALFORMED_RESPONSE`, `HIBP.NETWORK_ERROR`, `HIBP.ABORTED`, `HIBP.PAYLOAD_TOO_LARGE`. Every code SHALL be thrown by at least one production path. `HibpError extends SuaveplanError` SHALL serialize to a stable JSON form omitting the `apiKey`, the email, the password, and the full SHA-1 hash. #### Scenario: every code value is present - **WHEN** `Object.keys(HIBP_CODES)` is enumerated - **THEN** the result equals the seven documented codes (set equality) #### Scenario: every code is thrown by a production path - **WHEN** the source tree is grepped for `throw new HibpError(HIBP_CODES.)` for each code - **THEN** each code has at least one production-path call site (excluding test fixtures) #### Scenario: HibpError JSON form omits sensitive fields - **GIVEN** a `HibpClient` constructed with `apiKey: "k_test"` configured for email `"user@example.com"` - **WHEN** an authenticated call fails and `JSON.stringify(error)` is computed - **THEN** the serialized string does not contain `"k_test"`, `"user@example.com"`, or any 40-char hex string **Verified by:** `packages/security/hibp/src/errors/codes.test.ts::"registry contains exactly the seven documented codes"` AND `packages/security/hibp/src/errors/hibp-error.test.ts::"JSON form omits api key, email, password, and full hash"` AND `packages/security/hibp/src/errors/codes.test.ts::"every code is thrown by at least one production path"` --- ### Requirement: Zod boundary schemas The package SHALL Zod-validate every public boundary. `BreachSchema` SHALL mirror the [v3 `Breach` payload](https://haveibeenpwned.com/API/v3#Breach) covering `Name`, `Title`, `Domain`, `BreachDate`, `AddedDate`, `ModifiedDate`, `PwnCount`, `Description`, `LogoPath`, `DataClasses[]`, `IsVerified`, `IsFabricated`, `IsSensitive`, `IsRetired`, `IsSpamList`, `IsMalware`, `IsSubscriptionFree`, `IsStealerLog`. `PasteSchema` SHALL mirror `Source`, `Id`, `Title?`, `Date?`, `EmailCount`. `PwnedResultSchema` SHALL be `{ pwned: boolean, count: int >= 0 }`. `HibpClientOptionsSchema` SHALL be a strict object rejecting unknown keys, empty `apiKey`, and negative `extraPrefixes`. #### Scenario: BreachSchema accepts a recorded Adobe fixture - **GIVEN** the recorded `/breach/Adobe` fixture - **WHEN** `BreachSchema.parse(fixture)` is called - **THEN** the call returns the parsed value with no error #### Scenario: PwnedResultSchema rejects negative counts - **WHEN** `PwnedResultSchema.parse({ pwned: false, count: -1 })` is called - **THEN** the call throws #### Scenario: HibpClientOptionsSchema rejects unknown keys - **WHEN** `HibpClientOptionsSchema.parse({ apiKey: "k", unexpected: true })` is called - **THEN** the call throws **Verified by:** `packages/security/hibp/src/schemas/breach.schema.test.ts::"accepts recorded Adobe fixture"` AND `packages/security/hibp/src/schemas/pwned-result.schema.test.ts::"rejects negative counts"` AND `packages/security/hibp/src/client/options.schema.test.ts::"rejects unknown keys"` --- ### Requirement: AbortSignal threaded through every public method The package SHALL accept `signal?: AbortSignal` on every public async method (`checkPassword`, `getBreachesForAccount`, `getBreach`, `getAllBreaches`, `getPastesForAccount`). A pre-aborted signal SHALL throw `HibpError(HIBP.ABORTED)` before any fetch is dispatched. A mid-flight abort SHALL propagate to the underlying `fetch` `RequestInit.signal` and SHALL terminate the streaming parser. #### Scenario: pre-aborted signal throws ABORTED before fetch - **GIVEN** an `AbortSignal` whose `aborted === true` - **WHEN** `checkPassword("anything", { signal })` is called against a recording fetch - **THEN** zero fetches are recorded and the call throws `HibpError` with code `"HIBP.ABORTED"` #### Scenario: mid-flight abort terminates the streaming parser - **GIVEN** a stream that emits 4 chunks separated by 10 ms each and an `AbortController` aborted after the first chunk - **WHEN** `checkPassword("anything", { signal })` is called - **THEN** the call throws `HibpError` with code `"HIBP.ABORTED"` and the underlying stream reader is released #### Scenario: AbortSignal threaded into RequestInit - **GIVEN** a recording fetch that captures `RequestInit.signal` - **WHEN** `getBreachesForAccount("user@example.com", { signal })` is called - **THEN** the captured `RequestInit.signal === signal` (reference equality) **Verified by:** `packages/security/hibp/src/range/range.test.ts::"pre-aborted signal throws ABORTED before fetch"` AND `packages/security/hibp/src/range/range-stream-parse.test.ts::"mid-flight abort terminates the streaming parser"` AND `packages/security/hibp/src/breach/breach-client.test.ts::"AbortSignal is threaded into RequestInit"` --- ### Requirement: OpenTelemetry observability surface The package SHALL emit two histograms — `hibp.range.duration_ms` and `hibp.breach.duration_ms` — recording wall-clock duration on every range and breach/paste call respectively. The package SHALL emit three counters: `hibp.range.outcome_total{outcome}` with `outcome ∈ {pwned, clean, error}`; `hibp.errors_total{code}` with one of the seven `HIBP.*` code values; and `hibp.rate_limit_throttled_total` incremented on every gate-induced wait. Histograms and counters SHALL be acquired against `metrics.getMeter("@suaveplan/hibp")` from `@opentelemetry/api`. #### Scenario: range histogram records on success - **GIVEN** a real in-memory `MeterProvider` - **WHEN** `checkPassword("anything")` resolves - **THEN** `hibp.range.duration_ms` has exactly one recording with the elapsed wall-clock duration #### Scenario: outcome counter records `pwned` and `clean` correctly - **WHEN** one pwned and one clean call are made - **THEN** `hibp.range.outcome_total{outcome="pwned"}` equals 1 and `hibp.range.outcome_total{outcome="clean"}` equals 1 #### Scenario: errors counter increments on every code - **WHEN** a `429` triggers `RATE_LIMITED` and a malformed body triggers `MALFORMED_RESPONSE` - **THEN** `hibp.errors_total{code="HIBP.RATE_LIMITED"}` equals 1 and `hibp.errors_total{code="HIBP.MALFORMED_RESPONSE"}` equals 1 **Verified by:** `packages/security/hibp/src/observability/metrics.test.ts::"range histogram records on success"` AND `packages/security/hibp/src/observability/metrics.test.ts::"outcome counter splits pwned and clean"` AND `packages/security/hibp/src/observability/metrics.test.ts::"errors counter increments per code"` AND `packages/security/hibp/src/breach/rate-limit-gate.test.ts::"throttle counter increments per blocked call"` --- ### Requirement: Subpath exports The package SHALL declare subpath exports so consumers can tree-shake to the surface they need. The exports map SHALL include `.`, `./range/range`, `./range/range-mode`, `./breach/breach-client`, `./cache/cache`, `./cache/lru`, `./cache/null`, `./client/client`, `./client/options.schema`, `./errors/codes`, `./errors/hibp-error`, `./schemas/breach.schema`, `./schemas/paste.schema`, `./schemas/pwned-result.schema`, and `./observability/metrics`. #### Scenario: every documented subpath resolves - **WHEN** every documented subpath is imported in a consumer test fixture - **THEN** every import resolves with no `Module not found` errors #### Scenario: root barrel uses explicit named re-exports - **WHEN** `src/index.ts` is grepped for `^export \\*` - **THEN** the grep returns zero hits (per CLAUDE.md rule 10) **Verified by:** `packages/security/hibp/src/index.test.ts::"every documented subpath export resolves"` AND `packages/security/hibp/src/index.test.ts::"root barrel uses explicit named re-exports"`