@apitap/core 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (236) hide show
  1. package/LICENSE +60 -0
  2. package/README.md +362 -0
  3. package/SKILL.md +270 -0
  4. package/dist/auth/crypto.d.ts +31 -0
  5. package/dist/auth/crypto.js +66 -0
  6. package/dist/auth/crypto.js.map +1 -0
  7. package/dist/auth/handoff.d.ts +29 -0
  8. package/dist/auth/handoff.js +180 -0
  9. package/dist/auth/handoff.js.map +1 -0
  10. package/dist/auth/manager.d.ts +46 -0
  11. package/dist/auth/manager.js +127 -0
  12. package/dist/auth/manager.js.map +1 -0
  13. package/dist/auth/oauth-refresh.d.ts +16 -0
  14. package/dist/auth/oauth-refresh.js +91 -0
  15. package/dist/auth/oauth-refresh.js.map +1 -0
  16. package/dist/auth/refresh.d.ts +43 -0
  17. package/dist/auth/refresh.js +217 -0
  18. package/dist/auth/refresh.js.map +1 -0
  19. package/dist/capture/anti-bot.d.ts +15 -0
  20. package/dist/capture/anti-bot.js +43 -0
  21. package/dist/capture/anti-bot.js.map +1 -0
  22. package/dist/capture/blocklist.d.ts +6 -0
  23. package/dist/capture/blocklist.js +70 -0
  24. package/dist/capture/blocklist.js.map +1 -0
  25. package/dist/capture/body-diff.d.ts +8 -0
  26. package/dist/capture/body-diff.js +102 -0
  27. package/dist/capture/body-diff.js.map +1 -0
  28. package/dist/capture/body-variables.d.ts +13 -0
  29. package/dist/capture/body-variables.js +142 -0
  30. package/dist/capture/body-variables.js.map +1 -0
  31. package/dist/capture/domain.d.ts +8 -0
  32. package/dist/capture/domain.js +34 -0
  33. package/dist/capture/domain.js.map +1 -0
  34. package/dist/capture/entropy.d.ts +33 -0
  35. package/dist/capture/entropy.js +100 -0
  36. package/dist/capture/entropy.js.map +1 -0
  37. package/dist/capture/filter.d.ts +11 -0
  38. package/dist/capture/filter.js +49 -0
  39. package/dist/capture/filter.js.map +1 -0
  40. package/dist/capture/graphql.d.ts +21 -0
  41. package/dist/capture/graphql.js +99 -0
  42. package/dist/capture/graphql.js.map +1 -0
  43. package/dist/capture/idle.d.ts +23 -0
  44. package/dist/capture/idle.js +44 -0
  45. package/dist/capture/idle.js.map +1 -0
  46. package/dist/capture/monitor.d.ts +26 -0
  47. package/dist/capture/monitor.js +183 -0
  48. package/dist/capture/monitor.js.map +1 -0
  49. package/dist/capture/oauth-detector.d.ts +18 -0
  50. package/dist/capture/oauth-detector.js +96 -0
  51. package/dist/capture/oauth-detector.js.map +1 -0
  52. package/dist/capture/pagination.d.ts +9 -0
  53. package/dist/capture/pagination.js +40 -0
  54. package/dist/capture/pagination.js.map +1 -0
  55. package/dist/capture/parameterize.d.ts +17 -0
  56. package/dist/capture/parameterize.js +63 -0
  57. package/dist/capture/parameterize.js.map +1 -0
  58. package/dist/capture/scrubber.d.ts +5 -0
  59. package/dist/capture/scrubber.js +38 -0
  60. package/dist/capture/scrubber.js.map +1 -0
  61. package/dist/capture/session.d.ts +46 -0
  62. package/dist/capture/session.js +445 -0
  63. package/dist/capture/session.js.map +1 -0
  64. package/dist/capture/token-detector.d.ts +16 -0
  65. package/dist/capture/token-detector.js +62 -0
  66. package/dist/capture/token-detector.js.map +1 -0
  67. package/dist/capture/verifier.d.ts +17 -0
  68. package/dist/capture/verifier.js +147 -0
  69. package/dist/capture/verifier.js.map +1 -0
  70. package/dist/cli.d.ts +2 -0
  71. package/dist/cli.js +930 -0
  72. package/dist/cli.js.map +1 -0
  73. package/dist/discovery/auth.d.ts +17 -0
  74. package/dist/discovery/auth.js +81 -0
  75. package/dist/discovery/auth.js.map +1 -0
  76. package/dist/discovery/fetch.d.ts +17 -0
  77. package/dist/discovery/fetch.js +59 -0
  78. package/dist/discovery/fetch.js.map +1 -0
  79. package/dist/discovery/frameworks.d.ts +11 -0
  80. package/dist/discovery/frameworks.js +249 -0
  81. package/dist/discovery/frameworks.js.map +1 -0
  82. package/dist/discovery/index.d.ts +21 -0
  83. package/dist/discovery/index.js +219 -0
  84. package/dist/discovery/index.js.map +1 -0
  85. package/dist/discovery/openapi.d.ts +13 -0
  86. package/dist/discovery/openapi.js +175 -0
  87. package/dist/discovery/openapi.js.map +1 -0
  88. package/dist/discovery/probes.d.ts +9 -0
  89. package/dist/discovery/probes.js +70 -0
  90. package/dist/discovery/probes.js.map +1 -0
  91. package/dist/index.d.ts +25 -0
  92. package/dist/index.js +25 -0
  93. package/dist/index.js.map +1 -0
  94. package/dist/inspect/report.d.ts +52 -0
  95. package/dist/inspect/report.js +191 -0
  96. package/dist/inspect/report.js.map +1 -0
  97. package/dist/mcp.d.ts +8 -0
  98. package/dist/mcp.js +526 -0
  99. package/dist/mcp.js.map +1 -0
  100. package/dist/orchestration/browse.d.ts +38 -0
  101. package/dist/orchestration/browse.js +198 -0
  102. package/dist/orchestration/browse.js.map +1 -0
  103. package/dist/orchestration/cache.d.ts +15 -0
  104. package/dist/orchestration/cache.js +24 -0
  105. package/dist/orchestration/cache.js.map +1 -0
  106. package/dist/plugin.d.ts +17 -0
  107. package/dist/plugin.js +158 -0
  108. package/dist/plugin.js.map +1 -0
  109. package/dist/read/decoders/deepwiki.d.ts +2 -0
  110. package/dist/read/decoders/deepwiki.js +148 -0
  111. package/dist/read/decoders/deepwiki.js.map +1 -0
  112. package/dist/read/decoders/grokipedia.d.ts +2 -0
  113. package/dist/read/decoders/grokipedia.js +210 -0
  114. package/dist/read/decoders/grokipedia.js.map +1 -0
  115. package/dist/read/decoders/hackernews.d.ts +2 -0
  116. package/dist/read/decoders/hackernews.js +168 -0
  117. package/dist/read/decoders/hackernews.js.map +1 -0
  118. package/dist/read/decoders/index.d.ts +2 -0
  119. package/dist/read/decoders/index.js +12 -0
  120. package/dist/read/decoders/index.js.map +1 -0
  121. package/dist/read/decoders/reddit.d.ts +2 -0
  122. package/dist/read/decoders/reddit.js +142 -0
  123. package/dist/read/decoders/reddit.js.map +1 -0
  124. package/dist/read/decoders/twitter.d.ts +12 -0
  125. package/dist/read/decoders/twitter.js +187 -0
  126. package/dist/read/decoders/twitter.js.map +1 -0
  127. package/dist/read/decoders/wikipedia.d.ts +2 -0
  128. package/dist/read/decoders/wikipedia.js +66 -0
  129. package/dist/read/decoders/wikipedia.js.map +1 -0
  130. package/dist/read/decoders/youtube.d.ts +2 -0
  131. package/dist/read/decoders/youtube.js +69 -0
  132. package/dist/read/decoders/youtube.js.map +1 -0
  133. package/dist/read/extract.d.ts +25 -0
  134. package/dist/read/extract.js +320 -0
  135. package/dist/read/extract.js.map +1 -0
  136. package/dist/read/index.d.ts +14 -0
  137. package/dist/read/index.js +66 -0
  138. package/dist/read/index.js.map +1 -0
  139. package/dist/read/peek.d.ts +9 -0
  140. package/dist/read/peek.js +137 -0
  141. package/dist/read/peek.js.map +1 -0
  142. package/dist/read/types.d.ts +44 -0
  143. package/dist/read/types.js +3 -0
  144. package/dist/read/types.js.map +1 -0
  145. package/dist/replay/engine.d.ts +53 -0
  146. package/dist/replay/engine.js +441 -0
  147. package/dist/replay/engine.js.map +1 -0
  148. package/dist/replay/truncate.d.ts +16 -0
  149. package/dist/replay/truncate.js +92 -0
  150. package/dist/replay/truncate.js.map +1 -0
  151. package/dist/serve.d.ts +31 -0
  152. package/dist/serve.js +149 -0
  153. package/dist/serve.js.map +1 -0
  154. package/dist/skill/generator.d.ts +44 -0
  155. package/dist/skill/generator.js +419 -0
  156. package/dist/skill/generator.js.map +1 -0
  157. package/dist/skill/importer.d.ts +26 -0
  158. package/dist/skill/importer.js +80 -0
  159. package/dist/skill/importer.js.map +1 -0
  160. package/dist/skill/search.d.ts +19 -0
  161. package/dist/skill/search.js +51 -0
  162. package/dist/skill/search.js.map +1 -0
  163. package/dist/skill/signing.d.ts +16 -0
  164. package/dist/skill/signing.js +34 -0
  165. package/dist/skill/signing.js.map +1 -0
  166. package/dist/skill/ssrf.d.ts +27 -0
  167. package/dist/skill/ssrf.js +210 -0
  168. package/dist/skill/ssrf.js.map +1 -0
  169. package/dist/skill/store.d.ts +7 -0
  170. package/dist/skill/store.js +93 -0
  171. package/dist/skill/store.js.map +1 -0
  172. package/dist/stats/report.d.ts +26 -0
  173. package/dist/stats/report.js +157 -0
  174. package/dist/stats/report.js.map +1 -0
  175. package/dist/types.d.ts +214 -0
  176. package/dist/types.js +3 -0
  177. package/dist/types.js.map +1 -0
  178. package/package.json +58 -0
  179. package/src/auth/crypto.ts +92 -0
  180. package/src/auth/handoff.ts +229 -0
  181. package/src/auth/manager.ts +140 -0
  182. package/src/auth/oauth-refresh.ts +120 -0
  183. package/src/auth/refresh.ts +300 -0
  184. package/src/capture/anti-bot.ts +63 -0
  185. package/src/capture/blocklist.ts +75 -0
  186. package/src/capture/body-diff.ts +109 -0
  187. package/src/capture/body-variables.ts +156 -0
  188. package/src/capture/domain.ts +34 -0
  189. package/src/capture/entropy.ts +121 -0
  190. package/src/capture/filter.ts +56 -0
  191. package/src/capture/graphql.ts +124 -0
  192. package/src/capture/idle.ts +45 -0
  193. package/src/capture/monitor.ts +224 -0
  194. package/src/capture/oauth-detector.ts +106 -0
  195. package/src/capture/pagination.ts +49 -0
  196. package/src/capture/parameterize.ts +68 -0
  197. package/src/capture/scrubber.ts +49 -0
  198. package/src/capture/session.ts +502 -0
  199. package/src/capture/token-detector.ts +76 -0
  200. package/src/capture/verifier.ts +171 -0
  201. package/src/cli.ts +1031 -0
  202. package/src/discovery/auth.ts +99 -0
  203. package/src/discovery/fetch.ts +85 -0
  204. package/src/discovery/frameworks.ts +231 -0
  205. package/src/discovery/index.ts +256 -0
  206. package/src/discovery/openapi.ts +230 -0
  207. package/src/discovery/probes.ts +76 -0
  208. package/src/index.ts +26 -0
  209. package/src/inspect/report.ts +247 -0
  210. package/src/mcp.ts +618 -0
  211. package/src/orchestration/browse.ts +250 -0
  212. package/src/orchestration/cache.ts +37 -0
  213. package/src/plugin.ts +188 -0
  214. package/src/read/decoders/deepwiki.ts +180 -0
  215. package/src/read/decoders/grokipedia.ts +246 -0
  216. package/src/read/decoders/hackernews.ts +198 -0
  217. package/src/read/decoders/index.ts +15 -0
  218. package/src/read/decoders/reddit.ts +158 -0
  219. package/src/read/decoders/twitter.ts +211 -0
  220. package/src/read/decoders/wikipedia.ts +75 -0
  221. package/src/read/decoders/youtube.ts +75 -0
  222. package/src/read/extract.ts +396 -0
  223. package/src/read/index.ts +78 -0
  224. package/src/read/peek.ts +175 -0
  225. package/src/read/types.ts +37 -0
  226. package/src/replay/engine.ts +559 -0
  227. package/src/replay/truncate.ts +116 -0
  228. package/src/serve.ts +189 -0
  229. package/src/skill/generator.ts +473 -0
  230. package/src/skill/importer.ts +107 -0
  231. package/src/skill/search.ts +76 -0
  232. package/src/skill/signing.ts +36 -0
  233. package/src/skill/ssrf.ts +238 -0
  234. package/src/skill/store.ts +107 -0
  235. package/src/stats/report.ts +208 -0
  236. package/src/types.ts +233 -0
