@apitap/core 1.4.1 → 1.4.3

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/src/cli.ts CHANGED
@@ -17,11 +17,13 @@ import { buildInspectReport, formatInspectHuman } from './inspect/report.js';
17
17
  import { generateStatsReport, formatStatsHuman } from './stats/report.js';
18
18
  import { detectAntiBot, type AntiBotSignal } from './capture/anti-bot.js';
19
19
  import { discover } from './discovery/index.js';
20
+ import { readIndexEntry } from './index/reader.js';
20
21
  import { peek } from './read/peek.js';
21
22
  import { read } from './read/index.js';
22
23
  import { homedir } from 'node:os';
23
24
  import { join, resolve, dirname } from 'node:path';
24
25
  import { readFileSync } from 'node:fs';
26
+ import { stat, unlink } from 'node:fs/promises';
25
27
  import { fileURLToPath } from 'node:url';
26
28
  import { createMcpServer } from './mcp.js';
27
29
 
@@ -79,6 +81,8 @@ function printUsage(): void {
79
81
  apitap browse <url> Browse a URL (discover + replay in one step)
80
82
  apitap peek <url> Zero-cost triage (HEAD only)
81
83
  apitap read <url> Extract content without a browser
84
+ apitap audit Audit stored skill files and credentials
85
+ apitap forget <domain> Remove skill file and credentials for a domain
82
86
  apitap stats Show token savings report
83
87
  apitap extension install Register native messaging host for Chrome
84
88
 
@@ -847,8 +851,17 @@ async function handleDiscover(positional: string[], flags: Record<string, string
847
851
 
848
852
  const result = await discover(url);
849
853
 
854
+ // Look up passive index data for this domain
855
+ let domain: string;
856
+ try {
857
+ domain = new URL(fullDiscoverUrl).hostname;
858
+ } catch {
859
+ domain = url;
860
+ }
861
+ const indexEntry = await readIndexEntry(domain);
862
+
850
863
  if (json) {
851
- console.log(JSON.stringify(result, null, 2));
864
+ console.log(JSON.stringify({ ...result, indexEntry: indexEntry ?? undefined }, null, 2));
852
865
  } else {
853
866
  // Confidence summary
854
867
  const confidenceLabels: Record<string, string> = {
@@ -895,6 +908,24 @@ async function handleDiscover(positional: string[], flags: Record<string, string
895
908
  console.log(`\n Skill file: ${result.skillFile.endpoints.length} endpoints predicted`);
896
909
  }
897
910
 
911
+ if (indexEntry) {
912
+ console.log(`\n Passive Index (from browser extension):`);
913
+ console.log(` Domain: ${indexEntry.domain}`);
914
+ console.log(` Total hits: ${indexEntry.totalHits}`);
915
+ console.log(` Endpoints: ${indexEntry.endpoints.length}`);
916
+ console.log(` Promoted: ${indexEntry.promoted ? 'yes' : 'no'}`);
917
+ if (indexEntry.endpoints.length > 0) {
918
+ console.log(` Observed endpoints:`);
919
+ for (const ep of indexEntry.endpoints) {
920
+ const methods = ep.methods.join(', ');
921
+ const auth = ep.authType ? ` [${ep.authType}]` : '';
922
+ const pagination = ep.pagination ? ` (${ep.pagination})` : '';
923
+ const gql = ep.type === 'graphql' ? ' [GraphQL]' : '';
924
+ console.log(` ${methods} ${ep.path} \u2014 ${ep.hits} hits${auth}${pagination}${gql}`);
925
+ }
926
+ }
927
+ }
928
+
898
929
  if (result.confidence === 'none') {
899
930
  console.log(`\n Recommendation: Run \`apitap capture ${url}\` for browser-based discovery`);
900
931
  }
@@ -1050,6 +1081,112 @@ async function handleRead(positional: string[], flags: Record<string, string | b
1050
1081
  console.log();
1051
1082
  }
1052
1083
 
1084
+ async function handleAudit(flags: Record<string, string | boolean>): Promise<void> {
1085
+ const skillsDir = SKILLS_DIR || join(APITAP_DIR, 'skills');
1086
+ const summaries = await listSkillFiles(skillsDir);
1087
+ const machineId = await getEffectiveMachineId();
1088
+ const authManager = new AuthManager(APITAP_DIR, machineId);
1089
+ const authDomains = new Set(await authManager.listDomains());
1090
+
1091
+ // Get skill file last-modified dates
1092
+ const rows: { domain: string; endpoints: number; auth: string; modified: string }[] = [];
1093
+ for (const s of summaries) {
1094
+ let modified = 'unknown';
1095
+ try {
1096
+ const st = await stat(s.skillFile);
1097
+ modified = st.mtime.toISOString().slice(0, 10);
1098
+ } catch {}
1099
+ rows.push({
1100
+ domain: s.domain,
1101
+ endpoints: s.endpointCount,
1102
+ auth: authDomains.has(s.domain) ? 'yes' : 'no',
1103
+ modified,
1104
+ });
1105
+ }
1106
+
1107
+ // Get auth.enc last-modified
1108
+ let authModified = 'none';
1109
+ try {
1110
+ const authStat = await stat(join(APITAP_DIR, 'auth.enc'));
1111
+ authModified = authStat.mtime.toISOString().slice(0, 10);
1112
+ } catch {}
1113
+
1114
+ if (flags.json === true) {
1115
+ console.log(JSON.stringify({ domains: rows, authFileModified: authModified }, null, 2));
1116
+ return;
1117
+ }
1118
+
1119
+ if (rows.length === 0) {
1120
+ console.log('\n No skill files found.\n');
1121
+ return;
1122
+ }
1123
+
1124
+ // Table header
1125
+ const domW = Math.max(6, ...rows.map(r => r.domain.length));
1126
+ console.log();
1127
+ console.log(` ${'DOMAIN'.padEnd(domW)} ${'ENDPOINTS'.padStart(9)} ${'AUTH'.padEnd(4)} MODIFIED`);
1128
+ console.log(` ${''.padEnd(domW, '-')} ${''.padEnd(9, '-')} ${''.padEnd(4, '-')} ${''.padEnd(10, '-')}`);
1129
+ for (const r of rows) {
1130
+ console.log(` ${r.domain.padEnd(domW)} ${String(r.endpoints).padStart(9)} ${r.auth.padEnd(4)} ${r.modified}`);
1131
+ }
1132
+ console.log();
1133
+ console.log(` auth.enc last modified: ${authModified}`);
1134
+ console.log();
1135
+ }
1136
+
1137
+ async function handleForget(positional: string[]): Promise<void> {
1138
+ const domain = positional[0];
1139
+ if (!domain) {
1140
+ console.error('Error: Domain required. Usage: apitap forget <domain>');
1141
+ process.exit(1);
1142
+ }
1143
+
1144
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(domain) || domain.includes('..')) {
1145
+ console.error('Error: Invalid domain name');
1146
+ process.exit(1);
1147
+ }
1148
+
1149
+ const skillsDir = SKILLS_DIR || join(APITAP_DIR, 'skills');
1150
+ let skillRemoved = false;
1151
+ let authRemoved = false;
1152
+ let notFound = true;
1153
+
1154
+ // Remove skill file
1155
+ const skillFilePath = join(skillsDir, `${domain}.json`);
1156
+ try {
1157
+ await stat(skillFilePath);
1158
+ notFound = false;
1159
+ await unlink(skillFilePath);
1160
+ skillRemoved = true;
1161
+ } catch {}
1162
+
1163
+ // Remove stored auth
1164
+ try {
1165
+ const machineId = await getEffectiveMachineId();
1166
+ const authManager = new AuthManager(APITAP_DIR, machineId);
1167
+ const hasAuth = await authManager.has(domain);
1168
+ if (hasAuth) {
1169
+ notFound = false;
1170
+ await authManager.clear(domain);
1171
+ authRemoved = true;
1172
+ }
1173
+ } catch (err: any) {
1174
+ if (skillRemoved) {
1175
+ console.error(`Warning: Could not remove auth credentials: ${err.message}`);
1176
+ }
1177
+ }
1178
+
1179
+ if (notFound) {
1180
+ console.log(`${domain} not found`);
1181
+ return;
1182
+ }
1183
+
1184
+ const parts: string[] = [];
1185
+ if (skillRemoved) parts.push('skill file');
1186
+ if (authRemoved) parts.push('credentials');
1187
+ console.log(`Forgot ${domain} — ${parts.join(' and ')} removed`);
1188
+ }
1189
+
1053
1190
  async function handleExtension(positional: string[], flags: Record<string, string | boolean>): Promise<void> {
1054
1191
  const subcommand = positional[0];
1055
1192
 
@@ -1150,6 +1287,12 @@ async function main(): Promise<void> {
1150
1287
  case 'read':
1151
1288
  await handleRead(positional, flags);
1152
1289
  break;
1290
+ case 'audit':
1291
+ await handleAudit(flags);
1292
+ break;
1293
+ case 'forget':
1294
+ await handleForget(positional);
1295
+ break;
1153
1296
  case 'extension':
1154
1297
  await handleExtension(positional, flags);
1155
1298
  break;
@@ -0,0 +1,65 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+
5
+ // Types re-declared here (extension types can't be imported due to different tsconfig).
6
+ // Keep in sync with extension/src/types.ts IndexFile/IndexEntry/IndexEndpoint.
7
+ export interface IndexFile {
8
+ v: 1;
9
+ updatedAt: string;
10
+ entries: IndexEntry[];
11
+ }
12
+
13
+ export interface IndexEntry {
14
+ domain: string;
15
+ firstSeen: string;
16
+ lastSeen: string;
17
+ totalHits: number;
18
+ promoted: boolean;
19
+ lastPromoted?: string;
20
+ skillFileSource?: 'extension' | 'cli';
21
+ endpoints: IndexEndpoint[];
22
+ }
23
+
24
+ export interface IndexEndpoint {
25
+ path: string;
26
+ methods: string[];
27
+ authType?: string;
28
+ hasBody: boolean;
29
+ hits: number;
30
+ lastSeen: string;
31
+ pagination?: string;
32
+ type?: 'graphql';
33
+ queryParamNames?: string[];
34
+ }
35
+
36
+ const DEFAULT_APITAP_DIR = path.join(os.homedir(), '.apitap');
37
+
38
+ /**
39
+ * Read the full passive index from disk.
40
+ * Returns null if index.json doesn't exist or is invalid.
41
+ */
42
+ export async function readIndex(apitapDir: string = DEFAULT_APITAP_DIR): Promise<IndexFile | null> {
43
+ const indexPath = path.join(apitapDir, 'index.json');
44
+ try {
45
+ const raw = await fs.readFile(indexPath, 'utf-8');
46
+ const parsed = JSON.parse(raw);
47
+ if (parsed.v !== 1 || !Array.isArray(parsed.entries)) return null;
48
+ return parsed as IndexFile;
49
+ } catch {
50
+ return null;
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Read a single domain's index entry.
56
+ * Returns null if the domain is not in the index.
57
+ */
58
+ export async function readIndexEntry(
59
+ domain: string,
60
+ apitapDir: string = DEFAULT_APITAP_DIR,
61
+ ): Promise<IndexEntry | null> {
62
+ const index = await readIndex(apitapDir);
63
+ if (!index) return null;
64
+ return index.entries.find(e => e.domain === domain) ?? null;
65
+ }
package/src/mcp.ts CHANGED
@@ -11,6 +11,7 @@ import { deriveSigningKey } from './auth/crypto.js';
11
11
  import { requestAuth } from './auth/handoff.js';
12
12
  import { CaptureSession } from './capture/session.js';
13
13
  import { discover } from './discovery/index.js';
14
+ import { readIndexEntry } from './index/reader.js';
14
15
  import { SessionCache } from './orchestration/cache.js';
15
16
  import { peek } from './read/peek.js';
16
17
  import { read } from './read/index.js';
@@ -139,6 +140,11 @@ export function createMcpServer(options: McpServerOptions = {}): McpServer {
139
140
  }
140
141
  const result = await discover(url);
141
142
 
143
+ // Look up passive index data for this domain
144
+ let domain: string;
145
+ try { domain = new URL(url).hostname; } catch { domain = url; }
146
+ const indexEntry = await readIndexEntry(domain);
147
+
142
148
  // If we got a skill file, sign and save it automatically
143
149
  if (result.skillFile && (result.confidence === 'high' || result.confidence === 'medium')) {
144
150
  const { writeSkillFile } = await import('./skill/store.js');
@@ -147,10 +153,10 @@ export function createMcpServer(options: McpServerOptions = {}): McpServer {
147
153
  const sigKey = deriveSigningKey(machineId);
148
154
  result.skillFile = signSkillFile(result.skillFile, sigKey);
149
155
  const path = await writeSkillFile(result.skillFile, skillsDir);
150
- return wrapExternalContent({ ...result, savedTo: path }, 'apitap_discover');
156
+ return wrapExternalContent({ ...result, savedTo: path, indexEntry: indexEntry ?? undefined }, 'apitap_discover');
151
157
  }
152
158
 
153
- return wrapExternalContent(result, 'apitap_discover');
159
+ return wrapExternalContent({ ...result, indexEntry: indexEntry ?? undefined }, 'apitap_discover');
154
160
  } catch (err: any) {
155
161
  return {
156
162
  content: [{ type: 'text' as const, text: `Discovery failed: ${err.message}` }],
@@ -186,7 +192,7 @@ export function createMcpServer(options: McpServerOptions = {}): McpServer {
186
192
  }
187
193
  const machineId = await getMachineId();
188
194
  const signingKey = deriveSigningKey(machineId);
189
- const skill = await readSkillFile(domain, skillsDir, { verifySignature: true, signingKey });
195
+ const skill = await readSkillFile(domain, skillsDir, { verifySignature: true, signingKey, trustUnsigned: true });
190
196
  if (!skill) {
191
197
  return {
192
198
  content: [{ type: 'text' as const, text: `No skill file found for "${domain}". Use apitap_capture to capture it first.` }],
@@ -27,10 +27,11 @@ async function signSkillJson(skillJson: string): Promise<string> {
27
27
  }
28
28
 
29
29
  export interface NativeRequest {
30
- action: 'save_skill' | 'save_batch' | 'ping' | 'capture_request';
30
+ action: 'save_skill' | 'save_batch' | 'ping' | 'capture_request' | 'save_index';
31
31
  domain?: string;
32
32
  skillJson?: string;
33
33
  skills?: Array<{ domain: string; skillJson: string }>;
34
+ indexJson?: string;
34
35
  }
35
36
 
36
37
  export interface NativeResponse {
@@ -115,6 +116,32 @@ export async function handleNativeMessage(
115
116
  return { success: true, paths };
116
117
  }
117
118
 
119
+ if (request.action === 'save_index') {
120
+ if (!request.indexJson) {
121
+ return { success: false, error: 'Missing indexJson' };
122
+ }
123
+ if (request.indexJson.length > 5 * 1024 * 1024) {
124
+ return { success: false, error: 'Index too large (max 5MB)' };
125
+ }
126
+ try {
127
+ JSON.parse(request.indexJson);
128
+ } catch {
129
+ return { success: false, error: 'Invalid JSON in indexJson' };
130
+ }
131
+
132
+ // index.json lives in ~/.apitap/ (parent of skills dir)
133
+ const apitapDir = path.dirname(skillsDir);
134
+ await fs.mkdir(apitapDir, { recursive: true });
135
+ const indexPath = path.join(apitapDir, 'index.json');
136
+
137
+ // Atomic write: temp file + rename
138
+ const tmpPath = indexPath + '.tmp.' + process.pid;
139
+ await fs.writeFile(tmpPath, request.indexJson, { mode: 0o600 });
140
+ await fs.rename(tmpPath, indexPath);
141
+
142
+ return { success: true, path: indexPath };
143
+ }
144
+
118
145
  return { success: false, error: `Unknown action: ${request.action}` };
119
146
  } catch (err) {
120
147
  return { success: false, error: String(err) };
@@ -124,7 +151,7 @@ export async function handleNativeMessage(
124
151
  // --- Relay handler ---
125
152
 
126
153
  // Actions handled locally by the native host (filesystem operations)
127
- const LOCAL_ACTIONS = new Set(['save_skill', 'save_batch', 'ping']);
154
+ const LOCAL_ACTIONS = new Set(['save_skill', 'save_batch', 'ping', 'save_index']);
128
155
 
129
156
  // Actions relayed to the extension (browser operations)
130
157
  const EXTENSION_ACTIONS = new Set(['capture_request']);
@@ -315,6 +315,8 @@ export async function browse(
315
315
  // Check content-type: HTML responses are not usable API data
316
316
  const contentType = result.headers['content-type'] ?? '';
317
317
  if (contentType.includes('text/html')) {
318
+ // Invalidate stale cache so next call reads fresh skill file from disk
319
+ cache?.invalidate(domain);
318
320
  return {
319
321
  success: false,
320
322
  reason: 'non_api_response',
package/src/read/peek.ts CHANGED
@@ -48,6 +48,22 @@ export async function peek(url: string, options: PeekOptions = {}): Promise<Peek
48
48
  const contentType = headers['content-type'] || null;
49
49
  const server = headers['server'] || null;
50
50
 
51
+ // JSON API responses with HTTP 200 are always accessible, regardless of CDN headers
52
+ if (status === 200 && contentType && contentType.includes('application/json')) {
53
+ const framework = detectFramework(headers, signals);
54
+ return {
55
+ url,
56
+ status,
57
+ accessible: true,
58
+ contentType,
59
+ server,
60
+ framework,
61
+ botProtection: null,
62
+ signals,
63
+ recommendation: 'read',
64
+ };
65
+ }
66
+
51
67
  // Detect bot protection
52
68
  const botProtection = detectBotProtection(headers, signals);
53
69
 
@@ -628,7 +628,7 @@ export async function replayMultiple(
628
628
  const skillCache = new Map<string, SkillFile | null>();
629
629
  const uniqueDomains = [...new Set(requests.map(r => r.domain))];
630
630
  await Promise.all(uniqueDomains.map(async (domain) => {
631
- const skill = await readSkillFile(domain, options.skillsDir, { verifySignature: true, signingKey });
631
+ const skill = await readSkillFile(domain, options.skillsDir, { verifySignature: true, signingKey, trustUnsigned: true });
632
632
  skillCache.set(domain, skill);
633
633
  }));
634
634
 
@@ -85,7 +85,17 @@ export async function readSkillFile(
85
85
  }
86
86
  } else {
87
87
  const { verifySignature } = await import('./signing.js');
88
- if (!verifySignature(skill, signingKey)) {
88
+ let verified = verifySignature(skill, signingKey);
89
+ if (!verified) {
90
+ // Fallback: try pre-v1.4.0 legacy key (before HKDF signing key separation)
91
+ // Files signed with deriveKey() directly (not deriveSigningKey()) will match here
92
+ const { deriveKey } = await import('../auth/crypto.js');
93
+ const { getMachineId } = await import('../auth/manager.js');
94
+ const machineId = await getMachineId();
95
+ const legacyKey = deriveKey(machineId);
96
+ verified = verifySignature(skill, legacyKey);
97
+ }
98
+ if (!verified) {
89
99
  throw new Error(`Skill file signature verification failed for ${domain} — file may be tampered`);
90
100
  }
91
101
  }