api-tracer-kit 1.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.
Files changed (47) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/LICENSE +21 -0
  3. package/README.md +466 -0
  4. package/cli/bin/api-tracer.mjs +266 -0
  5. package/cli/config.mjs +224 -0
  6. package/cli/import.mjs +231 -0
  7. package/cli/index.mjs +10 -0
  8. package/cli/presets.mjs +212 -0
  9. package/cli/report.mjs +346 -0
  10. package/cli/scan.mjs +142 -0
  11. package/cli/server.mjs +1576 -0
  12. package/cli/shape.mjs +90 -0
  13. package/cli/test.mjs +342 -0
  14. package/cli/web/app.css +1424 -0
  15. package/cli/web/app.js +2260 -0
  16. package/cli/web/favicon.svg +5 -0
  17. package/cli/web/index.html +159 -0
  18. package/cli/web/logo.svg +7 -0
  19. package/dist/axios.cjs +856 -0
  20. package/dist/axios.cjs.map +1 -0
  21. package/dist/axios.d.cts +27 -0
  22. package/dist/axios.d.ts +27 -0
  23. package/dist/axios.js +853 -0
  24. package/dist/axios.js.map +1 -0
  25. package/dist/index.cjs +872 -0
  26. package/dist/index.cjs.map +1 -0
  27. package/dist/index.d.cts +74 -0
  28. package/dist/index.d.ts +74 -0
  29. package/dist/index.js +857 -0
  30. package/dist/index.js.map +1 -0
  31. package/dist/react.cjs +896 -0
  32. package/dist/react.cjs.map +1 -0
  33. package/dist/react.d.cts +22 -0
  34. package/dist/react.d.ts +22 -0
  35. package/dist/react.js +893 -0
  36. package/dist/react.js.map +1 -0
  37. package/dist/tracer-BUWdU2lG.d.ts +76 -0
  38. package/dist/tracer-DG2YUqK0.d.cts +76 -0
  39. package/dist/types-Bl2-K6_g.d.cts +111 -0
  40. package/dist/types-Bl2-K6_g.d.ts +111 -0
  41. package/dist/ui.cjs +1162 -0
  42. package/dist/ui.cjs.map +1 -0
  43. package/dist/ui.d.cts +16 -0
  44. package/dist/ui.d.ts +16 -0
  45. package/dist/ui.js +1157 -0
  46. package/dist/ui.js.map +1 -0
  47. package/package.json +92 -0
