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.
Files changed (36) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/LICENSE +15 -0
  4. package/README.md +74 -16
  5. package/dist/appilot-configurator.mcpb +0 -0
  6. package/dist/cli.d.ts +34 -0
  7. package/dist/cli.js +171 -0
  8. package/dist/client.d.ts +45 -1
  9. package/dist/client.js +74 -1
  10. package/dist/contract/healthContract.js +31 -4
  11. package/dist/index.bundle.js +820 -142
  12. package/dist/index.js +7 -0
  13. package/dist/manifest.d.ts +14 -2
  14. package/dist/manifest.js +31 -9
  15. package/dist/public-marketplace/.claude-plugin/marketplace.json +20 -0
  16. package/dist/public-marketplace/README.md +23 -0
  17. package/dist/public-marketplace/plugins/app-configurator/.claude-plugin/plugin.json +43 -0
  18. package/dist/public-marketplace/plugins/app-configurator/README.md +328 -0
  19. package/dist/public-marketplace/plugins/app-configurator/dist/index.bundle.js +57370 -0
  20. package/dist/public-marketplace/plugins/app-configurator/skills/app-configurator/SKILL.md +267 -0
  21. package/dist/public-marketplace/plugins/app-configurator/skills/app-configurator/agents/openai.yaml +13 -0
  22. package/dist/remote/consent.d.ts +10 -2
  23. package/dist/remote/consent.js +15 -6
  24. package/dist/remote/consentMessages.d.ts +4 -1
  25. package/dist/remote/consentMessages.js +9 -6
  26. package/dist/remote/httpServer.js +2 -2
  27. package/dist/remote/oauth.js +9 -9
  28. package/dist/scaffold.d.ts +68 -6
  29. package/dist/scaffold.js +424 -97
  30. package/dist/server.js +175 -18
  31. package/dist/version.d.ts +1 -1
  32. package/dist/version.js +1 -1
  33. package/examples/app.appilot.json +212 -0
  34. package/mcpb/manifest.json +117 -21
  35. package/package.json +5 -3
  36. package/skills/app-configurator/SKILL.md +61 -19
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "app-configurator",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Set up, configure, audit, repair, back up, restore, and verify an Appilot app: provision the app, its domains and widget key, scaffold the host integration, and keep the content model correct. Uses the Appilot MCP server and the app-configurator skill.",
5
5
  "author": {
6
6
  "name": "Appilot",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "app-configurator",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Set up, configure, audit, repair, back up, restore, and verify an Appilot app: provision the app, its domains and widget key, scaffold the host integration, and keep the content model correct. Uses the Appilot MCP server and the app-configurator skill.",
5
5
  "author": {
6
6
  "name": "Appilot",
package/LICENSE ADDED
@@ -0,0 +1,15 @@
1
+ ISC License
2
+
3
+ Copyright (c) 2026 BetterKnow GmbH
4
+
5
+ Permission to use, copy, modify, and/or distribute this software for any
6
+ purpose with or without fee is hereby granted, provided that the above
7
+ copyright notice and this permission notice appear in all copies.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
10
+ WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
11
+ MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
12
+ ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
13
+ WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
14
+ ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
15
+ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
package/README.md CHANGED
@@ -55,23 +55,58 @@ because each caller brings theirs.
55
55
  | `APPILOT_MCP_OAUTH_SECRET` | yes | At least 32 characters (`openssl rand -base64 32`). Everything the authorization server issues is sealed with it, so rotating it disconnects every client. |
56
56
  | `PORT` | no | Defaults to 8080. Cloud Run supplies it. |
57
57
  | `APPILOT_MCP_ALLOWED_HOSTS` | no | Extra `Host` values to accept, comma separated, for a proxy or a platform-assigned hostname. The public origin is always accepted. |
58
+ | `APPILOT_MCP_DOCS_URL` | no | Where the protected-resource metadata points a client for documentation. Defaults to the public docs site. |
59
+ | `APPILOT_MCP_BACKOFFICE_URL` | no | The Backoffice origin consent screens link to, and the only origin account approval is accepted from. Must be HTTPS (localhost excepted). Defaults to `https://backoffice.appilot.space` when the instance is the Appilot cloud. |
60
+ | `APPILOT_MCP_HANDOFF_SECRET` | no | Enables account approval instead of pasting a service token. At least 32 characters, and it must match the backend's. Separate from the OAuth secret, and it requires `APPILOT_MCP_BACKOFFICE_URL`. |
58
61
 
59
62
  ## Tools
60
63
 
64
+ Twenty-five, in the order the server registers them. `test/toolSurface.test.ts`
65
+ holds this table, `mcpb/manifest.json` and the registered tools to one list.
66
+
61
67
  | Tool | Purpose |
62
68
  |------|---------|
63
- | `capabilities` | Version + migration level + schema versions of the connected instance (negotiate before configuring) |
69
+ | `capabilities` | Version, migration level and the closed vocabularies this instance accepts (negotiate before configuring) |
64
70
  | `read_config` | Normalized snapshot of an app's content model |
65
71
  | `validate_config` | Severity-ranked findings against the health contract (local + server echo) |
66
72
  | `entity_template` | A valid skeleton per entity kind, carrying the closed enums this instance accepts |
67
73
  | `create_entity` / `update_entity` / `delete_entity` | Authoring across all eight kinds (server re-validates the trust boundary) |
68
74
  | `validate_action_plan` | Check draft plan sections before writing them |
69
- | `inspect_page` | Read a page and rank locator candidates by whether they survive the next render |
70
- | `scaffold_agent_first` | The four artifacts one capability needs to be operable by the agent |
71
75
  | `export_config` / `import_config` | Whole-app ConfigBundle round trip, dry-run first, merge or replace |
76
+ | `whoami` | Which organization, which app and which scopes the credential reaches |
77
+ | `create_app` | Provision the app, its domains and a widget key, with the snippets to paste in |
78
+ | `list_apps` | What this organization has provisioned, with each domain's verification status |
79
+ | `list_widget_keys` | The keys already minted, by label and prefix. No raw key, no secret |
80
+ | `verify_domain` | Where a domain's DNS verification stands, and the TXT record it needs |
81
+ | `integration_snippet` | The script tag and boot call for an app that already exists. Pure, no scope |
82
+ | `plan_manifest` / `apply_manifest` | A whole tenant from one versioned file, plan before apply |
83
+ | `scaffold_integration` | The host application's source: identity relay, widget boot, a client action |
84
+ | `verify_integration` | Load the running page and report what is actually true |
72
85
  | `soak_selectors` | Headless-browser check that each control selector resolves on the live page (needs Playwright) |
86
+ | `inspect_page` | Read a page and rank locator candidates by whether they survive the next render |
87
+ | `scaffold_agent_first` | The artifacts one capability needs to be operable by the agent |
73
88
  | `report_feedback` / `list_feedback` | Report a gap or a defect in Appilot, and see what this organization has already raised |
74
89
 
90
+ ## The command line
91
+
92
+ The package is an MCP server first, and a small CLI for the things a pipeline
93
+ needs without an MCP client in front of them.
94
+
95
+ ```bash
96
+ npx appilot-mcp --help
97
+ npx appilot-mcp --version
98
+ npx appilot-mcp install-skill --claude # or --codex, --cursor, --gemini
99
+ npx appilot-mcp plan app.appilot.json # preview, writes nothing
100
+ npx appilot-mcp apply app.appilot.json # apply the plan it just printed
101
+ ```
102
+
103
+ `plan` and `apply` read the same environment the stdio server does, and `apply`
104
+ runs the plan itself and passes its own token, so the preview cannot be skipped.
105
+ `install-skill` copies the bundled `app-configurator` skill into the client's
106
+ skills directory, which is what makes the skill reachable from a bare `npx`
107
+ install: inside the npm cache no client looks for it.
108
+
109
+
75
110
  ## Distribution artifacts
76
111
 
77
112
  One package carries a tool-neutral implementation plus thin discovery adapters:
@@ -83,6 +118,8 @@ One package carries a tool-neutral implementation plus thin discovery adapters:
83
118
  | `.codex-plugin/plugin.json` | Codex plugin |
84
119
  | `.claude-plugin/plugin.json` | Claude Code plugin |
85
120
  | `mcpb/manifest.json` | Claude Desktop bundle manifest, packed by `pnpm build:mcpb` |
121
+ | `examples/app.appilot.json` | A complete app manifest to copy, held valid by `test/exampleManifest.test.ts` |
122
+ | `.claude-plugin/public-marketplace/` | The public marketplace repository, prepared and not published |
86
123
  | `Dockerfile` | The remote HTTP service |
87
124
  | `dist/index.bundle.js` | Self-contained Node.js MCP server, both transports |
88
125
 
@@ -113,10 +150,20 @@ task after installation so the new tool surface is loaded.
113
150
 
114
151
  ### Claude Code
115
152
 
116
- Add the repository marketplace and install `app-configurator@appilot`. Claude's
117
- plugin settings prompt for the backend URL, service token, and optional app ID.
118
- The token is declared sensitive so Claude stores it in secure credential
119
- storage.
153
+ ```
154
+ /plugin marketplace add /absolute/path/to/app-pilot
155
+ /plugin install app-configurator@appilot
156
+ ```
157
+
158
+ Claude's plugin settings then prompt for the backend URL, the service token and
159
+ an optional app id. The token is declared sensitive, so Claude stores it in
160
+ secure credential storage.
161
+
162
+ There is no published marketplace repository yet: `appilot/appilot-plugins` does
163
+ not exist on GitHub, so a `/plugin marketplace add appilot/appilot-plugins`
164
+ anywhere is wrong until someone publishes it. The tree that repository would
165
+ carry is prepared in `.claude-plugin/public-marketplace/`, along with the human
166
+ steps, and `pnpm build:marketplace` assembles it.
120
167
 
121
168
  ### Claude Desktop
122
169
 
@@ -152,17 +199,21 @@ instance needs no public listener.
152
199
 
153
200
  ### Cursor
154
201
 
155
- Copy `skills/app-configurator` to `~/.cursor/skills/app-configurator`, then add
156
- the server to `~/.cursor/mcp.json`.
202
+ ```bash
203
+ npx -y appilot-mcp install-skill --cursor
204
+ ```
205
+
206
+ Then add the server to `~/.cursor/mcp.json`.
157
207
 
158
208
  ### Antigravity
159
209
 
160
- Copy `skills/app-configurator` to the applicable global skills directory:
161
-
162
- - Antigravity IDE: `~/.gemini/config/skills/app-configurator`
163
- - Antigravity CLI: `~/.gemini/antigravity-cli/skills/app-configurator`
210
+ ```bash
211
+ npx -y appilot-mcp install-skill --gemini
212
+ ```
164
213
 
165
- Register the stdio server in `~/.gemini/config/mcp_config.json`.
214
+ That writes `~/.gemini/config/skills/app-configurator`, which is where the IDE
215
+ looks. The CLI reads `~/.gemini/antigravity-cli/skills/`, so pass `--dir` for
216
+ that one. Register the stdio server in `~/.gemini/config/mcp_config.json`.
166
217
 
167
218
  ### Any stdio MCP client
168
219
 
@@ -184,8 +235,15 @@ Point the client at the built bundle:
184
235
  }
185
236
  ```
186
237
 
187
- For registry-backed distribution, replace `node` and the bundle path with
188
- `pnpm dlx appilot-mcp` after the package is published.
238
+ From npm, with no checkout at all, replace `node` and the bundle path with
239
+ `npx` and `-y appilot-mcp`, then install the skill the package ships:
240
+
241
+ ```bash
242
+ npx -y appilot-mcp install-skill --claude # or --codex, --cursor, --gemini
243
+ ```
244
+
245
+ Without that step the skill stays in the npm cache, where no client looks, and
246
+ the tools arrive with no procedure around them.
189
247
 
190
248
  Do not commit service tokens. Use the MCP client's secure secret storage or
191
249
  environment forwarding when available.
Binary file
package/dist/cli.d.ts ADDED
@@ -0,0 +1,34 @@
1
+ /**
2
+ * The command line around the MCP server.
3
+ *
4
+ * `npx appilot-mcp` starts the stdio server, which is what an MCP client wants
5
+ * and what nothing else does. Everything here exists because a person or a
6
+ * pipeline typed something else and got a server waiting silently on stdin:
7
+ * `--help` and `--version` did that, and so did every attempt to apply a
8
+ * manifest from CI, which had to script an MCP client to reach a plan and an
9
+ * apply that are two ordinary function calls.
10
+ *
11
+ * `install-skill` is the other half of the same problem. The npm tarball
12
+ * carries `skills/app-configurator`, and it lands in the npm cache, where no
13
+ * client looks. Copying it to the client's own skills directory is the step
14
+ * that was documented only as a marketplace command pointing at a repository
15
+ * that does not exist.
16
+ */
17
+ export declare const HELP = "appilot-mcp 0.3.0\n\nThe Appilot MCP server: read, validate, fix and provision an Appilot app.\n\nUSAGE\n appilot-mcp Start the MCP server on stdio (what a client launches)\n appilot-mcp --http Start the remote HTTP service instead\n appilot-mcp plan <file> Preview an appilot.app-manifest. Writes nothing\n appilot-mcp apply <file> Plan, then apply the same manifest\n appilot-mcp install-skill Copy the app-configurator skill to an agent client\n appilot-mcp --help | --version\n\nOPTIONS\n apply --mode merge|replace How the config half is imported. Default merge\n --allow-unhealthy Import a bundle the health gate would refuse\n install-skill --claude | --codex | --cursor | --gemini | --dir <path>\n\nENVIRONMENT\n APPILOT_BASE_URL Your instance, e.g. https://api.appilot.space or http://localhost:6001\n APPILOT_PAT A scoped service token from the Backoffice, under Service tokens\n APPILOT_APP_ID Optional default app id\n\nDocs: https://docs.appilot.space/docs/developers/configure-with-ai/overview";
18
+ /** Where each client looks for a skill. One directory per client, all under $HOME. */
19
+ export declare const SKILL_TARGETS: Record<string, string>;
20
+ /**
21
+ * The bundled skill directory, resolved from the running file.
22
+ *
23
+ * `dist/index.bundle.js` and `skills/` are siblings under the package root in
24
+ * the tarball and in the workspace, so one hop up is the answer in both. The
25
+ * fallback covers a deeper build output rather than guessing silently.
26
+ */
27
+ export declare function bundledSkillDir(from?: string): string | null;
28
+ /**
29
+ * Handle everything that is not "start a server".
30
+ *
31
+ * Returns null when the argv asks for a server, so the entry point stays the
32
+ * transport switch it was.
33
+ */
34
+ export declare function runCommand(argv: string[]): Promise<number | null>;
package/dist/cli.js ADDED
@@ -0,0 +1,171 @@
1
+ /**
2
+ * The command line around the MCP server.
3
+ *
4
+ * `npx appilot-mcp` starts the stdio server, which is what an MCP client wants
5
+ * and what nothing else does. Everything here exists because a person or a
6
+ * pipeline typed something else and got a server waiting silently on stdin:
7
+ * `--help` and `--version` did that, and so did every attempt to apply a
8
+ * manifest from CI, which had to script an MCP client to reach a plan and an
9
+ * apply that are two ordinary function calls.
10
+ *
11
+ * `install-skill` is the other half of the same problem. The npm tarball
12
+ * carries `skills/app-configurator`, and it lands in the npm cache, where no
13
+ * client looks. Copying it to the client's own skills directory is the step
14
+ * that was documented only as a marketplace command pointing at a repository
15
+ * that does not exist.
16
+ */
17
+ import { cpSync, existsSync, readFileSync, mkdirSync } from 'node:fs';
18
+ import { homedir } from 'node:os';
19
+ import { join } from 'node:path';
20
+ import { fileURLToPath } from 'node:url';
21
+ import { SERVER_VERSION } from './version.js';
22
+ import { loadConnection } from './config.js';
23
+ import { AppilotClient } from './client.js';
24
+ import { applyManifest, parseManifest, planManifest } from './manifest.js';
25
+ export const HELP = `appilot-mcp ${SERVER_VERSION}
26
+
27
+ The Appilot MCP server: read, validate, fix and provision an Appilot app.
28
+
29
+ USAGE
30
+ appilot-mcp Start the MCP server on stdio (what a client launches)
31
+ appilot-mcp --http Start the remote HTTP service instead
32
+ appilot-mcp plan <file> Preview an appilot.app-manifest. Writes nothing
33
+ appilot-mcp apply <file> Plan, then apply the same manifest
34
+ appilot-mcp install-skill Copy the app-configurator skill to an agent client
35
+ appilot-mcp --help | --version
36
+
37
+ OPTIONS
38
+ apply --mode merge|replace How the config half is imported. Default merge
39
+ --allow-unhealthy Import a bundle the health gate would refuse
40
+ install-skill --claude | --codex | --cursor | --gemini | --dir <path>
41
+
42
+ ENVIRONMENT
43
+ APPILOT_BASE_URL Your instance, e.g. https://api.appilot.space or http://localhost:6001
44
+ APPILOT_PAT A scoped service token from the Backoffice, under Service tokens
45
+ APPILOT_APP_ID Optional default app id
46
+
47
+ Docs: https://docs.appilot.space/docs/developers/configure-with-ai/overview`;
48
+ /** Where each client looks for a skill. One directory per client, all under $HOME. */
49
+ export const SKILL_TARGETS = {
50
+ claude: join('.claude', 'skills'),
51
+ codex: join('.codex', 'skills'),
52
+ cursor: join('.cursor', 'skills'),
53
+ gemini: join('.gemini', 'config', 'skills'),
54
+ };
55
+ /**
56
+ * The bundled skill directory, resolved from the running file.
57
+ *
58
+ * `dist/index.bundle.js` and `skills/` are siblings under the package root in
59
+ * the tarball and in the workspace, so one hop up is the answer in both. The
60
+ * fallback covers a deeper build output rather than guessing silently.
61
+ */
62
+ export function bundledSkillDir(from = fileURLToPath(import.meta.url)) {
63
+ const candidates = [
64
+ join(from, '..', '..', 'skills', 'app-configurator'),
65
+ join(from, '..', '..', '..', 'skills', 'app-configurator'),
66
+ ];
67
+ return candidates.find(candidate => existsSync(join(candidate, 'SKILL.md'))) ?? null;
68
+ }
69
+ function installSkill(args) {
70
+ const source = bundledSkillDir();
71
+ if (!source) {
72
+ process.stderr.write('[appilot-mcp] the bundled skill was not found next to this install. Reinstall the package (npm i -g appilot-mcp), or copy skills/app-configurator from the repository by hand.\n');
73
+ return 1;
74
+ }
75
+ const dirFlag = args.indexOf('--dir');
76
+ let destParent;
77
+ if (dirFlag !== -1) {
78
+ const explicit = args[dirFlag + 1];
79
+ if (!explicit) {
80
+ process.stderr.write('[appilot-mcp] --dir needs a path.\n');
81
+ return 2;
82
+ }
83
+ destParent = explicit;
84
+ }
85
+ else {
86
+ const client = Object.keys(SKILL_TARGETS).find(name => args.includes(`--${name}`));
87
+ if (!client) {
88
+ process.stderr.write(`[appilot-mcp] name the client: ${Object.keys(SKILL_TARGETS).map(c => `--${c}`).join(', ')}, or --dir <path>.\n`);
89
+ return 2;
90
+ }
91
+ destParent = join(homedir(), SKILL_TARGETS[client]);
92
+ }
93
+ const dest = join(destParent, 'app-configurator');
94
+ mkdirSync(destParent, { recursive: true });
95
+ cpSync(source, dest, { recursive: true });
96
+ process.stdout.write(`Installed the app-configurator skill to ${dest}\nStart a new session so the client loads it.\n`);
97
+ return 0;
98
+ }
99
+ function readManifestFile(path) {
100
+ if (!path)
101
+ throw new Error('Name the manifest file: appilot-mcp plan app.appilot.json');
102
+ if (!existsSync(path))
103
+ throw new Error(`No such file: ${path}`);
104
+ return JSON.parse(readFileSync(path, 'utf8'));
105
+ }
106
+ async function runPlan(args) {
107
+ const conn = loadConnection();
108
+ const client = new AppilotClient(conn);
109
+ const manifest = parseManifest(readManifestFile(args[0]));
110
+ const plan = await planManifest(client, manifest, id => {
111
+ const resolved = id ?? conn.defaultAppId;
112
+ return resolved != null && Number.isFinite(resolved) ? resolved : null;
113
+ });
114
+ process.stdout.write(JSON.stringify(plan, null, 2) + '\n');
115
+ return 0;
116
+ }
117
+ /**
118
+ * Apply runs its own plan and passes the token it produced.
119
+ *
120
+ * The token binds an apply to a preview of the same bytes, and a CLI that made
121
+ * the caller carry it between two commands would only teach them to store it.
122
+ * Planning here keeps the property (the manifest cannot change between the two)
123
+ * and prints the preview, so the run is still auditable in a CI log.
124
+ */
125
+ async function runApply(args) {
126
+ const conn = loadConnection();
127
+ const client = new AppilotClient(conn);
128
+ const manifest = parseManifest(readManifestFile(args[0]));
129
+ const modeFlag = args.indexOf('--mode');
130
+ const mode = modeFlag !== -1 ? args[modeFlag + 1] : undefined;
131
+ if (mode !== undefined && mode !== 'merge' && mode !== 'replace') {
132
+ throw new Error(`--mode is merge or replace, not ${mode}.`);
133
+ }
134
+ const plan = await planManifest(client, manifest, id => {
135
+ const resolved = id ?? conn.defaultAppId;
136
+ return resolved != null && Number.isFinite(resolved) ? resolved : null;
137
+ });
138
+ process.stderr.write(`[appilot-mcp] planned ${plan.planToken.slice(0, 12)}…\n`);
139
+ const result = await applyManifest(client, manifest, {
140
+ planToken: plan.planToken,
141
+ mode,
142
+ expectedCurrentHash: plan.config?.expectedCurrentHash,
143
+ allowUnhealthy: args.includes('--allow-unhealthy'),
144
+ });
145
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n');
146
+ return 0;
147
+ }
148
+ /**
149
+ * Handle everything that is not "start a server".
150
+ *
151
+ * Returns null when the argv asks for a server, so the entry point stays the
152
+ * transport switch it was.
153
+ */
154
+ export async function runCommand(argv) {
155
+ const [command, ...rest] = argv;
156
+ if (command === '--help' || command === '-h' || command === 'help') {
157
+ process.stdout.write(HELP + '\n');
158
+ return 0;
159
+ }
160
+ if (command === '--version' || command === '-v' || command === 'version') {
161
+ process.stdout.write(SERVER_VERSION + '\n');
162
+ return 0;
163
+ }
164
+ if (command === 'install-skill')
165
+ return installSkill(rest);
166
+ if (command === 'plan')
167
+ return runPlan(rest);
168
+ if (command === 'apply')
169
+ return runApply(rest);
170
+ return null;
171
+ }
package/dist/client.d.ts CHANGED
@@ -52,10 +52,19 @@ export interface ProvisionAppResponse {
52
52
  }>;
53
53
  widgetKey?: {
54
54
  id: number | null;
55
- action: 'created' | 'reused' | 'updated';
55
+ /**
56
+ * `blocked` is a real outcome, not an error: a LIVE key needs a verified
57
+ * domain, and the app and its domains are still provisioned without it.
58
+ * The union has to carry it or every caller reads a blocked key as a
59
+ * successful one.
60
+ */
61
+ action: 'created' | 'reused' | 'updated' | 'blocked';
56
62
  keyPrefix?: string;
57
63
  rawKey?: string;
58
64
  rawSecret?: string;
65
+ allowedDomains?: string[];
66
+ /** Present on `blocked`: what is missing and what to do about it. */
67
+ reason?: string;
59
68
  };
60
69
  integration: {
61
70
  apiUrl: string | null;
@@ -91,6 +100,14 @@ export interface ProvisionedAppSummary {
91
100
  verification_status: string;
92
101
  }>;
93
102
  }
103
+ /** A widget key as the org sees it back: no raw value, no secret, ever. */
104
+ export interface ProvisionedWidgetKey {
105
+ id: number;
106
+ name: string;
107
+ keyPrefix: string;
108
+ allowedDomains: string[];
109
+ isActive: boolean;
110
+ }
94
111
  export declare class AppilotApiError extends Error {
95
112
  readonly status: number;
96
113
  readonly body?: unknown;
@@ -169,6 +186,13 @@ export declare class AppilotClient {
169
186
  errors: string[];
170
187
  }>;
171
188
  listProvisioned(): Promise<ProvisionedAppSummary[]>;
189
+ /**
190
+ * The org's widget keys, prefixes only.
191
+ *
192
+ * A caller that cannot see the keys it already minted asks for another every
193
+ * run, and each one is a live credential.
194
+ */
195
+ listWidgetKeys(): Promise<ProvisionedWidgetKey[]>;
172
196
  provisionApp(body: ProvisionAppRequest): Promise<ProvisionAppResponse>;
173
197
  /**
174
198
  * Domain check: does this hostname resolve to a tenant? Public endpoint, so
@@ -180,6 +204,17 @@ export declare class AppilotClient {
180
204
  * the tenant on every single run.
181
205
  */
182
206
  checkDomain(domain: string): Promise<Record<string, unknown>>;
207
+ /**
208
+ * The TXT record a domain needs, and where its verification stands.
209
+ *
210
+ * These two live on the apps router rather than under `/provision`, and that
211
+ * router authenticates an organization SESSION. A service token is refused
212
+ * there today, which is why `verify_domain` falls back to the provisioning
213
+ * dry run for the status and says plainly what it could not do.
214
+ */
215
+ domainVerification(domainId: number): Promise<Record<string, unknown>>;
216
+ /** Ask Appilot to look for the TXT record now. */
217
+ triggerDomainVerification(domainId: number): Promise<Record<string, unknown>>;
183
218
  /** Who this credential is: org, app narrowing, scopes. Never a secret. */
184
219
  whoami(): Promise<Record<string, unknown>>;
185
220
  exportConfig(appId: number): Promise<import('appilot-shared/config-bundle').ConfigBundle>;
@@ -205,6 +240,15 @@ export declare class AppilotClient {
205
240
  * languages it had never claimed to support.
206
241
  */
207
242
  buildSnapshot(appId: number, expectedLocales?: string[]): Promise<ConfigSnapshot>;
243
+ /**
244
+ * Why an empty read should not be believed, or null when the credential is
245
+ * fine and the app genuinely has nothing.
246
+ *
247
+ * Only a 401 or a 403 counts. An instance too old to answer `whoami` at all
248
+ * says nothing about the token, and reporting a gap on that would be a
249
+ * confident false alarm on every on-premise deployment behind cloud.
250
+ */
251
+ private credentialRefusal;
208
252
  /**
209
253
  * Which locales this app's configuration is expected to cover: the union of
210
254
  * its domains' configured languages. Falls back to the trilingual baseline
package/dist/client.js CHANGED
@@ -116,7 +116,8 @@ export class AppilotClient {
116
116
  const text = await res.text();
117
117
  const body = text ? safeJson(text) : undefined;
118
118
  if (!res.ok) {
119
- throw new AppilotApiError(`${init.method ?? 'GET'} ${path} failed: ${describeError(body, res)}`, res.status, body);
119
+ const credential = res.status === 401 || res.status === 403 ? ` ${credentialAdvice(!!this.conn.token)}` : '';
120
+ throw new AppilotApiError(`${init.method ?? 'GET'} ${path} failed: ${describeError(body, res)}${credential}`, res.status, body);
120
121
  }
121
122
  return body;
122
123
  }
@@ -217,6 +218,16 @@ export class AppilotClient {
217
218
  listProvisioned() {
218
219
  return this.request('/provision/apps');
219
220
  }
221
+ /**
222
+ * The org's widget keys, prefixes only.
223
+ *
224
+ * A caller that cannot see the keys it already minted asks for another every
225
+ * run, and each one is a live credential.
226
+ */
227
+ async listWidgetKeys() {
228
+ const body = await this.request('/provision/widget-keys');
229
+ return Array.isArray(body?.widgetKeys) ? body.widgetKeys : [];
230
+ }
220
231
  provisionApp(body) {
221
232
  return this.request('/provision/app', { method: 'POST', body: JSON.stringify(body) });
222
233
  }
@@ -233,6 +244,21 @@ export class AppilotClient {
233
244
  const url = /^https?:\/\//i.test(domain) ? domain : `https://${domain}`;
234
245
  return this.request(`/domain/check?url=${encodeURIComponent(url)}`);
235
246
  }
247
+ /**
248
+ * The TXT record a domain needs, and where its verification stands.
249
+ *
250
+ * These two live on the apps router rather than under `/provision`, and that
251
+ * router authenticates an organization SESSION. A service token is refused
252
+ * there today, which is why `verify_domain` falls back to the provisioning
253
+ * dry run for the status and says plainly what it could not do.
254
+ */
255
+ domainVerification(domainId) {
256
+ return this.request(`/apps/domains/${domainId}/verification`);
257
+ }
258
+ /** Ask Appilot to look for the TXT record now. */
259
+ triggerDomainVerification(domainId) {
260
+ return this.request(`/apps/domains/${domainId}/verify`, { method: 'POST' });
261
+ }
236
262
  /** Who this credential is: org, app narrowing, scopes. Never a secret. */
237
263
  whoami() {
238
264
  return this.request('/config/whoami');
@@ -286,6 +312,17 @@ export class AppilotClient {
286
312
  read('knowledge', () => this.listKnowledge(appId), mapKnowledge),
287
313
  expectedLocales?.length ? Promise.resolve(expectedLocales) : this.resolveLocales(appId),
288
314
  ]);
315
+ // An empty knowledge list is the one read that can mean two things.
316
+ // `/knowledge/content` is an optional-auth route, so a missing or rejected
317
+ // bearer answers `200 []` instead of refusing, and from here that is
318
+ // indistinguishable from an app with no knowledge. Every knowledge lint
319
+ // then passes over a configuration nobody was allowed to look at, which is
320
+ // exactly the failure the `gaps` array exists to prevent.
321
+ if (knowledge.length === 0 && !gaps.some(g => g.entity === 'knowledge')) {
322
+ const refusal = await this.credentialRefusal();
323
+ if (refusal)
324
+ gaps.push({ entity: 'knowledge', reason: refusal });
325
+ }
289
326
  return {
290
327
  expectedLocales: locales,
291
328
  views,
@@ -298,6 +335,29 @@ export class AppilotClient {
298
335
  ...(gaps.length ? { gaps } : {}),
299
336
  };
300
337
  }
338
+ /**
339
+ * Why an empty read should not be believed, or null when the credential is
340
+ * fine and the app genuinely has nothing.
341
+ *
342
+ * Only a 401 or a 403 counts. An instance too old to answer `whoami` at all
343
+ * says nothing about the token, and reporting a gap on that would be a
344
+ * confident false alarm on every on-premise deployment behind cloud.
345
+ */
346
+ async credentialRefusal() {
347
+ if (!this.conn.token) {
348
+ return `No service token is set, so the knowledge read was anonymous and answered an empty list rather than refusing. ${credentialAdvice(false)}`;
349
+ }
350
+ try {
351
+ await this.whoami();
352
+ return null;
353
+ }
354
+ catch (err) {
355
+ if (err instanceof AppilotApiError && (err.status === 401 || err.status === 403)) {
356
+ return `The knowledge read returned nothing and the credential failed its own self-check, so the empty result is not evidence of an empty knowledge base. ${credentialAdvice(true)}`;
357
+ }
358
+ return null;
359
+ }
360
+ }
301
361
  /**
302
362
  * Which locales this app's configuration is expected to cover: the union of
303
363
  * its domains' configured languages. Falls back to the trilingual baseline
@@ -328,6 +388,19 @@ export class AppilotClient {
328
388
  return DEFAULT_EXPECTED_LOCALES;
329
389
  }
330
390
  }
391
+ /**
392
+ * What to do about a 401 or a 403, in the same voice as the offline errors.
393
+ *
394
+ * "Access denied. Token required." is the first thing a new developer meets and
395
+ * it names nothing: not the variable to set, not the screen the token comes
396
+ * from, not the preset to choose. The `APPILOT_BASE_URL` message next door does
397
+ * name all three, and it is the standard this one had to reach.
398
+ */
399
+ function credentialAdvice(hasToken) {
400
+ return hasToken
401
+ ? 'The credential was rejected. Check it in the Backoffice under Service tokens: it may be revoked, expired, or scoped to another app. A token narrowed to one app is refused on every other app in the organization.'
402
+ : 'No credential is set. Put a service token in APPILOT_PAT in the MCP client environment and restart the client. Mint one in the Backoffice under Service tokens: "Inspect only" to read and audit, "Edit configuration" to write, "Set up integrations" to create apps, domains and widget keys.';
403
+ }
331
404
  function safeJson(text) {
332
405
  try {
333
406
  return JSON.parse(text);
@@ -31,6 +31,19 @@ const CREATE_INTENT = /\b(create|add|new|update|edit|erstell|anleg|neu|hinzu|cre
31
31
  function localizedValues(text) {
32
32
  return Object.values(text).filter((v) => typeof v === 'string');
33
33
  }
34
+ /**
35
+ * `form_values` as a record, whatever arrived.
36
+ *
37
+ * The snapshot type says it is one, and a hand-authored or scaffolded plan can
38
+ * still carry null or a string. Reading it defensively keeps a malformed bundle
39
+ * a finding rather than an exception thrown out of the whole audit.
40
+ */
41
+ function authoredFormValues(plan) {
42
+ const raw = plan.form_values;
43
+ return raw && typeof raw === 'object' && !Array.isArray(raw)
44
+ ? raw
45
+ : {};
46
+ }
34
47
  // ---------------------------------------------------------------------------
35
48
  // Lint 1 — marker trust boundary (reuse appilot-shared).
36
49
  // ---------------------------------------------------------------------------
@@ -52,7 +65,7 @@ function lintMarkers(snap) {
52
65
  });
53
66
  }
54
67
  // form_values fields must belong to their form.
55
- for (const [formId, entry] of Object.entries(plan.form_values)) {
68
+ for (const [formId, entry] of Object.entries(authoredFormValues(plan))) {
56
69
  if (!formIds.has(formId)) {
57
70
  findings.push({
58
71
  severity: 'high', category: 'markers', entity: `action_plan:${plan.semantic_id}`,
@@ -60,8 +73,22 @@ function lintMarkers(snap) {
60
73
  });
61
74
  continue;
62
75
  }
76
+ // A malformed `form_values` entry is a FINDING, not a crash. The
77
+ // authored shape is `{ fields: [{ control_id, value }] }`, and a bundle
78
+ // carrying `{}` there used to take the whole contract down with
79
+ // "entry.fields is not iterable", so the one tool that could have named
80
+ // the defect answered with a stack trace instead.
81
+ const authored = entry;
82
+ if (!authored || !Array.isArray(authored.fields)) {
83
+ findings.push({
84
+ severity: 'high', category: 'markers', entity: `action_plan:${plan.semantic_id}`,
85
+ message: `form_values["${formId}"] is not in the authored shape { fields: [{ control_id, value }] }.`,
86
+ recommendation: 'Write an empty fields array when there are no authored defaults.',
87
+ });
88
+ continue;
89
+ }
63
90
  const fields = fieldsByForm.get(formId) ?? new Set();
64
- for (const f of entry.fields) {
91
+ for (const f of authored.fields) {
65
92
  if (!fields.has(f.control_id)) {
66
93
  findings.push({
67
94
  severity: 'high', category: 'markers', entity: `action_plan:${plan.semantic_id}`,
@@ -84,7 +111,7 @@ function lintActionability(snap) {
84
111
  const markers = planMarkers(plan);
85
112
  const hasFormStep = markers.some(m => m.kind === 'form');
86
113
  const hasSetValue = markers.some(m => m.kind === 'set_value' || m.kind === 'select');
87
- const hasFormValues = Object.keys(plan.form_values).length > 0;
114
+ const hasFormValues = Object.keys(authoredFormValues(plan)).length > 0;
88
115
  const entersValue = hasFormStep || hasSetValue || hasFormValues;
89
116
  const looksLikeCreate = localizedValues({ ...plan.name, ...plan.description }).some(v => CREATE_INTENT.test(v))
90
117
  || CREATE_INTENT.test(plan.semantic_id);
@@ -123,7 +150,7 @@ function lintActionability(snap) {
123
150
  if (!clicksSubmit)
124
151
  continue;
125
152
  const fillsForm = markers.some(m => m.kind === 'form' && m.id === form.semantic_id)
126
- || (plan.form_values[form.semantic_id]?.fields?.length ?? 0) > 0;
153
+ || (authoredFormValues(plan)[form.semantic_id]?.fields?.length ?? 0) > 0;
127
154
  if (!fillsForm) {
128
155
  findings.push({
129
156
  severity: 'high', category: 'actionability', entity: `action_plan:${plan.semantic_id}`,