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.
- package/CHANGELOG.md +32 -0
- package/LICENSE +21 -0
- package/README.md +466 -0
- package/cli/bin/api-tracer.mjs +266 -0
- package/cli/config.mjs +224 -0
- package/cli/import.mjs +231 -0
- package/cli/index.mjs +10 -0
- package/cli/presets.mjs +212 -0
- package/cli/report.mjs +346 -0
- package/cli/scan.mjs +142 -0
- package/cli/server.mjs +1576 -0
- package/cli/shape.mjs +90 -0
- package/cli/test.mjs +342 -0
- package/cli/web/app.css +1424 -0
- package/cli/web/app.js +2260 -0
- package/cli/web/favicon.svg +5 -0
- package/cli/web/index.html +159 -0
- package/cli/web/logo.svg +7 -0
- package/dist/axios.cjs +856 -0
- package/dist/axios.cjs.map +1 -0
- package/dist/axios.d.cts +27 -0
- package/dist/axios.d.ts +27 -0
- package/dist/axios.js +853 -0
- package/dist/axios.js.map +1 -0
- package/dist/index.cjs +872 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +74 -0
- package/dist/index.d.ts +74 -0
- package/dist/index.js +857 -0
- package/dist/index.js.map +1 -0
- package/dist/react.cjs +896 -0
- package/dist/react.cjs.map +1 -0
- package/dist/react.d.cts +22 -0
- package/dist/react.d.ts +22 -0
- package/dist/react.js +893 -0
- package/dist/react.js.map +1 -0
- package/dist/tracer-BUWdU2lG.d.ts +76 -0
- package/dist/tracer-DG2YUqK0.d.cts +76 -0
- package/dist/types-Bl2-K6_g.d.cts +111 -0
- package/dist/types-Bl2-K6_g.d.ts +111 -0
- package/dist/ui.cjs +1162 -0
- package/dist/ui.cjs.map +1 -0
- package/dist/ui.d.cts +16 -0
- package/dist/ui.d.ts +16 -0
- package/dist/ui.js +1157 -0
- package/dist/ui.js.map +1 -0
- 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
|
+
});
|