appilot-mcp 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/README.md +30 -10
- package/dist/appilot-configurator.mcpb +0 -0
- package/dist/client.d.ts +77 -2
- package/dist/client.js +233 -31
- package/dist/config.d.ts +14 -0
- package/dist/config.js +19 -0
- package/dist/contract/bundleSnapshot.js +8 -1
- package/dist/contract/healthContract.d.ts +1 -1
- package/dist/contract/healthContract.js +69 -6
- package/dist/contract/types.d.ts +37 -1
- package/dist/index.bundle.js +3786 -16475
- package/dist/inspect.d.ts +88 -0
- package/dist/inspect.js +384 -0
- package/dist/redaction.d.ts +18 -3
- package/dist/redaction.js +27 -3
- package/dist/remote/consent.d.ts +22 -20
- package/dist/remote/consent.js +105 -82
- package/dist/remote/consentMessages.d.ts +62 -0
- package/dist/remote/consentMessages.js +196 -0
- package/dist/remote/handoff.d.ts +10 -0
- package/dist/remote/handoff.js +44 -0
- package/dist/remote/httpServer.js +28 -5
- package/dist/remote/oauth.d.ts +39 -6
- package/dist/remote/oauth.js +280 -35
- package/dist/scaffold.d.ts +47 -0
- package/dist/scaffold.js +108 -0
- package/dist/server.js +278 -24
- package/dist/soak.js +21 -1
- package/dist/templates.d.ts +62 -0
- package/dist/templates.js +255 -0
- package/dist/verify.js +18 -1
- package/dist/version.d.ts +14 -0
- package/dist/version.js +14 -0
- package/mcpb/manifest.json +11 -5
- package/package.json +1 -1
- package/skills/app-configurator/SKILL.md +79 -10
package/dist/remote/oauth.d.ts
CHANGED
|
@@ -21,15 +21,33 @@ import type { OAuthServerProvider, AuthorizationParams } from '@modelcontextprot
|
|
|
21
21
|
import type { OAuthRegisteredClientsStore } from '@modelcontextprotocol/sdk/server/auth/clients.js';
|
|
22
22
|
import type { OAuthClientInformationFull, OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth.js';
|
|
23
23
|
import type { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js';
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
import { type ConsentLocale } from './consentMessages.js';
|
|
25
|
+
/**
|
|
26
|
+
* Scopes this authorization server can grant, mirroring the service-token scopes.
|
|
27
|
+
*
|
|
28
|
+
* `provision:write` belongs here even though the service never mints it: the
|
|
29
|
+
* sealed service token carries whatever scopes it was minted with, so leaving
|
|
30
|
+
* provisioning off this list did not remove the power, it only stopped the
|
|
31
|
+
* consent screen from disclosing it. The grant is now enforced per tool
|
|
32
|
+
* (see `server.ts`), which is what makes the screen's promise real.
|
|
33
|
+
*
|
|
34
|
+
* `feedback:write` is the one scope that governs sending data OUT of the
|
|
35
|
+
* tenant, which is why it is separate from the config scopes rather than folded
|
|
36
|
+
* into them.
|
|
37
|
+
*/
|
|
38
|
+
export declare const SUPPORTED_SCOPES: readonly ["config:read", "config:write", "provision:write", "feedback:write"];
|
|
26
39
|
/** What the instance says a credential reaches. Absent on instances without /config/whoami. */
|
|
27
40
|
export interface TokenIdentity {
|
|
41
|
+
organizationName?: string | null;
|
|
42
|
+
appName?: string | null;
|
|
28
43
|
organizationId: number | null;
|
|
29
44
|
appId: number | null;
|
|
30
45
|
scopes: string[];
|
|
31
46
|
}
|
|
32
47
|
export interface AppilotOAuthOptions {
|
|
48
|
+
handoffSecret?: string;
|
|
49
|
+
/** Trusted Backoffice origin configured by the operator, never a request parameter. */
|
|
50
|
+
backofficeUrl?: string;
|
|
33
51
|
/** Public origin of this service, used to build the consent form action. */
|
|
34
52
|
publicUrl: URL;
|
|
35
53
|
/** The Appilot backend this deployment serves. */
|
|
@@ -44,6 +62,7 @@ export declare class AppilotOAuthProvider implements OAuthServerProvider {
|
|
|
44
62
|
private readonly sealer;
|
|
45
63
|
private readonly consentPath;
|
|
46
64
|
private readonly verifyServiceToken;
|
|
65
|
+
private readonly redeemed;
|
|
47
66
|
constructor(options: AppilotOAuthOptions);
|
|
48
67
|
/**
|
|
49
68
|
* Client registrations are sealed into the client_id itself, so dynamic
|
|
@@ -52,19 +71,33 @@ export declare class AppilotOAuthProvider implements OAuthServerProvider {
|
|
|
52
71
|
*/
|
|
53
72
|
get clientsStore(): OAuthRegisteredClientsStore;
|
|
54
73
|
authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise<void>;
|
|
74
|
+
/** Complete only in the browser that initiated this OAuth request. */
|
|
75
|
+
finishConnection(id: string, code: string, cookieHeader: string, res: Response): Promise<string>;
|
|
55
76
|
/** The authorization request travels through the consent form, so it is sealed too. */
|
|
56
77
|
private sealAuthRequest;
|
|
57
78
|
/**
|
|
58
|
-
* Handle the consent form post
|
|
59
|
-
*
|
|
60
|
-
*
|
|
79
|
+
* Handle the consent form post.
|
|
80
|
+
*
|
|
81
|
+
* Two stages, because the first screen cannot yet say what it is granting.
|
|
82
|
+
* The service only learns which organization a token reaches, and which
|
|
83
|
+
* scopes it really carries, by asking the instance about the token the person
|
|
84
|
+
* just pasted. Redirecting straight from there would mean the one screen a
|
|
85
|
+
* person sees never names the org the connection will reach or the
|
|
86
|
+
* provisioning power the token may carry. So a verified token renders a
|
|
87
|
+
* confirmation naming both, and only that confirmation issues a code.
|
|
88
|
+
*
|
|
89
|
+
* `stage=request` the pasted-token form (sealed by `authorize`)
|
|
90
|
+
* `stage=confirm` what the token actually reaches, awaiting approval
|
|
91
|
+
* `stage=code` the authorization code itself
|
|
61
92
|
*/
|
|
62
|
-
handleConsent(form: Record<string, unknown
|
|
93
|
+
handleConsent(form: Record<string, unknown>, fallbackLocale?: ConsentLocale): Promise<{
|
|
63
94
|
redirect: string;
|
|
64
95
|
} | {
|
|
65
96
|
html: string;
|
|
66
97
|
status: number;
|
|
67
98
|
}>;
|
|
99
|
+
/** Seal the authorization code and hang it off the client's redirect. */
|
|
100
|
+
private issueCode;
|
|
68
101
|
private openCode;
|
|
69
102
|
challengeForAuthorizationCode(client: OAuthClientInformationFull, authorizationCode: string): Promise<string>;
|
|
70
103
|
exchangeAuthorizationCode(client: OAuthClientInformationFull, authorizationCode: string, _codeVerifier?: string, redirectUri?: string): Promise<OAuthTokens>;
|
package/dist/remote/oauth.js
CHANGED
|
@@ -17,15 +17,81 @@
|
|
|
17
17
|
* Spec: docs/architecture/appilot-mcp.md, section "Remote deployment".
|
|
18
18
|
*/
|
|
19
19
|
import { InvalidGrantError, InvalidTokenError, InvalidClientError, ServerError, } from '@modelcontextprotocol/sdk/server/auth/errors.js';
|
|
20
|
+
import { consentLocale, message } from './consentMessages.js';
|
|
21
|
+
import { cookieName, connectionRequest, startConnection } from './handoff.js';
|
|
22
|
+
import { randomUUID } from 'node:crypto';
|
|
23
|
+
import { SERVICE_TOKEN_PREFIX } from 'appilot-shared/types';
|
|
20
24
|
import { TokenSealer, SealError } from './tokens.js';
|
|
21
|
-
import { renderConsentPage, renderErrorPage } from './consent.js';
|
|
22
|
-
/**
|
|
23
|
-
|
|
25
|
+
import { renderConsentPage, renderConfirmPage, renderErrorPage } from './consent.js';
|
|
26
|
+
/**
|
|
27
|
+
* Scopes this authorization server can grant, mirroring the service-token scopes.
|
|
28
|
+
*
|
|
29
|
+
* `provision:write` belongs here even though the service never mints it: the
|
|
30
|
+
* sealed service token carries whatever scopes it was minted with, so leaving
|
|
31
|
+
* provisioning off this list did not remove the power, it only stopped the
|
|
32
|
+
* consent screen from disclosing it. The grant is now enforced per tool
|
|
33
|
+
* (see `server.ts`), which is what makes the screen's promise real.
|
|
34
|
+
*
|
|
35
|
+
* `feedback:write` is the one scope that governs sending data OUT of the
|
|
36
|
+
* tenant, which is why it is separate from the config scopes rather than folded
|
|
37
|
+
* into them.
|
|
38
|
+
*/
|
|
39
|
+
export const SUPPORTED_SCOPES = [
|
|
40
|
+
'config:read',
|
|
41
|
+
'config:write',
|
|
42
|
+
'provision:write',
|
|
43
|
+
'feedback:write',
|
|
44
|
+
];
|
|
45
|
+
/**
|
|
46
|
+
* What to ask for when the client asks for nothing.
|
|
47
|
+
*
|
|
48
|
+
* Most clients send a `scope` parameter and get exactly what they asked for.
|
|
49
|
+
* Some send none, and the previous default of `config:read` alone meant that a
|
|
50
|
+
* connection made by such a client came out read-only however wide the pasted
|
|
51
|
+
* token was. That was survivable while the surface was mostly reads. It is not
|
|
52
|
+
* now: an agent would meet a refusal on nearly every authoring call, mid-task,
|
|
53
|
+
* with no way to widen the grant except reconnecting.
|
|
54
|
+
*
|
|
55
|
+
* Requesting everything is safe here because of where the grant is actually
|
|
56
|
+
* decided. It is capped by the token (`granted = requested ∩ token.scopes`),
|
|
57
|
+
* and the confirm screen names the exact set before any code is issued, so the
|
|
58
|
+
* person still approves the real thing. A client that deliberately sends a
|
|
59
|
+
* narrow scope keeps getting exactly that scope, and its refusals stay legible.
|
|
60
|
+
*/
|
|
61
|
+
const DEFAULT_REQUESTED_SCOPES = SUPPORTED_SCOPES;
|
|
24
62
|
const AUTH_REQUEST_TTL = 10 * 60;
|
|
63
|
+
const CONFIRM_TTL = 5 * 60;
|
|
25
64
|
const CODE_TTL = 60;
|
|
26
65
|
const ACCESS_TTL = 60 * 60;
|
|
27
66
|
const REFRESH_TTL = 90 * 24 * 60 * 60;
|
|
28
|
-
|
|
67
|
+
/**
|
|
68
|
+
* Replay guard for authorization codes.
|
|
69
|
+
*
|
|
70
|
+
* OAuth 2.1 requires a code to be single-use, and a sealed artifact cannot be
|
|
71
|
+
* deleted server-side, so the only thing that can be remembered is that it was
|
|
72
|
+
* already redeemed. Entries expire with the code itself, which bounds the set to
|
|
73
|
+
* the codes issued in the last minute. This is per-instance: a replay routed to
|
|
74
|
+
* a second container is not caught by it, and stays bounded by the 60-second TTL
|
|
75
|
+
* and by PKCE, which is where the guarantee sat before. Cheap, correct for the
|
|
76
|
+
* common case, and it keeps the service free of a database.
|
|
77
|
+
*/
|
|
78
|
+
class RedeemedCodes {
|
|
79
|
+
seen = new Map();
|
|
80
|
+
/** True when this id had already been redeemed. Records it either way. */
|
|
81
|
+
check(id) {
|
|
82
|
+
const now = Date.now();
|
|
83
|
+
if (this.seen.size > 512) {
|
|
84
|
+
for (const [k, expiry] of this.seen)
|
|
85
|
+
if (expiry <= now)
|
|
86
|
+
this.seen.delete(k);
|
|
87
|
+
}
|
|
88
|
+
const previous = this.seen.get(id);
|
|
89
|
+
if (previous !== undefined && previous > now)
|
|
90
|
+
return true;
|
|
91
|
+
this.seen.set(id, now + CODE_TTL * 1000);
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
29
95
|
/**
|
|
30
96
|
* Ask the instance what a pasted service token actually reaches. Returns null
|
|
31
97
|
* when the instance rejects it. Returns an empty identity when the instance
|
|
@@ -37,10 +103,11 @@ async function defaultVerifyServiceToken(baseUrl, pat) {
|
|
|
37
103
|
try {
|
|
38
104
|
res = await fetch(`${baseUrl}/config/whoami`, {
|
|
39
105
|
headers: { Authorization: `Bearer ${pat}`, Accept: 'application/json' },
|
|
106
|
+
signal: AbortSignal.timeout(10_000),
|
|
40
107
|
});
|
|
41
108
|
}
|
|
42
|
-
catch
|
|
43
|
-
throw new ServerError(
|
|
109
|
+
catch {
|
|
110
|
+
throw new ServerError('Appilot could not be reached. Please try again.');
|
|
44
111
|
}
|
|
45
112
|
if (res.status === 401 || res.status === 403)
|
|
46
113
|
return null;
|
|
@@ -53,6 +120,8 @@ async function defaultVerifyServiceToken(baseUrl, pat) {
|
|
|
53
120
|
}
|
|
54
121
|
const body = (await res.json());
|
|
55
122
|
return {
|
|
123
|
+
organizationName: typeof body.organizationName === 'string' ? body.organizationName : null,
|
|
124
|
+
appName: typeof body.appName === 'string' ? body.appName : null,
|
|
56
125
|
organizationId: body.organizationId ?? null,
|
|
57
126
|
appId: body.appId ?? null,
|
|
58
127
|
scopes: Array.isArray(body.scopes) && body.scopes.length ? body.scopes : ['config:read'],
|
|
@@ -63,12 +132,13 @@ export class AppilotOAuthProvider {
|
|
|
63
132
|
sealer;
|
|
64
133
|
consentPath;
|
|
65
134
|
verifyServiceToken;
|
|
135
|
+
redeemed = new RedeemedCodes();
|
|
66
136
|
constructor(options) {
|
|
67
137
|
this.options = options;
|
|
68
138
|
this.sealer = new TokenSealer(options.secret);
|
|
69
139
|
this.consentPath = new URL('/consent', options.publicUrl).toString();
|
|
70
140
|
this.verifyServiceToken =
|
|
71
|
-
options.verifyServiceToken ?? (pat => defaultVerifyServiceToken(options.baseUrl, pat));
|
|
141
|
+
options.verifyServiceToken ?? ((pat) => defaultVerifyServiceToken(options.baseUrl, pat));
|
|
72
142
|
}
|
|
73
143
|
/**
|
|
74
144
|
* Client registrations are sealed into the client_id itself, so dynamic
|
|
@@ -85,7 +155,9 @@ export class AppilotOAuthProvider {
|
|
|
85
155
|
// the registration handler generated rather than seal it.
|
|
86
156
|
const { client_secret: _secret, client_secret_expires_at: _expires, ...meta } = client;
|
|
87
157
|
const publicMeta = { ...meta, token_endpoint_auth_method: 'none' };
|
|
88
|
-
const client_id = await this.sealer.seal('client', {
|
|
158
|
+
const client_id = await this.sealer.seal('client', {
|
|
159
|
+
meta: publicMeta,
|
|
160
|
+
});
|
|
89
161
|
return {
|
|
90
162
|
...publicMeta,
|
|
91
163
|
client_id,
|
|
@@ -109,33 +181,121 @@ export class AppilotOAuthProvider {
|
|
|
109
181
|
};
|
|
110
182
|
}
|
|
111
183
|
async authorize(client, params, res) {
|
|
112
|
-
const
|
|
184
|
+
const language = consentLocale(typeof res.req?.query?.lang === 'string'
|
|
185
|
+
? res.req.query.lang
|
|
186
|
+
: res.req?.get('Accept-Language'));
|
|
187
|
+
const requested = (params.scopes?.length ? params.scopes : DEFAULT_REQUESTED_SCOPES).filter((s) => SUPPORTED_SCOPES.includes(s));
|
|
113
188
|
const sealedRequest = await this.sealAuthRequest({
|
|
189
|
+
language,
|
|
114
190
|
client_id: client.client_id,
|
|
191
|
+
client_name: client.client_name || 'your assistant',
|
|
115
192
|
redirect_uri: params.redirectUri,
|
|
116
193
|
code_challenge: params.codeChallenge,
|
|
117
194
|
state: params.state,
|
|
118
|
-
scopes: requested.length ? requested : [
|
|
195
|
+
scopes: requested.length ? requested : [...DEFAULT_REQUESTED_SCOPES],
|
|
119
196
|
resource: params.resource?.toString(),
|
|
120
197
|
});
|
|
198
|
+
if (this.options.handoffSecret &&
|
|
199
|
+
this.options.backofficeUrl &&
|
|
200
|
+
res.req?.query?.manual !== '1') {
|
|
201
|
+
try {
|
|
202
|
+
await startConnection({
|
|
203
|
+
baseUrl: this.options.baseUrl,
|
|
204
|
+
secret: this.options.handoffSecret,
|
|
205
|
+
backofficeUrl: this.options.backofficeUrl,
|
|
206
|
+
secure: this.options.publicUrl.protocol === 'https:',
|
|
207
|
+
}, sealedRequest, client.client_id, client.client_name || 'AI assistant', requested.length ? requested : [...DEFAULT_REQUESTED_SCOPES], language, res);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
res
|
|
212
|
+
.status(503)
|
|
213
|
+
.set('Content-Type', 'text/html; charset=utf-8')
|
|
214
|
+
.send(renderConsentPage({
|
|
215
|
+
locale: language,
|
|
216
|
+
request: sealedRequest,
|
|
217
|
+
action: this.consentPath,
|
|
218
|
+
clientName: client.client_name || 'AI assistant',
|
|
219
|
+
baseUrl: this.options.baseUrl,
|
|
220
|
+
backofficeUrl: this.options.backofficeUrl,
|
|
221
|
+
scopes: requested.length ? requested : [...DEFAULT_REQUESTED_SCOPES],
|
|
222
|
+
error: message('sessionUnavailable', language),
|
|
223
|
+
}));
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
121
227
|
res.set('Content-Type', 'text/html; charset=utf-8').send(renderConsentPage({
|
|
228
|
+
locale: language,
|
|
122
229
|
request: sealedRequest,
|
|
123
230
|
action: this.consentPath,
|
|
124
231
|
clientName: client.client_name || 'an MCP client',
|
|
125
232
|
baseUrl: this.options.baseUrl,
|
|
126
|
-
|
|
233
|
+
backofficeUrl: this.options.backofficeUrl,
|
|
234
|
+
scopes: requested.length ? requested : [...DEFAULT_REQUESTED_SCOPES],
|
|
127
235
|
}));
|
|
128
236
|
}
|
|
237
|
+
/** Complete only in the browser that initiated this OAuth request. */
|
|
238
|
+
async finishConnection(id, code, cookieHeader, res) {
|
|
239
|
+
if (!this.options.handoffSecret ||
|
|
240
|
+
!/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(id))
|
|
241
|
+
throw new InvalidGrantError('Connection expired');
|
|
242
|
+
const name = cookieName(id);
|
|
243
|
+
const proof = cookieHeader
|
|
244
|
+
.split(';')
|
|
245
|
+
.map((s) => s.trim())
|
|
246
|
+
.find((s) => s.startsWith(`${name}=`))
|
|
247
|
+
?.slice(name.length + 1);
|
|
248
|
+
if (!proof || !/^[a-f0-9]{64}$/.test(proof))
|
|
249
|
+
throw new InvalidGrantError('Use the browser where you started the connection');
|
|
250
|
+
const grant = await connectionRequest(this.options.baseUrl, this.options.handoffSecret, 'redeem', { id, code, browserProof: proof });
|
|
251
|
+
const request = await this.sealer.open('code', grant.requestState);
|
|
252
|
+
if (request.stage !== 'request')
|
|
253
|
+
throw new InvalidGrantError('Invalid connection request');
|
|
254
|
+
res.clearCookie(name, {
|
|
255
|
+
path: '/connect/callback',
|
|
256
|
+
httpOnly: true,
|
|
257
|
+
sameSite: 'lax',
|
|
258
|
+
secure: this.options.publicUrl.protocol === 'https:',
|
|
259
|
+
});
|
|
260
|
+
const redirect = new URL(request.redirect_uri);
|
|
261
|
+
if (grant.denied) {
|
|
262
|
+
redirect.searchParams.set('error', 'access_denied');
|
|
263
|
+
if (request.state)
|
|
264
|
+
redirect.searchParams.set('state', request.state);
|
|
265
|
+
return redirect.toString();
|
|
266
|
+
}
|
|
267
|
+
if (typeof grant.rawToken !== 'string' ||
|
|
268
|
+
!Array.isArray(grant.scopes) ||
|
|
269
|
+
!grant.scopes.length ||
|
|
270
|
+
grant.scopes.some((s) => !request.scopes.includes(s)))
|
|
271
|
+
throw new InvalidGrantError('Invalid approval');
|
|
272
|
+
return this.issueCode(redirect, request, {
|
|
273
|
+
pat: grant.rawToken,
|
|
274
|
+
scopes: grant.scopes,
|
|
275
|
+
appId: grant.appId ?? undefined,
|
|
276
|
+
organizationId: grant.organizationId,
|
|
277
|
+
});
|
|
278
|
+
}
|
|
129
279
|
/** The authorization request travels through the consent form, so it is sealed too. */
|
|
130
280
|
sealAuthRequest(request) {
|
|
131
281
|
return this.sealer.seal('code', { ...request, stage: 'request' }, AUTH_REQUEST_TTL);
|
|
132
282
|
}
|
|
133
283
|
/**
|
|
134
|
-
* Handle the consent form post
|
|
135
|
-
*
|
|
136
|
-
*
|
|
284
|
+
* Handle the consent form post.
|
|
285
|
+
*
|
|
286
|
+
* Two stages, because the first screen cannot yet say what it is granting.
|
|
287
|
+
* The service only learns which organization a token reaches, and which
|
|
288
|
+
* scopes it really carries, by asking the instance about the token the person
|
|
289
|
+
* just pasted. Redirecting straight from there would mean the one screen a
|
|
290
|
+
* person sees never names the org the connection will reach or the
|
|
291
|
+
* provisioning power the token may carry. So a verified token renders a
|
|
292
|
+
* confirmation naming both, and only that confirmation issues a code.
|
|
293
|
+
*
|
|
294
|
+
* `stage=request` the pasted-token form (sealed by `authorize`)
|
|
295
|
+
* `stage=confirm` what the token actually reaches, awaiting approval
|
|
296
|
+
* `stage=code` the authorization code itself
|
|
137
297
|
*/
|
|
138
|
-
async handleConsent(form) {
|
|
298
|
+
async handleConsent(form, fallbackLocale = 'en') {
|
|
139
299
|
const rawRequest = typeof form.request === 'string' ? form.request : '';
|
|
140
300
|
let request;
|
|
141
301
|
try {
|
|
@@ -144,85 +304,168 @@ export class AppilotOAuthProvider {
|
|
|
144
304
|
catch {
|
|
145
305
|
return {
|
|
146
306
|
status: 400,
|
|
147
|
-
html: renderErrorPage('This sign-in link expired', 'Start the connection again from the client that sent you here. A consent link is valid for ten minutes.'),
|
|
307
|
+
html: renderErrorPage('This sign-in link expired', 'Start the connection again from the client that sent you here. A consent link is valid for ten minutes.', fallbackLocale),
|
|
148
308
|
};
|
|
149
309
|
}
|
|
150
|
-
|
|
151
|
-
|
|
310
|
+
const locale = request.language ?? fallbackLocale;
|
|
311
|
+
if (request.stage !== 'request' && request.stage !== 'confirm') {
|
|
312
|
+
return {
|
|
313
|
+
status: 400,
|
|
314
|
+
html: renderErrorPage('Invalid request', 'That confirmation is incomplete. Start again.', locale),
|
|
315
|
+
};
|
|
152
316
|
}
|
|
153
317
|
const redirect = new URL(request.redirect_uri);
|
|
154
|
-
|
|
318
|
+
const deny = () => {
|
|
155
319
|
redirect.searchParams.set('error', 'access_denied');
|
|
156
320
|
redirect.searchParams.set('error_description', 'The person declined the request.');
|
|
157
321
|
if (request.state)
|
|
158
322
|
redirect.searchParams.set('state', request.state);
|
|
159
323
|
return { redirect: redirect.toString() };
|
|
324
|
+
};
|
|
325
|
+
if (form.action === 'deny')
|
|
326
|
+
return deny();
|
|
327
|
+
// Stage 2: the person has seen what the token reaches and approved it.
|
|
328
|
+
if (request.stage === 'confirm') {
|
|
329
|
+
if (form.action === 'back') {
|
|
330
|
+
const { pat: _pat, identity: _identity, app_id: _app, ...original } = request;
|
|
331
|
+
const scopes = request.requested_scopes ?? request.scopes;
|
|
332
|
+
return {
|
|
333
|
+
status: 200,
|
|
334
|
+
html: renderConsentPage({
|
|
335
|
+
locale,
|
|
336
|
+
request: await this.sealAuthRequest({ ...original, scopes }),
|
|
337
|
+
action: this.consentPath,
|
|
338
|
+
clientName: request.client_name || 'your assistant',
|
|
339
|
+
baseUrl: this.options.baseUrl,
|
|
340
|
+
backofficeUrl: this.options.backofficeUrl,
|
|
341
|
+
scopes,
|
|
342
|
+
}),
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
if (form.action !== 'confirm' || !request.pat || !request.identity) {
|
|
346
|
+
return {
|
|
347
|
+
status: 400,
|
|
348
|
+
html: renderErrorPage('Invalid request', 'That confirmation is incomplete. Start again.', locale),
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
return {
|
|
352
|
+
redirect: await this.issueCode(redirect, request, {
|
|
353
|
+
pat: request.pat,
|
|
354
|
+
scopes: request.scopes,
|
|
355
|
+
appId: request.app_id,
|
|
356
|
+
organizationId: request.identity.organizationId ?? undefined,
|
|
357
|
+
}),
|
|
358
|
+
};
|
|
160
359
|
}
|
|
161
360
|
const pat = typeof form.pat === 'string' ? form.pat.trim() : '';
|
|
162
361
|
const reRender = (error) => ({
|
|
163
362
|
status: 400,
|
|
164
363
|
html: renderConsentPage({
|
|
364
|
+
locale,
|
|
165
365
|
request: rawRequest,
|
|
166
366
|
action: this.consentPath,
|
|
167
|
-
clientName:
|
|
367
|
+
clientName: request.client_name || 'your assistant',
|
|
168
368
|
baseUrl: this.options.baseUrl,
|
|
369
|
+
backofficeUrl: this.options.backofficeUrl,
|
|
169
370
|
scopes: request.scopes,
|
|
170
371
|
error,
|
|
171
372
|
}),
|
|
172
373
|
});
|
|
173
374
|
if (!pat.startsWith(SERVICE_TOKEN_PREFIX)) {
|
|
174
|
-
return reRender(
|
|
375
|
+
return reRender(message('wrongToken', locale));
|
|
175
376
|
}
|
|
176
377
|
let identity;
|
|
177
378
|
try {
|
|
178
379
|
identity = await this.verifyServiceToken(pat);
|
|
179
380
|
}
|
|
180
|
-
catch
|
|
181
|
-
return reRender(
|
|
381
|
+
catch {
|
|
382
|
+
return reRender(message('verifyError', locale));
|
|
182
383
|
}
|
|
183
384
|
if (!identity) {
|
|
184
|
-
return reRender('
|
|
385
|
+
return reRender(message('rejected', locale));
|
|
185
386
|
}
|
|
186
387
|
// Never grant more than the token itself carries.
|
|
187
|
-
const granted = request.scopes.filter(s => identity.scopes.includes(s));
|
|
388
|
+
const granted = request.scopes.filter((s) => identity.scopes.includes(s));
|
|
188
389
|
if (granted.length === 0) {
|
|
189
|
-
return reRender(
|
|
390
|
+
return reRender(message('noScopes', locale));
|
|
190
391
|
}
|
|
191
392
|
// An app-scoped token dictates its own app; a form value cannot widen it.
|
|
192
393
|
const formAppId = Number(form.app_id);
|
|
193
394
|
const appId = identity.appId ?? (Number.isInteger(formAppId) && formAppId > 0 ? formAppId : undefined);
|
|
194
|
-
const
|
|
395
|
+
const confirmRequest = await this.sealer.seal('code', {
|
|
195
396
|
...request,
|
|
196
|
-
|
|
397
|
+
requested_scopes: request.scopes,
|
|
398
|
+
stage: 'confirm',
|
|
197
399
|
scopes: granted,
|
|
198
400
|
pat,
|
|
199
401
|
app_id: appId,
|
|
200
|
-
|
|
402
|
+
identity,
|
|
403
|
+
}, CONFIRM_TTL);
|
|
404
|
+
return {
|
|
405
|
+
status: 200,
|
|
406
|
+
html: renderConfirmPage({
|
|
407
|
+
locale,
|
|
408
|
+
request: confirmRequest,
|
|
409
|
+
action: this.consentPath,
|
|
410
|
+
baseUrl: this.options.baseUrl,
|
|
411
|
+
backofficeUrl: this.options.backofficeUrl,
|
|
412
|
+
scopes: granted,
|
|
413
|
+
withheldScopes: identity.scopes.filter((s) => !granted.includes(s)),
|
|
414
|
+
unavailableScopes: request.scopes.filter((s) => !granted.includes(s)),
|
|
415
|
+
clientName: request.client_name,
|
|
416
|
+
organizationName: identity.organizationName,
|
|
417
|
+
appName: identity.appName,
|
|
418
|
+
organizationId: identity.organizationId,
|
|
419
|
+
appId: appId ?? null,
|
|
420
|
+
appScoped: identity.appId != null,
|
|
421
|
+
}),
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
/** Seal the authorization code and hang it off the client's redirect. */
|
|
425
|
+
async issueCode(redirect, request, grant) {
|
|
426
|
+
const code = await this.sealer.seal('code', {
|
|
427
|
+
...request,
|
|
428
|
+
stage: 'code',
|
|
429
|
+
// A per-code id, so redeeming one can be remembered. A sealed artifact
|
|
430
|
+
// carries no server-side handle otherwise.
|
|
431
|
+
jti: randomUUID(),
|
|
432
|
+
scopes: grant.scopes,
|
|
433
|
+
pat: grant.pat,
|
|
434
|
+
app_id: grant.appId,
|
|
435
|
+
organization_id: grant.organizationId,
|
|
201
436
|
}, CODE_TTL);
|
|
202
437
|
redirect.searchParams.set('code', code);
|
|
203
438
|
if (request.state)
|
|
204
439
|
redirect.searchParams.set('state', request.state);
|
|
205
|
-
return
|
|
440
|
+
return redirect.toString();
|
|
206
441
|
}
|
|
207
|
-
async openCode(client, authorizationCode) {
|
|
442
|
+
async openCode(client, authorizationCode, redeem) {
|
|
208
443
|
let code;
|
|
209
444
|
try {
|
|
210
445
|
code = await this.sealer.open('code', authorizationCode);
|
|
211
446
|
}
|
|
212
447
|
catch (err) {
|
|
213
|
-
throw new InvalidGrantError(err instanceof SealError
|
|
448
|
+
throw new InvalidGrantError(err instanceof SealError
|
|
449
|
+
? 'The authorization code is invalid or has expired.'
|
|
450
|
+
: String(err));
|
|
214
451
|
}
|
|
215
452
|
if (code.stage !== 'code')
|
|
216
453
|
throw new InvalidGrantError('That token is not an authorization code.');
|
|
217
454
|
if (code.client_id !== client.client_id)
|
|
218
455
|
throw new InvalidClientError('The authorization code was issued to a different client.');
|
|
456
|
+
// Single use, and only on the exchange: the SDK reads the PKCE challenge
|
|
457
|
+
// from the same code first, and burning it there would reject every
|
|
458
|
+
// legitimate exchange.
|
|
459
|
+
if (redeem && code.jti && this.redeemed.check(code.jti)) {
|
|
460
|
+
throw new InvalidGrantError('That authorization code has already been used.');
|
|
461
|
+
}
|
|
219
462
|
return code;
|
|
220
463
|
}
|
|
221
464
|
async challengeForAuthorizationCode(client, authorizationCode) {
|
|
222
|
-
return (await this.openCode(client, authorizationCode)).code_challenge;
|
|
465
|
+
return (await this.openCode(client, authorizationCode, false)).code_challenge;
|
|
223
466
|
}
|
|
224
467
|
async exchangeAuthorizationCode(client, authorizationCode, _codeVerifier, redirectUri) {
|
|
225
|
-
const code = await this.openCode(client, authorizationCode);
|
|
468
|
+
const code = await this.openCode(client, authorizationCode, true);
|
|
226
469
|
if (redirectUri !== undefined && redirectUri !== code.redirect_uri) {
|
|
227
470
|
throw new InvalidGrantError('redirect_uri does not match the one the code was issued for.');
|
|
228
471
|
}
|
|
@@ -240,7 +483,9 @@ export class AppilotOAuthProvider {
|
|
|
240
483
|
throw new InvalidClientError('The refresh token was issued to a different client.');
|
|
241
484
|
}
|
|
242
485
|
// A refresh may narrow the grant, never widen it.
|
|
243
|
-
const narrowed = scopes?.length
|
|
486
|
+
const narrowed = scopes?.length
|
|
487
|
+
? sealed.scopes.filter((s) => scopes.includes(s))
|
|
488
|
+
: sealed.scopes;
|
|
244
489
|
if (narrowed.length === 0)
|
|
245
490
|
throw new InvalidGrantError('The requested scopes are not covered by this grant.');
|
|
246
491
|
return this.issue({ ...sealed, scopes: narrowed });
|
package/dist/scaffold.d.ts
CHANGED
|
@@ -34,4 +34,51 @@ interface ScaffoldOptions {
|
|
|
34
34
|
idNamespace?: string;
|
|
35
35
|
}
|
|
36
36
|
export declare function scaffoldIntegration(options: ScaffoldOptions): ScaffoldResult;
|
|
37
|
+
/**
|
|
38
|
+
* `scaffold_agent_first`: the four pieces one capability needs, together.
|
|
39
|
+
*
|
|
40
|
+
* `scaffoldIntegration` above returns the integration. It does not return the
|
|
41
|
+
* shape of an agent-first application, and the two are not the same thing. A
|
|
42
|
+
* capability that a user can complete through the assistant alone is four
|
|
43
|
+
* artifacts that have to agree with each other: the way the agent calls the
|
|
44
|
+
* backend, the way the page acts in the user's own session, the procedure the
|
|
45
|
+
* agent follows in the interface, and the meaning behind it.
|
|
46
|
+
*
|
|
47
|
+
* The split between the last two is the rule that is easiest to state and
|
|
48
|
+
* easiest to break. The Action Plan is the procedure. Knowledge carries meaning.
|
|
49
|
+
* A step-by-step knowledge article is a procedure in the wrong place, and it
|
|
50
|
+
* teaches the agent to author steps instead of adopting the plan that already
|
|
51
|
+
* exists. Emitting both halves correctly is how a scaffold teaches that once.
|
|
52
|
+
*/
|
|
53
|
+
export interface AgentFirstScaffold {
|
|
54
|
+
capability: string;
|
|
55
|
+
/** Ready for `create_tool`, minus the credential. */
|
|
56
|
+
tool: Record<string, unknown>;
|
|
57
|
+
/** Ready for `create_action_plan` once the control ids exist. */
|
|
58
|
+
actionPlan: Record<string, unknown>;
|
|
59
|
+
/** Ready for `create_knowledge`. */
|
|
60
|
+
knowledge: Record<string, unknown>;
|
|
61
|
+
files: ScaffoldFile[];
|
|
62
|
+
order: string[];
|
|
63
|
+
notes: string[];
|
|
64
|
+
}
|
|
65
|
+
interface AgentFirstOptions {
|
|
66
|
+
/** What the user is trying to do, in their words. */
|
|
67
|
+
capability: string;
|
|
68
|
+
/** Short English slug, used for the tool name and the plan id. */
|
|
69
|
+
slug: string;
|
|
70
|
+
appId: number | null;
|
|
71
|
+
/**
|
|
72
|
+
* The host endpoint the agent should reach, as a PATH on the host origin.
|
|
73
|
+
* The executor rejects an absolute URL: a server-side tool call is proxied
|
|
74
|
+
* to the app's own origin, never to an arbitrary host.
|
|
75
|
+
*/
|
|
76
|
+
endpoint?: {
|
|
77
|
+
method: string;
|
|
78
|
+
path: string;
|
|
79
|
+
} | null;
|
|
80
|
+
/** True when the operation is UI-coupled and belongs in the page instead. */
|
|
81
|
+
clientSide?: boolean;
|
|
82
|
+
}
|
|
83
|
+
export declare function scaffoldAgentFirst(options: AgentFirstOptions): AgentFirstScaffold;
|
|
37
84
|
export {};
|