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.
@@ -0,0 +1,238 @@
1
+ // src/airapp-gate.ts
2
+ function selectAirAppGateScreen(status) {
3
+ if (!status) return "connect";
4
+ switch (status.readiness) {
5
+ case "needs_connection":
6
+ case "needs_auth":
7
+ return "connect";
8
+ case "needs_space":
9
+ return "space";
10
+ case "ready":
11
+ return "ready";
12
+ }
13
+ if (!status.connected) return "connect";
14
+ return status.requiresSpace ? "space" : "ready";
15
+ }
16
+ function describeAirAppSetupError(error) {
17
+ const code = typeof error === "object" && error !== null && "code" in error && typeof error.code === "string" ? error.code : "";
18
+ const raw = String(
19
+ (typeof error === "object" && error !== null && "message" in error ? error.message : error) ?? "SETUP_REQUIRED"
20
+ );
21
+ const parsed = /^([A-Z_]+):\s*(.*)$/s.exec(raw);
22
+ const resolvedCode = code || parsed?.[1] || raw.trim() || "SETUP_REQUIRED";
23
+ const detail = parsed ? parsed[2] : code ? raw : "";
24
+ const pending = resolvedCode === "SETUP_PENDING";
25
+ const canProvision = resolvedCode === "SETUP_REQUIRED";
26
+ return {
27
+ code: resolvedCode,
28
+ detail,
29
+ title: pending ? "Waiting for workspace approval" : canProvision ? "Initialize the Busabase workspace" : "Workspace not ready",
30
+ canProvision,
31
+ canRetry: resolvedCode === "SETUP_PENDING" || resolvedCode === "SCHEMA_INCOMPLETE"
32
+ };
33
+ }
34
+ var escapeHtml = (value) => String(value ?? "").replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#039;");
35
+ var panel = (labelledBy, head, body, footer) => `<div class="bb-gate-overlay"><section class="bb-gate-panel" role="dialog" aria-modal="true" aria-labelledby="${labelledBy}"><div class="bb-gate-head"><div>${head}</div></div><div class="bb-gate-body">${body}</div><div class="bb-gate-footer">${footer}</div></section></div>`;
36
+ var defaultAirAppGateRenderer = {
37
+ connect(view) {
38
+ const head = `<h1 id="bbGateConnectTitle">Connect Busabase</h1><p>${escapeHtml(view.appName)} reads and writes through your Busabase workspace.</p>`;
39
+ const body = (view.oauthError ? `<p class="bb-gate-error" role="alert">${escapeHtml(view.oauthError)}</p>` : "") + (view.reconnect ? `<p class="bb-gate-note">Your session expired. Reconnect to continue.</p>` : "") + `<h2>Server</h2><div class="bb-gate-server-grid"><label class="bb-gate-server-card is-selected"><input type="radio" name="server_mode" value="cloud" checked><span><strong>Busabase Cloud</strong><span>${escapeHtml(hostOf(view.cloudBaseUrl))}</span></span></label><label class="bb-gate-server-card"><input type="radio" name="server_mode" value="custom"><span><strong>Custom server</strong><span>Self-hosted or enterprise address</span></span></label></div><label class="bb-gate-custom-url" data-custom-url hidden><span>Busabase URL</span><input type="url" name="custom_base_url" inputmode="url" placeholder="https://busabase.example.com" autocomplete="url"></label><input type="hidden" name="base_url" value="${escapeHtml(view.cloudBaseUrl)}">`;
40
+ const footer = `<span class="bb-gate-note">OAuth credentials stay on this machine (~/.busabase/airapps)</span><button class="bb-gate-primary" type="submit">Connect Busabase</button>`;
41
+ return `<form method="post" action="${escapeHtml(`${view.authBasePath}/auth/start`)}" data-connect-form>${panel("bbGateConnectTitle", head, body, footer)}</form>`;
42
+ },
43
+ space(view) {
44
+ const options = view.spaces.map(
45
+ (space) => `<option value="${escapeHtml(space.id)}">${escapeHtml(space.name)} \xB7 ${escapeHtml(space.id)}</option>`
46
+ ).join("");
47
+ const head = `<h1 id="bbGateSpaceTitle">Choose a Busabase Space</h1><p>Signed in to <strong>${escapeHtml(view.baseUrl)}</strong>. Choose where ${escapeHtml(view.appName)}'s data lives.</p>`;
48
+ const body = `<label class="bb-gate-space-select"><span>Space</span><select name="space_id" required>${options}</select></label><p class="bb-gate-error" data-space-error hidden></p>`;
49
+ const footer = `<span class="bb-gate-note">Resources are only checked after you confirm</span><button class="bb-gate-primary" type="submit">Use this Space</button>`;
50
+ return `<form data-space-form>${panel("bbGateSpaceTitle", head, body, footer)}</form>`;
51
+ },
52
+ workspace(view) {
53
+ const head = `<h1 id="bbGateWorkspaceTitle">${escapeHtml(view.title)}</h1>`;
54
+ const body = view.canProvision ? `<p>${escapeHtml(view.appName)} will create its Folder and Bases in the current Space.</p><p>Submitted as one idempotent Busabase ChangeRequest; nothing existing is deleted or repurposed.</p><p class="bb-gate-error" data-workspace-status hidden></p>` : `<p>${escapeHtml(view.detail)}</p><p>${escapeHtml(view.appName)} never asks you to create Nodes or Bases by hand, and never silently falls back to local data.</p><p class="bb-gate-error" data-workspace-status hidden></p>`;
55
+ const footer = (view.demoHref ? `<a class="bb-gate-link" href="${escapeHtml(view.demoHref)}">Open the read-only demo</a>` : "<span></span>") + (view.canProvision ? `<button class="bb-gate-primary" type="button" data-provision>Initialize workspace</button>` : view.canRetry ? `<button class="bb-gate-primary" type="button" data-retry>Check again</button>` : "");
56
+ return panel("bbGateWorkspaceTitle", head, body, footer);
57
+ }
58
+ };
59
+ var hostOf = (baseUrl) => {
60
+ try {
61
+ return new URL(baseUrl).host;
62
+ } catch {
63
+ return baseUrl;
64
+ }
65
+ };
66
+ var DEFAULT_CLOUD_BASE_URL = "https://busabase.com";
67
+ function createAirAppConnectGate(options) {
68
+ const {
69
+ appName,
70
+ authBasePath = "",
71
+ onProvision,
72
+ demoHref = null,
73
+ render = defaultAirAppGateRenderer
74
+ } = options;
75
+ const doFetch = options.fetch ?? globalThis.fetch;
76
+ const root = () => {
77
+ if (options.mount) {
78
+ const element2 = typeof options.mount === "string" ? document.querySelector(options.mount) : options.mount;
79
+ if (!element2) throw new Error(`AirApp gate mount not found: ${String(options.mount)}`);
80
+ document.documentElement.classList.add("bb-gate-active");
81
+ return element2;
82
+ }
83
+ let element = document.querySelector("#busabaseAirAppGate");
84
+ if (!element) {
85
+ element = document.createElement("div");
86
+ element.id = "busabaseAirAppGate";
87
+ document.body.prepend(element);
88
+ }
89
+ document.documentElement.classList.add("bb-gate-active");
90
+ return element;
91
+ };
92
+ const close = () => {
93
+ if (options.mount) {
94
+ const element = typeof options.mount === "string" ? document.querySelector(options.mount) : options.mount;
95
+ if (element) element.innerHTML = "";
96
+ } else {
97
+ document.querySelector("#busabaseAirAppGate")?.remove();
98
+ }
99
+ document.documentElement.classList.remove("bb-gate-active");
100
+ };
101
+ const status = async () => {
102
+ try {
103
+ const response = await doFetch(`${authBasePath}/auth/status`, {
104
+ headers: { accept: "application/json" }
105
+ });
106
+ const type = response.headers.get("content-type") ?? "";
107
+ if (!response.ok || !type.includes("application/json")) return null;
108
+ return await response.json();
109
+ } catch {
110
+ return null;
111
+ }
112
+ };
113
+ const renderConnect = (current) => {
114
+ const element = root();
115
+ const oauthError = new URLSearchParams(window.location.search).get("oauth_error") ?? "";
116
+ element.innerHTML = render.connect({
117
+ appName,
118
+ cloudBaseUrl: current?.cloudBaseUrl || DEFAULT_CLOUD_BASE_URL,
119
+ reconnect: current?.readiness === "needs_auth",
120
+ oauthError,
121
+ authBasePath
122
+ });
123
+ wireConnect(element, current?.cloudBaseUrl || DEFAULT_CLOUD_BASE_URL);
124
+ };
125
+ const renderSpace = (current, onReady) => {
126
+ const element = root();
127
+ element.innerHTML = render.space({
128
+ appName,
129
+ baseUrl: current.baseUrl ?? "",
130
+ spaces: current.spaces ?? []
131
+ });
132
+ wireSpace(element, onReady);
133
+ };
134
+ const wireConnect = (element, cloudBaseUrl) => {
135
+ const form = element.querySelector("[data-connect-form]");
136
+ if (!form) return;
137
+ const customField = form.querySelector("[data-custom-url]");
138
+ const customInput = customField?.querySelector("input") ?? null;
139
+ const hiddenBaseUrl = form.querySelector('input[name="base_url"]');
140
+ for (const radio of form.querySelectorAll('input[name="server_mode"]')) {
141
+ radio.addEventListener("change", () => {
142
+ const custom = radio.value === "custom";
143
+ for (const card of form.querySelectorAll(".bb-gate-server-card")) {
144
+ card.classList.toggle("is-selected", card.querySelector("input")?.checked === true);
145
+ }
146
+ if (customField) customField.hidden = !custom;
147
+ if (customInput) customInput.required = custom;
148
+ if (hiddenBaseUrl) hiddenBaseUrl.value = custom ? customInput?.value ?? "" : cloudBaseUrl;
149
+ if (custom) customInput?.focus();
150
+ });
151
+ }
152
+ customInput?.addEventListener("input", () => {
153
+ if (hiddenBaseUrl) hiddenBaseUrl.value = customInput.value;
154
+ });
155
+ };
156
+ const wireSpace = (element, onReady) => {
157
+ const form = element.querySelector("[data-space-form]");
158
+ form?.addEventListener("submit", async (event) => {
159
+ event.preventDefault();
160
+ const button = form.querySelector("button[type=submit]");
161
+ const error = form.querySelector("[data-space-error]");
162
+ if (button) button.disabled = true;
163
+ if (error) error.hidden = true;
164
+ try {
165
+ const response = await doFetch(`${authBasePath}/auth/space`, {
166
+ method: "POST",
167
+ headers: { "content-type": "application/x-www-form-urlencoded" },
168
+ body: new URLSearchParams(new FormData(form))
169
+ });
170
+ const result = await response.json();
171
+ if (!response.ok) {
172
+ if (error) {
173
+ error.textContent = result.error || "Could not select a Space.";
174
+ error.hidden = false;
175
+ }
176
+ if (button) button.disabled = false;
177
+ return;
178
+ }
179
+ onReady();
180
+ } catch {
181
+ if (error) {
182
+ error.textContent = "Could not reach this app's server.";
183
+ error.hidden = false;
184
+ }
185
+ if (button) button.disabled = false;
186
+ }
187
+ });
188
+ };
189
+ const renderSetupRequired = (error, onRetry) => {
190
+ const element = root();
191
+ element.innerHTML = render.workspace({
192
+ ...describeAirAppSetupError(error),
193
+ appName,
194
+ demoHref
195
+ });
196
+ element.querySelector("[data-retry]")?.addEventListener("click", () => onRetry());
197
+ element.querySelector("[data-provision]")?.addEventListener("click", async (event) => {
198
+ const button = event.currentTarget;
199
+ const line = element.querySelector("[data-workspace-status]");
200
+ button.disabled = true;
201
+ if (line) {
202
+ line.hidden = false;
203
+ line.textContent = "Submitting the workspace structure\u2026";
204
+ }
205
+ try {
206
+ await onProvision?.();
207
+ onRetry();
208
+ } catch (provisionError) {
209
+ renderSetupRequired(provisionError, onRetry);
210
+ }
211
+ });
212
+ };
213
+ const pass = async ({ onReady } = {}) => {
214
+ if (options.shouldGate && !await options.shouldGate()) {
215
+ close();
216
+ return true;
217
+ }
218
+ const current = await status();
219
+ if (!current) return true;
220
+ const screen = selectAirAppGateScreen(current);
221
+ if (screen === "ready") {
222
+ close();
223
+ return true;
224
+ }
225
+ if (screen === "space") {
226
+ renderSpace(current, () => {
227
+ close();
228
+ onReady?.();
229
+ });
230
+ return false;
231
+ }
232
+ renderConnect(current);
233
+ return false;
234
+ };
235
+ return { pass, renderSetupRequired, close, status };
236
+ }
237
+
238
+ export { DEFAULT_CLOUD_BASE_URL, createAirAppConnectGate, defaultAirAppGateRenderer, describeAirAppSetupError, escapeHtml, selectAirAppGateScreen };
@@ -1,5 +1,5 @@
1
- import { getBusabaseAirAppAccessToken, storeBusabaseAirAppSelectedSpace, storeBusabaseAirAppOAuthCredential, loadBusabaseAirAppOAuthCredential, revokeBusabaseAirAppOAuthCredential } from './chunk-B2AWPFDI.js';
2
- import { BUSABASE_AIRAPP_CLIENT_ID, BusabaseOAuthError, createBusabaseOAuthRequest, parseBusabaseOAuthCallback, exchangeBusabaseOAuthCode } from './chunk-J2DZKX7A.js';
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
3
  import './chunk-5NYQX65A.js';
