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
42 changes: 42 additions & 0 deletions src/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
15 changes: 15 additions & 0 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -85,6 +90,9 @@ export type BoolAuth = {
};
resetPasswordForEmail(email: string): Promise<{ data: unknown; error: unknown }>;
confirmPasswordReset(opts: { token: string; password: string }): Promise<AuthResult>;
/** 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
Expand Down Expand Up @@ -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
Expand Down
Loading