@pitcher/cli-scripts 2.20.1 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +45 -45
- package/dist/index.js +4 -0
- package/dist/src/commands/analytics.d.ts +3 -3
- package/dist/src/commands/analytics.js +8 -6
- package/dist/src/commands/announcements.js +24 -3
- package/dist/src/commands/devSync.d.ts +3 -1
- package/dist/src/commands/devSync.js +17 -14
- package/dist/src/commands/login.d.ts +83 -0
- package/dist/src/commands/login.js +348 -0
- package/dist/src/commands/mcp.js +4 -3
- package/dist/src/commands/publishApp.d.ts +2 -1
- package/dist/src/commands/publishApp.js +10 -7
- package/dist/src/commands/sharedLinks.d.ts +0 -2
- package/dist/src/commands/sharedLinks.js +10 -20
- package/dist/src/commands/withAuth.d.ts +5 -1
- package/dist/src/commands/withAuth.js +8 -3
- package/dist/src/core/credential.d.ts +4 -16
- package/dist/src/core/credential.js +1 -25
- package/dist/src/core/http.d.ts +15 -7
- package/dist/src/core/http.js +33 -21
- package/dist/src/core/kpiTypes.d.ts +9 -0
- package/dist/src/core/kpiTypes.js +23 -0
- package/dist/src/core/ld.d.ts +3 -2
- package/dist/src/core/ld.js +45 -24
- package/dist/src/core/pitcherAuth.d.ts +86 -0
- package/dist/src/core/pitcherAuth.js +143 -0
- package/dist/src/core/session.d.ts +90 -0
- package/dist/src/core/session.js +293 -0
- package/dist/src/core/target.d.ts +47 -0
- package/dist/src/core/target.js +114 -0
- package/dist/src/mcp/local.d.ts +6 -0
- package/dist/src/mcp/local.js +8 -0
- package/dist/src/mcp/serve.d.ts +19 -4
- package/dist/src/mcp/serve.js +38 -15
- package/dist/src/mcp/server.d.ts +3 -1
- package/dist/src/mcp/server.js +2 -6
- package/dist/src/mcp/tools/analytics.js +3 -2
- package/dist/src/mcp/tools/publishApp.js +3 -3
- package/dist/src/mcp/tools/sharedLinks.js +4 -6
- package/package.json +11 -6
- package/dist/src/core/auth.d.ts +0 -3
- package/dist/src/core/auth.js +0 -10
- package/dist/src/mcp/bundled.d.ts +0 -4
- package/dist/src/mcp/bundled.js +0 -25
- package/dist/src/mcp/env.d.ts +0 -7
- package/dist/src/mcp/env.js +0 -13
package/dist/src/core/http.js
CHANGED
|
@@ -1,40 +1,49 @@
|
|
|
1
1
|
import axios from 'axios';
|
|
2
|
-
import { resolveBaseUrl } from './baseUrl.js';
|
|
3
|
-
import { authHeaders, credentialOf } from './credential.js';
|
|
4
2
|
import { createCliError, isCliError } from './errors.js';
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
3
|
+
import { authHeaders, resolveRequestTarget } from './target.js';
|
|
4
|
+
/**
|
|
5
|
+
* A static credential sets its header once, exactly as before. A function source (a `p login` session) is
|
|
6
|
+
* awaited PER REQUEST by an interceptor, so a long-running client (`dev-sync watch`) picks up a refreshed
|
|
7
|
+
* token and every builder stays synchronous.
|
|
8
|
+
*/
|
|
9
|
+
export function makeClient(baseUrl, source, opts = {}) {
|
|
10
|
+
const { headers, ...rest } = opts;
|
|
11
|
+
const client = axios.create({
|
|
12
|
+
...rest,
|
|
8
13
|
baseURL: baseUrl,
|
|
9
14
|
headers: {
|
|
10
15
|
accept: 'application/json',
|
|
11
|
-
...
|
|
16
|
+
...headers,
|
|
17
|
+
...(typeof source === 'function' ? {} : authHeaders(source)),
|
|
12
18
|
},
|
|
13
19
|
});
|
|
20
|
+
if (typeof source === 'function') {
|
|
21
|
+
client.interceptors.request.use(async (config) => {
|
|
22
|
+
for (const [name, value] of Object.entries(authHeaders(await source())))
|
|
23
|
+
config.headers.set(name, value);
|
|
24
|
+
return config;
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
return client;
|
|
14
28
|
}
|
|
15
29
|
/** Resolve auth + base URL into a client, or use an injected one (tests). */
|
|
16
30
|
export function clientFor(opts, deps = {}) {
|
|
17
|
-
|
|
31
|
+
if (deps.client)
|
|
32
|
+
return deps.client;
|
|
33
|
+
const target = resolveRequestTarget(opts);
|
|
34
|
+
return makeClient(target.baseUrl, target.source);
|
|
18
35
|
}
|
|
19
36
|
export const CLICKHOUSE_API_BASE_URL = 'https://clickhouse.pitcher.com';
|
|
20
37
|
/**
|
|
21
|
-
* Client for the clickhouse-api analytics service (PIT-7647). The service lives
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
* key validation + tenant scoping, so the usual flags select test vs prod.
|
|
38
|
+
* Client for the clickhouse-api analytics service (PIT-7647). The service lives on ONE host for every
|
|
39
|
+
* env/region; `x-original-domain` routes the request to the right core for key/token validation and tenant
|
|
40
|
+
* scoping. It is the TARGET's host, so a session call is scoped to the session's org, never a default.
|
|
25
41
|
*/
|
|
26
42
|
export function clickhouseClientFor(opts, deps = {}) {
|
|
27
43
|
if (deps.client)
|
|
28
44
|
return deps.client;
|
|
29
|
-
const
|
|
30
|
-
return
|
|
31
|
-
baseURL: CLICKHOUSE_API_BASE_URL,
|
|
32
|
-
headers: {
|
|
33
|
-
accept: 'application/json',
|
|
34
|
-
...authHeaders(credentialOf(opts)),
|
|
35
|
-
'x-original-domain': originalDomain,
|
|
36
|
-
},
|
|
37
|
-
});
|
|
45
|
+
const target = resolveRequestTarget(opts);
|
|
46
|
+
return makeClient(CLICKHOUSE_API_BASE_URL, target.source, { headers: { 'x-original-domain': target.host } });
|
|
38
47
|
}
|
|
39
48
|
/** Max number of `details` entries folded into a message before it is summarised. */
|
|
40
49
|
const MAX_DETAIL_PARTS = 3;
|
|
@@ -127,7 +136,10 @@ function truncateDetails(text, max) {
|
|
|
127
136
|
const points = [...text];
|
|
128
137
|
if (points.length <= max)
|
|
129
138
|
return text;
|
|
130
|
-
return `${points
|
|
139
|
+
return `${points
|
|
140
|
+
.slice(0, max - 1)
|
|
141
|
+
.join('')
|
|
142
|
+
.trimEnd()}\u2026`;
|
|
131
143
|
}
|
|
132
144
|
/**
|
|
133
145
|
* Flatten a `details` value into one capped, de-duplicated line.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every `p analytics kpi --type` value clickhouse-api implements. A literal copy: this package is published to
|
|
3
|
+
* npm and cannot import `clickhouse-api` (`src/lib/kpi-types.ts`, the source of truth). There is no aggregate
|
|
4
|
+
* `all` type. Drift guard: `test/commands/analyticsKpiTypes.test.ts` reads the clickhouse-api file and fails on
|
|
5
|
+
* any difference.
|
|
6
|
+
*/
|
|
7
|
+
export declare const KPI_TYPES: readonly ["total-canvases-created", "total-saved-canvases", "total-content-files", "total-templates", "total-sections", "total-members", "avg-file-duration", "avg-canvas-duration", "total-active-users", "total-files", "total-presentation-views", "sharing-kpis"];
|
|
8
|
+
export type KpiType = (typeof KPI_TYPES)[number];
|
|
9
|
+
export declare function isKpiType(value: string): value is KpiType;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every `p analytics kpi --type` value clickhouse-api implements. A literal copy: this package is published to
|
|
3
|
+
* npm and cannot import `clickhouse-api` (`src/lib/kpi-types.ts`, the source of truth). There is no aggregate
|
|
4
|
+
* `all` type. Drift guard: `test/commands/analyticsKpiTypes.test.ts` reads the clickhouse-api file and fails on
|
|
5
|
+
* any difference.
|
|
6
|
+
*/
|
|
7
|
+
export const KPI_TYPES = [
|
|
8
|
+
'total-canvases-created',
|
|
9
|
+
'total-saved-canvases',
|
|
10
|
+
'total-content-files',
|
|
11
|
+
'total-templates',
|
|
12
|
+
'total-sections',
|
|
13
|
+
'total-members',
|
|
14
|
+
'avg-file-duration',
|
|
15
|
+
'avg-canvas-duration',
|
|
16
|
+
'total-active-users',
|
|
17
|
+
'total-files',
|
|
18
|
+
'total-presentation-views',
|
|
19
|
+
'sharing-kpis',
|
|
20
|
+
];
|
|
21
|
+
export function isKpiType(value) {
|
|
22
|
+
return KPI_TYPES.includes(value);
|
|
23
|
+
}
|
package/dist/src/core/ld.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type AxiosInstance } from 'axios';
|
|
2
2
|
import { type CommonOpts } from './http.js';
|
|
3
|
+
import { type RequestTarget } from './target.js';
|
|
3
4
|
import { type CliError } from './errors.js';
|
|
4
5
|
export interface LdCommonOpts extends CommonOpts {
|
|
5
6
|
instanceId?: string;
|
|
@@ -38,7 +39,7 @@ export declare function pickTruthy<T extends object>(opts: T, keys: readonly (ke
|
|
|
38
39
|
* `app_metadata->>'name' = 'ld-app'` and `instance_id = $2`, `type = 'app'`, not deleted)
|
|
39
40
|
* over a credential the CLI actually holds.
|
|
40
41
|
*/
|
|
41
|
-
export declare function resolveLdBaseUrl(opts: LdCommonOpts, deps?: LdDeps, env?: NodeJS.ProcessEnv): Promise<string>;
|
|
42
|
+
export declare function resolveLdBaseUrl(opts: LdCommonOpts, deps?: LdDeps, env?: NodeJS.ProcessEnv, target?: RequestTarget): Promise<string>;
|
|
42
43
|
/** Max cached L&D tokens (one per distinct Pitcher token × L&D base). Oldest-used evicted first. */
|
|
43
44
|
export declare const LD_TOKEN_CACHE_MAX = 500;
|
|
44
45
|
/** Test-only: reset the L&D token-exchange cache between cases. */
|
|
@@ -60,7 +61,7 @@ export declare function createLdAuthHttp(): AxiosInstance;
|
|
|
60
61
|
* useless once that expires, and its own lifetime is env-configurable (`JWT_EXPIRES_IN`), so neither
|
|
61
62
|
* is assumed. A token whose TTL cannot be read, or is already ≤ 0, is used once and not cached.
|
|
62
63
|
*/
|
|
63
|
-
export declare function exchangeLdToken(ldBase: string, pitcherToken: string, deps: LdDeps, env: NodeJS.ProcessEnv): Promise<string>;
|
|
64
|
+
export declare function exchangeLdToken(ldBase: string, pitcherToken: string, deps: LdDeps, env: NodeJS.ProcessEnv, loopbackAllowed?: boolean): Promise<string>;
|
|
64
65
|
/**
|
|
65
66
|
* Build (or inject, in tests) an axios client pointed at the resolved L&D Lambda base.
|
|
66
67
|
* API key → sent as-is (`x-api-key`). Bearer → exchanged for an L&D JWT first (the Lambda does not
|
package/dist/src/core/ld.js
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import axios from 'axios';
|
|
2
2
|
import { createHash } from 'crypto';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import { resolveBaseUrl } from './baseUrl.js';
|
|
3
|
+
import { makeClient, toCliError } from './http.js';
|
|
4
|
+
import { credentialFrom, resolveRequestTarget } from './target.js';
|
|
6
5
|
import { createCliError } from './errors.js';
|
|
7
6
|
import { MAX_PAGE_SIZE } from './pagination.js';
|
|
8
7
|
const LD_APP_NAME = 'ld-app';
|
|
@@ -67,11 +66,18 @@ export function ldAllowedHosts(env = process.env) {
|
|
|
67
66
|
}
|
|
68
67
|
return hosts;
|
|
69
68
|
}
|
|
69
|
+
const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '[::1]']);
|
|
70
70
|
/**
|
|
71
71
|
* Fail CLOSED unless `url` is safe to receive a bearer token. The message names the host, never the
|
|
72
72
|
* token. Called at resolution AND immediately before the exchange POST (defence in depth).
|
|
73
|
+
*
|
|
74
|
+
* D-13: `loopbackAllowed` additionally accepts a local L&D at `http://127.0.0.1|localhost|[::1]:<explicit port>`.
|
|
75
|
+
* It is true ONLY on the CLI session path AND when the URL came from the person's own explicit override
|
|
76
|
+
* (`--ld-url` / `NG_LD_API_URL`), never from instance metadata or the cache: `api_base_url` is
|
|
77
|
+
* instance-admin-writable, so an admin setting it to `http://localhost:<port>` must not make every signed-in
|
|
78
|
+
* user POST their token to whatever listens there. The hosted server never sets it.
|
|
73
79
|
*/
|
|
74
|
-
function assertBearerLdBase(url, env) {
|
|
80
|
+
function assertBearerLdBase(url, env, loopbackAllowed = false) {
|
|
75
81
|
let parsed;
|
|
76
82
|
try {
|
|
77
83
|
parsed = new URL(url);
|
|
@@ -79,6 +85,8 @@ function assertBearerLdBase(url, env) {
|
|
|
79
85
|
catch {
|
|
80
86
|
throw createCliError('VALIDATION', 'LD_HOST_NOT_ALLOWED: the L&D base URL is not a valid URL');
|
|
81
87
|
}
|
|
88
|
+
if (loopbackAllowed && parsed.protocol === 'http:' && LOOPBACK_HOSTS.has(parsed.hostname) && parsed.port)
|
|
89
|
+
return;
|
|
82
90
|
if (parsed.protocol !== 'https:') {
|
|
83
91
|
throw createCliError('VALIDATION', `LD_HOST_NOT_ALLOWED: the L&D base URL must be https (got ${parsed.protocol})`);
|
|
84
92
|
}
|
|
@@ -227,27 +235,39 @@ async function fetchLdAppMetadata(platform, instanceId) {
|
|
|
227
235
|
* `app_metadata->>'name' = 'ld-app'` and `instance_id = $2`, `type = 'app'`, not deleted)
|
|
228
236
|
* over a credential the CLI actually holds.
|
|
229
237
|
*/
|
|
230
|
-
export async function resolveLdBaseUrl(opts, deps = {}, env = process.env) {
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
238
|
+
export async function resolveLdBaseUrl(opts, deps = {}, env = process.env, target = resolveRequestTarget(opts, env)) {
|
|
239
|
+
return (await resolveLdBase(opts, deps, env, target)).url;
|
|
240
|
+
}
|
|
241
|
+
/** The resolved base plus whether a bearer may go to a loopback URL (session path + explicit override only). */
|
|
242
|
+
async function resolveLdBase(opts, deps, env, target) {
|
|
243
|
+
const { url, fromOverride } = await resolveLdBaseUrlUnchecked(opts, deps, env, target);
|
|
244
|
+
const loopbackAllowed = target.via === 'session' && fromOverride;
|
|
245
|
+
// Every source (flag, env, instance metadata, fallback, cache) is checked, on every call. Keyed on the
|
|
246
|
+
// TARGET, not `opts.credential`: a session's bearer is resolved by the target and never arrives as opts.
|
|
247
|
+
if (sendsBearer(target))
|
|
248
|
+
assertBearerLdBase(url, env, loopbackAllowed);
|
|
249
|
+
return { url, loopbackAllowed };
|
|
250
|
+
}
|
|
251
|
+
/** A bearer leaves this process: the hosted explicit bearer, or a `p login` session (always a bearer). */
|
|
252
|
+
function sendsBearer(target) {
|
|
253
|
+
if (target.via === 'session')
|
|
254
|
+
return true;
|
|
255
|
+
return target.via === 'explicit' && typeof target.source !== 'function' && target.source.kind === 'bearer';
|
|
236
256
|
}
|
|
237
|
-
async function resolveLdBaseUrlUnchecked(opts, deps, env) {
|
|
257
|
+
async function resolveLdBaseUrlUnchecked(opts, deps, env, target) {
|
|
238
258
|
const override = opts.ldUrl?.trim() || env.NG_LD_API_URL?.trim();
|
|
239
259
|
if (override)
|
|
240
|
-
return trimUrl(override, opts.ldUrl ? '--ld-url' : 'NG_LD_API_URL');
|
|
260
|
+
return { url: trimUrl(override, opts.ldUrl ? '--ld-url' : 'NG_LD_API_URL'), fromOverride: true };
|
|
241
261
|
if (!opts.instanceId) {
|
|
242
262
|
throw createCliError('VALIDATION', '--instance-id is required to resolve the L&D API URL (or set NG_LD_API_URL)');
|
|
243
263
|
}
|
|
244
|
-
//
|
|
245
|
-
//
|
|
246
|
-
const cacheKey = `${
|
|
264
|
+
// Keyed on the resolved platform origin, so two hosts (an --org, a --url, a session) never share a
|
|
265
|
+
// cached L&D URL within one process.
|
|
266
|
+
const cacheKey = `${target.baseUrl}:${opts.instanceId}`;
|
|
247
267
|
const cached = baseUrlCache.get(cacheKey);
|
|
248
268
|
if (cached && cached.expiresAt > Date.now())
|
|
249
|
-
return cached.url;
|
|
250
|
-
const platform = deps.platformClient ??
|
|
269
|
+
return { url: cached.url, fromOverride: false };
|
|
270
|
+
const platform = deps.platformClient ?? makeClient(target.baseUrl, target.source);
|
|
251
271
|
const metadata = await fetchLdAppMetadata(platform, opts.instanceId);
|
|
252
272
|
if (!metadata) {
|
|
253
273
|
throw createCliError('NOT_FOUND', 'ld-app is not configured in this instance (not installed). Set --ld-url or NG_LD_API_URL.');
|
|
@@ -260,7 +280,7 @@ async function resolveLdBaseUrlUnchecked(opts, deps, env) {
|
|
|
260
280
|
url = trimUrl(apiBaseUrl.trim(), `ld-app config (metadata.api_base_url on instance ${opts.instanceId})`);
|
|
261
281
|
}
|
|
262
282
|
else {
|
|
263
|
-
const platformOrigin =
|
|
283
|
+
const platformOrigin = target.baseUrl;
|
|
264
284
|
if (platformOrigin !== LD_FALLBACK_PLATFORM_ORIGIN) {
|
|
265
285
|
throw createCliError('NOT_FOUND', `ld-app has no api_base_url configured on this instance, and the built-in default only serves ` +
|
|
266
286
|
`${LD_FALLBACK_PLATFORM_ORIGIN} (this run targets ${platformOrigin}). Set metadata.api_base_url ` +
|
|
@@ -269,7 +289,7 @@ async function resolveLdBaseUrlUnchecked(opts, deps, env) {
|
|
|
269
289
|
url = LD_FALLBACK_BASE_URL;
|
|
270
290
|
}
|
|
271
291
|
baseUrlCache.set(cacheKey, { url, expiresAt: Date.now() + LD_BASE_URL_TTL_MS });
|
|
272
|
-
return url;
|
|
292
|
+
return { url, fromOverride: false };
|
|
273
293
|
}
|
|
274
294
|
/** Max cached L&D tokens (one per distinct Pitcher token × L&D base). Oldest-used evicted first. */
|
|
275
295
|
export const LD_TOKEN_CACHE_MAX = 500;
|
|
@@ -318,9 +338,9 @@ export function createLdAuthHttp() {
|
|
|
318
338
|
* useless once that expires, and its own lifetime is env-configurable (`JWT_EXPIRES_IN`), so neither
|
|
319
339
|
* is assumed. A token whose TTL cannot be read, or is already ≤ 0, is used once and not cached.
|
|
320
340
|
*/
|
|
321
|
-
export async function exchangeLdToken(ldBase, pitcherToken, deps, env) {
|
|
341
|
+
export async function exchangeLdToken(ldBase, pitcherToken, deps, env, loopbackAllowed = false) {
|
|
322
342
|
// Re-checked at the point the token leaves, independent of how `ldBase` was obtained.
|
|
323
|
-
assertBearerLdBase(ldBase, env);
|
|
343
|
+
assertBearerLdBase(ldBase, env, loopbackAllowed);
|
|
324
344
|
const key = `${createHash('sha256').update(pitcherToken).digest('hex')}|${ldBase}`;
|
|
325
345
|
const hit = ldTokenCache.get(key);
|
|
326
346
|
if (hit) {
|
|
@@ -367,12 +387,13 @@ export async function exchangeLdToken(ldBase, pitcherToken, deps, env) {
|
|
|
367
387
|
export async function ldClientFor(opts, deps = {}, env = process.env) {
|
|
368
388
|
if (deps.ldClient)
|
|
369
389
|
return deps.ldClient;
|
|
370
|
-
const
|
|
371
|
-
const
|
|
390
|
+
const target = resolveRequestTarget(opts, env);
|
|
391
|
+
const { url: base, loopbackAllowed } = await resolveLdBase(opts, deps, env, target);
|
|
392
|
+
const cred = await credentialFrom(target.source);
|
|
372
393
|
if (cred.kind === 'apiKey')
|
|
373
394
|
return makeClient(base, cred);
|
|
374
395
|
// 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);
|
|
396
|
+
const ldToken = await exchangeLdToken(base, cred.token, deps, env, loopbackAllowed);
|
|
376
397
|
return makeClient(base, { kind: 'bearer', token: ldToken }, { maxRedirects: 0 });
|
|
377
398
|
}
|
|
378
399
|
/**
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pitcher login primitives shared by `p login` (this package) and the hosted MCP server (`mcp-server`, which
|
|
3
|
+
* imports them through the `@pitcher/cli-scripts/auth` export): the org-domain allowlist, org-config discovery,
|
|
4
|
+
* and the Auth0 authorize URL + token/refresh/revoke grants. No JWT verification here (that needs `jose`,
|
|
5
|
+
* which stays in mcp-server): the CLI only decodes, every backend verifies.
|
|
6
|
+
*
|
|
7
|
+
* Global `fetch`, `URLSearchParams` and `AbortSignal.timeout` only, so the published CLI gains no dependency.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* The Pitcher domain a user types. Ruling (a): only `<org>.my.pitcher.com`. The allowlist runs BEFORE any
|
|
11
|
+
* outbound fetch, so the field can never aim a request anywhere else.
|
|
12
|
+
*/
|
|
13
|
+
export declare const DOMAIN_RULING_MESSAGE = "Only <org>.my.pitcher.com addresses are supported for now \u2014 use your Pitcher web address, e.g. acme.my.pitcher.com";
|
|
14
|
+
export type DomainResult = {
|
|
15
|
+
ok: true;
|
|
16
|
+
domain: string;
|
|
17
|
+
} | {
|
|
18
|
+
ok: false;
|
|
19
|
+
error: string;
|
|
20
|
+
};
|
|
21
|
+
export declare function normalizeDomain(input: unknown): DomainResult;
|
|
22
|
+
/** `acme.my.pitcher.com` → `acme` (only call on a normalized domain). */
|
|
23
|
+
export declare function orgSubdomain(domain: string): string;
|
|
24
|
+
/**
|
|
25
|
+
* Auth0 Native "Pitcher MCP" app per tenant, keyed by the org-config `auth0.domain` (the CUSTOM domain). Public
|
|
26
|
+
* client ids (no secret). ⛔ Two sources, on purpose: the hosted server reads its own `AUTH0_CLIENT_IDS` Fly
|
|
27
|
+
* secret (`mcp-server/src/config.ts`) so a tenant can be enabled there without a CLI release; keep the entries
|
|
28
|
+
* in step when a tenant is added.
|
|
29
|
+
*/
|
|
30
|
+
export declare const AUTH0_NATIVE_CLIENT_IDS: Readonly<Record<string, string>>;
|
|
31
|
+
export interface OrgConfig {
|
|
32
|
+
/** The Auth0 CUSTOM domain (`auth0.test-eu.pitcher.com`) — the key into the client-id maps. */
|
|
33
|
+
auth0Domain: string;
|
|
34
|
+
audience: string;
|
|
35
|
+
/** Auth0 organization id (`org_…`). Nullable upstream: null = "no MCP login configured". */
|
|
36
|
+
organization: string | null;
|
|
37
|
+
/** `'test' | 'production'`. OPTIONAL upstream: treat unknown as production. */
|
|
38
|
+
environment?: string;
|
|
39
|
+
}
|
|
40
|
+
export interface Auth0Tokens {
|
|
41
|
+
access_token: string;
|
|
42
|
+
refresh_token?: string;
|
|
43
|
+
expires_in?: number;
|
|
44
|
+
}
|
|
45
|
+
export interface UpstreamError extends Error {
|
|
46
|
+
isUpstreamError: true;
|
|
47
|
+
/** `grant` = Auth0 said invalid_grant (the client must re-auth); `upstream` = anything else. */
|
|
48
|
+
kind: 'grant' | 'upstream';
|
|
49
|
+
}
|
|
50
|
+
export declare function createUpstreamError(kind: UpstreamError['kind'], message: string): UpstreamError;
|
|
51
|
+
export declare function isUpstreamError(e: unknown): e is UpstreamError;
|
|
52
|
+
export type FetchFn = typeof globalThis.fetch;
|
|
53
|
+
export interface HttpOpts {
|
|
54
|
+
fetch?: FetchFn;
|
|
55
|
+
/** Per request; default 5 s. */
|
|
56
|
+
timeoutMs?: number;
|
|
57
|
+
}
|
|
58
|
+
export declare function issuerFor(auth0Domain: string): string;
|
|
59
|
+
export declare function buildAuthorizeUrl(p: {
|
|
60
|
+
auth0Domain: string;
|
|
61
|
+
clientId: string;
|
|
62
|
+
redirectUri: string;
|
|
63
|
+
organization: string;
|
|
64
|
+
audience: string;
|
|
65
|
+
state: string;
|
|
66
|
+
codeChallenge: string;
|
|
67
|
+
}): string;
|
|
68
|
+
/** null = the domain is not a Pitcher org (4xx / no auth0 block). Throws an UpstreamError on network/5xx. */
|
|
69
|
+
export declare function fetchOrgConfig(domain: string, opts?: HttpOpts): Promise<OrgConfig | null>;
|
|
70
|
+
export declare function exchangeCode(p: {
|
|
71
|
+
auth0Domain: string;
|
|
72
|
+
clientId: string;
|
|
73
|
+
code: string;
|
|
74
|
+
verifier: string;
|
|
75
|
+
redirectUri: string;
|
|
76
|
+
}, opts?: HttpOpts): Promise<Auth0Tokens>;
|
|
77
|
+
export declare function refresh(p: {
|
|
78
|
+
auth0Domain: string;
|
|
79
|
+
clientId: string;
|
|
80
|
+
refreshToken: string;
|
|
81
|
+
}, opts?: HttpOpts): Promise<Auth0Tokens>;
|
|
82
|
+
export declare function revoke(p: {
|
|
83
|
+
auth0Domain: string;
|
|
84
|
+
clientId: string;
|
|
85
|
+
token: string;
|
|
86
|
+
}, opts?: HttpOpts): Promise<void>;
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pitcher login primitives shared by `p login` (this package) and the hosted MCP server (`mcp-server`, which
|
|
3
|
+
* imports them through the `@pitcher/cli-scripts/auth` export): the org-domain allowlist, org-config discovery,
|
|
4
|
+
* and the Auth0 authorize URL + token/refresh/revoke grants. No JWT verification here (that needs `jose`,
|
|
5
|
+
* which stays in mcp-server): the CLI only decodes, every backend verifies.
|
|
6
|
+
*
|
|
7
|
+
* Global `fetch`, `URLSearchParams` and `AbortSignal.timeout` only, so the published CLI gains no dependency.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* The Pitcher domain a user types. Ruling (a): only `<org>.my.pitcher.com`. The allowlist runs BEFORE any
|
|
11
|
+
* outbound fetch, so the field can never aim a request anywhere else.
|
|
12
|
+
*/
|
|
13
|
+
export const DOMAIN_RULING_MESSAGE = 'Only <org>.my.pitcher.com addresses are supported for now — use your Pitcher web address, e.g. acme.my.pitcher.com';
|
|
14
|
+
const ALLOWED_DOMAIN = /^[a-z0-9-]+\.my\.pitcher\.com$/;
|
|
15
|
+
export function normalizeDomain(input) {
|
|
16
|
+
if (typeof input !== 'string')
|
|
17
|
+
return { ok: false, error: DOMAIN_RULING_MESSAGE };
|
|
18
|
+
let d = input.trim().toLowerCase();
|
|
19
|
+
d = d.replace(/^https?:\/\//, '');
|
|
20
|
+
d = d.split(/[/?#]/, 1)[0] ?? '';
|
|
21
|
+
if (d.endsWith('.'))
|
|
22
|
+
d = d.slice(0, -1);
|
|
23
|
+
return ALLOWED_DOMAIN.test(d) ? { ok: true, domain: d } : { ok: false, error: DOMAIN_RULING_MESSAGE };
|
|
24
|
+
}
|
|
25
|
+
/** `acme.my.pitcher.com` → `acme` (only call on a normalized domain). */
|
|
26
|
+
export function orgSubdomain(domain) {
|
|
27
|
+
return domain.slice(0, domain.indexOf('.'));
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Auth0 Native "Pitcher MCP" app per tenant, keyed by the org-config `auth0.domain` (the CUSTOM domain). Public
|
|
31
|
+
* client ids (no secret). ⛔ Two sources, on purpose: the hosted server reads its own `AUTH0_CLIENT_IDS` Fly
|
|
32
|
+
* secret (`mcp-server/src/config.ts`) so a tenant can be enabled there without a CLI release; keep the entries
|
|
33
|
+
* in step when a tenant is added.
|
|
34
|
+
*/
|
|
35
|
+
export const AUTH0_NATIVE_CLIENT_IDS = {
|
|
36
|
+
'auth0.test-eu.pitcher.com': 'EvXiukSFA9LmxkoMyGfGrX6tk5eZdJFu',
|
|
37
|
+
'auth0.eu.pitcher.com': 'cJpAajaha7dWc2mnGy3GZSMBm3kzX370',
|
|
38
|
+
};
|
|
39
|
+
export function createUpstreamError(kind, message) {
|
|
40
|
+
return Object.assign(new Error(message), { isUpstreamError: true, kind });
|
|
41
|
+
}
|
|
42
|
+
export function isUpstreamError(e) {
|
|
43
|
+
return e instanceof Error && e.isUpstreamError === true;
|
|
44
|
+
}
|
|
45
|
+
export function issuerFor(auth0Domain) {
|
|
46
|
+
return `https://${auth0Domain}/`;
|
|
47
|
+
}
|
|
48
|
+
export function buildAuthorizeUrl(p) {
|
|
49
|
+
const u = new URL(`https://${p.auth0Domain}/authorize`);
|
|
50
|
+
u.search = new URLSearchParams({
|
|
51
|
+
response_type: 'code',
|
|
52
|
+
client_id: p.clientId,
|
|
53
|
+
redirect_uri: p.redirectUri,
|
|
54
|
+
organization: p.organization,
|
|
55
|
+
audience: p.audience,
|
|
56
|
+
scope: 'openid profile email offline_access',
|
|
57
|
+
code_challenge: p.codeChallenge,
|
|
58
|
+
code_challenge_method: 'S256',
|
|
59
|
+
state: p.state,
|
|
60
|
+
}).toString();
|
|
61
|
+
return u.href;
|
|
62
|
+
}
|
|
63
|
+
/** null = the domain is not a Pitcher org (4xx / no auth0 block). Throws an UpstreamError on network/5xx. */
|
|
64
|
+
export async function fetchOrgConfig(domain, opts = {}) {
|
|
65
|
+
const doFetch = opts.fetch ?? globalThis.fetch;
|
|
66
|
+
const url = `https://${domain}/core/api/org-config?org_subdomain=${encodeURIComponent(orgSubdomain(domain))}`;
|
|
67
|
+
let res;
|
|
68
|
+
try {
|
|
69
|
+
res = await doFetch(url, {
|
|
70
|
+
headers: { accept: 'application/json' },
|
|
71
|
+
// The allowlisted host is the only host we will talk to — never follow it elsewhere.
|
|
72
|
+
redirect: 'error',
|
|
73
|
+
signal: AbortSignal.timeout(opts.timeoutMs ?? 5000),
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
throw createUpstreamError('upstream', 'org-config unreachable');
|
|
78
|
+
}
|
|
79
|
+
if (res.status >= 500)
|
|
80
|
+
throw createUpstreamError('upstream', `org-config returned ${res.status}`);
|
|
81
|
+
if (res.status !== 200)
|
|
82
|
+
return null;
|
|
83
|
+
const json = await res.json().catch(() => null);
|
|
84
|
+
const a = json?.auth0;
|
|
85
|
+
if (!a || typeof a.domain !== 'string' || typeof a.audience !== 'string')
|
|
86
|
+
return null;
|
|
87
|
+
return {
|
|
88
|
+
auth0Domain: a.domain,
|
|
89
|
+
audience: a.audience,
|
|
90
|
+
organization: typeof a.organization === 'string' && a.organization ? a.organization : null,
|
|
91
|
+
environment: typeof json.environment === 'string' ? json.environment : undefined,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
async function postJson(url, body, opts) {
|
|
95
|
+
const doFetch = opts.fetch ?? globalThis.fetch;
|
|
96
|
+
let res;
|
|
97
|
+
try {
|
|
98
|
+
res = await doFetch(url, {
|
|
99
|
+
method: 'POST',
|
|
100
|
+
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
|
101
|
+
body: JSON.stringify(body),
|
|
102
|
+
// Codes, PKCE verifiers and refresh tokens must never be re-sent to a redirect target.
|
|
103
|
+
redirect: 'error',
|
|
104
|
+
signal: AbortSignal.timeout(opts.timeoutMs ?? 5000),
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
throw createUpstreamError('upstream', 'Auth0 unreachable');
|
|
109
|
+
}
|
|
110
|
+
const json = await res.json().catch(() => ({}));
|
|
111
|
+
return { status: res.status, json };
|
|
112
|
+
}
|
|
113
|
+
async function tokenGrant(auth0Domain, body, opts) {
|
|
114
|
+
const { status, json } = await postJson(`https://${auth0Domain}/oauth/token`, body, opts);
|
|
115
|
+
if (status === 200 && typeof json?.access_token === 'string') {
|
|
116
|
+
return {
|
|
117
|
+
access_token: json.access_token,
|
|
118
|
+
refresh_token: typeof json.refresh_token === 'string' ? json.refresh_token : undefined,
|
|
119
|
+
expires_in: typeof json.expires_in === 'number' ? json.expires_in : undefined,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
if ((status === 400 || status === 403) && json?.error === 'invalid_grant') {
|
|
123
|
+
throw createUpstreamError('grant', 'Auth0 rejected the grant');
|
|
124
|
+
}
|
|
125
|
+
throw createUpstreamError('upstream', `Auth0 token endpoint returned ${status}`);
|
|
126
|
+
}
|
|
127
|
+
export function exchangeCode(p, opts = {}) {
|
|
128
|
+
return tokenGrant(p.auth0Domain, {
|
|
129
|
+
grant_type: 'authorization_code',
|
|
130
|
+
client_id: p.clientId,
|
|
131
|
+
code: p.code,
|
|
132
|
+
code_verifier: p.verifier,
|
|
133
|
+
redirect_uri: p.redirectUri,
|
|
134
|
+
}, opts);
|
|
135
|
+
}
|
|
136
|
+
export function refresh(p, opts = {}) {
|
|
137
|
+
return tokenGrant(p.auth0Domain, { grant_type: 'refresh_token', client_id: p.clientId, refresh_token: p.refreshToken }, opts);
|
|
138
|
+
}
|
|
139
|
+
export async function revoke(p, opts = {}) {
|
|
140
|
+
const { status } = await postJson(`https://${p.auth0Domain}/oauth/revoke`, { client_id: p.clientId, token: p.token }, opts);
|
|
141
|
+
if (status !== 200)
|
|
142
|
+
throw createUpstreamError('upstream', `Auth0 revoke returned ${status}`);
|
|
143
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { Credential } from './credential.js';
|
|
2
|
+
import { type Auth0Tokens } from './pitcherAuth.js';
|
|
3
|
+
/**
|
|
4
|
+
* `p login` sessions, ONE PER ORG DOMAIN (OD-3): `${XDG_CONFIG_HOME:-~/.config}/pitcher/sessions/<domain>.json`
|
|
5
|
+
* (`PITCHER_CONFIG_DIR` replaces `…/pitcher`, for tests). Files are `0600` in `0700` directories, written
|
|
6
|
+
* atomically (temp file + rename), and refused on read when group/other can access them (ssh's rule).
|
|
7
|
+
*
|
|
8
|
+
* Refresh tokens ROTATE with a 30 s reuse window: a second use of the same RT outside it trips Auth0's reuse
|
|
9
|
+
* detection, which also kills the user's SSO session. So a refresh runs under a per-domain lock AND writes
|
|
10
|
+
* only if the file still holds the RT it sent (compare-and-write). Correctness rests on the compare-and-write;
|
|
11
|
+
* the lock only avoids most duplicate refreshes.
|
|
12
|
+
*/
|
|
13
|
+
export interface SessionV1 {
|
|
14
|
+
version: 1;
|
|
15
|
+
/** acme.my.pitcher.com, equals the file name */
|
|
16
|
+
domain: string;
|
|
17
|
+
/** org-config 'test' | 'production'; OPTIONAL upstream, so unknown means production */
|
|
18
|
+
environment?: string;
|
|
19
|
+
/** org-config auth0.domain (the custom domain), the key into AUTH0_NATIVE_CLIENT_IDS */
|
|
20
|
+
auth0Domain: string;
|
|
21
|
+
clientId: string;
|
|
22
|
+
audience: string;
|
|
23
|
+
/** org_…; every refreshed access token must carry the same org_id */
|
|
24
|
+
organization: string;
|
|
25
|
+
sub: string;
|
|
26
|
+
email?: string;
|
|
27
|
+
accessToken: string;
|
|
28
|
+
/** epoch seconds, from the access token's `exp` (not `expires_in`) */
|
|
29
|
+
accessTokenExpiresAt: number;
|
|
30
|
+
refreshToken: string;
|
|
31
|
+
updatedAt: number;
|
|
32
|
+
}
|
|
33
|
+
type Env = NodeJS.ProcessEnv;
|
|
34
|
+
export declare function configRoot(env?: Env): string;
|
|
35
|
+
export declare function sessionsDir(env?: Env): string;
|
|
36
|
+
/** Only call with a normalized domain (the allowlist makes it a safe file name). */
|
|
37
|
+
export declare function sessionPath(domain: string, env?: Env): string;
|
|
38
|
+
/** Sorted. Inclusion match on the file name, so stray files (temp, lock, .DS_Store) are never sessions. */
|
|
39
|
+
export declare function listSessionDomains(env?: Env): string[];
|
|
40
|
+
/** null = no file. AUTH on too-open permissions, bad JSON, an unknown version, or a domain ≠ the file name. */
|
|
41
|
+
export declare function readSession(domain: string, env?: Env): SessionV1 | null;
|
|
42
|
+
/** Atomic replace of ONE domain's session; other domains are untouched. The caller holds the domain lock. */
|
|
43
|
+
export declare function writeSession(domain: string, s: SessionV1, env?: Env): void;
|
|
44
|
+
/** The caller holds the domain lock. */
|
|
45
|
+
export declare function deleteSession(domain: string, env?: Env): void;
|
|
46
|
+
/**
|
|
47
|
+
* The ONE domain picker every session consumer uses: `--domain` (or `logout`'s positional) → `PITCHER_DOMAIN`
|
|
48
|
+
* → the single existing session. Never "most recent wins": a silent pick could send a prod write to the
|
|
49
|
+
* wrong org, so two or more sessions with nothing explicit is a VALIDATION error.
|
|
50
|
+
*/
|
|
51
|
+
export declare function selectDomain(opts: {
|
|
52
|
+
domain?: string;
|
|
53
|
+
}, env?: Env): string;
|
|
54
|
+
/**
|
|
55
|
+
* Break a lock that was observed stale with content `observed`, without the unlink race: MOVE it aside
|
|
56
|
+
* (only one waiter can move a given inode), then compare what was moved. Our stale lock → drop it. Someone
|
|
57
|
+
* else's fresh lock (created after we looked) → put it back with `link`, which fails harmlessly if an even
|
|
58
|
+
* newer lock exists. Exported for the tests.
|
|
59
|
+
*/
|
|
60
|
+
export declare function breakStaleLock(lock: string, observed: string, waiterToken: string): void;
|
|
61
|
+
export interface LockOpts {
|
|
62
|
+
now?: () => number;
|
|
63
|
+
timeoutMs?: number;
|
|
64
|
+
staleMs?: number;
|
|
65
|
+
retryMs?: number;
|
|
66
|
+
}
|
|
67
|
+
/** Serialise `fn` per domain across processes (`<domain>.json.lock`, `wx`). AUTH after 10 s of waiting. */
|
|
68
|
+
export declare function withDomainLock<T>(domain: string, fn: () => Promise<T> | T, env?: Env, opts?: LockOpts): Promise<T>;
|
|
69
|
+
/** Decode (NOT verify) a JWT payload. The CLI is not a security boundary here: every backend verifies. */
|
|
70
|
+
export declare function decodeJwtPayload(token: string): Record<string, unknown> | null;
|
|
71
|
+
export interface SessionCredentialOpts {
|
|
72
|
+
/** epoch ms */
|
|
73
|
+
now?: () => number;
|
|
74
|
+
auth0?: {
|
|
75
|
+
refresh: (p: {
|
|
76
|
+
auth0Domain: string;
|
|
77
|
+
clientId: string;
|
|
78
|
+
refreshToken: string;
|
|
79
|
+
}) => Promise<Auth0Tokens>;
|
|
80
|
+
};
|
|
81
|
+
env?: Env;
|
|
82
|
+
lock?: LockOpts;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* The session's bearer, refreshed when it is within 120 s of expiry. A refresh runs under the domain lock:
|
|
86
|
+
* re-read (another process may have refreshed), refresh, re-check `org_id`, then compare-and-write the
|
|
87
|
+
* rotated RT BEFORE returning the access token.
|
|
88
|
+
*/
|
|
89
|
+
export declare function sessionCredential(domain: string, opts?: SessionCredentialOpts): Promise<Credential>;
|
|
90
|
+
export {};
|