@dregs/sdk 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -0
- package/LICENSE +21 -0
- package/README.md +287 -0
- package/dist/chunk-AIUWT3Z7.js +149 -0
- package/dist/chunk-AIUWT3Z7.js.map +1 -0
- package/dist/chunk-K63AUEQR.cjs +170 -0
- package/dist/chunk-K63AUEQR.cjs.map +1 -0
- package/dist/index.cjs +645 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +576 -0
- package/dist/index.d.ts +576 -0
- package/dist/index.js +554 -0
- package/dist/index.js.map +1 -0
- package/dist/webhooks.cjs +36 -0
- package/dist/webhooks.cjs.map +1 -0
- package/dist/webhooks.d.cts +83 -0
- package/dist/webhooks.d.ts +83 -0
- package/dist/webhooks.js +3 -0
- package/dist/webhooks.js.map +1 -0
- package/package.json +86 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to
|
|
5
|
+
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
## [0.1.0] - 2026-09-22
|
|
10
|
+
|
|
11
|
+
The first release. A server-side client for tracking events and reading scores, ported from the
|
|
12
|
+
reference [Python SDK](https://github.com/dregs-sdk/dregs-sdk-python).
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- A `Dregs` client, authenticating with a credential's `sk_` secret key against a configurable
|
|
17
|
+
base URL. Every method returns a promise; there is no separate async client.
|
|
18
|
+
- `track()` for recording backend events against an identity, always sending an idempotency `id`
|
|
19
|
+
so a retry cannot double-count.
|
|
20
|
+
- `identities.get()`, `identities.scores()`, `identities.analysis()`, and `identities.analyze()`.
|
|
21
|
+
- Typed errors for 400, 401, 402, 403, 404, 429, and 5xx, plus connection and timeout failures,
|
|
22
|
+
all deriving from `DregsError`.
|
|
23
|
+
- Automatic retries with exponential backoff and full jitter, honouring `Retry-After`.
|
|
24
|
+
- `verifyWebhook()` for checking a webhook's signature and rejecting replays, exported from the
|
|
25
|
+
package root and from the `@dregs/sdk/webhooks` subpath.
|
|
26
|
+
- ESM and CommonJS builds with generated declarations for both, and no runtime dependencies.
|
|
27
|
+
|
|
28
|
+
[Unreleased]: https://github.com/dregs-sdk/dregs-sdk-typescript/compare/v0.1.0...HEAD
|
|
29
|
+
[0.1.0]: https://github.com/dregs-sdk/dregs-sdk-typescript/releases/tag/v0.1.0
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dregs
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
# Dregs TypeScript SDK
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@dregs/sdk)
|
|
4
|
+
[](https://www.npmjs.com/package/@dregs/sdk)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
|
|
7
|
+
The official TypeScript client for [Dregs](https://dregs.com), which scores the users of your application
|
|
8
|
+
for fraud and abuse across four categories: humanity, authenticity, uniqueness, and behavior.
|
|
9
|
+
|
|
10
|
+
Send events from your backend, read back the scores and the observations behind them.
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
npm install @dregs/sdk
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Node 20 or newer. No runtime dependencies: the SDK calls the runtime's own `fetch`.
|
|
17
|
+
|
|
18
|
+
> This is the **server-side** SDK, which authenticates with a secret key and can read scores.
|
|
19
|
+
> The browser tracking script is a separate package, [`dregs`](https://www.npmjs.com/package/dregs),
|
|
20
|
+
> and uses the public key. You will usually want both: the tracker in the browser, this on your
|
|
21
|
+
> backend.
|
|
22
|
+
|
|
23
|
+
## Getting started
|
|
24
|
+
|
|
25
|
+
You need the **secret key** from an API credential, which you will find under **Settings → Credentials**
|
|
26
|
+
in the Dregs dashboard. It starts with `sk_`. The `pk_` public key is for the browser tracker and cannot
|
|
27
|
+
read identities or scores.
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
import { Dregs } from '@dregs/sdk';
|
|
31
|
+
|
|
32
|
+
const client = new Dregs({ secretKey: process.env.DREGS_SECRET_KEY });
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
The key is read from `DREGS_SECRET_KEY` when you do not pass one, so `new Dregs()` on its own is usually
|
|
36
|
+
enough. Build one at startup and keep it; there is nothing to close.
|
|
37
|
+
|
|
38
|
+
CommonJS works too:
|
|
39
|
+
|
|
40
|
+
```js
|
|
41
|
+
const { Dregs } = require('@dregs/sdk');
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Tracking events
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
await client.track('user.signup', {
|
|
48
|
+
identity: 'user_12345',
|
|
49
|
+
data: { plan: 'pro', referrer: 'partner-x' },
|
|
50
|
+
identityData: { email: 'ada@example.com', name: 'Ada Lovelace' },
|
|
51
|
+
});
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
`identity` is your own id for the user — the same one you pass to `dregs.identify()` in the browser
|
|
55
|
+
tracker, and the one you look scores up by. It is required: a server-side event carries no device
|
|
56
|
+
signature, so the identity is the only thing tying the event to a user.
|
|
57
|
+
|
|
58
|
+
`identityData` carries attributes of the _user_ rather than the event. The analyzers lean on these
|
|
59
|
+
heavily, so send them whenever you have them. Name the keys the way your application already does and
|
|
60
|
+
map them to Dregs's canonical fields under **Settings → Mappings**; the same goes for event names.
|
|
61
|
+
|
|
62
|
+
### Idempotency
|
|
63
|
+
|
|
64
|
+
Every event is sent with an `id`, which makes ingestion idempotent: reposting the same id returns the
|
|
65
|
+
original event instead of recording a second one. Pass the id your application already has, and a retry
|
|
66
|
+
after a timeout can never double-count.
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
await client.track('purchase', { identity: 'user_12345', eventId: `order-${order.id}` });
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
When you omit it the SDK generates one, which is what makes its own retries safe.
|
|
73
|
+
|
|
74
|
+
### What comes back
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
const result = await client.track('user.signup', { identity: 'user_12345' });
|
|
78
|
+
|
|
79
|
+
result.accepted; // true when Dregs recorded the event
|
|
80
|
+
result.id; // the event's id
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
`accepted` is `false` in the uncommon case where Dregs accepts the request without recording
|
|
84
|
+
an event. Failures that are yours to act on throw instead — see [Errors](#errors).
|
|
85
|
+
|
|
86
|
+
## Reading scores
|
|
87
|
+
|
|
88
|
+
```ts
|
|
89
|
+
const scores = await client.identities.scores('user_12345');
|
|
90
|
+
|
|
91
|
+
scores.humanity; // 85
|
|
92
|
+
scores.authenticity; // 72
|
|
93
|
+
scores.uniqueness; // 91
|
|
94
|
+
scores.behavior; // 68
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
This is the cheap read and the one most integrations want. A category Dregs has not scored yet reads as
|
|
98
|
+
`null`, and a brand-new identity comes back empty. `Scores` is an array, so you can iterate, map, and
|
|
99
|
+
destructure it as usual.
|
|
100
|
+
|
|
101
|
+
Scoring is **asynchronous**. Scores appear moments after the events that move them, not in the same
|
|
102
|
+
breath, so read them at a decision point rather than immediately after a `track()` call.
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
if (scores.authenticity !== null && scores.authenticity < 40) {
|
|
106
|
+
await holdForReview('user_12345');
|
|
107
|
+
}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
### Seeing exactly why
|
|
111
|
+
|
|
112
|
+
The scores are the summary; the observations are the evidence. When you need to show or log _why_ an
|
|
113
|
+
identity scored the way it did, ask for the analysis.
|
|
114
|
+
|
|
115
|
+
```ts
|
|
116
|
+
const analysis = await client.identities.analysis('user_12345');
|
|
117
|
+
|
|
118
|
+
for (const observation of analysis.observations) {
|
|
119
|
+
console.log(`${observation.label}: ${observation.explanation} (value ${observation.value})`);
|
|
120
|
+
}
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Each observation carries the analyzer that produced it, a `value` from 0.0 (suspicious) to 1.0
|
|
124
|
+
(legitimate), a `confidence`, a `weight`, and the counts behind the finding in `metadata`. `analysis()`
|
|
125
|
+
throws `NotFoundError` until the identity has been analyzed at least once.
|
|
126
|
+
|
|
127
|
+
### The whole identity
|
|
128
|
+
|
|
129
|
+
```ts
|
|
130
|
+
const identity = await client.identities.get('user_12345');
|
|
131
|
+
|
|
132
|
+
identity.displayEmail; // "ada@example.com"
|
|
133
|
+
identity.humanityScore; // 85
|
|
134
|
+
identity.badges; // [{ name: "Account Takeover Suspected", ... }]
|
|
135
|
+
identity.data; // every attribute you have sent
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
### Forcing a rescore
|
|
139
|
+
|
|
140
|
+
```ts
|
|
141
|
+
await client.identities.analyze('user_12345');
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
This queues the work and resolves; it does not wait for the cycle to finish. Dregs rescores on its own
|
|
145
|
+
as events arrive, so you rarely need this outside of a support or backfill flow.
|
|
146
|
+
|
|
147
|
+
## Errors
|
|
148
|
+
|
|
149
|
+
```ts
|
|
150
|
+
import { DregsError, NotFoundError, QuotaExceededError, RateLimitError } from '@dregs/sdk';
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
await client.track('user.signup', { identity: 'user_12345' });
|
|
154
|
+
} catch (error) {
|
|
155
|
+
if (error instanceof QuotaExceededError) {
|
|
156
|
+
// over the monthly event limit; the event was not queued
|
|
157
|
+
} else if (error instanceof RateLimitError) {
|
|
158
|
+
// ingesting too fast; error.retryAfter when the server said how long
|
|
159
|
+
} else if (error instanceof DregsError) {
|
|
160
|
+
// anything else this library throws
|
|
161
|
+
} else {
|
|
162
|
+
throw error;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
| Error | When |
|
|
168
|
+
| ----------------------- | -------------------------------------------------- |
|
|
169
|
+
| `BadRequestError` | 400, the event was malformed |
|
|
170
|
+
| `AuthenticationError` | 401, the secret key was not recognized |
|
|
171
|
+
| `QuotaExceededError` | 402, the account is over its monthly event limit |
|
|
172
|
+
| `PermissionDeniedError` | 403, the credential may not do this |
|
|
173
|
+
| `NotFoundError` | 404, no such identity, or it has not been analyzed |
|
|
174
|
+
| `RateLimitError` | 429, too many requests |
|
|
175
|
+
| `ServerError` | 5xx |
|
|
176
|
+
| `DregsTimeoutError` | the request timed out |
|
|
177
|
+
| `DregsConnectionError` | the request never reached Dregs |
|
|
178
|
+
|
|
179
|
+
All of them derive from `DregsError`. Those that reached the API also carry `statusCode`, `body`, and
|
|
180
|
+
`requestId`; `error.message` is the message the API sent, and `error.toString()` prefixes it with the
|
|
181
|
+
status and the request id, which is the form worth putting in a log line.
|
|
182
|
+
|
|
183
|
+
Arguments the SDK can reject without asking Dregs — a missing identity, an event id over 64 characters,
|
|
184
|
+
a `pk_` key — throw a plain `TypeError` before anything is sent.
|
|
185
|
+
|
|
186
|
+
### Retries
|
|
187
|
+
|
|
188
|
+
Connection failures, timeouts, 408s, 429s, and 5xx are retried automatically with exponential backoff and
|
|
189
|
+
full jitter, honouring `Retry-After` when the server sends one. Two retries by default:
|
|
190
|
+
|
|
191
|
+
```ts
|
|
192
|
+
const client = new Dregs({ maxRetries: 5 }); // or 0 to handle it yourself
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
## Promises, not an async twin
|
|
196
|
+
|
|
197
|
+
There is one `Dregs` class and every method returns a promise. JavaScript has no meaningful sync/async
|
|
198
|
+
split, so unlike the Python SDK there is no async client to choose between — `await` everything.
|
|
199
|
+
|
|
200
|
+
## Webhooks
|
|
201
|
+
|
|
202
|
+
Dregs signs every webhook with the channel's signing secret. Verify it against the **raw request body**
|
|
203
|
+
before acting on the payload — a re-serialized object will not match, because key order and whitespace
|
|
204
|
+
change.
|
|
205
|
+
|
|
206
|
+
```ts
|
|
207
|
+
import express from 'express';
|
|
208
|
+
import { verifyWebhook, WebhookVerificationError } from '@dregs/sdk/webhooks';
|
|
209
|
+
|
|
210
|
+
app.post('/webhooks/dregs', express.raw({ type: 'application/json' }), (req, res) => {
|
|
211
|
+
let event;
|
|
212
|
+
|
|
213
|
+
try {
|
|
214
|
+
event = verifyWebhook({
|
|
215
|
+
payload: req.body, // the Buffer, not req.body parsed as JSON
|
|
216
|
+
signature: req.header('X-Dregs-Signature') ?? '',
|
|
217
|
+
secret: process.env.DREGS_WEBHOOK_SECRET!,
|
|
218
|
+
});
|
|
219
|
+
} catch (error) {
|
|
220
|
+
if (error instanceof WebhookVerificationError) {
|
|
221
|
+
return res.sendStatus(400);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
throw error;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
handle(event);
|
|
228
|
+
|
|
229
|
+
res.sendStatus(204);
|
|
230
|
+
});
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
`express.raw()` matters: the default `express.json()` hands you a parsed object and the original bytes are
|
|
234
|
+
gone. The helpers are exported from the package root as well, so `import { verifyWebhook } from '@dregs/sdk'`
|
|
235
|
+
works if you would rather not reach for the subpath.
|
|
236
|
+
|
|
237
|
+
`verifyWebhook` also rejects payloads older than five minutes as replays; pass `tolerance: null` to skip
|
|
238
|
+
that if you are deduplicating on the event id yourself. The signing secret is shown once, when you create
|
|
239
|
+
the webhook channel, and is not your API secret key.
|
|
240
|
+
|
|
241
|
+
## Configuration
|
|
242
|
+
|
|
243
|
+
```ts
|
|
244
|
+
const client = new Dregs({
|
|
245
|
+
secretKey: undefined, // defaults to $DREGS_SECRET_KEY
|
|
246
|
+
baseUrl: undefined, // defaults to $DREGS_BASE_URL, then https://dregs.com/api
|
|
247
|
+
timeout: 10_000, // milliseconds
|
|
248
|
+
maxRetries: 2,
|
|
249
|
+
fetch: undefined, // bring your own fetch for a proxy agent, custom TLS, or instrumentation
|
|
250
|
+
});
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
## Type checking
|
|
254
|
+
|
|
255
|
+
The package ships generated declarations for both the ESM and CommonJS entry points, so there is no
|
|
256
|
+
`@types/dregs` to install and every public type is exported. Responses are plain readonly objects; each
|
|
257
|
+
one also keeps the body it was built from in `raw`, so a field Dregs adds after this release is reachable
|
|
258
|
+
without waiting for an SDK upgrade.
|
|
259
|
+
|
|
260
|
+
```ts
|
|
261
|
+
import type { Analysis, Category, Identity, Observation, Score, TrackResult } from '@dregs/sdk';
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
## Contributing
|
|
265
|
+
|
|
266
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md). The short version:
|
|
267
|
+
|
|
268
|
+
```bash
|
|
269
|
+
npm ci
|
|
270
|
+
npm test
|
|
271
|
+
npm run lint
|
|
272
|
+
npm run typecheck
|
|
273
|
+
npm run build
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
`npm ci` installs exactly what `package-lock.json` pins and fails if the lock is out of step, so the same
|
|
277
|
+
commands produce the same environment locally and in CI.
|
|
278
|
+
|
|
279
|
+
## Links
|
|
280
|
+
|
|
281
|
+
- [Dregs manual](https://dregs.com/manual/) and [REST API reference](https://dregs.com/manual/api/)
|
|
282
|
+
- [Dregs MCP server](https://github.com/dregs-sdk/dregs-mcp), for connecting AI agents to your data
|
|
283
|
+
- [Security policy](SECURITY.md)
|
|
284
|
+
|
|
285
|
+
## License
|
|
286
|
+
|
|
287
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from 'crypto';
|
|
2
|
+
|
|
3
|
+
// src/webhooks.ts
|
|
4
|
+
|
|
5
|
+
// src/errors.ts
|
|
6
|
+
var DregsError = class extends Error {
|
|
7
|
+
constructor(message, options) {
|
|
8
|
+
super(message, options);
|
|
9
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
10
|
+
this.name = new.target.name;
|
|
11
|
+
}
|
|
12
|
+
};
|
|
13
|
+
var DregsConnectionError = class extends DregsError {
|
|
14
|
+
};
|
|
15
|
+
var DregsTimeoutError = class extends DregsConnectionError {
|
|
16
|
+
};
|
|
17
|
+
var WebhookVerificationError = class extends DregsError {
|
|
18
|
+
};
|
|
19
|
+
var DregsAPIError = class extends DregsError {
|
|
20
|
+
/** The HTTP status code. */
|
|
21
|
+
statusCode;
|
|
22
|
+
/** The parsed JSON body, or `null` when the response was not JSON. */
|
|
23
|
+
body;
|
|
24
|
+
/**
|
|
25
|
+
* Value of the `X-Request-Id` response header, when present.
|
|
26
|
+
*
|
|
27
|
+
* Quote it when you report a problem: it is what lets Dregs find your exact request.
|
|
28
|
+
*/
|
|
29
|
+
requestId;
|
|
30
|
+
constructor(message, options) {
|
|
31
|
+
super(message, options.cause === void 0 ? void 0 : { cause: options.cause });
|
|
32
|
+
this.statusCode = options.statusCode;
|
|
33
|
+
this.body = options.body ?? null;
|
|
34
|
+
this.requestId = options.requestId ?? null;
|
|
35
|
+
}
|
|
36
|
+
toString() {
|
|
37
|
+
const suffix = this.requestId ? ` (request ${this.requestId})` : "";
|
|
38
|
+
return `${this.name}: HTTP ${this.statusCode}: ${this.message}${suffix}`;
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
var BadRequestError = class extends DregsAPIError {
|
|
42
|
+
};
|
|
43
|
+
var AuthenticationError = class extends DregsAPIError {
|
|
44
|
+
};
|
|
45
|
+
var QuotaExceededError = class extends DregsAPIError {
|
|
46
|
+
};
|
|
47
|
+
var PermissionDeniedError = class extends DregsAPIError {
|
|
48
|
+
};
|
|
49
|
+
var NotFoundError = class extends DregsAPIError {
|
|
50
|
+
};
|
|
51
|
+
var RateLimitError = class extends DregsAPIError {
|
|
52
|
+
/**
|
|
53
|
+
* Seconds to wait before retrying, from the `Retry-After` header when the response carried a
|
|
54
|
+
* numeric one, and `null` otherwise.
|
|
55
|
+
*/
|
|
56
|
+
retryAfter;
|
|
57
|
+
constructor(message, options = {}) {
|
|
58
|
+
super(message, { ...options, statusCode: options.statusCode ?? 429 });
|
|
59
|
+
this.retryAfter = options.retryAfter ?? null;
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
var ServerError = class extends DregsAPIError {
|
|
63
|
+
};
|
|
64
|
+
var STATUS_ERRORS = {
|
|
65
|
+
400: BadRequestError,
|
|
66
|
+
401: AuthenticationError,
|
|
67
|
+
402: QuotaExceededError,
|
|
68
|
+
403: PermissionDeniedError,
|
|
69
|
+
404: NotFoundError
|
|
70
|
+
};
|
|
71
|
+
function errorForStatus(statusCode) {
|
|
72
|
+
const mapped = STATUS_ERRORS[statusCode];
|
|
73
|
+
if (mapped) {
|
|
74
|
+
return mapped;
|
|
75
|
+
}
|
|
76
|
+
return statusCode >= 500 ? ServerError : DregsAPIError;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// src/webhooks.ts
|
|
80
|
+
var SIGNATURE_HEADER = "X-Dregs-Signature";
|
|
81
|
+
var TIMESTAMP_HEADER = "X-Dregs-Timestamp";
|
|
82
|
+
var EVENT_HEADER = "X-Dregs-Event";
|
|
83
|
+
var DEFAULT_TOLERANCE_SECONDS = 300;
|
|
84
|
+
function computeWebhookSignature(payload, secret) {
|
|
85
|
+
return createHmac("sha256", secret).update(toBytes(payload)).digest("hex");
|
|
86
|
+
}
|
|
87
|
+
function verifyWebhookSignature(payload, signature, secret) {
|
|
88
|
+
if (!signature || !secret) {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
const expected = Buffer.from(computeWebhookSignature(payload, secret), "utf8");
|
|
92
|
+
const received = Buffer.from(signature.trim(), "utf8");
|
|
93
|
+
return expected.length === received.length && timingSafeEqual(expected, received);
|
|
94
|
+
}
|
|
95
|
+
function verifyWebhook(options) {
|
|
96
|
+
const { payload, signature, secret, tolerance = DEFAULT_TOLERANCE_SECONDS, now } = options;
|
|
97
|
+
if (!verifyWebhookSignature(payload, signature, secret)) {
|
|
98
|
+
throw new WebhookVerificationError(
|
|
99
|
+
"The webhook signature did not match. Check that you are verifying the raw request body rather than a re-serialized copy, and that the signing secret belongs to the channel that sent this delivery."
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
let event;
|
|
103
|
+
try {
|
|
104
|
+
event = JSON.parse(toText(payload));
|
|
105
|
+
} catch (cause) {
|
|
106
|
+
throw new WebhookVerificationError(
|
|
107
|
+
`The webhook body was not valid JSON: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
108
|
+
{ cause }
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
if (typeof event !== "object" || event === null || Array.isArray(event)) {
|
|
112
|
+
throw new WebhookVerificationError("The webhook body was not a JSON object.");
|
|
113
|
+
}
|
|
114
|
+
const body = event;
|
|
115
|
+
if (tolerance !== null && tolerance !== void 0) {
|
|
116
|
+
checkFreshness(body, tolerance, now);
|
|
117
|
+
}
|
|
118
|
+
return body;
|
|
119
|
+
}
|
|
120
|
+
function checkFreshness(event, tolerance, now) {
|
|
121
|
+
const raw = event.timestamp;
|
|
122
|
+
if (typeof raw !== "string" || !raw) {
|
|
123
|
+
throw new WebhookVerificationError(
|
|
124
|
+
"The webhook carried no timestamp, so it cannot be checked for replay. Pass tolerance: null if you are deduplicating deliveries some other way."
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
const sent = new Date(raw);
|
|
128
|
+
if (Number.isNaN(sent.getTime())) {
|
|
129
|
+
throw new WebhookVerificationError(
|
|
130
|
+
`The webhook timestamp was unreadable: ${JSON.stringify(raw)}`
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
const age = Math.abs(((now ?? /* @__PURE__ */ new Date()).getTime() - sent.getTime()) / 1e3);
|
|
134
|
+
if (age > tolerance) {
|
|
135
|
+
throw new WebhookVerificationError(
|
|
136
|
+
`The webhook timestamp is ${age.toFixed(0)}s away from now, beyond the ${tolerance}s tolerance. Treating it as a replay.`
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
function toBytes(payload) {
|
|
141
|
+
return typeof payload === "string" ? Buffer.from(payload, "utf8") : Buffer.from(payload);
|
|
142
|
+
}
|
|
143
|
+
function toText(payload) {
|
|
144
|
+
return typeof payload === "string" ? payload : Buffer.from(payload).toString("utf8");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export { AuthenticationError, BadRequestError, DEFAULT_TOLERANCE_SECONDS, DregsAPIError, DregsConnectionError, DregsError, DregsTimeoutError, EVENT_HEADER, NotFoundError, PermissionDeniedError, QuotaExceededError, RateLimitError, SIGNATURE_HEADER, ServerError, TIMESTAMP_HEADER, WebhookVerificationError, computeWebhookSignature, errorForStatus, verifyWebhook, verifyWebhookSignature };
|
|
148
|
+
//# sourceMappingURL=chunk-AIUWT3Z7.js.map
|
|
149
|
+
//# sourceMappingURL=chunk-AIUWT3Z7.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/errors.ts","../src/webhooks.ts"],"names":[],"mappings":"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","file":"chunk-AIUWT3Z7.js","sourcesContent":["/**\n * Errors thrown by the Dregs SDK.\n *\n * Everything this library throws derives from {@link DregsError}, so a caller that only wants a\n * coarse \"the Dregs call failed\" branch can catch that one class. Errors that came back from the\n * API carry the HTTP status and the parsed body; errors that never reached the API (DNS failure,\n * connection refused, timeout) derive from {@link DregsConnectionError} instead.\n *\n * @module\n */\n\n/**\n * Base class for everything this library throws.\n *\n * `instanceof DregsError` is the one check that catches every failure mode, including webhook\n * verification and transport failures that never produced an HTTP status.\n */\nexport class DregsError extends Error {\n constructor(message: string, options?: ErrorOptions) {\n super(message, options);\n\n // Subclassing a built-in loses the prototype link under a downlevelled target, so pin it\n // back on. Without this, `err instanceof RateLimitError` can quietly answer false.\n Object.setPrototypeOf(this, new.target.prototype);\n\n this.name = new.target.name;\n }\n}\n\n/** The request never reached Dregs: DNS, TCP, TLS, or a dropped connection. */\nexport class DregsConnectionError extends DregsError {}\n\n/** The request was still outstanding when the configured timeout elapsed. */\nexport class DregsTimeoutError extends DregsConnectionError {}\n\n/** An incoming webhook did not verify against the channel's signing secret. */\nexport class WebhookVerificationError extends DregsError {}\n\n/** The fields carried by every error that reached the API and came back an error. */\nexport interface DregsAPIErrorOptions {\n /** The HTTP status code. */\n statusCode: number;\n /** The parsed JSON body, or `null` when the response was not JSON. */\n body?: unknown;\n /** Value of the `X-Request-Id` response header, when the response carried one. */\n requestId?: string | null;\n /** The underlying cause, when there is one worth keeping. */\n cause?: unknown;\n}\n\n/**\n * Dregs answered, and the answer was an error.\n *\n * `message` is the human-readable message the API sent, so `err.message` reads the way a JS\n * caller expects. `toString()` prefixes it with the status and the request id, which is the form\n * worth putting in a log line when you open a support ticket.\n */\nexport class DregsAPIError extends DregsError {\n /** The HTTP status code. */\n readonly statusCode: number;\n\n /** The parsed JSON body, or `null` when the response was not JSON. */\n readonly body: unknown;\n\n /**\n * Value of the `X-Request-Id` response header, when present.\n *\n * Quote it when you report a problem: it is what lets Dregs find your exact request.\n */\n readonly requestId: string | null;\n\n constructor(message: string, options: DregsAPIErrorOptions) {\n super(message, options.cause === undefined ? undefined : { cause: options.cause });\n\n this.statusCode = options.statusCode;\n this.body = options.body ?? null;\n this.requestId = options.requestId ?? null;\n }\n\n override toString(): string {\n const suffix = this.requestId ? ` (request ${this.requestId})` : '';\n\n return `${this.name}: HTTP ${this.statusCode}: ${this.message}${suffix}`;\n }\n}\n\n/**\n * 400. The request was malformed or missing something Dregs requires.\n *\n * For event ingestion this most often means the event carried neither an identity nor a device,\n * or the body failed validation.\n */\nexport class BadRequestError extends DregsAPIError {}\n\n/** 401. The secret key was missing, unrecognized, revoked, or expired. */\nexport class AuthenticationError extends DregsAPIError {}\n\n/**\n * 402. The account is over its monthly event limit and ingestion is refused.\n *\n * Events are not queued while an account is over its limit, so the caller decides whether to drop\n * the event or hold it. The limit resets with the billing period; upgrading the plan clears it\n * immediately.\n */\nexport class QuotaExceededError extends DregsAPIError {}\n\n/** 403. The credential authenticated but is not allowed to do this. */\nexport class PermissionDeniedError extends DregsAPIError {}\n\n/** 404. No such identity, or no analysis has been run for it yet. */\nexport class NotFoundError extends DregsAPIError {}\n\n/** The fields carried by a 429, on top of the usual API error fields. */\nexport interface RateLimitErrorOptions extends Omit<DregsAPIErrorOptions, 'statusCode'> {\n statusCode?: number;\n /** Seconds to wait before retrying, from the `Retry-After` header. */\n retryAfter?: number | null;\n}\n\n/**\n * 429. The credential exceeded its request rate limit.\n *\n * The client retries these on its own; you only see one when the retries were exhausted or turned\n * off. Wait {@link RateLimitError.retryAfter} seconds before trying again when it is set.\n */\nexport class RateLimitError extends DregsAPIError {\n /**\n * Seconds to wait before retrying, from the `Retry-After` header when the response carried a\n * numeric one, and `null` otherwise.\n */\n readonly retryAfter: number | null;\n\n constructor(message: string, options: RateLimitErrorOptions = {}) {\n super(message, { ...options, statusCode: options.statusCode ?? 429 });\n\n this.retryAfter = options.retryAfter ?? null;\n }\n}\n\n/** 5xx. Something went wrong inside Dregs. These are retried automatically. */\nexport class ServerError extends DregsAPIError {}\n\nconst STATUS_ERRORS: Readonly<\n Record<number, new (message: string, options: DregsAPIErrorOptions) => DregsAPIError>\n> = {\n 400: BadRequestError,\n 401: AuthenticationError,\n 402: QuotaExceededError,\n 403: PermissionDeniedError,\n 404: NotFoundError,\n};\n\n/**\n * Returns the error class that represents `statusCode`.\n *\n * 429 is deliberately absent from the table: it needs the `Retry-After` header, so the client\n * builds a {@link RateLimitError} directly rather than going through here.\n */\nexport function errorForStatus(\n statusCode: number,\n): new (message: string, options: DregsAPIErrorOptions) => DregsAPIError {\n const mapped = STATUS_ERRORS[statusCode];\n\n if (mapped) {\n return mapped;\n }\n\n return statusCode >= 500 ? ServerError : DregsAPIError;\n}\n","/**\n * Verifying webhooks Dregs sends you.\n *\n * Dregs signs every webhook with the channel's signing secret: `X-Dregs-Signature` is the\n * hex-encoded HMAC-SHA256 of the raw request body. Verify it before you act on the payload, and\n * verify it against the bytes you received rather than a re-serialized object, because\n * re-serializing changes key order and whitespace and will not match.\n *\n * ```ts\n * import { verifyWebhook } from '@dregs/sdk/webhooks';\n *\n * app.post('/webhooks/dregs', express.raw({ type: 'application/json' }), (req, res) => {\n * const event = verifyWebhook({\n * payload: req.body, // the Buffer, not req.body parsed as JSON\n * signature: req.header('X-Dregs-Signature') ?? '',\n * secret: process.env.DREGS_WEBHOOK_SECRET!,\n * });\n *\n * handle(event);\n * });\n * ```\n *\n * The signing secret is shown once, when you create the webhook channel. It is not your API\n * secret key: one authenticates you to Dregs, the other proves a payload came from Dregs.\n *\n * @module\n */\n\nimport { createHmac, timingSafeEqual } from 'node:crypto';\n\nimport { WebhookVerificationError } from './errors.js';\n\n/** The header carrying the hex-encoded HMAC-SHA256 of the raw body. */\nexport const SIGNATURE_HEADER = 'X-Dregs-Signature';\n\n/** The header carrying the delivery's timestamp. */\nexport const TIMESTAMP_HEADER = 'X-Dregs-Timestamp';\n\n/** The header naming the event type. */\nexport const EVENT_HEADER = 'X-Dregs-Event';\n\n/** How far out of date a webhook's timestamp may be before {@link verifyWebhook} rejects it. */\nexport const DEFAULT_TOLERANCE_SECONDS = 300;\n\n/**\n * A raw webhook body.\n *\n * A `Buffer` or `Uint8Array` is what you want: the bytes exactly as received. A string is\n * accepted for frameworks that hand you the raw text, and is hashed as UTF-8.\n */\nexport type WebhookPayload = string | Uint8Array;\n\n/** A verified webhook body: `event`, `timestamp`, and the payload for that event. */\nexport type WebhookEvent = Record<string, unknown>;\n\n/** The arguments to {@link verifyWebhook}. */\nexport interface VerifyWebhookOptions {\n /** The raw request body, exactly as received. Not a parsed object. */\n payload: WebhookPayload;\n\n /** The `X-Dregs-Signature` header. */\n signature: string;\n\n /** The channel's signing secret. */\n secret: string;\n\n /**\n * How many seconds out of date the payload's own `timestamp` may be before it is treated as a\n * replay. Defaults to 300. Pass `null` to skip the check, which you should only do if you are\n * deduplicating on the event id yourself.\n *\n * The timestamp is inside the signed body, so an attacker cannot alter it without breaking\n * the signature.\n */\n tolerance?: number | null;\n\n /** The current time. For tests. */\n now?: Date;\n}\n\n/** Returns the hex-encoded HMAC-SHA256 of `payload` under `secret`. */\nexport function computeWebhookSignature(payload: WebhookPayload, secret: string): string {\n return createHmac('sha256', secret).update(toBytes(payload)).digest('hex');\n}\n\n/**\n * Returns whether `signature` matches `payload`.\n *\n * The comparison is constant-time. Prefer {@link verifyWebhook}, which also rejects replays and\n * hands back the parsed event; reach for this one only when you need the boolean.\n */\nexport function verifyWebhookSignature(\n payload: WebhookPayload,\n signature: string,\n secret: string,\n): boolean {\n if (!signature || !secret) {\n return false;\n }\n\n const expected = Buffer.from(computeWebhookSignature(payload, secret), 'utf8');\n const received = Buffer.from(signature.trim(), 'utf8');\n\n // timingSafeEqual throws on a length mismatch rather than returning false, and a wrong-length\n // signature is wrong regardless; the length is not a secret.\n return expected.length === received.length && timingSafeEqual(expected, received);\n}\n\n/**\n * Verifies a webhook and returns its parsed body.\n *\n * @returns The parsed webhook body: `event`, `timestamp`, and the payload for that event.\n * @throws {WebhookVerificationError} The signature did not match, the body was not a JSON\n * object, or the payload is older than the tolerance. Answer 400 and do not act on it.\n */\nexport function verifyWebhook(options: VerifyWebhookOptions): WebhookEvent {\n const { payload, signature, secret, tolerance = DEFAULT_TOLERANCE_SECONDS, now } = options;\n\n if (!verifyWebhookSignature(payload, signature, secret)) {\n throw new WebhookVerificationError(\n 'The webhook signature did not match. Check that you are verifying the raw request body ' +\n 'rather than a re-serialized copy, and that the signing secret belongs to the channel ' +\n 'that sent this delivery.',\n );\n }\n\n let event: unknown;\n\n try {\n event = JSON.parse(toText(payload));\n } catch (cause) {\n throw new WebhookVerificationError(\n `The webhook body was not valid JSON: ${cause instanceof Error ? cause.message : String(cause)}`,\n { cause },\n );\n }\n\n if (typeof event !== 'object' || event === null || Array.isArray(event)) {\n throw new WebhookVerificationError('The webhook body was not a JSON object.');\n }\n\n const body = event as WebhookEvent;\n\n if (tolerance !== null && tolerance !== undefined) {\n checkFreshness(body, tolerance, now);\n }\n\n return body;\n}\n\nfunction checkFreshness(event: WebhookEvent, tolerance: number, now: Date | undefined): void {\n const raw = event.timestamp;\n\n if (typeof raw !== 'string' || !raw) {\n throw new WebhookVerificationError(\n 'The webhook carried no timestamp, so it cannot be checked for replay. Pass ' +\n 'tolerance: null if you are deduplicating deliveries some other way.',\n );\n }\n\n const sent = new Date(raw);\n\n if (Number.isNaN(sent.getTime())) {\n throw new WebhookVerificationError(\n `The webhook timestamp was unreadable: ${JSON.stringify(raw)}`,\n );\n }\n\n const age = Math.abs(((now ?? new Date()).getTime() - sent.getTime()) / 1000);\n\n if (age > tolerance) {\n throw new WebhookVerificationError(\n `The webhook timestamp is ${age.toFixed(0)}s away from now, beyond the ${tolerance}s ` +\n 'tolerance. Treating it as a replay.',\n );\n }\n}\n\nfunction toBytes(payload: WebhookPayload): Buffer {\n return typeof payload === 'string' ? Buffer.from(payload, 'utf8') : Buffer.from(payload);\n}\n\nfunction toText(payload: WebhookPayload): string {\n return typeof payload === 'string' ? payload : Buffer.from(payload).toString('utf8');\n}\n"]}
|