Skip to content

Commit f08af27

Browse files
Add breachedDomain module (#538)
* feat: Add breachedDomain module Co-authored-by: justin.r.hall <justin.r.hall@gmail.com> * Refactor: Rename BreachedDomainResults to BreachedDomainsByEmailAlias Co-authored-by: justin.r.hall <justin.r.hall@gmail.com> * Increase maxSize for breached-domain.js in bundlewatch config Co-authored-by: justin.r.hall <justin.r.hall@gmail.com> * reorder ApiData members --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com>
1 parent e15c6a7 commit f08af27

File tree

9 files changed

+265
-0
lines changed

9 files changed

+265
-0
lines changed

.bundlewatch.config.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@
1414
"path": "dist/esm/breached-account.js",
1515
"maxSize": "1.2 kB"
1616
},
17+
{
18+
"path": "dist/esm/breached-domain.js",
19+
"maxSize": "1.2 kB"
20+
},
1721
{
1822
"path": "dist/esm/breaches.js",
1923
"maxSize": "1 kB"

.changeset/plain-cats-invite.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"hibp": minor
3+
---
4+
5+
Add `breachedDomain` module.
6+

API.md

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,18 @@
1212
without it will fail (unless you specify a <code>baseUrl</code> to a proxy that inserts
1313
a valid API key on your behalf).</p>
1414
</dd>
15+
<dt><a href="#breachedDomain">breachedDomain(domain, [options])</a> ⇒ <code><a href="#breach--objectedDomainsByEmailAlias">Promise.&lt;BreachedDomainsByEmailAlias&gt;</a></code> | <code>Promise.&lt;null&gt;</code></dt>
16+
<dd><p>Fetches all breached email addresses for a domain.</p>
17+
<p>The result maps email aliases (the local-part before the &#39;@&#39;) to an array of
18+
breach names. For example, querying <code>example.com</code> could return an object like
19+
<code>{ &quot;john&quot;: [&quot;Adobe&quot;], &quot;jane&quot;: [&quot;Adobe&quot;, &quot;Gawker&quot;] }</code>, corresponding to
20+
<code>john@example.com</code> and <code>jane@example.com</code>.</p>
21+
<p>🔑 <code>haveibeenpwned.com</code> requires an API key from
22+
<a href="https://haveibeenpwned.com/API/Key">https://haveibeenpwned.com/API/Key</a> for the <code>breacheddomain</code> endpoint. The
23+
<code>apiKey</code> option here is not explicitly required, but direct requests made
24+
without it will fail (unless you specify a <code>baseUrl</code> to a proxy that inserts
25+
a valid API key on your behalf).</p>
26+
</dd>
1527
<dt><a href="#breaches">breaches([options])</a> ⇒ <code><a href="#breach--object">Promise.&lt;Array.&lt;Breach&gt;&gt;</a></code></dt>
1628
<dd><p>Fetches all breach events in the system.</p>
1729
</dd>
@@ -73,6 +85,10 @@ a valid API key on your behalf).</p>
7385
<dt><a href="#breach--object">Breach</a> : <code>object</code></dt>
7486
<dd><p>An object representing a breach.</p>
7587
</dd>
88+
<dt><a href="#breach--objectedDomainsByEmailAlias">BreachedDomainsByEmailAlias</a> : <code>Object.&lt;string, Array.&lt;string&gt;&gt;</code></dt>
89+
<dd><p>An object mapping an email alias (local-part before the &#39;@&#39;) to the list of
90+
breach names that alias has appeared in for the specified domain.</p>
91+
</dd>
7692
<dt><a href="#Paste">Paste</a> : <code>object</code></dt>
7793
<dd><p>An object representing a paste.</p>
7894
</dd>
@@ -194,6 +210,49 @@ try {
194210
// ...
195211
}
196212
```
213+
<a name="breachedDomain"></a>
214+
215+
## breachedDomain(domain, [options]) ⇒ [<code>Promise.&lt;BreachedDomainsByEmailAlias&gt;</code>](#breach--objectedDomainsByEmailAlias) \| <code>Promise.&lt;null&gt;</code>
216+
Fetches all breached email addresses for a domain.
217+
218+
The result maps email aliases (the local-part before the '@') to an array of
219+
breach names. For example, querying `example.com` could return an object like
220+
`{ "john": ["Adobe"], "jane": ["Adobe", "Gawker"] }`, corresponding to
221+
`john@example.com` and `jane@example.com`.
222+
223+
🔑 `haveibeenpwned.com` requires an API key from
224+
https://haveibeenpwned.com/API/Key for the `breacheddomain` endpoint. The
225+
`apiKey` option here is not explicitly required, but direct requests made
226+
without it will fail (unless you specify a `baseUrl` to a proxy that inserts
227+
a valid API key on your behalf).
228+
229+
**Kind**: global function
230+
**Returns**: [<code>Promise.&lt;BreachedDomainsByEmailAlias&gt;</code>](#breach--objectedDomainsByEmailAlias) \| <code>Promise.&lt;null&gt;</code> - a Promise which
231+
resolves to an object mapping aliases to breach name arrays (or null if no
232+
results were found), or rejects with an Error
233+
234+
| Param | Type | Description |
235+
| --- | --- | --- |
236+
| domain | <code>string</code> | the domain to query (e.g., "example.com") |
237+
| [options] | <code>object</code> | a configuration object |
238+
| [options.apiKey] | <code>string</code> | an API key from https://haveibeenpwned.com/API/Key (default: undefined) |
239+
| [options.baseUrl] | <code>string</code> | a custom base URL for the haveibeenpwned.com API endpoints (default: `https://haveibeenpwned.com/api/v3`) |
240+
| [options.timeoutMs] | <code>number</code> | timeout for the request in milliseconds (default: none) |
241+
| [options.userAgent] | <code>string</code> | a custom string to send as the User-Agent field in the request headers (default: `hibp <version>`) |
242+
243+
**Example**
244+
```js
245+
try {
246+
const data = await breachedDomain("example.com", { apiKey: "my-api-key" });
247+
if (data) {
248+
// { "john": ["Adobe"], "jane": ["Adobe", "Gawker"] }
249+
} else {
250+
// no results
251+
}
252+
} catch (err) {
253+
// ...
254+
}
255+
```
197256
<a name="breaches"></a>
198257

199258
## breaches([options]) ⇒ <code><a href="#breach--object">Promise.&lt;Array.&lt;Breach&gt;&gt;</a></code>
@@ -573,6 +632,13 @@ An object representing a breach.
573632
| IsSubscriptionFree | <code>boolean</code> |
574633
| LogoPath | <code>string</code> |
575634

635+
<a name="BreachedDomainsByEmailAlias"></a>
636+
637+
## BreachedDomainsByEmailAlias : <code>Object.&lt;string, Array.&lt;string&gt;&gt;</code>
638+
An object mapping an email alias (local-part before the '@') to the list of
639+
breach names that alias has appeared in for the specified domain.
640+
641+
**Kind**: global typedef
576642
<a name="Paste"></a>
577643

578644
## Paste : <code>object</code>

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ browser.
3939
- Get the most recently added breach
4040
- Get a single breach event
4141
- Get all breaches for an account 🔑
42+
- Get all breached email addresses for a domain 🔑
4243
- Get all breach events in the system
4344
- Get all data classes
4445
- Get all pastes for an account 🔑
@@ -65,6 +66,7 @@ The following modules are available:
6566
- [breach](API.md#breach)
6667
- [breachedAccount](API.md#breachedaccount)
6768
- [breaches](API.md#breaches)
69+
- [breachedDomain](API.md#breacheddomain)
6870
- [dataClasses](API.md#dataclasses)
6971
- [latestBreach](API.md#latestbreach)
7072
- [pasteAccount](API.md#pasteaccount)
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { http } from 'msw';
3+
import { server } from '../../mocks/server.js';
4+
import { NOT_FOUND } from '../api/haveibeenpwned/responses.js';
5+
import { breachedDomain } from '../breached-domain.js';
6+
7+
describe('breachedDomain', () => {
8+
const DOMAIN_RESULTS = { alias1: ['Adobe'], alias2: ['Adobe', 'Gawker'] };
9+
10+
describe('found', () => {
11+
it('resolves with data from the remote API', () => {
12+
server.use(
13+
http.get('*', () => {
14+
return new Response(JSON.stringify(DOMAIN_RESULTS));
15+
}),
16+
);
17+
18+
return expect(breachedDomain('example.com', { apiKey: 'k' })).resolves.toEqual(
19+
DOMAIN_RESULTS,
20+
);
21+
});
22+
});
23+
24+
describe('not found', () => {
25+
it('resolves with null', () => {
26+
server.use(
27+
http.get('*', () => {
28+
return new Response(null, { status: NOT_FOUND.status });
29+
}),
30+
);
31+
32+
return expect(breachedDomain('example.com')).resolves.toBeNull();
33+
});
34+
});
35+
36+
describe('apiKey option', () => {
37+
it('sets the hibp-api-key header', async () => {
38+
expect.assertions(1);
39+
const apiKey = 'my-api-key';
40+
server.use(
41+
http.get('*', ({ request }) => {
42+
expect(request.headers.get('hibp-api-key')).toBe(apiKey);
43+
return new Response(JSON.stringify(DOMAIN_RESULTS));
44+
}),
45+
);
46+
47+
return breachedDomain('example.com', { apiKey });
48+
});
49+
});
50+
51+
describe('baseUrl option', () => {
52+
it('is the beginning of the final URL', () => {
53+
const baseUrl = 'https://my-hibp-proxy:8080';
54+
server.use(
55+
http.get(new RegExp(`^${baseUrl}`), () => {
56+
return new Response(JSON.stringify(DOMAIN_RESULTS));
57+
}),
58+
);
59+
60+
return expect(breachedDomain('example.com', { baseUrl })).resolves.toEqual(DOMAIN_RESULTS);
61+
});
62+
});
63+
64+
describe('timeoutMs option', () => {
65+
it('aborts the request after the given value', () => {
66+
expect.assertions(1);
67+
const timeoutMs = 1;
68+
server.use(
69+
http.get('*', async () => {
70+
await new Promise((resolve) => {
71+
setTimeout(resolve, timeoutMs + 1);
72+
});
73+
return new Response(JSON.stringify(DOMAIN_RESULTS));
74+
}),
75+
);
76+
77+
return expect(breachedDomain('example.com', { timeoutMs })).rejects.toMatchInlineSnapshot(
78+
`[TimeoutError: The operation was aborted due to timeout]`,
79+
);
80+
});
81+
});
82+
83+
describe('userAgent option', () => {
84+
it('is passed on as a request header', () => {
85+
expect.assertions(1);
86+
const userAgent = 'Custom UA';
87+
server.use(
88+
http.get('*', ({ request }) => {
89+
expect(request.headers.get('User-Agent')).toBe(userAgent);
90+
return new Response(JSON.stringify(DOMAIN_RESULTS));
91+
}),
92+
);
93+
94+
return breachedDomain('example.com', { userAgent });
95+
});
96+
});
97+
});

src/__tests__/hibp.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ describe('hibp', () => {
88
"RateLimitError": [Function],
99
"breach": [Function],
1010
"breachedAccount": [Function],
11+
"breachedDomain": [Function],
1112
"breaches": [Function],
1213
"dataClasses": [Function],
1314
"latestBreach": [Function],

src/api/haveibeenpwned/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ export interface SubscriptionStatus {
3939
IncludesStealerLogs: boolean;
4040
}
4141

42+
export type BreachedDomainsByEmailAlias = Record<string, string[]>;
43+
4244
//
4345
// Internal convenience types
4446
//
@@ -51,6 +53,7 @@ export interface SubscriptionStatus {
5153
export type ApiData =
5254
| Breach // breach
5355
| Breach[] // breachedaccount, breaches
56+
| BreachedDomainsByEmailAlias // breacheddomain
5457
| Paste[] // pasteaccount
5558
| string[] // dataclasses
5659
| SubscriptionStatus // subscription/status

src/breached-domain.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import type { BreachedDomainsByEmailAlias } from './api/haveibeenpwned/types.js';
2+
import { fetchFromApi } from './api/haveibeenpwned/fetch-from-api.js';
3+
4+
/**
5+
* An object mapping an email alias (local-part before the '@') to the list of
6+
* breach names that alias has appeared in for the specified domain.
7+
*
8+
* @typedef {Object.<string, string[]>} BreachedDomainsByEmailAlias
9+
*/
10+
11+
/**
12+
* Fetches all breached email addresses for a domain.
13+
*
14+
* The result maps email aliases (the local-part before the '@') to an array of
15+
* breach names. For example, querying `example.com` could return an object like
16+
* `{ "john": ["Adobe"], "jane": ["Adobe", "Gawker"] }`, corresponding to
17+
* `john@example.com` and `jane@example.com`.
18+
*
19+
* 🔑 `haveibeenpwned.com` requires an API key from
20+
* https://haveibeenpwned.com/API/Key for the `breacheddomain` endpoint. The
21+
* `apiKey` option here is not explicitly required, but direct requests made
22+
* without it will fail (unless you specify a `baseUrl` to a proxy that inserts
23+
* a valid API key on your behalf).
24+
*
25+
* @param {string} domain the domain to query (e.g., "example.com")
26+
* @param {object} [options] a configuration object
27+
* @param {string} [options.apiKey] an API key from
28+
* https://haveibeenpwned.com/API/Key (default: undefined)
29+
* @param {string} [options.baseUrl] a custom base URL for the
30+
* haveibeenpwned.com API endpoints (default:
31+
* `https://haveibeenpwned.com/api/v3`)
32+
* @param {number} [options.timeoutMs] timeout for the request in milliseconds
33+
* (default: none)
34+
* @param {string} [options.userAgent] a custom string to send as the User-Agent
35+
* field in the request headers (default: `hibp <version>`)
36+
* @returns {(Promise<BreachedDomainsByEmailAlias> | Promise<null>)} a Promise which
37+
* resolves to an object mapping aliases to breach name arrays (or null if no
38+
* results were found), or rejects with an Error
39+
* @example
40+
* try {
41+
* const data = await breachedDomain("example.com", { apiKey: "my-api-key" });
42+
* if (data) {
43+
* // { "john": ["Adobe"], "jane": ["Adobe", "Gawker"] }
44+
* } else {
45+
* // no results
46+
* }
47+
* } catch (err) {
48+
* // ...
49+
* }
50+
*/
51+
export function breachedDomain(
52+
domain: string,
53+
options: {
54+
/**
55+
* an API key from https://haveibeenpwned.com/API/Key (default: undefined)
56+
*/
57+
apiKey?: string;
58+
/**
59+
* a custom base URL for the haveibeenpwned.com API endpoints (default:
60+
* `https://haveibeenpwned.com/api/v3`)
61+
*/
62+
baseUrl?: string;
63+
/**
64+
* timeout for the request in milliseconds (default: none)
65+
*/
66+
timeoutMs?: number;
67+
/**
68+
* a custom string to send as the User-Agent field in the request headers
69+
* (default: `hibp <version>`)
70+
*/
71+
userAgent?: string;
72+
} = {},
73+
): Promise<BreachedDomainsByEmailAlias | null> {
74+
const { apiKey, baseUrl, timeoutMs, userAgent } = options;
75+
const endpoint = `/breacheddomain/${encodeURIComponent(domain)}`;
76+
77+
return fetchFromApi(endpoint, {
78+
apiKey,
79+
baseUrl,
80+
timeoutMs,
81+
userAgent,
82+
}) as Promise<BreachedDomainsByEmailAlias | null>;
83+
}

src/hibp.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { RateLimitError } from './api/haveibeenpwned/fetch-from-api.js';
22
import { breach } from './breach.js';
33
import { breachedAccount } from './breached-account.js';
4+
import { breachedDomain } from './breached-domain.js';
45
import { breaches } from './breaches.js';
56
import { dataClasses } from './data-classes.js';
67
import { latestBreach } from './latest-breach.js';
@@ -24,6 +25,7 @@ export type * from './api/haveibeenpwned/types.js';
2425
export {
2526
breach,
2627
breachedAccount,
28+
breachedDomain,
2729
breaches,
2830
dataClasses,
2931
latestBreach,
@@ -39,6 +41,7 @@ export {
3941
export interface HIBP {
4042
breach: typeof breach;
4143
breachedAccount: typeof breachedAccount;
44+
breachedDomain: typeof breachedDomain;
4245
breaches: typeof breaches;
4346
dataClasses: typeof dataClasses;
4447
latestBreach: typeof latestBreach;

0 commit comments

Comments
 (0)