busabase-sdk 0.14.1 → 0.16.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 +77 -0
- package/dist/airapp-gate.css +296 -0
- package/dist/airapp-gate.d.ts +157 -0
- package/dist/airapp-gate.js +238 -0
- package/dist/airapp-node.d.ts +66 -0
- package/dist/airapp-node.js +439 -0
- package/dist/airapp.d.ts +222 -0
- package/dist/airapp.js +359 -0
- package/dist/chunk-C23JVY2Y.js +211 -0
- package/dist/{chunk-J2DZKX7A.js → chunk-WSHJMHUS.js} +42 -2
- package/dist/client-DB7fREZX.d.ts +17112 -0
- package/dist/index.d.ts +4 -16983
- package/dist/index.js +104 -0
- package/dist/oauth-node.d.ts +32 -1
- package/dist/oauth-node.js +3 -147
- package/dist/oauth.d.ts +13 -1
- package/dist/oauth.js +1 -1
- package/package.json +17 -3
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
import { getBusabaseAirAppAccessToken, storeBusabaseAirAppSelectedSpace, loadBusabaseAirAppDynamicClientId, storeBusabaseAirAppDynamicClientId, storeBusabaseAirAppOAuthCredential, loadBusabaseAirAppOAuthCredential, revokeBusabaseAirAppOAuthCredential } from './chunk-C23JVY2Y.js';
|
|
2
|
+
import { BUSABASE_AIRAPP_CLIENT_ID, BusabaseOAuthError, registerBusabaseAirAppOAuthClient, createBusabaseOAuthRequest, parseBusabaseOAuthCallback, exchangeBusabaseOAuthCode } from './chunk-WSHJMHUS.js';
|
|
3
|
+
import './chunk-5NYQX65A.js';
|
|
4
|
+
|
|
5
|
+
// src/airapp-node.ts
|
|
6
|
+
var DEFAULT_CLOUD_BASE_URL = "https://busabase.com";
|
|
7
|
+
var DEFAULT_PENDING_TTL_MS = 5 * 6e4;
|
|
8
|
+
var DEFAULT_TIMEOUT_MS = 8e3;
|
|
9
|
+
var BUSABASE_AIRAPP_GATEWAY_REASONS = {
|
|
10
|
+
authRequired: "AUTH_REQUIRED",
|
|
11
|
+
authUnavailable: "AUTH_UNAVAILABLE",
|
|
12
|
+
connectionRequired: "CONNECTION_REQUIRED",
|
|
13
|
+
oauthCallbackInvalid: "OAUTH_CALLBACK_INVALID",
|
|
14
|
+
spaceNotAllowed: "SPACE_NOT_ALLOWED",
|
|
15
|
+
spaceSelectionRequired: "SPACE_SELECTION_REQUIRED"
|
|
16
|
+
};
|
|
17
|
+
var jsonError = (status, reason, message, data) => Response.json(
|
|
18
|
+
{
|
|
19
|
+
error: message,
|
|
20
|
+
code: status === 401 ? "UNAUTHORIZED" : status === 403 ? "FORBIDDEN" : status === 409 ? "CONFLICT" : status === 503 ? "SERVICE_UNAVAILABLE" : "BAD_REQUEST",
|
|
21
|
+
data: { reason, ...data }
|
|
22
|
+
},
|
|
23
|
+
{ status }
|
|
24
|
+
);
|
|
25
|
+
var LOOPBACK_HOSTNAMES = ["localhost", "127.0.0.1", "::1", "[::1]"];
|
|
26
|
+
var normalizeOrigin = (raw, fallback = DEFAULT_CLOUD_BASE_URL) => {
|
|
27
|
+
const withoutApi = String(raw || fallback).trim().replace(/\/+$/, "").replace(/\/api\/v1$/, "");
|
|
28
|
+
let url;
|
|
29
|
+
try {
|
|
30
|
+
url = new URL(withoutApi);
|
|
31
|
+
} catch {
|
|
32
|
+
throw new BusabaseOAuthError("invalid_base_url", "Busabase base URL is invalid");
|
|
33
|
+
}
|
|
34
|
+
if (url.username || url.password || url.search || url.hash || url.pathname !== "/" && url.pathname !== "") {
|
|
35
|
+
throw new BusabaseOAuthError("invalid_base_url", "Busabase base URL must be an origin");
|
|
36
|
+
}
|
|
37
|
+
const loopback = LOOPBACK_HOSTNAMES.includes(url.hostname);
|
|
38
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) {
|
|
39
|
+
throw new BusabaseOAuthError(
|
|
40
|
+
"invalid_base_url",
|
|
41
|
+
"Busabase requires HTTPS except for a loopback development server"
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
return url.origin;
|
|
45
|
+
};
|
|
46
|
+
var requestOrigin = (request) => new URL(request.url).origin;
|
|
47
|
+
var isLoopbackOrigin = (origin) => {
|
|
48
|
+
const url = new URL(origin);
|
|
49
|
+
return url.protocol === "http:" && LOOPBACK_HOSTNAMES.includes(url.hostname);
|
|
50
|
+
};
|
|
51
|
+
var assertSameOrigin = (request) => {
|
|
52
|
+
const origin = request.headers.get("origin");
|
|
53
|
+
if (origin && origin !== requestOrigin(request)) {
|
|
54
|
+
throw new BusabaseOAuthError("origin_mismatch", "Request origin did not match");
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
var readInput = async (request) => {
|
|
58
|
+
const contentType = request.headers.get("content-type") || "";
|
|
59
|
+
if (contentType.includes("application/json")) {
|
|
60
|
+
return await request.json().catch(() => ({}));
|
|
61
|
+
}
|
|
62
|
+
const form = await request.formData().catch(() => new FormData());
|
|
63
|
+
return Object.fromEntries(form.entries());
|
|
64
|
+
};
|
|
65
|
+
var safeSpaces = (spaces) => spaces.map(({ id, name, slug, plan }) => ({ id, name, slug, plan }));
|
|
66
|
+
var BusabaseAirAppLocalGateway = class {
|
|
67
|
+
#options;
|
|
68
|
+
#pendingOAuth = /* @__PURE__ */ new Map();
|
|
69
|
+
#usesDefaultClient;
|
|
70
|
+
#environmentSelectedSpace;
|
|
71
|
+
constructor(options) {
|
|
72
|
+
this.#usesDefaultClient = !options.clientId;
|
|
73
|
+
this.#options = {
|
|
74
|
+
...options,
|
|
75
|
+
appId: options.appId,
|
|
76
|
+
cloudBaseUrl: normalizeOrigin(options.cloudBaseUrl || DEFAULT_CLOUD_BASE_URL),
|
|
77
|
+
clientId: options.clientId || BUSABASE_AIRAPP_CLIENT_ID,
|
|
78
|
+
oauthPendingTtlMs: options.oauthPendingTtlMs ?? DEFAULT_PENDING_TTL_MS,
|
|
79
|
+
requestTimeoutMs: options.requestTimeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
80
|
+
successPath: options.successPath || "/",
|
|
81
|
+
errorPath: options.errorPath || "/"
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
get cloudBaseUrl() {
|
|
85
|
+
return this.#options.cloudBaseUrl;
|
|
86
|
+
}
|
|
87
|
+
#fetch() {
|
|
88
|
+
return this.#options.fetch ?? fetch;
|
|
89
|
+
}
|
|
90
|
+
#now() {
|
|
91
|
+
return this.#options.now?.() ?? Date.now();
|
|
92
|
+
}
|
|
93
|
+
#environment() {
|
|
94
|
+
return this.#options.environment ?? process.env;
|
|
95
|
+
}
|
|
96
|
+
async #target() {
|
|
97
|
+
const environment = this.#environment();
|
|
98
|
+
if (environment.BUSABASE_BASE_URL) {
|
|
99
|
+
const selectedSpaceId = environment.BUSABASE_SPACE_ID?.trim();
|
|
100
|
+
return {
|
|
101
|
+
baseUrl: normalizeOrigin(environment.BUSABASE_BASE_URL),
|
|
102
|
+
accessToken: environment.BUSABASE_API_KEY || "",
|
|
103
|
+
source: environment.BUSABASE_API_KEY ? "environment" : "open-server",
|
|
104
|
+
...selectedSpaceId ? { selectedSpace: { id: selectedSpaceId, name: selectedSpaceId } } : this.#environmentSelectedSpace ? { selectedSpace: this.#environmentSelectedSpace } : {}
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
const credential = await getBusabaseAirAppAccessToken(
|
|
108
|
+
this.#options.appId,
|
|
109
|
+
this.#options.credentialStore,
|
|
110
|
+
this.#fetch()
|
|
111
|
+
);
|
|
112
|
+
return credential ? {
|
|
113
|
+
baseUrl: credential.baseUrl,
|
|
114
|
+
accessToken: credential.accessToken,
|
|
115
|
+
source: "airapp-oauth-local",
|
|
116
|
+
...credential.selectedSpace ? { selectedSpace: credential.selectedSpace } : {}
|
|
117
|
+
} : null;
|
|
118
|
+
}
|
|
119
|
+
async #authInfo(target, spaceId = "") {
|
|
120
|
+
const headers = new Headers({ accept: "application/json" });
|
|
121
|
+
if (target.accessToken) headers.set("authorization", `Bearer ${target.accessToken}`);
|
|
122
|
+
if (spaceId) headers.set("x-busabase-space", spaceId);
|
|
123
|
+
const response = await this.#fetch()(new URL("/api/v1/auth", target.baseUrl), {
|
|
124
|
+
headers,
|
|
125
|
+
signal: AbortSignal.timeout(this.#options.requestTimeoutMs)
|
|
126
|
+
});
|
|
127
|
+
if (!response.ok) {
|
|
128
|
+
throw new BusabaseOAuthError(
|
|
129
|
+
response.status === 401 ? "auth_required" : "auth_verification_failed",
|
|
130
|
+
`Busabase auth verification failed (${response.status})`,
|
|
131
|
+
response.status
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
const info = await response.json();
|
|
135
|
+
if (!Array.isArray(info.spaces)) {
|
|
136
|
+
throw new BusabaseOAuthError(
|
|
137
|
+
"invalid_auth_response",
|
|
138
|
+
"Busabase auth response did not include Spaces"
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
return info;
|
|
142
|
+
}
|
|
143
|
+
#persistSelectedSpace(target, selectedSpace) {
|
|
144
|
+
if (target.source === "airapp-oauth-local") {
|
|
145
|
+
storeBusabaseAirAppSelectedSpace(
|
|
146
|
+
this.#options.appId,
|
|
147
|
+
selectedSpace ? { id: selectedSpace.id, name: selectedSpace.name } : null,
|
|
148
|
+
this.#options.credentialStore
|
|
149
|
+
);
|
|
150
|
+
} else {
|
|
151
|
+
this.#environmentSelectedSpace = selectedSpace || void 0;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
async status() {
|
|
155
|
+
let target = null;
|
|
156
|
+
try {
|
|
157
|
+
target = await this.#target();
|
|
158
|
+
if (!target) {
|
|
159
|
+
return {
|
|
160
|
+
connected: false,
|
|
161
|
+
cloudBaseUrl: this.cloudBaseUrl,
|
|
162
|
+
readiness: "needs_connection",
|
|
163
|
+
action: "connect",
|
|
164
|
+
reason: BUSABASE_AIRAPP_GATEWAY_REASONS.connectionRequired
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
const info = await this.#authInfo(target);
|
|
168
|
+
const spaces = safeSpaces(info.spaces);
|
|
169
|
+
if (!spaces.length) {
|
|
170
|
+
this.#persistSelectedSpace(target, null);
|
|
171
|
+
return {
|
|
172
|
+
connected: true,
|
|
173
|
+
cloudBaseUrl: this.cloudBaseUrl,
|
|
174
|
+
baseUrl: target.baseUrl,
|
|
175
|
+
source: target.source,
|
|
176
|
+
readiness: "needs_space",
|
|
177
|
+
action: "retry",
|
|
178
|
+
requiresSpace: true,
|
|
179
|
+
selectedSpace: null,
|
|
180
|
+
space: null,
|
|
181
|
+
spaces,
|
|
182
|
+
reason: BUSABASE_AIRAPP_GATEWAY_REASONS.spaceSelectionRequired,
|
|
183
|
+
message: "This account has no accessible Busabase Space"
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
const selectedSpaceId = target.selectedSpace?.id;
|
|
187
|
+
let selected = selectedSpaceId ? spaces.find((space) => space.id === selectedSpaceId) : void 0;
|
|
188
|
+
if (!selected && spaces.length === 1) selected = spaces[0];
|
|
189
|
+
if (target.selectedSpace && !selected) this.#persistSelectedSpace(target, null);
|
|
190
|
+
if (selected && selected.id !== selectedSpaceId) {
|
|
191
|
+
await this.#authInfo(target, selected.id);
|
|
192
|
+
this.#persistSelectedSpace(target, selected);
|
|
193
|
+
}
|
|
194
|
+
return {
|
|
195
|
+
connected: true,
|
|
196
|
+
cloudBaseUrl: this.cloudBaseUrl,
|
|
197
|
+
baseUrl: target.baseUrl,
|
|
198
|
+
source: target.source,
|
|
199
|
+
readiness: selected ? "ready" : "needs_space",
|
|
200
|
+
action: selected ? "continue" : "select_space",
|
|
201
|
+
requiresSpace: !selected,
|
|
202
|
+
selectedSpace: selected || null,
|
|
203
|
+
space: selected || null,
|
|
204
|
+
spaces,
|
|
205
|
+
...selected ? {} : { reason: BUSABASE_AIRAPP_GATEWAY_REASONS.spaceSelectionRequired }
|
|
206
|
+
};
|
|
207
|
+
} catch (error) {
|
|
208
|
+
const authRequired = error instanceof BusabaseOAuthError && error.status === 401;
|
|
209
|
+
return {
|
|
210
|
+
connected: Boolean(target) && !authRequired,
|
|
211
|
+
cloudBaseUrl: this.cloudBaseUrl,
|
|
212
|
+
...target ? { baseUrl: target.baseUrl, source: target.source, requiresSpace: true } : {},
|
|
213
|
+
readiness: authRequired ? "needs_auth" : "retry",
|
|
214
|
+
action: authRequired ? "reconnect" : "retry",
|
|
215
|
+
reason: authRequired ? BUSABASE_AIRAPP_GATEWAY_REASONS.authRequired : BUSABASE_AIRAPP_GATEWAY_REASONS.authUnavailable,
|
|
216
|
+
message: error instanceof Error ? error.message : "Busabase auth verification failed"
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
statusResponse = async () => Response.json(await this.status());
|
|
221
|
+
/** Resolve the client for this callback, then probe the authorize endpoint with it. */
|
|
222
|
+
async #startAuthorization(target, needsDynamicClient, forceRegistration) {
|
|
223
|
+
let clientId = this.#options.clientId;
|
|
224
|
+
let reusedStoredClient = false;
|
|
225
|
+
if (needsDynamicClient) {
|
|
226
|
+
const stored = forceRegistration ? null : loadBusabaseAirAppDynamicClientId(target, this.#options.credentialStore);
|
|
227
|
+
if (stored) {
|
|
228
|
+
clientId = stored;
|
|
229
|
+
reusedStoredClient = true;
|
|
230
|
+
} else {
|
|
231
|
+
const registration = await registerBusabaseAirAppOAuthClient(target, this.#fetch());
|
|
232
|
+
clientId = registration.clientId;
|
|
233
|
+
storeBusabaseAirAppDynamicClientId({ ...target, clientId }, this.#options.credentialStore);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
const oauthRequest = await createBusabaseOAuthRequest({
|
|
237
|
+
baseUrl: target.baseUrl,
|
|
238
|
+
redirectUri: target.redirectUri,
|
|
239
|
+
clientId
|
|
240
|
+
});
|
|
241
|
+
const probe = await this.#fetch()(oauthRequest.authorizeUrl, {
|
|
242
|
+
headers: { accept: "text/html" },
|
|
243
|
+
redirect: "manual",
|
|
244
|
+
signal: AbortSignal.timeout(this.#options.requestTimeoutMs)
|
|
245
|
+
});
|
|
246
|
+
return { oauthRequest, probe, reusedStoredClient };
|
|
247
|
+
}
|
|
248
|
+
start = async (request) => {
|
|
249
|
+
try {
|
|
250
|
+
assertSameOrigin(request);
|
|
251
|
+
const body = await readInput(request);
|
|
252
|
+
const baseUrl = normalizeOrigin(String(body.base_url || ""), this.cloudBaseUrl);
|
|
253
|
+
const redirectUri = new URL("/auth/callback", requestOrigin(request)).toString();
|
|
254
|
+
const needsDynamicClient = this.#usesDefaultClient && !isLoopbackOrigin(requestOrigin(request));
|
|
255
|
+
const target = { appId: this.#options.appId, baseUrl, redirectUri };
|
|
256
|
+
let attempt = await this.#startAuthorization(target, needsDynamicClient, false);
|
|
257
|
+
if (attempt.probe.status >= 400 && attempt.reusedStoredClient) {
|
|
258
|
+
attempt = await this.#startAuthorization(target, needsDynamicClient, true);
|
|
259
|
+
}
|
|
260
|
+
const { oauthRequest, probe } = attempt;
|
|
261
|
+
if (probe.status >= 400) {
|
|
262
|
+
throw new BusabaseOAuthError(
|
|
263
|
+
"oauth_unavailable",
|
|
264
|
+
`Busabase OAuth is unavailable (${probe.status})`,
|
|
265
|
+
probe.status
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
this.#pendingOAuth.set(oauthRequest.state, {
|
|
269
|
+
...oauthRequest,
|
|
270
|
+
expiresAt: this.#now() + this.#options.oauthPendingTtlMs
|
|
271
|
+
});
|
|
272
|
+
return Response.redirect(oauthRequest.authorizeUrl, 303);
|
|
273
|
+
} catch (error) {
|
|
274
|
+
const redirect = new URL(this.#options.errorPath, requestOrigin(request));
|
|
275
|
+
redirect.searchParams.set(
|
|
276
|
+
"oauth_error",
|
|
277
|
+
error instanceof Error ? error.message : "Unable to start Busabase OAuth"
|
|
278
|
+
);
|
|
279
|
+
return Response.redirect(redirect, 303);
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
callback = async (request) => {
|
|
283
|
+
const callback = new URL(request.url);
|
|
284
|
+
const state = callback.searchParams.get("state") || "";
|
|
285
|
+
const pending = this.#pendingOAuth.get(state);
|
|
286
|
+
this.#pendingOAuth.delete(state);
|
|
287
|
+
try {
|
|
288
|
+
if (!pending || pending.expiresAt <= this.#now()) {
|
|
289
|
+
throw new BusabaseOAuthError("oauth_request_expired", "OAuth request expired");
|
|
290
|
+
}
|
|
291
|
+
const code = parseBusabaseOAuthCallback(callback.toString(), pending);
|
|
292
|
+
const tokenSet = await exchangeBusabaseOAuthCode(pending, code, this.#fetch());
|
|
293
|
+
storeBusabaseAirAppOAuthCredential(
|
|
294
|
+
{
|
|
295
|
+
appId: this.#options.appId,
|
|
296
|
+
baseUrl: pending.baseUrl,
|
|
297
|
+
clientId: pending.clientId,
|
|
298
|
+
tokenSet
|
|
299
|
+
},
|
|
300
|
+
this.#options.credentialStore
|
|
301
|
+
);
|
|
302
|
+
return Response.redirect(new URL(this.#options.successPath, requestOrigin(request)), 303);
|
|
303
|
+
} catch (error) {
|
|
304
|
+
const redirect = new URL(this.#options.errorPath, requestOrigin(request));
|
|
305
|
+
redirect.searchParams.set(
|
|
306
|
+
"oauth_error",
|
|
307
|
+
error instanceof Error ? error.message : "Busabase OAuth callback failed"
|
|
308
|
+
);
|
|
309
|
+
return Response.redirect(redirect, 303);
|
|
310
|
+
}
|
|
311
|
+
};
|
|
312
|
+
selectSpace = async (request) => {
|
|
313
|
+
try {
|
|
314
|
+
assertSameOrigin(request);
|
|
315
|
+
const body = await readInput(request);
|
|
316
|
+
const spaceId = String(body.space_id || "").trim();
|
|
317
|
+
const target = await this.#target();
|
|
318
|
+
if (!target) {
|
|
319
|
+
return jsonError(
|
|
320
|
+
401,
|
|
321
|
+
BUSABASE_AIRAPP_GATEWAY_REASONS.connectionRequired,
|
|
322
|
+
"Connect Busabase before selecting a Space"
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
const info = await this.#authInfo(target);
|
|
326
|
+
const selected = info.spaces.find((space) => space.id === spaceId);
|
|
327
|
+
if (!selected) {
|
|
328
|
+
return jsonError(
|
|
329
|
+
403,
|
|
330
|
+
BUSABASE_AIRAPP_GATEWAY_REASONS.spaceNotAllowed,
|
|
331
|
+
"The selected Space is not accessible to this account"
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
await this.#authInfo(target, selected.id);
|
|
335
|
+
this.#persistSelectedSpace(target, selected);
|
|
336
|
+
return Response.json({ ok: true, space: { id: selected.id, name: selected.name } });
|
|
337
|
+
} catch (error) {
|
|
338
|
+
return jsonError(
|
|
339
|
+
400,
|
|
340
|
+
BUSABASE_AIRAPP_GATEWAY_REASONS.authUnavailable,
|
|
341
|
+
error instanceof Error ? error.message : "Unable to select Busabase Space"
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
};
|
|
345
|
+
logout = async (request) => {
|
|
346
|
+
try {
|
|
347
|
+
assertSameOrigin(request);
|
|
348
|
+
if (loadBusabaseAirAppOAuthCredential(this.#options.appId, this.#options.credentialStore)) {
|
|
349
|
+
await revokeBusabaseAirAppOAuthCredential(
|
|
350
|
+
this.#options.appId,
|
|
351
|
+
this.#options.credentialStore,
|
|
352
|
+
this.#fetch()
|
|
353
|
+
).catch(() => void 0);
|
|
354
|
+
}
|
|
355
|
+
this.#environmentSelectedSpace = void 0;
|
|
356
|
+
return Response.json({ ok: true });
|
|
357
|
+
} catch (error) {
|
|
358
|
+
return jsonError(
|
|
359
|
+
400,
|
|
360
|
+
BUSABASE_AIRAPP_GATEWAY_REASONS.authUnavailable,
|
|
361
|
+
error instanceof Error ? error.message : "Unable to disconnect Busabase"
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
};
|
|
365
|
+
proxy = async (request) => {
|
|
366
|
+
let target;
|
|
367
|
+
try {
|
|
368
|
+
target = await this.#target();
|
|
369
|
+
} catch {
|
|
370
|
+
return jsonError(
|
|
371
|
+
401,
|
|
372
|
+
BUSABASE_AIRAPP_GATEWAY_REASONS.authRequired,
|
|
373
|
+
"Busabase authentication expired"
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
if (!target) {
|
|
377
|
+
return jsonError(
|
|
378
|
+
401,
|
|
379
|
+
BUSABASE_AIRAPP_GATEWAY_REASONS.connectionRequired,
|
|
380
|
+
"Busabase connection required"
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
let selectedSpace = target.selectedSpace;
|
|
384
|
+
if (!selectedSpace) {
|
|
385
|
+
try {
|
|
386
|
+
const info = await this.#authInfo(target);
|
|
387
|
+
if (info.spaces.length === 1) {
|
|
388
|
+
selectedSpace = info.spaces[0];
|
|
389
|
+
this.#persistSelectedSpace(target, selectedSpace);
|
|
390
|
+
}
|
|
391
|
+
} catch {
|
|
392
|
+
return jsonError(
|
|
393
|
+
503,
|
|
394
|
+
BUSABASE_AIRAPP_GATEWAY_REASONS.authUnavailable,
|
|
395
|
+
"Busabase authentication could not be verified"
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
if (!selectedSpace) {
|
|
400
|
+
return jsonError(
|
|
401
|
+
409,
|
|
402
|
+
BUSABASE_AIRAPP_GATEWAY_REASONS.spaceSelectionRequired,
|
|
403
|
+
"Busabase Space selection required"
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
const incoming = new URL(request.url);
|
|
407
|
+
const targetUrl = new URL(incoming.pathname + incoming.search, target.baseUrl);
|
|
408
|
+
const headers = new Headers();
|
|
409
|
+
const contentType = request.headers.get("content-type");
|
|
410
|
+
const accept = request.headers.get("accept");
|
|
411
|
+
if (contentType) headers.set("content-type", contentType);
|
|
412
|
+
if (accept) headers.set("accept", accept);
|
|
413
|
+
headers.set("x-busabase-space", selectedSpace.id);
|
|
414
|
+
if (target.accessToken) headers.set("authorization", `Bearer ${target.accessToken}`);
|
|
415
|
+
const hasBody = request.method !== "GET" && request.method !== "HEAD";
|
|
416
|
+
let upstream;
|
|
417
|
+
try {
|
|
418
|
+
upstream = await this.#fetch()(targetUrl, {
|
|
419
|
+
method: request.method,
|
|
420
|
+
headers,
|
|
421
|
+
body: hasBody ? await request.arrayBuffer() : void 0,
|
|
422
|
+
redirect: "manual"
|
|
423
|
+
});
|
|
424
|
+
} catch {
|
|
425
|
+
return jsonError(
|
|
426
|
+
503,
|
|
427
|
+
BUSABASE_AIRAPP_GATEWAY_REASONS.authUnavailable,
|
|
428
|
+
"Busabase API is temporarily unavailable"
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
const responseHeaders = new Headers();
|
|
432
|
+
const upstreamType = upstream.headers.get("content-type");
|
|
433
|
+
if (upstreamType) responseHeaders.set("content-type", upstreamType);
|
|
434
|
+
return new Response(upstream.body, { status: upstream.status, headers: responseHeaders });
|
|
435
|
+
};
|
|
436
|
+
};
|
|
437
|
+
var createBusabaseAirAppLocalGateway = (options) => new BusabaseAirAppLocalGateway(options);
|
|
438
|
+
|
|
439
|
+
export { BUSABASE_AIRAPP_GATEWAY_REASONS, BusabaseAirAppLocalGateway, createBusabaseAirAppLocalGateway };
|
package/dist/airapp.d.ts
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { B as BusabaseClient } from './client-DB7fREZX.js';
|
|
2
|
+
import '@orpc/contract';
|
|
3
|
+
import '@orpc/shared';
|
|
4
|
+
import 'zod';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* AirApp resource provisioning — how an app claims (or creates) the Folder and
|
|
8
|
+
* Bases it declares, exactly once, without ever taking over someone else's.
|
|
9
|
+
*
|
|
10
|
+
* Every App-in-Skill shipped a byte-identical copy of this module (280 lines ×
|
|
11
|
+
* 65 apps, two spellings). That is the wrong place for it: the rules encoded
|
|
12
|
+
* here are not app preferences, they are the safety boundary that keeps an app
|
|
13
|
+
* from adopting a Folder a human created for something else. A third party
|
|
14
|
+
* re-deriving them from scratch gets the happy path right and the conflict
|
|
15
|
+
* cases wrong, and the failure is silent — the app happily writes into data it
|
|
16
|
+
* does not own.
|
|
17
|
+
*
|
|
18
|
+
* The contract, in one line: **an app owns a node only if it stamped it.**
|
|
19
|
+
* Ownership lives in `node.metadata` as `{ appId, resourceKey, schemaVersion }`.
|
|
20
|
+
* Anything else is either a legacy node this app plausibly created before
|
|
21
|
+
* stamping existed (claimable *only* after a full structural fingerprint match)
|
|
22
|
+
* or someone else's (never touched, always a `SETUP_CONFLICT`).
|
|
23
|
+
*
|
|
24
|
+
* This module is isomorphic — browser and Node both — and holds no I/O beyond
|
|
25
|
+
* the passed-in client.
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* ```ts
|
|
29
|
+
* import { createBusabaseClient } from "busabase-sdk";
|
|
30
|
+
* import { inspectProvisionedResources, provisionDeclaredResources } from "busabase-sdk/airapp";
|
|
31
|
+
*
|
|
32
|
+
* const client = createBusabaseClient({ baseUrl: window.location.origin });
|
|
33
|
+
* const config = {
|
|
34
|
+
* appId: "kelly-crm",
|
|
35
|
+
* appName: "Kelly CRM",
|
|
36
|
+
* schemaVersion: 1,
|
|
37
|
+
* folder: { slug: "kelly-crm", name: "Kelly CRM", description: "CRM workspace" },
|
|
38
|
+
* bases: [{ key: "contacts", slug: "kelly-crm-contacts-v1", name: "Contacts", fields: [...] }],
|
|
39
|
+
* };
|
|
40
|
+
*
|
|
41
|
+
* let resources = await inspectProvisionedResources(client, config);
|
|
42
|
+
* if (!resources.folder || resources.missing.length) {
|
|
43
|
+
* resources = await provisionDeclaredResources(client, config); // one idempotent ChangeRequest
|
|
44
|
+
* }
|
|
45
|
+
* ```
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
type NodeChangeRequestInput = Parameters<BusabaseClient["nodes"]["createChangeRequest"]>[0];
|
|
49
|
+
type NodeOperationInput = NodeChangeRequestInput["operations"][number];
|
|
50
|
+
type CreateNodeOperationInput = Extract<NodeOperationInput, {
|
|
51
|
+
kind: "create";
|
|
52
|
+
}>;
|
|
53
|
+
/**
|
|
54
|
+
* A Base field, typed straight off the contract so a declaration can never drift
|
|
55
|
+
* from what `nodes.createChangeRequest` actually accepts.
|
|
56
|
+
*/
|
|
57
|
+
type AirAppFieldDeclaration = NonNullable<CreateNodeOperationInput["fields"]>[number];
|
|
58
|
+
/** The client surface provisioning needs. Both `BusabaseClient` and `Busabase` satisfy it. */
|
|
59
|
+
type AirAppProvisioningClient = Pick<BusabaseClient, "nodes" | "bases">;
|
|
60
|
+
/** One Base the app declares it needs. `key` is the app's stable internal handle. */
|
|
61
|
+
interface AirAppBaseDeclaration {
|
|
62
|
+
/** Stable internal handle, e.g. `"contacts"`. Also the node's `resourceKey`. */
|
|
63
|
+
key: string;
|
|
64
|
+
slug: string;
|
|
65
|
+
name: string;
|
|
66
|
+
description?: string;
|
|
67
|
+
fields: AirAppFieldDeclaration[];
|
|
68
|
+
/** Resolved at provision time; ignore when declaring. */
|
|
69
|
+
nodeId?: string;
|
|
70
|
+
/** Resolved at provision time; ignore when declaring. */
|
|
71
|
+
baseId?: string;
|
|
72
|
+
/** Free-form extras an app keeps alongside its declaration (e.g. `readLimit`). */
|
|
73
|
+
[extra: string]: unknown;
|
|
74
|
+
}
|
|
75
|
+
interface AirAppFolderDeclaration {
|
|
76
|
+
slug: string;
|
|
77
|
+
name: string;
|
|
78
|
+
description?: string;
|
|
79
|
+
/** Pin the Folder by id once known; otherwise it is discovered by slug. */
|
|
80
|
+
nodeId?: string;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* The app's own AirApp node, when it ships one inside its Folder.
|
|
84
|
+
*
|
|
85
|
+
* It is provisioned by publishing the AirApp, not by this module — so it is
|
|
86
|
+
* never created here, only recognized and stamped. Declaring it matters for a
|
|
87
|
+
* second reason: without it, an unstamped Folder holding the app's own AirApp
|
|
88
|
+
* would look like it holds an unattributable stranger, and the legacy claim
|
|
89
|
+
* would be refused.
|
|
90
|
+
*/
|
|
91
|
+
interface AirAppNodeDeclaration {
|
|
92
|
+
slug: string;
|
|
93
|
+
name: string;
|
|
94
|
+
/** Ownership key written into the node's metadata, e.g. `"airapp"`. */
|
|
95
|
+
resourceKey: string;
|
|
96
|
+
}
|
|
97
|
+
/** Everything an app declares about the workspace shape it needs. */
|
|
98
|
+
interface AirAppResourceConfig {
|
|
99
|
+
appId: string;
|
|
100
|
+
appName: string;
|
|
101
|
+
/**
|
|
102
|
+
* Bump when the declared shape changes. A node stamped with an older version
|
|
103
|
+
* is re-stamped (a "repair"), not recreated — the data survives.
|
|
104
|
+
*/
|
|
105
|
+
schemaVersion: number;
|
|
106
|
+
folder: AirAppFolderDeclaration;
|
|
107
|
+
bases: AirAppBaseDeclaration[];
|
|
108
|
+
/** The app's own AirApp node inside the Folder, when it ships one. */
|
|
109
|
+
airApp?: AirAppNodeDeclaration;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* The ownership stamp written into `node.metadata`.
|
|
113
|
+
*
|
|
114
|
+
* A type alias rather than an `interface` on purpose: `nodes.updateMetadata`
|
|
115
|
+
* and the create operations take `Record<string, unknown>`, and an interface —
|
|
116
|
+
* being open to declaration merging — is not assignable to an index signature.
|
|
117
|
+
*/
|
|
118
|
+
type AirAppResourceOwnership = {
|
|
119
|
+
appId: string;
|
|
120
|
+
resourceKey: string;
|
|
121
|
+
schemaVersion: number;
|
|
122
|
+
};
|
|
123
|
+
/** A node this app owns but whose ownership stamp needs (re)writing. */
|
|
124
|
+
interface AirAppOwnershipRepair {
|
|
125
|
+
nodeId: string;
|
|
126
|
+
baseId?: string;
|
|
127
|
+
resourceKey: string;
|
|
128
|
+
metadata: AirAppResourceOwnership;
|
|
129
|
+
}
|
|
130
|
+
interface AirAppProvisionedBase extends AirAppBaseDeclaration {
|
|
131
|
+
nodeId: string;
|
|
132
|
+
baseId: string;
|
|
133
|
+
}
|
|
134
|
+
interface AirAppResources {
|
|
135
|
+
/** The app's root Folder, or `null` when it does not exist yet. */
|
|
136
|
+
folder: (AirAppFolderDeclaration & {
|
|
137
|
+
nodeId: string;
|
|
138
|
+
}) | null;
|
|
139
|
+
/** Declared Bases that exist and are owned, with their resolved ids. */
|
|
140
|
+
bases: AirAppProvisionedBase[];
|
|
141
|
+
/** Declared Bases that do not exist yet. */
|
|
142
|
+
missing: AirAppBaseDeclaration[];
|
|
143
|
+
/** Owned nodes whose ownership stamp is missing or stale. */
|
|
144
|
+
repairs: AirAppOwnershipRepair[];
|
|
145
|
+
/**
|
|
146
|
+
* Set when the server is too old for `nodes.updateMetadata`, so ownership was
|
|
147
|
+
* established by full structural fingerprint instead of a stamp.
|
|
148
|
+
*/
|
|
149
|
+
compatibilityMode?: "verified-legacy-fingerprint";
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Why setup cannot proceed. These are the app's five distinguishable states —
|
|
153
|
+
* each one wants a different screen, which is why they are codes and not prose.
|
|
154
|
+
*
|
|
155
|
+
* - `SETUP_REQUIRED` — nothing exists yet; offer to initialize.
|
|
156
|
+
* - `SETUP_PENDING` — the ChangeRequest was submitted and awaits human approval.
|
|
157
|
+
* - `SETUP_CONFLICT` — a node in the way is not this app's; nothing was changed.
|
|
158
|
+
* - `SETUP_PERMISSION` — this account may not create/repair here.
|
|
159
|
+
* - `SCHEMA_INCOMPLETE` — the change merged but read back incomplete.
|
|
160
|
+
*/
|
|
161
|
+
type AirAppSetupCode = "SETUP_REQUIRED" | "SETUP_PENDING" | "SETUP_CONFLICT" | "SETUP_PERMISSION" | "SCHEMA_INCOMPLETE";
|
|
162
|
+
/**
|
|
163
|
+
* A setup failure carrying its state as a `code`.
|
|
164
|
+
*
|
|
165
|
+
* `message` is deliberately kept in the historical `"CODE: detail"` shape: the
|
|
166
|
+
* generated apps parse the prefix off `error.message`, so an app can migrate to
|
|
167
|
+
* this class without touching its rendering code, then move to `error.code`.
|
|
168
|
+
*/
|
|
169
|
+
declare class AirAppSetupError extends Error {
|
|
170
|
+
readonly code: AirAppSetupCode;
|
|
171
|
+
/** The human-readable half, without the `CODE: ` prefix. */
|
|
172
|
+
readonly detail: string;
|
|
173
|
+
constructor(code: AirAppSetupCode, detail: string);
|
|
174
|
+
}
|
|
175
|
+
/** True for a 404 / NOT_FOUND from any of the client's transports. */
|
|
176
|
+
declare const isNotFound: (error: unknown) => boolean;
|
|
177
|
+
/** A node as this module reads it — the fields provisioning actually looks at. */
|
|
178
|
+
interface ReadNode {
|
|
179
|
+
id: string;
|
|
180
|
+
type: string;
|
|
181
|
+
slug: string;
|
|
182
|
+
name: string;
|
|
183
|
+
description: string;
|
|
184
|
+
baseId: string | null;
|
|
185
|
+
metadata: Record<string, unknown>;
|
|
186
|
+
children?: ReadNode[];
|
|
187
|
+
}
|
|
188
|
+
interface ReadFolderDetail {
|
|
189
|
+
node: ReadNode;
|
|
190
|
+
children: ReadNode[];
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Decide, from one already-read Folder, what exists / is missing / needs
|
|
194
|
+
* re-stamping. Pure — no I/O — so the ownership rules are directly testable.
|
|
195
|
+
*
|
|
196
|
+
* @throws {AirAppSetupError} `SETUP_CONFLICT` when a node in the way is not
|
|
197
|
+
* this app's. Nothing is ever mutated on that path.
|
|
198
|
+
*/
|
|
199
|
+
declare function resolveProvisionedFolder(folder: ReadFolderDetail | null | undefined, config: AirAppResourceConfig): AirAppResources;
|
|
200
|
+
/**
|
|
201
|
+
* The create operations for one idempotent ChangeRequest. Pure.
|
|
202
|
+
*
|
|
203
|
+
* When the Folder does not exist yet it is created under the temp `ref`
|
|
204
|
+
* `"app-root"` and the Bases nest under it via `parentNodeRef`, so the whole
|
|
205
|
+
* structure lands in a single reviewable change.
|
|
206
|
+
*/
|
|
207
|
+
declare function buildProvisionOperations(config: AirAppResourceConfig, folder: {
|
|
208
|
+
nodeId: string;
|
|
209
|
+
} | null, missingBases: AirAppBaseDeclaration[]): NodeOperationInput[];
|
|
210
|
+
/** Read the current state of this app's declared resources. Never mutates. */
|
|
211
|
+
declare function inspectProvisionedResources(client: AirAppProvisioningClient, config: AirAppResourceConfig): Promise<AirAppResources>;
|
|
212
|
+
/**
|
|
213
|
+
* Ensure the declared Folder and Bases exist, as one idempotent ChangeRequest.
|
|
214
|
+
*
|
|
215
|
+
* Safe to call concurrently: calls for the same client + `appId` share one
|
|
216
|
+
* in-flight promise, so a multi-pane app cannot submit the structure twice.
|
|
217
|
+
*
|
|
218
|
+
* @throws {AirAppSetupError} with a `code` describing which screen to show.
|
|
219
|
+
*/
|
|
220
|
+
declare function provisionDeclaredResources(client: AirAppProvisioningClient, config: AirAppResourceConfig): Promise<AirAppResources>;
|
|
221
|
+
|
|
222
|
+
export { type AirAppBaseDeclaration, type AirAppFieldDeclaration, type AirAppFolderDeclaration, type AirAppNodeDeclaration, type AirAppOwnershipRepair, type AirAppProvisionedBase, type AirAppProvisioningClient, type AirAppResourceConfig, type AirAppResourceOwnership, type AirAppResources, type AirAppSetupCode, AirAppSetupError, buildProvisionOperations, inspectProvisionedResources, isNotFound, provisionDeclaredResources, resolveProvisionedFolder };
|