@apitap/core 1.6.0 → 1.6.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.
@@ -45,6 +45,15 @@ const BLOCKED_REPLAY_HEADERS = new Set([
45
45
  'sec-fetch-user',
46
46
  ]);
47
47
 
48
+ export function safeParseJson(text: string): unknown {
49
+ if (text.length === 0) return text;
50
+ try {
51
+ return JSON.parse(text);
52
+ } catch {
53
+ return text;
54
+ }
55
+ }
56
+
48
57
  export interface ReplayOptions {
49
58
  /** User-provided parameters for path, query, and body substitution */
50
59
  params?: Record<string, string>;
@@ -70,6 +79,8 @@ export interface ReplayResult {
70
79
  truncated?: boolean;
71
80
  /** Contract warnings from schema drift detection */
72
81
  contractWarnings?: ContractWarning[];
82
+ /** Upgrade hint: set when a low-confidence endpoint gets a 2xx response */
83
+ upgrade?: { confidence: 1.0; endpointProvenance: 'captured' };
73
84
  }
74
85
 
75
86
  /**
@@ -548,8 +559,8 @@ export async function replayEndpoint(
548
559
  let retryData: unknown;
549
560
  const retryCt = retryResponse.headers.get('content-type') ?? '';
550
561
  const retryText = await retryResponse.text();
551
- if (retryCt.includes('json') && retryText.length > 0) {
552
- retryData = JSON.parse(retryText);
562
+ if (retryCt.includes('json')) {
563
+ retryData = safeParseJson(retryText);
553
564
  } else {
554
565
  retryData = retryText;
555
566
  }
@@ -558,6 +569,11 @@ export async function replayEndpoint(
558
569
  ? wrapAuthError(retryResponse.status, retryData, skill.domain)
559
570
  : retryData;
560
571
 
572
+ const retryUpgrade = (endpoint.confidence !== undefined && endpoint.confidence < 1.0
573
+ && retryResponse.status >= 200 && retryResponse.status < 300)
574
+ ? { confidence: 1.0 as const, endpointProvenance: 'captured' as const }
575
+ : undefined;
576
+
561
577
  if (options.maxBytes) {
562
578
  const truncated = truncateResponse(retryFinalData, { maxBytes: options.maxBytes });
563
579
  return {
@@ -566,6 +582,7 @@ export async function replayEndpoint(
566
582
  data: truncated.data,
567
583
  refreshed,
568
584
  ...(truncated.truncated ? { truncated: true } : {}),
585
+ ...(retryUpgrade ? { upgrade: retryUpgrade } : {}),
569
586
  };
570
587
  }
571
588
 
@@ -574,6 +591,7 @@ export async function replayEndpoint(
574
591
  headers: retryHeaders,
575
592
  data: retryFinalData,
576
593
  refreshed,
594
+ ...(retryUpgrade ? { upgrade: retryUpgrade } : {}),
577
595
  };
578
596
  }
579
597
  }
@@ -586,8 +604,8 @@ export async function replayEndpoint(
586
604
  let data: unknown;
587
605
  const ct = response.headers.get('content-type') ?? '';
588
606
  const text = await response.text();
589
- if (ct.includes('json') && text.length > 0) {
590
- data = JSON.parse(text);
607
+ if (ct.includes('json')) {
608
+ data = safeParseJson(text);
591
609
  } else {
592
610
  data = text;
593
611
  }
@@ -606,6 +624,11 @@ export async function replayEndpoint(
606
624
  }
607
625
  }
608
626
 
627
+ const upgrade = (endpoint.confidence !== undefined && endpoint.confidence < 1.0
628
+ && response.status >= 200 && response.status < 300)
629
+ ? { confidence: 1.0 as const, endpointProvenance: 'captured' as const }
630
+ : undefined;
631
+
609
632
  // Apply truncation if maxBytes is set
610
633
  if (options.maxBytes) {
611
634
  const truncated = truncateResponse(finalData, { maxBytes: options.maxBytes });
@@ -616,10 +639,11 @@ export async function replayEndpoint(
616
639
  ...(refreshed ? { refreshed } : {}),
617
640
  ...(truncated.truncated ? { truncated: true } : {}),
618
641
  ...(contractWarnings ? { contractWarnings } : {}),
642
+ ...(upgrade ? { upgrade } : {}),
619
643
  };
620
644
  }
621
645
 
622
- return { status: response.status, headers: responseHeaders, data: finalData, ...(refreshed ? { refreshed } : {}), ...(contractWarnings ? { contractWarnings } : {}) };
646
+ return { status: response.status, headers: responseHeaders, data: finalData, ...(refreshed ? { refreshed } : {}), ...(contractWarnings ? { contractWarnings } : {}), ...(upgrade ? { upgrade } : {}) };
623
647
  }
624
648
 
625
649
  // --- Batch replay ---
@@ -1,6 +1,29 @@
1
1
  // src/skill/apis-guru.ts
2
2
  import { resolveAndValidateUrl } from '../skill/ssrf.js';
3
3
 
4
+ const MAX_SPEC_SIZE = 10 * 1024 * 1024; // 10 MB per spec
5
+ const MAX_LIST_SIZE = 100 * 1024 * 1024; // 100 MB for APIs.guru list
6
+
7
+ async function fetchWithSizeLimit(url: string, maxBytes: number, options?: RequestInit): Promise<string> {
8
+ const response = await fetch(url, {
9
+ signal: AbortSignal.timeout(30_000),
10
+ ...options,
11
+ headers: { 'User-Agent': 'apitap-import/1.0', ...(options?.headers as Record<string, string> || {}) },
12
+ });
13
+ if (!response.ok) {
14
+ throw new Error(`HTTP ${response.status} ${response.statusText} for ${url}`);
15
+ }
16
+ const contentLength = response.headers.get('content-length');
17
+ if (contentLength && parseInt(contentLength, 10) > maxBytes) {
18
+ throw new Error(`Response too large: ${contentLength} bytes (limit: ${maxBytes})`);
19
+ }
20
+ const text = await response.text();
21
+ if (text.length > maxBytes) {
22
+ throw new Error(`Response body too large: ${text.length} bytes (limit: ${maxBytes})`);
23
+ }
24
+ return text;
25
+ }
26
+
4
27
  export interface ApisGuruEntry {
5
28
  apiId: string; // e.g., "twilio.com:api"
6
29
  providerName: string; // e.g., "twilio.com"
@@ -121,15 +144,7 @@ export async function fetchApisGuruList(): Promise<ApisGuruEntry[]> {
121
144
  if (!ssrf.safe) {
122
145
  throw new Error(`SSRF check failed for APIs.guru list URL: ${ssrf.reason}`);
123
146
  }
124
- const response = await fetch(APIS_GURU_LIST_URL, {
125
- signal: AbortSignal.timeout(30_000),
126
- });
127
- if (!response.ok) {
128
- throw new Error(
129
- `Failed to fetch APIs.guru list: ${response.status} ${response.statusText}`,
130
- );
131
- }
132
- const text = await response.text();
147
+ const text = await fetchWithSizeLimit(APIS_GURU_LIST_URL, MAX_LIST_SIZE);
133
148
  try {
134
149
  return parseApisGuruList(JSON.parse(text) as Record<string, any>);
135
150
  } catch {
@@ -145,16 +160,7 @@ export async function fetchSpec(specUrl: string): Promise<Record<string, any>> {
145
160
  if (!ssrf.safe) {
146
161
  throw new Error(`SSRF check failed for spec URL ${specUrl}: ${ssrf.reason}`);
147
162
  }
148
- const response = await fetch(specUrl, {
149
- signal: AbortSignal.timeout(30_000),
150
- redirect: 'error',
151
- });
152
- if (!response.ok) {
153
- throw new Error(
154
- `Failed to fetch spec at ${specUrl}: ${response.status} ${response.statusText}`,
155
- );
156
- }
157
- const text = await response.text();
163
+ const text = await fetchWithSizeLimit(specUrl, MAX_SPEC_SIZE, { redirect: 'error' });
158
164
  try {
159
165
  return JSON.parse(text) as Record<string, any>;
160
166
  } catch {
@@ -31,6 +31,14 @@ export function resolveRef(
31
31
  return merged;
32
32
  }
33
33
 
34
+ // Handle oneOf/anyOf: use first option as representative
35
+ if (obj.oneOf && Array.isArray(obj.oneOf) && obj.oneOf.length > 0) {
36
+ return resolveRef(obj.oneOf[0], spec, new Set(visited), depth + 1);
37
+ }
38
+ if (obj.anyOf && Array.isArray(obj.anyOf) && obj.anyOf.length > 0) {
39
+ return resolveRef(obj.anyOf[0], spec, new Set(visited), depth + 1);
40
+ }
41
+
34
42
  if (!obj.$ref) return obj;
35
43
 
36
44
  const ref = obj.$ref as string;
@@ -44,6 +44,20 @@ export function signSkillFile(skill: SkillFile, key: Buffer): SkillFile {
44
44
  };
45
45
  }
46
46
 
47
+ /**
48
+ * Sign a skill file with a specific provenance value.
49
+ * Use 'self' for captured files, 'imported-signed' for import-only files.
50
+ */
51
+ export function signSkillFileAs(
52
+ skill: SkillFile,
53
+ key: Buffer,
54
+ provenance: 'self' | 'imported-signed',
55
+ ): SkillFile {
56
+ const payload = canonicalize(skill);
57
+ const signature = hmacSign(payload, key);
58
+ return { ...skill, provenance, signature };
59
+ }
60
+
47
61
  /**
48
62
  * Verify a skill file's signature.
49
63
  * Returns true if the signature is valid for the given key.
@@ -1,5 +1,5 @@
1
1
  // src/skill/store.ts
2
- import { readFile, writeFile, mkdir, readdir, access } from 'node:fs/promises';
2
+ import { readFile, writeFile, mkdir, readdir, access, rename } from 'node:fs/promises';
3
3
  import { join, dirname } from 'node:path';
4
4
  import { homedir } from 'node:os';
5
5
  import type { SkillFile, SkillSummary } from '../types.js';
@@ -40,7 +40,10 @@ export async function writeSkillFile(
40
40
  await mkdir(skillsDir, { recursive: true, mode: 0o700 });
41
41
  await ensureGitignore(skillsDir);
42
42
  const filePath = skillPath(skill.domain, skillsDir);
43
- await writeFile(filePath, JSON.stringify(skill, null, 2) + '\n', { mode: 0o600 });
43
+ const tmpPath = `${filePath}.${process.pid}.tmp`;
44
+ const content = JSON.stringify(skill, null, 2) + '\n';
45
+ await writeFile(tmpPath, content, { mode: 0o600 });
46
+ await rename(tmpPath, filePath);
44
47
  return filePath;
45
48
  }
46
49
 
@@ -114,8 +117,9 @@ export async function listSkillFiles(
114
117
  let files: string[];
115
118
  try {
116
119
  files = await readdir(skillsDir);
117
- } catch {
118
- return [];
120
+ } catch (err: unknown) {
121
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') return [];
122
+ throw err;
119
123
  }
120
124
 
121
125
  const summaries: SkillSummary[] = [];
package/src/types.ts CHANGED
@@ -162,7 +162,7 @@ export interface SkillFile {
162
162
  endpointsEnriched: number;
163
163
  }>;
164
164
  };
165
- provenance: 'self' | 'imported' | 'unsigned';
165
+ provenance: 'self' | 'imported' | 'imported-signed' | 'unsigned';
166
166
  signature?: string;
167
167
  auth?: SkillAuth; // v0.8: top-level auth config
168
168
  }
@@ -215,7 +215,7 @@ export interface SkillSummary {
215
215
  skillFile: string;
216
216
  endpointCount: number;
217
217
  capturedAt: string;
218
- provenance: 'self' | 'imported' | 'unsigned';
218
+ provenance: 'self' | 'imported' | 'imported-signed' | 'unsigned';
219
219
  }
220
220
 
221
221
  // --- Discovery types (Milestone 2: Smart Discovery) ---