@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.
@@ -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: require email before any API call
1094
+ // REGISTRATION GATE: requireVerifiedOwner (single source of truth)
1050
1095
  // ============================================
1051
- if (!workspaceContext) {
1052
- return {
1053
- content: [{
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
- // Check workspace access for chains
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
- if (!workspaceContext) {
1499
- return {
1500
- content: [{
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
- // Check workspace access
2151
- const access = checkWorkspaceAccess();
2152
- if (!access.allowed) {
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)