iit-mcp-hudu 0.2.0 → 0.2.3
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/.env.example +45 -1
- package/README.md +62 -2
- package/dist/config.js +35 -0
- package/dist/credential.d.ts +67 -0
- package/dist/credential.js +115 -0
- package/dist/doctor.d.ts +64 -0
- package/dist/doctor.js +79 -0
- package/dist/http.d.ts +17 -2
- package/dist/http.js +23 -3
- package/dist/main.js +36 -0
- package/dist/tools/fetch-many.js +3 -3
- package/dist/tools/lists.js +4 -4
- package/dist/tools/reads.js +2 -2
- package/dist/tools/results.d.ts +73 -1
- package/dist/tools/results.js +164 -5
- package/dist/tools/schemas.js +5 -5
- package/dist/tools/search.js +1 -1
- package/dist/tools/server.js +87 -4
- package/package.json +2 -2
package/.env.example
CHANGED
|
@@ -17,6 +17,32 @@ MCP_TRANSPORT=stdio
|
|
|
17
17
|
# WITHOUT its own key runs as this key, so set it only when that is intended. It is only ever
|
|
18
18
|
# sent to HUDU_BASE_URL, never to a caller-named origin. Left commented so a copied example
|
|
19
19
|
# does not set an empty value.
|
|
20
|
+
#
|
|
21
|
+
# HOW TO LOAD THIS FILE -- read before the first live call.
|
|
22
|
+
# ---------------------------------------------------------------------------------------------
|
|
23
|
+
# Source it with a shell, which is what consumes the single quotes the values below are written in:
|
|
24
|
+
#
|
|
25
|
+
# set -a; . ./.env; set +a
|
|
26
|
+
#
|
|
27
|
+
# Do NOT use a quote-preserving dotenv parser (a plain KEY=VALUE split that keeps the quotes) on
|
|
28
|
+
# this file. It hands `'...'` -- quotes included -- to the server, and Hudu answers 401 for a
|
|
29
|
+
# credential that was never wrong. The trap is sharpest when the secret contains `$`:
|
|
30
|
+
# HUDU_API_KEY='a$b' is `a$b` when sourced and `'a$b'` when naively parsed, and both look correct
|
|
31
|
+
# in the file. A wrong credential is a real authentication failure against your Hudu account, and
|
|
32
|
+
# repeated ones risk an account lock.
|
|
33
|
+
#
|
|
34
|
+
# Then run the zero-wire preflight before any live session:
|
|
35
|
+
#
|
|
36
|
+
# npx -y iit-mcp-hudu doctor # or: node dist/main.js doctor
|
|
37
|
+
#
|
|
38
|
+
# It prints length + two-character prefix + shapeOk per credential field (never a value) and dials
|
|
39
|
+
# nothing, and exits non-zero when the credential would be refused. CONTRACT: a green doctor makes
|
|
40
|
+
# any later 401 vendor-side (rotated, revoked or role-restricted key) -- stop and check the vendor
|
|
41
|
+
# rather than re-dialing.
|
|
42
|
+
#
|
|
43
|
+
# The server enforces the same shape anyway: preserved surrounding quotes, an embedded quote, a
|
|
44
|
+
# carriage return, a newline, edge whitespace or a NUL byte in the value fails boot for
|
|
45
|
+
# HUDU_API_KEY, and is refused with CREDENTIAL_MALFORMED for a per-request X-Hudu-Api-Key.
|
|
20
46
|
# HUDU_API_KEY=
|
|
21
47
|
|
|
22
48
|
# http only, optional. Hosts a caller may name in X-Hudu-Base-Url: comma-separated exact hostnames or
|
|
@@ -32,13 +58,26 @@ MCP_TRANSPORT=stdio
|
|
|
32
58
|
# HUDU_ALLOW_ANY_BASE_HOST=false
|
|
33
59
|
|
|
34
60
|
# Omit mutation tools entirely by default. Set false before enabling a write policy.
|
|
61
|
+
#
|
|
62
|
+
# Deployment spectrum — the variables below compose it, and every step stays fail-closed:
|
|
63
|
+
# light read-only : the shipped defaults (HUDU_READ_ONLY=true, HUDU_WRITE_POLICY=deny,
|
|
64
|
+
# HUDU_SECRET_READS=deny). Reads only; no mutation tools at all.
|
|
65
|
+
# scoped writes : HUDU_READ_ONLY=false, HUDU_WRITE_POLICY=allow_list,
|
|
66
|
+
# HUDU_WRITE_ALLOW=<operation keys> (plus HUDU_SECRET_READS=allow if
|
|
67
|
+
# plaintext credential reads are wanted).
|
|
68
|
+
# full read-write : HUDU_READ_ONLY=false, HUDU_WRITE_POLICY=all (plus HUDU_SECRET_READS=allow).
|
|
69
|
+
# Every write and destructive operation is permitted; the SDK's per-mode
|
|
70
|
+
# client gates (read / write / destructive) still apply underneath.
|
|
35
71
|
HUDU_READ_ONLY=true
|
|
72
|
+
# HUDU_READ_ONLY=false
|
|
36
73
|
|
|
37
74
|
# Authority over the mutation dispatchers. deny (the default) refuses every write and destructive
|
|
38
75
|
# operation; allow_list permits only the keys in HUDU_WRITE_ALLOW; all is today's behaviour and has
|
|
39
76
|
# to be typed out deliberately. An unset value means deny, so forgetting this gets a read-only
|
|
40
77
|
# server, never an open one. Reads are never affected.
|
|
41
78
|
HUDU_WRITE_POLICY=deny
|
|
79
|
+
# HUDU_WRITE_POLICY=allow_list
|
|
80
|
+
# HUDU_WRITE_POLICY=all
|
|
42
81
|
|
|
43
82
|
# Authority over reads that return a plaintext credential -- independent of HUDU_WRITE_POLICY, which
|
|
44
83
|
# never covers them: asset_passwords.get returns password and otp_secret as an ordinary read. deny
|
|
@@ -47,10 +86,16 @@ HUDU_WRITE_POLICY=deny
|
|
|
47
86
|
# resource, not to the expand flag, because the secret comes back without expand. hudu_search is
|
|
48
87
|
# unaffected; it redacts those resources already.
|
|
49
88
|
HUDU_SECRET_READS=deny
|
|
89
|
+
# HUDU_SECRET_READS=allow # permits plaintext credential reads; SDK search results stay redacted
|
|
50
90
|
|
|
51
91
|
# Only read under HUDU_WRITE_POLICY=allow_list. Comma-separated registry operation keys, e.g.
|
|
52
92
|
# articles.update,assets.create. It must be non-empty and every key must exist in the registry (and
|
|
53
93
|
# be a write) or the container fails at boot with the variable named. Ignored under deny/all.
|
|
94
|
+
# The write client is offered the write-effect keys and the delete client the destructive-effect
|
|
95
|
+
# keys from this one list: to reach the same coverage as `all` via an allow list, it must name
|
|
96
|
+
# every write and every destructive operation (the full key set is the node-hudu capability
|
|
97
|
+
# registry, published in MCP_TOOL_MANIFEST.md). The two-key line below is the minimal shape, not
|
|
98
|
+
# full coverage.
|
|
54
99
|
# HUDU_WRITE_ALLOW=articles.update,assets.create
|
|
55
100
|
|
|
56
101
|
PORT=8787
|
|
@@ -96,4 +141,3 @@ HUDU_SEARCH_MAX_INDEX_PAGES=40
|
|
|
96
141
|
# Leave it out of .env — the deploy script exports it.
|
|
97
142
|
# HUDU_IMAGE_TAG=
|
|
98
143
|
|
|
99
|
-
# HUDU_SECRET_READS=allow permits resource access; SDK results remain redacted.
|
package/README.md
CHANGED
|
@@ -25,7 +25,7 @@ request; `asset_layouts` is not searchable (the SDK declares no vendor text filt
|
|
|
25
25
|
`hudu_list_asset_layouts` follows the same reachability as the existing ungated
|
|
26
26
|
`hudu_get_asset_layout` tool.
|
|
27
27
|
|
|
28
|
-
These tools run on
|
|
28
|
+
These tools run on node-hudu 0.13.2; the `SDK behaviour (0.12.0)` section below covers the behaviours that shape them: redirect refusal, the closed input contract, the error envelope, and the client opt-ins. Numeric ids must be positive integers on every tool; anything else is refused before a request is made.
|
|
29
29
|
|
|
30
30
|
`hudu_fetch_many` (node-hudu 0.12.0) fetches up to 20 single records across mixed resources in
|
|
31
31
|
one call: each item is exactly `{ resource, id, fields? }` — one of the 22 bare-id resources, a
|
|
@@ -76,6 +76,62 @@ or in `.mcp.json` / Claude Desktop config:
|
|
|
76
76
|
**From a clone** (for development): see [Development](#development). Point your client at
|
|
77
77
|
`node /absolute/path/to/iit-mcp-hudu/dist/main.js` after `npm run build`.
|
|
78
78
|
|
|
79
|
+
## Loading credential values (read this before a live session)
|
|
80
|
+
|
|
81
|
+
A Hudu credential whose bytes reach this server mangled costs a real authentication failure against
|
|
82
|
+
your Hudu account, and repeated ones risk an account lock. Two rules keep that from happening.
|
|
83
|
+
|
|
84
|
+
**Load an env file the way the file documents — shell sourcing, not a generic dotenv parse.**
|
|
85
|
+
|
|
86
|
+
```
|
|
87
|
+
set -a; . ./.env; set +a # the .env.example recipe, for a file of single-quoted values
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
`.env.example` writes its example values in single quotes, which is the normal `.env` style. A
|
|
91
|
+
**shell** consumes those quotes when the file is sourced, so the value the process sees is exactly
|
|
92
|
+
the secret. A **quote-preserving dotenv parser** (`dotenv`-style `KEY=VALUE` splitting that keeps
|
|
93
|
+
the quotes) does not: it hands `'…'` to the server, quotes included, and the vendor rejects it.
|
|
94
|
+
The trap is sharpest when the secret itself contains `$` — `HUDU_API_KEY='a$b'` becomes `a$b` when
|
|
95
|
+
sourced and `'a$b'` (with quotes) when naively parsed, and both look correct in the file.
|
|
96
|
+
|
|
97
|
+
The client-config and `--env` paths shown under *Local use* pass values verbatim and never parse a
|
|
98
|
+
file, so they are already safe — this rule is for anyone loading a `.env`.
|
|
99
|
+
|
|
100
|
+
**Run `doctor` before the first live call.** It answers "did my credential load intact?" from the
|
|
101
|
+
environment alone, with zero Hudu requests:
|
|
102
|
+
|
|
103
|
+
```
|
|
104
|
+
npx -y iit-mcp-hudu doctor # or: node dist/main.js doctor
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
It prints, per credential field, the value's `length`, its two-character `prefix2` and a `shapeOk`
|
|
108
|
+
flag with the anomaly names when it is not; plus the origin that will actually be dialed. It exits
|
|
109
|
+
non-zero when the credential would be refused, so a runbook step can gate on it:
|
|
110
|
+
|
|
111
|
+
```
|
|
112
|
+
node dist/main.js doctor && <start the live session>
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
**The contract: a green `doctor` makes any later 401 vendor-side.** With `doctor` clean, a 401 means
|
|
116
|
+
Hudu refused a well-shaped credential — a rotated, revoked, or role-restricted key — and the fix is
|
|
117
|
+
at the vendor, not in the load recipe. Do not re-dial to test it: a 401 at the auth gate is the
|
|
118
|
+
signal to stop and check, because repeated failures risk locking the account. Conversely, a 401
|
|
119
|
+
whose envelope names a shape anomaly (`credentialShape: "anomalous"`) points back at the load.
|
|
120
|
+
|
|
121
|
+
The server enforces the shape itself, so a bad load cannot reach the wire even if `doctor` is
|
|
122
|
+
skipped: a malformed `HUDU_API_KEY` fails boot; a malformed `X-Hudu-Api-Key` header is refused
|
|
123
|
+
before any client is built, with the typed `CREDENTIAL_MALFORMED` envelope (value-free, and
|
|
124
|
+
`retryable: false`). Neither path dials. Since node-hudu 0.13.2 the SDK enforces credential shape
|
|
125
|
+
itself too — refusing a contaminated value at client construction and attaching a value-free shape
|
|
126
|
+
diagnostic to a refused credential — and this server consumes that diagnostic directly on a 401;
|
|
127
|
+
the server's own gate stays as the boundary guard, a superset of the SDK's checks.
|
|
128
|
+
|
|
129
|
+
The server inspects **shape only** — length, a two-character prefix, and anomaly names. A credential
|
|
130
|
+
value is never logged, never echoed in an error, and never returned by `/doctor`. The anomalies that
|
|
131
|
+
fail a value are: preserved surrounding quotes (single or double), an embedded quote, a carriage
|
|
132
|
+
return, a newline, an embedded control character (any non-printable ASCII byte besides those),
|
|
133
|
+
edge whitespace, and a NUL byte.
|
|
134
|
+
|
|
79
135
|
## Configuration
|
|
80
136
|
|
|
81
137
|
Copy `.env.example`. Every variable is validated at boot; the process exits non-zero on an invalid
|
|
@@ -272,7 +328,7 @@ npm run dev
|
|
|
272
328
|
|
|
273
329
|
CI (`.github/workflows/ci.yml`) runs `typecheck`, `lint` and `test` on every push and pull request.
|
|
274
330
|
|
|
275
|
-
`node-hudu` is pinned to `0.
|
|
331
|
+
`node-hudu` is pinned to `0.13.2`. The catalog hash test detects metadata drift; upgrades require
|
|
276
332
|
review of the client authority, operation enums and redaction regressions. Docker uses `npm ci`.
|
|
277
333
|
|
|
278
334
|
## Deployment
|
|
@@ -281,6 +337,10 @@ review of the client authority, operation enums and redaction regressions. Docke
|
|
|
281
337
|
not call Hudu — otherwise an upstream outage would cycle the container, turning a partial failure
|
|
282
338
|
into a total one.
|
|
283
339
|
|
|
340
|
+
`GET /doctor` (same trust model as `/healthz`: unauthenticated, answered before the MCP handler,
|
|
341
|
+
zero Hudu requests) serves the same zero-wire credential-shape report the `doctor` subcommand
|
|
342
|
+
prints, so a deployment can check its loaded credential without a vendor dial.
|
|
343
|
+
|
|
284
344
|
The deployment definition is `docker-compose.yml`: its own Compose project, joining an existing
|
|
285
345
|
`app_net` external network (create it with `docker network create app_net`, or rename it in the
|
|
286
346
|
file). The listener is not published to the host, so put a TLS-terminating reverse proxy in front of
|
package/dist/config.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import * as z from 'zod';
|
|
10
10
|
import { getCapability } from 'node-hudu/capabilities';
|
|
11
11
|
import { SEARCH_RESOURCES } from 'node-hudu/mcp';
|
|
12
|
+
import { CREDENTIAL_LOAD_HINT, detectCredentialShape } from './credential.js';
|
|
12
13
|
import { isWriteEffect } from './policy.js';
|
|
13
14
|
/**
|
|
14
15
|
* `stdio` is the local path (Claude Code, Claude Desktop, VS Code): one process, one credential,
|
|
@@ -211,6 +212,12 @@ const schema = z
|
|
|
211
212
|
HUDU_TIMEOUT_MS: z.coerce.number().int().positive().default(30000),
|
|
212
213
|
})
|
|
213
214
|
.superRefine((c, ctx) => {
|
|
215
|
+
// Pre-dial integrity check (2026-10-07 Autotask incident class): a credential whose bytes were
|
|
216
|
+
// mangled by a quote-preserving load is refused HERE, before any client is constructed and so
|
|
217
|
+
// before the first dial. Refusal is transport-independent: `HUDU_API_KEY` under `http` is the
|
|
218
|
+
// default key for a request that brings none, and presenting a mangled one is the same
|
|
219
|
+
// incident on a different path.
|
|
220
|
+
checkCredentialShape(c.HUDU_API_KEY, ctx, 'HUDU_API_KEY');
|
|
214
221
|
// `stdio` has exactly one origin and one credential (the environment's), so it requires both.
|
|
215
222
|
// Under `http` both are optional defaults: a caller's `x-hudu-*` headers win, and the defaults
|
|
216
223
|
// fill in whatever a request leaves out (see `resolveHuduClient` for how they pair).
|
|
@@ -327,6 +334,34 @@ const schema = z
|
|
|
327
334
|
}
|
|
328
335
|
}
|
|
329
336
|
});
|
|
337
|
+
/**
|
|
338
|
+
* Apply the same check to `HUDU_API_KEY` here as to the per-request key under `http`.
|
|
339
|
+
*
|
|
340
|
+
* {@link CREDENTIAL_LOAD_HINT} — the env-load pitfall the refusal ends with — lives in
|
|
341
|
+
* `src/credential.ts` beside the anomaly vocabulary, so the boot error and the http refusal explain
|
|
342
|
+
* the same cause in the same words.
|
|
343
|
+
*
|
|
344
|
+
* Lives in a refinement rather than in the field's own `preprocess` so the issue carries the same
|
|
345
|
+
* `path` (the variable name) the operator already reads, and so a value that is BOTH blank and
|
|
346
|
+
* malformed reports only the blank case.
|
|
347
|
+
*
|
|
348
|
+
* The message never contains a character of the value: the anomaly names and the length are the
|
|
349
|
+
* whole diagnostic. That matters — the value is the secret, and this text is printed to the boot
|
|
350
|
+
* log (and, under `http`, would be too).
|
|
351
|
+
*/
|
|
352
|
+
function checkCredentialShape(value, ctx, path) {
|
|
353
|
+
if (value === undefined)
|
|
354
|
+
return;
|
|
355
|
+
const anomalies = detectCredentialShape(value);
|
|
356
|
+
if (anomalies.length === 0)
|
|
357
|
+
return;
|
|
358
|
+
ctx.addIssue({
|
|
359
|
+
code: 'custom',
|
|
360
|
+
path: [path],
|
|
361
|
+
message: `${path} has a value whose shape cannot be right (length ${value.length}, ${anomalies.join(', ')}); ` +
|
|
362
|
+
`the server refuses to boot rather than present a malformed credential to Hudu. ${CREDENTIAL_LOAD_HINT}`,
|
|
363
|
+
});
|
|
364
|
+
}
|
|
330
365
|
/**
|
|
331
366
|
* Parse and validate configuration from an environment-shaped record.
|
|
332
367
|
*
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* credential.ts — the pre-dispatch shape gate for Hudu credential values, and the value-free
|
|
3
|
+
* description of a credential the operator-facing doctor reports.
|
|
4
|
+
*
|
|
5
|
+
* Why this exists (2026-10-07 Autotask incident): a `.env` value written `HUDU_API_KEY='a$b'` was
|
|
6
|
+
* fed through a quote-preserving dotenv parser, so the quotes reached the wire as part of the
|
|
7
|
+
* secret. The vendor answered 401 and the operator read three opaque failures as a rotated key —
|
|
8
|
+
* on an account where retries risk a lock. Nothing in this package, and nothing in node-hudu
|
|
9
|
+
* 0.12.0, inspects the shape of a credential value before it is dialed, so that class of misload
|
|
10
|
+
* booted cleanly and dialed.
|
|
11
|
+
*
|
|
12
|
+
* This module closes that at the boundary. It never inspects, stores or reports a credential
|
|
13
|
+
* VALUE — only shape facts derived from it (a length, a two-character prefix, and which anomaly
|
|
14
|
+
* was seen). {@link describeCredential} and {@link detectCredentialShape} return metadata for a
|
|
15
|
+
* value caller; no field of either ever carries a character of the value itself, so both are safe
|
|
16
|
+
* to log and to serialize.
|
|
17
|
+
*/
|
|
18
|
+
export declare const CREDENTIAL_LOAD_HINT = "load the value the way the file documents: values written in single quotes (the .env.example style) are only unquoted by shell sourcing \u2014 `set -a; . ./.env; set +a` \u2014 and a quote-preserving parser such as a plain dotenv parse of the file feeds the quotes to the vendor; run `doctor` to confirm the shape";
|
|
19
|
+
/**
|
|
20
|
+
* The shape anomalies a mislanded credential file load produces, in the order they are reported.
|
|
21
|
+
*
|
|
22
|
+
* `surrounding_single_quotes` is the incident itself: a value written `KEY='a$b'` in a `.env` loses
|
|
23
|
+
* its quotes when the file is SOURCED by a shell, so a value that still carries them here was never
|
|
24
|
+
* shell-sourced (or was parsed by something that preserves them).
|
|
25
|
+
*/
|
|
26
|
+
export declare const CREDENTIAL_ANOMALIES: readonly ["surrounding_double_quotes", "surrounding_single_quotes", "embedded_quotes", "carriage_return", "newline", "embedded_control_character", "edge_whitespace", "nul_byte"];
|
|
27
|
+
export type CredentialAnomaly = (typeof CREDENTIAL_ANOMALIES)[number];
|
|
28
|
+
/**
|
|
29
|
+
* Whether a shape anomaly means the server must refuse to dial rather than warn.
|
|
30
|
+
*
|
|
31
|
+
* Every anomaly in {@link CREDENTIAL_ANOMALIES} changes the bytes presented to the vendor, so
|
|
32
|
+
* every one of them is refused by both intake paths. The predicate is a function, not a literal
|
|
33
|
+
* `true` in the callers, so the decision lives in one place if it ever narrows.
|
|
34
|
+
*/
|
|
35
|
+
export declare function isFatalAnomaly(anomaly: CredentialAnomaly): boolean;
|
|
36
|
+
/** The value-free shape description of one credential-shaped string. */
|
|
37
|
+
export interface CredentialShape {
|
|
38
|
+
/** Character count of the value as received. A count, never a character of the value. */
|
|
39
|
+
readonly length: number;
|
|
40
|
+
/**
|
|
41
|
+
* The first two characters, which identify the format for the operator (a Hudu API key starts
|
|
42
|
+
* `ey` — a base64url-encoded JWT). Two characters of an already-leaked-format credential say
|
|
43
|
+
* nothing; the other 500 do.
|
|
44
|
+
*/
|
|
45
|
+
readonly prefix2: string;
|
|
46
|
+
/** Every anomaly seen, in {@link CREDENTIAL_ANOMALIES} order. */
|
|
47
|
+
readonly anomalies: readonly CredentialAnomaly[];
|
|
48
|
+
}
|
|
49
|
+
/** Every anomaly present in `value`, in a fixed order. Never returns any part of the value. */
|
|
50
|
+
export declare function detectCredentialShape(value: string): readonly CredentialAnomaly[];
|
|
51
|
+
/**
|
|
52
|
+
* The shape facts an operator-facing surface may report for one credential field.
|
|
53
|
+
*
|
|
54
|
+
* `prefix2` is emitted for values of at least two characters and a single `<1>` otherwise, so the
|
|
55
|
+
* report never implies a one-character secret. The value itself is never returned.
|
|
56
|
+
*/
|
|
57
|
+
export declare function describeCredential(value: string | undefined): CredentialShape | undefined;
|
|
58
|
+
/**
|
|
59
|
+
* The partial config one credential-bearing attempt produced, in the shape the operator's next
|
|
60
|
+
* step needs: whether the variable was set at all, and its value-free shape when it was.
|
|
61
|
+
*/
|
|
62
|
+
export interface CredentialReport {
|
|
63
|
+
readonly present: boolean;
|
|
64
|
+
readonly shape?: CredentialShape;
|
|
65
|
+
}
|
|
66
|
+
/** Describe one credential-bearing variable for a diagnostic surface. Never carries its value. */
|
|
67
|
+
export declare function reportCredential(env: Record<string, string | undefined>, name: string): CredentialReport;
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* credential.ts — the pre-dispatch shape gate for Hudu credential values, and the value-free
|
|
3
|
+
* description of a credential the operator-facing doctor reports.
|
|
4
|
+
*
|
|
5
|
+
* Why this exists (2026-10-07 Autotask incident): a `.env` value written `HUDU_API_KEY='a$b'` was
|
|
6
|
+
* fed through a quote-preserving dotenv parser, so the quotes reached the wire as part of the
|
|
7
|
+
* secret. The vendor answered 401 and the operator read three opaque failures as a rotated key —
|
|
8
|
+
* on an account where retries risk a lock. Nothing in this package, and nothing in node-hudu
|
|
9
|
+
* 0.12.0, inspects the shape of a credential value before it is dialed, so that class of misload
|
|
10
|
+
* booted cleanly and dialed.
|
|
11
|
+
*
|
|
12
|
+
* This module closes that at the boundary. It never inspects, stores or reports a credential
|
|
13
|
+
* VALUE — only shape facts derived from it (a length, a two-character prefix, and which anomaly
|
|
14
|
+
* was seen). {@link describeCredential} and {@link detectCredentialShape} return metadata for a
|
|
15
|
+
* value caller; no field of either ever carries a character of the value itself, so both are safe
|
|
16
|
+
* to log and to serialize.
|
|
17
|
+
*/
|
|
18
|
+
export const CREDENTIAL_LOAD_HINT = 'load the value the way the file documents: values written in single quotes (the .env.example style) are only unquoted by shell sourcing — `set -a; . ./.env; set +a` — and a quote-preserving parser such as a plain dotenv parse of the file feeds the quotes to the vendor; run `doctor` to confirm the shape';
|
|
19
|
+
/**
|
|
20
|
+
* The shape anomalies a mislanded credential file load produces, in the order they are reported.
|
|
21
|
+
*
|
|
22
|
+
* `surrounding_single_quotes` is the incident itself: a value written `KEY='a$b'` in a `.env` loses
|
|
23
|
+
* its quotes when the file is SOURCED by a shell, so a value that still carries them here was never
|
|
24
|
+
* shell-sourced (or was parsed by something that preserves them).
|
|
25
|
+
*/
|
|
26
|
+
export const CREDENTIAL_ANOMALIES = [
|
|
27
|
+
'surrounding_double_quotes',
|
|
28
|
+
'surrounding_single_quotes',
|
|
29
|
+
'embedded_quotes',
|
|
30
|
+
'carriage_return',
|
|
31
|
+
'newline',
|
|
32
|
+
'embedded_control_character',
|
|
33
|
+
'edge_whitespace',
|
|
34
|
+
'nul_byte',
|
|
35
|
+
];
|
|
36
|
+
/**
|
|
37
|
+
* Whether a shape anomaly means the server must refuse to dial rather than warn.
|
|
38
|
+
*
|
|
39
|
+
* Every anomaly in {@link CREDENTIAL_ANOMALIES} changes the bytes presented to the vendor, so
|
|
40
|
+
* every one of them is refused by both intake paths. The predicate is a function, not a literal
|
|
41
|
+
* `true` in the callers, so the decision lives in one place if it ever narrows.
|
|
42
|
+
*/
|
|
43
|
+
export function isFatalAnomaly(anomaly) {
|
|
44
|
+
return CREDENTIAL_ANOMALIES.includes(anomaly);
|
|
45
|
+
}
|
|
46
|
+
/** Every anomaly present in `value`, in a fixed order. Never returns any part of the value. */
|
|
47
|
+
export function detectCredentialShape(value) {
|
|
48
|
+
const anomalies = [];
|
|
49
|
+
// The incident, first: quotes preserved by a parser that should have consumed them.
|
|
50
|
+
if (value.length >= 2 && value.startsWith('"') && value.endsWith('"'))
|
|
51
|
+
anomalies.push('surrounding_double_quotes');
|
|
52
|
+
if (value.length >= 2 && value.startsWith("'") && value.endsWith("'"))
|
|
53
|
+
anomalies.push('surrounding_single_quotes');
|
|
54
|
+
// A quote that survived inside the value: a shell-sourced value cannot contain a bare one, and
|
|
55
|
+
// a shell that never ran leaves them where a parser put them.
|
|
56
|
+
if (value.includes('"') || value.includes("'"))
|
|
57
|
+
anomalies.push('embedded_quotes');
|
|
58
|
+
// CRLF is the canonical Windows-edited .env contamination: the CRLF and everything after it
|
|
59
|
+
// reaches the vendor as part of the secret. (A per-request header cannot carry one — Node's
|
|
60
|
+
// `Headers` strips it — so this branch is reachable only through the server-held value.)
|
|
61
|
+
if (value.includes('\r'))
|
|
62
|
+
anomalies.push('carriage_return');
|
|
63
|
+
if (value.includes('\n'))
|
|
64
|
+
anomalies.push('newline');
|
|
65
|
+
// Any other non-printable ASCII byte (a tab, bell or stray escape; NUL is named separately
|
|
66
|
+
// below): a Hudu API key is base64url and cannot legally carry one, so any of them means the
|
|
67
|
+
// load mangled the value.
|
|
68
|
+
let embeddedControl = false;
|
|
69
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
70
|
+
const code = value.charCodeAt(i);
|
|
71
|
+
if ((code < 0x20 && code !== 0x0d && code !== 0x0a) || code === 0x7f) {
|
|
72
|
+
embeddedControl = true;
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (embeddedControl)
|
|
77
|
+
anomalies.push('embedded_control_character');
|
|
78
|
+
// A trailing space from `KEY=value ` is invisible in every editor and is presented verbatim.
|
|
79
|
+
// `trim()` also catches a tab, which survives a header hop even though Node's Headers strips
|
|
80
|
+
// space at the edges of a header value.
|
|
81
|
+
if (value.length > 0 && value !== value.trim())
|
|
82
|
+
anomalies.push('edge_whitespace');
|
|
83
|
+
// NUL is not a legal header character: Node's `Headers.set` THROWS on a value carrying one, and
|
|
84
|
+
// that throw happens before any auth strategy runs, so a request with such a key answers with a
|
|
85
|
+
// framework error instead of a typed refusal. Flagged here so the server-held value is refused at
|
|
86
|
+
// boot, where the operator can act on it.
|
|
87
|
+
if (value.includes('\u0000'))
|
|
88
|
+
anomalies.push('nul_byte');
|
|
89
|
+
return anomalies;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* The shape facts an operator-facing surface may report for one credential field.
|
|
93
|
+
*
|
|
94
|
+
* `prefix2` is emitted for values of at least two characters and a single `<1>` otherwise, so the
|
|
95
|
+
* report never implies a one-character secret. The value itself is never returned.
|
|
96
|
+
*/
|
|
97
|
+
export function describeCredential(value) {
|
|
98
|
+
if (value === undefined)
|
|
99
|
+
return undefined;
|
|
100
|
+
return {
|
|
101
|
+
length: value.length,
|
|
102
|
+
prefix2: value.length >= 2 ? value.slice(0, 2) : '<1>',
|
|
103
|
+
anomalies: detectCredentialShape(value),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
/** Describe one credential-bearing variable for a diagnostic surface. Never carries its value. */
|
|
107
|
+
export function reportCredential(env, name) {
|
|
108
|
+
const raw = env[name];
|
|
109
|
+
// `''` is the .env.example placeholder an operator forgot to fill in: "not set", exactly as the
|
|
110
|
+
// boot schema normalises it, rather than a zero-length credential.
|
|
111
|
+
if (raw === undefined || raw.trim() === '')
|
|
112
|
+
return { present: false };
|
|
113
|
+
return { present: true, shape: describeCredential(raw) };
|
|
114
|
+
}
|
|
115
|
+
//# sourceMappingURL=credential.js.map
|
package/dist/doctor.d.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* doctor.ts — the zero-wire credential/config preflight.
|
|
3
|
+
*
|
|
4
|
+
* The problem it solves is the expensive one: today the only way to confirm that a credential
|
|
5
|
+
* loaded intact is to dial Hudu, and against a vendor account where repeated auth failures risk a
|
|
6
|
+
* lock (2026-10-07 Autotask incident) that confirmation is the most expensive call the deployment
|
|
7
|
+
* can make. `doctor` answers the same question — "is the value I loaded shaped like the credential
|
|
8
|
+
* the vendor issued, and is it aimed at the origin I meant?" — from the process's own environment,
|
|
9
|
+
* with ZERO vendor requests, by construction: this module imports no Hudu client and reads no
|
|
10
|
+
* network, so there is nothing it could dial even if a value invited it.
|
|
11
|
+
*
|
|
12
|
+
* It reports shape, never value: a length, a two-character prefix and which anomalies were seen.
|
|
13
|
+
* The contract it feeds (README / `.env.example`): run `doctor` before a live session — a green
|
|
14
|
+
* doctor means any later 401 is vendor-side (rotated, revoked or role-restricted key), not a
|
|
15
|
+
* mangled load.
|
|
16
|
+
*/
|
|
17
|
+
import type { Config } from './config.js';
|
|
18
|
+
/** How one credential field's shape check came out. */
|
|
19
|
+
export type DoctorCheckStatus = 'absent' | 'ok' | 'malformed';
|
|
20
|
+
/** The shape report for one credential-bearing variable: metadata only, never a value. */
|
|
21
|
+
export interface DoctorCredentialCheck {
|
|
22
|
+
readonly name: string;
|
|
23
|
+
readonly status: DoctorCheckStatus;
|
|
24
|
+
readonly length?: number;
|
|
25
|
+
readonly prefix2?: string;
|
|
26
|
+
readonly shapeOk?: boolean;
|
|
27
|
+
readonly anomalies?: readonly string[];
|
|
28
|
+
/**
|
|
29
|
+
* Whether the intake path this variable feeds would refuse it. Under `stdio` `HUDU_API_KEY` is
|
|
30
|
+
* required, so an absent one is as fatal as a malformed one; under `http` it is an optional
|
|
31
|
+
* default and absence is fine.
|
|
32
|
+
*/
|
|
33
|
+
readonly blocks?: boolean;
|
|
34
|
+
}
|
|
35
|
+
/** The shape report for one origin value: host and scheme only, never a path or a query. */
|
|
36
|
+
export interface DoctorBaseCheck {
|
|
37
|
+
readonly name: string;
|
|
38
|
+
readonly present: boolean;
|
|
39
|
+
/** `origin` (scheme + host + port), never the full URL. The origin is what the SDK dials. */
|
|
40
|
+
readonly origin?: string;
|
|
41
|
+
readonly resolvable: boolean;
|
|
42
|
+
}
|
|
43
|
+
/** The whole zero-wire report. `ok: false` means at least one field would be refused. */
|
|
44
|
+
export interface DoctorReport {
|
|
45
|
+
readonly ok: boolean;
|
|
46
|
+
readonly transport: Config['MCP_TRANSPORT'];
|
|
47
|
+
readonly dialed: number;
|
|
48
|
+
readonly credentials: readonly DoctorCredentialCheck[];
|
|
49
|
+
readonly bases: readonly DoctorBaseCheck[];
|
|
50
|
+
readonly notes: readonly string[];
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Build the doctor report for an already-validated config.
|
|
54
|
+
*
|
|
55
|
+
* Takes the parsed `Config` rather than a raw environment on purpose: `loadConfig` has already
|
|
56
|
+
* refused a blank/missing/origin-invalid configuration — and, at boot, a malformed credential — so
|
|
57
|
+
* reaching here means every structural question is settled and only the SHAPE facts remain.
|
|
58
|
+
*
|
|
59
|
+
* A malformed credential therefore normally reaches the operator as the boot refusal (the CLI
|
|
60
|
+
* prints it verbatim, exit 1, and it names the anomaly and the load recipe). This report is the
|
|
61
|
+
* same facts in structured form, for a config that is already shaped like a working one; the two
|
|
62
|
+
* routes are tested together in `test/doctor.test.ts`.
|
|
63
|
+
*/
|
|
64
|
+
export declare function runDoctor(config: Config): DoctorReport;
|
package/dist/doctor.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { describeCredential } from './credential.js';
|
|
2
|
+
/** Check one credential-shaped value without ever carrying it out of this function. */
|
|
3
|
+
function credentialCheck(name, value, required) {
|
|
4
|
+
if (value === undefined || value.trim() === '') {
|
|
5
|
+
// "not set", never "length 0": the boot schema normalises a blank the same way, and a blank is
|
|
6
|
+
// the `.env.example` placeholder rather than a credential the vendor would see.
|
|
7
|
+
return { name, status: 'absent', blocks: required };
|
|
8
|
+
}
|
|
9
|
+
const shape = describeCredential(value);
|
|
10
|
+
const malformed = shape.anomalies.length > 0;
|
|
11
|
+
return {
|
|
12
|
+
name,
|
|
13
|
+
status: malformed ? 'malformed' : 'ok',
|
|
14
|
+
length: shape.length,
|
|
15
|
+
prefix2: shape.prefix2,
|
|
16
|
+
shapeOk: !malformed,
|
|
17
|
+
anomalies: shape.anomalies,
|
|
18
|
+
// `blocks` is "this field would be refused", and only a malformed or an absent-and-required
|
|
19
|
+
// field is. A present, well-shaped value never blocks, however much the deployment needs it.
|
|
20
|
+
blocks: malformed,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Check one origin value: does it parse, and what origin will actually be dialed?
|
|
25
|
+
*
|
|
26
|
+
* An origin is not a secret (the operator wrote it), but only its origin crosses into the report:
|
|
27
|
+
* a URL with userinfo (`https://user:pass@host`) would otherwise carry a credential into every log
|
|
28
|
+
* sink this report reaches, so the origin is derived and the raw value is dropped.
|
|
29
|
+
*/
|
|
30
|
+
function baseCheck(name, value) {
|
|
31
|
+
if (value === undefined || value.trim() === '')
|
|
32
|
+
return { name, present: false, resolvable: false };
|
|
33
|
+
try {
|
|
34
|
+
return { name, present: true, origin: new URL(value).origin, resolvable: true };
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return { name, present: true, resolvable: false };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Build the doctor report for an already-validated config.
|
|
42
|
+
*
|
|
43
|
+
* Takes the parsed `Config` rather than a raw environment on purpose: `loadConfig` has already
|
|
44
|
+
* refused a blank/missing/origin-invalid configuration — and, at boot, a malformed credential — so
|
|
45
|
+
* reaching here means every structural question is settled and only the SHAPE facts remain.
|
|
46
|
+
*
|
|
47
|
+
* A malformed credential therefore normally reaches the operator as the boot refusal (the CLI
|
|
48
|
+
* prints it verbatim, exit 1, and it names the anomaly and the load recipe). This report is the
|
|
49
|
+
* same facts in structured form, for a config that is already shaped like a working one; the two
|
|
50
|
+
* routes are tested together in `test/doctor.test.ts`.
|
|
51
|
+
*/
|
|
52
|
+
export function runDoctor(config) {
|
|
53
|
+
const isStdio = config.MCP_TRANSPORT === 'stdio';
|
|
54
|
+
// The environment is exactly one credential path; the per-request header is not reachable from a
|
|
55
|
+
// preflight (nothing is serving yet), and saying so is part of the contract.
|
|
56
|
+
const credentials = [
|
|
57
|
+
credentialCheck('HUDU_API_KEY', config.HUDU_API_KEY, isStdio),
|
|
58
|
+
];
|
|
59
|
+
const bases = [baseCheck('HUDU_BASE_URL', config.HUDU_BASE_URL)];
|
|
60
|
+
const notes = [
|
|
61
|
+
'zero vendor requests: this report is computed from the environment alone',
|
|
62
|
+
'shape facts only (length, two-character prefix, anomaly names) — a credential value is never read out',
|
|
63
|
+
];
|
|
64
|
+
if (!isStdio) {
|
|
65
|
+
notes.push('http transport: the tool-call credential arrives per request in x-hudu-api-key and is checked at that boundary, not here');
|
|
66
|
+
}
|
|
67
|
+
if (credentials.some((c) => c.blocks)) {
|
|
68
|
+
notes.push('a credential-bearing variable would be refused by the server: fix the load recipe before any live session (a 401 costs a real vendor auth failure, and repeated ones risk an account lock)');
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
ok: !credentials.some((c) => c.blocks) && bases.every((b) => !b.present || b.resolvable),
|
|
72
|
+
transport: config.MCP_TRANSPORT,
|
|
73
|
+
dialed: 0,
|
|
74
|
+
credentials,
|
|
75
|
+
bases,
|
|
76
|
+
notes,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
//# sourceMappingURL=doctor.js.map
|
package/dist/http.d.ts
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
import { type IncomingMessage, type ServerResponse, type Server } from 'node:http';
|
|
17
17
|
import type { Config } from './config.js';
|
|
18
18
|
import type { Logger } from './logger.js';
|
|
19
|
+
import { type DoctorReport } from './doctor.js';
|
|
19
20
|
/** A fetch-shaped MCP handler, as returned by the SDK's `createMcpHandler`. */
|
|
20
21
|
export interface FetchHandler {
|
|
21
22
|
fetch(request: Request): Promise<Response>;
|
|
@@ -23,11 +24,25 @@ export interface FetchHandler {
|
|
|
23
24
|
/** Liveness path. Answered before authentication so a container healthcheck needs no credential. */
|
|
24
25
|
export declare const HEALTH_PATH = "/healthz";
|
|
25
26
|
/**
|
|
26
|
-
*
|
|
27
|
+
* The zero-wire credential preflight route.
|
|
28
|
+
*
|
|
29
|
+
* Same trust model as {@link HEALTH_PATH}: answered before the MCP handler, never authenticated,
|
|
30
|
+
* and never touching Hudu. It reports the shape of the SERVER-HELD credential only — the caller's
|
|
31
|
+
* own per-request key is checked at the tool boundary, one request at a time, and is deliberately
|
|
32
|
+
* not echoed back to the caller that sent it. Reporting shape facts (a length, a two-character
|
|
33
|
+
* prefix, anomaly names) about the server's own environment is therefore not a disclosure: the
|
|
34
|
+
* caller that can reach this listener is the operator who set that environment.
|
|
35
|
+
*/
|
|
36
|
+
export declare const DOCTOR_PATH = "/doctor";
|
|
37
|
+
/**
|
|
38
|
+
* Build the fetch-level request handler: health, doctor, then MCP.
|
|
27
39
|
*
|
|
28
40
|
* Split out from the listener so the request path is testable without binding a port.
|
|
41
|
+
*
|
|
42
|
+
* `doctor` is a builder, not a constant, so the route reports THIS process's parsed config and so
|
|
43
|
+
* the route itself stays Hudu-free: nothing is read until a request arrives.
|
|
29
44
|
*/
|
|
30
|
-
export declare function createFetchHandler(mcp: FetchHandler): (request: Request) => Promise<Response>;
|
|
45
|
+
export declare function createFetchHandler(mcp: FetchHandler, doctor?: () => DoctorReport): (request: Request) => Promise<Response>;
|
|
31
46
|
/** Node has no fetch-shaped HTTP server; this is the smallest bridge to one. */
|
|
32
47
|
export declare function toWebRequest(req: IncomingMessage, port: number): Promise<Request>;
|
|
33
48
|
/**
|
package/dist/http.js
CHANGED
|
@@ -17,22 +17,42 @@ import { createServer } from 'node:http';
|
|
|
17
17
|
import { Readable } from 'node:stream';
|
|
18
18
|
import { pipeline } from 'node:stream/promises';
|
|
19
19
|
import { HUDU_BASE_URL_HEADER, HUDU_KEY_HEADER } from './auth.js';
|
|
20
|
+
import { runDoctor } from './doctor.js';
|
|
20
21
|
/** The headers that carry per-request scope, and so must never be read from a duplicated pair. */
|
|
21
22
|
const CREDENTIAL_HEADERS = new Set([HUDU_KEY_HEADER, HUDU_BASE_URL_HEADER]);
|
|
22
23
|
/** Liveness path. Answered before authentication so a container healthcheck needs no credential. */
|
|
23
24
|
export const HEALTH_PATH = '/healthz';
|
|
24
25
|
/**
|
|
25
|
-
*
|
|
26
|
+
* The zero-wire credential preflight route.
|
|
27
|
+
*
|
|
28
|
+
* Same trust model as {@link HEALTH_PATH}: answered before the MCP handler, never authenticated,
|
|
29
|
+
* and never touching Hudu. It reports the shape of the SERVER-HELD credential only — the caller's
|
|
30
|
+
* own per-request key is checked at the tool boundary, one request at a time, and is deliberately
|
|
31
|
+
* not echoed back to the caller that sent it. Reporting shape facts (a length, a two-character
|
|
32
|
+
* prefix, anomaly names) about the server's own environment is therefore not a disclosure: the
|
|
33
|
+
* caller that can reach this listener is the operator who set that environment.
|
|
34
|
+
*/
|
|
35
|
+
export const DOCTOR_PATH = '/doctor';
|
|
36
|
+
/**
|
|
37
|
+
* Build the fetch-level request handler: health, doctor, then MCP.
|
|
26
38
|
*
|
|
27
39
|
* Split out from the listener so the request path is testable without binding a port.
|
|
40
|
+
*
|
|
41
|
+
* `doctor` is a builder, not a constant, so the route reports THIS process's parsed config and so
|
|
42
|
+
* the route itself stays Hudu-free: nothing is read until a request arrives.
|
|
28
43
|
*/
|
|
29
|
-
export function createFetchHandler(mcp) {
|
|
44
|
+
export function createFetchHandler(mcp, doctor) {
|
|
30
45
|
return async function handle(request) {
|
|
31
46
|
const url = new URL(request.url);
|
|
32
47
|
// Liveness only. It must not touch Hudu — see the Dockerfile HEALTHCHECK note.
|
|
33
48
|
if (url.pathname === HEALTH_PATH) {
|
|
34
49
|
return Response.json({ status: 'ok' });
|
|
35
50
|
}
|
|
51
|
+
// Credential shape, still no Hudu. `dialed: 0` is a property of the implementation (see
|
|
52
|
+
// `src/doctor.ts`, which holds no client), not a counter that a bug could leave at zero.
|
|
53
|
+
if (url.pathname === DOCTOR_PATH && doctor !== undefined) {
|
|
54
|
+
return Response.json(doctor());
|
|
55
|
+
}
|
|
36
56
|
// No check for the Hudu key: a request with no key is let through so the SDK can answer
|
|
37
57
|
// `initialize`/`tools/list`, which never reach Hudu and which clients need to sync the tool
|
|
38
58
|
// catalog. The per-request factory resolves such a request to a client that holds no credential
|
|
@@ -98,7 +118,7 @@ export async function send(res, response) {
|
|
|
98
118
|
* `exit` is a parameter only so a test can watch a listen failure without killing the test runner.
|
|
99
119
|
*/
|
|
100
120
|
export function startHttpServer(config, mcp, log, exit = process.exit) {
|
|
101
|
-
const handle = createFetchHandler(mcp);
|
|
121
|
+
const handle = createFetchHandler(mcp, () => runDoctor(config));
|
|
102
122
|
const server = createServer((req, res) => {
|
|
103
123
|
void toWebRequest(req, config.PORT)
|
|
104
124
|
.then(handle)
|