@nordsym/apiclaw 1.3.5 ā 1.3.7
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/README.md +33 -0
- package/convex/_generated/api.d.ts +12 -0
- package/convex/billing.ts +651 -216
- package/convex/crons.ts +17 -0
- package/convex/email.ts +135 -82
- package/convex/feedback.ts +265 -0
- package/convex/http.ts +80 -4
- package/convex/logs.ts +287 -0
- package/convex/providerKeys.ts +209 -0
- package/convex/providers.ts +18 -0
- package/convex/schema.ts +115 -0
- package/convex/stripeActions.ts +512 -0
- package/convex/webhooks.ts +494 -0
- package/convex/workspaces.ts +74 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +38 -7
- package/dist/cli.js.map +1 -1
- package/dist/index.js +178 -0
- package/dist/index.js.map +1 -1
- package/dist/metered.d.ts +62 -0
- package/dist/metered.d.ts.map +1 -0
- package/dist/metered.js +81 -0
- package/dist/metered.js.map +1 -0
- package/dist/stripe.d.ts +62 -0
- package/dist/stripe.d.ts.map +1 -1
- package/dist/stripe.js +212 -0
- package/dist/stripe.js.map +1 -1
- package/docs/PRD-final-polish.md +117 -0
- package/docs/PRD-mobile-responsive.md +56 -0
- package/docs/PRD-navigation-expansion.md +295 -0
- package/docs/PRD-stripe-billing.md +312 -0
- package/docs/PRD-workspace-cleanup.md +200 -0
- package/landing/src/app/api/billing/checkout/route.ts +109 -0
- package/landing/src/app/api/billing/payment-method/route.ts +118 -0
- package/landing/src/app/api/billing/portal/route.ts +64 -0
- package/landing/src/app/auth/verify/page.tsx +20 -5
- package/landing/src/app/earn/page.tsx +6 -6
- package/landing/src/app/login/page.tsx +1 -1
- package/landing/src/app/page.tsx +70 -70
- package/landing/src/app/providers/dashboard/page.tsx +1 -1
- package/landing/src/app/workspace/page.tsx +3497 -535
- package/landing/src/components/CheckoutButton.tsx +188 -0
- package/landing/src/components/Toast.tsx +84 -0
- package/landing/src/lib/stats.json +1 -1
- package/landing/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/cli.ts +57 -7
- package/src/index.ts +205 -0
- package/src/metered.ts +149 -0
- package/src/stripe.ts +253 -0
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -83,23 +83,73 @@ async function registerOwner(email: string): Promise<void> {
|
|
|
83
83
|
|
|
84
84
|
try {
|
|
85
85
|
const fingerprint = getMachineFingerprint();
|
|
86
|
-
const result = await convex.mutation("workspaces:requestMagicLink" as any, {
|
|
87
|
-
email,
|
|
88
|
-
fingerprint,
|
|
89
|
-
}) as any;
|
|
90
86
|
|
|
91
|
-
|
|
87
|
+
// Use HTTP endpoint for magic link
|
|
88
|
+
const response = await fetch(`${CONVEX_URL.replace('.cloud', '.site')}/workspace/magic-link`, {
|
|
89
|
+
method: 'POST',
|
|
90
|
+
headers: { 'Content-Type': 'application/json' },
|
|
91
|
+
body: JSON.stringify({ email, fingerprint }),
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const result = await response.json() as { success?: boolean; token?: string; error?: string };
|
|
95
|
+
|
|
96
|
+
if (result?.success && result?.token) {
|
|
92
97
|
success(`Magic link sent to ${email}`);
|
|
93
98
|
log(`\nš§ Check your email and click the link to authenticate.`);
|
|
94
|
-
|
|
99
|
+
|
|
100
|
+
// Start polling for verification
|
|
101
|
+
log(`\nā³ Waiting for you to click the link...`);
|
|
102
|
+
log(` (Press Ctrl+C to cancel)\n`);
|
|
103
|
+
|
|
104
|
+
await pollForVerification(result.token, fingerprint);
|
|
95
105
|
} else {
|
|
96
|
-
error(`Failed
|
|
106
|
+
error(`Failed: ${result?.error || 'Unknown error'}`);
|
|
97
107
|
}
|
|
98
108
|
} catch (err) {
|
|
99
109
|
error(`Failed: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
|
100
110
|
}
|
|
101
111
|
}
|
|
102
112
|
|
|
113
|
+
async function pollForVerification(token: string, fingerprint: string): Promise<void> {
|
|
114
|
+
const maxAttempts = 60; // 5 minutes
|
|
115
|
+
for (let i = 0; i < maxAttempts; i++) {
|
|
116
|
+
await new Promise(r => setTimeout(r, 5000)); // Poll every 5 seconds
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
const response = await fetch(`${CONVEX_URL.replace('.cloud', '.site')}/workspace/poll?token=${token}`);
|
|
120
|
+
const result = await response.json() as {
|
|
121
|
+
verified?: boolean;
|
|
122
|
+
sessionToken?: string;
|
|
123
|
+
workspaceId?: string;
|
|
124
|
+
email?: string;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
if (result?.verified && result?.sessionToken) {
|
|
128
|
+
// Save the real session
|
|
129
|
+
writeSession(
|
|
130
|
+
result.sessionToken,
|
|
131
|
+
result.workspaceId || '',
|
|
132
|
+
result.email || ''
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
success(`Authenticated as ${result.email}!`);
|
|
136
|
+
|
|
137
|
+
// Reload workspace context
|
|
138
|
+
await validateSession();
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
} catch {
|
|
142
|
+
// Continue polling
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Show progress dot
|
|
146
|
+
process.stdout.write('.');
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
log('\n');
|
|
150
|
+
error('Verification timed out. Please try again.');
|
|
151
|
+
}
|
|
152
|
+
|
|
103
153
|
async function showStatus(): Promise<void> {
|
|
104
154
|
const valid = await validateSession();
|
|
105
155
|
|
package/src/index.ts
CHANGED
|
@@ -43,6 +43,13 @@ import {
|
|
|
43
43
|
import { executeCapability, listCapabilities, hasCapability } from './capability-router.js';
|
|
44
44
|
import { readSession, writeSession, clearSession, getMachineFingerprint, SessionData } from './session.js';
|
|
45
45
|
import { ConvexHttpClient } from 'convex/browser';
|
|
46
|
+
import {
|
|
47
|
+
getOrCreateCustomer,
|
|
48
|
+
createMeteredCheckoutSession,
|
|
49
|
+
getUsageSummary,
|
|
50
|
+
METERED_BILLING
|
|
51
|
+
} from './stripe.js';
|
|
52
|
+
import { estimateCost } from './metered.js';
|
|
46
53
|
|
|
47
54
|
// Default agent ID for MVP (in production, this would come from auth)
|
|
48
55
|
const DEFAULT_AGENT_ID = 'agent_default';
|
|
@@ -392,6 +399,59 @@ const tools: Tool[] = [
|
|
|
392
399
|
type: 'object',
|
|
393
400
|
properties: {}
|
|
394
401
|
}
|
|
402
|
+
},
|
|
403
|
+
// Metered Billing Tools
|
|
404
|
+
{
|
|
405
|
+
name: 'setup_metered_billing',
|
|
406
|
+
description: 'Set up pay-per-call billing. Creates a subscription that charges $0.002 per API call at end of month.',
|
|
407
|
+
inputSchema: {
|
|
408
|
+
type: 'object',
|
|
409
|
+
properties: {
|
|
410
|
+
email: {
|
|
411
|
+
type: 'string',
|
|
412
|
+
description: 'Email for the billing account'
|
|
413
|
+
},
|
|
414
|
+
success_url: {
|
|
415
|
+
type: 'string',
|
|
416
|
+
description: 'URL to redirect after successful setup',
|
|
417
|
+
default: 'https://apiclaw.nordsym.com/billing/success'
|
|
418
|
+
},
|
|
419
|
+
cancel_url: {
|
|
420
|
+
type: 'string',
|
|
421
|
+
description: 'URL to redirect if setup is cancelled',
|
|
422
|
+
default: 'https://apiclaw.nordsym.com/billing/cancel'
|
|
423
|
+
}
|
|
424
|
+
},
|
|
425
|
+
required: ['email']
|
|
426
|
+
}
|
|
427
|
+
},
|
|
428
|
+
{
|
|
429
|
+
name: 'get_usage_summary',
|
|
430
|
+
description: 'Get current billing period usage and estimated cost for metered billing.',
|
|
431
|
+
inputSchema: {
|
|
432
|
+
type: 'object',
|
|
433
|
+
properties: {
|
|
434
|
+
subscription_id: {
|
|
435
|
+
type: 'string',
|
|
436
|
+
description: 'Stripe subscription ID (stored after setup_metered_billing)'
|
|
437
|
+
}
|
|
438
|
+
},
|
|
439
|
+
required: ['subscription_id']
|
|
440
|
+
}
|
|
441
|
+
},
|
|
442
|
+
{
|
|
443
|
+
name: 'estimate_cost',
|
|
444
|
+
description: 'Estimate the cost for a given number of API calls.',
|
|
445
|
+
inputSchema: {
|
|
446
|
+
type: 'object',
|
|
447
|
+
properties: {
|
|
448
|
+
call_count: {
|
|
449
|
+
type: 'number',
|
|
450
|
+
description: 'Number of API calls to estimate cost for'
|
|
451
|
+
}
|
|
452
|
+
},
|
|
453
|
+
required: ['call_count']
|
|
454
|
+
}
|
|
395
455
|
}
|
|
396
456
|
];
|
|
397
457
|
|
|
@@ -1201,6 +1261,151 @@ Docs: https://apiclaw.nordsym.com
|
|
|
1201
1261
|
}
|
|
1202
1262
|
}
|
|
1203
1263
|
|
|
1264
|
+
// Metered Billing Tools
|
|
1265
|
+
case 'setup_metered_billing': {
|
|
1266
|
+
const { email, success_url, cancel_url } = args as {
|
|
1267
|
+
email: string;
|
|
1268
|
+
success_url?: string;
|
|
1269
|
+
cancel_url?: string;
|
|
1270
|
+
};
|
|
1271
|
+
|
|
1272
|
+
if (!email) {
|
|
1273
|
+
return {
|
|
1274
|
+
content: [{
|
|
1275
|
+
type: 'text',
|
|
1276
|
+
text: JSON.stringify({ status: 'error', error: 'Email is required' }, null, 2)
|
|
1277
|
+
}],
|
|
1278
|
+
isError: true
|
|
1279
|
+
};
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
// Create or get customer
|
|
1283
|
+
const customerResult = await getOrCreateCustomer(email, email);
|
|
1284
|
+
if ('error' in customerResult) {
|
|
1285
|
+
return {
|
|
1286
|
+
content: [{
|
|
1287
|
+
type: 'text',
|
|
1288
|
+
text: JSON.stringify({ status: 'error', error: customerResult.error }, null, 2)
|
|
1289
|
+
}],
|
|
1290
|
+
isError: true
|
|
1291
|
+
};
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
// Create checkout session for metered subscription
|
|
1295
|
+
const checkoutResult = await createMeteredCheckoutSession(
|
|
1296
|
+
email,
|
|
1297
|
+
success_url || 'https://apiclaw.nordsym.com/billing/success',
|
|
1298
|
+
cancel_url || 'https://apiclaw.nordsym.com/billing/cancel'
|
|
1299
|
+
);
|
|
1300
|
+
|
|
1301
|
+
if ('error' in checkoutResult) {
|
|
1302
|
+
return {
|
|
1303
|
+
content: [{
|
|
1304
|
+
type: 'text',
|
|
1305
|
+
text: JSON.stringify({ status: 'error', error: checkoutResult.error }, null, 2)
|
|
1306
|
+
}],
|
|
1307
|
+
isError: true
|
|
1308
|
+
};
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
return {
|
|
1312
|
+
content: [{
|
|
1313
|
+
type: 'text',
|
|
1314
|
+
text: JSON.stringify({
|
|
1315
|
+
status: 'checkout_ready',
|
|
1316
|
+
message: 'Complete checkout to activate pay-per-call billing',
|
|
1317
|
+
checkout_url: checkoutResult.url,
|
|
1318
|
+
session_id: checkoutResult.sessionId,
|
|
1319
|
+
customer_id: customerResult.customerId,
|
|
1320
|
+
pricing: {
|
|
1321
|
+
per_call: '$0.002',
|
|
1322
|
+
billing_period: 'monthly',
|
|
1323
|
+
billed_at: 'end of period based on usage'
|
|
1324
|
+
}
|
|
1325
|
+
}, null, 2)
|
|
1326
|
+
}]
|
|
1327
|
+
};
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
case 'get_usage_summary': {
|
|
1331
|
+
const { subscription_id } = args as { subscription_id: string };
|
|
1332
|
+
|
|
1333
|
+
if (!subscription_id) {
|
|
1334
|
+
return {
|
|
1335
|
+
content: [{
|
|
1336
|
+
type: 'text',
|
|
1337
|
+
text: JSON.stringify({ status: 'error', error: 'subscription_id is required' }, null, 2)
|
|
1338
|
+
}],
|
|
1339
|
+
isError: true
|
|
1340
|
+
};
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
const usage = await getUsageSummary(subscription_id);
|
|
1344
|
+
if ('error' in usage) {
|
|
1345
|
+
return {
|
|
1346
|
+
content: [{
|
|
1347
|
+
type: 'text',
|
|
1348
|
+
text: JSON.stringify({ status: 'error', error: usage.error }, null, 2)
|
|
1349
|
+
}],
|
|
1350
|
+
isError: true
|
|
1351
|
+
};
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
return {
|
|
1355
|
+
content: [{
|
|
1356
|
+
type: 'text',
|
|
1357
|
+
text: JSON.stringify({
|
|
1358
|
+
status: 'success',
|
|
1359
|
+
billing_period: {
|
|
1360
|
+
start: new Date(usage.period.start * 1000).toISOString(),
|
|
1361
|
+
end: new Date(usage.period.end * 1000).toISOString()
|
|
1362
|
+
},
|
|
1363
|
+
usage: {
|
|
1364
|
+
total_calls: usage.totalCalls,
|
|
1365
|
+
price_per_call: METERED_BILLING.pricePerCall,
|
|
1366
|
+
estimated_cost: `$${usage.totalCost.toFixed(4)}`
|
|
1367
|
+
}
|
|
1368
|
+
}, null, 2)
|
|
1369
|
+
}]
|
|
1370
|
+
};
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
case 'estimate_cost': {
|
|
1374
|
+
const { call_count } = args as { call_count: number };
|
|
1375
|
+
|
|
1376
|
+
if (!call_count || call_count < 0) {
|
|
1377
|
+
return {
|
|
1378
|
+
content: [{
|
|
1379
|
+
type: 'text',
|
|
1380
|
+
text: JSON.stringify({ status: 'error', error: 'Valid call_count is required' }, null, 2)
|
|
1381
|
+
}],
|
|
1382
|
+
isError: true
|
|
1383
|
+
};
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
const estimate = estimateCost(call_count);
|
|
1387
|
+
|
|
1388
|
+
return {
|
|
1389
|
+
content: [{
|
|
1390
|
+
type: 'text',
|
|
1391
|
+
text: JSON.stringify({
|
|
1392
|
+
status: 'success',
|
|
1393
|
+
estimate: {
|
|
1394
|
+
calls: estimate.calls,
|
|
1395
|
+
price_per_call: `$${estimate.pricePerCall}`,
|
|
1396
|
+
total_cost: `$${estimate.totalCost.toFixed(4)}`,
|
|
1397
|
+
currency: estimate.currency
|
|
1398
|
+
},
|
|
1399
|
+
examples: {
|
|
1400
|
+
'100 calls': `$${(100 * METERED_BILLING.pricePerCall).toFixed(2)}`,
|
|
1401
|
+
'1,000 calls': `$${(1000 * METERED_BILLING.pricePerCall).toFixed(2)}`,
|
|
1402
|
+
'10,000 calls': `$${(10000 * METERED_BILLING.pricePerCall).toFixed(2)}`
|
|
1403
|
+
}
|
|
1404
|
+
}, null, 2)
|
|
1405
|
+
}]
|
|
1406
|
+
};
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1204
1409
|
default:
|
|
1205
1410
|
return {
|
|
1206
1411
|
content: [
|
package/src/metered.ts
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* APIClaw Metered Billing - Pay-per-call execution wrapper
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* 1. Customer signs up with metered subscription
|
|
6
|
+
* 2. Each API call via executeMetered() reports usage to Stripe
|
|
7
|
+
* 3. Customer is billed at end of billing period based on usage
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { executeAPICall } from './execute.js';
|
|
11
|
+
import { reportUsage, hasActiveMeteredSubscription, METERED_BILLING } from './stripe.js';
|
|
12
|
+
|
|
13
|
+
interface MeteredResult {
|
|
14
|
+
success: boolean;
|
|
15
|
+
provider: string;
|
|
16
|
+
action: string;
|
|
17
|
+
data?: unknown;
|
|
18
|
+
error?: string;
|
|
19
|
+
cost?: number;
|
|
20
|
+
billing?: {
|
|
21
|
+
tracked: boolean;
|
|
22
|
+
customerId?: string;
|
|
23
|
+
pricePerCall: number;
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface MeteredOptions {
|
|
28
|
+
/** Stripe customer ID for usage tracking */
|
|
29
|
+
customerId?: string;
|
|
30
|
+
/** Skip usage reporting (for testing/free tier) */
|
|
31
|
+
skipBilling?: boolean;
|
|
32
|
+
/** Provider-specific API key (Direct Call mode) */
|
|
33
|
+
customerKey?: string;
|
|
34
|
+
/** User ID for dynamic providers */
|
|
35
|
+
userId?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Execute an API call with metered billing tracking
|
|
40
|
+
*
|
|
41
|
+
* If customerId is provided and has active metered subscription,
|
|
42
|
+
* usage is reported to Stripe after successful execution.
|
|
43
|
+
*/
|
|
44
|
+
export async function executeMetered(
|
|
45
|
+
providerId: string,
|
|
46
|
+
action: string,
|
|
47
|
+
params: Record<string, unknown>,
|
|
48
|
+
options: MeteredOptions = {}
|
|
49
|
+
): Promise<MeteredResult> {
|
|
50
|
+
const { customerId, skipBilling, customerKey, userId } = options;
|
|
51
|
+
|
|
52
|
+
// Execute the API call
|
|
53
|
+
const result = await executeAPICall(
|
|
54
|
+
providerId,
|
|
55
|
+
action,
|
|
56
|
+
params,
|
|
57
|
+
userId,
|
|
58
|
+
customerKey
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
// Build response
|
|
62
|
+
const response: MeteredResult = {
|
|
63
|
+
...result,
|
|
64
|
+
billing: {
|
|
65
|
+
tracked: false,
|
|
66
|
+
pricePerCall: METERED_BILLING.pricePerCall,
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
// Skip billing if requested or no customer
|
|
71
|
+
if (skipBilling || !customerId) {
|
|
72
|
+
return response;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Skip billing for failed calls
|
|
76
|
+
if (!result.success) {
|
|
77
|
+
return response;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Check for active metered subscription
|
|
81
|
+
const subscription = await hasActiveMeteredSubscription(customerId);
|
|
82
|
+
if (!subscription.active) {
|
|
83
|
+
console.log(`Customer ${customerId} has no active metered subscription`);
|
|
84
|
+
return response;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Report usage to Stripe meter
|
|
88
|
+
const usageReport = await reportUsage(
|
|
89
|
+
customerId,
|
|
90
|
+
1,
|
|
91
|
+
`${customerId}_${providerId}_${action}_${Date.now()}`
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
if (usageReport.success) {
|
|
95
|
+
response.billing = {
|
|
96
|
+
tracked: true,
|
|
97
|
+
customerId,
|
|
98
|
+
pricePerCall: METERED_BILLING.pricePerCall,
|
|
99
|
+
};
|
|
100
|
+
} else {
|
|
101
|
+
console.error(`Failed to report usage for ${customerId}:`, usageReport.error);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return response;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Execute multiple API calls in batch with usage tracking
|
|
109
|
+
*/
|
|
110
|
+
export async function executeMeteredBatch(
|
|
111
|
+
calls: Array<{
|
|
112
|
+
provider: string;
|
|
113
|
+
action: string;
|
|
114
|
+
params: Record<string, unknown>;
|
|
115
|
+
}>,
|
|
116
|
+
options: MeteredOptions = {}
|
|
117
|
+
): Promise<MeteredResult[]> {
|
|
118
|
+
const results = await Promise.all(
|
|
119
|
+
calls.map((call) =>
|
|
120
|
+
executeMetered(call.provider, call.action, call.params, options)
|
|
121
|
+
)
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
return results;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Calculate estimated cost for a number of API calls
|
|
129
|
+
*/
|
|
130
|
+
export function estimateCost(callCount: number): {
|
|
131
|
+
calls: number;
|
|
132
|
+
pricePerCall: number;
|
|
133
|
+
totalCost: number;
|
|
134
|
+
currency: string;
|
|
135
|
+
} {
|
|
136
|
+
return {
|
|
137
|
+
calls: callCount,
|
|
138
|
+
pricePerCall: METERED_BILLING.pricePerCall,
|
|
139
|
+
totalCost: callCount * METERED_BILLING.pricePerCall,
|
|
140
|
+
currency: 'USD',
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Get metered billing configuration
|
|
146
|
+
*/
|
|
147
|
+
export function getMeteredConfig(): typeof METERED_BILLING {
|
|
148
|
+
return METERED_BILLING;
|
|
149
|
+
}
|