appilot-mcp 0.2.1 → 0.4.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 +133 -27
- package/dist/appilot-configurator.mcpb +0 -0
- package/dist/cli.d.ts +34 -0
- package/dist/cli.js +173 -0
- package/dist/client.d.ts +45 -1
- package/dist/client.js +74 -1
- package/dist/config.d.ts +21 -0
- package/dist/config.js +6 -0
- package/dist/contract/healthContract.js +31 -4
- package/dist/index.bundle.js +2111 -826
- package/dist/index.d.ts +7 -1
- package/dist/index.js +22 -4
- 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/redaction.d.ts +51 -0
- package/dist/redaction.js +59 -0
- package/dist/remote/consent.d.ts +10 -2
- package/dist/remote/consent.js +16 -6
- package/dist/remote/consentMessages.d.ts +6 -1
- package/dist/remote/consentMessages.js +15 -6
- package/dist/remote/httpServer.d.ts +10 -0
- package/dist/remote/httpServer.js +126 -40
- package/dist/remote/oauth.d.ts +10 -1
- package/dist/remote/oauth.js +29 -11
- package/dist/scaffold.d.ts +68 -6
- package/dist/scaffold.js +424 -97
- package/dist/server.js +175 -18
- package/dist/userClient.d.ts +213 -0
- package/dist/userClient.js +400 -0
- package/dist/userServer.d.ts +47 -0
- package/dist/userServer.js +248 -0
- 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
|
@@ -2,6 +2,13 @@
|
|
|
2
2
|
* The remote Appilot MCP service: Streamable HTTP transport plus the OAuth
|
|
3
3
|
* authorization server that fronts it.
|
|
4
4
|
*
|
|
5
|
+
* One deployment answers for both servers, on two paths behind one authorization
|
|
6
|
+
* server. `/mcp` is Appilot Studio, which writes configuration for a developer.
|
|
7
|
+
* `/mcp/runtime` is Appilot, which operates a configured app for the person
|
|
8
|
+
* using it. They share the consent flow and nothing else: each path refuses a
|
|
9
|
+
* grant that was not approved for it, so a runtime connection cannot reach a
|
|
10
|
+
* Studio tool and a configuration connection cannot reach a runtime tool.
|
|
11
|
+
*
|
|
5
12
|
* Stateless by construction. Each request builds its own MCP server bound to the
|
|
6
13
|
* caller's own service token, which arrives sealed inside the bearer token and
|
|
7
14
|
* never crosses between callers. Nothing is retained between requests, so a
|
|
@@ -11,16 +18,20 @@
|
|
|
11
18
|
*/
|
|
12
19
|
import express from 'express';
|
|
13
20
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
14
|
-
import { mcpAuthRouter, getOAuthProtectedResourceMetadataUrl } from '@modelcontextprotocol/sdk/server/auth/router.js';
|
|
21
|
+
import { mcpAuthRouter, mcpAuthMetadataRouter, createOAuthMetadata, getOAuthProtectedResourceMetadataUrl, } from '@modelcontextprotocol/sdk/server/auth/router.js';
|
|
15
22
|
import { requireBearerAuth } from '@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js';
|
|
16
23
|
import { createAppilotServer } from '../server.js';
|
|
24
|
+
import { createAppilotRuntimeServer, RUNTIME_SCOPE, RUNTIME_SCOPE_HELP } from '../userServer.js';
|
|
17
25
|
import { consentLocale } from './consentMessages.js';
|
|
18
26
|
import { renderErrorPage } from './consent.js';
|
|
19
|
-
import { AppilotOAuthProvider, SUPPORTED_SCOPES } from './oauth.js';
|
|
27
|
+
import { AppilotOAuthProvider, SERVICE_TOKEN_SCOPES, SUPPORTED_SCOPES } from './oauth.js';
|
|
20
28
|
/** Where the docs root lands a person who follows the OAuth metadata. */
|
|
21
29
|
const CONFIGURE_WITH_AI_PATH = '/docs/developers/configure-with-ai/overview';
|
|
22
30
|
/** Body cap for a JSON-RPC request. A ConfigBundle import is the large one. */
|
|
23
31
|
const MAX_BODY = '32mb';
|
|
32
|
+
/** Where each server answers. One deployment, two MCP resources, one authorization server. */
|
|
33
|
+
export const STUDIO_MCP_PATH = '/mcp';
|
|
34
|
+
export const RUNTIME_MCP_PATH = '/mcp/runtime';
|
|
24
35
|
export function createRemoteApp(config, options = {}) {
|
|
25
36
|
const provider = options.provider ??
|
|
26
37
|
new AppilotOAuthProvider({
|
|
@@ -30,7 +41,8 @@ export function createRemoteApp(config, options = {}) {
|
|
|
30
41
|
handoffSecret: config.handoffSecret,
|
|
31
42
|
secret: config.secret,
|
|
32
43
|
});
|
|
33
|
-
const mcpUrl = new URL(
|
|
44
|
+
const mcpUrl = new URL(STUDIO_MCP_PATH, config.publicUrl);
|
|
45
|
+
const runtimeMcpUrl = new URL(RUNTIME_MCP_PATH, config.publicUrl);
|
|
34
46
|
const app = express();
|
|
35
47
|
app.disable('x-powered-by');
|
|
36
48
|
app.use(['/authorize', '/consent', '/connect/callback'], (_req, res, next) => {
|
|
@@ -49,6 +61,26 @@ export function createRemoteApp(config, options = {}) {
|
|
|
49
61
|
app.get(['/health', '/healthz'], (_req, res) => {
|
|
50
62
|
res.json({ status: 'ok', transport: 'http', instance: config.baseUrl });
|
|
51
63
|
});
|
|
64
|
+
// The runtime resource's own metadata, mounted first.
|
|
65
|
+
//
|
|
66
|
+
// RFC 9728 puts a resource's metadata at a path-specific well-known URL, and
|
|
67
|
+
// the SDK mounts each one with a prefix match. `/…/mcp` therefore also matches
|
|
68
|
+
// `/…/mcp/runtime`, so the more specific resource has to be registered ahead
|
|
69
|
+
// of the more general one or a runtime client discovers the Studio resource
|
|
70
|
+
// and asks for the wrong scope.
|
|
71
|
+
app.use(mcpAuthMetadataRouter({
|
|
72
|
+
oauthMetadata: createOAuthMetadata({
|
|
73
|
+
provider,
|
|
74
|
+
issuerUrl: config.publicUrl,
|
|
75
|
+
baseUrl: config.publicUrl,
|
|
76
|
+
scopesSupported: [...SUPPORTED_SCOPES],
|
|
77
|
+
serviceDocumentationUrl: new URL(CONFIGURE_WITH_AI_PATH, config.docsUrl),
|
|
78
|
+
}),
|
|
79
|
+
resourceServerUrl: runtimeMcpUrl,
|
|
80
|
+
resourceName: 'Appilot',
|
|
81
|
+
scopesSupported: [RUNTIME_SCOPE],
|
|
82
|
+
serviceDocumentationUrl: new URL(CONFIGURE_WITH_AI_PATH, config.docsUrl),
|
|
83
|
+
}));
|
|
52
84
|
// /.well-known/oauth-authorization-server, /authorize, /token, /register.
|
|
53
85
|
// Must be mounted at the application root.
|
|
54
86
|
app.use(mcpAuthRouter({
|
|
@@ -67,7 +99,7 @@ export function createRemoteApp(config, options = {}) {
|
|
|
67
99
|
res.redirect(303, await provider.finishConnection(id, code, req.get('Cookie') ?? '', res));
|
|
68
100
|
}
|
|
69
101
|
catch {
|
|
70
|
-
res.status(400).type('html').send(renderErrorPage('
|
|
102
|
+
res.status(400).type('html').send(renderErrorPage('failed', 'failedHelp', consentLocale(req.get('Accept-Language'))));
|
|
71
103
|
}
|
|
72
104
|
});
|
|
73
105
|
// The consent screen posts here. Form-encoded, same-origin, no JSON.
|
|
@@ -81,50 +113,102 @@ export function createRemoteApp(config, options = {}) {
|
|
|
81
113
|
res.status(outcome.status).set('Content-Type', 'text/html; charset=utf-8').send(outcome.html);
|
|
82
114
|
}
|
|
83
115
|
catch {
|
|
84
|
-
res.status(500).set('Content-Type', 'text/html; charset=utf-8').send(renderErrorPage('
|
|
116
|
+
res.status(500).set('Content-Type', 'text/html; charset=utf-8').send(renderErrorPage('unavailableTitle', 'unavailableDetail', consentLocale(req.get('Accept-Language'))));
|
|
85
117
|
}
|
|
86
118
|
});
|
|
87
119
|
const requireAuth = requireBearerAuth({
|
|
88
120
|
verifier: provider,
|
|
89
121
|
resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpUrl),
|
|
90
122
|
});
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
123
|
+
const requireRuntimeAuth = requireBearerAuth({
|
|
124
|
+
verifier: provider,
|
|
125
|
+
resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(runtimeMcpUrl),
|
|
126
|
+
});
|
|
127
|
+
/**
|
|
128
|
+
* Keep the two servers apart at the door.
|
|
129
|
+
*
|
|
130
|
+
* The tools enforce their own scopes one layer in, and that is not enough on
|
|
131
|
+
* its own: several Studio tools are pure and need no scope, so a runtime-only
|
|
132
|
+
* grant could still reach them if the endpoint let it through. The endpoint
|
|
133
|
+
* therefore decides first. Studio needs at least one configuration scope,
|
|
134
|
+
* Appilot needs `runtime:use`, and a grant approved for both reaches both,
|
|
135
|
+
* which is what approving both means.
|
|
136
|
+
*/
|
|
137
|
+
function wrongServer(scopes, wanted) {
|
|
138
|
+
const granted = scopes ?? [];
|
|
139
|
+
const reaches = wanted === 'runtime'
|
|
140
|
+
? granted.includes(RUNTIME_SCOPE)
|
|
141
|
+
: granted.some(scope => SERVICE_TOKEN_SCOPES.includes(scope));
|
|
142
|
+
if (reaches)
|
|
143
|
+
return null;
|
|
144
|
+
const held = granted.length ? granted.join(', ') : 'no scopes';
|
|
145
|
+
return wanted === 'runtime'
|
|
146
|
+
? `This connection was approved for ${held}, which does not include ${RUNTIME_SCOPE}, so it reaches Appilot Studio at ${STUDIO_MCP_PATH} and not the Appilot connector. ${RUNTIME_SCOPE_HELP}`
|
|
147
|
+
: `This connection was approved for ${held}, which carries no configuration scope, so it reaches the Appilot connector at ${RUNTIME_MCP_PATH} and not Appilot Studio. Studio is a separate connection: add it in your assistant and approve it with a service token that carries config:read or config:write, which an organization administrator mints in the Backoffice under Service tokens.`;
|
|
148
|
+
}
|
|
149
|
+
function mcpEndpoint(surface) {
|
|
150
|
+
return async (req, res) => {
|
|
151
|
+
const auth = req.auth;
|
|
152
|
+
const extra = (auth?.extra ?? {});
|
|
153
|
+
const refusal = wrongServer(auth?.scopes, surface);
|
|
154
|
+
if (refusal) {
|
|
155
|
+
res.status(403).json({
|
|
121
156
|
jsonrpc: '2.0',
|
|
122
|
-
error: { code: -
|
|
157
|
+
error: { code: -32001, message: refusal },
|
|
123
158
|
id: null,
|
|
124
159
|
});
|
|
160
|
+
return;
|
|
125
161
|
}
|
|
126
|
-
|
|
127
|
-
|
|
162
|
+
// One server and one transport per request. The connection profile is the
|
|
163
|
+
// caller's own: the deployment's fixed instance URL, the credential the
|
|
164
|
+
// approval sealed, and the scopes the person actually approved, which the
|
|
165
|
+
// tool surface enforces so the consent screen describes a real limit.
|
|
166
|
+
//
|
|
167
|
+
// The sealed credential is a service token for Studio and the person's
|
|
168
|
+
// own Appilot session for the connector. It is read here, never echoed,
|
|
169
|
+
// and the two never reach the same server.
|
|
170
|
+
const server = surface === 'runtime'
|
|
171
|
+
? createAppilotRuntimeServer({
|
|
172
|
+
baseUrl: config.baseUrl,
|
|
173
|
+
sessionToken: extra.pat,
|
|
174
|
+
transport: 'http',
|
|
175
|
+
grantedScopes: auth?.scopes ?? [],
|
|
176
|
+
})
|
|
177
|
+
: createAppilotServer({
|
|
178
|
+
baseUrl: config.baseUrl,
|
|
179
|
+
token: extra.pat,
|
|
180
|
+
defaultAppId: extra.appId,
|
|
181
|
+
transport: 'http',
|
|
182
|
+
grantedScopes: auth?.scopes ?? [],
|
|
183
|
+
});
|
|
184
|
+
const transport = new StreamableHTTPServerTransport({
|
|
185
|
+
sessionIdGenerator: undefined,
|
|
186
|
+
enableDnsRebindingProtection: true,
|
|
187
|
+
allowedHosts: config.allowedHosts,
|
|
188
|
+
});
|
|
189
|
+
res.on('close', () => {
|
|
190
|
+
void transport.close();
|
|
191
|
+
void server.close();
|
|
192
|
+
});
|
|
193
|
+
try {
|
|
194
|
+
await server.connect(transport);
|
|
195
|
+
await transport.handleRequest(req, res, req.body);
|
|
196
|
+
}
|
|
197
|
+
catch (err) {
|
|
198
|
+
if (!res.headersSent) {
|
|
199
|
+
res.status(500).json({
|
|
200
|
+
jsonrpc: '2.0',
|
|
201
|
+
error: { code: -32603, message: err instanceof Error ? err.message : 'Internal error' },
|
|
202
|
+
id: null,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
// The runtime path is registered first: express matches in order, and `/mcp`
|
|
209
|
+
// is a prefix of it in every other place this file touches.
|
|
210
|
+
app.post(RUNTIME_MCP_PATH, requireRuntimeAuth, express.json({ limit: MAX_BODY }), mcpEndpoint('runtime'));
|
|
211
|
+
app.post(STUDIO_MCP_PATH, requireAuth, express.json({ limit: MAX_BODY }), mcpEndpoint('studio'));
|
|
128
212
|
// Stateless: there is no stream to resume and no session to delete.
|
|
129
213
|
const methodNotAllowed = (_req, res) => {
|
|
130
214
|
res.status(405).set('Allow', 'POST').json({
|
|
@@ -133,8 +217,10 @@ export function createRemoteApp(config, options = {}) {
|
|
|
133
217
|
id: null,
|
|
134
218
|
});
|
|
135
219
|
};
|
|
136
|
-
|
|
137
|
-
|
|
220
|
+
for (const path of [STUDIO_MCP_PATH, RUNTIME_MCP_PATH]) {
|
|
221
|
+
app.get(path, methodNotAllowed);
|
|
222
|
+
app.delete(path, methodNotAllowed);
|
|
223
|
+
}
|
|
138
224
|
return app;
|
|
139
225
|
}
|
|
140
226
|
export function startRemote(config) {
|
package/dist/remote/oauth.d.ts
CHANGED
|
@@ -34,8 +34,17 @@ import { type ConsentLocale } from './consentMessages.js';
|
|
|
34
34
|
* `feedback:write` is the one scope that governs sending data OUT of the
|
|
35
35
|
* tenant, which is why it is separate from the config scopes rather than folded
|
|
36
36
|
* into them.
|
|
37
|
+
*
|
|
38
|
+
* `runtime:use` is the odd one and it is the reason this list is not simply the
|
|
39
|
+
* service-token scopes. It authorizes the Appilot connector, which holds a
|
|
40
|
+
* person's own session and operates their app through the page they are looking
|
|
41
|
+
* at. It grants nothing over configuration, and the four config scopes grant
|
|
42
|
+
* nothing over the runtime surface: the two servers sit on separate paths, each
|
|
43
|
+
* refusing a grant that was not approved for it (see `remote/httpServer.ts`).
|
|
37
44
|
*/
|
|
38
|
-
export declare const SUPPORTED_SCOPES: readonly ["config:read", "config:write", "provision:write", "feedback:write"];
|
|
45
|
+
export declare const SUPPORTED_SCOPES: readonly ["config:read", "config:write", "provision:write", "feedback:write", "runtime:use"];
|
|
46
|
+
/** The scopes a service token can carry. `runtime:use` is not one of them. */
|
|
47
|
+
export declare const SERVICE_TOKEN_SCOPES: readonly string[];
|
|
39
48
|
/** What the instance says a credential reaches. Absent on instances without /config/whoami. */
|
|
40
49
|
export interface TokenIdentity {
|
|
41
50
|
organizationName?: string | null;
|
package/dist/remote/oauth.js
CHANGED
|
@@ -35,12 +35,27 @@ import { renderConsentPage, renderConfirmPage, renderErrorPage } from './consent
|
|
|
35
35
|
* `feedback:write` is the one scope that governs sending data OUT of the
|
|
36
36
|
* tenant, which is why it is separate from the config scopes rather than folded
|
|
37
37
|
* into them.
|
|
38
|
+
*
|
|
39
|
+
* `runtime:use` is the odd one and it is the reason this list is not simply the
|
|
40
|
+
* service-token scopes. It authorizes the Appilot connector, which holds a
|
|
41
|
+
* person's own session and operates their app through the page they are looking
|
|
42
|
+
* at. It grants nothing over configuration, and the four config scopes grant
|
|
43
|
+
* nothing over the runtime surface: the two servers sit on separate paths, each
|
|
44
|
+
* refusing a grant that was not approved for it (see `remote/httpServer.ts`).
|
|
38
45
|
*/
|
|
39
46
|
export const SUPPORTED_SCOPES = [
|
|
40
47
|
'config:read',
|
|
41
48
|
'config:write',
|
|
42
49
|
'provision:write',
|
|
43
50
|
'feedback:write',
|
|
51
|
+
'runtime:use',
|
|
52
|
+
];
|
|
53
|
+
/** The scopes a service token can carry. `runtime:use` is not one of them. */
|
|
54
|
+
export const SERVICE_TOKEN_SCOPES = [
|
|
55
|
+
'config:read',
|
|
56
|
+
'config:write',
|
|
57
|
+
'provision:write',
|
|
58
|
+
'feedback:write',
|
|
44
59
|
];
|
|
45
60
|
/**
|
|
46
61
|
* What to ask for when the client asks for nothing.
|
|
@@ -112,8 +127,11 @@ async function defaultVerifyServiceToken(baseUrl, pat) {
|
|
|
112
127
|
if (res.status === 401 || res.status === 403)
|
|
113
128
|
return null;
|
|
114
129
|
if (res.status === 404) {
|
|
115
|
-
// Instance predates the self-check. Accept the token on shape alone
|
|
116
|
-
|
|
130
|
+
// Instance predates the self-check. Accept the token on shape alone, and
|
|
131
|
+
// only for what a service token can ever carry: `runtime:use` names a
|
|
132
|
+
// person's session, so an instance that cannot answer must not be read as
|
|
133
|
+
// having said yes to it.
|
|
134
|
+
return { organizationId: null, appId: null, scopes: [...SERVICE_TOKEN_SCOPES] };
|
|
117
135
|
}
|
|
118
136
|
if (!res.ok) {
|
|
119
137
|
throw new ServerError(`The Appilot instance answered ${res.status} while verifying the service token.`);
|
|
@@ -188,7 +206,7 @@ export class AppilotOAuthProvider {
|
|
|
188
206
|
const sealedRequest = await this.sealAuthRequest({
|
|
189
207
|
language,
|
|
190
208
|
client_id: client.client_id,
|
|
191
|
-
client_name: client.client_name || '
|
|
209
|
+
client_name: client.client_name || message('clientFallback', language),
|
|
192
210
|
redirect_uri: params.redirectUri,
|
|
193
211
|
code_challenge: params.codeChallenge,
|
|
194
212
|
state: params.state,
|
|
@@ -204,7 +222,7 @@ export class AppilotOAuthProvider {
|
|
|
204
222
|
secret: this.options.handoffSecret,
|
|
205
223
|
backofficeUrl: this.options.backofficeUrl,
|
|
206
224
|
secure: this.options.publicUrl.protocol === 'https:',
|
|
207
|
-
}, sealedRequest, client.client_id, client.client_name || '
|
|
225
|
+
}, sealedRequest, client.client_id, client.client_name || message('clientFallback', language), requested.length ? requested : [...DEFAULT_REQUESTED_SCOPES], language, res);
|
|
208
226
|
return;
|
|
209
227
|
}
|
|
210
228
|
catch {
|
|
@@ -215,7 +233,7 @@ export class AppilotOAuthProvider {
|
|
|
215
233
|
locale: language,
|
|
216
234
|
request: sealedRequest,
|
|
217
235
|
action: this.consentPath,
|
|
218
|
-
clientName: client.client_name || '
|
|
236
|
+
clientName: client.client_name || message('clientFallback', language),
|
|
219
237
|
baseUrl: this.options.baseUrl,
|
|
220
238
|
backofficeUrl: this.options.backofficeUrl,
|
|
221
239
|
scopes: requested.length ? requested : [...DEFAULT_REQUESTED_SCOPES],
|
|
@@ -228,7 +246,7 @@ export class AppilotOAuthProvider {
|
|
|
228
246
|
locale: language,
|
|
229
247
|
request: sealedRequest,
|
|
230
248
|
action: this.consentPath,
|
|
231
|
-
clientName: client.client_name || '
|
|
249
|
+
clientName: client.client_name || message('clientFallback', language),
|
|
232
250
|
baseUrl: this.options.baseUrl,
|
|
233
251
|
backofficeUrl: this.options.backofficeUrl,
|
|
234
252
|
scopes: requested.length ? requested : [...DEFAULT_REQUESTED_SCOPES],
|
|
@@ -304,14 +322,14 @@ export class AppilotOAuthProvider {
|
|
|
304
322
|
catch {
|
|
305
323
|
return {
|
|
306
324
|
status: 400,
|
|
307
|
-
html: renderErrorPage('
|
|
325
|
+
html: renderErrorPage('expired', 'expiredHelp', fallbackLocale),
|
|
308
326
|
};
|
|
309
327
|
}
|
|
310
328
|
const locale = request.language ?? fallbackLocale;
|
|
311
329
|
if (request.stage !== 'request' && request.stage !== 'confirm') {
|
|
312
330
|
return {
|
|
313
331
|
status: 400,
|
|
314
|
-
html: renderErrorPage('
|
|
332
|
+
html: renderErrorPage('invalid', 'invalidHelp', locale),
|
|
315
333
|
};
|
|
316
334
|
}
|
|
317
335
|
const redirect = new URL(request.redirect_uri);
|
|
@@ -335,7 +353,7 @@ export class AppilotOAuthProvider {
|
|
|
335
353
|
locale,
|
|
336
354
|
request: await this.sealAuthRequest({ ...original, scopes }),
|
|
337
355
|
action: this.consentPath,
|
|
338
|
-
clientName: request.client_name || '
|
|
356
|
+
clientName: request.client_name || message('clientFallback', locale),
|
|
339
357
|
baseUrl: this.options.baseUrl,
|
|
340
358
|
backofficeUrl: this.options.backofficeUrl,
|
|
341
359
|
scopes,
|
|
@@ -345,7 +363,7 @@ export class AppilotOAuthProvider {
|
|
|
345
363
|
if (form.action !== 'confirm' || !request.pat || !request.identity) {
|
|
346
364
|
return {
|
|
347
365
|
status: 400,
|
|
348
|
-
html: renderErrorPage('
|
|
366
|
+
html: renderErrorPage('invalid', 'invalidHelp', locale),
|
|
349
367
|
};
|
|
350
368
|
}
|
|
351
369
|
return {
|
|
@@ -364,7 +382,7 @@ export class AppilotOAuthProvider {
|
|
|
364
382
|
locale,
|
|
365
383
|
request: rawRequest,
|
|
366
384
|
action: this.consentPath,
|
|
367
|
-
clientName: request.client_name || '
|
|
385
|
+
clientName: request.client_name || message('clientFallback', locale),
|
|
368
386
|
baseUrl: this.options.baseUrl,
|
|
369
387
|
backofficeUrl: this.options.backofficeUrl,
|
|
370
388
|
scopes: request.scopes,
|
package/dist/scaffold.d.ts
CHANGED
|
@@ -10,8 +10,23 @@
|
|
|
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
|
+
/**
|
|
21
|
+
* The frameworks with a first-class relay, plus `other`.
|
|
22
|
+
*
|
|
23
|
+
* `appilot-server` is a Node package, so a Django, Rails or PHP host cannot use
|
|
24
|
+
* it. That is not a reason to answer a raw Zod enum dump: the exchange is one
|
|
25
|
+
* authenticated HTTPS call, and `other` returns it as curl plus a Python and a
|
|
26
|
+
* Ruby handler.
|
|
13
27
|
*/
|
|
14
|
-
export
|
|
28
|
+
export declare const SCAFFOLD_FRAMEWORKS: readonly ["next", "express", "fastify", "hono", "remix", "sveltekit", "other"];
|
|
29
|
+
export type Framework = (typeof SCAFFOLD_FRAMEWORKS)[number];
|
|
15
30
|
export interface ScaffoldFile {
|
|
16
31
|
/** Suggested path, relative to the repository root. The agent may move it. */
|
|
17
32
|
path: string;
|
|
@@ -49,19 +64,38 @@ export declare function scaffoldIntegration(options: ScaffoldOptions): ScaffoldR
|
|
|
49
64
|
* A step-by-step knowledge article is a procedure in the wrong place, and it
|
|
50
65
|
* teaches the agent to author steps instead of adopting the plan that already
|
|
51
66
|
* exists. Emitting both halves correctly is how a scaffold teaches that once.
|
|
67
|
+
*
|
|
68
|
+
* Everything emitted here is in the shape the API accepts, and
|
|
69
|
+
* `test/scaffoldAgentFirst.test.ts` proves it by running the output through the
|
|
70
|
+
* shared action-plan schemas and through the health contract. The previous
|
|
71
|
+
* version emitted `{ title, steps: [{ text }] }` sections and a `form_values`
|
|
72
|
+
* entry with no `fields`, so the tool meant to make an app agent-first produced
|
|
73
|
+
* a plan `create_entity` rejected and `runHealthContract` crashed on.
|
|
52
74
|
*/
|
|
53
75
|
export interface AgentFirstScaffold {
|
|
54
76
|
capability: string;
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
77
|
+
shape: CapabilityShape;
|
|
78
|
+
/** Ready for `create_entity({ kind: 'tool' })`, minus the credential. Null for a page-only capability. */
|
|
79
|
+
tool: Record<string, unknown> | null;
|
|
80
|
+
/** Ready for `create_entity({ kind: 'action_plan' })` once the control ids exist. Null when the capability has no in-page procedure. */
|
|
81
|
+
actionPlan: Record<string, unknown> | null;
|
|
82
|
+
/** Ready for `create_entity({ kind: 'knowledge' })`. */
|
|
60
83
|
knowledge: Record<string, unknown>;
|
|
61
84
|
files: ScaffoldFile[];
|
|
62
85
|
order: string[];
|
|
63
86
|
notes: string[];
|
|
64
87
|
}
|
|
88
|
+
/**
|
|
89
|
+
* What the capability does to the app, which decides whether a plan makes sense
|
|
90
|
+
* and what its steps are.
|
|
91
|
+
*
|
|
92
|
+
* `create` is a form the user fills and submits. `navigate` takes them to a
|
|
93
|
+
* place. `read` answers a question, and it gets NO action plan: a plan whose
|
|
94
|
+
* only step opens a screen does nothing, and the health contract says so. The
|
|
95
|
+
* scaffold used to emit the open/fill/submit template for all three, so asking
|
|
96
|
+
* it for "Show a booking" produced a plan that filled a form nobody had.
|
|
97
|
+
*/
|
|
98
|
+
export type CapabilityShape = 'create' | 'navigate' | 'read';
|
|
65
99
|
interface AgentFirstOptions {
|
|
66
100
|
/** What the user is trying to do, in their words. */
|
|
67
101
|
capability: string;
|
|
@@ -79,6 +113,34 @@ interface AgentFirstOptions {
|
|
|
79
113
|
} | null;
|
|
80
114
|
/** True when the operation is UI-coupled and belongs in the page instead. */
|
|
81
115
|
clientSide?: boolean;
|
|
116
|
+
/** What the capability does. Defaults to `create`. */
|
|
117
|
+
shape?: CapabilityShape;
|
|
118
|
+
/** Where the plan's steps run. Defaults to `/`. */
|
|
119
|
+
viewPath?: string;
|
|
82
120
|
}
|
|
83
121
|
export declare function scaffoldAgentFirst(options: AgentFirstOptions): AgentFirstScaffold;
|
|
122
|
+
export interface IntegrationSnippet {
|
|
123
|
+
apiUrl: string | null;
|
|
124
|
+
scriptTag: string;
|
|
125
|
+
bootSnippet: string;
|
|
126
|
+
tokenEndpointHint: string;
|
|
127
|
+
publicEnvName: string;
|
|
128
|
+
notes: string[];
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* The script tag and the boot call for an app that is already provisioned.
|
|
132
|
+
*
|
|
133
|
+
* `create_app` returns these, and returning them was the only way to get them,
|
|
134
|
+
* so a developer who had lost the snippet re-ran a provisioning write to read
|
|
135
|
+
* one line of HTML. They are composed from the connection and the key, so this
|
|
136
|
+
* is pure: it calls nothing and needs no scope. The shape matches what the
|
|
137
|
+
* backend's own `buildIntegration` returns, deliberately, so a curl caller and
|
|
138
|
+
* an agent read the same thing.
|
|
139
|
+
*/
|
|
140
|
+
export declare function integrationSnippet(options: {
|
|
141
|
+
widgetScriptUrl: string;
|
|
142
|
+
apiUrl?: string | null;
|
|
143
|
+
widgetKey?: string | null;
|
|
144
|
+
framework?: Framework;
|
|
145
|
+
}): IntegrationSnippet;
|
|
84
146
|
export {};
|