@@ -0,0 +1,559 @@
1
+ // src/replay/engine.ts
2
+ import type { SkillFile } from '../types.js';
3
+ import type { AuthManager } from '../auth/manager.js';
4
+ import { substituteBodyVariables } from '../capture/body-variables.js';
5
+ import { parseJwtClaims } from '../capture/entropy.js';
6
+ import { refreshTokens } from '../auth/refresh.js';
7
+ import { truncateResponse } from './truncate.js';
8
+ import { resolveAndValidateUrl } from '../skill/ssrf.js';
9
+
10
+ // Header security: prevent header injection from skill files
11
+ const ALLOWED_SKILL_HEADERS = new Set([
12
+ 'accept', 'accept-language', 'accept-encoding',
13
+ 'content-type', 'content-length',
14
+ 'x-requested-with', 'x-api-key',
15
+ 'origin', 'referer',
16
+ 'user-agent',
17
+ // Auth headers are injected separately from encrypted storage, not from skill file
18
+ ]);
19
+
20
+ const BLOCKED_HEADERS = new Set([
21
+ 'host', 'x-forwarded-for', 'x-forwarded-host', 'x-forwarded-proto',
22
+ 'x-real-ip', 'forwarded', 'via',
23
+ 'cookie', 'set-cookie',
24
+ 'authorization', // Must come from auth manager, not skill file
25
+ 'proxy-authorization',
26
+ 'transfer-encoding', 'te', 'trailer',
27
+ 'connection', 'upgrade',
28
+ ]);
29
+
30
+ export interface ReplayOptions {
31
+ /** User-provided parameters for path, query, and body substitution */
32
+ params?: Record<string, string>;
33
+ /** Auth manager for token injection (optional) */
34
+ authManager?: AuthManager;
35
+ /** Domain for auth lookups (required if authManager provided) */
36
+ domain?: string;
37
+ /** Force token refresh before replay (requires authManager) */
38
+ fresh?: boolean;
39
+ /** Maximum response size in bytes. If set, truncates large responses. */
40
+ maxBytes?: number;
41
+ /** @internal Skip SSRF check — for testing only */
42
+ _skipSsrfCheck?: boolean;
43
+ }
44
+
45
+ export interface ReplayResult {
46
+ status: number;
47
+ headers: Record<string, string>;
48
+ data: unknown;
49
+ /** Whether tokens were refreshed during this replay */
50
+ refreshed?: boolean;
51
+ /** Whether the response was truncated to fit maxBytes */
52
+ truncated?: boolean;
53
+ }
54
+
55
+ /**
56
+ * Extract default path param values from an example URL by comparing
57
+ * it to the parameterized path template.
58
+ */
59
+ function extractPathDefaults(
60
+ pathTemplate: string,
61
+ exampleUrl: string,
62
+ ): Record<string, string> {
63
+ const defaults: Record<string, string> = {};
64
+ try {
65
+ const examplePath = new URL(exampleUrl).pathname;
66
+ const templateParts = pathTemplate.split('/');
67
+ const exampleParts = examplePath.split('/');
68
+
69
+ for (let i = 0; i < templateParts.length && i < exampleParts.length; i++) {
70
+ if (templateParts[i].startsWith(':')) {
71
+ const paramName = templateParts[i].slice(1);
72
+ defaults[paramName] = exampleParts[i];
73
+ }
74
+ }
75
+ } catch {
76
+ // Invalid example URL — no defaults
77
+ }
78
+ return defaults;
79
+ }
80
+
81
+ /**
82
+ * Substitute :param placeholders in a path with values.
83
+ */
84
+ function substitutePath(
85
+ pathTemplate: string,
86
+ params: Record<string, string>,
87
+ ): string {
88
+ return pathTemplate.replace(/:([a-zA-Z_]+)/g, (match, name) => {
89
+ return params[name] ?? match;
90
+ });
91
+ }
92
+
93
+ /**
94
+ * Detect if options object is new-style ReplayOptions or legacy params.
95
+ * ReplayOptions has keys like authManager, domain, fresh, or params.
96
+ * Legacy params only have string values.
97
+ */
98
+ function normalizeOptions(
99
+ optionsOrParams?: ReplayOptions | Record<string, string>,
100
+ ): ReplayOptions {
101
+ if (!optionsOrParams) {
102
+ return {};
103
+ }
104
+
105
+ // Check for ReplayOptions signature (has known option keys or non-string values)
106
+ const hasOptionKeys =
107
+ 'authManager' in optionsOrParams ||
108
+ 'domain' in optionsOrParams ||
109
+ 'fresh' in optionsOrParams ||
110
+ 'params' in optionsOrParams ||
111
+ 'maxBytes' in optionsOrParams ||
112
+ '_skipSsrfCheck' in optionsOrParams;
113
+
114
+ if (hasOptionKeys) {
115
+ return optionsOrParams as ReplayOptions;
116
+ }
117
+
118
+ // Legacy: treat entire object as params
119
+ return { params: optionsOrParams as Record<string, string> };
120
+ }
121
+
122
+ /**
123
+ * Wrap a 401/403 response with structured auth guidance.
124
+ */
125
+ function wrapAuthError(
126
+ status: number,
127
+ originalData: unknown,
128
+ domain: string,
129
+ ): unknown {
130
+ if (status !== 401 && status !== 403) return originalData;
131
+
132
+ return {
133
+ status,
134
+ error: 'Authentication required',
135
+ suggestion: `Use apitap_auth_request to log in to ${domain}`,
136
+ domain,
137
+ originalResponse: originalData,
138
+ };
139
+ }
140
+
141
+ /**
142
+ * Replay a captured API endpoint.
143
+ *
144
+ * @param skill - Skill file containing endpoint definitions
145
+ * @param endpointId - ID of the endpoint to replay
146
+ * @param optionsOrParams - Either ReplayOptions object or params directly (for backward compat)
147
+ */
148
+ export async function replayEndpoint(
149
+ skill: SkillFile,
150
+ endpointId: string,
151
+ optionsOrParams?: ReplayOptions | Record<string, string>,
152
+ ): Promise<ReplayResult> {
153
+ // Normalize options: support both new ReplayOptions and legacy params-only
154
+ const options = normalizeOptions(optionsOrParams);
155
+ const { params = {}, authManager, domain } = options;
156
+
157
+ const endpoint = skill.endpoints.find(e => e.id === endpointId);
158
+ if (!endpoint) {
159
+ throw new Error(
160
+ `Endpoint "${endpointId}" not found in skill for ${skill.domain}. ` +
161
+ `Available: ${skill.endpoints.map(e => e.id).join(', ')}`,
162
+ );
163
+ }
164
+
165
+ // Resolve path: substitute :param placeholders
166
+ let resolvedPath = endpoint.path;
167
+ if (resolvedPath.includes(':')) {
168
+ const defaults = extractPathDefaults(endpoint.path, endpoint.examples.request.url);
169
+ const merged = { ...defaults, ...params };
170
+ resolvedPath = substitutePath(resolvedPath, merged);
171
+ }
172
+
173
+ const url = new URL(resolvedPath, skill.baseUrl);
174
+
175
+ // Apply query params: start with captured defaults, override with provided params
176
+ for (const [key, val] of Object.entries(endpoint.queryParams)) {
177
+ url.searchParams.set(key, val.example);
178
+ }
179
+ if (params) {
180
+ for (const [key, val] of Object.entries(params)) {
181
+ // Skip path params (already handled above)
182
+ if (endpoint.path.includes(`:${key}`)) continue;
183
+ // Skip body variables (they have dots in the path)
184
+ if (key.includes('.')) continue;
185
+ url.searchParams.set(key, val);
186
+ }
187
+ }
188
+
189
+ // SSRF validation — block requests to private/internal IPs and get resolved URL
190
+ let fetchUrl = url.toString();
191
+ if (!options._skipSsrfCheck) {
192
+ const ssrfCheck = await resolveAndValidateUrl(url.toString());
193
+ if (!ssrfCheck.safe) {
194
+ throw new Error(`SSRF blocked: ${ssrfCheck.reason}`);
195
+ }
196
+ // Use resolved IP to prevent DNS rebinding
197
+ if (ssrfCheck.resolvedUrl) {
198
+ fetchUrl = ssrfCheck.resolvedUrl;
199
+ }
200
+ }
201
+
202
+ // Prepare request body if present
203
+ let body: string | undefined;
204
+ const headers = { ...endpoint.headers };
205
+
206
+ // Filter headers from skill file — block dangerous headers
207
+ for (const key of Object.keys(headers)) {
208
+ const lower = key.toLowerCase();
209
+ if (BLOCKED_HEADERS.has(lower) || (!ALLOWED_SKILL_HEADERS.has(lower) && !lower.startsWith('x-'))) {
210
+ delete headers[key];
211
+ }
212
+ }
213
+
214
+ // If using DNS-pinned URL, preserve original Host header
215
+ if (fetchUrl !== url.toString()) {
216
+ headers['host'] = url.hostname;
217
+ }
218
+
219
+ // Inject auth header from auth manager (if available)
220
+ if (authManager && domain) {
221
+ const auth = await authManager.retrieve(domain);
222
+ if (auth && auth.header && auth.value) {
223
+ headers[auth.header] = auth.value;
224
+ }
225
+ }
226
+
227
+ if (endpoint.requestBody) {
228
+ let processedBody = endpoint.requestBody.template;
229
+
230
+ // Inject refreshable tokens from storage (v0.8)
231
+ if (authManager && domain && endpoint.requestBody.refreshableTokens?.length) {
232
+ const storedTokens = await authManager.retrieveTokens(domain);
233
+ if (storedTokens) {
234
+ const tokenValues: Record<string, string> = {};
235
+ for (const tokenName of endpoint.requestBody.refreshableTokens) {
236
+ if (storedTokens[tokenName]) {
237
+ tokenValues[tokenName] = storedTokens[tokenName].value;
238
+ }
239
+ }
240
+ if (Object.keys(tokenValues).length > 0) {
241
+ processedBody = substituteBodyVariables(processedBody, tokenValues);
242
+ }
243
+ }
244
+ }
245
+
246
+ // Substitute user-provided variables
247
+ if (params && endpoint.requestBody.variables) {
248
+ processedBody = substituteBodyVariables(processedBody, params);
249
+ }
250
+
251
+ // Serialize to string
252
+ if (typeof processedBody === 'object') {
253
+ body = JSON.stringify(processedBody);
254
+ } else {
255
+ body = processedBody;
256
+ }
257
+
258
+ // Ensure content-type is set
259
+ if (!headers['content-type']) {
260
+ headers['content-type'] = endpoint.requestBody.contentType;
261
+ }
262
+ }
263
+
264
+ // Proactive JWT expiry check: skip doomed request if token is expired
265
+ const fresh = options.fresh ?? false;
266
+ let refreshed = false;
267
+
268
+ if (authManager && domain) {
269
+ if (fresh) {
270
+ // --fresh flag: force refresh before replay
271
+ const refreshResult = await refreshTokens(skill, authManager, { domain, _skipSsrfCheck: options._skipSsrfCheck });
272
+ if (refreshResult.success) {
273
+ refreshed = true;
274
+ // Re-inject fresh auth header
275
+ const freshAuth = await authManager.retrieve(domain);
276
+ if (freshAuth) {
277
+ headers[freshAuth.header] = freshAuth.value;
278
+ }
279
+ }
280
+ } else {
281
+ // Proactive: check if JWT is expired (30s buffer for clock skew)
282
+ const currentAuth = await authManager.retrieve(domain);
283
+ if (currentAuth?.value) {
284
+ const raw = currentAuth.value.startsWith('Bearer ')
285
+ ? currentAuth.value.slice(7)
286
+ : currentAuth.value;
287
+ const jwt = parseJwtClaims(raw);
288
+ if (jwt?.exp && jwt.exp < Math.floor(Date.now() / 1000) + 30) {
289
+ const refreshResult = await refreshTokens(skill, authManager, { domain, _skipSsrfCheck: options._skipSsrfCheck });
290
+ if (refreshResult.success) {
291
+ refreshed = true;
292
+ const freshAuth = await authManager.retrieve(domain);
293
+ if (freshAuth) {
294
+ headers[freshAuth.header] = freshAuth.value;
295
+ }
296
+ }
297
+ }
298
+ }
299
+ }
300
+ }
301
+
302
+ let response = await fetch(fetchUrl, {
303
+ method: endpoint.method,
304
+ headers,
305
+ body,
306
+ signal: AbortSignal.timeout(30_000),
307
+ redirect: 'manual', // Don't auto-follow redirects
308
+ });
309
+
310
+ // Handle redirects with SSRF validation (single hop only)
311
+ if (response.status >= 300 && response.status < 400) {
312
+ const location = response.headers.get('location');
313
+ if (location) {
314
+ const redirectUrl = new URL(location, url);
315
+ let redirectFetchUrl = redirectUrl.toString();
316
+ if (!options._skipSsrfCheck) {
317
+ const redirectCheck = await resolveAndValidateUrl(redirectUrl.toString());
318
+ if (!redirectCheck.safe) {
319
+ throw new Error(`Redirect blocked (SSRF): ${redirectCheck.reason}`);
320
+ }
321
+ if (redirectCheck.resolvedUrl) {
322
+ redirectFetchUrl = redirectCheck.resolvedUrl;
323
+ headers['host'] = redirectUrl.hostname;
324
+ }
325
+ }
326
+ // Follow the redirect manually (single hop to prevent chains)
327
+ response = await fetch(redirectFetchUrl, {
328
+ method: 'GET', // Redirects typically become GET
329
+ headers, // Forward headers (already filtered)
330
+ signal: AbortSignal.timeout(30_000),
331
+ redirect: 'manual', // Prevent chaining
332
+ });
333
+ }
334
+ }
335
+
336
+ // Reactive: retry on 401/403 if we haven't already refreshed
337
+ if (
338
+ (response.status === 401 || response.status === 403) &&
339
+ !refreshed &&
340
+ authManager &&
341
+ domain
342
+ ) {
343
+ const refreshResult = await refreshTokens(skill, authManager, { domain, _skipSsrfCheck: options._skipSsrfCheck });
344
+ if (refreshResult.success) {
345
+ refreshed = true;
346
+ // Re-inject fresh auth
347
+ const freshAuth = await authManager.retrieve(domain);
348
+ if (freshAuth) {
349
+ headers[freshAuth.header] = freshAuth.value;
350
+ }
351
+
352
+ // Retry the request
353
+ let retryResponse = await fetch(fetchUrl, {
354
+ method: endpoint.method,
355
+ headers,
356
+ body,
357
+ signal: AbortSignal.timeout(30_000),
358
+ redirect: 'manual',
359
+ });
360
+
361
+ // Handle redirects on retry (single hop)
362
+ if (retryResponse.status >= 300 && retryResponse.status < 400) {
363
+ const location = retryResponse.headers.get('location');
364
+ if (location) {
365
+ const redirectUrl = new URL(location, url);
366
+ let retryRedirectFetchUrl = redirectUrl.toString();
367
+ if (!options._skipSsrfCheck) {
368
+ const redirectCheck = await resolveAndValidateUrl(redirectUrl.toString());
369
+ if (!redirectCheck.safe) {
370
+ throw new Error(`Redirect blocked (SSRF): ${redirectCheck.reason}`);
371
+ }
372
+ if (redirectCheck.resolvedUrl) {
373
+ retryRedirectFetchUrl = redirectCheck.resolvedUrl;
374
+ headers['host'] = redirectUrl.hostname;
375
+ }
376
+ }
377
+ retryResponse = await fetch(retryRedirectFetchUrl, {
378
+ method: 'GET',
379
+ headers,
380
+ signal: AbortSignal.timeout(30_000),
381
+ redirect: 'manual',
382
+ });
383
+ }
384
+ }
385
+
386
+ const retryHeaders: Record<string, string> = {};
387
+ retryResponse.headers.forEach((value, key) => {
388
+ retryHeaders[key] = value;
389
+ });
390
+
391
+ let retryData: unknown;
392
+ const retryCt = retryResponse.headers.get('content-type') ?? '';
393
+ const retryText = await retryResponse.text();
394
+ if (retryCt.includes('json') && retryText.length > 0) {
395
+ retryData = JSON.parse(retryText);
396
+ } else {
397
+ retryData = retryText;
398
+ }
399
+
400
+ const retryFinalData = (retryResponse.status === 401 || retryResponse.status === 403)
401
+ ? wrapAuthError(retryResponse.status, retryData, skill.domain)
402
+ : retryData;
403
+
404
+ if (options.maxBytes) {
405
+ const truncated = truncateResponse(retryFinalData, { maxBytes: options.maxBytes });
406
+ return {
407
+ status: retryResponse.status,
408
+ headers: retryHeaders,
409
+ data: truncated.data,
410
+ refreshed,
411
+ ...(truncated.truncated ? { truncated: true } : {}),
412
+ };
413
+ }
414
+
415
+ return {
416
+ status: retryResponse.status,
417
+ headers: retryHeaders,
418
+ data: retryFinalData,
419
+ refreshed,
420
+ };
421
+ }
422
+ }
423
+
424
+ const responseHeaders: Record<string, string> = {};
425
+ response.headers.forEach((value, key) => {
426
+ responseHeaders[key] = value;
427
+ });
428
+
429
+ let data: unknown;
430
+ const ct = response.headers.get('content-type') ?? '';
431
+ const text = await response.text();
432
+ if (ct.includes('json') && text.length > 0) {
433
+ data = JSON.parse(text);
434
+ } else {
435
+ data = text;
436
+ }
437
+
438
+ const finalData = (response.status === 401 || response.status === 403)
439
+ ? wrapAuthError(response.status, data, skill.domain)
440
+ : data;
441
+
442
+ // Apply truncation if maxBytes is set
443
+ if (options.maxBytes) {
444
+ const truncated = truncateResponse(finalData, { maxBytes: options.maxBytes });
445
+ return {
446
+ status: response.status,
447
+ headers: responseHeaders,
448
+ data: truncated.data,
449
+ ...(refreshed ? { refreshed } : {}),
450
+ ...(truncated.truncated ? { truncated: true } : {}),
451
+ };
452
+ }
453
+
454
+ return { status: response.status, headers: responseHeaders, data: finalData, ...(refreshed ? { refreshed } : {}) };
455
+ }
456
+
457
+ // --- Batch replay ---
458
+
459
+ export interface BatchReplayRequest {
460
+ domain: string;
461
+ endpointId: string;
462
+ params?: Record<string, string>;
463
+ }
464
+
465
+ export interface BatchReplayResult {
466
+ domain: string;
467
+ endpointId: string;
468
+ status: number;
469
+ data: unknown;
470
+ error?: string;
471
+ tier?: string;
472
+ capturedAt?: string;
473
+ truncated?: boolean;
474
+ }
475
+
476
+ export async function replayMultiple(
477
+ requests: BatchReplayRequest[],
478
+ options: { skillsDir?: string; maxBytes?: number; _skipSsrfCheck?: boolean } = {},
479
+ ): Promise<BatchReplayResult[]> {
480
+ if (requests.length === 0) return [];
481
+
482
+ const { readSkillFile } = await import('../skill/store.js');
483
+ const { AuthManager, getMachineId } = await import('../auth/manager.js');
484
+
485
+ // Deduplicate skill file reads
486
+ const skillCache = new Map<string, SkillFile | null>();
487
+ const uniqueDomains = [...new Set(requests.map(r => r.domain))];
488
+ await Promise.all(uniqueDomains.map(async (domain) => {
489
+ const skill = await readSkillFile(domain, options.skillsDir);
490
+ skillCache.set(domain, skill);
491
+ }));
492
+
493
+ // Shared auth manager
494
+ const machineId = await getMachineId();
495
+ const authManager = new AuthManager(
496
+ (await import('node:os')).homedir() + '/.apitap',
497
+ machineId,
498
+ );
499
+
500
+ // Replay all in parallel
501
+ const settled = await Promise.allSettled(
502
+ requests.map(async (req): Promise<BatchReplayResult> => {
503
+ const skill = skillCache.get(req.domain);
504
+ if (!skill) {
505
+ return {
506
+ domain: req.domain,
507
+ endpointId: req.endpointId,
508
+ status: 0,
509
+ data: null,
510
+ error: `No skill file found for "${req.domain}"`,
511
+ };
512
+ }
513
+
514
+ const endpoint = skill.endpoints.find(e => e.id === req.endpointId);
515
+ const tier = endpoint?.replayability?.tier ?? 'unknown';
516
+
517
+ try {
518
+ const result = await replayEndpoint(skill, req.endpointId, {
519
+ params: req.params,
520
+ authManager,
521
+ domain: req.domain,
522
+ maxBytes: options.maxBytes,
523
+ _skipSsrfCheck: options._skipSsrfCheck,
524
+ });
525
+ return {
526
+ domain: req.domain,
527
+ endpointId: req.endpointId,
528
+ status: result.status,
529
+ data: result.data,
530
+ tier,
531
+ capturedAt: skill.capturedAt,
532
+ ...(result.truncated ? { truncated: true } : {}),
533
+ };
534
+ } catch (err: any) {
535
+ return {
536
+ domain: req.domain,
537
+ endpointId: req.endpointId,
538
+ status: 0,
539
+ data: null,
540
+ error: err.message,
541
+ tier,
542
+ capturedAt: skill.capturedAt,
543
+ };
544
+ }
545
+ }),
546
+ );
547
+
548
+ return settled.map((s) =>
549
+ s.status === 'fulfilled'
550
+ ? s.value
551
+ : {
552
+ domain: '',
553
+ endpointId: '',
554
+ status: 0,
555
+ data: null,
556
+ error: s.reason?.message ?? 'Unknown error',
557
+ },
558
+ );
559
+ }
@@ -0,0 +1,116 @@
1
+ // src/replay/truncate.ts
2
+
3
+ export interface TruncateOptions {
4
+ maxBytes?: number; // default 50000 (50KB)
5
+ }
6
+
7
+ export interface TruncateResult {
8
+ data: unknown;
9
+ truncated: boolean;
10
+ }
11
+
12
+ const DEFAULT_MAX_BYTES = 50_000;
13
+ const STRING_CAP = 500;
14
+
15
+ function byteLength(s: string): number {
16
+ return Buffer.byteLength(s, 'utf-8');
17
+ }
18
+
19
+ /**
20
+ * Truncate long string fields in an object, largest-first, until
21
+ * the serialized size is under maxBytes.
22
+ */
23
+ function truncateObjectStrings(obj: Record<string, unknown>, maxBytes: number): Record<string, unknown> {
24
+ const result = { ...obj };
25
+
26
+ // Collect string fields with their lengths
27
+ const stringFields: { key: string; len: number }[] = [];
28
+ for (const [key, val] of Object.entries(result)) {
29
+ if (typeof val === 'string' && val.length > STRING_CAP) {
30
+ stringFields.push({ key, len: val.length });
31
+ }
32
+ }
33
+
34
+ // Sort largest first
35
+ stringFields.sort((a, b) => b.len - a.len);
36
+
37
+ for (const { key } of stringFields) {
38
+ const val = result[key] as string;
39
+ result[key] = val.slice(0, STRING_CAP) + '... [truncated]';
40
+ if (byteLength(JSON.stringify(result)) <= maxBytes) break;
41
+ }
42
+
43
+ return result;
44
+ }
45
+
46
+ /**
47
+ * Truncate a response to fit within maxBytes when serialized as JSON.
48
+ *
49
+ * - Arrays: remove items from the end until it fits. If a single item
50
+ * exceeds the limit, truncate long string fields within that item.
51
+ * - Objects: truncate long string fields largest-first.
52
+ * - Primitives/strings: returned as-is (or sliced if string).
53
+ */
54
+ export function truncateResponse(data: unknown, options?: TruncateOptions): TruncateResult {
55
+ const maxBytes = options?.maxBytes ?? DEFAULT_MAX_BYTES;
56
+
57
+ if (data === null || data === undefined) {
58
+ return { data, truncated: false };
59
+ }
60
+
61
+ const serialized = JSON.stringify(data);
62
+ if (byteLength(serialized) <= maxBytes) {
63
+ return { data, truncated: false };
64
+ }
65
+
66
+ // Array truncation
67
+ if (Array.isArray(data)) {
68
+ const arr = [...data];
69
+
70
+ // Remove items from the end until it fits
71
+ while (arr.length > 1 && byteLength(JSON.stringify(arr)) > maxBytes) {
72
+ arr.pop();
73
+ }
74
+
75
+ // If single item still exceeds limit, truncate strings within it
76
+ if (arr.length === 1 && byteLength(JSON.stringify(arr)) > maxBytes) {
77
+ const item = arr[0];
78
+ if (item && typeof item === 'object' && !Array.isArray(item)) {
79
+ arr[0] = truncateObjectStrings(item as Record<string, unknown>, maxBytes);
80
+ }
81
+ }
82
+
83
+ // If still over (e.g. array of primitives), return empty array
84
+ if (arr.length === 1 && byteLength(JSON.stringify(arr)) > maxBytes) {
85
+ return { data: [], truncated: true };
86
+ }
87
+
88
+ return { data: arr, truncated: true };
89
+ }
90
+
91
+ // Object truncation
92
+ if (typeof data === 'object') {
93
+ const result = truncateObjectStrings(data as Record<string, unknown>, maxBytes);
94
+ return { data: result, truncated: true };
95
+ }
96
+
97
+ // String truncation as last resort
98
+ if (typeof data === 'string') {
99
+ // Binary search for the right length
100
+ let lo = 0;
101
+ let hi = data.length;
102
+ const suffix = '... [truncated]';
103
+ while (lo < hi) {
104
+ const mid = (lo + hi + 1) >> 1;
105
+ if (byteLength(JSON.stringify(data.slice(0, mid) + suffix)) <= maxBytes) {
106
+ lo = mid;
107
+ } else {
108
+ hi = mid - 1;
109
+ }
110
+ }
111
+ return { data: data.slice(0, lo) + suffix, truncated: true };
112
+ }
113
+
114
+ // Numbers, booleans — can't truncate further
115
+ return { data, truncated: false };
116
+ }