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 +21 -0
- package/README.md +244 -0
- package/dist/api.d.ts +86 -0
- package/dist/api.js +258 -0
- package/dist/check.d.ts +60 -0
- package/dist/check.js +114 -0
- package/dist/config.d.ts +76 -0
- package/dist/config.js +135 -0
- package/dist/confirm.d.ts +37 -0
- package/dist/confirm.js +87 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +40 -0
- package/dist/result.d.ts +72 -0
- package/dist/result.js +200 -0
- package/dist/schema.d.ts +78 -0
- package/dist/schema.js +171 -0
- package/dist/server.d.ts +3 -0
- package/dist/server.js +39 -0
- package/dist/tool-filter.d.ts +45 -0
- package/dist/tool-filter.js +192 -0
- package/dist/tools/catalogue.d.ts +44 -0
- package/dist/tools/catalogue.js +69 -0
- package/dist/tools/read.d.ts +3 -0
- package/dist/tools/read.js +344 -0
- package/dist/tools/write.d.ts +4 -0
- package/dist/tools/write.js +259 -0
- package/package.json +66 -0
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
|
+
[](https://github.com/ni-c/healthchecks-mcp/actions/workflows/ci.yml)
|
|
4
|
+
[](https://www.npmjs.com/package/healthchecks-mcp)
|
|
5
|
+
[](https://www.npmjs.com/package/healthchecks-mcp)
|
|
6
|
+
[](https://nodejs.org)
|
|
7
|
+
[](LICENSE)
|
|
8
|
+
[](https://github.com/ni-c/healthchecks-mcp/pkgs/container/healthchecks-mcp)
|
|
9
|
+
[](https://healthchecks-mcp.ni-c.de)
|
|
10
|
+
[](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
|
+

|
|
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
|
package/dist/check.d.ts
ADDED
|
@@ -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[];
|