appilot-mcp 0.2.1 → 0.3.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/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/LICENSE +15 -0
- package/README.md +74 -16
- package/dist/appilot-configurator.mcpb +0 -0
- package/dist/cli.d.ts +34 -0
- package/dist/cli.js +171 -0
- package/dist/client.d.ts +45 -1
- package/dist/client.js +74 -1
- package/dist/contract/healthContract.js +31 -4
- package/dist/index.bundle.js +820 -142
- package/dist/index.js +7 -0
- package/dist/manifest.d.ts +14 -2
- package/dist/manifest.js +31 -9
- package/dist/public-marketplace/.claude-plugin/marketplace.json +20 -0
- package/dist/public-marketplace/README.md +23 -0
- package/dist/public-marketplace/plugins/app-configurator/.claude-plugin/plugin.json +43 -0
- package/dist/public-marketplace/plugins/app-configurator/README.md +328 -0
- package/dist/public-marketplace/plugins/app-configurator/dist/index.bundle.js +57370 -0
- package/dist/public-marketplace/plugins/app-configurator/skills/app-configurator/SKILL.md +267 -0
- package/dist/public-marketplace/plugins/app-configurator/skills/app-configurator/agents/openai.yaml +13 -0
- package/dist/remote/consent.d.ts +10 -2
- package/dist/remote/consent.js +15 -6
- package/dist/remote/consentMessages.d.ts +4 -1
- package/dist/remote/consentMessages.js +9 -6
- package/dist/remote/httpServer.js +2 -2
- package/dist/remote/oauth.js +9 -9
- package/dist/scaffold.d.ts +68 -6
- package/dist/scaffold.js +424 -97
- package/dist/server.js +175 -18
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/examples/app.appilot.json +212 -0
- package/mcpb/manifest.json +117 -21
- package/package.json +5 -3
- package/skills/app-configurator/SKILL.md +61 -19
package/dist/scaffold.js
CHANGED
|
@@ -10,7 +10,62 @@
|
|
|
10
10
|
* integration a developer must write in their own backend, it is
|
|
11
11
|
* security-critical, and getting it wrong is invisible until an auth edge case
|
|
12
12
|
* shows up in production.
|
|
13
|
+
*
|
|
14
|
+
* Everything returned here has to COMPILE. Three of the six framework outputs
|
|
15
|
+
* did not typecheck under `strict` when the 2026-09-07 audit ran them through
|
|
16
|
+
* `tsc`, and the relay, the piece a developer is least able to review, was the
|
|
17
|
+
* file that failed. `test/scaffoldTypecheck.test.ts` now compiles every output
|
|
18
|
+
* against the workspace sources of `appilot` and `appilot-server`.
|
|
19
|
+
*/
|
|
20
|
+
import { IDENTIFIER_KINDS } from 'appilot-shared/validation';
|
|
21
|
+
/**
|
|
22
|
+
* The frameworks with a first-class relay, plus `other`.
|
|
23
|
+
*
|
|
24
|
+
* `appilot-server` is a Node package, so a Django, Rails or PHP host cannot use
|
|
25
|
+
* it. That is not a reason to answer a raw Zod enum dump: the exchange is one
|
|
26
|
+
* authenticated HTTPS call, and `other` returns it as curl plus a Python and a
|
|
27
|
+
* Ruby handler.
|
|
13
28
|
*/
|
|
29
|
+
export const SCAFFOLD_FRAMEWORKS = [
|
|
30
|
+
'next',
|
|
31
|
+
'express',
|
|
32
|
+
'fastify',
|
|
33
|
+
'hono',
|
|
34
|
+
'remix',
|
|
35
|
+
'sveltekit',
|
|
36
|
+
'other',
|
|
37
|
+
];
|
|
38
|
+
function publicKeyIdiom(framework) {
|
|
39
|
+
switch (framework) {
|
|
40
|
+
case 'next':
|
|
41
|
+
return {
|
|
42
|
+
envName: 'NEXT_PUBLIC_APPILOT_WIDGET_KEY',
|
|
43
|
+
expr: 'process.env.NEXT_PUBLIC_APPILOT_WIDGET_KEY',
|
|
44
|
+
};
|
|
45
|
+
case 'sveltekit':
|
|
46
|
+
return {
|
|
47
|
+
envName: 'PUBLIC_APPILOT_WIDGET_KEY',
|
|
48
|
+
expr: 'publicEnv.PUBLIC_APPILOT_WIDGET_KEY',
|
|
49
|
+
importLine: "import { env as publicEnv } from '$env/dynamic/public';",
|
|
50
|
+
};
|
|
51
|
+
default:
|
|
52
|
+
return {
|
|
53
|
+
envName: 'VITE_APPILOT_WIDGET_KEY',
|
|
54
|
+
expr: 'import.meta.env.VITE_APPILOT_WIDGET_KEY',
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* The two ways a developer runs this against a machine they are sitting at.
|
|
60
|
+
* Both are true at once and the difference between them is what the turn can
|
|
61
|
+
* see, so the notes state both rather than picking one.
|
|
62
|
+
*/
|
|
63
|
+
function localDevelopmentNotes(idiom) {
|
|
64
|
+
return [
|
|
65
|
+
'Local development, first choice: run the app on a hostname that resolves to 127.0.0.1 (myapp.lvh.me:3000, or an /etc/hosts entry such as myapp.local) and register THAT hostname as a domain of the app. The page then resolves its tenant by its own hostname, no widget key is needed at all, DNS verification is not required for this path, and the turn gets the full app context: views, controls, forms, action plans and knowledge.',
|
|
66
|
+
`Local development, fallback: on bare localhost no domain resolves, so the page needs a wk_test_ key in ${idiom.envName}. The turn gets the organization and the signed-in user and NO app context, because there is no domain to resolve it from. Use this only where the hostname above is impossible.`,
|
|
67
|
+
];
|
|
68
|
+
}
|
|
14
69
|
function relayFetchHandler(namespace) {
|
|
15
70
|
return `import { createWidgetTokenHandler } from 'appilot-server';
|
|
16
71
|
|
|
@@ -31,40 +86,68 @@ export const POST = createWidgetTokenHandler({
|
|
|
31
86
|
});
|
|
32
87
|
`;
|
|
33
88
|
}
|
|
89
|
+
/**
|
|
90
|
+
* Express and Fastify share the Node adapter and differ only in how the handler
|
|
91
|
+
* is mounted.
|
|
92
|
+
*
|
|
93
|
+
* Two things here are not decoration. The session lives on a property the host's
|
|
94
|
+
* own middleware added, which `IncomingMessage` does not declare, so the cast
|
|
95
|
+
* names the shape instead of leaving the read as an error. And Fastify owns the
|
|
96
|
+
* response until it is told otherwise: writing to `reply.raw` without
|
|
97
|
+
* `reply.hijack()` first is how a correct-looking relay produces "Reply was
|
|
98
|
+
* already sent" in production.
|
|
99
|
+
*/
|
|
34
100
|
function relayNodeHandler(namespace, framework) {
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
101
|
+
const head = `import type { IncomingMessage } from 'node:http';
|
|
102
|
+
import { createNodeWidgetTokenHandler } from 'appilot-server/node';
|
|
103
|
+
|
|
104
|
+
/** What your session middleware puts on the request. Yours will differ. */
|
|
105
|
+
type RequestWithSession = IncomingMessage & {
|
|
106
|
+
session?: { user?: { id: string | number; name?: string } };
|
|
107
|
+
};
|
|
39
108
|
|
|
40
109
|
// resolveUser is the security boundary. Derive the user from a credential you
|
|
41
110
|
// trust (req.session, req.user). Never from the request body.
|
|
42
111
|
const relay = createNodeWidgetTokenHandler({
|
|
43
|
-
apiUrl: process.env.APPILOT_API_URL
|
|
44
|
-
widgetKey: process.env.APPILOT_WIDGET_KEY
|
|
45
|
-
widgetSecret: process.env.APPILOT_WIDGET_SECRET
|
|
112
|
+
apiUrl: process.env.APPILOT_API_URL!,
|
|
113
|
+
widgetKey: process.env.APPILOT_WIDGET_KEY!,
|
|
114
|
+
widgetSecret: process.env.APPILOT_WIDGET_SECRET!, // server-side only
|
|
46
115
|
resolveUser: req => {
|
|
47
|
-
const user = req.session?.user;
|
|
48
|
-
if (!user) return null;
|
|
116
|
+
const user = (req as RequestWithSession).session?.user; // your auth, unchanged
|
|
117
|
+
if (!user) return null; // 401 IDENTITY_REQUIRED
|
|
49
118
|
return { externalId: \`${namespace}:\${user.id}\`, displayName: user.name };
|
|
50
119
|
},
|
|
51
120
|
});
|
|
121
|
+
`;
|
|
122
|
+
if (framework === 'express') {
|
|
123
|
+
return `${head}
|
|
124
|
+
import type { Express } from 'express';
|
|
125
|
+
|
|
126
|
+
export function mountWidgetToken(app: Express): void {
|
|
127
|
+
app.post('/api/widget/token', relay);
|
|
128
|
+
}
|
|
129
|
+
`;
|
|
130
|
+
}
|
|
131
|
+
return `${head}
|
|
132
|
+
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
|
52
133
|
|
|
53
|
-
|
|
134
|
+
export function mountWidgetToken(fastify: FastifyInstance): void {
|
|
135
|
+
fastify.post('/api/widget/token', async (request: FastifyRequest, reply: FastifyReply) => {
|
|
136
|
+
// The relay answers the raw response itself, so Fastify has to be told it
|
|
137
|
+
// no longer owns the reply. Without hijack() it reports "Reply was already
|
|
138
|
+
// sent" on every call.
|
|
139
|
+
reply.hijack();
|
|
140
|
+
await relay(request.raw, reply.raw);
|
|
141
|
+
});
|
|
142
|
+
}
|
|
54
143
|
`;
|
|
55
144
|
}
|
|
56
145
|
function bootFile(options) {
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
'export function startAppilot() {',
|
|
63
|
-
' bootAppilotWidget({',
|
|
64
|
-
` widgetScriptUrl: '${options.widgetScriptUrl}',`,
|
|
65
|
-
];
|
|
66
|
-
if (options.widgetKey)
|
|
67
|
-
lines.push(` widgetKey: '${options.widgetKey}',`);
|
|
146
|
+
const idiom = publicKeyIdiom(options.framework);
|
|
147
|
+
const lines = ["import { bootAppilotWidget } from 'appilot';"];
|
|
148
|
+
if (idiom.importLine)
|
|
149
|
+
lines.push(idiom.importLine);
|
|
150
|
+
lines.push('', '// The widget key is publishable: it names the app to Appilot and is meant to', '// sit in the page. The widget SECRET is a server credential and never appears', `// in this file. Leave ${idiom.envName} unset when the page runs on a hostname`, '// you registered as a domain: the tenant then resolves from the hostname and', '// the turn gets the full app context.', `const widgetKey = ${idiom.expr};`, '', '// Call once, after your app knows the user is signed in. The widget', '// requires an identified user; the relay mints that identity.', 'export function startAppilot() {', ' bootAppilotWidget({', ` widgetScriptUrl: '${options.widgetScriptUrl}',`, ' widgetKey,');
|
|
68
151
|
if (options.apiUrl)
|
|
69
152
|
lines.push(` appilotApiUrl: '${options.apiUrl}',`);
|
|
70
153
|
lines.push(" tokenEndpoint: '/api/widget/token',", ' });', '}');
|
|
@@ -96,8 +179,141 @@ export function registerBookingActions(navigate: (path: string) => void) {
|
|
|
96
179
|
return () => handle.unregister(); // unregister on route change
|
|
97
180
|
}
|
|
98
181
|
`;
|
|
182
|
+
/** The raw exchange, for a host `appilot-server` cannot serve. */
|
|
183
|
+
function rawExchangeFiles(namespace, apiUrl) {
|
|
184
|
+
return [
|
|
185
|
+
{
|
|
186
|
+
path: 'appilot/widget-token.sh',
|
|
187
|
+
language: 'bash',
|
|
188
|
+
contents: `# The whole relay, as one call. Run it from your BACKEND, never a browser:
|
|
189
|
+
# the widget secret authenticates the exchange and must never leave the server.
|
|
190
|
+
curl -sS -X POST '${apiUrl}/widget/token' \\
|
|
191
|
+
-H 'Content-Type: application/json' \\
|
|
192
|
+
-H "X-Widget-Key: $APPILOT_WIDGET_KEY" \\
|
|
193
|
+
-H "X-Widget-Secret: $APPILOT_WIDGET_SECRET" \\
|
|
194
|
+
-d '{"externalId":"${namespace}:4711","displayName":"Ada Lovelace"}'
|
|
195
|
+
|
|
196
|
+
# 200 -> {"token":"...","expiresIn":900}. Return that body to the page as-is.
|
|
197
|
+
`,
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
path: 'appilot/widget_token.py',
|
|
201
|
+
language: 'python',
|
|
202
|
+
contents: `"""Appilot identity relay, Django view. Flask and FastAPI differ only in the
|
|
203
|
+
decorator and the response helper.
|
|
204
|
+
|
|
205
|
+
The user is resolved from the session the host already trusts. Never from the
|
|
206
|
+
request body: the body is client-supplied, so reading an id from it would let
|
|
207
|
+
any caller ask for any user's token.
|
|
208
|
+
"""
|
|
209
|
+
|
|
210
|
+
import os
|
|
211
|
+
import json
|
|
212
|
+
import urllib.request
|
|
213
|
+
|
|
214
|
+
APPILOT_API_URL = os.environ["APPILOT_API_URL"]
|
|
215
|
+
WIDGET_KEY = os.environ["APPILOT_WIDGET_KEY"]
|
|
216
|
+
WIDGET_SECRET = os.environ["APPILOT_WIDGET_SECRET"] # server-side only
|
|
217
|
+
NAMESPACE = "${namespace}"
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def widget_token(request):
|
|
221
|
+
from django.http import JsonResponse
|
|
222
|
+
|
|
223
|
+
user = getattr(request, "user", None)
|
|
224
|
+
if user is None or not user.is_authenticated:
|
|
225
|
+
return JsonResponse({"code": "IDENTITY_REQUIRED"}, status=401)
|
|
226
|
+
|
|
227
|
+
payload = json.dumps(
|
|
228
|
+
{
|
|
229
|
+
"externalId": f"{NAMESPACE}:{user.pk}",
|
|
230
|
+
"displayName": user.get_full_name() or user.get_username(),
|
|
231
|
+
}
|
|
232
|
+
).encode()
|
|
233
|
+
|
|
234
|
+
req = urllib.request.Request(
|
|
235
|
+
f"{APPILOT_API_URL}/widget/token",
|
|
236
|
+
data=payload,
|
|
237
|
+
headers={
|
|
238
|
+
"Content-Type": "application/json",
|
|
239
|
+
"X-Widget-Key": WIDGET_KEY,
|
|
240
|
+
"X-Widget-Secret": WIDGET_SECRET,
|
|
241
|
+
},
|
|
242
|
+
method="POST",
|
|
243
|
+
)
|
|
244
|
+
try:
|
|
245
|
+
with urllib.request.urlopen(req, timeout=10) as response:
|
|
246
|
+
body = json.load(response)
|
|
247
|
+
except Exception:
|
|
248
|
+
# Never forward the upstream message: it is not yours to surface.
|
|
249
|
+
return JsonResponse({"code": "WIDGET_TOKEN_EXCHANGE_FAILED"}, status=502)
|
|
250
|
+
|
|
251
|
+
return JsonResponse({"token": body["token"], "expiresIn": body["expiresIn"]})
|
|
252
|
+
`,
|
|
253
|
+
},
|
|
254
|
+
{
|
|
255
|
+
path: 'appilot/widget_token_controller.rb',
|
|
256
|
+
language: 'ruby',
|
|
257
|
+
contents: `# Appilot identity relay, Rails controller.
|
|
258
|
+
#
|
|
259
|
+
# current_user comes from the session Rails already established. Never from
|
|
260
|
+
# params: those are client-supplied, and reading an id from them would let any
|
|
261
|
+
# caller ask for any user's token.
|
|
262
|
+
require "net/http"
|
|
263
|
+
require "json"
|
|
264
|
+
|
|
265
|
+
class WidgetTokenController < ApplicationController
|
|
266
|
+
NAMESPACE = "${namespace}".freeze
|
|
267
|
+
|
|
268
|
+
def create
|
|
269
|
+
return render(json: { code: "IDENTITY_REQUIRED" }, status: :unauthorized) unless current_user
|
|
270
|
+
|
|
271
|
+
uri = URI("#{ENV.fetch('APPILOT_API_URL')}/widget/token")
|
|
272
|
+
request = Net::HTTP::Post.new(uri)
|
|
273
|
+
request["Content-Type"] = "application/json"
|
|
274
|
+
request["X-Widget-Key"] = ENV.fetch("APPILOT_WIDGET_KEY")
|
|
275
|
+
request["X-Widget-Secret"] = ENV.fetch("APPILOT_WIDGET_SECRET") # server-side only
|
|
276
|
+
request.body = {
|
|
277
|
+
externalId: "#{NAMESPACE}:#{current_user.id}",
|
|
278
|
+
displayName: current_user.name
|
|
279
|
+
}.to_json
|
|
280
|
+
|
|
281
|
+
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https", open_timeout: 10, read_timeout: 10) do |http|
|
|
282
|
+
http.request(request)
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
286
|
+
# Never forward the upstream message.
|
|
287
|
+
return render(json: { code: "WIDGET_TOKEN_EXCHANGE_FAILED" }, status: :bad_gateway)
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
body = JSON.parse(response.body)
|
|
291
|
+
render json: { token: body["token"], expiresIn: body["expiresIn"] }
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
`,
|
|
295
|
+
},
|
|
296
|
+
];
|
|
297
|
+
}
|
|
298
|
+
function rawBootFile(options) {
|
|
299
|
+
const keyAttr = options.widgetKey ? `\n data-api-key="${options.widgetKey}"` : '';
|
|
300
|
+
const apiAttr = options.apiUrl ? `\n data-api-url="${options.apiUrl}"` : '';
|
|
301
|
+
return {
|
|
302
|
+
path: 'appilot/widget-snippet.html',
|
|
303
|
+
language: 'html',
|
|
304
|
+
contents: `<!-- Paste into the layout template your signed-in pages render.
|
|
305
|
+
The relay above must answer at /api/widget/token on the same origin. -->
|
|
306
|
+
<script
|
|
307
|
+
src="${options.widgetScriptUrl}"${keyAttr}${apiAttr}
|
|
308
|
+
async
|
|
309
|
+
></script>
|
|
310
|
+
`,
|
|
311
|
+
};
|
|
312
|
+
}
|
|
99
313
|
export function scaffoldIntegration(options) {
|
|
100
314
|
const namespace = options.idNamespace ?? 'app';
|
|
315
|
+
const apiUrl = options.apiUrl ?? 'https://api.appilot.space';
|
|
316
|
+
const idiom = publicKeyIdiom(options.framework);
|
|
101
317
|
const files = [];
|
|
102
318
|
switch (options.framework) {
|
|
103
319
|
case 'next':
|
|
@@ -134,18 +350,21 @@ export const action = ({ request }: { request: Request }) => handler(request);
|
|
|
134
350
|
language: 'typescript',
|
|
135
351
|
contents: `import { createWidgetTokenHandler } from 'appilot-server';
|
|
136
352
|
import { env } from '$env/dynamic/private';
|
|
353
|
+
import type { RequestHandler } from './$types';
|
|
137
354
|
|
|
138
355
|
const handler = createWidgetTokenHandler({
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
356
|
+
// $env/dynamic/private is Record<string, string | undefined>, so each value is
|
|
357
|
+
// asserted here rather than handed to the relay as possibly-undefined.
|
|
358
|
+
apiUrl: env.APPILOT_API_URL!,
|
|
359
|
+
widgetKey: env.APPILOT_WIDGET_KEY!,
|
|
360
|
+
widgetSecret: env.APPILOT_WIDGET_SECRET!,
|
|
142
361
|
resolveUser: async request => {
|
|
143
362
|
const user = await getUserFromCookies(request.headers.get('cookie'));
|
|
144
363
|
return user ? { externalId: \`${namespace}:\${user.id}\`, displayName: user.name } : null;
|
|
145
364
|
},
|
|
146
365
|
});
|
|
147
366
|
|
|
148
|
-
export const POST = ({ request }) => handler(request);
|
|
367
|
+
export const POST: RequestHandler = ({ request }) => handler(request);
|
|
149
368
|
`,
|
|
150
369
|
});
|
|
151
370
|
break;
|
|
@@ -178,68 +397,135 @@ export const widgetToken = new Hono().post('/api/widget/token', c => handler(c.r
|
|
|
178
397
|
contents: relayNodeHandler(namespace, options.framework),
|
|
179
398
|
});
|
|
180
399
|
break;
|
|
400
|
+
case 'other':
|
|
401
|
+
files.push(...rawExchangeFiles(namespace, apiUrl));
|
|
402
|
+
break;
|
|
403
|
+
}
|
|
404
|
+
if (options.framework === 'other') {
|
|
405
|
+
files.push(rawBootFile(options));
|
|
406
|
+
}
|
|
407
|
+
else {
|
|
408
|
+
files.push({ path: 'src/appilot/boot.ts', language: 'typescript', contents: bootFile(options) });
|
|
409
|
+
files.push({
|
|
410
|
+
path: 'src/appilot/actions.ts',
|
|
411
|
+
language: 'typescript',
|
|
412
|
+
contents: CLIENT_ACTION_EXAMPLE,
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
const serverEnv = [
|
|
416
|
+
`APPILOT_API_URL=${apiUrl}`,
|
|
417
|
+
`APPILOT_WIDGET_KEY=${options.widgetKey ?? 'wk_test_...'}`,
|
|
418
|
+
'APPILOT_WIDGET_SECRET=wsk_secret_... # server-side only, never in a client bundle',
|
|
419
|
+
];
|
|
420
|
+
if (options.framework !== 'other') {
|
|
421
|
+
serverEnv.push(`${idiom.envName}=${options.widgetKey ?? 'wk_test_...'} # the same publishable key, this time for the page`);
|
|
422
|
+
}
|
|
423
|
+
const notes = [
|
|
424
|
+
'The widget secret must never appear in a client bundle or a public env var. In Next.js that means no NEXT_PUBLIC_ prefix; in Vite, keep it out of VITE_.',
|
|
425
|
+
'resolveUser is the security boundary: derive the user from a credential you trust, never from the request body.',
|
|
426
|
+
...localDevelopmentNotes(idiom),
|
|
427
|
+
'Call verify_integration once this is wired to confirm the relay answers and the widget actually boots.',
|
|
428
|
+
];
|
|
429
|
+
if (options.framework === 'other') {
|
|
430
|
+
notes.unshift('appilot-server is a Node package, so this host implements the exchange itself. It is one authenticated POST to {apiUrl}/widget/token carrying X-Widget-Key and X-Widget-Secret, and the response body goes back to the page unchanged.');
|
|
181
431
|
}
|
|
182
|
-
files.push({ path: 'src/appilot/boot.ts', language: 'typescript', contents: bootFile(options) });
|
|
183
|
-
files.push({
|
|
184
|
-
path: 'src/appilot/actions.ts',
|
|
185
|
-
language: 'typescript',
|
|
186
|
-
contents: CLIENT_ACTION_EXAMPLE,
|
|
187
|
-
});
|
|
188
432
|
return {
|
|
189
433
|
framework: options.framework,
|
|
190
434
|
files,
|
|
191
|
-
install:
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
].join('\n'),
|
|
197
|
-
notes: [
|
|
198
|
-
'The widget secret must never appear in a client bundle or a public env var. In Next.js that means no NEXT_PUBLIC_ prefix; in Vite, keep it out of VITE_.',
|
|
199
|
-
'resolveUser is the security boundary: derive the user from a credential you trust, never from the request body.',
|
|
200
|
-
'Call verify_integration once this is wired to confirm the relay answers and the widget actually boots.',
|
|
201
|
-
],
|
|
435
|
+
install: options.framework === 'other'
|
|
436
|
+
? 'No package to install. The relay is one HTTPS call and the widget loads from the script tag above.'
|
|
437
|
+
: 'npm install appilot appilot-server',
|
|
438
|
+
env: serverEnv.join('\n'),
|
|
439
|
+
notes,
|
|
202
440
|
};
|
|
203
441
|
}
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
442
|
+
/** `open-booking` -> `open_booking`, and `Open booking!` -> `open_booking`. */
|
|
443
|
+
function toolNameFrom(slug) {
|
|
444
|
+
return IDENTIFIER_KINDS.tool_name.normalize(slug) || 'do_the_thing';
|
|
445
|
+
}
|
|
446
|
+
/** `open_booking` -> `open-booking`, the shape control and plan ids take. */
|
|
447
|
+
function semanticSlugFrom(slug) {
|
|
448
|
+
return IDENTIFIER_KINDS.semantic_id.normalize(slug) || 'the-thing';
|
|
449
|
+
}
|
|
450
|
+
function planSections(shape, capability, ids, viewPath) {
|
|
451
|
+
if (shape === 'read')
|
|
452
|
+
return null;
|
|
453
|
+
if (shape === 'navigate') {
|
|
454
|
+
return [
|
|
455
|
+
{
|
|
456
|
+
view_path: viewPath,
|
|
457
|
+
steps: [`Open [${capability}]({{click:${ids.open}}}).`],
|
|
458
|
+
},
|
|
459
|
+
];
|
|
460
|
+
}
|
|
461
|
+
return [
|
|
462
|
+
{
|
|
463
|
+
view_path: viewPath,
|
|
464
|
+
steps: [
|
|
465
|
+
`Open the form with [${capability}]({{click:${ids.open}}}).`,
|
|
466
|
+
`Fill in [the ${capability.toLowerCase()} form]({{form:${ids.form}}}).`,
|
|
467
|
+
`Submit it with [Save]({{click:${ids.submit}}}).`,
|
|
468
|
+
],
|
|
223
469
|
},
|
|
224
|
-
|
|
225
|
-
|
|
470
|
+
];
|
|
471
|
+
}
|
|
472
|
+
export function scaffoldAgentFirst(options) {
|
|
473
|
+
const { capability, appId } = options;
|
|
474
|
+
const shape = options.shape ?? 'create';
|
|
475
|
+
const viewPath = options.viewPath ?? '/';
|
|
476
|
+
// Both identifier kinds, from the one slug. `tool_name` is snake_case because
|
|
477
|
+
// it reaches the model as a callable name; control, form and plan ids are
|
|
478
|
+
// semantic_ids. Normalising here is what stopped the scaffold from failing
|
|
479
|
+
// the server's own identifier contract on its first call.
|
|
480
|
+
const toolName = toolNameFrom(options.slug);
|
|
481
|
+
const slug = semanticSlugFrom(options.slug);
|
|
482
|
+
// `submit-x` rather than `btn-x-submit`: the contract's own identifier lint
|
|
483
|
+
// treats four segments over twenty-four characters as a slugified UI label,
|
|
484
|
+
// and a two-word capability made the scaffold trip that heuristic on an
|
|
485
|
+
// identifier it had just generated itself.
|
|
486
|
+
const ids = {
|
|
487
|
+
open: `btn-${slug}`,
|
|
488
|
+
form: `form-${slug}`,
|
|
489
|
+
submit: `submit-${slug}`,
|
|
226
490
|
};
|
|
227
|
-
const
|
|
491
|
+
const method = options.endpoint?.method?.toUpperCase() ?? (shape === 'read' ? 'GET' : 'POST');
|
|
492
|
+
const pathTemplate = options.endpoint?.path ?? `/api/${slug}`;
|
|
493
|
+
const trigger = `Use when the user wants to ${capability.toLowerCase()}.`;
|
|
494
|
+
// A client action and an HTTP-proxy tool are two answers to the same
|
|
495
|
+
// question, and emitting both taught the reader that a capability needs one
|
|
496
|
+
// of each. It needs whichever one matches where the work happens.
|
|
497
|
+
const tool = options.clientSide
|
|
498
|
+
? null
|
|
499
|
+
: {
|
|
500
|
+
app_id: appId,
|
|
501
|
+
tool_name: toolName,
|
|
502
|
+
title: capability,
|
|
503
|
+
description: trigger,
|
|
504
|
+
parameters: {
|
|
505
|
+
type: 'object',
|
|
506
|
+
properties: {},
|
|
507
|
+
required: [],
|
|
508
|
+
},
|
|
509
|
+
runtime_spec: {
|
|
510
|
+
kind: 'http_proxy',
|
|
511
|
+
method,
|
|
512
|
+
path_template: pathTemplate,
|
|
513
|
+
body_template: {},
|
|
514
|
+
},
|
|
515
|
+
title_i18n: {},
|
|
516
|
+
description_i18n: {},
|
|
517
|
+
};
|
|
518
|
+
const sections = planSections(shape, capability, ids, viewPath);
|
|
519
|
+
const actionPlan = sections && {
|
|
228
520
|
app_id: appId,
|
|
229
|
-
semantic_id: `plan-${slug}
|
|
521
|
+
semantic_id: IDENTIFIER_KINDS.plan_id.normalize(`plan-${slug}`),
|
|
230
522
|
name: capability,
|
|
231
|
-
description:
|
|
232
|
-
sections
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
{ text: `Fill it in: [the ${slug} form]({{form:form-${slug}}})` },
|
|
238
|
-
{ text: `Submit: [Save]({{click:btn-${slug}-submit}})` },
|
|
239
|
-
],
|
|
240
|
-
},
|
|
241
|
-
],
|
|
242
|
-
form_values: { [`form-${slug}`]: {} },
|
|
523
|
+
description: trigger,
|
|
524
|
+
sections,
|
|
525
|
+
// Keyed by form id, and each entry carries a `fields` array of
|
|
526
|
+
// { control_id, value }. The empty array is the authored default: the
|
|
527
|
+
// user supplies the values at runtime.
|
|
528
|
+
form_values: shape === 'create' ? { [ids.form]: { fields: [] } } : {},
|
|
243
529
|
step_narratives_i18n: {},
|
|
244
530
|
is_active: false,
|
|
245
531
|
};
|
|
@@ -261,6 +547,7 @@ export function scaffoldAgentFirst(options) {
|
|
|
261
547
|
};
|
|
262
548
|
const files = [];
|
|
263
549
|
if (options.clientSide) {
|
|
550
|
+
const registerName = toolName.replace(/(^|_)([a-z])/g, (_m, _s, c) => c.toUpperCase());
|
|
264
551
|
files.push({
|
|
265
552
|
path: `src/appilot/${slug}.ts`,
|
|
266
553
|
language: 'typescript',
|
|
@@ -270,11 +557,11 @@ export function scaffoldAgentFirst(options) {
|
|
|
270
557
|
//
|
|
271
558
|
// This runs in the page, in the user's own session, so it inherits their
|
|
272
559
|
// permissions and needs no credential of its own. Anything the backend must
|
|
273
|
-
// authorize belongs in
|
|
274
|
-
export function register${
|
|
560
|
+
// authorize belongs in an HTTP-proxy tool instead.
|
|
561
|
+
export function register${registerName}() {
|
|
275
562
|
const handle = registerTool({
|
|
276
|
-
name: '${
|
|
277
|
-
description: '
|
|
563
|
+
name: '${toolName}',
|
|
564
|
+
description: '${trigger}',
|
|
278
565
|
inputSchema: { type: 'object', properties: {}, required: [] },
|
|
279
566
|
// Omit readOnlyHint, or set it false, when this changes something: the
|
|
280
567
|
// agent confirms with the user before calling a mutating action.
|
|
@@ -288,24 +575,64 @@ export function register${slug.replace(/(^|[-_])([a-z])/g, (_m, _s, c) => c.toUp
|
|
|
288
575
|
`,
|
|
289
576
|
});
|
|
290
577
|
}
|
|
578
|
+
const order = [];
|
|
579
|
+
if (sections) {
|
|
580
|
+
order.push(`1. Create the controls the plan names (${Object.values(ids).slice(0, shape === 'create' ? 3 : 1).join(', ')}), with stable locators. inspect_page gives you candidates.`);
|
|
581
|
+
if (shape === 'create') {
|
|
582
|
+
order.push(`2. Create the form ${ids.form}, naming those controls as entry, submit and required fields.`);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
order.push(options.clientSide
|
|
586
|
+
? 'Register the client action in the page. It runs in the user\'s session, so it needs no credential.'
|
|
587
|
+
: 'Create the tool. path_template is a path on the host origin, not an absolute URL, and the executor rejects an absolute one. If it calls an authenticated backend, set auth_secret in the same call, because a tool with no stored credential runs in the page and cannot be used as session preflight.');
|
|
588
|
+
if (sections) {
|
|
589
|
+
order.push('Create the action plan. Run validate_action_plan on the sections first.');
|
|
590
|
+
}
|
|
591
|
+
order.push('Create the knowledge article, and keep the procedure out of it.');
|
|
592
|
+
order.push('Activate the plan and the article once validate_config is clean.');
|
|
593
|
+
const notes = [
|
|
594
|
+
'The test for agent-first is whether a user could complete this capability end to end through the assistant alone. If not, it has a screen and nothing else.',
|
|
595
|
+
'Both the plan description and the tool description are how the agent finds them. Write the trigger, in the user\'s words, and localize it.',
|
|
596
|
+
'is_active is false on the plan and the article on purpose. Turn them on after validate_config passes, not before.',
|
|
597
|
+
];
|
|
598
|
+
if (shape === 'read') {
|
|
599
|
+
notes.push('No action plan is emitted for a read capability. A plan whose only step opens a screen does nothing, and the health contract reports it. The tool answers the question and the knowledge article explains what the answer means.');
|
|
600
|
+
}
|
|
601
|
+
if (options.clientSide) {
|
|
602
|
+
notes.push('No HTTP-proxy tool is emitted, because the operation runs in the page. Add one only if a second, server-authorized path to the same capability is genuinely needed.');
|
|
603
|
+
}
|
|
604
|
+
notes.push(`The plan's steps run on view_path "${viewPath}". Pass viewPath when the screen is somewhere else; the value must match a registered View.`);
|
|
605
|
+
return { capability, shape, tool, actionPlan, knowledge, files, order, notes };
|
|
606
|
+
}
|
|
607
|
+
/**
|
|
608
|
+
* The script tag and the boot call for an app that is already provisioned.
|
|
609
|
+
*
|
|
610
|
+
* `create_app` returns these, and returning them was the only way to get them,
|
|
611
|
+
* so a developer who had lost the snippet re-ran a provisioning write to read
|
|
612
|
+
* one line of HTML. They are composed from the connection and the key, so this
|
|
613
|
+
* is pure: it calls nothing and needs no scope. The shape matches what the
|
|
614
|
+
* backend's own `buildIntegration` returns, deliberately, so a curl caller and
|
|
615
|
+
* an agent read the same thing.
|
|
616
|
+
*/
|
|
617
|
+
export function integrationSnippet(options) {
|
|
618
|
+
const idiom = publicKeyIdiom(options.framework ?? 'other');
|
|
619
|
+
const keyAttr = options.widgetKey ? `\n data-api-key="${options.widgetKey}"` : '';
|
|
620
|
+
const apiAttr = options.apiUrl ? `\n data-api-url="${options.apiUrl}"` : '';
|
|
621
|
+
const bootLines = ["import { bootAppilotWidget } from 'appilot';", '', 'bootAppilotWidget({'];
|
|
622
|
+
bootLines.push(` widgetScriptUrl: '${options.widgetScriptUrl}',`);
|
|
623
|
+
bootLines.push(` widgetKey: ${idiom.expr},`);
|
|
624
|
+
if (options.apiUrl)
|
|
625
|
+
bootLines.push(` appilotApiUrl: '${options.apiUrl}',`);
|
|
626
|
+
bootLines.push(" tokenEndpoint: '/api/widget/token',", '});');
|
|
291
627
|
return {
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
order: [
|
|
298
|
-
'1. Create the controls the plan names, with stable locators. inspect_page gives you candidates.',
|
|
299
|
-
'2. Create the form, naming those controls as entry, submit and required fields.',
|
|
300
|
-
'3. Create the tool. path_template is a path on the host origin, not an absolute URL, and the executor rejects an absolute one. If it calls an authenticated backend, set auth_secret in the same call, because a tool with no stored credential runs in the page and cannot be used as session preflight.',
|
|
301
|
-
'4. Create the action plan. Run validate_action_plan first; a plan that only opens an element does nothing.',
|
|
302
|
-
'5. Create the knowledge article, and keep the procedure out of it.',
|
|
303
|
-
'6. Activate the plan and the article once validate_config is clean.',
|
|
304
|
-
],
|
|
628
|
+
apiUrl: options.apiUrl ?? null,
|
|
629
|
+
scriptTag: `<script\n src="${options.widgetScriptUrl}"${keyAttr}${apiAttr}\n async\n></script>`,
|
|
630
|
+
bootSnippet: bootLines.join('\n'),
|
|
631
|
+
tokenEndpointHint: 'Serve /api/widget/token from your backend with createWidgetTokenHandler from appilot-server. The widget requires an identified user, and the widget secret must never reach a browser.',
|
|
632
|
+
publicEnvName: idiom.envName,
|
|
305
633
|
notes: [
|
|
306
|
-
'The
|
|
307
|
-
|
|
308
|
-
'is_active is false on the plan and the article on purpose. Turn them on after validate_config passes, not before.',
|
|
634
|
+
'The script tag is the no-build form and carries the key as an attribute. The boot call is the bundled form and reads it from the public variable, which is what survives a key rotation.',
|
|
635
|
+
...localDevelopmentNotes(idiom),
|
|
309
636
|
],
|
|
310
637
|
};
|
|
311
638
|
}
|