busabase-sdk 0.10.2 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -40,9 +40,49 @@ Every constructor field is optional and falls back to an environment variable:
40
40
 
41
41
  `baseUrl` accepts either the server root (`http://host`) or the full API path (`http://host/api/v1`) — the `/api/v1` suffix is normalized away.
42
42
 
43
- ## Two entry points
43
+ ## Local AirApp OAuth
44
44
 
45
- **`Busabase` class** an ergonomic wrapper with namespaced methods (`bb.bases`, `bb.records`, `bb.changeRequests`, `bb.nodes`, `bb.views`, `bb.assets`, `bb.skills`, `bb.docs`, `bb.folders`, `bb.comments`, `bb.auditEvents`, `bb.agent`, `bb.agentTasks`, `bb.embedLinks`, `bb.search()`, `bb.health()`, `bb.me()`). Drop to `bb.client` for the raw oRPC client (e.g. `bb.client.system.meta()`).
45
+ Local Hono apps can connect without asking the user to run the CLI or paste an API key. Start a PKCE authorization request, validate the callback, exchange the code, then register the returned token set in the local Busabase credential directory:
46
+
47
+ ```ts
48
+ import {
49
+ createBusabaseOAuthRequest,
50
+ exchangeBusabaseOAuthCode,
51
+ parseBusabaseOAuthCallback,
52
+ } from "busabase-sdk/oauth";
53
+ import { storeBusabaseAirAppOAuthCredential } from "busabase-sdk/oauth-node";
54
+
55
+ const request = await createBusabaseOAuthRequest({
56
+ baseUrl: "https://busabase.com",
57
+ redirectUri: "http://127.0.0.1:3107/auth/callback",
58
+ });
59
+
60
+ // Keep request.codeVerifier and request.state in the Hono server, then redirect
61
+ // the browser to request.authorizeUrl.
62
+ const code = parseBusabaseOAuthCallback(callbackUrl, request);
63
+ const tokenSet = await exchangeBusabaseOAuthCode(request, code);
64
+ storeBusabaseAirAppOAuthCredential({
65
+ appId: "kelly-invest-stock",
66
+ baseUrl: request.baseUrl,
67
+ tokenSet,
68
+ });
69
+ ```
70
+
71
+ The Node-only helper writes `~/.busabase/airapps/<app-id>.json` with an owner-only directory and file (`0700`/`0600`). Use `getBusabaseAirAppAccessToken()` inside Hono when proxying `/api/v1`; it refreshes and persists the token set when needed. Use `revokeBusabaseAirAppOAuthCredential()` on logout. The browser must never receive the `bso_` access token, `bsr_` refresh token, or PKCE verifier through JavaScript-visible storage. This local AirApp registration is separate from the CLI's active `~/.busabase/.env` profile.
72
+
73
+ ## Data client entry points
74
+
75
+ **`Busabase` class** — an ergonomic wrapper with namespaced methods (`bb.bases`, `bb.records`, `bb.changeRequests`, `bb.nodes`, `bb.views`, `bb.assets`, `bb.fileTrees`, `bb.files`, `bb.docs`, `bb.comments`, `bb.auditEvents`, `bb.agent`, `bb.agentTasks`, `bb.embedLinks`, `bb.search()`, `bb.grep()`, `bb.health()`, `bb.me()`). Drop to `bb.client` for the raw oRPC client (e.g. `bb.client.system.meta()`).
76
+
77
+ Reading or listing nodes goes through `bb.nodes`, whatever the node's type:
78
+
79
+ ```ts
80
+ const docs = await bb.nodes.list({ types: ["doc"] }); // flat summaries, one call
81
+ const detail = await bb.nodes.get({ nodeId }); // discriminated by `detail.type`
82
+ if (detail.type === "doc") console.log(detail.body);
83
+ ```
84
+
85
+ `bb.nodes.get` replaced the per-type gets (`docs.get` / `files.get` / `folders.get` / `fileTrees.get`), so a caller holding an id no longer has to discover the node's type before it can read it — and there is no `bb.folders` namespace any more (a folder is `type: "folder"`). `bb.docs` / `bb.files` / `bb.fileTrees` remain for the operations that are genuinely type-specific: creating a node, reading a Doc line range, updating a Doc body, and listing/reading a Skill-, Drive-, or AirApp file.
46
86
 
