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.
@@ -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
- /** Scopes this authorization server can grant, mirroring the service-token scopes. */
25
- export declare const SUPPORTED_SCOPES: readonly ["config:read", "config:write"];
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: verify the pasted service token, then either
59
- * redirect back with an authorization code or re-render with the reason it
60
- * failed. Returns the HTML to render, or the redirect to follow.
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>): Promise<{
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>;
@@ -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
- /** Scopes this authorization server can grant, mirroring the service-token scopes. */
23
- export const SUPPORTED_SCOPES = ['config:read', 'config:write'];
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
- const SERVICE_TOKEN_PREFIX = 'appilot_pat_';
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 (err) {
43
- throw new ServerError(`Could not reach the Appilot instance at ${baseUrl}: ${err instanceof Error ? err.message : String(err)}`);
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', { meta: publicMeta });
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 requested = (params.scopes?.length ? params.scopes : ['config:read']).filter(s => SUPPORTED_SCOPES.includes(s));
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 : ['config:read'],
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
- scopes: requested.length ? requested : ['config:read'],
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: verify the pasted service token, then either
135
- * redirect back with an authorization code or re-render with the reason it
136
- * failed. Returns the HTML to render, or the redirect to follow.
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
- if (request.stage !== 'request') {
151
- return { status: 400, html: renderErrorPage('Invalid request', 'That token is not a consent request.') };
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
- if (form.action === 'deny') {
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: 'an MCP client',
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(`That does not look like a service token. A service token starts with ${SERVICE_TOKEN_PREFIX} and is created in the Backoffice under Settings, Service Tokens.`);
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 (err) {
181
- return reRender(err instanceof Error ? err.message : String(err));
381
+ catch {
382
+ return reRender(message('verifyError', locale));
182
383
  }
183
384
  if (!identity) {
184
- return reRender('The instance rejected that service token. It may have been revoked or have expired.');
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(`That token grants ${identity.scopes.join(', ')}, which does not cover the requested ${request.scopes.join(', ')}. Create a token with the needed scope.`);
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 code = await this.sealer.seal('code', {
395
+ const confirmRequest = await this.sealer.seal('code', {
195
396
  ...request,
196
- stage: 'code',
397
+ requested_scopes: request.scopes,
398
+ stage: 'confirm',
197
399
  scopes: granted,
198
400
  pat,
199
401
  app_id: appId,
200
- organization_id: identity.organizationId ?? undefined,
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 { redirect: redirect.toString() };
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 ? 'The authorization code is invalid or has expired.' : String(err));
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 ? sealed.scopes.filter(s => scopes.includes(s)) : sealed.scopes;
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 });
@@ -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 {};