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
|
@@ -1,1170 +1,31 @@
|
|
|
1
|
-
import { createHmac } from "crypto";
|
|
2
|
-
import { DEFAULT_BASE_URL, CHARACTER_LIMIT, DEFAULT_PAGE_SIZE, ROUTE_MAX_RESULTS, DEFAULT_MAX_RESULTS, DEFAULT_HTTP_TIMEOUT_MS, DEFAULT_HTTP_MAX_RETRIES, DEFAULT_HTTP_RETRY_BASE_MS, DEFAULT_HTTP_RETRY_MAX_MS, DEFAULT_HTTP_RATE_LIMIT_RPS, DEFAULT_HTTP_RATE_LIMIT_BURST, } from "../constants.js";
|
|
3
|
-
import { TokenBucket } from "./rate-limiter.js";
|
|
4
|
-
import { oauthContext } from "./oauth.js";
|
|
5
|
-
let config = null;
|
|
6
1
|
/**
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
export
|
|
35
|
-
|
|
36
|
-
const claims = { userToken, clientToken };
|
|
37
|
-
if (options?.expiresInSeconds && options.expiresInSeconds > 0) {
|
|
38
|
-
const now = options.nowSeconds ?? Math.floor(Date.now() / 1000);
|
|
39
|
-
claims.iat = now;
|
|
40
|
-
claims.exp = now + options.expiresInSeconds;
|
|
41
|
-
}
|
|
42
|
-
const payload = base64url(JSON.stringify(claims));
|
|
43
|
-
const signature = base64url(createHmac("sha256", clientKey).update(`${header}.${payload}`).digest());
|
|
44
|
-
return `${header}.${payload}.${signature}`;
|
|
45
|
-
}
|
|
46
|
-
/**
|
|
47
|
-
* Return the env value if it is a real user-supplied value, or undefined otherwise.
|
|
48
|
-
*
|
|
49
|
-
* "Real" excludes three things a config form can produce for an option the user
|
|
50
|
-
* left alone — and the MCPB extension and the Claude Code plugin both build
|
|
51
|
-
* their env block by substituting `${user_config.*}` into every var, so all
|
|
52
|
-
* fourteen are always *defined*:
|
|
53
|
-
*
|
|
54
|
-
* - `""` — an untouched optional field;
|
|
55
|
-
* - whitespace only — a field that got a stray space or a pasted newline
|
|
56
|
-
* (`BOOND_BASE_URL=" "` would otherwise become the request base URL);
|
|
57
|
-
* - `"${…}"` — a placeholder no host resolved.
|
|
58
|
-
*
|
|
59
|
-
* All three must read as "not configured" so the defaults apply. Same rule as
|
|
60
|
-
* `readEnv` in `config/access-policy.ts` and `config/dictionary-overrides.ts`.
|
|
61
|
-
*/
|
|
62
|
-
function envOrUndefined(key) {
|
|
63
|
-
const v = process.env[key];
|
|
64
|
-
if (!v || v.startsWith("${") || v.trim().length === 0)
|
|
65
|
-
return undefined;
|
|
66
|
-
return v;
|
|
67
|
-
}
|
|
68
|
-
export const JWT_HEADER_NAME = "X-Jwt-Client-Boondmanager";
|
|
69
|
-
/**
|
|
70
|
-
* Wrap a static header pair in the dynamic AuthProvider contract.
|
|
71
|
-
* Used by the stdio transport, which sticks to the JWT / BasicAuth paths.
|
|
72
|
-
*/
|
|
73
|
-
function staticAuth(name, value) {
|
|
74
|
-
const cached = Promise.resolve({ name, value });
|
|
75
|
-
return () => cached;
|
|
76
|
-
}
|
|
77
|
-
/**
|
|
78
|
-
* JWT auth provider. When `ttlSeconds` is set (via BOOND_JWT_TTL_SECONDS), a
|
|
79
|
-
* fresh token with `iat`/`exp` is minted per request so a leaked token expires;
|
|
80
|
-
* otherwise the token is built once and cached (legacy, never-expiring).
|
|
81
|
-
*/
|
|
82
|
-
function jwtAuth(userToken, clientToken, clientKey, ttlSeconds) {
|
|
83
|
-
if (!ttlSeconds || ttlSeconds <= 0) {
|
|
84
|
-
return staticAuth(JWT_HEADER_NAME, buildJwt(userToken, clientToken, clientKey));
|
|
85
|
-
}
|
|
86
|
-
return () => Promise.resolve({
|
|
87
|
-
name: JWT_HEADER_NAME,
|
|
88
|
-
value: buildJwt(userToken, clientToken, clientKey, { expiresInSeconds: ttlSeconds }),
|
|
89
|
-
});
|
|
90
|
-
}
|
|
91
|
-
export function initClient() {
|
|
92
|
-
const baseUrl = envOrUndefined("BOOND_BASE_URL") || DEFAULT_BASE_URL;
|
|
93
|
-
// Auth priority (stdio transport):
|
|
94
|
-
// 1. Build JWT from components (userToken + clientToken + clientKey)
|
|
95
|
-
// 2. Pre-built JWT token
|
|
96
|
-
// 3. BasicAuth (user:password)
|
|
97
|
-
//
|
|
98
|
-
// Per BoondManager's JWT spec the token must travel in the
|
|
99
|
-
// `X-Jwt-Client-Boondmanager` header — sending it as `Authorization: Bearer`
|
|
100
|
-
// makes the API reject the request with 422 "Signature verification failed".
|
|
101
|
-
// BasicAuth, on the other hand, uses the standard `Authorization` header.
|
|
102
|
-
//
|
|
103
|
-
// HTTP transport uses OAuth2 exclusively — see `initClientWithAuth`.
|
|
104
|
-
const userToken = envOrUndefined("BOOND_USER_TOKEN");
|
|
105
|
-
const clientToken = envOrUndefined("BOOND_CLIENT_TOKEN");
|
|
106
|
-
const clientKey = envOrUndefined("BOOND_CLIENT_KEY");
|
|
107
|
-
const token = envOrUndefined("BOOND_API_TOKEN");
|
|
108
|
-
const user = envOrUndefined("BOOND_USER");
|
|
109
|
-
const password = envOrUndefined("BOOND_PASSWORD");
|
|
110
|
-
let auth;
|
|
111
|
-
if (userToken && clientToken && clientKey) {
|
|
112
|
-
const ttlRaw = envOrUndefined("BOOND_JWT_TTL_SECONDS");
|
|
113
|
-
const ttlSeconds = ttlRaw ? Number(ttlRaw) : undefined;
|
|
114
|
-
auth = jwtAuth(userToken, clientToken, clientKey, Number.isFinite(ttlSeconds) ? ttlSeconds : undefined);
|
|
115
|
-
}
|
|
116
|
-
else if (token) {
|
|
117
|
-
auth = staticAuth(JWT_HEADER_NAME, token);
|
|
118
|
-
}
|
|
119
|
-
else if (user && password) {
|
|
120
|
-
auth = staticAuth("Authorization", `Basic ${Buffer.from(`${user}:${password}`).toString("base64")}`);
|
|
121
|
-
}
|
|
122
|
-
else {
|
|
123
|
-
throw new Error("Authentication required. Set BOOND_USER_TOKEN + BOOND_CLIENT_TOKEN + BOOND_CLIENT_KEY, or BOOND_API_TOKEN, or both BOOND_USER and BOOND_PASSWORD.");
|
|
124
|
-
}
|
|
125
|
-
config = { baseUrl, auth };
|
|
126
|
-
}
|
|
127
|
-
/**
|
|
128
|
-
* True when env-based credentials (JWT components, API token, or BasicAuth) are
|
|
129
|
-
* configured. Used by the HTTP transport to decide whether static-auth mode is
|
|
130
|
-
* possible without attempting a full `initClient()` call.
|
|
131
|
-
*/
|
|
132
|
-
export function hasEnvCredentials() {
|
|
133
|
-
return !!((envOrUndefined("BOOND_USER_TOKEN") &&
|
|
134
|
-
envOrUndefined("BOOND_CLIENT_TOKEN") &&
|
|
135
|
-
envOrUndefined("BOOND_CLIENT_KEY")) ||
|
|
136
|
-
envOrUndefined("BOOND_API_TOKEN") ||
|
|
137
|
-
(envOrUndefined("BOOND_USER") && envOrUndefined("BOOND_PASSWORD")));
|
|
138
|
-
}
|
|
139
|
-
/**
|
|
140
|
-
* Install a custom auth provider — used by the HTTP transport bootstrap to
|
|
141
|
-
* wire in an OAuth2 token source (where the access token is refreshed
|
|
142
|
-
* transparently per request rather than baked in at startup).
|
|
143
|
-
*/
|
|
144
|
-
export function initClientWithAuth(auth, baseUrl) {
|
|
145
|
-
config = {
|
|
146
|
-
baseUrl: baseUrl ?? envOrUndefined("BOOND_BASE_URL") ?? DEFAULT_BASE_URL,
|
|
147
|
-
auth,
|
|
148
|
-
};
|
|
149
|
-
}
|
|
150
|
-
/** Test helper — reset the cached config so the next call re-initialises. */
|
|
151
|
-
export function resetClientForTests() {
|
|
152
|
-
config = null;
|
|
153
|
-
}
|
|
154
|
-
function getConfig() {
|
|
155
|
-
if (!config) {
|
|
156
|
-
initClient();
|
|
157
|
-
}
|
|
158
|
-
return config;
|
|
159
|
-
}
|
|
160
|
-
/**
|
|
161
|
-
* Pull the human-readable bits out of a BoondManager error body.
|
|
162
|
-
*
|
|
163
|
-
* Boond returns JSON:API errors of the form:
|
|
164
|
-
* { "errors": [ { "status": "422", "code": "422", "detail": "...", "title": "..." } ] }
|
|
165
|
-
*
|
|
166
|
-
* Surfacing `detail` (and `title` when present) gives the model a focused
|
|
167
|
-
* message like `422 - password mismatch` instead of the full ~500-char body
|
|
168
|
-
* dump that previously made it hard for the LLM to reason about the failure.
|
|
169
|
-
*
|
|
170
|
-
* Exported for unit testing.
|
|
171
|
-
*/
|
|
172
|
-
export function parseBoondErrorBody(body) {
|
|
173
|
-
if (!body)
|
|
174
|
-
return null;
|
|
175
|
-
try {
|
|
176
|
-
const parsed = JSON.parse(body);
|
|
177
|
-
const errors = Array.isArray(parsed.errors) ? parsed.errors : [];
|
|
178
|
-
const messages = errors
|
|
179
|
-
.map((e) => {
|
|
180
|
-
const parts = [];
|
|
181
|
-
if (e.title && e.title !== e.detail)
|
|
182
|
-
parts.push(e.title);
|
|
183
|
-
if (e.detail)
|
|
184
|
-
parts.push(e.detail);
|
|
185
|
-
else if (e.code)
|
|
186
|
-
parts.push(`code ${e.code}`);
|
|
187
|
-
// Boond's JSON:API errors put the offending query/body field in
|
|
188
|
-
// source.parameter (or source.pointer). Surfacing it turns the
|
|
189
|
-
// otherwise-opaque "1017 - Missing required attribute" into
|
|
190
|
-
// "1017 - Missing required attribute (parameter: startMonth)".
|
|
191
|
-
const ref = e.source?.parameter ?? e.source?.pointer;
|
|
192
|
-
const head = parts.join(": ").trim();
|
|
193
|
-
if (!head)
|
|
194
|
-
return ref ? `parameter: ${ref}` : "";
|
|
195
|
-
return ref ? `${head} (parameter: ${ref})` : head;
|
|
196
|
-
})
|
|
197
|
-
.filter((m) => m.length > 0);
|
|
198
|
-
if (messages.length === 0)
|
|
199
|
-
return null;
|
|
200
|
-
return messages.join(" | ");
|
|
201
|
-
}
|
|
202
|
-
catch {
|
|
203
|
-
return null;
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
/**
|
|
207
|
-
* Resolve the per-request HTTP timeout in milliseconds.
|
|
208
|
-
*
|
|
209
|
-
* Reads BOOND_HTTP_TIMEOUT_MS at call time so tests / runtime overrides take
|
|
210
|
-
* effect without restarting the process. Falls back to the default for
|
|
211
|
-
* unset, non-numeric, or non-positive values.
|
|
212
|
-
*
|
|
213
|
-
* Exported for unit testing.
|
|
214
|
-
*/
|
|
215
|
-
export function resolveTimeoutMs() {
|
|
216
|
-
const raw = envOrUndefined("BOOND_HTTP_TIMEOUT_MS");
|
|
217
|
-
if (!raw)
|
|
218
|
-
return DEFAULT_HTTP_TIMEOUT_MS;
|
|
219
|
-
const parsed = Number(raw);
|
|
220
|
-
if (!Number.isFinite(parsed) || parsed <= 0)
|
|
221
|
-
return DEFAULT_HTTP_TIMEOUT_MS;
|
|
222
|
-
return Math.floor(parsed);
|
|
223
|
-
}
|
|
224
|
-
/** True when an error from fetch() came from an AbortSignal firing. */
|
|
225
|
-
function isAbortError(err) {
|
|
226
|
-
if (!(err instanceof Error))
|
|
227
|
-
return false;
|
|
228
|
-
// AbortSignal.timeout() rejects with a DOMException whose name is "TimeoutError";
|
|
229
|
-
// generic aborts surface as "AbortError". Both indicate the request never
|
|
230
|
-
// completed end-to-end and should be reported as a timeout.
|
|
231
|
-
return err.name === "TimeoutError" || err.name === "AbortError";
|
|
232
|
-
}
|
|
233
|
-
function readPositiveInt(name, fallback, allowZero = false) {
|
|
234
|
-
const raw = envOrUndefined(name);
|
|
235
|
-
if (!raw)
|
|
236
|
-
return fallback;
|
|
237
|
-
const parsed = Number(raw);
|
|
238
|
-
if (!Number.isFinite(parsed))
|
|
239
|
-
return fallback;
|
|
240
|
-
if (parsed < 0)
|
|
241
|
-
return fallback;
|
|
242
|
-
if (parsed === 0 && !allowZero)
|
|
243
|
-
return fallback;
|
|
244
|
-
return Math.floor(parsed);
|
|
245
|
-
}
|
|
246
|
-
/** Resolve retry configuration from env, with safe fallbacks. Exported for tests. */
|
|
247
|
-
export function resolveRetryConfig() {
|
|
248
|
-
return {
|
|
249
|
-
maxRetries: readPositiveInt("BOOND_HTTP_MAX_RETRIES", DEFAULT_HTTP_MAX_RETRIES, true),
|
|
250
|
-
baseDelayMs: readPositiveInt("BOOND_HTTP_RETRY_BASE_MS", DEFAULT_HTTP_RETRY_BASE_MS),
|
|
251
|
-
maxDelayMs: readPositiveInt("BOOND_HTTP_RETRY_MAX_MS", DEFAULT_HTTP_RETRY_MAX_MS),
|
|
252
|
-
};
|
|
253
|
-
}
|
|
254
|
-
/**
|
|
255
|
-
* Decide whether a failed attempt is worth retrying.
|
|
256
|
-
*
|
|
257
|
-
* Retry policy is intentionally conservative for non-idempotent verbs to avoid
|
|
258
|
-
* silently duplicating writes when the server's response was lost or delayed:
|
|
259
|
-
* - 429 (Too Many Requests) is always retried — the server explicitly
|
|
260
|
-
* rejected the request before processing it, so it is safe regardless of
|
|
261
|
-
* verb.
|
|
262
|
-
* - For GET only, 5xx responses, network failures, and timeouts are retried
|
|
263
|
-
* because GET is idempotent.
|
|
264
|
-
* - 4xx responses (other than 429) are never retried — the client must change
|
|
265
|
-
* the request before another attempt makes sense.
|
|
266
|
-
*
|
|
267
|
-
* Exported for unit testing.
|
|
268
|
-
*/
|
|
269
|
-
export function isRetryable(method, status, isNetworkOrTimeout) {
|
|
270
|
-
if (status === 429)
|
|
271
|
-
return true;
|
|
272
|
-
if (method !== "GET")
|
|
273
|
-
return false;
|
|
274
|
-
if (isNetworkOrTimeout)
|
|
275
|
-
return true;
|
|
276
|
-
if (status !== undefined && status >= 500 && status < 600)
|
|
277
|
-
return true;
|
|
278
|
-
return false;
|
|
279
|
-
}
|
|
280
|
-
/**
|
|
281
|
-
* Parse a `Retry-After` header value into milliseconds.
|
|
282
|
-
*
|
|
283
|
-
* Accepts either a non-negative number of seconds or an HTTP-date. Returns
|
|
284
|
-
* null when the value is absent or unparseable. Negative computed delays are
|
|
285
|
-
* clamped to 0. Exported for unit testing.
|
|
286
|
-
*/
|
|
287
|
-
export function parseRetryAfter(value, now = Date.now()) {
|
|
288
|
-
if (!value)
|
|
289
|
-
return null;
|
|
290
|
-
const trimmed = value.trim();
|
|
291
|
-
if (trimmed === "")
|
|
292
|
-
return null;
|
|
293
|
-
const seconds = Number(trimmed);
|
|
294
|
-
if (Number.isFinite(seconds)) {
|
|
295
|
-
// Numeric form is authoritative once we recognise it as a number — falling
|
|
296
|
-
// through to Date.parse on a negative/odd numeric would silently produce
|
|
297
|
-
// weird timestamps (e.g. Date.parse("-1") → year -1).
|
|
298
|
-
return seconds >= 0 ? Math.floor(seconds * 1000) : null;
|
|
299
|
-
}
|
|
300
|
-
const date = Date.parse(trimmed);
|
|
301
|
-
if (!Number.isNaN(date))
|
|
302
|
-
return Math.max(0, date - now);
|
|
303
|
-
return null;
|
|
304
|
-
}
|
|
305
|
-
/**
|
|
306
|
-
* Compute the next backoff delay using full jitter:
|
|
307
|
-
* delay = random(0, min(maxMs, baseMs * 2^attempt))
|
|
308
|
-
*
|
|
309
|
-
* Full jitter (vs. exponential-only) reduces thundering-herd risk when many
|
|
310
|
-
* clients retry in lockstep. Exported for unit testing.
|
|
311
|
-
*/
|
|
312
|
-
export function computeBackoffMs(attempt, baseMs, maxMs, random = Math.random) {
|
|
313
|
-
const exp = baseMs * 2 ** attempt;
|
|
314
|
-
const capped = Math.min(maxMs, exp);
|
|
315
|
-
return Math.floor(random() * capped);
|
|
316
|
-
}
|
|
317
|
-
function sleep(ms) {
|
|
318
|
-
if (ms <= 0)
|
|
319
|
-
return Promise.resolve();
|
|
320
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
321
|
-
}
|
|
322
|
-
/**
|
|
323
|
-
* Read rate-limit env vars. `rps` of 0 (or non-numeric) disables rate
|
|
324
|
-
* limiting entirely. `burst` falls back to `rps * 2` when unset, mirroring
|
|
325
|
-
* the documented default behaviour. Exported for unit testing.
|
|
326
|
-
*/
|
|
327
|
-
export function resolveRateLimitConfig() {
|
|
328
|
-
const rpsRaw = envOrUndefined("BOOND_HTTP_RATE_LIMIT_RPS");
|
|
329
|
-
const rps = rpsRaw === undefined ? DEFAULT_HTTP_RATE_LIMIT_RPS : Number(rpsRaw);
|
|
330
|
-
if (!Number.isFinite(rps) || rps <= 0)
|
|
331
|
-
return null;
|
|
332
|
-
const burstRaw = envOrUndefined("BOOND_HTTP_RATE_LIMIT_BURST");
|
|
333
|
-
let burst;
|
|
334
|
-
if (burstRaw === undefined) {
|
|
335
|
-
burst = rpsRaw === undefined ? DEFAULT_HTTP_RATE_LIMIT_BURST : Math.max(1, Math.ceil(rps));
|
|
336
|
-
}
|
|
337
|
-
else {
|
|
338
|
-
const parsed = Number(burstRaw);
|
|
339
|
-
burst = Number.isFinite(parsed) && parsed >= 1 ? Math.floor(parsed) : Math.max(1, Math.ceil(rps));
|
|
340
|
-
}
|
|
341
|
-
return { rps, burst };
|
|
342
|
-
}
|
|
343
|
-
let rateLimiter = null;
|
|
344
|
-
let rateLimiterInitialised = false;
|
|
345
|
-
function getRateLimiter() {
|
|
346
|
-
if (rateLimiterInitialised)
|
|
347
|
-
return rateLimiter;
|
|
348
|
-
const config = resolveRateLimitConfig();
|
|
349
|
-
rateLimiter = config ? new TokenBucket(config.burst, config.rps) : null;
|
|
350
|
-
rateLimiterInitialised = true;
|
|
351
|
-
return rateLimiter;
|
|
352
|
-
}
|
|
353
|
-
/**
|
|
354
|
-
* Reset the cached rate limiter so the next request re-reads env vars.
|
|
355
|
-
* Intended for tests that toggle `BOOND_HTTP_RATE_LIMIT_*` between cases.
|
|
356
|
-
*/
|
|
357
|
-
export function resetRateLimiterForTests() {
|
|
358
|
-
rateLimiter = null;
|
|
359
|
-
rateLimiterInitialised = false;
|
|
360
|
-
}
|
|
361
|
-
/** Status-specific hint to help the LLM (or human) recover from common failures. */
|
|
362
|
-
function hintForStatus(status) {
|
|
363
|
-
switch (status) {
|
|
364
|
-
case 400:
|
|
365
|
-
return "Check the request body or query parameters — likely a malformed field.";
|
|
366
|
-
case 401:
|
|
367
|
-
return "Authentication failed. Verify BOOND_USER_TOKEN + BOOND_CLIENT_TOKEN + BOOND_CLIENT_KEY (or BOOND_API_TOKEN, or BOOND_USER + BOOND_PASSWORD). On HTTP transport, the OAuth access token may have expired — re-run boondmanager-mcp-oauth-login.";
|
|
368
|
-
case 403:
|
|
369
|
-
return "Authenticated, but the user lacks permission for this endpoint or scope.";
|
|
370
|
-
case 404:
|
|
371
|
-
return "Endpoint or entity not found. Double-check the id and the API path.";
|
|
372
|
-
case 422:
|
|
373
|
-
return "Unprocessable: typically wrong credentials (the API returns 422 for password mismatch) or a query parameter the API rejects.";
|
|
374
|
-
case 429:
|
|
375
|
-
return "Rate-limited. Back off and retry after a few seconds.";
|
|
376
|
-
default:
|
|
377
|
-
if (status >= 500)
|
|
378
|
-
return "BoondManager-side error. Retrying after a short delay usually helps.";
|
|
379
|
-
return "Check your credentials and permissions for this endpoint.";
|
|
380
|
-
}
|
|
381
|
-
}
|
|
382
|
-
/**
|
|
383
|
-
* Detect whether the response body looks like a Cloudflare WAF challenge or
|
|
384
|
-
* block page rather than a BoondManager JSON:API response. When this is true,
|
|
385
|
-
* the upstream service is unreachable and the JSON:API hint above is
|
|
386
|
-
* misleading — the request never reached BoondManager.
|
|
387
|
-
*/
|
|
388
|
-
function containsCloudflareChallengeHost(htmlSnippet) {
|
|
389
|
-
const urlMatches = htmlSnippet.match(/https?:\/\/[^\s"'<>]+/gi) ?? [];
|
|
390
|
-
for (const rawUrl of urlMatches) {
|
|
391
|
-
try {
|
|
392
|
-
const hostname = new URL(rawUrl).hostname.toLowerCase();
|
|
393
|
-
if (hostname === "challenges.cloudflare.com" || hostname.endsWith(".challenges.cloudflare.com")) {
|
|
394
|
-
return true;
|
|
395
|
-
}
|
|
396
|
-
}
|
|
397
|
-
catch {
|
|
398
|
-
// Ignore unparsable URL fragments in HTML.
|
|
399
|
-
}
|
|
400
|
-
}
|
|
401
|
-
return false;
|
|
402
|
-
}
|
|
403
|
-
function looksLikeCloudflareBlock(body) {
|
|
404
|
-
if (!body)
|
|
405
|
-
return false;
|
|
406
|
-
const head = body.slice(0, 1000).toLowerCase();
|
|
407
|
-
if (!head.includes("<!doctype html") && !head.includes("<html"))
|
|
408
|
-
return false;
|
|
409
|
-
return (head.includes("cloudflare") ||
|
|
410
|
-
head.includes("attention required") ||
|
|
411
|
-
head.includes("just a moment") ||
|
|
412
|
-
head.includes("cf-ray") ||
|
|
413
|
-
containsCloudflareChallengeHost(head));
|
|
414
|
-
}
|
|
415
|
-
/** Build the Error message for a non-2xx HTTP response. Exported for testing. */
|
|
416
|
-
export function formatApiError(status, statusText, method, path, body) {
|
|
417
|
-
const detail = parseBoondErrorBody(body);
|
|
418
|
-
const cloudflareBlocked = looksLikeCloudflareBlock(body);
|
|
419
|
-
const headline = cloudflareBlocked
|
|
420
|
-
? `BoondManager API ${status} ${statusText} — request blocked by Cloudflare WAF before reaching the API`
|
|
421
|
-
: detail
|
|
422
|
-
? `BoondManager API ${status} ${statusText}: ${detail}`
|
|
423
|
-
: `BoondManager API ${status} ${statusText}`;
|
|
424
|
-
const lines = [headline, `Endpoint: ${method} ${path}`];
|
|
425
|
-
// Only attach the raw body when we couldn't extract a structured detail
|
|
426
|
-
// and we don't already know it's a Cloudflare HTML page — in either case
|
|
427
|
-
// the raw HTML/error chunk just buries the useful message.
|
|
428
|
-
if (!detail && !cloudflareBlocked && body) {
|
|
429
|
-
const trimmed = body.length > 500 ? body.slice(0, 500) + "…" : body;
|
|
430
|
-
lines.push(`Body: ${trimmed}`);
|
|
431
|
-
}
|
|
432
|
-
if (cloudflareBlocked) {
|
|
433
|
-
lines.push("Hint: The BoondManager edge (Cloudflare) blocked this request. " +
|
|
434
|
-
"This often means the endpoint is restricted on this tenant, or you've made too many calls in a short window. " +
|
|
435
|
-
"Wait a few seconds and retry; if it persists, the endpoint is not enabled for this account.");
|
|
436
|
-
}
|
|
437
|
-
else {
|
|
438
|
-
lines.push(`Hint: ${hintForStatus(status)}`);
|
|
439
|
-
}
|
|
440
|
-
return lines.join("\n");
|
|
441
|
-
}
|
|
442
|
-
/**
|
|
443
|
-
* Defense-in-depth against path traversal / query injection through entity
|
|
444
|
-
* ids interpolated into API paths at ~40 call sites. Even though the id
|
|
445
|
-
* schemas are now numeric-only, a future tool could forget to validate, so we
|
|
446
|
-
* assert here that the path is well-formed: it must start with `/`, carry no
|
|
447
|
-
* query (`?`) or fragment (`#`) — those arrive via `queryParams`, never the
|
|
448
|
-
* path — and contain no traversal (`..`) or percent/backslash escapes. Built
|
|
449
|
-
* paths only ever combine static segments with numeric ids and hyphenated tab
|
|
450
|
-
* names, so this rejects nothing legitimate. Exported for unit testing.
|
|
451
|
-
*/
|
|
452
|
-
export function assertSafeApiPath(path) {
|
|
453
|
-
if (!path.startsWith("/")) {
|
|
454
|
-
throw new Error(`Invalid API path (must start with "/"): ${path}`);
|
|
455
|
-
}
|
|
456
|
-
// `?`/`#` would inject a query/fragment; `%`/`\` could encode a traversal;
|
|
457
|
-
// `..` is a literal traversal segment.
|
|
458
|
-
if (/[?#%\\]/.test(path) || path.includes("..")) {
|
|
459
|
-
throw new Error(`Unsafe API path rejected: ${path}`);
|
|
460
|
-
}
|
|
461
|
-
}
|
|
462
|
-
/**
|
|
463
|
-
* Validates `path` and resolves it against `baseUrl`, returning the
|
|
464
|
-
* constructed URL. Throws if the path is unsafe (see `assertSafeApiPath`) or
|
|
465
|
-
* if the resolved URL escapes the configured API base origin/path. Centralises
|
|
466
|
-
* the guard shared by apiRequest / apiDownload / apiUploadForm. Exported for
|
|
467
|
-
* unit testing.
|
|
468
|
-
*/
|
|
469
|
-
export function resolveApiUrl(baseUrl, path) {
|
|
470
|
-
assertSafeApiPath(path);
|
|
471
|
-
const url = new URL(`${baseUrl}${path}`);
|
|
472
|
-
// Belt-and-braces: confirm the constructed URL did not escape the API base
|
|
473
|
-
// origin/path despite the textual guard above.
|
|
474
|
-
const base = new URL(baseUrl);
|
|
475
|
-
if (url.origin !== base.origin || !url.pathname.startsWith(base.pathname)) {
|
|
476
|
-
throw new Error(`API path escaped the configured base URL: ${path}`);
|
|
477
|
-
}
|
|
478
|
-
return url;
|
|
479
|
-
}
|
|
480
|
-
export async function apiRequest(path, method = "GET", body, queryParams) {
|
|
481
|
-
const { baseUrl, auth } = getConfig();
|
|
482
|
-
const url = resolveApiUrl(baseUrl, path);
|
|
483
|
-
if (queryParams) {
|
|
484
|
-
for (const [key, value] of Object.entries(queryParams)) {
|
|
485
|
-
if (value === undefined || value === null)
|
|
486
|
-
continue;
|
|
487
|
-
if (Array.isArray(value)) {
|
|
488
|
-
// BoondManager expects repeated bracket notation: key[]=v1&key[]=v2
|
|
489
|
-
const bracketKey = key.endsWith("[]") ? key : `${key}[]`;
|
|
490
|
-
for (const v of value) {
|
|
491
|
-
if (v !== undefined && v !== null && v !== "") {
|
|
492
|
-
url.searchParams.append(bracketKey, String(v));
|
|
493
|
-
}
|
|
494
|
-
}
|
|
495
|
-
}
|
|
496
|
-
else {
|
|
497
|
-
url.searchParams.set(key, String(value));
|
|
498
|
-
}
|
|
499
|
-
}
|
|
500
|
-
}
|
|
501
|
-
const timeoutMs = resolveTimeoutMs();
|
|
502
|
-
const retry = resolveRetryConfig();
|
|
503
|
-
const totalAttempts = retry.maxRetries + 1;
|
|
504
|
-
const buildBody = () => body && (method === "POST" || method === "PUT" || method === "PATCH") ? JSON.stringify(body) : undefined;
|
|
505
|
-
const serializedBody = buildBody();
|
|
506
|
-
let lastError;
|
|
507
|
-
const limiter = getRateLimiter();
|
|
508
|
-
for (let attempt = 0; attempt < totalAttempts; attempt++) {
|
|
509
|
-
// Acquire a token before each attempt so retries also count toward the
|
|
510
|
-
// rate budget — this is what actually protects us from feedback loops
|
|
511
|
-
// (transient 5xx → retry → transient 5xx → …) saturating the API.
|
|
512
|
-
if (limiter)
|
|
513
|
-
await limiter.acquire();
|
|
514
|
-
// Resolve the auth header per-attempt so OAuth2 refreshes are picked
|
|
515
|
-
// up between retries (the access token may have expired since the
|
|
516
|
-
// previous attempt).
|
|
517
|
-
const authHeader = await auth();
|
|
518
|
-
const headers = {
|
|
519
|
-
[authHeader.name]: authHeader.value,
|
|
520
|
-
Accept: "application/json",
|
|
521
|
-
"Content-Type": "application/json",
|
|
522
|
-
};
|
|
523
|
-
const fetchOptions = {
|
|
524
|
-
method,
|
|
525
|
-
headers,
|
|
526
|
-
// Each attempt gets its own abort signal — once a signal has fired it
|
|
527
|
-
// can't be reused for the next try.
|
|
528
|
-
signal: AbortSignal.timeout(timeoutMs),
|
|
529
|
-
};
|
|
530
|
-
if (serializedBody !== undefined) {
|
|
531
|
-
fetchOptions.body = serializedBody;
|
|
532
|
-
}
|
|
533
|
-
let response;
|
|
534
|
-
let networkError;
|
|
535
|
-
try {
|
|
536
|
-
response = await fetch(url.toString(), fetchOptions);
|
|
537
|
-
}
|
|
538
|
-
catch (err) {
|
|
539
|
-
if (isAbortError(err)) {
|
|
540
|
-
networkError = new Error([
|
|
541
|
-
`BoondManager API request timed out after ${timeoutMs}ms`,
|
|
542
|
-
`Endpoint: ${method} ${path}`,
|
|
543
|
-
"Hint: Increase BOOND_HTTP_TIMEOUT_MS or check connectivity to the BoondManager API.",
|
|
544
|
-
].join("\n"), { cause: err });
|
|
545
|
-
}
|
|
546
|
-
else {
|
|
547
|
-
networkError = err instanceof Error ? err : new Error(String(err));
|
|
548
|
-
}
|
|
549
|
-
}
|
|
550
|
-
if (response && response.ok) {
|
|
551
|
-
// DELETE may return empty body
|
|
552
|
-
if (response.status === 204 || response.headers.get("content-length") === "0") {
|
|
553
|
-
return { data: [] };
|
|
554
|
-
}
|
|
555
|
-
return (await response.json());
|
|
556
|
-
}
|
|
557
|
-
let attemptError;
|
|
558
|
-
let retryAfterMs = null;
|
|
559
|
-
let isNetworkOrTimeout = false;
|
|
560
|
-
if (response) {
|
|
561
|
-
const errorText = await response.text().catch(() => "");
|
|
562
|
-
attemptError = new Error(formatApiError(response.status, response.statusText, method, path, errorText));
|
|
563
|
-
}
|
|
564
|
-
else {
|
|
565
|
-
attemptError = networkError;
|
|
566
|
-
isNetworkOrTimeout = true;
|
|
567
|
-
}
|
|
568
|
-
const hasMoreAttempts = attempt < totalAttempts - 1;
|
|
569
|
-
const retryable = isRetryable(method, response?.status, isNetworkOrTimeout);
|
|
570
|
-
if (!hasMoreAttempts || !retryable) {
|
|
571
|
-
throw attemptError;
|
|
572
|
-
}
|
|
573
|
-
// Only inspect Retry-After when we've actually decided to retry — keeps
|
|
574
|
-
// the fast path off the headers object and matches existing tests that
|
|
575
|
-
// build minimal Response stubs.
|
|
576
|
-
if (response) {
|
|
577
|
-
retryAfterMs = parseRetryAfter(response.headers?.get("retry-after") ?? null);
|
|
578
|
-
}
|
|
579
|
-
const backoff = retryAfterMs !== null
|
|
580
|
-
? Math.min(retry.maxDelayMs, retryAfterMs)
|
|
581
|
-
: computeBackoffMs(attempt, retry.baseDelayMs, retry.maxDelayMs);
|
|
582
|
-
await sleep(backoff);
|
|
583
|
-
lastError = attemptError;
|
|
584
|
-
}
|
|
585
|
-
// Defensive — the loop always returns or throws. If somehow exhausted:
|
|
586
|
-
throw lastError ?? new Error("BoondManager API request exhausted retries with no recorded error.");
|
|
587
|
-
}
|
|
588
|
-
/**
|
|
589
|
-
* Parse the filename out of a `Content-Disposition` header. Handles the
|
|
590
|
-
* common `filename="…"`/`filename=…` forms and the RFC 5987
|
|
591
|
-
* `filename*=UTF-8''…` form. Returns undefined when absent. Exported for
|
|
592
|
-
* unit testing.
|
|
593
|
-
*/
|
|
594
|
-
export function parseContentDispositionFilename(header) {
|
|
595
|
-
if (!header)
|
|
596
|
-
return undefined;
|
|
597
|
-
const star = header.match(/filename\*\s*=\s*(?:UTF-8|utf-8)''([^;]+)/);
|
|
598
|
-
if (star) {
|
|
599
|
-
try {
|
|
600
|
-
return decodeURIComponent(star[1].trim());
|
|
601
|
-
}
|
|
602
|
-
catch {
|
|
603
|
-
// fall through to the plain form
|
|
604
|
-
}
|
|
605
|
-
}
|
|
606
|
-
const plain = header.match(/filename\s*=\s*"([^"]+)"/) ?? header.match(/filename\s*=\s*([^;]+)/);
|
|
607
|
-
return plain ? plain[1].trim() : undefined;
|
|
608
|
-
}
|
|
609
|
-
/** Human-readable byte count for progress messages (same units as the tool output). */
|
|
610
|
-
function formatBytes(bytes) {
|
|
611
|
-
return bytes >= 1024 * 1024 ? `${(bytes / 1024 / 1024).toFixed(1)} Mo` : `${Math.round(bytes / 1024)} Ko`;
|
|
612
|
-
}
|
|
613
|
-
/** Progress steps emitted while streaming a download (≈ every 10 %). */
|
|
614
|
-
const DOWNLOAD_PROGRESS_STEPS = 10;
|
|
615
|
-
/**
|
|
616
|
-
* Read a download body, reporting bytes received as it goes.
|
|
617
|
-
*
|
|
618
|
-
* The streaming path only runs when someone is actually listening **and** the
|
|
619
|
-
* response announced a `Content-Length` — without a total there is nothing
|
|
620
|
-
* meaningful to report, and buffering through `arrayBuffer()` is both simpler
|
|
621
|
-
* and faster. So the default path is byte-for-byte the previous behaviour.
|
|
622
|
-
*/
|
|
623
|
-
async function readDownloadBody(response, onProgress) {
|
|
624
|
-
const totalBytes = Number(response.headers.get("content-length"));
|
|
625
|
-
const body = response.body;
|
|
626
|
-
if (!onProgress?.enabled || !body || !Number.isFinite(totalBytes) || totalBytes <= 0) {
|
|
627
|
-
return Buffer.from(await response.arrayBuffer());
|
|
628
|
-
}
|
|
629
|
-
const reader = body.getReader();
|
|
630
|
-
const step = Math.max(1, Math.floor(totalBytes / DOWNLOAD_PROGRESS_STEPS));
|
|
631
|
-
const chunks = [];
|
|
632
|
-
let received = 0;
|
|
633
|
-
let reported = 0;
|
|
634
|
-
for (;;) {
|
|
635
|
-
const { done, value } = await reader.read();
|
|
636
|
-
if (done)
|
|
637
|
-
break;
|
|
638
|
-
if (!value)
|
|
639
|
-
continue;
|
|
640
|
-
chunks.push(value);
|
|
641
|
-
received += value.byteLength;
|
|
642
|
-
// Throttled to ~10 notifications: a 5 MiB file arrives in ~80 network
|
|
643
|
-
// chunks, and one notification each would be its own kind of flood.
|
|
644
|
-
if (received - reported >= step) {
|
|
645
|
-
reported = received;
|
|
646
|
-
onProgress(received, totalBytes, `Téléchargement — ${formatBytes(received)} / ${formatBytes(totalBytes)}`);
|
|
647
|
-
}
|
|
648
|
-
}
|
|
649
|
-
if (received > reported) {
|
|
650
|
-
onProgress(received, totalBytes, `Téléchargement terminé — ${formatBytes(received)}`);
|
|
651
|
-
}
|
|
652
|
-
return Buffer.concat(chunks);
|
|
653
|
-
}
|
|
654
|
-
/**
|
|
655
|
-
* Download a binary payload (documents, justificatifs…) from the BoondManager
|
|
656
|
-
* API. Same auth/safety/rate-limit plumbing as `apiRequest`, but the body is
|
|
657
|
-
* returned raw instead of being parsed as JSON:API. Single attempt: document
|
|
658
|
-
* downloads are interactive one-offs, not worth a retry loop.
|
|
659
|
-
*
|
|
660
|
-
* `onProgress` reports bytes received when the client asked for progress and
|
|
661
|
-
* the response carries a `Content-Length`; otherwise nothing is emitted.
|
|
662
|
-
*/
|
|
663
|
-
export async function apiDownload(path, onProgress) {
|
|
664
|
-
const { baseUrl, auth } = getConfig();
|
|
665
|
-
const url = resolveApiUrl(baseUrl, path);
|
|
666
|
-
const limiter = getRateLimiter();
|
|
667
|
-
if (limiter)
|
|
668
|
-
await limiter.acquire();
|
|
669
|
-
const authHeader = await auth();
|
|
670
|
-
const timeoutMs = resolveTimeoutMs();
|
|
671
|
-
let response;
|
|
672
|
-
try {
|
|
673
|
-
response = await fetch(url.toString(), {
|
|
674
|
-
method: "GET",
|
|
675
|
-
headers: { [authHeader.name]: authHeader.value, Accept: "*/*" },
|
|
676
|
-
signal: AbortSignal.timeout(timeoutMs),
|
|
677
|
-
});
|
|
678
|
-
}
|
|
679
|
-
catch (err) {
|
|
680
|
-
if (isAbortError(err)) {
|
|
681
|
-
throw new Error([
|
|
682
|
-
`BoondManager API request timed out after ${timeoutMs}ms`,
|
|
683
|
-
`Endpoint: GET ${path}`,
|
|
684
|
-
"Hint: Increase BOOND_HTTP_TIMEOUT_MS or check connectivity to the BoondManager API.",
|
|
685
|
-
].join("\n"), { cause: err });
|
|
686
|
-
}
|
|
687
|
-
throw err instanceof Error ? err : new Error(String(err));
|
|
688
|
-
}
|
|
689
|
-
if (!response.ok) {
|
|
690
|
-
const errorText = await response.text().catch(() => "");
|
|
691
|
-
throw new Error(formatApiError(response.status, response.statusText, "GET", path, errorText));
|
|
692
|
-
}
|
|
693
|
-
const contentType = response.headers.get("content-type")?.split(";")[0].trim() || "application/octet-stream";
|
|
694
|
-
const filename = parseContentDispositionFilename(response.headers.get("content-disposition"));
|
|
695
|
-
// BoondManager only answers 404 on an unknown document when the request asks
|
|
696
|
-
// for JSON. With the `Accept: */*` this function sends, it serves the
|
|
697
|
-
// application shell instead — HTTP 200, `text/html`, ~9 KB — which the caller
|
|
698
|
-
// would happily surface as the document's text content. A truncated id then
|
|
699
|
-
// looks like a corrupted file rather than a wrong id, so refuse the shell
|
|
700
|
-
// here. An HTML *document* is still downloadable: a real file download
|
|
701
|
-
// carries a `Content-Disposition` filename, the shell doesn't.
|
|
702
|
-
if (contentType === "text/html" && !filename) {
|
|
703
|
-
throw new Error([
|
|
704
|
-
"BoondManager returned an HTML page instead of a document (HTTP 200, text/html).",
|
|
705
|
-
`Endpoint: GET ${path}`,
|
|
706
|
-
"Hint: The document id is most likely wrong or truncated. Entity relations expose suffixed ids " +
|
|
707
|
-
"(e.g. `123_resume`, `123_file`) — pass the id verbatim, suffix included. BoondManager serves its " +
|
|
708
|
-
"application shell for an unknown /documents/<id> instead of a 404.",
|
|
709
|
-
].join("\n"));
|
|
710
|
-
}
|
|
711
|
-
const data = await readDownloadBody(response, onProgress);
|
|
712
|
-
return { data, contentType, filename };
|
|
713
|
-
}
|
|
714
|
-
/**
|
|
715
|
-
* POST a multipart/form-data payload to the BoondManager API (document
|
|
716
|
-
* upload). Form values are simple string fields — the file itself travels by
|
|
717
|
-
* reference via the `fileUrl` field (Boond downloads it server-side), so the
|
|
718
|
-
* MCP server never buffers file bytes.
|
|
719
|
-
*/
|
|
720
|
-
export async function apiUploadForm(path, fields) {
|
|
721
|
-
const { baseUrl, auth } = getConfig();
|
|
722
|
-
const url = resolveApiUrl(baseUrl, path);
|
|
723
|
-
const limiter = getRateLimiter();
|
|
724
|
-
if (limiter)
|
|
725
|
-
await limiter.acquire();
|
|
726
|
-
const form = new FormData();
|
|
727
|
-
for (const [key, value] of Object.entries(fields)) {
|
|
728
|
-
form.set(key, value);
|
|
729
|
-
}
|
|
730
|
-
const authHeader = await auth();
|
|
731
|
-
// No Content-Type header: fetch derives the multipart boundary from FormData.
|
|
732
|
-
const response = await fetch(url.toString(), {
|
|
733
|
-
method: "POST",
|
|
734
|
-
headers: { [authHeader.name]: authHeader.value, Accept: "application/json" },
|
|
735
|
-
body: form,
|
|
736
|
-
signal: AbortSignal.timeout(resolveTimeoutMs()),
|
|
737
|
-
});
|
|
738
|
-
if (!response.ok) {
|
|
739
|
-
const errorText = await response.text().catch(() => "");
|
|
740
|
-
throw new Error(formatApiError(response.status, response.statusText, "POST", path, errorText));
|
|
741
|
-
}
|
|
742
|
-
if (response.status === 204 || response.headers.get("content-length") === "0") {
|
|
743
|
-
return { data: [] };
|
|
744
|
-
}
|
|
745
|
-
return (await response.json());
|
|
746
|
-
}
|
|
747
|
-
export function buildSearchQuery(params) {
|
|
748
|
-
const query = {};
|
|
749
|
-
if (params.keywords)
|
|
750
|
-
query["keywords"] = params.keywords;
|
|
751
|
-
if (params.page !== undefined)
|
|
752
|
-
query["page"] = params.page;
|
|
753
|
-
if (params.pageSize !== undefined)
|
|
754
|
-
query["maxResults"] = params.pageSize;
|
|
755
|
-
// Forward any additional filter params (strings, numbers, or arrays).
|
|
756
|
-
// `fields` is a client-side projection consumed by formatListResponse,
|
|
757
|
-
// never a BoondManager query parameter.
|
|
758
|
-
for (const [key, value] of Object.entries(params)) {
|
|
759
|
-
if (["keywords", "page", "pageSize", "fields"].includes(key))
|
|
760
|
-
continue;
|
|
761
|
-
if (value === undefined || value === null)
|
|
762
|
-
continue;
|
|
763
|
-
if (Array.isArray(value)) {
|
|
764
|
-
// Pass arrays through so apiRequest emits repeated bracket notation
|
|
765
|
-
query[key] = value;
|
|
766
|
-
}
|
|
767
|
-
else if (typeof value === "string" || typeof value === "number") {
|
|
768
|
-
query[key] = value;
|
|
769
|
-
}
|
|
770
|
-
else {
|
|
771
|
-
query[key] = String(value);
|
|
772
|
-
}
|
|
773
|
-
}
|
|
774
|
-
return query;
|
|
775
|
-
}
|
|
776
|
-
/**
|
|
777
|
-
* Search wrapper around `apiRequest` that enforces BoondManager's per-route
|
|
778
|
-
* `maxResults` ceiling (see `ROUTE_MAX_RESULTS`). When the caller requests more
|
|
779
|
-
* results than the route allows, the request is transparently split into
|
|
780
|
-
* chunks of `cap` records and the pages are merged into a single JSON:API
|
|
781
|
-
* response — the caller still receives the full page, but BoondManager never
|
|
782
|
-
* sees `maxResults` above the cap (which overflows memory on `/actions`).
|
|
783
|
-
*
|
|
784
|
-
* Routes whose ceiling already covers the requested page size take the fast
|
|
785
|
-
* path: a single `apiRequest`, byte-for-byte identical to calling it directly.
|
|
786
|
-
* The chunk count is bounded by `ceil((offset + requested) / cap)`, so there is
|
|
787
|
-
* no unbounded loop; the loop also stops early once a page comes back short
|
|
788
|
-
* (end of the result set on the server).
|
|
789
|
-
*
|
|
790
|
-
* `onProgress` (optional, last position — no existing caller had to change) is
|
|
791
|
-
* invoked **only on the chunked path**: one step per BoondManager page. The
|
|
792
|
-
* fast path stays silent on purpose — a single API call has nothing to report
|
|
793
|
-
* and a "1/1" notification would be pure noise. The reporter is a no-op unless
|
|
794
|
-
* the client sent a `progressToken` (see `services/progress.ts`).
|
|
795
|
-
*/
|
|
796
|
-
export async function apiSearch(path, query, onProgress) {
|
|
797
|
-
const cap = ROUTE_MAX_RESULTS[path] ?? DEFAULT_MAX_RESULTS;
|
|
798
|
-
const requested = typeof query["maxResults"] === "number" ? query["maxResults"] : DEFAULT_PAGE_SIZE;
|
|
799
|
-
const page = typeof query["page"] === "number" ? query["page"] : 1;
|
|
800
|
-
// Fast path: one call, maxResults left exactly as the caller built it.
|
|
801
|
-
if (requested <= cap) {
|
|
802
|
-
return apiRequest(path, "GET", undefined, query);
|
|
803
|
-
}
|
|
804
|
-
// Chunked path: fetch `requested` records starting at the absolute offset
|
|
805
|
-
// implied by (page, requested), in BoondManager pages of `cap` records.
|
|
806
|
-
const startRow = (page - 1) * requested;
|
|
807
|
-
const firstBoondPage = Math.floor(startRow / cap) + 1;
|
|
808
|
-
const offsetInFirstChunk = startRow % cap;
|
|
809
|
-
const needed = offsetInFirstChunk + requested;
|
|
810
|
-
const collected = [];
|
|
811
|
-
let meta;
|
|
812
|
-
// Upper bound of the loop, and the `total` advertised to the client. It stays
|
|
813
|
-
// constant across the notifications of one call, as the spec requires.
|
|
814
|
-
const totalChunks = Math.ceil(needed / cap);
|
|
815
|
-
let fetchedChunks = 0;
|
|
816
|
-
for (let i = 0; collected.length < needed; i++) {
|
|
817
|
-
const chunkQuery = { ...query, page: firstBoondPage + i, maxResults: cap };
|
|
818
|
-
const response = await apiRequest(path, "GET", undefined, chunkQuery);
|
|
819
|
-
if (meta === undefined)
|
|
820
|
-
meta = response.meta;
|
|
821
|
-
const chunk = Array.isArray(response.data) ? response.data : response.data ? [response.data] : [];
|
|
822
|
-
collected.push(...chunk);
|
|
823
|
-
fetchedChunks = i + 1;
|
|
824
|
-
onProgress?.(fetchedChunks, totalChunks, `Récupération ${path} — page ${fetchedChunks}/${totalChunks}`);
|
|
825
|
-
// A short page means there is no more data on the server — stop early.
|
|
826
|
-
if (chunk.length < cap)
|
|
827
|
-
break;
|
|
828
|
-
}
|
|
829
|
-
const data = collected.slice(offsetInFirstChunk, offsetInFirstChunk + requested);
|
|
830
|
-
// Early stop (result set exhausted): close the bar rather than leaving the
|
|
831
|
-
// client at 2/5 forever. Skipped when the last page already reported `total`,
|
|
832
|
-
// which would repeat a value instead of increasing it.
|
|
833
|
-
if (fetchedChunks < totalChunks) {
|
|
834
|
-
onProgress?.(totalChunks, totalChunks, `Récupération ${path} — terminé (${data.length} résultat(s))`);
|
|
835
|
-
}
|
|
836
|
-
return meta !== undefined ? { data, meta } : { data };
|
|
837
|
-
}
|
|
838
|
-
/**
|
|
839
|
-
* Business identifiers used as a last resort when a list row has no
|
|
840
|
-
* human-readable identity (no name, no title, no dictionary `value`).
|
|
841
|
-
*
|
|
842
|
-
* Transactional endpoints (`/invoices`, `/orders`, `/actions`,
|
|
843
|
-
* `/deliveries-groupments`, `/projects`…) key their rows on a reference, a
|
|
844
|
-
* number or a date rather than on a name, so the standard summary rendered
|
|
845
|
-
* them as a bare `[order #1234] | Statut: 1` — a line the model cannot act on
|
|
846
|
-
* without a follow-up `_get` per row.
|
|
847
|
-
*
|
|
848
|
-
* These are deliberately NOT appended unconditionally: `/resources` and
|
|
849
|
-
* `/opportunities` also carry `reference` and amount attributes, and their
|
|
850
|
-
* rows already read well (name, title). Enriching them too would only inflate
|
|
851
|
-
* every line. See `hasIdentity` in formatEntitySummary.
|
|
852
|
-
*/
|
|
853
|
-
const AMOUNT_FALLBACK_FIELDS = [
|
|
854
|
-
["turnoverInvoicedExcludingTax", "CA facturé HT"],
|
|
855
|
-
["turnoverOrderedExcludingTax", "CA commandé HT"],
|
|
856
|
-
["turnoverSimulatedExcludingTax", "CA simulé HT"],
|
|
857
|
-
["averageDailyPriceExcludingTax", "TJM HT"],
|
|
858
|
-
];
|
|
859
|
-
/** Max amount entries appended to a fallback line, to keep it scannable. */
|
|
860
|
-
const MAX_FALLBACK_AMOUNTS = 2;
|
|
861
|
-
/** Max length (in code points) of the `text` excerpt used to identify an action. */
|
|
862
|
-
const MAX_TEXT_EXCERPT = 80;
|
|
863
|
-
/**
|
|
864
|
-
* HTML comments, then element tags. The tag pattern requires a tag name right
|
|
865
|
-
* after the `<` (or `</`), so free text such as
|
|
866
|
-
* `Relancer si < 3 jours > sinon cloturer` survives intact — a naive
|
|
867
|
-
* `/<[^>]*>/` swallowed everything between the two operators. Quoted attribute
|
|
868
|
-
* values are matched explicitly so a `>` inside one (`<a href="a>b">`) doesn't
|
|
869
|
-
* end the tag early and leak `b">` into the excerpt. The alternatives are
|
|
870
|
-
* mutually exclusive on their first character, so there is no backtracking
|
|
871
|
-
* blow-up on unterminated input.
|
|
872
|
-
*/
|
|
873
|
-
const HTML_COMMENT_RE = /<!--[\s\S]*?-->/g;
|
|
874
|
-
const HTML_TAG_RE = /<\/?[a-zA-Z][a-zA-Z0-9:._-]*(?:\s+(?:"[^"]*"|'[^']*'|[^"'<>])*)?\/?>/g;
|
|
875
|
-
/** Entities actually seen in BoondManager notes (WYSIWYG output + French text). */
|
|
876
|
-
const NAMED_ENTITIES = {
|
|
877
|
-
amp: "&",
|
|
878
|
-
lt: "<",
|
|
879
|
-
gt: ">",
|
|
880
|
-
quot: '"',
|
|
881
|
-
apos: "'",
|
|
882
|
-
nbsp: " ",
|
|
883
|
-
hellip: "…",
|
|
884
|
-
agrave: "à",
|
|
885
|
-
acirc: "â",
|
|
886
|
-
ccedil: "ç",
|
|
887
|
-
eacute: "é",
|
|
888
|
-
egrave: "è",
|
|
889
|
-
ecirc: "ê",
|
|
890
|
-
euml: "ë",
|
|
891
|
-
icirc: "î",
|
|
892
|
-
iuml: "ï",
|
|
893
|
-
ocirc: "ô",
|
|
894
|
-
ugrave: "ù",
|
|
895
|
-
ucirc: "û",
|
|
896
|
-
uuml: "ü",
|
|
897
|
-
laquo: "«",
|
|
898
|
-
raquo: "»",
|
|
899
|
-
rsquo: "’",
|
|
900
|
-
lsquo: "‘",
|
|
901
|
-
ldquo: "“",
|
|
902
|
-
rdquo: "”",
|
|
903
|
-
deg: "°",
|
|
904
|
-
euro: "€",
|
|
905
|
-
ndash: "–",
|
|
906
|
-
mdash: "—",
|
|
907
|
-
};
|
|
908
|
-
/** Decodes numeric and common named entities so the excerpt reads as text, not as markup. */
|
|
909
|
-
function decodeHtmlEntities(input) {
|
|
910
|
-
return input.replace(/&(#[0-9]+|#[xX][0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]*);/g, (match, body) => {
|
|
911
|
-
if (body.startsWith("#")) {
|
|
912
|
-
const hex = body[1] === "x" || body[1] === "X";
|
|
913
|
-
const code = Number.parseInt(hex ? body.slice(2) : body.slice(1), hex ? 16 : 10);
|
|
914
|
-
// Surrogate code points are rejected on purpose: decoding `�`
|
|
915
|
-
// would inject the very unpaired surrogate the excerpt guards against.
|
|
916
|
-
if (!Number.isInteger(code) || code <= 0 || code > 0x10ffff)
|
|
917
|
-
return match;
|
|
918
|
-
if (code >= 0xd800 && code <= 0xdfff)
|
|
919
|
-
return match;
|
|
920
|
-
return String.fromCodePoint(code);
|
|
921
|
-
}
|
|
922
|
-
return NAMED_ENTITIES[body.toLowerCase()] ?? match;
|
|
923
|
-
});
|
|
924
|
-
}
|
|
925
|
-
/**
|
|
926
|
-
* Renders BoondManager's HTML note fields (`/actions`.text is a `<div>…</div>`)
|
|
927
|
-
* as a short single-line excerpt. Only strings are excerpted — `text: null` and
|
|
928
|
-
* nested objects are skipped by the caller rather than printed as `null` /
|
|
929
|
-
* `[object Object]`.
|
|
930
|
-
*
|
|
931
|
-
* Truncation runs on code points (`Array.from`), never on UTF-16 code units, so
|
|
932
|
-
* an emoji sitting on the boundary can't be cut into an unpaired surrogate.
|
|
933
|
-
*/
|
|
934
|
-
function textExcerpt(raw) {
|
|
935
|
-
const stripped = decodeHtmlEntities(raw.replace(HTML_COMMENT_RE, " ").replace(HTML_TAG_RE, " "))
|
|
936
|
-
.replace(/\s+/g, " ")
|
|
937
|
-
.trim();
|
|
938
|
-
if (stripped === "")
|
|
939
|
-
return undefined;
|
|
940
|
-
const chars = Array.from(stripped);
|
|
941
|
-
return chars.length > MAX_TEXT_EXCERPT ? `${chars.slice(0, MAX_TEXT_EXCERPT).join("")}…` : stripped;
|
|
942
|
-
}
|
|
943
|
-
/**
|
|
944
|
-
* Single rendering rule for a raw JSON:API attribute value, shared by the
|
|
945
|
-
* fallback summary and the `fields` projection — some Boond amounts come back
|
|
946
|
-
* as `{ amount, currency }` objects, and the two paths used to disagree
|
|
947
|
-
* (`[object Object]` on one side, JSON on the other).
|
|
948
|
-
*/
|
|
949
|
-
function renderAttributeValue(value) {
|
|
950
|
-
return value === null || typeof value === "object" ? JSON.stringify(value) : String(value);
|
|
951
|
-
}
|
|
952
|
-
/**
|
|
953
|
-
* A row is considered to name itself through `value` only when that value is a
|
|
954
|
-
* non-empty string once rendered. `value: null` / `value: ""` used to both
|
|
955
|
-
* print a bogus token *and* suppress the business-identifier fallback.
|
|
956
|
-
*/
|
|
957
|
-
function hasValueIdentity(value) {
|
|
958
|
-
return value !== undefined && value !== null && renderAttributeValue(value) !== "";
|
|
959
|
-
}
|
|
960
|
-
/**
|
|
961
|
-
* Secondary identifiers for rows that have no name/title/value. Order is
|
|
962
|
-
* chosen so the most identifying token comes first (number, then reference,
|
|
963
|
-
* then when it happened, then how much).
|
|
964
|
-
*/
|
|
965
|
-
function fallbackIdentityParts(attrs) {
|
|
966
|
-
const parts = [];
|
|
967
|
-
if (attrs.number)
|
|
968
|
-
parts.push(`N°: ${attrs.number}`);
|
|
969
|
-
if (attrs.reference)
|
|
970
|
-
parts.push(`Réf: ${attrs.reference}`);
|
|
971
|
-
if (attrs.date) {
|
|
972
|
-
parts.push(`Date: ${attrs.date}`);
|
|
973
|
-
}
|
|
974
|
-
else if (attrs.startDate && attrs.endDate) {
|
|
975
|
-
parts.push(`Du ${attrs.startDate} au ${attrs.endDate}`);
|
|
976
|
-
}
|
|
977
|
-
else if (attrs.startDate) {
|
|
978
|
-
parts.push(`Début: ${attrs.startDate}`);
|
|
979
|
-
}
|
|
980
|
-
else if (attrs.endDate) {
|
|
981
|
-
parts.push(`Fin: ${attrs.endDate}`);
|
|
982
|
-
}
|
|
983
|
-
let amounts = 0;
|
|
984
|
-
for (const [field, label] of AMOUNT_FALLBACK_FIELDS) {
|
|
985
|
-
if (amounts >= MAX_FALLBACK_AMOUNTS)
|
|
986
|
-
break;
|
|
987
|
-
const value = attrs[field];
|
|
988
|
-
// 0 is meaningful here (an order with no turnover yet), so only
|
|
989
|
-
// undefined/null are skipped.
|
|
990
|
-
if (value === undefined || value === null)
|
|
991
|
-
continue;
|
|
992
|
-
parts.push(`${label}: ${renderAttributeValue(value)}`);
|
|
993
|
-
amounts++;
|
|
994
|
-
}
|
|
995
|
-
// `typeOf` is an integer resolved through boond://dictionary/typeOf/* — on
|
|
996
|
-
// its own it is weak, but on an action it is often the only discriminator.
|
|
997
|
-
if (attrs.typeOf !== undefined && attrs.typeOf !== null)
|
|
998
|
-
parts.push(`Type: ${attrs.typeOf}`);
|
|
999
|
-
// End-user-authored free text: labelled and quoted so the model reads it as
|
|
1000
|
-
// a data field of the row and not as server-authored instructions.
|
|
1001
|
-
if (typeof attrs.text === "string") {
|
|
1002
|
-
const excerpt = textExcerpt(attrs.text);
|
|
1003
|
-
if (excerpt !== undefined)
|
|
1004
|
-
parts.push(`Note: "${excerpt}"`);
|
|
1005
|
-
}
|
|
1006
|
-
return parts;
|
|
1007
|
-
}
|
|
1008
|
-
export function formatEntitySummary(entity) {
|
|
1009
|
-
// A few BoondManager endpoints (e.g. `/calendars`, `/application/dictionary`)
|
|
1010
|
-
// return reference items as flat objects without a JSON:API `attributes`
|
|
1011
|
-
// wrapper. Treating the whole entity as the attribute bag in that case
|
|
1012
|
-
// keeps `formatListResponse` from crashing on `attrs.firstName` and yields
|
|
1013
|
-
// a still-useful summary.
|
|
1014
|
-
const e = (entity ?? {});
|
|
1015
|
-
const hasAttrs = e.attributes !== undefined && e.attributes !== null && typeof e.attributes === "object";
|
|
1016
|
-
const attrs = hasAttrs ? e.attributes : e;
|
|
1017
|
-
const id = e.id !== undefined ? String(e.id) : undefined;
|
|
1018
|
-
const type = e.type !== undefined ? String(e.type) : undefined;
|
|
1019
|
-
const header = id !== undefined && type !== undefined
|
|
1020
|
-
? `[${type} #${id}]`
|
|
1021
|
-
: id !== undefined
|
|
1022
|
-
? `[#${id}]`
|
|
1023
|
-
: type !== undefined
|
|
1024
|
-
? `[${type}]`
|
|
1025
|
-
: "[item]";
|
|
1026
|
-
const parts = [header];
|
|
1027
|
-
// Common name fields
|
|
1028
|
-
if (attrs.firstName || attrs.lastName) {
|
|
1029
|
-
parts.push(`${attrs.firstName || ""} ${attrs.lastName || ""}`.trim());
|
|
1030
|
-
}
|
|
1031
|
-
if (attrs.name)
|
|
1032
|
-
parts.push(String(attrs.name));
|
|
1033
|
-
// `value` covers the `/calendars` and dictionary-style payloads. `0` is a
|
|
1034
|
-
// legitimate label there, so only null/undefined/"" are skipped.
|
|
1035
|
-
if (!attrs.firstName && !attrs.lastName && !attrs.name && hasValueIdentity(attrs.value)) {
|
|
1036
|
-
parts.push(renderAttributeValue(attrs.value));
|
|
1037
|
-
}
|
|
1038
|
-
if (attrs.email1)
|
|
1039
|
-
parts.push(`Email: ${attrs.email1}`);
|
|
1040
|
-
if (attrs.phone1)
|
|
1041
|
-
parts.push(`Tel: ${attrs.phone1}`);
|
|
1042
|
-
if (attrs.city)
|
|
1043
|
-
parts.push(`Ville: ${attrs.city}`);
|
|
1044
|
-
if (attrs.state !== undefined)
|
|
1045
|
-
parts.push(`Statut: ${attrs.state}`);
|
|
1046
|
-
if (attrs.title)
|
|
1047
|
-
parts.push(`Titre: ${attrs.title}`);
|
|
1048
|
-
if (attrs.iso !== undefined && String(attrs.iso) !== id)
|
|
1049
|
-
parts.push(`ISO: ${attrs.iso}`);
|
|
1050
|
-
// Rows that named themselves are already useful — leave them untouched.
|
|
1051
|
-
// Only the ones reduced to `[type #id]` (+ maybe a status integer) get the
|
|
1052
|
-
// business identifiers appended.
|
|
1053
|
-
const hasIdentity = Boolean(attrs.firstName) ||
|
|
1054
|
-
Boolean(attrs.lastName) ||
|
|
1055
|
-
Boolean(attrs.name) ||
|
|
1056
|
-
Boolean(attrs.title) ||
|
|
1057
|
-
hasValueIdentity(attrs.value);
|
|
1058
|
-
if (!hasIdentity) {
|
|
1059
|
-
parts.push(...fallbackIdentityParts(attrs));
|
|
1060
|
-
}
|
|
1061
|
-
return parts.join(" | ");
|
|
1062
|
-
}
|
|
1063
|
-
/**
|
|
1064
|
-
* One result line restricted to the caller-selected attribute names.
|
|
1065
|
-
* Unknown names are skipped silently (the schemas document this), so a typo
|
|
1066
|
-
* degrades to a shorter line rather than an error. Non-primitive values are
|
|
1067
|
-
* JSON-serialised — some Boond attributes are nested objects.
|
|
1068
|
-
*/
|
|
1069
|
-
function formatProjectedSummary(entity, fields) {
|
|
1070
|
-
const e = (entity ?? {});
|
|
1071
|
-
const attrs = (e.attributes ?? e);
|
|
1072
|
-
// Reference endpoints return flat rows keyed on something else than `id`
|
|
1073
|
-
// (`/calendars` keys countries on `iso`), so a missing id renders as the same
|
|
1074
|
-
// `[item]` token the standard summary uses — not as a `[#?]` that reads like
|
|
1075
|
-
// a formatting bug.
|
|
1076
|
-
const parts = [e.id !== undefined ? `[#${String(e.id)}]` : "[item]"];
|
|
1077
|
-
for (const field of fields) {
|
|
1078
|
-
const value = attrs[field];
|
|
1079
|
-
if (value === undefined)
|
|
1080
|
-
continue;
|
|
1081
|
-
parts.push(`${field}: ${renderAttributeValue(value)}`);
|
|
1082
|
-
}
|
|
1083
|
-
return parts.join(" | ");
|
|
1084
|
-
}
|
|
1085
|
-
export function formatListResponse(response, entityType, fields) {
|
|
1086
|
-
const data = Array.isArray(response.data) ? response.data : [response.data];
|
|
1087
|
-
const total = response.meta?.totals?.rows;
|
|
1088
|
-
if (data.length === 0) {
|
|
1089
|
-
return `Aucun(e) ${entityType} trouvé(e).`;
|
|
1090
|
-
}
|
|
1091
|
-
const projected = fields !== undefined && fields.length > 0;
|
|
1092
|
-
const lines = data.map((item) => (projected ? formatProjectedSummary(item, fields) : formatEntitySummary(item)));
|
|
1093
|
-
const header = total !== undefined ? `Total: ${total} ${entityType}(s)\n\n` : "";
|
|
1094
|
-
const body = lines.join("\n");
|
|
1095
|
-
if (header.length + body.length <= CHARACTER_LIMIT)
|
|
1096
|
-
return header + body;
|
|
1097
|
-
// Cut on line boundaries and say how many rows were dropped. A mid-line cut
|
|
1098
|
-
// produced a half-row indistinguishable from a complete one, and the count
|
|
1099
|
-
// is what tells the model to narrow the query (or use `fields`/`pageSize`)
|
|
1100
|
-
// instead of trusting an implicitly complete page.
|
|
1101
|
-
const notice = (shown) => `\n\n[Résultats tronqués : ${shown}/${lines.length} ligne(s) affichée(s) (limite de ${CHARACTER_LIMIT} caractères). ` +
|
|
1102
|
-
`Affinez les filtres, réduisez pageSize, ou utilisez 'fields' pour raccourcir chaque ligne.]`;
|
|
1103
|
-
const budget = CHARACTER_LIMIT - header.length - notice(lines.length).length;
|
|
1104
|
-
const kept = [];
|
|
1105
|
-
let used = 0;
|
|
1106
|
-
for (const line of lines) {
|
|
1107
|
-
const cost = kept.length === 0 ? line.length : line.length + 1;
|
|
1108
|
-
if (used + cost > budget)
|
|
1109
|
-
break;
|
|
1110
|
-
used += cost;
|
|
1111
|
-
kept.push(line);
|
|
1112
|
-
}
|
|
1113
|
-
// A single row longer than the whole budget still has to show something.
|
|
1114
|
-
if (kept.length === 0)
|
|
1115
|
-
return header + body.substring(0, Math.max(budget, 0)) + notice(0);
|
|
1116
|
-
return header + kept.join("\n") + notice(kept.length);
|
|
1117
|
-
}
|
|
1118
|
-
/**
|
|
1119
|
-
* Formate la réponse d'un endpoint d'onglet (ex: /resources/{id}/positionings).
|
|
1120
|
-
* Contrairement à formatDetailResponse, un tableau est restitué en entier :
|
|
1121
|
-
* certains onglets renvoient plusieurs entités (positionnements, contacts...)
|
|
1122
|
-
* et n'afficher que la première masquait les autres.
|
|
1123
|
-
*/
|
|
1124
|
-
export function formatTabResponse(response) {
|
|
1125
|
-
if (!Array.isArray(response.data)) {
|
|
1126
|
-
return formatDetailResponse(response);
|
|
1127
|
-
}
|
|
1128
|
-
const entities = response.data.map((entity) => ({
|
|
1129
|
-
id: entity.id,
|
|
1130
|
-
type: entity.type,
|
|
1131
|
-
attributes: entity.attributes,
|
|
1132
|
-
relationships: entity.relationships,
|
|
1133
|
-
}));
|
|
1134
|
-
let result = `${entities.length} élément(s)\n\n` + JSON.stringify(entities, null, 2);
|
|
1135
|
-
if (result.length > CHARACTER_LIMIT) {
|
|
1136
|
-
result = result.substring(0, CHARACTER_LIMIT) + "\n\n[Résultat tronqué...]";
|
|
1137
|
-
}
|
|
1138
|
-
return result;
|
|
1139
|
-
}
|
|
1140
|
-
/**
|
|
1141
|
-
* The canonical shape of a single entity as this server hands it to the model:
|
|
1142
|
-
* the JSON:API resource minus the envelope noise (`links`, `meta`).
|
|
1143
|
-
*
|
|
1144
|
-
* Extracted from `formatDetailResponse` so the entity resource templates
|
|
1145
|
-
* (`boond://candidate/{id}`) aggregate the *same* projection instead of
|
|
1146
|
-
* defining a second, drifting idea of what an entity looks like. They cannot
|
|
1147
|
-
* reuse `formatDetailResponse` itself: it renders one entity and truncates
|
|
1148
|
-
* mid-string at `CHARACTER_LIMIT`, which on pretty-printed JSON yields an
|
|
1149
|
-
* unparseable body — acceptable for a tool's text content, not for a resource
|
|
1150
|
-
* whose whole point is to be read as JSON.
|
|
1151
|
-
*/
|
|
1152
|
-
export function projectEntity(entity) {
|
|
1153
|
-
return {
|
|
1154
|
-
id: entity.id,
|
|
1155
|
-
type: entity.type,
|
|
1156
|
-
attributes: entity.attributes,
|
|
1157
|
-
relationships: entity.relationships,
|
|
1158
|
-
};
|
|
1159
|
-
}
|
|
1160
|
-
export function formatDetailResponse(response) {
|
|
1161
|
-
const entity = Array.isArray(response.data) ? response.data[0] : response.data;
|
|
1162
|
-
if (!entity)
|
|
1163
|
-
return "Entité non trouvée.";
|
|
1164
|
-
const result = JSON.stringify(projectEntity(entity), null, 2);
|
|
1165
|
-
if (result.length > CHARACTER_LIMIT) {
|
|
1166
|
-
return result.substring(0, CHARACTER_LIMIT) + "\n\n[Résultat tronqué...]";
|
|
1167
|
-
}
|
|
1168
|
-
return result;
|
|
1169
|
-
}
|
|
2
|
+
* BoondManager client — public surface.
|
|
3
|
+
*
|
|
4
|
+
* This module is a barrel: the 38 tool files, the transports and the
|
|
5
|
+
* resources import from here, and the tests mock this path
|
|
6
|
+
* (`vi.mock("../services/boond-client.js", …)`). The implementation lives in
|
|
7
|
+
* one module per responsibility (issue #239):
|
|
8
|
+
*
|
|
9
|
+
* http/auth.ts who the request is sent as (JWT / static / OAuth), config
|
|
10
|
+
* http/errors.ts error envelope parsing, Cloudflare detection, hints, BoondApiError
|
|
11
|
+
* http/retry.ts retry policy: config, retryability, Retry-After, backoff
|
|
12
|
+
* http/rate-limit.ts per-identity token buckets
|
|
13
|
+
* http/transport.ts the single `send()` path + apiRequest / apiUploadForm
|
|
14
|
+
* http/download.ts apiDownload: streaming under a byte cap, progress, guards
|
|
15
|
+
* search.ts buildSearchQuery + apiSearch (per-route chunking)
|
|
16
|
+
* format/*.ts list / detail / tab rendering, summaries, HTML excerpts
|
|
17
|
+
*
|
|
18
|
+
* Add a new export here when a tool needs it; do not add implementation.
|
|
19
|
+
*/
|
|
20
|
+
export { oauthContextAuth, buildJwt, JWT_HEADER_NAME, initClient, hasEnvCredentials, initClientWithAuth, resetClientForTests, } from "./http/auth.js";
|
|
21
|
+
export { parseBoondErrorBody, hintForUnauthorized, BoondApiError, formatApiError } from "./http/errors.js";
|
|
22
|
+
export { resolveRetryConfig, isRetryable, parseRetryAfter, computeBackoffMs } from "./http/retry.js";
|
|
23
|
+
export { resolveRateLimitConfig, MAX_RATE_LIMIT_BUCKETS, getRateLimiter, rateLimiterBucketCountForTests, resetRateLimiterForTests, } from "./http/rate-limit.js";
|
|
24
|
+
export { resolveTimeoutMs, assertSafeApiPath, resolveApiUrl, apiRequest, apiUploadForm, } from "./http/transport.js";
|
|
25
|
+
export { parseContentDispositionFilename, DownloadTooLargeError, apiDownload, } from "./http/download.js";
|
|
26
|
+
export { buildSearchQuery, apiSearch } from "./search.js";
|
|
27
|
+
export { formatEntitySummary } from "./format/summary.js";
|
|
28
|
+
export { formatListResponse } from "./format/list.js";
|
|
29
|
+
export { projectEntity, formatDetailResponse } from "./format/detail.js";
|
|
30
|
+
export { formatTabResponse } from "./format/tab.js";
|
|
1170
31
|
//# sourceMappingURL=boond-client.js.map
|