@nimbusnexus/webhooks-sdk 0.1.0 → 0.1.1
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/dist/index.cjs +6 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +6 -2
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -54,8 +54,12 @@ function verify(secret, rawBody, signature, opts = {}) {
|
|
|
54
54
|
if (Math.abs(current - ts) > toleranceSeconds) return false;
|
|
55
55
|
}
|
|
56
56
|
const expected = Buffer.from(sign(secret, rawBody, ts));
|
|
57
|
-
const
|
|
58
|
-
|
|
57
|
+
for (const raw of signature.split(",")) {
|
|
58
|
+
const token = raw.trim();
|
|
59
|
+
const candidate = Buffer.from(token.startsWith(PREFIX) ? token : `${PREFIX}${token}`);
|
|
60
|
+
if (expected.length === candidate.length && (0, import_node_crypto.timingSafeEqual)(expected, candidate)) return true;
|
|
61
|
+
}
|
|
62
|
+
return false;
|
|
59
63
|
}
|
|
60
64
|
|
|
61
65
|
// src/errors.ts
|
package/dist/index.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts","../src/signature.ts","../src/errors.ts","../src/client.ts"],"sourcesContent":["/**\n * @nimbusnexus/webhooks-sdk — the official TypeScript client for NimbusNexus Webhooks.\n *\n * - `verify` — verify an incoming webhook's HMAC signature (for subscribers).\n * - `WebhookdClient` — publish events to webhookd (for producers).\n */\nexport { DEFAULT_TOLERANCE_SECONDS, sign, verify } from \"./signature\";\nexport type { VerifyOptions } from \"./signature\";\nexport { WebhookdClient } from \"./client\";\nexport type { ClientOptions, PublishOptions, WebhookdEvent } from \"./client\";\nexport { WebhookdApiError, WebhookdError } from \"./errors\";\n\nexport const VERSION = \"0.1.0\";\n","/**\n * Verify webhookd webhook signatures.\n *\n * webhookd signs every delivery as `HMAC_SHA256(secret, \"<timestamp>.\" + rawBody)` (its default\n * timestamped mode) and sends `X-Webhook-Signature: sha256=<hex>` plus `X-Webhook-Timestamp`\n * (unix seconds). A subscriber MUST verify the signature to prove the request genuinely came from\n * webhookd and wasn't tampered with. Mirrors `delivery_core.webhook_outbox.{sign,verify}`.\n */\nimport { createHmac, timingSafeEqual } from \"node:crypto\";\n\nconst PREFIX = \"sha256=\";\nexport const DEFAULT_TOLERANCE_SECONDS = 300;\n\nfunction toBuffer(value: string | Buffer): Buffer {\n return Buffer.isBuffer(value) ? value : Buffer.from(value, \"utf8\");\n}\n\nfunction signedBytes(rawBody: Buffer, timestamp: number | null): Buffer {\n if (timestamp === null) return rawBody;\n return Buffer.concat([Buffer.from(`${timestamp}.`, \"ascii\"), rawBody]);\n}\n\n/** The `sha256=<hex>` signature webhookd would send for `rawBody` (+ optional timestamp). */\nexport function sign(\n secret: string | Buffer,\n rawBody: string | Buffer,\n timestamp: number | null = null,\n): string {\n const digest = createHmac(\"sha256\", secret)\n .update(signedBytes(toBuffer(rawBody), timestamp))\n .digest(\"hex\");\n return `${PREFIX}${digest}`;\n}\n\nexport interface VerifyOptions {\n /** The `X-Webhook-Timestamp` header value. When given, the replay window is enforced — always pass it. */\n timestamp?: number | string | null;\n /** Replay window in seconds; webhookd's default is 300. */\n toleranceSeconds?: number;\n /** Override the current unix time (for tests). */\n now?: number;\n}\n\n/**\n * Return `true` iff `signature` is a valid webhookd signature for `rawBody`.\n *\n * Pass the EXACT bytes you received as `rawBody` (a string or Buffer) — do not re-serialize the\n * JSON, or the signature won't match. Comparison is constant-time.\n */\nexport function verify(\n secret: string | Buffer,\n rawBody: string | Buffer,\n signature: string,\n opts: VerifyOptions = {},\n): boolean {\n const { timestamp = null, toleranceSeconds = DEFAULT_TOLERANCE_SECONDS, now } = opts;\n const ts = timestamp === null || timestamp === undefined ? null : Number(timestamp);\n if (ts !== null) {\n if (Number.isNaN(ts)) return false;\n const current = now ?? Math.floor(Date.now() / 1000);\n if (Math.abs(current - ts) > toleranceSeconds) return false;\n }\n const expected = Buffer.from(sign(secret, rawBody, ts));\n const candidate = Buffer.from(signature.startsWith(PREFIX) ? signature : `${PREFIX}${signature}`);\n // timingSafeEqual requires equal-length buffers; a length mismatch is already a non-match.\n return expected.length === candidate.length && timingSafeEqual(expected, candidate);\n}\n","/** Base class for all webhookd SDK errors (network failures, etc.). */\nexport class WebhookdError extends Error {\n constructor(message: string) {\n super(message);\n this.name = \"WebhookdError\";\n }\n}\n\n/**\n * A non-2xx response from the webhookd API. Carries the M5a error envelope: a stable machine\n * `code` (e.g. `\"rate_limited\"`, `\"not_found\"`, `\"validation_error\"`) and a human `message`,\n * plus the HTTP `statusCode`.\n */\nexport class WebhookdApiError extends WebhookdError {\n readonly statusCode: number;\n readonly code: string;\n\n constructor(statusCode: number, code: string, message: string) {\n super(`[${statusCode} ${code}] ${message}`);\n this.name = \"WebhookdApiError\";\n this.statusCode = statusCode;\n this.code = code;\n }\n}\n","/** Typed publish client for the webhookd API (zero runtime deps — uses the global `fetch`). */\nimport { WebhookdApiError, WebhookdError } from \"./errors\";\n\nconst RETRY_STATUSES = new Set([429, 500, 502, 503, 504]);\n\n/** The published event, as returned by `POST /v1/events` (webhookd's `EventOut`). */\nexport interface WebhookdEvent {\n id: string;\n eventUid: string;\n eventType: string;\n application: string;\n environment: string;\n deliveriesCreated: number;\n source: string | null;\n}\n\nexport interface ClientOptions {\n baseUrl: string;\n /** A per-tenant API key (`whsk_…`) or a service token. */\n apiKey: string;\n timeoutMs?: number;\n maxRetries?: number;\n /** Inject a `fetch` implementation (defaults to the global `fetch`); used for tests. */\n fetchImpl?: typeof fetch;\n}\n\nexport interface PublishOptions {\n environment?: string;\n application?: string;\n source?: string;\n /** Makes the publish safe to retry — a replay returns the original event without re-fanning-out. */\n idempotencyKey?: string;\n}\n\nexport class WebhookdClient {\n private readonly baseUrl: string;\n private readonly apiKey: string;\n private readonly timeoutMs: number;\n private readonly maxRetries: number;\n private readonly fetchImpl: typeof fetch;\n\n constructor(opts: ClientOptions) {\n this.baseUrl = opts.baseUrl.replace(/\\/+$/, \"\");\n this.apiKey = opts.apiKey;\n this.timeoutMs = opts.timeoutMs ?? 10_000;\n this.maxRetries = opts.maxRetries ?? 2;\n this.fetchImpl = opts.fetchImpl ?? fetch;\n }\n\n async publish(\n eventType: string,\n payload: Record<string, unknown>,\n opts: PublishOptions = {},\n ): Promise<WebhookdEvent> {\n const body: Record<string, unknown> = {\n event_type: eventType,\n payload,\n environment: opts.environment ?? \"prod\",\n application: opts.application ?? \"default\",\n };\n if (opts.source !== undefined) body.source = opts.source;\n\n const headers: Record<string, string> = {\n \"Content-Type\": \"application/json\",\n Authorization: `Bearer ${this.apiKey}`,\n };\n if (opts.idempotencyKey !== undefined) headers[\"Idempotency-Key\"] = opts.idempotencyKey;\n\n const resp = await this.post(\"/v1/events\", JSON.stringify(body), headers);\n const data = (await resp.json()) as Record<string, unknown>;\n return {\n id: String(data.id),\n eventUid: String(data.event_uid),\n eventType: String(data.event_type),\n application: String(data.application ?? \"default\"),\n environment: String(data.environment ?? \"prod\"),\n deliveriesCreated: Number(data.deliveries_created ?? 0),\n source: (data.source as string | null) ?? null,\n };\n }\n\n private async post(\n path: string,\n body: string,\n headers: Record<string, string>,\n ): Promise<Response> {\n const url = `${this.baseUrl}${path}`;\n let lastErr: unknown;\n for (let attempt = 0; attempt <= this.maxRetries; attempt++) {\n let resp: Response;\n try {\n resp = await this.fetchImpl(url, {\n method: \"POST\",\n headers,\n body,\n signal: AbortSignal.timeout(this.timeoutMs),\n });\n } catch (err) {\n lastErr = err;\n if (attempt < this.maxRetries) {\n await sleep(backoffMs(attempt));\n continue;\n }\n throw new WebhookdError(`request failed: ${String(err)}`);\n }\n\n if (RETRY_STATUSES.has(resp.status) && attempt < this.maxRetries) {\n await sleep(retryAfterMs(resp) ?? backoffMs(attempt));\n continue;\n }\n if (resp.status >= 400) throw await apiError(resp);\n return resp;\n }\n throw new WebhookdError(`request failed after retries: ${String(lastErr)}`);\n }\n}\n\nfunction backoffMs(attempt: number): number {\n return Math.min(2000, 200 * 2 ** attempt);\n}\n\nfunction retryAfterMs(resp: Response): number | null {\n const raw = resp.headers.get(\"Retry-After\");\n if (raw && /^\\d+$/.test(raw)) return Number(raw) * 1000;\n return null;\n}\n\nasync function apiError(resp: Response): Promise<WebhookdApiError> {\n let code = \"error\";\n let message = \"\";\n try {\n const data = (await resp.json()) as { error?: { code?: string; message?: string } };\n code = data.error?.code ?? code;\n message = data.error?.message ?? \"\";\n } catch {\n message = \"\";\n }\n return new WebhookdApiError(resp.status, code, message);\n}\n\nfunction sleep(ms: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/signature.ts","../src/errors.ts","../src/client.ts"],"sourcesContent":["/**\n * @nimbusnexus/webhooks-sdk — the official TypeScript client for NimbusNexus Webhooks.\n *\n * - `verify` — verify an incoming webhook's HMAC signature (for subscribers).\n * - `WebhookdClient` — publish events to webhookd (for producers).\n */\nexport { DEFAULT_TOLERANCE_SECONDS, sign, verify } from \"./signature\";\nexport type { VerifyOptions } from \"./signature\";\nexport { WebhookdClient } from \"./client\";\nexport type { ClientOptions, PublishOptions, WebhookdEvent } from \"./client\";\nexport { WebhookdApiError, WebhookdError } from \"./errors\";\n\nexport const VERSION = \"0.1.0\";\n","/**\n * Verify webhookd webhook signatures.\n *\n * webhookd signs every delivery as `HMAC_SHA256(secret, \"<timestamp>.\" + rawBody)` (its default\n * timestamped mode) and sends `X-Webhook-Signature: sha256=<hex>` plus `X-Webhook-Timestamp`\n * (unix seconds). A subscriber MUST verify the signature to prove the request genuinely came from\n * webhookd and wasn't tampered with. Mirrors `delivery_core.webhook_outbox.{sign,verify}`.\n */\nimport { createHmac, timingSafeEqual } from \"node:crypto\";\n\nconst PREFIX = \"sha256=\";\nexport const DEFAULT_TOLERANCE_SECONDS = 300;\n\nfunction toBuffer(value: string | Buffer): Buffer {\n return Buffer.isBuffer(value) ? value : Buffer.from(value, \"utf8\");\n}\n\nfunction signedBytes(rawBody: Buffer, timestamp: number | null): Buffer {\n if (timestamp === null) return rawBody;\n return Buffer.concat([Buffer.from(`${timestamp}.`, \"ascii\"), rawBody]);\n}\n\n/** The `sha256=<hex>` signature webhookd would send for `rawBody` (+ optional timestamp). */\nexport function sign(\n secret: string | Buffer,\n rawBody: string | Buffer,\n timestamp: number | null = null,\n): string {\n const digest = createHmac(\"sha256\", secret)\n .update(signedBytes(toBuffer(rawBody), timestamp))\n .digest(\"hex\");\n return `${PREFIX}${digest}`;\n}\n\nexport interface VerifyOptions {\n /** The `X-Webhook-Timestamp` header value. When given, the replay window is enforced — always pass it. */\n timestamp?: number | string | null;\n /** Replay window in seconds; webhookd's default is 300. */\n toleranceSeconds?: number;\n /** Override the current unix time (for tests). */\n now?: number;\n}\n\n/**\n * Return `true` iff `signature` is a valid webhookd signature for `rawBody`.\n *\n * Pass the EXACT bytes you received as `rawBody` (a string or Buffer) — do not re-serialize the\n * JSON, or the signature won't match. Comparison is constant-time.\n */\nexport function verify(\n secret: string | Buffer,\n rawBody: string | Buffer,\n signature: string,\n opts: VerifyOptions = {},\n): boolean {\n const { timestamp = null, toleranceSeconds = DEFAULT_TOLERANCE_SECONDS, now } = opts;\n const ts = timestamp === null || timestamp === undefined ? null : Number(timestamp);\n if (ts !== null) {\n if (Number.isNaN(ts)) return false;\n const current = now ?? Math.floor(Date.now() / 1000);\n if (Math.abs(current - ts) > toleranceSeconds) return false;\n }\n const expected = Buffer.from(sign(secret, rawBody, ts));\n // X-Webhook-Signature carries one token normally, or several comma-separated tokens during a\n // signing-secret rotation (webhookd dual-sign overlap) — accept if ANY token verifies, so a\n // subscriber configured with EITHER the current or the previous secret keeps working.\n for (const raw of signature.split(\",\")) {\n const token = raw.trim();\n const candidate = Buffer.from(token.startsWith(PREFIX) ? token : `${PREFIX}${token}`);\n // timingSafeEqual requires equal-length buffers; a length mismatch is already a non-match.\n if (expected.length === candidate.length && timingSafeEqual(expected, candidate)) return true;\n }\n return false;\n}\n","/** Base class for all webhookd SDK errors (network failures, etc.). */\nexport class WebhookdError extends Error {\n constructor(message: string) {\n super(message);\n this.name = \"WebhookdError\";\n }\n}\n\n/**\n * A non-2xx response from the webhookd API. Carries the M5a error envelope: a stable machine\n * `code` (e.g. `\"rate_limited\"`, `\"not_found\"`, `\"validation_error\"`) and a human `message`,\n * plus the HTTP `statusCode`.\n */\nexport class WebhookdApiError extends WebhookdError {\n readonly statusCode: number;\n readonly code: string;\n\n constructor(statusCode: number, code: string, message: string) {\n super(`[${statusCode} ${code}] ${message}`);\n this.name = \"WebhookdApiError\";\n this.statusCode = statusCode;\n this.code = code;\n }\n}\n","/** Typed publish client for the webhookd API (zero runtime deps — uses the global `fetch`). */\nimport { WebhookdApiError, WebhookdError } from \"./errors\";\n\nconst RETRY_STATUSES = new Set([429, 500, 502, 503, 504]);\n\n/** The published event, as returned by `POST /v1/events` (webhookd's `EventOut`). */\nexport interface WebhookdEvent {\n id: string;\n eventUid: string;\n eventType: string;\n application: string;\n environment: string;\n deliveriesCreated: number;\n source: string | null;\n}\n\nexport interface ClientOptions {\n baseUrl: string;\n /** A per-tenant API key (`whsk_…`) or a service token. */\n apiKey: string;\n timeoutMs?: number;\n maxRetries?: number;\n /** Inject a `fetch` implementation (defaults to the global `fetch`); used for tests. */\n fetchImpl?: typeof fetch;\n}\n\nexport interface PublishOptions {\n environment?: string;\n application?: string;\n source?: string;\n /** Makes the publish safe to retry — a replay returns the original event without re-fanning-out. */\n idempotencyKey?: string;\n}\n\nexport class WebhookdClient {\n private readonly baseUrl: string;\n private readonly apiKey: string;\n private readonly timeoutMs: number;\n private readonly maxRetries: number;\n private readonly fetchImpl: typeof fetch;\n\n constructor(opts: ClientOptions) {\n this.baseUrl = opts.baseUrl.replace(/\\/+$/, \"\");\n this.apiKey = opts.apiKey;\n this.timeoutMs = opts.timeoutMs ?? 10_000;\n this.maxRetries = opts.maxRetries ?? 2;\n this.fetchImpl = opts.fetchImpl ?? fetch;\n }\n\n async publish(\n eventType: string,\n payload: Record<string, unknown>,\n opts: PublishOptions = {},\n ): Promise<WebhookdEvent> {\n const body: Record<string, unknown> = {\n event_type: eventType,\n payload,\n environment: opts.environment ?? \"prod\",\n application: opts.application ?? \"default\",\n };\n if (opts.source !== undefined) body.source = opts.source;\n\n const headers: Record<string, string> = {\n \"Content-Type\": \"application/json\",\n Authorization: `Bearer ${this.apiKey}`,\n };\n if (opts.idempotencyKey !== undefined) headers[\"Idempotency-Key\"] = opts.idempotencyKey;\n\n const resp = await this.post(\"/v1/events\", JSON.stringify(body), headers);\n const data = (await resp.json()) as Record<string, unknown>;\n return {\n id: String(data.id),\n eventUid: String(data.event_uid),\n eventType: String(data.event_type),\n application: String(data.application ?? \"default\"),\n environment: String(data.environment ?? \"prod\"),\n deliveriesCreated: Number(data.deliveries_created ?? 0),\n source: (data.source as string | null) ?? null,\n };\n }\n\n private async post(\n path: string,\n body: string,\n headers: Record<string, string>,\n ): Promise<Response> {\n const url = `${this.baseUrl}${path}`;\n let lastErr: unknown;\n for (let attempt = 0; attempt <= this.maxRetries; attempt++) {\n let resp: Response;\n try {\n resp = await this.fetchImpl(url, {\n method: \"POST\",\n headers,\n body,\n signal: AbortSignal.timeout(this.timeoutMs),\n });\n } catch (err) {\n lastErr = err;\n if (attempt < this.maxRetries) {\n await sleep(backoffMs(attempt));\n continue;\n }\n throw new WebhookdError(`request failed: ${String(err)}`);\n }\n\n if (RETRY_STATUSES.has(resp.status) && attempt < this.maxRetries) {\n await sleep(retryAfterMs(resp) ?? backoffMs(attempt));\n continue;\n }\n if (resp.status >= 400) throw await apiError(resp);\n return resp;\n }\n throw new WebhookdError(`request failed after retries: ${String(lastErr)}`);\n }\n}\n\nfunction backoffMs(attempt: number): number {\n return Math.min(2000, 200 * 2 ** attempt);\n}\n\nfunction retryAfterMs(resp: Response): number | null {\n const raw = resp.headers.get(\"Retry-After\");\n if (raw && /^\\d+$/.test(raw)) return Number(raw) * 1000;\n return null;\n}\n\nasync function apiError(resp: Response): Promise<WebhookdApiError> {\n let code = \"error\";\n let message = \"\";\n try {\n const data = (await resp.json()) as { error?: { code?: string; message?: string } };\n code = data.error?.code ?? code;\n message = data.error?.message ?? \"\";\n } catch {\n message = \"\";\n }\n return new WebhookdApiError(resp.status, code, message);\n}\n\nfunction sleep(ms: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\n"],"mappings":"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","names":[]}
|
package/dist/index.js
CHANGED
|
@@ -22,8 +22,12 @@ function verify(secret, rawBody, signature, opts = {}) {
|
|
|
22
22
|
if (Math.abs(current - ts) > toleranceSeconds) return false;
|
|
23
23
|
}
|
|
24
24
|
const expected = Buffer.from(sign(secret, rawBody, ts));
|
|
25
|
-
const
|
|
26
|
-
|
|
25
|
+
for (const raw of signature.split(",")) {
|
|
26
|
+
const token = raw.trim();
|
|
27
|
+
const candidate = Buffer.from(token.startsWith(PREFIX) ? token : `${PREFIX}${token}`);
|
|
28
|
+
if (expected.length === candidate.length && timingSafeEqual(expected, candidate)) return true;
|
|
29
|
+
}
|
|
30
|
+
return false;
|
|
27
31
|
}
|
|
28
32
|
|
|
29
33
|
// src/errors.ts
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/signature.ts","../src/errors.ts","../src/client.ts","../src/index.ts"],"sourcesContent":["/**\n * Verify webhookd webhook signatures.\n *\n * webhookd signs every delivery as `HMAC_SHA256(secret, \"<timestamp>.\" + rawBody)` (its default\n * timestamped mode) and sends `X-Webhook-Signature: sha256=<hex>` plus `X-Webhook-Timestamp`\n * (unix seconds). A subscriber MUST verify the signature to prove the request genuinely came from\n * webhookd and wasn't tampered with. Mirrors `delivery_core.webhook_outbox.{sign,verify}`.\n */\nimport { createHmac, timingSafeEqual } from \"node:crypto\";\n\nconst PREFIX = \"sha256=\";\nexport const DEFAULT_TOLERANCE_SECONDS = 300;\n\nfunction toBuffer(value: string | Buffer): Buffer {\n return Buffer.isBuffer(value) ? value : Buffer.from(value, \"utf8\");\n}\n\nfunction signedBytes(rawBody: Buffer, timestamp: number | null): Buffer {\n if (timestamp === null) return rawBody;\n return Buffer.concat([Buffer.from(`${timestamp}.`, \"ascii\"), rawBody]);\n}\n\n/** The `sha256=<hex>` signature webhookd would send for `rawBody` (+ optional timestamp). */\nexport function sign(\n secret: string | Buffer,\n rawBody: string | Buffer,\n timestamp: number | null = null,\n): string {\n const digest = createHmac(\"sha256\", secret)\n .update(signedBytes(toBuffer(rawBody), timestamp))\n .digest(\"hex\");\n return `${PREFIX}${digest}`;\n}\n\nexport interface VerifyOptions {\n /** The `X-Webhook-Timestamp` header value. When given, the replay window is enforced — always pass it. */\n timestamp?: number | string | null;\n /** Replay window in seconds; webhookd's default is 300. */\n toleranceSeconds?: number;\n /** Override the current unix time (for tests). */\n now?: number;\n}\n\n/**\n * Return `true` iff `signature` is a valid webhookd signature for `rawBody`.\n *\n * Pass the EXACT bytes you received as `rawBody` (a string or Buffer) — do not re-serialize the\n * JSON, or the signature won't match. Comparison is constant-time.\n */\nexport function verify(\n secret: string | Buffer,\n rawBody: string | Buffer,\n signature: string,\n opts: VerifyOptions = {},\n): boolean {\n const { timestamp = null, toleranceSeconds = DEFAULT_TOLERANCE_SECONDS, now } = opts;\n const ts = timestamp === null || timestamp === undefined ? null : Number(timestamp);\n if (ts !== null) {\n if (Number.isNaN(ts)) return false;\n const current = now ?? Math.floor(Date.now() / 1000);\n if (Math.abs(current - ts) > toleranceSeconds) return false;\n }\n const expected = Buffer.from(sign(secret, rawBody, ts));\n const candidate = Buffer.from(signature.startsWith(PREFIX) ? signature : `${PREFIX}${signature}`);\n // timingSafeEqual requires equal-length buffers; a length mismatch is already a non-match.\n return expected.length === candidate.length && timingSafeEqual(expected, candidate);\n}\n","/** Base class for all webhookd SDK errors (network failures, etc.). */\nexport class WebhookdError extends Error {\n constructor(message: string) {\n super(message);\n this.name = \"WebhookdError\";\n }\n}\n\n/**\n * A non-2xx response from the webhookd API. Carries the M5a error envelope: a stable machine\n * `code` (e.g. `\"rate_limited\"`, `\"not_found\"`, `\"validation_error\"`) and a human `message`,\n * plus the HTTP `statusCode`.\n */\nexport class WebhookdApiError extends WebhookdError {\n readonly statusCode: number;\n readonly code: string;\n\n constructor(statusCode: number, code: string, message: string) {\n super(`[${statusCode} ${code}] ${message}`);\n this.name = \"WebhookdApiError\";\n this.statusCode = statusCode;\n this.code = code;\n }\n}\n","/** Typed publish client for the webhookd API (zero runtime deps — uses the global `fetch`). */\nimport { WebhookdApiError, WebhookdError } from \"./errors\";\n\nconst RETRY_STATUSES = new Set([429, 500, 502, 503, 504]);\n\n/** The published event, as returned by `POST /v1/events` (webhookd's `EventOut`). */\nexport interface WebhookdEvent {\n id: string;\n eventUid: string;\n eventType: string;\n application: string;\n environment: string;\n deliveriesCreated: number;\n source: string | null;\n}\n\nexport interface ClientOptions {\n baseUrl: string;\n /** A per-tenant API key (`whsk_…`) or a service token. */\n apiKey: string;\n timeoutMs?: number;\n maxRetries?: number;\n /** Inject a `fetch` implementation (defaults to the global `fetch`); used for tests. */\n fetchImpl?: typeof fetch;\n}\n\nexport interface PublishOptions {\n environment?: string;\n application?: string;\n source?: string;\n /** Makes the publish safe to retry — a replay returns the original event without re-fanning-out. */\n idempotencyKey?: string;\n}\n\nexport class WebhookdClient {\n private readonly baseUrl: string;\n private readonly apiKey: string;\n private readonly timeoutMs: number;\n private readonly maxRetries: number;\n private readonly fetchImpl: typeof fetch;\n\n constructor(opts: ClientOptions) {\n this.baseUrl = opts.baseUrl.replace(/\\/+$/, \"\");\n this.apiKey = opts.apiKey;\n this.timeoutMs = opts.timeoutMs ?? 10_000;\n this.maxRetries = opts.maxRetries ?? 2;\n this.fetchImpl = opts.fetchImpl ?? fetch;\n }\n\n async publish(\n eventType: string,\n payload: Record<string, unknown>,\n opts: PublishOptions = {},\n ): Promise<WebhookdEvent> {\n const body: Record<string, unknown> = {\n event_type: eventType,\n payload,\n environment: opts.environment ?? \"prod\",\n application: opts.application ?? \"default\",\n };\n if (opts.source !== undefined) body.source = opts.source;\n\n const headers: Record<string, string> = {\n \"Content-Type\": \"application/json\",\n Authorization: `Bearer ${this.apiKey}`,\n };\n if (opts.idempotencyKey !== undefined) headers[\"Idempotency-Key\"] = opts.idempotencyKey;\n\n const resp = await this.post(\"/v1/events\", JSON.stringify(body), headers);\n const data = (await resp.json()) as Record<string, unknown>;\n return {\n id: String(data.id),\n eventUid: String(data.event_uid),\n eventType: String(data.event_type),\n application: String(data.application ?? \"default\"),\n environment: String(data.environment ?? \"prod\"),\n deliveriesCreated: Number(data.deliveries_created ?? 0),\n source: (data.source as string | null) ?? null,\n };\n }\n\n private async post(\n path: string,\n body: string,\n headers: Record<string, string>,\n ): Promise<Response> {\n const url = `${this.baseUrl}${path}`;\n let lastErr: unknown;\n for (let attempt = 0; attempt <= this.maxRetries; attempt++) {\n let resp: Response;\n try {\n resp = await this.fetchImpl(url, {\n method: \"POST\",\n headers,\n body,\n signal: AbortSignal.timeout(this.timeoutMs),\n });\n } catch (err) {\n lastErr = err;\n if (attempt < this.maxRetries) {\n await sleep(backoffMs(attempt));\n continue;\n }\n throw new WebhookdError(`request failed: ${String(err)}`);\n }\n\n if (RETRY_STATUSES.has(resp.status) && attempt < this.maxRetries) {\n await sleep(retryAfterMs(resp) ?? backoffMs(attempt));\n continue;\n }\n if (resp.status >= 400) throw await apiError(resp);\n return resp;\n }\n throw new WebhookdError(`request failed after retries: ${String(lastErr)}`);\n }\n}\n\nfunction backoffMs(attempt: number): number {\n return Math.min(2000, 200 * 2 ** attempt);\n}\n\nfunction retryAfterMs(resp: Response): number | null {\n const raw = resp.headers.get(\"Retry-After\");\n if (raw && /^\\d+$/.test(raw)) return Number(raw) * 1000;\n return null;\n}\n\nasync function apiError(resp: Response): Promise<WebhookdApiError> {\n let code = \"error\";\n let message = \"\";\n try {\n const data = (await resp.json()) as { error?: { code?: string; message?: string } };\n code = data.error?.code ?? code;\n message = data.error?.message ?? \"\";\n } catch {\n message = \"\";\n }\n return new WebhookdApiError(resp.status, code, message);\n}\n\nfunction sleep(ms: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\n","/**\n * @nimbusnexus/webhooks-sdk — the official TypeScript client for NimbusNexus Webhooks.\n *\n * - `verify` — verify an incoming webhook's HMAC signature (for subscribers).\n * - `WebhookdClient` — publish events to webhookd (for producers).\n */\nexport { DEFAULT_TOLERANCE_SECONDS, sign, verify } from \"./signature\";\nexport type { VerifyOptions } from \"./signature\";\nexport { WebhookdClient } from \"./client\";\nexport type { ClientOptions, PublishOptions, WebhookdEvent } from \"./client\";\nexport { WebhookdApiError, WebhookdError } from \"./errors\";\n\nexport const VERSION = \"0.1.0\";\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/signature.ts","../src/errors.ts","../src/client.ts","../src/index.ts"],"sourcesContent":["/**\n * Verify webhookd webhook signatures.\n *\n * webhookd signs every delivery as `HMAC_SHA256(secret, \"<timestamp>.\" + rawBody)` (its default\n * timestamped mode) and sends `X-Webhook-Signature: sha256=<hex>` plus `X-Webhook-Timestamp`\n * (unix seconds). A subscriber MUST verify the signature to prove the request genuinely came from\n * webhookd and wasn't tampered with. Mirrors `delivery_core.webhook_outbox.{sign,verify}`.\n */\nimport { createHmac, timingSafeEqual } from \"node:crypto\";\n\nconst PREFIX = \"sha256=\";\nexport const DEFAULT_TOLERANCE_SECONDS = 300;\n\nfunction toBuffer(value: string | Buffer): Buffer {\n return Buffer.isBuffer(value) ? value : Buffer.from(value, \"utf8\");\n}\n\nfunction signedBytes(rawBody: Buffer, timestamp: number | null): Buffer {\n if (timestamp === null) return rawBody;\n return Buffer.concat([Buffer.from(`${timestamp}.`, \"ascii\"), rawBody]);\n}\n\n/** The `sha256=<hex>` signature webhookd would send for `rawBody` (+ optional timestamp). */\nexport function sign(\n secret: string | Buffer,\n rawBody: string | Buffer,\n timestamp: number | null = null,\n): string {\n const digest = createHmac(\"sha256\", secret)\n .update(signedBytes(toBuffer(rawBody), timestamp))\n .digest(\"hex\");\n return `${PREFIX}${digest}`;\n}\n\nexport interface VerifyOptions {\n /** The `X-Webhook-Timestamp` header value. When given, the replay window is enforced — always pass it. */\n timestamp?: number | string | null;\n /** Replay window in seconds; webhookd's default is 300. */\n toleranceSeconds?: number;\n /** Override the current unix time (for tests). */\n now?: number;\n}\n\n/**\n * Return `true` iff `signature` is a valid webhookd signature for `rawBody`.\n *\n * Pass the EXACT bytes you received as `rawBody` (a string or Buffer) — do not re-serialize the\n * JSON, or the signature won't match. Comparison is constant-time.\n */\nexport function verify(\n secret: string | Buffer,\n rawBody: string | Buffer,\n signature: string,\n opts: VerifyOptions = {},\n): boolean {\n const { timestamp = null, toleranceSeconds = DEFAULT_TOLERANCE_SECONDS, now } = opts;\n const ts = timestamp === null || timestamp === undefined ? null : Number(timestamp);\n if (ts !== null) {\n if (Number.isNaN(ts)) return false;\n const current = now ?? Math.floor(Date.now() / 1000);\n if (Math.abs(current - ts) > toleranceSeconds) return false;\n }\n const expected = Buffer.from(sign(secret, rawBody, ts));\n // X-Webhook-Signature carries one token normally, or several comma-separated tokens during a\n // signing-secret rotation (webhookd dual-sign overlap) — accept if ANY token verifies, so a\n // subscriber configured with EITHER the current or the previous secret keeps working.\n for (const raw of signature.split(\",\")) {\n const token = raw.trim();\n const candidate = Buffer.from(token.startsWith(PREFIX) ? token : `${PREFIX}${token}`);\n // timingSafeEqual requires equal-length buffers; a length mismatch is already a non-match.\n if (expected.length === candidate.length && timingSafeEqual(expected, candidate)) return true;\n }\n return false;\n}\n","/** Base class for all webhookd SDK errors (network failures, etc.). */\nexport class WebhookdError extends Error {\n constructor(message: string) {\n super(message);\n this.name = \"WebhookdError\";\n }\n}\n\n/**\n * A non-2xx response from the webhookd API. Carries the M5a error envelope: a stable machine\n * `code` (e.g. `\"rate_limited\"`, `\"not_found\"`, `\"validation_error\"`) and a human `message`,\n * plus the HTTP `statusCode`.\n */\nexport class WebhookdApiError extends WebhookdError {\n readonly statusCode: number;\n readonly code: string;\n\n constructor(statusCode: number, code: string, message: string) {\n super(`[${statusCode} ${code}] ${message}`);\n this.name = \"WebhookdApiError\";\n this.statusCode = statusCode;\n this.code = code;\n }\n}\n","/** Typed publish client for the webhookd API (zero runtime deps — uses the global `fetch`). */\nimport { WebhookdApiError, WebhookdError } from \"./errors\";\n\nconst RETRY_STATUSES = new Set([429, 500, 502, 503, 504]);\n\n/** The published event, as returned by `POST /v1/events` (webhookd's `EventOut`). */\nexport interface WebhookdEvent {\n id: string;\n eventUid: string;\n eventType: string;\n application: string;\n environment: string;\n deliveriesCreated: number;\n source: string | null;\n}\n\nexport interface ClientOptions {\n baseUrl: string;\n /** A per-tenant API key (`whsk_…`) or a service token. */\n apiKey: string;\n timeoutMs?: number;\n maxRetries?: number;\n /** Inject a `fetch` implementation (defaults to the global `fetch`); used for tests. */\n fetchImpl?: typeof fetch;\n}\n\nexport interface PublishOptions {\n environment?: string;\n application?: string;\n source?: string;\n /** Makes the publish safe to retry — a replay returns the original event without re-fanning-out. */\n idempotencyKey?: string;\n}\n\nexport class WebhookdClient {\n private readonly baseUrl: string;\n private readonly apiKey: string;\n private readonly timeoutMs: number;\n private readonly maxRetries: number;\n private readonly fetchImpl: typeof fetch;\n\n constructor(opts: ClientOptions) {\n this.baseUrl = opts.baseUrl.replace(/\\/+$/, \"\");\n this.apiKey = opts.apiKey;\n this.timeoutMs = opts.timeoutMs ?? 10_000;\n this.maxRetries = opts.maxRetries ?? 2;\n this.fetchImpl = opts.fetchImpl ?? fetch;\n }\n\n async publish(\n eventType: string,\n payload: Record<string, unknown>,\n opts: PublishOptions = {},\n ): Promise<WebhookdEvent> {\n const body: Record<string, unknown> = {\n event_type: eventType,\n payload,\n environment: opts.environment ?? \"prod\",\n application: opts.application ?? \"default\",\n };\n if (opts.source !== undefined) body.source = opts.source;\n\n const headers: Record<string, string> = {\n \"Content-Type\": \"application/json\",\n Authorization: `Bearer ${this.apiKey}`,\n };\n if (opts.idempotencyKey !== undefined) headers[\"Idempotency-Key\"] = opts.idempotencyKey;\n\n const resp = await this.post(\"/v1/events\", JSON.stringify(body), headers);\n const data = (await resp.json()) as Record<string, unknown>;\n return {\n id: String(data.id),\n eventUid: String(data.event_uid),\n eventType: String(data.event_type),\n application: String(data.application ?? \"default\"),\n environment: String(data.environment ?? \"prod\"),\n deliveriesCreated: Number(data.deliveries_created ?? 0),\n source: (data.source as string | null) ?? null,\n };\n }\n\n private async post(\n path: string,\n body: string,\n headers: Record<string, string>,\n ): Promise<Response> {\n const url = `${this.baseUrl}${path}`;\n let lastErr: unknown;\n for (let attempt = 0; attempt <= this.maxRetries; attempt++) {\n let resp: Response;\n try {\n resp = await this.fetchImpl(url, {\n method: \"POST\",\n headers,\n body,\n signal: AbortSignal.timeout(this.timeoutMs),\n });\n } catch (err) {\n lastErr = err;\n if (attempt < this.maxRetries) {\n await sleep(backoffMs(attempt));\n continue;\n }\n throw new WebhookdError(`request failed: ${String(err)}`);\n }\n\n if (RETRY_STATUSES.has(resp.status) && attempt < this.maxRetries) {\n await sleep(retryAfterMs(resp) ?? backoffMs(attempt));\n continue;\n }\n if (resp.status >= 400) throw await apiError(resp);\n return resp;\n }\n throw new WebhookdError(`request failed after retries: ${String(lastErr)}`);\n }\n}\n\nfunction backoffMs(attempt: number): number {\n return Math.min(2000, 200 * 2 ** attempt);\n}\n\nfunction retryAfterMs(resp: Response): number | null {\n const raw = resp.headers.get(\"Retry-After\");\n if (raw && /^\\d+$/.test(raw)) return Number(raw) * 1000;\n return null;\n}\n\nasync function apiError(resp: Response): Promise<WebhookdApiError> {\n let code = \"error\";\n let message = \"\";\n try {\n const data = (await resp.json()) as { error?: { code?: string; message?: string } };\n code = data.error?.code ?? code;\n message = data.error?.message ?? \"\";\n } catch {\n message = \"\";\n }\n return new WebhookdApiError(resp.status, code, message);\n}\n\nfunction sleep(ms: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\n","/**\n * @nimbusnexus/webhooks-sdk — the official TypeScript client for NimbusNexus Webhooks.\n *\n * - `verify` — verify an incoming webhook's HMAC signature (for subscribers).\n * - `WebhookdClient` — publish events to webhookd (for producers).\n */\nexport { DEFAULT_TOLERANCE_SECONDS, sign, verify } from \"./signature\";\nexport type { VerifyOptions } from \"./signature\";\nexport { WebhookdClient } from \"./client\";\nexport type { ClientOptions, PublishOptions, WebhookdEvent } from \"./client\";\nexport { WebhookdApiError, WebhookdError } from \"./errors\";\n\nexport const VERSION = \"0.1.0\";\n"],"mappings":"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","names":[]}
|
package/package.json
CHANGED