busabase-sdk 0.15.0 → 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/dist/airapp.js ADDED
@@ -0,0 +1,359 @@
1
+ // src/airapp.ts
2
+ var AirAppSetupError = class extends Error {
3
+ code;
4
+ /** The human-readable half, without the `CODE: ` prefix. */
5
+ detail;
6
+ constructor(code, detail) {
7
+ super(`${code}: ${detail}`);
8
+ this.name = "AirAppSetupError";
9
+ this.code = code;
10
+ this.detail = detail;
11
+ }
12
+ };
13
+ var setupError = (code, detail) => new AirAppSetupError(code, detail);
14
+ var isNotFound = (error) => typeof error === "object" && error !== null && ("code" in error && error.code === "NOT_FOUND" || "status" in error && error.status === 404);
15
+ var isForbidden = (error) => typeof error === "object" && error !== null && ("code" in error && error.code === "FORBIDDEN" || "status" in error && error.status === 403);
16
+ var ownsResource = (node, appId, resourceKey, schemaVersion) => node?.metadata?.appId === appId && node?.metadata?.resourceKey === resourceKey && node?.metadata?.schemaVersion === schemaVersion;
17
+ var hasResourceIdentity = (node, appId, resourceKey) => node?.metadata?.appId === appId && node?.metadata?.resourceKey === resourceKey;
18
+ var ownsAppRoot = (node, appId, schemaVersion) => hasResourceIdentity(node, appId, "app-root") && node?.metadata?.schemaVersion === schemaVersion;
19
+ var hasEmptyMetadata = (node) => Object.keys(node?.metadata ?? {}).length === 0;
20
+ var matchesDeclaration = (node, declaration, type) => node?.type === type && node?.slug === declaration.slug && node?.name === declaration.name && node?.description === (declaration.description ?? "");
21
+ var matchesLegacyAirApp = (node, config) => hasEmptyMetadata(node) && node?.type === "airapp" && node?.slug === config.airApp?.slug && node?.name === config.airApp?.name;
22
+ var resourceMetadata = (config, resourceKey) => ({
23
+ appId: config.appId,
24
+ resourceKey,
25
+ schemaVersion: config.schemaVersion
26
+ });
27
+ function resolveProvisionedFolder(folder, config) {
28
+ if (!folder) {
29
+ return { folder: null, bases: [], missing: [...config.bases], repairs: [] };
30
+ }
31
+ if (folder.node?.type !== "folder" || folder.node?.slug !== config.folder.slug) {
32
+ throw setupError(
33
+ "SETUP_CONFLICT",
34
+ `A different Folder already uses the slug ${config.folder.slug}; nothing was changed`
35
+ );
36
+ }
37
+ const rootOwned = hasResourceIdentity(folder.node, config.appId, "app-root");
38
+ const legacyRoot = hasEmptyMetadata(folder.node) && matchesDeclaration(folder.node, config.folder, "folder");
39
+ if (!rootOwned && !legacyRoot) {
40
+ throw setupError(
41
+ "SETUP_CONFLICT",
42
+ `The Folder ${config.folder.slug} does not belong to this app; nothing was changed`
43
+ );
44
+ }
45
+ const bases = [];
46
+ const missing = [];
47
+ const repairs = [];
48
+ if (!ownsAppRoot(folder.node, config.appId, config.schemaVersion)) {
49
+ repairs.push({
50
+ nodeId: folder.node.id,
51
+ resourceKey: "app-root",
52
+ metadata: resourceMetadata(config, "app-root")
53
+ });
54
+ }
55
+ for (const base of config.bases) {
56
+ const matches = (folder.children ?? []).filter((node2) => node2.slug === base.slug);
57
+ if (!matches.length) {
58
+ if (legacyRoot) {
59
+ throw setupError(
60
+ "SETUP_CONFLICT",
61
+ `The existing unstamped Folder is missing the resource ${base.slug}, so it cannot be claimed safely`
62
+ );
63
+ }
64
+ missing.push(base);
65
+ continue;
66
+ }
67
+ const node = matches[0];
68
+ if (matches.length !== 1 || node.type !== "base" || !node.baseId) {
69
+ throw setupError(
70
+ "SETUP_CONFLICT",
71
+ `The resource ${base.slug} does not match this app's declaration; nothing was changed`
72
+ );
73
+ }
74
+ const owned = hasResourceIdentity(node, config.appId, base.key);
75
+ const legacy = hasEmptyMetadata(node) && matchesDeclaration(node, base, "base");
76
+ if (!owned && !legacy) {
77
+ throw setupError(
78
+ "SETUP_CONFLICT",
79
+ `The resource ${base.slug} does not match this app's declaration; nothing was changed`
80
+ );
81
+ }
82
+ if (!ownsResource(node, config.appId, base.key, config.schemaVersion)) {
83
+ repairs.push({
84
+ nodeId: node.id,
85
+ baseId: node.baseId,
86
+ resourceKey: base.key,
87
+ metadata: resourceMetadata(config, base.key)
88
+ });
89
+ }
90
+ bases.push({ ...base, nodeId: node.id, baseId: node.baseId });
91
+ }
92
+ const airAppNode = config.airApp ? (folder.children ?? []).find(
93
+ (node) => hasResourceIdentity(
94
+ node,
95
+ config.appId,
96
+ config.airApp.resourceKey
97
+ ) || matchesLegacyAirApp(node, config)
98
+ ) : void 0;
99
+ if (config.airApp && airAppNode && !ownsResource(airAppNode, config.appId, config.airApp.resourceKey, config.schemaVersion)) {
100
+ repairs.push({
101
+ nodeId: airAppNode.id,
102
+ resourceKey: config.airApp.resourceKey,
103
+ metadata: resourceMetadata(config, config.airApp.resourceKey)
104
+ });
105
+ }
106
+ if (legacyRoot) {
107
+ const declaredSlugs = new Set(config.bases.map((base) => base.slug));
108
+ const ambiguousExtra = (folder.children ?? []).find(
109
+ (node) => !declaredSlugs.has(node.slug) && node.id !== airAppNode?.id && node?.metadata?.appId !== config.appId
110
+ );
111
+ if (ambiguousExtra) {
112
+ throw setupError(
113
+ "SETUP_CONFLICT",
114
+ `The existing unstamped Folder holds an unattributable resource ${ambiguousExtra.slug}; nothing was changed`
115
+ );
116
+ }
117
+ }
118
+ return {
119
+ folder: { ...config.folder, nodeId: folder.node.id },
120
+ bases,
121
+ missing,
122
+ repairs
123
+ };
124
+ }
125
+ function buildProvisionOperations(config, folder, missingBases) {
126
+ const operations = [];
127
+ if (!folder) {
128
+ operations.push({
129
+ kind: "create",
130
+ ref: "app-root",
131
+ nodeType: "folder",
132
+ slug: config.folder.slug,
133
+ name: config.folder.name,
134
+ description: config.folder.description ?? "",
135
+ metadata: resourceMetadata(config, "app-root")
136
+ });
137
+ }
138
+ for (const base of missingBases) {
139
+ operations.push({
140
+ kind: "create",
141
+ ...folder ? { parentNodeId: folder.nodeId } : { parentNodeRef: "app-root" },
142
+ nodeType: "base",
143
+ slug: base.slug,
144
+ name: base.name,
145
+ description: base.description ?? "",
146
+ metadata: resourceMetadata(config, base.key),
147
+ fields: base.fields
148
+ });
149
+ }
150
+ return operations;
151
+ }
152
+ var findTopLevelFolder = async (client, config) => {
153
+ const roots = await client.nodes.list({ parentId: null, depth: 2 });
154
+ const candidates = (roots ?? []).flatMap((node) => [node, ...node.children ?? []]).filter((node) => node.type === "folder" && node.slug === config.folder.slug);
155
+ if (candidates.length > 1) {
156
+ throw setupError(
157
+ "SETUP_CONFLICT",
158
+ `Found more than one Folder with the slug ${config.folder.slug}; nothing was changed`
159
+ );
160
+ }
161
+ return candidates[0] ?? null;
162
+ };
163
+ var readFolder = async (client, config) => {
164
+ let nodeId = config.folder.nodeId;
165
+ if (!nodeId) nodeId = (await findTopLevelFolder(client, config))?.id;
166
+ if (!nodeId) return null;
167
+ try {
168
+ return await client.nodes.get({ nodeId, type: "folder" });
169
+ } catch (error) {
170
+ if (isNotFound(error) && config.folder.nodeId) {
171
+ const discovered = await findTopLevelFolder(client, config);
172
+ return discovered ? await client.nodes.get({
173
+ nodeId: discovered.id,
174
+ type: "folder"
175
+ }) : null;
176
+ }
177
+ if (isNotFound(error)) return null;
178
+ throw error;
179
+ }
180
+ };
181
+ async function inspectProvisionedResources(client, config) {
182
+ return resolveProvisionedFolder(await readFolder(client, config), config);
183
+ }
184
+ var provisionStates = /* @__PURE__ */ new WeakMap();
185
+ var stateFor = (client, appId) => {
186
+ let byApp = provisionStates.get(client);
187
+ if (!byApp) {
188
+ byApp = /* @__PURE__ */ new Map();
189
+ provisionStates.set(client, byApp);
190
+ }
191
+ let state = byApp.get(appId);
192
+ if (!state) {
193
+ state = { inFlight: null, metadataUpdatesSupported: void 0 };
194
+ byApp.set(appId, state);
195
+ }
196
+ return state;
197
+ };
198
+ var sameFieldName = (actual, expected) => JSON.stringify(actual) === JSON.stringify(expected);
199
+ var fieldMatches = (actual, expected) => actual?.slug === expected.slug && actual?.type === expected.type && actual?.required === expected.required && sameFieldName(actual?.name, expected.name);
200
+ var additiveFieldsFor = (actual, expected) => {
201
+ const fields = actual?.fields ?? [];
202
+ if (fields.length > expected.fields.length || !fields.every((field, index) => fieldMatches(field, expected.fields[index]))) {
203
+ throw setupError(
204
+ "SETUP_CONFLICT",
205
+ `The structure of ${expected.slug} does not match this app's declaration, so it cannot be upgraded safely`
206
+ );
207
+ }
208
+ return expected.fields.slice(fields.length);
209
+ };
210
+ var validateRepairBase = (actual, expected, nodeId) => {
211
+ if (!expected) {
212
+ throw setupError("SETUP_CONFLICT", "Cannot repair a resource this app does not declare");
213
+ }
214
+ const fields = actual?.fields ?? [];
215
+ const exactFields = fields.length === expected.fields.length && fields.every((field, index) => fieldMatches(field, expected.fields[index]));
216
+ if (actual?.nodeId !== nodeId || actual?.slug !== expected.slug || actual?.name !== expected.name || actual?.description !== (expected.description ?? "") || !exactFields) {
217
+ throw setupError(
218
+ "SETUP_CONFLICT",
219
+ `The structure of ${expected.slug} does not match this app's declaration, so it cannot be claimed safely`
220
+ );
221
+ }
222
+ };
223
+ async function repairResourceOwnership(client, config, current) {
224
+ if (!current.repairs.length) return current;
225
+ const state = stateFor(client, config.appId);
226
+ const baseRepairs = current.repairs.filter((repair) => repair.baseId);
227
+ const baseByKey = new Map(config.bases.map((base) => [base.key, base]));
228
+ const details = await Promise.all(
229
+ baseRepairs.map((repair) => client.bases.get({ baseId: repair.baseId }))
230
+ );
231
+ const migrations = details.map((detail, index) => {
232
+ const repair = baseRepairs[index];
233
+ const expected = baseByKey.get(repair.resourceKey);
234
+ if (!expected) {
235
+ throw setupError("SETUP_CONFLICT", "Cannot repair a resource this app does not declare");
236
+ }
237
+ if (detail?.nodeId !== repair.nodeId || detail?.slug !== expected.slug || detail?.name !== expected.name || detail?.description !== (expected.description ?? "")) {
238
+ throw setupError(
239
+ "SETUP_CONFLICT",
240
+ `The structure of ${expected.slug} does not match this app's declaration, so it cannot be upgraded safely`
241
+ );
242
+ }
243
+ return { repair, expected, fields: additiveFieldsFor(detail, expected) };
244
+ });
245
+ const pendingFieldRequests = [];
246
+ for (const migration of migrations) {
247
+ for (const field of migration.fields) {
248
+ const changeRequest = await client.bases.fieldChangeRequest({
249
+ operation: "create",
250
+ baseId: migration.repair.baseId,
251
+ slug: field.slug,
252
+ name: field.name,
253
+ type: field.type,
254
+ required: field.required,
255
+ message: `Upgrade ${config.appName}: add ${field.slug}`,
256
+ submittedBy: config.appId
257
+ });
258
+ const merged = changeRequest?.status === "merged" || changeRequest?.materialized === true;
259
+ if (!merged) pendingFieldRequests.push(changeRequest?.id ?? field.slug);
260
+ }
261
+ }
262
+ if (pendingFieldRequests.length) {
263
+ throw setupError(
264
+ "SETUP_PENDING",
265
+ `Submitted ${pendingFieldRequests.length} field upgrade request(s) awaiting Space admin approval: ${pendingFieldRequests.join(", ")}`
266
+ );
267
+ }
268
+ const verified = migrations.some((migration) => migration.fields.length) ? await Promise.all(
269
+ baseRepairs.map((repair) => client.bases.get({ baseId: repair.baseId }))
270
+ ) : details;
271
+ verified.forEach((detail, index) => {
272
+ const repair = baseRepairs[index];
273
+ validateRepairBase(detail, baseByKey.get(repair.resourceKey), repair.nodeId);
274
+ });
275
+ if (state.metadataUpdatesSupported === false) {
276
+ return { ...current, repairs: [], compatibilityMode: "verified-legacy-fingerprint" };
277
+ }
278
+ try {
279
+ for (const repair of current.repairs) {
280
+ await client.nodes.updateMetadata({ nodeId: repair.nodeId, metadata: repair.metadata });
281
+ state.metadataUpdatesSupported = true;
282
+ }
283
+ } catch (error) {
284
+ if (isNotFound(error)) {
285
+ state.metadataUpdatesSupported = false;
286
+ return { ...current, repairs: [], compatibilityMode: "verified-legacy-fingerprint" };
287
+ }
288
+ if (isForbidden(error)) {
289
+ throw setupError(
290
+ "SETUP_PERMISSION",
291
+ "This account may not repair resource ownership metadata for this app"
292
+ );
293
+ }
294
+ throw error;
295
+ }
296
+ const repaired = await inspectProvisionedResources(client, config);
297
+ if (repaired.repairs.length) {
298
+ throw setupError("SCHEMA_INCOMPLETE", "Ownership was repaired but read back incomplete");
299
+ }
300
+ return repaired;
301
+ }
302
+ var waitForMaterializedResources = async (client, config, attempts = 20) => {
303
+ let current;
304
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
305
+ current = await inspectProvisionedResources(client, config);
306
+ current = await repairResourceOwnership(client, config, current);
307
+ if (current.folder && current.missing.length === 0) return current;
308
+ if (attempt < attempts - 1) {
309
+ await new Promise((resolve) => globalThis.setTimeout(resolve, 100));
310
+ }
311
+ }
312
+ throw setupError(
313
+ "SCHEMA_INCOMPLETE",
314
+ "Initialization merged but the resources read back incomplete"
315
+ );
316
+ };
317
+ async function provisionOnce(client, config) {
318
+ let current = await inspectProvisionedResources(client, config);
319
+ current = await repairResourceOwnership(client, config, current);
320
+ if (current.folder && current.missing.length === 0) return current;
321
+ const operations = buildProvisionOperations(config, current.folder, current.missing);
322
+ let changeRequest;
323
+ try {
324
+ changeRequest = await client.nodes.createChangeRequest({
325
+ message: `Initialize ${config.appName} workspace`,
326
+ submittedBy: config.appId,
327
+ autoMerge: true,
328
+ operations
329
+ });
330
+ } catch (error) {
331
+ if (isForbidden(error)) {
332
+ throw setupError(
333
+ "SETUP_PERMISSION",
334
+ "This account may not create this app's resources in this Space"
335
+ );
336
+ }
337
+ const concurrent = await inspectProvisionedResources(client, config).catch(() => null);
338
+ if (concurrent?.folder && concurrent.missing.length === 0) return concurrent;
339
+ throw error;
340
+ }
341
+ if (changeRequest?.status !== "merged") {
342
+ throw setupError(
343
+ "SETUP_PENDING",
344
+ `Initialization request ${changeRequest?.id ?? ""} was submitted and awaits Space admin approval`.trim()
345
+ );
346
+ }
347
+ return waitForMaterializedResources(client, config);
348
+ }
349
+ function provisionDeclaredResources(client, config) {
350
+ const state = stateFor(client, config.appId);
351
+ if (!state.inFlight) {
352
+ state.inFlight = provisionOnce(client, config).finally(() => {
353
+ state.inFlight = null;
354
+ });
355
+ }
356
+ return state.inFlight;
357
+ }
358
+
359
+ export { AirAppSetupError, buildProvisionOperations, inspectProvisionedResources, isNotFound, provisionDeclaredResources, resolveProvisionedFolder };
@@ -1,7 +1,7 @@
1
- import { BusabaseOAuthError, BUSABASE_AIRAPP_CLIENT_ID, refreshBusabaseOAuthToken, revokeBusabaseOAuthToken } from './chunk-J2DZKX7A.js';
1
+ import { BusabaseOAuthError, BUSABASE_AIRAPP_CLIENT_ID, refreshBusabaseOAuthToken, revokeBusabaseOAuthToken } from './chunk-WSHJMHUS.js';
2
2
  import { normalizeBaseUrl } from './chunk-5NYQX65A.js';
