From ffb3e20c4d029385d00e6a42592dc755c82a82a5 Mon Sep 17 00:00:00 2001 From: Max Bader Date: Thu, 16 Jul 2026 13:26:37 -0500 Subject: [PATCH] Per-user API keys: apiKey on BoolUser + auth.rotateApiKey() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gateway's /users/me now lazily mints and returns a personal api_key for the signed-in end user (Base44 convention): external callers send it as the `api_key` header and act exactly as that user, same RLS scoping as in the app. SDK side is thin because getUser() passes the server user object through: add the `apiKey?: string` field to BoolUser (typed access to (await auth.getUser()).data.user.apiKey) and an auth.rotateApiKey() helper that POSTs /users/api-key/rotate (old key dies immediately). Pairs with the bool-side gateway change (api_key header → sub → RLS). Co-Authored-By: Claude Opus 4.8 --- src/client.test.ts | 42 ++++++++++++++++++++++++++++++++++++++++++ src/client.ts | 15 +++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/src/client.test.ts b/src/client.test.ts index af86f3b..a43aefc 100644 --- a/src/client.test.ts +++ b/src/client.test.ts @@ -240,6 +240,48 @@ describe("end-user auth (gateway users plane)", () => { }); }); +describe("per-user API key", () => { + test("getUser passes the gateway's apiKey field through on the user", async () => { + respond = () => + new Response( + JSON.stringify({ + user: { id: "u1", email: "a@b.c", apiKey: "boolk_abc123" }, + }), + { headers: { "content-type": "application/json" } }, + ); + const client = createBoolClient(CONFIG); + const { data } = await client.auth.getUser(); + expect((data.user as any).apiKey).toBe("boolk_abc123"); + }); + + test("rotateApiKey POSTs the rotate route and returns the fresh key", async () => { + respond = () => + new Response(JSON.stringify({ apiKey: "boolk_fresh456" }), { + headers: { "content-type": "application/json" }, + }); + const client = createBoolClient(CONFIG); + const { data, error } = await client.auth.rotateApiKey(); + expect(error).toBeNull(); + expect(data.apiKey).toBe("boolk_fresh456"); + expect(calls[0]!.url).toBe( + "https://bool.test/served/my-app/_bool/v1/users/api-key/rotate", + ); + expect(calls[0]!.init?.method).toBe("POST"); + }); + + test("rotateApiKey surfaces a 503 (keys not configured) as an error, null key", async () => { + respond = () => + new Response(JSON.stringify({ error: "api_keys_not_configured" }), { + status: 503, + headers: { "content-type": "application/json" }, + }); + const client = createBoolClient(CONFIG); + const { data, error } = await client.auth.rotateApiKey(); + expect(data.apiKey).toBeNull(); + expect(error).toEqual({ error: "api_keys_not_configured" }); + }); +}); + 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 f3f05db..77ff09b 100644 --- a/src/client.ts +++ b/src/client.ts @@ -62,6 +62,11 @@ export type BoolUser = { provider: "password" | "google"; emailVerified: boolean; createdAt: string; + /** The user's personal API key for external/programmatic calls (sent as the + * `api_key` header). Acts exactly as this user — same per-user data scoping + * as in the app. Lazily minted by the gateway on first /me; absent when the + * deployment hasn't configured API keys. Rotate via auth.rotateApiKey(). */ + apiKey?: string; }; export type AuthEvent = "SIGNED_IN" | "SIGNED_OUT"; @@ -85,6 +90,9 @@ export type BoolAuth = { }; resetPasswordForEmail(email: string): Promise<{ data: unknown; error: unknown }>; confirmPasswordReset(opts: { token: string; password: string }): Promise; + /** Mint a replacement personal API key; the old one stops working + * immediately. Returns the new key (also reflected by the next getUser()). */ + rotateApiKey(): Promise<{ data: { apiKey: string | null }; error: unknown }>; }; /** A row-data-free change notification: some row in `table` saw `op`. Refetch @@ -386,6 +394,13 @@ export function createBoolClient(config: BoolClientConfig): BoolClient { const { res, body } = await usersCall("/me", { method: "GET" }); return { data: { user: res.ok && body ? body.user : null }, error: null }; }, + async rotateApiKey(): Promise<{ data: { apiKey: string | null }; error: unknown }> { + const { res, body } = await usersCall("/api-key/rotate", { method: "POST" }); + if (!res.ok) { + return { data: { apiKey: null }, error: body || { error: "rotate_failed" } }; + } + return { data: { apiKey: body?.apiKey ?? null }, error: null }; + }, onAuthStateChange(callback: AuthChangeListener) { authListeners.add(callback); auth