busa-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/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 +15 -2
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default styling for the AirApp connect gate (`busabase-sdk/airapp-gate`).
|
|
3
|
+
*
|
|
4
|
+
* Ported from the stylesheet 62 App-in-Skills carried as a byte-identical copy,
|
|
5
|
+
* minus the rules left over from the retired setup-gate (secret rows, ready
|
|
6
|
+
* pills, prompt blocks) that the gate never rendered.
|
|
7
|
+
*
|
|
8
|
+
* Every value an app is likely to want to change is a custom property, so an
|
|
9
|
+
* app themes the gate by overriding these on `:root` instead of forking the
|
|
10
|
+
* file — that is the whole reason this can live in the SDK without dictating
|
|
11
|
+
* anyone's design system.
|
|
12
|
+
*
|
|
13
|
+
* :root { --bb-gate-accent: #7c3aed; --bb-gate-radius: 14px; }
|
|
14
|
+
*
|
|
15
|
+
* Load it however the app loads CSS:
|
|
16
|
+
* <link rel="stylesheet" href="./vendor/airapp-gate.css">
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
:root {
|
|
20
|
+
--bb-gate-accent: #1570ef;
|
|
21
|
+
--bb-gate-accent-contrast: #fff;
|
|
22
|
+
--bb-gate-accent-wash: #f5f9ff;
|
|
23
|
+
--bb-gate-surface: #fff;
|
|
24
|
+
--bb-gate-surface-muted: #f9fafb;
|
|
25
|
+
--bb-gate-text: #17202a;
|
|
26
|
+
--bb-gate-text-strong: #101828;
|
|
27
|
+
--bb-gate-text-muted: #667085;
|
|
28
|
+
--bb-gate-text-label: #344054;
|
|
29
|
+
--bb-gate-border: #d0d5dd;
|
|
30
|
+
--bb-gate-border-subtle: #eaecf0;
|
|
31
|
+
--bb-gate-danger: #b42318;
|
|
32
|
+
--bb-gate-scrim: color-mix(in srgb, #101828 72%, transparent);
|
|
33
|
+
--bb-gate-radius: 8px;
|
|
34
|
+
--bb-gate-radius-inner: 6px;
|
|
35
|
+
--bb-gate-font: Inter, ui-sans-serif, system-ui, sans-serif;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
html.bb-gate-active,
|
|
39
|
+
html.bb-gate-active body {
|
|
40
|
+
overflow: hidden;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.bb-gate-overlay {
|
|
44
|
+
position: fixed;
|
|
45
|
+
inset: 0;
|
|
46
|
+
/* Below the max z-index so an app's own toast/portal layer can still sit on
|
|
47
|
+
top if it deliberately opts to. */
|
|
48
|
+
z-index: 2147483000;
|
|
49
|
+
display: flex;
|
|
50
|
+
align-items: center;
|
|
51
|
+
justify-content: center;
|
|
52
|
+
padding: 24px;
|
|
53
|
+
background: var(--bb-gate-scrim);
|
|
54
|
+
font-family: var(--bb-gate-font);
|
|
55
|
+
color: var(--bb-gate-text);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
.bb-gate-panel {
|
|
59
|
+
width: min(720px, 100%);
|
|
60
|
+
max-height: min(760px, calc(100vh - 48px));
|
|
61
|
+
display: grid;
|
|
62
|
+
grid-template-rows: auto minmax(0, 1fr) auto;
|
|
63
|
+
overflow: hidden;
|
|
64
|
+
background: var(--bb-gate-surface);
|
|
65
|
+
border: 1px solid var(--bb-gate-border);
|
|
66
|
+
border-radius: var(--bb-gate-radius);
|
|
67
|
+
box-shadow: 0 24px 64px rgb(16 24 40 / 28%);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
.bb-gate-head {
|
|
71
|
+
display: flex;
|
|
72
|
+
gap: 24px;
|
|
73
|
+
align-items: start;
|
|
74
|
+
justify-content: space-between;
|
|
75
|
+
padding: 22px 24px 18px;
|
|
76
|
+
border-bottom: 1px solid var(--bb-gate-border-subtle);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
.bb-gate-head h1 {
|
|
80
|
+
margin: 0;
|
|
81
|
+
font-size: 22px;
|
|
82
|
+
line-height: 1.25;
|
|
83
|
+
letter-spacing: 0;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.bb-gate-head p {
|
|
87
|
+
margin: 6px 0 0;
|
|
88
|
+
color: var(--bb-gate-text-muted);
|
|
89
|
+
font-size: 14px;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
.bb-gate-body {
|
|
93
|
+
overflow: auto;
|
|
94
|
+
padding: 20px 24px;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
.bb-gate-body h2 {
|
|
98
|
+
margin: 0 0 12px;
|
|
99
|
+
font-size: 14px;
|
|
100
|
+
letter-spacing: 0;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
.bb-gate-server-grid {
|
|
104
|
+
display: grid;
|
|
105
|
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
106
|
+
gap: 12px;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
.bb-gate-server-card {
|
|
110
|
+
min-height: 104px;
|
|
111
|
+
display: flex;
|
|
112
|
+
align-items: center;
|
|
113
|
+
gap: 10px;
|
|
114
|
+
padding: 16px;
|
|
115
|
+
text-align: left;
|
|
116
|
+
color: var(--bb-gate-text-label);
|
|
117
|
+
background: var(--bb-gate-surface);
|
|
118
|
+
border: 1px solid var(--bb-gate-border);
|
|
119
|
+
border-radius: 7px;
|
|
120
|
+
cursor: pointer;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
.bb-gate-server-card:hover {
|
|
124
|
+
border-color: var(--bb-gate-text-muted);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
.bb-gate-server-card.is-selected {
|
|
128
|
+
border-color: var(--bb-gate-accent);
|
|
129
|
+
box-shadow: inset 0 0 0 1px var(--bb-gate-accent);
|
|
130
|
+
background: var(--bb-gate-accent-wash);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
.bb-gate-server-card input {
|
|
134
|
+
flex: none;
|
|
135
|
+
margin: 0;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
.bb-gate-server-card > span {
|
|
139
|
+
display: grid;
|
|
140
|
+
gap: 2px;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
.bb-gate-server-card strong {
|
|
144
|
+
font-size: 15px;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
.bb-gate-server-card span span {
|
|
148
|
+
color: var(--bb-gate-text-muted);
|
|
149
|
+
font-size: 13px;
|
|
150
|
+
line-height: 1.45;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
.bb-gate-custom-url {
|
|
154
|
+
display: grid;
|
|
155
|
+
gap: 6px;
|
|
156
|
+
margin-top: 16px;
|
|
157
|
+
padding: 16px;
|
|
158
|
+
border: 1px solid var(--bb-gate-border-subtle);
|
|
159
|
+
border-radius: 7px;
|
|
160
|
+
background: var(--bb-gate-surface-muted);
|
|
161
|
+
color: var(--bb-gate-text-label);
|
|
162
|
+
font-size: 13px;
|
|
163
|
+
font-weight: 600;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
.bb-gate-custom-url[hidden] {
|
|
167
|
+
display: none;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
.bb-gate-custom-url input {
|
|
171
|
+
min-width: 0;
|
|
172
|
+
min-height: 38px;
|
|
173
|
+
padding: 8px 10px;
|
|
174
|
+
color: var(--bb-gate-text-strong);
|
|
175
|
+
background: var(--bb-gate-surface);
|
|
176
|
+
border: 1px solid var(--bb-gate-border);
|
|
177
|
+
border-radius: var(--bb-gate-radius-inner);
|
|
178
|
+
font: inherit;
|
|
179
|
+
font-weight: 400;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
.bb-gate-space-select {
|
|
183
|
+
display: grid;
|
|
184
|
+
gap: 6px;
|
|
185
|
+
margin-top: 14px;
|
|
186
|
+
color: var(--bb-gate-text-label);
|
|
187
|
+
font-size: 13px;
|
|
188
|
+
font-weight: 600;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
.bb-gate-space-select select {
|
|
192
|
+
min-height: 38px;
|
|
193
|
+
padding: 8px 10px;
|
|
194
|
+
color: var(--bb-gate-text-strong);
|
|
195
|
+
background: var(--bb-gate-surface);
|
|
196
|
+
border: 1px solid var(--bb-gate-border);
|
|
197
|
+
border-radius: var(--bb-gate-radius-inner);
|
|
198
|
+
font: inherit;
|
|
199
|
+
font-weight: 400;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
.bb-gate-error {
|
|
203
|
+
color: var(--bb-gate-danger);
|
|
204
|
+
font-size: 13px;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
.bb-gate-note {
|
|
208
|
+
color: var(--bb-gate-text-muted);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
.bb-gate-link {
|
|
212
|
+
color: var(--bb-gate-accent);
|
|
213
|
+
font-size: 13px;
|
|
214
|
+
font-weight: 650;
|
|
215
|
+
text-decoration: none;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
.bb-gate-link:hover {
|
|
219
|
+
text-decoration: underline;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
.bb-gate-footer {
|
|
223
|
+
display: flex;
|
|
224
|
+
justify-content: space-between;
|
|
225
|
+
align-items: center;
|
|
226
|
+
gap: 10px;
|
|
227
|
+
padding: 14px 24px;
|
|
228
|
+
border-top: 1px solid var(--bb-gate-border-subtle);
|
|
229
|
+
background: var(--bb-gate-surface);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
.bb-gate-footer button {
|
|
233
|
+
min-height: 38px;
|
|
234
|
+
padding: 0 14px;
|
|
235
|
+
color: var(--bb-gate-text-label);
|
|
236
|
+
background: var(--bb-gate-surface);
|
|
237
|
+
border: 1px solid var(--bb-gate-border);
|
|
238
|
+
border-radius: var(--bb-gate-radius-inner);
|
|
239
|
+
font-weight: 650;
|
|
240
|
+
cursor: pointer;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
.bb-gate-footer .bb-gate-primary {
|
|
244
|
+
color: var(--bb-gate-accent-contrast);
|
|
245
|
+
background: var(--bb-gate-accent);
|
|
246
|
+
border-color: var(--bb-gate-accent);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
.bb-gate-footer button:disabled {
|
|
250
|
+
opacity: 0.55;
|
|
251
|
+
cursor: wait;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
@media (max-width: 600px) {
|
|
255
|
+
.bb-gate-overlay {
|
|
256
|
+
align-items: stretch;
|
|
257
|
+
padding: 0;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
.bb-gate-panel {
|
|
261
|
+
width: 100%;
|
|
262
|
+
max-height: 100vh;
|
|
263
|
+
border: 0;
|
|
264
|
+
border-radius: 0;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
.bb-gate-head {
|
|
268
|
+
padding: 18px 16px 14px;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
.bb-gate-head h1 {
|
|
272
|
+
font-size: 19px;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
.bb-gate-head p {
|
|
276
|
+
font-size: 13px;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
.bb-gate-body {
|
|
280
|
+
padding: 16px;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
.bb-gate-server-grid {
|
|
284
|
+
grid-template-columns: 1fr;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
.bb-gate-footer {
|
|
288
|
+
padding: 12px 16px;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
.bb-gate-footer button {
|
|
292
|
+
min-width: 0;
|
|
293
|
+
flex: 1;
|
|
294
|
+
padding-inline: 8px;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { BusabaseAirAppAuthStatus } from './airapp-node.js';
|
|
2
|
+
import './oauth-node.js';
|
|
3
|
+
import './oauth.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The AirApp connect gate — the browser half of local onboarding.
|
|
7
|
+
*
|
|
8
|
+
* `busabase-sdk/airapp-node` already owns the *flow* (PKCE, credential
|
|
9
|
+
* rotation, Space validation, the `/api/v1` proxy). This module owns the
|
|
10
|
+
* remaining copy-paste: deciding which of the three screens to show, and
|
|
11
|
+
* drawing them. 62 App-in-Skills carried a 160-line renderer whose only
|
|
12
|
+
* per-app difference was the display name interpolated into an HTML string,
|
|
13
|
+
* plus a byte-identical 290-line stylesheet — while the app-creator template
|
|
14
|
+
* grew a second, differently-classed implementation of the same three screens.
|
|
15
|
+
*
|
|
16
|
+
* Structure, deliberately: every decision and every piece of markup is a pure
|
|
17
|
+
* function of the gateway's status. The DOM is touched only in `mount` /
|
|
18
|
+
* `wire`. That is what makes the escaping (the one security-relevant part —
|
|
19
|
+
* `oauth_error` and Space names both come from outside) testable without a
|
|
20
|
+
* browser, and what lets an app swap the renderer without reimplementing the
|
|
21
|
+
* state machine.
|
|
22
|
+
*
|
|
23
|
+
* Import `busabase-sdk/airapp-gate.css` (or copy it) for the default look; it
|
|
24
|
+
* is themed entirely through custom properties.
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* ```ts
|
|
28
|
+
* import { createAirAppConnectGate } from "busabase-sdk/airapp-gate";
|
|
29
|
+
*
|
|
30
|
+
* const gate = createAirAppConnectGate({
|
|
31
|
+
* appName: "Kelly CRM",
|
|
32
|
+
* onProvision: () => provider.provisionResources(),
|
|
33
|
+
* });
|
|
34
|
+
*
|
|
35
|
+
* // Before mounting the app shell:
|
|
36
|
+
* if (await gate.pass({ onReady: () => start() })) start();
|
|
37
|
+
*
|
|
38
|
+
* // When loading data fails because the workspace is not set up yet:
|
|
39
|
+
* gate.renderSetupRequired(error, () => start());
|
|
40
|
+
* ```
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
/** Which screen the gate owes the operator right now. */
|
|
44
|
+
type AirAppGateScreen = "connect" | "space" | "ready";
|
|
45
|
+
/**
|
|
46
|
+
* Decide the screen from the gateway's status. Headless — a custom renderer
|
|
47
|
+
* should call this rather than re-deriving the rules from `connected`.
|
|
48
|
+
*
|
|
49
|
+
* Driven by `readiness`, with a fallback to the older `connected` /
|
|
50
|
+
* `requiresSpace` pair so an app can upgrade the SDK before its server.
|
|
51
|
+
*/
|
|
52
|
+
declare function selectAirAppGateScreen(status: Partial<BusabaseAirAppAuthStatus> | null | undefined): AirAppGateScreen;
|
|
53
|
+
/** The five setup states, decoded into what the workspace screen needs to know. */
|
|
54
|
+
interface AirAppSetupDescription {
|
|
55
|
+
code: string;
|
|
56
|
+
/** The message with its `CODE: ` prefix stripped. */
|
|
57
|
+
detail: string;
|
|
58
|
+
title: string;
|
|
59
|
+
/** Offer an "Initialize workspace" button. */
|
|
60
|
+
canProvision: boolean;
|
|
61
|
+
/** Offer a "Check again" button (the app cannot act; the operator can retry). */
|
|
62
|
+
canRetry: boolean;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Decode a setup failure. Accepts an `AirAppSetupError`, any `Error` whose
|
|
66
|
+
* message uses the historical `"CODE: detail"` shape, or a bare string — the
|
|
67
|
+
* generated apps throw all three spellings.
|
|
68
|
+
*/
|
|
69
|
+
declare function describeAirAppSetupError(error: unknown): AirAppSetupDescription;
|
|
70
|
+
interface AirAppGateOptions {
|
|
71
|
+
/** Shown in the gate's prose, e.g. "Kelly CRM". The one per-app value. */
|
|
72
|
+
appName: string;
|
|
73
|
+
/**
|
|
74
|
+
* Where to mount. A selector, an element, or omitted to create and prepend
|
|
75
|
+
* `#busabaseAirAppGate` to `<body>`.
|
|
76
|
+
*/
|
|
77
|
+
mount?: string | HTMLElement;
|
|
78
|
+
/** Prefix for the gateway routes; `""` means `/auth/status`, `/auth/start`, … */
|
|
79
|
+
authBasePath?: string;
|
|
80
|
+
/**
|
|
81
|
+
* Whether this run owes a gate at all. Return `false` for a Busabase-hosted
|
|
82
|
+
* AirApp (the ambient session owns auth) or for a demo/offline mode.
|
|
83
|
+
*
|
|
84
|
+
* **Pass this.** Omitted, `pass()` falls back to probing `/auth/status` and
|
|
85
|
+
* treating an unreachable or non-JSON answer as "hosted" — which works, but
|
|
86
|
+
* infers something the host already states outright. A Busabase-hosted AirApp
|
|
87
|
+
* is identified by the `BUSABASE_AIRAPP_RUNTIME` env var Busabase injects into
|
|
88
|
+
* the process it spawns, surfaced to the browser by the app's own server;
|
|
89
|
+
* never by hostname, iframe nesting, or path, since a hosted AirApp can be
|
|
90
|
+
* served from localhost and a standalone run can be reached over a dev tunnel.
|
|
91
|
+
*/
|
|
92
|
+
shouldGate?: () => boolean | Promise<boolean>;
|
|
93
|
+
/** Injected for tests. */
|
|
94
|
+
fetch?: typeof globalThis.fetch;
|
|
95
|
+
/** Called by the workspace screen's "Initialize workspace" button. */
|
|
96
|
+
onProvision?: () => Promise<unknown>;
|
|
97
|
+
/** Where the "open the demo" escape hatch points. Omit to hide it. */
|
|
98
|
+
demoHref?: string | null;
|
|
99
|
+
/** Replace the default markup wholesale while keeping the state machine. */
|
|
100
|
+
render?: AirAppGateRenderer;
|
|
101
|
+
}
|
|
102
|
+
interface AirAppGateRenderer {
|
|
103
|
+
connect(view: AirAppConnectView): string;
|
|
104
|
+
space(view: AirAppSpaceView): string;
|
|
105
|
+
workspace(view: AirAppWorkspaceView): string;
|
|
106
|
+
}
|
|
107
|
+
interface AirAppConnectView {
|
|
108
|
+
appName: string;
|
|
109
|
+
cloudBaseUrl: string;
|
|
110
|
+
/** Set when the operator is here because the session lapsed, not first-run. */
|
|
111
|
+
reconnect: boolean;
|
|
112
|
+
/** `?oauth_error=` from the callback redirect. Untrusted. */
|
|
113
|
+
oauthError: string;
|
|
114
|
+
authBasePath: string;
|
|
115
|
+
}
|
|
116
|
+
interface AirAppSpaceView {
|
|
117
|
+
appName: string;
|
|
118
|
+
baseUrl: string;
|
|
119
|
+
spaces: {
|
|
120
|
+
id: string;
|
|
121
|
+
name: string;
|
|
122
|
+
}[];
|
|
123
|
+
}
|
|
124
|
+
interface AirAppWorkspaceView extends AirAppSetupDescription {
|
|
125
|
+
appName: string;
|
|
126
|
+
demoHref: string | null;
|
|
127
|
+
}
|
|
128
|
+
/** HTML-escape. Every interpolation below goes through this. */
|
|
129
|
+
declare const escapeHtml: (value: unknown) => string;
|
|
130
|
+
/** The stock three screens. Pure string builders — no DOM, no fetch. */
|
|
131
|
+
declare const defaultAirAppGateRenderer: AirAppGateRenderer;
|
|
132
|
+
declare const DEFAULT_CLOUD_BASE_URL = "https://busabase.com";
|
|
133
|
+
interface AirAppConnectGate {
|
|
134
|
+
/**
|
|
135
|
+
* Resolve the gate before the app shell mounts. `true` means the app may
|
|
136
|
+
* load data; `false` means a screen is up and waiting on the operator — call
|
|
137
|
+
* again after `onReady` fires.
|
|
138
|
+
*/
|
|
139
|
+
pass(options?: {
|
|
140
|
+
onReady?: () => void;
|
|
141
|
+
}): Promise<boolean>;
|
|
142
|
+
/** Show the workspace screen for a setup error thrown while loading data. */
|
|
143
|
+
renderSetupRequired(error: unknown, onRetry: () => void): void;
|
|
144
|
+
/** Take the gate down and give scrolling back to the page. */
|
|
145
|
+
close(): void;
|
|
146
|
+
/** Fetch the gateway's status without rendering anything. */
|
|
147
|
+
status(): Promise<BusabaseAirAppAuthStatus | null>;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Build a gate bound to one app.
|
|
151
|
+
*
|
|
152
|
+
* Nothing is rendered until `pass()` decides a screen is owed, so it is safe to
|
|
153
|
+
* construct at module scope.
|
|
154
|
+
*/
|
|
155
|
+
declare function createAirAppConnectGate(options: AirAppGateOptions): AirAppConnectGate;
|
|
156
|
+
|
|
157
|
+
export { type AirAppConnectGate, type AirAppConnectView, type AirAppGateOptions, type AirAppGateRenderer, type AirAppGateScreen, type AirAppSetupDescription, type AirAppSpaceView, type AirAppWorkspaceView, BusabaseAirAppAuthStatus, DEFAULT_CLOUD_BASE_URL, createAirAppConnectGate, defaultAirAppGateRenderer, describeAirAppSetupError, escapeHtml, selectAirAppGateScreen };
|
|
@@ -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("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
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 };
|