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
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,26 @@
# Changelog

## 0.2.0-next.10

Adds `bool.ai` — the AI battery. A deployed app can call a model with NO API key
in the bundle: the call routes through the gateway's AI plane (`/_bool/v1/ai`),
which runs the prompt against Bool's own provider credential and meters one AI
credit against the app owner's workspace. The key never reaches the client.

- `bool.ai.generate(prompt)` → `Promise<string>` — plain text.
- `bool.ai.generate({ prompt, schema })` → `Promise<T>` — structured output
validated against a JSON Schema; returns the parsed, typed object.
- `bool.ai.stream(prompt)` → `AsyncIterable<string>` — text chunks for
typewriter UIs.
- New exports: `BoolAi`, `BoolAiSchema`, and `BoolAiError` (carries `status` +
machine-readable `code`, e.g. `"out_of_ai_credits"` on a 402).

Additive on the canary channel. The plane is gated server-side by the `bool-ai`
feature flag (off by default), so `bool.ai` only works where the workspace has
been opted in.

Requires the gateway AI plane in the Bool platform repo (`lib/gateway/ai-route.ts`).

## 0.2.0-next.9

Fix: `AuthGate` / `useSignInForm` no longer disagree about a pending
Expand Down
30 changes: 26 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,28 @@ tested, and upgradable independently of any one app.
`onAuthStateChange`, password reset) but talks to the Bool gateway's users
plane, so each app has its own isolated accounts and the client never
handles a credential.
- **AI battery.** `client.ai` gives a deployed app server-side AI with **no API
key in the bundle** — calls route through the gateway's AI plane
(`/_bool/v1/ai`), which runs the prompt against Bool's provider credential and
meters one AI credit against the app owner. Returns results directly and throws
a `BoolAiError` (with `status` + `code`, e.g. `"out_of_ai_credits"`) on failure:
```ts
const text = await bool.ai.generate("Summarize this review: " + review);

const { sentiment, topics } = await bool.ai.generate<{
sentiment: string; topics: string[];
}>({
prompt: review,
schema: {
type: "object",
properties: { sentiment: { type: "string" }, topics: { type: "array", items: { type: "string" } } },
required: ["sentiment", "topics"],
},
});

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.
- **React auth layer** (`bool-sdk/react`): `<BoolAuthProvider>`,
`useBoolAuth()`, `<AuthGate>`, and the headless `useSignInForm()` state
machine that login forms bind to.
Expand Down Expand Up @@ -83,10 +105,10 @@ create more than one.

## Compatibility

The gateway wire paths (`/_bool/v1/db`, `/_bool/v1/users`) are append-only:
new server behavior ships under a new version segment, never by mutating what
existing SDK versions call. Keep this SDK in sync with the gateway routes in
the Bool platform repo (`lib/gateway/`).
The gateway wire paths (`/_bool/v1/db`, `/_bool/v1/users`, `/_bool/v1/ai`) are
append-only: new server behavior ships under a new path/version segment, never
by mutating what existing SDK versions call. Keep this SDK in sync with the
gateway routes in the Bool platform repo (`lib/gateway/`).

## Development

Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "bool-sdk",
"version": "0.2.0-next.9",
"description": "Client SDK for apps built on Bool — gateway data access, end-user auth, and the React auth layer.",
"version": "0.2.0-next.10",
"description": "Client SDK for apps built on Bool — gateway data access, end-user auth, the AI battery, and the React auth layer.",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
Expand Down
85 changes: 84 additions & 1 deletion src/client.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { beforeEach, describe, expect, test } from "bun:test";
import { createBoolClient, getDefaultBoolClient, type BoolClientConfig } from "./client";
import {
createBoolClient,
getDefaultBoolClient,
BoolAiError,
type BoolClientConfig,
} from "./client";

// Behavioral tests for the gateway client with fetch/sessionStorage stubbed.
// These pin the invariants that make the client correct + secure: REST and
Expand Down Expand Up @@ -282,6 +287,84 @@ describe("per-user API key", () => {
});
});

describe("bool.ai battery", () => {
test("generate(prompt) POSTs to the ai plane and returns text", async () => {
respond = () =>
new Response(JSON.stringify({ text: "a summary" }), {
headers: { "content-type": "application/json" },
});
const client = createBoolClient(CONFIG);
const out = await client.ai.generate("summarize this");
expect(out).toBe("a summary");
expect(calls).toHaveLength(1);
expect(calls[0]!.url).toBe("https://bool.test/served/my-app/_bool/v1/ai/generate");
expect(calls[0]!.init?.method).toBe("POST");
expect(calls[0]!.init?.credentials).toBe("include");
expect(JSON.parse(String(calls[0]!.init?.body))).toEqual({ prompt: "summarize this" });
});

test("generate({prompt, schema}) sends the schema and returns the object", async () => {
respond = () =>
new Response(JSON.stringify({ object: { sentiment: "positive" } }), {
headers: { "content-type": "application/json" },
});
const client = createBoolClient(CONFIG);
const out = await client.ai.generate<{ sentiment: string }>({
prompt: "rate this",
schema: { type: "object", properties: { sentiment: { type: "string" } } },
});
expect(out).toEqual({ sentiment: "positive" });
const body = JSON.parse(String(calls[0]!.init?.body));
expect(body.schema).toEqual({ type: "object", properties: { sentiment: { type: "string" } } });
});

test("generate throws BoolAiError carrying status + code on failure", async () => {
respond = () =>
new Response(JSON.stringify({ error: "out_of_ai_credits" }), {
status: 402,
headers: { "content-type": "application/json" },
});
const client = createBoolClient(CONFIG);
const err = (await client.ai.generate("hi").catch((e) => e)) as BoolAiError;
expect(err).toBeInstanceOf(BoolAiError);
expect(err.status).toBe(402);
expect(err.code).toBe("out_of_ai_credits");
});

test("stream yields decoded text chunks", async () => {
respond = () => new Response("Hello, world");
const client = createBoolClient(CONFIG);
let acc = "";
for await (const chunk of client.ai.stream("tell me a story")) acc += chunk;
expect(acc).toBe("Hello, world");
expect(calls[0]!.url).toBe("https://bool.test/served/my-app/_bool/v1/ai/stream");
expect(calls[0]!.init?.method).toBe("POST");
});

test("stream throws BoolAiError on a non-ok response", async () => {
respond = () =>
new Response(JSON.stringify({ error: "rate_limited" }), {
status: 429,
headers: { "content-type": "application/json" },
});
const client = createBoolClient(CONFIG);
const iter = client.ai.stream("go");
const err = await iter[Symbol.asyncIterator]().next().catch((e) => e);
expect(err).toBeInstanceOf(BoolAiError);
expect((err as BoolAiError).code).toBe("rate_limited");
});

test("replays the preview viewer token as x-bool-viewer", async () => {
respond = () =>
new Response(JSON.stringify({ text: "ok" }), {
headers: { "content-type": "application/json" },
});
const client = createBoolClient({ ...CONFIG, viewerToken: "viewer-123" });
await client.ai.generate("hi");
expect(headersOf(calls[0]!).get("x-bool-viewer")).toBe("viewer-123");
});
});

describe("default client registry", () => {
test("the last-created client is the default (hot reload re-registers)", () => {
const first = createBoolClient(CONFIG);
Expand Down
107 changes: 107 additions & 0 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,41 @@ export type BoolAuth = {
* whatever you derive from that table — the ping never carries the data. */
export type BoolChangePayload = { table?: string; op?: string };

/** A JSON Schema describing the shape `bool.ai.generate` should return. Passed
* straight to the gateway, which validates the model's output against it. e.g.
* `{ type: "object", properties: { sentiment: { type: "string" } }, required: ["sentiment"] }`. */
export type BoolAiSchema = Record<string, unknown>;

/** Thrown when a bool.ai call fails. `status` is the gateway HTTP status and
* `code` its machine-readable error (e.g. "out_of_ai_credits" on a 402,
* "rate_limited" on a 429) so app code can branch without string-matching. */
export class BoolAiError extends Error {
readonly status: number;
readonly code: string;
constructor(code: string, status: number) {
super(`bool.ai failed: ${code} (${status})`);
this.name = "BoolAiError";
this.code = code;
this.status = status;
}
}

/** The AI battery — server-side AI with NO API key in the app bundle. Each call
* routes through the Bool gateway (/_bool/v1/ai), which runs the prompt against
* Bool's own provider credential and meters one AI credit against the app
* owner's workspace. The key never reaches the client. Returns results directly
* and THROWS a {@link BoolAiError} on failure (same ergonomics as `entities`). */
export type BoolAi = {
/** Generate plain text from a prompt. */
generate(prompt: string): Promise<string>;
/** Generate structured output validated against a JSON Schema. Returns the
* parsed object, typed as `T` when you supply it. */
generate<T = unknown>(opts: { prompt: string; schema: BoolAiSchema }): Promise<T>;
/** Stream generated text as it's produced — an async iterator of text chunks,
* for typewriter UIs: `for await (const chunk of bool.ai.stream(p)) …`. */
stream(prompt: string): AsyncIterable<string>;
};

/** 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<any, any, any, any, any>;
Expand All @@ -113,6 +148,9 @@ export type BoolClient = {
entities: EntitiesModule;
/** End-user auth for this app (gateway users plane). */
auth: BoolAuth;
/** 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;
/** This app's private Postgres schema name. */
schema: string;
/** Subscribe to the app's realtime "doorbell": fires whenever any row in the
Expand Down Expand Up @@ -445,6 +483,74 @@ export function createBoolClient(config: BoolClientConfig): BoolClient {
},
};

// bool.ai battery — POST the prompt to the gateway AI plane
// (/_bool/v1/ai/*), which runs it against Bool's provider credential and
// meters one AI credit against the app owner. credentials:include + the
// 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<string, string> {
const headers: Record<string, string> = { "content-type": "application/json" };
if (viewerToken) headers["x-bool-viewer"] = viewerToken;
if (euSessionToken) headers["x-bool-eu-session"] = euSessionToken;
return headers;
}
const ai: BoolAi = {
// One impl covers both overloads (string prompt → text; {prompt, schema} →
// structured object). The public BoolAi type exposes the two typed forms.
generate: (async (
promptOrOpts: string | { prompt: string; schema?: BoolAiSchema },
): Promise<unknown> => {
const opts =
typeof promptOrOpts === "string" ? { prompt: promptOrOpts } : promptOrOpts;
const res = await fetch(`${GATEWAY}/_bool/${GATEWAY_API}/ai/generate`, {
method: "POST",
headers: aiHeaders(),
credentials: "include",
body: JSON.stringify({ prompt: opts.prompt, schema: opts.schema }),
});
let body: any = null;
try {
body = await res.json();
} catch (_) {}
if (!res.ok) throw new BoolAiError(body?.error ?? "ai_failed", res.status);
// Structured → { object }; plain → { text }. Return the inner value.
return opts.schema ? body?.object : body?.text;
}) as BoolAi["generate"],

async *stream(prompt: string): AsyncIterable<string> {
const res = await fetch(`${GATEWAY}/_bool/${GATEWAY_API}/ai/stream`, {
method: "POST",
headers: aiHeaders(),
credentials: "include",
body: JSON.stringify({ prompt }),
});
if (!res.ok || !res.body) {
let body: any = null;
try {
body = await res.json();
} catch (_) {}
throw new BoolAiError(body?.error ?? "ai_failed", res.status);
}
// The gateway streams raw text deltas (text/plain). Decode and yield each
// chunk as it arrives.
const reader = res.body.getReader();
const decoder = new TextDecoder();
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
if (chunk) yield chunk;
}
const tail = decoder.decode();
if (tail) yield tail;
} finally {
reader.releaseLock();
}
},
};

// Realtime "doorbell": the app schema's grants are revoked, so Supabase
// `postgres_changes` never fires. Instead the server broadcasts a
// row-data-free ping on the PUBLIC channel "bool:" + schema whenever any
Expand All @@ -468,6 +574,7 @@ export function createBoolClient(config: BoolClientConfig): BoolClient {
db,
entities: createEntitiesModule(db, subscribeToChanges),
auth,
ai,
schema,
subscribeToChanges,
};
Expand Down
3 changes: 3 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ export {
type BoolClient,
type BoolClientConfig,
type BoolAuth,
type BoolAi,
type BoolAiSchema,
BoolAiError,
type BoolUser,
type BoolChangePayload,
type AuthEvent,
Expand Down
Loading