@apitap/core 1.4.1 → 1.4.2

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/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # ApiTap
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/@apitap/core)](https://www.npmjs.com/package/@apitap/core)
4
- [![tests](https://img.shields.io/badge/tests-998%20passing-brightgreen)](https://github.com/n1byn1kt/apitap)
4
+ [![tests](https://img.shields.io/badge/tests-1051%20passing-brightgreen)](https://github.com/n1byn1kt/apitap)
5
5
  [![license](https://img.shields.io/badge/license-BSL--1.1-blue)](./LICENSE)
6
6
 
7
7
  **The MCP server that turns any website into an API — no docs, no SDK, no browser.**
@@ -393,6 +393,8 @@ All commands support `--json` for machine-readable output.
393
393
  | `apitap serve <domain>` | Serve a skill file as an MCP server |
394
394
  | `apitap inspect <url>` | Discover APIs without saving |
395
395
  | `apitap stats` | Show token savings report |
396
+ | `apitap audit` | Audit stored skill files and credentials |
397
+ | `apitap forget <domain>` | Remove skill file and credentials for a domain |
396
398
  | `apitap --version` | Print version |
397
399
 
398
400
  ### Capture flags
@@ -414,7 +416,7 @@ All commands support `--json` for machine-readable output.
414
416
  git clone https://github.com/n1byn1kt/apitap.git
415
417
  cd apitap
416
418
  npm install
417
- npm test # 998 tests, Node built-in test runner
419
+ npm test # 1051 tests, Node built-in test runner
418
420
  npm run typecheck # Type checking
419
421
  npm run build # Compile to dist/
420
422
  npx tsx src/cli.ts capture <url> # Run from source
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@apitap/core",
3
- "version": "1.4.1",
3
+ "version": "1.4.2",
4
4
  "description": "Intercept web API traffic during browsing. Generate portable skill files so AI agents can call APIs directly instead of scraping.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
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}` }],
@@ -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']);