@pitcher/cli-scripts 2.21.0 → 3.0.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.
Files changed (45) hide show
  1. package/README.md +42 -41
  2. package/dist/index.js +4 -0
  3. package/dist/src/commands/analytics.d.ts +3 -3
  4. package/dist/src/commands/analytics.js +8 -6
  5. package/dist/src/commands/announcements.js +24 -3
  6. package/dist/src/commands/devSync.d.ts +3 -1
  7. package/dist/src/commands/devSync.js +26 -15
  8. package/dist/src/commands/login.d.ts +83 -0
  9. package/dist/src/commands/login.js +348 -0
  10. package/dist/src/commands/mcp.js +4 -3
  11. package/dist/src/commands/publishApp.d.ts +2 -1
  12. package/dist/src/commands/publishApp.js +10 -7
  13. package/dist/src/commands/withAuth.d.ts +5 -1
  14. package/dist/src/commands/withAuth.js +8 -3
  15. package/dist/src/core/archive.d.ts +2 -1
  16. package/dist/src/core/archive.js +3 -2
  17. package/dist/src/core/credential.d.ts +4 -16
  18. package/dist/src/core/credential.js +1 -25
  19. package/dist/src/core/http.d.ts +15 -7
  20. package/dist/src/core/http.js +33 -21
  21. package/dist/src/core/kpiTypes.d.ts +9 -0
  22. package/dist/src/core/kpiTypes.js +23 -0
  23. package/dist/src/core/ld.d.ts +3 -2
  24. package/dist/src/core/ld.js +45 -24
  25. package/dist/src/core/pitcherAuth.d.ts +86 -0
  26. package/dist/src/core/pitcherAuth.js +143 -0
  27. package/dist/src/core/session.d.ts +90 -0
  28. package/dist/src/core/session.js +293 -0
  29. package/dist/src/core/target.d.ts +47 -0
  30. package/dist/src/core/target.js +114 -0
  31. package/dist/src/mcp/local.d.ts +6 -0
  32. package/dist/src/mcp/local.js +8 -0
  33. package/dist/src/mcp/serve.d.ts +19 -4
  34. package/dist/src/mcp/serve.js +38 -15
  35. package/dist/src/mcp/server.d.ts +3 -1
  36. package/dist/src/mcp/server.js +2 -6
  37. package/dist/src/mcp/tools/analytics.js +3 -2
  38. package/dist/src/mcp/tools/publishApp.js +3 -3
  39. package/package.json +11 -6
  40. package/dist/src/core/auth.d.ts +0 -3
  41. package/dist/src/core/auth.js +0 -10
  42. package/dist/src/mcp/bundled.d.ts +0 -4
  43. package/dist/src/mcp/bundled.js +0 -25
  44. package/dist/src/mcp/env.d.ts +0 -7
  45. package/dist/src/mcp/env.js +0 -13
@@ -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
- export function makeClient(baseUrl, cred, opts = {}) {
6
- return axios.create({
7
- ...opts,
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
- ...authHeaders(cred),
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
- return deps.client ?? makeClient(resolveBaseUrl({ org: opts.org, url: opts.url }), credentialOf(opts));
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
- * on ONE host for every env/region; `x-original-domain` (the same `--org`/`--url`
23
- * resolution as Django calls) is what routes the request to the right core for
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 originalDomain = new URL(resolveBaseUrl({ org: opts.org, url: opts.url })).hostname;
30
- return axios.create({
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.slice(0, max - 1).join('').trimEnd()}\u2026`;
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
+ }
@@ -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
@@ -1,8 +1,7 @@
1
1
  import axios from 'axios';
2
2
  import { createHash } from 'crypto';
3
- import { credentialOf } from './credential.js';
4
- import { clientFor, makeClient, toCliError } from './http.js';
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
- 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;
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
- // `--url` outranks `--org` in resolveBaseUrl (baseUrl.ts), so keying on org alone lets two
245
- // different platform hosts collide on one cached L&D URL within a single process.
246
- const cacheKey = `${opts.url ?? opts.org ?? 'dev'}:${opts.instanceId}`;
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 ?? clientFor(opts);
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 = resolveBaseUrl({ org: opts.org, url: opts.url });
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 base = await resolveLdBaseUrl(opts, deps, env);
371
- const cred = credentialOf(opts);
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 {};