healthchecks-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Willi Thiel
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,244 @@
1
+ # healthchecks-mcp
2
+
3
+ [![CI](https://img.shields.io/github/actions/workflow/status/ni-c/healthchecks-mcp/ci.yml?branch=main&label=CI)](https://github.com/ni-c/healthchecks-mcp/actions/workflows/ci.yml)
4
+ [![npm version](https://img.shields.io/npm/v/healthchecks-mcp)](https://www.npmjs.com/package/healthchecks-mcp)
5
+ [![npm downloads](https://img.shields.io/npm/dm/healthchecks-mcp)](https://www.npmjs.com/package/healthchecks-mcp)
6
+ [![node](https://img.shields.io/node/v/healthchecks-mcp)](https://nodejs.org)
7
+ [![license](https://img.shields.io/npm/l/healthchecks-mcp)](LICENSE)
8
+ [![container](https://img.shields.io/badge/ghcr.io-ni--c%2Fhealthchecks--mcp-blue)](https://github.com/ni-c/healthchecks-mcp/pkgs/container/healthchecks-mcp)
9
+ [![docs](https://img.shields.io/badge/docs-healthchecks--mcp.ni--c.de-informational)](https://healthchecks-mcp.ni-c.de)
10
+ [![sponsor](https://img.shields.io/badge/sponsor-ni--c-ea4aaa?logo=githubsponsors&logoColor=white)](https://github.com/sponsors/ni-c)
11
+
12
+ A [Model Context Protocol](https://modelcontextprotocol.io) (MCP) server for
13
+ [Healthchecks](https://healthchecks.io), the dead man's switch for cron jobs and
14
+ scheduled tasks — it alerts you when a job stops checking in. Works against the
15
+ hosted service and against a self-hosted instance alike.
16
+
17
+ Lets MCP clients like Claude Code, Claude Desktop or Codex see which scheduled
18
+ jobs are healthy, read the output the failing one reported, and create or adjust
19
+ checks — with the irreversible operations behind a confirmation token and the
20
+ write tools switchable off entirely.
21
+
22
+ Fourteen tools is the ceiling, not the floor:
23
+ `HEALTHCHECKS_ALLOW_TOOLS=essential` registers a curated seven instead, and a
24
+ model picks the right tool far more reliably from seven than from fourteen — see
25
+ [choosing which tools load](#choosing-which-tools-load).
26
+
27
+ <picture>
28
+ <source media="(prefers-color-scheme: dark)" srcset="https://healthchecks-mcp.ni-c.de/architecture-dark.svg">
29
+ <source media="(prefers-color-scheme: light)" srcset="https://healthchecks-mcp.ni-c.de/architecture-light.svg">
30
+ <img src="https://healthchecks-mcp.ni-c.de/architecture.svg" alt="An MCP client talks to healthchecks-mcp over stdio; the server calls the Healthchecks Management API v3 over HTTPS." width="800">
31
+ </picture>
32
+
33
+ ![Listing the tools, narrowing them to the essential preset, and the startup abort a mistyped tool name produces](https://healthchecks-mcp.ni-c.de/demo.gif)
34
+
35
+ ## What makes it different
36
+
37
+ **It reads the ping bodies.** `get_ping_body` returns what a job actually printed
38
+ when it reported failure. Every other question — which check is down, since when,
39
+ how often — is one step away from that one, and it is the endpoint the other
40
+ Healthchecks MCP servers leave out.
41
+
42
+ **Read-only API keys work properly.** Healthchecks hands a read-only key a
43
+ different object: no `uuid`, no `ping_url`, no `channels` — a 40-character
44
+ `unique_key` instead. This server addresses checks by either, and
45
+ `get_api_key_info` tells you up front which kind of key you configured and which
46
+ tools it cannot reach, rather than leaving you with a `401 missing api key` for a
47
+ key that was sent.
48
+
49
+ **It never pings a check.** Pinging is how a job reports that it ran. A tool that
50
+ could ping would let a model make a dead job look alive, which is the one thing
51
+ monitoring must not allow — see [Not exposed, on purpose](#not-exposed-on-purpose).
52
+
53
+ **It knows where this API is sharp.** `timeout` and `schedule` cannot be combined
54
+ because the upstream silently discards one of them; tags are validated against
55
+ their space separator and keywords against their comma; a new check is given
56
+ every integration unless you say otherwise, because the API's own default is a
57
+ check that alerts nobody.
58
+
59
+ ## Requirements
60
+
61
+ - Node.js ≥ 22
62
+ - A **Healthchecks** project API key — Project Settings → API Access. Keys are per
63
+ project, not per account, and are exactly 32 characters long.
64
+
65
+ ## Configuration
66
+
67
+ | Variable | Required | Description |
68
+ | --------------------------- | -------- | ----------------------------------------------------------------------------------------------------- |
69
+ | `HEALTHCHECKS_API_KEY` | yes | Project API key. A read-only key works for part of the tool surface — see below |
70
+ | `HEALTHCHECKS_URL` | no | Site root of a self-hosted instance, e.g. `https://hc.example.net`. Default `https://healthchecks.io` |
71
+ | `HEALTHCHECKS_READ_ONLY` | no | `true` registers only the read tools |
72
+ | `HEALTHCHECKS_ALLOW_TOOLS` | no | Comma-separated tool names, `list_*` prefixes, or `essential` for a curated preset |
73
+ | `HEALTHCHECKS_DENY_TOOLS` | no | Same syntax; removed from whatever `HEALTHCHECKS_ALLOW_TOOLS` left |
74
+ | `HEALTHCHECKS_INSECURE_TLS` | no | `true` accepts self-signed certificates (scoped to this connection) |
75
+
76
+ `HEALTHCHECKS_URL` is the site root, not the API root: `https://hc.example.net`,
77
+ not `https://hc.example.net/api/v3`. Both are accepted — the suffix is trimmed —
78
+ because the API documentation spells every example the long way.
79
+
80
+ > **Use `https://`.** Over plain http the API key travels unencrypted; the server
81
+ > prints a warning unless the host is local. For self-signed certificates prefer a
82
+ > proper internal CA over `HEALTHCHECKS_INSECURE_TLS`.
83
+
84
+ Without an API key the server still starts and lists its tools (so registries and
85
+ inspectors can introspect it), but every call except `get_status` fails with setup
86
+ instructions instead of reaching the API.
87
+
88
+ **Read-only keys.** Healthchecks gates three tools that only read — `list_pings`,
89
+ `get_ping_body` and `list_integrations` — behind a read-write key anyway. With a
90
+ read-only key those fail, along with all five write tools.
91
+
92
+ The failure does not look like a permission problem: the API answers
93
+ `401 {"error": "wrong api key"}`, which reads as if the key were wrong or
94
+ missing. It is not — those three tools translate it into what actually happened.
95
+ `get_api_key_info` reports which kind of key is configured, and
96
+ `HEALTHCHECKS_DENY_TOOLS` is the tidy way to stop offering them at all.
97
+
98
+ ### Choosing which tools load
99
+
100
+ `HEALTHCHECKS_ALLOW_TOOLS` and `HEALTHCHECKS_DENY_TOOLS` take comma-separated tool
101
+ names; a trailing `*` matches a whole family. `essential` is a curated preset —
102
+ `list_checks`, `get_check`, `list_pings`, `list_flips`, `create_check`,
103
+ `update_check` and `resume_check` — marked as such in the
104
+ [tool reference](https://healthchecks-mcp.ni-c.de/reference/tools).
105
+
106
+ ```sh
107
+ HEALTHCHECKS_ALLOW_TOOLS=essential
108
+ HEALTHCHECKS_ALLOW_TOOLS=list_*,get_check
109
+ HEALTHCHECKS_DENY_TOOLS=delete_check,pause_check
110
+ ```
111
+
112
+ An entry that matches no tool aborts startup and names it, so a typo cannot silently
113
+ hide a tool — an absent tool is not something anyone traces back to an environment
114
+ variable. A filtered tool is never registered, so it is absent from `tools/list` and
115
+ unknown to `tools/call` alike, exactly like a write tool under
116
+ `HEALTHCHECKS_READ_ONLY`.
117
+
118
+ If you run several of these servers at once, [mcp-hub](https://mcp-hub.ni-c.de) is
119
+ the other answer — its `/hub` endpoint replaces every server's tools with six
120
+ meta-tools.
121
+
122
+ ## Installation
123
+
124
+ ### Claude Code
125
+
126
+ ```sh
127
+ claude mcp add healthchecks-mcp -- npx -y healthchecks-mcp
128
+ ```
129
+
130
+ ### Claude Desktop
131
+
132
+ ```json
133
+ {
134
+ "mcpServers": {
135
+ "healthchecks-mcp": {
136
+ "command": "npx",
137
+ "args": ["-y", "healthchecks-mcp"],
138
+ "env": {
139
+ "HEALTHCHECKS_API_KEY": "…"
140
+ }
141
+ }
142
+ }
143
+ }
144
+ ```
145
+
146
+ ### Codex
147
+
148
+ ```toml
149
+ [mcp_servers.healthchecks-mcp]
150
+ command = "npx"
151
+ args = ["-y", "healthchecks-mcp"]
152
+ env = { HEALTHCHECKS_API_KEY = "…" }
153
+ ```
154
+
155
+ ### Docker
156
+
157
+ ```sh
158
+ docker run --rm -i \
159
+ -e HEALTHCHECKS_API_KEY=… \
160
+ ghcr.io/ni-c/healthchecks-mcp
161
+ ```
162
+
163
+ Add `-e HEALTHCHECKS_URL=https://hc.example.net` for a self-hosted instance.
164
+
165
+ ## Tools
166
+
167
+ Read tools are always registered. 🔑 marks the ones Healthchecks requires a
168
+ read-write key for even though they only read; 👤 marks the ones that ask for a
169
+ confirmation token before acting.
170
+
171
+ | Tool | Description |
172
+ | ---------------------- | -------------------------------------------------------------- |
173
+ | `list_checks` | Checks in the project, with `tag`, `slug` and `status` filters |
174
+ | `get_check` | One check with every field, by UUID or `unique_key` |
175
+ | `list_pings` 🔑 | Recent pings of a check, newest first |
176
+ | `get_ping_body` 🔑 | The body a job POSTed with one ping — its output |
177
+ | `list_flips` | Up/down transitions of a check, with a time window |
178
+ | `list_integrations` 🔑 | Notification integrations and the UUIDs the write tools accept |
179
+ | `list_badges` | Status badge URLs, per tag and for the project |
180
+ | `get_status` | Whether the instance is reachable — needs no API key at all |
181
+ | `get_api_key_info` | Which instance, which kind of key, and what that key cannot do |
182
+
183
+ Write tools are registered unless `HEALTHCHECKS_READ_ONLY=true`.
184
+
185
+ | Tool | Description |
186
+ | ----------------- | --------------------------------------------------------------------------------------------------------- |
187
+ | `create_check` | Creates a check. Notifies every integration unless `channels` says otherwise |
188
+ | `update_check` | Changes the given fields. `channels` replaces the list rather than adding to it; an empty list is refused |
189
+ | `pause_check` 👤 | Stops the check expecting pings — and alerting |
190
+ | `resume_check` | Puts a paused check back into the `new` state |
191
+ | `delete_check` 👤 | Deletes a check. The UUID is not recoverable |
192
+
193
+ ## Not exposed, on purpose
194
+
195
+ - **Pinging.** The server never calls a ping URL. Pinging is how a job says it
196
+ ran; a tool that could ping would let a model — or text a model read — report
197
+ success for a job that never executed, and a monitoring system that can be
198
+ talked into a green status is worse than none.
199
+ - **Ping keys.** They are not readable through the Management API, and this server
200
+ does not ask for one.
201
+ - **Creating integrations.** The API has no endpoint for it; they are configured
202
+ in the web UI. `list_integrations` reads them.
203
+ - **The `unique` upsert as a default.** `create_check` accepts it and says loudly
204
+ in its result when it was used, because it turns a create into a silent update
205
+ of a check that already exists.
206
+
207
+ ## Safety
208
+
209
+ - **`pause_check` and `delete_check` are two-step.** The first call returns a
210
+ short-lived confirmation token bound to that exact check and that exact
211
+ operation; only a second call carrying that token acts. A model cannot satisfy
212
+ this gate on its own, and a pause token is not a delete token.
213
+ - **Confirmation prompts never quote content from Healthchecks** — a check's name
214
+ and description are free text this server does not control, and that text is
215
+ read by a model.
216
+ - **Ping bodies and check descriptions are marked as untrusted data**, because
217
+ anything that can ping a check can write into them.
218
+ - Error bodies are truncated, HTML error pages are dropped, and every response has
219
+ a byte ceiling enforced while it streams — the Management API paginates nothing.
220
+ - `HEALTHCHECKS_READ_ONLY=true` does not register the write tools at all, and
221
+ `HEALTHCHECKS_DENY_TOOLS` cuts finer along the same line — a filtered tool is
222
+ never built, not refused at call time.
223
+ - The API key is deleted from `process.env` once it has been read, and never
224
+ travels in a request body.
225
+
226
+ ## Development
227
+
228
+ ```sh
229
+ npm install
230
+ npm run lint && npm run build && npm run test:coverage
231
+ ```
232
+
233
+ ## Releasing
234
+
235
+ 1. Add the CHANGELOG entry and bump `package.json`.
236
+ 2. `npm run lint && npm run build && npm run test:coverage`
237
+ 3. Commit, then push a signed tag: `git tag -s vX.Y.Z -m "vX.Y.Z" && git push origin main vX.Y.Z`
238
+
239
+ The release workflow publishes to npm (Trusted Publishing, with provenance), creates
240
+ the GitHub release from the CHANGELOG section and updates the MCP Registry entry.
241
+
242
+ ## License
243
+
244
+ MIT © Willi Thiel
package/dist/api.d.ts ADDED
@@ -0,0 +1,86 @@
1
+ import { type Config } from './config.js';
2
+ /**
3
+ * Ceiling on a single upstream response.
4
+ *
5
+ * `GET /checks/` has no pagination at all and returns every check in the
6
+ * project, so the size of the answer is decided by the instance, not by us.
7
+ * `await response.text()` would buffer whatever arrives; this bounds it before
8
+ * the bytes are ever in memory as a string.
9
+ */
10
+ export declare const MAX_RESPONSE_BYTES: number;
11
+ /** The API version this server speaks. v3 is the first one that accepts an explicit slug. */
12
+ export declare const API_VERSION = "v3";
13
+ export declare class HealthchecksApiError extends Error {
14
+ readonly status: number;
15
+ readonly body: string;
16
+ constructor(status: number, body: string, method: string, path: string);
17
+ }
18
+ /**
19
+ * Thrown when a response is larger than the ceiling that applied to it.
20
+ *
21
+ * The limit is a parameter rather than {@link MAX_RESPONSE_BYTES}, because
22
+ * callers override it — 64 KB for a ping body, 4 KB for the status probe — and
23
+ * an error announcing "exceeds 5 MB" for a 6 KB response is one nobody believes.
24
+ */
25
+ export declare class ResponseTooLargeError extends Error {
26
+ constructor(path: string, limit: number);
27
+ }
28
+ /**
29
+ * Thrown when an endpoint that only reads was refused for needing a read-write key.
30
+ *
31
+ * Healthchecks gates `/channels/`, `/pings/` and `/pings/<n>/body` behind
32
+ * `@authorize` even though all three are GETs, and refuses a read-only key there
33
+ * with `401 {"error": "wrong api key"}`. Relaying that verbatim sends the reader
34
+ * to re-check a key that is correct — so the three call sites translate it.
35
+ */
36
+ export declare class ReadWriteKeyRequiredError extends Error {
37
+ readonly tool: string;
38
+ constructor(tool: string);
39
+ }
40
+ /** Thrown when a response that has to be JSON is not. */
41
+ export declare class UnexpectedContentTypeError extends Error {
42
+ constructor(path: string, contentType: string);
43
+ }
44
+ export interface RequestOptions {
45
+ /** Send no `X-Api-Key` header. Only `/status/`, which is unauthenticated. */
46
+ anonymous?: boolean;
47
+ /** Return the raw body instead of parsing it. `/pings/<n>/body` is text/plain. */
48
+ raw?: boolean;
49
+ /** Overrides {@link MAX_RESPONSE_BYTES} for endpoints with a known small ceiling. */
50
+ maxBytes?: number;
51
+ }
52
+ export interface RawResponse {
53
+ body: string;
54
+ truncated: boolean;
55
+ contentType: string;
56
+ }
57
+ /** Client for the Healthchecks Management API. */
58
+ export declare class HealthchecksApi {
59
+ private readonly config;
60
+ private readonly baseUrl;
61
+ /**
62
+ * Only set when HEALTHCHECKS_INSECURE_TLS is enabled. Scopes the relaxed
63
+ * certificate validation to requests against the configured host instead of
64
+ * disabling it process-wide via NODE_TLS_REJECT_UNAUTHORIZED.
65
+ */
66
+ private readonly insecureDispatcher?;
67
+ constructor(config: Config);
68
+ /** The configured site root, for messages that need to name the instance. */
69
+ get siteRoot(): string;
70
+ get apiKey(): string | undefined;
71
+ requestRaw(method: string, path: string, body?: unknown, options?: RequestOptions): Promise<RawResponse>;
72
+ request(method: string, path: string, body?: unknown, options?: RequestOptions): Promise<unknown>;
73
+ get(path: string, options?: RequestOptions): Promise<unknown>;
74
+ post(path: string, body?: unknown): Promise<unknown>;
75
+ delete(path: string): Promise<unknown>;
76
+ }
77
+ /**
78
+ * Guards a value that ends up in a URL path. Path traversal here would let a
79
+ * caller reach a different resource — or a different API entirely.
80
+ *
81
+ * Defence in depth: every caller already validated the value against a UUID or
82
+ * SHA1 schema, and this catches the one that some day will not.
83
+ */
84
+ export declare function assertPathSegment(value: string, what: string): string;
85
+ /** Builds a query string from the parameters that are actually set. */
86
+ export declare function query(params: Record<string, string | number | undefined | (string | number)[]>): string;
package/dist/api.js ADDED
@@ -0,0 +1,258 @@
1
+ import { Agent, fetch as undiciFetch, } from 'undici';
2
+ import { malformedApiKeyMessage, missingConfigKeys, missingConfigMessage, } from './config.js';
3
+ const REQUEST_TIMEOUT_MS = 15_000;
4
+ /**
5
+ * Ceiling on a single upstream response.
6
+ *
7
+ * `GET /checks/` has no pagination at all and returns every check in the
8
+ * project, so the size of the answer is decided by the instance, not by us.
9
+ * `await response.text()` would buffer whatever arrives; this bounds it before
10
+ * the bytes are ever in memory as a string.
11
+ */
12
+ export const MAX_RESPONSE_BYTES = 5 * 1024 * 1024;
13
+ /** The API version this server speaks. v3 is the first one that accepts an explicit slug. */
14
+ export const API_VERSION = 'v3';
15
+ export class HealthchecksApiError extends Error {
16
+ status;
17
+ body;
18
+ constructor(status, body, method, path) {
19
+ super(`Healthchecks API ${method} ${path} failed with HTTP ${status}`);
20
+ this.status = status;
21
+ this.body = body;
22
+ this.name = 'HealthchecksApiError';
23
+ }
24
+ }
25
+ /**
26
+ * Thrown when a response is larger than the ceiling that applied to it.
27
+ *
28
+ * The limit is a parameter rather than {@link MAX_RESPONSE_BYTES}, because
29
+ * callers override it — 64 KB for a ping body, 4 KB for the status probe — and
30
+ * an error announcing "exceeds 5 MB" for a 6 KB response is one nobody believes.
31
+ */
32
+ export class ResponseTooLargeError extends Error {
33
+ constructor(path, limit) {
34
+ super(`the Healthchecks response for ${path} exceeds the ${formatLimit(limit)} ` +
35
+ 'ceiling and was not read.');
36
+ this.name = 'ResponseTooLargeError';
37
+ }
38
+ }
39
+ function formatLimit(bytes) {
40
+ return bytes >= 1024 * 1024
41
+ ? `${Math.round(bytes / 1024 / 1024)} MB`
42
+ : `${Math.round(bytes / 1024)} KB`;
43
+ }
44
+ /**
45
+ * Thrown when an endpoint that only reads was refused for needing a read-write key.
46
+ *
47
+ * Healthchecks gates `/channels/`, `/pings/` and `/pings/<n>/body` behind
48
+ * `@authorize` even though all three are GETs, and refuses a read-only key there
49
+ * with `401 {"error": "wrong api key"}`. Relaying that verbatim sends the reader
50
+ * to re-check a key that is correct — so the three call sites translate it.
51
+ */
52
+ export class ReadWriteKeyRequiredError extends Error {
53
+ tool;
54
+ constructor(tool) {
55
+ super(`${tool} needs a read-write Healthchecks API key. The configured key was ` +
56
+ 'rejected with HTTP 401 "wrong api key", which is how Healthchecks ' +
57
+ 'answers a read-only key on this endpoint — it only reads, but the API ' +
58
+ 'gates it behind a read-write key anyway. Nothing is wrong with the key ' +
59
+ 'itself. Use a read-write key from Project Settings → API Access, or ' +
60
+ `HEALTHCHECKS_DENY_TOOLS=${tool} to stop offering the tool. ` +
61
+ 'get_api_key_info reports which kind of key is configured.');
62
+ this.tool = tool;
63
+ this.name = 'ReadWriteKeyRequiredError';
64
+ }
65
+ }
66
+ /** Thrown when a response that has to be JSON is not. */
67
+ export class UnexpectedContentTypeError extends Error {
68
+ constructor(path, contentType) {
69
+ super(`Healthchecks answered ${path} with "${contentType || 'no content type'}" ` +
70
+ 'instead of JSON. A 200 that is not JSON usually means something in front ' +
71
+ 'of the instance answered instead of the API — an SSO portal, a captive ' +
72
+ 'proxy or a login page. Check HEALTHCHECKS_URL and try get_status.');
73
+ this.name = 'UnexpectedContentTypeError';
74
+ }
75
+ }
76
+ /** Client for the Healthchecks Management API. */
77
+ export class HealthchecksApi {
78
+ config;
79
+ baseUrl;
80
+ /**
81
+ * Only set when HEALTHCHECKS_INSECURE_TLS is enabled. Scopes the relaxed
82
+ * certificate validation to requests against the configured host instead of
83
+ * disabling it process-wide via NODE_TLS_REJECT_UNAUTHORIZED.
84
+ */
85
+ insecureDispatcher;
86
+ constructor(config) {
87
+ this.config = config;
88
+ this.baseUrl = `${config.url}/api/${API_VERSION}`;
89
+ if (config.insecureTls) {
90
+ this.insecureDispatcher = new Agent({
91
+ connect: { rejectUnauthorized: false },
92
+ });
93
+ }
94
+ }
95
+ /** The configured site root, for messages that need to name the instance. */
96
+ get siteRoot() {
97
+ return this.config.url;
98
+ }
99
+ get apiKey() {
100
+ return this.config.apiKey;
101
+ }
102
+ async requestRaw(method, path, body, options = {}) {
103
+ // The key is only required here, not at startup, so the server can still be
104
+ // started and introspected without one.
105
+ if (!options.anonymous) {
106
+ const missing = missingConfigKeys(this.config);
107
+ if (missing.length > 0)
108
+ throw new Error(missingConfigMessage(missing));
109
+ const malformed = malformedApiKeyMessage(this.config);
110
+ if (malformed)
111
+ throw new Error(malformed);
112
+ }
113
+ const headers = { Accept: '*/*' };
114
+ if (!options.anonymous && this.config.apiKey) {
115
+ // The header, never the `api_key` body field the API also accepts: a body
116
+ // field ends up in request logs, and it only works for POST anyway.
117
+ headers['X-Api-Key'] = this.config.apiKey;
118
+ }
119
+ const init = {
120
+ method,
121
+ headers,
122
+ // Never follow a redirect: it would resend the API key to whatever host
123
+ // the upstream points at.
124
+ redirect: 'error',
125
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
126
+ };
127
+ if (body !== undefined) {
128
+ // The API rejects multipart and form encoding outright; every POST is
129
+ // JSON, including the empty-bodied pause and resume calls.
130
+ headers['Content-Type'] = 'application/json';
131
+ init.body = JSON.stringify(body);
132
+ }
133
+ const url = `${this.baseUrl}${path}`;
134
+ // The insecure dispatcher requires undici's own fetch; the default path uses
135
+ // the (stubbable) global fetch so tests can intercept it.
136
+ const response = this.insecureDispatcher
137
+ ? await undiciFetch(url, {
138
+ ...init,
139
+ dispatcher: this.insecureDispatcher,
140
+ })
141
+ : await fetch(url, init);
142
+ const limit = options.maxBytes ?? MAX_RESPONSE_BYTES;
143
+ // A raw caller wants text and can live with less of it; a JSON caller
144
+ // cannot, because half a document is not a smaller answer.
145
+ const { text, truncated } = await readCapped(response, limit, path, options.raw === true);
146
+ if (!response.ok) {
147
+ throw new HealthchecksApiError(response.status, text, method, path);
148
+ }
149
+ if (truncated && !options.raw) {
150
+ throw new ResponseTooLargeError(path, limit);
151
+ }
152
+ return {
153
+ body: text,
154
+ truncated,
155
+ contentType: response.headers.get('content-type') ?? '',
156
+ };
157
+ }
158
+ async request(method, path, body, options = {}) {
159
+ const raw = await this.requestRaw(method, path, body, options);
160
+ if (options.raw)
161
+ return raw;
162
+ if (raw.body.length === 0)
163
+ return undefined;
164
+ // Anything that is not JSON here is a foreign answer, not a Healthchecks
165
+ // one. Returning the body would send an HTML login page into `listOf`,
166
+ // which finds no array and reports an empty project — an error swallowed
167
+ // and replaced with a plausible wrong answer. The endpoints that do speak
168
+ // text (`/status/`, ping bodies) ask for `raw` and never reach this.
169
+ if (!raw.contentType.includes('application/json')) {
170
+ throw new UnexpectedContentTypeError(path, raw.contentType);
171
+ }
172
+ try {
173
+ return JSON.parse(raw.body);
174
+ }
175
+ catch {
176
+ throw new UnexpectedContentTypeError(path, `${raw.contentType} (unparseable)`);
177
+ }
178
+ }
179
+ get(path, options) {
180
+ return this.request('GET', path, undefined, options);
181
+ }
182
+ post(path, body) {
183
+ return this.request('POST', path, body);
184
+ }
185
+ delete(path) {
186
+ return this.request('DELETE', path);
187
+ }
188
+ }
189
+ /**
190
+ * Reads a response body with a hard byte ceiling.
191
+ *
192
+ * Both halves matter: `content-length` catches an oversized answer before a
193
+ * single byte is read, and the streaming count catches a chunked response,
194
+ * which declares no length at all.
195
+ */
196
+ async function readCapped(response, maxBytes, path, allowTruncation) {
197
+ const declared = Number(response.headers.get('content-length'));
198
+ if (Number.isFinite(declared) && declared > maxBytes && !allowTruncation) {
199
+ // Nothing has been read yet, so the body can simply be discarded.
200
+ await response.body?.cancel();
201
+ throw new ResponseTooLargeError(path, maxBytes);
202
+ }
203
+ const body = response.body;
204
+ if (!body)
205
+ return { text: '', truncated: false };
206
+ const reader = body.getReader();
207
+ const chunks = [];
208
+ let total = 0;
209
+ let truncated = false;
210
+ for (;;) {
211
+ const { done, value } = await reader.read();
212
+ if (done)
213
+ break;
214
+ if (value === undefined)
215
+ continue;
216
+ if (total + value.byteLength > maxBytes) {
217
+ // `maxBytes - total` is exactly the remaining budget, and the `>` above
218
+ // makes an exactly-maxBytes response legal rather than truncated.
219
+ chunks.push(value.subarray(0, maxBytes - total));
220
+ truncated = true;
221
+ await reader.cancel();
222
+ // NOTE: `total` is deliberately left stale here — the loop exits on the
223
+ // next line and nothing reads it afterwards. Anything added below this
224
+ // point must recompute it from `chunks` rather than trusting it.
225
+ break;
226
+ }
227
+ chunks.push(value);
228
+ total += value.byteLength;
229
+ }
230
+ return { text: Buffer.concat(chunks).toString('utf8'), truncated };
231
+ }
232
+ /**
233
+ * Guards a value that ends up in a URL path. Path traversal here would let a
234
+ * caller reach a different resource — or a different API entirely.
235
+ *
236
+ * Defence in depth: every caller already validated the value against a UUID or
237
+ * SHA1 schema, and this catches the one that some day will not.
238
+ */
239
+ export function assertPathSegment(value, what) {
240
+ if (!/^[A-Za-z0-9._-]+$/.test(value) || value === '.' || value === '..') {
241
+ throw new Error(`invalid ${what}: only letters, digits, dot, underscore and hyphen are allowed`);
242
+ }
243
+ return value;
244
+ }
245
+ /** Builds a query string from the parameters that are actually set. */
246
+ export function query(params) {
247
+ const search = new URLSearchParams();
248
+ for (const [key, value] of Object.entries(params)) {
249
+ if (value === undefined)
250
+ continue;
251
+ for (const entry of Array.isArray(value) ? value : [value]) {
252
+ search.append(key, String(entry));
253
+ }
254
+ }
255
+ const rendered = search.toString();
256
+ return rendered ? `?${rendered}` : '';
257
+ }
258
+ //# sourceMappingURL=api.js.map
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Shaping of the API's check objects.
3
+ *
4
+ * Three things need doing before a check is worth handing to a model, and all
5
+ * three are properties of the API rather than preferences:
6
+ *
7
+ * 1. There is no `kind` field. Whether a check is a simple timeout or a
8
+ * scheduled one has to be inferred from which of `timeout` / `schedule` is
9
+ * present, and a reader who does not know that reads a missing `timeout` as
10
+ * a missing value.
11
+ * 2. The identifier is `uuid` for a read-write key and `unique_key` for a
12
+ * read-only one — never both, and the difference is invisible in the object.
13
+ * 3. `subject` and `subject_fail` are legacy derived duplicates of
14
+ * `success_kw` / `failure_kw`; they are not writable and having them in the
15
+ * output invites an update that silently does nothing.
16
+ */
17
+ export interface Check {
18
+ [key: string]: unknown;
19
+ uuid?: string;
20
+ unique_key?: string;
21
+ name?: string;
22
+ slug?: string;
23
+ tags?: string;
24
+ status?: string;
25
+ timeout?: number;
26
+ schedule?: string;
27
+ tz?: string;
28
+ }
29
+ /** The identifier this object can actually be addressed by, whichever it carries. */
30
+ export declare function checkIdOf(check: Check): string | undefined;
31
+ /**
32
+ * Splits the space-delimited `tags` string into a list.
33
+ *
34
+ * Space-delimited is why `tagParam` refuses a tag containing a space: the round
35
+ * trip through this function would turn one tag into two.
36
+ */
37
+ export declare function tagsOf(check: Check): string[];
38
+ /** `simple` when the check is driven by `timeout`, `scheduled` when by `schedule`. */
39
+ export declare function scheduleKindOf(check: Check): 'simple' | 'scheduled';
40
+ /** Normalizes one check without dropping anything a caller might need. */
41
+ export declare function normalizeCheck(check: Check): Record<string, unknown>;
42
+ /**
43
+ * The compact projection used in list results.
44
+ *
45
+ * `desc` is deliberately absent: it is free text of up to ten thousand
46
+ * characters and a list of two hundred checks would be mostly descriptions.
47
+ * `get_check` returns it.
48
+ */
49
+ export declare function summarizeCheck(check: Check): Record<string, unknown>;
50
+ /**
51
+ * Reads the flips out of a response.
52
+ *
53
+ * The published documentation shows a bare array while the implementation
54
+ * returns `{"flips": [...]}`; both spellings are in the wild, since a
55
+ * self-hosted instance can be any release. Accepting both is one line and
56
+ * removes a whole class of "it works against my instance" bug report.
57
+ */
58
+ export declare function flipsOf(body: unknown): unknown[];
59
+ /** Reads a `{"<key>": [...]}` envelope, tolerating a bare array. */
60
+ export declare function listOf(body: unknown, key: string): unknown[];