package/cli/server.mjs ADDED
@@ -0,0 +1,1576 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * The console server: serves the UI and forwards test calls to the real API.
4
+ *
5
+ * Requests are forwarded from here rather than from the page for two reasons:
6
+ * the browser would be blocked by CORS, and the auth token would have to live
7
+ * in client-side JS. The token is written owner-only to the data directory so a
8
+ * restart does not sign you out, and `Sign out` deletes it.
9
+ *
10
+ * Nothing in here knows anything about a particular application. The catalog
11
+ * supplies the endpoints, the base URLs, the auth header and how to read a
12
+ * response envelope; the config file supplies the sign-in flow, if the API has
13
+ * one worth automating.
14
+ */
15
+ import { createServer } from 'node:http';
16
+ import { randomUUID, createHmac, randomInt, timingSafeEqual } from 'node:crypto';
17
+ import { readFileSync, writeFileSync, appendFileSync, existsSync, mkdirSync, watchFile, rmSync } from 'node:fs';
18
+ import { join, dirname, extname, resolve } from 'node:path';
19
+ import { fileURLToPath } from 'node:url';
20
+ import { importHar, importCurl, toComparablePath, matchEndpoint, parseQuery, redact } from './import.mjs';
21
+ import { shapeOfBody, diffShapes, summarizeDrift } from './shape.mjs';
22
+ import { buildReport, toMarkdown } from './report.mjs';
23
+
24
+ const HERE = dirname(fileURLToPath(import.meta.url));
25
+ const WEB = join(HERE, 'web');
26
+ /**
27
+ * Where the console keeps what it has learned. Defaults to `.api-tracer` in the
28
+ * project it was started from, so nothing is written inside the package and a
29
+ * second project gets its own captures.
30
+ */
31
+ const DATA = process.env.API_TRACER_DATA
32
+ ? resolve(process.env.API_TRACER_DATA)
33
+ : resolve(process.cwd(), '.api-tracer');
34
+ mkdirSync(DATA, { recursive: true });
35
+
36
+ const CATALOG = join(DATA, 'endpoints.json');
37
+ const RESULTS = join(DATA, 'results.json');
38
+ const VARS = join(DATA, 'variables.json');
39
+ const SAMPLES = join(DATA, 'samples.json');
40
+ const CONTRACTS = join(DATA, 'contracts.json');
41
+ const EXTRAS = join(DATA, 'uncatalogued.json');
42
+ const LASTRUN = join(DATA, 'lastrun.json');
43
+ const TOKENS = join(DATA, 'tokens.json');
44
+ const RUNS = join(DATA, 'runs.jsonl');
45
+ // a blank PORT would become 0 (a random port), so treat blank as unset
46
+ const PORT = Number(process.env.PORT) || 4400;
47
+ // deployed behind a proxy this needs 0.0.0.0; on a laptop it should stay local
48
+ const HOST = process.env.HOST || '127.0.0.1';
49
+ /**
50
+ * Origins allowed to post recordings. Localhost always works for local use; a
51
+ * deployed app origin has to be named explicitly, so a stray site cannot feed or
52
+ * read this console.
53
+ */
54
+ const ALLOWED_ORIGINS = (process.env.ALLOWED_ORIGINS ?? '')
55
+ .split(',')
56
+ .map((o) => o.trim())
57
+ .filter(Boolean);
58
+ /** mount point when served under a path, e.g. BASE_PATH=/api-console */
59
+ const BASE_PATH = (process.env.BASE_PATH ?? '').replace(/\/+$/, '');
60
+ /*
61
+ * Shared-instance guards. On a console several developers use, one person's click
62
+ * should not change what everyone else is recording or which API they are hitting.
63
+ */
64
+ const LOCK_RECORDING = process.env.LOCK_RECORDING === '1';
65
+ const LOCK_ENV = process.env.LOCK_ENV ?? '';
66
+
67
+ /** optional gate; the recorder is exempt, since the app cannot send credentials */
68
+ const AUTH_USER = process.env.CONSOLE_USER ?? '';
69
+ const AUTH_PASS = process.env.CONSOLE_PASS ?? '';
70
+ const BATCH_CONCURRENCY = 6;
71
+
72
+ // importing this file (tests) must not start a server or hold the event loop open
73
+ const isMain = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
74
+
75
+ /**
76
+ * An empty catalog is a valid state, not an error: a console pointed at a
77
+ * project it has not scanned yet still records live traffic, and every call it
78
+ * cannot explain becomes an endpoint of its own.
79
+ */
80
+ const EMPTY_CATALOG = { scannedAt: null, baseUrls: {}, auth: { header: 'Authorization' }, endpoints: [] };
81
+
82
+ function readCatalog() {
83
+ try {
84
+ const raw = JSON.parse(readFileSync(CATALOG, 'utf8'));
85
+ return { ...EMPTY_CATALOG, ...raw, auth: { ...EMPTY_CATALOG.auth, ...(raw.auth ?? {}) } };
86
+ } catch {
87
+ return { ...EMPTY_CATALOG };
88
+ }
89
+ }
90
+
91
+ let catalog = readCatalog();
92
+
93
+ /**
94
+ * Replaces the loaded catalog. `api-tracer serve` uses this after reading a
95
+ * project's config, and it is how a test gets a known catalog in without
96
+ * writing one to disk first.
97
+ */
98
+ export function useCatalog(next) {
99
+ catalog = { ...EMPTY_CATALOG, ...next, auth: { ...EMPTY_CATALOG.auth, ...(next?.auth ?? {}) } };
100
+ return catalog;
101
+ }
102
+
103
+ /** the config file, when the project has one; every field is optional */
104
+ export function useConfig(next) {
105
+ config = { ...config, ...(next ?? {}) };
106
+ if (config.name) config.name = String(config.name);
107
+ return config;
108
+ }
109
+ let config = { name: 'API', login: null, envelope: undefined };
110
+
111
+ // `yarn scan` while the server is up should take effect without a restart
112
+ if (isMain)
113
+ watchFile(CATALOG, { interval: 1000 }, () => {
114
+ try {
115
+ catalog = readCatalog();
116
+ console.log(`catalog reloaded: ${catalog.endpoints.length} endpoints`);
117
+ } catch (e) {
118
+ console.warn(`catalog reload failed, keeping the old one: ${e.message}`);
119
+ }
120
+ });
121
+
122
+ /**
123
+ * Tokens are kept so a restart does not make you sign in again. The file is
124
+ * written owner-only and lives in the gitignored data/ directory -- it is still a
125
+ * real credential on disk, so `Sign out` deletes it, and it is worth doing that
126
+ * on a shared machine.
127
+ */
128
+ function loadStoredTokens() {
129
+ try {
130
+ const raw = JSON.parse(readFileSync(TOKENS, 'utf8'));
131
+ // older files were the bare header -> value map
132
+ return raw.tokens ? raw : { tokens: raw, owner: null };
133
+ } catch {
134
+ return { tokens: {}, owner: null };
135
+ }
136
+ }
137
+
138
+ function saveTokens() {
139
+ try {
140
+ if (!Object.keys(session.tokens).length) {
141
+ rmSync(TOKENS, { force: true });
142
+ return;
143
+ }
144
+ writeFileSync(
145
+ TOKENS,
146
+ `${JSON.stringify({ tokens: session.tokens, owner: session.tokenOwner }, null, 2)}\n`,
147
+ { mode: 0o600 },
148
+ );
149
+ } catch (e) {
150
+ console.warn(`could not save the token: ${e.message}`);
151
+ }
152
+ }
153
+
154
+ const stored = loadStoredTokens();
155
+
156
+ const session = {
157
+ tokens: stored.tokens, // header name -> value, e.g. { AUTH_TOKEN: '...' }
158
+ env: LOCK_ENV || 'dev',
159
+ results: loadResults(), // endpoint id -> last result summary, for the dashboard
160
+ vars: loadVars(), // {{name}} -> value, filled by hand or captured from a response
161
+ samples: loadSamples(), // endpoint id -> real request seen in traffic
162
+ live: { on: true, count: 0, since: null, unmatched: [] }, // live recorder state
163
+ contracts: loadContracts(), // endpoint id -> { shape, at, from } baseline
164
+ extras: loadExtras(), // calls the app made that no service file explains
165
+ run: loadLastRun(), // the last run, kept so the home page can show it after a restart
166
+ tokenOwner: stored.owner, // who signed in, for a console more than one person uses
167
+ };
168
+
169
+ function loadLastRun() {
170
+ try {
171
+ return JSON.parse(readFileSync(LASTRUN, 'utf8'));
172
+ } catch {
173
+ return null;
174
+ }
175
+ }
176
+
177
+ /** one line per finished run; the report's trend section reads these back */
178
+ function recordRun() {
179
+ const scanned = catalog.endpoints.length;
180
+ const captured = catalog.endpoints.filter((e) => session.samples[e.id]).length;
181
+ const line = {
182
+ at: new Date().toISOString(),
183
+ env: session.env,
184
+ ran: session.run.done,
185
+ passed: session.run.passed,
186
+ failed: session.run.failed,
187
+ cancelled: session.run.cancelled,
188
+ captured,
189
+ coveragePct: scanned ? Math.round((captured / scanned) * 100) : 0,
190
+ };
191
+ try {
192
+ appendFileSync(RUNS, `${JSON.stringify(line)}\n`);
193
+ } catch (e) {
194
+ console.warn(`could not record the run: ${e.message}`);
195
+ }
196
+ }
197
+
198
+ function loadRuns() {
199
+ try {
200
+ return readFileSync(RUNS, 'utf8')
201
+ .split('\n')
202
+ .filter(Boolean)
203
+ .map((l) => {
204
+ try {
205
+ return JSON.parse(l);
206
+ } catch {
207
+ return null; // a truncated final line should not lose the history
208
+ }
209
+ })
210
+ .filter(Boolean);
211
+ } catch {
212
+ return [];
213
+ }
214
+ }
215
+
216
+ function saveLastRun() {
217
+ try {
218
+ writeFileSync(LASTRUN, `${JSON.stringify(session.run, null, 2)}\n`);
219
+ } catch (e) {
220
+ console.warn(`could not save the last run: ${e.message}`);
221
+ }
222
+ }
223
+
224
+ function loadExtras() {
225
+ try {
226
+ return JSON.parse(readFileSync(EXTRAS, 'utf8'));
227
+ } catch {
228
+ return {};
229
+ }
230
+ }
231
+
232
+ function saveExtras() {
233
+ try {
234
+ writeFileSync(EXTRAS, `${JSON.stringify(session.extras, null, 2)}\n`);
235
+ } catch (e) {
236
+ console.warn(`could not save uncatalogued endpoints: ${e.message}`);
237
+ }
238
+ }
239
+
240
+ /**
241
+ * Everything the tracker knows about: what the scanner found in src/services,
242
+ * plus anything the app called that the scanner cannot explain. Recording only
243
+ * what we could match would quietly hide real traffic.
244
+ */
245
+ const allEndpoints = () => [...catalog.endpoints, ...Object.values(session.extras)];
246
+ const findEndpoint = (id) => allEndpoints().find((e) => e.id === id);
247
+
248
+ /** turns an unexplained call into a first-class endpoint so it behaves like the rest */
249
+ function adoptEndpoint(method, path) {
250
+ const id = `uncatalogued.${method} ${path}`;
251
+ session.extras[id] ??= {
252
+ id,
253
+ module: '(uncatalogued)',
254
+ name: `${method} ${path}`,
255
+ method,
256
+ subUrl: path,
257
+ holes: [],
258
+ usesParams: true,
259
+ usesData: MUTATING.has(method),
260
+ absolute: /^https?:\/\//.test(path),
261
+ customToken: null,
262
+ file: 'seen in traffic, not found in src/services',
263
+ line: 0,
264
+ usedIn: null,
265
+ uncatalogued: true,
266
+ };
267
+ saveExtras();
268
+ return session.extras[id];
269
+ }
270
+
271
+ function loadContracts() {
272
+ try {
273
+ return JSON.parse(readFileSync(CONTRACTS, 'utf8'));
274
+ } catch {
275
+ return {};
276
+ }
277
+ }
278
+
279
+ function saveContracts() {
280
+ try {
281
+ writeFileSync(CONTRACTS, `${JSON.stringify(session.contracts, null, 2)}\n`);
282
+ } catch (e) {
283
+ console.warn(`could not save contracts: ${e.message}`);
284
+ }
285
+ }
286
+
287
+ /**
288
+ * Compares a response against the endpoint's baseline shape. The first good
289
+ * response becomes the baseline, so drift only ever reports a real change.
290
+ */
291
+ function checkContract(id, bodyText, source) {
292
+ const shape = shapeOfBody(bodyText);
293
+ if (!shape) return undefined;
294
+
295
+ const baseline = session.contracts[id];
296
+ if (!baseline) {
297
+ session.contracts[id] = { shape, at: new Date().toISOString(), from: source };
298
+ saveContracts();
299
+ return undefined; // nothing to compare against yet
300
+ }
301
+
302
+ const diff = diffShapes(baseline.shape, shape);
303
+ if (!diff.drifted) {
304
+ if (baseline.pending) {
305
+ delete baseline.pending; // drift resolved itself, nothing left to accept
306
+ saveContracts();
307
+ }
308
+ return undefined;
309
+ }
310
+
311
+ // remember the drifted shape so "accept" needs no shape from the browser
312
+ baseline.pending = shape;
313
+ saveContracts();
314
+ return { ...diff, summary: summarizeDrift(diff), baselineAt: baseline.at };
315
+ }
316
+
317
+ function loadSamples() {
318
+ try {
319
+ return JSON.parse(readFileSync(SAMPLES, 'utf8'));
320
+ } catch {
321
+ return {};
322
+ }
323
+ }
324
+
325
+ function saveSamples() {
326
+ try {
327
+ writeFileSync(SAMPLES, `${JSON.stringify(session.samples, null, 2)}\n`);
328
+ } catch (e) {
329
+ console.warn(`could not save samples: ${e.message}`);
330
+ }
331
+ }
332
+
333
+ function loadVars() {
334
+ try {
335
+ return JSON.parse(readFileSync(VARS, 'utf8'));
336
+ } catch {
337
+ return {};
338
+ }
339
+ }
340
+
341
+ function saveVars() {
342
+ try {
343
+ writeFileSync(VARS, `${JSON.stringify(session.vars, null, 2)}\n`);
344
+ } catch (e) {
345
+ console.warn(`could not save variables: ${e.message}`);
346
+ }
347
+ }
348
+
349
+ /** `{{patient_id}}` -> its stored value; unknown names are left alone so they stay visible */
350
+ export function fillVars(text) {
351
+ return String(text ?? '').replace(/\{\{(\w+)\}\}/g, (whole, name) =>
352
+ session.vars[name] !== undefined ? String(session.vars[name]) : whole,
353
+ );
354
+ }
355
+
356
+ /**
357
+ * Reads a dotted path out of a response body: `data.0.id` walks arrays too.
358
+ * Returns undefined if any step is missing, which the caller reports.
359
+ */
360
+ export function pluck(obj, path) {
361
+ return path
362
+ .split('.')
363
+ .reduce((acc, key) => (acc === null || acc === undefined ? undefined : acc[key]), obj);
364
+ }
365
+
366
+ function loadResults() {
367
+ try {
368
+ return JSON.parse(readFileSync(RESULTS, 'utf8'));
369
+ } catch {
370
+ return {}; // first run, or the file was cleared
371
+ }
372
+ }
373
+
374
+ /** best-effort: losing the results file must never sink a response */
375
+ let saveTimer;
376
+ function saveResults() {
377
+ clearTimeout(saveTimer);
378
+ // a bulk run writes 170 times in a few seconds; one write at the end is plenty
379
+ saveTimer = setTimeout(() => {
380
+ try {
381
+ writeFileSync(RESULTS, `${JSON.stringify(session.results, null, 2)}\n`);
382
+ } catch (e) {
383
+ console.warn(`could not save results: ${e.message}`);
384
+ }
385
+ }, 250);
386
+ }
387
+
388
+ const baseUrl = () =>
389
+ catalog.baseUrls[session.env] ?? catalog.baseUrls.dev ?? Object.values(catalog.baseUrls)[0] ?? '';
390
+
391
+ /* -------------------------------------------------------------------- http */
392
+
393
+ function json(res, status, body) {
394
+ const payload = JSON.stringify(body);
395
+ res.writeHead(status, {
396
+ 'content-type': 'application/json',
397
+ 'content-length': Buffer.byteLength(payload),
398
+ });
399
+ res.end(payload);
400
+ }
401
+
402
+ function readBody(req) {
403
+ return new Promise((ok, fail) => {
404
+ const chunks = [];
405
+ req.on('data', (c) => chunks.push(c));
406
+ req.on('end', () => {
407
+ try {
408
+ ok(chunks.length ? JSON.parse(Buffer.concat(chunks).toString('utf8')) : {});
409
+ } catch (e) {
410
+ fail(new Error(`bad JSON body: ${e.message}`));
411
+ }
412
+ });
413
+ req.on('error', fail);
414
+ });
415
+ }
416
+
417
+ const MIME = {
418
+ '.html': 'text/html; charset=utf-8',
419
+ '.js': 'text/javascript; charset=utf-8',
420
+ '.css': 'text/css; charset=utf-8',
421
+ '.svg': 'image/svg+xml',
422
+ '.png': 'image/png',
423
+ '.woff2': 'font/woff2',
424
+ '.json': 'application/json; charset=utf-8',
425
+ };
426
+
427
+ function serveStatic(res, urlPath) {
428
+ const file = join(WEB, urlPath === '/' ? '/index.html' : urlPath);
429
+ if (!file.startsWith(WEB) || !existsSync(file)) return json(res, 404, { error: 'not found' });
430
+ res.writeHead(200, {
431
+ 'content-type': MIME[extname(file)] ?? 'application/octet-stream',
432
+ // dev tool: never serve a stale app.js after an edit
433
+ 'cache-control': 'no-store',
434
+ });
435
+ res.end(readFileSync(file));
436
+ }
437
+
438
+ /* ------------------------------------------------------------------- calls */
439
+
440
+ const MUTATING = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
441
+
442
+ /**
443
+ * Serializes params the way axios does by default, so what we send matches what
444
+ * the app sends: arrays become `k[]=a&k[]=b`, nested objects become `k[sub]=v`.
445
+ */
446
+ export function appendParams(url, params, prefix = '') {
447
+ for (const [rawKey, v] of Object.entries(params)) {
448
+ const key = prefix ? `${prefix}[${rawKey}]` : rawKey;
449
+ if (v === undefined || v === null || v === '') continue;
450
+ if (Array.isArray(v)) {
451
+ for (const item of v) {
452
+ if (item !== null && typeof item === 'object') appendParams(url, item, `${key}[]`);
453
+ else url.searchParams.append(`${key}[]`, String(item));
454
+ }
455
+ } else if (typeof v === 'object') {
456
+ appendParams(url, v, key);
457
+ } else {
458
+ url.searchParams.append(key, String(v));
459
+ }
460
+ }
461
+ }
462
+
463
+ /** builds the absolute URL, filling `${...}` holes with the supplied values */
464
+ export function buildUrl(ep, holeValues = {}, params = {}, rawQuery = '', stringifyKey = '') {
465
+ let path = ep.subUrl;
466
+ for (const hole of ep.holes) {
467
+ path = path.replaceAll(`\${${hole.expr}}`, holeValues[hole.expr] ?? '');
468
+ }
469
+ const target = ep.absolute || /^https?:\/\//.test(path) ? path : `${baseUrl()}${path}`;
470
+ if (!/^https?:\/\//.test(target)) {
471
+ throw new Error(`no base URL for "${session.env}"; add baseUrls to the config or rerun the scan`);
472
+ }
473
+ const url = new URL(target);
474
+
475
+ // raw mode: the string is passed through untouched, so odd encodings survive
476
+ const raw = rawQuery.trim().replace(/^[?&]+/, '');
477
+ if (raw) return `${url.toString()}${url.search ? '&' : '?'}${raw}`;
478
+
479
+ // stringified mode: the whole object goes in one param as encoded JSON
480
+ if (stringifyKey) {
481
+ url.searchParams.set(stringifyKey, JSON.stringify(params));
482
+ return url.toString();
483
+ }
484
+
485
+ appendParams(url, params);
486
+ return url.toString();
487
+ }
488
+
489
+ /**
490
+ * Some APIs answer 200 with the real verdict in the body, e.g.
491
+ * `{ status: 801, success: false, message: "..." }`, so a 2xx alone is not
492
+ * enough to call an endpoint healthy. Which fields carry that verdict is read
493
+ * from the catalog, and an API without the convention simply has no envelope
494
+ * configured, in which case the HTTP status decides on its own.
495
+ */
496
+ export function verdict(httpOk, parsed, envelope) {
497
+ const cfg = envelope ?? config.envelope ?? catalog.envelope;
498
+ if (cfg === false || !cfg) return { innerCode: undefined, ok: httpOk };
499
+
500
+ const codeFields = cfg.codeFields ?? ['status', 'code'];
501
+ const okField = cfg.okField ?? 'success';
502
+ const failFrom = cfg.failFrom ?? 400;
503
+
504
+ let innerCode;
505
+ for (const field of codeFields) {
506
+ if (typeof parsed?.[field] === 'number') {
507
+ innerCode = parsed[field];
508
+ break;
509
+ }
510
+ }
511
+ return {
512
+ innerCode,
513
+ ok: httpOk && !(typeof innerCode === 'number' && innerCode >= failFrom) && parsed?.[okField] !== false,
514
+ };
515
+ }
516
+
517
+ /**
518
+ * Sends the body the same way the app did. Several screens post FormData, and
519
+ * those endpoints reject a JSON body, so replaying one as JSON would fail for
520
+ * reasons that have nothing to do with the endpoint's health.
521
+ */
522
+ function encodeBody(ep, data, bodyType, headers) {
523
+ if (!MUTATING.has(ep.method) || bodyType === 'none') return { headers };
524
+
525
+ if (bodyType === 'formdata') {
526
+ const form = new FormData();
527
+ for (const [k, v] of Object.entries(data ?? {})) {
528
+ for (const item of Array.isArray(v) ? v : [v]) form.append(k, String(item));
529
+ }
530
+ // fetch must set its own multipart boundary, so drop any JSON content-type
531
+ const { 'Content-Type': _drop, ...rest } = headers;
532
+ return { headers: rest, body: form };
533
+ }
534
+
535
+ if (bodyType === 'urlencoded') {
536
+ return {
537
+ headers: { ...headers, 'Content-Type': 'application/x-www-form-urlencoded' },
538
+ body: new URLSearchParams(
539
+ Object.entries(data ?? {}).map(([k, v]) => [k, String(v)]),
540
+ ).toString(),
541
+ };
542
+ }
543
+
544
+ return { headers, body: JSON.stringify(data ?? {}) };
545
+ }
546
+
547
+ async function callEndpoint(spec) {
548
+ const ep = findEndpoint(spec.id);
549
+ if (!ep) throw new Error(`unknown endpoint ${spec.id}`);
550
+
551
+ // {{vars}} are substituted everywhere the user can type
552
+ const holes = Object.fromEntries(
553
+ Object.entries(spec.holes ?? {}).map(([k, v]) => [k, fillVars(v)]),
554
+ );
555
+ const params = JSON.parse(fillVars(JSON.stringify(spec.params ?? {})));
556
+ const data = JSON.parse(fillVars(JSON.stringify(spec.data ?? {})));
557
+ const url =
558
+ spec.url || buildUrl(ep, holes, params, fillVars(spec.rawQuery ?? ''), spec.stringifyKey ?? '');
559
+ const headers = { 'Content-Type': 'application/json' };
560
+ // third-party absolute URLs must not receive any of our tokens
561
+ if (!ep.absolute) {
562
+ if (ep.customToken === 'ACCESS_TOKEN') {
563
+ // the app signs a fresh JWT for these and sends no AUTH_TOKEN at all
564
+ headers.ACCESS_TOKEN = accessJwt();
565
+ } else {
566
+ const key = ep.customToken && ep.customToken !== 'CS token' ? ep.customToken : catalog.auth.header;
567
+ const token = session.tokens[key] ?? session.tokens[catalog.auth.header];
568
+ if (token) headers[key] = token;
569
+ }
570
+ }
571
+ Object.assign(headers, spec.headers ?? {});
572
+
573
+ const started = performance.now();
574
+ try {
575
+ const upstream = await fetch(url, {
576
+ method: ep.method,
577
+ ...encodeBody(ep, data, spec.bodyType ?? session.samples[ep.id]?.bodyType, headers),
578
+ signal: AbortSignal.timeout(spec.timeoutMs ?? 30_000),
579
+ });
580
+ const text = await upstream.text();
581
+ const ms = Math.round(performance.now() - started);
582
+
583
+ // the API answers 200 with an error code in the body often enough to matter
584
+ let parsed;
585
+ try {
586
+ parsed = JSON.parse(text);
587
+ } catch {
588
+ /* not JSON */
589
+ }
590
+ const { ok, innerCode } = verdict(upstream.ok, parsed);
591
+ const drift = ok ? checkContract(ep.id, text, 'send') : undefined;
592
+
593
+ const result = {
594
+ id: ep.id,
595
+ url,
596
+ method: ep.method,
597
+ status: upstream.status,
598
+ innerCode,
599
+ ok,
600
+ ms,
601
+ size: Buffer.byteLength(text),
602
+ headers: Object.fromEntries(upstream.headers),
603
+ body: text,
604
+ drift,
605
+ origin: spec.origin,
606
+ at: new Date().toISOString(),
607
+ };
608
+ // capture rules turn this response into variables the next call can use
609
+ if (ok && spec.capture && parsed !== undefined) {
610
+ result.captured = {};
611
+ for (const [name, path] of Object.entries(spec.capture)) {
612
+ if (!name || !path) continue;
613
+ const value = pluck(parsed, path);
614
+ if (value === undefined || value === null || typeof value === 'object') {
615
+ result.captured[name] = { path, error: `nothing usable at ${path}` };
616
+ } else {
617
+ session.vars[name] = value;
618
+ result.captured[name] = { path, value };
619
+ }
620
+ }
621
+ saveVars();
622
+ }
623
+
624
+ session.results[ep.id] = summarize(result);
625
+ saveResults();
626
+ return result;
627
+ } catch (e) {
628
+ const ms = Math.round(performance.now() - started);
629
+ const message = e.name === 'TimeoutError' ? `timed out after ${ms}ms` : e.message;
630
+ const result = { id: ep.id, url, method: ep.method, status: null, ok: false, ms, size: 0, error: message, origin: spec.origin, at: new Date().toISOString() };
631
+ session.results[ep.id] = summarize(result);
632
+ saveResults();
633
+ return result;
634
+ }
635
+ }
636
+
637
+ const MAX_KEPT_BODY = 20_000;
638
+
639
+ const summarize = (r) => {
640
+ const previous = session.results[r.id];
641
+ // a failure you cannot inspect afterwards is only half a report
642
+ const failure = r.ok
643
+ ? undefined
644
+ : {
645
+ status: r.status,
646
+ innerCode: r.innerCode,
647
+ error: r.error,
648
+ body: typeof r.body === 'string' ? r.body.slice(0, MAX_KEPT_BODY) : undefined,
649
+ url: r.url,
650
+ at: r.at,
651
+ from: r.origin ?? 'send',
652
+ };
653
+
654
+ return {
655
+ failure,
656
+ // set when this endpoint failed during a replay, so the UI can say so plainly
657
+ failedInReplay: r.origin === 'replay' && !r.ok ? true : undefined,
658
+ url: r.url, // what was actually called, so a bulk result can be audited
659
+ drift: r.drift ? { summary: r.drift.summary, count:
660
+ r.drift.added.length + r.drift.removed.length + r.drift.changed.length } : undefined,
661
+ status: r.status,
662
+ innerCode: r.innerCode,
663
+ ok: r.ok,
664
+ ms: r.ms,
665
+ error: r.error,
666
+ at: r.at,
667
+ env: session.env,
668
+ // was it passing before this run? lets the UI call out fresh breakage
669
+ wasOk: previous ? previous.ok : undefined,
670
+ lastOkAt: r.ok ? r.at : previous?.lastOkAt,
671
+ };
672
+ };
673
+
674
+ /** logging out mid-run would invalidate the token the rest of the run needs */
675
+ const INVALIDATES_SESSION = /log_?out|sign_?out|revoke_token/i;
676
+
677
+ /** a payload whose secret was stripped cannot be replayed as-is */
678
+ const hasRedacted = (sample) => JSON.stringify(sample?.data ?? {}).includes('<redacted>');
679
+
680
+ /**
681
+ * Decides what a bulk run may actually fire, and why anything was left out.
682
+ * The caller gets the reasons back so the UI never silently drops an endpoint.
683
+ */
684
+ function planRun(ids, { allowMutating, includeDeletes, includeLogout, includeAccessToken }) {
685
+ const plan = {
686
+ queue: [],
687
+ skipped: { readOnly: 0, writes: [], deletes: [], logout: [], redacted: [], accessToken: [] },
688
+ };
689
+
690
+ for (const id of ids) {
691
+ const ep = findEndpoint(id);
692
+ if (!ep) continue;
693
+
694
+ // these mint or spend a signed JWT, and several of them are the sign-in chain itself
695
+ if (ep.customToken === 'ACCESS_TOKEN' && !includeAccessToken) {
696
+ plan.skipped.accessToken.push(ep.id);
697
+ continue;
698
+ }
699
+
700
+ if (!MUTATING.has(ep.method)) {
701
+ plan.queue.push(ep);
702
+ continue;
703
+ }
704
+ if (!allowMutating) {
705
+ plan.skipped.writes.push(ep.id);
706
+ continue;
707
+ }
708
+ if (ep.method === 'DELETE' && !includeDeletes) {
709
+ plan.skipped.deletes.push(ep.id);
710
+ continue;
711
+ }
712
+ if (INVALIDATES_SESSION.test(ep.subUrl) && !includeLogout) {
713
+ plan.skipped.logout.push(ep.id);
714
+ continue;
715
+ }
716
+ if (hasRedacted(session.samples[ep.id])) {
717
+ // sending the literal "<redacted>" would fail for a reason that is not the endpoint's fault
718
+ plan.skipped.redacted.push(ep.id);
719
+ continue;
720
+ }
721
+ plan.queue.push(ep);
722
+ }
723
+ return plan;
724
+ }
725
+
726
+ /**
727
+ * Runs a set of endpoints. Read-only runs go wide; a run that writes goes one at
728
+ * a time, because captured writes often depend on each other and a failure order
729
+ * you cannot reproduce is worse than a slow run.
730
+ */
731
+ async function runBatch(ids, options = {}) {
732
+ const { allowMutating = false, includeDeletes = false, includeLogout = false } = options;
733
+ const sequential = options.sequential ?? allowMutating;
734
+
735
+ const { queue, skipped } = planRun(ids, { allowMutating, includeDeletes, includeLogout });
736
+
737
+ const out = [];
738
+ let unfilled = 0; // endpoints run with a blank path value, so their result means little
739
+ let cursor = 0;
740
+
741
+ // progress the UI can poll while this runs
742
+ session.run = {
743
+ active: true,
744
+ total: queue.length,
745
+ done: 0,
746
+ passed: 0,
747
+ failed: 0,
748
+ current: null,
749
+ finished: [],
750
+ cancelled: false,
751
+ startedAt: new Date().toISOString(),
752
+ };
753
+
754
+ const worker = async () => {
755
+ while (cursor < queue.length) {
756
+ if (session.run.cancelled) break;
757
+ const ep = queue[cursor++];
758
+ session.run.current = { id: ep.id, method: ep.method, module: ep.module };
759
+ // real traffic first, then a variable named after the hole, then blank
760
+ const sample = session.samples[ep.id];
761
+ const holes = {};
762
+ let missing = false;
763
+ for (const h of ep.holes) {
764
+ const value =
765
+ sample?.pathValues?.[h.expr] ?? session.vars[h.label] ?? session.vars[h.expr];
766
+ if (value === undefined) missing = true;
767
+ holes[h.expr] = value === undefined ? '' : String(value);
768
+ }
769
+ if (missing) unfilled++;
770
+ const result = summarize(
771
+ await callEndpoint({
772
+ id: ep.id,
773
+ holes,
774
+ params: sample?.params ?? {},
775
+ data: sample?.data ?? {},
776
+ bodyType: sample?.bodyType,
777
+ origin: allowMutating ? 'replay' : 'bulk',
778
+ }),
779
+ );
780
+ out.push(result);
781
+
782
+ session.run.done++;
783
+ session.run[result.ok ? 'passed' : 'failed']++;
784
+ session.run.finished.push({
785
+ id: ep.id,
786
+ method: ep.method,
787
+ ok: result.ok,
788
+ status: result.status,
789
+ innerCode: result.innerCode,
790
+ ms: result.ms,
791
+ error: result.error,
792
+ });
793
+ }
794
+ };
795
+
796
+ const workers = sequential ? 1 : BATCH_CONCURRENCY;
797
+ await Promise.all(Array.from({ length: workers }, worker));
798
+
799
+ const cancelled = session.run.cancelled;
800
+ session.run = { ...session.run, active: false, current: null };
801
+ saveLastRun();
802
+ recordRun();
803
+
804
+ return {
805
+ cancelled,
806
+ ran: out.length,
807
+ failed: out.filter((r) => !r.ok).length,
808
+ skipped:
809
+ skipped.writes.length +
810
+ skipped.deletes.length +
811
+ skipped.logout.length +
812
+ skipped.redacted.length +
813
+ skipped.accessToken.length,
814
+ skippedDetail: skipped,
815
+ sequential,
816
+ unfilled,
817
+ results: session.results,
818
+ };
819
+ }
820
+
821
+ /** what a write run would do, so the user can be told before it happens */
822
+ function previewRun(ids, options) {
823
+ const { queue, skipped } = planRun(ids, options);
824
+ const byMethod = {};
825
+ for (const ep of queue) byMethod[ep.method] = (byMethod[ep.method] ?? 0) + 1;
826
+ return {
827
+ total: queue.length,
828
+ byMethod,
829
+ env: session.env,
830
+ baseUrl: baseUrl(),
831
+ skippedDetail: skipped,
832
+ deletes: ids.map(findEndpoint).filter((e) => e?.method === 'DELETE').map((e) => e.id),
833
+ accessToken: ids
834
+ .map(findEndpoint)
835
+ .filter((e) => e?.customToken === 'ACCESS_TOKEN')
836
+ .map((e) => e.id),
837
+ logout: ids
838
+ .map(findEndpoint)
839
+ .filter((e) => e && MUTATING.has(e.method) && INVALIDATES_SESSION.test(e.subUrl))
840
+ .map((e) => e.id),
841
+ };
842
+ }
843
+
844
+ /* -------------------------------------------------------------- live record */
845
+
846
+ /**
847
+ * One call, streamed from the running app. We keep it as the endpoint's sample
848
+ * (so its payload and params are prefilled) and as a health result (so the app's
849
+ * own traffic counts as evidence the endpoint works).
850
+ */
851
+ function recordLive(call) {
852
+ if (!session.live.on) return { ignored: 'recording is paused' };
853
+ if (!call?.url) return { ignored: 'no url' };
854
+
855
+ const path = toComparablePath(call.url, catalog.baseUrls);
856
+ if (path === null) return { ignored: 'not an API host' };
857
+
858
+ const method = (call.method ?? 'GET').toUpperCase();
859
+ const hit = matchEndpoint(method, path, catalog.endpoints);
860
+
861
+ // no service file explains this one: record it anyway, flagged as uncatalogued
862
+ const endpoint = hit?.endpoint ?? adoptEndpoint(method, path);
863
+ const pathValues = hit?.pathValues ?? {};
864
+ if (!hit) {
865
+ const miss = `${method} ${path}`;
866
+ if (!session.live.unmatched.includes(miss)) session.live.unmatched.push(miss);
867
+ }
868
+
869
+ const id = endpoint.id;
870
+ session.live.count++;
871
+ session.live.since ??= new Date().toISOString();
872
+
873
+ const query = call.url.includes('?') ? call.url.slice(call.url.indexOf('?')) : '';
874
+ const params = redact(call.params ?? {});
875
+ session.samples[id] = {
876
+ pathValues,
877
+ params: Object.keys(params).length ? params : parseQuery(query),
878
+ data: redact(call.data ?? {}),
879
+ seenAt: new Date().toISOString(),
880
+ from: `${method} ${path}`,
881
+ live: true,
882
+ // kept so an exported HAR carries real content
883
+ status: call.status,
884
+ ms: call.ms,
885
+ // how the app sent the body: json, formdata or urlencoded
886
+ bodyType: call.bodyType ?? 'json',
887
+ responseBody: typeof call.body === 'string' ? call.body.slice(0, 100_000) : undefined,
888
+ };
889
+ saveSamples();
890
+
891
+ // the app's own call is a real health signal, so score it the same way
892
+ let parsed;
893
+ try {
894
+ parsed = call.body ? JSON.parse(call.body) : undefined;
895
+ } catch {
896
+ /* not JSON */
897
+ }
898
+ const { ok, innerCode } = verdict(
899
+ typeof call.status === 'number' && call.status >= 200 && call.status < 300,
900
+ parsed,
901
+ );
902
+ session.results[id] = summarize({
903
+ id,
904
+ url: call.url,
905
+ status: call.status,
906
+ innerCode,
907
+ ok,
908
+ ms: call.ms ?? 0,
909
+ error: call.error,
910
+ drift: ok ? checkContract(id, call.body, 'live') : undefined,
911
+ at: new Date().toISOString(),
912
+ });
913
+ saveResults();
914
+
915
+ return { recorded: id, ok, count: session.live.count };
916
+ }
917
+
918
+ /* ------------------------------------------------------------------ export */
919
+
920
+ /** the captured requests for a module, or all of them when module is empty */
921
+ function capturedFor(module) {
922
+ return allEndpoints()
923
+ .filter((ep) => (!module || ep.module === module) && session.samples[ep.id])
924
+ .map((ep) => ({ ep, sample: session.samples[ep.id] }))
925
+ .sort((a, b) => (a.sample.seenAt < b.sample.seenAt ? -1 : 1));
926
+ }
927
+
928
+ /** a HAR the browser, Postman or anything else will accept */
929
+ function toHar(entries) {
930
+ return {
931
+ log: {
932
+ version: '1.2',
933
+ creator: { name: 'api-tracer-kit', version: '1.0' },
934
+ entries: entries.map(({ ep, sample }) => {
935
+ const url = buildUrl(ep, sample.pathValues ?? {}, sample.params ?? {});
936
+ const body = sample.data && Object.keys(sample.data).length ? JSON.stringify(sample.data) : null;
937
+ const responseText = sample.responseBody ?? '';
938
+ return {
939
+ startedDateTime: sample.seenAt,
940
+ time: sample.ms ?? 0,
941
+ request: {
942
+ method: ep.method,
943
+ url,
944
+ httpVersion: 'HTTP/1.1',
945
+ cookies: [],
946
+ headers: [
947
+ { name: 'Content-Type', value: 'application/json' },
948
+ // deliberately a placeholder: real tokens are never exported
949
+ { name: catalog.auth.header, value: '<paste your token>' },
950
+ ],
951
+ queryString: Object.entries(sample.params ?? {}).map(([name, value]) => ({
952
+ name,
953
+ value: typeof value === 'object' ? JSON.stringify(value) : String(value),
954
+ })),
955
+ headersSize: -1,
956
+ bodySize: body ? Buffer.byteLength(body) : 0,
957
+ ...(body
958
+ ? { postData: { mimeType: 'application/json', text: body, params: [] } }
959
+ : {}),
960
+ },
961
+ response: {
962
+ status: sample.status ?? 0,
963
+ statusText: '',
964
+ httpVersion: 'HTTP/1.1',
965
+ cookies: [],
966
+ headers: [],
967
+ content: {
968
+ size: Buffer.byteLength(responseText),
969
+ mimeType: 'application/json',
970
+ text: responseText,
971
+ },
972
+ redirectURL: '',
973
+ headersSize: -1,
974
+ bodySize: Buffer.byteLength(responseText),
975
+ },
976
+ cache: {},
977
+ timings: { send: 0, wait: sample.ms ?? 0, receive: 0 },
978
+ };
979
+ }),
980
+ },
981
+ };
982
+ }
983
+
984
+ /**
985
+ * Postman Collection v2.1. Modules become folders, and the host plus token are
986
+ * left as {{baseUrl}} / {{token}} so the collection works against dev, stage or
987
+ * prod by switching a Postman environment rather than re-exporting.
988
+ */
989
+ function toPostman(entries, title) {
990
+ const base = baseUrl();
991
+
992
+ const folders = new Map();
993
+ for (const { ep, sample } of entries) {
994
+ if (!folders.has(ep.module)) folders.set(ep.module, []);
995
+
996
+ const full = buildUrl(ep, sample.pathValues ?? {}, sample.params ?? {});
997
+ const rest = full.startsWith(base) ? full.slice(base.length) : full;
998
+ const [pathPart, queryPart = ''] = rest.split('?');
999
+
1000
+ const body = bodyForPostman(sample);
1001
+ folders.get(ep.module).push({
1002
+ name: ep.name,
1003
+ request: {
1004
+ method: ep.method,
1005
+ header: [
1006
+ ...(sample.bodyType === 'formdata'
1007
+ ? [] // Postman sets multipart's own content-type, boundary included
1008
+ : [{ key: 'Content-Type', value: contentTypeFor(sample.bodyType) }]),
1009
+ { key: catalog.auth.header, value: '{{token}}' },
1010
+ ],
1011
+ ...(body ? { body } : {}),
1012
+ url: {
1013
+ raw: `{{baseUrl}}${rest}`,
1014
+ host: ['{{baseUrl}}'],
1015
+ path: pathPart.split('/').filter(Boolean),
1016
+ query: [...new URLSearchParams(queryPart)].map(([key, value]) => ({ key, value })),
1017
+ },
1018
+ description: `${ep.file}:${ep.line}\nCaptured ${sample.seenAt}${
1019
+ sample.status ? `, answered ${sample.status}` : ''
1020
+ }`,
1021
+ },
1022
+ });
1023
+ }
1024
+
1025
+ return {
1026
+ info: {
1027
+ name: title,
1028
+ _postman_id: randomUUID(),
1029
+ schema: 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json',
1030
+ description:
1031
+ `Generated by api-tracer-kit from calls the app actually made. ` +
1032
+ `Set the token variable to your ${catalog.auth.header} before running.`,
1033
+ },
1034
+ item: [...folders.entries()]
1035
+ .sort((a, b) => a[0].localeCompare(b[0]))
1036
+ .map(([name, item]) => ({ name, item })),
1037
+ variable: [
1038
+ { key: 'baseUrl', value: base },
1039
+ { key: 'token', value: '', type: 'string' },
1040
+ ],
1041
+ };
1042
+ }
1043
+
1044
+ const contentTypeFor = (bodyType) =>
1045
+ bodyType === 'urlencoded' ? 'application/x-www-form-urlencoded' : 'application/json';
1046
+
1047
+ /** the body in whichever mode Postman needs for how the app really sent it */
1048
+ function bodyForPostman(sample) {
1049
+ const data = sample.data ?? {};
1050
+ if (!Object.keys(data).length) return null;
1051
+
1052
+ const pairs = Object.entries(data).flatMap(([key, value]) =>
1053
+ (Array.isArray(value) ? value : [value]).map((v) => ({
1054
+ key,
1055
+ value: typeof v === 'object' && v !== null ? JSON.stringify(v) : String(v),
1056
+ type: 'text',
1057
+ })),
1058
+ );
1059
+
1060
+ if (sample.bodyType === 'formdata') return { mode: 'formdata', formdata: pairs };
1061
+ if (sample.bodyType === 'urlencoded') {
1062
+ return { mode: 'urlencoded', urlencoded: pairs.map(({ key, value }) => ({ key, value })) };
1063
+ }
1064
+ return {
1065
+ mode: 'raw',
1066
+ raw: JSON.stringify(data, null, 2),
1067
+ options: { raw: { language: 'json' } },
1068
+ };
1069
+ }
1070
+
1071
+ /** one runnable cURL command per captured request */
1072
+ function toCurl(entries) {
1073
+ const q = (v) => `'${String(v).replaceAll("'", `'\\''`)}'`;
1074
+ return entries
1075
+ .map(({ ep, sample }) => {
1076
+ const url = buildUrl(ep, sample.pathValues ?? {}, sample.params ?? {});
1077
+ const lines = [
1078
+ `# ${ep.id} (${ep.file}:${ep.line})`,
1079
+ `curl -X ${ep.method} ${q(url)}`,
1080
+ ` -H 'Content-Type: application/json'`,
1081
+ ` -H '${catalog.auth.header}: <paste your token>'`,
1082
+ ];
1083
+ if (sample.data && Object.keys(sample.data).length) {
1084
+ lines.push(` -d ${q(JSON.stringify(sample.data))}`);
1085
+ }
1086
+ return `${lines[0]}\n${lines.slice(1).join(' \\\n')}`;
1087
+ })
1088
+ .join('\n\n');
1089
+ }
1090
+
1091
+ /* ------------------------------------------------------------------- login */
1092
+
1093
+ /*
1094
+ * Signing in, without knowing anything about the API doing it.
1095
+ *
1096
+ * Plenty of APIs need more than one call to hand out a usable token -- password,
1097
+ * then a one-time code, then a choice of organisation. Rather than make everyone
1098
+ * paste a token, the config file can describe that chain as a list of steps, and
1099
+ * this drives it:
1100
+ *
1101
+ * login: {
1102
+ * steps: [
1103
+ * { name: 'credentials',
1104
+ * fields: [{ name: 'email', type: 'email' }, { name: 'password', type: 'password' }],
1105
+ * endpointId: 'session.loginApi', path: '/users/login.json',
1106
+ * body: (input, state) => ({ user: input }),
1107
+ * then: (data, state) => ({ state: { apiToken: data.api_token }, next: 'otp' }) },
1108
+ * ],
1109
+ * }
1110
+ *
1111
+ * A project with no `login` config still gets `Paste a token`, which is all most
1112
+ * APIs need. The password is forwarded once and is never stored, logged, or
1113
+ * written to disk; only the resulting token is kept.
1114
+ */
1115
+ const login = { state: {}, stage: null };
1116
+
1117
+ const b64url = (buf) => Buffer.from(buf).toString('base64url');
1118
+
1119
+ /**
1120
+ * A short-lived HS256 JWT, for APIs that want every call signed. The signing key
1121
+ * and the payload shape come from the config or from what the scan read out of
1122
+ * the app, so a rotated key is picked up by rescanning rather than edited here.
1123
+ */
1124
+ export function accessJwt() {
1125
+ const cfg = config.login?.jwt ?? catalog.jwt;
1126
+ if (!cfg) throw new Error('no JWT signing config; add login.jwt to the config file');
1127
+
1128
+ const secret = (session.env === 'prod' ? cfg.secretProd : cfg.secretDefault) ?? cfg.secret;
1129
+ if (!secret) throw new Error('no signing secret in the JWT config');
1130
+
1131
+ const now = Math.floor(Date.now() / 1000);
1132
+ const alphabet = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
1133
+ const secretCode = Array.from({ length: 10 }, () => alphabet[randomInt(alphabet.length)]).join('');
1134
+
1135
+ const payload = cfg.payload
1136
+ ? cfg.payload({ now, secretCode, env: session.env })
1137
+ : {
1138
+ timestamp: `${cfg.timestampPrefix ?? ''}${new Date().toISOString().slice(0, 10)}`,
1139
+ secret_code: secretCode,
1140
+ exp: now + (cfg.ttlSeconds ?? 600),
1141
+ code: randomUUID(),
1142
+ iat: now,
1143
+ };
1144
+
1145
+ const signingInput = `${b64url(JSON.stringify({ alg: 'HS256' }))}.${b64url(JSON.stringify(payload))}`;
1146
+ const signature = createHmac('sha256', secret).update(signingInput).digest('base64url');
1147
+ return `${signingInput}.${signature}`;
1148
+ }
1149
+
1150
+ /** the scanned URL for a step, so a path change in the app is picked up here */
1151
+ const loginUrl = (id, fallback) => `${baseUrl()}${(id && findEndpoint(id)?.subUrl) || fallback}`;
1152
+
1153
+ async function loginCall(url, body, headers) {
1154
+ const upstream = await fetch(url, {
1155
+ method: 'POST',
1156
+ headers: { 'Content-Type': 'application/json', ...headers },
1157
+ body: JSON.stringify(body),
1158
+ signal: AbortSignal.timeout(30_000),
1159
+ });
1160
+
1161
+ const text = await upstream.text();
1162
+ let parsed;
1163
+ try {
1164
+ parsed = JSON.parse(text);
1165
+ } catch {
1166
+ throw new Error(`login step returned non-JSON (${upstream.status})`);
1167
+ }
1168
+ // failure may be reported in the envelope rather than the status line
1169
+ const { ok } = verdict(upstream.ok, parsed);
1170
+ if (!ok) throw new Error(parsed.message || parsed.error || `login step failed (${upstream.status})`);
1171
+ return parsed.data ?? parsed;
1172
+ }
1173
+
1174
+ const loginSteps = () => config.login?.steps ?? [];
1175
+ const stepNamed = (name) => loginSteps().find((s) => s.name === name) ?? loginSteps()[0];
1176
+
1177
+ /** what the UI should ask for next, without the UI knowing the flow */
1178
+ export function loginPlan() {
1179
+ const steps = loginSteps();
1180
+ if (!steps.length) return { supported: false };
1181
+ const step = login.stage ? stepNamed(login.stage) : steps[0];
1182
+ return {
1183
+ supported: true,
1184
+ stage: step?.name ?? null,
1185
+ title: step?.title ?? step?.name ?? null,
1186
+ fields: step?.fields ?? [],
1187
+ choices: login.choices ?? null,
1188
+ state: { ...login.state, password: undefined },
1189
+ };
1190
+ }
1191
+
1192
+ /** runs one step of the configured chain and says what comes next */
1193
+ export async function loginStep(input = {}) {
1194
+ const steps = loginSteps();
1195
+ if (!steps.length) throw new Error('this console has no sign-in flow configured; paste a token instead');
1196
+
1197
+ const step = stepNamed(input.stage ?? login.stage ?? steps[0].name);
1198
+ if (!step) throw new Error(`unknown login step ${input.stage}`);
1199
+
1200
+ const headers = {};
1201
+ if (config.login?.jwt) headers[config.login.jwt.headerName ?? 'ACCESS_TOKEN'] = accessJwt();
1202
+ Object.assign(headers, step.headers ? step.headers(login.state) : {});
1203
+
1204
+ const body = step.body ? step.body(input, login.state) : input;
1205
+ const data = await loginCall(loginUrl(step.endpointId, step.path), body, headers);
1206
+
1207
+ const outcome = step.then ? step.then(data, login.state, input) : {};
1208
+ Object.assign(login.state, outcome.state ?? {});
1209
+ login.choices = outcome.choices ?? null;
1210
+
1211
+ if (outcome.token) {
1212
+ session.tokens[config.login?.header ?? catalog.auth.header] = outcome.token;
1213
+ session.tokenOwner = outcome.owner
1214
+ ? { ...outcome.owner, at: new Date().toISOString() }
1215
+ : { at: new Date().toISOString() };
1216
+ saveTokens();
1217
+ // the intermediate tokens have done their job
1218
+ login.state = {};
1219
+ login.stage = null;
1220
+ login.choices = null;
1221
+ return { done: true, tokensHeld: Object.keys(session.tokens), user: session.tokenOwner };
1222
+ }
1223
+
1224
+ login.stage = outcome.next ?? null;
1225
+ return { done: false, ...loginPlan(), ...(outcome.info ?? {}) };
1226
+ }
1227
+
1228
+ const currentReport = () =>
1229
+ buildReport({
1230
+ catalog,
1231
+ endpoints: allEndpoints(),
1232
+ samples: session.samples,
1233
+ results: session.results,
1234
+ contracts: session.contracts,
1235
+ runs: loadRuns(),
1236
+ env: session.env,
1237
+ });
1238
+
1239
+ /* ------------------------------------------------------------------ routes */
1240
+
1241
+ /** constant-time compare, so the password cannot be guessed a byte at a time */
1242
+ function sameSecret(a, b) {
1243
+ const x = Buffer.from(a);
1244
+ const y = Buffer.from(b);
1245
+ return x.length === y.length && timingSafeEqual(x, y);
1246
+ }
1247
+
1248
+ function authorised(req) {
1249
+ if (!AUTH_USER && !AUTH_PASS) return true;
1250
+ const header = req.headers.authorization ?? '';
1251
+ if (!header.startsWith('Basic ')) return false;
1252
+ const [user, ...rest] = Buffer.from(header.slice(6), 'base64').toString().split(':');
1253
+ return sameSecret(user, AUTH_USER) && sameSecret(rest.join(':'), AUTH_PASS);
1254
+ }
1255
+
1256
+ async function handle(req, res) {
1257
+ const url = new URL(req.url ?? '/', `http://localhost:${PORT}`);
1258
+ const method = req.method ?? 'GET';
1259
+
1260
+ // CloudFront forwards the mount path as-is, so strip it before routing
1261
+ let { pathname } = url;
1262
+ if (BASE_PATH && pathname.startsWith(BASE_PATH)) {
1263
+ // /api-console must become /api-console/ or relative assets resolve one level up
1264
+ if (pathname === BASE_PATH) {
1265
+ res.writeHead(302, { location: `${BASE_PATH}/` });
1266
+ return res.end();
1267
+ }
1268
+ pathname = pathname.slice(BASE_PATH.length) || '/';
1269
+ }
1270
+
1271
+ // the app runs on another localhost port, so the recorder needs CORS
1272
+ const origin = req.headers.origin;
1273
+ const originAllowed =
1274
+ origin && (/^https?:\/\/localhost(:\d+)?$/.test(origin) || ALLOWED_ORIGINS.includes(origin));
1275
+ if (originAllowed) {
1276
+ res.setHeader('access-control-allow-origin', origin);
1277
+ res.setHeader('access-control-allow-headers', 'content-type');
1278
+ res.setHeader('access-control-allow-methods', 'POST, GET, DELETE, OPTIONS');
1279
+ }
1280
+ if (method === 'OPTIONS') {
1281
+ res.writeHead(204);
1282
+ return res.end();
1283
+ }
1284
+
1285
+ // the recorder posts from the app, which has no way to send basic auth
1286
+ if (pathname !== '/api/record' && !authorised(req)) {
1287
+ res.writeHead(401, {
1288
+ 'www-authenticate': 'Basic realm="API console", charset="UTF-8"',
1289
+ 'content-type': 'application/json',
1290
+ });
1291
+ return res.end(JSON.stringify({ error: 'authentication required' }));
1292
+ }
1293
+
1294
+ if (!pathname.startsWith('/api/')) return serveStatic(res, pathname);
1295
+
1296
+ if (pathname === '/api/endpoints' && method === 'GET') {
1297
+ return json(res, 200, {
1298
+ scannedAt: catalog.scannedAt,
1299
+ baseUrls: catalog.baseUrls,
1300
+ auth: catalog.auth,
1301
+ endpoints: allEndpoints(),
1302
+ env: session.env,
1303
+ // names of tokens held, never the values
1304
+ tokensHeld: Object.keys(session.tokens),
1305
+ // on a shared console it matters whose session the token belongs to
1306
+ tokenOwner: session.tokenOwner ?? null,
1307
+ locks: { recording: LOCK_RECORDING, env: LOCK_ENV || null },
1308
+ name: config.name ?? 'API',
1309
+ login: loginPlan(),
1310
+ sourceLabel: catalog.sourceLabel ?? 'the source',
1311
+ results: session.results,
1312
+ vars: session.vars,
1313
+ samples: session.samples,
1314
+ live: session.live,
1315
+ contracts: session.contracts,
1316
+ lastRun: session.run,
1317
+ });
1318
+ }
1319
+
1320
+ if (pathname === '/api/login' && method === 'GET') {
1321
+ return json(res, 200, loginPlan());
1322
+ }
1323
+
1324
+ if (pathname === '/api/login' && method === 'POST') {
1325
+ return json(res, 200, await loginStep(await readBody(req)));
1326
+ }
1327
+
1328
+ if (pathname === '/api/token' && method === 'POST') {
1329
+ const { key, token } = await readBody(req);
1330
+ const name = key || catalog.auth.header;
1331
+ if (token) session.tokens[name] = token;
1332
+ else {
1333
+ delete session.tokens[name];
1334
+ session.tokenOwner = null;
1335
+ }
1336
+ saveTokens();
1337
+ return json(res, 200, { tokensHeld: Object.keys(session.tokens) });
1338
+ }
1339
+
1340
+ if (pathname === '/api/env' && method === 'POST') {
1341
+ const { env } = await readBody(req);
1342
+ if (LOCK_ENV) {
1343
+ return json(res, 400, { error: `this console is pinned to ${LOCK_ENV}` });
1344
+ }
1345
+ if (!catalog.baseUrls[env]) return json(res, 400, { error: `unknown env ${env}` });
1346
+ session.env = env;
1347
+ // results from another environment would be misleading
1348
+ session.results = {};
1349
+ saveResults();
1350
+ return json(res, 200, { env: session.env, baseUrl: baseUrl() });
1351
+ }
1352
+
1353
+ if (pathname === '/api/export' && method === 'GET') {
1354
+ const module = url.searchParams.get('module') ?? '';
1355
+ const format = url.searchParams.get('format') ?? 'har';
1356
+ const entries = capturedFor(module);
1357
+ if (!entries.length) return json(res, 404, { error: 'nothing captured yet for that selection' });
1358
+
1359
+ const stamp = new Date().toISOString().slice(0, 10);
1360
+ const name = `${module || 'all-modules'}-${stamp}`;
1361
+
1362
+ const shape = {
1363
+ curl: { ext: 'sh', type: 'text/plain; charset=utf-8', build: () => toCurl(entries) },
1364
+ postman: {
1365
+ ext: 'postman_collection.json',
1366
+ type: 'application/json',
1367
+ build: () =>
1368
+ `${JSON.stringify(toPostman(entries, `${module || config.name || 'API'} (captured ${stamp})`), null, 2)}\n`,
1369
+ },
1370
+ har: {
1371
+ ext: 'har',
1372
+ type: 'application/json',
1373
+ build: () => `${JSON.stringify(toHar(entries), null, 2)}\n`,
1374
+ },
1375
+ }[format] ?? null;
1376
+ if (!shape) return json(res, 400, { error: `unknown format ${format}` });
1377
+
1378
+ const body = shape.build();
1379
+ res.writeHead(200, {
1380
+ 'content-type': shape.type,
1381
+ 'content-disposition': `attachment; filename="${name}.${shape.ext}"`,
1382
+ 'content-length': Buffer.byteLength(body),
1383
+ });
1384
+ return res.end(body);
1385
+ }
1386
+
1387
+ if (pathname === '/api/record' && method === 'POST') {
1388
+ return json(res, 200, recordLive(await readBody(req)));
1389
+ }
1390
+
1391
+ if (pathname === '/api/live' && method === 'GET') {
1392
+ return json(res, 200, { ...session.live, samples: session.samples, results: session.results });
1393
+ }
1394
+
1395
+ if (pathname === '/api/live' && method === 'POST') {
1396
+ const { on } = await readBody(req);
1397
+ if (LOCK_RECORDING && !on) {
1398
+ return json(res, 400, {
1399
+ error: 'recording is locked on for this console, so one person cannot stop it for everyone',
1400
+ });
1401
+ }
1402
+ session.live.on = Boolean(on);
1403
+ if (session.live.on && !session.live.since) session.live.since = new Date().toISOString();
1404
+ return json(res, 200, session.live);
1405
+ }
1406
+
1407
+ if (pathname === '/api/live' && method === 'DELETE') {
1408
+ session.live = { on: session.live.on, count: 0, since: new Date().toISOString(), unmatched: [] };
1409
+ return json(res, 200, session.live);
1410
+ }
1411
+
1412
+ if (pathname === '/api/import' && method === 'POST') {
1413
+ const { kind, text } = await readBody(req);
1414
+ if (!text?.trim()) return json(res, 400, { error: 'nothing to import' });
1415
+ const ctx = { baseUrls: catalog.baseUrls, endpoints: catalog.endpoints };
1416
+ const out = kind === 'curl' ? importCurl(text, ctx) : importHar(text, ctx);
1417
+ Object.assign(session.samples, out.samples);
1418
+
1419
+ // the same rule as live capture: an unexplained call is still recorded
1420
+ for (const miss of out.unmatchedCalls ?? []) {
1421
+ const ep = adoptEndpoint(miss.method, miss.path);
1422
+ session.samples[ep.id] = miss.sample;
1423
+ }
1424
+ saveSamples();
1425
+ return json(res, 200, {
1426
+ ...out,
1427
+ adopted: (out.unmatchedCalls ?? []).length,
1428
+ samples: session.samples,
1429
+ endpoints: allEndpoints(),
1430
+ });
1431
+ }
1432
+
1433
+ if (pathname === '/api/contracts' && method === 'GET') {
1434
+ return json(res, 200, { contracts: session.contracts });
1435
+ }
1436
+
1437
+ if (pathname === '/api/contracts' && method === 'POST') {
1438
+ // promote the last seen shape to the baseline: "this change is expected"
1439
+ const { id } = await readBody(req);
1440
+ const next = session.contracts[id]?.pending;
1441
+ if (!id || !next) return json(res, 400, { error: 'no drifted shape waiting for that endpoint' });
1442
+ session.contracts[id] = { shape: next, at: new Date().toISOString(), from: 'accepted' };
1443
+ saveContracts();
1444
+ if (session.results[id]) delete session.results[id].drift;
1445
+ saveResults();
1446
+ return json(res, 200, { contracts: session.contracts, results: session.results });
1447
+ }
1448
+
1449
+ if (pathname === '/api/contracts' && method === 'DELETE') {
1450
+ session.contracts = {};
1451
+ saveContracts();
1452
+ return json(res, 200, { contracts: session.contracts });
1453
+ }
1454
+
1455
+ if (pathname === '/api/uncatalogued' && method === 'DELETE') {
1456
+ for (const id of Object.keys(session.extras)) {
1457
+ delete session.samples[id];
1458
+ delete session.results[id];
1459
+ delete session.contracts[id];
1460
+ }
1461
+ session.extras = {};
1462
+ session.live.unmatched = [];
1463
+ saveExtras();
1464
+ saveSamples();
1465
+ saveResults();
1466
+ saveContracts();
1467
+ return json(res, 200, { extras: session.extras });
1468
+ }
1469
+
1470
+ if (pathname === '/api/samples' && method === 'DELETE') {
1471
+ session.samples = {};
1472
+ saveSamples();
1473
+ return json(res, 200, { samples: session.samples });
1474
+ }
1475
+
1476
+ if (pathname === '/api/variables' && method === 'GET') {
1477
+ return json(res, 200, { vars: session.vars });
1478
+ }
1479
+
1480
+ if (pathname === '/api/variables' && method === 'POST') {
1481
+ const { name, value } = await readBody(req);
1482
+ if (!name) return json(res, 400, { error: 'name required' });
1483
+ if (value === '' || value === null) delete session.vars[name];
1484
+ else session.vars[name] = value;
1485
+ saveVars();
1486
+ return json(res, 200, { vars: session.vars });
1487
+ }
1488
+
1489
+ if (pathname === '/api/results' && method === 'DELETE') {
1490
+ session.results = {};
1491
+ session.run = null;
1492
+ saveResults();
1493
+ saveLastRun();
1494
+ return json(res, 200, { results: session.results });
1495
+ }
1496
+
1497
+ if (pathname === '/api/call' && method === 'POST') {
1498
+ return json(res, 200, await callEndpoint(await readBody(req)));
1499
+ }
1500
+
1501
+ if (pathname === '/api/report' && method === 'GET') {
1502
+ return json(res, 200, currentReport());
1503
+ }
1504
+
1505
+ if (pathname === '/api/report/export' && method === 'GET') {
1506
+ const format = url.searchParams.get('format') ?? 'md';
1507
+ const report = currentReport();
1508
+ const stamp = new Date().toISOString().slice(0, 10);
1509
+ const body = format === 'json' ? `${JSON.stringify(report, null, 2)}\n` : toMarkdown(report);
1510
+ res.writeHead(200, {
1511
+ 'content-type': format === 'json' ? 'application/json' : 'text/markdown; charset=utf-8',
1512
+ 'content-disposition': `attachment; filename="api-report-${session.env}-${stamp}.${
1513
+ format === 'json' ? 'json' : 'md'
1514
+ }"`,
1515
+ 'content-length': Buffer.byteLength(body),
1516
+ });
1517
+ return res.end(body);
1518
+ }
1519
+
1520
+ if (pathname === '/api/run/status' && method === 'GET') {
1521
+ return json(res, 200, session.run ?? { active: false });
1522
+ }
1523
+
1524
+ if (pathname === '/api/run/cancel' && method === 'POST') {
1525
+ if (session.run?.active) session.run.cancelled = true;
1526
+ return json(res, 200, { cancelling: Boolean(session.run?.active) });
1527
+ }
1528
+
1529
+ if (pathname === '/api/run/preview' && method === 'POST') {
1530
+ const { ids, ...opts } = await readBody(req);
1531
+ return json(res, 200, previewRun(ids ?? [], opts));
1532
+ }
1533
+
1534
+ if (pathname === '/api/run' && method === 'POST') {
1535
+ const { ids, ...opts } = await readBody(req);
1536
+ // replaying writes against production is never what someone meant to do
1537
+ if (opts.allowMutating && session.env === 'prod') {
1538
+ return json(res, 400, { error: 'refusing to replay writes against prod; switch environment first' });
1539
+ }
1540
+ return json(res, 200, await runBatch(ids ?? [], opts));
1541
+ }
1542
+
1543
+ return json(res, 404, { error: 'not found' });
1544
+ }
1545
+
1546
+ export { handle, session, catalog as currentCatalog };
1547
+
1548
+ export function startServer({ port = PORT, host = HOST } = {}) {
1549
+ return createServer((req, res) => {
1550
+ handle(req, res).catch((e) => json(res, 500, { error: e.message }));
1551
+ }).listen(port, host);
1552
+ }
1553
+
1554
+ if (isMain)
1555
+ createServer((req, res) => {
1556
+ handle(req, res).catch((e) => json(res, 500, { error: e.message }));
1557
+ }).listen(PORT, HOST, () => {
1558
+ console.log(`api-tracer console -> http://${HOST}:${PORT}${BASE_PATH}/`);
1559
+ console.log(
1560
+ catalog.endpoints.length
1561
+ ? `${catalog.endpoints.length} endpoints, env "${session.env}" (${baseUrl() ?? 'no base URL'})`
1562
+ : `no catalog yet -- run \`api-tracer scan\`; live traffic is still recorded`,
1563
+ );
1564
+ if (BASE_PATH) console.log(`mounted under ${BASE_PATH}`);
1565
+ if (ALLOWED_ORIGINS.length) console.log(`recording accepted from: ${ALLOWED_ORIGINS.join(', ')}`);
1566
+ if (AUTH_USER) console.log('basic auth on; the recorder endpoint stays open');
1567
+ if (LOCK_RECORDING) console.log('recording locked on');
1568
+ if (LOCK_ENV) console.log(`environment pinned to ${LOCK_ENV}`);
1569
+ console.log(
1570
+ Object.keys(session.tokens).length
1571
+ ? `${catalog.auth.header} restored from ${TOKENS}${
1572
+ session.tokenOwner ? ` (${session.tokenOwner.email})` : ''
1573
+ }`
1574
+ : 'Sign in from the UI to get a token.',
1575
+ );
1576
+ });