4
4
 
5
5
  // src/airapp-node.ts
@@ -22,6 +22,7 @@ var jsonError = (status, reason, message, data) => Response.json(
22
22
  },
23
23
  { status }
24
24
  );
25
+ var LOOPBACK_HOSTNAMES = ["localhost", "127.0.0.1", "::1", "[::1]"];
25
26
  var normalizeOrigin = (raw, fallback = DEFAULT_CLOUD_BASE_URL) => {
26
27
  const withoutApi = String(raw || fallback).trim().replace(/\/+$/, "").replace(/\/api\/v1$/, "");
27
28
  let url;
@@ -33,7 +34,7 @@ var normalizeOrigin = (raw, fallback = DEFAULT_CLOUD_BASE_URL) => {
33
34
  if (url.username || url.password || url.search || url.hash || url.pathname !== "/" && url.pathname !== "") {
34
35
  throw new BusabaseOAuthError("invalid_base_url", "Busabase base URL must be an origin");
35
36
  }
36
- const loopback = ["localhost", "127.0.0.1", "::1"].includes(url.hostname);
37
+ const loopback = LOOPBACK_HOSTNAMES.includes(url.hostname);
37
38
  if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) {
38
39
  throw new BusabaseOAuthError(
39
40
  "invalid_base_url",
@@ -43,6 +44,10 @@ var normalizeOrigin = (raw, fallback = DEFAULT_CLOUD_BASE_URL) => {
43
44
  return url.origin;
44
45
  };
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
+ };
46
51
  var assertSameOrigin = (request) => {
47
52
  const origin = request.headers.get("origin");
48
53
  if (origin && origin !== requestOrigin(request)) {
@@ -61,8 +66,10 @@ var safeSpaces = (spaces) => spaces.map(({ id, name, slug, plan }) => ({ id, nam
61
66
  var BusabaseAirAppLocalGateway = class {
62
67
  #options;
63
68
  #pendingOAuth = /* @__PURE__ */ new Map();
69
+ #usesDefaultClient;
64
70
  #environmentSelectedSpace;
65
71
  constructor(options) {
72
+ this.#usesDefaultClient = !options.clientId;
66
73
  this.#options = {
67
74
  ...options,
68
75
  appId: options.appId,
@@ -211,22 +218,46 @@ var BusabaseAirAppLocalGateway = class {
211
218
  }
212
219
  }
213
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
+ }
214
248
  start = async (request) => {
215
249
  try {
216
250
  assertSameOrigin(request);
217
251
  const body = await readInput(request);
218
252
  const baseUrl = normalizeOrigin(String(body.base_url || ""), this.cloudBaseUrl);
219
253
  const redirectUri = new URL("/auth/callback", requestOrigin(request)).toString();
220
- const oauthRequest = await createBusabaseOAuthRequest({
221
- baseUrl,
222
- redirectUri,
223
- clientId: this.#options.clientId
224
- });
225
- const probe = await this.#fetch()(oauthRequest.authorizeUrl, {
226
- headers: { accept: "text/html" },
227
- redirect: "manual",
228
- signal: AbortSignal.timeout(this.#options.requestTimeoutMs)
229
- });
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;
230
261
  if (probe.status >= 400) {
231
262
  throw new BusabaseOAuthError(
232
263
  "oauth_unavailable",
@@ -263,7 +294,7 @@ var BusabaseAirAppLocalGateway = class {
263
294
  {
264
295
  appId: this.#options.appId,
265
296
  baseUrl: pending.baseUrl,
266
- clientId: this.#options.clientId,
297
+ clientId: pending.clientId,
267
298
  tokenSet
268
299
  },
269
300
  this.#options.credentialStore
@@ -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 };