@cronwatch/sdk 0.14.2 → 0.15.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/LICENSE +1 -1
- package/dist/anthropic.cjs +5 -1
- package/dist/anthropic.cjs.map +1 -1
- package/dist/anthropic.d.cts +6 -1
- package/dist/anthropic.d.ts +6 -1
- package/dist/anthropic.js +5 -1
- package/dist/anthropic.js.map +1 -1
- package/dist/{client-CHjrNHH-.d.cts → client-IuAn2-v9.d.cts} +7 -4
- package/dist/{client-C8j7B2Bo.d.ts → client-MLN3rDFE.d.ts} +7 -4
- package/dist/index.cjs +174 -57
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +3 -3
- package/dist/index.d.ts +3 -3
- package/dist/index.js +174 -57
- package/dist/index.js.map +1 -1
- package/dist/pg-cron.cjs.map +1 -1
- package/dist/pg-cron.d.cts +1 -1
- package/dist/pg-cron.d.ts +1 -1
- package/dist/pg-cron.js.map +1 -1
- package/dist/postgres.cjs +15 -1
- package/dist/postgres.cjs.map +1 -1
- package/dist/postgres.d.cts +29 -1
- package/dist/postgres.d.ts +29 -1
- package/dist/postgres.js +15 -1
- package/dist/postgres.js.map +1 -1
- package/dist/slack.cjs +7 -2
- package/dist/slack.cjs.map +1 -1
- package/dist/slack.js +7 -2
- package/dist/slack.js.map +1 -1
- package/dist/webhook.cjs +1 -8
- package/dist/webhook.cjs.map +1 -1
- package/dist/webhook.js +1 -8
- package/dist/webhook.js.map +1 -1
- package/package.json +1 -1
package/dist/webhook.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/alerts/shared.ts","../src/alerts/webhook.ts","../src/entries/webhook.ts"],"names":["origin","signature"],"mappings":"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","file":"webhook.cjs","sourcesContent":["/**\n * Helpers shared by the provider channels. Only fetch and Web Crypto, so the\n * channels run on Node, Cloudflare Workers, Deno, and Bun alike. Not an entry\n * point: each channel bundles its own copy.\n */\nimport { isoTime } from \"../duration.js\";\nimport type { Alert } from \"../types.js\";\n\nexport const TIMEOUT_MS = 10_000;\n\n/** Severity for trackers that have levels. Recovered is informational. */\nexport type Severity = \"error\" | \"warning\" | \"info\";\n\nexport function severity(type: Alert[\"type\"]): Severity {\n if (type === \"recovered\") return \"info\";\n if (type === \"slow\" || type === \"over_budget\" || type === \"under_floor\") return \"warning\";\n return \"error\";\n}\n\n/** The scheme, host, and port only. A URL's path or query can hold a credential. */\nexport function origin(url: string): string {\n try {\n return new URL(url).origin;\n } catch {\n return \"(invalid URL)\";\n }\n}\n\n/**\n * The URL, once it is one fetch can post to. Refused without quoting it:\n * fetch's own error for a URL it cannot parse (\"Failed to parse URL from ...\")\n * quotes the whole URL, and a webhook URL's path is its credential.\n */\nexport function postable(url: string): string {\n let parsed: URL;\n try {\n parsed = new URL(url);\n } catch {\n throw new Error(\"only http and https URLs can be posted to, not this URL\");\n }\n if (parsed.protocol !== \"http:\" && parsed.protocol !== \"https:\") throw new Error(`only http and https URLs can be posted to, not ${parsed.protocol}`);\n if (parsed.username || parsed.password) throw new Error(\"URLs containing credentials cannot be posted to\");\n return url;\n}\n\n/** Fetch can quote credentials in construction errors. Only safe diagnostics leave this boundary. */\nexport async function fetchPost(url: string, init: { headers: Record<string, string>; body: string }): Promise<Response> {\n const target = postable(url);\n try {\n new Headers(init.headers);\n } catch {\n throw new Error(\"request headers are not valid\");\n }\n try {\n return await fetch(target, { ...init, method: \"POST\", redirect: \"error\", signal: AbortSignal.timeout(TIMEOUT_MS) });\n } catch (error) {\n if ((error as Error)?.name === \"TimeoutError\") throw new DOMException(\"The operation was aborted due to timeout\", \"TimeoutError\");\n throw new Error(`POST ${origin(target)} failed`);\n }\n}\n\n/** How much of a provider's error body goes into the error message. */\nexport const ERROR_BODY_MAX = 200;\n\n/**\n * POSTs and throws on a non-2xx answer. The error names the provider and the\n * URL's origin, plus the start of the response body with every secret the\n * channel holds cut out, in case a provider echoes one back. Secrets are cut\n * from a prefix long enough to hold one that starts inside the first\n * ERROR_BODY_MAX characters, and only then is it cut to that length, so no\n * part of a secret survives at the edge. A redirect is refused rather than\n * followed: the credential headers would go with it to wherever it points.\n */\nexport async function post(provider: string, url: string, init: { headers: Record<string, string>; body: string }, secrets: (string | undefined)[] = []): Promise<Response> {\n const response = await fetchPost(url, init);\n if (!response.ok) {\n const text = await errorText(response);\n throw new Error(`${provider} ${origin(url)} answered ${response.status}${text ? `: ${errorBody(text, secrets)}` : \"\"}`);\n }\n await drain(response);\n return response;\n}\n\n/** The most of an error body read (64 KiB), far more than any secret plus ERROR_BODY_MAX needs. */\nexport const ERROR_READ_MAX = 64 * 1024;\n\n/**\n * The start of an error answer's body, at most ERROR_READ_MAX bytes read\n * from the stream and the rest let go, so a provider (or something posing\n * as one) cannot make a send hold a body of any size. \"\" when it cannot be\n * read. A character cut at the end decodes as U+FFFD, far past what\n * errorBody keeps.\n */\nexport async function errorText(response: Response): Promise<string> {\n const reader = response.body?.getReader();\n if (!reader) return \"\";\n const chunks: Uint8Array[] = [];\n let size = 0;\n try {\n while (size < ERROR_READ_MAX) {\n const { done, value } = await reader.read();\n if (done) break;\n const take = value.subarray(0, ERROR_READ_MAX - size);\n chunks.push(take);\n size += take.byteLength;\n }\n } catch {\n // The status is enough; whatever was read is kept.\n } finally {\n await reader.cancel().catch(() => {});\n }\n const bytes = new Uint8Array(size);\n let at = 0;\n for (const chunk of chunks) {\n bytes.set(chunk, at);\n at += chunk.byteLength;\n }\n return new TextDecoder().decode(bytes);\n}\n\n/**\n * Lets go of a successful answer's body, which nothing reads, so its\n * connection goes back to the pool now rather than when it is collected.\n */\nexport async function drain(response: Response): Promise<void> {\n try {\n await response.body?.cancel();\n } catch {\n // Already read, or the connection is gone: nothing to let go of.\n }\n}\n\n/**\n * The parts of a webhook URL that are its credential, to cut from an error\n * body that echoes the request: its path and query together, then the last\n * segment of its path (Slack's and Discord's token) on its own.\n */\nexport function urlSecrets(url: string): string[] {\n try {\n const parsed = new URL(url);\n const last = parsed.pathname.split(\"/\").pop() ?? \"\";\n return [parsed.pathname + parsed.search, last].filter((part) => part.length > 0 && part !== \"/\");\n } catch {\n return [];\n }\n}\n\n/** The start of an error body, secrets cut out first, then cut to ERROR_BODY_MAX on a code point. */\nexport function errorBody(text: string, secrets: (string | undefined)[] = []): string {\n const kept = secrets.filter((s): s is string => typeof s === \"string\" && s.length >= 4);\n const longest = kept.reduce((n, s) => Math.max(n, s.length), 0);\n let head = cut(text, ERROR_BODY_MAX + longest);\n for (const secret of kept) head = head.split(secret).join(\"[redacted]\");\n return cut(head, ERROR_BODY_MAX);\n}\n\n/** A credential with the spaces and newlines a paste leaves around it taken off. Anything not a string is empty. */\nexport function trimmed(value: unknown): string {\n return typeof value === \"string\" ? value.trim() : \"\";\n}\n\n/** Base64 of UTF-8, without Buffer. */\nexport function base64(text: string): string {\n let binary = \"\";\n for (const byte of new TextEncoder().encode(text)) binary += String.fromCharCode(byte);\n return btoa(binary);\n}\n\nexport function basicAuth(user: string, password: string): string {\n return `Basic ${base64(`${user}:${password}`)}`;\n}\n\nexport function hex(bytes: ArrayBuffer | Uint8Array): string {\n return Array.from(bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes), (b) => b.toString(16).padStart(2, \"0\")).join(\"\");\n}\n\nexport async function sha256Hex(text: string): Promise<string> {\n return hex(await crypto.subtle.digest(\"SHA-256\", new TextEncoder().encode(text)));\n}\n\n/**\n * A stable 32 hex character id for one alert: the same job, type, and time\n * always give the same id, so a provider that deduplicates on it drops a\n * resend of an alert it already took.\n */\nexport async function alertId(alert: Alert): Promise<string> {\n return (await sha256Hex(`${alert.job}\\n${alert.type}\\n${alert.at}`)).slice(0, 32);\n}\n\n/** The same id laid out as a UUID, for APIs that ask for one. */\nexport function asUuid(id: string): string {\n return `${id.slice(0, 8)}-${id.slice(8, 12)}-${id.slice(12, 16)}-${id.slice(16, 20)}-${id.slice(20, 32)}`;\n}\n\n/** Cuts to at most `max` UTF-16 units without splitting a surrogate pair. */\nexport function cut(text: string, max: number): string {\n if (text.length <= max) return text;\n let end = max;\n const code = text.charCodeAt(end - 1);\n if (code >= 0xd800 && code <= 0xdbff) end -= 1;\n return text.slice(0, end);\n}\n\n/** The run fields worth attaching to a tracker event. A start before the year 1 or after 9999 is null. */\nexport function runSummary(alert: Alert): Record<string, unknown> | null {\n const run = alert.run;\n if (!run) return null;\n return { id: run.id, status: run.status, startedAt: isoTime(run.startedAt), durationMs: run.durationMs, trigger: run.trigger };\n}\n\n/** Title, message, triage, and link as one plain text block, the way every channel reads. */\nexport function plainText(alert: Alert, link: string | undefined): string {\n return [alert.title, \"\", alert.message, ...(alert.triage ? [\"\", `Triage: ${alert.triage}`] : []), ...(link ? [\"\", `Open: ${link}`] : [])].join(\"\\n\");\n}\n","import type { AlertChannel } from \"../types.js\";\nimport { drain, fetchPost } from \"./shared.js\";\n\nexport interface WebhookOptions {\n url: string;\n /**\n * Extra request headers, for an Authorization header say. Values are\n * trimmed. A name is matched without regard to case, so a Content-Type\n * given here replaces the default one rather than going beside it; the\n * signature headers are always the channel's own.\n */\n headers?: Record<string, string>;\n /**\n * When set, each request is signed with this secret (HMAC-SHA256, as\n * lowercase hex), so the receiver can verify it:\n *\n * - `X-CronWatch-Signature: sha256=<hex>` signs the raw body alone, as it\n * always has.\n * - `X-CronWatch-Timestamp: <unix seconds>` is when it was sent, and\n * `X-CronWatch-Timestamp-Signature: sha256=<hex>` signs\n * `<timestamp>.<raw body>`, so a receiver that checks this one and\n * refuses a timestamp more than five minutes from its own clock cannot\n * be sent a captured alert again later.\n *\n * An empty string (an unset variable read as \"\", say) signs nothing, and\n * each send reports that to onError.\n */\n secret?: string;\n /** The clock the timestamp is read from, in milliseconds. Default Date.now. */\n now?: () => number;\n}\n\n/**\n * The payload's version, sent as its first field. It goes up only if a\n * major release changes the payload in a way that is not additive; see\n * https://cronwatch.dev/schemas/webhook/1.json.\n */\nexport const WEBHOOK_SCHEMA = 1;\n\nfunction origin(url: string): string {\n try {\n return new URL(url).origin;\n } catch {\n return \"(invalid URL)\";\n }\n}\n\n/**\n * POSTs the alert as JSON to any URL. The body is the Alert object after a\n * version: { schema: 1, type, job, title, message, run, details, triage, at,\n * definition }. A redirect is an error: point the url at where the receiver\n * really is.\n */\nexport function webhook(options: WebhookOptions): AlertChannel {\n if (!options.url) throw new Error(\"webhook() needs a url\");\n return {\n name: \"webhook\",\n async send(alert, context) {\n const body = JSON.stringify({ schema: WEBHOOK_SCHEMA, ...alert });\n if (options.secret === \"\") {\n context?.onError?.(new Error(\"webhook: secret is an empty string, so this request is sent unsigned; set the secret, or leave it out to send unsigned on purpose\"));\n }\n const headers: Record<string, string> = { \"content-type\": \"application/json\", \"user-agent\": \"cronwatch\" };\n // A pasted Authorization value often carries a stray space or newline, which fetch would refuse.\n for (const [name, value] of Object.entries(options.headers ?? {})) setHeader(headers, name, typeof value === \"string\" ? value.trim() : value);\n if (options.secret) {\n const timestamp = String(Math.floor((options.now ?? Date.now)() / 1000));\n setHeader(headers, \"x-cronwatch-signature\", `sha256=${await signature(options.secret, body)}`);\n setHeader(headers, \"x-cronwatch-timestamp\", timestamp);\n setHeader(headers, \"x-cronwatch-timestamp-signature\", `sha256=${await signature(options.secret, `${timestamp}.${body}`)}`);\n }\n // A redirect is refused, not followed: the headers (and the signature) would go with it.\n const response = await fetchPost(options.url, { headers, body });\n // Only the origin: a webhook URL's path or query often is the credential.\n if (!response.ok) throw new Error(`Webhook ${origin(options.url)} answered ${response.status}`);\n await drain(response);\n },\n };\n}\n\n/** Sets a header, first taking out any of the same name in another case. */\nfunction setHeader(headers: Record<string, string>, name: string, value: string): void {\n const lower = name.toLowerCase();\n for (const key of Object.keys(headers)) if (key.toLowerCase() === lower) delete headers[key];\n headers[name] = value;\n}\n\n/**\n * The webhook's signature of a body: the HMAC-SHA256 of the body with the\n * secret, as lowercase hex. A request carries it as\n * `X-CronWatch-Signature: sha256=<signature>`, so a receiver checks one with\n * the raw body it got (before any JSON parsing) and a constant-time compare.\n * The timestamped one is the signature of `<timestamp>.<body>`, with the\n * X-CronWatch-Timestamp value as sent.\n * Web Crypto, so it runs on Workers and Deno too. Every port has it, named\n * the same.\n */\nexport async function signature(secret: string, body: string): Promise<string> {\n const encoder = new TextEncoder();\n const key = await crypto.subtle.importKey(\"raw\", encoder.encode(secret), { name: \"HMAC\", hash: \"SHA-256\" }, false, [\"sign\"]);\n const signature = new Uint8Array(await crypto.subtle.sign(\"HMAC\", key, encoder.encode(body)));\n return Array.from(signature, (b) => b.toString(16).padStart(2, \"0\")).join(\"\");\n}\n","// @cronwatch/sdk/webhook: the channel, and signature(), the one helper for\n// a receiver, named the same in every port.\nimport * as internal from \"../alerts/webhook.js\";\n\nexport { signature, webhook } from \"../alerts/webhook.js\";\nexport type { WebhookOptions } from \"../alerts/webhook.js\";\n\n/** @deprecated Renamed `signature`, the name every port uses; this name leaves the exports in 1.0. */\nexport const hmacSha256Hex = internal.signature;\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/alerts/shared.ts","../src/alerts/webhook.ts","../src/entries/webhook.ts"],"names":["signature"],"mappings":"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","file":"webhook.cjs","sourcesContent":["/**\n * Helpers shared by the provider channels. Only fetch and Web Crypto, so the\n * channels run on Node, Cloudflare Workers, Deno, and Bun alike. Not an entry\n * point: each channel bundles its own copy.\n */\nimport { isoTime } from \"../duration.js\";\nimport type { Alert } from \"../types.js\";\n\nexport const TIMEOUT_MS = 10_000;\n\n/** Severity for trackers that have levels. Recovered is informational. */\nexport type Severity = \"error\" | \"warning\" | \"info\";\n\nexport function severity(type: Alert[\"type\"]): Severity {\n if (type === \"recovered\") return \"info\";\n if (type === \"slow\" || type === \"over_budget\" || type === \"under_floor\") return \"warning\";\n return \"error\";\n}\n\n/** The scheme, host, and port only. A URL's path or query can hold a credential. */\nexport function origin(url: string): string {\n try {\n return new URL(url).origin;\n } catch {\n return \"(invalid URL)\";\n }\n}\n\n/**\n * The URL, once it is one fetch can post to. Refused without quoting it:\n * fetch's own error for a URL it cannot parse (\"Failed to parse URL from ...\")\n * quotes the whole URL, and a webhook URL's path is its credential.\n */\nexport function postable(url: string): string {\n let parsed: URL;\n try {\n parsed = new URL(url);\n } catch {\n throw new Error(\"only http and https URLs can be posted to, not this URL\");\n }\n if (parsed.protocol !== \"http:\" && parsed.protocol !== \"https:\") throw new Error(`only http and https URLs can be posted to, not ${parsed.protocol}`);\n if (parsed.username || parsed.password) throw new Error(\"URLs containing credentials cannot be posted to\");\n return url;\n}\n\n/** Fetch can quote credentials in construction errors. Only safe diagnostics leave this boundary. */\nexport async function fetchPost(url: string, init: { headers: Record<string, string>; body: string }): Promise<Response> {\n const target = postable(url);\n try {\n new Headers(init.headers);\n } catch {\n throw new Error(\"request headers are not valid\");\n }\n try {\n return await fetch(target, { ...init, method: \"POST\", redirect: \"error\", signal: AbortSignal.timeout(TIMEOUT_MS) });\n } catch (error) {\n if ((error as Error)?.name === \"TimeoutError\") throw new DOMException(\"The operation was aborted due to timeout\", \"TimeoutError\");\n throw new Error(`POST ${origin(target)} failed`);\n }\n}\n\n/** How much of a provider's error body goes into the error message. */\nexport const ERROR_BODY_MAX = 200;\n\n/**\n * POSTs and throws on a non-2xx answer. The error names the provider and the\n * URL's origin, plus the start of the response body with every secret the\n * channel holds cut out, in case a provider echoes one back. Secrets are cut\n * from a prefix long enough to hold one that starts inside the first\n * ERROR_BODY_MAX characters, and only then is it cut to that length, so no\n * part of a secret survives at the edge. A redirect is refused rather than\n * followed: the credential headers would go with it to wherever it points.\n */\nexport async function post(provider: string, url: string, init: { headers: Record<string, string>; body: string }, secrets: (string | undefined)[] = []): Promise<Response> {\n const response = await fetchPost(url, init);\n if (!response.ok) {\n const text = await errorText(response);\n throw new Error(`${provider} ${origin(url)} answered ${response.status}${text ? `: ${errorBody(text, secrets)}` : \"\"}`);\n }\n await drain(response);\n return response;\n}\n\n/** The most of an error body read (64 KiB), far more than any secret plus ERROR_BODY_MAX needs. */\nexport const ERROR_READ_MAX = 64 * 1024;\n\n/**\n * The start of an error answer's body, at most ERROR_READ_MAX bytes read\n * from the stream and the rest let go, so a provider (or something posing\n * as one) cannot make a send hold a body of any size. \"\" when it cannot be\n * read. A character cut at the end decodes as U+FFFD, far past what\n * errorBody keeps.\n */\nexport async function errorText(response: Response): Promise<string> {\n const reader = response.body?.getReader();\n if (!reader) return \"\";\n const chunks: Uint8Array[] = [];\n let size = 0;\n try {\n while (size < ERROR_READ_MAX) {\n const { done, value } = await reader.read();\n if (done) break;\n const take = value.subarray(0, ERROR_READ_MAX - size);\n chunks.push(take);\n size += take.byteLength;\n }\n } catch {\n // The status is enough; whatever was read is kept.\n } finally {\n await reader.cancel().catch(() => {});\n }\n const bytes = new Uint8Array(size);\n let at = 0;\n for (const chunk of chunks) {\n bytes.set(chunk, at);\n at += chunk.byteLength;\n }\n return new TextDecoder().decode(bytes);\n}\n\n/**\n * Lets go of a successful answer's body, which nothing reads, so its\n * connection goes back to the pool now rather than when it is collected.\n */\nexport async function drain(response: Response): Promise<void> {\n try {\n await response.body?.cancel();\n } catch {\n // Already read, or the connection is gone: nothing to let go of.\n }\n}\n\n/**\n * The parts of a webhook URL that are its credential, to cut from an error\n * body that echoes the request: its path and query together, then the last\n * segment of its path (Slack's and Discord's token) on its own.\n */\nexport function urlSecrets(url: string): string[] {\n try {\n const parsed = new URL(url);\n const last = parsed.pathname.split(\"/\").pop() ?? \"\";\n return [parsed.pathname + parsed.search, last].filter((part) => part.length > 0 && part !== \"/\");\n } catch {\n return [];\n }\n}\n\n/** The start of an error body, secrets cut out first, then cut to ERROR_BODY_MAX on a code point. */\nexport function errorBody(text: string, secrets: (string | undefined)[] = []): string {\n const kept = secrets.filter((s): s is string => typeof s === \"string\" && s.length >= 4);\n const longest = kept.reduce((n, s) => Math.max(n, s.length), 0);\n let head = cut(text, ERROR_BODY_MAX + longest);\n for (const secret of kept) head = head.split(secret).join(\"[redacted]\");\n return cut(head, ERROR_BODY_MAX);\n}\n\n/** A credential with the spaces and newlines a paste leaves around it taken off. Anything not a string is empty. */\nexport function trimmed(value: unknown): string {\n return typeof value === \"string\" ? value.trim() : \"\";\n}\n\n/** Base64 of UTF-8, without Buffer. */\nexport function base64(text: string): string {\n let binary = \"\";\n for (const byte of new TextEncoder().encode(text)) binary += String.fromCharCode(byte);\n return btoa(binary);\n}\n\nexport function basicAuth(user: string, password: string): string {\n return `Basic ${base64(`${user}:${password}`)}`;\n}\n\nexport function hex(bytes: ArrayBuffer | Uint8Array): string {\n return Array.from(bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes), (b) => b.toString(16).padStart(2, \"0\")).join(\"\");\n}\n\nexport async function sha256Hex(text: string): Promise<string> {\n return hex(await crypto.subtle.digest(\"SHA-256\", new TextEncoder().encode(text)));\n}\n\n/**\n * A stable 32 hex character id for one alert: the same job, type, and time\n * always give the same id, so a provider that deduplicates on it drops a\n * resend of an alert it already took.\n */\nexport async function alertId(alert: Alert): Promise<string> {\n return (await sha256Hex(`${alert.job}\\n${alert.type}\\n${alert.at}`)).slice(0, 32);\n}\n\n/** The same id laid out as a UUID, for APIs that ask for one. */\nexport function asUuid(id: string): string {\n return `${id.slice(0, 8)}-${id.slice(8, 12)}-${id.slice(12, 16)}-${id.slice(16, 20)}-${id.slice(20, 32)}`;\n}\n\n/** Cuts to at most `max` UTF-16 units without splitting a surrogate pair. */\nexport function cut(text: string, max: number): string {\n if (text.length <= max) return text;\n let end = max;\n const code = text.charCodeAt(end - 1);\n if (code >= 0xd800 && code <= 0xdbff) end -= 1;\n return text.slice(0, end);\n}\n\n/** The run fields worth attaching to a tracker event. A start before the year 1 or after 9999 is null. */\nexport function runSummary(alert: Alert): Record<string, unknown> | null {\n const run = alert.run;\n if (!run) return null;\n return { id: run.id, status: run.status, startedAt: isoTime(run.startedAt), durationMs: run.durationMs, trigger: run.trigger };\n}\n\n/** Title, message, triage, and link as one plain text block, the way every channel reads. */\nexport function plainText(alert: Alert, link: string | undefined): string {\n return [alert.title, \"\", alert.message, ...(alert.triage ? [\"\", `Triage: ${alert.triage}`] : []), ...(link ? [\"\", `Open: ${link}`] : [])].join(\"\\n\");\n}\n","import type { AlertChannel } from \"../types.js\";\nimport { drain, fetchPost, origin } from \"./shared.js\";\n\nexport interface WebhookOptions {\n url: string;\n /**\n * Extra request headers, for an Authorization header say. Values are\n * trimmed. A name is matched without regard to case, so a Content-Type\n * given here replaces the default one rather than going beside it; the\n * signature headers are always the channel's own.\n */\n headers?: Record<string, string>;\n /**\n * When set, each request is signed with this secret (HMAC-SHA256, as\n * lowercase hex), so the receiver can verify it:\n *\n * - `X-CronWatch-Signature: sha256=<hex>` signs the raw body alone, as it\n * always has.\n * - `X-CronWatch-Timestamp: <unix seconds>` is when it was sent, and\n * `X-CronWatch-Timestamp-Signature: sha256=<hex>` signs\n * `<timestamp>.<raw body>`, so a receiver that checks this one and\n * refuses a timestamp more than five minutes from its own clock cannot\n * be sent a captured alert again later.\n *\n * An empty string (an unset variable read as \"\", say) signs nothing, and\n * each send reports that to onError.\n */\n secret?: string;\n /** The clock the timestamp is read from, in milliseconds. Default Date.now. */\n now?: () => number;\n}\n\n/**\n * The payload's version, sent as its first field. It goes up only if a\n * major release changes the payload in a way that is not additive; see\n * https://cronwatch.dev/schemas/webhook/1.json.\n */\nexport const WEBHOOK_SCHEMA = 1;\n\n/**\n * POSTs the alert as JSON to any URL. The body is the Alert object after a\n * version: { schema: 1, type, job, title, message, run, details, triage, at,\n * definition }. A redirect is an error: point the url at where the receiver\n * really is.\n */\nexport function webhook(options: WebhookOptions): AlertChannel {\n if (!options.url) throw new Error(\"webhook() needs a url\");\n return {\n name: \"webhook\",\n async send(alert, context) {\n const body = JSON.stringify({ schema: WEBHOOK_SCHEMA, ...alert });\n if (options.secret === \"\") {\n context?.onError?.(new Error(\"webhook: secret is an empty string, so this request is sent unsigned; set the secret, or leave it out to send unsigned on purpose\"));\n }\n const headers: Record<string, string> = { \"content-type\": \"application/json\", \"user-agent\": \"cronwatch\" };\n // A pasted Authorization value often carries a stray space or newline, which fetch would refuse.\n for (const [name, value] of Object.entries(options.headers ?? {})) setHeader(headers, name, typeof value === \"string\" ? value.trim() : value);\n if (options.secret) {\n const timestamp = String(Math.floor((options.now ?? Date.now)() / 1000));\n setHeader(headers, \"x-cronwatch-signature\", `sha256=${await signature(options.secret, body)}`);\n setHeader(headers, \"x-cronwatch-timestamp\", timestamp);\n setHeader(headers, \"x-cronwatch-timestamp-signature\", `sha256=${await signature(options.secret, `${timestamp}.${body}`)}`);\n }\n // A redirect is refused, not followed: the headers (and the signature) would go with it.\n const response = await fetchPost(options.url, { headers, body });\n // Only the origin: a webhook URL's path or query often is the credential.\n if (!response.ok) throw new Error(`Webhook ${origin(options.url)} answered ${response.status}`);\n await drain(response);\n },\n };\n}\n\n/** Sets a header, first taking out any of the same name in another case. */\nfunction setHeader(headers: Record<string, string>, name: string, value: string): void {\n const lower = name.toLowerCase();\n for (const key of Object.keys(headers)) if (key.toLowerCase() === lower) delete headers[key];\n headers[name] = value;\n}\n\n/**\n * The webhook's signature of a body: the HMAC-SHA256 of the body with the\n * secret, as lowercase hex. A request carries it as\n * `X-CronWatch-Signature: sha256=<signature>`, so a receiver checks one with\n * the raw body it got (before any JSON parsing) and a constant-time compare.\n * The timestamped one is the signature of `<timestamp>.<body>`, with the\n * X-CronWatch-Timestamp value as sent.\n * Web Crypto, so it runs on Workers and Deno too. Every port has it, named\n * the same.\n */\nexport async function signature(secret: string, body: string): Promise<string> {\n const encoder = new TextEncoder();\n const key = await crypto.subtle.importKey(\"raw\", encoder.encode(secret), { name: \"HMAC\", hash: \"SHA-256\" }, false, [\"sign\"]);\n const signature = new Uint8Array(await crypto.subtle.sign(\"HMAC\", key, encoder.encode(body)));\n return Array.from(signature, (b) => b.toString(16).padStart(2, \"0\")).join(\"\");\n}\n","// @cronwatch/sdk/webhook: the channel, and signature(), the one helper for\n// a receiver, named the same in every port.\nimport * as internal from \"../alerts/webhook.js\";\n\nexport { signature, webhook } from \"../alerts/webhook.js\";\nexport type { WebhookOptions } from \"../alerts/webhook.js\";\n\n/** @deprecated Renamed `signature`, the name every port uses; this name leaves the exports in 1.0. */\nexport const hmacSha256Hex = internal.signature;\n"]}
|
package/dist/webhook.js
CHANGED
|
@@ -41,13 +41,6 @@ async function drain(response) {
|
|
|
41
41
|
|
|
42
42
|
// src/alerts/webhook.ts
|
|
43
43
|
var WEBHOOK_SCHEMA = 1;
|
|
44
|
-
function origin2(url) {
|
|
45
|
-
try {
|
|
46
|
-
return new URL(url).origin;
|
|
47
|
-
} catch {
|
|
48
|
-
return "(invalid URL)";
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
44
|
function webhook(options) {
|
|
52
45
|
if (!options.url) throw new Error("webhook() needs a url");
|
|
53
46
|
return {
|
|
@@ -66,7 +59,7 @@ function webhook(options) {
|
|
|
66
59
|
setHeader(headers, "x-cronwatch-timestamp-signature", `sha256=${await signature(options.secret, `${timestamp}.${body}`)}`);
|
|
67
60
|
}
|
|
68
61
|
const response = await fetchPost(options.url, { headers, body });
|
|
69
|
-
if (!response.ok) throw new Error(`Webhook ${
|
|
62
|
+
if (!response.ok) throw new Error(`Webhook ${origin(options.url)} answered ${response.status}`);
|
|
70
63
|
await drain(response);
|
|
71
64
|
}
|
|
72
65
|
};
|
package/dist/webhook.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/alerts/shared.ts","../src/alerts/webhook.ts","../src/entries/webhook.ts"],"names":["origin","signature"],"mappings":"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","file":"webhook.js","sourcesContent":["/**\n * Helpers shared by the provider channels. Only fetch and Web Crypto, so the\n * channels run on Node, Cloudflare Workers, Deno, and Bun alike. Not an entry\n * point: each channel bundles its own copy.\n */\nimport { isoTime } from \"../duration.js\";\nimport type { Alert } from \"../types.js\";\n\nexport const TIMEOUT_MS = 10_000;\n\n/** Severity for trackers that have levels. Recovered is informational. */\nexport type Severity = \"error\" | \"warning\" | \"info\";\n\nexport function severity(type: Alert[\"type\"]): Severity {\n if (type === \"recovered\") return \"info\";\n if (type === \"slow\" || type === \"over_budget\" || type === \"under_floor\") return \"warning\";\n return \"error\";\n}\n\n/** The scheme, host, and port only. A URL's path or query can hold a credential. */\nexport function origin(url: string): string {\n try {\n return new URL(url).origin;\n } catch {\n return \"(invalid URL)\";\n }\n}\n\n/**\n * The URL, once it is one fetch can post to. Refused without quoting it:\n * fetch's own error for a URL it cannot parse (\"Failed to parse URL from ...\")\n * quotes the whole URL, and a webhook URL's path is its credential.\n */\nexport function postable(url: string): string {\n let parsed: URL;\n try {\n parsed = new URL(url);\n } catch {\n throw new Error(\"only http and https URLs can be posted to, not this URL\");\n }\n if (parsed.protocol !== \"http:\" && parsed.protocol !== \"https:\") throw new Error(`only http and https URLs can be posted to, not ${parsed.protocol}`);\n if (parsed.username || parsed.password) throw new Error(\"URLs containing credentials cannot be posted to\");\n return url;\n}\n\n/** Fetch can quote credentials in construction errors. Only safe diagnostics leave this boundary. */\nexport async function fetchPost(url: string, init: { headers: Record<string, string>; body: string }): Promise<Response> {\n const target = postable(url);\n try {\n new Headers(init.headers);\n } catch {\n throw new Error(\"request headers are not valid\");\n }\n try {\n return await fetch(target, { ...init, method: \"POST\", redirect: \"error\", signal: AbortSignal.timeout(TIMEOUT_MS) });\n } catch (error) {\n if ((error as Error)?.name === \"TimeoutError\") throw new DOMException(\"The operation was aborted due to timeout\", \"TimeoutError\");\n throw new Error(`POST ${origin(target)} failed`);\n }\n}\n\n/** How much of a provider's error body goes into the error message. */\nexport const ERROR_BODY_MAX = 200;\n\n/**\n * POSTs and throws on a non-2xx answer. The error names the provider and the\n * URL's origin, plus the start of the response body with every secret the\n * channel holds cut out, in case a provider echoes one back. Secrets are cut\n * from a prefix long enough to hold one that starts inside the first\n * ERROR_BODY_MAX characters, and only then is it cut to that length, so no\n * part of a secret survives at the edge. A redirect is refused rather than\n * followed: the credential headers would go with it to wherever it points.\n */\nexport async function post(provider: string, url: string, init: { headers: Record<string, string>; body: string }, secrets: (string | undefined)[] = []): Promise<Response> {\n const response = await fetchPost(url, init);\n if (!response.ok) {\n const text = await errorText(response);\n throw new Error(`${provider} ${origin(url)} answered ${response.status}${text ? `: ${errorBody(text, secrets)}` : \"\"}`);\n }\n await drain(response);\n return response;\n}\n\n/** The most of an error body read (64 KiB), far more than any secret plus ERROR_BODY_MAX needs. */\nexport const ERROR_READ_MAX = 64 * 1024;\n\n/**\n * The start of an error answer's body, at most ERROR_READ_MAX bytes read\n * from the stream and the rest let go, so a provider (or something posing\n * as one) cannot make a send hold a body of any size. \"\" when it cannot be\n * read. A character cut at the end decodes as U+FFFD, far past what\n * errorBody keeps.\n */\nexport async function errorText(response: Response): Promise<string> {\n const reader = response.body?.getReader();\n if (!reader) return \"\";\n const chunks: Uint8Array[] = [];\n let size = 0;\n try {\n while (size < ERROR_READ_MAX) {\n const { done, value } = await reader.read();\n if (done) break;\n const take = value.subarray(0, ERROR_READ_MAX - size);\n chunks.push(take);\n size += take.byteLength;\n }\n } catch {\n // The status is enough; whatever was read is kept.\n } finally {\n await reader.cancel().catch(() => {});\n }\n const bytes = new Uint8Array(size);\n let at = 0;\n for (const chunk of chunks) {\n bytes.set(chunk, at);\n at += chunk.byteLength;\n }\n return new TextDecoder().decode(bytes);\n}\n\n/**\n * Lets go of a successful answer's body, which nothing reads, so its\n * connection goes back to the pool now rather than when it is collected.\n */\nexport async function drain(response: Response): Promise<void> {\n try {\n await response.body?.cancel();\n } catch {\n // Already read, or the connection is gone: nothing to let go of.\n }\n}\n\n/**\n * The parts of a webhook URL that are its credential, to cut from an error\n * body that echoes the request: its path and query together, then the last\n * segment of its path (Slack's and Discord's token) on its own.\n */\nexport function urlSecrets(url: string): string[] {\n try {\n const parsed = new URL(url);\n const last = parsed.pathname.split(\"/\").pop() ?? \"\";\n return [parsed.pathname + parsed.search, last].filter((part) => part.length > 0 && part !== \"/\");\n } catch {\n return [];\n }\n}\n\n/** The start of an error body, secrets cut out first, then cut to ERROR_BODY_MAX on a code point. */\nexport function errorBody(text: string, secrets: (string | undefined)[] = []): string {\n const kept = secrets.filter((s): s is string => typeof s === \"string\" && s.length >= 4);\n const longest = kept.reduce((n, s) => Math.max(n, s.length), 0);\n let head = cut(text, ERROR_BODY_MAX + longest);\n for (const secret of kept) head = head.split(secret).join(\"[redacted]\");\n return cut(head, ERROR_BODY_MAX);\n}\n\n/** A credential with the spaces and newlines a paste leaves around it taken off. Anything not a string is empty. */\nexport function trimmed(value: unknown): string {\n return typeof value === \"string\" ? value.trim() : \"\";\n}\n\n/** Base64 of UTF-8, without Buffer. */\nexport function base64(text: string): string {\n let binary = \"\";\n for (const byte of new TextEncoder().encode(text)) binary += String.fromCharCode(byte);\n return btoa(binary);\n}\n\nexport function basicAuth(user: string, password: string): string {\n return `Basic ${base64(`${user}:${password}`)}`;\n}\n\nexport function hex(bytes: ArrayBuffer | Uint8Array): string {\n return Array.from(bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes), (b) => b.toString(16).padStart(2, \"0\")).join(\"\");\n}\n\nexport async function sha256Hex(text: string): Promise<string> {\n return hex(await crypto.subtle.digest(\"SHA-256\", new TextEncoder().encode(text)));\n}\n\n/**\n * A stable 32 hex character id for one alert: the same job, type, and time\n * always give the same id, so a provider that deduplicates on it drops a\n * resend of an alert it already took.\n */\nexport async function alertId(alert: Alert): Promise<string> {\n return (await sha256Hex(`${alert.job}\\n${alert.type}\\n${alert.at}`)).slice(0, 32);\n}\n\n/** The same id laid out as a UUID, for APIs that ask for one. */\nexport function asUuid(id: string): string {\n return `${id.slice(0, 8)}-${id.slice(8, 12)}-${id.slice(12, 16)}-${id.slice(16, 20)}-${id.slice(20, 32)}`;\n}\n\n/** Cuts to at most `max` UTF-16 units without splitting a surrogate pair. */\nexport function cut(text: string, max: number): string {\n if (text.length <= max) return text;\n let end = max;\n const code = text.charCodeAt(end - 1);\n if (code >= 0xd800 && code <= 0xdbff) end -= 1;\n return text.slice(0, end);\n}\n\n/** The run fields worth attaching to a tracker event. A start before the year 1 or after 9999 is null. */\nexport function runSummary(alert: Alert): Record<string, unknown> | null {\n const run = alert.run;\n if (!run) return null;\n return { id: run.id, status: run.status, startedAt: isoTime(run.startedAt), durationMs: run.durationMs, trigger: run.trigger };\n}\n\n/** Title, message, triage, and link as one plain text block, the way every channel reads. */\nexport function plainText(alert: Alert, link: string | undefined): string {\n return [alert.title, \"\", alert.message, ...(alert.triage ? [\"\", `Triage: ${alert.triage}`] : []), ...(link ? [\"\", `Open: ${link}`] : [])].join(\"\\n\");\n}\n","import type { AlertChannel } from \"../types.js\";\nimport { drain, fetchPost } from \"./shared.js\";\n\nexport interface WebhookOptions {\n url: string;\n /**\n * Extra request headers, for an Authorization header say. Values are\n * trimmed. A name is matched without regard to case, so a Content-Type\n * given here replaces the default one rather than going beside it; the\n * signature headers are always the channel's own.\n */\n headers?: Record<string, string>;\n /**\n * When set, each request is signed with this secret (HMAC-SHA256, as\n * lowercase hex), so the receiver can verify it:\n *\n * - `X-CronWatch-Signature: sha256=<hex>` signs the raw body alone, as it\n * always has.\n * - `X-CronWatch-Timestamp: <unix seconds>` is when it was sent, and\n * `X-CronWatch-Timestamp-Signature: sha256=<hex>` signs\n * `<timestamp>.<raw body>`, so a receiver that checks this one and\n * refuses a timestamp more than five minutes from its own clock cannot\n * be sent a captured alert again later.\n *\n * An empty string (an unset variable read as \"\", say) signs nothing, and\n * each send reports that to onError.\n */\n secret?: string;\n /** The clock the timestamp is read from, in milliseconds. Default Date.now. */\n now?: () => number;\n}\n\n/**\n * The payload's version, sent as its first field. It goes up only if a\n * major release changes the payload in a way that is not additive; see\n * https://cronwatch.dev/schemas/webhook/1.json.\n */\nexport const WEBHOOK_SCHEMA = 1;\n\nfunction origin(url: string): string {\n try {\n return new URL(url).origin;\n } catch {\n return \"(invalid URL)\";\n }\n}\n\n/**\n * POSTs the alert as JSON to any URL. The body is the Alert object after a\n * version: { schema: 1, type, job, title, message, run, details, triage, at,\n * definition }. A redirect is an error: point the url at where the receiver\n * really is.\n */\nexport function webhook(options: WebhookOptions): AlertChannel {\n if (!options.url) throw new Error(\"webhook() needs a url\");\n return {\n name: \"webhook\",\n async send(alert, context) {\n const body = JSON.stringify({ schema: WEBHOOK_SCHEMA, ...alert });\n if (options.secret === \"\") {\n context?.onError?.(new Error(\"webhook: secret is an empty string, so this request is sent unsigned; set the secret, or leave it out to send unsigned on purpose\"));\n }\n const headers: Record<string, string> = { \"content-type\": \"application/json\", \"user-agent\": \"cronwatch\" };\n // A pasted Authorization value often carries a stray space or newline, which fetch would refuse.\n for (const [name, value] of Object.entries(options.headers ?? {})) setHeader(headers, name, typeof value === \"string\" ? value.trim() : value);\n if (options.secret) {\n const timestamp = String(Math.floor((options.now ?? Date.now)() / 1000));\n setHeader(headers, \"x-cronwatch-signature\", `sha256=${await signature(options.secret, body)}`);\n setHeader(headers, \"x-cronwatch-timestamp\", timestamp);\n setHeader(headers, \"x-cronwatch-timestamp-signature\", `sha256=${await signature(options.secret, `${timestamp}.${body}`)}`);\n }\n // A redirect is refused, not followed: the headers (and the signature) would go with it.\n const response = await fetchPost(options.url, { headers, body });\n // Only the origin: a webhook URL's path or query often is the credential.\n if (!response.ok) throw new Error(`Webhook ${origin(options.url)} answered ${response.status}`);\n await drain(response);\n },\n };\n}\n\n/** Sets a header, first taking out any of the same name in another case. */\nfunction setHeader(headers: Record<string, string>, name: string, value: string): void {\n const lower = name.toLowerCase();\n for (const key of Object.keys(headers)) if (key.toLowerCase() === lower) delete headers[key];\n headers[name] = value;\n}\n\n/**\n * The webhook's signature of a body: the HMAC-SHA256 of the body with the\n * secret, as lowercase hex. A request carries it as\n * `X-CronWatch-Signature: sha256=<signature>`, so a receiver checks one with\n * the raw body it got (before any JSON parsing) and a constant-time compare.\n * The timestamped one is the signature of `<timestamp>.<body>`, with the\n * X-CronWatch-Timestamp value as sent.\n * Web Crypto, so it runs on Workers and Deno too. Every port has it, named\n * the same.\n */\nexport async function signature(secret: string, body: string): Promise<string> {\n const encoder = new TextEncoder();\n const key = await crypto.subtle.importKey(\"raw\", encoder.encode(secret), { name: \"HMAC\", hash: \"SHA-256\" }, false, [\"sign\"]);\n const signature = new Uint8Array(await crypto.subtle.sign(\"HMAC\", key, encoder.encode(body)));\n return Array.from(signature, (b) => b.toString(16).padStart(2, \"0\")).join(\"\");\n}\n","// @cronwatch/sdk/webhook: the channel, and signature(), the one helper for\n// a receiver, named the same in every port.\nimport * as internal from \"../alerts/webhook.js\";\n\nexport { signature, webhook } from \"../alerts/webhook.js\";\nexport type { WebhookOptions } from \"../alerts/webhook.js\";\n\n/** @deprecated Renamed `signature`, the name every port uses; this name leaves the exports in 1.0. */\nexport const hmacSha256Hex = internal.signature;\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/alerts/shared.ts","../src/alerts/webhook.ts","../src/entries/webhook.ts"],"names":["signature"],"mappings":"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","file":"webhook.js","sourcesContent":["/**\n * Helpers shared by the provider channels. Only fetch and Web Crypto, so the\n * channels run on Node, Cloudflare Workers, Deno, and Bun alike. Not an entry\n * point: each channel bundles its own copy.\n */\nimport { isoTime } from \"../duration.js\";\nimport type { Alert } from \"../types.js\";\n\nexport const TIMEOUT_MS = 10_000;\n\n/** Severity for trackers that have levels. Recovered is informational. */\nexport type Severity = \"error\" | \"warning\" | \"info\";\n\nexport function severity(type: Alert[\"type\"]): Severity {\n if (type === \"recovered\") return \"info\";\n if (type === \"slow\" || type === \"over_budget\" || type === \"under_floor\") return \"warning\";\n return \"error\";\n}\n\n/** The scheme, host, and port only. A URL's path or query can hold a credential. */\nexport function origin(url: string): string {\n try {\n return new URL(url).origin;\n } catch {\n return \"(invalid URL)\";\n }\n}\n\n/**\n * The URL, once it is one fetch can post to. Refused without quoting it:\n * fetch's own error for a URL it cannot parse (\"Failed to parse URL from ...\")\n * quotes the whole URL, and a webhook URL's path is its credential.\n */\nexport function postable(url: string): string {\n let parsed: URL;\n try {\n parsed = new URL(url);\n } catch {\n throw new Error(\"only http and https URLs can be posted to, not this URL\");\n }\n if (parsed.protocol !== \"http:\" && parsed.protocol !== \"https:\") throw new Error(`only http and https URLs can be posted to, not ${parsed.protocol}`);\n if (parsed.username || parsed.password) throw new Error(\"URLs containing credentials cannot be posted to\");\n return url;\n}\n\n/** Fetch can quote credentials in construction errors. Only safe diagnostics leave this boundary. */\nexport async function fetchPost(url: string, init: { headers: Record<string, string>; body: string }): Promise<Response> {\n const target = postable(url);\n try {\n new Headers(init.headers);\n } catch {\n throw new Error(\"request headers are not valid\");\n }\n try {\n return await fetch(target, { ...init, method: \"POST\", redirect: \"error\", signal: AbortSignal.timeout(TIMEOUT_MS) });\n } catch (error) {\n if ((error as Error)?.name === \"TimeoutError\") throw new DOMException(\"The operation was aborted due to timeout\", \"TimeoutError\");\n throw new Error(`POST ${origin(target)} failed`);\n }\n}\n\n/** How much of a provider's error body goes into the error message. */\nexport const ERROR_BODY_MAX = 200;\n\n/**\n * POSTs and throws on a non-2xx answer. The error names the provider and the\n * URL's origin, plus the start of the response body with every secret the\n * channel holds cut out, in case a provider echoes one back. Secrets are cut\n * from a prefix long enough to hold one that starts inside the first\n * ERROR_BODY_MAX characters, and only then is it cut to that length, so no\n * part of a secret survives at the edge. A redirect is refused rather than\n * followed: the credential headers would go with it to wherever it points.\n */\nexport async function post(provider: string, url: string, init: { headers: Record<string, string>; body: string }, secrets: (string | undefined)[] = []): Promise<Response> {\n const response = await fetchPost(url, init);\n if (!response.ok) {\n const text = await errorText(response);\n throw new Error(`${provider} ${origin(url)} answered ${response.status}${text ? `: ${errorBody(text, secrets)}` : \"\"}`);\n }\n await drain(response);\n return response;\n}\n\n/** The most of an error body read (64 KiB), far more than any secret plus ERROR_BODY_MAX needs. */\nexport const ERROR_READ_MAX = 64 * 1024;\n\n/**\n * The start of an error answer's body, at most ERROR_READ_MAX bytes read\n * from the stream and the rest let go, so a provider (or something posing\n * as one) cannot make a send hold a body of any size. \"\" when it cannot be\n * read. A character cut at the end decodes as U+FFFD, far past what\n * errorBody keeps.\n */\nexport async function errorText(response: Response): Promise<string> {\n const reader = response.body?.getReader();\n if (!reader) return \"\";\n const chunks: Uint8Array[] = [];\n let size = 0;\n try {\n while (size < ERROR_READ_MAX) {\n const { done, value } = await reader.read();\n if (done) break;\n const take = value.subarray(0, ERROR_READ_MAX - size);\n chunks.push(take);\n size += take.byteLength;\n }\n } catch {\n // The status is enough; whatever was read is kept.\n } finally {\n await reader.cancel().catch(() => {});\n }\n const bytes = new Uint8Array(size);\n let at = 0;\n for (const chunk of chunks) {\n bytes.set(chunk, at);\n at += chunk.byteLength;\n }\n return new TextDecoder().decode(bytes);\n}\n\n/**\n * Lets go of a successful answer's body, which nothing reads, so its\n * connection goes back to the pool now rather than when it is collected.\n */\nexport async function drain(response: Response): Promise<void> {\n try {\n await response.body?.cancel();\n } catch {\n // Already read, or the connection is gone: nothing to let go of.\n }\n}\n\n/**\n * The parts of a webhook URL that are its credential, to cut from an error\n * body that echoes the request: its path and query together, then the last\n * segment of its path (Slack's and Discord's token) on its own.\n */\nexport function urlSecrets(url: string): string[] {\n try {\n const parsed = new URL(url);\n const last = parsed.pathname.split(\"/\").pop() ?? \"\";\n return [parsed.pathname + parsed.search, last].filter((part) => part.length > 0 && part !== \"/\");\n } catch {\n return [];\n }\n}\n\n/** The start of an error body, secrets cut out first, then cut to ERROR_BODY_MAX on a code point. */\nexport function errorBody(text: string, secrets: (string | undefined)[] = []): string {\n const kept = secrets.filter((s): s is string => typeof s === \"string\" && s.length >= 4);\n const longest = kept.reduce((n, s) => Math.max(n, s.length), 0);\n let head = cut(text, ERROR_BODY_MAX + longest);\n for (const secret of kept) head = head.split(secret).join(\"[redacted]\");\n return cut(head, ERROR_BODY_MAX);\n}\n\n/** A credential with the spaces and newlines a paste leaves around it taken off. Anything not a string is empty. */\nexport function trimmed(value: unknown): string {\n return typeof value === \"string\" ? value.trim() : \"\";\n}\n\n/** Base64 of UTF-8, without Buffer. */\nexport function base64(text: string): string {\n let binary = \"\";\n for (const byte of new TextEncoder().encode(text)) binary += String.fromCharCode(byte);\n return btoa(binary);\n}\n\nexport function basicAuth(user: string, password: string): string {\n return `Basic ${base64(`${user}:${password}`)}`;\n}\n\nexport function hex(bytes: ArrayBuffer | Uint8Array): string {\n return Array.from(bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes), (b) => b.toString(16).padStart(2, \"0\")).join(\"\");\n}\n\nexport async function sha256Hex(text: string): Promise<string> {\n return hex(await crypto.subtle.digest(\"SHA-256\", new TextEncoder().encode(text)));\n}\n\n/**\n * A stable 32 hex character id for one alert: the same job, type, and time\n * always give the same id, so a provider that deduplicates on it drops a\n * resend of an alert it already took.\n */\nexport async function alertId(alert: Alert): Promise<string> {\n return (await sha256Hex(`${alert.job}\\n${alert.type}\\n${alert.at}`)).slice(0, 32);\n}\n\n/** The same id laid out as a UUID, for APIs that ask for one. */\nexport function asUuid(id: string): string {\n return `${id.slice(0, 8)}-${id.slice(8, 12)}-${id.slice(12, 16)}-${id.slice(16, 20)}-${id.slice(20, 32)}`;\n}\n\n/** Cuts to at most `max` UTF-16 units without splitting a surrogate pair. */\nexport function cut(text: string, max: number): string {\n if (text.length <= max) return text;\n let end = max;\n const code = text.charCodeAt(end - 1);\n if (code >= 0xd800 && code <= 0xdbff) end -= 1;\n return text.slice(0, end);\n}\n\n/** The run fields worth attaching to a tracker event. A start before the year 1 or after 9999 is null. */\nexport function runSummary(alert: Alert): Record<string, unknown> | null {\n const run = alert.run;\n if (!run) return null;\n return { id: run.id, status: run.status, startedAt: isoTime(run.startedAt), durationMs: run.durationMs, trigger: run.trigger };\n}\n\n/** Title, message, triage, and link as one plain text block, the way every channel reads. */\nexport function plainText(alert: Alert, link: string | undefined): string {\n return [alert.title, \"\", alert.message, ...(alert.triage ? [\"\", `Triage: ${alert.triage}`] : []), ...(link ? [\"\", `Open: ${link}`] : [])].join(\"\\n\");\n}\n","import type { AlertChannel } from \"../types.js\";\nimport { drain, fetchPost, origin } from \"./shared.js\";\n\nexport interface WebhookOptions {\n url: string;\n /**\n * Extra request headers, for an Authorization header say. Values are\n * trimmed. A name is matched without regard to case, so a Content-Type\n * given here replaces the default one rather than going beside it; the\n * signature headers are always the channel's own.\n */\n headers?: Record<string, string>;\n /**\n * When set, each request is signed with this secret (HMAC-SHA256, as\n * lowercase hex), so the receiver can verify it:\n *\n * - `X-CronWatch-Signature: sha256=<hex>` signs the raw body alone, as it\n * always has.\n * - `X-CronWatch-Timestamp: <unix seconds>` is when it was sent, and\n * `X-CronWatch-Timestamp-Signature: sha256=<hex>` signs\n * `<timestamp>.<raw body>`, so a receiver that checks this one and\n * refuses a timestamp more than five minutes from its own clock cannot\n * be sent a captured alert again later.\n *\n * An empty string (an unset variable read as \"\", say) signs nothing, and\n * each send reports that to onError.\n */\n secret?: string;\n /** The clock the timestamp is read from, in milliseconds. Default Date.now. */\n now?: () => number;\n}\n\n/**\n * The payload's version, sent as its first field. It goes up only if a\n * major release changes the payload in a way that is not additive; see\n * https://cronwatch.dev/schemas/webhook/1.json.\n */\nexport const WEBHOOK_SCHEMA = 1;\n\n/**\n * POSTs the alert as JSON to any URL. The body is the Alert object after a\n * version: { schema: 1, type, job, title, message, run, details, triage, at,\n * definition }. A redirect is an error: point the url at where the receiver\n * really is.\n */\nexport function webhook(options: WebhookOptions): AlertChannel {\n if (!options.url) throw new Error(\"webhook() needs a url\");\n return {\n name: \"webhook\",\n async send(alert, context) {\n const body = JSON.stringify({ schema: WEBHOOK_SCHEMA, ...alert });\n if (options.secret === \"\") {\n context?.onError?.(new Error(\"webhook: secret is an empty string, so this request is sent unsigned; set the secret, or leave it out to send unsigned on purpose\"));\n }\n const headers: Record<string, string> = { \"content-type\": \"application/json\", \"user-agent\": \"cronwatch\" };\n // A pasted Authorization value often carries a stray space or newline, which fetch would refuse.\n for (const [name, value] of Object.entries(options.headers ?? {})) setHeader(headers, name, typeof value === \"string\" ? value.trim() : value);\n if (options.secret) {\n const timestamp = String(Math.floor((options.now ?? Date.now)() / 1000));\n setHeader(headers, \"x-cronwatch-signature\", `sha256=${await signature(options.secret, body)}`);\n setHeader(headers, \"x-cronwatch-timestamp\", timestamp);\n setHeader(headers, \"x-cronwatch-timestamp-signature\", `sha256=${await signature(options.secret, `${timestamp}.${body}`)}`);\n }\n // A redirect is refused, not followed: the headers (and the signature) would go with it.\n const response = await fetchPost(options.url, { headers, body });\n // Only the origin: a webhook URL's path or query often is the credential.\n if (!response.ok) throw new Error(`Webhook ${origin(options.url)} answered ${response.status}`);\n await drain(response);\n },\n };\n}\n\n/** Sets a header, first taking out any of the same name in another case. */\nfunction setHeader(headers: Record<string, string>, name: string, value: string): void {\n const lower = name.toLowerCase();\n for (const key of Object.keys(headers)) if (key.toLowerCase() === lower) delete headers[key];\n headers[name] = value;\n}\n\n/**\n * The webhook's signature of a body: the HMAC-SHA256 of the body with the\n * secret, as lowercase hex. A request carries it as\n * `X-CronWatch-Signature: sha256=<signature>`, so a receiver checks one with\n * the raw body it got (before any JSON parsing) and a constant-time compare.\n * The timestamped one is the signature of `<timestamp>.<body>`, with the\n * X-CronWatch-Timestamp value as sent.\n * Web Crypto, so it runs on Workers and Deno too. Every port has it, named\n * the same.\n */\nexport async function signature(secret: string, body: string): Promise<string> {\n const encoder = new TextEncoder();\n const key = await crypto.subtle.importKey(\"raw\", encoder.encode(secret), { name: \"HMAC\", hash: \"SHA-256\" }, false, [\"sign\"]);\n const signature = new Uint8Array(await crypto.subtle.sign(\"HMAC\", key, encoder.encode(body)));\n return Array.from(signature, (b) => b.toString(16).padStart(2, \"0\")).join(\"\");\n}\n","// @cronwatch/sdk/webhook: the channel, and signature(), the one helper for\n// a receiver, named the same in every port.\nimport * as internal from \"../alerts/webhook.js\";\n\nexport { signature, webhook } from \"../alerts/webhook.js\";\nexport type { WebhookOptions } from \"../alerts/webhook.js\";\n\n/** @deprecated Renamed `signature`, the name every port uses; this name leaves the exports in 1.0. */\nexport const hmacSha256Hex = internal.signature;\n"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cronwatch/sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "Cron and scheduled-job monitoring that lives inside your app. Wrap a job, mount a route, get told when it is missed, failed, slow, or over budget. No server to run.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|