@pitcher/cli-scripts 2.17.0 → 2.17.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.
@@ -17,8 +17,16 @@ export declare function requireInstance(opts: LdCommonOpts): string;
17
17
  export declare function pickTruthy<T extends object>(opts: T, keys: readonly (keyof T)[]): Partial<Record<keyof T, unknown>>;
18
18
  /**
19
19
  * Resolve the standalone L&D Lambda base URL. Order: --ld-url flag → NG_LD_API_URL env →
20
- * ld-app app config (api_base_url) via the platform client. Cached per (org, instanceId).
21
- * The org key can't be region-inferred, so app config (region-correct) is the default.
20
+ * the installed ld-app record's `metadata.api_base_url` → the ld-app built-in default.
21
+ * Cached per (org, instanceId).
22
+ *
23
+ * PIT-7546: this used to read `/core/api/protected/apps/ld-app/config`, which is a
24
+ * machine-to-machine route gated on an `x-integrations-token` shared secret. The CLI sends
25
+ * `x-api-key`, so next-core rejected every call with 'Invalid m2m integrations token' before
26
+ * the L&D Lambda was ever contacted — one root cause behind all 63 `pitcher_ld_*` failures.
27
+ * The replacement reads the same row that route's SQL reads (`pitcher_file` where
28
+ * `app_metadata->>'name' = 'ld-app'` and `instance_id = $2`, `type = 'app'`, not deleted)
29
+ * over a credential the CLI actually holds.
22
30
  */
23
31
  export declare function resolveLdBaseUrl(opts: LdCommonOpts, deps?: LdDeps, env?: NodeJS.ProcessEnv): Promise<string>;
24
32
  /** Build (or inject, in tests) an axios client pointed at the resolved L&D Lambda base. */
@@ -1,7 +1,56 @@
1
1
  import { resolveApiKey } from './auth.js';
2
2
  import { clientFor, makeClient, toCliError } from './http.js';
3
+ import { resolveBaseUrl } from './baseUrl.js';
3
4
  import { createCliError } from './errors.js';
4
- const LD_APP_CONFIG_PATH = '/core/api/protected/apps/ld-app/config';
5
+ import { MAX_PAGE_SIZE } from './pagination.js';
6
+ const LD_APP_NAME = 'ld-app';
7
+ const FILES_LIST_PATH = '/api/v1/files/';
8
+ /**
9
+ * Sparse fieldset for the installed-apps scan (`FileViewSet.get_serializer` forwards `?fields=`
10
+ * to `AlwaysIncludeIdFieldMixin`, which also re-adds `id`). Without it the full `FileSerializer`
11
+ * renders `content_url`/`thumbnail_url`/`zip_url`/`original_url` for every row — each one a
12
+ * CloudFront signing — on every single `p ld` invocation, since the cache is per-process.
13
+ * Measured on a 59-app instance: 153,837 -> 77,402 bytes and no signed URLs.
14
+ */
15
+ const LD_APP_FIELDS = 'app_metadata,metadata';
16
+ /**
17
+ * Fallback L&D base URL, kept byte-identical to the ld-app frontend's own default
18
+ * (`ng-production-apps/ld-app/src/lib/apiClient.ts`). `api_base_url` is an OPTIONAL
19
+ * per-instance override (`app.config.json`, `is_required: false`), so on every instance
20
+ * that has never set it the app falls back to this single deployed Lambda.
21
+ *
22
+ * ONLY usable from the platform host below — see LD_FALLBACK_PLATFORM_ORIGIN.
23
+ */
24
+ const LD_FALLBACK_BASE_URL = 'https://cgmfr9gjza.execute-api.eu-west-1.amazonaws.com/dev/api';
25
+ /**
26
+ * The one platform host the fallback Lambda authenticates against, so the only one whose
27
+ * API key may travel to it.
28
+ *
29
+ * The Lambda lives in the IaC Test account with `DefaultAuthorizer: NONE` (ld-app README
30
+ * section 2) and validates an incoming `x-api-key` by relaying it verbatim to
31
+ * `https://dev.my.pitcher.com/api/v1` (`ld-app/server/src/config/index.ts` →
32
+ * `resolvePitcherApiBaseUrl`, `providers/pitcherApiProvider.ts` → `credentialHeaders`).
33
+ * Defaulting any other environment's org key onto it would ship that credential into a
34
+ * test account and then fail validation, surfacing as a misleading AUTH — and `ldClientFor`
35
+ * sends exactly that key. The L&D server refuses the mirror-image default for the same
36
+ * stated reason ("would send credential validation and org-membership probes to the wrong
37
+ * environment"), so the CLI matches it rather than re-deciding.
38
+ *
39
+ * The frontend precedent does NOT extend here: it carries a short-lived per-user JWT, not
40
+ * the org-wide authoring key. Once a prod L&D stack exists, per-instance `api_base_url` is
41
+ * what points each instance at it — the fallback must never silently stand in.
42
+ */
43
+ const LD_FALLBACK_PLATFORM_ORIGIN = 'https://dev.my.pitcher.com';
44
+ /**
45
+ * Safety stop for the installed-apps scan. At 500 records/page this is 10k apps in one
46
+ * instance — unreachable in practice, so tripping it means the server kept advertising a
47
+ * `next` page. Fail loudly rather than spin: a silent break would look identical to
48
+ * "ld-app is not installed", which is the exact misreport paging is here to prevent.
49
+ */
50
+ const MAX_APP_PAGES = 20;
51
+ function isRecord(v) {
52
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
53
+ }
5
54
  const baseUrlCache = new Map();