3
3
  import { randomUUID } from 'crypto';
4
- import { readFileSync, mkdirSync, chmodSync, writeFileSync, renameSync, rmSync } from 'fs';
4
+ import { readFileSync, rmSync, mkdirSync, chmodSync, writeFileSync, renameSync } from 'fs';
5
5
  import { homedir } from 'os';
6
6
  import { join, dirname } from 'path';
7
7
 
@@ -21,6 +21,7 @@ var assertAppId = (appId) => {
21
21
  var storeRoot = (options = {}) => options.rootDir ?? join(homedir(), ".busabase");
22
22
  var busabaseAirAppCredentialsDir = (options = {}) => join(storeRoot(options), "airapps");
23
23
  var busabaseAirAppCredentialPath = (appId, options = {}) => join(busabaseAirAppCredentialsDir(options), `${assertAppId(appId)}.json`);
24
+ var busabaseAirAppDynamicClientsPath = (appId, options = {}) => join(busabaseAirAppCredentialsDir(options), `${assertAppId(appId)}.clients.json`);
24
25
  var normalizeOrigin = (raw) => {
25
26
  const url = new URL(normalizeBaseUrl(raw));
26
27
  if (url.username || url.password || url.search || url.hash) {
@@ -28,6 +29,22 @@ var normalizeOrigin = (raw) => {
28
29
  }
29
30
  return url.origin;
30
31
  };
32
+ var writeOwnerOnlyJson = (path, value) => {
33
+ const directory = dirname(path);
34
+ mkdirSync(directory, { recursive: true, mode: 448 });
35
+ try {
36
+ chmodSync(directory, 448);
37
+ } catch {
38
+ }
39
+ const temporaryPath = `${path}.${randomUUID()}.tmp`;
40
+ writeFileSync(temporaryPath, `${JSON.stringify(value, null, 2)}
41
+ `, { mode: 384 });
42
+ try {
43
+ chmodSync(temporaryPath, 384);
44
+ } catch {
45
+ }
46
+ renameSync(temporaryPath, path);
47
+ };
31
48
  var parseCredential = (raw, expectedAppId) => {
32
49
  let value;
33
50
  try {
@@ -72,22 +89,49 @@ function storeBusabaseAirAppOAuthCredential(input, options = {}) {
72
89
  tokenType: input.tokenSet.tokenType,
73
90
  ...input.selectedSpace ? { selectedSpace: input.selectedSpace } : {}
74
91
  };
75
- const path = busabaseAirAppCredentialPath(input.appId, options);
76
- const directory = dirname(path);
77
- mkdirSync(directory, { recursive: true, mode: 448 });
92
+ writeOwnerOnlyJson(busabaseAirAppCredentialPath(input.appId, options), credential);
93
+ return credential;
94
+ }
95
+ var dynamicClientKey = (baseUrl, redirectUri) => `${normalizeOrigin(baseUrl)}|${new URL(redirectUri).toString()}`;
96
+ var loadDynamicClientRegistry = (appId, options) => {
97
+ const empty = {
98
+ version: STORE_VERSION,
99
+ appId: assertAppId(appId),
100
+ clients: {}
101
+ };
102
+ let raw;
78
103
  try {
79
- chmodSync(directory, 448);
80
- } catch {
104
+ raw = readFileSync(busabaseAirAppDynamicClientsPath(appId, options), "utf8");
105
+ } catch (error) {
106
+ if (error.code === "ENOENT") return empty;
107
+ throw error;
81
108
  }
82
- const temporaryPath = `${path}.${randomUUID()}.tmp`;
83
- writeFileSync(temporaryPath, `${JSON.stringify(credential, null, 2)}
84
- `, { mode: 384 });
85
109
  try {
86
- chmodSync(temporaryPath, 384);
110
+ const value = JSON.parse(raw);
111
+ if (value.version !== STORE_VERSION || value.appId !== appId || typeof value.clients !== "object" || value.clients === null) {
112
+ return empty;
113
+ }
114
+ const clients = Object.fromEntries(
115
+ Object.entries(value.clients).filter(([, clientId]) => typeof clientId === "string")
116
+ );
117
+ return { ...empty, clients };
87
118
  } catch {
119
+ return empty;
88
120
  }
89
- renameSync(temporaryPath, path);
90
- return credential;
121
+ };
122
+ function loadBusabaseAirAppDynamicClientId(input, options = {}) {
123
+ const registry = loadDynamicClientRegistry(input.appId, options);
124
+ return registry.clients[dynamicClientKey(input.baseUrl, input.redirectUri)] ?? null;
125
+ }
126
+ function storeBusabaseAirAppDynamicClientId(input, options = {}) {
127
+ const registry = loadDynamicClientRegistry(input.appId, options);
128
+ const key = dynamicClientKey(input.baseUrl, input.redirectUri);
129
+ delete registry.clients[key];
130
+ const entries = [...Object.entries(registry.clients), [key, input.clientId]];
131
+ writeOwnerOnlyJson(busabaseAirAppDynamicClientsPath(input.appId, options), {
132
+ ...registry,
133
+ clients: Object.fromEntries(entries.slice(-32))
134
+ });
91
135
  }
92
136
  async function getBusabaseAirAppAccessToken(appId, options = {}, fetchImpl = fetch) {
93
137
  const credential = loadBusabaseAirAppOAuthCredential(appId, options);
@@ -141,15 +185,7 @@ function storeBusabaseAirAppSelectedSpace(appId, selectedSpace, options = {}) {
141
185
  ...credential,
142
186
  ...selectedSpace ? { selectedSpace } : { selectedSpace: void 0 }
143
187
  };
144
- const path = busabaseAirAppCredentialPath(appId, options);
145
- const temporaryPath = `${path}.${randomUUID()}.tmp`;
146
- writeFileSync(temporaryPath, `${JSON.stringify(next, null, 2)}
147
- `, { mode: 384 });
148
- try {
149
- chmodSync(temporaryPath, 384);
150
- } catch {
151
- }
152
- renameSync(temporaryPath, path);
188
+ writeOwnerOnlyJson(busabaseAirAppCredentialPath(appId, options), next);
153
189
  return next;
154
190
  }
155
191
  function clearBusabaseAirAppOAuthCredential(appId, options = {}) {
@@ -172,4 +208,4 @@ async function revokeBusabaseAirAppOAuthCredential(appId, options = {}, fetchImp
172
208
  }
173
209
  }
174
210
 
175
- export { busabaseAirAppCredentialPath, busabaseAirAppCredentialsDir, clearBusabaseAirAppOAuthCredential, getBusabaseAirAppAccessToken, loadBusabaseAirAppOAuthCredential, revokeBusabaseAirAppOAuthCredential, storeBusabaseAirAppOAuthCredential, storeBusabaseAirAppSelectedSpace };
211
+ export { busabaseAirAppCredentialPath, busabaseAirAppCredentialsDir, busabaseAirAppDynamicClientsPath, clearBusabaseAirAppOAuthCredential, getBusabaseAirAppAccessToken, loadBusabaseAirAppDynamicClientId, loadBusabaseAirAppOAuthCredential, revokeBusabaseAirAppOAuthCredential, storeBusabaseAirAppDynamicClientId, storeBusabaseAirAppOAuthCredential, storeBusabaseAirAppSelectedSpace };
@@ -4,6 +4,7 @@ import { normalizeBaseUrl } from './chunk-5NYQX65A.js';
4
4
  var BUSABASE_AIRAPP_CLIENT_ID = "busabase-airapp";
5
5
 
6
6
  // src/oauth.ts
7
+ var AIRAPP_OAUTH_SCOPE = "api";
7
8
  var BusabaseOAuthError = class extends Error {
8
9
  code;
9
10
  status;
@@ -46,6 +47,45 @@ var digestBase64Url = async (value) => {
46
47
  for (const byte of new Uint8Array(digest)) binary += String.fromCharCode(byte);
47
48
  return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
48
49
  };
50
+ async function registerBusabaseAirAppOAuthClient(input, fetchImpl = fetch) {
51
+ const baseUrl = oauthBaseUrl(input.baseUrl);
52
+ const redirectUri = new URL(input.redirectUri).toString();
53
+ const response = await fetchImpl(new URL("/api/oauth/register", baseUrl), {
54
+ method: "POST",
55
+ headers: { "content-type": "application/json" },
56
+ body: JSON.stringify({
57
+ client_name: input.appId,
58
+ client_kind: "airapp",
59
+ scope: AIRAPP_OAUTH_SCOPE,
60
+ redirect_uris: [redirectUri],
61
+ grant_types: ["authorization_code", "refresh_token"],
62
+ response_types: ["code"],
63
+ token_endpoint_auth_method: "none"
64
+ })
65
+ });
66
+ const body = await response.json().catch(() => null);
67
+ if (!response.ok) {
68
+ throw new BusabaseOAuthError(
69
+ typeof body?.error === "string" ? body.error : "client_registration_failed",
70
+ typeof body?.error_description === "string" ? body.error_description : `Busabase OAuth client registration failed (${response.status})`,
71
+ response.status
72
+ );
73
+ }
74
+ if (typeof body?.client_id !== "string" || !Array.isArray(body.redirect_uris) || body.redirect_uris.length !== 1 || body.redirect_uris[0] !== redirectUri) {
75
+ throw new BusabaseOAuthError(
76
+ "invalid_client_registration",
77
+ "Busabase returned an invalid OAuth client registration"
78
+ );
79
+ }
80
+ const grantedScopes = typeof body.scope === "string" ? body.scope.split(/\s+/).filter(Boolean) : null;
81
+ if (!grantedScopes?.includes(AIRAPP_OAUTH_SCOPE)) {
82
+ throw new BusabaseOAuthError(
83
+ "unsupported_airapp_registration",
84
+ `This Busabase server did not grant the "${AIRAPP_OAUTH_SCOPE}" scope to a dynamically registered AirApp. Upgrade Busabase, or run the AirApp on a loopback address to use the shared AirApp client.`
85
+ );
86
+ }
87
+ return { clientId: body.client_id, redirectUri };
88
+ }
49
89
  async function createBusabaseOAuthRequest(input) {
50
90
  const baseUrl = oauthBaseUrl(input.baseUrl);
51
91
  const redirectUri = new URL(input.redirectUri).toString();
@@ -58,7 +98,7 @@ async function createBusabaseOAuthRequest(input) {
58
98
  response_type: "code",
59
99
  client_id: clientId,
60
100
  resource,
61
- scope: "api",
101
+ scope: AIRAPP_OAUTH_SCOPE,
62
102
  code_challenge: await digestBase64Url(codeVerifier),
63
103
  code_challenge_method: "S256",
64
104
  redirect_uri: redirectUri,
@@ -172,4 +212,4 @@ async function revokeBusabaseOAuthToken(input, fetchImpl = fetch) {
172
212
  }
173
213
  }
174
214
 
175
- export { BUSABASE_AIRAPP_CLIENT_ID, BusabaseOAuthError, createBusabaseOAuthRequest, exchangeBusabaseOAuthCode, parseBusabaseOAuthCallback, refreshBusabaseOAuthToken, revokeBusabaseOAuthToken };
215
+ export { AIRAPP_OAUTH_SCOPE, BUSABASE_AIRAPP_CLIENT_ID, BusabaseOAuthError, createBusabaseOAuthRequest, exchangeBusabaseOAuthCode, parseBusabaseOAuthCallback, refreshBusabaseOAuthToken, registerBusabaseAirAppOAuthClient, revokeBusabaseOAuthToken };