32 KiB
Capability Spec: hibp
Package: @suaveplan/hibp
Status: ACTIVE
Every requirement below carries a
**Verified by:**line at authoring time. (Seeopenspec/AGENTS.mdSection 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 injectedfetchthat records every outgoingRequest - 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:42and 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 injectedfetch - 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 injectedfetch - 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 injectedfetch - 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
Responsewhosetext()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
ReadableStreamthat 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
countnumerically 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
Requestwhose URL contains the full SHA-1 - THEN the call throws
HibpErrorwith 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
Requestwhose 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
Requestwhose body byte-stream contains those bytes - THEN the call throws
HibpErrorwith 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 injectedfetch - 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
Namefield on an entry - WHEN
getBreachesForAccount("user@example.com")is called - THEN the call throws
HibpErrorwith 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
BreachSchemaandresult.Name === "Adobe"
Scenario: getBreach throws NOT_FOUND on 404
- WHEN
getBreach("DoesNotExist")is called and the API returns404 - THEN the call throws
HibpErrorwith code"HIBP.NOT_FOUND"
Scenario: getAllBreaches forwards the domain filter
- WHEN
getAllBreaches({ domain: "adobe.com" })is called with an injectedfetch - 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 injectedfetch - 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
HibpClientconstructed withapiKey: "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
HibpErrorandJSON.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.tsis grepped forprocess.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
Clockinitialised att0 - WHEN
getBreachesForAccount(a)resolves att0andgetBreachesForAccount(b)is called att0 + 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
getBreachesForAccountcalls fire within 200 ms of each other - THEN
hibp.rate_limit_throttled_totalincrements 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
429withRetry-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
429withRetry-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
429withRetry-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
429for every attempt andmaxAttemptsis exhausted - WHEN
getBreachesForAccount("user@example.com")is called - THEN the call throws
HibpErrorwith 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
t0against an injectable clock - WHEN
get(key)is called att0 + 24h + 1ms - THEN the result is
undefined
Scenario: keys are normalised to uppercase hex
- WHEN
set("abcde", v)is called andget("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
HibpCacheLikewhosegetrecords 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
HibpClientconstructed withcache: 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
HibpErrorwith code"HIBP.MALFORMED_RESPONSE"
Scenario: strict mode rejects malformed count
- WHEN the response contains
1B41F11D33EAB4D6E16AAFA77B5BCD4A6BD:notanumber - THEN the call throws
HibpErrorwith code"HIBP.MALFORMED_RESPONSE"
Scenario: off-mode accepts lowercase hex
- GIVEN
HibpClientconstructed withstrict: 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 withdifferentialPrivacy: { extraPrefixes: 3 } - THEN the recorder captures exactly 4 outgoing fetches
Scenario: real prefix is always present in the dispatched set
- GIVEN a deterministic
Rngreturning 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 withdifferentialPrivacy: { 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
HibpClientconstructed withapiKey: "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/Adobefixture - 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
AbortSignalwhoseaborted === true - WHEN
checkPassword("anything", { signal })is called against a recording fetch - THEN zero fetches are recorded and the call throws
HibpErrorwith 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
AbortControlleraborted after the first chunk - WHEN
checkPassword("anything", { signal })is called - THEN the call throws
HibpErrorwith 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_mshas 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 andhibp.range.outcome_total{outcome="clean"}equals 1
Scenario: errors counter increments on every code
- WHEN a
429triggersRATE_LIMITEDand a malformed body triggersMALFORMED_RESPONSE - THEN
hibp.errors_total{code="HIBP.RATE_LIMITED"}equals 1 andhibp.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 founderrors
Scenario: root barrel uses explicit named re-exports
- WHEN
src/index.tsis 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"