6
55
  /** Test-only: reset the resolved-base-URL cache between cases. */
7
56
  export function __clearLdBaseUrlCache() {
@@ -36,10 +85,95 @@ function trimUrl(raw, source) {
36
85
  // origin + pathname only: strips any userinfo/query/hash; trailing slash trimmed.
37
86
  return `${parsed.origin}${parsed.pathname}`.replace(/\/+$/, '');
38
87
  }
88
+ /**
89
+ * Find the installed `ld-app` file record for an instance and return its per-instance
90
+ * config (`pitcher_file.metadata`), or undefined when the app is not installed.
91
+ *
92
+ * Django's `FileFilter` (ng-rest-api `pitcher/views/fileviews.py`) has NO filter on
93
+ * `app_metadata` — its declared filters and `Meta.fields` cover `name`, `type`, `tags__in`
94
+ * etc., and `name` is the file's DISPLAY name ('L&D'), not `app_metadata->>'name'`
95
+ * ('ld-app'). So the name match has to happen client-side, which in turn means paging to
96
+ * exhaustion: a first-page-only match would report 'not installed' on an instance that
97
+ * simply has more apps than one page holds.
98
+ *
99
+ * If an instance somehow carries two ld-app records, the first in server order wins. That
100
+ * order is `-modified_at` (`fileviews.py` FileViewSet.ordering), so the most-recently-modified
101
+ * one does — strictly more deterministic than the `LIMIT 1` with no ORDER BY in the m2m route
102
+ * this replaces. No dev instance has more than one (measured across all 20 that have it).
103
+ */
104
+ async function fetchLdAppMetadata(platform, instanceId) {
105
+ let scanned = 0;
106
+ let named = 0;
107
+ let exhausted = false;
108
+ for (let page = 1; page <= MAX_APP_PAGES; page += 1) {
109
+ const res = await platform.get(FILES_LIST_PATH, {
110
+ params: { instance_id: instanceId, type__in: 'app', fields: LD_APP_FIELDS, page, page_size: MAX_PAGE_SIZE },
111
+ });
112
+ const body = res.data;
113
+ // Fail loudly on an unexpected body rather than coercing it to an empty page: that would
114
+ // surface as 'ld-app is not installed', the exact misreport the paging below is here to
115
+ // prevent, and it would be indistinguishable from the real thing.
116
+ if (!isRecord(body) || !Array.isArray(body.results)) {
117
+ throw createCliError('UNKNOWN', `Unexpected response shape from ${FILES_LIST_PATH} (expected a paginated body)`);
118
+ }
119
+ scanned += body.results.length;
120
+ for (const row of body.results) {
121
+ const file = row;
122
+ // A single nameless row is DATA, not a bad response — `app_metadata` is a JSONField
123
+ // defaulting to `{}`, and such a row is definitively not ld-app, so skipping it is
124
+ // correct. It also cannot hide ld-app, which has a name and is found by scanning every
125
+ // row. Hard-failing on it would take out `p ld` for an instance over one junk record.
126
+ if (typeof file?.app_metadata?.name !== 'string')
127
+ continue;
128
+ named += 1;
129
+ if (file.app_metadata.name !== LD_APP_NAME)
130
+ continue;
131
+ // Behaviourally this is just null-safety: the sole consumer reads `.api_base_url`, which
132
+ // is undefined on a string or array anyway, so `isRecord` and `?? {}` are equivalent for
133
+ // every input. It is written this way because it narrows without an `as` cast — do not
134
+ // read the strictness as a contract. What DOES matter is never returning a nullish value:
135
+ // the caller treats that as "ld-app not installed".
136
+ return isRecord(file.metadata) ? file.metadata : {};
137
+ }
138
+ // DRF's pagination contract for `next` is a non-empty URL string, `null`, or absent.
139
+ // Anything else means we cannot trust our own stopping condition: a truthy non-string
140
+ // would otherwise page all the way to the cap before failing, and an empty string would
141
+ // read as "exhausted" and land on NOT_FOUND. Fail immediately and accurately instead.
142
+ const next = body.next;
143
+ if (next !== undefined && next !== null && (typeof next !== 'string' || !next)) {
144
+ throw createCliError('UNKNOWN', `Unexpected \`next\` in the ${FILES_LIST_PATH} response (expected a URL, null or absent)`);
145
+ }
146
+ if (!next) {
147
+ exhausted = true;
148
+ break;
149
+ }
150
+ }
151
+ // Ran out of pages before the server ran out of `next`. Throw rather than return: a silent
152
+ // give-up is indistinguishable from "not installed", the exact misreport paging prevents.
153
+ if (!exhausted) {
154
+ throw createCliError('UNKNOWN', `Gave up looking for ${LD_APP_NAME} after ${MAX_APP_PAGES} pages of installed apps. Set --ld-url or NG_LD_API_URL.`);
155
+ }
156
+ // The systemic case IS a shape problem: rows came back but not one carried
157
+ // `app_metadata.name`, so the field we match on is missing wholesale (a dropped `?fields=`
158
+ // entry, a serializer change). Reporting NOT_FOUND there would claim "ld-app is not
159
+ // installed" on an instance that has it — the misreport this whole scan is built to avoid.
160
+ if (scanned > 0 && named === 0) {
161
+ throw createCliError('UNKNOWN', `No app row from ${FILES_LIST_PATH} carried app_metadata.name (${scanned} scanned) — the files list may no longer return it`);
162
+ }
163
+ return undefined;
164
+ }
39
165
  /**
40
166
  * Resolve the standalone L&D Lambda base URL. Order: --ld-url flag → NG_LD_API_URL env →
41
- * ld-app app config (api_base_url) via the platform client. Cached per (org, instanceId).
42
- * The org key can't be region-inferred, so app config (region-correct) is the default.
167
+ * the installed ld-app record's `metadata.api_base_url` → the ld-app built-in default.
168
+ * Cached per (org, instanceId).
169
+ *
170
+ * PIT-7546: this used to read `/core/api/protected/apps/ld-app/config`, which is a
171
+ * machine-to-machine route gated on an `x-integrations-token` shared secret. The CLI sends
172
+ * `x-api-key`, so next-core rejected every call with 'Invalid m2m integrations token' before
173
+ * the L&D Lambda was ever contacted — one root cause behind all 63 `pitcher_ld_*` failures.
174
+ * The replacement reads the same row that route's SQL reads (`pitcher_file` where
175
+ * `app_metadata->>'name' = 'ld-app'` and `instance_id = $2`, `type = 'app'`, not deleted)
176
+ * over a credential the CLI actually holds.
43
177
  */
44
178
  export async function resolveLdBaseUrl(opts, deps = {}, env = process.env) {
45
179
  const override = opts.ldUrl?.trim() || env.NG_LD_API_URL?.trim();
@@ -48,17 +182,33 @@ export async function resolveLdBaseUrl(opts, deps = {}, env = process.env) {
48
182
  if (!opts.instanceId) {
49
183
  throw createCliError('VALIDATION', '--instance-id is required to resolve the L&D API URL (or set NG_LD_API_URL)');
50
184
  }
51
- const cacheKey = `${opts.org ?? 'dev'}:${opts.instanceId}`;
185
+ // `--url` outranks `--org` in resolveBaseUrl (baseUrl.ts), so keying on org alone lets two
186
+ // different platform hosts collide on one cached L&D URL within a single process.
187
+ const cacheKey = `${opts.url ?? opts.org ?? 'dev'}:${opts.instanceId}`;
52
188
  const cached = baseUrlCache.get(cacheKey);
53
189
  if (cached)
54
190
  return cached;
55
191
  const platform = deps.platformClient ?? clientFor(opts);
56
- const res = await platform.get(LD_APP_CONFIG_PATH, { params: { instance_id: opts.instanceId } });
57
- const apiBaseUrl = res.data?.api_base_url;
58
- if (typeof apiBaseUrl !== 'string' || !apiBaseUrl) {
59
- throw createCliError('NOT_FOUND', 'ld-app is not configured in this instance (no api_base_url). Set --ld-url or NG_LD_API_URL.');
192
+ const metadata = await fetchLdAppMetadata(platform, opts.instanceId);
193
+ if (!metadata) {
194
+ throw createCliError('NOT_FOUND', 'ld-app is not configured in this instance (not installed). Set --ld-url or NG_LD_API_URL.');
195
+ }
196
+ // `api_base_url` is an optional override; absent means "use the app's own default" —
197
+ // but only where that default's credential environment matches ours (see the constant).
198
+ const apiBaseUrl = metadata.api_base_url;
199
+ let url;
200
+ if (typeof apiBaseUrl === 'string' && apiBaseUrl.trim()) {
201
+ url = trimUrl(apiBaseUrl.trim(), `ld-app config (metadata.api_base_url on instance ${opts.instanceId})`);
202
+ }
203
+ else {
204
+ const platformOrigin = resolveBaseUrl({ org: opts.org, url: opts.url });
205
+ if (platformOrigin !== LD_FALLBACK_PLATFORM_ORIGIN) {
206
+ throw createCliError('NOT_FOUND', `ld-app has no api_base_url configured on this instance, and the built-in default only serves ` +
207
+ `${LD_FALLBACK_PLATFORM_ORIGIN} (this run targets ${platformOrigin}). Set metadata.api_base_url ` +
208
+ `on the instance, or pass --ld-url / NG_LD_API_URL.`);
209
+ }
210
+ url = LD_FALLBACK_BASE_URL;
60
211
  }
61
- const url = trimUrl(apiBaseUrl, 'ld-app config');
62
212
  baseUrlCache.set(cacheKey, url);
63
213
  return url;
64
214
  }
@@ -160,7 +160,8 @@ export const analyticsTools = [
160
160
  title: 'DSR per-viewer session timeline',
161
161
  description: 'Per-viewer session event timeline for a DSR canvas (drill-down for one viewer). Returns the raw, ' +
162
162
  'uncapped event list (not summarized). Works with a member key. Requires canvasId and userEmail — ' +
163
- 'get both from pitcher_analytics_viewers.',
163
+ 'get both from pitcher_analytics_viewers: pass its `normalized_email` (or its `email`) VERBATIM. ' +
164
+ 'Do not tidy the address — the stored identity carries an ` (External)` suffix.',
164
165
  inputShape: {
165
166
  instanceId: z.string().describe('Instance ID (required)'),
166
167
  canvasId: z.string().describe('Canvas ID (required)'),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pitcher/cli-scripts",
3
- "version": "2.17.0",
3
+ "version": "2.17.1",
4
4
  "description": "The Pitcher CLI for various tasks in its ecosystem.",
5
5
  "author": "Pitcher AG",
6
6
  "license": "MIT",