@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,145 @@
|
|
|
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 { requireVerifiedOwner, FREE_CALL_PATHS, ENFORCED_CALL_PATHS, } from './registration-guard.js';
|
|
8
|
+
let failed = 0;
|
|
9
|
+
let passed = 0;
|
|
10
|
+
function test(name, fn) {
|
|
11
|
+
try {
|
|
12
|
+
fn();
|
|
13
|
+
console.log(`✅ ${name}`);
|
|
14
|
+
passed++;
|
|
15
|
+
}
|
|
16
|
+
catch (e) {
|
|
17
|
+
console.log(`❌ ${name}`);
|
|
18
|
+
console.log(` ${e.message || e}`);
|
|
19
|
+
failed++;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
// ------------------------------------------------------------------
|
|
23
|
+
// Classification
|
|
24
|
+
// ------------------------------------------------------------------
|
|
25
|
+
test('classify: human is default', () => {
|
|
26
|
+
assert.equal(classifyLocalSource({ env: {} }), 'human');
|
|
27
|
+
});
|
|
28
|
+
test('classify: CI env flag → ci', () => {
|
|
29
|
+
assert.equal(classifyLocalSource({ env: { CI: 'true' } }), 'ci');
|
|
30
|
+
assert.equal(classifyLocalSource({ env: { GITHUB_ACTIONS: '1' } }), 'ci');
|
|
31
|
+
assert.equal(classifyLocalSource({ env: { CIRCLECI: 'true' } }), 'ci');
|
|
32
|
+
});
|
|
33
|
+
test('classify: CI=false is NOT ci', () => {
|
|
34
|
+
assert.equal(classifyLocalSource({ env: { CI: 'false' } }), 'human');
|
|
35
|
+
assert.equal(classifyLocalSource({ env: { CI: '0' } }), 'human');
|
|
36
|
+
});
|
|
37
|
+
test('classify: bot UA → bot', () => {
|
|
38
|
+
assert.equal(classifyLocalSource({ env: {}, userAgent: 'curl/7.88' }), 'bot');
|
|
39
|
+
assert.equal(classifyLocalSource({ env: {}, userAgent: 'Mozilla/5.0 (compatible; Googlebot/2.1)' }), 'bot');
|
|
40
|
+
assert.equal(classifyLocalSource({ env: {}, userAgent: 'python-requests/2.31' }), 'bot');
|
|
41
|
+
});
|
|
42
|
+
test('classify: internal email domain → internal', () => {
|
|
43
|
+
assert.equal(classifyLocalSource({ env: {}, email: 'someone@nordsym.com' }), 'internal');
|
|
44
|
+
assert.equal(classifyLocalSource({ env: {}, email: 'test@apiclaw.cloud' }), 'internal');
|
|
45
|
+
});
|
|
46
|
+
test('classify: internal exact email → internal (even with CI set)', () => {
|
|
47
|
+
assert.equal(classifyLocalSource({
|
|
48
|
+
env: { CI: 'true' },
|
|
49
|
+
email: 'gustavnordsync@gmail.com',
|
|
50
|
+
}), 'internal');
|
|
51
|
+
});
|
|
52
|
+
test('classify: precedence internal > ci > bot > human', () => {
|
|
53
|
+
// internal wins over CI
|
|
54
|
+
assert.equal(classifyLocalSource({ env: { CI: 'true' }, email: 'x@nordsym.com' }), 'internal');
|
|
55
|
+
// ci wins over bot
|
|
56
|
+
assert.equal(classifyLocalSource({ env: { CI: 'true' }, userAgent: 'curl/8' }), 'ci');
|
|
57
|
+
});
|
|
58
|
+
// ------------------------------------------------------------------
|
|
59
|
+
// requireVerifiedOwner
|
|
60
|
+
// ------------------------------------------------------------------
|
|
61
|
+
const good = {
|
|
62
|
+
sessionToken: 'tok',
|
|
63
|
+
workspaceId: 'ws_1',
|
|
64
|
+
email: 'user@example.com',
|
|
65
|
+
tier: 'free',
|
|
66
|
+
status: 'active',
|
|
67
|
+
usageRemaining: 42,
|
|
68
|
+
usageCount: 8,
|
|
69
|
+
};
|
|
70
|
+
test('guard: no workspace → no_session', () => {
|
|
71
|
+
const r = requireVerifiedOwner(null);
|
|
72
|
+
assert.equal(r.ok, false);
|
|
73
|
+
if (!r.ok)
|
|
74
|
+
assert.equal(r.reason, 'no_session');
|
|
75
|
+
});
|
|
76
|
+
test('guard: workspace without email → pending_verification', () => {
|
|
77
|
+
const r = requireVerifiedOwner({ ...good, email: '' });
|
|
78
|
+
assert.equal(r.ok, false);
|
|
79
|
+
if (!r.ok)
|
|
80
|
+
assert.equal(r.reason, 'pending_verification');
|
|
81
|
+
});
|
|
82
|
+
test('guard: workspace status pending → not_verified', () => {
|
|
83
|
+
const r = requireVerifiedOwner({ ...good, status: 'pending' });
|
|
84
|
+
assert.equal(r.ok, false);
|
|
85
|
+
if (!r.ok)
|
|
86
|
+
assert.equal(r.reason, 'not_verified');
|
|
87
|
+
});
|
|
88
|
+
test('guard: quota exhausted → quota_exceeded', () => {
|
|
89
|
+
const r = requireVerifiedOwner({ ...good, usageRemaining: 0 });
|
|
90
|
+
assert.equal(r.ok, false);
|
|
91
|
+
if (!r.ok)
|
|
92
|
+
assert.equal(r.reason, 'quota_exceeded');
|
|
93
|
+
});
|
|
94
|
+
test('guard: happy path → ok', () => {
|
|
95
|
+
const r = requireVerifiedOwner(good);
|
|
96
|
+
assert.equal(r.ok, true);
|
|
97
|
+
if (r.ok)
|
|
98
|
+
assert.equal(r.ctx.email, 'user@example.com');
|
|
99
|
+
});
|
|
100
|
+
test('guard: unlimited (usageRemaining=-1) → ok', () => {
|
|
101
|
+
const r = requireVerifiedOwner({ ...good, usageRemaining: -1 });
|
|
102
|
+
assert.equal(r.ok, true);
|
|
103
|
+
});
|
|
104
|
+
// ------------------------------------------------------------------
|
|
105
|
+
// Enforcement matrix coverage
|
|
106
|
+
// ------------------------------------------------------------------
|
|
107
|
+
test('matrix: discover_apis is free', () => {
|
|
108
|
+
assert.equal(FREE_CALL_PATHS.has('discover_apis'), true);
|
|
109
|
+
assert.equal(ENFORCED_CALL_PATHS.has('discover_apis'), false);
|
|
110
|
+
});
|
|
111
|
+
test('matrix: call_api / capability / resume_chain are enforced', () => {
|
|
112
|
+
for (const p of ['call_api', 'capability', 'resume_chain']) {
|
|
113
|
+
assert.equal(ENFORCED_CALL_PATHS.has(p), true, `${p} should be enforced`);
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
test('matrix: register_owner + verify_code are free (they BECOME the auth)', () => {
|
|
117
|
+
assert.equal(FREE_CALL_PATHS.has('register_owner'), true);
|
|
118
|
+
assert.equal(FREE_CALL_PATHS.has('verify_code'), true);
|
|
119
|
+
});
|
|
120
|
+
test('matrix: free and enforced sets are disjoint', () => {
|
|
121
|
+
for (const p of FREE_CALL_PATHS) {
|
|
122
|
+
assert.equal(ENFORCED_CALL_PATHS.has(p), false, `${p} is in both sets`);
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
test('event canon: all 11 approved events are typed', () => {
|
|
126
|
+
const names = [
|
|
127
|
+
'install',
|
|
128
|
+
'first_run',
|
|
129
|
+
'register_owner',
|
|
130
|
+
'verify_code',
|
|
131
|
+
'first_call_api_success',
|
|
132
|
+
'register_owner_failed',
|
|
133
|
+
'verify_code_failed',
|
|
134
|
+
'call_api_blocked',
|
|
135
|
+
'call_api_error',
|
|
136
|
+
'quota_hit',
|
|
137
|
+
'gateway_retry',
|
|
138
|
+
];
|
|
139
|
+
assert.equal(names.length, 11);
|
|
140
|
+
});
|
|
141
|
+
console.log('');
|
|
142
|
+
console.log(`Results: ${passed} passed, ${failed} failed`);
|
|
143
|
+
if (failed > 0)
|
|
144
|
+
process.exit(1);
|
|
145
|
+
//# sourceMappingURL=funnel.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"funnel.test.js","sourceRoot":"","sources":["../src/funnel.test.ts"],"names":[],"mappings":"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"}
|
package/dist/index.js
CHANGED
|
@@ -24,6 +24,8 @@ import { getGateway, isGatewayEnabled } from './gateway-client.js';
|
|
|
24
24
|
import { requiresConfirmationAsync, createPendingAction, consumePendingAction, generatePreview, validateParams } from './confirmation.js';
|
|
25
25
|
import { executeCapability, listCapabilities, hasCapability } from './capability-router.js';
|
|
26
26
|
import { readSession, writeSession, clearSession, getMachineFingerprint, detectMCPClient } from './session.js';
|
|
27
|
+
import { requireVerifiedOwner } from './registration-guard.js';
|
|
28
|
+
import { emitFunnelEvent, hasLocalMarker, setLocalMarker } from './funnel-client.js';
|
|
27
29
|
import { ConvexHttpClient } from 'convex/browser';
|
|
28
30
|
import { getOrCreateCustomer, createMeteredCheckoutSession, getUsageSummary, METERED_BILLING } from './stripe.js';
|
|
29
31
|
import { estimateCost } from './metered.js';
|
|
@@ -245,6 +247,49 @@ function checkWorkspaceAccess(providerId) {
|
|
|
245
247
|
}
|
|
246
248
|
return { allowed: true, isAnonymous: false };
|
|
247
249
|
}
|
|
250
|
+
/**
|
|
251
|
+
* Single enforcement entry point for every paying call path.
|
|
252
|
+
* Returns either a verified workspace context or an MCP-formatted block response.
|
|
253
|
+
*/
|
|
254
|
+
function enforceOwner(channel) {
|
|
255
|
+
const result = requireVerifiedOwner(workspaceContext);
|
|
256
|
+
if (result.ok) {
|
|
257
|
+
return { ok: true, ctx: result.ctx };
|
|
258
|
+
}
|
|
259
|
+
// Diagnostic: record why the call was blocked.
|
|
260
|
+
try {
|
|
261
|
+
emitFunnelEvent({
|
|
262
|
+
event: 'call_api_blocked',
|
|
263
|
+
workspaceId: workspaceContext?.workspaceId,
|
|
264
|
+
email: workspaceContext?.email,
|
|
265
|
+
fingerprint: getMachineFingerprint(),
|
|
266
|
+
mcpClient: detectMCPClient(),
|
|
267
|
+
platform: process.platform,
|
|
268
|
+
version: process.env.npm_package_version || 'unknown',
|
|
269
|
+
props: { reason: result.reason, channel },
|
|
270
|
+
});
|
|
271
|
+
if (result.reason === 'quota_exceeded') {
|
|
272
|
+
emitFunnelEvent({
|
|
273
|
+
event: 'quota_hit',
|
|
274
|
+
workspaceId: workspaceContext?.workspaceId,
|
|
275
|
+
email: workspaceContext?.email,
|
|
276
|
+
fingerprint: getMachineFingerprint(),
|
|
277
|
+
version: process.env.npm_package_version || 'unknown',
|
|
278
|
+
props: { tier: workspaceContext?.tier, limit: workspaceContext?.usageCount },
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
catch { /* non-blocking */ }
|
|
283
|
+
return {
|
|
284
|
+
ok: false,
|
|
285
|
+
response: {
|
|
286
|
+
content: [{ type: 'text', text: JSON.stringify(result.payload, null, 2) }],
|
|
287
|
+
isError: true,
|
|
288
|
+
},
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
// Per-process marker: ensure first_call_api_success fires once per server boot.
|
|
292
|
+
let firstCallEmitted = false;
|
|
248
293
|
/**
|
|
249
294
|
* Get customer API key from environment variable
|
|
250
295
|
* Convention: {PROVIDER}_API_KEY (e.g., COACCEPT_API_KEY, ELKS_API_KEY)
|
|
@@ -1046,23 +1091,11 @@ Docs: https://apiclaw.cloud
|
|
|
1046
1091
|
}
|
|
1047
1092
|
case 'call_api': {
|
|
1048
1093
|
// ============================================
|
|
1049
|
-
// REGISTRATION GATE:
|
|
1094
|
+
// REGISTRATION GATE: requireVerifiedOwner (single source of truth)
|
|
1050
1095
|
// ============================================
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
type: 'text',
|
|
1055
|
-
text: JSON.stringify({
|
|
1056
|
-
status: 'registration_required',
|
|
1057
|
-
error: 'You need to register before making API calls.',
|
|
1058
|
-
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.',
|
|
1059
|
-
action: 'register_owner',
|
|
1060
|
-
free_tier: '50 API calls/month, unlimited discovery -- completely free.',
|
|
1061
|
-
}, null, 2)
|
|
1062
|
-
}],
|
|
1063
|
-
isError: true
|
|
1064
|
-
};
|
|
1065
|
-
}
|
|
1096
|
+
const gate = enforceOwner("mcp:call_api");
|
|
1097
|
+
if (!gate.ok)
|
|
1098
|
+
return gate.response;
|
|
1066
1099
|
const provider = args?.provider;
|
|
1067
1100
|
const action = args?.action;
|
|
1068
1101
|
const params = args?.params || {};
|
|
@@ -1090,32 +1123,7 @@ Docs: https://apiclaw.cloud
|
|
|
1090
1123
|
// CHAIN EXECUTION MODE
|
|
1091
1124
|
// ============================================
|
|
1092
1125
|
if (chain && Array.isArray(chain) && chain.length > 0) {
|
|
1093
|
-
//
|
|
1094
|
-
const access = checkWorkspaceAccess();
|
|
1095
|
-
if (!access.allowed) {
|
|
1096
|
-
// If error is already formatted JSON (from rate limit checks), return as-is
|
|
1097
|
-
if (access.error?.startsWith('{')) {
|
|
1098
|
-
return {
|
|
1099
|
-
content: [{
|
|
1100
|
-
type: 'text',
|
|
1101
|
-
text: access.error
|
|
1102
|
-
}],
|
|
1103
|
-
isError: true
|
|
1104
|
-
};
|
|
1105
|
-
}
|
|
1106
|
-
// Otherwise, wrap in standard error format
|
|
1107
|
-
return {
|
|
1108
|
-
content: [{
|
|
1109
|
-
type: 'text',
|
|
1110
|
-
text: JSON.stringify({
|
|
1111
|
-
status: 'error',
|
|
1112
|
-
error: access.error,
|
|
1113
|
-
hint: 'Use register_owner to authenticate your workspace.',
|
|
1114
|
-
}, null, 2)
|
|
1115
|
-
}],
|
|
1116
|
-
isError: true
|
|
1117
|
-
};
|
|
1118
|
-
}
|
|
1126
|
+
// Gate already enforced at top of call_api via enforceOwner().
|
|
1119
1127
|
try {
|
|
1120
1128
|
// Construct ChainDefinition from the input
|
|
1121
1129
|
const chainDefinition = {
|
|
@@ -1443,6 +1451,37 @@ Docs: https://apiclaw.cloud
|
|
|
1443
1451
|
if (isGatewayEnabled() && result.success && workspaceContext && !isFreeAPI) {
|
|
1444
1452
|
workspaceContext.usageCount = (workspaceContext.usageCount || 0) + 1;
|
|
1445
1453
|
}
|
|
1454
|
+
// Funnel: call_api_error (provider-level failure)
|
|
1455
|
+
if (!result.success && workspaceContext) {
|
|
1456
|
+
emitFunnelEvent({
|
|
1457
|
+
event: 'call_api_error',
|
|
1458
|
+
workspaceId: workspaceContext.workspaceId,
|
|
1459
|
+
email: workspaceContext.email,
|
|
1460
|
+
fingerprint: getMachineFingerprint(),
|
|
1461
|
+
version: process.env.npm_package_version || 'unknown',
|
|
1462
|
+
props: {
|
|
1463
|
+
provider: result.provider || provider,
|
|
1464
|
+
action: result.action || action,
|
|
1465
|
+
errorCode: (result.error || '').slice(0, 80) || 'unknown',
|
|
1466
|
+
},
|
|
1467
|
+
});
|
|
1468
|
+
}
|
|
1469
|
+
// Funnel: first_call_api_success (once per workspace, deduped server-side)
|
|
1470
|
+
if (result.success && workspaceContext && !isFreeAPI && !firstCallEmitted) {
|
|
1471
|
+
firstCallEmitted = true;
|
|
1472
|
+
emitFunnelEvent({
|
|
1473
|
+
event: 'first_call_api_success',
|
|
1474
|
+
email: workspaceContext.email,
|
|
1475
|
+
workspaceId: workspaceContext.workspaceId,
|
|
1476
|
+
sessionToken: workspaceContext.sessionToken,
|
|
1477
|
+
fingerprint: getMachineFingerprint(),
|
|
1478
|
+
mcpClient: detectMCPClient(),
|
|
1479
|
+
platform: process.platform,
|
|
1480
|
+
version: process.env.npm_package_version || 'unknown',
|
|
1481
|
+
dedupeKey: `first_call:${workspaceContext.workspaceId}`,
|
|
1482
|
+
props: { provider, action, channel: 'mcp:call_api' },
|
|
1483
|
+
});
|
|
1484
|
+
}
|
|
1446
1485
|
// Build response with signup nudge for unregistered users
|
|
1447
1486
|
const responseData = {
|
|
1448
1487
|
status: result.success ? 'success' : 'error',
|
|
@@ -1494,21 +1533,10 @@ Docs: https://apiclaw.cloud
|
|
|
1494
1533
|
};
|
|
1495
1534
|
}
|
|
1496
1535
|
case 'capability': {
|
|
1497
|
-
// Registration gate
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
type: 'text',
|
|
1502
|
-
text: JSON.stringify({
|
|
1503
|
-
status: 'registration_required',
|
|
1504
|
-
error: 'You need to register before making API calls.',
|
|
1505
|
-
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.',
|
|
1506
|
-
action: 'register_owner',
|
|
1507
|
-
}, null, 2)
|
|
1508
|
-
}],
|
|
1509
|
-
isError: true
|
|
1510
|
-
};
|
|
1511
|
-
}
|
|
1536
|
+
// Registration gate: requireVerifiedOwner (single source of truth)
|
|
1537
|
+
const capGate = enforceOwner("mcp:capability");
|
|
1538
|
+
if (!capGate.ok)
|
|
1539
|
+
return capGate.response;
|
|
1512
1540
|
const capabilityId = args?.capability;
|
|
1513
1541
|
const action = args?.action;
|
|
1514
1542
|
const params = args?.params || {};
|
|
@@ -1588,6 +1616,14 @@ Docs: https://apiclaw.cloud
|
|
|
1588
1616
|
case 'register_owner': {
|
|
1589
1617
|
const email = args?.email;
|
|
1590
1618
|
if (!email || !email.includes('@')) {
|
|
1619
|
+
emitFunnelEvent({
|
|
1620
|
+
event: 'register_owner_failed',
|
|
1621
|
+
email,
|
|
1622
|
+
fingerprint: getMachineFingerprint(),
|
|
1623
|
+
mcpClient: detectMCPClient(),
|
|
1624
|
+
version: process.env.npm_package_version || 'unknown',
|
|
1625
|
+
props: { reason: 'invalid_email' },
|
|
1626
|
+
});
|
|
1591
1627
|
return {
|
|
1592
1628
|
content: [{
|
|
1593
1629
|
type: 'text',
|
|
@@ -1682,10 +1718,27 @@ Docs: https://apiclaw.cloud
|
|
|
1682
1718
|
});
|
|
1683
1719
|
if (!emailResponse.ok) {
|
|
1684
1720
|
const errorData = await emailResponse.text();
|
|
1721
|
+
emitFunnelEvent({
|
|
1722
|
+
event: 'register_owner_failed',
|
|
1723
|
+
email,
|
|
1724
|
+
fingerprint: getMachineFingerprint(),
|
|
1725
|
+
mcpClient: detectMCPClient(),
|
|
1726
|
+
version: process.env.npm_package_version || 'unknown',
|
|
1727
|
+
props: { reason: 'email_send_failed' },
|
|
1728
|
+
});
|
|
1685
1729
|
throw new Error(`Failed to send verification email: ${errorData}`);
|
|
1686
1730
|
}
|
|
1687
1731
|
// Store pending email for verify_code
|
|
1688
1732
|
pendingRegistrationEmail = email;
|
|
1733
|
+
// Funnel: register_owner
|
|
1734
|
+
emitFunnelEvent({
|
|
1735
|
+
event: 'register_owner',
|
|
1736
|
+
email,
|
|
1737
|
+
fingerprint: getMachineFingerprint(),
|
|
1738
|
+
mcpClient: detectMCPClient(),
|
|
1739
|
+
platform: process.platform,
|
|
1740
|
+
version: process.env.npm_package_version || 'unknown',
|
|
1741
|
+
});
|
|
1689
1742
|
return {
|
|
1690
1743
|
content: [{
|
|
1691
1744
|
type: 'text',
|
|
@@ -1741,6 +1794,17 @@ Docs: https://apiclaw.cloud
|
|
|
1741
1794
|
await convex.mutation("workspaces:incrementOTPAttempt", { email, code: code.trim() });
|
|
1742
1795
|
}
|
|
1743
1796
|
catch (_) { }
|
|
1797
|
+
const reason = result.error === 'code_expired' ? 'expired'
|
|
1798
|
+
: result.error === 'attempts_exceeded' ? 'attempts_exceeded'
|
|
1799
|
+
: 'invalid';
|
|
1800
|
+
emitFunnelEvent({
|
|
1801
|
+
event: 'verify_code_failed',
|
|
1802
|
+
email,
|
|
1803
|
+
fingerprint: getMachineFingerprint(),
|
|
1804
|
+
mcpClient: detectMCPClient(),
|
|
1805
|
+
version: process.env.npm_package_version || 'unknown',
|
|
1806
|
+
props: { reason },
|
|
1807
|
+
});
|
|
1744
1808
|
return {
|
|
1745
1809
|
content: [{
|
|
1746
1810
|
type: 'text',
|
|
@@ -1779,6 +1843,19 @@ Docs: https://apiclaw.cloud
|
|
|
1779
1843
|
status: result.workspace.status,
|
|
1780
1844
|
};
|
|
1781
1845
|
pendingRegistrationEmail = null;
|
|
1846
|
+
// Funnel: verify_code (dedupe per workspace so re-verifies don't double-count)
|
|
1847
|
+
emitFunnelEvent({
|
|
1848
|
+
event: 'verify_code',
|
|
1849
|
+
email: result.workspace.email,
|
|
1850
|
+
workspaceId: result.workspace.id,
|
|
1851
|
+
fingerprint: getMachineFingerprint(),
|
|
1852
|
+
sessionToken: result.sessionToken,
|
|
1853
|
+
mcpClient: detectMCPClient(),
|
|
1854
|
+
platform: process.platform,
|
|
1855
|
+
version: process.env.npm_package_version || 'unknown',
|
|
1856
|
+
dedupeKey: `verify_code:${result.workspace.id}`,
|
|
1857
|
+
props: { isNewUser: !!result.isNewUser },
|
|
1858
|
+
});
|
|
1782
1859
|
return {
|
|
1783
1860
|
content: [{
|
|
1784
1861
|
type: 'text',
|
|
@@ -2147,21 +2224,10 @@ Docs: https://apiclaw.cloud
|
|
|
2147
2224
|
isError: true
|
|
2148
2225
|
};
|
|
2149
2226
|
}
|
|
2150
|
-
//
|
|
2151
|
-
const
|
|
2152
|
-
if (!
|
|
2153
|
-
return
|
|
2154
|
-
content: [{
|
|
2155
|
-
type: 'text',
|
|
2156
|
-
text: JSON.stringify({
|
|
2157
|
-
status: 'error',
|
|
2158
|
-
error: access.error,
|
|
2159
|
-
hint: 'Use register_owner to authenticate your workspace.',
|
|
2160
|
-
}, null, 2)
|
|
2161
|
-
}],
|
|
2162
|
-
isError: true
|
|
2163
|
-
};
|
|
2164
|
-
}
|
|
2227
|
+
// Registration gate: requireVerifiedOwner (single source of truth)
|
|
2228
|
+
const resumeGate = enforceOwner("mcp:resume_chain");
|
|
2229
|
+
if (!resumeGate.ok)
|
|
2230
|
+
return resumeGate.response;
|
|
2165
2231
|
try {
|
|
2166
2232
|
// Note: The resume_chain function requires the original chain definition
|
|
2167
2233
|
// In practice, you'd store this or require the caller to provide it
|
|
@@ -2272,6 +2338,27 @@ async function main() {
|
|
|
2272
2338
|
const transport = new StdioServerTransport();
|
|
2273
2339
|
await server.connect(transport);
|
|
2274
2340
|
trackStartup();
|
|
2341
|
+
// Funnel: first_run (once per fingerprint, persisted across restarts)
|
|
2342
|
+
try {
|
|
2343
|
+
const fp = getMachineFingerprint();
|
|
2344
|
+
const mcpClient = detectMCPClient();
|
|
2345
|
+
const version = process.env.npm_package_version || 'unknown';
|
|
2346
|
+
const dedupeKey = `first_run:${fp}`;
|
|
2347
|
+
if (!hasLocalMarker(dedupeKey)) {
|
|
2348
|
+
emitFunnelEvent({
|
|
2349
|
+
event: 'first_run',
|
|
2350
|
+
fingerprint: fp,
|
|
2351
|
+
mcpClient,
|
|
2352
|
+
platform: process.platform,
|
|
2353
|
+
version,
|
|
2354
|
+
dedupeKey,
|
|
2355
|
+
});
|
|
2356
|
+
setLocalMarker(dedupeKey);
|
|
2357
|
+
}
|
|
2358
|
+
}
|
|
2359
|
+
catch {
|
|
2360
|
+
/* non-blocking */
|
|
2361
|
+
}
|
|
2275
2362
|
// Validate session on startup
|
|
2276
2363
|
const hasValidSession = await validateSession();
|
|
2277
2364
|
// Register/update agent identity (fire-and-forget)
|