@vantageos/mcp-boilerplate 0.2.4 → 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.
Files changed (57) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +147 -0
  3. package/README.fr.md +318 -15
  4. package/README.md +302 -15
  5. package/dist/buildIdentity.d.ts +19 -0
  6. package/dist/buildIdentity.js +62 -0
  7. package/dist/declaredVariables.d.ts +205 -0
  8. package/dist/declaredVariables.js +451 -0
  9. package/dist/exampleItems.d.ts +15 -0
  10. package/dist/exampleItems.js +28 -1
  11. package/dist/httpApp.d.ts +17 -0
  12. package/dist/httpApp.js +19 -2
  13. package/dist/i18n.d.ts +10 -0
  14. package/dist/i18n.js +12 -0
  15. package/dist/index.d.ts +12 -0
  16. package/dist/index.js +6 -0
  17. package/dist/lake/convex.d.ts +101 -0
  18. package/dist/lake/convex.js +224 -0
  19. package/dist/lake/reader.d.ts +88 -0
  20. package/dist/lake/reader.js +120 -0
  21. package/dist/locales/en.json +42 -0
  22. package/dist/locales/fr.json +42 -0
  23. package/dist/mcpServerFactory.d.ts +31 -14
  24. package/dist/mcpServerFactory.js +116 -48
  25. package/dist/oauth/authorizationServer.js +63 -18
  26. package/dist/oauth/clerk.d.ts +2 -0
  27. package/dist/oauth/clerk.js +15 -5
  28. package/dist/oauth/clientRegistry.d.ts +19 -4
  29. package/dist/oauth/clientRegistry.js +20 -10
  30. package/dist/oauth/picker.d.ts +5 -2
  31. package/dist/oauth/picker.js +34 -4
  32. package/dist/oauth/registerLimiter.d.ts +34 -0
  33. package/dist/oauth/registerLimiter.js +95 -0
  34. package/dist/oauth/settings.d.ts +2 -0
  35. package/dist/oauth/settings.js +2 -0
  36. package/dist/pagination.d.ts +66 -0
  37. package/dist/pagination.js +100 -0
  38. package/dist/serverManifest.d.ts +32 -0
  39. package/dist/serverManifest.js +68 -0
  40. package/dist/stdio.js +5 -1
  41. package/dist/tenancy.d.ts +1 -1
  42. package/dist/toolRecord.d.ts +94 -0
  43. package/dist/toolRecord.js +52 -0
  44. package/dist/ui/uiResource.d.ts +11 -4
  45. package/dist/ui/uiResource.js +8 -4
  46. package/dist/upstream/clientCredentials.d.ts +106 -0
  47. package/dist/upstream/clientCredentials.js +283 -0
  48. package/dist/upstream/paginatedClient.d.ts +142 -0
  49. package/dist/upstream/paginatedClient.js +422 -0
  50. package/dist/verifiedPerson.d.ts +85 -0
  51. package/dist/verifiedPerson.js +112 -0
  52. package/docs/oauth-stores.md +2 -1
  53. package/examples/oauth.env.example +5 -0
  54. package/mcp-server.json +13 -0
  55. package/package.json +19 -4
  56. package/plugin.json +1 -1
  57. 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>;