@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
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var crypto = require('crypto');
|
|
4
|
+
|
|
5
|
+
// src/webhooks.ts
|
|
6
|
+
|
|
7
|
+
// src/errors.ts
|
|
8
|
+
var DregsError = class extends Error {
|
|
9
|
+
constructor(message, options) {
|
|
10
|
+
super(message, options);
|
|
11
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
12
|
+
this.name = new.target.name;
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
var DregsConnectionError = class extends DregsError {
|
|
16
|
+
};
|
|
17
|
+
var DregsTimeoutError = class extends DregsConnectionError {
|
|
18
|
+
};
|
|
19
|
+
var WebhookVerificationError = class extends DregsError {
|
|
20
|
+
};
|
|
21
|
+
var DregsAPIError = class extends DregsError {
|
|
22
|
+
/** The HTTP status code. */
|
|
23
|
+
statusCode;
|
|
24
|
+
/** The parsed JSON body, or `null` when the response was not JSON. */
|
|
25
|
+
body;
|
|
26
|
+
/**
|
|
27
|
+
* Value of the `X-Request-Id` response header, when present.
|
|
28
|
+
*
|
|
29
|
+
* Quote it when you report a problem: it is what lets Dregs find your exact request.
|
|
30
|
+
*/
|
|
31
|
+
requestId;
|
|
32
|
+
constructor(message, options) {
|
|
33
|
+
super(message, options.cause === void 0 ? void 0 : { cause: options.cause });
|
|
34
|
+
this.statusCode = options.statusCode;
|
|
35
|
+
this.body = options.body ?? null;
|
|
36
|
+
this.requestId = options.requestId ?? null;
|
|
37
|
+
}
|
|
38
|
+
toString() {
|
|
39
|
+
const suffix = this.requestId ? ` (request ${this.requestId})` : "";
|
|
40
|
+
return `${this.name}: HTTP ${this.statusCode}: ${this.message}${suffix}`;
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
var BadRequestError = class extends DregsAPIError {
|
|
44
|
+
};
|
|
45
|
+
var AuthenticationError = class extends DregsAPIError {
|
|
46
|
+
};
|
|
47
|
+
var QuotaExceededError = class extends DregsAPIError {
|
|
48
|
+
};
|
|
49
|
+
var PermissionDeniedError = class extends DregsAPIError {
|
|
50
|
+
};
|
|
51
|
+
var NotFoundError = class extends DregsAPIError {
|
|
52
|
+
};
|
|
53
|
+
var RateLimitError = class extends DregsAPIError {
|
|
54
|
+
/**
|
|
55
|
+
* Seconds to wait before retrying, from the `Retry-After` header when the response carried a
|
|
56
|
+
* numeric one, and `null` otherwise.
|
|
57
|
+
*/
|
|
58
|
+
retryAfter;
|
|
59
|
+
constructor(message, options = {}) {
|
|
60
|
+
super(message, { ...options, statusCode: options.statusCode ?? 429 });
|
|
61
|
+
this.retryAfter = options.retryAfter ?? null;
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
var ServerError = class extends DregsAPIError {
|
|
65
|
+
};
|
|
66
|
+
var STATUS_ERRORS = {
|
|
67
|
+
400: BadRequestError,
|
|
68
|
+
401: AuthenticationError,
|
|
69
|
+
402: QuotaExceededError,
|
|
70
|
+
403: PermissionDeniedError,
|
|
71
|
+
404: NotFoundError
|
|
72
|
+
};
|
|
73
|
+
function errorForStatus(statusCode) {
|
|
74
|
+
const mapped = STATUS_ERRORS[statusCode];
|
|
75
|
+
if (mapped) {
|
|
76
|
+
return mapped;
|
|
77
|
+
}
|
|
78
|
+
return statusCode >= 500 ? ServerError : DregsAPIError;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// src/webhooks.ts
|
|
82
|
+
var SIGNATURE_HEADER = "X-Dregs-Signature";
|
|
83
|
+
var TIMESTAMP_HEADER = "X-Dregs-Timestamp";
|
|
84
|
+
var EVENT_HEADER = "X-Dregs-Event";
|
|
85
|
+
var DEFAULT_TOLERANCE_SECONDS = 300;
|
|
86
|
+
function computeWebhookSignature(payload, secret) {
|
|
87
|
+
return crypto.createHmac("sha256", secret).update(toBytes(payload)).digest("hex");
|
|
88
|
+
}
|
|
89
|
+
function verifyWebhookSignature(payload, signature, secret) {
|
|
90
|
+
if (!signature || !secret) {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
const expected = Buffer.from(computeWebhookSignature(payload, secret), "utf8");
|
|
94
|
+
const received = Buffer.from(signature.trim(), "utf8");
|
|
95
|
+
return expected.length === received.length && crypto.timingSafeEqual(expected, received);
|
|
96
|
+
}
|
|
97
|
+
function verifyWebhook(options) {
|
|
98
|
+
const { payload, signature, secret, tolerance = DEFAULT_TOLERANCE_SECONDS, now } = options;
|
|
99
|
+
if (!verifyWebhookSignature(payload, signature, secret)) {
|
|
100
|
+
throw new WebhookVerificationError(
|
|
101
|
+
"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."
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
let event;
|
|
105
|
+
try {
|
|
106
|
+
event = JSON.parse(toText(payload));
|
|
107
|
+
} catch (cause) {
|
|
108
|
+
throw new WebhookVerificationError(
|
|
109
|
+
`The webhook body was not valid JSON: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
110
|
+
{ cause }
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
if (typeof event !== "object" || event === null || Array.isArray(event)) {
|
|
114
|
+
throw new WebhookVerificationError("The webhook body was not a JSON object.");
|
|
115
|
+
}
|
|
116
|
+
const body = event;
|
|
117
|
+
if (tolerance !== null && tolerance !== void 0) {
|
|
118
|
+
checkFreshness(body, tolerance, now);
|
|
119
|
+
}
|
|
120
|
+
return body;
|
|
121
|
+
}
|
|
122
|
+
function checkFreshness(event, tolerance, now) {
|
|
123
|
+
const raw = event.timestamp;
|
|
124
|
+
if (typeof raw !== "string" || !raw) {
|
|
125
|
+
throw new WebhookVerificationError(
|
|
126
|
+
"The webhook carried no timestamp, so it cannot be checked for replay. Pass tolerance: null if you are deduplicating deliveries some other way."
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
const sent = new Date(raw);
|
|
130
|
+
if (Number.isNaN(sent.getTime())) {
|
|
131
|
+
throw new WebhookVerificationError(
|
|
132
|
+
`The webhook timestamp was unreadable: ${JSON.stringify(raw)}`
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
const age = Math.abs(((now ?? /* @__PURE__ */ new Date()).getTime() - sent.getTime()) / 1e3);
|
|
136
|
+
if (age > tolerance) {
|
|
137
|
+
throw new WebhookVerificationError(
|
|
138
|
+
`The webhook timestamp is ${age.toFixed(0)}s away from now, beyond the ${tolerance}s tolerance. Treating it as a replay.`
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
function toBytes(payload) {
|
|
143
|
+
return typeof payload === "string" ? Buffer.from(payload, "utf8") : Buffer.from(payload);
|
|
144
|
+
}
|
|
145
|
+
function toText(payload) {
|
|
146
|
+
return typeof payload === "string" ? payload : Buffer.from(payload).toString("utf8");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
exports.AuthenticationError = AuthenticationError;
|
|
150
|
+
exports.BadRequestError = BadRequestError;
|
|
151
|
+
exports.DEFAULT_TOLERANCE_SECONDS = DEFAULT_TOLERANCE_SECONDS;
|
|
152
|
+
exports.DregsAPIError = DregsAPIError;
|
|
153
|
+
exports.DregsConnectionError = DregsConnectionError;
|
|
154
|
+
exports.DregsError = DregsError;
|
|
155
|
+
exports.DregsTimeoutError = DregsTimeoutError;
|
|
156
|
+
exports.EVENT_HEADER = EVENT_HEADER;
|
|
157
|
+
exports.NotFoundError = NotFoundError;
|
|
158
|
+
exports.PermissionDeniedError = PermissionDeniedError;
|
|
159
|
+
exports.QuotaExceededError = QuotaExceededError;
|
|
160
|
+
exports.RateLimitError = RateLimitError;
|
|
161
|
+
exports.SIGNATURE_HEADER = SIGNATURE_HEADER;
|
|
162
|
+
exports.ServerError = ServerError;
|
|
163
|
+
exports.TIMESTAMP_HEADER = TIMESTAMP_HEADER;
|
|
164
|
+
exports.WebhookVerificationError = WebhookVerificationError;
|
|
165
|
+
exports.computeWebhookSignature = computeWebhookSignature;
|
|
166
|
+
exports.errorForStatus = errorForStatus;
|
|
167
|
+
exports.verifyWebhook = verifyWebhook;
|
|
168
|
+
exports.verifyWebhookSignature = verifyWebhookSignature;
|
|
169
|
+
//# sourceMappingURL=chunk-K63AUEQR.cjs.map
|
|
170
|
+
//# sourceMappingURL=chunk-K63AUEQR.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/errors.ts","../src/webhooks.ts"],"names":["createHmac","timingSafeEqual"],"mappings":"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","file":"chunk-K63AUEQR.cjs","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"]}
|