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.
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Read a page and report what an author needs to register it.
3
+ *
4
+ * This is the half of the tool surface that was missing for the developer who
5
+ * says "look at this screen and configure it". Everything else could read the
6
+ * configuration and validate it; nothing could see the thing being configured,
7
+ * so a control's locator had to come from a human picking it in the extension.
8
+ *
9
+ * Two paths, because the transports differ in what they can run.
10
+ *
11
+ * url Loads the real page in Playwright. Playwright is an optional peer, so
12
+ * this path degrades rather than crashing, exactly as `soak.ts` and
13
+ * `verify.ts` do.
14
+ * html Scans pasted markup with no browser. Flat and structural only: it
15
+ * reads the attributes on interactive tags in document order and groups
16
+ * fields by the form they appear inside. It cannot compute an accessible
17
+ * name, and it does not know what is visible. It is what keeps the
18
+ * hosted endpoint useful, and it says which of the two produced a
19
+ * result so nobody mistakes one for the other.
20
+ *
21
+ * A locator candidate is ranked by whether it survives the next render, which
22
+ * is the only property that matters: an auto-generated id passes every static
23
+ * check and is gone on redeploy. The `type` on each candidate is an Appilot
24
+ * `locator_type`, not a CSS selector kind, so the output can be pasted into a
25
+ * control body unchanged.
26
+ */
27
+ export type LocatorType = 'id' | 'class_text' | 'aria' | 'xpath' | 'semantic';
28
+ export interface LocatorCandidate {
29
+ locator: string;
30
+ type: LocatorType;
31
+ /** Higher is more likely to still resolve after the next deploy. */
32
+ stability: 'stable' | 'reasonable' | 'fragile';
33
+ why: string;
34
+ }
35
+ export interface InspectedElement {
36
+ tag: string;
37
+ role: string | null;
38
+ text: string | null;
39
+ kind: 'action' | 'field' | 'region';
40
+ /** Best first. */
41
+ candidates: LocatorCandidate[];
42
+ /** Semantic id of an already-configured control whose locator matches. */
43
+ configuredAs?: string;
44
+ }
45
+ export interface InspectedForm {
46
+ /** The form's own candidates, for the entry control. */
47
+ candidates: LocatorCandidate[];
48
+ fields: InspectedElement[];
49
+ submit: InspectedElement | null;
50
+ }
51
+ export interface InspectResult {
52
+ source: 'browser' | 'html';
53
+ available: boolean;
54
+ url?: string;
55
+ /** The path a View would carry, when a URL was given. */
56
+ viewPath?: string;
57
+ elements: InspectedElement[];
58
+ forms: InspectedForm[];
59
+ /** What is already registered for this view, so nothing is authored twice. */
60
+ alreadyConfigured?: {
61
+ controls: string[];
62
+ forms: string[];
63
+ zones: string[];
64
+ };
65
+ note?: string;
66
+ }
67
+ /**
68
+ * Rank the ways to address one element.
69
+ *
70
+ * Order is the contract: whoever consumes this takes the first candidate unless
71
+ * they have a reason not to, so the first candidate must be the one that ages
72
+ * best, never the shortest.
73
+ */
74
+ export declare function rankCandidates(attrs: Record<string, string>, tag: string, text: string | null): LocatorCandidate[];
75
+ /**
76
+ * Scan pasted markup for interactive elements.
77
+ *
78
+ * Deliberately flat. It walks tag openings in document order and tracks only one
79
+ * nesting fact, whether it is currently inside a form, because that is the one
80
+ * relationship the content model needs and the one a regex can get right.
81
+ */
82
+ export declare function inspectHtml(html: string): InspectResult;
83
+ export declare function inspectPage(opts: {
84
+ url?: string;
85
+ html?: string;
86
+ storageStatePath?: string;
87
+ timeoutMs?: number;
88
+ }): Promise<InspectResult>;
@@ -0,0 +1,384 @@
1
+ /**
2
+ * Read a page and report what an author needs to register it.
3
+ *
4
+ * This is the half of the tool surface that was missing for the developer who
5
+ * says "look at this screen and configure it". Everything else could read the
6
+ * configuration and validate it; nothing could see the thing being configured,
7
+ * so a control's locator had to come from a human picking it in the extension.
8
+ *
9
+ * Two paths, because the transports differ in what they can run.
10
+ *
11
+ * url Loads the real page in Playwright. Playwright is an optional peer, so
12
+ * this path degrades rather than crashing, exactly as `soak.ts` and
13
+ * `verify.ts` do.
14
+ * html Scans pasted markup with no browser. Flat and structural only: it
15
+ * reads the attributes on interactive tags in document order and groups
16
+ * fields by the form they appear inside. It cannot compute an accessible
17
+ * name, and it does not know what is visible. It is what keeps the
18
+ * hosted endpoint useful, and it says which of the two produced a
19
+ * result so nobody mistakes one for the other.
20
+ *
21
+ * A locator candidate is ranked by whether it survives the next render, which
22
+ * is the only property that matters: an auto-generated id passes every static
23
+ * check and is gone on redeploy. The `type` on each candidate is an Appilot
24
+ * `locator_type`, not a CSS selector kind, so the output can be pasted into a
25
+ * control body unchanged.
26
+ */
27
+ /**
28
+ * Ids a framework generated. Registering one of these is the single most common
29
+ * way a configuration passes every check and then does nothing on the real page.
30
+ */
31
+ const GENERATED_ID = [
32
+ /^(nc|mui|radix|headlessui|chakra|mantine|ember|ext-gen|yui)[-_:]/i,
33
+ /^:r[0-9a-z]+:$/i,
34
+ /^[a-f0-9]{8,}$/i,
35
+ /\d{4,}$/,
36
+ /^react-aria\d+$/i,
37
+ ];
38
+ function idLooksGenerated(id) {
39
+ return GENERATED_ID.some(re => re.test(id));
40
+ }
41
+ const TEST_ATTRS = ['data-testid', 'data-test-id', 'data-test', 'data-qa', 'data-cy', 'data-automation-id'];
42
+ const ACTION_TAGS = new Set(['button', 'a', 'summary']);
43
+ const FIELD_TAGS = new Set(['input', 'select', 'textarea']);
44
+ const REGION_TAGS = new Set(['nav', 'main', 'aside', 'header', 'footer', 'section', 'form']);
45
+ /**
46
+ * Rank the ways to address one element.
47
+ *
48
+ * Order is the contract: whoever consumes this takes the first candidate unless
49
+ * they have a reason not to, so the first candidate must be the one that ages
50
+ * best, never the shortest.
51
+ */
52
+ export function rankCandidates(attrs, tag, text) {
53
+ const out = [];
54
+ const q = (v) => v.replace(/"/g, '\\"');
55
+ for (const attr of TEST_ATTRS) {
56
+ const value = attrs[attr];
57
+ if (value) {
58
+ out.push({
59
+ locator: `[${attr}="${q(value)}"]`,
60
+ type: 'class_text',
61
+ stability: 'stable',
62
+ why: 'A test attribute exists to be addressed and is not rewritten by a redesign.',
63
+ });
64
+ }
65
+ }
66
+ if (attrs['aria-label']) {
67
+ out.push({
68
+ locator: attrs['aria-label'],
69
+ type: 'aria',
70
+ stability: 'stable',
71
+ why: 'An accessible name is user-visible and changes only when the interface changes.',
72
+ });
73
+ }
74
+ const id = attrs.id;
75
+ if (id) {
76
+ if (idLooksGenerated(id)) {
77
+ out.push({
78
+ locator: `#${id}`,
79
+ type: 'id',
80
+ stability: 'fragile',
81
+ why: 'This id looks generated by a framework. It will differ on the next render. Do not register it.',
82
+ });
83
+ }
84
+ else {
85
+ out.push({
86
+ locator: `#${id}`,
87
+ type: 'id',
88
+ stability: 'stable',
89
+ why: 'An authored id is addressed directly and survives styling changes.',
90
+ });
91
+ }
92
+ }
93
+ if (attrs.name && (FIELD_TAGS.has(tag) || tag === 'form')) {
94
+ out.push({
95
+ locator: `[name="${q(attrs.name)}"]`,
96
+ type: 'class_text',
97
+ stability: 'stable',
98
+ why: 'A field name is part of the form contract, so it outlives the markup around it.',
99
+ });
100
+ }
101
+ if (attrs.placeholder) {
102
+ out.push({
103
+ locator: `[placeholder="${q(attrs.placeholder)}"]`,
104
+ type: 'class_text',
105
+ stability: 'reasonable',
106
+ why: 'A placeholder is user-visible copy, so it moves with translation and rewording.',
107
+ });
108
+ }
109
+ if (attrs.role && text) {
110
+ out.push({
111
+ locator: `${attrs.role}:${text}`,
112
+ type: 'aria',
113
+ stability: 'reasonable',
114
+ why: 'Role plus accessible name, which is how a person finds the element.',
115
+ });
116
+ }
117
+ if (text && text.length <= 60) {
118
+ out.push({
119
+ locator: text,
120
+ type: 'semantic',
121
+ stability: 'reasonable',
122
+ why: 'Visible text. Correct until the copy is reworded or translated.',
123
+ });
124
+ }
125
+ if (out.length === 0) {
126
+ out.push({
127
+ locator: tag,
128
+ type: 'class_text',
129
+ stability: 'fragile',
130
+ why: 'Nothing addressable on this element. Ask the app team for a data attribute before registering it.',
131
+ });
132
+ }
133
+ const rank = { stable: 0, reasonable: 1, fragile: 2 };
134
+ return out.sort((a, b) => rank[a.stability] - rank[b.stability]);
135
+ }
136
+ function classify(tag, role) {
137
+ if (FIELD_TAGS.has(tag))
138
+ return 'field';
139
+ if (ACTION_TAGS.has(tag) || role === 'button' || role === 'link' || role === 'menuitem')
140
+ return 'action';
141
+ return 'region';
142
+ }
143
+ /* -------------------------------------------------------------------------- */
144
+ /* Pasted markup */
145
+ /* -------------------------------------------------------------------------- */
146
+ const TAG_RE = /<\/?([a-zA-Z][a-zA-Z0-9-]*)((?:\s+[^\s"'>/=]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'>`]+))?)*)\s*\/?>/g;
147
+ const ATTR_RE = /([^\s"'>/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>`]+)))?/g;
148
+ function parseAttrs(raw) {
149
+ const attrs = {};
150
+ ATTR_RE.lastIndex = 0;
151
+ let m;
152
+ while ((m = ATTR_RE.exec(raw)) !== null) {
153
+ const name = m[1].toLowerCase();
154
+ attrs[name] = m[2] ?? m[3] ?? m[4] ?? '';
155
+ }
156
+ return attrs;
157
+ }
158
+ function stripTags(html) {
159
+ return html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
160
+ }
161
+ /**
162
+ * Scan pasted markup for interactive elements.
163
+ *
164
+ * Deliberately flat. It walks tag openings in document order and tracks only one
165
+ * nesting fact, whether it is currently inside a form, because that is the one
166
+ * relationship the content model needs and the one a regex can get right.
167
+ */
168
+ export function inspectHtml(html) {
169
+ const elements = [];
170
+ const forms = [];
171
+ let current = null;
172
+ TAG_RE.lastIndex = 0;
173
+ let match;
174
+ while ((match = TAG_RE.exec(html)) !== null) {
175
+ const raw = match[0];
176
+ const tag = match[1].toLowerCase();
177
+ const closing = raw.startsWith('</');
178
+ if (tag === 'form') {
179
+ if (closing) {
180
+ if (current)
181
+ forms.push(current);
182
+ current = null;
183
+ }
184
+ else {
185
+ const attrs = parseAttrs(match[2] ?? '');
186
+ current = { candidates: rankCandidates(attrs, 'form', null), fields: [], submit: null };
187
+ }
188
+ continue;
189
+ }
190
+ if (closing)
191
+ continue;
192
+ if (!ACTION_TAGS.has(tag) && !FIELD_TAGS.has(tag) && !REGION_TAGS.has(tag))
193
+ continue;
194
+ const attrs = parseAttrs(match[2] ?? '');
195
+ if (attrs.type === 'hidden')
196
+ continue;
197
+ // Text runs to the matching close tag when there is one on the same
198
+ // nesting level. Good enough for a button label, which is what it is for.
199
+ let text = null;
200
+ if (!FIELD_TAGS.has(tag)) {
201
+ const close = html.indexOf(`</${tag}`, match.index + raw.length);
202
+ if (close > -1 && close - match.index < 2000) {
203
+ const inner = stripTags(html.slice(match.index + raw.length, close));
204
+ text = inner ? inner.slice(0, 120) : null;
205
+ }
206
+ }
207
+ if (!text && attrs.value && FIELD_TAGS.has(tag))
208
+ text = attrs.value.slice(0, 120);
209
+ const role = attrs.role ?? null;
210
+ const element = {
211
+ tag,
212
+ role,
213
+ text,
214
+ kind: classify(tag, role),
215
+ candidates: rankCandidates(attrs, tag, text),
216
+ };
217
+ const isSubmit = tag === 'button'
218
+ ? attrs.type !== 'button' && attrs.type !== 'reset'
219
+ : tag === 'input' && attrs.type === 'submit';
220
+ if (current) {
221
+ if (isSubmit && !current.submit)
222
+ current.submit = element;
223
+ else if (element.kind === 'field')
224
+ current.fields.push(element);
225
+ else
226
+ elements.push(element);
227
+ }
228
+ else {
229
+ elements.push(element);
230
+ }
231
+ }
232
+ if (current)
233
+ forms.push(current);
234
+ return {
235
+ source: 'html',
236
+ available: true,
237
+ elements,
238
+ forms,
239
+ note: 'Scanned pasted markup with no browser. Visibility, computed roles and accessible names are unknown here, and nothing confirms these selectors resolve on the running page. Run soak_selectors, or inspect by URL from a local server, before trusting a locator.',
240
+ };
241
+ }
242
+ /* -------------------------------------------------------------------------- */
243
+ /* Live page */
244
+ /* -------------------------------------------------------------------------- */
245
+ /* eslint-disable @typescript-eslint/no-explicit-any */
246
+ async function loadPlaywright() {
247
+ try {
248
+ return await import('playwright');
249
+ }
250
+ catch {
251
+ return null;
252
+ }
253
+ }
254
+ /**
255
+ * What runs inside the page. Returns raw attribute bags, so the ranking stays
256
+ * here in one place and is unit-testable without a browser.
257
+ */
258
+ const EXTRACT = `() => {
259
+ const out = [];
260
+ const sel = 'button, a[href], input, select, textarea, summary, [role="button"], [role="link"], [role="menuitem"], nav, main, aside, header, footer, form';
261
+ for (const el of document.querySelectorAll(sel)) {
262
+ const rect = el.getBoundingClientRect();
263
+ const style = getComputedStyle(el);
264
+ if (style.display === 'none' || style.visibility === 'hidden') continue;
265
+ if (rect.width === 0 && rect.height === 0 && el.tagName !== 'FORM') continue;
266
+ const attrs = {};
267
+ for (const a of el.attributes) attrs[a.name.toLowerCase()] = a.value;
268
+ const label = (el.getAttribute('aria-label') || el.textContent || '').replace(/\\s+/g, ' ').trim();
269
+ out.push({
270
+ tag: el.tagName.toLowerCase(),
271
+ attrs,
272
+ text: label ? label.slice(0, 120) : null,
273
+ formIndex: el.form ? Array.prototype.indexOf.call(document.forms, el.form) : -1,
274
+ isSubmit: (el.tagName === 'BUTTON' && el.type !== 'button' && el.type !== 'reset') || (el.tagName === 'INPUT' && el.type === 'submit'),
275
+ });
276
+ }
277
+ return out;
278
+ }`;
279
+ export async function inspectPage(opts) {
280
+ if (opts.html)
281
+ return inspectHtml(opts.html);
282
+ if (!opts.url) {
283
+ return {
284
+ source: 'html',
285
+ available: false,
286
+ elements: [],
287
+ forms: [],
288
+ note: 'Pass either a url to load, or html to scan.',
289
+ };
290
+ }
291
+ const playwright = await loadPlaywright();
292
+ if (!playwright) {
293
+ return {
294
+ source: 'browser',
295
+ available: false,
296
+ elements: [],
297
+ forms: [],
298
+ note: 'No browser is available here, so this page cannot be loaded. Two ways forward, in order of preference: ask the person to open the page, copy the outerHTML of the region they care about, and call this tool again with `html` instead of `url`; or run the Appilot MCP server locally over stdio, where Playwright can be installed (pnpm add -D playwright && npx playwright install chromium).',
299
+ };
300
+ }
301
+ let browser;
302
+ try {
303
+ browser = await playwright.chromium.launch({ headless: true });
304
+ }
305
+ catch (err) {
306
+ return {
307
+ source: 'browser',
308
+ available: false,
309
+ elements: [],
310
+ forms: [],
311
+ note: `A browser could not be launched: ${err instanceof Error ? err.message : String(err)}. Run npx playwright install chromium, or pass html instead of url.`,
312
+ };
313
+ }
314
+ try {
315
+ const context = await browser.newContext(opts.storageStatePath ? { storageState: opts.storageStatePath } : undefined);
316
+ const page = await context.newPage();
317
+ await page.goto(opts.url, { waitUntil: 'domcontentloaded', timeout: opts.timeoutMs ?? 20000 });
318
+ const raw = (await page.evaluate(EXTRACT));
319
+ const elements = [];
320
+ const formMap = new Map();
321
+ for (const item of raw) {
322
+ const role = item.attrs.role ?? null;
323
+ const element = {
324
+ tag: item.tag,
325
+ role,
326
+ text: item.text,
327
+ kind: classify(item.tag, role),
328
+ candidates: rankCandidates(item.attrs, item.tag, item.text),
329
+ };
330
+ if (item.tag === 'form') {
331
+ const index = Array.from(formMap.keys()).length;
332
+ formMap.set(index, { candidates: element.candidates, fields: [], submit: null });
333
+ continue;
334
+ }
335
+ const form = item.formIndex >= 0 ? formMap.get(item.formIndex) : undefined;
336
+ if (form) {
337
+ if (item.isSubmit && !form.submit)
338
+ form.submit = element;
339
+ else if (element.kind === 'field')
340
+ form.fields.push(element);
341
+ else
342
+ elements.push(element);
343
+ }
344
+ else {
345
+ elements.push(element);
346
+ }
347
+ }
348
+ let viewPath;
349
+ try {
350
+ viewPath = new URL(opts.url).pathname;
351
+ }
352
+ catch {
353
+ viewPath = undefined;
354
+ }
355
+ return {
356
+ source: 'browser',
357
+ available: true,
358
+ url: opts.url,
359
+ viewPath,
360
+ elements,
361
+ forms: Array.from(formMap.values()),
362
+ };
363
+ }
364
+ catch (err) {
365
+ // A navigation failure is a finding, not a crash. The same rule the soak
366
+ // and the integration verifier follow: report it and let the caller act.
367
+ return {
368
+ source: 'browser',
369
+ available: false,
370
+ url: opts.url,
371
+ elements: [],
372
+ forms: [],
373
+ note: `The page could not be inspected: ${err instanceof Error ? err.message : String(err)}`,
374
+ };
375
+ }
376
+ finally {
377
+ try {
378
+ await browser.close();
379
+ }
380
+ catch {
381
+ /* closing a browser that already died is not a failure worth reporting */
382
+ }
383
+ }
384
+ }
@@ -8,9 +8,14 @@
8
8
  * claude.ai), where it is stored, summarized, and outside the operator's
9
9
  * control.
10
10
  *
11
- * Only one value in the whole surface is affected: the widget SECRET minted
12
- * during provisioning. The widget KEY beside it is a publishable identifier
13
- * that is meant to sit in public HTML, so it travels either way.
11
+ * Two values are affected, and they travel in opposite directions. The widget
12
+ * SECRET minted during provisioning comes back in a result, so it is stripped
13
+ * from one. A tool's vault credential goes the other way, in a tool ARGUMENT,
14
+ * so it is refused before the call is made: over the remote transport the
15
+ * argument is already stored in the third party's conversation by the time this
16
+ * server sees it, and there is no useful way to unsend it. The widget KEY beside
17
+ * the secret is a publishable identifier meant to sit in public HTML, so it
18
+ * travels either way.
14
19
  */
15
20
  export declare const SECRET_WITHHELD_NOTICE = "The widget secret is not returned over the remote transport, because a tool result here is stored in this conversation. Read it once from the Backoffice widget-keys page, or run the Appilot MCP server locally over stdio.";
16
21
  interface ProvisionLike {
@@ -27,4 +32,14 @@ interface ProvisionLike {
27
32
  * minted (a converging run never mints one).
28
33
  */
29
34
  export declare function redactForTransport<T extends ProvisionLike>(result: T, transport: 'stdio' | 'http' | undefined): T;
35
+ export declare const CREDENTIAL_REFUSED_NOTICE = "A tool credential (auth_secret) cannot be set over the remote transport, because a tool argument here is stored in this conversation before it reaches Appilot. Two ways through: run the Appilot MCP server locally over stdio and set it there, or open the tool in the Backoffice and paste the credential. Everything else about this entity can be written from here; send the same call again without auth_secret.";
36
+ /**
37
+ * Refuse a write that would carry a vault credential across the remote
38
+ * transport. Returns null when the call may proceed.
39
+ *
40
+ * The check is on the field name rather than on the entity kind on purpose. A
41
+ * credential is refused wherever it appears, so a body that reaches the tool
42
+ * routes by some other path cannot slip one through.
43
+ */
44
+ export declare function refuseSecretOverRemote(body: unknown, transport: 'stdio' | 'http' | undefined): string | null;
30
45
  export {};
package/dist/redaction.js CHANGED
@@ -8,9 +8,14 @@
8
8
  * claude.ai), where it is stored, summarized, and outside the operator's
9
9
  * control.
10
10
  *
11
- * Only one value in the whole surface is affected: the widget SECRET minted
12
- * during provisioning. The widget KEY beside it is a publishable identifier
13
- * that is meant to sit in public HTML, so it travels either way.
11
+ * Two values are affected, and they travel in opposite directions. The widget
12
+ * SECRET minted during provisioning comes back in a result, so it is stripped
13
+ * from one. A tool's vault credential goes the other way, in a tool ARGUMENT,
14
+ * so it is refused before the call is made: over the remote transport the
15
+ * argument is already stored in the third party's conversation by the time this
16
+ * server sees it, and there is no useful way to unsend it. The widget KEY beside
17
+ * the secret is a publishable identifier meant to sit in public HTML, so it
18
+ * travels either way.
14
19
  */
15
20
  export const SECRET_WITHHELD_NOTICE = 'The widget secret is not returned over the remote transport, because a tool result here is stored in this conversation. Read it once from the Backoffice widget-keys page, or run the Appilot MCP server locally over stdio.';
16
21
  /**
@@ -31,3 +36,22 @@ export function redactForTransport(result, transport) {
31
36
  widgetKey.secretWithheld = SECRET_WITHHELD_NOTICE;
32
37
  return { ...result, widgetKey };
33
38
  }
39
+ export const CREDENTIAL_REFUSED_NOTICE = 'A tool credential (auth_secret) cannot be set over the remote transport, because a tool argument here is stored in this conversation before it reaches Appilot. Two ways through: run the Appilot MCP server locally over stdio and set it there, or open the tool in the Backoffice and paste the credential. Everything else about this entity can be written from here; send the same call again without auth_secret.';
40
+ /**
41
+ * Refuse a write that would carry a vault credential across the remote
42
+ * transport. Returns null when the call may proceed.
43
+ *
44
+ * The check is on the field name rather than on the entity kind on purpose. A
45
+ * credential is refused wherever it appears, so a body that reaches the tool
46
+ * routes by some other path cannot slip one through.
47
+ */
48
+ export function refuseSecretOverRemote(body, transport) {
49
+ if (transport !== 'http')
50
+ return null;
51
+ if (typeof body !== 'object' || body === null)
52
+ return null;
53
+ const value = body.auth_secret;
54
+ if (value === undefined || value === null || value === '')
55
+ return null;
56
+ return CREDENTIAL_REFUSED_NOTICE;
57
+ }
@@ -1,29 +1,31 @@
1
- /**
2
- * The consent screen of the remote MCP service.
3
- *
4
- * This is the one page a person sees when connecting ChatGPT or Claude to their
5
- * Appilot instance. It asks for a service token rather than an Appilot password
6
- * on purpose: the connector needs a long-lived, scoped, revocable credential,
7
- * which is exactly what a service token is and exactly what a password is not.
8
- * A password would also make this host a credential-collection surface for the
9
- * whole account, and it can grant no less than everything.
10
- *
11
- * Plain server-rendered HTML with no external assets, so it renders inside the
12
- * in-app browsers ChatGPT and Claude use for the OAuth hop.
13
- */
1
+ import { type ConsentLocale } from './consentMessages.js';
2
+ export declare function escapeHtml(value: string): string;
14
3
  export interface ConsentPageOptions {
15
- /** Sealed authorization request, round-tripped through the form. */
16
4
  request: string;
17
- /** Where the form posts. */
18
5
  action: string;
19
- /** Display name of the MCP client asking for access. */
20
6
  clientName: string;
21
- /** The Appilot backend this deployment serves, shown so the person can check it. */
22
7
  baseUrl: string;
23
- /** Scopes the client asked for. */
24
8
  scopes: string[];
25
- /** Set after a failed attempt so the person can correct it in place. */
9
+ backofficeUrl?: string;
26
10
  error?: string;
11
+ locale?: ConsentLocale;
27
12
  }
28
13
  export declare function renderConsentPage(opts: ConsentPageOptions): string;
29
- export declare function renderErrorPage(title: string, detail: string): string;
14
+ export interface ConfirmPageOptions {
15
+ request: string;
16
+ action: string;
17
+ baseUrl: string;
18
+ scopes: string[];
19
+ withheldScopes: string[];
20
+ unavailableScopes?: string[];
21
+ organizationId: number | null;
22
+ organizationName?: string | null;
23
+ appId: number | null;
24
+ appName?: string | null;
25
+ appScoped: boolean;
26
+ clientName?: string;
27
+ backofficeUrl?: string;
28
+ locale?: ConsentLocale;
29
+ }
30
+ export declare function renderConfirmPage(opts: ConfirmPageOptions): string;
31
+ export declare function renderErrorPage(title: string, detail: string, locale?: ConsentLocale): string;