@nordsym/apiclaw 2.0.0 → 2.2.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.
- package/.claude/settings.local.json +5 -1
- package/convex/_generated/api.d.ts +8 -0
- package/convex/_listWorkspaces.ts +13 -0
- package/convex/crons.ts +15 -0
- package/convex/funnel.ts +431 -0
- package/convex/guards.ts +174 -0
- package/convex/http.ts +334 -8
- package/convex/nurture.ts +355 -0
- package/convex/schema.ts +70 -0
- package/convex/workspaces.ts +185 -0
- package/dist/funnel-client.d.ts +24 -0
- package/dist/funnel-client.d.ts.map +1 -0
- package/dist/funnel-client.js +131 -0
- package/dist/funnel-client.js.map +1 -0
- package/dist/funnel.test.d.ts +2 -0
- package/dist/funnel.test.d.ts.map +1 -0
- package/dist/funnel.test.js +145 -0
- package/dist/funnel.test.js.map +1 -0
- package/dist/index.js +338 -120
- package/dist/index.js.map +1 -1
- package/dist/postinstall.d.ts +0 -5
- package/dist/postinstall.d.ts.map +1 -1
- package/dist/postinstall.js +24 -3
- package/dist/postinstall.js.map +1 -1
- package/dist/registration-guard.d.ts +29 -0
- package/dist/registration-guard.d.ts.map +1 -0
- package/dist/registration-guard.js +87 -0
- package/dist/registration-guard.js.map +1 -0
- package/package.json +1 -1
- package/src/funnel-client.ts +168 -0
- package/src/funnel.test.ts +187 -0
- package/src/index.ts +381 -145
- package/src/postinstall.ts +24 -2
- package/src/registration-guard.ts +117 -0
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* APIClaw Funnel — client-side emitter for MCP server.
|
|
3
|
+
*
|
|
4
|
+
* Fire-and-forget POST to Convex funnel:recordEvent. Never throws, never
|
|
5
|
+
* blocks. See convex/funnel.ts for schema and classification rules.
|
|
6
|
+
*/
|
|
7
|
+
import * as fs from "fs";
|
|
8
|
+
import * as path from "path";
|
|
9
|
+
import * as os from "os";
|
|
10
|
+
|
|
11
|
+
const CONVEX_URL = process.env.CONVEX_URL || "https://adventurous-avocet-799.convex.cloud";
|
|
12
|
+
|
|
13
|
+
export type FunnelEventName =
|
|
14
|
+
| "install"
|
|
15
|
+
| "first_run"
|
|
16
|
+
| "register_owner"
|
|
17
|
+
| "verify_code"
|
|
18
|
+
| "first_call_api_success"
|
|
19
|
+
// Diagnostics
|
|
20
|
+
| "register_owner_failed"
|
|
21
|
+
| "verify_code_failed"
|
|
22
|
+
| "call_api_blocked"
|
|
23
|
+
| "call_api_error"
|
|
24
|
+
| "quota_hit"
|
|
25
|
+
| "gateway_retry";
|
|
26
|
+
|
|
27
|
+
export type Classification = "human" | "ci" | "bot" | "internal";
|
|
28
|
+
|
|
29
|
+
const CI_ENV_KEYS = [
|
|
30
|
+
"CI",
|
|
31
|
+
"GITHUB_ACTIONS",
|
|
32
|
+
"GITLAB_CI",
|
|
33
|
+
"CIRCLECI",
|
|
34
|
+
"BUILDKITE",
|
|
35
|
+
"JENKINS_URL",
|
|
36
|
+
"TEAMCITY_VERSION",
|
|
37
|
+
"TRAVIS",
|
|
38
|
+
"BITBUCKET_BUILD_NUMBER",
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
const BOT_UA_MARKERS = [
|
|
42
|
+
"bot",
|
|
43
|
+
"crawl",
|
|
44
|
+
"spider",
|
|
45
|
+
"scanner",
|
|
46
|
+
"curl/",
|
|
47
|
+
"wget/",
|
|
48
|
+
"httpclient",
|
|
49
|
+
"python-requests",
|
|
50
|
+
"go-http-client",
|
|
51
|
+
"okhttp",
|
|
52
|
+
"java/",
|
|
53
|
+
"httrack",
|
|
54
|
+
"headlesschrome",
|
|
55
|
+
"phantomjs",
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
const INTERNAL_EMAIL_DOMAINS = ["nordsym.com", "apiclaw.cloud"];
|
|
59
|
+
const INTERNAL_EMAIL_EXACT = ["gustav@nordsym.com", "gustavnordsync@gmail.com"];
|
|
60
|
+
|
|
61
|
+
export function classifyLocalSource(input: {
|
|
62
|
+
userAgent?: string | null;
|
|
63
|
+
email?: string | null;
|
|
64
|
+
fingerprint?: string | null;
|
|
65
|
+
env?: NodeJS.ProcessEnv;
|
|
66
|
+
}): Classification {
|
|
67
|
+
const email = (input.email || "").toLowerCase().trim();
|
|
68
|
+
if (email) {
|
|
69
|
+
if (INTERNAL_EMAIL_EXACT.includes(email)) return "internal";
|
|
70
|
+
const domain = email.split("@")[1] || "";
|
|
71
|
+
if (INTERNAL_EMAIL_DOMAINS.includes(domain)) return "internal";
|
|
72
|
+
}
|
|
73
|
+
const env = input.env || process.env;
|
|
74
|
+
for (const key of CI_ENV_KEYS) {
|
|
75
|
+
const val = env[key];
|
|
76
|
+
if (val && val !== "false" && val !== "0") return "ci";
|
|
77
|
+
}
|
|
78
|
+
const ua = (input.userAgent || "").toLowerCase();
|
|
79
|
+
if (ua) {
|
|
80
|
+
for (const m of BOT_UA_MARKERS) {
|
|
81
|
+
if (ua.includes(m)) return "bot";
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return "human";
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Persistent first-run marker stored alongside the session file.
|
|
88
|
+
const MARKER_DIR = path.join(os.homedir(), ".apiclaw");
|
|
89
|
+
const MARKER_FILE = path.join(MARKER_DIR, "funnel-markers.json");
|
|
90
|
+
|
|
91
|
+
function readMarkers(): Record<string, number> {
|
|
92
|
+
try {
|
|
93
|
+
if (!fs.existsSync(MARKER_FILE)) return {};
|
|
94
|
+
return JSON.parse(fs.readFileSync(MARKER_FILE, "utf8")) || {};
|
|
95
|
+
} catch {
|
|
96
|
+
return {};
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function writeMarkers(m: Record<string, number>): void {
|
|
101
|
+
try {
|
|
102
|
+
if (!fs.existsSync(MARKER_DIR)) fs.mkdirSync(MARKER_DIR, { mode: 0o700 });
|
|
103
|
+
fs.writeFileSync(MARKER_FILE, JSON.stringify(m), { mode: 0o600 });
|
|
104
|
+
} catch {
|
|
105
|
+
/* ignore */
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function hasLocalMarker(key: string): boolean {
|
|
110
|
+
return !!readMarkers()[key];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function setLocalMarker(key: string): void {
|
|
114
|
+
const m = readMarkers();
|
|
115
|
+
m[key] = Date.now();
|
|
116
|
+
writeMarkers(m);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface EmitArgs {
|
|
120
|
+
event: FunnelEventName;
|
|
121
|
+
workspaceId?: string;
|
|
122
|
+
fingerprint?: string;
|
|
123
|
+
sessionToken?: string;
|
|
124
|
+
email?: string;
|
|
125
|
+
mcpClient?: string;
|
|
126
|
+
platform?: string;
|
|
127
|
+
version?: string;
|
|
128
|
+
dedupeKey?: string;
|
|
129
|
+
props?: Record<string, unknown>;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function emitFunnelEvent(args: EmitArgs): void {
|
|
133
|
+
if (process.env.APICLAW_TELEMETRY === "false") return;
|
|
134
|
+
|
|
135
|
+
const classification = classifyLocalSource({
|
|
136
|
+
email: args.email,
|
|
137
|
+
fingerprint: args.fingerprint,
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
const payload = {
|
|
141
|
+
path: "funnel:recordEvent",
|
|
142
|
+
args: {
|
|
143
|
+
event: args.event,
|
|
144
|
+
classification,
|
|
145
|
+
workspaceId: args.workspaceId,
|
|
146
|
+
fingerprint: args.fingerprint,
|
|
147
|
+
sessionToken: args.sessionToken,
|
|
148
|
+
email: args.email,
|
|
149
|
+
userAgent: `apiclaw-mcp/${args.version || "unknown"}`,
|
|
150
|
+
mcpClient: args.mcpClient,
|
|
151
|
+
platform: args.platform || process.platform,
|
|
152
|
+
version: args.version,
|
|
153
|
+
dedupeKey: args.dedupeKey,
|
|
154
|
+
props: args.props,
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
// Fire and forget.
|
|
159
|
+
try {
|
|
160
|
+
fetch(`${CONVEX_URL}/api/mutation`, {
|
|
161
|
+
method: "POST",
|
|
162
|
+
headers: { "Content-Type": "application/json" },
|
|
163
|
+
body: JSON.stringify(payload),
|
|
164
|
+
}).catch(() => {});
|
|
165
|
+
} catch {
|
|
166
|
+
/* ignore */
|
|
167
|
+
}
|
|
168
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for funnel classification + registration guard + event canon.
|
|
3
|
+
* Run: npx tsx src/funnel.test.ts
|
|
4
|
+
*/
|
|
5
|
+
import { strict as assert } from 'node:assert';
|
|
6
|
+
import { classifyLocalSource } from './funnel-client.js';
|
|
7
|
+
import {
|
|
8
|
+
requireVerifiedOwner,
|
|
9
|
+
FREE_CALL_PATHS,
|
|
10
|
+
ENFORCED_CALL_PATHS,
|
|
11
|
+
type WorkspaceContextLike,
|
|
12
|
+
} from './registration-guard.js';
|
|
13
|
+
|
|
14
|
+
let failed = 0;
|
|
15
|
+
let passed = 0;
|
|
16
|
+
|
|
17
|
+
function test(name: string, fn: () => void) {
|
|
18
|
+
try {
|
|
19
|
+
fn();
|
|
20
|
+
console.log(`✅ ${name}`);
|
|
21
|
+
passed++;
|
|
22
|
+
} catch (e: any) {
|
|
23
|
+
console.log(`❌ ${name}`);
|
|
24
|
+
console.log(` ${e.message || e}`);
|
|
25
|
+
failed++;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ------------------------------------------------------------------
|
|
30
|
+
// Classification
|
|
31
|
+
// ------------------------------------------------------------------
|
|
32
|
+
test('classify: human is default', () => {
|
|
33
|
+
assert.equal(classifyLocalSource({ env: {} }), 'human');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('classify: CI env flag → ci', () => {
|
|
37
|
+
assert.equal(classifyLocalSource({ env: { CI: 'true' } }), 'ci');
|
|
38
|
+
assert.equal(classifyLocalSource({ env: { GITHUB_ACTIONS: '1' } }), 'ci');
|
|
39
|
+
assert.equal(classifyLocalSource({ env: { CIRCLECI: 'true' } }), 'ci');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('classify: CI=false is NOT ci', () => {
|
|
43
|
+
assert.equal(classifyLocalSource({ env: { CI: 'false' } }), 'human');
|
|
44
|
+
assert.equal(classifyLocalSource({ env: { CI: '0' } }), 'human');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('classify: bot UA → bot', () => {
|
|
48
|
+
assert.equal(classifyLocalSource({ env: {}, userAgent: 'curl/7.88' }), 'bot');
|
|
49
|
+
assert.equal(
|
|
50
|
+
classifyLocalSource({ env: {}, userAgent: 'Mozilla/5.0 (compatible; Googlebot/2.1)' }),
|
|
51
|
+
'bot'
|
|
52
|
+
);
|
|
53
|
+
assert.equal(classifyLocalSource({ env: {}, userAgent: 'python-requests/2.31' }), 'bot');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('classify: internal email domain → internal', () => {
|
|
57
|
+
assert.equal(
|
|
58
|
+
classifyLocalSource({ env: {}, email: 'someone@nordsym.com' }),
|
|
59
|
+
'internal'
|
|
60
|
+
);
|
|
61
|
+
assert.equal(
|
|
62
|
+
classifyLocalSource({ env: {}, email: 'test@apiclaw.cloud' }),
|
|
63
|
+
'internal'
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('classify: internal exact email → internal (even with CI set)', () => {
|
|
68
|
+
assert.equal(
|
|
69
|
+
classifyLocalSource({
|
|
70
|
+
env: { CI: 'true' },
|
|
71
|
+
email: 'gustavnordsync@gmail.com',
|
|
72
|
+
}),
|
|
73
|
+
'internal'
|
|
74
|
+
);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('classify: precedence internal > ci > bot > human', () => {
|
|
78
|
+
// internal wins over CI
|
|
79
|
+
assert.equal(
|
|
80
|
+
classifyLocalSource({ env: { CI: 'true' }, email: 'x@nordsym.com' }),
|
|
81
|
+
'internal'
|
|
82
|
+
);
|
|
83
|
+
// ci wins over bot
|
|
84
|
+
assert.equal(
|
|
85
|
+
classifyLocalSource({ env: { CI: 'true' }, userAgent: 'curl/8' }),
|
|
86
|
+
'ci'
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// ------------------------------------------------------------------
|
|
91
|
+
// requireVerifiedOwner
|
|
92
|
+
// ------------------------------------------------------------------
|
|
93
|
+
const good: WorkspaceContextLike = {
|
|
94
|
+
sessionToken: 'tok',
|
|
95
|
+
workspaceId: 'ws_1',
|
|
96
|
+
email: 'user@example.com',
|
|
97
|
+
tier: 'free',
|
|
98
|
+
status: 'active',
|
|
99
|
+
usageRemaining: 42,
|
|
100
|
+
usageCount: 8,
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
test('guard: no workspace → no_session', () => {
|
|
104
|
+
const r = requireVerifiedOwner(null);
|
|
105
|
+
assert.equal(r.ok, false);
|
|
106
|
+
if (!r.ok) assert.equal(r.reason, 'no_session');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('guard: workspace without email → pending_verification', () => {
|
|
110
|
+
const r = requireVerifiedOwner({ ...good, email: '' });
|
|
111
|
+
assert.equal(r.ok, false);
|
|
112
|
+
if (!r.ok) assert.equal(r.reason, 'pending_verification');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('guard: workspace status pending → not_verified', () => {
|
|
116
|
+
const r = requireVerifiedOwner({ ...good, status: 'pending' });
|
|
117
|
+
assert.equal(r.ok, false);
|
|
118
|
+
if (!r.ok) assert.equal(r.reason, 'not_verified');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('guard: quota exhausted → quota_exceeded', () => {
|
|
122
|
+
const r = requireVerifiedOwner({ ...good, usageRemaining: 0 });
|
|
123
|
+
assert.equal(r.ok, false);
|
|
124
|
+
if (!r.ok) assert.equal(r.reason, 'quota_exceeded');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('guard: happy path → ok', () => {
|
|
128
|
+
const r = requireVerifiedOwner(good);
|
|
129
|
+
assert.equal(r.ok, true);
|
|
130
|
+
if (r.ok) assert.equal(r.ctx.email, 'user@example.com');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('guard: unlimited (usageRemaining=-1) → ok', () => {
|
|
134
|
+
const r = requireVerifiedOwner({ ...good, usageRemaining: -1 });
|
|
135
|
+
assert.equal(r.ok, true);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// ------------------------------------------------------------------
|
|
139
|
+
// Enforcement matrix coverage
|
|
140
|
+
// ------------------------------------------------------------------
|
|
141
|
+
test('matrix: discover_apis is free', () => {
|
|
142
|
+
assert.equal(FREE_CALL_PATHS.has('discover_apis'), true);
|
|
143
|
+
assert.equal(ENFORCED_CALL_PATHS.has('discover_apis'), false);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('matrix: call_api / capability / resume_chain are enforced', () => {
|
|
147
|
+
for (const p of ['call_api', 'capability', 'resume_chain']) {
|
|
148
|
+
assert.equal(ENFORCED_CALL_PATHS.has(p), true, `${p} should be enforced`);
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('matrix: register_owner + verify_code are free (they BECOME the auth)', () => {
|
|
153
|
+
assert.equal(FREE_CALL_PATHS.has('register_owner'), true);
|
|
154
|
+
assert.equal(FREE_CALL_PATHS.has('verify_code'), true);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('matrix: free and enforced sets are disjoint', () => {
|
|
158
|
+
for (const p of FREE_CALL_PATHS) {
|
|
159
|
+
assert.equal(ENFORCED_CALL_PATHS.has(p), false, `${p} is in both sets`);
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// ------------------------------------------------------------------
|
|
164
|
+
// Event canon type surface
|
|
165
|
+
// ------------------------------------------------------------------
|
|
166
|
+
import type { FunnelEventName } from './funnel-client.js';
|
|
167
|
+
|
|
168
|
+
test('event canon: all 11 approved events are typed', () => {
|
|
169
|
+
const names: FunnelEventName[] = [
|
|
170
|
+
'install',
|
|
171
|
+
'first_run',
|
|
172
|
+
'register_owner',
|
|
173
|
+
'verify_code',
|
|
174
|
+
'first_call_api_success',
|
|
175
|
+
'register_owner_failed',
|
|
176
|
+
'verify_code_failed',
|
|
177
|
+
'call_api_blocked',
|
|
178
|
+
'call_api_error',
|
|
179
|
+
'quota_hit',
|
|
180
|
+
'gateway_retry',
|
|
181
|
+
];
|
|
182
|
+
assert.equal(names.length, 11);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
console.log('');
|
|
186
|
+
console.log(`Results: ${passed} passed, ${failed} failed`);
|
|
187
|
+
if (failed > 0) process.exit(1);
|