@bevel-software/platform-core-backend 0.13.5 → 0.14.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/THIRD-PARTY-NOTICES.md +639 -433
- package/dist/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +6 -3
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +2 -0
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +9 -0
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/core-config.d.ts.map +1 -1
- package/dist/core-config.js +12 -5
- package/dist/core-config.js.map +1 -1
- package/dist/modules/access-model/kb-read-filter.d.ts +12 -0
- package/dist/modules/access-model/kb-read-filter.d.ts.map +1 -1
- package/dist/modules/access-model/kb-read-filter.js +15 -0
- package/dist/modules/access-model/kb-read-filter.js.map +1 -1
- package/dist/modules/connection-probe/connection-probe.contract.d.ts +43 -0
- package/dist/modules/connection-probe/connection-probe.contract.d.ts.map +1 -0
- package/dist/modules/connection-probe/connection-probe.contract.js +2 -0
- package/dist/modules/connection-probe/connection-probe.contract.js.map +1 -0
- package/dist/modules/connection-probe/connection-probe.service.d.ts +94 -0
- package/dist/modules/connection-probe/connection-probe.service.d.ts.map +1 -0
- package/dist/modules/connection-probe/connection-probe.service.js +684 -0
- package/dist/modules/connection-probe/connection-probe.service.js.map +1 -0
- package/dist/modules/connection-probe/index.d.ts +3 -0
- package/dist/modules/connection-probe/index.d.ts.map +1 -0
- package/dist/modules/connection-probe/index.js +3 -0
- package/dist/modules/connection-probe/index.js.map +1 -0
- package/dist/modules/diff/diff.routes.d.ts.map +1 -1
- package/dist/modules/diff/diff.routes.js +3 -5
- package/dist/modules/diff/diff.routes.js.map +1 -1
- package/dist/modules/kb-fs/mutex.d.ts +37 -0
- package/dist/modules/kb-fs/mutex.d.ts.map +1 -1
- package/dist/modules/kb-fs/mutex.js +48 -5
- package/dist/modules/kb-fs/mutex.js.map +1 -1
- package/dist/modules/mcp/mcp.routes.d.ts.map +1 -1
- package/dist/modules/mcp/mcp.routes.js +95 -13
- package/dist/modules/mcp/mcp.routes.js.map +1 -1
- package/dist/modules/secrets-vault/db-secrets-vault.service.js +1 -1
- package/dist/modules/secrets-vault/db-secrets-vault.service.js.map +1 -1
- package/dist/modules/secrets-vault/secrets-vault.routes.d.ts +6 -0
- package/dist/modules/secrets-vault/secrets-vault.routes.d.ts.map +1 -1
- package/dist/modules/secrets-vault/secrets-vault.routes.js +31 -1
- package/dist/modules/secrets-vault/secrets-vault.routes.js.map +1 -1
- package/dist/modules/tool-manuals/mcp-json-discovery.d.ts.map +1 -1
- package/dist/modules/tool-manuals/mcp-json-discovery.js +10 -1
- package/dist/modules/tool-manuals/mcp-json-discovery.js.map +1 -1
- package/dist/modules/tool-manuals/mcp-server-edit.service.d.ts.map +1 -1
- package/dist/modules/tool-manuals/mcp-server-edit.service.js +10 -4
- package/dist/modules/tool-manuals/mcp-server-edit.service.js.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +83 -0
- package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.service.d.ts +9 -1
- package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.service.js +181 -13
- package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
- package/dist/modules/workflow/git/git.service.d.ts +18 -1
- package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/git.service.js +98 -6
- package/dist/modules/workflow/git/git.service.js.map +1 -1
- package/dist/modules/workflow/workflow.routes.d.ts +2 -1
- package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.routes.js +98 -9
- package/dist/modules/workflow/workflow.routes.js.map +1 -1
- package/dist/modules/workflow/workflow.service.d.ts +81 -8
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.service.js +220 -43
- package/dist/modules/workflow/workflow.service.js.map +1 -1
- package/dist/modules/workspace/workspace.routes.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.routes.js +2 -5
- package/dist/modules/workspace/workspace.routes.js.map +1 -1
- package/dist/modules/workspace/workspace.service.d.ts +5 -1
- package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.service.js +21 -4
- package/dist/modules/workspace/workspace.service.js.map +1 -1
- package/dist/shared/token-crypto.d.ts +17 -2
- package/dist/shared/token-crypto.d.ts.map +1 -1
- package/dist/shared/token-crypto.js +25 -8
- package/dist/shared/token-crypto.js.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/core-config.admin.test.ts +21 -0
- package/src/core/create-core-server.ts +7 -2
- package/src/core/create-core-services.ts +11 -0
- package/src/core-config.ts +14 -7
- package/src/modules/access-model/kb-read-filter.ts +22 -0
- package/src/modules/connection-probe/__tests__/connection-probe.service.test.ts +685 -0
- package/src/modules/connection-probe/connection-probe.contract.ts +44 -0
- package/src/modules/connection-probe/connection-probe.service.ts +734 -0
- package/src/modules/connection-probe/index.ts +2 -0
- package/src/modules/diff/diff.routes.ts +9 -5
- package/src/modules/kb-fs/__tests__/mutex.test.ts +107 -0
- package/src/modules/kb-fs/mutex.ts +50 -5
- package/src/modules/mcp/__tests__/mcp-routes-harness.ts +89 -0
- package/src/modules/mcp/__tests__/mcp.routes.delete.test.ts +6 -34
- package/src/modules/mcp/__tests__/mcp.routes.local-token.test.ts +8 -40
- package/src/modules/mcp/__tests__/mcp.routes.session.test.ts +226 -0
- package/src/modules/mcp/mcp.routes.ts +482 -398
- package/src/modules/secrets-vault/__tests__/connect-pending.route.test.ts +12 -0
- package/src/modules/secrets-vault/__tests__/oauth-return-to.route.test.ts +10 -3
- package/src/modules/secrets-vault/__tests__/tool-owner-gate.route.test.ts +26 -0
- package/src/modules/secrets-vault/db-secrets-vault.service.ts +1 -1
- package/src/modules/secrets-vault/secrets-vault.routes.ts +35 -1
- package/src/modules/tool-manuals/__tests__/mcp-json-discovery.test.ts +11 -0
- package/src/modules/tool-manuals/__tests__/tool-manuals.health-check.test.ts +104 -0
- package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +56 -0
- package/src/modules/tool-manuals/mcp-json-discovery.ts +13 -1
- package/src/modules/tool-manuals/mcp-server-edit.service.ts +13 -4
- package/src/modules/tool-manuals/tool-manuals.contract.ts +89 -0
- package/src/modules/tool-manuals/tool-manuals.service.ts +196 -14
- package/src/modules/workflow/__tests__/workflow.routes.history-read-gate.test.ts +139 -0
- package/src/modules/workflow/__tests__/workflow.service.branch-in-use.test.ts +337 -0
- package/src/modules/workflow/__tests__/workflow.service.deleted-branch-sweep.test.ts +7 -1
- package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +95 -8
- package/src/modules/workflow/git/__tests__/git.service.history-guards.test.ts +86 -0
- package/src/modules/workflow/git/git.service.ts +115 -7
- package/src/modules/workflow/workflow.routes.ts +104 -4
- package/src/modules/workflow/workflow.service.ts +245 -55
- package/src/modules/workspace/workspace.routes.ts +8 -4
- package/src/modules/workspace/workspace.service.ts +21 -3
- package/src/shared/__tests__/token-crypto.test.ts +34 -0
- package/src/shared/token-crypto.ts +28 -10
|
@@ -0,0 +1,684 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import '@utcp/http'; // side effect: registers the 'http' UTCP communication protocol
|
|
3
|
+
import '@utcp/mcp'; // side effect: registers the 'mcp' protocol (remote MCP `.tool` sources)
|
|
4
|
+
import { CommunicationProtocol, UtcpClientConfigSerializer } from '@utcp/sdk';
|
|
5
|
+
import { CodeModeUtcpClient } from '@utcp/code-mode';
|
|
6
|
+
import { registerManual } from '@bevel-software/platform-mcp-core';
|
|
7
|
+
// The concrete module, NOT the `secrets-vault` barrel: that barrel re-exports
|
|
8
|
+
// the routes, and the routes import this module's contract — so going through
|
|
9
|
+
// it would close an import cycle between the two modules.
|
|
10
|
+
import { bevelSecretsLoaderConfig } from '../secrets-vault/secrets-variable-loader.js';
|
|
11
|
+
import { utcpNamespacedKey } from '../../shared/utcp-namespace.js';
|
|
12
|
+
import { assertSafeFetchUrl } from '../../shared/ssrf.js';
|
|
13
|
+
/** How long a probe may take before we call it inconclusive. */
|
|
14
|
+
const PROBE_TIMEOUT_MS = 10_000;
|
|
15
|
+
const OK = { status: 'ok', detail: null };
|
|
16
|
+
const NO_PROBE = {
|
|
17
|
+
status: 'unverifiable',
|
|
18
|
+
detail: "This tool doesn't offer a way to test its connection.",
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* Variable references, in the SAME grammar UTCP itself substitutes.
|
|
22
|
+
*
|
|
23
|
+
* `@utcp/sdk`'s `DefaultVariableSubstitutor` accepts BOTH `${VAR}` and bare
|
|
24
|
+
* `$VAR`, and so does the scanner that decides which variables the UI asks the
|
|
25
|
+
* user to fill in. Matching only the braced form meant a manual written as
|
|
26
|
+
* `Authorization: Bearer $API_KEY` — which works perfectly in real calls, and
|
|
27
|
+
* whose `API_KEY` the tool page duly asks for — had the LITERAL string sent by
|
|
28
|
+
* the probe, drawing a 401 and reporting a correct credential as "Not working".
|
|
29
|
+
*
|
|
30
|
+
* A fresh regex per call: `lastIndex` is mutable state on a `/g` pattern, and
|
|
31
|
+
* this one is used for both scanning and replacing.
|
|
32
|
+
*/
|
|
33
|
+
function varRefPattern() {
|
|
34
|
+
return /\$\{([a-zA-Z0-9_]+)\}|\$([a-zA-Z0-9_]+)/g;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Does this failure ACCUSE the credential, or merely fail to exonerate it?
|
|
38
|
+
*
|
|
39
|
+
* Only a definitive rejection may set `failed`, because that is the status that
|
|
40
|
+
* tells a user their key is wrong. A provider that is down, rate-limiting, or
|
|
41
|
+
* unreachable says nothing about the key, and reporting those as "not working"
|
|
42
|
+
* would make the badge cry wolf during someone else's outage — which teaches
|
|
43
|
+
* people to ignore it, landing us back where we started by a different road.
|
|
44
|
+
*
|
|
45
|
+
* Matching on message text is unavoidable for the MCP path: registration
|
|
46
|
+
* failures surface as prose from the transport, not as a status code. The list
|
|
47
|
+
* is deliberately narrow and the default is "we don't know", so the failure
|
|
48
|
+
* mode of a miss is an over-cautious `unverifiable` rather than a false
|
|
49
|
+
* accusation.
|
|
50
|
+
*/
|
|
51
|
+
function looksLikeAuthRejection(message) {
|
|
52
|
+
const m = message.toLowerCase();
|
|
53
|
+
// A status number proves nothing from inside a URL — `/v1/401/stream`, a
|
|
54
|
+
// port, an id in a path all contain the digits without any rejection having
|
|
55
|
+
// happened. Scrub URLs before reading digits; the word list below still
|
|
56
|
+
// runs against the full message, because words like "unauthorized" accuse
|
|
57
|
+
// wherever they appear.
|
|
58
|
+
const scrubbed = m.replace(/\bhttps?:\/\/\S+/g, ' ');
|
|
59
|
+
return (/\b(401|403)\b/.test(scrubbed) ||
|
|
60
|
+
m.includes('unauthorized') ||
|
|
61
|
+
m.includes('unauthenticated') ||
|
|
62
|
+
m.includes('forbidden') ||
|
|
63
|
+
m.includes('invalid_token') ||
|
|
64
|
+
m.includes('invalid token') ||
|
|
65
|
+
m.includes('invalid api key') ||
|
|
66
|
+
m.includes('invalid_api_key') ||
|
|
67
|
+
m.includes('authentication failed') ||
|
|
68
|
+
m.includes('invalid_grant'));
|
|
69
|
+
}
|
|
70
|
+
/** What {@link withProbeTimeout} returns for work that outlived the deadline. */
|
|
71
|
+
const TIMED_OUT = Symbol('probe-timed-out');
|
|
72
|
+
/**
|
|
73
|
+
* Resolve `work`, or {@link TIMED_OUT} if it outlives `PROBE_TIMEOUT_MS`.
|
|
74
|
+
*
|
|
75
|
+
* The declared-probe path gets its deadline from `AbortSignal.timeout`, but the
|
|
76
|
+
* MCP path cannot: neither `CodeModeUtcpClient.create` nor `registerManual`
|
|
77
|
+
* accepts a signal. Without this, an unresponsive server leaves a probe pending
|
|
78
|
+
* forever and the caller never gets a verdict at all.
|
|
79
|
+
*
|
|
80
|
+
* Abandoning work is not the same as stopping it, and on the MCP path the
|
|
81
|
+
* difference is a live socket. `@utcp/mcp` caches a session only AFTER its
|
|
82
|
+
* `connect()` resolves, so a handshake still in flight at the deadline has
|
|
83
|
+
* nothing for the caller's `finally` to find — and then caches its session (and
|
|
84
|
+
* its standalone SSE stream) moments later with nobody left holding it. That is
|
|
85
|
+
* one leaked session per probe against a slow server, on every credential save.
|
|
86
|
+
* `onLateSettle` is the owner for exactly that window: it runs when abandoned
|
|
87
|
+
* work eventually settles, and it is the only thing that can close a session
|
|
88
|
+
* opened after the verdict was returned.
|
|
89
|
+
*/
|
|
90
|
+
async function withProbeTimeout(work, onLateSettle) {
|
|
91
|
+
let timer;
|
|
92
|
+
let abandoned = false;
|
|
93
|
+
// Attached before the race, so the continuation exists no matter when `work`
|
|
94
|
+
// settles. Both branches: a rejection can also have opened something — a
|
|
95
|
+
// successful `connect()` followed by a failing `listTools` leaves a session
|
|
96
|
+
// cached and the promise rejected.
|
|
97
|
+
void work
|
|
98
|
+
.then(() => (abandoned ? onLateSettle?.() : undefined), () => (abandoned ? onLateSettle?.() : undefined))
|
|
99
|
+
.catch((err) => {
|
|
100
|
+
console.warn(`[probe] late cleanup failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
101
|
+
});
|
|
102
|
+
const deadline = new Promise((resolve) => {
|
|
103
|
+
timer = setTimeout(() => {
|
|
104
|
+
abandoned = true;
|
|
105
|
+
resolve(TIMED_OUT);
|
|
106
|
+
}, PROBE_TIMEOUT_MS);
|
|
107
|
+
});
|
|
108
|
+
try {
|
|
109
|
+
return await Promise.race([work, deadline]);
|
|
110
|
+
}
|
|
111
|
+
finally {
|
|
112
|
+
if (timer)
|
|
113
|
+
clearTimeout(timer);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/** Below this, a "secret" is too short to blank out without eating the message. */
|
|
117
|
+
const MIN_SECRET_CHARS = 6;
|
|
118
|
+
/** The shortest leading run of a secret we will treat as an echo of it. */
|
|
119
|
+
const MIN_ECHOED_PREFIX_CHARS = 8;
|
|
120
|
+
const REDACTED = '[redacted]';
|
|
121
|
+
/**
|
|
122
|
+
* Every secret this probe resolved, so that none of them can come back out in
|
|
123
|
+
* something we quote.
|
|
124
|
+
*
|
|
125
|
+
* A rejection is quoted verbatim because the provider's own words are the
|
|
126
|
+
* actionable part — but providers routinely echo the credential they refused
|
|
127
|
+
* ("Incorrect API key provided: sk-…"). Anyone who can READ a tool can probe
|
|
128
|
+
* it, so without this a plain reader could recover part of a shared ADMIN key
|
|
129
|
+
* they were never allowed to see by saving a probe's failure text. The 200-char
|
|
130
|
+
* cap on the quote bounds it; it does not redact it.
|
|
131
|
+
*
|
|
132
|
+
* Leading PREFIXES count as well as whole values, because that echo is usually
|
|
133
|
+
* masked in the middle ("sk-live-abcd…wxyz"): a whole-value search finds
|
|
134
|
+
* nothing there and prints the head of the key regardless. Short values are
|
|
135
|
+
* left alone — blanking a five-character string would hit unrelated text and
|
|
136
|
+
* destroy the message that makes the verdict useful.
|
|
137
|
+
*/
|
|
138
|
+
class SecretRedactor {
|
|
139
|
+
values = new Set();
|
|
140
|
+
remember(value) {
|
|
141
|
+
if (value.length >= MIN_SECRET_CHARS)
|
|
142
|
+
this.values.add(value);
|
|
143
|
+
}
|
|
144
|
+
redact(text) {
|
|
145
|
+
let out = text;
|
|
146
|
+
// Longest SECRET first, and not merely the longest prefix of each: when one
|
|
147
|
+
// resolved value is a prefix of another (a key and that same key with a
|
|
148
|
+
// suffix), redacting the short one first eats the head of the long one and
|
|
149
|
+
// leaves its tail printed — the exact half-redaction this class exists to
|
|
150
|
+
// avoid, arrived at from the other direction.
|
|
151
|
+
for (const secret of [...this.values].sort((a, b) => b.length - a.length)) {
|
|
152
|
+
// Longest match first: replacing the whole value when it is present beats
|
|
153
|
+
// replacing a prefix of it and leaving the tail on screen.
|
|
154
|
+
const shortest = Math.min(secret.length, MIN_ECHOED_PREFIX_CHARS);
|
|
155
|
+
for (let len = secret.length; len >= shortest; len--) {
|
|
156
|
+
const candidate = secret.slice(0, len);
|
|
157
|
+
if (!out.includes(candidate))
|
|
158
|
+
continue;
|
|
159
|
+
out = out.split(candidate).join(REDACTED);
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return out;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Names — of a header or of a query parameter — whose value IS a credential
|
|
168
|
+
* rather than a description of the request. Matched as substrings and on
|
|
169
|
+
* purpose generously: a name wrongly treated as secret costs one blanked word
|
|
170
|
+
* in an error message, while one wrongly treated as public costs the
|
|
171
|
+
* credential. `signature` and `hmac` earn their place because a signed url
|
|
172
|
+
* (`X-Amz-Signature`, an Azure SAS) grants access exactly as a bearer token
|
|
173
|
+
* does — the secret is what derived it, and the derivation is what is sent.
|
|
174
|
+
*/
|
|
175
|
+
const CREDENTIAL_MARKERS = [
|
|
176
|
+
'auth',
|
|
177
|
+
'key',
|
|
178
|
+
'token',
|
|
179
|
+
'secret',
|
|
180
|
+
'password',
|
|
181
|
+
'cookie',
|
|
182
|
+
'credential',
|
|
183
|
+
'signature',
|
|
184
|
+
'hmac',
|
|
185
|
+
];
|
|
186
|
+
/**
|
|
187
|
+
* The same, for names too short to match as substrings: `sig` inside a word
|
|
188
|
+
* would swallow `design`, `assign` and `signal`, and blanking those would eat
|
|
189
|
+
* the message rather than protect anything in it.
|
|
190
|
+
*/
|
|
191
|
+
const CREDENTIAL_WORDS = /(?:^|[-_])(sig|hash)(?:$|[-_])/;
|
|
192
|
+
function carriesCredential(fieldName) {
|
|
193
|
+
const name = fieldName.toLowerCase();
|
|
194
|
+
return CREDENTIAL_MARKERS.some((marker) => name.includes(marker)) || CREDENTIAL_WORDS.test(name);
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Remember a credential the manual spelled out in full.
|
|
198
|
+
*
|
|
199
|
+
* {@link ConnectionProbeService.substitute} only ever learns values it resolved
|
|
200
|
+
* from the vault, so a `.tool` whose header reads `Authorization: Bearer sk-…`
|
|
201
|
+
* rather than `Bearer ${TOKEN}` left the redactor with nothing to look for. That
|
|
202
|
+
* is the one credential a reader has no other route to: `ToolManualSummary`
|
|
203
|
+
* withholds `headers` from the browser for precisely this reason, and a
|
|
204
|
+
* provider quoting it in a 401 would have handed it back regardless.
|
|
205
|
+
*
|
|
206
|
+
* What follows the auth scheme is remembered as well as the whole value,
|
|
207
|
+
* because `Bearer sk-…` is echoed as the bare token far more often than in
|
|
208
|
+
* full, and `Bearer` by itself is not worth blanking out of a message.
|
|
209
|
+
*/
|
|
210
|
+
function rememberCredentialHeaders(headers, redactor) {
|
|
211
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
212
|
+
if (typeof value !== 'string' || !carriesCredential(name))
|
|
213
|
+
continue;
|
|
214
|
+
redactor.remember(value);
|
|
215
|
+
const afterScheme = /^\S+\s+(\S.*)$/.exec(value.trim());
|
|
216
|
+
if (afterScheme)
|
|
217
|
+
redactor.remember(afterScheme[1]);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* The same, for a credential written into a query string (`?api_key=…`) rather
|
|
222
|
+
* than a header. Only the values of credential-NAMED parameters: a rejection
|
|
223
|
+
* usually names the endpoint it rejected, and blanking the url wholesale would
|
|
224
|
+
* cost more of the message than it protects.
|
|
225
|
+
*/
|
|
226
|
+
function rememberCredentialQuery(url, redactor) {
|
|
227
|
+
let params;
|
|
228
|
+
try {
|
|
229
|
+
params = new URL(url).searchParams;
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
for (const [name, value] of params) {
|
|
235
|
+
if (carriesCredential(name))
|
|
236
|
+
redactor.remember(value);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* The same, for the headers an `mcp` manual carries on its server entry. This
|
|
241
|
+
* path never substitutes them — UTCP's own loader does that inside the SDK — so
|
|
242
|
+
* a templated value belongs to the vault and `rememberTemplateSecrets` already
|
|
243
|
+
* holds it; only what the file spells out in full is new here, and skipping the
|
|
244
|
+
* rest keeps a half-literal value like `example.com/${TENANT}` from lending its
|
|
245
|
+
* public prefix to the redactor.
|
|
246
|
+
*/
|
|
247
|
+
function rememberLiteralServerHeaders(template, redactor) {
|
|
248
|
+
const servers = template.config?.mcpServers;
|
|
249
|
+
for (const server of Object.values(servers ?? {})) {
|
|
250
|
+
const headers = server.headers;
|
|
251
|
+
if (!headers || typeof headers !== 'object')
|
|
252
|
+
continue;
|
|
253
|
+
const literal = Object.fromEntries(Object.entries(headers).filter(([, value]) => typeof value === 'string' && !varRefPattern().test(value)));
|
|
254
|
+
rememberCredentialHeaders(literal, redactor);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* A copy of `template` whose MCP server keys are unique to one probe.
|
|
259
|
+
*
|
|
260
|
+
* This is what makes an MCP probe test the credential rather than the cache.
|
|
261
|
+
* `@utcp/mcp` registers a MODULE-LEVEL singleton protocol whose session map is
|
|
262
|
+
* keyed only `<serverName>:<transport>`, and `_getOrCreateSession` returns a
|
|
263
|
+
* cached hit WITHOUT consulting the auth it was handed — so a throwaway client
|
|
264
|
+
* is emphatically not a throwaway session. Probing under the manual's own name
|
|
265
|
+
* would hand back whatever session the MCP proxy opened earlier, `listTools`
|
|
266
|
+
* would succeed on that older token, and a freshly mistyped key would be
|
|
267
|
+
* reported **Connected**: the exact bug this module exists to remove, rebuilt
|
|
268
|
+
* inside its own fix. It would also let one user's probe answer from another
|
|
269
|
+
* user's session, and let a failing probe evict the session everyone is using.
|
|
270
|
+
*
|
|
271
|
+
* The key is only a cache key and a tool-name prefix (`<manual>.<server>.<tool>`),
|
|
272
|
+
* and variables resolve against the MANUAL's name, not this one — so renaming
|
|
273
|
+
* it changes nothing about what gets dialled or which secrets it carries.
|
|
274
|
+
*/
|
|
275
|
+
function withIsolatedServerKeys(template) {
|
|
276
|
+
const clone = structuredClone(template);
|
|
277
|
+
const servers = clone.config?.mcpServers;
|
|
278
|
+
if (!servers)
|
|
279
|
+
return clone;
|
|
280
|
+
const nonce = randomUUID().replace(/-/g, '');
|
|
281
|
+
clone.config.mcpServers = Object.fromEntries(Object.entries(servers).map(([key, value]) => [`${key}__probe_${nonce}`, value]));
|
|
282
|
+
return clone;
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Close whatever MCP sessions this probe opened.
|
|
286
|
+
*
|
|
287
|
+
* Through the PROTOCOL rather than `client.deregisterManual(name)`, which only
|
|
288
|
+
* works for a manual the client managed to register: on a failed handshake the
|
|
289
|
+
* client never saves the manual, so the client-level call finds nothing and
|
|
290
|
+
* returns `false` — while the session, created before `listTools` was ever
|
|
291
|
+
* attempted, stays cached and open. A failed probe is exactly when that
|
|
292
|
+
* happens, so cleanup has to be reachable without the registry. The protocol's
|
|
293
|
+
* own `deregisterManual` takes the template directly and closes each
|
|
294
|
+
* `<server>:<transport>` session it names.
|
|
295
|
+
*
|
|
296
|
+
* Best-effort: the probe has already produced its verdict, and failing to tidy
|
|
297
|
+
* up must not turn a successful check into an error.
|
|
298
|
+
*/
|
|
299
|
+
async function closeProbeSessions(client, template) {
|
|
300
|
+
try {
|
|
301
|
+
const protocol = CommunicationProtocol.communicationProtocols[template.call_template_type];
|
|
302
|
+
await protocol?.deregisterManual(client, template);
|
|
303
|
+
}
|
|
304
|
+
catch (err) {
|
|
305
|
+
console.warn(`[probe] closing session failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Runs one credential probe and hands back the answer. Holds no state and
|
|
310
|
+
* writes nothing: see {@link ProbeVerdict} for why the verdict isn't stored.
|
|
311
|
+
*/
|
|
312
|
+
export class ConnectionProbeService {
|
|
313
|
+
toolManualService;
|
|
314
|
+
secretsVault;
|
|
315
|
+
constructor(toolManualService, secretsVault) {
|
|
316
|
+
this.toolManualService = toolManualService;
|
|
317
|
+
this.secretsVault = secretsVault;
|
|
318
|
+
}
|
|
319
|
+
async probe(userId, userEmail, slug) {
|
|
320
|
+
// ONE catalog + ACL pass for every fact this probe needs: whether the tool
|
|
321
|
+
// is local-only, what it declares as a health check, and the call template
|
|
322
|
+
// an `mcp` handshake would dial.
|
|
323
|
+
const target = await this.toolManualService.probeTargetFor(userEmail, slug);
|
|
324
|
+
if (!target)
|
|
325
|
+
return null;
|
|
326
|
+
const outcome = await this.runProbe(userId, target);
|
|
327
|
+
return { status: outcome.status, detail: outcome.detail, checkedAt: new Date() };
|
|
328
|
+
}
|
|
329
|
+
// --- internal --------------------------------------------------------------
|
|
330
|
+
/**
|
|
331
|
+
* Pick the probe for this manual.
|
|
332
|
+
*
|
|
333
|
+
* A DECLARED health check wins for every type, including `mcp`. The author
|
|
334
|
+
* naming an endpoint is a stronger statement about how to test their tool
|
|
335
|
+
* than any inference of ours, and honouring it means a declaration is never
|
|
336
|
+
* silently ignored — which is the whole contract of the field.
|
|
337
|
+
*
|
|
338
|
+
* Failing that, an `mcp` manual tests itself: its handshake is authenticated,
|
|
339
|
+
* so connecting at all proves the token. `http` and `inline` have no such
|
|
340
|
+
* moment (an `http` manual's registration fetches its public DESCRIPTION; an
|
|
341
|
+
* `inline` manual's points at Bevel's own API), so with nothing declared
|
|
342
|
+
* there is genuinely nothing to call.
|
|
343
|
+
*/
|
|
344
|
+
async runProbe(userId, target) {
|
|
345
|
+
// A local-only tool runs in someone's own agent; this server cannot reach it
|
|
346
|
+
// (that is what `remote: false` MEANS), so a failure here would say nothing
|
|
347
|
+
// about the credential.
|
|
348
|
+
if (target.remote === false) {
|
|
349
|
+
return { status: 'unverifiable', detail: 'This tool runs only in a local agent, so the server cannot test it.' };
|
|
350
|
+
}
|
|
351
|
+
if (target.healthCheck)
|
|
352
|
+
return this.probeDeclared(userId, target, target.healthCheck);
|
|
353
|
+
if (target.type === 'mcp')
|
|
354
|
+
return this.probeMcpHandshake(userId, target);
|
|
355
|
+
return NO_PROBE;
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* Call the endpoint the manual declared, carrying the caller's credential.
|
|
359
|
+
*
|
|
360
|
+
* Headers default to the manual's own — that is where the credential normally
|
|
361
|
+
* lives, so the common case is a one-line `healthCheck: { url }` and the probe
|
|
362
|
+
* authenticates exactly like a real call.
|
|
363
|
+
*/
|
|
364
|
+
async probeDeclared(userId, target, check) {
|
|
365
|
+
// `headers` is already defaulted to the manual's own at parse time, so a
|
|
366
|
+
// one-line `healthCheck: { url }` still carries the credential.
|
|
367
|
+
const headerTemplate = check.headers ?? {};
|
|
368
|
+
// Collects what the substitutions resolve, so the provider cannot quote any
|
|
369
|
+
// of it back at a reader who is not allowed to see it.
|
|
370
|
+
const redactor = new SecretRedactor();
|
|
371
|
+
let url;
|
|
372
|
+
let headers;
|
|
373
|
+
try {
|
|
374
|
+
url = await this.substitute(userId, target.name, check.url, redactor);
|
|
375
|
+
headers = Object.fromEntries(await Promise.all(Object.entries(headerTemplate).map(async ([k, v]) => [k, await this.substitute(userId, target.name, v, redactor)])));
|
|
376
|
+
}
|
|
377
|
+
catch (err) {
|
|
378
|
+
// An unset variable is not a broken credential — the vault's own status
|
|
379
|
+
// already says "needs a key from you", and firing a request with an empty
|
|
380
|
+
// Bearer would turn that into a spurious rejection.
|
|
381
|
+
return { status: 'unverifiable', detail: err instanceof Error ? err.message : String(err) };
|
|
382
|
+
}
|
|
383
|
+
// A `.tool` may write its token straight into `headers` rather than through
|
|
384
|
+
// a `${VAR}`, and substitution never saw it. Record what is actually about
|
|
385
|
+
// to be sent, so the provider cannot quote it back at a reader.
|
|
386
|
+
rememberCredentialHeaders(headers, redactor);
|
|
387
|
+
rememberCredentialQuery(url, redactor);
|
|
388
|
+
// Re-check at fetch time even though the declaration was checked at parse
|
|
389
|
+
// time: a TEMPLATED host is unknowable until now, so this is the first
|
|
390
|
+
// moment the real target exists (`${HOST}` could resolve to the metadata IP).
|
|
391
|
+
try {
|
|
392
|
+
assertSafeFetchUrl(url, { label: 'healthCheck.url' });
|
|
393
|
+
}
|
|
394
|
+
catch (err) {
|
|
395
|
+
return { status: 'unverifiable', detail: err instanceof Error ? err.message : String(err) };
|
|
396
|
+
}
|
|
397
|
+
let res;
|
|
398
|
+
try {
|
|
399
|
+
res = await fetch(url, {
|
|
400
|
+
method: check.method ?? 'GET',
|
|
401
|
+
headers,
|
|
402
|
+
// Don't follow redirects: a validated host could 302 to an internal
|
|
403
|
+
// target, and a 3xx tells us nothing about the credential anyway.
|
|
404
|
+
redirect: 'manual',
|
|
405
|
+
signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
catch (err) {
|
|
409
|
+
// Redacted like everything else quoted from outside: a transport error
|
|
410
|
+
// names the url it failed on, and a templated url can carry a token.
|
|
411
|
+
return {
|
|
412
|
+
status: 'unverifiable',
|
|
413
|
+
detail: `Couldn't reach the provider to check: ${redactor.redact(err instanceof Error ? err.message : String(err))}`,
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
if (res.ok)
|
|
417
|
+
return OK;
|
|
418
|
+
if (res.status === 401 || res.status === 403) {
|
|
419
|
+
return { status: 'failed', detail: await describeRejection(res, redactor) };
|
|
420
|
+
}
|
|
421
|
+
return {
|
|
422
|
+
status: 'unverifiable',
|
|
423
|
+
detail: `The provider answered ${res.status}, which doesn't say whether the credential is valid.`,
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* Register the manual on a throwaway client and a session of its own: for a
|
|
428
|
+
* remote MCP server the handshake is authenticated, so a successful
|
|
429
|
+
* registration IS proof the token works, and a rejection is the provider's own
|
|
430
|
+
* verdict.
|
|
431
|
+
*/
|
|
432
|
+
async probeMcpHandshake(userId, target) {
|
|
433
|
+
if (!target.callTemplate) {
|
|
434
|
+
return { status: 'unverifiable', detail: "This tool's definition couldn't be resolved, so it wasn't tested." };
|
|
435
|
+
}
|
|
436
|
+
// See `withIsolatedServerKeys`: without this the probe can be answered by a
|
|
437
|
+
// session opened with an older credential.
|
|
438
|
+
const template = withIsolatedServerKeys(target.callTemplate);
|
|
439
|
+
const redactor = new SecretRedactor();
|
|
440
|
+
// The SAME fetch-time SSRF re-check the declared probe gets, for the same
|
|
441
|
+
// reason: a templated host does not exist at parse time, so the guard there
|
|
442
|
+
// could not see it. Without this, an `mcp` manual whose url is
|
|
443
|
+
// `https://${HOST}/mcp` reaches the metadata endpoint the moment someone
|
|
444
|
+
// clicks Test connection. `@utcp/mcp`'s own `ensureSecureMcpUrl` is no help
|
|
445
|
+
// here — it checks the SCHEME, and allows https to any host at all.
|
|
446
|
+
const unsafe = await this.resolveServerUrls(userId, target.name, template, redactor);
|
|
447
|
+
if (unsafe)
|
|
448
|
+
return unsafe;
|
|
449
|
+
// The rest of the template keeps its `${VAR}`s for UTCP's own loader; these
|
|
450
|
+
// values are read only so that nothing quoted below can echo one back.
|
|
451
|
+
await this.rememberTemplateSecrets(userId, target.name, target.callTemplate, redactor);
|
|
452
|
+
rememberLiteralServerHeaders(target.callTemplate, redactor);
|
|
453
|
+
const timedOut = {
|
|
454
|
+
status: 'unverifiable',
|
|
455
|
+
detail: `The server didn't answer within ${PROBE_TIMEOUT_MS / 1000}s, so the credential wasn't tested.`,
|
|
456
|
+
};
|
|
457
|
+
let client = null;
|
|
458
|
+
try {
|
|
459
|
+
// No loopback seeding (`API_URL`/`CONNECTION_KEY`): those belong to
|
|
460
|
+
// Bevel-hosted manuals, and this path only ever registers a third-party
|
|
461
|
+
// MCP server. Its `${VAR}`s resolve lazily through the caller's own vault
|
|
462
|
+
// loader, which is exactly the credential under test.
|
|
463
|
+
const config = new UtcpClientConfigSerializer().validateDict({
|
|
464
|
+
variables: {},
|
|
465
|
+
load_variables_from: [bevelSecretsLoaderConfig(userId)],
|
|
466
|
+
});
|
|
467
|
+
// No late-settle owner: a client that arrives after the deadline has no
|
|
468
|
+
// manual registered and therefore no session, and neither
|
|
469
|
+
// `CodeModeUtcpClient` nor the SDK client it wraps exposes any disposal.
|
|
470
|
+
const created = await withProbeTimeout(CodeModeUtcpClient.create(process.cwd(), config));
|
|
471
|
+
if (created === TIMED_OUT)
|
|
472
|
+
return timedOut;
|
|
473
|
+
client = created;
|
|
474
|
+
// The handshake IS the connection-opener, so its abandoned form gets a
|
|
475
|
+
// closing owner: past the deadline the `finally` below has already run
|
|
476
|
+
// and found nothing to close.
|
|
477
|
+
const dialled = client;
|
|
478
|
+
const result = await withProbeTimeout(registerManual(dialled, template), () => closeProbeSessions(dialled, template));
|
|
479
|
+
if (result === TIMED_OUT)
|
|
480
|
+
return timedOut;
|
|
481
|
+
if (result.ok)
|
|
482
|
+
return OK;
|
|
483
|
+
// Classified on the raw error, shown redacted: the provider's rejection
|
|
484
|
+
// text may quote the very credential under test back at us.
|
|
485
|
+
const detail = redactor.redact(result.error);
|
|
486
|
+
return looksLikeAuthRejection(result.error)
|
|
487
|
+
? { status: 'failed', detail }
|
|
488
|
+
: { status: 'unverifiable', detail: `Couldn't reach the provider to check: ${detail}` };
|
|
489
|
+
}
|
|
490
|
+
catch (err) {
|
|
491
|
+
return {
|
|
492
|
+
status: 'unverifiable',
|
|
493
|
+
detail: `Couldn't build the connection to test: ${redactor.redact(err instanceof Error ? err.message : String(err))}`,
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
finally {
|
|
497
|
+
// Runs on every path that got as far as a client: the session is keyed to
|
|
498
|
+
// this probe alone, so leaving it open leaks one Streamable-HTTP session
|
|
499
|
+
// per credential save. Past the deadline this finds nothing — the session
|
|
500
|
+
// does not exist yet — which is what the late-settle owner above is for.
|
|
501
|
+
if (client)
|
|
502
|
+
await closeProbeSessions(client, template);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* Resolve every MCP server url in `template` IN PLACE, or say why one of them
|
|
507
|
+
* may not be dialled.
|
|
508
|
+
*
|
|
509
|
+
* The substitution and the check are the same act, deliberately: checking a
|
|
510
|
+
* resolved url and then handing UTCP the `${HOST}` template back would leave
|
|
511
|
+
* the vault free to answer differently the second time. A concurrent
|
|
512
|
+
* `PUT …/vars/HOST` landing in that gap aims the handshake at whatever the
|
|
513
|
+
* new value says — the metadata endpoint, say — with the guard's approval
|
|
514
|
+
* behind it. Writing the resolved url back means the bytes that passed the
|
|
515
|
+
* check are the bytes dialled, which is how the declared probe already works.
|
|
516
|
+
*/
|
|
517
|
+
async resolveServerUrls(userId, manualName, template, redactor) {
|
|
518
|
+
const servers = template.config?.mcpServers;
|
|
519
|
+
for (const server of Object.values(servers ?? {})) {
|
|
520
|
+
// `stdio` servers carry a command, not a url, and are never reached from
|
|
521
|
+
// this process anyway (they imply `remote: false`, refused above).
|
|
522
|
+
const entry = server;
|
|
523
|
+
if (typeof entry.url !== 'string')
|
|
524
|
+
continue;
|
|
525
|
+
let resolved;
|
|
526
|
+
try {
|
|
527
|
+
resolved = await this.substitute(userId, manualName, entry.url, redactor);
|
|
528
|
+
}
|
|
529
|
+
catch (err) {
|
|
530
|
+
// An unset variable, exactly as on the declared path: nothing to test,
|
|
531
|
+
// and nothing said about the credential.
|
|
532
|
+
return { status: 'unverifiable', detail: err instanceof Error ? err.message : String(err) };
|
|
533
|
+
}
|
|
534
|
+
try {
|
|
535
|
+
assertSafeFetchUrl(resolved, { label: 'MCP server url' });
|
|
536
|
+
}
|
|
537
|
+
catch (err) {
|
|
538
|
+
return { status: 'unverifiable', detail: err instanceof Error ? err.message : String(err) };
|
|
539
|
+
}
|
|
540
|
+
entry.url = resolved;
|
|
541
|
+
rememberCredentialQuery(resolved, redactor);
|
|
542
|
+
}
|
|
543
|
+
return null;
|
|
544
|
+
}
|
|
545
|
+
/**
|
|
546
|
+
* Read the values behind every variable `template` references, purely so
|
|
547
|
+
* {@link SecretRedactor} can keep them out of anything quoted.
|
|
548
|
+
*
|
|
549
|
+
* The MCP path leaves headers and auth as `${VAR}` for UTCP's loader, so
|
|
550
|
+
* unlike the declared probe it never learns those values in passing — and a
|
|
551
|
+
* rejected handshake surfaces as prose from the transport, which on many
|
|
552
|
+
* servers includes the response body and therefore the key. Best-effort by
|
|
553
|
+
* design: redaction is a safety net over the quote, and failing to build it
|
|
554
|
+
* must not fail the probe.
|
|
555
|
+
*/
|
|
556
|
+
async rememberTemplateSecrets(userId, manualName, template, redactor) {
|
|
557
|
+
const names = new Set([...JSON.stringify(template).matchAll(varRefPattern())].map((m) => m[1] ?? m[2]));
|
|
558
|
+
for (const name of names) {
|
|
559
|
+
try {
|
|
560
|
+
const value = await this.secretsVault.resolve(userId, utcpNamespacedKey(manualName, name));
|
|
561
|
+
if (value)
|
|
562
|
+
redactor.remember(value);
|
|
563
|
+
}
|
|
564
|
+
catch {
|
|
565
|
+
// A variable we cannot read is one the provider cannot echo either.
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
/**
|
|
570
|
+
* Replace every variable reference with the caller's resolved secret. Throws
|
|
571
|
+
* when one has no value, so the caller can report "not set yet" rather than
|
|
572
|
+
* sending a request with an empty credential and reading the inevitable 401
|
|
573
|
+
* as proof the key is wrong.
|
|
574
|
+
*
|
|
575
|
+
* One left-to-right pass, like the SDK's own substitutor, rather than a
|
|
576
|
+
* replace per name: with bare `$VAR` accepted, substituting `$API` before
|
|
577
|
+
* `$API_KEY` would corrupt the longer reference.
|
|
578
|
+
*/
|
|
579
|
+
async substitute(userId, manualName, template, redactor) {
|
|
580
|
+
// UTCP leaves any string containing `$ref` alone (JSON-Schema references),
|
|
581
|
+
// so a probe that substituted one would stop testing what a real call sends.
|
|
582
|
+
if (template.includes('$ref'))
|
|
583
|
+
return template;
|
|
584
|
+
const names = new Set([...template.matchAll(varRefPattern())].map((m) => m[1] ?? m[2]));
|
|
585
|
+
if (names.size === 0)
|
|
586
|
+
return template;
|
|
587
|
+
const values = new Map();
|
|
588
|
+
for (const name of names) {
|
|
589
|
+
let value = await this.secretsVault.resolve(userId, utcpNamespacedKey(manualName, name));
|
|
590
|
+
if (value === null) {
|
|
591
|
+
// UTCP keeps `process.env` as its LAST resolution tier, so a real
|
|
592
|
+
// call can resolve what the vault does not hold (a deployment-level
|
|
593
|
+
// variable). The probe must test the request the call would send —
|
|
594
|
+
// and the SDK's `_getVariable` builds ONE `effectiveKey` (the
|
|
595
|
+
// namespace with doubled underscores plus the variable, exactly
|
|
596
|
+
// `utcpNamespacedKey`) and consults every tier, env included, under
|
|
597
|
+
// that single spelling. No bare-name lookup here: an env var under a
|
|
598
|
+
// spelling the call never reads would have the probe testing a
|
|
599
|
+
// request no call sends.
|
|
600
|
+
value = process.env[utcpNamespacedKey(manualName, name)] ?? null;
|
|
601
|
+
}
|
|
602
|
+
if (value === null)
|
|
603
|
+
throw new Error(`\${${name}} isn't set yet, so there was nothing to test.`);
|
|
604
|
+
values.set(name, value);
|
|
605
|
+
redactor?.remember(value);
|
|
606
|
+
}
|
|
607
|
+
return template.replace(varRefPattern(), (_full, braced, bare) => {
|
|
608
|
+
const name = braced ?? bare;
|
|
609
|
+
return name === undefined ? _full : (values.get(name) ?? _full);
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
/** How much of a rejecting body we are willing to read to quote it. */
|
|
614
|
+
const MAX_REJECTION_BYTES = 8 * 1024;
|
|
615
|
+
/** How much of what we read we are willing to show. */
|
|
616
|
+
const REJECTION_SNIPPET_CHARS = 200;
|
|
617
|
+
/**
|
|
618
|
+
* A short, human-readable reason from a rejecting response — the provider's own
|
|
619
|
+
* words are far more actionable than "401" — with every secret this probe
|
|
620
|
+
* resolved taken back out of it (see {@link SecretRedactor}). Bounded and
|
|
621
|
+
* best-effort: a body that is huge, binary, or unreadable falls back to the
|
|
622
|
+
* status line.
|
|
623
|
+
*
|
|
624
|
+
* Read from the STREAM with a byte cap rather than `res.text()`. The body here
|
|
625
|
+
* is written by whatever server the credential points at, so buffering all of
|
|
626
|
+
* it to keep 200 characters lets that server decide how much memory this
|
|
627
|
+
* process spends and how long an unattended probe takes. The reader is
|
|
628
|
+
* cancelled as soon as we have enough, which closes the connection rather than
|
|
629
|
+
* politely draining a response we already stopped caring about.
|
|
630
|
+
*/
|
|
631
|
+
async function describeRejection(res, redactor) {
|
|
632
|
+
const fallback = `The provider rejected this credential (${res.status}).`;
|
|
633
|
+
try {
|
|
634
|
+
// Redact BEFORE the snippet is cut, so a secret straddling the 200-character
|
|
635
|
+
// boundary loses its head rather than surviving as one.
|
|
636
|
+
const body = redactor.redact((await readCapped(res, MAX_REJECTION_BYTES)).trim());
|
|
637
|
+
if (!body)
|
|
638
|
+
return fallback;
|
|
639
|
+
const snippet = body.length > REJECTION_SNIPPET_CHARS ? `${body.slice(0, REJECTION_SNIPPET_CHARS)}…` : body;
|
|
640
|
+
return `${fallback} ${snippet}`;
|
|
641
|
+
}
|
|
642
|
+
catch {
|
|
643
|
+
return fallback;
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
/**
|
|
647
|
+
* At most `limit` bytes of a response body, decoded as text.
|
|
648
|
+
*
|
|
649
|
+
* Each CHUNK is sliced to the remaining budget before being decoded, not just
|
|
650
|
+
* counted against it: a provider that answers in one 5MB chunk would otherwise
|
|
651
|
+
* put the whole thing through the decoder before the loop noticed it was over,
|
|
652
|
+
* which is the bound bypassed by the very party it is meant to bound.
|
|
653
|
+
*
|
|
654
|
+
* Exported for its test — the property here is how much is held in memory,
|
|
655
|
+
* which the caller's 200-character snippet hides completely.
|
|
656
|
+
*/
|
|
657
|
+
export async function readCapped(res, limit) {
|
|
658
|
+
const reader = res.body?.getReader();
|
|
659
|
+
// No stream (a mocked or already-consumed response): fall back to the whole
|
|
660
|
+
// body, which for those cases is ours and small.
|
|
661
|
+
if (!reader)
|
|
662
|
+
return (await res.text()).slice(0, limit);
|
|
663
|
+
const decoder = new TextDecoder();
|
|
664
|
+
let out = '';
|
|
665
|
+
let read = 0;
|
|
666
|
+
try {
|
|
667
|
+
while (read < limit) {
|
|
668
|
+
const { done, value } = await reader.read();
|
|
669
|
+
if (done)
|
|
670
|
+
break;
|
|
671
|
+
const remaining = limit - read;
|
|
672
|
+
const chunk = value.byteLength > remaining ? value.subarray(0, remaining) : value;
|
|
673
|
+
read += chunk.byteLength;
|
|
674
|
+
out += decoder.decode(chunk, { stream: true });
|
|
675
|
+
if (chunk.byteLength < value.byteLength)
|
|
676
|
+
break;
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
finally {
|
|
680
|
+
await reader.cancel().catch(() => { });
|
|
681
|
+
}
|
|
682
|
+
return out;
|
|
683
|
+
}
|
|
684
|
+
//# sourceMappingURL=connection-probe.service.js.map
|