@pitcher/cli-scripts 2.17.1 → 2.19.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.
@@ -1,9 +1,9 @@
1
- import archiver from 'archiver';
1
+ import { ZipArchive } from 'archiver';
2
2
  import fs from 'fs';
3
3
  export async function zipDirectoryToBuffer(dir) {
4
4
  return new Promise((resolve, reject) => {
5
5
  const chunks = [];
6
- const archive = archiver('zip', { zlib: { level: 9 } });
6
+ const archive = new ZipArchive({ zlib: { level: 9 } });
7
7
  archive.on('data', (chunk) => chunks.push(chunk));
8
8
  archive.on('end', () => resolve(Buffer.concat(chunks)));
9
9
  archive.on('error', reject);
@@ -14,7 +14,7 @@ export async function zipDirectoryToBuffer(dir) {
14
14
  export async function zipDirectoryToFile(dir, outPath) {
15
15
  return new Promise((resolve, reject) => {
16
16
  const output = fs.createWriteStream(outPath);
17
- const archive = archiver('zip', { zlib: { level: 9 } });
17
+ const archive = new ZipArchive({ zlib: { level: 9 } });
18
18
  output.on('close', () => resolve(archive.pointer()));
19
19
  output.on('error', reject);
20
20
  archive.on('error', reject);
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The credential a request is sent with. `apiKey` is the Knox key the CLI (and stdio `p mcp`) has
3
+ * always used; `bearer` is a user's Auth0 access token, used by the hosted MCP server (PIT-8912).
4
+ */
5
+ export type Credential = {
6
+ kind: 'bearer';
7
+ token: string;
8
+ } | {
9
+ kind: 'apiKey';
10
+ key: string;
11
+ };
12
+ /** The auth header(s) for a credential. Exactly one header, never both. */
13
+ export declare function authHeaders(c: Credential): Record<string, string>;
14
+ /**
15
+ * The ONE credential resolver every call site uses.
16
+ *
17
+ * An explicit `credential` always wins and never falls through: a bearer context must fail CLOSED,
18
+ * so it can never pick up `--api-key` or `NG_API_KEY` from the process env (the hosted server
19
+ * would otherwise act as whoever minted that key). Only when no credential is given (CLI / stdio)
20
+ * does this reach `resolveApiKey`, which reads `--api-key` then `NG_API_KEY`.
21
+ */
22
+ export declare function credentialOf(opts: {
23
+ apiKey?: string;
24
+ credential?: Credential;
25
+ }): Credential;
@@ -0,0 +1,25 @@
1
+ import { resolveApiKey } from './auth.js';
2
+ import { createCliError } from './errors.js';
3
+ /** The auth header(s) for a credential. Exactly one header, never both. */
4
+ export function authHeaders(c) {
5
+ return c.kind === 'bearer' ? { authorization: `Bearer ${c.token}` } : { 'x-api-key': c.key };
6
+ }
7
+ /**
8
+ * The ONE credential resolver every call site uses.
9
+ *
10
+ * An explicit `credential` always wins and never falls through: a bearer context must fail CLOSED,
11
+ * so it can never pick up `--api-key` or `NG_API_KEY` from the process env (the hosted server
12
+ * would otherwise act as whoever minted that key). Only when no credential is given (CLI / stdio)
13
+ * does this reach `resolveApiKey`, which reads `--api-key` then `NG_API_KEY`.
14
+ */
15
+ export function credentialOf(opts) {
16
+ const c = opts.credential;
17
+ if (c) {
18
+ const value = c.kind === 'bearer' ? c.token : c.key;
19
+ if (typeof value !== 'string' || !value.trim()) {
20
+ throw createCliError('AUTH', `Empty ${c.kind === 'bearer' ? 'bearer token' : 'API key'} credential`);
21
+ }
22
+ return c;
23
+ }
24
+ return { kind: 'apiKey', key: resolveApiKey(opts) };
25
+ }
@@ -1,8 +1,13 @@
1
1
  import { type AxiosInstance } from 'axios';
2
+ import { type Credential } from './credential.js';
2
3
  import { type CliError } from './errors.js';
3
- export declare function makeClient(baseUrl: string, apiKey: string): AxiosInstance;
4
+ export declare function makeClient(baseUrl: string, cred: Credential, opts?: {
5
+ maxRedirects?: number;
6
+ }): AxiosInstance;
4
7
  export interface CommonOpts {
5
8
  apiKey?: string;
9
+ /** Explicit credential (hosted MCP: a user's bearer token). Wins over `apiKey`/`NG_API_KEY` — see `credentialOf`. */
10
+ credential?: Credential;
6
11
  org?: string;
7
12
  url?: string;
8
13
  quiet?: boolean;
@@ -24,7 +29,8 @@ export declare function clickhouseClientFor(opts: CommonOpts, deps?: {
24
29
  /**
25
30
  * Build a human-readable message from a server error body. Handles DRF shapes:
26
31
  * a `detail`/`error` string, `non_field_errors`, and field-level error arrays
27
- * (e.g. `{ version: ["already exists"] }`). Falls back to the raw body so the
32
+ * (e.g. `{ version: ["already exists"] }`), plus next-core's
33
+ * `{ error, details }` validation shape. Falls back to the raw body so the
28
34
  * full server response is visible in logs instead of a bare status line.
29
35
  */
30
36
  export declare function extractApiMessage(data: unknown, fallback: string): string;
@@ -1,19 +1,20 @@
1
1
  import axios from 'axios';
2
- import { resolveApiKey } from './auth.js';
3
2
  import { resolveBaseUrl } from './baseUrl.js';
3
+ import { authHeaders, credentialOf } from './credential.js';
4
4
  import { createCliError, isCliError } from './errors.js';
5
- export function makeClient(baseUrl, apiKey) {
5
+ export function makeClient(baseUrl, cred, opts = {}) {
6
6
  return axios.create({
7
+ ...opts,
7
8
  baseURL: baseUrl,
8
9
  headers: {
9
10
  accept: 'application/json',
10
- 'x-api-key': apiKey,
11
+ ...authHeaders(cred),
11
12
  },
12
13
  });
13
14
  }
14
15
  /** Resolve auth + base URL into a client, or use an injected one (tests). */
15
16
  export function clientFor(opts, deps = {}) {
16
- return deps.client ?? makeClient(resolveBaseUrl({ org: opts.org, url: opts.url }), resolveApiKey(opts));
17
+ return deps.client ?? makeClient(resolveBaseUrl({ org: opts.org, url: opts.url }), credentialOf(opts));
17
18
  }
18
19
  export const CLICKHOUSE_API_BASE_URL = 'https://clickhouse.pitcher.com';
19
20
  /**
@@ -30,15 +31,163 @@ export function clickhouseClientFor(opts, deps = {}) {
30
31
  baseURL: CLICKHOUSE_API_BASE_URL,
31
32
  headers: {
32
33
  accept: 'application/json',
33
- 'x-api-key': resolveApiKey(opts),
34
+ ...authHeaders(credentialOf(opts)),
34
35
  'x-original-domain': originalDomain,
35
36
  },
36
37
  });
37
38
  }
39
+ /** Max number of `details` entries folded into a message before it is summarised. */
40
+ const MAX_DETAIL_PARTS = 3;
41
+ /** Max characters of joined `details` text, before the `(+N more)` marker is appended. */
42
+ const MAX_DETAIL_CHARS = 200;
43
+ /** How deep to walk a nested `details` value before falling back to JSON. */
44
+ const MAX_DETAIL_DEPTH = 3;
45
+ /**
46
+ * DRF envelope keys: the two places a serializer puts a message that names no field.
47
+ * next-core's `upstreamErrorMessage()` (`app/api/utils/upstream-error.ts`) reads `detail`
48
+ * first and sorts `non_field_errors` ahead of everything else, so a summary derived from a
49
+ * relayed upstream body is usually one of these two verbatim. Every other key IS a field
50
+ * name, and naming the field is the whole point of folding `details` in — so only a line
51
+ * from one of these keys may be dropped for merely repeating the summary.
52
+ */
53
+ const ENVELOPE_KEYS = new Set(['detail', 'non_field_errors']);
54
+ /**
55
+ * `JSON.stringify` throws on a circular structure and on a BigInt. This runs inside the
56
+ * error formatter, so a throw here would lose the API error entirely and print a raw
57
+ * stack instead — every failure has to collapse to a string.
58
+ */
59
+ function safeDetailJson(val) {
60
+ try {
61
+ const text = JSON.stringify(val, (_key, entry) => (typeof entry === 'bigint' ? String(entry) : entry));
62
+ return text ?? '[unserializable]';
63
+ }
64
+ catch {
65
+ return '[unserializable]';
66
+ }
67
+ }
68
+ /**
69
+ * Whether an array entry already identifies itself in the output. A zod issue carries
70
+ * its own `path`, and an entry that renders as a single scalar (`{ title: ['Required'] }`)
71
+ * reads better bare; anything else gets an `[i]` so entries do not blur together.
72
+ */
73
+ function labelsItself(entry) {
74
+ if (typeof entry !== 'object' || entry === null)
75
+ return true;
76
+ return typeof entry.message === 'string';
77
+ }
78
+ /**
79
+ * Flatten one `details` value into human-readable, path-prefixed lines.
80
+ *
81
+ * next-core emits several shapes for the same field: zod issues
82
+ * (`[{ path, message }]`, from `_4xx('Invalid request data', 400, { details: error.errors })`),
83
+ * a `zod.flatten()` object, a relayed upstream body (`{ details: error.response.data }`),
84
+ * a plain object (`{ details: { id } }`) and a bare string. All of them collapse to
85
+ * `path: message` lines here.
86
+ *
87
+ * The walk is total: every recursive branch sits below the depth guard, and the guard's
88
+ * own fallback cannot throw.
89
+ *
90
+ * `envelope` marks the lines that descend from a top-level `ENVELOPE_KEYS` key; it is what
91
+ * lets `renderDetails` drop a relay echo without dropping a labelled field error.
92
+ */
93
+ function detailParts(val, prefix = '', depth = 0, envelope = false) {
94
+ if (val == null)
95
+ return [];
96
+ const part = (value) => value ? [{ text: prefix ? `${prefix}: ${value}` : value, value, envelope }] : [];
97
+ if (typeof val === 'string')
98
+ return part(val.trim());
99
+ if (typeof val === 'number' || typeof val === 'boolean')
100
+ return part(String(val));
101
+ if (typeof val !== 'object')
102
+ return [];
103
+ // zod issue shape: `{ path: ['canvas_id'], message: 'Invalid ULID format for canvas_id' }`.
104
+ // A leaf, so it is answered above the depth guard.
105
+ if (!Array.isArray(val)) {
106
+ const obj = val;
107
+ if (typeof obj.message === 'string' && obj.message.trim()) {
108
+ const path = Array.isArray(obj.path) ? obj.path.join('.') : '';
109
+ const scope = [prefix, path].filter(Boolean).join('.');
110
+ const value = obj.message.trim();
111
+ return [{ text: scope ? `${scope}: ${value}` : value, value, envelope }];
112
+ }
113
+ }
114
+ if (depth >= MAX_DETAIL_DEPTH)
115
+ return part(safeDetailJson(val));
116
+ if (Array.isArray(val)) {
117
+ return val.flatMap((entry, i) => detailParts(entry, labelsItself(entry) ? prefix : `${prefix}[${i}]`, depth + 1, envelope));
118
+ }
119
+ const obj = val;
120
+ return Object.entries(obj).flatMap(([key, entry]) =>
121
+ // Only a key at the root of `details` can be the envelope — `{ canvas: { detail: … } }`
122
+ // is a field named `canvas`, not a DRF envelope.
123
+ detailParts(entry, prefix ? `${prefix}.${key}` : key, depth + 1, envelope || (!prefix && ENVELOPE_KEYS.has(key))));
124
+ }
125
+ /** Cut `text` to `max` code points — never splitting a surrogate pair — and mark the cut. */
126
+ function truncateDetails(text, max) {
127
+ const points = [...text];
128
+ if (points.length <= max)
129
+ return text;
130
+ return `${points.slice(0, max - 1).join('').trimEnd()}\u2026`;
131
+ }
132
+ /**
133
+ * Flatten a `details` value into one capped, de-duplicated line.
134
+ *
135
+ * When `summary` is given, two kinds of echo are suppressed, and only these two:
136
+ * - a whole rendered line equal to the summary (`_400Validation` in next-core answers a
137
+ * failed parse with `"<path>: <message>"`, which is exactly how the same issue renders
138
+ * here), and
139
+ * - a line from a DRF envelope key (`ENVELOPE_KEYS`) whose message equals the summary —
140
+ * the relay path, where `next-core .../shared-links/route.ts` builds its summary with
141
+ * `upstreamErrorMessage(body)` and then relays that same body as `details`, so the most
142
+ * common `p dsr` 400 would otherwise print `Not found. | detail: Not found.`
143
+ *
144
+ * A *field* entry is never dropped for matching the summary, however: in
145
+ * `{ error: 'This field is required.', details: { canvas: [...], expires_at: [...] } }` the
146
+ * field path is the only thing telling the user which field, and dropping `canvas` there
147
+ * left the message actively misleading (PR #5570 review).
148
+ *
149
+ * Output is capped so a large validation payload cannot become a wall of text. The
150
+ * `(+N more)` marker is appended after the cap, so it is never cut into a wrong count.
151
+ */
152
+ function renderDetails(details, summary) {
153
+ const seen = new Set();
154
+ const parts = [];
155
+ let dropped = 0;
156
+ for (const { text, value, envelope } of detailParts(details)) {
157
+ if (summary !== undefined && (text === summary || (envelope && value === summary)))
158
+ continue;
159
+ if (seen.has(text))
160
+ continue;
161
+ seen.add(text);
162
+ if (parts.length < MAX_DETAIL_PARTS)
163
+ parts.push(text);
164
+ else
165
+ dropped += 1;
166
+ }
167
+ if (!parts.length)
168
+ return '';
169
+ let text = truncateDetails(parts.join('; '), MAX_DETAIL_CHARS);
170
+ if (dropped > 0)
171
+ text += ` (+${dropped} more)`;
172
+ return text;
173
+ }
174
+ /**
175
+ * Fold an error body's `details` into the top-level summary so the user sees both
176
+ * halves on one line — next-core answers a failed schema parse with a generic
177
+ * `"Invalid request data"` and puts the only actionable text (the field and why it
178
+ * was rejected) in `details`, which used to be dropped entirely (PIT-7521).
179
+ */
180
+ function withDetails(summary, details) {
181
+ if (details === undefined)
182
+ return summary;
183
+ const text = renderDetails(details, summary);
184
+ return text ? `${summary} | ${text}` : summary;
185
+ }
38
186
  /**
39
187
  * Build a human-readable message from a server error body. Handles DRF shapes:
40
188
  * a `detail`/`error` string, `non_field_errors`, and field-level error arrays
41
- * (e.g. `{ version: ["already exists"] }`). Falls back to the raw body so the
189
+ * (e.g. `{ version: ["already exists"] }`), plus next-core's
190
+ * `{ error, details }` validation shape. Falls back to the raw body so the
42
191
  * full server response is visible in logs instead of a bare status line.
43
192
  */
44
193
  export function extractApiMessage(data, fallback) {
@@ -50,11 +199,22 @@ export function extractApiMessage(data, fallback) {
50
199
  return String(data);
51
200
  const obj = data;
52
201
  if (typeof obj.detail === 'string' && obj.detail)
53
- return obj.detail;
202
+ return withDetails(obj.detail, obj.details);
54
203
  if (typeof obj.error === 'string' && obj.error)
55
- return obj.error;
204
+ return withDetails(obj.error, obj.details);
56
205
  const parts = [];
57
206
  for (const [key, val] of Object.entries(obj)) {
207
+ // A body carrying `details` but no string `detail`/`error` summary lands here. next-core
208
+ // cannot produce that shape (`_4xx` always emits `{ error: message, ...data }`), but Django
209
+ // or another upstream can — and the generic rendering below would `join('; ')` structured
210
+ // entries into `details: [object Object]`, so walk it the same way `withDetails` does.
211
+ if (key === 'details') {
212
+ const rendered = renderDetails(val);
213
+ if (rendered) {
214
+ parts.push(rendered);
215
+ continue;
216
+ }
217
+ }
58
218
  const text = Array.isArray(val) ? val.join('; ') : typeof val === 'string' ? val : JSON.stringify(val);
59
219
  parts.push(key === 'non_field_errors' ? text : `${key}: ${text}`);
60
220
  }
@@ -1,4 +1,4 @@
1
- import type { AxiosInstance } from 'axios';
1
+ import { type AxiosInstance } from 'axios';
2
2
  import { type CommonOpts } from './http.js';
3
3
  import { type CliError } from './errors.js';
4
4
  export interface LdCommonOpts extends CommonOpts {
@@ -8,7 +8,17 @@ export interface LdCommonOpts extends CommonOpts {
8
8
  export interface LdDeps {
9
9
  ldClient?: AxiosInstance;
10
10
  platformClient?: AxiosInstance;
11
+ /** Test seam for the bearer → L&D token exchange POST (`{ldBase}/auth/token`). */
12
+ ldAuthClient?: AxiosInstance;
11
13
  }
14
+ /** The exact hosts a bearer credential may be sent to. An unusable `LD_ALLOWED_HOSTS` entry fails closed. */
15
+ export declare function ldAllowedHosts(env?: NodeJS.ProcessEnv): Set<string>;
16
+ /**
17
+ * Resolved L&D base URL per (platform host, instance). Bounded in TIME, not just per process: the
18
+ * hosted MCP server is one long-lived process, so an admin re-pointing an instance's ld-app
19
+ * `api_base_url` must take effect without a restart.
20
+ */
21
+ export declare const LD_BASE_URL_TTL_MS: number;
12
22
  /** Test-only: reset the resolved-base-URL cache between cases. */
13
23
  export declare function __clearLdBaseUrlCache(): void;
14
24
  /** Every L&D run* requires an instance; returns it narrowed to string, else VALIDATION. */
@@ -29,7 +39,33 @@ export declare function pickTruthy<T extends object>(opts: T, keys: readonly (ke
29
39
  * over a credential the CLI actually holds.
30
40
  */
31
41
  export declare function resolveLdBaseUrl(opts: LdCommonOpts, deps?: LdDeps, env?: NodeJS.ProcessEnv): Promise<string>;
32
- /** Build (or inject, in tests) an axios client pointed at the resolved L&D Lambda base. */
42
+ /** Max cached L&D tokens (one per distinct Pitcher token × L&D base). Oldest-used evicted first. */
43
+ export declare const LD_TOKEN_CACHE_MAX = 500;
44
+ /** Test-only: reset the L&D token-exchange cache between cases. */
45
+ export declare function __clearLdTokenCache(): void;
46
+ /** Test-only: current number of cached L&D tokens. */
47
+ export declare function __ldTokenCacheSize(): number;
48
+ /**
49
+ * The client that POSTs the user's token. `maxRedirects: 0` is load-bearing: axios follows 307/308 and
50
+ * RE-SENDS the body, so an allowlisted host answering 307 would forward the token to any host it names —
51
+ * straight past the allowlist. A 3xx is therefore a failed exchange (non-2xx), never followed.
52
+ */
53
+ export declare function createLdAuthHttp(): AxiosInstance;
54
+ /**
55
+ * Exchange a Pitcher (Auth0) access token for an L&D JWT at `POST {ldBase}/auth/token {token}`.
56
+ * The L&D envelope is `{ success, message, data: { token, refreshToken, user }, statusCode }`, so the
57
+ * JWT is at `res.data.data.token` (ld-app `authController.ts`).
58
+ *
59
+ * Cached until `min(L&D exp, Pitcher exp) − 60 s`: the L&D JWT embeds the Pitcher token, so it is
60
+ * useless once that expires, and its own lifetime is env-configurable (`JWT_EXPIRES_IN`), so neither
61
+ * is assumed. A token whose TTL cannot be read, or is already ≤ 0, is used once and not cached.
62
+ */
63
+ export declare function exchangeLdToken(ldBase: string, pitcherToken: string, deps: LdDeps, env: NodeJS.ProcessEnv): Promise<string>;
64
+ /**
65
+ * Build (or inject, in tests) an axios client pointed at the resolved L&D Lambda base.
66
+ * API key → sent as-is (`x-api-key`). Bearer → exchanged for an L&D JWT first (the Lambda does not
67
+ * accept an Auth0 token directly), and that JWT is sent as the bearer.
68
+ */
33
69
  export declare function ldClientFor(opts: LdCommonOpts, deps?: LdDeps, env?: NodeJS.ProcessEnv): Promise<AxiosInstance>;
34
70
  /**
35
71
  * Unwrap the L&D success envelope `{ success, data, message? }` to its resource payload.
@@ -1,4 +1,6 @@
1
- import { resolveApiKey } from './auth.js';
1
+ import axios from 'axios';
2
+ import { createHash } from 'crypto';
3
+ import { credentialOf } from './credential.js';
2
4
  import { clientFor, makeClient, toCliError } from './http.js';
3
5
  import { resolveBaseUrl } from './baseUrl.js';
4
6
  import { createCliError } from './errors.js';
@@ -41,6 +43,50 @@ const LD_FALLBACK_BASE_URL = 'https://cgmfr9gjza.execute-api.eu-west-1.amazonaws
41
43
  * what points each instance at it — the fallback must never silently stand in.
42
44
  */
43
45
  const LD_FALLBACK_PLATFORM_ORIGIN = 'https://dev.my.pitcher.com';
46
+ /**
47
+ * Bearer mode (the hosted MCP server) sends the USER's Pitcher Auth0 access token to
48
+ * `POST {ldBase}/auth/token`. `ldBase` can come from `metadata.api_base_url`, which any
49
+ * instance admin can write — so without a gate an admin could point it at a host they own and
50
+ * collect every user's token (plus SSRF from the server). In bearer mode the resolved base
51
+ * must therefore be `https:` on an EXACT allowlisted host (no port, no wildcard, no suffix
52
+ * match — anyone can mint a `*.execute-api` host). Default = the fallback Lambda's host;
53
+ * `LD_ALLOWED_HOSTS` (comma-separated exact hostnames) extends it. API-key mode is unchanged.
54
+ */
55
+ const LD_DEFAULT_ALLOWED_HOST = new URL(LD_FALLBACK_BASE_URL).host;
56
+ /** The exact hosts a bearer credential may be sent to. An unusable `LD_ALLOWED_HOSTS` entry fails closed. */
57
+ export function ldAllowedHosts(env = process.env) {
58
+ const hosts = new Set([LD_DEFAULT_ALLOWED_HOST]);
59
+ for (const raw of (env.LD_ALLOWED_HOSTS ?? '').split(',')) {
60
+ const entry = raw.trim().toLowerCase();
61
+ if (!entry)
62
+ continue;
63
+ if (!/^[a-z0-9-]+(\.[a-z0-9-]+)+$/.test(entry)) {
64
+ throw createCliError('VALIDATION', `LD_ALLOWED_HOSTS entries must be exact hostnames (no wildcard/port/scheme): ${entry}`);
65
+ }
66
+ hosts.add(entry);
67
+ }
68
+ return hosts;
69
+ }
70
+ /**
71
+ * Fail CLOSED unless `url` is safe to receive a bearer token. The message names the host, never the
72
+ * token. Called at resolution AND immediately before the exchange POST (defence in depth).
73
+ */
74
+ function assertBearerLdBase(url, env) {
75
+ let parsed;
76
+ try {
77
+ parsed = new URL(url);
78
+ }
79
+ catch {
80
+ throw createCliError('VALIDATION', 'LD_HOST_NOT_ALLOWED: the L&D base URL is not a valid URL');
81
+ }
82
+ if (parsed.protocol !== 'https:') {
83
+ throw createCliError('VALIDATION', `LD_HOST_NOT_ALLOWED: the L&D base URL must be https (got ${parsed.protocol})`);
84
+ }
85
+ if (!ldAllowedHosts(env).has(parsed.host)) {
86
+ throw createCliError('VALIDATION', `LD_HOST_NOT_ALLOWED: ${parsed.host} is not an allowed L&D host for a signed-in user. ` +
87
+ `Add it to LD_ALLOWED_HOSTS on the server if it is a genuine L&D deployment.`);
88
+ }
89
+ }
44
90
  /**
45
91
  * Safety stop for the installed-apps scan. At 500 records/page this is 10k apps in one
46
92
  * instance — unreachable in practice, so tripping it means the server kept advertising a
@@ -51,6 +97,12 @@ const MAX_APP_PAGES = 20;
51
97
  function isRecord(v) {
52
98
  return typeof v === 'object' && v !== null && !Array.isArray(v);
53
99
  }
100
+ /**
101
+ * Resolved L&D base URL per (platform host, instance). Bounded in TIME, not just per process: the
102
+ * hosted MCP server is one long-lived process, so an admin re-pointing an instance's ld-app
103
+ * `api_base_url` must take effect without a restart.
104
+ */
105
+ export const LD_BASE_URL_TTL_MS = 10 * 60000;
54
106
  const baseUrlCache = new Map();
55
107
  /** Test-only: reset the resolved-base-URL cache between cases. */
56
108
  export function __clearLdBaseUrlCache() {
@@ -176,6 +228,13 @@ async function fetchLdAppMetadata(platform, instanceId) {
176
228
  * over a credential the CLI actually holds.
177
229
  */
178
230
  export async function resolveLdBaseUrl(opts, deps = {}, env = process.env) {
231
+ const url = await resolveLdBaseUrlUnchecked(opts, deps, env);
232
+ // Every source (flag, env, instance metadata, fallback, cache) is checked, on every call.
233
+ if (opts.credential?.kind === 'bearer')
234
+ assertBearerLdBase(url, env);
235
+ return url;
236
+ }
237
+ async function resolveLdBaseUrlUnchecked(opts, deps, env) {
179
238
  const override = opts.ldUrl?.trim() || env.NG_LD_API_URL?.trim();
180
239
  if (override)
181
240
  return trimUrl(override, opts.ldUrl ? '--ld-url' : 'NG_LD_API_URL');
@@ -186,8 +245,8 @@ export async function resolveLdBaseUrl(opts, deps = {}, env = process.env) {
186
245
  // different platform hosts collide on one cached L&D URL within a single process.
187
246
  const cacheKey = `${opts.url ?? opts.org ?? 'dev'}:${opts.instanceId}`;
188
247
  const cached = baseUrlCache.get(cacheKey);
189
- if (cached)
190
- return cached;
248
+ if (cached && cached.expiresAt > Date.now())
249
+ return cached.url;
191
250
  const platform = deps.platformClient ?? clientFor(opts);
192
251
  const metadata = await fetchLdAppMetadata(platform, opts.instanceId);
193
252
  if (!metadata) {
@@ -209,15 +268,112 @@ export async function resolveLdBaseUrl(opts, deps = {}, env = process.env) {
209
268
  }
210
269
  url = LD_FALLBACK_BASE_URL;
211
270
  }
212
- baseUrlCache.set(cacheKey, url);
271
+ baseUrlCache.set(cacheKey, { url, expiresAt: Date.now() + LD_BASE_URL_TTL_MS });
213
272
  return url;
214
273
  }
215
- /** Build (or inject, in tests) an axios client pointed at the resolved L&D Lambda base. */
274
+ /** Max cached L&D tokens (one per distinct Pitcher token × L&D base). Oldest-used evicted first. */
275
+ export const LD_TOKEN_CACHE_MAX = 500;
276
+ /** Safety margin subtracted from the earliest expiry, so a cached token is never used at the edge. */
277
+ const LD_TOKEN_SKEW_MS = 60000;
278
+ /**
279
+ * Bearer → L&D token cache, keyed `sha256(pitcherToken)|ldBase` (the raw token is never a key).
280
+ * A `Map` iterates in insertion order, so re-inserting on hit makes the first key the LRU one.
281
+ */
282
+ const ldTokenCache = new Map();
283
+ /** Test-only: reset the L&D token-exchange cache between cases. */
284
+ export function __clearLdTokenCache() {
285
+ ldTokenCache.clear();
286
+ }
287
+ /** Test-only: current number of cached L&D tokens. */
288
+ export function __ldTokenCacheSize() {
289
+ return ldTokenCache.size;
290
+ }
291
+ /** `exp` (seconds) of a JWT, read WITHOUT verification — only ever used to bound a cache TTL. */
292
+ function jwtExpMs(token) {
293
+ const payload = token.split('.')[1];
294
+ if (!payload)
295
+ return undefined;
296
+ try {
297
+ const exp = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')).exp;
298
+ return typeof exp === 'number' && Number.isFinite(exp) ? exp * 1000 : undefined;
299
+ }
300
+ catch {
301
+ return undefined;
302
+ }
303
+ }
304
+ /**
305
+ * The client that POSTs the user's token. `maxRedirects: 0` is load-bearing: axios follows 307/308 and
306
+ * RE-SENDS the body, so an allowlisted host answering 307 would forward the token to any host it names —
307
+ * straight past the allowlist. A 3xx is therefore a failed exchange (non-2xx), never followed.
308
+ */
309
+ export function createLdAuthHttp() {
310
+ return axios.create({ headers: { accept: 'application/json' }, timeout: 10000, maxRedirects: 0 });
311
+ }
312
+ /**
313
+ * Exchange a Pitcher (Auth0) access token for an L&D JWT at `POST {ldBase}/auth/token {token}`.
314
+ * The L&D envelope is `{ success, message, data: { token, refreshToken, user }, statusCode }`, so the
315
+ * JWT is at `res.data.data.token` (ld-app `authController.ts`).
316
+ *
317
+ * Cached until `min(L&D exp, Pitcher exp) − 60 s`: the L&D JWT embeds the Pitcher token, so it is
318
+ * useless once that expires, and its own lifetime is env-configurable (`JWT_EXPIRES_IN`), so neither
319
+ * is assumed. A token whose TTL cannot be read, or is already ≤ 0, is used once and not cached.
320
+ */
321
+ export async function exchangeLdToken(ldBase, pitcherToken, deps, env) {
322
+ // Re-checked at the point the token leaves, independent of how `ldBase` was obtained.
323
+ assertBearerLdBase(ldBase, env);
324
+ const key = `${createHash('sha256').update(pitcherToken).digest('hex')}|${ldBase}`;
325
+ const hit = ldTokenCache.get(key);
326
+ if (hit) {
327
+ ldTokenCache.delete(key);
328
+ if (hit.expiresAt > Date.now()) {
329
+ ldTokenCache.set(key, hit);
330
+ return hit.token;
331
+ }
332
+ }
333
+ let ldToken;
334
+ try {
335
+ const http = deps.ldAuthClient ?? createLdAuthHttp();
336
+ const res = await http.post(`${ldBase}/auth/token`, { token: pitcherToken });
337
+ ldToken = res.data?.data?.token;
338
+ }
339
+ catch (e) {
340
+ const err = toLdCliError(e);
341
+ throw createCliError('AUTH', `L&D token exchange failed: ${err.message}`);
342
+ }
343
+ if (typeof ldToken !== 'string' || !ldToken) {
344
+ throw createCliError('AUTH', 'L&D token exchange failed: no token in the /auth/token response');
345
+ }
346
+ const ldExp = jwtExpMs(ldToken);
347
+ const pitcherExp = jwtExpMs(pitcherToken);
348
+ if (ldExp !== undefined && pitcherExp !== undefined) {
349
+ const expiresAt = Math.min(ldExp, pitcherExp) - LD_TOKEN_SKEW_MS;
350
+ if (expiresAt > Date.now()) {
351
+ ldTokenCache.set(key, { token: ldToken, expiresAt });
352
+ while (ldTokenCache.size > LD_TOKEN_CACHE_MAX) {
353
+ const oldest = ldTokenCache.keys().next().value;
354
+ if (oldest === undefined)
355
+ break;
356
+ ldTokenCache.delete(oldest);
357
+ }
358
+ }
359
+ }
360
+ return ldToken;
361
+ }
362
+ /**
363
+ * Build (or inject, in tests) an axios client pointed at the resolved L&D Lambda base.
364
+ * API key → sent as-is (`x-api-key`). Bearer → exchanged for an L&D JWT first (the Lambda does not
365
+ * accept an Auth0 token directly), and that JWT is sent as the bearer.
366
+ */
216
367
  export async function ldClientFor(opts, deps = {}, env = process.env) {
217
368
  if (deps.ldClient)
218
369
  return deps.ldClient;
219
370
  const base = await resolveLdBaseUrl(opts, deps, env);
220
- return makeClient(base, resolveApiKey(opts));
371
+ const cred = credentialOf(opts);
372
+ if (cred.kind === 'apiKey')
373
+ return makeClient(base, cred);
374
+ // No redirects for a bearer either: the L&D JWT embeds the user's Pitcher token.
375
+ const ldToken = await exchangeLdToken(base, cred.token, deps, env);
376
+ return makeClient(base, { kind: 'bearer', token: ldToken }, { maxRedirects: 0 });
221
377
  }
222
378
  /**
223
379
  * Unwrap the L&D success envelope `{ success, data, message? }` to its resource payload.
@@ -48,9 +48,16 @@ export function resolveTextInput(inline, file, label) {
48
48
  // Resolve an inline-or-file JSON flag and parse it. VALIDATION when neither is given
49
49
  // (`provide --<label> (inline JSON) or --<label>-file`) or the content is not valid JSON.
50
50
  // Used by the L&D canvas / question / assessment-link inputs.
51
+ //
52
+ // The "neither" test is `=== undefined`, NOT truthiness: `resolveTextInput` returns `''` for a
53
+ // ZERO-BYTE file, and a falsy test would report that as "you did not pass the flag" when the
54
+ // caller passed `--<label>-file empty.json` and the file is simply empty. An empty string is
55
+ // content, so let it reach `JSON.parse` and be named for what it is — not valid JSON. (An empty
56
+ // INLINE value still lands on the "neither" message: `resolveTextInput`'s own XOR is truthy-based,
57
+ // so it returns undefined for `--<label> ''` before this line runs.)
51
58
  export function parseJsonInput(inline, file, label) {
52
59
  const raw = resolveTextInput(inline, file, label);
53
- if (!raw)
60
+ if (raw === undefined)
54
61
  throw createCliError('VALIDATION', `provide --${label} (inline JSON) or --${label}-file`);
55
62
  try {
56
63
  return JSON.parse(raw);
@@ -1,7 +1,7 @@
1
1
  import { allTools } from './registry.js';
2
2
  // Developer-only tools excluded from the consumer .mcpb bundle. `publish_app` is alwaysDestructive
3
3
  // (creates a live, org-visible app release); `dev_sync`/`dev_sync_revert` mutate remote S3 dev state.
4
- // None are relevant to a non-technical adopter installing the extension. The CLI `p mcp` keeps all 123.
4
+ // None are relevant to a non-technical adopter installing the extension. The CLI `p mcp` registers every tool.
5
5
  export const BUNDLE_EXCLUDED_TOOLS = new Set(['pitcher_publish_app', 'pitcher_dev_sync', 'pitcher_dev_sync_revert']);
6
6
  // The tool set the .mcpb bundle registers.
7
7
  export const bundledTools = allTools.filter((t) => !BUNDLE_EXCLUDED_TOOLS.has(t.name));
@@ -0,0 +1,21 @@
1
+ import type { BuildServerOptions } from './server.js';
2
+ import type { ToolDescriptor } from './tools/types.js';
3
+ /**
4
+ * The tool set served by the hosted (remote, OAuth) MCP server — PIT-8912.
5
+ *
6
+ * Differences from stdio `p mcp`:
7
+ * - the three disk-bound tools (`LOCAL_ONLY`) are dropped: they zip a folder on the server host;
8
+ * - every file-path input (`FILE_KEY`) is stripped from the schemas AND rejected at runtime, so a
9
+ * hosted call can never make the server read its own disk; every one has an inline alternative;
10
+ * - descriptions that talk about local files are rewritten.
11
+ */
12
+ export declare const LOCAL_ONLY: ReadonlySet<string>;
13
+ /**
14
+ * A file-path input. Covers the 8 schema keys on main (`filePath`, `configFile`, `landingContextFile`,
15
+ * `htmlFile`, `cssFile`, `bodyFile`, `fromFile`, `sectionsFile`) AND command-layer keys no schema
16
+ * exposes today (`questionFile`, `answersFile`, `canvasFile`, `assessmentsFile`, `contentHtmlFile`).
17
+ */
18
+ export declare const FILE_KEY: RegExp;
19
+ /** Build options every hosted server uses: a write always asks the client to confirm. */
20
+ export declare const HOSTED_BUILD_OPTIONS: Readonly<BuildServerOptions>;
21
+ export declare const hostedTools: ToolDescriptor[];