47
87
  **`createBusabaseClient(config?)`** — returns the raw, fully-typed [oRPC](https://orpc.unnoq.com) client directly, if you'd rather not wrap it in a class:
48
88
 
@@ -0,0 +1,6 @@
1
+ // src/url.ts
2
+ function normalizeBaseUrl(raw) {
3
+ return raw.replace(/\/+$/, "").replace(/\/api\/v1$/, "");
4
+ }
5
+
6
+ export { normalizeBaseUrl };
@@ -0,0 +1,175 @@
1
+ import { normalizeBaseUrl } from './chunk-5NYQX65A.js';
2
+
3
+ // ../../packages/busabase-contract/src/auth/device-authorization.ts
4
+ var BUSABASE_AIRAPP_CLIENT_ID = "busabase-airapp";
5
+
6
+ // src/oauth.ts
7
+ var BusabaseOAuthError = class extends Error {
8
+ code;
9
+ status;
10
+ constructor(code, message, status) {
11
+ super(message);
12
+ this.name = "BusabaseOAuthError";
13
+ this.code = code;
14
+ this.status = status;
15
+ }
16
+ };
17
+ var oauthBaseUrl = (raw) => {
18
+ let url;
19
+ try {
20
+ url = new URL(normalizeBaseUrl(raw));
21
+ } catch {
22
+ throw new BusabaseOAuthError("invalid_base_url", "Busabase base URL is invalid");
23
+ }
24
+ if (url.protocol !== "https:" && url.protocol !== "http:" || url.username || url.password || url.search || url.hash || url.pathname !== "/" && url.pathname !== "") {
25
+ throw new BusabaseOAuthError(
26
+ "invalid_base_url",
27
+ "Busabase base URL must be an HTTP(S) origin without credentials, query, or path"
28
+ );
29
+ }
30
+ return url.origin;
31
+ };
32
+ var randomBase64Url = (byteLength) => {
33
+ const bytes = globalThis.crypto.getRandomValues(new Uint8Array(byteLength));
34
+ let binary = "";
35
+ for (const byte of bytes) binary += String.fromCharCode(byte);
36
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
37
+ };
38
+ var digestBase64Url = async (value) => {
39
+ const encoded = new TextEncoder().encode(value);
40
+ const data = encoded.buffer.slice(
41
+ encoded.byteOffset,
42
+ encoded.byteOffset + encoded.byteLength
43
+ );
44
+ const digest = await globalThis.crypto.subtle.digest("SHA-256", data);
45
+ let binary = "";
46
+ for (const byte of new Uint8Array(digest)) binary += String.fromCharCode(byte);
47
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
48
+ };
49
+ async function createBusabaseOAuthRequest(input) {
50
+ const baseUrl = oauthBaseUrl(input.baseUrl);
51
+ const redirectUri = new URL(input.redirectUri).toString();
52
+ const clientId = input.clientId ?? BUSABASE_AIRAPP_CLIENT_ID;
53
+ const codeVerifier = randomBase64Url(32);
54
+ const state = input.state ?? randomBase64Url(24);
55
+ const resource = new URL("/api/v1", baseUrl).toString();
56
+ const authorizeUrl = new URL("/api/oauth/authorize", baseUrl);
57
+ authorizeUrl.search = new URLSearchParams({
58
+ response_type: "code",
59
+ client_id: clientId,
60
+ resource,
61
+ scope: "api",
62
+ code_challenge: await digestBase64Url(codeVerifier),
63
+ code_challenge_method: "S256",
64
+ redirect_uri: redirectUri,
65
+ state
66
+ }).toString();
67
+ if (input.prompt) authorizeUrl.searchParams.set("prompt", input.prompt);
68
+ return {
69
+ authorizeUrl: authorizeUrl.toString(),
70
+ baseUrl,
71
+ clientId,
72
+ codeVerifier,
73
+ redirectUri,
74
+ resource,
75
+ state
76
+ };
77
+ }
78
+ function parseBusabaseOAuthCallback(callbackUrl, request) {
79
+ const callback = new URL(callbackUrl);
80
+ const error = callback.searchParams.get("error");
81
+ if (error) {
82
+ throw new BusabaseOAuthError(
83
+ error,
84
+ callback.searchParams.get("error_description") || "Busabase authorization was denied"
85
+ );
86
+ }
87
+ if (callback.searchParams.get("state") !== request.state) {
88
+ throw new BusabaseOAuthError("state_mismatch", "OAuth callback state did not match");
89
+ }
90
+ const issuer = callback.searchParams.get("iss");
91
+ let issuerMatches = false;
92
+ try {
93
+ issuerMatches = Boolean(issuer && new URL(issuer).origin === new URL(request.baseUrl).origin);
94
+ } catch {
95
+ issuerMatches = false;
96
+ }
97
+ if (!issuerMatches) {
98
+ throw new BusabaseOAuthError("issuer_mismatch", "OAuth callback issuer did not match");
99
+ }
100
+ const code = callback.searchParams.get("code");
101
+ if (!code) throw new BusabaseOAuthError("missing_code", "OAuth callback had no code");
102
+ return code;
103
+ }
104
+ var parseTokenResponse = async (response) => {
105
+ const body = await response.json().catch(() => null);
106
+ if (!response.ok) {
107
+ const code = typeof body?.error === "string" ? body.error : "token_request_failed";
108
+ const message = typeof body?.error_description === "string" ? body.error_description : `Busabase OAuth token request failed (${response.status})`;
109
+ throw new BusabaseOAuthError(code, message, response.status);
110
+ }
111
+ if (typeof body?.access_token !== "string" || typeof body.expires_in !== "number") {
112
+ throw new BusabaseOAuthError(
113
+ "invalid_token_response",
114
+ "Busabase returned an invalid token set"
115
+ );
116
+ }
117
+ return {
118
+ accessToken: body.access_token,
119
+ refreshToken: typeof body.refresh_token === "string" ? body.refresh_token : void 0,
120
+ expiresIn: body.expires_in,
121
+ expiresAt: new Date(Date.now() + body.expires_in * 1e3).toISOString(),
122
+ scope: typeof body.scope === "string" ? body.scope.split(/\s+/).filter(Boolean) : [],
123
+ tokenType: typeof body.token_type === "string" ? body.token_type : "Bearer",
124
+ user: body.user && typeof body.user === "object" ? body.user : void 0
125
+ };
126
+ };
127
+ async function exchangeBusabaseOAuthCode(request, code, fetchImpl = fetch) {
128
+ const response = await fetchImpl(new URL("/api/oauth/token", request.baseUrl), {
129
+ method: "POST",
130
+ headers: { "content-type": "application/x-www-form-urlencoded" },
131
+ body: new URLSearchParams({
132
+ grant_type: "authorization_code",
133
+ client_id: request.clientId,
134
+ code,
135
+ code_verifier: request.codeVerifier,
136
+ redirect_uri: request.redirectUri,
137
+ resource: request.resource
138
+ })
139
+ });
140
+ return parseTokenResponse(response);
141
+ }
142
+ async function refreshBusabaseOAuthToken(input, fetchImpl = fetch) {
143
+ const baseUrl = oauthBaseUrl(input.baseUrl);
144
+ const response = await fetchImpl(new URL("/api/oauth/token", baseUrl), {
145
+ method: "POST",
146
+ headers: { "content-type": "application/x-www-form-urlencoded" },
147
+ body: new URLSearchParams({
148
+ grant_type: "refresh_token",
149
+ refresh_token: input.refreshToken,
150
+ client_id: input.clientId ?? BUSABASE_AIRAPP_CLIENT_ID,
151
+ resource: new URL("/api/v1", baseUrl).toString()
152
+ })
153
+ });
154
+ return parseTokenResponse(response);
155
+ }
156
+ async function revokeBusabaseOAuthToken(input, fetchImpl = fetch) {
157
+ const baseUrl = oauthBaseUrl(input.baseUrl);
158
+ const response = await fetchImpl(new URL("/api/oauth/revoke", baseUrl), {
159
+ method: "POST",
160
+ headers: { "content-type": "application/x-www-form-urlencoded" },
161
+ body: new URLSearchParams({
162
+ token: input.token,
163
+ client_id: input.clientId ?? BUSABASE_AIRAPP_CLIENT_ID
164
+ })
165
+ });
166
+ if (!response.ok) {
167
+ throw new BusabaseOAuthError(
168
+ "revoke_failed",
169
+ `Busabase OAuth revocation failed (${response.status})`,
170
+ response.status
171
+ );
172
+ }
173
+ }
174
+
175
+ export { BUSABASE_AIRAPP_CLIENT_ID, BusabaseOAuthError, createBusabaseOAuthRequest, exchangeBusabaseOAuthCode, parseBusabaseOAuthCallback, refreshBusabaseOAuthToken, revokeBusabaseOAuthToken };