@nordsym/apiclaw 2.0.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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,
@@ -84,6 +86,7 @@ interface WorkspaceContext {
84
86
 
85
87
  let workspaceContext: WorkspaceContext | null = null;
86
88
  let currentAgentId: string | null = null; // Agent ID from agents table (set on startup)
89
+ let pendingRegistrationEmail: string | null = null; // Email waiting for OTP verification
87
90
 
88
91
  // Anonymous rate limit tracking (in-memory, per machine fingerprint)
89
92
  interface AnonymousRateLimitState {
@@ -278,35 +281,21 @@ const rateLimitStore = new Map<string, RateLimitState>();
278
281
  const UNREGISTERED_CALL_LIMIT = 5;
279
282
 
280
283
  /**
281
- * For proxy providers, allow anonymous usage with rate limiting
284
+ * Check workspace access -- registration required for all API calls
282
285
  */
283
286
  function checkWorkspaceAccess(providerId?: string): { allowed: boolean; error?: string; isAnonymous?: boolean } {
284
- // Allow anonymous access for proxy providers
285
- if (providerId && PROXY_PROVIDERS.includes(providerId)) {
286
- if (!workspaceContext) {
287
- // Anonymous user - check rate limits
288
- const fingerprint = getMachineFingerprint();
289
- const rateLimitCheck = checkAnonymousRateLimit(fingerprint);
290
-
291
- if (!rateLimitCheck.allowed) {
292
- return {
293
- allowed: false,
294
- error: rateLimitCheck.error,
295
- isAnonymous: true
296
- };
297
- }
298
-
299
- return { allowed: true, isAnonymous: true };
300
- }
301
- // Authenticated user using proxy provider - allow with higher limits
302
- return { allowed: true, isAnonymous: false };
303
- }
304
-
305
- // Non-proxy providers require authentication
287
+ // All API calls require registration now
306
288
  if (!workspaceContext) {
307
- return {
308
- allowed: false,
309
- error: 'Not authenticated. Use register_owner to authenticate your workspace.'
289
+ return {
290
+ allowed: false,
291
+ error: JSON.stringify({
292
+ status: 'registration_required',
293
+ error: 'Registration required to call APIs.',
294
+ 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.',
295
+ action: 'register_owner',
296
+ free_tier: '50 API calls/month -- completely free.',
297
+ }, null, 2),
298
+ isAnonymous: true,
310
299
  };
311
300
  }
312
301
 
@@ -355,6 +344,52 @@ function checkWorkspaceAccess(providerId?: string): { allowed: boolean; error?:
355
344
  return { allowed: true, isAnonymous: false };
356
345
  }
357
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
+
358
393
  /**
359
394
  * Get customer API key from environment variable
360
395
  * Convention: {PROVIDER}_API_KEY (e.g., COACCEPT_API_KEY, ELKS_API_KEY)
@@ -511,7 +546,7 @@ const tools: Tool[] = [
511
546
  },
512
547
  {
513
548
  name: 'call_api',
514
- description: `Execute an API call through APIClaw. Supports single calls AND multi-step chains.
549
+ description: `Execute an API call through APIClaw. Requires registration (free). If not registered, call register_owner first.
515
550
 
516
551
  SINGLE CALL: Provide provider + action + params
517
552
  CHAIN: Provide chain array to execute multiple APIs in sequence/parallel with cross-step references.
@@ -683,7 +718,7 @@ Example chain:
683
718
  // ============================================
684
719
  {
685
720
  name: 'register_owner',
686
- description: 'Register your email to create a workspace. This authenticates your agent with APIClaw. You will receive a magic link to verify ownership.',
721
+ description: 'REQUIRED before using any API. Register your email to create a workspace. A 6-digit verification code will be sent to your email. After calling this, ask the user for the code and call verify_code.',
687
722
  inputSchema: {
688
723
  type: 'object',
689
724
  properties: {
@@ -695,6 +730,24 @@ Example chain:
695
730
  required: ['email']
696
731
  }
697
732
  },
733
+ {
734
+ name: 'verify_code',
735
+ description: 'Verify the 6-digit code sent to your email after register_owner. This completes registration and activates your workspace. Ask the user to check their email and paste the code.',
736
+ inputSchema: {
737
+ type: 'object',
738
+ properties: {
739
+ email: {
740
+ type: 'string',
741
+ description: 'The email address used in register_owner'
742
+ },
743
+ code: {
744
+ type: 'string',
745
+ description: 'The 6-digit verification code from the email'
746
+ }
747
+ },
748
+ required: ['email', 'code']
749
+ }
750
+ },
698
751
  {
699
752
  name: 'check_workspace_status',
700
753
  description: 'Check your workspace status, tier, and usage remaining.',
@@ -831,40 +884,36 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
831
884
  try {
832
885
  switch (name) {
833
886
  case 'apiclaw_help': {
887
+ const isAuthenticated = !!workspaceContext;
834
888
  const helpText = `
835
- 🦞 APIClaw The API Layer for AI Agents
889
+ 🦞 APIClaw -- The API Layer for AI Agents
836
890
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
837
-
838
- DISCOVER APIs:
891
+ ${!isAuthenticated ? `
892
+ GET STARTED (free):
893
+ 1. register_owner({ email: "you@example.com" }) — sends 6-digit code
894
+ 2. verify_code({ email: "you@example.com", code: "123456" }) — activates workspace
895
+ ` : `
896
+ STATUS: Authenticated as ${workspaceContext!.email} (${workspaceContext!.tier} tier)
897
+ `}
898
+ DISCOVER APIs (free, no registration needed):
839
899
  discover_apis({ query: "send SMS to Sweden" })
840
- discover_apis({ query: "search the web", max_results: 10 })
841
900
  discover_apis({ query: "text to speech", category: "ai" })
842
901
 
843
- GET DETAILS:
844
- get_api_details({ api_id: "46elks" })
845
-
846
- DIRECT CALL (8 APIs, no key needed):
847
- get_connected_providers()
848
- call_api({ provider: "brave_search", endpoint: "search", params: { query: "AI agents" } })
849
-
850
- Available direct-call providers:
851
- brave_search Web search
852
- • 46elks — SMS (Sweden)
853
- twilio SMS (Global)
854
- • resend — Email
855
- • openrouter — LLM routing (100+ models)
856
- • elevenlabs — Text-to-speech
857
- • replicate — AI models (images, video, audio)
858
- • firecrawl — Web scraping & crawling
859
- • github — Code repos & developer data
860
- • e2b — Code sandbox for AI agents
861
-
862
- BROWSE:
863
- list_categories()
864
- list_all_apis({ category: "communication", limit: 20 })
902
+ CALL APIs (requires free registration):
903
+ call_api({ provider: "brave_search", action: "search", params: { q: "AI agents" } })
904
+ call_api({ provider: "elevenlabs", action: "tts", params: { text: "Hello" } })
905
+
906
+ 23 MANAGED PROVIDERS:
907
+ OpenAI, Anthropic, xAI/Grok, Groq, Mistral, OpenRouter, Together AI,
908
+ Replicate, ElevenLabs, Deepgram, AssemblyAI, Brave Search, Firecrawl,
909
+ Serper, Resend, 46elks, Twilio, E2B, Stability AI, Cohere, Voyage AI,
910
+ GitHub, APILayer (27 sub-APIs)
911
+
912
+ 26,700+ DISCOVERABLE | 1,654 CALLABLE | Free tier: 50 calls/month
865
913
 
866
914
  Docs: https://apiclaw.cloud
867
915
  `;
916
+
868
917
  return {
869
918
  content: [{ type: 'text', text: helpText }]
870
919
  };
@@ -1180,6 +1229,12 @@ Docs: https://apiclaw.cloud
1180
1229
  }
1181
1230
 
1182
1231
  case 'call_api': {
1232
+ // ============================================
1233
+ // REGISTRATION GATE: requireVerifiedOwner (single source of truth)
1234
+ // ============================================
1235
+ const gate = enforceOwner("mcp:call_api");
1236
+ if (!gate.ok) return gate.response;
1237
+
1183
1238
  const provider = args?.provider as string;
1184
1239
  const action = args?.action as string;
1185
1240
  const params = (args?.params as Record<string, any>) || {};
@@ -1188,7 +1243,7 @@ Docs: https://apiclaw.cloud
1188
1243
  const chain = args?.chain as ChainStepUnion[] | undefined;
1189
1244
  const subagentId = args?.subagent_id as string | undefined;
1190
1245
  const aiBackend = args?.ai_backend as string | undefined;
1191
-
1246
+
1192
1247
  // Track AI backend if provided
1193
1248
  if (aiBackend && workspaceContext?.sessionToken) {
1194
1249
  fetch('https://adventurous-avocet-799.convex.cloud/api/mutation', {
@@ -1209,34 +1264,7 @@ Docs: https://apiclaw.cloud
1209
1264
  // CHAIN EXECUTION MODE
1210
1265
  // ============================================
1211
1266
  if (chain && Array.isArray(chain) && chain.length > 0) {
1212
- // Check workspace access for chains
1213
- const access = checkWorkspaceAccess();
1214
- if (!access.allowed) {
1215
- // If error is already formatted JSON (from rate limit checks), return as-is
1216
- if (access.error?.startsWith('{')) {
1217
- return {
1218
- content: [{
1219
- type: 'text',
1220
- text: access.error
1221
- }],
1222
- isError: true
1223
- };
1224
- }
1225
-
1226
- // Otherwise, wrap in standard error format
1227
- return {
1228
- content: [{
1229
- type: 'text',
1230
- text: JSON.stringify({
1231
- status: 'error',
1232
- error: access.error,
1233
- hint: 'Use register_owner to authenticate your workspace.',
1234
- }, null, 2)
1235
- }],
1236
- isError: true
1237
- };
1238
- }
1239
-
1267
+ // Gate already enforced at top of call_api via enforceOwner().
1240
1268
  try {
1241
1269
  // Construct ChainDefinition from the input
1242
1270
  const chainDefinition: ChainDefinition = {
@@ -1607,6 +1635,39 @@ Docs: https://apiclaw.cloud
1607
1635
  workspaceContext.usageCount = (workspaceContext.usageCount || 0) + 1;
1608
1636
  }
1609
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
+
1610
1671
  // Build response with signup nudge for unregistered users
1611
1672
  const responseData: Record<string, unknown> = {
1612
1673
  status: result.success ? 'success' : 'error',
@@ -1663,13 +1724,17 @@ Docs: https://apiclaw.cloud
1663
1724
  }
1664
1725
 
1665
1726
  case 'capability': {
1727
+ // Registration gate: requireVerifiedOwner (single source of truth)
1728
+ const capGate = enforceOwner("mcp:capability");
1729
+ if (!capGate.ok) return capGate.response;
1730
+
1666
1731
  const capabilityId = args?.capability as string;
1667
1732
  const action = args?.action as string;
1668
1733
  const params = (args?.params as Record<string, any>) || {};
1669
1734
  const preferences = (args?.preferences as Record<string, any>) || {};
1670
1735
  const subagentId = args?.subagent_id as string | undefined;
1671
1736
  const aiBackend = args?.ai_backend as string | undefined;
1672
-
1737
+
1673
1738
  // Track AI backend if provided
1674
1739
  if (aiBackend && workspaceContext?.sessionToken) {
1675
1740
  fetch('https://adventurous-avocet-799.convex.cloud/api/mutation', {
@@ -1755,8 +1820,16 @@ Docs: https://apiclaw.cloud
1755
1820
 
1756
1821
  case 'register_owner': {
1757
1822
  const email = args?.email as string;
1758
-
1823
+
1759
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
+ });
1760
1833
  return {
1761
1834
  content: [{
1762
1835
  type: 'text',
@@ -1768,13 +1841,12 @@ Docs: https://apiclaw.cloud
1768
1841
  isError: true
1769
1842
  };
1770
1843
  }
1771
-
1844
+
1772
1845
  try {
1773
- // Check if workspace already exists
1846
+ // Check if workspace already exists and is active -- auto-login
1774
1847
  const existing = await convex.query("workspaces:getByEmail" as any, { email }) as { id: string; status: string; tier: string; usageCount: number; usageLimit: number } | null;
1775
1848
 
1776
1849
  if (existing && existing.status === 'active') {
1777
- // Workspace exists and is active - create session directly
1778
1850
  const fingerprint = getMachineFingerprint();
1779
1851
  const sessionResult = await convex.mutation("workspaces:createAgentSession" as any, {
1780
1852
  workspaceId: existing.id,
@@ -1783,23 +1855,17 @@ Docs: https://apiclaw.cloud
1783
1855
 
1784
1856
  if (sessionResult.success) {
1785
1857
  writeSession(sessionResult.sessionToken!, existing.id, email);
1786
-
1787
- // Claim anonymous usage history
1858
+
1788
1859
  try {
1789
1860
  const claimResult = await convex.mutation("workspaces:claimAnonymousUsage" as any, {
1790
1861
  workspaceId: existing.id,
1791
1862
  machineFingerprint: fingerprint,
1792
- }) as { success: boolean; claimedCount?: number; message?: string };
1793
-
1863
+ }) as { success: boolean; claimedCount?: number };
1794
1864
  if (claimResult.success && claimResult.claimedCount) {
1795
- console.error(`[APIClaw] Claimed ${claimResult.claimedCount} anonymous usage records`);
1865
+ console.error(`[APIClaw] Claimed ${claimResult.claimedCount} anonymous usage records`);
1796
1866
  }
1797
- } catch (err) {
1798
- // Non-critical error, just log it
1799
- console.error('[APIClaw] Warning: Failed to claim anonymous usage:', err);
1800
- }
1801
-
1802
- // Update global context
1867
+ } catch (_) {}
1868
+
1803
1869
  workspaceContext = {
1804
1870
  sessionToken: sessionResult.sessionToken!,
1805
1871
  workspaceId: existing.id,
@@ -1809,7 +1875,7 @@ Docs: https://apiclaw.cloud
1809
1875
  usageCount: existing.usageCount,
1810
1876
  status: existing.status,
1811
1877
  };
1812
-
1878
+
1813
1879
  return {
1814
1880
  content: [{
1815
1881
  type: 'text',
@@ -1827,29 +1893,15 @@ Docs: https://apiclaw.cloud
1827
1893
  };
1828
1894
  }
1829
1895
  }
1830
-
1831
- // Create workspace and magic link
1832
- const createResult = await convex.mutation("workspaces:createWorkspace" as any, { email }) as { success: boolean; workspaceId?: string; error?: string };
1833
-
1834
- let workspaceId: string;
1835
- if (createResult.success) {
1836
- workspaceId = createResult.workspaceId!;
1837
- } else if (createResult.error === 'workspace_exists') {
1838
- workspaceId = createResult.workspaceId!;
1839
- } else {
1840
- throw new Error(createResult.error);
1841
- }
1842
-
1843
- // Create magic link
1896
+
1897
+ // New user or pending workspace -- send OTP
1844
1898
  const fingerprint = getMachineFingerprint();
1845
- const magicLinkResult = await convex.mutation("workspaces:createMagicLink" as any, {
1899
+ const otpResult = await convex.mutation("workspaces:createOTP" as any, {
1846
1900
  email,
1847
1901
  fingerprint,
1848
- }) as { token: string; expiresAt: number };
1849
-
1850
- // Send magic link via email
1851
- const verifyUrl = `https://apiclaw.cloud/auth/verify?token=${magicLinkResult.token}`;
1852
-
1902
+ }) as { code: string; expiresAt: number };
1903
+
1904
+ // Send OTP email
1853
1905
  const emailResponse = await fetch('https://api.resend.com/emails', {
1854
1906
  method: 'POST',
1855
1907
  headers: {
@@ -1859,25 +1911,60 @@ Docs: https://apiclaw.cloud
1859
1911
  body: JSON.stringify({
1860
1912
  from: 'APIClaw <noreply@apiclaw.cloud>',
1861
1913
  to: email,
1862
- subject: 'Verify your APIClaw workspace',
1863
- html: `<p>Click to verify: <a href="${verifyUrl}">${verifyUrl}</a></p><p>Expires in 15 minutes.</p>`
1914
+ subject: `Your APIClaw verification code: ${otpResult.code}`,
1915
+ html: `
1916
+ <div style="font-family: Inter, sans-serif; max-width: 480px; margin: 0 auto; padding: 40px 24px;">
1917
+ <div style="text-align: center; margin-bottom: 32px;">
1918
+ <span style="font-size: 48px;">🦞</span>
1919
+ </div>
1920
+ <h1 style="font-size: 24px; font-weight: 700; color: #0A0A0A; text-align: center; margin-bottom: 8px;">Your verification code</h1>
1921
+ <p style="font-size: 16px; color: #525252; text-align: center; margin-bottom: 32px;">Paste this code in your terminal to activate APIClaw.</p>
1922
+ <div style="background: #F5F5F5; border: 1px solid #E5E5E5; border-radius: 12px; padding: 24px; text-align: center; margin-bottom: 24px;">
1923
+ <code style="font-size: 36px; font-weight: 700; letter-spacing: 0.3em; color: #EF4444; font-family: 'JetBrains Mono', monospace;">${otpResult.code}</code>
1924
+ </div>
1925
+ <p style="font-size: 13px; color: #737373; text-align: center;">This code expires in 10 minutes. If you didn't request this, ignore this email.</p>
1926
+ <hr style="border: none; border-top: 1px solid #E5E5E5; margin: 32px 0 16px;" />
1927
+ <p style="font-size: 12px; color: #A3A3A3; text-align: center;">APIClaw -- The API Layer For AI Agents</p>
1928
+ </div>
1929
+ `
1864
1930
  })
1865
1931
  });
1866
-
1932
+
1867
1933
  if (!emailResponse.ok) {
1868
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
+ });
1869
1943
  throw new Error(`Failed to send verification email: ${errorData}`);
1870
1944
  }
1871
-
1945
+
1946
+ // Store pending email for verify_code
1947
+ pendingRegistrationEmail = email;
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
+
1872
1959
  return {
1873
1960
  content: [{
1874
1961
  type: 'text',
1875
1962
  text: JSON.stringify({
1876
- status: 'pending_verification',
1877
- message: 'Workspace created! Check your email for verification link.',
1963
+ status: 'code_sent',
1964
+ message: `Verification code sent to ${email}`,
1965
+ next_step: 'Ask the user to check their email for a 6-digit code, then call verify_code with the email and code.',
1878
1966
  email,
1879
- expires_in_minutes: 15,
1880
- next_step: 'Check your email, click the verification link, then run check_workspace_status',
1967
+ expires_in_minutes: 10,
1881
1968
  }, null, 2)
1882
1969
  }]
1883
1970
  };
@@ -1894,6 +1981,146 @@ Docs: https://apiclaw.cloud
1894
1981
  };
1895
1982
  }
1896
1983
  }
1984
+
1985
+ case 'verify_code': {
1986
+ const email = (args?.email as string) || pendingRegistrationEmail;
1987
+ const code = args?.code as string;
1988
+
1989
+ if (!email || !code) {
1990
+ return {
1991
+ content: [{
1992
+ type: 'text',
1993
+ text: JSON.stringify({
1994
+ status: 'error',
1995
+ error: 'Both email and code are required.',
1996
+ hint: 'Call register_owner first to receive a verification code.',
1997
+ }, null, 2)
1998
+ }],
1999
+ isError: true
2000
+ };
2001
+ }
2002
+
2003
+ try {
2004
+ const fingerprint = getMachineFingerprint();
2005
+ const result = await convex.mutation("workspaces:verifyOTP" as any, {
2006
+ email,
2007
+ code: code.trim(),
2008
+ fingerprint,
2009
+ }) as {
2010
+ success: boolean;
2011
+ error?: string;
2012
+ message?: string;
2013
+ isNewUser?: boolean;
2014
+ sessionToken?: string;
2015
+ workspace?: { id: string; email: string; tier: string; status: string; usageCount: number; usageLimit: number }
2016
+ };
2017
+
2018
+ if (!result.success) {
2019
+ // Increment attempt counter
2020
+ try {
2021
+ await convex.mutation("workspaces:incrementOTPAttempt" as any, { email, code: code.trim() });
2022
+ } catch (_) {}
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
+
2037
+ return {
2038
+ content: [{
2039
+ type: 'text',
2040
+ text: JSON.stringify({
2041
+ status: 'error',
2042
+ error: result.message || 'Verification failed',
2043
+ hint: result.error === 'code_expired'
2044
+ ? 'Run register_owner again to get a new code.'
2045
+ : 'Check the code and try again.',
2046
+ }, null, 2)
2047
+ }],
2048
+ isError: true
2049
+ };
2050
+ }
2051
+
2052
+ // Success! Save session
2053
+ writeSession(result.sessionToken!, result.workspace!.id, result.workspace!.email);
2054
+
2055
+ // Claim anonymous usage
2056
+ try {
2057
+ const claimResult = await convex.mutation("workspaces:claimAnonymousUsage" as any, {
2058
+ workspaceId: result.workspace!.id,
2059
+ machineFingerprint: fingerprint,
2060
+ }) as { success: boolean; claimedCount?: number };
2061
+ if (claimResult.success && claimResult.claimedCount) {
2062
+ console.error(`[APIClaw] Claimed ${claimResult.claimedCount} anonymous usage records`);
2063
+ }
2064
+ } catch (_) {}
2065
+
2066
+ // Update global context
2067
+ workspaceContext = {
2068
+ sessionToken: result.sessionToken!,
2069
+ workspaceId: result.workspace!.id,
2070
+ email: result.workspace!.email,
2071
+ tier: result.workspace!.tier,
2072
+ usageRemaining: result.workspace!.usageLimit - result.workspace!.usageCount,
2073
+ usageCount: result.workspace!.usageCount,
2074
+ status: result.workspace!.status,
2075
+ };
2076
+
2077
+ pendingRegistrationEmail = null;
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
+
2093
+ return {
2094
+ content: [{
2095
+ type: 'text',
2096
+ text: JSON.stringify({
2097
+ status: 'success',
2098
+ message: result.isNewUser
2099
+ ? `Welcome to APIClaw! Workspace activated for ${result.workspace!.email}`
2100
+ : `Welcome back! Authenticated as ${result.workspace!.email}`,
2101
+ workspace: {
2102
+ email: result.workspace!.email,
2103
+ tier: result.workspace!.tier,
2104
+ usageCount: result.workspace!.usageCount,
2105
+ usageLimit: result.workspace!.usageLimit,
2106
+ },
2107
+ ready: 'You can now use discover_apis and call_api.',
2108
+ }, null, 2)
2109
+ }]
2110
+ };
2111
+ } catch (error) {
2112
+ return {
2113
+ content: [{
2114
+ type: 'text',
2115
+ text: JSON.stringify({
2116
+ status: 'error',
2117
+ error: error instanceof Error ? error.message : 'Verification failed',
2118
+ }, null, 2)
2119
+ }],
2120
+ isError: true
2121
+ };
2122
+ }
2123
+ }
1897
2124
 
1898
2125
  case 'check_workspace_status': {
1899
2126
  // Check if we have a local session
@@ -2273,22 +2500,10 @@ Docs: https://apiclaw.cloud
2273
2500
  };
2274
2501
  }
2275
2502
 
2276
- // Check workspace access
2277
- const access = checkWorkspaceAccess();
2278
- if (!access.allowed) {
2279
- return {
2280
- content: [{
2281
- type: 'text',
2282
- text: JSON.stringify({
2283
- status: 'error',
2284
- error: access.error,
2285
- hint: 'Use register_owner to authenticate your workspace.',
2286
- }, null, 2)
2287
- }],
2288
- isError: true
2289
- };
2290
- }
2291
-
2503
+ // Registration gate: requireVerifiedOwner (single source of truth)
2504
+ const resumeGate = enforceOwner("mcp:resume_chain");
2505
+ if (!resumeGate.ok) return resumeGate.response;
2506
+
2292
2507
  try {
2293
2508
  // Note: The resume_chain function requires the original chain definition
2294
2509
  // In practice, you'd store this or require the caller to provide it
@@ -2411,7 +2626,28 @@ async function main() {
2411
2626
  const transport = new StdioServerTransport();
2412
2627
  await server.connect(transport);
2413
2628
  trackStartup();
2414
-
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
+
2415
2651
  // Validate session on startup
2416
2652
  const hasValidSession = await validateSession();
2417
2653