@capacms/mcp 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/lib/client.mjs ADDED
@@ -0,0 +1,512 @@
1
+ /**
2
+ * client.mjs — the HTTP client every tool goes through.
3
+ *
4
+ * Zero dependencies, matching @capa/config-guard and the Circular MCP server:
5
+ * a server an agent runs on its own machine should not drag a dependency tree
6
+ * along with it, and this one only needs `fetch`.
7
+ *
8
+ * AUTH IS NO LONGER READ-ONLY, AND THE COLUMN IS NOW A CONTROL
9
+ *
10
+ * This file used to say there was "no write verb behind an API key anywhere in
11
+ * the codebase". That was true when it was measured (2026-08-21) and is not
12
+ * true now. Two things changed it:
13
+ *
14
+ * - THE AGENT SURFACE (#383 / ZVN-5741) mounted `/v2/agent/models` and
15
+ * `/v2/agent/model-instances` behind `verifyApiKey`, and
16
+ * ADMIN_UI_OVERHAUL 0h.4b added `/v2/agent/workspaces`. These are the same
17
+ * handlers the admin uses, with the key authenticator instead of the
18
+ * session one.
19
+ * - `tenant_api_keys.permission` STOPPED BEING DECORATION (#4646).
20
+ * `requireApiKeyPermission` reads it through the same `can()` every
21
+ * session route asks, so `read`, `write`, `delete` and `agent` now grant
22
+ * genuinely different things. The old note here said the column was "read
23
+ * by nothing" — do not restore that claim.
24
+ *
25
+ * So a write tool CAN be built, and the workspace tools below are. What a key
26
+ * may actually do is its bundle's business: a `read` key gets 403 on every
27
+ * write, and this client surfaces the API's own message rather than guessing.
28
+ *
29
+ * Still true, and still the reason most tools here are reads: the CONTENT write
30
+ * surface an agent would want (edit an entry, publish it) is behind
31
+ * `/v2/agent/model-instances`, and the tools for it are a separate piece of
32
+ * work. Absent rather than stubbed — a tool that always fails teaches an agent
33
+ * to stop trying.
34
+ */
35
+
36
+ export class CapaApiError extends Error {
37
+ /**
38
+ * `envelope` is the `error` object of an `/api/` failure, when there was one.
39
+ * The legacy surface answers `{ error: "<string>" }` and has no such object,
40
+ * so the three legacy functions never pass it and `code`/`hint` stay null.
41
+ */
42
+ constructor(status, path, body, envelope) {
43
+ super(
44
+ envelope && envelope.code
45
+ ? `Capa API ${status} on ${path}: ${envelope.code}: ${envelope.message ?? ""}`.trimEnd()
46
+ : `Capa API ${status} on ${path}${body ? `: ${body.slice(0, 200)}` : ""}`,
47
+ );
48
+ this.name = "CapaApiError";
49
+ this.status = status;
50
+ this.path = path;
51
+ /**
52
+ * The response body, whole and unsliced.
53
+ *
54
+ * The message carries only the first 200 characters, which is right for a
55
+ * log line and wrong for a caller that has to ACT on the body. A 400 from
56
+ * `PUT /v2/models/:id/layout` is `{ error, path }` where `path` names the
57
+ * node to fix (ADMIN_UI_OVERHAUL 0m.4), and an agent can only fix it if the
58
+ * tool can read it back out.
59
+ */
60
+ this.body = body ?? "";
61
+ /**
62
+ * The `/api/` envelope's two act-on-it fields.
63
+ *
64
+ * `code` is the exact reason (`page_not_found`, `scope_missing`), which is
65
+ * what a caller branches on: the STATUS is also produced by things that are
66
+ * not the API, so a tool that switches on 404 alone is switching on
67
+ * something a proxy can author. `hint` names the fix in this deployment's
68
+ * own values, which is the only part of an error a model can act on without
69
+ * guessing. Null for the legacy surface, which carries neither.
70
+ */
71
+ this.code = envelope?.code ?? null;
72
+ this.hint = envelope?.hint ?? null;
73
+ }
74
+ }
75
+
76
+ /**
77
+ * The dated `/api/` platform version this package asks for when the
78
+ * environment names none.
79
+ *
80
+ * A COPY, and deliberately so: this package has no dependencies and cannot
81
+ * import from the api. The source of truth is the registry in
82
+ * `apps/api/src/api-next/versions.ts`, whose newest date this is; a date added
83
+ * there moves this constant and the README row with it. A date the deployment
84
+ * does not know answers `400 invalid_version` with a hint listing the dates it
85
+ * does, so a stale copy here fails loudly rather than quietly serving a shape
86
+ * nobody asked for.
87
+ */
88
+ export const DEFAULT_API_VERSION = "2026-10-01";
89
+
90
+ /**
91
+ * Which family a key belongs to, from its prefix and nothing else.
92
+ *
93
+ * `cap_` keys are hashed at rest and accepted on `/api/` ONLY: the legacy
94
+ * middleware refuses the prefix before it looks anything up. Every other key
95
+ * is legacy: `pk_`, `sk_` and the unprefixed keys older tenants hold are
96
+ * plaintext and reach both surfaces. That one fact is what decides which
97
+ * tools this server registers, so it is read once here rather than sniffed
98
+ * per call.
99
+ *
100
+ * Case-sensitive, matching the api: `CAP_live_…` is not a key this system
101
+ * mints, and a lookup path chosen by a header's capitalisation is a bug.
102
+ * `@capacms/sdk` applies the same rule; both packages are tested against
103
+ * `packages/sdk/test/fixtures/key-family.json`.
104
+ */
105
+ export function keyFamily(apiKey) {
106
+ return String(apiKey ?? "").startsWith("cap_") ? "cap" : "legacy";
107
+ }
108
+
109
+ /**
110
+ * Read config from the environment, failing with a message that names the fix.
111
+ *
112
+ * ONE PAIR OF NAMES FOR THE WHOLE PRODUCT: `CAPA_API_URL` and `CAPA_KEY`, the
113
+ * names `@capacms/sdk/nextjs`, `capa-codegen`, the SDK snippets these tools
114
+ * write and every curl example in docs/api read, so one
115
+ * `.env` serves a site and this server. `CAPA_BASE_URL` and `CAPA_API_KEY`,
116
+ * this package's first names, still work as aliases; the new name wins when
117
+ * both are set.
118
+ *
119
+ * NOTE ON THE ENV INVENTORY. `gen-env-template.mjs` scans for the literal
120
+ * `process.env.X` form, and the `env = process.env` parameter here (which
121
+ * exists so this is testable) hides every name below from it. The names reach
122
+ * .env.next.template anyway, because `packages/sdk`'s codegen bin reads them
123
+ * in the literal form; their notes in scripts/env-notes.json say this package
124
+ * shares them. CAPA_API_VERSION is read HERE and nowhere else, so it is listed
125
+ * in that file's `unread` block instead, which is the generator's second
126
+ * source for exactly this case.
127
+ */
128
+ export function loadConfig(env = process.env) {
129
+ // Trimmed: a key pasted with a space or a newline around it is still the key,
130
+ // and its prefix decides the family below.
131
+ const baseUrl = (env.CAPA_API_URL || env.CAPA_BASE_URL || "").trim().replace(/\/+$/, "");
132
+ const apiKey = (env.CAPA_KEY || env.CAPA_API_KEY || "").trim();
133
+ const tenantId = (env.CAPA_TENANT_ID || "").trim();
134
+ // Optional, and the only one of the four that is. An agent that never sets it
135
+ // gets today's shape; one pinning an older date gets the shape it was written
136
+ // against, which is the whole point of a dated surface.
137
+ const apiVersion = (env.CAPA_API_VERSION || "").trim() || DEFAULT_API_VERSION;
138
+ const family = keyFamily(apiKey);
139
+
140
+ const missing = [];
141
+ if (!baseUrl) missing.push("CAPA_API_URL");
142
+ if (!apiKey) missing.push("CAPA_KEY");
143
+ // REQUIRED FOR A LEGACY KEY ONLY, and that is not a convenience.
144
+ //
145
+ // `X-Tenant-Key` is read by the `/v2` and `/v3` mounts, which is where every
146
+ // legacy tool calls, so a legacy key without it gets a refusal on the
147
+ // first call. A `cap_` key reaches `/api/` and nothing else: that surface
148
+ // resolves the tenant from the key itself and never reads the header, and
149
+ // the registry gives a `cap_` key no legacy tool to call. Demanding it would
150
+ // be demanding a value nothing this process can reach will read, and refusing
151
+ // to start without one would refuse to start over a value that does not
152
+ // matter.
153
+ //
154
+ // It is therefore EMPTY STRING in the returned config for a `cap_` key. No
155
+ // tool can send it, because no tool that would is registered.
156
+ // Only a legacy key that was given is missing its tenant: with no key yet,
157
+ // nothing says one is needed, and a cap_ key never needs it.
158
+ if (!tenantId && apiKey && family === "legacy") missing.push("CAPA_TENANT_ID");
159
+
160
+ if (missing.length) {
161
+ // Each line says which key needs it, so a cap_ key's reader never goes
162
+ // hunting for a tenant id that changes nothing.
163
+ const keyLine = !apiKey
164
+ ? ` CAPA_KEY a cap_ key from Developers > Keys, or a legacy key\n` +
165
+ ` (or CAPA_API_KEY)\n`
166
+ : family === "cap"
167
+ ? ` CAPA_KEY a cap_ key (or CAPA_API_KEY). Its scopes decide which tools register;\n` +
168
+ ` the page tools need instance:read\n`
169
+ : ` CAPA_KEY a tenant API key, or CAPA_API_KEY (read scope for the read tools;\n` +
170
+ ` the workspace write tools need a key with write permission)\n`;
171
+ const tenantLine =
172
+ family === "cap" && apiKey
173
+ ? ` CAPA_TENANT_ID not needed for a cap_ key: /api/ resolves the tenant\n` +
174
+ ` from the key, and no legacy tool is registered\n`
175
+ : ` CAPA_TENANT_ID legacy keys only (pk_, sk_ or unprefixed): the tenant the key belongs to\n`;
176
+ throw new Error(
177
+ `capa-mcp: missing ${missing.join(", ")}.\n` +
178
+ ` CAPA_API_URL the API, e.g. https://api.capacms.com (or CAPA_BASE_URL)\n` +
179
+ keyLine +
180
+ tenantLine +
181
+ ` CAPA_API_VERSION optional; the dated /api/ version, default ${DEFAULT_API_VERSION}`
182
+ );
183
+ }
184
+ const route = routeIn(baseUrl);
185
+ if (route) {
186
+ throw new Error(
187
+ `capa-mcp: CAPA_API_URL is the API's address with no route, e.g. https://api.capacms.com: drop ${route}.\n` +
188
+ " Every tool adds the route it calls (/api/graphql, /api/entries), so a URL ending in one calls it twice.",
189
+ );
190
+ }
191
+ return { baseUrl, apiKey, tenantId, apiVersion, family };
192
+ }
193
+
194
+ /**
195
+ * The API route an address ends in, pasted with it: `/api`, `/api/graphql`,
196
+ * `/v2/api`. A path before the routes (a proxy's `/capa`) is the address.
197
+ */
198
+ function routeIn(baseUrl) {
199
+ let path;
200
+ try {
201
+ path = new URL(baseUrl).pathname;
202
+ } catch {
203
+ return null;
204
+ }
205
+ const at = path.search(/\/(?:api|v2|v3)(?:\/|$)/);
206
+ return at === -1 ? null : path.slice(at);
207
+ }
208
+
209
+ /**
210
+ * How long one request may take, response body included, on every surface.
211
+ * `fetch` has no timeout of its own (undici gives up after about five
212
+ * minutes), and a tool call held open that long stalls the agent's whole
213
+ * turn. The API's own statement timeout is 5 seconds by default, so 25 leaves
214
+ * room for a slow network without letting a silent host hold the call.
215
+ * `config.requestTimeoutMs` overrides it (tests use a few hundred ms).
216
+ */
217
+ export const REQUEST_TIMEOUT_MS = 25_000;
218
+
219
+ /**
220
+ * What to do when Capa never answered, in the words every such error ends
221
+ * with: this client's own, and capa_explain_error's for one pasted from a
222
+ * site's log (error-guide.mjs).
223
+ */
224
+ export const UNREACHABLE_NEXT =
225
+ "check that CAPA_API_URL is right and that the API is running and reachable from this machine, then retry.";
226
+ export const TIMEOUT_NEXT =
227
+ "retry once, narrower if you can (fewer fields, a smaller first); if it times out again, the API at CAPA_API_URL is down or unreachable.";
228
+
229
+ /**
230
+ * A request that never got an answer from Capa: the host refused the
231
+ * connection, its name did not resolve, TLS failed, or the connection closed
232
+ * mid-answer. `fetch` reports every one of those as a bare "fetch failed" and
233
+ * keeps the reason in `cause`, which is the part a person can act on, so the
234
+ * message names the host, that reason and the next step.
235
+ *
236
+ * `origin` is CAPA_API_URL's scheme, host and port, never its path or any
237
+ * credentials written into it.
238
+ */
239
+ export class CapaUnreachable extends Error {
240
+ constructor({ method, path, origin, reason, midAnswer }) {
241
+ super(
242
+ midAnswer
243
+ ? `Capa at ${origin} closed the connection before ${method} ${path} finished (${reason}). ` +
244
+ "Next: retry once; if it happens again, the API at CAPA_API_URL is failing mid-answer."
245
+ : `Could not reach Capa at ${origin} for ${method} ${path} (${reason}). Next: ${UNREACHABLE_NEXT}`,
246
+ );
247
+ this.name = "CapaUnreachable";
248
+ this.origin = origin;
249
+ this.reason = reason;
250
+ }
251
+ }
252
+
253
+ /**
254
+ * What went wrong under `fetch`'s "fetch failed": the system code
255
+ * (ECONNREFUSED, ENOTFOUND, a TLS code) when there is one. A URL carrying a
256
+ * user name or password is refused by `fetch` before any connection, in a
257
+ * message that quotes the URL whole, so that case is named here instead.
258
+ */
259
+ function transportReason(error, url) {
260
+ if (url.username || url.password) {
261
+ return "CAPA_API_URL carries a user name or password, which fetch refuses to send; remove them, the key goes in CAPA_KEY";
262
+ }
263
+ const cause = error?.cause;
264
+ return cause?.code ?? cause?.message ?? error?.message ?? String(error);
265
+ }
266
+
267
+ /**
268
+ * `fetch` plus reading the body as text, bounded by the request timeout, by
269
+ * `signal` when the caller has its own (the startup `/api/me` call does), and
270
+ * by `config.signal`, the tool call's, which the client aborts when it
271
+ * cancels the call. A caller's own abort surfaces as it was raised, so the
272
+ * caller can tell it apart; this file's timeout and a failed connection
273
+ * become errors naming the next step.
274
+ */
275
+ async function boundedFetch(config, method, path, url, init) {
276
+ const timeoutMs = config.requestTimeoutMs ?? REQUEST_TIMEOUT_MS;
277
+ const timeout = AbortSignal.timeout(timeoutMs);
278
+ const own = [init.signal, config.signal].filter(Boolean);
279
+ const failed = (error, midAnswer) => {
280
+ if (own.some((signal) => signal.aborted)) return error;
281
+ if (timeout.aborted) {
282
+ return new Error(
283
+ `Capa did not answer ${method} ${path} within ${timeoutMs >= 1000 ? `${Math.round(timeoutMs / 1000)} seconds` : `${timeoutMs} ms`}. Next: ${TIMEOUT_NEXT}`,
284
+ );
285
+ }
286
+ const parsed = new URL(url);
287
+ return new CapaUnreachable({ method, path, origin: parsed.origin, reason: transportReason(error, parsed), midAnswer });
288
+ };
289
+ let res;
290
+ try {
291
+ res = await fetch(url, {
292
+ ...init,
293
+ method,
294
+ signal: own.length ? AbortSignal.any([...own, timeout]) : timeout,
295
+ });
296
+ } catch (error) {
297
+ throw failed(error, false);
298
+ }
299
+ try {
300
+ return { res, text: await res.text() };
301
+ } catch (error) {
302
+ throw failed(error, true);
303
+ }
304
+ }
305
+
306
+ /** Build the request URL + headers once, for both the JSON and text readers. */
307
+ function prepare(config, path, query) {
308
+ const url = new URL(config.baseUrl + path);
309
+ for (const [k, v] of Object.entries(query)) {
310
+ if (v === undefined || v === null || v === "") continue;
311
+ url.searchParams.set(k, String(v));
312
+ }
313
+ return url;
314
+ }
315
+
316
+ /**
317
+ * GET an endpoint that returns text rather than JSON.
318
+ *
319
+ * /v2/schema/types serves `text/plain` — generated TypeScript source, not a
320
+ * JSON document — so apiGet's JSON.parse would reject a perfectly good
321
+ * response. Kept as a separate function rather than a flag on apiGet so the
322
+ * caller states which it expects and a content-type change surfaces as a
323
+ * failing test instead of a silent shape change.
324
+ */
325
+ export async function apiGetText(config, path, query = {}) {
326
+ const url = prepare(config, path, query);
327
+ const { res, text } = await boundedFetch(config, "GET", path, url, {
328
+ headers: {
329
+ "x-api-key": config.apiKey,
330
+ "X-Tenant-Key": config.tenantId,
331
+ Accept: "text/plain, */*",
332
+ },
333
+ });
334
+ if (!res.ok) throw new CapaApiError(res.status, path, text);
335
+ return text;
336
+ }
337
+
338
+ export async function apiGet(config, path, query = {}) {
339
+ const url = prepare(config, path, query);
340
+ const { res, text } = await boundedFetch(config, "GET", path, url, {
341
+ headers: {
342
+ "x-api-key": config.apiKey,
343
+ "X-Tenant-Key": config.tenantId,
344
+ Accept: "application/json",
345
+ },
346
+ });
347
+ if (!res.ok) throw new CapaApiError(res.status, path, text);
348
+ try {
349
+ return JSON.parse(text);
350
+ } catch {
351
+ throw new CapaApiError(res.status, path, `response was not JSON: ${text.slice(0, 200)}`);
352
+ }
353
+ }
354
+
355
+ /**
356
+ * GET a route on the `/api/` surface — a SECOND request path, not a flag on
357
+ * `apiGet`, because the two surfaces differ in three ways a caller has to see.
358
+ *
359
+ * NO `X-Tenant-Key`. `/api/` resolves the tenant from the key itself, so the
360
+ * header is not merely unnecessary, it is a value this surface never reads.
361
+ * Sending it would teach the next reader that a tenant id is part of an
362
+ * `/api/` request, and it is not.
363
+ *
364
+ * `Capa-Version`. Every `/api/` response is shaped by a dated version. Not
365
+ * sending one means the key's stored pin decides, which makes the shape a
366
+ * property of when the key was minted rather than of what this code expects.
367
+ * One date from every environment is the point of the header.
368
+ *
369
+ * `{ data, meta, page }`, not a bare body. `meta` carries the counters a
370
+ * list's row array cannot hold (the window it measured, the cap, whether the
371
+ * cap cut anything), and `page` an entry list's cursors, so every part comes
372
+ * back rather than the caller being handed `data` and told the rest did not
373
+ * exist.
374
+ *
375
+ * Errors are the `/api/` envelope, `{ error: { type, code, message, param,
376
+ * hint, docs } }`, and `code` plus `hint` are lifted onto `CapaApiError` so a
377
+ * tool can branch on the reason and hand the model the fix in one step.
378
+ */
379
+ export async function apiNextGet(config, path, query = {}, init = {}) {
380
+ const url = prepare(config, path, query);
381
+ const { res, text } = await boundedFetch(config, "GET", path, url, {
382
+ headers: {
383
+ "x-api-key": config.apiKey,
384
+ "Capa-Version": config.apiVersion || DEFAULT_API_VERSION,
385
+ Accept: "application/json",
386
+ },
387
+ // `init.signal` is how the startup call bounds itself more tightly than
388
+ // the request timeout: it runs before the first client message is read.
389
+ signal: init.signal,
390
+ });
391
+ let body = null;
392
+ try {
393
+ body = text ? JSON.parse(text) : null;
394
+ } catch {
395
+ body = null;
396
+ }
397
+ if (!res.ok) {
398
+ const envelope = body && typeof body === "object" ? body.error : null;
399
+ // A non-object `error` is the LEGACY body shape (`{ error: "<string>" }`),
400
+ // which reaches here when a request lands on a stack that does not serve
401
+ // `/api/` at all. It has no code and no hint, and pretending otherwise
402
+ // would invent both.
403
+ throw new CapaApiError(
404
+ res.status,
405
+ path,
406
+ text,
407
+ envelope && typeof envelope === "object" ? envelope : undefined,
408
+ );
409
+ }
410
+ if (!body || typeof body !== "object") {
411
+ throw new CapaApiError(res.status, path, `response was not JSON: ${text.slice(0, 200)}`);
412
+ }
413
+ return { data: body.data, meta: body.meta ?? null, page: body.page ?? null };
414
+ }
415
+
416
+ /**
417
+ * A write verb behind the API key — POST, PUT, PATCH or DELETE.
418
+ *
419
+ * Deliberately a separate function from `apiGet` rather than a `method` flag on
420
+ * it: the two have different failure modes an agent has to distinguish. A 403
421
+ * here means "this key may read but not write", which is a fixable
422
+ * configuration fact, and the caller has to be able to say so without parsing a
423
+ * shared error message.
424
+ *
425
+ * The API's OWN message is preserved rather than replaced. `CapaApiError`
426
+ * carries the body, so `capa_set_workspace` can hand the model back exactly
427
+ * what the server said about the grant it lacked.
428
+ */
429
+ export async function apiWrite(config, method, path, body) {
430
+ const url = new URL(config.baseUrl + path);
431
+ const { res, text } = await boundedFetch(config, method, path, url, {
432
+ headers: {
433
+ "x-api-key": config.apiKey,
434
+ "X-Tenant-Key": config.tenantId,
435
+ Accept: "application/json",
436
+ "Content-Type": "application/json",
437
+ },
438
+ body: body === undefined ? undefined : JSON.stringify(body),
439
+ });
440
+ if (!res.ok) throw new CapaApiError(res.status, path, text);
441
+ if (!text) return null;
442
+ try {
443
+ return JSON.parse(text);
444
+ } catch {
445
+ throw new CapaApiError(res.status, path, `response was not JSON: ${text.slice(0, 200)}`);
446
+ }
447
+ }
448
+
449
+ /**
450
+ * The status of a GraphQL request the API refused as a whole, however it was
451
+ * sent. GraphQL over HTTP answers the refusal 200 on `application/json`, which
452
+ * this client asks for, and 4xx on `application/graphql-response+json`
453
+ * (decision of 2026-09-25); every refusal it moves to a 200 is a 400 in the
454
+ * API's error table, so both read the same.
455
+ */
456
+ export const REFUSED_STATUS = 400;
457
+
458
+ /** GraphQL over HTTP: a refused request has `errors` and no `data` entry; a body with `data`, null included, ran. */
459
+ function refusedWhole(body) {
460
+ return Array.isArray(body?.errors) && body.errors.length > 0 && !Object.hasOwn(body, "data");
461
+ }
462
+
463
+ /**
464
+ * POST JSON to the `/api/` surface: today only `/api/graphql`.
465
+ *
466
+ * The same three rules as `apiNextGet` (no `X-Tenant-Key`, `Capa-Version` on
467
+ * every call, the envelope's `code` and `hint` lifted onto the error), plus
468
+ * the one GraphQL adds: a request the API ran is an answer even when it
469
+ * carries `errors`, because the root fields that worked still carry data. So
470
+ * this returns the whole parsed body when the API ran the request, and throws
471
+ * `CapaApiError` when it refused it: any status but 2xx, or a 200 with
472
+ * `errors` and no `data`, which throws as the 400 it is on
473
+ * `application/graphql-response+json`.
474
+ *
475
+ * A refused GraphQL request answers `{ errors: [...] }` rather than the REST
476
+ * `{ error }` object, so the envelope is read from `errors[0].extensions` when
477
+ * that is what came back. `body` keeps every error for a caller that wants
478
+ * them all.
479
+ */
480
+ export async function apiNextPost(config, path, payload, init = {}) {
481
+ const url = new URL(config.baseUrl + path);
482
+ const { res, text } = await boundedFetch(config, "POST", path, url, {
483
+ headers: {
484
+ "x-api-key": config.apiKey,
485
+ "Capa-Version": config.apiVersion || DEFAULT_API_VERSION,
486
+ Accept: "application/json",
487
+ "Content-Type": "application/json",
488
+ },
489
+ body: JSON.stringify(payload),
490
+ signal: init.signal,
491
+ });
492
+ let body = null;
493
+ try {
494
+ body = text ? JSON.parse(text) : null;
495
+ } catch {
496
+ body = null;
497
+ }
498
+ const refused = res.ok && refusedWhole(body);
499
+ if (!res.ok || refused) {
500
+ const first = Array.isArray(body?.errors) ? body.errors[0] : null;
501
+ const envelope = first
502
+ ? { code: first.extensions?.code, message: first.message, hint: first.extensions?.hint }
503
+ : body && typeof body.error === "object"
504
+ ? body.error
505
+ : undefined;
506
+ throw new CapaApiError(refused ? REFUSED_STATUS : res.status, path, text, envelope);
507
+ }
508
+ if (!body || typeof body !== "object") {
509
+ throw new CapaApiError(res.status, path, `response was not JSON: ${text.slice(0, 200)}`);
510
+ }
511
+ return body;
512
+ }