@nordsym/apiclaw 2.1.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 +57 -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 +159 -72
- 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 +167 -76
- 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);
|
package/src/index.ts
CHANGED
|
@@ -45,6 +45,8 @@ import {
|
|
|
45
45
|
} from './confirmation.js';
|
|
46
46
|
import { executeCapability, listCapabilities, hasCapability } from './capability-router.js';
|
|
47
47
|
import { readSession, writeSession, clearSession, getMachineFingerprint, detectMCPClient, SessionData } from './session.js';
|
|
48
|
+
import { requireVerifiedOwner, type WorkspaceContextLike } from './registration-guard.js';
|
|
49
|
+
import { emitFunnelEvent, hasLocalMarker, setLocalMarker } from './funnel-client.js';
|
|
48
50
|
import { ConvexHttpClient } from 'convex/browser';
|
|
49
51
|
import {
|
|
50
52
|
getOrCreateCustomer,
|
|
@@ -342,6 +344,52 @@ function checkWorkspaceAccess(providerId?: string): { allowed: boolean; error?:
|
|
|
342
344
|
return { allowed: true, isAnonymous: false };
|
|
343
345
|
}
|
|
344
346
|
|
|
347
|
+
/**
|
|
348
|
+
* Single enforcement entry point for every paying call path.
|
|
349
|
+
* Returns either a verified workspace context or an MCP-formatted block response.
|
|
350
|
+
*/
|
|
351
|
+
function enforceOwner(channel: string):
|
|
352
|
+
| { ok: true; ctx: WorkspaceContextLike }
|
|
353
|
+
| { ok: false; response: { content: { type: 'text'; text: string }[]; isError: true } } {
|
|
354
|
+
const result = requireVerifiedOwner(workspaceContext as WorkspaceContextLike | null);
|
|
355
|
+
if (result.ok) {
|
|
356
|
+
return { ok: true, ctx: result.ctx };
|
|
357
|
+
}
|
|
358
|
+
// Diagnostic: record why the call was blocked.
|
|
359
|
+
try {
|
|
360
|
+
emitFunnelEvent({
|
|
361
|
+
event: 'call_api_blocked',
|
|
362
|
+
workspaceId: workspaceContext?.workspaceId,
|
|
363
|
+
email: workspaceContext?.email,
|
|
364
|
+
fingerprint: getMachineFingerprint(),
|
|
365
|
+
mcpClient: detectMCPClient(),
|
|
366
|
+
platform: process.platform,
|
|
367
|
+
version: process.env.npm_package_version || 'unknown',
|
|
368
|
+
props: { reason: result.reason, channel },
|
|
369
|
+
});
|
|
370
|
+
if (result.reason === 'quota_exceeded') {
|
|
371
|
+
emitFunnelEvent({
|
|
372
|
+
event: 'quota_hit',
|
|
373
|
+
workspaceId: workspaceContext?.workspaceId,
|
|
374
|
+
email: workspaceContext?.email,
|
|
375
|
+
fingerprint: getMachineFingerprint(),
|
|
376
|
+
version: process.env.npm_package_version || 'unknown',
|
|
377
|
+
props: { tier: workspaceContext?.tier, limit: workspaceContext?.usageCount },
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
} catch { /* non-blocking */ }
|
|
381
|
+
return {
|
|
382
|
+
ok: false,
|
|
383
|
+
response: {
|
|
384
|
+
content: [{ type: 'text', text: JSON.stringify(result.payload, null, 2) }],
|
|
385
|
+
isError: true,
|
|
386
|
+
},
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Per-process marker: ensure first_call_api_success fires once per server boot.
|
|
391
|
+
let firstCallEmitted = false;
|
|
392
|
+
|
|
345
393
|
/**
|
|
346
394
|
* Get customer API key from environment variable
|
|
347
395
|
* Convention: {PROVIDER}_API_KEY (e.g., COACCEPT_API_KEY, ELKS_API_KEY)
|
|
@@ -1182,23 +1230,10 @@ Docs: https://apiclaw.cloud
|
|
|
1182
1230
|
|
|
1183
1231
|
case 'call_api': {
|
|
1184
1232
|
// ============================================
|
|
1185
|
-
// REGISTRATION GATE:
|
|
1233
|
+
// REGISTRATION GATE: requireVerifiedOwner (single source of truth)
|
|
1186
1234
|
// ============================================
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
content: [{
|
|
1190
|
-
type: 'text',
|
|
1191
|
-
text: JSON.stringify({
|
|
1192
|
-
status: 'registration_required',
|
|
1193
|
-
error: 'You need to register before making API calls.',
|
|
1194
|
-
message: 'APIClaw requires a free account to use APIs. Ask the user for their email address, then call register_owner({ email: "user@example.com" }). A 6-digit verification code will be sent to their email. Then call verify_code with the code.',
|
|
1195
|
-
action: 'register_owner',
|
|
1196
|
-
free_tier: '50 API calls/month, unlimited discovery -- completely free.',
|
|
1197
|
-
}, null, 2)
|
|
1198
|
-
}],
|
|
1199
|
-
isError: true
|
|
1200
|
-
};
|
|
1201
|
-
}
|
|
1235
|
+
const gate = enforceOwner("mcp:call_api");
|
|
1236
|
+
if (!gate.ok) return gate.response;
|
|
1202
1237
|
|
|
1203
1238
|
const provider = args?.provider as string;
|
|
1204
1239
|
const action = args?.action as string;
|
|
@@ -1229,34 +1264,7 @@ Docs: https://apiclaw.cloud
|
|
|
1229
1264
|
// CHAIN EXECUTION MODE
|
|
1230
1265
|
// ============================================
|
|
1231
1266
|
if (chain && Array.isArray(chain) && chain.length > 0) {
|
|
1232
|
-
//
|
|
1233
|
-
const access = checkWorkspaceAccess();
|
|
1234
|
-
if (!access.allowed) {
|
|
1235
|
-
// If error is already formatted JSON (from rate limit checks), return as-is
|
|
1236
|
-
if (access.error?.startsWith('{')) {
|
|
1237
|
-
return {
|
|
1238
|
-
content: [{
|
|
1239
|
-
type: 'text',
|
|
1240
|
-
text: access.error
|
|
1241
|
-
}],
|
|
1242
|
-
isError: true
|
|
1243
|
-
};
|
|
1244
|
-
}
|
|
1245
|
-
|
|
1246
|
-
// Otherwise, wrap in standard error format
|
|
1247
|
-
return {
|
|
1248
|
-
content: [{
|
|
1249
|
-
type: 'text',
|
|
1250
|
-
text: JSON.stringify({
|
|
1251
|
-
status: 'error',
|
|
1252
|
-
error: access.error,
|
|
1253
|
-
hint: 'Use register_owner to authenticate your workspace.',
|
|
1254
|
-
}, null, 2)
|
|
1255
|
-
}],
|
|
1256
|
-
isError: true
|
|
1257
|
-
};
|
|
1258
|
-
}
|
|
1259
|
-
|
|
1267
|
+
// Gate already enforced at top of call_api via enforceOwner().
|
|
1260
1268
|
try {
|
|
1261
1269
|
// Construct ChainDefinition from the input
|
|
1262
1270
|
const chainDefinition: ChainDefinition = {
|
|
@@ -1627,6 +1635,39 @@ Docs: https://apiclaw.cloud
|
|
|
1627
1635
|
workspaceContext.usageCount = (workspaceContext.usageCount || 0) + 1;
|
|
1628
1636
|
}
|
|
1629
1637
|
|
|
1638
|
+
// Funnel: call_api_error (provider-level failure)
|
|
1639
|
+
if (!result.success && workspaceContext) {
|
|
1640
|
+
emitFunnelEvent({
|
|
1641
|
+
event: 'call_api_error',
|
|
1642
|
+
workspaceId: workspaceContext.workspaceId,
|
|
1643
|
+
email: workspaceContext.email,
|
|
1644
|
+
fingerprint: getMachineFingerprint(),
|
|
1645
|
+
version: process.env.npm_package_version || 'unknown',
|
|
1646
|
+
props: {
|
|
1647
|
+
provider: result.provider || provider,
|
|
1648
|
+
action: result.action || action,
|
|
1649
|
+
errorCode: (result.error || '').slice(0, 80) || 'unknown',
|
|
1650
|
+
},
|
|
1651
|
+
});
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1654
|
+
// Funnel: first_call_api_success (once per workspace, deduped server-side)
|
|
1655
|
+
if (result.success && workspaceContext && !isFreeAPI && !firstCallEmitted) {
|
|
1656
|
+
firstCallEmitted = true;
|
|
1657
|
+
emitFunnelEvent({
|
|
1658
|
+
event: 'first_call_api_success',
|
|
1659
|
+
email: workspaceContext.email,
|
|
1660
|
+
workspaceId: workspaceContext.workspaceId,
|
|
1661
|
+
sessionToken: workspaceContext.sessionToken,
|
|
1662
|
+
fingerprint: getMachineFingerprint(),
|
|
1663
|
+
mcpClient: detectMCPClient(),
|
|
1664
|
+
platform: process.platform,
|
|
1665
|
+
version: process.env.npm_package_version || 'unknown',
|
|
1666
|
+
dedupeKey: `first_call:${workspaceContext.workspaceId}`,
|
|
1667
|
+
props: { provider, action, channel: 'mcp:call_api' },
|
|
1668
|
+
});
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1630
1671
|
// Build response with signup nudge for unregistered users
|
|
1631
1672
|
const responseData: Record<string, unknown> = {
|
|
1632
1673
|
status: result.success ? 'success' : 'error',
|
|
@@ -1683,21 +1724,9 @@ Docs: https://apiclaw.cloud
|
|
|
1683
1724
|
}
|
|
1684
1725
|
|
|
1685
1726
|
case 'capability': {
|
|
1686
|
-
// Registration gate
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
content: [{
|
|
1690
|
-
type: 'text',
|
|
1691
|
-
text: JSON.stringify({
|
|
1692
|
-
status: 'registration_required',
|
|
1693
|
-
error: 'You need to register before making API calls.',
|
|
1694
|
-
message: 'Ask the user for their email, then call register_owner({ email: "..." }). A 6-digit code will be sent. Then call verify_code with the code.',
|
|
1695
|
-
action: 'register_owner',
|
|
1696
|
-
}, null, 2)
|
|
1697
|
-
}],
|
|
1698
|
-
isError: true
|
|
1699
|
-
};
|
|
1700
|
-
}
|
|
1727
|
+
// Registration gate: requireVerifiedOwner (single source of truth)
|
|
1728
|
+
const capGate = enforceOwner("mcp:capability");
|
|
1729
|
+
if (!capGate.ok) return capGate.response;
|
|
1701
1730
|
|
|
1702
1731
|
const capabilityId = args?.capability as string;
|
|
1703
1732
|
const action = args?.action as string;
|
|
@@ -1793,6 +1822,14 @@ Docs: https://apiclaw.cloud
|
|
|
1793
1822
|
const email = args?.email as string;
|
|
1794
1823
|
|
|
1795
1824
|
if (!email || !email.includes('@')) {
|
|
1825
|
+
emitFunnelEvent({
|
|
1826
|
+
event: 'register_owner_failed',
|
|
1827
|
+
email,
|
|
1828
|
+
fingerprint: getMachineFingerprint(),
|
|
1829
|
+
mcpClient: detectMCPClient(),
|
|
1830
|
+
version: process.env.npm_package_version || 'unknown',
|
|
1831
|
+
props: { reason: 'invalid_email' },
|
|
1832
|
+
});
|
|
1796
1833
|
return {
|
|
1797
1834
|
content: [{
|
|
1798
1835
|
type: 'text',
|
|
@@ -1895,12 +1932,30 @@ Docs: https://apiclaw.cloud
|
|
|
1895
1932
|
|
|
1896
1933
|
if (!emailResponse.ok) {
|
|
1897
1934
|
const errorData = await emailResponse.text();
|
|
1935
|
+
emitFunnelEvent({
|
|
1936
|
+
event: 'register_owner_failed',
|
|
1937
|
+
email,
|
|
1938
|
+
fingerprint: getMachineFingerprint(),
|
|
1939
|
+
mcpClient: detectMCPClient(),
|
|
1940
|
+
version: process.env.npm_package_version || 'unknown',
|
|
1941
|
+
props: { reason: 'email_send_failed' },
|
|
1942
|
+
});
|
|
1898
1943
|
throw new Error(`Failed to send verification email: ${errorData}`);
|
|
1899
1944
|
}
|
|
1900
1945
|
|
|
1901
1946
|
// Store pending email for verify_code
|
|
1902
1947
|
pendingRegistrationEmail = email;
|
|
1903
1948
|
|
|
1949
|
+
// Funnel: register_owner
|
|
1950
|
+
emitFunnelEvent({
|
|
1951
|
+
event: 'register_owner',
|
|
1952
|
+
email,
|
|
1953
|
+
fingerprint: getMachineFingerprint(),
|
|
1954
|
+
mcpClient: detectMCPClient(),
|
|
1955
|
+
platform: process.platform,
|
|
1956
|
+
version: process.env.npm_package_version || 'unknown',
|
|
1957
|
+
});
|
|
1958
|
+
|
|
1904
1959
|
return {
|
|
1905
1960
|
content: [{
|
|
1906
1961
|
type: 'text',
|
|
@@ -1966,6 +2021,19 @@ Docs: https://apiclaw.cloud
|
|
|
1966
2021
|
await convex.mutation("workspaces:incrementOTPAttempt" as any, { email, code: code.trim() });
|
|
1967
2022
|
} catch (_) {}
|
|
1968
2023
|
|
|
2024
|
+
const reason =
|
|
2025
|
+
result.error === 'code_expired' ? 'expired'
|
|
2026
|
+
: result.error === 'attempts_exceeded' ? 'attempts_exceeded'
|
|
2027
|
+
: 'invalid';
|
|
2028
|
+
emitFunnelEvent({
|
|
2029
|
+
event: 'verify_code_failed',
|
|
2030
|
+
email,
|
|
2031
|
+
fingerprint: getMachineFingerprint(),
|
|
2032
|
+
mcpClient: detectMCPClient(),
|
|
2033
|
+
version: process.env.npm_package_version || 'unknown',
|
|
2034
|
+
props: { reason },
|
|
2035
|
+
});
|
|
2036
|
+
|
|
1969
2037
|
return {
|
|
1970
2038
|
content: [{
|
|
1971
2039
|
type: 'text',
|
|
@@ -2008,6 +2076,20 @@ Docs: https://apiclaw.cloud
|
|
|
2008
2076
|
|
|
2009
2077
|
pendingRegistrationEmail = null;
|
|
2010
2078
|
|
|
2079
|
+
// Funnel: verify_code (dedupe per workspace so re-verifies don't double-count)
|
|
2080
|
+
emitFunnelEvent({
|
|
2081
|
+
event: 'verify_code',
|
|
2082
|
+
email: result.workspace!.email,
|
|
2083
|
+
workspaceId: result.workspace!.id,
|
|
2084
|
+
fingerprint: getMachineFingerprint(),
|
|
2085
|
+
sessionToken: result.sessionToken,
|
|
2086
|
+
mcpClient: detectMCPClient(),
|
|
2087
|
+
platform: process.platform,
|
|
2088
|
+
version: process.env.npm_package_version || 'unknown',
|
|
2089
|
+
dedupeKey: `verify_code:${result.workspace!.id}`,
|
|
2090
|
+
props: { isNewUser: !!result.isNewUser },
|
|
2091
|
+
});
|
|
2092
|
+
|
|
2011
2093
|
return {
|
|
2012
2094
|
content: [{
|
|
2013
2095
|
type: 'text',
|
|
@@ -2418,22 +2500,10 @@ Docs: https://apiclaw.cloud
|
|
|
2418
2500
|
};
|
|
2419
2501
|
}
|
|
2420
2502
|
|
|
2421
|
-
//
|
|
2422
|
-
const
|
|
2423
|
-
if (!
|
|
2424
|
-
|
|
2425
|
-
content: [{
|
|
2426
|
-
type: 'text',
|
|
2427
|
-
text: JSON.stringify({
|
|
2428
|
-
status: 'error',
|
|
2429
|
-
error: access.error,
|
|
2430
|
-
hint: 'Use register_owner to authenticate your workspace.',
|
|
2431
|
-
}, null, 2)
|
|
2432
|
-
}],
|
|
2433
|
-
isError: true
|
|
2434
|
-
};
|
|
2435
|
-
}
|
|
2436
|
-
|
|
2503
|
+
// Registration gate: requireVerifiedOwner (single source of truth)
|
|
2504
|
+
const resumeGate = enforceOwner("mcp:resume_chain");
|
|
2505
|
+
if (!resumeGate.ok) return resumeGate.response;
|
|
2506
|
+
|
|
2437
2507
|
try {
|
|
2438
2508
|
// Note: The resume_chain function requires the original chain definition
|
|
2439
2509
|
// In practice, you'd store this or require the caller to provide it
|
|
@@ -2556,7 +2626,28 @@ async function main() {
|
|
|
2556
2626
|
const transport = new StdioServerTransport();
|
|
2557
2627
|
await server.connect(transport);
|
|
2558
2628
|
trackStartup();
|
|
2559
|
-
|
|
2629
|
+
|
|
2630
|
+
// Funnel: first_run (once per fingerprint, persisted across restarts)
|
|
2631
|
+
try {
|
|
2632
|
+
const fp = getMachineFingerprint();
|
|
2633
|
+
const mcpClient = detectMCPClient();
|
|
2634
|
+
const version = process.env.npm_package_version || 'unknown';
|
|
2635
|
+
const dedupeKey = `first_run:${fp}`;
|
|
2636
|
+
if (!hasLocalMarker(dedupeKey)) {
|
|
2637
|
+
emitFunnelEvent({
|
|
2638
|
+
event: 'first_run',
|
|
2639
|
+
fingerprint: fp,
|
|
2640
|
+
mcpClient,
|
|
2641
|
+
platform: process.platform,
|
|
2642
|
+
version,
|
|
2643
|
+
dedupeKey,
|
|
2644
|
+
});
|
|
2645
|
+
setLocalMarker(dedupeKey);
|
|
2646
|
+
}
|
|
2647
|
+
} catch {
|
|
2648
|
+
/* non-blocking */
|
|
2649
|
+
}
|
|
2650
|
+
|
|
2560
2651
|
// Validate session on startup
|
|
2561
2652
|
const hasValidSession = await validateSession();
|
|
2562
2653
|
|