Files
2026-05-27 23:29:53 +01:00

32 KiB

Capability Spec: hibp

Package: @suaveplan/hibp Status: ACTIVE

Every requirement below carries a **Verified by:** line at authoring time. (See openspec/AGENTS.md Section 13.)

Requirements

Requirement: Range API k-anonymity check

The package SHALL export checkPassword(password, opts?): Promise<PwnedResult> 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/<prefix> to the Pwned Passwords range API. 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/crypto/hibp/src/range/range.test.ts::"sends only the 5-hex prefix in the URL" AND packages/crypto/hibp/src/range/range.test.ts::"returns pwned with documented count when suffix matches" AND packages/crypto/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/<prefix> regardless of caller configuration. This MUST NOT be configurable off. The header is required by the Pwned Passwords padding contract 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/crypto/hibp/src/range/range.test.ts::"sets Add-Padding true on default range call" AND packages/crypto/hibp/src/range/range.test.ts::"sets Add-Padding true on NTLM range call" AND packages/crypto/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 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/<prefix>?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/crypto/hibp/src/range/range-mode.test.ts::"NTLM mode appends ?mode=ntlm" AND packages/crypto/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/<prefix> response by streaming over Response.body (ReadableStream<Uint8Array>) 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/crypto/hibp/src/range/range-stream-parse.test.ts::"never calls Response.text" AND packages/crypto/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/crypto/hibp/src/range/constant-time-compare.test.ts::"equal inputs touch every byte" AND packages/crypto/hibp/src/range/constant-time-compare.test.ts::"differing inputs touch every byte" AND packages/crypto/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/crypto/hibp/src/privacy/assert-no-leak.test.ts::"throws when URL contains the full SHA-1" AND packages/crypto/hibp/src/privacy/assert-no-leak.test.ts::"passes when URL contains only the 5-hex prefix" AND packages/crypto/hibp/src/privacy/assert-no-leak.test.ts::"throws when body contains the email" AND packages/crypto/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. 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/crypto/hibp/src/breach/breach-client.test.ts::"builds breachedaccount URL with documented query parameters" AND packages/crypto/hibp/src/breach/breach-client.test.ts::"throws MALFORMED_RESPONSE when Breach payload is missing fields" AND packages/crypto/hibp/src/breach/breach-client.test.ts::"resolves to empty array on 404"


Requirement: getBreach and getAllBreaches

The package SHALL export getBreach(name): Promise<Breach> against /breach/{name} and getAllBreaches(opts?): Promise<Breach[]> against /breaches, 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/crypto/hibp/src/breach/breach-client.test.ts::"getBreach returns a Zod-validated Breach" AND packages/crypto/hibp/src/breach/breach-client.test.ts::"getBreach throws NOT_FOUND on 404" AND packages/crypto/hibp/src/breach/breach-client.test.ts::"getAllBreaches forwards domain query parameter"


Requirement: getPastesForAccount

The package SHALL export getPastesForAccount(email): Promise<Paste[]> against /pasteaccount/{email}. 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/crypto/hibp/src/breach/breach-client.test.ts::"getPastesForAccount URL-encodes the email" AND packages/crypto/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.

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/crypto/hibp/src/breach/breach-client.test.ts::"hibp-api-key header is set from constructor option" AND packages/crypto/hibp/src/breach/breach-client.test.ts::"thrown error does not contain the api key" AND packages/crypto/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. 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/crypto/hibp/src/breach/rate-limit-gate.test.ts::"second call within 1500ms window blocks until release" AND packages/crypto/hibp/src/breach/rate-limit-gate.test.ts::"range calls bypass the gate" AND packages/crypto/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. 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: <HTTP-date 7 seconds in the future>
  • 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/crypto/hibp/src/breach/retry-after.test.ts::"honours delta-seconds Retry-After" AND packages/crypto/hibp/src/breach/retry-after.test.ts::"honours HTTP-date Retry-After" AND packages/crypto/hibp/src/breach/retry-after.test.ts::"caps backoff at 60 seconds" AND packages/crypto/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/crypto/hibp/src/cache/lru.test.ts::"evicts least-recently-used at capacity" AND packages/crypto/hibp/src/cache/lru.test.ts::"evicts on TTL expiry" AND packages/crypto/hibp/src/cache/lru.test.ts::"normalises keys to uppercase hex"


Requirement: Pluggable cache adapter

The package SHALL accept any HibpCacheLike<V> 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/crypto/hibp/src/client/client.test.ts::"caller-supplied cache is consulted on hit and miss" AND packages/crypto/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 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/crypto/hibp/src/range/range-strict-frame.test.ts::"strict mode rejects lowercase hex" AND packages/crypto/hibp/src/range/range-strict-frame.test.ts::"strict mode rejects non-numeric count" AND packages/crypto/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/<prefix> 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/crypto/hibp/src/range/differential-privacy.test.ts::"dispatches real plus N decoys when extraPrefixes is N" AND packages/crypto/hibp/src/range/differential-privacy.test.ts::"real prefix is always present in the dispatched set" AND packages/crypto/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<HibpErrorCode> 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.<code>) 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/crypto/hibp/src/errors/codes.test.ts::"registry contains exactly the seven documented codes" AND packages/crypto/hibp/src/errors/hibp-error.test.ts::"JSON form omits api key, email, password, and full hash" AND packages/crypto/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 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/crypto/hibp/src/schemas/breach.schema.test.ts::"accepts recorded Adobe fixture" AND packages/crypto/hibp/src/schemas/pwned-result.schema.test.ts::"rejects negative counts" AND packages/crypto/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/crypto/hibp/src/range/range.test.ts::"pre-aborted signal throws ABORTED before fetch" AND packages/crypto/hibp/src/range/range-stream-parse.test.ts::"mid-flight abort terminates the streaming parser" AND packages/crypto/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/crypto/hibp/src/observability/metrics.test.ts::"range histogram records on success" AND packages/crypto/hibp/src/observability/metrics.test.ts::"outcome counter splits pwned and clean" AND packages/crypto/hibp/src/observability/metrics.test.ts::"errors counter increments per code" AND packages/crypto/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/crypto/hibp/src/index.test.ts::"every documented subpath export resolves" AND packages/crypto/hibp/src/index.test.ts::"root barrel uses explicit named re-exports"