@vantageos/mcp-boilerplate 0.2.3 → 0.3.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/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +161 -0
- package/README.fr.md +323 -15
- package/README.md +306 -15
- package/dist/buildIdentity.d.ts +19 -0
- package/dist/buildIdentity.js +62 -0
- package/dist/declaredVariables.d.ts +205 -0
- package/dist/declaredVariables.js +451 -0
- package/dist/exampleItems.d.ts +15 -0
- package/dist/exampleItems.js +28 -1
- package/dist/httpApp.d.ts +17 -0
- package/dist/httpApp.js +19 -2
- package/dist/i18n.d.ts +10 -0
- package/dist/i18n.js +12 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +6 -0
- package/dist/lake/convex.d.ts +101 -0
- package/dist/lake/convex.js +224 -0
- package/dist/lake/reader.d.ts +88 -0
- package/dist/lake/reader.js +120 -0
- package/dist/locales/en.json +42 -0
- package/dist/locales/fr.json +42 -0
- package/dist/mcpServerFactory.d.ts +31 -14
- package/dist/mcpServerFactory.js +116 -48
- package/dist/oauth/authorizationServer.js +64 -18
- package/dist/oauth/clerk.d.ts +2 -0
- package/dist/oauth/clerk.js +15 -5
- package/dist/oauth/clientRegistry.d.ts +19 -4
- package/dist/oauth/clientRegistry.js +20 -10
- package/dist/oauth/picker.d.ts +7 -2
- package/dist/oauth/picker.js +90 -13
- package/dist/oauth/registerLimiter.d.ts +34 -0
- package/dist/oauth/registerLimiter.js +95 -0
- package/dist/oauth/settings.d.ts +4 -0
- package/dist/oauth/settings.js +4 -0
- package/dist/pagination.d.ts +66 -0
- package/dist/pagination.js +100 -0
- package/dist/serverManifest.d.ts +32 -0
- package/dist/serverManifest.js +68 -0
- package/dist/stdio.js +5 -1
- package/dist/tenancy.d.ts +1 -1
- package/dist/toolRecord.d.ts +94 -0
- package/dist/toolRecord.js +52 -0
- package/dist/ui/uiResource.d.ts +11 -4
- package/dist/ui/uiResource.js +8 -4
- package/dist/upstream/clientCredentials.d.ts +106 -0
- package/dist/upstream/clientCredentials.js +283 -0
- package/dist/upstream/paginatedClient.d.ts +142 -0
- package/dist/upstream/paginatedClient.js +422 -0
- package/dist/verifiedPerson.d.ts +85 -0
- package/dist/verifiedPerson.js +112 -0
- package/docs/oauth-stores.md +2 -1
- package/examples/oauth.env.example +7 -0
- package/mcp-server.json +13 -0
- package/package.json +19 -4
- package/plugin.json +1 -1
- package/skills/connect-mcp-server/SKILL.md +5 -5
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Outbound OAuth 2 `client_credentials` token source (RFC 6749 §4.4), for a server derived from
|
|
3
|
+
* this boilerplate that reads a third-party upstream API. It is the client side only: the inbound
|
|
4
|
+
* authorization server in `src/oauth/` is unrelated and untouched.
|
|
5
|
+
*
|
|
6
|
+
* CREATE IT ONCE, AT MODULE SCOPE. The MCP server is rebuilt per request, so a token source built
|
|
7
|
+
* inside the factory or a tool handler would fetch a fresh token on every request and defeat the
|
|
8
|
+
* cache. Build it once per process and reuse it from every request:
|
|
9
|
+
*
|
|
10
|
+
* ```ts
|
|
11
|
+
* // src/upstream.ts — evaluated once per process
|
|
12
|
+
* import { clientCredentialsFromEnv, createClientCredentialsTokenSource } from "./upstream/clientCredentials.js";
|
|
13
|
+
*
|
|
14
|
+
* export const upstream = createClientCredentialsTokenSource(
|
|
15
|
+
* clientCredentialsFromEnv({
|
|
16
|
+
* clientIdVar: "UPSTREAM_CLIENT_ID",
|
|
17
|
+
* clientSecretVar: "UPSTREAM_CLIENT_SECRET",
|
|
18
|
+
* tokenUrlVar: "UPSTREAM_TOKEN_URL",
|
|
19
|
+
* scopeVar: "UPSTREAM_SCOPE",
|
|
20
|
+
* })
|
|
21
|
+
* );
|
|
22
|
+
*
|
|
23
|
+
* // in a tool handler, on any request
|
|
24
|
+
* const res = await upstream.fetchWithToken("https://api.upstream.example/v1/things");
|
|
25
|
+
* ```
|
|
26
|
+
*
|
|
27
|
+
* The variable names above are examples: the caller chooses them, nothing here hardcodes one.
|
|
28
|
+
*
|
|
29
|
+
* Behaviour:
|
|
30
|
+
* - The token is cached until `expires_in − refreshSkewSeconds` (default skew 60 s), measured from
|
|
31
|
+
* the moment the token was REQUESTED, on the injectable `now()` clock.
|
|
32
|
+
* - Concurrent callers while a token is being fetched share ONE in-flight request (single-flight).
|
|
33
|
+
* A failed fetch rejects every waiter and is never cached: the next call fetches again.
|
|
34
|
+
* - `fetchWithToken` attaches `Authorization: Bearer`; on a 401 it invalidates the token, fetches a
|
|
35
|
+
* new one ONCE and retries ONCE. A second 401 is returned to the caller, never looped.
|
|
36
|
+
* - Every failure of the token endpoint is an `UpstreamTokenError` naming the HTTP status and the
|
|
37
|
+
* OAuth `error` code. It never contains the client secret, the Basic header, an access token,
|
|
38
|
+
* or the endpoint's `error_description` (free text an endpoint may fill with the request).
|
|
39
|
+
* - A response it cannot read is refused (fail closed); there is no fallback.
|
|
40
|
+
*
|
|
41
|
+
* Errors are operator-facing (configuration and upstream failures, logged server-side), so they
|
|
42
|
+
* stay in English like `src/oauth/` and `src/envKnobs.ts`; they never reach the MCP client as a
|
|
43
|
+
* localised tool message.
|
|
44
|
+
*/
|
|
45
|
+
import { rawKnob } from "../envKnobs.js";
|
|
46
|
+
/** A failure of the upstream token endpoint. Carries no credential and no token. */
|
|
47
|
+
export class UpstreamTokenError extends Error {
|
|
48
|
+
/** HTTP status of the token endpoint, when it answered. */
|
|
49
|
+
status;
|
|
50
|
+
/** The OAuth `error` code (RFC 6749 §5.2), when the endpoint sent a readable one. */
|
|
51
|
+
oauthError;
|
|
52
|
+
constructor(message, details = {}) {
|
|
53
|
+
super(message);
|
|
54
|
+
this.name = "UpstreamTokenError";
|
|
55
|
+
this.status = details.status;
|
|
56
|
+
this.oauthError = details.oauthError;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const DEFAULT_SKEW_SECONDS = 60;
|
|
60
|
+
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
61
|
+
/**
|
|
62
|
+
* `expires_in` is RECOMMENDED, not required (RFC 6749 §5.1). When the endpoint omits it, the token
|
|
63
|
+
* is assumed to live 300 s: short enough that a token which really lives less is soon replaced (and
|
|
64
|
+
* a 401 in between is absorbed by the one-shot refetch), long enough not to hit the endpoint on
|
|
65
|
+
* every call. With the default 60 s skew it is refreshed after 240 s.
|
|
66
|
+
*/
|
|
67
|
+
const DEFAULT_EXPIRES_IN_SECONDS = 300;
|
|
68
|
+
/** RFC 6749 §5.2 `error` charset (%x20-21 / %x23-5B / %x5D-7E), capped at 64 characters. */
|
|
69
|
+
const OAUTH_ERROR_CODE = /^[\x20\x21\x23-\x5B\x5D-\x7E]{1,64}$/;
|
|
70
|
+
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
71
|
+
/** application/x-www-form-urlencoded encoding of one value (RFC 6749 Appendix B). */
|
|
72
|
+
function formEncode(value) {
|
|
73
|
+
return new URLSearchParams([["", value]]).toString().slice(1);
|
|
74
|
+
}
|
|
75
|
+
function validateTokenUrl(tokenUrl) {
|
|
76
|
+
let url;
|
|
77
|
+
try {
|
|
78
|
+
url = new URL(tokenUrl);
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
throw new Error("client_credentials: tokenUrl is not a valid URL (value not shown)");
|
|
82
|
+
}
|
|
83
|
+
const loopbackHttp = url.protocol === "http:" && LOOPBACK_HOSTS.has(url.hostname);
|
|
84
|
+
if (url.protocol !== "https:" && !loopbackHttp) {
|
|
85
|
+
throw new Error("client_credentials: tokenUrl must use https (http is accepted on loopback only) (value not shown)");
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
export function createClientCredentialsTokenSource(options) {
|
|
89
|
+
const { tokenUrl, clientId, clientSecret, scope, audience, authMethod = "client_secret_basic", refreshSkewSeconds = DEFAULT_SKEW_SECONDS, timeoutMs = DEFAULT_TIMEOUT_MS, fetchImpl = fetch, now = Date.now, } = options;
|
|
90
|
+
validateTokenUrl(tokenUrl);
|
|
91
|
+
if (!clientId)
|
|
92
|
+
throw new Error("client_credentials: clientId is empty");
|
|
93
|
+
if (!clientSecret)
|
|
94
|
+
throw new Error("client_credentials: clientSecret is empty");
|
|
95
|
+
if (authMethod !== "client_secret_basic" && authMethod !== "client_secret_post") {
|
|
96
|
+
throw new Error('client_credentials: authMethod must be "client_secret_basic" or "client_secret_post"');
|
|
97
|
+
}
|
|
98
|
+
if (!Number.isFinite(refreshSkewSeconds) || refreshSkewSeconds < 0) {
|
|
99
|
+
throw new Error("client_credentials: refreshSkewSeconds must be a non-negative number");
|
|
100
|
+
}
|
|
101
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
|
102
|
+
throw new Error("client_credentials: timeoutMs must be a positive number");
|
|
103
|
+
}
|
|
104
|
+
// Each part is form-encoded first (RFC 6749 §2.3.1), so the input to btoa is ASCII.
|
|
105
|
+
const basicCredentials = btoa(`${formEncode(clientId)}:${formEncode(clientSecret)}`);
|
|
106
|
+
const basicHeader = `Basic ${basicCredentials}`;
|
|
107
|
+
/** The endpoint's `error` code, or undefined when unreadable or echoing a credential. */
|
|
108
|
+
const readOauthError = (body) => {
|
|
109
|
+
if (!body || typeof body !== "object" || Array.isArray(body))
|
|
110
|
+
return undefined;
|
|
111
|
+
const code = body.error;
|
|
112
|
+
if (typeof code !== "string" || !OAUTH_ERROR_CODE.test(code))
|
|
113
|
+
return undefined;
|
|
114
|
+
if (code.includes(clientSecret) || code.includes(formEncode(clientSecret)))
|
|
115
|
+
return undefined;
|
|
116
|
+
if (code.includes(basicCredentials))
|
|
117
|
+
return undefined;
|
|
118
|
+
return code;
|
|
119
|
+
};
|
|
120
|
+
const requestToken = async () => {
|
|
121
|
+
const requestedAt = now();
|
|
122
|
+
const form = new URLSearchParams({ grant_type: "client_credentials" });
|
|
123
|
+
if (scope)
|
|
124
|
+
form.set("scope", scope);
|
|
125
|
+
if (audience)
|
|
126
|
+
form.set("audience", audience);
|
|
127
|
+
const headers = {
|
|
128
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
129
|
+
Accept: "application/json",
|
|
130
|
+
};
|
|
131
|
+
if (authMethod === "client_secret_basic") {
|
|
132
|
+
headers.Authorization = basicHeader;
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
form.set("client_id", clientId);
|
|
136
|
+
form.set("client_secret", clientSecret);
|
|
137
|
+
}
|
|
138
|
+
let res;
|
|
139
|
+
try {
|
|
140
|
+
res = await fetchImpl(tokenUrl, {
|
|
141
|
+
method: "POST",
|
|
142
|
+
headers,
|
|
143
|
+
body: form.toString(),
|
|
144
|
+
// A redirect is refused, never followed: a 307/308 would re-send this body, which carries
|
|
145
|
+
// the client secret under client_secret_post, to whatever host the Location names.
|
|
146
|
+
redirect: "error",
|
|
147
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
catch (err) {
|
|
151
|
+
// Only the error's NAME travels: a transport message is not ours to vouch for. A refused
|
|
152
|
+
// redirect lands here too (fetch rejects with a TypeError): its target never travels.
|
|
153
|
+
const kind = err instanceof Error ? err.name : "unknown error";
|
|
154
|
+
throw new UpstreamTokenError(`Upstream token endpoint unreachable (${kind})`);
|
|
155
|
+
}
|
|
156
|
+
let body;
|
|
157
|
+
let readable = true;
|
|
158
|
+
try {
|
|
159
|
+
body = await res.json();
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
readable = false;
|
|
163
|
+
}
|
|
164
|
+
if (!res.ok) {
|
|
165
|
+
const oauthError = readable ? readOauthError(body) : undefined;
|
|
166
|
+
throw new UpstreamTokenError(`Upstream token endpoint answered ${res.status}${oauthError ? ` (error: ${oauthError})` : " (no readable OAuth error code)"}`, { status: res.status, oauthError });
|
|
167
|
+
}
|
|
168
|
+
const refuse = (what) => new UpstreamTokenError(`Upstream token endpoint answered ${res.status} with ${what}`, {
|
|
169
|
+
status: res.status,
|
|
170
|
+
});
|
|
171
|
+
if (!readable)
|
|
172
|
+
throw refuse("a body that is not JSON");
|
|
173
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
174
|
+
throw refuse("a JSON body that is not an object");
|
|
175
|
+
}
|
|
176
|
+
const fields = body;
|
|
177
|
+
const token = fields.access_token;
|
|
178
|
+
if (typeof token !== "string" || token === "") {
|
|
179
|
+
throw refuse("no usable access_token");
|
|
180
|
+
}
|
|
181
|
+
// token_type is REQUIRED by RFC 6749 §5.1 but some endpoints omit it: an absent type is
|
|
182
|
+
// taken as Bearer. A PRESENT type other than Bearer (DPoP, mac, ...) cannot be sent as
|
|
183
|
+
// `Authorization: Bearer`, so it is refused.
|
|
184
|
+
const tokenType = fields.token_type;
|
|
185
|
+
if (tokenType !== undefined) {
|
|
186
|
+
if (typeof tokenType !== "string" || tokenType.toLowerCase() !== "bearer") {
|
|
187
|
+
throw refuse("a token_type other than Bearer");
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
let expiresIn;
|
|
191
|
+
const rawExpiresIn = fields.expires_in;
|
|
192
|
+
if (rawExpiresIn === undefined) {
|
|
193
|
+
expiresIn = DEFAULT_EXPIRES_IN_SECONDS; // see the constant: absent means 300 s
|
|
194
|
+
}
|
|
195
|
+
else if (typeof rawExpiresIn === "number") {
|
|
196
|
+
expiresIn = rawExpiresIn;
|
|
197
|
+
}
|
|
198
|
+
else if (typeof rawExpiresIn === "string" && /^\d+$/.test(rawExpiresIn)) {
|
|
199
|
+
// Some endpoints send the number as a string; only plain digits are read.
|
|
200
|
+
expiresIn = Number(rawExpiresIn);
|
|
201
|
+
}
|
|
202
|
+
else {
|
|
203
|
+
throw refuse("an unreadable expires_in");
|
|
204
|
+
}
|
|
205
|
+
if (!Number.isFinite(expiresIn) || expiresIn < 0) {
|
|
206
|
+
throw refuse("an unreadable expires_in");
|
|
207
|
+
}
|
|
208
|
+
// A lifetime shorter than twice the skew would be stale on arrival and refetched on every
|
|
209
|
+
// call: such a token is refreshed at half its lifetime instead.
|
|
210
|
+
const lead = Math.min(refreshSkewSeconds, expiresIn / 2);
|
|
211
|
+
return { token, refreshAt: requestedAt + (expiresIn - lead) * 1000 };
|
|
212
|
+
};
|
|
213
|
+
let cached;
|
|
214
|
+
let inflight;
|
|
215
|
+
const getToken = () => {
|
|
216
|
+
if (cached && now() < cached.refreshAt)
|
|
217
|
+
return Promise.resolve(cached.token);
|
|
218
|
+
if (!inflight) {
|
|
219
|
+
inflight = requestToken()
|
|
220
|
+
.then((fresh) => {
|
|
221
|
+
cached = fresh;
|
|
222
|
+
return fresh.token;
|
|
223
|
+
})
|
|
224
|
+
.finally(() => {
|
|
225
|
+
inflight = undefined;
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
return inflight;
|
|
229
|
+
};
|
|
230
|
+
const invalidate = (token) => {
|
|
231
|
+
if (token === undefined || cached?.token === token)
|
|
232
|
+
cached = undefined;
|
|
233
|
+
};
|
|
234
|
+
const fetchWithToken = async (input, init) => {
|
|
235
|
+
// A stream body is consumed by the first send and cannot be replayed: such a request gets
|
|
236
|
+
// the token refresh on a 401 but no retry; the 401 is returned to the caller.
|
|
237
|
+
const replayable = !(init?.body instanceof ReadableStream);
|
|
238
|
+
const send = (token, last) => {
|
|
239
|
+
const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined));
|
|
240
|
+
headers.set("Authorization", `Bearer ${token}`);
|
|
241
|
+
// A Request input is cloned for the first send so its body is still there for the retry.
|
|
242
|
+
const target = input instanceof Request && !last ? input.clone() : input;
|
|
243
|
+
// The caller's init is spread as given, redirect mode included: the caller decides whether
|
|
244
|
+
// its API request follows redirects (the paginated client passes redirect: "error").
|
|
245
|
+
return fetchImpl(target, { ...init, headers });
|
|
246
|
+
};
|
|
247
|
+
const token = await getToken();
|
|
248
|
+
const first = await send(token, !replayable);
|
|
249
|
+
if (first.status !== 401)
|
|
250
|
+
return first;
|
|
251
|
+
invalidate(token);
|
|
252
|
+
if (!replayable)
|
|
253
|
+
return first;
|
|
254
|
+
await first.body?.cancel().catch(() => { });
|
|
255
|
+
return send(await getToken(), true);
|
|
256
|
+
};
|
|
257
|
+
return { getToken, invalidate, fetchWithToken };
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Reads the token source's configuration from the variables the caller names. Unset and empty
|
|
261
|
+
* are both missing. Every missing required variable is named in one error; no value is ever
|
|
262
|
+
* shown. Pass the result (plus any `fetchImpl`, `authMethod`, ...) to
|
|
263
|
+
* `createClientCredentialsTokenSource`.
|
|
264
|
+
*/
|
|
265
|
+
export function clientCredentialsFromEnv(vars, env = process.env) {
|
|
266
|
+
const required = [vars.clientIdVar, vars.clientSecretVar, vars.tokenUrlVar];
|
|
267
|
+
const missing = required.filter((name) => rawKnob(env, name) === undefined);
|
|
268
|
+
if (missing.length > 0) {
|
|
269
|
+
throw new Error(`client_credentials: missing environment variable${missing.length > 1 ? "s" : ""} ${missing.join(", ")} (values not shown)`);
|
|
270
|
+
}
|
|
271
|
+
const options = {
|
|
272
|
+
clientId: rawKnob(env, vars.clientIdVar),
|
|
273
|
+
clientSecret: rawKnob(env, vars.clientSecretVar),
|
|
274
|
+
tokenUrl: rawKnob(env, vars.tokenUrlVar),
|
|
275
|
+
};
|
|
276
|
+
const scope = vars.scopeVar ? rawKnob(env, vars.scopeVar) : undefined;
|
|
277
|
+
if (scope !== undefined)
|
|
278
|
+
options.scope = scope;
|
|
279
|
+
const audience = vars.audienceVar ? rawKnob(env, vars.audienceVar) : undefined;
|
|
280
|
+
if (audience !== undefined)
|
|
281
|
+
options.audience = audience;
|
|
282
|
+
return options;
|
|
283
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Paginated upstream client, for a server derived from this boilerplate that reads a third-party
|
|
3
|
+
* endpoint paginated by OFFSET and LIMIT. The upstream is configuration: the parameter names,
|
|
4
|
+
* where they travel (query string or JSON body) and the HTTP method are options, nothing here
|
|
5
|
+
* names a vendor.
|
|
6
|
+
*
|
|
7
|
+
* Two reads:
|
|
8
|
+
* - `readAll(path, params)` walks the offset pages until a short or empty page (or, when the
|
|
9
|
+
* response declares a total, until the total is reached) and returns every row. It is bounded
|
|
10
|
+
* by a REQUIRED finite `rowCeiling`: a set that would exceed it is refused with an
|
|
11
|
+
* `UpstreamPageError` (`row_ceiling_exceeded`) naming the ceiling. A truncated set is never
|
|
12
|
+
* returned as if it were complete.
|
|
13
|
+
* - `readPage(path, params, cursor?)` reads ONE upstream page per MCP call and returns
|
|
14
|
+
* `{ rows, nextCursor?, exhausted }`. The cursor is opaque: base64url of a small JSON object
|
|
15
|
+
* carrying a version, the upstream offset and a fingerprint of path + params. A cursor that
|
|
16
|
+
* does not decode, is replayed against another path or other params, or was altered without
|
|
17
|
+
* recomputing its fingerprint is refused with the `invalid_cursor` semantics of
|
|
18
|
+
* `src/pagination.ts` (`InvalidCursorError`), never read as page one. The fingerprint is an
|
|
19
|
+
* UNKEYED hash: it detects misuse, not forgery. Anyone can mint a well-formed cursor at any
|
|
20
|
+
* offset, which is acceptable because the offset carries no authority (the caller could ask
|
|
21
|
+
* for that offset anyway): a minted offset above the ceiling is refused before any request,
|
|
22
|
+
* and the same ceiling bounds the page-by-page walk.
|
|
23
|
+
*
|
|
24
|
+
* ```ts
|
|
25
|
+
* // src/upstream.ts — evaluated once per process, beside the token source
|
|
26
|
+
* export const things = createPaginatedUpstreamClient({
|
|
27
|
+
* fetchImpl: upstream.fetchWithToken, // G1's token source; default: the global fetch
|
|
28
|
+
* baseUrl: "https://api.upstream.example/v1/",
|
|
29
|
+
* allowedOrigins: ["https://api.upstream.example"],
|
|
30
|
+
* offsetParam: "offset",
|
|
31
|
+
* limitParam: "limit",
|
|
32
|
+
* placement: "jsonBody", // or "query"
|
|
33
|
+
* rowCeiling: 5000,
|
|
34
|
+
* extractRows: (body) => (body as { data: unknown[] }).data,
|
|
35
|
+
* });
|
|
36
|
+
* ```
|
|
37
|
+
*
|
|
38
|
+
* Safety:
|
|
39
|
+
* - Every request URL is resolved against `baseUrl` and its origin must be in `allowedOrigins`,
|
|
40
|
+
* checked BEFORE any network call: a path (possibly from a tool argument) naming another host
|
|
41
|
+
* never receives the bearer token. Origins are https, or http on loopback only (as G1).
|
|
42
|
+
* - Redirects are never followed (`redirect: "error"`): a 30x from an allowed origin cannot carry
|
|
43
|
+
* the request to another host. The refused redirect surfaces as `unreachable`, by error kind.
|
|
44
|
+
* - 429 and 503 are retried with exponential backoff and jitter, honouring `Retry-After`
|
|
45
|
+
* (seconds or HTTP date) capped by `maxDelayMs`; after `maxAttempts` the error names the
|
|
46
|
+
* status and the attempt count. No other status is retried, and neither is a transport
|
|
47
|
+
* failure (its error KIND is reported, never its message).
|
|
48
|
+
* - A body that is not JSON, an extractor that throws or does not return an array, an unreadable
|
|
49
|
+
* total, or a page longer than the limit asked for is refused (fail closed). There is no
|
|
50
|
+
* silent empty page: an empty page before a declared total is `inconsistent_page`, so a walk
|
|
51
|
+
* can never re-request the same offset forever.
|
|
52
|
+
* - Errors may name the request PATH (pathname only) and the HTTP status. They never carry a
|
|
53
|
+
* header, a token, the query string, a parameter value or the response body.
|
|
54
|
+
*
|
|
55
|
+
* Offset pagination over an order the upstream does not declare can skip or repeat rows when the
|
|
56
|
+
* data moves between two pages. This client cannot detect that; pass a sort parameter in
|
|
57
|
+
* `params` when the upstream offers one. And when the upstream silently caps the page below
|
|
58
|
+
* `pageSize`, a short page reads as the end of the set: keep `pageSize` within the upstream's
|
|
59
|
+
* real maximum, or supply `extractTotal`, which then decides the end instead.
|
|
60
|
+
*
|
|
61
|
+
* Errors are operator-facing (logged server-side), in English like `clientCredentials.ts`.
|
|
62
|
+
*/
|
|
63
|
+
export type PaginationPlacement = "query" | "jsonBody";
|
|
64
|
+
/** Any `fetch`-compatible function: the global fetch, or G1's `tokenSource.fetchWithToken`. */
|
|
65
|
+
export type FetchLike = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
|
66
|
+
export interface PaginatedUpstreamOptions<Row = unknown> {
|
|
67
|
+
/** Default: the global fetch. */
|
|
68
|
+
fetchImpl?: FetchLike;
|
|
69
|
+
/** Paths are resolved against it (`new URL(path, baseUrl)`). Its origin must be allowed. */
|
|
70
|
+
baseUrl: string;
|
|
71
|
+
/** REQUIRED, non-empty. Exact origins (`https://host[:port]`), https or loopback http. */
|
|
72
|
+
allowedOrigins: readonly string[];
|
|
73
|
+
/** Name of the upstream offset parameter. */
|
|
74
|
+
offsetParam: string;
|
|
75
|
+
/** Name of the upstream page-size parameter. */
|
|
76
|
+
limitParam: string;
|
|
77
|
+
/** Where params, offset and limit travel. */
|
|
78
|
+
placement: PaginationPlacement;
|
|
79
|
+
/** Default: GET for `query`, POST for `jsonBody`. `jsonBody` refuses GET and HEAD. */
|
|
80
|
+
method?: string;
|
|
81
|
+
/** Rows asked per upstream page. Default 100. */
|
|
82
|
+
pageSize?: number;
|
|
83
|
+
/** REQUIRED. The most rows one walk may return; a positive finite integer. */
|
|
84
|
+
rowCeiling: number;
|
|
85
|
+
/** Returns the rows of one parsed page. Must return an array. */
|
|
86
|
+
extractRows: (body: unknown) => Row[];
|
|
87
|
+
/** Optional: the total the page declares (a non-negative integer), or undefined. */
|
|
88
|
+
extractTotal?: (body: unknown) => number | undefined;
|
|
89
|
+
/** Headers added to every request (e.g. `Accept`). */
|
|
90
|
+
headers?: Record<string, string>;
|
|
91
|
+
/** Total attempts per page on 429/503, first one included. Default 5. */
|
|
92
|
+
maxAttempts?: number;
|
|
93
|
+
/** First backoff delay. Default 500 ms. */
|
|
94
|
+
baseDelayMs?: number;
|
|
95
|
+
/** Cap on any one delay, `Retry-After` included. Default 30 000 ms. */
|
|
96
|
+
maxDelayMs?: number;
|
|
97
|
+
/** Time budget of one request. Default 30 s. */
|
|
98
|
+
timeoutMs?: number;
|
|
99
|
+
/** Default: a `setTimeout` promise. */
|
|
100
|
+
sleep?: (ms: number) => Promise<void>;
|
|
101
|
+
/** In [0, 1). Default `Math.random`. */
|
|
102
|
+
random?: () => number;
|
|
103
|
+
/** Milliseconds since the epoch, for `Retry-After` dates. Default `Date.now`. */
|
|
104
|
+
now?: () => number;
|
|
105
|
+
}
|
|
106
|
+
export type ParamValue = string | number | boolean | null | ParamValue[] | ParamObject;
|
|
107
|
+
export interface ParamObject {
|
|
108
|
+
[key: string]: ParamValue | undefined;
|
|
109
|
+
}
|
|
110
|
+
export type UpstreamParams = Record<string, ParamValue | undefined>;
|
|
111
|
+
export interface UpstreamPage<Row> {
|
|
112
|
+
rows: Row[];
|
|
113
|
+
/** Present exactly when more rows may follow. */
|
|
114
|
+
nextCursor?: string;
|
|
115
|
+
exhausted: boolean;
|
|
116
|
+
/** The total the page declared, when `extractTotal` is set. */
|
|
117
|
+
total?: number;
|
|
118
|
+
}
|
|
119
|
+
export interface PaginatedUpstreamClient<Row> {
|
|
120
|
+
readAll(path: string, params: UpstreamParams): Promise<Row[]>;
|
|
121
|
+
readPage(path: string, params: UpstreamParams, cursor?: string): Promise<UpstreamPage<Row>>;
|
|
122
|
+
}
|
|
123
|
+
export type UpstreamPageErrorCode = "origin_not_allowed" | "invalid_params" | "row_ceiling_exceeded" | "rate_limited" | "upstream_status" | "unreachable" | "unreadable_page" | "inconsistent_page";
|
|
124
|
+
/** A refused or failed upstream page read. Carries no header, token, parameter or body. */
|
|
125
|
+
export declare class UpstreamPageError extends Error {
|
|
126
|
+
readonly code: UpstreamPageErrorCode;
|
|
127
|
+
/** The request pathname (no query string), when a request was built. */
|
|
128
|
+
readonly path: string | undefined;
|
|
129
|
+
/** HTTP status, when the upstream answered. */
|
|
130
|
+
readonly status: number | undefined;
|
|
131
|
+
/** Attempts made, on `rate_limited`. */
|
|
132
|
+
readonly attempts: number | undefined;
|
|
133
|
+
/** The ceiling, on `row_ceiling_exceeded`. */
|
|
134
|
+
readonly rowCeiling: number | undefined;
|
|
135
|
+
constructor(code: UpstreamPageErrorCode, message: string, details?: {
|
|
136
|
+
path?: string;
|
|
137
|
+
status?: number;
|
|
138
|
+
attempts?: number;
|
|
139
|
+
rowCeiling?: number;
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
export declare function createPaginatedUpstreamClient<Row = unknown>(options: PaginatedUpstreamOptions<Row>): PaginatedUpstreamClient<Row>;
|