@belvador/api 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,27 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Belvador
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
23
+ ---
24
+
25
+ This software depends on the Sentry JavaScript SDK (https://github.com/getsentry/sentry-javascript),
26
+ which is distributed under its own MIT License (Copyright (c) Functional Software, Inc. dba Sentry)
27
+ in each of its npm packages. Belvador is not affiliated with or endorsed by Sentry.
package/README.md CHANGED
@@ -1,3 +1,59 @@
1
- # Temporary Holding Version
1
+ # @belvador/api
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Typed client for the [Belvador API](https://belvador.dev/docs/reference/api) (`https://api.belvador.dev`, OpenAPI 3.1).
4
+ Every operation is called by its OpenAPI `operationId` with parameters in the API's wire names (snake_case);
5
+ the operation table and parameter types are generated from the same registry as the OpenAPI document.
6
+
7
+ ```sh
8
+ npm install @belvador/api
9
+ ```
10
+
11
+ ```ts
12
+ import { BelvadorApiError, createClient } from "@belvador/api";
13
+
14
+ const belvador = createClient({ token: process.env.BELVADOR_TOKEN! });
15
+
16
+ const { data } = await belvador.call("issues.list", { status: "unresolved", period: "24h", limit: 20 });
17
+
18
+ for await (const rows of belvador.pages("monitors.list", { limit: 100 })) console.log(rows.length);
19
+
20
+ try {
21
+ await belvador.call(
22
+ "monitors.create",
23
+ {
24
+ project_id: 1,
25
+ slug: "nightly-backup",
26
+ name: "Nightly backup",
27
+ kind: "crontab",
28
+ schedule: { type: "crontab", value: "0 2 * * *" },
29
+ timezone: "UTC",
30
+ checkin_margin_min: 5,
31
+ max_runtime_min: 30,
32
+ failure_threshold: 1,
33
+ recovery_threshold: 1,
34
+ expected_environments: ["production"],
35
+ },
36
+ { idempotencyKey: crypto.randomUUID() },
37
+ );
38
+ } catch (e) {
39
+ if (e instanceof BelvadorApiError && e.code === "API_SCOPE_INSUFFICIENT") console.log("needs", e.scope);
40
+ }
41
+ ```
42
+
43
+ - **Tokens**: a personal token (`blv_pat_`), service token (`blv_svc_`) or OAuth access token (`blv_oat_`).
44
+ Create one in Settings → API tokens. The client never puts the token in an error message.
45
+ - **Errors** are RFC 9457 problem+json, surfaced as `BelvadorApiError` with a stable `code`,
46
+ `status`, `scope` (for `API_SCOPE_INSUFFICIENT`), `retryAfter` and `requestId`.
47
+ - **Approvals**: sensitive operations (keys, members, tokens, alert destinations, node changes) throw
48
+ `APPROVAL_REQUIRED` with `e.approval` = `{ id, url, expiresAt, state }`. A person opens `url` on
49
+ belvador.dev and approves with their authenticator code; call again with the same `idempotencyKey`
50
+ (or `call("approvals.get", { approval_id })`) to read the outcome. Only https `/approve/<id>` links are kept.
51
+ - **Retries**: reads, and writes sent with an `idempotencyKey`, are retried on 429 / 502–504 and network
52
+ errors (`retries`, default 2), honouring `Retry-After` up to 10 s. `NODE_OFFLINE` is not retried.
53
+ - **Scopes**: `await belvador.scopes()` returns the token's effective scopes (the `Belvador-Scopes` header).
54
+ - **Node data** (issues, events, spans, traces, logs) lives on your workspace's own node. With
55
+ `nodeReads: "edge"` the client asks `POST /v1/tickets` for a short-lived read ticket and reads
56
+ `edge.belvador.dev` directly, so the data never passes the API gateway; it falls back to the gateway
57
+ when the token may not hold a ticket.
58
+
59
+ Node ≥ 20.10, Bun, Deno and modern browsers (any runtime with `fetch` and `AbortSignal.any`). MIT licensed.
@@ -0,0 +1,70 @@
1
+ import { OPERATIONS, type OperationId, type OperationMeta, type OperationParams } from "./operations.gen.js";
2
+ export declare const CLIENT_VERSION = "0.2.0";
3
+ export declare const DEFAULT_BASE_URL = "https://api.belvador.dev";
4
+ /** Personal (`blv_pat_`), service (`blv_svc_`) or OAuth access (`blv_oat_`) token. */
5
+ export declare const TOKEN_PATTERN: RegExp;
6
+ /** Response header naming the credential's effective scopes. */
7
+ export declare const SCOPES_HEADER = "belvador-scopes";
8
+ export type ClientOptions = {
9
+ /** `blv_pat_…`, `blv_svc_…` or an OAuth access token `blv_oat_…`. */
10
+ token: string;
11
+ /** Default `https://api.belvador.dev`. Plain http only for localhost. */
12
+ baseUrl?: string;
13
+ fetch?: typeof fetch;
14
+ /**
15
+ * Where node data (issues, events, spans, logs) is read:
16
+ * `gateway` (default) through api.belvador.dev; `edge` straight from edge.belvador.dev with a
17
+ * short-lived ticket from `POST /v1/tickets`, so the data never passes the API gateway.
18
+ */
19
+ nodeReads?: "gateway" | "edge";
20
+ /** Per request, default 30 000 ms. */
21
+ timeoutMs?: number;
22
+ /** Retries for reads and for writes sent with an Idempotency-Key (429, 502–504, network). Default 2. */
23
+ retries?: number;
24
+ userAgent?: string;
25
+ };
26
+ export type CallOptions = {
27
+ /** Makes a write safe to retry: the same key and body answer once (24 h). */
28
+ idempotencyKey?: string;
29
+ /** ETag from the last read, for operations that need `If-Match`. */
30
+ ifMatch?: string;
31
+ signal?: AbortSignal;
32
+ };
33
+ export type ApiResult<T = unknown> = {
34
+ status: number;
35
+ data: T;
36
+ etag: string | null;
37
+ requestId: string | null;
38
+ /** A replayed answer to the same Idempotency-Key. */
39
+ replayed: boolean;
40
+ /** Where the answer came from. */
41
+ via: "gateway" | "edge";
42
+ };
43
+ export type Page<T = unknown> = {
44
+ data: T[];
45
+ next_cursor: string | null;
46
+ complete: boolean;
47
+ };
48
+ export type PagedOperationId = {
49
+ [K in OperationId]: (typeof OPERATIONS)[K]["paged"] extends true ? K : never;
50
+ }[OperationId];
51
+ type Args<Id extends OperationId> = Record<string, never> extends OperationParams[Id] ? [params?: OperationParams[Id], opts?: CallOptions] : [params: OperationParams[Id], opts?: CallOptions];
52
+ export interface BelvadorClient {
53
+ /** One API operation by its OpenAPI `operationId`, with parameters in wire names. */
54
+ call<Id extends OperationId>(id: Id, ...args: Args<Id>): Promise<ApiResult>;
55
+ /** Every page of a list operation (follows `next_cursor`, at most `maxPages`). */
56
+ pages<Id extends PagedOperationId>(id: Id, params?: OperationParams[Id], opts?: CallOptions & {
57
+ maxPages?: number;
58
+ }): AsyncGenerator<unknown[], void, undefined>;
59
+ /** The credential's effective scopes (asks the API once when nothing answered yet). */
60
+ scopes(): Promise<string[]>;
61
+ }
62
+ /** Wire parameters → path, query (GET) or JSON body; the same split the MCP tools use. */
63
+ export declare function requestParts(meta: OperationMeta, params: Record<string, unknown>): {
64
+ path: string;
65
+ query: string;
66
+ body: Record<string, unknown> | undefined;
67
+ };
68
+ /** A client for the Belvador API (`https://api.belvador.dev`, OpenAPI 3.1). */
69
+ export declare function createClient(options: ClientOptions): BelvadorClient;
70
+ export {};
package/dist/client.js ADDED
@@ -0,0 +1,290 @@
1
+ import { BelvadorApiError, errorFrom } from "./errors.js";
2
+ import { nodeErrorWord, nodeReadRequest, nodeRefusal, nodeWindowMissing, NODE_REFUSAL_STATUS, reshapeNodeAnswer, } from "./node-wire.gen.js";
3
+ import { OPERATIONS } from "./operations.gen.js";
4
+ export const CLIENT_VERSION = "0.2.0";
5
+ export const DEFAULT_BASE_URL = "https://api.belvador.dev";
6
+ /** Personal (`blv_pat_`), service (`blv_svc_`) or OAuth access (`blv_oat_`) token. */
7
+ export const TOKEN_PATTERN = /^blv_(pat|svc|oat)_[A-Za-z0-9_-]{20,200}$/;
8
+ /** Response header naming the credential's effective scopes. */
9
+ export const SCOPES_HEADER = "belvador-scopes";
10
+ const NODE_RESPONSE_MAX_BYTES = 8 * 1024 * 1024;
11
+ const TICKET_MARGIN_MS = 30_000;
12
+ const EDGE_OFF_MS = 5 * 60_000;
13
+ const MAX_RETRY_AFTER_S = 10;
14
+ const NO_RETRY_CODES = new Set(["NODE_OFFLINE", "PUBLIC_API_PAUSED"]);
15
+ const isLocalHost = (u) => ["localhost", "127.0.0.1", "[::1]"].includes(u.hostname);
16
+ function checkedOrigin(raw, what) {
17
+ let u;
18
+ try {
19
+ u = new URL(raw);
20
+ }
21
+ catch {
22
+ throw new TypeError(`${what} is not a URL`);
23
+ }
24
+ if (u.protocol !== "https:" && !(u.protocol === "http:" && isLocalHost(u)))
25
+ throw new TypeError(`${what} must use https`);
26
+ return u;
27
+ }
28
+ function timeoutSignal(ms, outer) {
29
+ const t = AbortSignal.timeout(ms);
30
+ return outer ? AbortSignal.any([t, outer]) : t;
31
+ }
32
+ const sleep = (ms, signal) => new Promise((resolve, reject) => {
33
+ if (signal?.aborted)
34
+ return reject(new BelvadorApiError("ABORTED", 0));
35
+ const id = setTimeout(resolve, ms);
36
+ signal?.addEventListener("abort", () => {
37
+ clearTimeout(id);
38
+ reject(new BelvadorApiError("ABORTED", 0));
39
+ }, { once: true });
40
+ });
41
+ const queryValue = (v) => (typeof v === "string" ? v : JSON.stringify(v));
42
+ /** Wire parameters → path, query (GET) or JSON body; the same split the MCP tools use. */
43
+ export function requestParts(meta, params) {
44
+ const names = new Set(meta.pathParams);
45
+ const qs = new URLSearchParams();
46
+ const body = {};
47
+ for (const [k, v] of Object.entries(params)) {
48
+ if (v === undefined || v === null || names.has(k))
49
+ continue;
50
+ if (meta.method === "GET")
51
+ qs.set(k, queryValue(v));
52
+ else
53
+ body[k] = v;
54
+ }
55
+ const path = meta.path.replace(/\{([a-z_]+)\}/g, (_, name) => {
56
+ const v = params[name];
57
+ if (v === undefined || v === null || v === "")
58
+ throw new BelvadorApiError("INVALID_INPUT", 400, { errors: [{ pointer: `/${name}`, code: "required" }] });
59
+ return encodeURIComponent(String(v));
60
+ });
61
+ return { path, query: qs.toString(), body: meta.method === "GET" ? undefined : body };
62
+ }
63
+ class Client {
64
+ #token;
65
+ #base;
66
+ #fetch;
67
+ #nodeReads;
68
+ #timeoutMs;
69
+ #retries;
70
+ #userAgent;
71
+ #scopes = null;
72
+ #ticket = null;
73
+ #ticketInFlight = null;
74
+ #edgeOffUntil = 0;
75
+ constructor(o) {
76
+ if (typeof o.token !== "string" || !TOKEN_PATTERN.test(o.token))
77
+ throw new TypeError("token must be a Belvador token (blv_pat_, blv_svc_ or blv_oat_)");
78
+ this.#token = o.token;
79
+ this.#base = checkedOrigin(o.baseUrl ?? DEFAULT_BASE_URL, "baseUrl").origin;
80
+ this.#fetch = o.fetch ?? globalThis.fetch.bind(globalThis);
81
+ this.#nodeReads = o.nodeReads ?? "gateway";
82
+ this.#timeoutMs = o.timeoutMs ?? 30_000;
83
+ this.#retries = Math.max(0, Math.min(5, o.retries ?? 2));
84
+ this.#userAgent = o.userAgent ?? `belvador-api-js/${CLIENT_VERSION}`;
85
+ }
86
+ async call(id, ...args) {
87
+ const meta = OPERATIONS[id];
88
+ if (!meta)
89
+ throw new BelvadorApiError("NOT_FOUND", 404, { detail: "unknown_operation" });
90
+ const params = (args[0] ?? {});
91
+ const opts = args[1] ?? {};
92
+ if (this.#nodeReads === "edge" && meta.node?.path.startsWith("q/") && Date.now() >= this.#edgeOffUntil) {
93
+ const direct = await this.#edgeRead(meta, meta.node, params, opts);
94
+ if (direct)
95
+ return direct;
96
+ }
97
+ return this.#gateway(meta, params, opts);
98
+ }
99
+ async *pages(id, params, opts = {}) {
100
+ let cursor = null;
101
+ const max = opts.maxPages ?? 100;
102
+ for (let i = 0; i < max; i++) {
103
+ const p = { ...(params ?? {}), ...(cursor ? { cursor } : {}) };
104
+ const r = await this.call(id, p, opts);
105
+ const page = r.data;
106
+ yield Array.isArray(page?.data) ? page.data : [];
107
+ cursor = page?.next_cursor ?? null;
108
+ if (!cursor)
109
+ return;
110
+ }
111
+ }
112
+ async scopes() {
113
+ if (this.#scopes)
114
+ return [...this.#scopes];
115
+ try {
116
+ await this.#gateway(OPERATIONS["workspace.get"], {}, {});
117
+ }
118
+ catch (e) {
119
+ // A refusal after authentication still names the scopes; only a bad token has none.
120
+ if (!this.#scopes)
121
+ throw e;
122
+ }
123
+ return [...(this.#scopes ?? [])];
124
+ }
125
+ #noteScopes(res) {
126
+ const h = res.headers.get(SCOPES_HEADER);
127
+ if (h !== null)
128
+ this.#scopes = h.split(" ").filter(Boolean);
129
+ }
130
+ async #send(url, init, retryable, signal) {
131
+ for (let attempt = 0;; attempt++) {
132
+ let res;
133
+ try {
134
+ res = await this.#fetch(url, { ...init, signal: timeoutSignal(this.#timeoutMs, signal) });
135
+ }
136
+ catch {
137
+ if (signal?.aborted)
138
+ throw new BelvadorApiError("ABORTED", 0);
139
+ if (retryable && attempt < this.#retries) {
140
+ await sleep(250 * 2 ** attempt + Math.random() * 100, signal);
141
+ continue;
142
+ }
143
+ throw new BelvadorApiError("UNAVAILABLE", 0, { detail: "network_error" });
144
+ }
145
+ if (!retryable || attempt >= this.#retries || ![429, 502, 503, 504].includes(res.status))
146
+ return res;
147
+ const err = await errorFrom(res.clone());
148
+ if (NO_RETRY_CODES.has(err.code))
149
+ return res;
150
+ const wait = err.retryAfter ? Math.min(err.retryAfter, MAX_RETRY_AFTER_S) * 1000 : 250 * 2 ** attempt;
151
+ if (err.retryAfter && err.retryAfter > MAX_RETRY_AFTER_S)
152
+ return res;
153
+ await sleep(wait + Math.random() * 100, signal);
154
+ }
155
+ }
156
+ async #gateway(meta, params, opts) {
157
+ const parts = requestParts(meta, params);
158
+ const headers = {
159
+ authorization: `Bearer ${this.#token}`,
160
+ accept: "application/json",
161
+ "user-agent": this.#userAgent,
162
+ };
163
+ if (parts.body !== undefined)
164
+ headers["content-type"] = "application/json";
165
+ if (opts.idempotencyKey)
166
+ headers["idempotency-key"] = opts.idempotencyKey;
167
+ if (opts.ifMatch)
168
+ headers["if-match"] = opts.ifMatch;
169
+ const res = await this.#send(`${this.#base}${parts.path}${parts.query ? `?${parts.query}` : ""}`, {
170
+ method: meta.method,
171
+ headers,
172
+ ...(parts.body !== undefined ? { body: JSON.stringify(parts.body) } : {}),
173
+ }, meta.method === "GET" || !!opts.idempotencyKey, opts.signal);
174
+ this.#noteScopes(res);
175
+ if (res.status === 401)
176
+ this.#ticket = null;
177
+ if (!res.ok)
178
+ throw await errorFrom(res);
179
+ const text = await res.text();
180
+ let data = null;
181
+ try {
182
+ data = text ? JSON.parse(text) : null;
183
+ }
184
+ catch {
185
+ throw new BelvadorApiError("UNAVAILABLE", res.status, { detail: "invalid_json" });
186
+ }
187
+ return {
188
+ status: res.status,
189
+ data,
190
+ etag: res.headers.get("etag"),
191
+ requestId: res.headers.get("belvador-request-id"),
192
+ replayed: res.headers.get("idempotent-replayed") === "true",
193
+ via: "gateway",
194
+ };
195
+ }
196
+ /** A read ticket, reused until 30 s before it ends; null = read through the gateway instead. */
197
+ async #readTicket(fresh) {
198
+ if (!fresh && this.#ticket && this.#ticket.expiresAt - Date.now() > TICKET_MARGIN_MS)
199
+ return this.#ticket;
200
+ this.#ticketInFlight ??= (async () => {
201
+ try {
202
+ const r = await this.#gateway(OPERATIONS["tickets.create"], { scope: "read" }, {});
203
+ const t = r.data;
204
+ if (typeof t?.ticket !== "string" || typeof t.node_url !== "string")
205
+ return null;
206
+ // The ticket only ever goes to https (plain http only to localhost while testing locally).
207
+ let node;
208
+ try {
209
+ node = new URL(t.node_url);
210
+ }
211
+ catch {
212
+ return null;
213
+ }
214
+ const localTest = isLocalHost(new URL(this.#base)) && isLocalHost(node) && node.protocol === "http:";
215
+ if (node.protocol !== "https:" && !localTest)
216
+ return null;
217
+ this.#ticket = {
218
+ ticket: t.ticket,
219
+ nodeUrl: t.node_url.replace(/\/+$/, ""),
220
+ expiresAt: Date.parse(String(t.expires_at)) || Date.now() + 60_000,
221
+ scopes: Array.isArray(t.scopes) ? t.scopes.map(String) : [],
222
+ };
223
+ return this.#ticket;
224
+ }
225
+ catch (e) {
226
+ if (e instanceof BelvadorApiError && (e.code === "API_SCOPE_INSUFFICIENT" || e.code === "API_FORBIDDEN")) {
227
+ this.#edgeOffUntil = Date.now() + EDGE_OFF_MS;
228
+ return null;
229
+ }
230
+ throw e;
231
+ }
232
+ finally {
233
+ this.#ticketInFlight = null;
234
+ }
235
+ })();
236
+ return this.#ticketInFlight;
237
+ }
238
+ /** Node data straight from the edge with a read ticket; null when the gateway should answer. */
239
+ async #edgeRead(meta, route, params, opts) {
240
+ const wire = { ...meta.defaults, ...params };
241
+ if (nodeWindowMissing(route, wire))
242
+ throw new BelvadorApiError("QUERY_WINDOW_REQUIRED", NODE_REFUSAL_STATUS.QUERY_WINDOW_REQUIRED);
243
+ const req = nodeReadRequest(route, wire);
244
+ if (!req)
245
+ throw new BelvadorApiError("INVALID_INPUT", NODE_REFUSAL_STATUS.INVALID_INPUT);
246
+ for (let attempt = 0; attempt < 2; attempt++) {
247
+ const t = await this.#readTicket(attempt > 0);
248
+ if (!t || !t.scopes.includes(meta.scope))
249
+ return null;
250
+ const res = await this.#send(`${t.nodeUrl}/${req.path}${req.query ? `?${req.query}` : ""}`, { method: "GET", headers: { authorization: `Bearer ${t.ticket}`, accept: "application/json" } }, false, // like the gateway: a node answer is final (NODE_BUSY carries its own Retry-After)
251
+ opts.signal).catch((e) => {
252
+ if (e instanceof BelvadorApiError && e.code === "ABORTED")
253
+ throw e;
254
+ throw new BelvadorApiError("NODE_OFFLINE", NODE_REFUSAL_STATUS.NODE_OFFLINE);
255
+ });
256
+ const len = Number(res.headers.get("content-length") ?? "0");
257
+ if (len > NODE_RESPONSE_MAX_BYTES)
258
+ throw new BelvadorApiError("UNAVAILABLE", 503, { detail: "response_too_large" });
259
+ const text = await res.text();
260
+ if (text.length > NODE_RESPONSE_MAX_BYTES)
261
+ throw new BelvadorApiError("UNAVAILABLE", 503, { detail: "response_too_large" });
262
+ let body = null;
263
+ try {
264
+ body = text ? JSON.parse(text) : null;
265
+ }
266
+ catch {
267
+ throw new BelvadorApiError("UNAVAILABLE", 503);
268
+ }
269
+ if (res.status === 401 && attempt === 0) {
270
+ this.#ticket = null;
271
+ continue;
272
+ }
273
+ if (res.ok)
274
+ return { status: 200, data: reshapeNodeAnswer(route.list, body), etag: null, requestId: null, replayed: false, via: "edge" };
275
+ if (res.status === 401)
276
+ throw new BelvadorApiError("UNAVAILABLE", 503);
277
+ const r = nodeRefusal(res.status, nodeErrorWord(body));
278
+ throw new BelvadorApiError(r.code, r.status, {
279
+ ...(r.detail ? { detail: r.detail } : {}),
280
+ ...(r.retryAfter ? { retryAfter: r.retryAfter } : {}),
281
+ ...(r.code === "API_SCOPE_INSUFFICIENT" ? { scope: meta.scope } : {}),
282
+ });
283
+ }
284
+ return null;
285
+ }
286
+ }
287
+ /** A client for the Belvador API (`https://api.belvador.dev`, OpenAPI 3.1). */
288
+ export function createClient(options) {
289
+ return new Client(options);
290
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * An API refusal (RFC 9457 problem+json) or a transport failure, as a stable `code`.
3
+ * The token never appears in a message.
4
+ */
5
+ export declare class BelvadorApiError extends Error {
6
+ readonly name = "BelvadorApiError";
7
+ /** Stable error code, e.g. `API_SCOPE_INSUFFICIENT`, `NODE_OFFLINE`, `RATE_LIMITED`. */
8
+ readonly code: string;
9
+ /** HTTP status (0 when the request never got an answer). */
10
+ readonly status: number;
11
+ readonly title: string | undefined;
12
+ readonly detail: string | undefined;
13
+ /** The scope to ask for when `code` is `API_SCOPE_INSUFFICIENT`. */
14
+ readonly scope: string | undefined;
15
+ /** Seconds to wait before trying again (429 / 503). */
16
+ readonly retryAfter: number | undefined;
17
+ readonly requestId: string | undefined;
18
+ /** Field errors: JSON pointers into the request with a code. */
19
+ readonly errors: ReadonlyArray<{
20
+ pointer: string;
21
+ code: string;
22
+ }> | undefined;
23
+ /**
24
+ * `APPROVAL_REQUIRED` (403): the approval a person gives on belvador.dev. Retry the same call
25
+ * with the same Idempotency-Key after they approved to get the stored result.
26
+ */
27
+ readonly approval: Approval | undefined;
28
+ constructor(code: string, status: number, extra?: {
29
+ title?: string;
30
+ detail?: string;
31
+ scope?: string;
32
+ retryAfter?: number;
33
+ requestId?: string;
34
+ errors?: ReadonlyArray<{
35
+ pointer: string;
36
+ code: string;
37
+ }>;
38
+ approval?: Approval;
39
+ });
40
+ }
41
+ export type ApprovalState = "pending" | "approved" | "executed" | "rejected" | "expired" | "failed";
42
+ export type Approval = {
43
+ id: string;
44
+ url: string;
45
+ expiresAt: string;
46
+ state: ApprovalState;
47
+ };
48
+ /** Approval members of a problem body; only an https `/approve/<id>` link is accepted. */
49
+ export declare function approvalFrom(body: Record<string, unknown>): Approval | undefined;
50
+ /** An error answer → BelvadorApiError (problem+json fields, scope challenge, Retry-After). */
51
+ export declare function errorFrom(res: Response): Promise<BelvadorApiError>;
package/dist/errors.js ADDED
@@ -0,0 +1,108 @@
1
+ /**
2
+ * An API refusal (RFC 9457 problem+json) or a transport failure, as a stable `code`.
3
+ * The token never appears in a message.
4
+ */
5
+ export class BelvadorApiError extends Error {
6
+ name = "BelvadorApiError";
7
+ /** Stable error code, e.g. `API_SCOPE_INSUFFICIENT`, `NODE_OFFLINE`, `RATE_LIMITED`. */
8
+ code;
9
+ /** HTTP status (0 when the request never got an answer). */
10
+ status;
11
+ title;
12
+ detail;
13
+ /** The scope to ask for when `code` is `API_SCOPE_INSUFFICIENT`. */
14
+ scope;
15
+ /** Seconds to wait before trying again (429 / 503). */
16
+ retryAfter;
17
+ requestId;
18
+ /** Field errors: JSON pointers into the request with a code. */
19
+ errors;
20
+ /**
21
+ * `APPROVAL_REQUIRED` (403): the approval a person gives on belvador.dev. Retry the same call
22
+ * with the same Idempotency-Key after they approved to get the stored result.
23
+ */
24
+ approval;
25
+ constructor(code, status, extra = {}) {
26
+ super(extra.title ? `${code}: ${extra.title}` : extra.detail ? `${code}: ${extra.detail}` : code);
27
+ this.code = code;
28
+ this.status = status;
29
+ this.title = extra.title;
30
+ this.detail = extra.detail;
31
+ this.scope = extra.scope;
32
+ this.retryAfter = extra.retryAfter;
33
+ this.requestId = extra.requestId;
34
+ this.errors = extra.errors;
35
+ this.approval = extra.approval;
36
+ }
37
+ }
38
+ const APPROVAL_STATES = ["pending", "approved", "executed", "rejected", "expired", "failed"];
39
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
40
+ /** Approval members of a problem body; only an https `/approve/<id>` link is accepted. */
41
+ export function approvalFrom(body) {
42
+ const id = body["approval_id"];
43
+ const url = body["approval_url"];
44
+ const exp = body["expires_at"];
45
+ const state = body["state"];
46
+ if (typeof id !== "string" || !UUID.test(id) || typeof url !== "string" || url.length > 300)
47
+ return undefined;
48
+ let u;
49
+ try {
50
+ u = new URL(url);
51
+ }
52
+ catch {
53
+ return undefined;
54
+ }
55
+ const local = u.protocol === "http:" && (u.hostname === "localhost" || u.hostname === "127.0.0.1");
56
+ if ((u.protocol !== "https:" && !local) || !u.pathname.endsWith(`/approve/${id}`))
57
+ return undefined;
58
+ if (typeof exp !== "string" || Number.isNaN(Date.parse(exp)))
59
+ return undefined;
60
+ if (typeof state !== "string" || !APPROVAL_STATES.includes(state))
61
+ return undefined;
62
+ return { id, url: u.toString(), expiresAt: exp, state: state };
63
+ }
64
+ const str = (v, max = 500) => typeof v === "string" && v ? v.slice(0, max) : undefined;
65
+ /** An error answer → BelvadorApiError (problem+json fields, scope challenge, Retry-After). */
66
+ export async function errorFrom(res) {
67
+ let body = {};
68
+ try {
69
+ const parsed = await res.json();
70
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed))
71
+ body = parsed;
72
+ }
73
+ catch {
74
+ body = {};
75
+ }
76
+ const scope = /scope="([^"]+)"/.exec(res.headers.get("www-authenticate") ?? "")?.[1];
77
+ const retry = Number(res.headers.get("retry-after"));
78
+ const errors = Array.isArray(body["errors"])
79
+ ? body["errors"]
80
+ .filter((e) => {
81
+ const o = e;
82
+ return !!e && typeof o.pointer === "string" && typeof o.code === "string";
83
+ })
84
+ .slice(0, 50)
85
+ .map((e) => ({ pointer: e.pointer, code: e.code }))
86
+ : undefined;
87
+ const code = str(body["code"], 64) ?? (res.status === 401 ? "API_CREDENTIAL_INVALID" : res.status === 429 ? "RATE_LIMITED" : "UNAVAILABLE");
88
+ const extra = {};
89
+ const title = str(body["title"]);
90
+ const detail = str(body["detail"]);
91
+ const requestId = str(body["request_id"], 64) ?? str(res.headers.get("belvador-request-id"), 64);
92
+ if (title)
93
+ extra.title = title;
94
+ if (detail)
95
+ extra.detail = detail;
96
+ if (scope)
97
+ extra.scope = scope;
98
+ if (Number.isFinite(retry) && retry > 0)
99
+ extra.retryAfter = retry;
100
+ if (requestId)
101
+ extra.requestId = requestId;
102
+ if (errors?.length)
103
+ extra.errors = errors;
104
+ const approval = approvalFrom(body);
105
+ if (approval)
106
+ extra.approval = approval;
107
+ return new BelvadorApiError(code, res.status, extra);
108
+ }
@@ -0,0 +1,3 @@
1
+ export { CLIENT_VERSION, createClient, DEFAULT_BASE_URL, requestParts, SCOPES_HEADER, TOKEN_PATTERN, type ApiResult, type BelvadorClient, type CallOptions, type ClientOptions, type Page, type PagedOperationId, } from "./client.js";
2
+ export { BelvadorApiError, type Approval, type ApprovalState } from "./errors.js";
3
+ export { API_VERSION, OPERATIONS, type HttpMethod, type OperationId, type OperationMeta, type OperationParams, } from "./operations.gen.js";
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export { CLIENT_VERSION, createClient, DEFAULT_BASE_URL, requestParts, SCOPES_HEADER, TOKEN_PATTERN, } from "./client.js";
2
+ export { BelvadorApiError } from "./errors.js";
3
+ export { API_VERSION, OPERATIONS, } from "./operations.gen.js";
@@ -0,0 +1,41 @@
1
+ export type NodeReadRoute = {
2
+ /** `q/...` with `{snake}` placeholders. */
3
+ path: string;
4
+ /** List answers: the node field holding the rows, answered as `data`. */
5
+ list?: string;
6
+ /** `start`/`end` required. */
7
+ window?: boolean;
8
+ };
9
+ /** Wire arguments → the node path (placeholders filled) and query string; null when a path value is missing. */
10
+ export declare function nodeReadRequest(route: NodeReadRoute, wire: Record<string, unknown>): {
11
+ path: string;
12
+ query: string;
13
+ } | null;
14
+ /** Window routes need both ends; the node and the gateway judge the size against the plan. */
15
+ export declare function nodeWindowMissing(route: NodeReadRoute, wire: Record<string, unknown>): boolean;
16
+ /** A node list answer as the API's page: `{data, next_cursor, complete, ...rest}`. */
17
+ export declare function reshapeNodeAnswer(list: string | undefined, body: unknown): unknown;
18
+ /** HTTP status of the codes a node read can end in (equal to `apiHttpStatus`; a test holds them equal). */
19
+ export declare const NODE_REFUSAL_STATUS: {
20
+ readonly NOT_FOUND: 404;
21
+ readonly API_SCOPE_INSUFFICIENT: 403;
22
+ readonly API_FORBIDDEN: 403;
23
+ readonly NODE_BUSY: 429;
24
+ readonly QUERY_TIMEOUT: 422;
25
+ readonly NODE_OFFLINE: 503;
26
+ readonly QUERY_WINDOW_REQUIRED: 400;
27
+ readonly QUERY_WINDOW_TOO_LARGE: 400;
28
+ readonly INVALID_INPUT: 400;
29
+ readonly UNAVAILABLE: 503;
30
+ };
31
+ export type NodeRefusalCode = keyof typeof NODE_REFUSAL_STATUS;
32
+ export type NodeRefusal = {
33
+ code: NodeRefusalCode;
34
+ status: number;
35
+ detail?: string;
36
+ retryAfter?: number;
37
+ };
38
+ /** A node error (status + its fixed `error` word) → the API error code; nothing else from the payload. */
39
+ export declare function nodeRefusal(status: number, error: string): NodeRefusal;
40
+ /** The node's `error` word from a parsed body ("" when absent). */
41
+ export declare function nodeErrorWord(body: unknown): string;