From c489414b7b277d6bbaaa2f25e48e34616f953169 Mon Sep 17 00:00:00 2001 From: Max Bader Date: Tue, 4 Aug 2026 15:35:28 -0500 Subject: [PATCH] Add the fetch battery: client.fetch(url, init) with server-side key substitution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lets a deployed app call a third-party API using a key its owner stored with Bool, without the key entering the app bundle. Write {{SECRET_NAME}} wherever the key belongs — the URL, a header value, the body — and the gateway's fetch plane substitutes the real value server-side. const res = await bool.fetch( "https://api.example.com/v1/things?key={{EXAMPLE_API_KEY}}", ); if (!res.ok) return; const data = await res.json(); Shaped as fetch on purpose. It takes the same arguments as the global fetch and resolves to a real Response carrying the third party's status, headers and body, so res.ok / res.status / res.json() mean what they always mean and the only new thing to learn is the placeholder. An object-shaped API returning {status, body} was the alternative; it reads as more explicit but every caller then has to relearn a shape they already know, and every generated call site pays for that. Two details worth knowing: - A response from the API is never an error, including a 4xx. BoolFetchError is thrown only when the request was never made (secret_not_set, unknown_secret, host_not_allowed, rate_limited, out_of_app_credits), mirroring how fetch throws on a network failure but not on a 404. It carries `secrets`, the key names involved, so an app can tell its user which key is still missing rather than failing opaquely. - 204/205/304 come back with a null body. The Response constructor rejects a body on those statuses, so passing one through would turn a successful DELETE into a thrown error. The call routes through the resolved gateway base like every other plane, not a relative URL: the editor preview runs on a different origin from the gateway, so a relative call would only work once an app was published. `aiHeaders` becomes `batteryHeaders` — same envelope (preview viewer token, end-user session, local-development key), now shared by both batteries rather than named after one. Minor release, purely additive: apps on ^0.3.x are unaffected until they reinstall. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 35 ++++++++++++ README.md | 26 +++++++++ package.json | 2 +- src/client.test.ts | 137 +++++++++++++++++++++++++++++++++++++++++++++ src/client.ts | 132 ++++++++++++++++++++++++++++++++++++++++++- src/index.ts | 3 + 6 files changed, 331 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c2963d3..c7c3568 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,40 @@ # Changelog +## 0.4.0 + +- **New: the fetch battery — `client.fetch`.** Call a third-party API with a key + the app's owner stored with Bool, without the key ever entering the app bundle. + Write `{{SECRET_NAME}}` wherever the key belongs (the URL, a header value, the + body) and the gateway substitutes the real value server-side. + + ```ts + const res = await bool.fetch( + "https://api.example.com/v1/things?key={{EXAMPLE_API_KEY}}", + ); + if (!res.ok) return; + const data = await res.json(); + ``` + + It takes the same arguments as the global `fetch` and resolves to a real + `Response` carrying the third party's status, headers and body, so `res.ok`, + `res.status` and `res.json()` mean what they always mean. That shape is the + point: the only new thing to learn is the placeholder. + + A response from the API is never an error, including a 4xx. `BoolFetchError` is + thrown only when the request was never made — `secret_not_set`, + `unknown_secret`, `host_not_allowed`, `rate_limited`, `out_of_app_credits` — + mirroring how `fetch` throws on a network failure but not on a 404. The error + carries `secrets`, the key names involved, so an app can tell its user which + key is still missing. + + A stored key may only be sent to the one host its owner registered it for; a + call that would send it anywhere else is refused before the request leaves the + server. + + A minor release: purely additive, so existing apps on `^0.3.x` are unaffected + until they reinstall. Requires a gateway with the fetch plane enabled for the + workspace. + ## 0.3.1 - **Fixes every published app that uses a live view.** `0.3.0` shipped diff --git a/README.md b/README.md index 44dde4e..24fadfb 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,32 @@ tested, and upgradable independently of any one app. for await (const chunk of bool.ai.stream("Write a haiku")) setText((t) => t + chunk); ``` Requires the workspace to be opted into the `bool-ai` server flag. +- **Fetch battery.** `client.fetch` calls a third-party API using a key the app's + owner stored with Bool, **without the key entering the bundle**. Write + `{{SECRET_NAME}}` wherever the key belongs — the URL, a header value, the body + — and the gateway's fetch plane (`/_bool/v1/fetch`) substitutes the real value + server-side. Same arguments as the global `fetch`, and it resolves to a real + `Response` carrying the third party's status, headers and body: + ```ts + const res = await bool.fetch( + "https://api.example.com/v1/things?key={{EXAMPLE_API_KEY}}", + ); + if (!res.ok) return; // the API's status, exactly like fetch + const data = await res.json(); + + await bool.fetch("https://api.example.com/v1/things", { + method: "POST", + headers: { Authorization: "Bearer {{EXAMPLE_API_KEY}}" }, + body: JSON.stringify({ name }), + }); + ``` + A response from the API — including a 4xx — is data, not an error. It throws a + `BoolFetchError` only when the request was never made, mirroring how `fetch` + throws on a network failure rather than on a 404: `code` is `secret_not_set` + (the owner hasn't provided that key yet), `unknown_secret`, `host_not_allowed`, + `rate_limited` or `out_of_app_credits`, and `secrets` names the keys involved + so an app can say which one is missing. Each key may only be sent to the one + host its owner registered it for, so call the host the key belongs to. - **React auth layer** (`bool-sdk/react`): ``, `useBoolAuth()`, ``, and the headless `useSignInForm()` state machine that login forms bind to. diff --git a/package.json b/package.json index 6219369..d0d1f8b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bool-sdk", - "version": "0.3.3", + "version": "0.4.0", "description": "Client SDK for apps built on Bool \u2014 gateway data access, end-user auth, the AI battery, the React auth layer, and the local-dev CLI (link, types, deploy).", "type": "module", "main": "./dist/index.js", diff --git a/src/client.test.ts b/src/client.test.ts index d69433b..fbc7195 100644 --- a/src/client.test.ts +++ b/src/client.test.ts @@ -5,6 +5,7 @@ import { hasDefaultBoolClient, isDeploymentSubdomain, BoolAiError, + BoolFetchError, type BoolClientConfig, } from "./client"; @@ -420,6 +421,142 @@ describe("bool.ai battery", () => { // `createBoolClient` from "bool-sdk" and `useEntity` from "bool-sdk/react", // nothing imported the bootstrap module at all, and every hook threw "No Bool // client exists yet" at first render. +describe("bool.fetch battery", () => { + // The gateway answers with the third party's response described as data: + // { status, headers, body }. bool.fetch turns that back into a Response. + const planeOk = (payload: unknown) => + new Response(JSON.stringify(payload), { + headers: { "content-type": "application/json" }, + }); + + test("POSTs the call to the fetch plane, describing it as data", async () => { + respond = () => planeOk({ status: 200, headers: {}, body: { ok: true } }); + const client = createBoolClient(CONFIG); + await client.fetch("https://api.example.com/v1/things?key={{EXAMPLE_KEY}}"); + expect(calls).toHaveLength(1); + expect(calls[0]!.url).toBe("https://bool.test/served/my-app/_bool/v1/fetch"); + expect(calls[0]!.init?.method).toBe("POST"); + // credentials:include so the identity cookie rides along when same-origin. + expect(calls[0]!.init?.credentials).toBe("include"); + expect(JSON.parse(String(calls[0]!.init?.body))).toEqual({ + url: "https://api.example.com/v1/things?key={{EXAMPLE_KEY}}", + }); + }); + + test("forwards method, headers and body", async () => { + respond = () => planeOk({ status: 201, headers: {}, body: {} }); + const client = createBoolClient(CONFIG); + await client.fetch("https://api.example.com/v1/charges", { + method: "POST", + headers: { authorization: "Bearer {{EXAMPLE_KEY}}" }, + body: '{"amount":500}', + }); + expect(JSON.parse(String(calls[0]!.init?.body))).toEqual({ + url: "https://api.example.com/v1/charges", + method: "POST", + headers: { authorization: "Bearer {{EXAMPLE_KEY}}" }, + body: '{"amount":500}', + }); + }); + + test("accepts a Headers instance and a URL", async () => { + respond = () => planeOk({ status: 200, headers: {}, body: {} }); + const client = createBoolClient(CONFIG); + await client.fetch(new URL("https://api.example.com/v1/things"), { + headers: new Headers({ "x-api-key": "{{EXAMPLE_KEY}}" }), + }); + const sent = JSON.parse(String(calls[0]!.init?.body)); + expect(sent.url).toBe("https://api.example.com/v1/things"); + expect(sent.headers).toEqual({ "x-api-key": "{{EXAMPLE_KEY}}" }); + }); + + test("resolves to the THIRD PARTY's response, not the plane's", async () => { + // The whole point of the Response shape: res.ok and res.status describe the + // API that was called. A 401 from them is data, not an exception. + respond = () => + planeOk({ + status: 401, + headers: { "content-type": "application/json" }, + body: { message: "bad key" }, + }); + const client = createBoolClient(CONFIG); + const res = await client.fetch("https://api.example.com/v1/things"); + expect(res.ok).toBe(false); + expect(res.status).toBe(401); + expect(await res.json()).toEqual({ message: "bad key" }); + }); + + test("parses a JSON body and passes a text body through", async () => { + respond = () => + planeOk({ status: 200, headers: { "content-type": "application/json" }, body: { t: 1 } }); + const client = createBoolClient(CONFIG); + expect(await (await client.fetch("https://api.example.com/x")).json()).toEqual({ t: 1 }); + + respond = () => + planeOk({ status: 200, headers: { "content-type": "text/plain" }, body: "plain" }); + expect(await (await client.fetch("https://api.example.com/x")).text()).toBe("plain"); + }); + + test("a 204 comes back without a body instead of throwing", async () => { + // Response rejects a body on a null-body status, so a successful DELETE would + // otherwise surface as an error. + respond = () => planeOk({ status: 204, headers: {}, body: "" }); + const client = createBoolClient(CONFIG); + const res = await client.fetch("https://api.example.com/v1/things/1", { + method: "DELETE", + }); + expect(res.status).toBe(204); + expect(res.ok).toBe(true); + }); + + test("throws BoolFetchError when the call was never made", async () => { + respond = () => + new Response(JSON.stringify({ error: "secret_not_set", secrets: ["EXAMPLE_KEY"] }), { + status: 409, + headers: { "content-type": "application/json" }, + }); + const client = createBoolClient(CONFIG); + let caught: unknown; + try { + await client.fetch("https://api.example.com/x?key={{EXAMPLE_KEY}}"); + } catch (err) { + caught = err; + } + expect(caught).toBeInstanceOf(BoolFetchError); + const err = caught as BoolFetchError; + expect(err.code).toBe("secret_not_set"); + expect(err.status).toBe(409); + // The names let an app say WHICH key its owner still has to provide. + expect(err.secrets).toEqual(["EXAMPLE_KEY"]); + }); + + test("throws with a fallback code when the failure carries no JSON", async () => { + respond = () => new Response("upstream exploded", { status: 502 }); + const client = createBoolClient(CONFIG); + await expect(client.fetch("https://api.example.com/x")).rejects.toBeInstanceOf( + BoolFetchError, + ); + }); + + test("sends the preview identity headers, like every other battery", async () => { + respond = () => planeOk({ status: 200, headers: {}, body: {} }); + const client = createBoolClient({ ...CONFIG, viewerToken: "vt-123" }); + await client.fetch("https://api.example.com/x"); + const headers = calls[0]!.init?.headers as Record; + expect(headers["x-bool-viewer"]).toBe("vt-123"); + expect(headers["content-type"]).toBe("application/json"); + }); + + test("goes through the gateway, never a relative URL", async () => { + // A relative call would reach the gateway only on the deployed origin; the + // editor preview runs cross-origin, where it would hit the dev server. + respond = () => planeOk({ status: 200, headers: {}, body: {} }); + const client = createBoolClient(CONFIG); + await client.fetch("https://api.example.com/x"); + expect(calls[0]!.url.startsWith("https://bool.test/served/my-app/")).toBe(true); + }); +}); + describe("default client registry", () => { test("the last-created client is the default (hot reload re-registers)", () => { const first = createBoolClient(CONFIG); diff --git a/src/client.ts b/src/client.ts index 96b3fe9..8802751 100644 --- a/src/client.ts +++ b/src/client.ts @@ -180,6 +180,66 @@ export type BoolAi = { stream(prompt: string): AsyncIterable; }; +/** Thrown when a `fetch` call could not be made at all — the secret isn't set, + * the URL isn't allowed for that secret, the app is out of credits. A response + * FROM the third party, including a 4xx or 5xx, is not an error here: it comes + * back as a normal `Response` for you to check, exactly like `fetch`. */ +export class BoolFetchError extends Error { + /** Machine-readable reason. Common values: `secret_not_set` (the app's owner + * hasn't provided that key yet), `unknown_secret`, `host_not_allowed`, + * `rate_limited`, `out_of_app_credits`. */ + readonly code: string; + readonly status: number; + /** The secret names the failure refers to, when the reason names any. */ + readonly secrets: string[]; + constructor(code: string, status: number, secrets: string[] = []) { + super(`bool.fetch failed: ${code} (${status})`); + this.name = "BoolFetchError"; + this.code = code; + this.status = status; + this.secrets = secrets; + } +} + +/** The subset of `RequestInit` a proxied call supports. Streaming request bodies, + * `FormData`, `AbortSignal` and the rest of `fetch`'s surface aren't forwarded: + * the call is described to the gateway as data, not opened from the browser. */ +export type BoolFetchInit = { + method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + headers?: Record | Headers; + /** A string body, as with `fetch`. `JSON.stringify` your object. */ + body?: string; +}; + +/** Call a third-party API that needs one of this app's stored API keys, without + * the key ever entering the app bundle. + * + * Write `{{SECRET_NAME}}` anywhere the key belongs — in the URL, in a header + * value, or inside the body — and the gateway substitutes the real value + * server-side before making the request: + * + * ```ts + * const res = await bool.fetch( + * "https://api.example.com/v1/things?key={{EXAMPLE_API_KEY}}", + * ); + * if (!res.ok) return; + * const data = await res.json(); + * ``` + * + * Takes the same arguments as `fetch` and resolves to a real `Response` carrying + * the third party's status, headers and body, so `res.ok` / `res.status` / + * `res.json()` mean what they always mean. It THROWS a {@link BoolFetchError} + * only when the request was never made — mirroring how `fetch` throws on a + * network failure rather than on a 404. + * + * Each key may only be sent to the one host its owner registered it for, so call + * the API host the key actually belongs to. + */ +export type BoolFetch = ( + input: string | URL, + init?: BoolFetchInit, +) => Promise; + /** The gateway-routed supabase-js client. Loosely typed on the schema-name * generic because each Bool runs in its own non-"public" schema. */ export type BoolDb = SupabaseClient; @@ -197,6 +257,10 @@ export type BoolClient = { /** The AI battery: `ai.generate(prompt)` / `ai.generate({prompt, schema})` / * `ai.stream(prompt)`. Server-side AI with no API key in the bundle. */ ai: BoolAi; + /** Call a third-party API using one of this app's stored keys, with + * `{{SECRET_NAME}}` substituted server-side: `fetch(url, init)`. Same + * arguments and same `Response` as the global `fetch`. */ + fetch: BoolFetch; /** This app's private Postgres schema name. */ schema: string; /** Subscribe to the app's realtime "doorbell": fires whenever any row in the @@ -567,7 +631,9 @@ export function createBoolClient(config: BoolClientConfig): BoolClient { // viewer/eu-session identity headers mirror the db and users planes so the // same live-gate identity flows (same-origin cookie deployed, viewer token // cross-origin in preview). - function aiHeaders(): Record { + // Identity envelope shared by every battery plane: the preview viewer token, + // the end-user session, and the local-development API key when present. + function batteryHeaders(): Record { const headers: Record = { "content-type": "application/json" }; if (viewerToken) headers["x-bool-viewer"] = viewerToken; if (euSessionToken) headers["x-bool-eu-session"] = euSessionToken; @@ -584,7 +650,7 @@ export function createBoolClient(config: BoolClientConfig): BoolClient { typeof promptOrOpts === "string" ? { prompt: promptOrOpts } : promptOrOpts; const res = await fetch(`${GATEWAY}/_bool/${GATEWAY_API}/ai/generate`, { method: "POST", - headers: aiHeaders(), + headers: batteryHeaders(), credentials: "include", body: JSON.stringify({ prompt: opts.prompt, schema: opts.schema }), }); @@ -600,7 +666,7 @@ export function createBoolClient(config: BoolClientConfig): BoolClient { async *stream(prompt: string): AsyncIterable { const res = await fetch(`${GATEWAY}/_bool/${GATEWAY_API}/ai/stream`, { method: "POST", - headers: aiHeaders(), + headers: batteryHeaders(), credentials: "include", body: JSON.stringify({ prompt }), }); @@ -692,6 +758,65 @@ export function createBoolClient(config: BoolClientConfig): BoolClient { }, }); + // The fetch battery. The gateway holds the app's API keys and substitutes + // {{SECRET_NAME}} into the outbound request, so a key is never in the bundle. + // + // Note this is NOT a relative call: the editor preview runs on a different + // origin from the gateway, so it resolves through GATEWAY like every other + // plane. A relative URL would reach the gateway only once the app is published. + const boolFetch: BoolFetch = async (input, init = {}) => { + // Headers may arrive as a Headers instance or a plain object; the call is + // described to the gateway as JSON, so normalize to a record either way. + const headers: Record = {}; + if (typeof Headers !== "undefined" && init.headers instanceof Headers) { + init.headers.forEach((v, k) => { + headers[k] = v; + }); + } else if (init.headers) { + Object.assign(headers, init.headers as Record); + } + + const res = await fetch(`${GATEWAY}/_bool/${GATEWAY_API}/fetch`, { + method: "POST", + headers: batteryHeaders(), + credentials: "include", + body: JSON.stringify({ + url: String(input), + method: init.method, + headers: Object.keys(headers).length ? headers : undefined, + body: init.body, + }), + }); + + let payload: any = null; + try { + payload = await res.json(); + } catch (_) {} + + // A failure here means the request never left the server, so there is no + // third-party response to hand back — throw instead of inventing one. + if (!res.ok) { + throw new BoolFetchError( + payload?.error ?? "fetch_failed", + res.status, + Array.isArray(payload?.secrets) ? payload.secrets : [], + ); + } + + // Rebuild the third party's own response, so res.ok / res.status / + // res.json() describe the API that was called and not this transport. + // 204/205/304 carry no body by spec — passing one to the Response + // constructor throws, which would turn a successful DELETE into an error. + const status: number = payload?.status ?? 200; + const body = + typeof payload?.body === "string" ? payload.body : JSON.stringify(payload?.body); + const bodyless = status === 204 || status === 205 || status === 304; + return new Response(bodyless ? null : body, { + status, + headers: (payload?.headers as Record) ?? {}, + }); + }; + const subscribeToChanges = ( listener: (payload: BoolChangePayload) => void, ): (() => void) => doorbell.subscribe(listener); @@ -701,6 +826,7 @@ export function createBoolClient(config: BoolClientConfig): BoolClient { entities: createEntitiesModule(db, subscribeToChanges), auth, ai, + fetch: boolFetch, schema, subscribeToChanges, }; diff --git a/src/index.ts b/src/index.ts index bc06389..9c3d582 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,9 @@ export { type BoolAi, type BoolAiSchema, BoolAiError, + type BoolFetch, + type BoolFetchInit, + BoolFetchError, type BoolUser, type BoolChangePayload, type AuthEvent,