boondmanager-mcp-server 2.15.2 → 2.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1 -1
- package/NOTICE +16 -2
- package/README.md +60 -15
- package/dist/config/access-policy.d.ts.map +1 -1
- package/dist/config/access-policy.js +6 -18
- package/dist/config/access-policy.js.map +1 -1
- package/dist/config/dictionary-overrides.d.ts.map +1 -1
- package/dist/config/dictionary-overrides.js +2 -12
- package/dist/config/dictionary-overrides.js.map +1 -1
- package/dist/config/env.d.ts +63 -0
- package/dist/config/env.d.ts.map +1 -0
- package/dist/config/env.js +116 -0
- package/dist/config/env.js.map +1 -0
- package/dist/config/profiles.d.ts +22 -8
- package/dist/config/profiles.d.ts.map +1 -1
- package/dist/config/profiles.js +48 -4
- package/dist/config/profiles.js.map +1 -1
- package/dist/constants.d.ts +13 -2
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +25 -1
- package/dist/constants.js.map +1 -1
- package/dist/icons.d.ts.map +1 -1
- package/dist/icons.js +6 -1
- package/dist/icons.js.map +1 -1
- package/dist/index.js +33 -15
- package/dist/index.js.map +1 -1
- package/dist/instructions.d.ts +1 -1
- package/dist/instructions.d.ts.map +1 -1
- package/dist/instructions.js +6 -6
- package/dist/instructions.js.map +1 -1
- package/dist/prompts/index.d.ts +19 -1
- package/dist/prompts/index.d.ts.map +1 -1
- package/dist/prompts/index.js +495 -25
- package/dist/prompts/index.js.map +1 -1
- package/dist/prompts/periods.d.ts +45 -0
- package/dist/prompts/periods.d.ts.map +1 -0
- package/dist/prompts/periods.js +154 -0
- package/dist/prompts/periods.js.map +1 -0
- package/dist/resources/index.d.ts +14 -0
- package/dist/resources/index.d.ts.map +1 -1
- package/dist/resources/index.js +285 -0
- package/dist/resources/index.js.map +1 -1
- package/dist/resources/templates.d.ts +1 -1
- package/dist/resources/templates.d.ts.map +1 -1
- package/dist/resources/templates.js +2 -0
- package/dist/resources/templates.js.map +1 -1
- package/dist/schema-dialect.js.map +1 -1
- package/dist/schemas/filter-aliases.d.ts +1 -1
- package/dist/schemas/filter-aliases.d.ts.map +1 -1
- package/dist/schemas/filter-aliases.js +101 -2
- package/dist/schemas/filter-aliases.js.map +1 -1
- package/dist/schemas/index.d.ts +987 -161
- package/dist/schemas/index.d.ts.map +1 -1
- package/dist/schemas/index.js +853 -323
- package/dist/schemas/index.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +10 -3
- package/dist/server.js.map +1 -1
- package/dist/services/boond-client.d.ts +29 -213
- package/dist/services/boond-client.d.ts.map +1 -1
- package/dist/services/boond-client.js +29 -1168
- package/dist/services/boond-client.js.map +1 -1
- package/dist/services/dictionary.d.ts +27 -3
- package/dist/services/dictionary.d.ts.map +1 -1
- package/dist/services/dictionary.js +60 -23
- package/dist/services/dictionary.js.map +1 -1
- package/dist/services/document-text.d.ts +32 -0
- package/dist/services/document-text.d.ts.map +1 -0
- package/dist/services/document-text.js +120 -0
- package/dist/services/document-text.js.map +1 -0
- package/dist/services/format/detail.d.ts +16 -0
- package/dist/services/format/detail.d.ts.map +1 -0
- package/dist/services/format/detail.js +36 -0
- package/dist/services/format/detail.js.map +1 -0
- package/dist/services/format/html.d.ts +19 -0
- package/dist/services/format/html.d.ts.map +1 -0
- package/dist/services/format/html.js +87 -0
- package/dist/services/format/html.js.map +1 -0
- package/dist/services/format/list.d.ts +3 -0
- package/dist/services/format/list.d.ts.map +1 -0
- package/dist/services/format/list.js +44 -0
- package/dist/services/format/list.js.map +1 -0
- package/dist/services/format/summary.d.ts +16 -0
- package/dist/services/format/summary.d.ts.map +1 -0
- package/dist/services/format/summary.js +172 -0
- package/dist/services/format/summary.js.map +1 -0
- package/dist/services/format/tab.d.ts +9 -0
- package/dist/services/format/tab.d.ts.map +1 -0
- package/dist/services/format/tab.js +32 -0
- package/dist/services/format/tab.js.map +1 -0
- package/dist/services/http/auth.d.ts +44 -0
- package/dist/services/http/auth.d.ts.map +1 -0
- package/dist/services/http/auth.js +146 -0
- package/dist/services/http/auth.js.map +1 -0
- package/dist/services/http/download.d.ts +60 -0
- package/dist/services/http/download.d.ts.map +1 -0
- package/dist/services/http/download.js +160 -0
- package/dist/services/http/download.js.map +1 -0
- package/dist/services/http/errors.d.ts +45 -0
- package/dist/services/http/errors.d.ts.map +1 -0
- package/dist/services/http/errors.js +188 -0
- package/dist/services/http/errors.js.map +1 -0
- package/dist/services/http/rate-limit.d.ts +40 -0
- package/dist/services/http/rate-limit.d.ts.map +1 -0
- package/dist/services/http/rate-limit.js +92 -0
- package/dist/services/http/rate-limit.js.map +1 -0
- package/dist/services/http/retry.d.ts +46 -0
- package/dist/services/http/retry.d.ts.map +1 -0
- package/dist/services/http/retry.js +101 -0
- package/dist/services/http/retry.js.map +1 -0
- package/dist/services/http/transport.d.ts +73 -0
- package/dist/services/http/transport.d.ts.map +1 -0
- package/dist/services/http/transport.js +272 -0
- package/dist/services/http/transport.js.map +1 -0
- package/dist/services/logger.d.ts +53 -2
- package/dist/services/logger.d.ts.map +1 -1
- package/dist/services/logger.js +82 -21
- package/dist/services/logger.js.map +1 -1
- package/dist/services/oauth.d.ts +12 -0
- package/dist/services/oauth.d.ts.map +1 -1
- package/dist/services/oauth.js +21 -8
- package/dist/services/oauth.js.map +1 -1
- package/dist/services/rate-limiter.d.ts +9 -2
- package/dist/services/rate-limiter.d.ts.map +1 -1
- package/dist/services/rate-limiter.js +18 -6
- package/dist/services/rate-limiter.js.map +1 -1
- package/dist/services/request-context.d.ts +47 -0
- package/dist/services/request-context.d.ts.map +1 -0
- package/dist/services/request-context.js +112 -0
- package/dist/services/request-context.js.map +1 -0
- package/dist/services/search.d.ts +26 -0
- package/dist/services/search.d.ts.map +1 -0
- package/dist/services/search.js +98 -0
- package/dist/services/search.js.map +1 -0
- package/dist/services/update-checker.d.ts.map +1 -1
- package/dist/services/update-checker.js +15 -8
- package/dist/services/update-checker.js.map +1 -1
- package/dist/tools/absences.d.ts +9 -0
- package/dist/tools/absences.d.ts.map +1 -1
- package/dist/tools/absences.js +65 -20
- package/dist/tools/absences.js.map +1 -1
- package/dist/tools/actions.d.ts.map +1 -1
- package/dist/tools/actions.js +11 -27
- package/dist/tools/actions.js.map +1 -1
- package/dist/tools/advantages.d.ts +2 -0
- package/dist/tools/advantages.d.ts.map +1 -1
- package/dist/tools/advantages.js +66 -13
- package/dist/tools/advantages.js.map +1 -1
- package/dist/tools/alerts.d.ts +18 -0
- package/dist/tools/alerts.d.ts.map +1 -0
- package/dist/tools/alerts.js +82 -0
- package/dist/tools/alerts.js.map +1 -0
- package/dist/tools/application.d.ts +2 -1
- package/dist/tools/application.d.ts.map +1 -1
- package/dist/tools/application.js +9 -3
- package/dist/tools/application.js.map +1 -1
- package/dist/tools/contacts.d.ts.map +1 -1
- package/dist/tools/contacts.js +3 -7
- package/dist/tools/contacts.js.map +1 -1
- package/dist/tools/contracts.d.ts +22 -0
- package/dist/tools/contracts.d.ts.map +1 -1
- package/dist/tools/contracts.js +185 -52
- package/dist/tools/contracts.js.map +1 -1
- package/dist/tools/crud-factory.d.ts +33 -2
- package/dist/tools/crud-factory.d.ts.map +1 -1
- package/dist/tools/crud-factory.js +42 -15
- package/dist/tools/crud-factory.js.map +1 -1
- package/dist/tools/deliveries.d.ts +3 -0
- package/dist/tools/deliveries.d.ts.map +1 -1
- package/dist/tools/deliveries.js +50 -94
- package/dist/tools/deliveries.js.map +1 -1
- package/dist/tools/description-builders.d.ts +0 -1
- package/dist/tools/description-builders.d.ts.map +1 -1
- package/dist/tools/description-builders.js +46 -3
- package/dist/tools/description-builders.js.map +1 -1
- package/dist/tools/documents.d.ts.map +1 -1
- package/dist/tools/documents.js +73 -21
- package/dist/tools/documents.js.map +1 -1
- package/dist/tools/expenses.d.ts.map +1 -1
- package/dist/tools/expenses.js +2 -11
- package/dist/tools/expenses.js.map +1 -1
- package/dist/tools/find.d.ts +61 -0
- package/dist/tools/find.d.ts.map +1 -0
- package/dist/tools/find.js +221 -0
- package/dist/tools/find.js.map +1 -0
- package/dist/tools/flags.d.ts +5 -0
- package/dist/tools/flags.d.ts.map +1 -1
- package/dist/tools/flags.js +116 -1
- package/dist/tools/flags.js.map +1 -1
- package/dist/tools/forms.d.ts +5 -0
- package/dist/tools/forms.d.ts.map +1 -0
- package/dist/tools/forms.js +64 -0
- package/dist/tools/forms.js.map +1 -0
- package/dist/tools/groupments.d.ts +5 -0
- package/dist/tools/groupments.d.ts.map +1 -0
- package/dist/tools/groupments.js +68 -0
- package/dist/tools/groupments.js.map +1 -0
- package/dist/tools/inactivities.d.ts +5 -0
- package/dist/tools/inactivities.d.ts.map +1 -0
- package/dist/tools/inactivities.js +59 -0
- package/dist/tools/inactivities.js.map +1 -0
- package/dist/tools/index.d.ts +4 -0
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +4 -0
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/invoices.d.ts.map +1 -1
- package/dist/tools/invoices.js +27 -2
- package/dist/tools/invoices.js.map +1 -1
- package/dist/tools/linked-entity-filters.d.ts +48 -0
- package/dist/tools/linked-entity-filters.d.ts.map +1 -0
- package/dist/tools/linked-entity-filters.js +64 -0
- package/dist/tools/linked-entity-filters.js.map +1 -0
- package/dist/tools/notifications.d.ts.map +1 -1
- package/dist/tools/notifications.js +0 -5
- package/dist/tools/notifications.js.map +1 -1
- package/dist/tools/orders.d.ts.map +1 -1
- package/dist/tools/orders.js +33 -4
- package/dist/tools/orders.js.map +1 -1
- package/dist/tools/parameter-disclosure.js.map +1 -1
- package/dist/tools/payments.d.ts +3 -0
- package/dist/tools/payments.d.ts.map +1 -1
- package/dist/tools/payments.js +43 -109
- package/dist/tools/payments.js.map +1 -1
- package/dist/tools/positionings.d.ts.map +1 -1
- package/dist/tools/positionings.js +9 -39
- package/dist/tools/positionings.js.map +1 -1
- package/dist/tools/projects.d.ts.map +1 -1
- package/dist/tools/projects.js +5 -12
- package/dist/tools/projects.js.map +1 -1
- package/dist/tools/provider-invoices.d.ts +2 -0
- package/dist/tools/provider-invoices.d.ts.map +1 -1
- package/dist/tools/provider-invoices.js +30 -111
- package/dist/tools/provider-invoices.js.map +1 -1
- package/dist/tools/purchases.d.ts +2 -0
- package/dist/tools/purchases.d.ts.map +1 -1
- package/dist/tools/purchases.js +50 -104
- package/dist/tools/purchases.js.map +1 -1
- package/dist/tools/registration-decorators.d.ts +28 -0
- package/dist/tools/registration-decorators.d.ts.map +1 -1
- package/dist/tools/registration-decorators.js +107 -0
- package/dist/tools/registration-decorators.js.map +1 -1
- package/dist/tools/resources.d.ts.map +1 -1
- package/dist/tools/resources.js +27 -5
- package/dist/tools/resources.js.map +1 -1
- package/dist/tools/rights.d.ts +101 -0
- package/dist/tools/rights.d.ts.map +1 -0
- package/dist/tools/rights.js +75 -0
- package/dist/tools/rights.js.map +1 -0
- package/dist/tools/tab-tools.js +3 -3
- package/dist/tools/tab-tools.js.map +1 -1
- package/dist/tools/timesheets.d.ts +29 -0
- package/dist/tools/timesheets.d.ts.map +1 -1
- package/dist/tools/timesheets.js +196 -126
- package/dist/tools/timesheets.js.map +1 -1
- package/dist/tools/todolists.d.ts +2 -0
- package/dist/tools/todolists.d.ts.map +1 -1
- package/dist/tools/todolists.js +64 -1
- package/dist/tools/todolists.js.map +1 -1
- package/dist/tools/validations.d.ts +9 -0
- package/dist/tools/validations.d.ts.map +1 -1
- package/dist/tools/validations.js +86 -9
- package/dist/tools/validations.js.map +1 -1
- package/dist/tools/workflows.js +1 -1
- package/dist/tools/workflows.js.map +1 -1
- package/dist/transports/http.d.ts +116 -0
- package/dist/transports/http.d.ts.map +1 -1
- package/dist/transports/http.js +422 -92
- package/dist/transports/http.js.map +1 -1
- package/dist/types.d.ts +3 -3
- package/dist/types.d.ts.map +1 -1
- package/manifest.json +7 -7
- package/package.json +14 -10
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { send } from "./transport.js";
|
|
2
|
+
/**
|
|
3
|
+
* Parse the filename out of a `Content-Disposition` header. Handles the
|
|
4
|
+
* common `filename="…"`/`filename=…` forms and the RFC 5987
|
|
5
|
+
* `filename*=UTF-8''…` form. Returns undefined when absent. Exported for
|
|
6
|
+
* unit testing.
|
|
7
|
+
*/
|
|
8
|
+
export function parseContentDispositionFilename(header) {
|
|
9
|
+
if (!header)
|
|
10
|
+
return undefined;
|
|
11
|
+
const star = header.match(/filename\*\s*=\s*(?:UTF-8|utf-8)''([^;]+)/);
|
|
12
|
+
if (star) {
|
|
13
|
+
try {
|
|
14
|
+
return decodeURIComponent((star[1] ?? "").trim());
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
// fall through to the plain form
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
const plain = header.match(/filename\s*=\s*"([^"]+)"/) ?? header.match(/filename\s*=\s*([^;]+)/);
|
|
21
|
+
const name = plain?.[1]?.trim();
|
|
22
|
+
return name === undefined ? undefined : repairUtf8Filename(name);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* BoondManager writes the plain `filename="…"` form with UTF-8 bytes (often
|
|
26
|
+
* NFD: `e` + combining acute), and `fetch` decodes header bytes as Latin-1 as
|
|
27
|
+
* the spec says — so "Frédéric" arrives as "FreÌdeÌric" (observed live on
|
|
28
|
+
* 2026-09-26, issue #311). Re-decode those bytes as UTF-8 when that yields a
|
|
29
|
+
* clean string, and normalise to NFC so the name compares like a normal one.
|
|
30
|
+
*/
|
|
31
|
+
export function repairUtf8Filename(name) {
|
|
32
|
+
if (!/[\u0080-\u00ff]/.test(name))
|
|
33
|
+
return name;
|
|
34
|
+
const decoded = Buffer.from(name, "latin1").toString("utf8");
|
|
35
|
+
if (decoded.includes("\ufffd"))
|
|
36
|
+
return name.normalize("NFC");
|
|
37
|
+
return decoded.normalize("NFC");
|
|
38
|
+
}
|
|
39
|
+
/** Human-readable byte count for progress messages (same units as the tool output). */
|
|
40
|
+
function formatBytes(bytes) {
|
|
41
|
+
return bytes >= 1024 * 1024 ? `${(bytes / 1024 / 1024).toFixed(1)} Mo` : `${Math.round(bytes / 1024)} Ko`;
|
|
42
|
+
}
|
|
43
|
+
/** Progress steps emitted while streaming a download (≈ every 10 %). */
|
|
44
|
+
const DOWNLOAD_PROGRESS_STEPS = 10;
|
|
45
|
+
/**
|
|
46
|
+
* Thrown by `apiDownload` when the payload exceeds `maxBytes` — before the
|
|
47
|
+
* body is read when the response announces its size, otherwise the moment the
|
|
48
|
+
* running total crosses the cap (the stream is cancelled, nothing more is
|
|
49
|
+
* buffered). `bytes` is the announced size in the first case and a lower
|
|
50
|
+
* bound (bytes received so far) in the second; `announced` tells which.
|
|
51
|
+
*/
|
|
52
|
+
export class DownloadTooLargeError extends Error {
|
|
53
|
+
bytes;
|
|
54
|
+
maxBytes;
|
|
55
|
+
announced;
|
|
56
|
+
constructor(path, bytes, maxBytes, announced) {
|
|
57
|
+
super(`Document exceeds the ${formatBytes(maxBytes)} limit (${announced ? "announced size" : "more than"} ${formatBytes(bytes)}).\nEndpoint: GET ${path}`);
|
|
58
|
+
this.name = "DownloadTooLargeError";
|
|
59
|
+
this.bytes = bytes;
|
|
60
|
+
this.maxBytes = maxBytes;
|
|
61
|
+
this.announced = announced;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Read a download body chunk by chunk, enforcing `maxBytes` as the bytes
|
|
66
|
+
* arrive and reporting progress as it goes.
|
|
67
|
+
*
|
|
68
|
+
* Streaming is the single path (#235): the cap has to be applied *while*
|
|
69
|
+
* reading, or a 200 MB file is fully buffered before it is refused. Progress
|
|
70
|
+
* is only an observer on top of it — emitted when someone is listening and the
|
|
71
|
+
* response announced a `Content-Length` (without a total there is nothing
|
|
72
|
+
* meaningful to report). A response without a readable stream falls back to
|
|
73
|
+
* `arrayBuffer()` and is checked afterwards.
|
|
74
|
+
*/
|
|
75
|
+
async function readDownloadBody(response, path, maxBytes, onProgress) {
|
|
76
|
+
const contentLength = Number(response.headers.get("content-length"));
|
|
77
|
+
const totalBytes = Number.isFinite(contentLength) && contentLength > 0 ? contentLength : undefined;
|
|
78
|
+
const body = response.body;
|
|
79
|
+
if (!body) {
|
|
80
|
+
const buffered = Buffer.from(await response.arrayBuffer());
|
|
81
|
+
if (buffered.byteLength > maxBytes)
|
|
82
|
+
throw new DownloadTooLargeError(path, buffered.byteLength, maxBytes, true);
|
|
83
|
+
return buffered;
|
|
84
|
+
}
|
|
85
|
+
const reporting = onProgress?.enabled === true && totalBytes !== undefined;
|
|
86
|
+
const reader = body.getReader();
|
|
87
|
+
const step = totalBytes !== undefined ? Math.max(1, Math.floor(totalBytes / DOWNLOAD_PROGRESS_STEPS)) : Infinity;
|
|
88
|
+
const chunks = [];
|
|
89
|
+
let received = 0;
|
|
90
|
+
let reported = 0;
|
|
91
|
+
for (;;) {
|
|
92
|
+
const { done, value } = await reader.read();
|
|
93
|
+
if (done)
|
|
94
|
+
break;
|
|
95
|
+
if (!value)
|
|
96
|
+
continue;
|
|
97
|
+
received += value.byteLength;
|
|
98
|
+
if (received > maxBytes) {
|
|
99
|
+
// Stop pulling: what is already in `chunks` is dropped with this frame.
|
|
100
|
+
await reader.cancel().catch(() => undefined);
|
|
101
|
+
throw new DownloadTooLargeError(path, received, maxBytes, false);
|
|
102
|
+
}
|
|
103
|
+
chunks.push(value);
|
|
104
|
+
// Throttled to ~10 notifications: a 5 MiB file arrives in ~80 network
|
|
105
|
+
// chunks, and one notification each would be its own kind of flood.
|
|
106
|
+
if (reporting && received - reported >= step) {
|
|
107
|
+
reported = received;
|
|
108
|
+
onProgress(received, totalBytes, `Téléchargement — ${formatBytes(received)} / ${formatBytes(totalBytes)}`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (reporting && received > reported) {
|
|
112
|
+
onProgress(received, totalBytes, `Téléchargement terminé — ${formatBytes(received)}`);
|
|
113
|
+
}
|
|
114
|
+
return Buffer.concat(chunks);
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Download a binary payload (documents, justificatifs…) from the BoondManager
|
|
118
|
+
* API. Same pipeline as `apiRequest` (`send()`: auth, path guard, rate limit,
|
|
119
|
+
* timeout, retry policy for a GET), but the body is streamed raw instead of
|
|
120
|
+
* being parsed as JSON:API.
|
|
121
|
+
*
|
|
122
|
+
* `onProgress` reports bytes received when the client asked for progress and
|
|
123
|
+
* the response carries a `Content-Length`; otherwise nothing is emitted.
|
|
124
|
+
*
|
|
125
|
+
* `options.maxBytes` bounds memory: a `Content-Length` above it is refused
|
|
126
|
+
* before a single body byte is read, and a body without one is cancelled the
|
|
127
|
+
* moment it crosses the cap (`DownloadTooLargeError` either way).
|
|
128
|
+
*/
|
|
129
|
+
export async function apiDownload(path, onProgress, options = {}) {
|
|
130
|
+
const maxBytes = options.maxBytes ?? Infinity;
|
|
131
|
+
const response = await send(path, { method: "GET", headers: { Accept: "*/*" } });
|
|
132
|
+
const contentType = response.headers.get("content-type")?.split(";")[0]?.trim() || "application/octet-stream";
|
|
133
|
+
const filename = parseContentDispositionFilename(response.headers.get("content-disposition"));
|
|
134
|
+
// BoondManager only answers 404 on an unknown document when the request asks
|
|
135
|
+
// for JSON. With the `Accept: */*` this function sends, it serves the
|
|
136
|
+
// application shell instead — HTTP 200, `text/html`, ~9 KB — which the caller
|
|
137
|
+
// would happily surface as the document's text content. A truncated id then
|
|
138
|
+
// looks like a corrupted file rather than a wrong id, so refuse the shell
|
|
139
|
+
// here. An HTML *document* is still downloadable: a real file download
|
|
140
|
+
// carries a `Content-Disposition` filename, the shell doesn't.
|
|
141
|
+
if (contentType === "text/html" && !filename) {
|
|
142
|
+
throw new Error([
|
|
143
|
+
"BoondManager returned an HTML page instead of a document (HTTP 200, text/html).",
|
|
144
|
+
`Endpoint: GET ${path}`,
|
|
145
|
+
"Hint: The document id is most likely wrong or truncated. Entity relations expose suffixed ids " +
|
|
146
|
+
"(e.g. `123_resume`, `123_file`) — pass the id verbatim, suffix included. BoondManager serves its " +
|
|
147
|
+
"application shell for an unknown /documents/<id> instead of a 404.",
|
|
148
|
+
].join("\n"));
|
|
149
|
+
}
|
|
150
|
+
// Announced size over the cap: refuse without reading the body. The
|
|
151
|
+
// 30 s timeout used to be the only bound on a 200 MB document (#235).
|
|
152
|
+
const announced = Number(response.headers.get("content-length"));
|
|
153
|
+
if (Number.isFinite(announced) && announced > maxBytes) {
|
|
154
|
+
await response.body?.cancel().catch(() => undefined);
|
|
155
|
+
throw new DownloadTooLargeError(path, announced, maxBytes, true);
|
|
156
|
+
}
|
|
157
|
+
const data = await readDownloadBody(response, path, maxBytes, onProgress);
|
|
158
|
+
return { data, contentType, ...(filename !== undefined ? { filename } : {}) };
|
|
159
|
+
}
|
|
160
|
+
//# sourceMappingURL=download.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"download.js","sourceRoot":"","sources":["../../../src/services/http/download.ts"],"names":[],"mappings":"AAOA,OAAO,EAAE,IAAI,EAAE,MAAM,gBAAgB,CAAC;AAEtC;;;;;GAKG;AACH,MAAM,UAAU,+BAA+B,CAAC,MAAqB;IACnE,IAAI,CAAC,MAAM;QAAE,OAAO,SAAS,CAAC;IAC9B,MAAM,IAAI,GAAG,MAAM,CAAC,KAAK,CAAC,2CAA2C,CAAC,CAAC;IACvE,IAAI,IAAI,EAAE,CAAC;QACT,IAAI,CAAC;YACH,OAAO,kBAAkB,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC;QACpD,CAAC;QAAC,MAAM,CAAC;YACP,iCAAiC;QACnC,CAAC;IACH,CAAC;IACD,MAAM,KAAK,GAAG,MAAM,CAAC,KAAK,CAAC,0BAA0B,CAAC,IAAI,MAAM,CAAC,KAAK,CAAC,wBAAwB,CAAC,CAAC;IACjG,MAAM,IAAI,GAAG,KAAK,EAAE,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,CAAC;IAChC,OAAO,IAAI,KAAK,SAAS,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,kBAAkB,CAAC,IAAI,CAAC,CAAC;AACnE,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,kBAAkB,CAAC,IAAY;IAC7C,IAAI,CAAC,iBAAiB,CAAC,IAAI,CAAC,IAAI,CAAC;QAAE,OAAO,IAAI,CAAC;IAC/C,MAAM,OAAO,GAAG,MAAM,CAAC,IAAI,CAAC,IAAI,EAAE,QAAQ,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC;IAC7D,IAAI,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAC;QAAE,OAAO,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,CAAC;IAC7D,OAAO,OAAO,CAAC,SAAS,CAAC,KAAK,CAAC,CAAC;AAClC,CAAC;AAQD,uFAAuF;AACvF,SAAS,WAAW,CAAC,KAAa;IAChC,OAAO,KAAK,IAAI,IAAI,GAAG,IAAI,CAAC,CAAC,CAAC,GAAG,CAAC,KAAK,GAAG,IAAI,GAAG,IAAI,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,GAAG,IAAI,CAAC,KAAK,CAAC,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC;AAC5G,CAAC;AAED,wEAAwE;AACxE,MAAM,uBAAuB,GAAG,EAAE,CAAC;AAEnC;;;;;;GAMG;AACH,MAAM,OAAO,qBAAsB,SAAQ,KAAK;IACrC,KAAK,CAAS;IACd,QAAQ,CAAS;IACjB,SAAS,CAAU;IAC5B,YAAY,IAAY,EAAE,KAAa,EAAE,QAAgB,EAAE,SAAkB;QAC3E,KAAK,CACH,wBAAwB,WAAW,CAAC,QAAQ,CAAC,WAAW,SAAS,CAAC,CAAC,CAAC,gBAAgB,CAAC,CAAC,CAAC,WAAW,IAAI,WAAW,CAAC,KAAK,CAAC,qBAAqB,IAAI,EAAE,CACpJ,CAAC;QACF,IAAI,CAAC,IAAI,GAAG,uBAAuB,CAAC;QACpC,IAAI,CAAC,KAAK,GAAG,KAAK,CAAC;QACnB,IAAI,CAAC,QAAQ,GAAG,QAAQ,CAAC;QACzB,IAAI,CAAC,SAAS,GAAG,SAAS,CAAC;IAC7B,CAAC;CACF;AAQD;;;;;;;;;;GAUG;AACH,KAAK,UAAU,gBAAgB,CAC7B,QAAkB,EAClB,IAAY,EACZ,QAAgB,EAChB,UAA6B;IAE7B,MAAM,aAAa,GAAG,MAAM,CAAC,QAAQ,CAAC,OAAO,CAAC,GAAG,CAAC,gBAAgB,CAAC,CAAC,CAAC;IACrE,MAAM,UAAU,GAAG,MAAM,CAAC,QAAQ,CAAC,aAAa,CAAC,IAAI,aAAa,GAAG,CAAC,CAAC,CAAC,CAAC,aAAa,CAAC,CAAC,CAAC,SAAS,CAAC;IACnG,MAAM,IAAI,GAAG,QAAQ,CAAC,IAAI,CAAC;IAC3B,IAAI,CAAC,IAAI,EAAE,CAAC;QACV,MAAM,QAAQ,GAAG,MAAM,CAAC,IAAI,CAAC,MAAM,QAAQ,CAAC,WAAW,EAAE,CAAC,CAAC;QAC3D,IAAI,QAAQ,CAAC,UAAU,GAAG,QAAQ;YAAE,MAAM,IAAI,qBAAqB,CAAC,IAAI,EAAE,QAAQ,CAAC,UAAU,EAAE,QAAQ,EAAE,IAAI,CAAC,CAAC;QAC/G,OAAO,QAAQ,CAAC;IAClB,CAAC;IAED,MAAM,SAAS,GAAG,UAAU,EAAE,OAAO,KAAK,IAAI,IAAI,UAAU,KAAK,SAAS,CAAC;IAC3E,MAAM,MAAM,GAAG,IAAI,CAAC,SAAS,EAAE,CAAC;IAChC,MAAM,IAAI,GAAG,UAAU,KAAK,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,KAAK,CAAC,UAAU,GAAG,uBAAuB,CAAC,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC;IACjH,MAAM,MAAM,GAAiB,EAAE,CAAC;IAChC,IAAI,QAAQ,GAAG,CAAC,CAAC;IACjB,IAAI,QAAQ,GAAG,CAAC,CAAC;IAEjB,SAAS,CAAC;QACR,MAAM,EAAE,IAAI,EAAE,KAAK,EAAE,GAAG,MAAM,MAAM,CAAC,IAAI,EAAE,CAAC;QAC5C,IAAI,IAAI;YAAE,MAAM;QAChB,IAAI,CAAC,KAAK;YAAE,SAAS;QACrB,QAAQ,IAAI,KAAK,CAAC,UAAU,CAAC;QAC7B,IAAI,QAAQ,GAAG,QAAQ,EAAE,CAAC;YACxB,wEAAwE;YACxE,MAAM,MAAM,CAAC,MAAM,EAAE,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,SAAS,CAAC,CAAC;YAC7C,MAAM,IAAI,qBAAqB,CAAC,IAAI,EAAE,QAAQ,EAAE,QAAQ,EAAE,KAAK,CAAC,CAAC;QACnE,CAAC;QACD,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;QACnB,sEAAsE;QACtE,oEAAoE;QACpE,IAAI,SAAS,IAAI,QAAQ,GAAG,QAAQ,IAAI,IAAI,EAAE,CAAC;YAC7C,QAAQ,GAAG,QAAQ,CAAC;YACpB,UAAU,CAAC,QAAQ,EAAE,UAAU,EAAE,oBAAoB,WAAW,CAAC,QAAQ,CAAC,MAAM,WAAW,CAAC,UAAU,CAAC,EAAE,CAAC,CAAC;QAC7G,CAAC;IACH,CAAC;IAED,IAAI,SAAS,IAAI,QAAQ,GAAG,QAAQ,EAAE,CAAC;QACrC,UAAU,CAAC,QAAQ,EAAE,UAAU,EAAE,4BAA4B,WAAW,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC;IACxF,CAAC;IACD,OAAO,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;AAC/B,CAAC;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,CAAC,KAAK,UAAU,WAAW,CAC/B,IAAY,EACZ,UAA6B,EAC7B,UAA2B,EAAE;IAE7B,MAAM,QAAQ,GAAG,OAAO,CAAC,QAAQ,IAAI,QAAQ,CAAC;IAC9C,MAAM,QAAQ,GAAG,MAAM,IAAI,CAAC,IAAI,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,EAAE,CAAC,CAAC;IAEjF,MAAM,WAAW,GAAG,QAAQ,CAAC,OAAO,CAAC,GAAG,CAAC,cAAc,CAAC,EAAE,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,IAAI,0BAA0B,CAAC;IAC9G,MAAM,QAAQ,GAAG,+BAA+B,CAAC,QAAQ,CAAC,OAAO,CAAC,GAAG,CAAC,qBAAqB,CAAC,CAAC,CAAC;IAE9F,6EAA6E;IAC7E,sEAAsE;IACtE,8EAA8E;IAC9E,4EAA4E;IAC5E,0EAA0E;IAC1E,uEAAuE;IACvE,+DAA+D;IAC/D,IAAI,WAAW,KAAK,WAAW,IAAI,CAAC,QAAQ,EAAE,CAAC;QAC7C,MAAM,IAAI,KAAK,CACb;YACE,iFAAiF;YACjF,iBAAiB,IAAI,EAAE;YACvB,gGAAgG;gBAC9F,mGAAmG;gBACnG,oEAAoE;SACvE,CAAC,IAAI,CAAC,IAAI,CAAC,CACb,CAAC;IACJ,CAAC;IAED,oEAAoE;IACpE,sEAAsE;IACtE,MAAM,SAAS,GAAG,MAAM,CAAC,QAAQ,CAAC,OAAO,CAAC,GAAG,CAAC,gBAAgB,CAAC,CAAC,CAAC;IACjE,IAAI,MAAM,CAAC,QAAQ,CAAC,SAAS,CAAC,IAAI,SAAS,GAAG,QAAQ,EAAE,CAAC;QACvD,MAAM,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,SAAS,CAAC,CAAC;QACrD,MAAM,IAAI,qBAAqB,CAAC,IAAI,EAAE,SAAS,EAAE,QAAQ,EAAE,IAAI,CAAC,CAAC;IACnE,CAAC;IAED,MAAM,IAAI,GAAG,MAAM,gBAAgB,CAAC,QAAQ,EAAE,IAAI,EAAE,QAAQ,EAAE,UAAU,CAAC,CAAC;IAC1E,OAAO,EAAE,IAAI,EAAE,WAAW,EAAE,GAAG,CAAC,QAAQ,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,QAAQ,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC;AAChF,CAAC"}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pull the human-readable bits out of a BoondManager error body.
|
|
3
|
+
*
|
|
4
|
+
* Boond returns JSON:API errors of the form:
|
|
5
|
+
* { "errors": [ { "status": "422", "code": "422", "detail": "...", "title": "..." } ] }
|
|
6
|
+
*
|
|
7
|
+
* Surfacing `detail` (and `title` when present) gives the model a focused
|
|
8
|
+
* message like `422 - password mismatch` instead of the full ~500-char body
|
|
9
|
+
* dump that previously made it hard for the LLM to reason about the failure.
|
|
10
|
+
*
|
|
11
|
+
* Exported for unit testing.
|
|
12
|
+
*/
|
|
13
|
+
export declare function parseBoondErrorBody(body: string): string | null;
|
|
14
|
+
/**
|
|
15
|
+
* What a caller can do about a BoondManager 401, which depends on who holds
|
|
16
|
+
* the credentials (issue #234). Under the HTTP OAuth transport the token
|
|
17
|
+
* belongs to the MCP client, and the only remedy is a new authorization —
|
|
18
|
+
* there is no CLI to re-run (the historical hint named one that is no longer
|
|
19
|
+
* shipped). With env credentials it is the operator's configuration.
|
|
20
|
+
*/
|
|
21
|
+
export declare function hintForUnauthorized(): string;
|
|
22
|
+
/**
|
|
23
|
+
* A non-2xx answer from BoondManager, with the status kept as data so callers
|
|
24
|
+
* (the HTTP transport's token validation, #234) can branch on it instead of
|
|
25
|
+
* parsing the message. The message is unchanged from the plain `Error` this
|
|
26
|
+
* replaces — `formatApiError()` output.
|
|
27
|
+
*/
|
|
28
|
+
export declare class BoondApiError extends Error {
|
|
29
|
+
readonly status: number;
|
|
30
|
+
readonly method: string;
|
|
31
|
+
readonly path: string;
|
|
32
|
+
constructor(message: string, status: number, method: string, path: string);
|
|
33
|
+
}
|
|
34
|
+
/** Build the Error message for a non-2xx HTTP response. Exported for testing. */
|
|
35
|
+
export declare function formatApiError(status: number, statusText: string, method: string, path: string, body: string): string;
|
|
36
|
+
/** The error every helper raises when an attempt never completed within `BOOND_HTTP_TIMEOUT_MS`. */
|
|
37
|
+
export declare function timeoutError(timeoutMs: number, method: string, path: string, cause: unknown): Error;
|
|
38
|
+
/**
|
|
39
|
+
* A 2xx whose body is not JSON. Before #239 this surfaced as a bare
|
|
40
|
+
* `SyntaxError: Unexpected token '<'` with no endpoint — indistinguishable
|
|
41
|
+
* from a bug in the server — although it almost always means the request hit
|
|
42
|
+
* the application shell or a WAF page rather than the API.
|
|
43
|
+
*/
|
|
44
|
+
export declare function nonJsonResponseError(status: number, method: string, path: string, cause: unknown): Error;
|
|
45
|
+
//# sourceMappingURL=errors.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../../../src/services/http/errors.ts"],"names":[],"mappings":"AAOA;;;;;;;;;;;GAWG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAiC/D;AAED;;;;;;GAMG;AACH,wBAAgB,mBAAmB,IAAI,MAAM,CAS5C;AAED;;;;;GAKG;AACH,qBAAa,aAAc,SAAQ,KAAK;IACtC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;gBACV,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM;CAO1E;AAyDD,iFAAiF;AACjF,wBAAgB,cAAc,CAAC,MAAM,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CA0BrH;AAED,oGAAoG;AACpG,wBAAgB,YAAY,CAAC,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,GAAG,KAAK,CASnG;AAED;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,GAAG,KAAK,CASxG"}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error shaping for BoondManager responses: JSON:API error envelope parsing,
|
|
3
|
+
* Cloudflare block detection, status-specific hints and the typed
|
|
4
|
+
* `BoondApiError`. Pure functions — nothing here touches the network.
|
|
5
|
+
*/
|
|
6
|
+
import { oauthContext } from "../oauth.js";
|
|
7
|
+
/**
|
|
8
|
+
* Pull the human-readable bits out of a BoondManager error body.
|
|
9
|
+
*
|
|
10
|
+
* Boond returns JSON:API errors of the form:
|
|
11
|
+
* { "errors": [ { "status": "422", "code": "422", "detail": "...", "title": "..." } ] }
|
|
12
|
+
*
|
|
13
|
+
* Surfacing `detail` (and `title` when present) gives the model a focused
|
|
14
|
+
* message like `422 - password mismatch` instead of the full ~500-char body
|
|
15
|
+
* dump that previously made it hard for the LLM to reason about the failure.
|
|
16
|
+
*
|
|
17
|
+
* Exported for unit testing.
|
|
18
|
+
*/
|
|
19
|
+
export function parseBoondErrorBody(body) {
|
|
20
|
+
if (!body)
|
|
21
|
+
return null;
|
|
22
|
+
try {
|
|
23
|
+
const parsed = JSON.parse(body);
|
|
24
|
+
const errors = Array.isArray(parsed.errors) ? parsed.errors : [];
|
|
25
|
+
const messages = errors
|
|
26
|
+
.map((e) => {
|
|
27
|
+
const parts = [];
|
|
28
|
+
if (e.title && e.title !== e.detail)
|
|
29
|
+
parts.push(e.title);
|
|
30
|
+
if (e.detail)
|
|
31
|
+
parts.push(e.detail);
|
|
32
|
+
else if (e.code)
|
|
33
|
+
parts.push(`code ${e.code}`);
|
|
34
|
+
// Boond's JSON:API errors put the offending query/body field in
|
|
35
|
+
// source.parameter (or source.pointer). Surfacing it turns the
|
|
36
|
+
// otherwise-opaque "1017 - Missing required attribute" into
|
|
37
|
+
// "1017 - Missing required attribute (parameter: startMonth)".
|
|
38
|
+
const ref = e.source?.parameter ?? e.source?.pointer;
|
|
39
|
+
const head = parts.join(": ").trim();
|
|
40
|
+
if (!head)
|
|
41
|
+
return ref ? `parameter: ${ref}` : "";
|
|
42
|
+
return ref ? `${head} (parameter: ${ref})` : head;
|
|
43
|
+
})
|
|
44
|
+
.filter((m) => m.length > 0);
|
|
45
|
+
if (messages.length === 0)
|
|
46
|
+
return null;
|
|
47
|
+
return messages.join(" | ");
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* What a caller can do about a BoondManager 401, which depends on who holds
|
|
55
|
+
* the credentials (issue #234). Under the HTTP OAuth transport the token
|
|
56
|
+
* belongs to the MCP client, and the only remedy is a new authorization —
|
|
57
|
+
* there is no CLI to re-run (the historical hint named one that is no longer
|
|
58
|
+
* shipped). With env credentials it is the operator's configuration.
|
|
59
|
+
*/
|
|
60
|
+
export function hintForUnauthorized() {
|
|
61
|
+
if (oauthContext.getStore()) {
|
|
62
|
+
return ("The BoondManager access token was rejected (expired or revoked). Re-authorize the connector from the MCP client. " +
|
|
63
|
+
'With MCP_HTTP_VALIDATE_TOKEN=true the server answers the next request with HTTP 401 + `error="invalid_token"`, ' +
|
|
64
|
+
"which makes spec-compliant clients restart the OAuth flow on their own; otherwise disconnect and reconnect the server in the client.");
|
|
65
|
+
}
|
|
66
|
+
return "Authentication failed. Verify BOOND_USER_TOKEN + BOOND_CLIENT_TOKEN + BOOND_CLIENT_KEY (or BOOND_API_TOKEN, or BOOND_USER + BOOND_PASSWORD).";
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* A non-2xx answer from BoondManager, with the status kept as data so callers
|
|
70
|
+
* (the HTTP transport's token validation, #234) can branch on it instead of
|
|
71
|
+
* parsing the message. The message is unchanged from the plain `Error` this
|
|
72
|
+
* replaces — `formatApiError()` output.
|
|
73
|
+
*/
|
|
74
|
+
export class BoondApiError extends Error {
|
|
75
|
+
status;
|
|
76
|
+
method;
|
|
77
|
+
path;
|
|
78
|
+
constructor(message, status, method, path) {
|
|
79
|
+
super(message);
|
|
80
|
+
this.name = "BoondApiError";
|
|
81
|
+
this.status = status;
|
|
82
|
+
this.method = method;
|
|
83
|
+
this.path = path;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/** Status-specific hint to help the LLM (or human) recover from common failures. */
|
|
87
|
+
function hintForStatus(status) {
|
|
88
|
+
switch (status) {
|
|
89
|
+
case 400:
|
|
90
|
+
return "Check the request body or query parameters — likely a malformed field.";
|
|
91
|
+
case 401:
|
|
92
|
+
return hintForUnauthorized();
|
|
93
|
+
case 403:
|
|
94
|
+
return "Authenticated, but the user lacks permission for this endpoint or scope.";
|
|
95
|
+
case 404:
|
|
96
|
+
return "Endpoint or entity not found. Double-check the id and the API path.";
|
|
97
|
+
case 422:
|
|
98
|
+
return "Unprocessable: typically wrong credentials (the API returns 422 for password mismatch) or a query parameter the API rejects.";
|
|
99
|
+
case 429:
|
|
100
|
+
return "Rate-limited. Back off and retry after a few seconds.";
|
|
101
|
+
default:
|
|
102
|
+
if (status >= 500)
|
|
103
|
+
return "BoondManager-side error. Retrying after a short delay usually helps.";
|
|
104
|
+
return "Check your credentials and permissions for this endpoint.";
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Detect whether the response body looks like a Cloudflare WAF challenge or
|
|
109
|
+
* block page rather than a BoondManager JSON:API response. When this is true,
|
|
110
|
+
* the upstream service is unreachable and the JSON:API hint above is
|
|
111
|
+
* misleading — the request never reached BoondManager.
|
|
112
|
+
*/
|
|
113
|
+
function containsCloudflareChallengeHost(htmlSnippet) {
|
|
114
|
+
const urlMatches = htmlSnippet.match(/https?:\/\/[^\s"'<>]+/gi) ?? [];
|
|
115
|
+
for (const rawUrl of urlMatches) {
|
|
116
|
+
try {
|
|
117
|
+
const hostname = new URL(rawUrl).hostname.toLowerCase();
|
|
118
|
+
if (hostname === "challenges.cloudflare.com" || hostname.endsWith(".challenges.cloudflare.com")) {
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
// Ignore unparsable URL fragments in HTML.
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
function looksLikeCloudflareBlock(body) {
|
|
129
|
+
if (!body)
|
|
130
|
+
return false;
|
|
131
|
+
const head = body.slice(0, 1000).toLowerCase();
|
|
132
|
+
if (!head.includes("<!doctype html") && !head.includes("<html"))
|
|
133
|
+
return false;
|
|
134
|
+
return (head.includes("cloudflare") ||
|
|
135
|
+
head.includes("attention required") ||
|
|
136
|
+
head.includes("just a moment") ||
|
|
137
|
+
head.includes("cf-ray") ||
|
|
138
|
+
containsCloudflareChallengeHost(head));
|
|
139
|
+
}
|
|
140
|
+
/** Build the Error message for a non-2xx HTTP response. Exported for testing. */
|
|
141
|
+
export function formatApiError(status, statusText, method, path, body) {
|
|
142
|
+
const detail = parseBoondErrorBody(body);
|
|
143
|
+
const cloudflareBlocked = looksLikeCloudflareBlock(body);
|
|
144
|
+
const headline = cloudflareBlocked
|
|
145
|
+
? `BoondManager API ${status} ${statusText} — request blocked by Cloudflare WAF before reaching the API`
|
|
146
|
+
: detail
|
|
147
|
+
? `BoondManager API ${status} ${statusText}: ${detail}`
|
|
148
|
+
: `BoondManager API ${status} ${statusText}`;
|
|
149
|
+
const lines = [headline, `Endpoint: ${method} ${path}`];
|
|
150
|
+
// Only attach the raw body when we couldn't extract a structured detail
|
|
151
|
+
// and we don't already know it's a Cloudflare HTML page — in either case
|
|
152
|
+
// the raw HTML/error chunk just buries the useful message.
|
|
153
|
+
if (!detail && !cloudflareBlocked && body) {
|
|
154
|
+
const trimmed = body.length > 500 ? body.slice(0, 500) + "…" : body;
|
|
155
|
+
lines.push(`Body: ${trimmed}`);
|
|
156
|
+
}
|
|
157
|
+
if (cloudflareBlocked) {
|
|
158
|
+
lines.push("Hint: The BoondManager edge (Cloudflare) blocked this request. " +
|
|
159
|
+
"This often means the endpoint is restricted on this tenant, or you've made too many calls in a short window. " +
|
|
160
|
+
"Wait a few seconds and retry; if it persists, the endpoint is not enabled for this account.");
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
lines.push(`Hint: ${hintForStatus(status)}`);
|
|
164
|
+
}
|
|
165
|
+
return lines.join("\n");
|
|
166
|
+
}
|
|
167
|
+
/** The error every helper raises when an attempt never completed within `BOOND_HTTP_TIMEOUT_MS`. */
|
|
168
|
+
export function timeoutError(timeoutMs, method, path, cause) {
|
|
169
|
+
return new Error([
|
|
170
|
+
`BoondManager API request timed out after ${timeoutMs}ms`,
|
|
171
|
+
`Endpoint: ${method} ${path}`,
|
|
172
|
+
"Hint: Increase BOOND_HTTP_TIMEOUT_MS or check connectivity to the BoondManager API.",
|
|
173
|
+
].join("\n"), { cause });
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* A 2xx whose body is not JSON. Before #239 this surfaced as a bare
|
|
177
|
+
* `SyntaxError: Unexpected token '<'` with no endpoint — indistinguishable
|
|
178
|
+
* from a bug in the server — although it almost always means the request hit
|
|
179
|
+
* the application shell or a WAF page rather than the API.
|
|
180
|
+
*/
|
|
181
|
+
export function nonJsonResponseError(status, method, path, cause) {
|
|
182
|
+
return new Error([
|
|
183
|
+
`BoondManager API answered HTTP ${status} with a body that is not JSON.`,
|
|
184
|
+
`Endpoint: ${method} ${path}`,
|
|
185
|
+
"Hint: The response is probably an HTML page (application shell, Cloudflare challenge). Check the path and the tenant's access to this endpoint.",
|
|
186
|
+
].join("\n"), { cause });
|
|
187
|
+
}
|
|
188
|
+
//# sourceMappingURL=errors.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"errors.js","sourceRoot":"","sources":["../../../src/services/http/errors.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { TokenBucket } from "../rate-limiter.js";
|
|
2
|
+
export interface RateLimitConfig {
|
|
3
|
+
rps: number;
|
|
4
|
+
burst: number;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Read rate-limit env vars. `rps` of 0 (or non-numeric) disables rate
|
|
8
|
+
* limiting entirely. `burst` falls back to `rps * 2` when unset, mirroring
|
|
9
|
+
* the documented default behaviour. Exported for unit testing.
|
|
10
|
+
*/
|
|
11
|
+
export declare function resolveRateLimitConfig(): RateLimitConfig | null;
|
|
12
|
+
/**
|
|
13
|
+
* Upper bound on live per-identity buckets. A bucket is a few numbers, so
|
|
14
|
+
* 500 idle users cost nothing measurable; the cap exists so an attacker
|
|
15
|
+
* cycling tokens cannot grow the map without bound. Eviction is LRU: an
|
|
16
|
+
* evicted identity simply starts again with a full bucket.
|
|
17
|
+
*/
|
|
18
|
+
export declare const MAX_RATE_LIMIT_BUCKETS = 500;
|
|
19
|
+
/**
|
|
20
|
+
* The token bucket for the *current* caller (issue #232).
|
|
21
|
+
*
|
|
22
|
+
* One bucket per process was right for stdio (one user) and wrong for the
|
|
23
|
+
* HTTP OAuth transport, where every request may belong to a different user:
|
|
24
|
+
* a single client's burst throttled everyone else, and one buggy client
|
|
25
|
+
* could hold the whole deployment at the rate limit. Buckets are keyed by
|
|
26
|
+
* `currentAuthIdentity()` — `sha256(token)` in OAuth, the constant `env`
|
|
27
|
+
* identity on stdio / static auth, so those keep exactly one bucket.
|
|
28
|
+
*
|
|
29
|
+
* Note this is fairness *between* users of the same server, not a change to
|
|
30
|
+
* the ceiling BoondManager sees: `BOOND_HTTP_RATE_LIMIT_RPS` is per identity.
|
|
31
|
+
*/
|
|
32
|
+
export declare function getRateLimiter(): TokenBucket | null;
|
|
33
|
+
/** Number of live per-identity buckets. Exposed for tests. */
|
|
34
|
+
export declare function rateLimiterBucketCountForTests(): number;
|
|
35
|
+
/**
|
|
36
|
+
* Reset the rate limiters so the next request re-reads env vars.
|
|
37
|
+
* Intended for tests that toggle `BOOND_HTTP_RATE_LIMIT_*` between cases.
|
|
38
|
+
*/
|
|
39
|
+
export declare function resetRateLimiterForTests(): void;
|
|
40
|
+
//# sourceMappingURL=rate-limit.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"rate-limit.d.ts","sourceRoot":"","sources":["../../../src/services/http/rate-limit.ts"],"names":[],"mappings":"AAQA,OAAO,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AAEjD,MAAM,WAAW,eAAe;IAC9B,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;CACf;AAED;;;;GAIG;AACH,wBAAgB,sBAAsB,IAAI,eAAe,GAAG,IAAI,CAa/D;AAED;;;;;GAKG;AACH,eAAO,MAAM,sBAAsB,MAAM,CAAC;AAO1C;;;;;;;;;;;;GAYG;AACH,wBAAgB,cAAc,IAAI,WAAW,GAAG,IAAI,CAqBnD;AAED,8DAA8D;AAC9D,wBAAgB,8BAA8B,IAAI,MAAM,CAEvD;AAED;;;GAGG;AACH,wBAAgB,wBAAwB,IAAI,IAAI,CAI/C"}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side rate limiting towards BoondManager: one token bucket per caller
|
|
3
|
+
* identity (issue #232), LRU-bounded. The bucket itself is `TokenBucket` in
|
|
4
|
+
* `services/rate-limiter.ts`; this module owns the configuration and the map.
|
|
5
|
+
*/
|
|
6
|
+
import { readString } from "../../config/env.js";
|
|
7
|
+
import { DEFAULT_HTTP_RATE_LIMIT_BURST, DEFAULT_HTTP_RATE_LIMIT_RPS } from "../../constants.js";
|
|
8
|
+
import { currentAuthIdentity } from "../oauth.js";
|
|
9
|
+
import { TokenBucket } from "../rate-limiter.js";
|
|
10
|
+
/**
|
|
11
|
+
* Read rate-limit env vars. `rps` of 0 (or non-numeric) disables rate
|
|
12
|
+
* limiting entirely. `burst` falls back to `rps * 2` when unset, mirroring
|
|
13
|
+
* the documented default behaviour. Exported for unit testing.
|
|
14
|
+
*/
|
|
15
|
+
export function resolveRateLimitConfig() {
|
|
16
|
+
const rpsRaw = readString("BOOND_HTTP_RATE_LIMIT_RPS");
|
|
17
|
+
const rps = rpsRaw === undefined ? DEFAULT_HTTP_RATE_LIMIT_RPS : Number(rpsRaw);
|
|
18
|
+
if (!Number.isFinite(rps) || rps <= 0)
|
|
19
|
+
return null;
|
|
20
|
+
const burstRaw = readString("BOOND_HTTP_RATE_LIMIT_BURST");
|
|
21
|
+
let burst;
|
|
22
|
+
if (burstRaw === undefined) {
|
|
23
|
+
burst = rpsRaw === undefined ? DEFAULT_HTTP_RATE_LIMIT_BURST : Math.max(1, Math.ceil(rps));
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
const parsed = Number(burstRaw);
|
|
27
|
+
burst = Number.isFinite(parsed) && parsed >= 1 ? Math.floor(parsed) : Math.max(1, Math.ceil(rps));
|
|
28
|
+
}
|
|
29
|
+
return { rps, burst };
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Upper bound on live per-identity buckets. A bucket is a few numbers, so
|
|
33
|
+
* 500 idle users cost nothing measurable; the cap exists so an attacker
|
|
34
|
+
* cycling tokens cannot grow the map without bound. Eviction is LRU: an
|
|
35
|
+
* evicted identity simply starts again with a full bucket.
|
|
36
|
+
*/
|
|
37
|
+
export const MAX_RATE_LIMIT_BUCKETS = 500;
|
|
38
|
+
let rateLimitConfig = null;
|
|
39
|
+
let rateLimitConfigResolved = false;
|
|
40
|
+
/** Insertion-ordered map used as an LRU: a hit re-inserts, an insert past the cap evicts the oldest. */
|
|
41
|
+
const rateLimiters = new Map();
|
|
42
|
+
/**
|
|
43
|
+
* The token bucket for the *current* caller (issue #232).
|
|
44
|
+
*
|
|
45
|
+
* One bucket per process was right for stdio (one user) and wrong for the
|
|
46
|
+
* HTTP OAuth transport, where every request may belong to a different user:
|
|
47
|
+
* a single client's burst throttled everyone else, and one buggy client
|
|
48
|
+
* could hold the whole deployment at the rate limit. Buckets are keyed by
|
|
49
|
+
* `currentAuthIdentity()` — `sha256(token)` in OAuth, the constant `env`
|
|
50
|
+
* identity on stdio / static auth, so those keep exactly one bucket.
|
|
51
|
+
*
|
|
52
|
+
* Note this is fairness *between* users of the same server, not a change to
|
|
53
|
+
* the ceiling BoondManager sees: `BOOND_HTTP_RATE_LIMIT_RPS` is per identity.
|
|
54
|
+
*/
|
|
55
|
+
export function getRateLimiter() {
|
|
56
|
+
if (!rateLimitConfigResolved) {
|
|
57
|
+
rateLimitConfig = resolveRateLimitConfig();
|
|
58
|
+
rateLimitConfigResolved = true;
|
|
59
|
+
}
|
|
60
|
+
if (!rateLimitConfig)
|
|
61
|
+
return null;
|
|
62
|
+
const key = currentAuthIdentity();
|
|
63
|
+
const existing = rateLimiters.get(key);
|
|
64
|
+
if (existing) {
|
|
65
|
+
rateLimiters.delete(key);
|
|
66
|
+
rateLimiters.set(key, existing);
|
|
67
|
+
return existing;
|
|
68
|
+
}
|
|
69
|
+
const bucket = new TokenBucket(rateLimitConfig.burst, rateLimitConfig.rps);
|
|
70
|
+
rateLimiters.set(key, bucket);
|
|
71
|
+
while (rateLimiters.size > MAX_RATE_LIMIT_BUCKETS) {
|
|
72
|
+
const oldest = rateLimiters.keys().next().value;
|
|
73
|
+
if (oldest === undefined)
|
|
74
|
+
break;
|
|
75
|
+
rateLimiters.delete(oldest);
|
|
76
|
+
}
|
|
77
|
+
return bucket;
|
|
78
|
+
}
|
|
79
|
+
/** Number of live per-identity buckets. Exposed for tests. */
|
|
80
|
+
export function rateLimiterBucketCountForTests() {
|
|
81
|
+
return rateLimiters.size;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Reset the rate limiters so the next request re-reads env vars.
|
|
85
|
+
* Intended for tests that toggle `BOOND_HTTP_RATE_LIMIT_*` between cases.
|
|
86
|
+
*/
|
|
87
|
+
export function resetRateLimiterForTests() {
|
|
88
|
+
rateLimiters.clear();
|
|
89
|
+
rateLimitConfig = null;
|
|
90
|
+
rateLimitConfigResolved = false;
|
|
91
|
+
}
|
|
92
|
+
//# sourceMappingURL=rate-limit.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"rate-limit.js","sourceRoot":"","sources":["../../../src/services/http/rate-limit.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
export interface RetryConfig {
|
|
2
|
+
maxRetries: number;
|
|
3
|
+
baseDelayMs: number;
|
|
4
|
+
maxDelayMs: number;
|
|
5
|
+
}
|
|
6
|
+
/** Resolve retry configuration from env, with safe fallbacks. Exported for tests. */
|
|
7
|
+
export declare function resolveRetryConfig(): RetryConfig;
|
|
8
|
+
/**
|
|
9
|
+
* Decide whether a failed attempt is worth retrying.
|
|
10
|
+
*
|
|
11
|
+
* Retry policy is intentionally conservative for non-idempotent verbs to avoid
|
|
12
|
+
* silently duplicating writes when the server's response was lost or delayed:
|
|
13
|
+
* - 429 (Too Many Requests) is always retried — the server explicitly
|
|
14
|
+
* rejected the request before processing it, so it is safe regardless of
|
|
15
|
+
* verb.
|
|
16
|
+
* - For GET only, 5xx responses, network failures, and timeouts are retried
|
|
17
|
+
* because GET is idempotent.
|
|
18
|
+
* - 4xx responses (other than 429) are never retried — the client must change
|
|
19
|
+
* the request before another attempt makes sense.
|
|
20
|
+
*
|
|
21
|
+
* Exported for unit testing.
|
|
22
|
+
*/
|
|
23
|
+
export declare function isRetryable(method: string, status: number | undefined, isNetworkOrTimeout: boolean): boolean;
|
|
24
|
+
/**
|
|
25
|
+
* Parse a `Retry-After` header value into milliseconds.
|
|
26
|
+
*
|
|
27
|
+
* Accepts either a non-negative number of seconds or an HTTP-date. Returns
|
|
28
|
+
* null when the value is absent or unparseable. Negative computed delays are
|
|
29
|
+
* clamped to 0. Exported for unit testing.
|
|
30
|
+
*/
|
|
31
|
+
export declare function parseRetryAfter(value: string | null, now?: number): number | null;
|
|
32
|
+
/**
|
|
33
|
+
* Compute the next backoff delay using full jitter:
|
|
34
|
+
* delay = random(0, min(maxMs, baseMs * 2^attempt))
|
|
35
|
+
*
|
|
36
|
+
* Full jitter (vs. exponential-only) reduces thundering-herd risk when many
|
|
37
|
+
* clients retry in lockstep. Exported for unit testing.
|
|
38
|
+
*/
|
|
39
|
+
export declare function computeBackoffMs(attempt: number, baseMs: number, maxMs: number, random?: () => number): number;
|
|
40
|
+
/**
|
|
41
|
+
* Backoff sleep. With a `signal`, the wait ends the moment it fires — a
|
|
42
|
+
* cancelled caller does not sit out a 5 s `Retry-After` (issue #231); the
|
|
43
|
+
* rejection carries the signal's reason.
|
|
44
|
+
*/
|
|
45
|
+
export declare function sleep(ms: number, signal?: AbortSignal): Promise<void>;
|
|
46
|
+
//# sourceMappingURL=retry.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"retry.d.ts","sourceRoot":"","sources":["../../../src/services/http/retry.ts"],"names":[],"mappings":"AAQA,MAAM,WAAW,WAAW;IAC1B,UAAU,EAAE,MAAM,CAAC;IACnB,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,qFAAqF;AACrF,wBAAgB,kBAAkB,IAAI,WAAW,CAMhD;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,WAAW,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,SAAS,EAAE,kBAAkB,EAAE,OAAO,GAAG,OAAO,CAM5G;AAED;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,EAAE,GAAG,GAAE,MAAmB,GAAG,MAAM,GAAG,IAAI,CAc7F;AAED;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAC9B,OAAO,EAAE,MAAM,EACf,MAAM,EAAE,MAAM,EACd,KAAK,EAAE,MAAM,EACb,MAAM,GAAE,MAAM,MAAoB,GACjC,MAAM,CAIR;AAED;;;;GAIG;AACH,wBAAgB,KAAK,CAAC,EAAE,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC,CAcrE"}
|