@bnbagent/deploy-provider-bnb 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/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "@bnbagent/deploy-provider-bnb",
3
+ "version": "0.3.0",
4
+ "type": "module",
5
+ "description": "BNB Chain trial-platform provider for @bnbagent/deploy — deploys agents to the managed free-trial runtime (bnbagent-api) over plain HTTPS: GitHub device-flow login, zip artifact upload, deploy + poll. No cloud credentials of your own.",
6
+ "license": "Apache-2.0",
7
+ "publishConfig": {
8
+ "access": "public"
9
+ },
10
+ "files": [
11
+ "src"
12
+ ],
13
+ "exports": {
14
+ ".": "./src/index.ts"
15
+ },
16
+ "scripts": {
17
+ "typecheck": "tsc --noEmit"
18
+ },
19
+ "peerDependencies": {
20
+ "@bnbagent/deploy-core": "*"
21
+ },
22
+ "devDependencies": {
23
+ "@bnbagent/deploy-core": "^0.3.0",
24
+ "@types/bun": "^1.3.0",
25
+ "typescript": "^5.7.0"
26
+ }
27
+ }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * The bnbagent-api response shapes this provider depends on — the SINGLE place
3
+ * the cross-repo contract is written down in code.
4
+ *
5
+ * ★ Source of truth: bnbagent-api `docs/client-integration.md` (pinned against
6
+ * commit 59cde52, 2026-06-29). There is no shared package or OpenAPI client
7
+ * yet, so drift is only caught here: when the platform changes a field, update
8
+ * this file (and ideally: the platform starts publishing these types — see
9
+ * docs/integration-bnbagent-api.md).
10
+ *
11
+ * Extension note: the platform may later deploy to backends other than AWS
12
+ * (e.g. Azure). This client is coupled to the PLATFORM contract, not to the
13
+ * backend — the one backend-shaped assumption is the artifact target
14
+ * architecture, which the platform advertises (see `platform` on the image
15
+ * artifact response) rather than this client hardcoding it.
16
+ */
17
+
18
+ /** POST /v1/auth/device/start */
19
+ export interface DeviceStart {
20
+ device_code: string;
21
+ user_code: string;
22
+ verification_uri: string;
23
+ expires_in: number;
24
+ interval: number;
25
+ }
26
+
27
+ /** POST /v1/auth/device/poll (200) · POST /v1/auth/refresh (200) */
28
+ export interface TokenPair {
29
+ access_token: string;
30
+ refresh_token: string;
31
+ token_type: string;
32
+ expires_in: number;
33
+ }
34
+
35
+ /** POST /v1/auth/device/poll (202 pending) */
36
+ export interface DevicePending {
37
+ status: "authorization_pending" | "slow_down";
38
+ }
39
+
40
+ /** POST /v1/artifacts/zip (201) */
41
+ export interface ZipArtifactCreated {
42
+ artifactId: string;
43
+ uploadUrl: string;
44
+ method: "PUT";
45
+ }
46
+
47
+ /** POST /v1/artifacts/image (201) — brokered private repo + short-lived push credential. */
48
+ export interface ImageArtifactCreated {
49
+ artifactId: string;
50
+ /** Full repo ref WITHOUT a tag (e.g. `<acct>.dkr.ecr.<region>.amazonaws.com/x/y`). */
51
+ repository: string;
52
+ username: string;
53
+ /** Registry auth token — stdin-only, never argv, never logged. */
54
+ password: string;
55
+ registry: string;
56
+ /** The runtime's image platform (e.g. "linux/arm64") — authoritative, advertised per-artifact. */
57
+ platform: string;
58
+ expiresAt: string;
59
+ }
60
+
61
+ /** POST /v1/agents/:slug/invoke-clients (201) — client_secret is shown ONCE. */
62
+ export interface InvokeClientCreated {
63
+ client_id: string;
64
+ client_secret: string;
65
+ token_url: string;
66
+ scope: string; // "invoke:<agentId>"
67
+ }
68
+
69
+ /** GET /v1/agents/:slug/invoke-clients */
70
+ export interface InvokeClientList {
71
+ clients: Array<{
72
+ id: string;
73
+ clientId: string;
74
+ label: string | null;
75
+ revokedAt: string | null;
76
+ createdAt: string;
77
+ }>;
78
+ }
79
+
80
+ /** POST /v1/tokens (201) — `token` (bnbk_…) is shown ONCE. */
81
+ export interface ApiTokenCreated {
82
+ id: string;
83
+ token: string;
84
+ prefix: string;
85
+ scope: string;
86
+ expires_at: string | null;
87
+ }
88
+
89
+ /** GET /v1/tokens */
90
+ export interface ApiTokenList {
91
+ tokens: Array<{ id: string; prefix?: string; scope?: string; created_at?: string; expires_at?: string | null }>;
92
+ }
93
+
94
+ /** POST /v1/agents/:slug/deployments (202) */
95
+ export interface DeploymentAccepted {
96
+ deployment_id: string;
97
+ agent_id: string;
98
+ protocol: string;
99
+ status_url: string;
100
+ invoke_url: string | null;
101
+ }
102
+
103
+ /** GET /v1/deployments/:id */
104
+ export interface DeploymentStatus {
105
+ id: string;
106
+ agent_id: string;
107
+ agent_slug: string;
108
+ protocol: string;
109
+ state: "queued" | "deploying" | "ready" | "failed" | string;
110
+ invoke_url: string | null;
111
+ issues?: Array<string | { level?: string; code?: string; message?: string }>;
112
+ }
113
+
114
+ /** GET /v1/agents/:slug */
115
+ export interface AgentInfo {
116
+ /** Current production API field. */
117
+ id?: string;
118
+ /** Additive snake_case alias returned by newer API versions. */
119
+ agent_id?: string;
120
+ slug: string;
121
+ state: "provisioning" | "ready" | "updating" | "reclaiming" | "deleted" | "failed" | string;
122
+ protocol: string | null;
123
+ invoke_url: string | null;
124
+ }
125
+
126
+ export const agentIdOf = (agent: AgentInfo): string | undefined => agent.agent_id ?? agent.id;
127
+
128
+ /** GET /v1/account */
129
+ export interface AccountInfo {
130
+ tenant_id: string;
131
+ github_user_id: number | string | null;
132
+ login: string | null;
133
+ agents: number;
134
+ }
135
+
136
+ /** GET /v1/account/trial */
137
+ export interface TrialStatus {
138
+ case: "available" | "active" | "expired";
139
+ started_at?: string;
140
+ expires_at?: string;
141
+ remaining_seconds?: number;
142
+ }
143
+
144
+ /** GET /v1/campaign (public) */
145
+ export interface CampaignStatus {
146
+ campaign_active: boolean;
147
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Ship a zip artifact to the trial platform:
3
+ * create → PUT bytes (auth-gated blob endpoint) → complete (sha256 integrity).
4
+ *
5
+ * The dist directory is zipped VERBATIM (vendored deps included — the managed
6
+ * runtime never installs), mirroring provider-aws/zip.ts; a ready .zip is
7
+ * uploaded as-is. Caps: 250 MB compressed (platform + AgentCore hard limit).
8
+ */
9
+ import { existsSync } from "node:fs";
10
+ import { mkdtemp, rm } from "node:fs/promises";
11
+ import { tmpdir } from "node:os";
12
+ import { basename, join, resolve } from "node:path";
13
+ import type { Context, DeploymentSpec } from "@bnbagent/deploy-core";
14
+ import { assertNoEscapingSymlinks } from "@bnbagent/deploy-core";
15
+ import { authedRequest } from "./client.ts";
16
+ import type { ZipArtifactCreated } from "./api-types.ts";
17
+
18
+ const ZIP_LIMIT_BYTES = 250 * 1024 * 1024;
19
+
20
+ export class TrialZipError extends Error {
21
+ constructor(message: string) {
22
+ super(message);
23
+ this.name = "TrialZipError";
24
+ }
25
+ }
26
+
27
+ export interface ShippedZip {
28
+ artifactId: string;
29
+ /** sha256 of the uploaded bytes — the deploy idempotency key's content half. */
30
+ checksum: string;
31
+ }
32
+
33
+ /** Build the zip bytes, upload them, and return the verified artifact id + checksum. */
34
+ export async function shipZipArtifact(spec: DeploymentSpec, ctx: Context): Promise<ShippedZip> {
35
+ const projectDir = resolve(ctx.baseDir, spec.path ?? ".");
36
+ const distAbs = resolve(projectDir, spec.dist!);
37
+ if (!existsSync(distAbs)) {
38
+ throw new TrialZipError(
39
+ `deploy.dist not found: ${distAbs}\n` +
40
+ ` \`deploy\` only deploys — build first (Python: \`bnbagent-deploy pack\`; ` +
41
+ `Node: your build script), then re-run deploy.`,
42
+ );
43
+ }
44
+
45
+ const staging = await mkdtemp(join(tmpdir(), "bnbagent-bnb-zip-"));
46
+ try {
47
+ let zipFile: string;
48
+ if (distAbs.endsWith(".zip")) {
49
+ zipFile = distAbs;
50
+ ctx.logger.step(`Using prebuilt archive ${spec.dist}`);
51
+ // Recorded exemption: a ready .zip is uploaded AS-IS — the env-file /
52
+ // escaping-symlink hygiene checks only run when WE build the archive.
53
+ ctx.logger.warn("Prebuilt archive: bundle hygiene checks (env files, symlinks) are skipped — it ships as-is.");
54
+ } else {
55
+ if (spec.entrypoint && !existsSync(join(distAbs, spec.entrypoint))) {
56
+ throw new TrialZipError(
57
+ `deploy.entrypoint "${spec.entrypoint}" not found inside deploy.dist (${distAbs}). ` +
58
+ `Rebuild, or fix the entrypoint path.`,
59
+ );
60
+ }
61
+ ctx.logger.step(`Packaging ${spec.dist} as-is (entry: ${spec.entrypoint})…`);
62
+ zipFile = join(staging, "bundle.zip");
63
+ // `zip` dereferences symlinks — a link escaping the dist tree would ship
64
+ // its target's content to the third-party platform. Reject before zipping.
65
+ try {
66
+ await assertNoEscapingSymlinks(distAbs);
67
+ } catch (e) {
68
+ throw new TrialZipError((e as Error).message);
69
+ }
70
+ await zipDir(distAbs, zipFile, spec.secrets?.envFile && basename(spec.secrets.envFile));
71
+ }
72
+
73
+ const bytes = new Uint8Array(await Bun.file(zipFile).arrayBuffer());
74
+ if (bytes.byteLength > ZIP_LIMIT_BYTES) {
75
+ throw new TrialZipError(
76
+ `The code package is ${(bytes.byteLength / 1024 / 1024).toFixed(0)}MB — the trial platform ` +
77
+ `caps zip deploys at 250MB compressed.`,
78
+ );
79
+ }
80
+ const checksum = new Bun.CryptoHasher("sha256").update(bytes).digest("hex");
81
+
82
+ ctx.logger.info(`Uploading bundle (${(bytes.byteLength / 1024).toFixed(0)} KB) → trial platform…`);
83
+ const created: ZipArtifactCreated = await authedRequest(ctx, "POST", "/v1/artifacts/zip", { json: {} });
84
+ // Tens-of-MB body — the default 30s request timeout is too tight for it.
85
+ await authedRequest(ctx, "PUT", created.uploadUrl, { bytes, timeoutMs: 300_000 });
86
+ await authedRequest(ctx, "POST", `/v1/artifacts/${created.artifactId}/complete`, {
87
+ json: { checksum },
88
+ });
89
+ ctx.logger.debug(`[bnb] artifact ${created.artifactId} verified (sha256 ${checksum.slice(0, 12)}…)`);
90
+ return { artifactId: created.artifactId, checksum };
91
+ } finally {
92
+ await rm(staging, { recursive: true, force: true });
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Zip a directory's contents at the archive root. Beyond OS/VCS cruft, this
98
+ * EXCLUDES env files (`.env*`, plus a custom secrets.envFile basename like
99
+ * "prod.env") and the emitted cognito CDK dir — a stray secret in dist must
100
+ * never be uploaded to the third-party trial platform. (Secrets travel via
101
+ * the deploy request's `secrets` field, not the bundle.)
102
+ */
103
+ async function zipDir(dir: string, outFile: string, envFileBase?: string | false): Promise<void> {
104
+ const excludes = [
105
+ ".git/*", "*/.DS_Store", ".DS_Store", ".bnbagent*",
106
+ "*/__pycache__/*", "__pycache__/*", "*.pyc",
107
+ ".env*", "*/.env*",
108
+ ...(envFileBase ? [envFileBase, `*/${envFileBase}`] : []),
109
+ "cognito-cdk/*",
110
+ ];
111
+ const proc = Bun.spawn(["zip", "-r", "-q", "-X", outFile, ".", "-x", ...excludes], {
112
+ cwd: dir,
113
+ stdout: "ignore",
114
+ stderr: "pipe",
115
+ });
116
+ const code = await proc.exited;
117
+ if (code !== 0) {
118
+ const err = await new Response(proc.stderr).text();
119
+ throw new TrialZipError(`zip failed (exit ${code}): ${err.trim() || "is the 'zip' command installed?"}`);
120
+ }
121
+ }
package/src/client.ts ADDED
@@ -0,0 +1,258 @@
1
+ /**
2
+ * Minimal HTTP client for bnbagent-api (docs/client-integration.md in that repo):
3
+ * bearer injection, one 401→refresh→retry pass (single-use rotating refresh
4
+ * tokens — the new pair is persisted BEFORE the retry), 429/5xx backoff, and
5
+ * the platform's uniform error envelope {error:{code,message,request_id}}.
6
+ *
7
+ * ★ Never logs request bodies (deploy bodies carry secret values).
8
+ */
9
+ import type { Context } from "@bnbagent/deploy-core";
10
+ import { loadSession, saveSession, withSessionLock, type Session } from "./session.ts";
11
+
12
+ /**
13
+ * Where the trial platform lives: [bnb] apiUrl → BNBAGENT_API_URL. There is
14
+ * deliberately NO default — a published CLI silently talking to localhost is a
15
+ * footgun; local platform dev sets BNBAGENT_API_URL=http://localhost:3000.
16
+ */
17
+ export function apiUrl(ctx: Context): string {
18
+ const fromSpec = ctx.overrides?.bnb?.apiUrl;
19
+ const url = (typeof fromSpec === "string" && fromSpec) || process.env.BNBAGENT_API_URL;
20
+ if (!url) {
21
+ throw new BnbApiError(
22
+ 0,
23
+ "config.api_url_missing",
24
+ "The trial platform endpoint is not configured. Set `[bnb] apiUrl = \"https://…\"` in " +
25
+ "agent-deploy.toml (or the BNBAGENT_API_URL env var; local platform dev: http://localhost:3000).",
26
+ );
27
+ }
28
+ const clean = url.replace(/\/+$/, "");
29
+ // Guard against sending session tokens over cleartext to a non-loopback host.
30
+ if (clean.startsWith("http://") && !/^http:\/\/(localhost|127\.0\.0\.1|\[::1\])(:|\/|$)/.test(clean)) {
31
+ throw new BnbApiError(
32
+ 0,
33
+ "config.insecure_api_url",
34
+ `Refusing to use a non-HTTPS trial API endpoint (${clean}) — session tokens and secrets ` +
35
+ `would cross the network in cleartext. Use https:// (http:// is allowed only for localhost).`,
36
+ );
37
+ }
38
+ return clean;
39
+ }
40
+
41
+ /**
42
+ * Automation/CI bearer: a platform API token (`bnbk_…`, minted with
43
+ * `bnbagent-deploy token --new`) via BNBAGENT_API_TOKEN. When set it REPLACES
44
+ * the session entirely — no device flow, no refresh, no session file.
45
+ */
46
+ export function envApiToken(): string | undefined {
47
+ const t = process.env.BNBAGENT_API_TOKEN?.trim();
48
+ return t || undefined;
49
+ }
50
+
51
+ /** Same-origin test — the bearer is attached to absolute URLs only when they match the platform base. */
52
+ export function sameOrigin(base: string, url: string): boolean {
53
+ try {
54
+ return new URL(url).origin === new URL(base).origin;
55
+ } catch {
56
+ return false;
57
+ }
58
+ }
59
+
60
+ export class BnbApiError extends Error {
61
+ readonly code: string;
62
+ readonly status: number;
63
+ readonly requestId?: string;
64
+ constructor(status: number, code: string, message: string, requestId?: string) {
65
+ super(message);
66
+ this.name = "BnbApiError";
67
+ this.status = status;
68
+ this.code = code;
69
+ this.requestId = requestId;
70
+ }
71
+ }
72
+
73
+ interface RequestOptions {
74
+ /** Raw body bytes (zip upload); `json` wins when both are set. */
75
+ bytes?: Uint8Array;
76
+ json?: unknown;
77
+ headers?: Record<string, string>;
78
+ /** Return the raw text instead of parsing JSON. */
79
+ text?: boolean;
80
+ /** Per-request timeout; default 30s (artifact uploads pass a longer one). */
81
+ timeoutMs?: number;
82
+ }
83
+
84
+ const DEFAULT_TIMEOUT_MS = 30_000;
85
+
86
+ const RETRYABLE = new Set([429, 502, 503, 504]);
87
+
88
+ /** Unauthenticated request (device flow, /v1/campaign). */
89
+ export async function publicRequest(
90
+ base: string,
91
+ method: string,
92
+ path: string,
93
+ opts: RequestOptions = {},
94
+ ): Promise<any> {
95
+ return send(base, method, path, undefined, opts);
96
+ }
97
+
98
+ /**
99
+ * Authenticated request. Loads the session, attaches the bearer, and on a 401
100
+ * refreshes ONCE (persisting the rotated pair first) before retrying.
101
+ */
102
+ export async function authedRequest(
103
+ ctx: Context,
104
+ method: string,
105
+ path: string,
106
+ opts: RequestOptions = {},
107
+ ): Promise<any> {
108
+ const base = apiUrl(ctx);
109
+ // CI/automation path: a bnbk_ API token wins over the interactive session.
110
+ const apiToken = envApiToken();
111
+ if (apiToken) {
112
+ try {
113
+ return await send(base, method, path, apiToken, opts, base);
114
+ } catch (e) {
115
+ if (e instanceof BnbApiError && e.status === 401) {
116
+ throw new BnbApiError(
117
+ 401,
118
+ "auth.token_invalid",
119
+ "BNBAGENT_API_TOKEN was rejected (revoked/expired?) — mint a new one: `bnbagent-deploy token --new` (while signed in).",
120
+ );
121
+ }
122
+ throw e;
123
+ }
124
+ }
125
+ let session = await loadSession();
126
+ if (!session) {
127
+ throw new BnbApiError(
128
+ 401,
129
+ "auth.signed_out",
130
+ "Not signed in — run `bnbagent-deploy login` first (CI: set BNBAGENT_API_TOKEN).",
131
+ );
132
+ }
133
+ // Host-bind the token: never send a session minted against platform A to a
134
+ // different [bnb] apiUrl (a malicious agent-deploy.toml could otherwise
135
+ // exfiltrate the bearer + single-use refresh token to an attacker host).
136
+ if (session.apiUrl && !sameOrigin(session.apiUrl, base)) {
137
+ throw new BnbApiError(
138
+ 401,
139
+ "auth.host_mismatch",
140
+ `Your session was minted against ${session.apiUrl}, but this command targets ${base}. ` +
141
+ `Refusing to send credentials cross-host — run \`bnbagent-deploy login\` against ${base}.`,
142
+ );
143
+ }
144
+ try {
145
+ return await send(base, method, path, session.access_token, opts, base);
146
+ } catch (e) {
147
+ if (!(e instanceof BnbApiError) || e.status !== 401) throw e;
148
+ session = await refreshSession(base, session);
149
+ return send(base, method, path, session.access_token, opts, base);
150
+ }
151
+ }
152
+
153
+ /** Exchange the single-use refresh token; persist the rotated pair immediately. */
154
+ async function refreshSession(base: string, session: Session): Promise<Session> {
155
+ return withSessionLock(async () => {
156
+ const fresh = await loadSession();
157
+ if (!fresh || !sameOrigin(fresh.apiUrl, base)) {
158
+ throw new BnbApiError(401, "auth.expired", "Session expired — run `bnbagent-deploy login` again.");
159
+ }
160
+ if (fresh.refresh_token !== session.refresh_token) {
161
+ return fresh;
162
+ }
163
+ let res: any;
164
+ try {
165
+ res = await send(base, "POST", "/v1/auth/refresh", undefined, {
166
+ json: { refresh_token: fresh.refresh_token },
167
+ });
168
+ } catch (e) {
169
+ const detail = e instanceof BnbApiError ? ` (${e.code})` : "";
170
+ throw new BnbApiError(
171
+ 401,
172
+ "auth.expired",
173
+ `Session expired${detail} — run \`bnbagent-deploy login\` again.`,
174
+ );
175
+ }
176
+ const next: Session = { apiUrl: base, access_token: res.access_token, refresh_token: res.refresh_token };
177
+ await saveSession(next);
178
+ return next;
179
+ });
180
+ }
181
+
182
+ async function send(
183
+ base: string,
184
+ method: string,
185
+ path: string,
186
+ bearer: string | undefined,
187
+ opts: RequestOptions,
188
+ /** When set, a bearer is attached to an absolute `path` only if it is same-origin. */
189
+ bearerOrigin?: string,
190
+ ): Promise<any> {
191
+ const url = path.startsWith("http") ? path : `${base}${path}`;
192
+ const headers: Record<string, string> = { ...(opts.headers ?? {}) };
193
+ // For a relative path the origin is `base`; for an absolute URL (uploadUrl,
194
+ // status_url) attach the bearer ONLY when it matches the platform origin.
195
+ const attachBearer = bearer && (!path.startsWith("http") || sameOrigin(bearerOrigin ?? base, url));
196
+ if (attachBearer) headers.authorization = `Bearer ${bearer}`;
197
+ let body: string | Uint8Array | undefined;
198
+ if (opts.json !== undefined) {
199
+ headers["content-type"] = "application/json";
200
+ body = JSON.stringify(opts.json);
201
+ } else if (opts.bytes) {
202
+ headers["content-type"] ??= "application/zip";
203
+ body = opts.bytes;
204
+ }
205
+
206
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
207
+ for (let attempt = 1; ; attempt++) {
208
+ let res: Response;
209
+ try {
210
+ res = await fetch(url, { method, headers, body, signal: AbortSignal.timeout(timeoutMs) });
211
+ } catch (e) {
212
+ if ((e as Error).name === "TimeoutError") {
213
+ throw new BnbApiError(
214
+ 0,
215
+ "network.timeout",
216
+ `Request to the trial platform timed out after ${timeoutMs / 1000}s (${method} ${path}).`,
217
+ );
218
+ }
219
+ throw new BnbApiError(
220
+ 0,
221
+ "network.unreachable",
222
+ `Cannot reach the trial platform at ${base}: ${(e as Error).message}\n` +
223
+ ` Set the API endpoint via BNBAGENT_API_URL or an [bnb] apiUrl entry in agent-deploy.toml.`,
224
+ );
225
+ }
226
+ // Retry transient statuses only for methods that are safe to replay: GET and
227
+ // POSTs that carry an Idempotency-Key (deployments). A bare non-idempotent
228
+ // POST (invoke, artifact create) must NOT be silently re-sent.
229
+ const idempotent = method === "GET" || method === "DELETE" || "idempotency-key" in headers;
230
+ if (RETRYABLE.has(res.status) && idempotent && attempt < 3) {
231
+ await Bun.sleep(attempt * 2000);
232
+ continue;
233
+ }
234
+ if (!res.ok) {
235
+ let code = `http.${res.status}`;
236
+ let message = res.statusText || `HTTP ${res.status}`;
237
+ let requestId: string | undefined;
238
+ try {
239
+ const env = (await res.json()) as {
240
+ error?: string | { code?: string; message?: string; request_id?: string };
241
+ };
242
+ if (typeof env.error === "string") {
243
+ // Device-flow terminal errors use a flat { error: "expired_token" } (OAuth shape).
244
+ code = env.error;
245
+ message = env.error.replace(/_/g, " ");
246
+ } else if (env.error) {
247
+ code = env.error.code ?? code;
248
+ message = env.error.message ?? message;
249
+ requestId = env.error.request_id;
250
+ }
251
+ } catch {
252
+ /* non-JSON error body — keep the status text */
253
+ }
254
+ throw new BnbApiError(res.status, code, message, requestId);
255
+ }
256
+ return opts.text ? res.text() : res.status === 204 ? undefined : res.json();
257
+ }
258
+ }