Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`): `<BoolAuthProvider>`,
`useBoolAuth()`, `<AuthGate>`, and the headless `useSignInForm()` state
machine that login forms bind to.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
137 changes: 137 additions & 0 deletions src/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
hasDefaultBoolClient,
isDeploymentSubdomain,
BoolAiError,
BoolFetchError,
type BoolClientConfig,
} from "./client";

Expand Down Expand Up @@ -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<string, string>;
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);
Expand Down
Loading
Loading