appilot 0.0.1 → 0.1.1
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 +101 -7
- package/dist/dom/sensitiveFields.d.ts +33 -0
- package/dist/dom/sensitiveFields.js +72 -0
- package/dist/dom/surfaces.d.ts +81 -0
- package/dist/dom/surfaces.js +91 -0
- package/dist/dom/uniqueSelector.d.ts +23 -0
- package/dist/dom/uniqueSelector.js +104 -0
- package/dist/domResponder.d.ts +38 -0
- package/dist/domResponder.js +355 -0
- package/dist/focusedSessions/pageSdk.d.ts +107 -0
- package/dist/focusedSessions/pageSdk.js +238 -0
- package/dist/httpFetch.d.ts +52 -0
- package/dist/httpFetch.js +137 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.js +24 -0
- package/dist/runtime.d.ts +16 -0
- package/dist/runtime.js +21 -0
- package/dist/sessions/types.d.ts +140 -0
- package/dist/sessions/types.js +12 -0
- package/dist/webmcp/registry.d.ts +75 -0
- package/dist/webmcp/registry.js +152 -0
- package/dist/webmcp/sdk.d.ts +38 -0
- package/dist/webmcp/sdk.js +81 -0
- package/dist/widget/boot.d.ts +91 -0
- package/dist/widget/boot.js +313 -0
- package/package.json +51 -16
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Appilot widget boot: load the embeddable assistant into a host app with an
|
|
3
|
+
* identified user token, and keep it authenticated for the life of the page.
|
|
4
|
+
*
|
|
5
|
+
* The host app owns identity: it exposes a backend RELAY endpoint that mints an
|
|
6
|
+
* identified widget token (so the widget key never sees the end user's
|
|
7
|
+
* credentials), and this boot loader POSTs to it, then loads the widget bundle
|
|
8
|
+
* with the returned token.
|
|
9
|
+
*
|
|
10
|
+
* Design guarantees:
|
|
11
|
+
* - Boot does not latch on failure: a failed relay or bundle load returns
|
|
12
|
+
* availability to 'unavailable' and a later bootAppilotWidget() call retries
|
|
13
|
+
* from scratch (one hiccup never permanently disables the assistant).
|
|
14
|
+
* - The widget token (TTL ~3600s) is re-relayed at ~80% of its TTL and handed
|
|
15
|
+
* to the live instance through `window.Appilot.setUserToken`, so a long-lived
|
|
16
|
+
* tab never starts 401ing on a healthy host session. The refreshed token is
|
|
17
|
+
* NOT written back to the DOM (see "The token and the DOM" below).
|
|
18
|
+
* - The widget can request an out-of-band refresh by dispatching
|
|
19
|
+
* 'appilot:identity-refresh-requested' on window (it does so when its
|
|
20
|
+
* federated identity is rejected); the loader re-relays immediately.
|
|
21
|
+
* - When the host supplies `getBearer` and it returns null (the host session
|
|
22
|
+
* is gone), the scheduled refresh does NOT call the relay: relaying without
|
|
23
|
+
* a bearer would mint whatever fallback identity the endpoint assigns,
|
|
24
|
+
* silently swapping the signed-in user for a default persona. The loader
|
|
25
|
+
* retries until the host session is back.
|
|
26
|
+
* - The relay call carries a bearer supplied by the host (`getBearer`) when one
|
|
27
|
+
* exists, so a logged-in user federates as themselves; without it the relay
|
|
28
|
+
* falls back to whatever identity the endpoint assigns.
|
|
29
|
+
* - Availability is observable (getWidgetAvailability / subscribeWidgetAvailability)
|
|
30
|
+
* so pages can render an honest "assistant unavailable" state. Pairs with
|
|
31
|
+
* React useSyncExternalStore(subscribeWidgetAvailability, getWidgetAvailability).
|
|
32
|
+
*
|
|
33
|
+
* ## The token and the DOM
|
|
34
|
+
*
|
|
35
|
+
* `data-user-token` is a bearer token for the end user. Anything that can read
|
|
36
|
+
* the host page's DOM can read it and act as that person against the Appilot
|
|
37
|
+
* backend, so it must not live there.
|
|
38
|
+
*
|
|
39
|
+
* Two changes follow. The refresh no longer writes the rotated token back to
|
|
40
|
+
* the tag: `window.Appilot.setUserToken` already feeds the live instance, and a
|
|
41
|
+
* later re-boot re-relays a fresh token anyway, so the DOM write bought a
|
|
42
|
+
* hypothetical reboot path at the price of a token sitting in the page for the
|
|
43
|
+
* life of the tab, being renewed there every ~48 minutes.
|
|
44
|
+
*
|
|
45
|
+
* The boot token still passes through the attribute, because that is the only
|
|
46
|
+
* channel the shipped widget bundle reads its configuration from, and it is
|
|
47
|
+
* removed as soon as the bundle has consumed it (`releaseBootToken`). That
|
|
48
|
+
* shrinks the exposure from the lifetime of the page to the interval between
|
|
49
|
+
* appending the script and the bundle parsing its config. Closing it entirely
|
|
50
|
+
* needs the widget to accept a non-DOM handoff; until then, treat the boot
|
|
51
|
+
* token as short-lived and keep the relay's TTL short.
|
|
52
|
+
*/
|
|
53
|
+
const DEFAULT_APPILOT_API_URL = 'http://localhost:6001';
|
|
54
|
+
/** Dispatched by the widget when its federated identity is rejected (401),
|
|
55
|
+
* asking the loader for an immediate re-relay instead of waiting for the
|
|
56
|
+
* scheduled ~80%-TTL refresh. Mirror of the widget's WidgetSDK constant. */
|
|
57
|
+
const IDENTITY_REFRESH_REQUESTED_EVENT = 'appilot:identity-refresh-requested';
|
|
58
|
+
const DEFAULT_TOKEN_ENDPOINT = '/api/widget/token';
|
|
59
|
+
const DEFAULT_TOKEN_TTL_SECONDS = 3600;
|
|
60
|
+
const TOKEN_REFRESH_FRACTION = 0.8;
|
|
61
|
+
const REFRESH_RETRY_MS = 60_000;
|
|
62
|
+
const DEFAULT_BOOT_TIMEOUT_MS = 10_000;
|
|
63
|
+
let availability = 'unknown';
|
|
64
|
+
const listeners = new Set();
|
|
65
|
+
let scriptEl = null;
|
|
66
|
+
let refreshTimer = null;
|
|
67
|
+
let bootTimer = null;
|
|
68
|
+
let bootAbortController = null;
|
|
69
|
+
let activeAttempt = 0;
|
|
70
|
+
let warnedMissingConfig = false;
|
|
71
|
+
let refreshRequestListener = null;
|
|
72
|
+
/**
|
|
73
|
+
* Marks OUR loader tag without naming the token. The tag was previously found
|
|
74
|
+
* by `[data-user-token]`, which stops working the moment the token is removed,
|
|
75
|
+
* and this attribute is deliberately not one the widget's config parser matches
|
|
76
|
+
* (it looks for the exact names `data-api-key`, `data-user-token`,
|
|
77
|
+
* `data-appilot`).
|
|
78
|
+
*/
|
|
79
|
+
const LOADER_MARKER_ATTR = 'data-appilot-loader';
|
|
80
|
+
/**
|
|
81
|
+
* Drop the boot token from the DOM once the widget has read its config.
|
|
82
|
+
*
|
|
83
|
+
* The bundle parses its config at module evaluation, which is over by the time
|
|
84
|
+
* `load` fires. The one exception is a document still parsing when the script
|
|
85
|
+
* executes: the widget then defers its auto-init to DOMContentLoaded, so this
|
|
86
|
+
* defers with it. Our listener is registered from inside the load handler,
|
|
87
|
+
* after the bundle registered its own, and DOMContentLoaded listeners run in
|
|
88
|
+
* registration order.
|
|
89
|
+
*/
|
|
90
|
+
function releaseBootToken(script) {
|
|
91
|
+
const drop = () => script.removeAttribute('data-user-token');
|
|
92
|
+
if (document.readyState === 'loading') {
|
|
93
|
+
document.addEventListener('DOMContentLoaded', drop, { once: true });
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
drop();
|
|
97
|
+
}
|
|
98
|
+
function detachRefreshRequestListener() {
|
|
99
|
+
if (refreshRequestListener) {
|
|
100
|
+
window.removeEventListener(IDENTITY_REFRESH_REQUESTED_EVENT, refreshRequestListener);
|
|
101
|
+
refreshRequestListener = null;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
function attachRefreshRequestListener(options) {
|
|
105
|
+
detachRefreshRequestListener();
|
|
106
|
+
refreshRequestListener = () => {
|
|
107
|
+
clearRefreshTimer();
|
|
108
|
+
void refreshWidgetToken(options);
|
|
109
|
+
};
|
|
110
|
+
window.addEventListener(IDENTITY_REFRESH_REQUESTED_EVENT, refreshRequestListener);
|
|
111
|
+
}
|
|
112
|
+
function setAvailability(next) {
|
|
113
|
+
if (availability === next)
|
|
114
|
+
return;
|
|
115
|
+
availability = next;
|
|
116
|
+
for (const listener of listeners)
|
|
117
|
+
listener(next);
|
|
118
|
+
}
|
|
119
|
+
export function getWidgetAvailability() {
|
|
120
|
+
return availability;
|
|
121
|
+
}
|
|
122
|
+
/** Subscribe to availability changes. Returns the unsubscribe function. */
|
|
123
|
+
export function subscribeWidgetAvailability(listener) {
|
|
124
|
+
listeners.add(listener);
|
|
125
|
+
return () => {
|
|
126
|
+
listeners.delete(listener);
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
async function relayWidgetToken(options, signal) {
|
|
130
|
+
const endpoint = options.tokenEndpoint ?? DEFAULT_TOKEN_ENDPOINT;
|
|
131
|
+
// The host bearer lets the relay federate the LOGGED-IN user instead of a
|
|
132
|
+
// default identity; without it the relay assigns whatever identity it wants.
|
|
133
|
+
const bearer = options.getBearer?.() ?? null;
|
|
134
|
+
const response = await fetch(endpoint, {
|
|
135
|
+
method: 'POST',
|
|
136
|
+
signal,
|
|
137
|
+
headers: {
|
|
138
|
+
...(bearer ? { Authorization: `Bearer ${bearer}` } : {}),
|
|
139
|
+
...(options.tokenBody !== undefined ? { 'Content-Type': 'application/json' } : {}),
|
|
140
|
+
},
|
|
141
|
+
...(options.tokenBody !== undefined ? { body: JSON.stringify(options.tokenBody) } : {}),
|
|
142
|
+
});
|
|
143
|
+
if (!response.ok) {
|
|
144
|
+
throw new Error(`widget token relay responded ${response.status}`);
|
|
145
|
+
}
|
|
146
|
+
return (await response.json());
|
|
147
|
+
}
|
|
148
|
+
function clearRefreshTimer() {
|
|
149
|
+
if (refreshTimer !== null) {
|
|
150
|
+
clearTimeout(refreshTimer);
|
|
151
|
+
refreshTimer = null;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
function clearBootTimer() {
|
|
155
|
+
if (bootTimer !== null) {
|
|
156
|
+
clearTimeout(bootTimer);
|
|
157
|
+
bootTimer = null;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
function hasRequiredWidgetApi() {
|
|
161
|
+
return typeof window.Appilot?.open === 'function';
|
|
162
|
+
}
|
|
163
|
+
function cleanFailedAttempt(attempt, script) {
|
|
164
|
+
if (attempt !== activeAttempt) {
|
|
165
|
+
script?.remove();
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
activeAttempt += 1;
|
|
169
|
+
clearBootTimer();
|
|
170
|
+
clearRefreshTimer();
|
|
171
|
+
detachRefreshRequestListener();
|
|
172
|
+
bootAbortController?.abort();
|
|
173
|
+
bootAbortController = null;
|
|
174
|
+
const target = script ?? scriptEl;
|
|
175
|
+
target?.remove();
|
|
176
|
+
if (!target || scriptEl === target)
|
|
177
|
+
scriptEl = null;
|
|
178
|
+
setAvailability('unavailable');
|
|
179
|
+
}
|
|
180
|
+
function scheduleTokenRefresh(expiresIn, options) {
|
|
181
|
+
clearRefreshTimer();
|
|
182
|
+
const ttl = expiresIn && expiresIn > 0 ? expiresIn : DEFAULT_TOKEN_TTL_SECONDS;
|
|
183
|
+
refreshTimer = setTimeout(() => {
|
|
184
|
+
void refreshWidgetToken(options);
|
|
185
|
+
}, ttl * TOKEN_REFRESH_FRACTION * 1000);
|
|
186
|
+
}
|
|
187
|
+
async function refreshWidgetToken(options) {
|
|
188
|
+
// Host session gone: do NOT hit the relay bearerless. The relay would fall
|
|
189
|
+
// back to its default identity (dev personas in development), silently
|
|
190
|
+
// swapping the federated user on the next token. Wait for the session to
|
|
191
|
+
// come back (login) and retry; the widget shows its own identity-expired
|
|
192
|
+
// state in the meantime.
|
|
193
|
+
if (options.getBearer && !options.getBearer()) {
|
|
194
|
+
clearRefreshTimer();
|
|
195
|
+
refreshTimer = setTimeout(() => {
|
|
196
|
+
void refreshWidgetToken(options);
|
|
197
|
+
}, REFRESH_RETRY_MS);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
try {
|
|
201
|
+
const relayed = await relayWidgetToken(options);
|
|
202
|
+
// The rotated token goes to the live instance ONLY. Writing it back to
|
|
203
|
+
// the loader tag put a live bearer token for the signed-in user in the
|
|
204
|
+
// host page's DOM, readable by every script on the page, and refreshed
|
|
205
|
+
// there for as long as the tab stayed open.
|
|
206
|
+
// (`window.Appilot.setUserToken`, widget >= 2026-08-25.)
|
|
207
|
+
const appilot = window.Appilot;
|
|
208
|
+
appilot?.setUserToken?.(relayed.token);
|
|
209
|
+
scheduleTokenRefresh(relayed.expiresIn, options);
|
|
210
|
+
}
|
|
211
|
+
catch (error) {
|
|
212
|
+
console.warn('[appilot] widget token refresh failed; retrying shortly', error);
|
|
213
|
+
clearRefreshTimer();
|
|
214
|
+
refreshTimer = setTimeout(() => {
|
|
215
|
+
void refreshWidgetToken(options);
|
|
216
|
+
}, REFRESH_RETRY_MS);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
export async function bootAppilotWidget(options) {
|
|
220
|
+
// Idempotent while healthy; 'unavailable' deliberately falls through so a
|
|
221
|
+
// later call retries after a failure.
|
|
222
|
+
if (availability === 'booting' || availability === 'ready')
|
|
223
|
+
return;
|
|
224
|
+
const label = options.surfaceLabel ?? 'assistant';
|
|
225
|
+
if (!options.widgetScriptUrl) {
|
|
226
|
+
if (!warnedMissingConfig) {
|
|
227
|
+
warnedMissingConfig = true;
|
|
228
|
+
console.warn(`[appilot] widget script URL is not configured; ${label} disabled`);
|
|
229
|
+
}
|
|
230
|
+
setAvailability('unavailable');
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
setAvailability('booting');
|
|
234
|
+
const attempt = ++activeAttempt;
|
|
235
|
+
bootAbortController?.abort();
|
|
236
|
+
bootAbortController = new AbortController();
|
|
237
|
+
clearBootTimer();
|
|
238
|
+
bootTimer = setTimeout(() => {
|
|
239
|
+
if (attempt !== activeAttempt)
|
|
240
|
+
return;
|
|
241
|
+
console.warn(`[appilot] widget boot timed out; ${label} disabled`);
|
|
242
|
+
cleanFailedAttempt(attempt);
|
|
243
|
+
}, options.timeoutMs ?? DEFAULT_BOOT_TIMEOUT_MS);
|
|
244
|
+
let relayed;
|
|
245
|
+
try {
|
|
246
|
+
relayed = await relayWidgetToken(options, bootAbortController.signal);
|
|
247
|
+
}
|
|
248
|
+
catch (error) {
|
|
249
|
+
if (attempt !== activeAttempt)
|
|
250
|
+
return;
|
|
251
|
+
console.warn(`[appilot] widget token relay unavailable; ${label} disabled`, error);
|
|
252
|
+
cleanFailedAttempt(attempt);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
if (attempt !== activeAttempt)
|
|
256
|
+
return;
|
|
257
|
+
const script = document.createElement('script');
|
|
258
|
+
script.src = options.widgetScriptUrl;
|
|
259
|
+
script.async = true;
|
|
260
|
+
script.setAttribute(LOADER_MARKER_ATTR, '');
|
|
261
|
+
if (options.widgetKey)
|
|
262
|
+
script.dataset.apiKey = options.widgetKey;
|
|
263
|
+
script.dataset.apiUrl = options.appilotApiUrl || DEFAULT_APPILOT_API_URL;
|
|
264
|
+
script.dataset.userToken = relayed.token;
|
|
265
|
+
if (options.brandName)
|
|
266
|
+
script.dataset.brandName = options.brandName;
|
|
267
|
+
script.dataset.position = options.position ?? 'bottom-right';
|
|
268
|
+
script.dataset.theme = options.theme ?? 'host';
|
|
269
|
+
script.dataset.language = options.language ?? 'en';
|
|
270
|
+
script.addEventListener('load', () => {
|
|
271
|
+
if (attempt !== activeAttempt) {
|
|
272
|
+
script.remove();
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
if (!hasRequiredWidgetApi()) {
|
|
276
|
+
console.warn(`[appilot] widget bundle loaded without its panel API; ${label} disabled`);
|
|
277
|
+
cleanFailedAttempt(attempt, script);
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
clearBootTimer();
|
|
281
|
+
bootAbortController = null;
|
|
282
|
+
setAvailability('ready');
|
|
283
|
+
// The bundle has its config; the token has no further reason to be in
|
|
284
|
+
// the page.
|
|
285
|
+
releaseBootToken(script);
|
|
286
|
+
scheduleTokenRefresh(relayed.expiresIn, options);
|
|
287
|
+
attachRefreshRequestListener(options);
|
|
288
|
+
});
|
|
289
|
+
script.addEventListener('error', () => {
|
|
290
|
+
if (attempt !== activeAttempt) {
|
|
291
|
+
script.remove();
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
console.warn(`[appilot] widget bundle failed to load; ${label} disabled`);
|
|
295
|
+
cleanFailedAttempt(attempt, script);
|
|
296
|
+
});
|
|
297
|
+
document.head.appendChild(script);
|
|
298
|
+
scriptEl = script;
|
|
299
|
+
}
|
|
300
|
+
/** Test-only: reset module state between tests. Never call from app code. */
|
|
301
|
+
export function __resetAppilotWidgetForTests() {
|
|
302
|
+
activeAttempt += 1;
|
|
303
|
+
clearBootTimer();
|
|
304
|
+
bootAbortController?.abort();
|
|
305
|
+
bootAbortController = null;
|
|
306
|
+
clearRefreshTimer();
|
|
307
|
+
detachRefreshRequestListener();
|
|
308
|
+
scriptEl?.remove();
|
|
309
|
+
scriptEl = null;
|
|
310
|
+
listeners.clear();
|
|
311
|
+
availability = 'unknown';
|
|
312
|
+
warnedMissingConfig = false;
|
|
313
|
+
}
|
package/package.json
CHANGED
|
@@ -1,17 +1,52 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
2
|
+
"name": "appilot",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Appilot SDK: the single developer-facing surface for building on Appilot. Boot the assistant (bootAppilotWidget), register client actions (registerTool, WebMCP-aligned), and run Focused Sessions (startFocusedSession). Browser-only, React-free, zero runtime dependencies.",
|
|
5
|
+
"homepage": "https://appilot.space",
|
|
6
|
+
"author": "BetterKnow GmbH",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"appilot",
|
|
9
|
+
"webmcp",
|
|
10
|
+
"agent",
|
|
11
|
+
"assistant",
|
|
12
|
+
"widget",
|
|
13
|
+
"sdk"
|
|
14
|
+
],
|
|
15
|
+
"type": "module",
|
|
16
|
+
"main": "./dist/index.js",
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"exports": {
|
|
19
|
+
".": {
|
|
20
|
+
"types": "./dist/index.d.ts",
|
|
21
|
+
"default": "./dist/index.js"
|
|
22
|
+
},
|
|
23
|
+
"./runtime": {
|
|
24
|
+
"types": "./dist/runtime.d.ts",
|
|
25
|
+
"default": "./dist/runtime.js"
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"sideEffects": false,
|
|
29
|
+
"files": [
|
|
30
|
+
"dist"
|
|
31
|
+
],
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"jsdom": "^26.1.0",
|
|
34
|
+
"typescript": "^5.5.0",
|
|
35
|
+
"vitest": "^2.0.0"
|
|
36
|
+
},
|
|
37
|
+
"engines": {
|
|
38
|
+
"node": ">=18"
|
|
39
|
+
},
|
|
40
|
+
"publishConfig": {
|
|
41
|
+
"access": "public",
|
|
42
|
+
"registry": "https://registry.npmjs.org/"
|
|
43
|
+
},
|
|
44
|
+
"license": "ISC",
|
|
45
|
+
"scripts": {
|
|
46
|
+
"build": "tsc",
|
|
47
|
+
"typecheck": "tsc --noEmit",
|
|
48
|
+
"type-check": "tsc --noEmit",
|
|
49
|
+
"test": "vitest run",
|
|
50
|
+
"test:watch": "vitest"
|
|
51
|
+
}
|
|
52
|
+
}
|