botversion-sdk 2.1.0 → 2.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/scan-backend.js +2 -2
- package/bin/scan-frontend.js +2 -2
- package/client.js +33 -10
- package/index.js +4 -1
- package/interceptor.js +676 -92
- package/package.json +1 -1
- package/scanner.js +588 -78
package/interceptor.js
CHANGED
|
@@ -3,6 +3,71 @@
|
|
|
3
3
|
|
|
4
4
|
const reportedEndpoints = new Set();
|
|
5
5
|
|
|
6
|
+
// ── Query-string capture (GET / DELETE) ──────────────────────────────────
|
|
7
|
+
// Records only the NAMES of query keys, never their values.
|
|
8
|
+
const seenQueryVariants = new Set();
|
|
9
|
+
const queryVariantCounts = {};
|
|
10
|
+
const MAX_QUERY_VARIANTS = 5; // different key sets remembered per endpoint
|
|
11
|
+
const MAX_QUERY_KEYS = 20;
|
|
12
|
+
const QUERY_NOISE_KEYS = new Set([
|
|
13
|
+
"t",
|
|
14
|
+
"ts",
|
|
15
|
+
"timestamp",
|
|
16
|
+
"cb",
|
|
17
|
+
"cachebust",
|
|
18
|
+
"nocache",
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
function buildQueryStructure(method, rawUrl, rawPath) {
|
|
22
|
+
if (method !== "GET" && method !== "DELETE") return null;
|
|
23
|
+
if (String(rawPath || "").indexOf("/trpc/") !== -1) return null; // tRPC has its own input handling
|
|
24
|
+
const url = String(rawUrl || "");
|
|
25
|
+
const q = url.indexOf("?");
|
|
26
|
+
if (q === -1) return null;
|
|
27
|
+
let params;
|
|
28
|
+
try {
|
|
29
|
+
params = new URLSearchParams(url.slice(q + 1).split("#")[0]);
|
|
30
|
+
} catch (e) {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
const structure = {};
|
|
34
|
+
let count = 0;
|
|
35
|
+
params.forEach(function (_value, rawKey) {
|
|
36
|
+
if (count >= MAX_QUERY_KEYS) return;
|
|
37
|
+
const key = rawKey.replace(/\[\]$/, "");
|
|
38
|
+
if (!/^[A-Za-z][A-Za-z0-9_.-]{0,63}$/.test(key)) return;
|
|
39
|
+
if (QUERY_NOISE_KEYS.has(key.toLowerCase()) || looksLikeIdKey(key)) return;
|
|
40
|
+
if (Object.prototype.hasOwnProperty.call(structure, key)) return;
|
|
41
|
+
structure[key] = "string";
|
|
42
|
+
count++;
|
|
43
|
+
});
|
|
44
|
+
return count > 0 ? structure : null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Returns the query key names for this call, or null (also once an endpoint has too many variants)
|
|
48
|
+
function resolveQueryStructure(baseKey, method, rawUrl, rawPath) {
|
|
49
|
+
const structure = buildQueryStructure(method, rawUrl, rawPath);
|
|
50
|
+
if (!structure) return null;
|
|
51
|
+
const variant = baseKey + queryKeySuffix(structure);
|
|
52
|
+
if (seenQueryVariants.has(variant)) return structure;
|
|
53
|
+
const used = queryVariantCounts[baseKey] || 0;
|
|
54
|
+
if (used >= MAX_QUERY_VARIANTS) {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
queryVariantCounts[baseKey] = used + 1;
|
|
58
|
+
seenQueryVariants.add(variant);
|
|
59
|
+
return structure;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function queryKeySuffix(structure) {
|
|
63
|
+
return structure ? ":q:" + Object.keys(structure).sort().join(",") : "";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function mergeQueryIntoStructure(bodyStructure, queryStructure) {
|
|
67
|
+
if (!queryStructure) return bodyStructure;
|
|
68
|
+
return Object.assign({}, queryStructure, bodyStructure || {});
|
|
69
|
+
}
|
|
70
|
+
|
|
6
71
|
function structureToJsonSchema(bodyStructure) {
|
|
7
72
|
if (!bodyStructure) return null;
|
|
8
73
|
return {
|
|
@@ -26,6 +91,339 @@ function structureToJsonSchema(bodyStructure) {
|
|
|
26
91
|
};
|
|
27
92
|
}
|
|
28
93
|
|
|
94
|
+
// ── Response shape capture ───────────────────────────────────────────────
|
|
95
|
+
// Records only field names and types of successful JSON replies (never values)
|
|
96
|
+
// so the platform knows what each endpoint returns.
|
|
97
|
+
const reportedResponses = new Set();
|
|
98
|
+
// Stops watching an endpoint after a few replies that yielded no usable shape
|
|
99
|
+
const responseAttempts = {};
|
|
100
|
+
const MAX_RESPONSE_ATTEMPTS = 5;
|
|
101
|
+
const MAX_RESPONSE_CAPTURE_BYTES = 256 * 1024; // stop buffering bigger replies
|
|
102
|
+
const MAX_RESPONSE_DEPTH = 4;
|
|
103
|
+
const MAX_RESPONSE_FIELDS = 50;
|
|
104
|
+
|
|
105
|
+
// Keys that look like record IDs or emails are never sent as field names
|
|
106
|
+
// (guards against objects keyed by IDs leaking them as "fields").
|
|
107
|
+
function looksLikeIdKey(key) {
|
|
108
|
+
return (
|
|
109
|
+
/^\d+$/.test(key) ||
|
|
110
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
|
|
111
|
+
key,
|
|
112
|
+
) ||
|
|
113
|
+
/^[0-9a-f]{24}$/i.test(key) ||
|
|
114
|
+
/^c[a-z0-9]{20,}$/i.test(key) ||
|
|
115
|
+
(key.length >= 16 && /[a-zA-Z]/.test(key) && /[0-9]/.test(key)) ||
|
|
116
|
+
key.includes("@")
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function describeResponseValue(val, depth) {
|
|
121
|
+
if (val === null || val === undefined) return { type: "string" };
|
|
122
|
+
if (Array.isArray(val)) {
|
|
123
|
+
if (depth >= MAX_RESPONSE_DEPTH || val.length === 0) {
|
|
124
|
+
return { type: "array", items: { type: "object" } };
|
|
125
|
+
}
|
|
126
|
+
return {
|
|
127
|
+
type: "array",
|
|
128
|
+
items: describeArrayItems(val.slice(0, 5), depth + 1),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
if (typeof val === "object") {
|
|
132
|
+
const keys = Object.keys(val);
|
|
133
|
+
if (depth >= MAX_RESPONSE_DEPTH || keys.some(looksLikeIdKey)) {
|
|
134
|
+
return { type: "object" };
|
|
135
|
+
}
|
|
136
|
+
const properties = {};
|
|
137
|
+
keys.slice(0, MAX_RESPONSE_FIELDS).forEach(function (k) {
|
|
138
|
+
properties[k] = describeResponseValue(val[k], depth + 1);
|
|
139
|
+
});
|
|
140
|
+
return { type: "object", properties: properties };
|
|
141
|
+
}
|
|
142
|
+
if (typeof val === "number" || typeof val === "boolean") {
|
|
143
|
+
return { type: typeof val };
|
|
144
|
+
}
|
|
145
|
+
return { type: "string" };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Combines the first few list items so fields missing from one item still appear
|
|
149
|
+
function describeArrayItems(items, depth) {
|
|
150
|
+
const objs = items.filter(function (i) {
|
|
151
|
+
return i && typeof i === "object" && !Array.isArray(i);
|
|
152
|
+
});
|
|
153
|
+
if (objs.length === 0) {
|
|
154
|
+
const first = items.find(function (i) {
|
|
155
|
+
return i !== null && i !== undefined;
|
|
156
|
+
});
|
|
157
|
+
return first === undefined
|
|
158
|
+
? { type: "object" }
|
|
159
|
+
: describeResponseValue(first, depth);
|
|
160
|
+
}
|
|
161
|
+
const merged = {};
|
|
162
|
+
objs.forEach(function (o) {
|
|
163
|
+
Object.keys(o)
|
|
164
|
+
.slice(0, MAX_RESPONSE_FIELDS)
|
|
165
|
+
.forEach(function (k) {
|
|
166
|
+
if (merged[k] === undefined || merged[k] === null) merged[k] = o[k];
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
return describeResponseValue(merged, depth);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// A shape with no fields (empty list, {}) is not useful — keep waiting for a better reply
|
|
173
|
+
function hasResponseFields(schema) {
|
|
174
|
+
if (!schema) return false;
|
|
175
|
+
if (schema.type === "array") return hasResponseFields(schema.items);
|
|
176
|
+
return !!(schema.properties && Object.keys(schema.properties).length > 0);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// tRPC wraps each result as { result: { data: { json: ... } } }; errors as { error }
|
|
180
|
+
function unwrapTrpcResult(entry) {
|
|
181
|
+
if (entry && typeof entry === "object" && !Array.isArray(entry)) {
|
|
182
|
+
if (entry.error && !entry.result) return undefined;
|
|
183
|
+
if (
|
|
184
|
+
entry.result &&
|
|
185
|
+
typeof entry.result === "object" &&
|
|
186
|
+
"data" in entry.result
|
|
187
|
+
) {
|
|
188
|
+
const data = entry.result.data;
|
|
189
|
+
return data &&
|
|
190
|
+
typeof data === "object" &&
|
|
191
|
+
!Array.isArray(data) &&
|
|
192
|
+
"json" in data
|
|
193
|
+
? data.json
|
|
194
|
+
: data;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return entry;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Parses the captured reply; also handles replies compressed by the host app
|
|
201
|
+
function parseCapturedBody(buffer, res) {
|
|
202
|
+
try {
|
|
203
|
+
return JSON.parse(buffer.toString("utf8"));
|
|
204
|
+
} catch (e) {}
|
|
205
|
+
try {
|
|
206
|
+
const zlib = require("zlib");
|
|
207
|
+
const enc = String(res.getHeader("content-encoding") || "").toLowerCase();
|
|
208
|
+
const opts = { maxOutputLength: 1024 * 1024 };
|
|
209
|
+
let raw = null;
|
|
210
|
+
if (enc.includes("gzip")) raw = zlib.gunzipSync(buffer, opts);
|
|
211
|
+
else if (enc.includes("br")) raw = zlib.brotliDecompressSync(buffer, opts);
|
|
212
|
+
else if (enc.includes("deflate")) raw = zlib.inflateSync(buffer, opts);
|
|
213
|
+
if (raw) return JSON.parse(raw.toString("utf8"));
|
|
214
|
+
} catch (e) {}
|
|
215
|
+
return undefined;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Our own scan-trigger route must never be reported as one of the host's endpoints
|
|
219
|
+
function isBotVersionInternalPath(path) {
|
|
220
|
+
return String(path || "").indexOf("/__botversion/") === 0;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// True while at least one endpoint behind this path still has no captured reply shape
|
|
224
|
+
function isResponseShapeNeeded(method, rawPath) {
|
|
225
|
+
if (isBotVersionInternalPath(rawPath)) return false;
|
|
226
|
+
return splitBatchPath(rawPath)
|
|
227
|
+
.map(normalizePath)
|
|
228
|
+
.some(function (p) {
|
|
229
|
+
const k = method + ":" + p;
|
|
230
|
+
return (
|
|
231
|
+
!reportedResponses.has(k) &&
|
|
232
|
+
(responseAttempts[k] || 0) < MAX_RESPONSE_ATTEMPTS
|
|
233
|
+
);
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Turns an already-parsed reply into field-name shapes and reports each new one
|
|
238
|
+
function reportResponseShape(client, method, rawPath, parsed) {
|
|
239
|
+
try {
|
|
240
|
+
if (!parsed || typeof parsed !== "object") return;
|
|
241
|
+
|
|
242
|
+
const isTrpc = rawPath.includes("/trpc/");
|
|
243
|
+
const paths = splitBatchPath(rawPath).map(normalizePath);
|
|
244
|
+
|
|
245
|
+
let slots;
|
|
246
|
+
if (paths.length > 1) {
|
|
247
|
+
if (!isTrpc || !Array.isArray(parsed) || parsed.length !== paths.length)
|
|
248
|
+
return;
|
|
249
|
+
slots = parsed.map(unwrapTrpcResult);
|
|
250
|
+
} else {
|
|
251
|
+
slots = [isTrpc ? unwrapTrpcResult(parsed) : parsed];
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
paths.forEach(function (p, i) {
|
|
255
|
+
const key = method + ":" + p;
|
|
256
|
+
if (reportedResponses.has(key)) return;
|
|
257
|
+
const slot = slots[i];
|
|
258
|
+
if (!slot || typeof slot !== "object") return;
|
|
259
|
+
|
|
260
|
+
const schema = describeResponseValue(slot, 0);
|
|
261
|
+
if (!hasResponseFields(schema)) return;
|
|
262
|
+
|
|
263
|
+
reportedResponses.add(key);
|
|
264
|
+
client
|
|
265
|
+
.updateEndpoint({
|
|
266
|
+
method: method,
|
|
267
|
+
path: p,
|
|
268
|
+
requestBody: null,
|
|
269
|
+
responseBody: schema,
|
|
270
|
+
detectedBy: "runtime",
|
|
271
|
+
})
|
|
272
|
+
.catch(function () {});
|
|
273
|
+
});
|
|
274
|
+
} catch (e) {}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// For frameworks that expose the reply body before sending it (Koa, Hapi)
|
|
278
|
+
function reportFrameworkBody(
|
|
279
|
+
client,
|
|
280
|
+
method,
|
|
281
|
+
rawPath,
|
|
282
|
+
statusCode,
|
|
283
|
+
contentType,
|
|
284
|
+
body,
|
|
285
|
+
) {
|
|
286
|
+
// Deferred so it is always sent after the endpoint's first registration
|
|
287
|
+
setImmediate(function () {
|
|
288
|
+
reportFrameworkBodyNow(
|
|
289
|
+
client,
|
|
290
|
+
method,
|
|
291
|
+
rawPath,
|
|
292
|
+
statusCode,
|
|
293
|
+
contentType,
|
|
294
|
+
body,
|
|
295
|
+
);
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function reportFrameworkBodyNow(
|
|
300
|
+
client,
|
|
301
|
+
method,
|
|
302
|
+
rawPath,
|
|
303
|
+
statusCode,
|
|
304
|
+
contentType,
|
|
305
|
+
body,
|
|
306
|
+
) {
|
|
307
|
+
try {
|
|
308
|
+
if (statusCode < 200 || statusCode >= 300) return;
|
|
309
|
+
if (!isResponseShapeNeeded(method, rawPath)) return;
|
|
310
|
+
|
|
311
|
+
let text;
|
|
312
|
+
if (typeof body === "string" || Buffer.isBuffer(body)) {
|
|
313
|
+
const ct = String(contentType || "").toLowerCase();
|
|
314
|
+
if (ct && !ct.includes("json")) return;
|
|
315
|
+
text = Buffer.isBuffer(body) ? body.toString("utf8") : body;
|
|
316
|
+
} else if (
|
|
317
|
+
body &&
|
|
318
|
+
typeof body === "object" &&
|
|
319
|
+
typeof body.pipe !== "function"
|
|
320
|
+
) {
|
|
321
|
+
// Round-trip so we see exactly what is sent (handles toJSON, dates, class instances)
|
|
322
|
+
text = JSON.stringify(body);
|
|
323
|
+
} else {
|
|
324
|
+
return; // streams and empty replies cannot be read
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (!text || text.length > MAX_RESPONSE_CAPTURE_BYTES) return;
|
|
328
|
+
reportResponseShape(client, method, rawPath, JSON.parse(text));
|
|
329
|
+
} catch (e) {}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Watches one Node reply (Express and the plain http server) and, if it is a
|
|
333
|
+
// successful JSON reply, reports its shape. Never changes the reply and never
|
|
334
|
+
// throws into the host app.
|
|
335
|
+
function watchJsonResponse(res, client, method, rawPath, req) {
|
|
336
|
+
try {
|
|
337
|
+
if (
|
|
338
|
+
!res ||
|
|
339
|
+
typeof res.write !== "function" ||
|
|
340
|
+
typeof res.once !== "function"
|
|
341
|
+
)
|
|
342
|
+
return;
|
|
343
|
+
if (!isResponseShapeNeeded(method, rawPath)) return; // no overhead once captured
|
|
344
|
+
|
|
345
|
+
const chunks = [];
|
|
346
|
+
let size = 0;
|
|
347
|
+
let skip = false;
|
|
348
|
+
|
|
349
|
+
// Drops cache-validation headers so the app sends a full reply instead of an empty 304
|
|
350
|
+
// (which has no body to read). Only runs while this endpoint still has no captured shape.
|
|
351
|
+
try {
|
|
352
|
+
if (req && req.headers) {
|
|
353
|
+
delete req.headers["if-none-match"];
|
|
354
|
+
delete req.headers["if-modified-since"];
|
|
355
|
+
}
|
|
356
|
+
} catch (e) {}
|
|
357
|
+
|
|
358
|
+
function collect(chunk, encoding) {
|
|
359
|
+
if (
|
|
360
|
+
skip ||
|
|
361
|
+
chunk === undefined ||
|
|
362
|
+
chunk === null ||
|
|
363
|
+
typeof chunk === "function"
|
|
364
|
+
)
|
|
365
|
+
return;
|
|
366
|
+
try {
|
|
367
|
+
if (chunks.length === 0) {
|
|
368
|
+
const ct = String(res.getHeader("content-type") || "").toLowerCase();
|
|
369
|
+
if (ct && !ct.includes("json")) {
|
|
370
|
+
skip = true;
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
const buf =
|
|
375
|
+
typeof chunk === "string"
|
|
376
|
+
? Buffer.from(
|
|
377
|
+
chunk,
|
|
378
|
+
typeof encoding === "string" ? encoding : "utf8",
|
|
379
|
+
)
|
|
380
|
+
: Buffer.from(chunk);
|
|
381
|
+
size += buf.length;
|
|
382
|
+
if (size > MAX_RESPONSE_CAPTURE_BYTES) {
|
|
383
|
+
skip = true;
|
|
384
|
+
chunks.length = 0;
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
chunks.push(buf);
|
|
388
|
+
} catch (e) {
|
|
389
|
+
skip = true;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const originalWrite = res.write;
|
|
394
|
+
const originalEnd = res.end;
|
|
395
|
+
res.write = function (chunk, encoding) {
|
|
396
|
+
collect(chunk, encoding);
|
|
397
|
+
return originalWrite.apply(this, arguments);
|
|
398
|
+
};
|
|
399
|
+
res.end = function (chunk, encoding) {
|
|
400
|
+
collect(chunk, encoding);
|
|
401
|
+
return originalEnd.apply(this, arguments);
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
res.once("finish", function () {
|
|
405
|
+
if (res.statusCode < 200 || res.statusCode >= 300) return;
|
|
406
|
+
|
|
407
|
+
// Counts each successful reply so endpoints with no usable shape are eventually dropped
|
|
408
|
+
splitBatchPath(rawPath)
|
|
409
|
+
.map(normalizePath)
|
|
410
|
+
.forEach(function (p) {
|
|
411
|
+
const k = method + ":" + p;
|
|
412
|
+
responseAttempts[k] = (responseAttempts[k] || 0) + 1;
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
if (skip || chunks.length === 0) return;
|
|
416
|
+
|
|
417
|
+
setImmediate(function () {
|
|
418
|
+
try {
|
|
419
|
+
const parsed = parseCapturedBody(Buffer.concat(chunks), res);
|
|
420
|
+
reportResponseShape(client, method, rawPath, parsed);
|
|
421
|
+
} catch (e) {}
|
|
422
|
+
});
|
|
423
|
+
});
|
|
424
|
+
} catch (e) {}
|
|
425
|
+
}
|
|
426
|
+
|
|
29
427
|
/**
|
|
30
428
|
* Attaches a middleware to the Express app that
|
|
31
429
|
* silently intercepts every request and reports
|
|
@@ -92,34 +490,55 @@ function attachInterceptor(app, client, options) {
|
|
|
92
490
|
}
|
|
93
491
|
|
|
94
492
|
const method = req.method.toUpperCase();
|
|
95
|
-
const normalizedPath = normalizePath(path);
|
|
96
|
-
const endpointKey = method + ":" + normalizedPath;
|
|
97
|
-
|
|
98
|
-
const bodyStructure = buildBodyStructure(req.body);
|
|
99
|
-
const bodyKey =
|
|
100
|
-
endpointKey +
|
|
101
|
-
":" +
|
|
102
|
-
Object.keys(bodyStructure || {})
|
|
103
|
-
.sort()
|
|
104
|
-
.join(",");
|
|
105
|
-
|
|
106
|
-
if (!reportedEndpoints.has(bodyKey)) {
|
|
107
|
-
reportedEndpoints.add(bodyKey);
|
|
108
493
|
|
|
109
|
-
|
|
494
|
+
// Split batched tRPC paths into separate clean paths + matching body
|
|
495
|
+
// slots, so each real procedure is registered as its own endpoint.
|
|
496
|
+
const rawPaths = splitBatchPath(path);
|
|
497
|
+
const bodySlots =
|
|
498
|
+
rawPaths.length > 1 ? splitBatchBody(req.body) : [req.body];
|
|
499
|
+
|
|
500
|
+
rawPaths.forEach(function (singlePath, i) {
|
|
501
|
+
const normalizedPath = normalizePath(singlePath);
|
|
502
|
+
const endpointKey = method + ":" + normalizedPath;
|
|
503
|
+
const slotBody = bodySlots[i] || null;
|
|
504
|
+
|
|
505
|
+
// GET/DELETE inputs live in the query string, so their names are added too
|
|
506
|
+
const bodyStructure = mergeQueryIntoStructure(
|
|
507
|
+
buildBodyStructure(slotBody),
|
|
508
|
+
resolveQueryStructure(
|
|
509
|
+
endpointKey,
|
|
510
|
+
method,
|
|
511
|
+
req.originalUrl || req.url || "",
|
|
512
|
+
singlePath,
|
|
513
|
+
),
|
|
514
|
+
);
|
|
515
|
+
const bodyKey =
|
|
516
|
+
endpointKey +
|
|
517
|
+
":" +
|
|
518
|
+
Object.keys(bodyStructure || {})
|
|
519
|
+
.sort()
|
|
520
|
+
.join(",");
|
|
521
|
+
|
|
522
|
+
if (!reportedEndpoints.has(bodyKey)) {
|
|
523
|
+
reportedEndpoints.add(bodyKey);
|
|
524
|
+
|
|
525
|
+
const jsonSchema = structureToJsonSchema(bodyStructure);
|
|
526
|
+
|
|
527
|
+
setImmediate(function () {
|
|
528
|
+
client
|
|
529
|
+
.updateEndpoint({
|
|
530
|
+
method: method,
|
|
531
|
+
path: normalizedPath,
|
|
532
|
+
requestBody: jsonSchema,
|
|
533
|
+
detectedBy: "runtime",
|
|
534
|
+
})
|
|
535
|
+
.catch(function (err) {});
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
});
|
|
110
539
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
client
|
|
114
|
-
.updateEndpoint({
|
|
115
|
-
method: method,
|
|
116
|
-
path: normalizedPath,
|
|
117
|
-
requestBody: jsonSchema,
|
|
118
|
-
detectedBy: "runtime",
|
|
119
|
-
})
|
|
120
|
-
.catch(function (err) {});
|
|
121
|
-
});
|
|
122
|
-
}
|
|
540
|
+
// Capture the shape of the successful JSON reply (field names only)
|
|
541
|
+
watchJsonResponse(res, client, method, path, req);
|
|
123
542
|
|
|
124
543
|
next();
|
|
125
544
|
});
|
|
@@ -184,6 +603,74 @@ function unwrapTrpcJsonEnvelope(obj) {
|
|
|
184
603
|
return obj;
|
|
185
604
|
}
|
|
186
605
|
|
|
606
|
+
/**
|
|
607
|
+
* tRPC batches multiple procedure calls into one URL, e.g.
|
|
608
|
+
* /api/trpc/me.get,getUserTopBanners,bookingUnconfirmedCount
|
|
609
|
+
* The procedure names appear comma-separated, only in the LAST path segment.
|
|
610
|
+
* This splits that into separate, clean paths — one per real procedure —
|
|
611
|
+
* so each gets registered as its own endpoint instead of one garbled,
|
|
612
|
+
* comma-joined path. Non-batched paths are returned unchanged as a
|
|
613
|
+
* single-item array.
|
|
614
|
+
*/
|
|
615
|
+
function splitBatchPath(rawPath) {
|
|
616
|
+
// tRPC is always mounted at a fixed base containing "/trpc/" — e.g.
|
|
617
|
+
// "/api/trpc/". Everything after that marker is the comma-separated
|
|
618
|
+
// procedure list, and each comma-separated piece is ALREADY a complete
|
|
619
|
+
// procedure path in its own right (it may contain further "/" for
|
|
620
|
+
// nested routers, e.g. "me/get") — it must never have another
|
|
621
|
+
// procedure's prefix re-attached to it.
|
|
622
|
+
const trpcMarker = "/trpc/";
|
|
623
|
+
const markerIndex = rawPath.indexOf(trpcMarker);
|
|
624
|
+
if (markerIndex === -1) return [rawPath];
|
|
625
|
+
|
|
626
|
+
const base = rawPath.slice(0, markerIndex + trpcMarker.length);
|
|
627
|
+
const tail = rawPath.slice(markerIndex + trpcMarker.length);
|
|
628
|
+
|
|
629
|
+
if (!tail.includes(",")) return [rawPath];
|
|
630
|
+
|
|
631
|
+
return tail
|
|
632
|
+
.split(",")
|
|
633
|
+
.map(function (proc) {
|
|
634
|
+
return proc.trim();
|
|
635
|
+
})
|
|
636
|
+
.filter(Boolean)
|
|
637
|
+
.map(function (proc) {
|
|
638
|
+
return base + proc;
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* Splits a batched tRPC request body/input — { "0": {...}, "1": {...} } —
|
|
644
|
+
* into an array of individual bodies, in the same order as the batch keys,
|
|
645
|
+
* which lines up with the same order splitBatchPath() returns procedure
|
|
646
|
+
* names in. Each slot is unwrapped from its own { json: {...} } envelope.
|
|
647
|
+
* Returns a single-item array unchanged if the body isn't actually batched,
|
|
648
|
+
* so callers can always treat the result uniformly.
|
|
649
|
+
*/
|
|
650
|
+
function splitBatchBody(bodyObj) {
|
|
651
|
+
if (!bodyObj || typeof bodyObj !== "object") return [bodyObj || null];
|
|
652
|
+
|
|
653
|
+
const keys = Object.keys(bodyObj);
|
|
654
|
+
const isBatch =
|
|
655
|
+
keys.length > 0 &&
|
|
656
|
+
keys.every(function (k) {
|
|
657
|
+
return /^\d+$/.test(k);
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
if (!isBatch) return [bodyObj];
|
|
661
|
+
|
|
662
|
+
return keys
|
|
663
|
+
.sort(function (a, b) {
|
|
664
|
+
return Number(a) - Number(b);
|
|
665
|
+
})
|
|
666
|
+
.map(function (k) {
|
|
667
|
+
const entry = bodyObj[k];
|
|
668
|
+
return entry && typeof entry === "object"
|
|
669
|
+
? unwrapTrpcJsonEnvelope(entry)
|
|
670
|
+
: entry || null;
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
|
|
187
674
|
/**
|
|
188
675
|
* Extract just the structure of a request body
|
|
189
676
|
* (keys and value types — never actual values for security)
|
|
@@ -349,8 +836,18 @@ function attachNextJsInterceptor(client, options) {
|
|
|
349
836
|
const isApiPath = path.startsWith(options.apiPrefix || "/api");
|
|
350
837
|
|
|
351
838
|
if (!shouldIgnore && isApiPath) {
|
|
839
|
+
// Capture the shape of the successful JSON reply (field names only)
|
|
840
|
+
watchJsonResponse(res, client, method, path, req);
|
|
352
841
|
const normalizedPath = normalizePath(path);
|
|
353
|
-
const
|
|
842
|
+
const baseKey = method + ":" + normalizedPath;
|
|
843
|
+
// GET/DELETE inputs live in the query string, so their names are tracked too
|
|
844
|
+
const queryStructure = resolveQueryStructure(
|
|
845
|
+
baseKey,
|
|
846
|
+
method,
|
|
847
|
+
req.url || "",
|
|
848
|
+
path,
|
|
849
|
+
);
|
|
850
|
+
const endpointKey = baseKey + queryKeySuffix(queryStructure);
|
|
354
851
|
|
|
355
852
|
if (!reportedEndpoints.has(endpointKey)) {
|
|
356
853
|
reportedEndpoints.add(endpointKey);
|
|
@@ -372,25 +869,28 @@ function attachNextJsInterceptor(client, options) {
|
|
|
372
869
|
// Helper to send body structure to platform
|
|
373
870
|
function reportBody(bodyObj) {
|
|
374
871
|
try {
|
|
375
|
-
//
|
|
376
|
-
//
|
|
377
|
-
//
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
// 2. The URL contains an "input" query parameter
|
|
381
|
-
// 3. The path looks like a tRPC endpoint (/trpc/)
|
|
382
|
-
// This prevents accidentally treating regular REST query params
|
|
383
|
-
// (e.g. ?filter=active&limit=10) as a body schema.
|
|
872
|
+
// Split the (possibly batched) tRPC path into separate,
|
|
873
|
+
// clean paths — one per real procedure — instead of one
|
|
874
|
+
// garbled comma-joined path.
|
|
875
|
+
const splitPaths = splitBatchPath(path).map(normalizePath);
|
|
876
|
+
|
|
384
877
|
const isTrpcPath = path.includes("/trpc/");
|
|
385
878
|
const hasInputParam = req.url && req.url.includes("input=");
|
|
386
879
|
|
|
880
|
+
// ── tRPC GET input extraction ──────────────────────────────────────
|
|
881
|
+
// tRPC GET requests send their input as a URL-encoded JSON query
|
|
882
|
+
// parameter called "input". We only attempt this extraction when
|
|
883
|
+
// the body is empty, the URL has an "input" param, and the path
|
|
884
|
+
// looks like a tRPC endpoint — so regular REST query params are
|
|
885
|
+
// never mistaken for a body schema.
|
|
886
|
+
let bodySlots = null;
|
|
887
|
+
|
|
387
888
|
if (
|
|
388
889
|
isTrpcPath &&
|
|
389
890
|
hasInputParam &&
|
|
390
891
|
(!bodyObj || Object.keys(bodyObj).length === 0)
|
|
391
892
|
) {
|
|
392
893
|
try {
|
|
393
|
-
// Use URL constructor to safely parse query params
|
|
394
894
|
const urlObj = new URL(req.url, "http://localhost");
|
|
395
895
|
const inputParam = urlObj.searchParams.get("input");
|
|
396
896
|
|
|
@@ -399,7 +899,6 @@ function attachNextJsInterceptor(client, options) {
|
|
|
399
899
|
try {
|
|
400
900
|
decoded = JSON.parse(decodeURIComponent(inputParam));
|
|
401
901
|
} catch (e) {
|
|
402
|
-
// input param exists but is not valid JSON — skip
|
|
403
902
|
decoded = null;
|
|
404
903
|
}
|
|
405
904
|
|
|
@@ -408,44 +907,10 @@ function attachNextJsInterceptor(client, options) {
|
|
|
408
907
|
typeof decoded === "object" &&
|
|
409
908
|
!Array.isArray(decoded)
|
|
410
909
|
) {
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
//
|
|
414
|
-
|
|
415
|
-
const isBatch =
|
|
416
|
-
keys.length > 0 && keys.every((k) => /^\d+$/.test(k));
|
|
417
|
-
|
|
418
|
-
if (isBatch) {
|
|
419
|
-
// Merge all batch entries into one flat object
|
|
420
|
-
// so we capture the full schema across all procedures
|
|
421
|
-
const merged = {};
|
|
422
|
-
keys.forEach((k) => {
|
|
423
|
-
const entry = decoded[k];
|
|
424
|
-
if (entry && typeof entry === "object") {
|
|
425
|
-
// Unwrap { json: {...} } envelope if present
|
|
426
|
-
const unwrapped =
|
|
427
|
-
entry.json && typeof entry.json === "object"
|
|
428
|
-
? entry.json
|
|
429
|
-
: entry;
|
|
430
|
-
Object.assign(merged, unwrapped);
|
|
431
|
-
}
|
|
432
|
-
});
|
|
433
|
-
if (Object.keys(merged).length > 0) {
|
|
434
|
-
bodyObj = merged;
|
|
435
|
-
}
|
|
436
|
-
}
|
|
437
|
-
// Format 2 — single with superjson: { json: {...}, meta: {...} }
|
|
438
|
-
else if (
|
|
439
|
-
decoded.json &&
|
|
440
|
-
typeof decoded.json === "object"
|
|
441
|
-
) {
|
|
442
|
-
bodyObj = decoded.json;
|
|
443
|
-
}
|
|
444
|
-
// Format 3 — already unwrapped: { group: {...}, limit: 10 }
|
|
445
|
-
// Only use this if it doesn't look like a tRPC envelope
|
|
446
|
-
else if (!decoded.meta && !decoded.json) {
|
|
447
|
-
bodyObj = decoded;
|
|
448
|
-
}
|
|
910
|
+
// Splits batched input into one slot per procedure,
|
|
911
|
+
// in path order — never merges different procedures'
|
|
912
|
+
// fields together.
|
|
913
|
+
bodySlots = splitBatchBody(decoded);
|
|
449
914
|
}
|
|
450
915
|
}
|
|
451
916
|
} catch (e) {
|
|
@@ -453,18 +918,32 @@ function attachNextJsInterceptor(client, options) {
|
|
|
453
918
|
}
|
|
454
919
|
}
|
|
455
920
|
|
|
456
|
-
//
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
921
|
+
// Normal (non-GET-input) body — split the same way if the
|
|
922
|
+
// path turned out to be a batch.
|
|
923
|
+
if (!bodySlots) {
|
|
924
|
+
bodySlots =
|
|
925
|
+
splitPaths.length > 1 ? splitBatchBody(bodyObj) : [bodyObj];
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
// Report each real procedure as its own endpoint, with only
|
|
929
|
+
// its own fields — never another procedure's fields mixed in.
|
|
930
|
+
splitPaths.forEach(function (singlePath, i) {
|
|
931
|
+
const slotBody = bodySlots[i] || null;
|
|
932
|
+
const parsedBody = structureToJsonSchema(
|
|
933
|
+
mergeQueryIntoStructure(
|
|
934
|
+
buildBodyStructure(slotBody),
|
|
935
|
+
splitPaths.length === 1 ? queryStructure : null,
|
|
936
|
+
),
|
|
937
|
+
);
|
|
938
|
+
client
|
|
939
|
+
.updateEndpoint({
|
|
940
|
+
method: method,
|
|
941
|
+
path: singlePath,
|
|
942
|
+
requestBody: parsedBody,
|
|
943
|
+
detectedBy: "runtime",
|
|
944
|
+
})
|
|
945
|
+
.catch(function () {});
|
|
946
|
+
});
|
|
468
947
|
} catch (e) {}
|
|
469
948
|
}
|
|
470
949
|
|
|
@@ -581,7 +1060,15 @@ function attachFastifyInterceptor(fastify, client, options) {
|
|
|
581
1060
|
if (options.apiPrefix && !path.startsWith(options.apiPrefix)) return;
|
|
582
1061
|
|
|
583
1062
|
const normalizedPath = normalizePath(path);
|
|
584
|
-
const
|
|
1063
|
+
const baseKey = method + ":" + normalizedPath;
|
|
1064
|
+
// GET/DELETE inputs live in the query string, so their names are reported too
|
|
1065
|
+
const queryStructure = resolveQueryStructure(
|
|
1066
|
+
baseKey,
|
|
1067
|
+
method,
|
|
1068
|
+
request.url || "",
|
|
1069
|
+
path,
|
|
1070
|
+
);
|
|
1071
|
+
const endpointKey = baseKey + queryKeySuffix(queryStructure);
|
|
585
1072
|
|
|
586
1073
|
if (!reportedEndpoints.has(endpointKey)) {
|
|
587
1074
|
reportedEndpoints.add(endpointKey);
|
|
@@ -591,7 +1078,9 @@ function attachFastifyInterceptor(fastify, client, options) {
|
|
|
591
1078
|
.updateEndpoint({
|
|
592
1079
|
method,
|
|
593
1080
|
path: normalizedPath,
|
|
594
|
-
requestBody:
|
|
1081
|
+
requestBody: queryStructure
|
|
1082
|
+
? structureToJsonSchema(queryStructure)
|
|
1083
|
+
: null,
|
|
595
1084
|
detectedBy: "runtime-fastify",
|
|
596
1085
|
})
|
|
597
1086
|
.catch(function () {});
|
|
@@ -628,6 +1117,45 @@ function attachFastifyInterceptor(fastify, client, options) {
|
|
|
628
1117
|
.catch(function () {});
|
|
629
1118
|
});
|
|
630
1119
|
});
|
|
1120
|
+
|
|
1121
|
+
// Capture the shape of the successful JSON reply (field names only)
|
|
1122
|
+
fastify.addHook("onSend", async function (request, reply, payload) {
|
|
1123
|
+
try {
|
|
1124
|
+
const path = request.url ? request.url.split("?")[0] : "";
|
|
1125
|
+
const method = request.method ? request.method.toUpperCase() : "";
|
|
1126
|
+
|
|
1127
|
+
const shouldIgnore = ignorePaths.some(function (p) {
|
|
1128
|
+
return path.startsWith(p);
|
|
1129
|
+
});
|
|
1130
|
+
if (shouldIgnore) return payload;
|
|
1131
|
+
if (options.apiPrefix && !path.startsWith(options.apiPrefix))
|
|
1132
|
+
return payload;
|
|
1133
|
+
if (reply.statusCode < 200 || reply.statusCode >= 300) return payload;
|
|
1134
|
+
if (!isResponseShapeNeeded(method, path)) return payload;
|
|
1135
|
+
|
|
1136
|
+
const contentType = String(
|
|
1137
|
+
reply.getHeader("content-type") || "",
|
|
1138
|
+
).toLowerCase();
|
|
1139
|
+
if (contentType && !contentType.includes("json")) return payload;
|
|
1140
|
+
|
|
1141
|
+
// Only fully-built replies can be read; streams are skipped
|
|
1142
|
+
if (typeof payload !== "string" && !Buffer.isBuffer(payload))
|
|
1143
|
+
return payload;
|
|
1144
|
+
const buffer = Buffer.from(payload);
|
|
1145
|
+
if (buffer.length > MAX_RESPONSE_CAPTURE_BYTES) return payload;
|
|
1146
|
+
|
|
1147
|
+
const encoding = reply.getHeader("content-encoding");
|
|
1148
|
+
setImmediate(function () {
|
|
1149
|
+
const parsed = parseCapturedBody(buffer, {
|
|
1150
|
+
getHeader: function () {
|
|
1151
|
+
return encoding;
|
|
1152
|
+
},
|
|
1153
|
+
});
|
|
1154
|
+
reportResponseShape(client, method, path, parsed);
|
|
1155
|
+
});
|
|
1156
|
+
} catch (e) {}
|
|
1157
|
+
return payload;
|
|
1158
|
+
});
|
|
631
1159
|
}
|
|
632
1160
|
|
|
633
1161
|
/**
|
|
@@ -692,7 +1220,16 @@ function attachKoaInterceptor(app, client, options) {
|
|
|
692
1220
|
const endpointKey = method + ":" + normalizedPath;
|
|
693
1221
|
|
|
694
1222
|
const body = ctx.request.body;
|
|
695
|
-
|
|
1223
|
+
// GET/DELETE inputs live in the query string, so their names are added too
|
|
1224
|
+
const bodyStructure = mergeQueryIntoStructure(
|
|
1225
|
+
buildBodyStructure(body),
|
|
1226
|
+
resolveQueryStructure(
|
|
1227
|
+
endpointKey,
|
|
1228
|
+
method,
|
|
1229
|
+
ctx.originalUrl || ctx.url || "",
|
|
1230
|
+
path,
|
|
1231
|
+
),
|
|
1232
|
+
);
|
|
696
1233
|
const bodyKey =
|
|
697
1234
|
endpointKey +
|
|
698
1235
|
":" +
|
|
@@ -716,6 +1253,10 @@ function attachKoaInterceptor(app, client, options) {
|
|
|
716
1253
|
.catch(function () {});
|
|
717
1254
|
});
|
|
718
1255
|
}
|
|
1256
|
+
|
|
1257
|
+
// Capture the shape of the successful JSON reply (field names only).
|
|
1258
|
+
// Kept last so it is queued after the endpoint's first registration above.
|
|
1259
|
+
reportFrameworkBody(client, method, path, ctx.status, ctx.type, ctx.body);
|
|
719
1260
|
});
|
|
720
1261
|
}
|
|
721
1262
|
|
|
@@ -784,7 +1325,16 @@ function attachHapiInterceptor(server, client, options) {
|
|
|
784
1325
|
const endpointKey = method + ":" + normalizedPath;
|
|
785
1326
|
|
|
786
1327
|
const body = request.payload;
|
|
787
|
-
|
|
1328
|
+
// GET/DELETE inputs live in the query string, so their names are added too
|
|
1329
|
+
const bodyStructure = mergeQueryIntoStructure(
|
|
1330
|
+
buildBodyStructure(body),
|
|
1331
|
+
resolveQueryStructure(
|
|
1332
|
+
endpointKey,
|
|
1333
|
+
method,
|
|
1334
|
+
path + ((request.url && request.url.search) || ""),
|
|
1335
|
+
path,
|
|
1336
|
+
),
|
|
1337
|
+
);
|
|
788
1338
|
const bodyKey =
|
|
789
1339
|
endpointKey +
|
|
790
1340
|
":" +
|
|
@@ -811,6 +1361,40 @@ function attachHapiInterceptor(server, client, options) {
|
|
|
811
1361
|
|
|
812
1362
|
return h.continue;
|
|
813
1363
|
});
|
|
1364
|
+
|
|
1365
|
+
// Capture the shape of the successful JSON reply (field names only)
|
|
1366
|
+
server.ext("onPreResponse", function (request, h) {
|
|
1367
|
+
try {
|
|
1368
|
+
const path = request.path || "";
|
|
1369
|
+
const method = request.method ? request.method.toUpperCase() : "";
|
|
1370
|
+
const response = request.response;
|
|
1371
|
+
|
|
1372
|
+
const shouldIgnore = ignorePaths.some(function (p) {
|
|
1373
|
+
return path.startsWith(p);
|
|
1374
|
+
});
|
|
1375
|
+
const outsidePrefix =
|
|
1376
|
+
options.apiPrefix && !path.startsWith(options.apiPrefix);
|
|
1377
|
+
|
|
1378
|
+
// Only plain replies (not errors, views or streams) carry a readable body
|
|
1379
|
+
if (
|
|
1380
|
+
!shouldIgnore &&
|
|
1381
|
+
!outsidePrefix &&
|
|
1382
|
+
response &&
|
|
1383
|
+
!response.isBoom &&
|
|
1384
|
+
response.variety === "plain"
|
|
1385
|
+
) {
|
|
1386
|
+
reportFrameworkBody(
|
|
1387
|
+
client,
|
|
1388
|
+
method,
|
|
1389
|
+
path,
|
|
1390
|
+
response.statusCode,
|
|
1391
|
+
response.headers && response.headers["content-type"],
|
|
1392
|
+
response.source,
|
|
1393
|
+
);
|
|
1394
|
+
}
|
|
1395
|
+
} catch (e) {}
|
|
1396
|
+
return h.continue;
|
|
1397
|
+
});
|
|
814
1398
|
}
|
|
815
1399
|
|
|
816
1400
|
/**
|