appilot-mcp 0.0.1 → 0.1.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.
Files changed (44) hide show
  1. package/.claude-plugin/plugin.json +43 -0
  2. package/.codex-plugin/plugin.json +37 -0
  3. package/.mcp.json +19 -0
  4. package/README.md +268 -6
  5. package/dist/appilot-configurator.mcpb +0 -0
  6. package/dist/client.d.ts +140 -0
  7. package/dist/client.js +252 -0
  8. package/dist/config.d.ts +64 -0
  9. package/dist/config.js +78 -0
  10. package/dist/contract/bundleSnapshot.d.ts +12 -0
  11. package/dist/contract/bundleSnapshot.js +65 -0
  12. package/dist/contract/healthContract.d.ts +19 -0
  13. package/dist/contract/healthContract.js +297 -0
  14. package/dist/contract/index.d.ts +3 -0
  15. package/dist/contract/index.js +3 -0
  16. package/dist/contract/types.d.ts +86 -0
  17. package/dist/contract/types.js +9 -0
  18. package/dist/index.bundle.js +70059 -0
  19. package/dist/index.d.ts +20 -0
  20. package/dist/index.js +50 -0
  21. package/dist/manifest.d.ts +93 -0
  22. package/dist/manifest.js +147 -0
  23. package/dist/redaction.d.ts +30 -0
  24. package/dist/redaction.js +33 -0
  25. package/dist/remote/consent.d.ts +29 -0
  26. package/dist/remote/consent.js +99 -0
  27. package/dist/remote/httpServer.d.ts +20 -0
  28. package/dist/remote/httpServer.js +125 -0
  29. package/dist/remote/oauth.d.ts +74 -0
  30. package/dist/remote/oauth.js +288 -0
  31. package/dist/remote/tokens.d.ts +28 -0
  32. package/dist/remote/tokens.js +50 -0
  33. package/dist/scaffold.d.ts +37 -0
  34. package/dist/scaffold.js +203 -0
  35. package/dist/server.d.ts +15 -0
  36. package/dist/server.js +358 -0
  37. package/dist/soak.d.ts +32 -0
  38. package/dist/soak.js +51 -0
  39. package/dist/verify.d.ts +40 -0
  40. package/dist/verify.js +149 -0
  41. package/mcpb/manifest.json +67 -0
  42. package/package.json +70 -16
  43. package/skills/app-configurator/SKILL.md +198 -0
  44. package/skills/app-configurator/agents/openai.yaml +13 -0
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Tool surface of the Appilot MCP server, built as a factory.
3
+ *
4
+ * One connection profile in, one McpServer out. The factory shape is what lets
5
+ * the same tools serve two transports: a local stdio process that carries the
6
+ * operator's own credentials in its environment, and the remote HTTP service,
7
+ * where every request arrives with the caller's own service token and therefore
8
+ * needs its own server instance. Nothing here knows which transport it is under.
9
+ *
10
+ * Contract: docs/content-model/config-health-contract.md.
11
+ * Server: docs/architecture/appilot-mcp.md.
12
+ */
13
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
14
+ import type { AppilotConnection } from './config.js';
15
+ export declare function createAppilotServer(conn: AppilotConnection): McpServer;
package/dist/server.js ADDED
@@ -0,0 +1,358 @@
1
+ /**
2
+ * Tool surface of the Appilot MCP server, built as a factory.
3
+ *
4
+ * One connection profile in, one McpServer out. The factory shape is what lets
5
+ * the same tools serve two transports: a local stdio process that carries the
6
+ * operator's own credentials in its environment, and the remote HTTP service,
7
+ * where every request arrives with the caller's own service token and therefore
8
+ * needs its own server instance. Nothing here knows which transport it is under.
9
+ *
10
+ * Contract: docs/content-model/config-health-contract.md.
11
+ * Server: docs/architecture/appilot-mcp.md.
12
+ */
13
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
14
+ import { z } from 'zod';
15
+ import { AppilotClient } from './client.js';
16
+ import { runHealthContract } from './contract/healthContract.js';
17
+ import { soakSelectors } from './soak.js';
18
+ import { applyManifest, parseManifest, planManifest } from './manifest.js';
19
+ import { scaffoldIntegration } from './scaffold.js';
20
+ import { verifyIntegration } from './verify.js';
21
+ import { redactForTransport } from './redaction.js';
22
+ /**
23
+ * Where the widget bundle is served from when the caller does not say. Cloud
24
+ * default; an on-premise instance serves its own copy and passes the URL.
25
+ */
26
+ const DEFAULT_WIDGET_SCRIPT_URL = 'https://cdn.appilot.space/widget/v1/appilot.esm.js';
27
+ export function createAppilotServer(conn) {
28
+ const client = new AppilotClient(conn);
29
+ function text(value) {
30
+ const body = typeof value === 'string' ? value : JSON.stringify(value, null, 2);
31
+ return { content: [{ type: 'text', text: body }] };
32
+ }
33
+ function errorText(err) {
34
+ const message = err instanceof Error ? err.message : String(err);
35
+ return { content: [{ type: 'text', text: `Error: ${message}` }], isError: true };
36
+ }
37
+ function resolveAppId(appId) {
38
+ const id = appId ?? conn.defaultAppId;
39
+ if (id == null || !Number.isFinite(id)) {
40
+ throw new Error('appId is required (pass it, or set APPILOT_APP_ID).');
41
+ }
42
+ return id;
43
+ }
44
+ function formatReport(report) {
45
+ if (report.findings.length === 0)
46
+ return 'No findings. Configuration passes the health contract.';
47
+ const lines = [
48
+ `${report.findings.length} finding(s), critical: ${report.counts.critical}, high: ${report.counts.high}, medium: ${report.counts.medium}, low: ${report.counts.low}`,
49
+ '',
50
+ ];
51
+ for (const f of report.findings) {
52
+ lines.push(`[${f.severity.toUpperCase()}] ${f.category} · ${f.entity}`);
53
+ lines.push(` ${f.message}`);
54
+ if (f.recommendation)
55
+ lines.push(` → ${f.recommendation}`);
56
+ lines.push('');
57
+ }
58
+ return lines.join('\n');
59
+ }
60
+ const server = new McpServer({ name: 'appilot-mcp', version: '0.1.0' });
61
+ server.registerTool('capabilities', {
62
+ title: 'Discover instance capabilities',
63
+ description: 'Probe the connected Appilot instance for its version, applied migration level, payload-schema versions, and configurable entities. Call this first so you configure against what THIS instance supports (on-premise instances can trail cloud).',
64
+ inputSchema: {},
65
+ }, async () => {
66
+ try {
67
+ const [caps, health] = await Promise.all([
68
+ client.getCapabilities(),
69
+ client.getHealth().catch(() => null),
70
+ ]);
71
+ return text({ baseUrl: conn.baseUrl, health, capabilities: caps });
72
+ }
73
+ catch (err) {
74
+ return errorText(err);
75
+ }
76
+ });
77
+ server.registerTool('read_config', {
78
+ title: 'Read app configuration',
79
+ description: 'Read the content-model configuration (views, controls, forms, action plans, knowledge) for an app and return a normalized snapshot. Use before validating or editing.',
80
+ inputSchema: { appId: z.number().int().optional(), locales: z.array(z.string()).optional() },
81
+ }, async ({ appId, locales }) => {
82
+ try {
83
+ const snapshot = await client.buildSnapshot(resolveAppId(appId), locales);
84
+ return text(snapshot);
85
+ }
86
+ catch (err) {
87
+ return errorText(err);
88
+ }
89
+ });
90
+ server.registerTool('validate_config', {
91
+ title: 'Validate against the health contract',
92
+ description: 'Audit an app\'s configuration against the Appilot config health contract: plan actionability (a create flow must enter a value and submit, not just open an element), marker resolution, selector stability (no auto-generated ids), i18n coverage, KB scope/hygiene, and identifier hygiene. Runs locally; also echoes the server-side plan trust boundary. Returns severity-ranked findings.',
93
+ inputSchema: { appId: z.number().int().optional(), locales: z.array(z.string()).optional() },
94
+ }, async ({ appId, locales }) => {
95
+ try {
96
+ const id = resolveAppId(appId);
97
+ const snapshot = await client.buildSnapshot(id, locales);
98
+ const report = runHealthContract(snapshot);
99
+ // Server-side echo per plan (best-effort; needs a config:write token).
100
+ for (const plan of snapshot.actionPlans) {
101
+ try {
102
+ const echo = await client.validatePlan(id, plan.sections, plan.form_values);
103
+ for (const e of echo.errors) {
104
+ report.findings.push({ severity: 'high', category: 'markers', entity: `action_plan:${plan.semantic_id}`, message: `[server] ${e}` });
105
+ }
106
+ }
107
+ catch { /* read-only token or older instance: local gate already ran */ }
108
+ }
109
+ return text(formatReport(report) + '\n\n' + JSON.stringify(report.counts));
110
+ }
111
+ catch (err) {
112
+ return errorText(err);
113
+ }
114
+ });
115
+ server.registerTool('update_action_plan', {
116
+ title: 'Update an action plan',
117
+ description: 'Apply a patch to a stored action plan (sections, form_values, name/description/step narratives, is_active). The server re-validates the marker trust boundary and rejects an invalid patch. Requires a config:write service token.',
118
+ inputSchema: { id: z.string(), patch: z.record(z.any()) },
119
+ }, async ({ id, patch }) => {
120
+ try {
121
+ return text(await client.updateActionPlan(id, patch));
122
+ }
123
+ catch (err) {
124
+ return errorText(err);
125
+ }
126
+ });
127
+ server.registerTool('update_control', {
128
+ title: 'Update a control',
129
+ description: 'Apply a patch to a control (e.g. replace an unstable locator with a stable selector list). Requires a config:write service token.',
130
+ inputSchema: { id: z.string(), patch: z.record(z.any()) },
131
+ }, async ({ id, patch }) => {
132
+ try {
133
+ return text(await client.updateControl(id, patch));
134
+ }
135
+ catch (err) {
136
+ return errorText(err);
137
+ }
138
+ });
139
+ server.registerTool('update_knowledge', {
140
+ title: 'Update a knowledge article',
141
+ description: 'Apply a patch to a knowledge_content row (e.g. fix scope, add a translation, remove chatbot filler). Requires a config:write service token.',
142
+ inputSchema: { id: z.string(), patch: z.record(z.any()) },
143
+ }, async ({ id, patch }) => {
144
+ try {
145
+ return text(await client.updateKnowledge(id, patch));
146
+ }
147
+ catch (err) {
148
+ return errorText(err);
149
+ }
150
+ });
151
+ server.registerTool('export_config', {
152
+ title: 'Export the app configuration as a portable bundle',
153
+ description: 'Export the whole content-model configuration (views, controls, forms, tools, zones, action plans, knowledge, session templates) as a canonical, versioned ConfigBundle: the round-trip artifact for backup, clone, and restore. Secrets never travel; the bundle carries secretRefs[] references only, so the file is safe to save or share. Distinct from read_config, which is the reasoning view.',
154
+ inputSchema: { appId: z.number().int().optional() },
155
+ }, async ({ appId }) => {
156
+ try {
157
+ const bundle = await client.exportConfig(resolveAppId(appId));
158
+ const header = `contentHash ${bundle.contentHash} · formatVersion ${bundle.formatVersion} · secretRefs ${bundle.secretRefs.length}`;
159
+ return text(`${header}\n${JSON.stringify(bundle, null, 2)}`);
160
+ }
161
+ catch (err) {
162
+ return errorText(err);
163
+ }
164
+ });
165
+ server.registerTool('import_config', {
166
+ title: 'Import a ConfigBundle (merge or replace)',
167
+ description: 'Import a ConfigBundle into an app. ALWAYS dry-run first (dryRun defaults to true): the dry-run returns the per-entity diff, health findings, and the currentHash confirm token. mode=merge upserts by semantic key and never deletes; mode=replace makes the app match the bundle exactly, INCLUDING deletions, and a replace commit requires expectedCurrentHash from the dry-run (409 CONFIG_STALE_WRITE if the config changed in between). Every commit first persists a pre_restore revision, so a wrong import is undone by restoring it. Requires a config:write service token.',
168
+ inputSchema: {
169
+ appId: z.number().int().optional(),
170
+ bundle: z.union([z.record(z.any()), z.string()]),
171
+ mode: z.enum(['merge', 'replace']),
172
+ dryRun: z.boolean().optional(),
173
+ expectedCurrentHash: z.string().optional(),
174
+ allowUnhealthy: z.boolean().optional(),
175
+ },
176
+ }, async ({ appId, bundle, mode, dryRun, expectedCurrentHash, allowUnhealthy }) => {
177
+ try {
178
+ const id = resolveAppId(appId);
179
+ // Capability negotiation is client-side UX; the server re-validates
180
+ // regardless. An instance without configPortability predates the
181
+ // import surface entirely.
182
+ const caps = await client.getCapabilities();
183
+ if (!caps.configPortability) {
184
+ return errorText(new Error('The connected instance does not support config import (no configPortability capability). Upgrade the instance or fall back to per-entity update_* tools.'));
185
+ }
186
+ const parsedBundle = typeof bundle === 'string' ? JSON.parse(bundle) : bundle;
187
+ const bundleVersion = String(parsedBundle.formatVersion ?? '');
188
+ const supported = caps.configPortability.bundleFormatVersion;
189
+ const newer = (a, b) => {
190
+ const [am, an] = a.split('.').map(Number);
191
+ const [bm, bn] = b.split('.').map(Number);
192
+ return am > bm || (am === bm && an > bn);
193
+ };
194
+ if (bundleVersion && newer(bundleVersion, supported)) {
195
+ return errorText(new Error(`Bundle format ${bundleVersion} is newer than the instance supports (${supported}). Export from a matching instance or upgrade the target.`));
196
+ }
197
+ const result = await client.importConfig(id, {
198
+ mode,
199
+ dryRun: dryRun !== false,
200
+ bundle: parsedBundle,
201
+ expectedCurrentHash,
202
+ allowUnhealthy,
203
+ });
204
+ return text(result);
205
+ }
206
+ catch (err) {
207
+ return errorText(err);
208
+ }
209
+ });
210
+ // -- provisioning ------------------------------------------------------
211
+ // The path from "I have an account" to "the widget answers on my page".
212
+ // Everything here needs a service token holding `provision:write`.
213
+ server.registerTool('whoami', {
214
+ title: 'Check which org and scopes this credential reaches',
215
+ description: 'Self-check the connected credential: which organization it reaches, which app it is narrowed to, and which scopes it holds. Call this before any write so a mistyped or revoked token fails here rather than as an opaque 401 mid-task. Returns no secret.',
216
+ inputSchema: {},
217
+ }, async () => {
218
+ try {
219
+ return text({ baseUrl: conn.baseUrl, ...(await client.whoami()) });
220
+ }
221
+ catch (err) {
222
+ return errorText(err);
223
+ }
224
+ });
225
+ server.registerTool('create_app', {
226
+ title: 'Create an app, its domains, and a widget key',
227
+ description: 'Provision an Appilot app in one call: the app, the domains it runs on, and optionally a widget key, plus the exact script tag and boot snippet to paste into the host application. Idempotent: re-running converges on the existing app rather than creating a second one. Pass dryRun to preview. The widget key and its secret are returned EXACTLY ONCE, at creation; store the secret in the host backend only. Requires a provision:write service token.',
228
+ inputSchema: {
229
+ name: z.string().min(1),
230
+ description: z.string().optional(),
231
+ domains: z.array(z.string()).optional(),
232
+ widgetKeyName: z.string().optional(),
233
+ isTestKey: z.boolean().optional(),
234
+ dryRun: z.boolean().optional(),
235
+ },
236
+ }, async ({ name, description, domains, widgetKeyName, isTestKey, dryRun }) => {
237
+ try {
238
+ const result = await client.provisionApp({
239
+ app: { name, description },
240
+ domains: (domains ?? []).map(domain => ({ domain })),
241
+ widgetKey: widgetKeyName || isTestKey !== undefined
242
+ ? { name: widgetKeyName, isTest: isTestKey }
243
+ : undefined,
244
+ dryRun: dryRun === true,
245
+ });
246
+ return text(redactForTransport(result, conn.transport));
247
+ }
248
+ catch (err) {
249
+ return errorText(err);
250
+ }
251
+ });
252
+ server.registerTool('plan_manifest', {
253
+ title: 'Preview an app manifest (writes nothing)',
254
+ description: 'Diff an appilot.app-manifest against the live instance and return what would change: provisioning actions per app/domain/key, the config-bundle import diff, and the health findings over the resulting state. Writes nothing. Returns a planToken that apply_manifest requires, so an apply always follows a preview of the exact same manifest. Keep the manifest in the repository under version control.',
255
+ inputSchema: { manifest: z.union([z.record(z.any()), z.string()]) },
256
+ }, async ({ manifest }) => {
257
+ try {
258
+ const parsed = parseManifest(manifest);
259
+ const plan = await planManifest(client, parsed, id => {
260
+ const resolved = id ?? conn.defaultAppId;
261
+ return resolved != null && Number.isFinite(resolved) ? resolved : null;
262
+ });
263
+ return text(plan);
264
+ }
265
+ catch (err) {
266
+ return errorText(err);
267
+ }
268
+ });
269
+ server.registerTool('apply_manifest', {
270
+ title: 'Apply a previously planned app manifest',
271
+ description: 'Provision and configure an app from an appilot.app-manifest. Requires the planToken returned by plan_manifest for the SAME manifest: a mismatch means the manifest changed after it was previewed, and the apply is refused. Pass expectedCurrentHash from the plan so a concurrent config edit is a 409 rather than a silent overwrite. mode=replace makes the config match the bundle exactly, including deletions. Requires provision:write, and config:write when the manifest carries a config bundle.',
272
+ inputSchema: {
273
+ manifest: z.union([z.record(z.any()), z.string()]),
274
+ planToken: z.string(),
275
+ mode: z.enum(['merge', 'replace']).optional(),
276
+ expectedCurrentHash: z.string().optional(),
277
+ allowUnhealthy: z.boolean().optional(),
278
+ },
279
+ }, async ({ manifest, planToken, mode, expectedCurrentHash, allowUnhealthy }) => {
280
+ try {
281
+ const parsed = parseManifest(manifest);
282
+ const result = await applyManifest(client, parsed, {
283
+ planToken,
284
+ mode,
285
+ expectedCurrentHash,
286
+ allowUnhealthy,
287
+ });
288
+ return text({ ...result, provisioning: redactForTransport(result.provisioning, conn.transport) });
289
+ }
290
+ catch (err) {
291
+ return errorText(err);
292
+ }
293
+ });
294
+ server.registerTool('scaffold_integration', {
295
+ title: 'Generate the host application integration code',
296
+ description: 'Return the source a host application needs: the identity relay for its backend (the one security-critical piece, built on appilot-server), the widget boot call, and a client-action example. Returns file CONTENTS for you to write into the repository; this server never touches the filesystem. Pick the framework that matches the host.',
297
+ inputSchema: {
298
+ framework: z.enum(['next', 'express', 'fastify', 'hono', 'remix', 'sveltekit']),
299
+ widgetKey: z.string().optional(),
300
+ widgetScriptUrl: z.string().optional(),
301
+ idNamespace: z.string().optional(),
302
+ },
303
+ }, async ({ framework, widgetKey, widgetScriptUrl, idNamespace }) => {
304
+ try {
305
+ return text(scaffoldIntegration({
306
+ framework,
307
+ widgetScriptUrl: widgetScriptUrl ?? DEFAULT_WIDGET_SCRIPT_URL,
308
+ apiUrl: conn.baseUrl || null,
309
+ widgetKey: widgetKey ?? null,
310
+ idNamespace,
311
+ }));
312
+ }
313
+ catch (err) {
314
+ return errorText(err);
315
+ }
316
+ });
317
+ server.registerTool('verify_integration', {
318
+ title: 'Verify the integration against the running app',
319
+ description: 'Load the real page and report what is actually true: is the Appilot instance reachable, does the page\'s domain resolve to a tenant, is the widget referenced, does the identity relay answer correctly for an unauthenticated caller, does the widget boot in a browser, and are there Appilot errors in the console. This catches the class of failures no static check can see (a stale bundle, a rotated key, a domain resolving to the wrong tenant). The browser half needs Playwright and is skipped, not failed, without it.',
320
+ inputSchema: {
321
+ url: z.string().url(),
322
+ appId: z.number().int().optional(),
323
+ tokenEndpoint: z.string().optional(),
324
+ },
325
+ }, async ({ url, appId, tokenEndpoint }) => {
326
+ try {
327
+ const result = await verifyIntegration(client, {
328
+ url,
329
+ appId: appId ?? conn.defaultAppId ?? null,
330
+ tokenEndpoint,
331
+ storageStatePath: conn.soakStorageStatePath,
332
+ });
333
+ return text(result);
334
+ }
335
+ catch (err) {
336
+ return errorText(err);
337
+ }
338
+ });
339
+ server.registerTool('soak_selectors', {
340
+ title: 'Soak control selectors against the live DOM',
341
+ description: 'Load a real page in a headless browser and check that each of the app\'s control selectors resolves to at least one element. Catches unstable selectors (e.g. auto-generated ids) that pass every static check but are gone on the next render. Requires Playwright; an authenticated site session can be supplied via APPILOT_SOAK_STORAGE_STATE.',
342
+ inputSchema: { url: z.string().url(), appId: z.number().int().optional() },
343
+ }, async ({ url, appId }) => {
344
+ try {
345
+ const id = resolveAppId(appId);
346
+ const controls = await client.listControls(id);
347
+ const selectors = controls
348
+ .map(c => ({ semantic_id: String(c.semantic_id ?? ''), locator: String(c.locator ?? c.locator_value ?? '') }))
349
+ .filter(s => s.locator);
350
+ const result = await soakSelectors({ url, selectors, storageStatePath: conn.soakStorageStatePath });
351
+ return text(result);
352
+ }
353
+ catch (err) {
354
+ return errorText(err);
355
+ }
356
+ });
357
+ return server;
358
+ }
package/dist/soak.d.ts ADDED
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Live DOM soak: drive the real rendered page to confirm a plan's control
3
+ * selectors actually resolve (and optionally that executing the flow produces
4
+ * the expected effect). This is what catches an unstable selector like
5
+ * `#nc-vue-30` that passes every static check but is gone on the next render.
6
+ *
7
+ * Playwright is a peer dependency, lazily imported so the rest of the server
8
+ * works without it. If it is absent we return a clear, non-fatal message.
9
+ * An authenticated site session can be supplied via a Playwright storageState
10
+ * JSON file (APPILOT_SOAK_STORAGE_STATE).
11
+ */
12
+ export interface SoakSelector {
13
+ semantic_id: string;
14
+ locator: string;
15
+ }
16
+ export interface SoakResult {
17
+ available: boolean;
18
+ url?: string;
19
+ selectors?: Array<{
20
+ semantic_id: string;
21
+ locator: string;
22
+ resolved: boolean;
23
+ count: number;
24
+ }>;
25
+ note?: string;
26
+ }
27
+ export declare function soakSelectors(opts: {
28
+ url: string;
29
+ selectors: SoakSelector[];
30
+ storageStatePath?: string;
31
+ timeoutMs?: number;
32
+ }): Promise<SoakResult>;
package/dist/soak.js ADDED
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Live DOM soak: drive the real rendered page to confirm a plan's control
3
+ * selectors actually resolve (and optionally that executing the flow produces
4
+ * the expected effect). This is what catches an unstable selector like
5
+ * `#nc-vue-30` that passes every static check but is gone on the next render.
6
+ *
7
+ * Playwright is a peer dependency, lazily imported so the rest of the server
8
+ * works without it. If it is absent we return a clear, non-fatal message.
9
+ * An authenticated site session can be supplied via a Playwright storageState
10
+ * JSON file (APPILOT_SOAK_STORAGE_STATE).
11
+ */
12
+ /* eslint-disable @typescript-eslint/no-explicit-any */
13
+ async function loadPlaywright() {
14
+ try {
15
+ // Lazy, optional: absent Playwright must not break the server.
16
+ return await import('playwright');
17
+ }
18
+ catch {
19
+ return null;
20
+ }
21
+ }
22
+ export async function soakSelectors(opts) {
23
+ const playwright = await loadPlaywright();
24
+ if (!playwright) {
25
+ return {
26
+ available: false,
27
+ note: 'Playwright is not installed. Run `pnpm add -D playwright && npx playwright install chromium` in the MCP package to enable live DOM soak.',
28
+ };
29
+ }
30
+ const browser = await playwright.chromium.launch({ headless: true });
31
+ try {
32
+ const context = await browser.newContext(opts.storageStatePath ? { storageState: opts.storageStatePath } : undefined);
33
+ const page = await context.newPage();
34
+ await page.goto(opts.url, { waitUntil: 'domcontentloaded', timeout: opts.timeoutMs ?? 20000 });
35
+ const selectors = [];
36
+ for (const s of opts.selectors) {
37
+ let count = 0;
38
+ try {
39
+ count = await page.locator(s.locator).count();
40
+ }
41
+ catch {
42
+ count = 0;
43
+ }
44
+ selectors.push({ semantic_id: s.semantic_id, locator: s.locator, resolved: count > 0, count });
45
+ }
46
+ return { available: true, url: opts.url, selectors };
47
+ }
48
+ finally {
49
+ await browser.close();
50
+ }
51
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * `verify_integration`: load the real thing and report what is actually true.
3
+ *
4
+ * Every integration failure recorded in `l3/docs/agent-first.md` was invisible
5
+ * from the source: a widget bundle nine days older than the feature it was meant
6
+ * to ship, a `presentation` column still NULL because the rows predated the
7
+ * seed, a widget secret rotated under a running process, a tenant quietly
8
+ * resolving to the default organization. Static validation cannot see any of
9
+ * them. This can, and that is what lets a coding agent correct itself instead of
10
+ * filing a support ticket.
11
+ *
12
+ * The checks degrade rather than fail: each one reports pass, fail, or skipped
13
+ * with a reason, so a run without Playwright still answers the network half.
14
+ */
15
+ import type { AppilotClient } from './client.js';
16
+ export type CheckStatus = 'pass' | 'fail' | 'warn' | 'skip';
17
+ export interface IntegrationCheck {
18
+ id: string;
19
+ title: string;
20
+ status: CheckStatus;
21
+ detail: string;
22
+ /** What to do about it. Present on anything that is not a pass. */
23
+ fix?: string;
24
+ }
25
+ export interface VerifyIntegrationResult {
26
+ url: string;
27
+ ok: boolean;
28
+ checks: IntegrationCheck[];
29
+ summary: string;
30
+ }
31
+ interface VerifyOptions {
32
+ url: string;
33
+ appId?: number | null;
34
+ /** Path the host serves its identity relay on. Default `/api/widget/token`. */
35
+ tokenEndpoint?: string;
36
+ storageStatePath?: string;
37
+ fetchImpl?: typeof fetch;
38
+ }
39
+ export declare function verifyIntegration(client: AppilotClient, options: VerifyOptions): Promise<VerifyIntegrationResult>;
40
+ export {};
package/dist/verify.js ADDED
@@ -0,0 +1,149 @@
1
+ /**
2
+ * `verify_integration`: load the real thing and report what is actually true.
3
+ *
4
+ * Every integration failure recorded in `l3/docs/agent-first.md` was invisible
5
+ * from the source: a widget bundle nine days older than the feature it was meant
6
+ * to ship, a `presentation` column still NULL because the rows predated the
7
+ * seed, a widget secret rotated under a running process, a tenant quietly
8
+ * resolving to the default organization. Static validation cannot see any of
9
+ * them. This can, and that is what lets a coding agent correct itself instead of
10
+ * filing a support ticket.
11
+ *
12
+ * The checks degrade rather than fail: each one reports pass, fail, or skipped
13
+ * with a reason, so a run without Playwright still answers the network half.
14
+ */
15
+ const WIDGET_MARKERS = ['appilot.esm.js', 'appilot.js', 'bootAppilotWidget', 'data-api-key', 'appilot-widget'];
16
+ function check(id, title, status, detail, fix) {
17
+ return { id, title, status, detail, ...(fix ? { fix } : {}) };
18
+ }
19
+ /**
20
+ * Probe the relay without a session. A correctly mounted relay answers 401 with
21
+ * its typed code; 404 means it is not mounted at all, which is the single most
22
+ * common wiring mistake and is otherwise only visible as "the assistant says I
23
+ * am not signed in".
24
+ */
25
+ async function checkTokenEndpoint(origin, path, doFetch) {
26
+ const endpoint = new URL(path, origin).toString();
27
+ let res;
28
+ try {
29
+ res = await doFetch(endpoint, {
30
+ method: 'POST',
31
+ headers: { 'Content-Type': 'application/json' },
32
+ body: '{}',
33
+ });
34
+ }
35
+ catch (error) {
36
+ return check('token-endpoint', 'Identity relay reachable', 'fail', `POST ${endpoint} did not respond (${error.message}).`, 'Mount the relay with createWidgetTokenHandler from appilot-server.');
37
+ }
38
+ if (res.status === 404) {
39
+ return check('token-endpoint', 'Identity relay reachable', 'fail', `POST ${endpoint} returned 404.`, 'The relay is not mounted at this path. Mount createWidgetTokenHandler there, or pass the path you actually use as tokenEndpoint.');
40
+ }
41
+ if (res.status === 401 || res.status === 403) {
42
+ return check('token-endpoint', 'Identity relay reachable', 'pass', `POST ${endpoint} answered ${res.status} for an unauthenticated caller, which is the correct refusal.`);
43
+ }
44
+ if (res.ok) {
45
+ return check('token-endpoint', 'Identity relay reachable', 'warn', `POST ${endpoint} returned a token to a caller with no session.`, 'resolveUser must derive the user from a credential the host trusts and return null otherwise. A relay that mints a token for anyone hands every visitor an identity.');
46
+ }
47
+ return check('token-endpoint', 'Identity relay reachable', 'warn', `POST ${endpoint} returned ${res.status}.`, 'Expected 401 for an unauthenticated probe. A 5xx usually means the relay is mounted but misconfigured (missing key or secret).');
48
+ }
49
+ /**
50
+ * Try Playwright for the half only a browser can answer: does the widget
51
+ * actually boot on the page. Absence of Playwright is a skip, never a crash,
52
+ * which is the same posture soak_selectors takes.
53
+ */
54
+ async function checkWidgetBoots(url, storageStatePath) {
55
+ let chromium;
56
+ try {
57
+ ({ chromium } = await import('playwright'));
58
+ }
59
+ catch {
60
+ return [
61
+ check('widget-boot', 'Widget boots on the page', 'skip', 'Playwright is not installed.', 'npm i -D playwright && npx playwright install chromium, then re-run to check the browser half.'),
62
+ ];
63
+ }
64
+ const browser = await chromium.launch();
65
+ try {
66
+ const context = await browser.newContext(storageStatePath ? { storageState: storageStatePath } : {});
67
+ const page = await context.newPage();
68
+ const consoleErrors = [];
69
+ page.on('console', message => {
70
+ if (message.type() === 'error')
71
+ consoleErrors.push(message.text());
72
+ });
73
+ await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30_000 });
74
+ // The widget mounts its own element under documentElement, so a plain
75
+ // selector on body would miss it (see docs/architecture/in-page-overlays.md).
76
+ const mounted = await page
77
+ .waitForFunction(() => Boolean(document.querySelector('appilot-widget') ||
78
+ document.querySelector('[data-appilot-surface]') ||
79
+ window.Appilot), undefined, { timeout: 15_000 })
80
+ .then(() => true)
81
+ .catch(() => false);
82
+ const checks = [
83
+ mounted
84
+ ? check('widget-boot', 'Widget boots on the page', 'pass', 'The widget mounted and window.Appilot is present.')
85
+ : check('widget-boot', 'Widget boots on the page', 'fail', 'No Appilot surface appeared within 15s.', 'Check that the loader script is on the page, that its origin is reachable (CSP script-src), and that the domain is registered or a wk_test_ key is set.'),
86
+ ];
87
+ const appilotErrors = consoleErrors.filter(e => /appilot/i.test(e));
88
+ if (appilotErrors.length > 0) {
89
+ checks.push(check('console-errors', 'No Appilot errors in the console', 'fail', appilotErrors.slice(0, 5).join(' | '), 'Read the first error: a 401 points at the key or the relay, a CSP violation at script-src or connect-src.'));
90
+ }
91
+ else {
92
+ checks.push(check('console-errors', 'No Appilot errors in the console', 'pass', 'Clean.'));
93
+ }
94
+ return checks;
95
+ }
96
+ finally {
97
+ await browser.close();
98
+ }
99
+ }
100
+ export async function verifyIntegration(client, options) {
101
+ const doFetch = options.fetchImpl ?? fetch;
102
+ const checks = [];
103
+ const target = new URL(options.url);
104
+ // 1. The instance answers at all.
105
+ try {
106
+ const caps = await client.getCapabilities();
107
+ checks.push(check('instance', 'Appilot instance reachable', 'pass', `Version ${caps.appVersion ?? 'unknown'}.`));
108
+ }
109
+ catch (error) {
110
+ checks.push(check('instance', 'Appilot instance reachable', 'fail', error.message, 'Check APPILOT_BASE_URL. Everything below depends on this.'));
111
+ }
112
+ // 2. Does the page's own hostname resolve to a tenant? This is the check
113
+ // that catches a widget pointed at an unregistered domain, which silently
114
+ // degrades to the key's org (dev fallback) or to no org at all.
115
+ try {
116
+ const resolution = (await client.checkDomain(target.hostname));
117
+ const registered = resolution.status === 'registered' || resolution.registered === true;
118
+ checks.push(registered
119
+ ? check('domain', 'Domain resolves to a tenant', 'pass', `${target.hostname} is registered.`)
120
+ : check('domain', 'Domain resolves to a tenant', target.hostname === 'localhost' ? 'warn' : 'fail', `${target.hostname} is not registered (status: ${String(resolution.status ?? 'unknown')}).`, 'Register the domain, or use a wk_test_ key for local development. On an unregistered production domain the tenant cannot be resolved from the page.'));
121
+ }
122
+ catch (error) {
123
+ checks.push(check('domain', 'Domain resolves to a tenant', 'warn', error.message));
124
+ }
125
+ // 3. Is the widget referenced by the served HTML at all?
126
+ try {
127
+ const res = await doFetch(options.url, { headers: { Accept: 'text/html' } });
128
+ const html = await res.text();
129
+ const found = WIDGET_MARKERS.filter(marker => html.includes(marker));
130
+ checks.push(found.length > 0
131
+ ? check('markup', 'Widget referenced by the page', 'pass', `Found: ${found.join(', ')}.`)
132
+ : check('markup', 'Widget referenced by the page', 'warn', 'No Appilot loader found in the served HTML.', 'This is expected when the widget boots from client-side JavaScript. The browser check below is the authority.'));
133
+ }
134
+ catch (error) {
135
+ checks.push(check('markup', 'Widget referenced by the page', 'fail', `Could not fetch ${options.url}: ${error.message}`));
136
+ }
137
+ // 4. The identity relay.
138
+ checks.push(await checkTokenEndpoint(target.origin, options.tokenEndpoint ?? '/api/widget/token', doFetch));
139
+ // 5. The browser half.
140
+ checks.push(...(await checkWidgetBoots(options.url, options.storageStatePath)));
141
+ const failed = checks.filter(c => c.status === 'fail');
142
+ const warned = checks.filter(c => c.status === 'warn');
143
+ const summary = failed.length
144
+ ? `${failed.length} check(s) failed: ${failed.map(c => c.id).join(', ')}.`
145
+ : warned.length
146
+ ? `All checks passed with ${warned.length} warning(s).`
147
+ : 'Integration verified end to end.';
148
+ return { url: options.url, ok: failed.length === 0, checks, summary };
149
+ }