@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,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: require email before any API call
1233
+ // REGISTRATION GATE: requireVerifiedOwner (single source of truth)
1186
1234
  // ============================================
1187
- if (!workspaceContext) {
1188
- return {
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
- // Check workspace access for chains
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
- if (!workspaceContext) {
1688
- return {
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
- // Check workspace access
2422
- const access = checkWorkspaceAccess();
2423
- if (!access.allowed) {
2424
- return {
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