@apiosk/mcp 1.3.1 → 1.7.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,552 @@
1
+ // Hosted-payment bridge: turn a wallet sign-in (dashboard session) into a
2
+ // payable connect token.
3
+ //
4
+ // A hosted MCP buyer (ChatGPT, Claude, ...) signs in on the /authorize page by
5
+ // signing a message with their browser wallet. That proves identity and yields
6
+ // an Apiosk dashboard (Supabase) session, but the gateway cannot settle a paid
7
+ // call from a browser wallet: it never holds that key. The only autonomous
8
+ // buyer rail the gateway can settle is a managed (custodial) agent wallet
9
+ // authorized by an `aw_live_…` connect token (see gateway proxy/wallet.rs).
10
+ //
11
+ // So after sign-in we resolve the user's first payable managed wallet and mint
12
+ // a connect token for it. That token is carried on the OAuth access token
13
+ // (extra.apiosk_connect_token) and the runtime threads it to the gateway as
14
+ // X-Apiosk-Connect-Token, which settles the call from the managed wallet.
15
+ //
16
+ // The insert path mirrors the buyer portal's createConnection exactly
17
+ // (buyer/src/lib/data/connections.ts + agent-wallet.ts) so the token format is
18
+ // byte-identical and RLS applies as the signed-in user (we call PostgREST with
19
+ // the user's own JWT as the Authorization bearer). The one service-role
20
+ // exception is reclaimWalletRowForUser: rebinding an orphaned connected-wallet
21
+ // row to the user who just proved control of the address via SIWE.
22
+
23
+ import crypto from "node:crypto";
24
+
25
+ const DEFAULT_SUPABASE_URL = "https://jgjoiyqdyypouskftzeq.supabase.co";
26
+ // Matches the buyer portal's DEFAULT_EXPIRY_DAYS for created connections.
27
+ const CONNECT_TOKEN_TTL_DAYS = 90;
28
+ const CONNECT_TOKEN_LABEL = "Apiosk MCP connection";
29
+ const BASE_CHAIN_ID = 8453;
30
+ const BASE_USDC_ADDRESS = "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913";
31
+
32
+ function trimString(value) {
33
+ return typeof value === "string" ? value.trim() : "";
34
+ }
35
+
36
+ function normalizeBaseUrl(value, fallback) {
37
+ return (trimString(value) || fallback).replace(/\/+$/, "");
38
+ }
39
+
40
+ function asNumber(value) {
41
+ const parsed = Number(value);
42
+ return Number.isFinite(parsed) ? parsed : 0;
43
+ }
44
+
45
+ function statusError(message, status = 500) {
46
+ const error = new Error(message);
47
+ error.status = status;
48
+ return error;
49
+ }
50
+
51
+ function isoAfterDays(days) {
52
+ const date = new Date();
53
+ date.setDate(date.getDate() + days);
54
+ return date.toISOString();
55
+ }
56
+
57
+ // Same key precedence as resolveMcpWalletAuthConfig in oauth.mjs, so the mint
58
+ // reuses whatever Supabase credentials already power wallet sign-in.
59
+ export function resolveSupabaseConfig(env = process.env) {
60
+ const supabaseUrl = normalizeBaseUrl(
61
+ env.APIOSK_SUPABASE_URL || env.SUPABASE_URL,
62
+ DEFAULT_SUPABASE_URL
63
+ );
64
+ const serviceRoleKey =
65
+ trimString(env.APIOSK_SUPABASE_SERVICE_ROLE_KEY) ||
66
+ trimString(env.SUPABASE_SERVICE_ROLE_KEY) ||
67
+ trimString(env.SUPABASE_SERVICE_KEY);
68
+ const apiKey =
69
+ serviceRoleKey ||
70
+ trimString(env.APIOSK_SUPABASE_PUBLISHABLE_KEY) ||
71
+ trimString(env.APIOSK_SUPABASE_ANON_KEY) ||
72
+ trimString(env.SUPABASE_PUBLISHABLE_KEY) ||
73
+ trimString(env.SUPABASE_ANON_KEY);
74
+
75
+ return {
76
+ supabaseUrl,
77
+ apiKey,
78
+ serviceRoleKey,
79
+ configured: Boolean(supabaseUrl && apiKey),
80
+ };
81
+ }
82
+
83
+ // Byte-identical to buyer/src/lib/agent-wallet.ts generateConnectToken():
84
+ // token = `aw_live_${12-hex}_${32-char-base64url}`
85
+ // tokenHash = sha256(token) as lowercase hex
86
+ // tokenPrefix = token.slice(0, 22)
87
+ // The gateway stores sha256(token) and matches on token_prefix, so the format
88
+ // must not drift.
89
+ export function generateConnectToken() {
90
+ const publicId = crypto.randomBytes(6).toString("hex");
91
+ const secret = crypto.randomBytes(24).toString("base64url");
92
+ const token = `aw_live_${publicId}_${secret}`;
93
+ const tokenHash = crypto.createHash("sha256").update(token).digest("hex");
94
+ const tokenPrefix = token.slice(0, 22);
95
+ return { token, tokenHash, tokenPrefix };
96
+ }
97
+
98
+ export async function supabaseRest(
99
+ config,
100
+ path,
101
+ { method = "GET", sessionToken, body, prefer } = {}
102
+ ) {
103
+ const headers = {
104
+ apikey: config.apiKey,
105
+ authorization: `Bearer ${sessionToken}`,
106
+ accept: "application/json",
107
+ };
108
+ if (body !== undefined) {
109
+ headers["content-type"] = "application/json";
110
+ }
111
+ if (prefer) {
112
+ headers.prefer = prefer;
113
+ }
114
+
115
+ const response = await fetch(`${config.supabaseUrl}/rest/v1/${path}`, {
116
+ method,
117
+ headers,
118
+ cache: "no-store",
119
+ body: body === undefined ? undefined : JSON.stringify(body),
120
+ });
121
+
122
+ const text = await response.text();
123
+ let payload = null;
124
+ try {
125
+ payload = text ? JSON.parse(text) : null;
126
+ } catch {
127
+ payload = text || null;
128
+ }
129
+
130
+ if (!response.ok) {
131
+ const detail =
132
+ (payload && typeof payload === "object"
133
+ ? payload.message || payload.error || payload.hint
134
+ : payload) || `HTTP ${response.status}`;
135
+ const error = new Error(
136
+ `Supabase REST ${method} ${path} failed: ${
137
+ typeof detail === "string" ? detail : JSON.stringify(detail)
138
+ }`
139
+ );
140
+ error.status = response.status;
141
+ throw error;
142
+ }
143
+
144
+ return payload;
145
+ }
146
+
147
+ export async function listHostedPayableWallets({
148
+ env = process.env,
149
+ sessionToken,
150
+ } = {}) {
151
+ const token = trimString(sessionToken);
152
+ const config = resolveSupabaseConfig(env);
153
+ if (!token || !config.configured) return [];
154
+
155
+ const rows = await supabaseRest(
156
+ config,
157
+ "agent_wallets?select=id,label,wallet_address,status,daily_limit_usdc,per_tx_limit_usdc,encrypted_private_key,created_at&status=eq.active&order=created_at.asc",
158
+ { sessionToken: token }
159
+ );
160
+ if (!Array.isArray(rows)) return [];
161
+
162
+ return rows
163
+ .filter((wallet) => wallet && trimString(wallet.id) && trimString(wallet.wallet_address))
164
+ .map((wallet) => ({
165
+ id: wallet.id,
166
+ label: trimString(wallet.label) || "Managed wallet",
167
+ address: String(wallet.wallet_address).toLowerCase(),
168
+ dailyLimitUsdc: asNumber(wallet.daily_limit_usdc),
169
+ perTxLimitUsdc: asNumber(wallet.per_tx_limit_usdc),
170
+ custodial: Boolean(trimString(wallet.encrypted_private_key)),
171
+ }));
172
+ }
173
+
174
+ function isDuplicateWalletAddressError(error) {
175
+ const message = error instanceof Error ? error.message : String(error || "");
176
+ return error?.status === 409 || /duplicate key|23505|agent_wallets_wallet_address_key/i.test(message);
177
+ }
178
+
179
+ async function fetchWalletRowByAddress(config, { bearer, address, select }) {
180
+ const rows = await supabaseRest(
181
+ config,
182
+ `agent_wallets?select=${select}&wallet_address=eq.${encodeURIComponent(address)}&limit=1`,
183
+ { sessionToken: bearer }
184
+ );
185
+ return Array.isArray(rows) && rows.length ? rows[0] : null;
186
+ }
187
+
188
+ // Service-role repair for the reconnect 409: agent_wallets.wallet_address is
189
+ // globally UNIQUE, so when the row for this address is invisible to the user's
190
+ // JWT (it belongs to an older auth user, e.g. after re-onboarding) the plain
191
+ // insert always collides. The caller has just proven control of the address by
192
+ // signing the SIWE message, so rebinding the row to the current user is the
193
+ // legitimate outcome. Custodial rows (stored key) are never rebound — proving
194
+ // control of the address does not prove ownership of the platform-held key's
195
+ // account.
196
+ async function reclaimWalletRowForUser(config, { userId, address }) {
197
+ const serviceKey = trimString(config.serviceRoleKey);
198
+ if (!serviceKey) return false;
199
+
200
+ const row = await fetchWalletRowByAddress(config, {
201
+ bearer: serviceKey,
202
+ address,
203
+ select: "id,user_id,status,encrypted_private_key",
204
+ });
205
+ if (!row || trimString(row.encrypted_private_key)) return false;
206
+
207
+ const previousOwner = trimString(row.user_id);
208
+ const patched = await supabaseRest(config, `agent_wallets?id=eq.${encodeURIComponent(row.id)}`, {
209
+ method: "PATCH",
210
+ sessionToken: serviceKey,
211
+ prefer: "return=representation",
212
+ body: { user_id: userId, status: "active", updated_at: new Date().toISOString() },
213
+ });
214
+ if (!Array.isArray(patched) || !patched.length) return false;
215
+
216
+ if (previousOwner && previousOwner !== userId) {
217
+ // The row changed hands: revoke live connect tokens minted under the old
218
+ // account so they cannot keep authorizing spends from this wallet.
219
+ await supabaseRest(
220
+ config,
221
+ `agent_wallet_connect_tokens?wallet_id=eq.${encodeURIComponent(row.id)}&revoked_at=is.null`,
222
+ {
223
+ method: "PATCH",
224
+ sessionToken: serviceKey,
225
+ body: { revoked_at: new Date().toISOString() },
226
+ }
227
+ ).catch(() => {});
228
+ }
229
+ return true;
230
+ }
231
+
232
+ async function ensureConnectedWalletRegistered(config, { sessionToken, userId, address }) {
233
+ // The payable listing filters on status=active, so a paused/revoked row the
234
+ // user already owns must be looked up separately and re-activated instead of
235
+ // re-inserted (the global UNIQUE on wallet_address rejects the insert).
236
+ const own = await fetchWalletRowByAddress(config, {
237
+ bearer: sessionToken,
238
+ address,
239
+ select: "id,status",
240
+ });
241
+ if (own) {
242
+ if (own.status === "active") return;
243
+ const updated = await supabaseRest(config, `agent_wallets?id=eq.${encodeURIComponent(own.id)}`, {
244
+ method: "PATCH",
245
+ sessionToken,
246
+ prefer: "return=representation",
247
+ body: { status: "active", updated_at: new Date().toISOString() },
248
+ });
249
+ if (Array.isArray(updated) && updated.length) return;
250
+ if (await reclaimWalletRowForUser(config, { userId, address })) return;
251
+ throw statusError(
252
+ `This wallet is registered on your account but ${trimString(own.status) || "inactive"}, and it could not be re-activated automatically. Re-activate it in the Apiosk dashboard, then connect again.`,
253
+ 409
254
+ );
255
+ }
256
+
257
+ try {
258
+ const inserted = await supabaseRest(config, "agent_wallets", {
259
+ method: "POST",
260
+ sessionToken,
261
+ prefer: "return=representation",
262
+ body: {
263
+ user_id: userId,
264
+ label: "Connected wallet",
265
+ wallet_address: address,
266
+ chain: "base",
267
+ chain_id: BASE_CHAIN_ID,
268
+ status: "active",
269
+ daily_limit_usdc: 10,
270
+ per_tx_limit_usdc: 1,
271
+ },
272
+ });
273
+ if (!Array.isArray(inserted) || !inserted.length) {
274
+ throw statusError("Could not register the connected wallet for payments.", 502);
275
+ }
276
+ } catch (error) {
277
+ if (!isDuplicateWalletAddressError(error)) throw error;
278
+ // A concurrent connect may have inserted the row for this same user
279
+ // between our lookup and the POST — if it is visible now, adopt it.
280
+ const raced = await fetchWalletRowByAddress(config, {
281
+ bearer: sessionToken,
282
+ address,
283
+ select: "id,status",
284
+ });
285
+ if (raced) {
286
+ if (raced.status === "active") return;
287
+ const updated = await supabaseRest(config, `agent_wallets?id=eq.${encodeURIComponent(raced.id)}`, {
288
+ method: "PATCH",
289
+ sessionToken,
290
+ prefer: "return=representation",
291
+ body: { status: "active", updated_at: new Date().toISOString() },
292
+ });
293
+ if (Array.isArray(updated) && updated.length) return;
294
+ }
295
+ if (await reclaimWalletRowForUser(config, { userId, address })) return;
296
+ throw statusError(
297
+ "This wallet address is already registered to a different Apiosk account, so it could not be linked here automatically. Sign in with the account that originally registered it, or contact support to move the wallet.",
298
+ 409
299
+ );
300
+ }
301
+ }
302
+
303
+ export async function prepareHostedPaymentWallets({
304
+ env = process.env,
305
+ sessionToken,
306
+ userId,
307
+ connectedAddress,
308
+ } = {}) {
309
+ const token = trimString(sessionToken);
310
+ const uid = trimString(userId);
311
+ const address = trimString(connectedAddress).toLowerCase();
312
+ const config = resolveSupabaseConfig(env);
313
+ if (!token || !uid || !config.configured || !/^0x[a-f0-9]{40}$/.test(address)) return [];
314
+
315
+ let wallets = await listHostedPayableWallets({ env, sessionToken: token });
316
+ if (!wallets.some((wallet) => wallet.address === address)) {
317
+ await ensureConnectedWalletRegistered(config, { sessionToken: token, userId: uid, address });
318
+ wallets = await listHostedPayableWallets({ env, sessionToken: token });
319
+ }
320
+
321
+ return wallets
322
+ .filter((wallet) => wallet.custodial || wallet.address === address)
323
+ .map((wallet) => ({
324
+ ...wallet,
325
+ connected: wallet.address === address,
326
+ requiresApproval: !wallet.custodial,
327
+ }));
328
+ }
329
+
330
+ function resolveSettlementConfig(env = process.env) {
331
+ return {
332
+ contractAddress: trimString(env.SETTLEMENT_CONTRACT_ADDRESS).toLowerCase(),
333
+ rpcUrl: trimString(env.SETTLEMENT_RPC_URL) || "https://mainnet.base.org",
334
+ usdcAddress: BASE_USDC_ADDRESS,
335
+ chainId: BASE_CHAIN_ID,
336
+ };
337
+ }
338
+
339
+ export function hostedSettlementPublicConfig(env = process.env) {
340
+ const config = resolveSettlementConfig(env);
341
+ return /^0x[a-f0-9]{40}$/.test(config.contractAddress)
342
+ ? { contractAddress: config.contractAddress, usdcAddress: config.usdcAddress, chainId: config.chainId }
343
+ : null;
344
+ }
345
+
346
+ async function rpc(rpcUrl, method, params) {
347
+ const response = await fetch(rpcUrl, {
348
+ method: "POST",
349
+ headers: { "content-type": "application/json" },
350
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }),
351
+ });
352
+ const body = await response.json().catch(() => ({}));
353
+ if (!response.ok || body.error) throw statusError("Could not verify the on-chain USDC approval.", 502);
354
+ return body.result;
355
+ }
356
+
357
+ async function verifyUsdcApproval({ env, txHash, payer, minimumUsdc }) {
358
+ const config = resolveSettlementConfig(env);
359
+ const hash = trimString(txHash).toLowerCase();
360
+ if (!/^0x[a-f0-9]{64}$/.test(hash) || !/^0x[a-f0-9]{40}$/.test(config.contractAddress)) {
361
+ throw statusError("A confirmed Base USDC approval is required for this wallet.", 400);
362
+ }
363
+ const [receipt, transaction] = await Promise.all([
364
+ rpc(config.rpcUrl, "eth_getTransactionReceipt", [hash]),
365
+ rpc(config.rpcUrl, "eth_getTransactionByHash", [hash]),
366
+ ]);
367
+ if (!receipt || receipt.status !== "0x1" || !transaction) {
368
+ throw statusError("The USDC approval transaction is not confirmed successfully.", 400);
369
+ }
370
+ const from = trimString(transaction.from).toLowerCase();
371
+ const to = trimString(transaction.to).toLowerCase();
372
+ const input = trimString(transaction.input).toLowerCase();
373
+ const spenderWord = config.contractAddress.slice(2).padStart(64, "0");
374
+ if (
375
+ from !== String(payer).toLowerCase() ||
376
+ to !== config.usdcAddress ||
377
+ !input.startsWith(`0x095ea7b3${spenderWord}`) ||
378
+ input.length < 138
379
+ ) {
380
+ throw statusError("The transaction does not authorize the Apiosk settlement contract for this wallet.", 400);
381
+ }
382
+ const approvedAtomic = BigInt(`0x${input.slice(74, 138)}`);
383
+ const requiredAtomic = BigInt(Math.ceil(Number(minimumUsdc) * 1_000_000));
384
+ if (approvedAtomic < requiredAtomic) {
385
+ throw statusError("The USDC approval is lower than the selected daily spending limit.", 400);
386
+ }
387
+ }
388
+
389
+ /**
390
+ * Resolve the signed-in user's first payable managed wallet (active + has a
391
+ * custodied private key) and mint an `aw_live_…` connect token authorizing it.
392
+ *
393
+ * Best-effort: returns `null` (never throws) when Supabase is not configured,
394
+ * the user has no payable managed wallet, or any REST call fails. Callers embed
395
+ * the returned `connectToken` on the OAuth token so the gateway can settle paid
396
+ * calls from that managed wallet.
397
+ */
398
+ export async function mintHostedConnectToken({
399
+ env = process.env,
400
+ sessionToken,
401
+ userId,
402
+ walletId,
403
+ dailyLimitUsdc,
404
+ perTxLimitUsdc,
405
+ approvalTxHash,
406
+ strict = false,
407
+ } = {}) {
408
+ try {
409
+ const token = trimString(sessionToken);
410
+ const uid = trimString(userId);
411
+ if (!token || !uid) {
412
+ return null;
413
+ }
414
+
415
+ const config = resolveSupabaseConfig(env);
416
+ if (!config.configured) {
417
+ return null;
418
+ }
419
+
420
+ const payableWallets = await listHostedPayableWallets({ env, sessionToken: token });
421
+ const requestedWalletId = trimString(walletId);
422
+ const payable = requestedWalletId
423
+ ? payableWallets.find((wallet) => wallet.id === requestedWalletId)
424
+ : payableWallets.find((wallet) => wallet.custodial);
425
+ if (!payable) {
426
+ if (strict) {
427
+ throw statusError(
428
+ "No active payable Apiosk wallet was found. Create or activate a managed wallet before connecting this app.",
429
+ 400
430
+ );
431
+ }
432
+ return null;
433
+ }
434
+
435
+ const requestedDaily = dailyLimitUsdc === undefined ? payable.dailyLimitUsdc : Number(dailyLimitUsdc);
436
+ const requestedPerTx = perTxLimitUsdc === undefined ? payable.perTxLimitUsdc : Number(perTxLimitUsdc);
437
+ if (!Number.isFinite(requestedDaily) || requestedDaily <= 0) {
438
+ throw statusError("Daily spending limit must be greater than 0 USDC.", 400);
439
+ }
440
+ if (!Number.isFinite(requestedPerTx) || requestedPerTx <= 0) {
441
+ throw statusError("Per-request spending limit must be greater than 0 USDC.", 400);
442
+ }
443
+ if (requestedPerTx > requestedDaily) {
444
+ throw statusError("Per-request spending limit cannot exceed the daily limit.", 400);
445
+ }
446
+ // Keep accidental approvals bounded. These are authorization guardrails,
447
+ // not account balances; higher limits can still be configured in the portal.
448
+ if (requestedDaily > 1000 || requestedPerTx > 100) {
449
+ throw statusError("MCP limits may not exceed 1,000 USDC/day or 100 USDC/request.", 400);
450
+ }
451
+
452
+ const address = payable.address;
453
+ const dailyLimit = requestedDaily;
454
+ const perTxLimit = requestedPerTx;
455
+ if (!payable.custodial) {
456
+ if (!strict) return null;
457
+ await verifyUsdcApproval({
458
+ env,
459
+ txHash: approvalTxHash,
460
+ payer: address,
461
+ minimumUsdc: dailyLimit,
462
+ });
463
+ }
464
+ const { token: connectToken, tokenHash, tokenPrefix } = generateConnectToken();
465
+ const expiresAt = isoAfterDays(CONNECT_TOKEN_TTL_DAYS);
466
+ const permissions = {
467
+ scope: ["x402.verify", "x402.settle"],
468
+ wallet_ids: [payable.id],
469
+ wallets: [
470
+ {
471
+ id: payable.id,
472
+ address,
473
+ daily_limit_usdc: dailyLimit,
474
+ per_tx_limit_usdc: perTxLimit,
475
+ },
476
+ ],
477
+ include_sepa: false,
478
+ label: CONNECT_TOKEN_LABEL,
479
+ };
480
+
481
+ const inserted = await supabaseRest(config, "agent_wallet_connect_tokens", {
482
+ method: "POST",
483
+ sessionToken: token,
484
+ prefer: "return=representation",
485
+ body: {
486
+ wallet_id: payable.id,
487
+ user_id: uid,
488
+ token_prefix: tokenPrefix,
489
+ token_hash: tokenHash,
490
+ token_name: CONNECT_TOKEN_LABEL,
491
+ permissions,
492
+ include_sepa: false,
493
+ sepa_per_tx_limit_eur: null,
494
+ sepa_daily_limit_eur: null,
495
+ expires_at: expiresAt,
496
+ },
497
+ });
498
+
499
+ const row = Array.isArray(inserted) ? inserted[0] : inserted;
500
+ const tokenId = row && trimString(row.id);
501
+ if (!tokenId) {
502
+ return null;
503
+ }
504
+
505
+ try {
506
+ await supabaseRest(config, "agent_wallet_connect_token_wallets", {
507
+ method: "POST",
508
+ sessionToken: token,
509
+ body: [{
510
+ token_id: tokenId,
511
+ wallet_id: payable.id,
512
+ user_id: uid,
513
+ daily_limit_usdc: dailyLimit,
514
+ per_tx_limit_usdc: perTxLimit,
515
+ }],
516
+ });
517
+ } catch (joinError) {
518
+ // Roll back the dangling token so a failed link never leaves an
519
+ // unusable connect token behind (mirrors the buyer portal cleanup).
520
+ try {
521
+ await supabaseRest(
522
+ config,
523
+ `agent_wallet_connect_tokens?id=eq.${encodeURIComponent(tokenId)}`,
524
+ {
525
+ method: "PATCH",
526
+ sessionToken: token,
527
+ body: { revoked_at: new Date().toISOString() },
528
+ }
529
+ );
530
+ } catch {
531
+ // Best-effort; the orphan token authorizes no wallet, so it is inert.
532
+ }
533
+ throw joinError;
534
+ }
535
+
536
+ return {
537
+ connectToken,
538
+ walletAddress: address,
539
+ walletId: payable.id,
540
+ tokenId,
541
+ expiresAt,
542
+ };
543
+ } catch (error) {
544
+ console.warn(
545
+ `Hosted connect-token mint failed: ${
546
+ error instanceof Error ? error.message : String(error)
547
+ }`
548
+ );
549
+ if (strict) throw error;
550
+ return null;
551
+ }
552
+ }