@apiosk/mcp 1.7.0 → 2.0.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.
Files changed (100) hide show
  1. package/README.md +83 -612
  2. package/assets/brand/apiosk-a-20260921.png +0 -0
  3. package/assets/brand/apple-touch-icon.png +0 -0
  4. package/assets/brand/favicon-dark.ico +0 -0
  5. package/assets/brand/favicon-light.ico +0 -0
  6. package/assets/brand/favicon.ico +0 -0
  7. package/assets/brand/icon-192.png +0 -0
  8. package/assets/brand/icon-512.png +0 -0
  9. package/assets/brand/icon-maskable-512.png +0 -0
  10. package/assets/brand/inter-OFL.txt +93 -0
  11. package/assets/brand/inter-latin-400-normal.woff2 +0 -0
  12. package/assets/brand/inter-latin-500-normal.woff2 +0 -0
  13. package/assets/brand/inter-latin-600-normal.woff2 +0 -0
  14. package/assets/brand/mark-20260905-transparent.svg +1 -0
  15. package/assets/brand/mark-20260918.svg +1 -0
  16. package/assets/brand/mark-black-20260918.svg +1 -0
  17. package/assets/brand/mark-dark-20260905-transparent.png +0 -0
  18. package/assets/brand/mark-dark-20260918.png +0 -0
  19. package/assets/brand/mark-dark-96.png +0 -0
  20. package/assets/brand/mark-light-20260905-transparent.png +0 -0
  21. package/assets/brand/mark-light-20260918.png +0 -0
  22. package/assets/brand/mark-light-96.png +0 -0
  23. package/assets/brand/wordmark-black-320.png +0 -0
  24. package/assets/brand/wordmark-white-320.png +0 -0
  25. package/docs/branding.md +11 -0
  26. package/docs/marketplace-submission-2026-04-08.md +4 -0
  27. package/docs/marketplace-submission-2026-08-20.md +157 -0
  28. package/docs/openai-plugin-submission-2026-09-05.md +143 -0
  29. package/dxt.json +26 -22
  30. package/index.mjs +3 -1
  31. package/logo-optimized-light.png +0 -0
  32. package/package.json +14 -14
  33. package/plugin/apiosk/.codex-plugin/plugin.json +45 -0
  34. package/plugin/apiosk/.mcp.json +8 -0
  35. package/plugin/apiosk/assets/icon-dark.png +0 -0
  36. package/plugin/apiosk/assets/icon.png +0 -0
  37. package/plugin/apiosk/assets/icon.svg +1 -0
  38. package/plugin/apiosk/assets/logo.png +0 -0
  39. package/plugin/apiosk/skills/apiosk/SKILL.md +33 -0
  40. package/plugin/apiosk/skills/apiosk/agents/openai.yaml +12 -0
  41. package/server.json +58 -24
  42. package/server.mjs +136 -151
  43. package/src/approval-feedback.mjs +21 -0
  44. package/src/brand-routes.mjs +28 -0
  45. package/src/create-server.mjs +160 -46
  46. package/src/display-money.mjs +20 -0
  47. package/src/display-text.mjs +67 -0
  48. package/src/gateway-client.mjs +42 -0
  49. package/src/gateway-v2-ask.mjs +50 -0
  50. package/src/gateway-v2-card-account.mjs +23 -0
  51. package/src/gateway-v2-card-actions.mjs +67 -0
  52. package/src/gateway-v2-card-answer-text.mjs +130 -0
  53. package/src/gateway-v2-card-answer.mjs +68 -0
  54. package/src/gateway-v2-card-blocks.mjs +166 -0
  55. package/src/gateway-v2-card-body.mjs +259 -0
  56. package/src/gateway-v2-card-budget.mjs +21 -0
  57. package/src/gateway-v2-card-cbs.mjs +56 -0
  58. package/src/gateway-v2-card-choices.mjs +4 -0
  59. package/src/gateway-v2-card-clarification.mjs +25 -0
  60. package/src/gateway-v2-card-compact.mjs +34 -0
  61. package/src/gateway-v2-card-events.mjs +41 -0
  62. package/src/gateway-v2-card-presentation.mjs +125 -0
  63. package/src/gateway-v2-card-research.mjs +63 -0
  64. package/src/gateway-v2-card-result.mjs +32 -0
  65. package/src/gateway-v2-card-search.mjs +39 -0
  66. package/src/gateway-v2-card-sources.mjs +32 -0
  67. package/src/gateway-v2-card-style.mjs +76 -0
  68. package/src/gateway-v2-card-verdict.mjs +66 -0
  69. package/src/gateway-v2-card.mjs +91 -0
  70. package/src/gateway-v2-contracts.json +66 -0
  71. package/src/gateway-v2-instructions.md +113 -0
  72. package/src/gateway-v2-recovery.mjs +18 -0
  73. package/src/gateway-v2-report-links.mjs +14 -0
  74. package/src/gateway-v2-workflows.mjs +17 -0
  75. package/src/gateway-v2.mjs +179 -0
  76. package/src/oauth.mjs +402 -1949
  77. package/src/observability.mjs +19 -3
  78. package/src/result-presentation.mjs +9 -0
  79. package/src/runtime.mjs +24 -3670
  80. package/src/source-groups.mjs +91 -0
  81. package/src/source-value-format.mjs +25 -0
  82. package/src/tool-result.mjs +20 -0
  83. package/src/ui-bridge.mjs +185 -0
  84. package/src/well-known-routes.mjs +132 -0
  85. package/src/assets/wallet-accounts.mjs +0 -20513
  86. package/src/assets/walletconnect-provider.mjs +0 -6319
  87. package/src/discovery.mjs +0 -929
  88. package/src/external-fetch.mjs +0 -203
  89. package/src/funding-options.mjs +0 -255
  90. package/src/gateway-management.mjs +0 -107
  91. package/src/hosted-payment.mjs +0 -552
  92. package/src/hosted-wallets.mjs +0 -530
  93. package/src/listing-metadata.mjs +0 -287
  94. package/src/local-config.mjs +0 -256
  95. package/src/payment-guidance.mjs +0 -298
  96. package/src/publisher.mjs +0 -1288
  97. package/src/result-canvas.mjs +0 -16
  98. package/src/source-registry.mjs +0 -215
  99. package/src/wallet-store.mjs +0 -476
  100. package/src/x402-inspect.mjs +0 -361
package/src/oauth.mjs CHANGED
@@ -5,19 +5,11 @@ import express from "express";
5
5
  import { OAuthClientMetadataSchema } from "@modelcontextprotocol/sdk/shared/auth.js";
6
6
  import { createOAuthMetadata, getOAuthProtectedResourceMetadataUrl } from "@modelcontextprotocol/sdk/server/auth/router.js";
7
7
  import { authorizationHandler } from "@modelcontextprotocol/sdk/server/auth/handlers/authorize.js";
8
- import { SETTLEMENT_DISCLOSURE_PATH } from "./settlement-disclosure.mjs";
9
8
  import { tokenHandler } from "@modelcontextprotocol/sdk/server/auth/handlers/token.js";
9
+ import { InvalidGrantError } from "@modelcontextprotocol/sdk/server/auth/errors.js";
10
10
  import { clientRegistrationHandler } from "@modelcontextprotocol/sdk/server/auth/handlers/register.js";
11
11
  import { metadataHandler } from "@modelcontextprotocol/sdk/server/auth/handlers/metadata.js";
12
12
 
13
- import { isProviderApiKey, verifyProviderKey } from "./publisher.mjs";
14
- import {
15
- hostedSettlementPublicConfig,
16
- listHostedPayableWallets,
17
- mintHostedConnectToken,
18
- prepareHostedPaymentWallets,
19
- } from "./hosted-payment.mjs";
20
-
21
13
  const ACCESS_TOKEN_TTL_SECONDS = 60 * 60;
22
14
  const AUTHORIZATION_CODE_TTL_SECONDS = 10 * 60;
23
15
  const REFRESH_TOKEN_TTL_SECONDS = 30 * 24 * 60 * 60;
@@ -25,8 +17,6 @@ const CLIENT_ID_TTL_SECONDS = 20 * 365 * 24 * 60 * 60;
25
17
  const DEFAULT_SCOPE = "mcp:tools";
26
18
  const OFFLINE_ACCESS_SCOPE = "offline_access";
27
19
  const SUPPORTED_SCOPES = [DEFAULT_SCOPE, OFFLINE_ACCESS_SCOPE];
28
- const DEFAULT_SUPABASE_URL = "https://jgjoiyqdyypouskftzeq.supabase.co";
29
- const EVM_ADDRESS_PATTERN = /^0x[a-fA-F0-9]{40}$/;
30
20
  // Every transport surface an MCP client may connect to and treat as the
31
21
  // OAuth "resource". Streamable HTTP clients target /mcp; the legacy HTTP+SSE
32
22
  // transport (ChatGPT's connector) opens /sse and posts to /messages. We
@@ -36,6 +26,39 @@ const TRANSPORT_RESOURCE_PATHS = ["/mcp", "/sse", "/messages"];
36
26
  const UUID_LIKE_CLIENT_ID_PATTERN =
37
27
  /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
38
28
 
29
+ // The client_id this server authorizes as. One client for Claude, ChatGPT and
30
+ // Cursor alike — they arrive with an optional app_name shown beside it, which
31
+ // the agent gateway passes to the approval screen as `name`.
32
+ //
33
+ // It matches `^[a-z][a-z0-9-]{0,63}$`, which is what `startAuthorization` in
34
+ // the agent gateway's oauth.ts requires of a client_id.
35
+ const PORTAL_CLIENT_ID = "apiosk-mcp";
36
+ /**
37
+ * The agent gateway. ONE approval screen, and this is how this server reaches
38
+ * it.
39
+ *
40
+ * It used to send the browser to `https://buy.apiosk.com/connect` and redeem
41
+ * the resulting code at `https://gateway.apiosk.com/v1/connect/oauth/token`.
42
+ * That was a second approval screen, in a second frontend, backed by a second
43
+ * token system — beside the one the pasted-invitation flow already used, which
44
+ * approves at `app.apiosk.com/connect`.
45
+ *
46
+ * Now this server starts an ordinary authorization request at the agent
47
+ * gateway, which validates it and redirects to that same screen. The person
48
+ * approving a connection from Claude sees the page they see approving one from
49
+ * a pasted prompt, and sets the limits in the same place.
50
+ */
51
+ const DEFAULT_GATEWAY_URL = "https://api.apiosk.com/functions/v1/agent-gateway";
52
+ // Same window as the authorization code it feeds into — the round trip to the
53
+ // portal and back happens in one browser session, not across a coffee break.
54
+ const PORTAL_HANDOFF_TTL_SECONDS = AUTHORIZATION_CODE_TTL_SECONDS;
55
+ /**
56
+ * How close to expiry an upstream access token is renewed rather than passed
57
+ * on. Comfortably longer than this server's own access token life, so a token
58
+ * minted here never carries an upstream credential that dies before it does.
59
+ */
60
+ const UPSTREAM_RENEWAL_MARGIN_SECONDS = 2 * ACCESS_TOKEN_TTL_SECONDS;
61
+
39
62
  function trimString(value) {
40
63
  return typeof value === "string" ? value.trim() : "";
41
64
  }
@@ -54,72 +77,6 @@ function escapeHtml(value) {
54
77
  .replaceAll("'", "'");
55
78
  }
56
79
 
57
- function jsStringLiteral(value) {
58
- return JSON.stringify(String(value)).replaceAll("<", "\\u003c");
59
- }
60
-
61
- // Inline brand marks so the wallet options always show a recognizable logo,
62
- // even before an injected wallet announces its own EIP-6963 icon (and for the
63
- // "install" state where no provider exists yet). Encoded as base64 data URIs so
64
- // they can be handed to the client as plain <img src> strings without any
65
- // template-literal escaping concerns.
66
- function svgDataUri(svg) {
67
- return `data:image/svg+xml;base64,${Buffer.from(svg.trim()).toString("base64")}`;
68
- }
69
-
70
- // Official MetaMask fox mark.
71
- const METAMASK_ICON = svgDataUri(`
72
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 212 189" width="212" height="189">
73
- <g fill="none" fill-rule="evenodd">
74
- <polygon fill="#CDBDB2" points="60.75 173.25 88.313 180.563 88.313 171 90.563 168.75 106.313 168.75 106.313 187.875 89.438 187.875 68.625 178.875"/>
75
- <polygon fill="#CDBDB2" points="151.25 173.25 123.75 180.563 123.75 171 121.5 168.75 105.75 168.75 105.75 187.875 122.688 187.875 143.438 178.875"/>
76
- <polygon fill="#393939" points="90.563 152.438 88.313 171 91.125 168.75 120.938 168.75 123.75 171 121.5 152.438 117 149.625 94.5 150.188"/>
77
- <polygon fill="#F89C35" points="75.375 27 88.875 58.5 95.063 150.188 117 150.188 123.75 58.5 136.125 27"/>
78
- <polygon fill="#F89D35" points="16.313 96.188 .563 141.75 39.938 139.5 65.25 139.5 65.25 119.813 64.125 79.313 58.5 83.813"/>
79
- <polygon fill="#D87C30" points="46.125 101.25 92.25 102.375 87.188 126 65.25 120.375"/>
80
- <polygon fill="#EA8D3A" points="46.125 101.813 65.25 119.813 65.25 137.813"/>
81
- <polygon fill="#F89D35" points="65.25 120.375 87.75 126 95.063 150.188 90 153 65.25 138.375"/>
82
- <polygon fill="#EB8F35" points="65.25 138.375 60.75 173.25 90.563 152.438"/>
83
- <polygon fill="#EA8E3A" points="92.25 102.375 95.063 150.188 86.625 125.719"/>
84
- <polygon fill="#D87C30" points="39.375 138.938 65.25 138.375 60.75 173.25"/>
85
- <polygon fill="#EB8F35" points="12.938 188.438 60.75 173.25 39.375 138.938 .563 141.75"/>
86
- <polygon fill="#E8821E" points="88.875 58.5 64.688 78.75 46.125 101.25 92.25 102.938"/>
87
- <polygon fill="#DFCEC3" points="60.75 173.25 90.563 152.438 88.313 170.438 88.313 180.563 68.063 176.625"/>
88
- <polygon fill="#E88F35" points="12.375 .563 88.875 58.5 75.938 27"/>
89
- <path fill="#8E5A30" d="M12.375.563L2.25 31.5l5.625 33.75-3.938 2.25 5.625 5.063-4.5 3.938 6.188 5.625-3.938 3.375 8.813 11.25 41.063-12.75c20.063-16.125 29.925-24.375 29.588-24.75-.337-.375-29.925-22.688-88.762-66.938z"/>
90
- <polygon fill="#F89D35" points="195.188 96.188 210.938 141.75 171.563 139.5 146.25 139.5 146.25 119.813 147.375 79.313 153 83.813"/>
91
- <polygon fill="#D87C30" points="165.375 101.25 119.25 102.375 124.313 126 146.25 120.375"/>
92
- <polygon fill="#EA8D3A" points="165.375 101.813 146.25 119.813 146.25 137.813"/>
93
- <polygon fill="#F89D35" points="146.25 120.375 123.75 126 116.438 150.188 121.5 153 146.25 138.375"/>
94
- <polygon fill="#EB8F35" points="146.25 138.375 150.75 173.25 121.5 152.438"/>
95
- <polygon fill="#D87C30" points="172.125 138.938 146.25 138.375 150.75 173.25"/>
96
- <polygon fill="#EB8F35" points="198.563 188.438 150.75 173.25 172.125 138.938 210.938 141.75"/>
97
- <polygon fill="#E8821E" points="122.625 58.5 146.813 78.75 165.375 101.25 119.25 102.938"/>
98
- <polygon fill="#E88F35" points="199.125 .563 122.625 58.5 135.563 27"/>
99
- <path fill="#8E5A30" d="M199.125.563L209.25 31.5l-5.625 33.75 3.938 2.25-5.625 5.063 4.5 3.938-6.188 5.625 3.938 3.375-8.813 11.25-41.063-12.75c-20.063-16.125-29.925-24.375-29.588-24.75.337-.375 29.925-22.688 88.762-66.938z"/>
100
- </g></svg>`);
101
-
102
- // WalletConnect blue tile + signal wave.
103
- const WALLETCONNECT_ICON = svgDataUri(`
104
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40" width="40" height="40">
105
- <rect width="40" height="40" rx="11" fill="#3396FF"/>
106
- <path fill="#fff" d="M12.4 15.6c4.2-4.1 11-4.1 15.2 0l.5.5c.21.2.21.55 0 .75l-1.73 1.7c-.1.1-.27.1-.38 0l-.7-.68c-2.93-2.87-7.68-2.87-10.6 0l-.75.73c-.1.1-.28.1-.38 0l-1.73-1.7c-.2-.2-.2-.55 0-.75l.75-.55zm18.77 3.5 1.54 1.5c.2.2.2.55 0 .76l-6.94 6.8c-.2.2-.54.2-.75 0l-4.93-4.83a.135.135 0 0 0-.19 0l-4.92 4.83c-.2.2-.55.2-.76 0l-6.94-6.8c-.2-.2-.2-.55 0-.76l1.54-1.5c.2-.2.55-.2.75 0l4.93 4.83c.05.05.14.05.19 0l4.92-4.83c.2-.2.55-.2.76 0l4.93 4.83c.05.05.13.05.19 0l4.92-4.83c.21-.2.55-.2.76 0z"/>
107
- </svg>`);
108
-
109
- // Coinbase Wallet blue mark.
110
- const COINBASE_ICON = svgDataUri(`
111
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40" width="40" height="40">
112
- <rect width="40" height="40" rx="11" fill="#0052FF"/>
113
- <path fill="#fff" d="M20 8a12 12 0 1 0 0 24 12 12 0 0 0 0-24zm-3.4 8.1c0-.83.67-1.5 1.5-1.5h3.8c.83 0 1.5.67 1.5 1.5v7.8c0 .83-.67 1.5-1.5 1.5h-3.8c-.83 0-1.5-.67-1.5-1.5v-7.8z"/>
114
- </svg>`);
115
-
116
- // Generic browser-wallet glyph for injected wallets with no announced icon.
117
- const GENERIC_WALLET_ICON = svgDataUri(`
118
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40" width="40" height="40">
119
- <rect width="40" height="40" rx="11" fill="#6b38d4"/>
120
- <path fill="#fff" d="M11 14.5A2.5 2.5 0 0 1 13.5 12h13a2.5 2.5 0 0 1 2.5 2.5V16h-2v-1.5a.5.5 0 0 0-.5-.5h-13a.5.5 0 0 0-.5.5v11a.5.5 0 0 0 .5.5h13a.5.5 0 0 0 .5-.5V24h2v1.5a2.5 2.5 0 0 1-2.5 2.5h-13A2.5 2.5 0 0 1 11 25.5v-11zM24 18h6v4h-6a2 2 0 0 1 0-4zm2 1.4a.6.6 0 1 0 0 1.2.6.6 0 0 0 0-1.2z"/>
121
- </svg>`);
122
-
123
80
  function toBase64Url(value) {
124
81
  return Buffer.from(value).toString("base64url");
125
82
  }
@@ -181,11 +138,26 @@ function resolveEffectiveExpiry(requestedExpiry, upperBound = null) {
181
138
  return requestedExpiry;
182
139
  }
183
140
 
141
+ // OAuth clients need invalid_grant to prompt reconnection. Plain Errors are
142
+ // converted by the SDK into HTTP 500 and retried as service outages.
143
+ function parseOAuthGrant(secret, token, type, client) {
144
+ let payload;
145
+ try {
146
+ payload = parseSignedToken(secret, token);
147
+ } catch {
148
+ throw new InvalidGrantError("Invalid or expired grant. Reconnect your Apiosk account.");
149
+ }
150
+ if (payload.typ !== type || payload.clientId !== client.client_id) {
151
+ throw new InvalidGrantError("Invalid or expired grant. Reconnect your Apiosk account.");
152
+ }
153
+ return payload;
154
+ }
155
+
184
156
  function buildIssuedToken(secret, type, payload, ttlSeconds, maxExpiry = null) {
185
157
  const issuedAt = getIssuedAtSeconds();
186
158
  const exp = resolveEffectiveExpiry(issuedAt + ttlSeconds, maxExpiry);
187
159
  if (!Number.isFinite(exp) || exp <= issuedAt) {
188
- throw new Error("Session has expired. Re-authorize the Apiosk app and retry.");
160
+ throw new InvalidGrantError("Session has expired. Re-authorize the Apiosk app and retry.");
189
161
  }
190
162
 
191
163
  return {
@@ -210,13 +182,6 @@ function buildRedirectUri(baseRedirectUri, params) {
210
182
  return redirectUrl.toString();
211
183
  }
212
184
 
213
- function normalizeSessionExpiry(value) {
214
- const sessionExpiry = Number(value);
215
- return Number.isFinite(sessionExpiry) && sessionExpiry > getIssuedAtSeconds()
216
- ? sessionExpiry
217
- : null;
218
- }
219
-
220
185
  function responseMessage(body, fallback) {
221
186
  if (body && typeof body === "object") {
222
187
  return trimString(body.message) || trimString(body.error) || fallback;
@@ -280,1476 +245,42 @@ function restoreSignedClient(secret, clientId) {
280
245
  }
281
246
  }
282
247
 
283
- function createAuthorizePage({
284
- actionPath,
285
- appName,
286
- clientName,
287
- errorMessage = "",
288
- infoMessage = "",
289
- oauthParams,
290
- walletEnabled = true,
291
- walletNoncePath = "/api/auth/mcp-wallet-nonce",
292
- walletConnectProjectId = "",
293
- }) {
294
- const scope = Array.isArray(oauthParams.scopes) ? oauthParams.scopes.join(" ") : "";
295
- const resource = oauthParams.resource ? oauthParams.resource.href : "";
296
-
297
- const hiddenInputs = [
298
- ["client_id", clientName.client_id],
299
- ["redirect_uri", oauthParams.redirectUri],
300
- ["response_type", "code"],
301
- ["code_challenge", oauthParams.codeChallenge],
302
- ["code_challenge_method", "S256"],
303
- ["scope", scope],
304
- ["state", oauthParams.state || ""],
305
- ["resource", resource],
306
- ]
307
- .map(
308
- ([name, value]) =>
309
- `<input type="hidden" name="${escapeHtml(name)}" value="${escapeHtml(value)}" />`
310
- )
311
- .join("\n");
312
-
313
- return `<!doctype html>
314
- <html lang="en">
315
- <head>
316
- <meta charset="utf-8" />
317
- <meta name="viewport" content="width=device-width, initial-scale=1" />
318
- <meta name="color-scheme" content="light dark" />
319
- <meta name="theme-color" content="#f8f9fb" media="(prefers-color-scheme: light)" />
320
- <meta name="theme-color" content="#0e1013" media="(prefers-color-scheme: dark)" />
321
- <title>Connect ${escapeHtml(appName)}</title>
322
- <link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDI0IDEwMjQiPjxkZWZzPjxsaW5lYXJHcmFkaWVudCBpZD0iZyIgeDE9IjE1OCIgeTE9IjEyMCIgeDI9Ijg2NiIgeTI9IjkwNiIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiPjxzdG9wIG9mZnNldD0iMCIgc3RvcC1jb2xvcj0iIzhiNWNmNiIvPjxzdG9wIG9mZnNldD0iLjUyIiBzdG9wLWNvbG9yPSIjNmIzOGQ0Ii8+PHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSIjNTUxNmJlIi8+PC9saW5lYXJHcmFkaWVudD48L2RlZnM+PGcgZmlsbD0ibm9uZSIgc3Ryb2tlPSJ1cmwoI2cpIiBzdHJva2Utd2lkdGg9IjQzIj48cGF0aCBkPSJNNTExLjUgMTkwLjV2MzIxIi8+PHBhdGggZD0iTTIzMSAzMzVsMjgwLjUgMTc2LjUiLz48cGF0aCBkPSJNNzkyLjUgMzM1TDUxMS41IDUxMS41Ii8+PHBhdGggZD0iTTIzMSA2NzVsMjgwLjUtMTYzLjUiLz48cGF0aCBkPSJNNzkyLjUgNjc1TDUxMS41IDUxMS41Ii8+PHBhdGggZD0iTTUxMS41IDgyNS41di0zMTQiLz48L2c+PGcgZmlsbD0idXJsKCNnKSI+PGNpcmNsZSBjeD0iNTExLjUiIGN5PSIxOTAuNSIgcj0iODAuNSIvPjxjaXJjbGUgY3g9IjIzMSIgY3k9IjMzNSIgcj0iODAiLz48Y2lyY2xlIGN4PSI3OTIuNSIgY3k9IjMzNSIgcj0iODAiLz48Y2lyY2xlIGN4PSI1MTEuNSIgY3k9IjUxMS41IiByPSIxMzUiLz48Y2lyY2xlIGN4PSIyMzEiIGN5PSI2NzUiIHI9IjgwIi8+PGNpcmNsZSBjeD0iNzkyLjUiIGN5PSI2NzUiIHI9IjgwIi8+PGNpcmNsZSBjeD0iNTExLjUiIGN5PSI4MjUuNSIgcj0iODAuNSIvPjwvZz48L3N2Zz4K" />
323
- <link rel="preconnect" href="https://fonts.googleapis.com" />
324
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
325
- <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" />
326
- <style>
327
- :root {
328
- color-scheme: light dark;
329
- --background: #f8f9fb;
330
- --foreground: #191c1e;
331
- --card: #ffffff;
332
- --border: #e5e3ec;
333
- --muted: #5d5a68;
334
- --muted-surface: #f2f4f6;
335
- --primary: #6b38d4;
336
- --primary-strong: #5516be;
337
- /* Accent for text/icons. Split from --primary because --primary doubles
338
- as a filled-button background under white label text: dark mode needs
339
- the button deep and the accent light, and one token can't be both. */
340
- --primary-accent: #6b38d4;
341
- --primary-soft: #f3effd;
342
- --danger: #ba1a1a;
343
- --danger-soft: #ffdad6;
344
- --success: #1f8a5b;
345
- --success-soft: #e6f4ee;
346
- --shadow: 0 24px 48px -16px rgba(60, 30, 120, 0.22), 0 8px 20px -12px rgba(26, 35, 53, 0.12);
347
- }
348
-
349
- /* Follows the OS/browser preference — no toggle, no persisted choice. */
350
- @media (prefers-color-scheme: dark) {
351
- :root {
352
- --background: #0e1013;
353
- --foreground: #e8e8ec;
354
- --card: #16181d;
355
- --border: #2a2d35;
356
- --muted: #a1a1ae;
357
- --muted-surface: #1e2128;
358
- --primary: #6d3fd9;
359
- --primary-strong: #8b5cf6;
360
- --primary-accent: #b9a4fb;
361
- --primary-soft: #201b33;
362
- --danger: #ffb4ab;
363
- --danger-soft: #3b1512;
364
- --success: #6fd8a6;
365
- --success-soft: #10281f;
366
- --shadow: 0 24px 48px -16px rgba(0, 0, 0, 0.62), 0 8px 20px -12px rgba(0, 0, 0, 0.5);
367
- }
368
- }
369
-
370
- * {
371
- box-sizing: border-box;
372
- }
373
-
374
- body {
375
- margin: 0;
376
- min-height: 100vh;
377
- font-family: "Inter", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
378
- background:
379
- radial-gradient(900px 520px at 88% -12%, color-mix(in srgb, var(--primary) 16%, transparent), transparent 60%),
380
- radial-gradient(760px 520px at -8% 112%, color-mix(in srgb, #8b5cf6 13%, transparent), transparent 55%),
381
- var(--background);
382
- background-attachment: fixed;
383
- color: var(--foreground);
384
- }
385
-
386
- .shell {
387
- width: 100%;
388
- min-height: 100vh;
389
- display: grid;
390
- grid-template-rows: auto 1fr auto;
391
- }
392
-
393
- .topbar,
394
- footer {
395
- width: 100%;
396
- display: flex;
397
- align-items: center;
398
- justify-content: space-between;
399
- padding: 20px clamp(20px, 5vw, 42px);
400
- }
401
-
402
- .brand {
403
- display: inline-flex;
404
- align-items: center;
405
- gap: 10px;
406
- color: var(--foreground);
407
- text-decoration: none;
408
- font-weight: 700;
409
- }
410
-
411
- .brand-mark {
412
- width: 36px;
413
- height: 36px;
414
- display: inline-grid;
415
- place-items: center;
416
- }
417
-
418
- .brand-mark svg {
419
- width: 100%;
420
- height: 100%;
421
- }
422
-
423
- .page {
424
- width: 100%;
425
- display: grid;
426
- place-items: center;
427
- padding: 24px clamp(20px, 5vw, 42px) 40px;
428
- }
429
-
430
- .layout {
431
- width: min(100%, 960px);
432
- display: grid;
433
- grid-template-columns: 1fr;
434
- justify-items: center;
435
- gap: 36px;
436
- align-items: center;
437
- }
438
-
439
- .hero {
440
- display: none;
441
- }
442
-
443
- @media (min-width: 900px) {
444
- .layout {
445
- grid-template-columns: 1.02fr 0.98fr;
446
- gap: 56px;
447
- justify-items: stretch;
448
- }
449
-
450
- .hero {
451
- display: grid;
452
- gap: 22px;
453
- align-content: center;
454
- }
455
- }
456
-
457
- .hero-eyebrow {
458
- color: var(--primary-accent);
459
- font-weight: 700;
460
- font-size: 0.85rem;
461
- letter-spacing: 0.01em;
462
- }
463
-
464
- .hero h2 {
465
- margin: 6px 0 0;
466
- font-size: clamp(1.9rem, 2.6vw, 2.55rem);
467
- line-height: 1.08;
468
- letter-spacing: -0.02em;
469
- }
470
-
471
- .hero .lead {
472
- font-size: 1.02rem;
473
- line-height: 1.55;
474
- max-width: 42ch;
475
- }
476
-
477
- .value-list {
478
- display: grid;
479
- gap: 16px;
480
- margin-top: 4px;
481
- }
482
-
483
- .value {
484
- display: flex;
485
- gap: 13px;
486
- align-items: flex-start;
487
- }
488
-
489
- .value-icon {
490
- flex: 0 0 auto;
491
- width: 40px;
492
- height: 40px;
493
- border-radius: 11px;
494
- display: grid;
495
- place-items: center;
496
- background: var(--primary-soft);
497
- color: var(--primary-accent);
498
- border: 1px solid color-mix(in srgb, var(--primary) 16%, transparent);
499
- }
500
-
501
- .value-icon svg {
502
- width: 20px;
503
- height: 20px;
504
- }
505
-
506
- .value-title {
507
- font-weight: 600;
508
- font-size: 0.96rem;
509
- }
510
-
511
- .value-desc {
512
- color: var(--muted);
513
- font-size: 0.9rem;
514
- line-height: 1.45;
515
- }
516
-
517
- main {
518
- width: 100%;
519
- max-width: 440px;
520
- justify-self: center;
521
- border: 1px solid var(--border);
522
- background: var(--card);
523
- border-radius: 18px;
524
- padding: 28px;
525
- box-shadow: var(--shadow);
526
- }
527
-
528
- h1 {
529
- margin: 0 0 8px;
530
- font-size: 1.35rem;
531
- line-height: 1.2;
532
- letter-spacing: 0;
533
- }
534
-
535
- p {
536
- margin: 0;
537
- color: var(--muted);
538
- line-height: 1.55;
539
- }
540
-
541
- .stack {
542
- display: grid;
543
- gap: 14px;
544
- }
545
-
546
- .header {
547
- gap: 8px;
548
- }
549
-
550
- .eyebrow {
551
- margin-bottom: 6px;
552
- color: var(--primary-accent);
553
- font-size: 0.83rem;
554
- font-weight: 700;
555
- }
556
-
557
- .message {
558
- border-radius: 8px;
559
- padding: 12px 14px;
560
- font-size: 0.9rem;
561
- }
562
-
563
- .message.error {
564
- border: 1px solid color-mix(in srgb, var(--danger) 30%, transparent);
565
- background: var(--danger-soft);
566
- color: var(--danger);
567
- }
568
-
569
- .message.info {
570
- border: 1px solid color-mix(in srgb, var(--success) 26%, transparent);
571
- background: var(--success-soft);
572
- color: var(--success);
573
- }
574
-
575
- .panel {
576
- display: grid;
577
- gap: 10px;
578
- }
579
-
580
- button {
581
- appearance: none;
582
- border: 1px solid transparent;
583
- border-radius: 8px;
584
- padding: 12px 14px;
585
- font-size: 0.98rem;
586
- font-weight: 600;
587
- cursor: pointer;
588
- min-height: 40px;
589
- transition: background-color 140ms ease, border-color 140ms ease, color 140ms ease, opacity 140ms ease;
590
- }
591
-
592
- button:disabled {
593
- cursor: not-allowed;
594
- opacity: 0.58;
595
- }
596
-
597
- button.primary {
598
- background: var(--primary);
599
- color: #fff;
600
- }
601
-
602
- button.primary:hover:not(:disabled) {
603
- background: var(--primary-strong);
604
- }
605
-
606
- button.secondary {
607
- background: transparent;
608
- border-color: var(--border);
609
- color: var(--foreground);
610
- }
611
-
612
- button.secondary:hover:not(:disabled) {
613
- background: var(--muted-surface);
614
- }
615
-
616
- .wallet-list {
617
- display: grid;
618
- gap: 8px;
619
- }
620
-
621
- .wallet-option {
622
- width: 100%;
623
- display: flex;
624
- align-items: center;
625
- justify-content: space-between;
626
- gap: 12px;
627
- padding: 13px 16px;
628
- min-height: 58px;
629
- border-radius: 12px;
630
- background: var(--card);
631
- border: 1px solid var(--border);
632
- color: var(--foreground);
633
- text-align: left;
634
- text-decoration: none;
635
- font-weight: 600;
636
- cursor: pointer;
637
- transition: border-color 140ms ease, background-color 140ms ease, box-shadow 140ms ease, transform 140ms ease;
638
- }
639
-
640
- .wallet-option:hover:not(:disabled) {
641
- border-color: color-mix(in srgb, var(--primary) 45%, var(--border));
642
- background: var(--primary-soft);
643
- box-shadow: 0 6px 16px -10px color-mix(in srgb, var(--primary) 60%, transparent);
644
- transform: translateY(-1px);
645
- }
646
-
647
- .wallet-name {
648
- min-width: 0;
649
- display: inline-flex;
650
- align-items: center;
651
- gap: 8px;
652
- }
653
-
654
- .wallet-icon {
655
- width: 24px;
656
- height: 24px;
657
- border-radius: 6px;
658
- flex: 0 0 auto;
659
- object-fit: contain;
660
- background: var(--card);
661
- }
662
-
663
- .wallet-name-text {
664
- min-width: 0;
665
- overflow: hidden;
666
- text-overflow: ellipsis;
667
- white-space: nowrap;
668
- font-size: 0.95rem;
669
- font-weight: 500;
670
- }
671
-
672
- .wallet-action {
673
- flex: 0 0 auto;
674
- display: inline-flex;
675
- align-items: center;
676
- gap: 4px;
677
- color: var(--muted);
678
- font-size: 0.78rem;
679
- }
680
-
681
- .wallet-option.install {
682
- border-style: dashed;
683
- }
684
-
685
- .wallet-option.busy {
686
- opacity: 0.6;
687
- }
688
-
689
- .wallet-connect-option .wallet-action {
690
- color: var(--primary);
691
- font-weight: 600;
692
- }
693
-
694
- .spinner {
695
- width: 15px;
696
- height: 15px;
697
- border-radius: 999px;
698
- border: 2px solid color-mix(in srgb, var(--primary) 30%, transparent);
699
- border-top-color: var(--primary);
700
- animation: spin 0.7s linear infinite;
701
- }
702
-
703
- @keyframes spin {
704
- to {
705
- transform: rotate(360deg);
706
- }
707
- }
708
-
709
- .divider {
710
- display: flex;
711
- align-items: center;
712
- gap: 12px;
713
- color: var(--muted);
714
- font-size: 0.76rem;
715
- text-transform: uppercase;
716
- letter-spacing: 0.06em;
717
- }
718
-
719
- .divider::before,
720
- .divider::after {
721
- content: "";
722
- flex: 1;
723
- height: 1px;
724
- background: var(--border);
725
- }
726
-
727
- .cancel-row {
728
- display: flex;
729
- justify-content: center;
730
- margin: 0;
731
- }
732
-
733
- .cancel-link {
734
- appearance: none;
735
- background: none;
736
- border: none;
737
- box-shadow: none;
738
- min-height: auto;
739
- padding: 4px 8px;
740
- color: var(--muted);
741
- font-size: 0.86rem;
742
- font-weight: 500;
743
- cursor: pointer;
744
- }
745
-
746
- .cancel-link:hover {
747
- color: var(--foreground);
748
- text-decoration: underline;
749
- text-underline-offset: 3px;
750
- }
751
-
752
- .meta {
753
- font-size: 0.84rem;
754
- color: var(--muted);
755
- padding-top: 2px;
756
- }
757
-
758
- .grant {
759
- margin-top: 2px;
760
- border-top: 1px solid var(--border);
761
- padding-top: 14px;
762
- font-size: 0.8rem;
763
- line-height: 1.7;
764
- color: var(--muted);
765
- }
766
-
767
- code {
768
- font-family: ui-monospace, SFMono-Regular, SFMono-Regular, Menlo, monospace;
769
- color: var(--foreground);
770
- word-break: break-word;
771
- }
772
-
773
- footer {
774
- justify-content: center;
775
- gap: 18px;
776
- color: var(--muted);
777
- font-size: 0.78rem;
778
- }
779
-
780
- footer a {
781
- color: inherit;
782
- text-decoration: none;
783
- }
784
-
785
- footer a:hover {
786
- color: var(--foreground);
787
- }
788
-
789
- .hidden {
790
- display: none;
791
- }
792
-
793
- /* Connect / Create segmented tabs — mirrors the provider portal's
794
- wallet-connection toggle so the two sign-in surfaces feel like one. */
795
- .mode-tabs {
796
- display: grid;
797
- grid-template-columns: 1fr 1fr;
798
- gap: 4px;
799
- padding: 4px;
800
- border: 1px solid var(--border);
801
- border-radius: 10px;
802
- background: var(--muted-surface);
803
- }
804
-
805
- .mode-tab {
806
- appearance: none;
807
- border: none;
808
- border-radius: 8px;
809
- background: transparent;
810
- color: var(--muted);
811
- font-size: 0.92rem;
812
- font-weight: 600;
813
- padding: 9px 12px;
814
- min-height: auto;
815
- cursor: pointer;
816
- }
817
-
818
- .mode-tab.active {
819
- background: var(--primary);
820
- color: #fff;
821
- }
822
-
823
- .mode-tab:not(.active):hover {
824
- color: var(--foreground);
825
- }
826
-
827
- .create-panel {
828
- display: grid;
829
- gap: 12px;
830
- }
831
-
832
- /* .create-panel's display:grid would otherwise win over .hidden (same
833
- specificity, declared later), leaving the Create pane visible on the
834
- Connect tab. */
835
- .create-panel.hidden {
836
- display: none;
837
- }
838
-
839
- .create-note {
840
- font-size: 0.84rem;
841
- color: var(--muted);
842
- line-height: 1.55;
843
- border: 1px solid var(--border);
844
- border-radius: 8px;
845
- padding: 10px 12px;
846
- }
847
-
848
- .create-address {
849
- font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
850
- font-size: 0.8rem;
851
- word-break: break-all;
852
- color: var(--foreground);
853
- border: 1px solid var(--border);
854
- border-radius: 8px;
855
- padding: 10px 12px;
856
- background: var(--muted-surface);
857
- }
858
-
859
- .phrase-grid {
860
- display: grid;
861
- grid-template-columns: repeat(3, 1fr);
862
- gap: 6px;
863
- border: 1px solid var(--border);
864
- border-radius: 8px;
865
- padding: 12px;
866
- background: var(--muted-surface);
867
- }
868
-
869
- .phrase-grid.blurred .phrase-word-text {
870
- filter: blur(5px);
871
- }
872
-
873
- .phrase-word {
874
- display: flex;
875
- gap: 6px;
876
- align-items: baseline;
877
- font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
878
- font-size: 0.8rem;
879
- color: var(--foreground);
880
- }
881
-
882
- .phrase-word .phrase-index {
883
- color: var(--muted);
884
- font-size: 0.7rem;
885
- min-width: 16px;
886
- text-align: right;
887
- }
888
-
889
- .create-actions {
890
- display: grid;
891
- grid-template-columns: 1fr 1fr 1fr;
892
- gap: 8px;
893
- }
894
-
895
- .create-actions button {
896
- background: transparent;
897
- border: 1px solid var(--border);
898
- color: var(--foreground);
899
- font-size: 0.84rem;
900
- padding: 8px 10px;
901
- min-height: auto;
902
- }
903
-
904
- .create-actions button:hover:not(:disabled) {
905
- background: var(--muted-surface);
906
- }
907
-
908
- .checkbox-row {
909
- display: flex;
910
- gap: 10px;
911
- align-items: flex-start;
912
- font-size: 0.84rem;
913
- color: var(--muted);
914
- line-height: 1.5;
915
- cursor: pointer;
916
- }
917
-
918
- .checkbox-row input {
919
- width: auto;
920
- margin-top: 2px;
921
- accent-color: var(--primary);
922
- }
923
-
924
- button.primary-wide {
925
- background: var(--primary);
926
- border: 1px solid transparent;
927
- color: #fff;
928
- font-weight: 600;
929
- width: 100%;
930
- }
931
-
932
- button.primary-wide:hover:not(:disabled) {
933
- background: var(--primary-strong);
934
- }
935
-
936
- @media (max-width: 560px) {
937
- .topbar {
938
- padding: 16px 20px;
939
- }
940
-
941
- .page {
942
- padding: 16px;
943
- place-items: start center;
944
- }
945
-
946
- main {
947
- padding: 20px;
948
- }
949
- }
950
- </style>
951
- </head>
952
- <body>
953
- <div class="shell">
954
- <header class="topbar">
955
- <a class="brand" href="https://apiosk.com" rel="noreferrer">
956
- <span class="brand-mark" aria-hidden="true">
957
- <svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Apiosk">
958
- <defs>
959
- <linearGradient id="apiosk-brand-mark" x1="158" y1="120" x2="866" y2="906" gradientUnits="userSpaceOnUse">
960
- <stop offset="0%" stop-color="#8b5cf6" />
961
- <stop offset="52%" stop-color="#6b38d4" />
962
- <stop offset="100%" stop-color="#5516be" />
963
- </linearGradient>
964
- </defs>
965
- <g fill="none" stroke="url(#apiosk-brand-mark)" stroke-width="43" stroke-linecap="butt">
966
- <line x1="511.5" y1="190.5" x2="511.5" y2="511.5" />
967
- <line x1="231" y1="335" x2="511.5" y2="511.5" />
968
- <line x1="792.5" y1="335" x2="511.5" y2="511.5" />
969
- <line x1="231" y1="675" x2="511.5" y2="511.5" />
970
- <line x1="792.5" y1="675" x2="511.5" y2="511.5" />
971
- <line x1="511.5" y1="825.5" x2="511.5" y2="511.5" />
972
- </g>
973
- <g fill="url(#apiosk-brand-mark)">
974
- <circle cx="511.5" cy="190.5" r="80.5" />
975
- <circle cx="231" cy="335" r="80" />
976
- <circle cx="792.5" cy="335" r="80" />
977
- <circle cx="511.5" cy="511.5" r="135" />
978
- <circle cx="231" cy="675" r="80" />
979
- <circle cx="792.5" cy="675" r="80" />
980
- <circle cx="511.5" cy="825.5" r="80.5" />
981
- </g>
982
- </svg>
983
- </span>
984
- <span>Apiosk</span>
985
- </a>
986
- </header>
987
- <div class="page">
988
- <div class="layout">
989
- <section class="hero" aria-hidden="true">
990
- <div>
991
- <p class="hero-eyebrow">Apiosk payment infrastructure</p>
992
- <h2>Pay for any API, straight from your agent</h2>
993
- </div>
994
- <p class="lead">Sign in once to let ${escapeHtml(clientName.client_name || "your AI app")} discover and call APIs through Apiosk. Paid calls use USDC from the wallet you explicitly authorize.</p>
995
- <div class="value-list">
996
- <div class="value">
997
- <span class="value-icon">
998
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="6" /><path d="M18.09 10.37A6 6 0 1 1 10.34 18" /><path d="M7 6h1v4" /><path d="m16.71 13.88.7.71-2.82 2.82" /></svg>
999
- </span>
1000
- <div>
1001
- <div class="value-title">Pay per call, automatically</div>
1002
- <div class="value-desc">USDC over x402 on Base. No invoices, no API-key juggling.</div>
1003
- </div>
1004
- </div>
1005
- <div class="value">
1006
- <span class="value-icon">
1007
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 7V5a2 2 0 0 0-2-2H5a2 2 0 0 0 0 4h14a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5" /><path d="M16 12h.01" /></svg>
1008
- </span>
1009
- <div>
1010
- <div class="value-title">One secure connection</div>
1011
- <div class="value-desc">Your identity signature signs you in; a separate, limited USDC approval authorizes automatic payments.</div>
1012
- </div>
1013
- </div>
1014
- <div class="value">
1015
- <span class="value-icon">
1016
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="7" height="7" x="3" y="3" rx="1" /><rect width="7" height="7" x="14" y="3" rx="1" /><rect width="7" height="7" x="14" y="14" rx="1" /><rect width="7" height="7" x="3" y="14" rx="1" /></svg>
1017
- </span>
1018
- <div>
1019
- <div class="value-title">One catalog, one contract</div>
1020
- <div class="value-desc">Discover APIs and datasets and call them without leaving your chat.</div>
1021
- </div>
1022
- </div>
1023
- </div>
1024
- </section>
1025
- <main class="stack">
1026
- <header class="stack header">
1027
- <div>
1028
- <p class="eyebrow">${escapeHtml(clientName.client_name || "Remote MCP client")}</p>
1029
- <h1>Connect ${escapeHtml(appName)}</h1>
1030
- </div>
1031
- <p>Connect a wallet to sign in without a password. You can then authorize limited USDC payments from that same wallet on Base.</p>
1032
- </header>
1033
- ${errorMessage ? `<div class="message error">${escapeHtml(errorMessage)}</div>` : ""}
1034
- ${infoMessage ? `<div class="message info">${escapeHtml(infoMessage)}</div>` : ""}
1035
- <section class="panel" aria-label="Wallet sign in">
1036
- ${
1037
- walletEnabled
1038
- ? `<div class="mode-tabs" role="tablist" aria-label="Wallet sign-in mode">
1039
- <button id="mode-connect" class="mode-tab active" type="button" role="tab" aria-selected="true">Connect</button>
1040
- <button id="mode-create" class="mode-tab" type="button" role="tab" aria-selected="false">Create</button>
1041
- </div>`
1042
- : ""
1043
- }
1044
- <div id="connect-panel">
1045
- <div id="wallet-list" class="wallet-list" role="list">
1046
- ${
1047
- walletEnabled
1048
- ? `<button class="wallet-option" type="button" disabled>
1049
- <span class="wallet-name">
1050
- <img class="wallet-icon" src="${METAMASK_ICON}" alt="" />
1051
- <span class="wallet-name-text">Detecting wallets&hellip;</span>
1052
- </span>
1053
- <span class="wallet-action">Please wait</span>
1054
- </button>`
1055
- : `<button class="wallet-option" type="button" disabled>
1056
- <span class="wallet-name">
1057
- <span class="wallet-name-text">Wallet sign-in unavailable</span>
1058
- </span>
1059
- <span class="wallet-action">Unavailable</span>
1060
- </button>`
1061
- }
1062
- </div>
1063
- ${
1064
- walletEnabled && walletConnectProjectId
1065
- ? `<div class="divider">or</div>
1066
- <button id="walletconnect-button" class="wallet-option wallet-connect-option" type="button">
1067
- <span class="wallet-name">
1068
- <img class="wallet-icon" src="${WALLETCONNECT_ICON}" alt="" />
1069
- <span class="wallet-name-text">WalletConnect</span>
1070
- </span>
1071
- <span class="wallet-action">Mobile / QR</span>
1072
- </button>`
1073
- : ""
1074
- }
1075
- </div>
1076
- ${
1077
- walletEnabled
1078
- ? `<div id="create-panel" class="create-panel hidden">
1079
- <p class="create-note">Generate a new self-custody sign-in wallet in your browser. The recovery phrase never leaves this tab. Fund it with ETH for gas and USDC on Base before authorizing payments.</p>
1080
- <div id="create-start">
1081
- <button id="create-generate" class="primary-wide" type="button">Generate new wallet</button>
1082
- </div>
1083
- <div id="create-result" class="create-panel hidden">
1084
- <div id="create-address" class="create-address"></div>
1085
- <div id="phrase-grid" class="phrase-grid blurred" aria-label="Recovery phrase"></div>
1086
- <div class="create-actions">
1087
- <button id="phrase-reveal" type="button">Reveal</button>
1088
- <button id="phrase-copy" type="button">Copy phrase</button>
1089
- <button id="phrase-download" type="button">Download backup</button>
1090
- </div>
1091
- <label class="checkbox-row">
1092
- <input id="phrase-saved" type="checkbox" />
1093
- <span>I&rsquo;ve securely saved my recovery phrase. I understand it&rsquo;s the only way to recover this wallet and Apiosk cannot restore it for me.</span>
1094
- </label>
1095
- <button id="create-sign-in" class="primary-wide" type="button" disabled>Sign in with this wallet</button>
1096
- </div>
1097
- </div>`
1098
- : ""
1099
- }
1100
- <p id="wallet-status" class="meta">${walletEnabled ? "Connect MetaMask, Coinbase Wallet, or another browser wallet. The next step separately authorizes limited USDC payments." : "Wallet sign-in is not configured on this MCP server."}</p>
1101
- </section>
1102
- <form method="post" action="${escapeHtml(actionPath)}" class="cancel-row">
1103
- ${hiddenInputs}
1104
- <button class="cancel-link" type="submit" name="action" value="cancel" formnovalidate>Cancel</button>
1105
- </form>
1106
- <form id="wallet-form" method="post" action="${escapeHtml(actionPath)}" class="hidden">
1107
- ${hiddenInputs}
1108
- <input type="hidden" name="action" value="wallet_sign_in" />
1109
- <input type="hidden" name="wallet_address" />
1110
- <input type="hidden" name="wallet_message" />
1111
- <input type="hidden" name="wallet_message_encoding" value="base64url" />
1112
- <input type="hidden" name="wallet_signature" />
1113
- <input type="hidden" name="wallet_method" value="connected_wallet" />
1114
- </form>
1115
- <div class="grant">
1116
- Requested scope: <code>${escapeHtml(scope || DEFAULT_SCOPE)}</code><br />
1117
- Resource: <code>${escapeHtml(resource || "default")}</code>
1118
- </div>
1119
- </main>
1120
- </div>
1121
- </div>
1122
- <footer>
1123
- <span>© Apiosk</span>
1124
- <a href="https://apiosk.com/terms" target="_blank" rel="noreferrer">Terms</a>
1125
- <a href="https://apiosk.com/privacy" target="_blank" rel="noreferrer">Privacy</a>
1126
- </footer>
1127
- </div>
1128
- <script>
1129
- (() => {
1130
- const walletEnabled = ${walletEnabled ? "true" : "false"};
1131
- if (!walletEnabled) return;
1132
-
1133
- const noncePath = ${jsStringLiteral(walletNoncePath)};
1134
- const walletConnectProjectId = ${jsStringLiteral(walletConnectProjectId)};
1135
- const BRAND_ICONS = {
1136
- metamask: ${jsStringLiteral(METAMASK_ICON)},
1137
- coinbase: ${jsStringLiteral(COINBASE_ICON)},
1138
- generic: ${jsStringLiteral(GENERIC_WALLET_ICON)},
1139
- };
1140
-
1141
- // First-class injected wallets, always offered so users recognise them
1142
- // even before detection; when none is installed we link to install.
1143
- const KNOWN = [
1144
- { rdns: "io.metamask", name: "MetaMask", flag: "isMetaMask", icon: BRAND_ICONS.metamask, install: "https://metamask.io/download/" },
1145
- { rdns: "com.coinbase.wallet", name: "Coinbase Wallet", flag: "isCoinbaseWallet", icon: BRAND_ICONS.coinbase, install: "https://www.coinbase.com/wallet/downloads" },
1146
- ];
1147
-
1148
- const walletForm = document.getElementById("wallet-form");
1149
- const walletList = document.getElementById("wallet-list");
1150
- const walletStatus = document.getElementById("wallet-status");
1151
- const walletConnectButton = document.getElementById("walletconnect-button");
1152
- const detected = new Map();
1153
- let busy = false;
1154
-
1155
- function setStatus(message, tone) {
1156
- walletStatus.textContent = message;
1157
- walletStatus.style.color =
1158
- tone === "error" ? "var(--danger)" : tone === "success" ? "var(--success)" : "var(--muted)";
1159
- }
1160
-
1161
- function setBusy(next) {
1162
- busy = next;
1163
- if (walletConnectButton) walletConnectButton.disabled = next;
1164
- for (const el of walletList.querySelectorAll("button")) el.disabled = next;
1165
- const createGenerateButton = document.getElementById("create-generate");
1166
- if (createGenerateButton) createGenerateButton.disabled = next;
1167
- const createSignInButton = document.getElementById("create-sign-in");
1168
- if (createSignInButton) {
1169
- const saved = document.getElementById("phrase-saved");
1170
- createSignInButton.disabled = next || !saved || !saved.checked;
1171
- }
1172
- }
1173
-
1174
- function hexEncode(value) {
1175
- return "0x" + Array.from(new TextEncoder().encode(value))
1176
- .map((byte) => byte.toString(16).padStart(2, "0"))
1177
- .join("");
1178
- }
1179
-
1180
- // Base64url keeps the exact signed bytes intact across the form POST; a
1181
- // plain text control would rewrite line breaks and change the EIP-191
1182
- // digest, so server-side signature recovery would fail.
1183
- function base64UrlEncode(value) {
1184
- const bytes = new TextEncoder().encode(value);
1185
- let binary = "";
1186
- for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
1187
- return btoa(binary).replace(/\\+/g, "-").replace(/\\//g, "_").replace(/=+$/, "");
1188
- }
1189
-
1190
- // Resolve a live provider for a known wallet: EIP-6963 announcement
1191
- // first, then a legacy window.ethereum.providers[] entry carrying the
1192
- // wallet flag, then a lone window.ethereum whose flag is set.
1193
- function resolveKnown(wallet) {
1194
- const announced = detected.get(wallet.rdns);
1195
- if (announced) return announced.provider;
1196
- const eth = window.ethereum;
1197
- if (!eth) return null;
1198
- if (Array.isArray(eth.providers)) {
1199
- const match = eth.providers.find((p) => p && p[wallet.flag]);
1200
- if (match) return match;
1201
- }
1202
- return eth[wallet.flag] ? eth : null;
1203
- }
1204
-
1205
- function connectButton(icon, name, detail) {
1206
- const button = document.createElement("button");
1207
- button.type = "button";
1208
- button.className = "wallet-option";
1209
- button.disabled = busy;
1210
- const nameWrap = document.createElement("span");
1211
- nameWrap.className = "wallet-name";
1212
- const iconImg = document.createElement("img");
1213
- iconImg.className = "wallet-icon";
1214
- iconImg.alt = "";
1215
- iconImg.src = icon || BRAND_ICONS.generic;
1216
- const nameText = document.createElement("span");
1217
- nameText.className = "wallet-name-text";
1218
- nameText.textContent = name;
1219
- nameWrap.append(iconImg, nameText);
1220
- const actionEl = document.createElement("span");
1221
- actionEl.className = "wallet-action";
1222
- actionEl.textContent = "Connect";
1223
- button.append(nameWrap, actionEl);
1224
- button.addEventListener("click", () => signWithProvider(detail));
1225
- return button;
1226
- }
1227
-
1228
- function installLink(wallet) {
1229
- const link = document.createElement("a");
1230
- link.className = "wallet-option install";
1231
- link.href = wallet.install;
1232
- link.target = "_blank";
1233
- link.rel = "noreferrer";
1234
- const nameWrap = document.createElement("span");
1235
- nameWrap.className = "wallet-name";
1236
- const iconImg = document.createElement("img");
1237
- iconImg.className = "wallet-icon";
1238
- iconImg.alt = "";
1239
- iconImg.src = wallet.icon;
1240
- const nameText = document.createElement("span");
1241
- nameText.className = "wallet-name-text";
1242
- nameText.textContent = wallet.name;
1243
- nameWrap.append(iconImg, nameText);
1244
- const actionEl = document.createElement("span");
1245
- actionEl.className = "wallet-action";
1246
- actionEl.textContent = "Install";
1247
- link.append(nameWrap, actionEl);
1248
- return link;
1249
- }
1250
-
1251
- function render() {
1252
- walletList.innerHTML = "";
1253
- const shown = new Set();
1254
- const hasNonLegacy = [...detected.keys()].some((k) => k !== "legacy.injected");
1255
-
1256
- for (const wallet of KNOWN) {
1257
- shown.add(wallet.rdns);
1258
- const announced = detected.get(wallet.rdns);
1259
- const provider = announced ? announced.provider : resolveKnown(wallet);
1260
- const icon = (announced && announced.info && announced.info.icon) || wallet.icon;
1261
- if (provider) {
1262
- walletList.appendChild(connectButton(icon, wallet.name, { provider, info: { name: wallet.name } }));
1263
- } else {
1264
- walletList.appendChild(installLink(wallet));
1265
- }
1266
- }
1267
-
1268
- // Any other injected wallet the browser announced (Rabby, Frame, ...).
1269
- for (const [rdns, detail] of detected) {
1270
- if (shown.has(rdns)) continue;
1271
- if (rdns === "legacy.injected" && hasNonLegacy) continue;
1272
- const icon = (detail.info && detail.info.icon) || BRAND_ICONS.generic;
1273
- const name = (detail.info && detail.info.name) || "Browser wallet";
1274
- walletList.appendChild(connectButton(icon, name, detail));
1275
- }
1276
- }
1277
-
1278
- async function requestAccounts(provider) {
1279
- try {
1280
- await provider.request({ method: "wallet_requestPermissions", params: [{ eth_accounts: {} }] });
1281
- } catch (error) {
1282
- if (error && error.code === 4001) throw error;
1283
- }
1284
- const accounts = await provider.request({ method: "eth_requestAccounts" });
1285
- if (!Array.isArray(accounts) || !accounts.length) {
1286
- throw new Error("No wallet account was returned.");
1287
- }
1288
- return String(accounts[0]);
1289
- }
1290
-
1291
- async function fetchNonce() {
1292
- const response = await fetch(noncePath, {
1293
- method: "POST",
1294
- headers: { accept: "application/json" },
1295
- credentials: "same-origin",
1296
- });
1297
- const body = await response.json().catch(() => ({}));
1298
- if (!response.ok || !body.nonce) {
1299
- throw new Error(body.message || body.error || "Could not start wallet sign-in.");
1300
- }
1301
- return body.nonce;
1302
- }
1303
-
1304
- async function connectWalletConnect() {
1305
- // Keep this same-origin: iOS and other embedded OAuth browsers can
1306
- // reject a cross-origin dynamic module import before WalletConnect
1307
- // gets a chance to open its mobile/QR pairing modal.
1308
- const mod = await import("/assets/walletconnect-provider.mjs");
1309
- const EthereumProvider = mod.EthereumProvider || (mod.default && mod.default.EthereumProvider) || mod.default;
1310
- const origin = window.location.origin;
1311
- const provider = await EthereumProvider.init({
1312
- projectId: walletConnectProjectId,
1313
- chains: [8453],
1314
- optionalChains: [8453],
1315
- showQrModal: true,
1316
- // The pairing modal is WalletConnect's own DOM, so our CSS cannot
1317
- // reach it; hand it the same system preference the page follows.
1318
- themeMode: window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light",
1319
- metadata: {
1320
- name: "Apiosk",
1321
- description: "Apiosk MCP sign-in",
1322
- url: origin,
1323
- // Absolute, always-hosted mark for the WalletConnect pairing UI
1324
- // (the MCP server serves no static assets). A PNG raster renders
1325
- // more reliably across wallets than the gradient SVG.
1326
- icons: ["https://apiosk.com/web-app-manifest-512x512.png"],
1327
- },
1328
- });
1329
- await provider.enable();
1330
- return { provider, info: { name: "WalletConnect" } };
1331
- }
1332
-
1333
- async function signWithProvider(detail) {
1334
- if (busy || !detail || !detail.provider) return;
1335
- setBusy(true);
1336
- setStatus("Opening wallet...");
1337
- try {
1338
- const provider = detail.provider;
1339
- const address = await requestAccounts(provider);
1340
- const nonce = await fetchNonce();
1341
- const message = [
1342
- "Apiosk Provider wallet sign-in",
1343
- "wallet: " + address.toLowerCase(),
1344
- "origin: " + window.location.origin,
1345
- "nonce: " + nonce,
1346
- "issued_at: " + new Date().toISOString(),
1347
- ].join("\\n");
1348
- setStatus("Confirm the signature in your wallet...");
1349
- const signature = await provider.request({
1350
- method: "personal_sign",
1351
- params: [hexEncode(message), address],
1352
- });
1353
-
1354
- walletForm.elements.wallet_address.value = address;
1355
- walletForm.elements.wallet_message.value = base64UrlEncode(message);
1356
- walletForm.elements.wallet_signature.value = signature;
1357
- walletForm.elements.wallet_method.value = "connected_wallet";
1358
- setStatus("Wallet verified. Continuing...", "success");
1359
- walletForm.submit();
1360
- } catch (error) {
1361
- const rejected = error && error.code === 4001;
1362
- setStatus(
1363
- rejected
1364
- ? "Signature request was rejected in the wallet."
1365
- : error instanceof Error ? error.message : "Wallet sign-in failed.",
1366
- "error"
1367
- );
1368
- setBusy(false);
1369
- }
1370
- }
1371
-
1372
- window.addEventListener("eip6963:announceProvider", (event) => {
1373
- const detail = event.detail;
1374
- if (!detail || !detail.info || !detail.info.rdns || !detail.provider) return;
1375
- detected.set(detail.info.rdns, detail);
1376
- render();
1377
- });
1378
- window.dispatchEvent(new Event("eip6963:requestProvider"));
1379
-
1380
- // Legacy fallback for a lone window.ethereum with no EIP-6963 support.
1381
- window.setTimeout(() => {
1382
- if (!detected.size && window.ethereum) {
1383
- detected.set("legacy.injected", {
1384
- info: { name: "Browser wallet", rdns: "legacy.injected", icon: "" },
1385
- provider: window.ethereum,
1386
- });
1387
- }
1388
- render();
1389
- }, 300);
1390
- render();
1391
-
1392
- if (walletConnectButton) {
1393
- walletConnectButton.addEventListener("click", async () => {
1394
- if (busy) return;
1395
- setBusy(true);
1396
- setStatus("Opening WalletConnect...");
1397
- try {
1398
- const detail = await connectWalletConnect();
1399
- setBusy(false);
1400
- await signWithProvider(detail);
1401
- } catch (error) {
1402
- setStatus(error instanceof Error ? error.message : "WalletConnect sign-in failed.", "error");
1403
- setBusy(false);
1404
- }
1405
- });
1406
- }
1407
-
1408
- // ── Create-wallet mode ─────────────────────────────────────────────
1409
- // Mirrors the provider portal's Connect | Create toggle: a fresh
1410
- // self-custody wallet is generated in this tab with viem (same library
1411
- // and derivation the portal uses); the phrase/key never leave the
1412
- // browser — the server only ever receives the address + signature.
1413
- const modeConnect = document.getElementById("mode-connect");
1414
- const modeCreate = document.getElementById("mode-create");
1415
- const connectPanel = document.getElementById("connect-panel");
1416
- const createPanel = document.getElementById("create-panel");
1417
- const createStart = document.getElementById("create-start");
1418
- const createResult = document.getElementById("create-result");
1419
- const createGenerate = document.getElementById("create-generate");
1420
- const createAddress = document.getElementById("create-address");
1421
- const phraseGrid = document.getElementById("phrase-grid");
1422
- const phraseReveal = document.getElementById("phrase-reveal");
1423
- const phraseCopy = document.getElementById("phrase-copy");
1424
- const phraseDownload = document.getElementById("phrase-download");
1425
- const phraseSaved = document.getElementById("phrase-saved");
1426
- const createSignIn = document.getElementById("create-sign-in");
1427
- let generatedWallet = null;
1428
-
1429
- function setMode(mode) {
1430
- if (!modeConnect || !modeCreate) return;
1431
- const isCreate = mode === "create";
1432
- modeConnect.classList.toggle("active", !isCreate);
1433
- modeCreate.classList.toggle("active", isCreate);
1434
- modeConnect.setAttribute("aria-selected", String(!isCreate));
1435
- modeCreate.setAttribute("aria-selected", String(isCreate));
1436
- connectPanel.classList.toggle("hidden", isCreate);
1437
- createPanel.classList.toggle("hidden", !isCreate);
1438
- setStatus(
1439
- isCreate
1440
- ? "Create a brand-new wallet and sign in with it — no extension needed."
1441
- : "Connect MetaMask, Coinbase Wallet, or any browser wallet to sign in."
1442
- );
1443
- }
1444
-
1445
- function refreshCreateSignIn() {
1446
- if (createSignIn) {
1447
- createSignIn.disabled = busy || !generatedWallet || !phraseSaved.checked;
1448
- }
1449
- }
1450
-
1451
- function renderGeneratedWallet() {
1452
- createAddress.textContent = generatedWallet.address;
1453
- phraseGrid.innerHTML = "";
1454
- generatedWallet.mnemonic.split(" ").forEach((word, index) => {
1455
- const cell = document.createElement("span");
1456
- cell.className = "phrase-word";
1457
- const num = document.createElement("span");
1458
- num.className = "phrase-index";
1459
- num.textContent = String(index + 1) + ".";
1460
- const text = document.createElement("span");
1461
- text.className = "phrase-word-text";
1462
- text.textContent = word;
1463
- cell.append(num, text);
1464
- phraseGrid.appendChild(cell);
1465
- });
1466
- createStart.classList.add("hidden");
1467
- createResult.classList.remove("hidden");
1468
- }
1469
-
1470
- if (modeConnect && modeCreate) {
1471
- modeConnect.addEventListener("click", () => setMode("connect"));
1472
- modeCreate.addEventListener("click", () => setMode("create"));
1473
- }
1474
-
1475
- if (createGenerate) {
1476
- createGenerate.addEventListener("click", async () => {
1477
- if (busy) return;
1478
- setBusy(true);
1479
- setStatus("Generating a new wallet in your browser...");
1480
- try {
1481
- // Same-origin bundle (see server.mjs /assets/wallet-accounts.mjs)
1482
- // — embedded browsers can block cross-origin dynamic imports, so
1483
- // the wallet library must never come from a CDN.
1484
- const accounts = await import("/assets/wallet-accounts.mjs");
1485
- const mnemonic = accounts.generateMnemonic(accounts.english);
1486
- const account = accounts.mnemonicToAccount(mnemonic);
1487
- generatedWallet = { account, mnemonic, address: account.address };
1488
- renderGeneratedWallet();
1489
- setStatus("Wallet created. Save the recovery phrase before signing in.", "success");
1490
- } catch (error) {
1491
- setStatus(error instanceof Error ? error.message : "Could not generate a wallet.", "error");
1492
- } finally {
1493
- setBusy(false);
1494
- refreshCreateSignIn();
1495
- }
1496
- });
1497
- }
1498
-
1499
- if (phraseReveal) {
1500
- phraseReveal.addEventListener("click", () => {
1501
- const blurred = phraseGrid.classList.toggle("blurred");
1502
- phraseReveal.textContent = blurred ? "Reveal" : "Hide";
1503
- });
1504
- }
1505
-
1506
- if (phraseCopy) {
1507
- phraseCopy.addEventListener("click", async () => {
1508
- if (!generatedWallet) return;
1509
- try {
1510
- await navigator.clipboard.writeText(generatedWallet.mnemonic);
1511
- setStatus("Recovery phrase copied. Store it somewhere safe.", "success");
1512
- } catch {
1513
- setStatus("Could not copy — reveal the phrase and copy it manually.", "error");
1514
- }
1515
- });
1516
- }
1517
-
1518
- if (phraseDownload) {
1519
- phraseDownload.addEventListener("click", () => {
1520
- if (!generatedWallet) return;
1521
- const backup = [
1522
- "Apiosk wallet backup",
1523
- "",
1524
- "Address: " + generatedWallet.address,
1525
- "Recovery phrase: " + generatedWallet.mnemonic,
1526
- "Derivation path: m/44'/60'/0'/0/0 (standard Ethereum)",
1527
- "",
1528
- "Keep this file offline and private. Anyone with the phrase controls the wallet.",
1529
- ].join("\\n");
1530
- const blob = new Blob([backup], { type: "text/plain" });
1531
- const url = URL.createObjectURL(blob);
1532
- const link = document.createElement("a");
1533
- link.href = url;
1534
- link.download = "apiosk-wallet-" + generatedWallet.address.slice(2, 8) + ".txt";
1535
- document.body.appendChild(link);
1536
- link.click();
1537
- link.remove();
1538
- URL.revokeObjectURL(url);
1539
- });
1540
- }
1541
-
1542
- if (phraseSaved) {
1543
- phraseSaved.addEventListener("change", refreshCreateSignIn);
1544
- }
1545
-
1546
- if (createSignIn) {
1547
- createSignIn.addEventListener("click", async () => {
1548
- if (busy || !generatedWallet || !phraseSaved.checked) return;
1549
- setBusy(true);
1550
- setStatus("Signing you in with the new wallet...");
1551
- try {
1552
- const address = generatedWallet.address;
1553
- const nonce = await fetchNonce();
1554
- const message = [
1555
- "Apiosk Provider wallet sign-in",
1556
- "wallet: " + address.toLowerCase(),
1557
- "origin: " + window.location.origin,
1558
- "nonce: " + nonce,
1559
- "issued_at: " + new Date().toISOString(),
1560
- ].join("\\n");
1561
- const signature = await generatedWallet.account.signMessage({ message });
1562
-
1563
- walletForm.elements.wallet_address.value = address;
1564
- walletForm.elements.wallet_message.value = base64UrlEncode(message);
1565
- walletForm.elements.wallet_signature.value = signature;
1566
- walletForm.elements.wallet_method.value = "created_wallet";
1567
- setStatus("Wallet verified. Continuing...", "success");
1568
- walletForm.submit();
1569
- } catch (error) {
1570
- setStatus(error instanceof Error ? error.message : "Wallet sign-in failed.", "error");
1571
- setBusy(false);
1572
- refreshCreateSignIn();
1573
- }
1574
- });
1575
- }
1576
- })();
1577
- </script>
1578
- </body>
1579
- </html>`;
1580
- }
1581
-
1582
- function createPaymentAuthorizationPage({
1583
- actionPath,
1584
- appName,
1585
- clientName,
1586
- oauthParams,
1587
- pendingAuthorization,
1588
- wallets,
1589
- settlement,
1590
- disclosureUrl,
1591
- errorMessage = "",
1592
- }) {
1593
- const scope = Array.isArray(oauthParams.scopes) ? oauthParams.scopes.join(" ") : "";
1594
- const resource = oauthParams.resource ? oauthParams.resource.href : "";
1595
- const hidden = [
1596
- ["client_id", clientName.client_id],
1597
- ["redirect_uri", oauthParams.redirectUri],
1598
- ["response_type", "code"],
1599
- ["code_challenge", oauthParams.codeChallenge],
1600
- ["code_challenge_method", "S256"],
1601
- ["scope", scope],
1602
- ["state", oauthParams.state || ""],
1603
- ["resource", resource],
1604
- ["pending_authorization", pendingAuthorization],
1605
- ]
1606
- .map(([name, value]) => `<input type="hidden" name="${escapeHtml(name)}" value="${escapeHtml(value)}" />`)
1607
- .join("\n");
1608
- const options = wallets
1609
- .map(
1610
- (wallet, index) => `<label class="wallet ${index === 0 ? "selected" : ""}">
1611
- <input type="radio" name="managed_wallet_id" value="${escapeHtml(wallet.id)}" data-address="${escapeHtml(wallet.address)}" data-requires-approval="${wallet.requiresApproval ? "true" : "false"}" ${index === 0 ? "checked" : ""} required />
1612
- <span><strong>${escapeHtml(wallet.label)}${wallet.connected ? " (connected)" : ""}</strong><small>${escapeHtml(wallet.address)} · ${wallet.requiresApproval ? "USDC approval required" : "Apiosk managed"}</small></span>
1613
- </label>`
1614
- )
1615
- .join("\n");
1616
- const first = wallets[0];
1617
-
1618
- return `<!doctype html><html lang="en"><head><meta charset="utf-8" />
1619
- <meta name="viewport" content="width=device-width,initial-scale=1" />
1620
- <meta name="color-scheme" content="light dark" />
1621
- <meta name="theme-color" content="#f8f9fb" media="(prefers-color-scheme: light)" />
1622
- <meta name="theme-color" content="#0e1013" media="(prefers-color-scheme: dark)" />
1623
- <title>Authorize payments · ${escapeHtml(appName)}</title>
1624
- <style>
1625
- /* Token names/values mirror the sign-in shell above so both steps of the
1626
- flow theme identically off the OS preference. */
1627
- :root{font-family:"Google Sans","Product Sans","Google Sans Text",Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color-scheme:light dark;--background:#f8f9fb;--foreground:#1f2028;--card:#fff;--border:#e7e3ef;--field-border:#d8d3e2;--muted:#676371;--consent:#55515f;--primary:#6b38d4;--primary-accent:#8b5cf6;--primary-soft:#f5f1ff;--glow:#eadfff;--danger:#8c1d18;--danger-soft:#ffdad6;--success:#34a877;--shadow:0 22px 54px -28px rgba(48,28,100,.34);color:var(--foreground);background:var(--background)}
1628
- @media (prefers-color-scheme:dark){:root{--background:#0d0f13;--foreground:#ecebf2;--card:#15171d;--border:#262a34;--field-border:#343844;--muted:#a5a2b0;--consent:#b8b4c3;--primary:#7a45e6;--primary-accent:#a78bfa;--primary-soft:#211a34;--glow:#1d1730;--danger:#ffb4ab;--danger-soft:#3b1512;--success:#6fd8a6;--shadow:0 24px 52px -26px rgba(0,0,0,.72)}}
1629
- *{box-sizing:border-box}body{margin:0;min-height:100vh;background:radial-gradient(820px 440px at 88% -10%,var(--glow),transparent 62%),var(--background);font-weight:400;-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility}
1630
- header{position:fixed;top:0;left:0;right:0;padding:22px clamp(22px,5vw,44px);z-index:1}.brand{display:inline-flex;align-items:center;gap:10px;color:var(--foreground);text-decoration:none}.brand svg{width:28px;height:28px;display:block;filter:drop-shadow(0 6px 18px rgba(107,56,212,.24))}.brand span{font-size:20px;font-weight:600;letter-spacing:-.01em}
1631
- .page{min-height:100vh;display:grid;place-items:center;padding:96px 20px 44px}
1632
- main{width:min(590px,100%);background:color-mix(in srgb,var(--card) 96%,transparent);border:1px solid var(--border);border-radius:18px;padding:28px;box-shadow:var(--shadow);backdrop-filter:blur(10px)}
1633
- .eyebrow{margin:0 0 9px;color:var(--primary-accent);font-size:12px;font-weight:500;text-transform:uppercase;letter-spacing:.055em}h1{margin:0 0 10px;font-size:29px;line-height:1.12;font-weight:600;letter-spacing:-.02em}p{color:var(--muted);line-height:1.55;font-weight:400}
1634
- .steps{display:flex;gap:7px;margin:20px 0}.step{height:5px;flex:1;border-radius:99px;background:var(--primary)}.step.done{background:var(--success)}
1635
- .wallets{display:grid;gap:9px;margin:18px 0}.wallet{display:flex;align-items:center;gap:12px;border:1px solid var(--border);border-radius:12px;padding:13px;cursor:pointer}.wallet:has(input:checked){border-color:color-mix(in srgb,var(--primary) 80%,var(--border));background:var(--primary-soft)}.wallet strong{font-weight:600}.wallet small{display:block;color:var(--muted);font-family:"SFMono-Regular","Roboto Mono",ui-monospace,monospace;font-size:11px;margin-top:3px;word-break:break-all}.wallet input{accent-color:var(--primary)}
1636
- .limits{display:grid;grid-template-columns:1fr 1fr;gap:12px}.field label{display:block;font-size:13px;font-weight:550;margin-bottom:6px}.field input{width:100%;padding:11px;border:1px solid var(--field-border);border-radius:10px;font:inherit;background:var(--card);color:var(--foreground)}
1637
- .consent{display:flex;gap:10px;align-items:flex-start;margin:20px 0;color:var(--consent);font-size:14px;line-height:1.45}.consent input{margin-top:3px;accent-color:var(--primary)}
1638
- button{width:100%;border:0;border-radius:10px;padding:13px 16px;font:inherit;font-weight:600;background:linear-gradient(135deg,#6b38d4,#7c4ee6);color:#fff;cursor:pointer;box-shadow:0 10px 22px -14px rgba(107,56,212,.8);transition:transform .16s ease,box-shadow .16s ease,filter .16s ease}button:hover{transform:translateY(-1px);box-shadow:0 14px 28px -15px rgba(107,56,212,.9)}button:disabled{cursor:not-allowed;filter:saturate(.7);transform:none}.error{background:var(--danger-soft);color:var(--danger);padding:11px;border-radius:9px}
1639
- .note{font-size:12px}.cancel{display:block;text-align:center;margin-top:14px;color:var(--muted);text-decoration:none}@media(max-width:520px){header{padding:18px 20px}.page{place-items:start center;min-height:auto;padding:76px 16px 28px}main{padding:21px}.limits{grid-template-columns:1fr}.brand span{font-size:19px}}
1640
- </style></head><body><header><a class="brand" href="https://apiosk.com" aria-label="Apiosk"><svg viewBox="0 0 1024 1024" role="img" aria-hidden="true" shape-rendering="geometricPrecision"><defs><linearGradient id="apiosk-mark" x1="158" y1="120" x2="866" y2="906" gradientUnits="userSpaceOnUse"><stop offset="0%" stop-color="#8b5cf6"/><stop offset="52%" stop-color="#6b38d4"/><stop offset="100%" stop-color="#5516be"/></linearGradient></defs><g fill="none" stroke="url(#apiosk-mark)" stroke-width="43" stroke-linecap="butt"><line x1="511.5" y1="190.5" x2="511.5" y2="511.5"/><line x1="231" y1="335" x2="511.5" y2="511.5"/><line x1="792.5" y1="335" x2="511.5" y2="511.5"/><line x1="231" y1="675" x2="511.5" y2="511.5"/><line x1="792.5" y1="675" x2="511.5" y2="511.5"/><line x1="511.5" y1="825.5" x2="511.5" y2="511.5"/></g><g fill="url(#apiosk-mark)"><circle cx="511.5" cy="190.5" r="80.5"/><circle cx="231" cy="335" r="80"/><circle cx="792.5" cy="335" r="80"/><circle cx="511.5" cy="511.5" r="135"/><circle cx="231" cy="675" r="80"/><circle cx="792.5" cy="675" r="80"/><circle cx="511.5" cy="825.5" r="80.5"/></g></svg><span>Apiosk</span></a></header><div class="page"><main>
1641
- <p class="eyebrow">${escapeHtml(clientName.client_name || "AI application")}</p><h1>Authorize automatic API payments</h1>
1642
- <p>Your identity is verified. Choose the managed wallet and hard spending limits for this connection.</p>
1643
- <div class="steps" aria-label="Authorization progress"><span class="step done"></span><span class="step done"></span><span class="step"></span></div>
1644
- ${errorMessage ? `<div class="error">${escapeHtml(errorMessage)}</div>` : ""}
1645
- <form id="payment-form" method="post" action="${escapeHtml(actionPath)}">${hidden}<input type="hidden" name="action" value="authorize_payment" /><input type="hidden" name="approval_tx_hash" />
1646
- <div class="wallets">${options}</div>
1647
- <div class="limits"><div class="field"><label for="per_tx">Maximum per request (USDC)</label><input id="per_tx" name="per_tx_limit_usdc" type="number" min="0.000001" max="100" step="0.000001" value="${escapeHtml(first?.perTxLimitUsdc || 1)}" required /></div>
1648
- <div class="field"><label for="daily">Maximum per day (USDC)</label><input id="daily" name="daily_limit_usdc" type="number" min="0.000001" max="1000" step="0.000001" value="${escapeHtml(first?.dailyLimitUsdc || 10)}" required /></div></div>
1649
- <label class="consent"><input type="checkbox" name="payment_consent" value="yes" required /><span>I authorize ${escapeHtml(clientName.client_name || "this app")} to make pay-per-call x402 payments from this wallet within these limits. I can revoke the connection at any time.</span></label>
1650
- <button id="authorize-button" type="submit">Authorize and return to ${escapeHtml(clientName.client_name || "app")}</button>
1651
- </form><p class="note">Only this MCP connection receives the scoped token. Direct x402 payments and other Apiosk connections are unchanged. <a href="${escapeHtml(disclosureUrl)}" target="_blank" rel="noopener noreferrer">Verify the settlement contract, fee history, and approval scope</a>.</p>
1652
- </main></div><script>(()=>{
1653
- const settlement=${JSON.stringify(settlement || null).replaceAll("<", "\\u003c")};
1654
- const form=document.getElementById("payment-form");
1655
- const button=document.getElementById("authorize-button");
1656
- const sleep=(ms)=>new Promise(r=>setTimeout(r,ms));
1657
- const pad64=(hex)=>hex.replace(/^0x/,"").padStart(64,"0");
1658
- form.addEventListener("submit",async(event)=>{
1659
- const selected=form.querySelector('input[name="managed_wallet_id"]:checked');
1660
- if(!selected||selected.dataset.requiresApproval!=="true") return;
1661
- event.preventDefault();
1662
- if(!settlement||!window.ethereum){alert("Open this page in a browser with your connected wallet.");return;}
1663
- button.disabled=true;button.textContent="Authorizing USDC on Base…";
1664
- try{
1665
- await window.ethereum.request({method:"wallet_switchEthereumChain",params:[{chainId:"0x2105"}]});
1666
- const accounts=await window.ethereum.request({method:"eth_requestAccounts"});
1667
- const payer=String(accounts[0]||"").toLowerCase();
1668
- if(payer!==selected.dataset.address.toLowerCase()) throw new Error("Select the same wallet used to sign in.");
1669
- const daily=Number(form.elements.daily_limit_usdc.value);
1670
- if(!Number.isFinite(daily)||daily<=0) throw new Error("Enter a valid daily limit.");
1671
- const amount=BigInt(Math.ceil(daily*1e6));
1672
- const data="0x095ea7b3"+pad64(settlement.contractAddress)+amount.toString(16).padStart(64,"0");
1673
- const tx=await window.ethereum.request({method:"eth_sendTransaction",params:[{from:payer,to:settlement.usdcAddress,data}]});
1674
- button.textContent="Waiting for confirmation…";
1675
- for(let i=0;i<90;i++){
1676
- const receipt=await window.ethereum.request({method:"eth_getTransactionReceipt",params:[tx]});
1677
- if(receipt){if(receipt.status!=="0x1") throw new Error("USDC approval failed on-chain.");break;}
1678
- if(i===89) throw new Error("Approval confirmation timed out.");
1679
- await sleep(2000);
1680
- }
1681
- form.elements.approval_tx_hash.value=tx;button.textContent="Finishing connection…";form.submit();
1682
- }catch(error){alert(error instanceof Error?error.message:"USDC authorization failed.");button.disabled=false;button.textContent="Authorize and return to ${escapeHtml(clientName.client_name || "app")}";}
1683
- });
1684
- })();</script></body></html>`;
1685
- }
1686
-
248
+ // The interstitial shown after the round trip to buy.apiosk.com finishes,
249
+ // right before bouncing back to the MCP client. The client only completes the
250
+ // connection once its own callback runs, so this still carries the
251
+ // authorization code (auto-continue + manual link) rather than replacing it.
1687
252
  function createConnectionCompletePage({ appName, clientName, redirectTarget }) {
1688
253
  const clientLabel = clientName.client_name || "the app";
1689
254
  return `<!doctype html><html lang="en"><head><meta charset="utf-8" />
1690
255
  <meta name="viewport" content="width=device-width,initial-scale=1" />
1691
- <meta name="color-scheme" content="light dark" />
256
+ <meta name="color-scheme" content="only light" />
1692
257
  <meta http-equiv="refresh" content="2;url=${escapeHtml(redirectTarget)}" />
1693
258
  <title>Connected · ${escapeHtml(appName)}</title>
1694
259
  <style>
1695
- :root{font-family:"Google Sans","Product Sans","Google Sans Text",Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color-scheme:light dark;--background:#f8f9fb;--foreground:#1f2028;--card:#fff;--border:#e7e3ef;--muted:#676371;--primary:#6b38d4;--primary-accent:#8b5cf6;--glow:#eadfff;--success:#34a877;--shadow:0 22px 54px -28px rgba(48,28,100,.34);color:var(--foreground);background:var(--background)}
1696
- @media (prefers-color-scheme:dark){:root{--background:#0d0f13;--foreground:#ecebf2;--card:#15171d;--border:#262a34;--muted:#a5a2b0;--primary:#7a45e6;--primary-accent:#a78bfa;--glow:#1d1730;--success:#6fd8a6;--shadow:0 24px 52px -26px rgba(0,0,0,.72)}}
260
+ @font-face{font-family:Inter;src:url("/brand/inter-latin-500-normal.woff2") format("woff2");font-weight:500;font-display:swap}@font-face{font-family:Inter;src:url("/brand/inter-latin-600-normal.woff2") format("woff2");font-weight:600;font-display:swap}
261
+ :root{font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color-scheme:light dark;--background:#f8f8fb;--foreground:#1f2028;--card:#fff;--border:#e7e3ef;--muted:#676371;--primary:#6349db;--primary-fg:#fff;--glow:rgb(99 73 219/.07);--success:#057857;--shadow:0 22px 54px -30px rgba(48,28,100,.3);color:var(--foreground);background:var(--background);font-weight:500;letter-spacing:-.011em}
262
+ @media (prefers-color-scheme:dark){:root{--background:#0d0f13;--foreground:#ecebf2;--card:#15171d;--border:#262a34;--muted:#a5a2b0;--primary:#c3a0ff;--primary-fg:#25153c;--glow:rgb(195 160 255/.12);--success:#6ee7b7;--shadow:0 24px 52px -26px rgba(0,0,0,.72)}}
1697
263
  *{box-sizing:border-box}body{margin:0;min-height:100vh;background:radial-gradient(820px 440px at 88% -10%,var(--glow),transparent 62%),var(--background);-webkit-font-smoothing:antialiased}
1698
- .page{min-height:100vh;display:grid;place-items:center;padding:44px 20px}
1699
- main{width:min(480px,100%);background:color-mix(in srgb,var(--card) 96%,transparent);border:1px solid var(--border);border-radius:18px;padding:32px;box-shadow:var(--shadow);text-align:center}
1700
- .check{width:56px;height:56px;margin:0 auto 18px;border-radius:50%;background:color-mix(in srgb,var(--success) 16%,transparent);display:grid;place-items:center}
1701
- .check svg{width:28px;height:28px;stroke:var(--success)}
1702
- h1{margin:0 0 10px;font-size:27px;font-weight:600;letter-spacing:-.02em}p{color:var(--muted);line-height:1.55;margin:0 0 22px}
1703
- .steps{display:flex;gap:7px;margin:0 0 22px}.step{height:5px;flex:1;border-radius:99px;background:var(--success)}
1704
- a.continue{display:block;border-radius:10px;padding:13px 16px;font-weight:600;background:linear-gradient(135deg,#6b38d4,#7c4ee6);color:#fff;text-decoration:none;box-shadow:0 10px 22px -14px rgba(107,56,212,.8)}
264
+ .page{min-height:100vh;display:grid;place-items:center;padding:28px 18px}
265
+ main{width:min(430px,100%);background:color-mix(in srgb,var(--card) 97%,transparent);border:1px solid var(--border);border-radius:20px;padding:28px;box-shadow:var(--shadow);text-align:center}
266
+ .brand{display:block;width:92px;height:30px;margin:0 auto 20px}.brand img{display:block;width:92px;height:30px;object-fit:contain}
267
+ .check{width:38px;height:38px;margin:0 auto 15px;border-radius:50%;background:color-mix(in srgb,var(--success) 13%,transparent);display:grid;place-items:center}
268
+ .check svg{width:19px;height:19px;stroke:var(--success)}
269
+ h1{margin:0 0 8px;font-size:24px;font-weight:600;letter-spacing:-.03em}p{color:var(--muted);font-size:14px;line-height:1.55;margin:0 0 20px}
270
+ .steps{display:flex;gap:6px;margin:0 0 20px}.step{height:4px;flex:1;border-radius:99px;background:var(--success)}
271
+ a.continue{display:block;border-radius:11px;padding:12px 15px;font-weight:600;letter-spacing:-.016em;background:var(--primary);color:var(--primary-fg);text-decoration:none;box-shadow:0 10px 22px -16px var(--primary)}
1705
272
  .note{font-size:12px;color:var(--muted);margin:14px 0 0}
1706
- </style></head><body><div class="page"><main>
273
+ :root{color-scheme:only light;--background:#f8f8fb;--foreground:#1f2028;--card:#fff;--border:#e5e7eb;--muted:#676371;--primary:#6349db;--primary-fg:#fff;--success:#057857}body{background:var(--background)}main{background:#fff;border-radius:12px;box-shadow:none}.steps{display:none}a.continue{box-shadow:none}
274
+ </style></head><body><div class="page"><main><picture class="brand"><img src="/brand/wordmark-black-320.png" alt="Apiosk" width="320" height="103"></picture>
1707
275
  <div class="check"><svg viewBox="0 0 24 24" fill="none" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg></div>
1708
276
  <h1>You're connected</h1>
1709
- <p>Payments are authorized. Finishing up in ${escapeHtml(clientLabel)} — you can close this window once ${escapeHtml(clientLabel)} shows the connection.</p>
277
+ <p>Your Apiosk account is connected. Finishing securely in ${escapeHtml(clientLabel)}.</p>
1710
278
  <div class="steps" aria-label="Authorization complete"><span class="step"></span><span class="step"></span><span class="step"></span></div>
1711
279
  <a class="continue" id="continue" href="${escapeHtml(redirectTarget)}">Continue to ${escapeHtml(clientLabel)}</a>
1712
280
  <p class="note">Returning you to ${escapeHtml(clientLabel)} automatically…</p>
1713
281
  </main></div><script>setTimeout(()=>{window.location.replace(${JSON.stringify(redirectTarget).replaceAll("<", "\\u003c")})},1200);</script></body></html>`;
1714
282
  }
1715
283
 
1716
- function resolveMcpWalletAuthConfig(env = process.env) {
1717
- const supabaseUrl = normalizeBaseUrl(
1718
- env.APIOSK_SUPABASE_URL || env.SUPABASE_URL,
1719
- DEFAULT_SUPABASE_URL
1720
- );
1721
- const key =
1722
- trimString(env.APIOSK_SUPABASE_SERVICE_ROLE_KEY) ||
1723
- trimString(env.SUPABASE_SERVICE_ROLE_KEY) ||
1724
- trimString(env.SUPABASE_SERVICE_KEY) ||
1725
- trimString(env.APIOSK_SUPABASE_PUBLISHABLE_KEY) ||
1726
- trimString(env.APIOSK_SUPABASE_ANON_KEY) ||
1727
- trimString(env.SUPABASE_PUBLISHABLE_KEY) ||
1728
- trimString(env.SUPABASE_ANON_KEY);
1729
- const walletAuthUrl = normalizeBaseUrl(
1730
- env.APIOSK_WALLET_AUTH_URL || env.APIOSK_WALLET_AUTH_BASE_URL,
1731
- `${supabaseUrl}/functions/v1/wallet-auth`
1732
- );
1733
-
1734
- return {
1735
- configured: Boolean(supabaseUrl && key && walletAuthUrl),
1736
- key,
1737
- supabaseUrl,
1738
- walletAuthUrl,
1739
- };
1740
- }
1741
-
1742
- export function isMcpWalletAuthConfigured(env = process.env) {
1743
- return resolveMcpWalletAuthConfig(env).configured;
1744
- }
1745
-
1746
- function resolveWalletConnectProjectId(env = process.env) {
1747
- return (
1748
- trimString(env.APIOSK_MCP_WALLETCONNECT_PROJECT_ID) ||
1749
- trimString(env.WALLETCONNECT_PROJECT_ID)
1750
- );
1751
- }
1752
-
1753
284
  async function fetchJsonWithBody(url, options) {
1754
285
  const response = await fetch(url, options);
1755
286
  const text = await response.text();
@@ -1767,119 +298,98 @@ async function fetchJsonWithBody(url, options) {
1767
298
  };
1768
299
  }
1769
300
 
1770
- async function fetchWalletAuthJson(env, path, payload = {}) {
1771
- const config = resolveMcpWalletAuthConfig(env);
1772
- if (!config.configured) {
1773
- throw statusError("Wallet sign-in is not configured on this MCP server.", 503);
1774
- }
1775
-
1776
- const response = await fetchJsonWithBody(`${config.walletAuthUrl}/${path}`, {
1777
- method: "POST",
1778
- headers: {
1779
- accept: "application/json",
1780
- apikey: config.key,
1781
- authorization: `Bearer ${config.key}`,
1782
- "content-type": "application/json",
1783
- },
1784
- body: JSON.stringify(payload),
1785
- cache: "no-store",
1786
- });
1787
-
1788
- if (!response.ok) {
1789
- throw statusError(
1790
- responseMessage(response.body, "Wallet sign-in is temporarily unavailable."),
1791
- response.status
1792
- );
1793
- }
1794
-
1795
- return response.body && typeof response.body === "object" ? response.body : {};
301
+ function resolveGatewayBaseUrl(env = process.env) {
302
+ return normalizeBaseUrl(
303
+ env?.APIOSK_GATEWAY_URL || env?.APIOSK_GATEWAY_BASE_URL,
304
+ DEFAULT_GATEWAY_URL
305
+ );
1796
306
  }
1797
307
 
1798
- export async function createMcpWalletAuthNonce({ env = process.env } = {}) {
1799
- const body = await fetchWalletAuthJson(env, "nonce");
1800
- const nonce = trimString(body.nonce);
1801
- if (!nonce) {
1802
- throw statusError("Wallet sign-in did not return a nonce.", 502);
1803
- }
1804
- return body;
308
+ /**
309
+ * Where the browser is sent to approve: the agent gateway's own authorize
310
+ * endpoint, which validates the request and redirects on to
311
+ * `app.apiosk.com/connect`.
312
+ *
313
+ * IT IS THE GATEWAY BASE AND NOTHING ELSE. `APIOSK_BUYER_PORTAL_URL` used to
314
+ * name a frontend here and is deliberately no longer read: a deploy that still
315
+ * sets it to `buy.apiosk.com` would otherwise be sent to
316
+ * `buy.apiosk.com/v1/oauth/authorize`, which is not a page, and a wrong host
317
+ * that answers 404 is worse than one that was never consulted. A deploy that
318
+ * needs to move this moves `APIOSK_GATEWAY_URL`, which moves the token
319
+ * endpoint with it - and those two must agree or the code will not redeem.
320
+ */
321
+ function resolveAuthorizeBaseUrl(env = process.env) {
322
+ return resolveGatewayBaseUrl(env);
1805
323
  }
1806
324
 
1807
- async function verifySupabaseTokenHash(env, tokenHash) {
1808
- const config = resolveMcpWalletAuthConfig(env);
1809
- if (!config.configured) {
1810
- throw statusError("Wallet sign-in is not configured on this MCP server.", 503);
1811
- }
1812
-
1813
- const response = await fetchJsonWithBody(`${config.supabaseUrl}/auth/v1/verify`, {
325
+ /**
326
+ * One request to the agent gateway's token endpoint, and the reading of its
327
+ * answer.
328
+ *
329
+ * The endpoint takes a form or JSON (`readForm` in the gateway's oauth.ts
330
+ * accepts both), and answers the same shape for both grants this server uses.
331
+ * No wallet key, no on-chain transaction and no x402 payload is ever
332
+ * constructed here — settlement belongs to the gateway.
333
+ *
334
+ * THE REFRESH TOKEN IS THE PART THAT IS NEW AND THE PART THAT MATTERS. The
335
+ * agent gateway's access tokens live 24 hours; this server's own live one.
336
+ * Kept only the access token, a connection would work for a day and then ask
337
+ * the person to approve something they had already approved. The refresh token
338
+ * rotates on every use, so what is held is at most a day old.
339
+ */
340
+ async function requestUpstreamToken(env, grant, failureMessage) {
341
+ const url = `${resolveGatewayBaseUrl(env)}/v1/oauth/token`;
342
+ const response = await fetchJsonWithBody(url, {
1814
343
  method: "POST",
1815
- headers: {
1816
- accept: "application/json",
1817
- apikey: config.key,
1818
- authorization: `Bearer ${config.key}`,
1819
- "content-type": "application/json",
1820
- },
1821
- body: JSON.stringify({
1822
- token_hash: tokenHash,
1823
- type: "magiclink",
1824
- }),
1825
- cache: "no-store",
344
+ headers: { accept: "application/json", "content-type": "application/json" },
345
+ body: JSON.stringify({ ...grant, client_id: PORTAL_CLIENT_ID }),
1826
346
  });
1827
347
 
1828
348
  if (!response.ok) {
1829
349
  throw statusError(
1830
- responseMessage(response.body, "Could not establish an Apiosk dashboard session."),
1831
- response.status
350
+ responseMessage(response.body, failureMessage),
351
+ response.status >= 400 ? response.status : 502
1832
352
  );
1833
353
  }
1834
354
 
1835
355
  const body = response.body && typeof response.body === "object" ? response.body : {};
1836
- const session = body.session && typeof body.session === "object" ? body.session : body;
1837
- const user = body.user && typeof body.user === "object" ? body.user : session.user || {};
1838
- const sessionToken = trimString(session.access_token);
1839
- if (!sessionToken) {
1840
- throw statusError("Wallet sign-in did not return a dashboard session.", 502);
356
+ const connectToken = trimString(body.access_token);
357
+ if (!connectToken) {
358
+ throw statusError("The gateway did not return an access token.", 502);
1841
359
  }
1842
360
 
1843
- const expiresAt =
1844
- Number(session.expires_at) ||
1845
- (Number.isFinite(Number(session.expires_in))
1846
- ? getIssuedAtSeconds() + Number(session.expires_in)
1847
- : null);
1848
-
1849
361
  return {
1850
- session_token: sessionToken,
1851
- expires_at: expiresAt,
1852
- user_id: trimString(user.id),
1853
- email: trimString(user.email),
362
+ connectToken,
363
+ refreshToken: trimString(body.refresh_token) || null,
364
+ expiresInSeconds: Number.isFinite(Number(body.expires_in)) ? Number(body.expires_in) : null,
1854
365
  };
1855
366
  }
1856
367
 
1857
- async function verifyWalletDashboardSession(env, { address, message, signature, method }) {
1858
- const normalizedAddress = trimString(address);
1859
- if (!EVM_ADDRESS_PATTERN.test(normalizedAddress)) {
1860
- throw statusError("Invalid wallet address.", 400);
1861
- }
1862
- if (!trimString(message) || !trimString(signature)) {
1863
- throw statusError("Missing wallet message or signature.", 400);
1864
- }
1865
-
1866
- const walletAuth = await fetchWalletAuthJson(env, "verify", {
1867
- address: normalizedAddress,
1868
- message,
1869
- signature,
1870
- method: method === "created_wallet" ? "created_wallet" : "connected_wallet",
1871
- });
1872
- const tokenHash = trimString(walletAuth.tokenHash || walletAuth.token_hash);
1873
- if (!tokenHash) {
1874
- throw statusError("Wallet sign-in did not return a session token.", 502);
1875
- }
368
+ /**
369
+ * Redeem the one-time code the approval screen produced, server side.
370
+ *
371
+ * Injectable so tests can bypass the network call.
372
+ */
373
+ async function defaultExchangePortalCode(env, { code, codeVerifier, redirectUri }) {
374
+ return await requestUpstreamToken(
375
+ env,
376
+ {
377
+ grant_type: "authorization_code",
378
+ code,
379
+ code_verifier: codeVerifier,
380
+ redirect_uri: redirectUri,
381
+ },
382
+ "Could not exchange the approval code for an access token."
383
+ );
384
+ }
1876
385
 
1877
- const session = await verifySupabaseTokenHash(env, tokenHash);
1878
- return {
1879
- ...session,
1880
- email: session.email || trimString(walletAuth.email),
1881
- wallet_address: normalizedAddress.toLowerCase(),
1882
- };
386
+ /** Trade a rotating upstream refresh token for a fresh pair. */
387
+ async function defaultRefreshPortalToken(env, { refreshToken }) {
388
+ return await requestUpstreamToken(
389
+ env,
390
+ { grant_type: "refresh_token", refresh_token: refreshToken },
391
+ "Could not refresh the Apiosk connection."
392
+ );
1883
393
  }
1884
394
 
1885
395
  class ApioskOAuthClientsStore {
@@ -1983,9 +493,8 @@ class ApioskHostedOAuthProvider {
1983
493
  mcpServerUrl,
1984
494
  appName,
1985
495
  resourceName,
1986
- connectTokenMinter,
1987
- payableWalletLister,
1988
- requirePaymentAuthorization,
496
+ exchangePortalCode,
497
+ refreshPortalToken,
1989
498
  }) {
1990
499
  this.env = env;
1991
500
  this.secret = secret;
@@ -1993,16 +502,11 @@ class ApioskHostedOAuthProvider {
1993
502
  this.mcpServerUrl = mcpServerUrl;
1994
503
  this.appName = appName;
1995
504
  this.resourceName = resourceName;
1996
- // Injectable so tests can bypass the Supabase round-trip; defaults to the
1997
- // real hosted-payment minter.
1998
- this.connectTokenMinter = connectTokenMinter || mintHostedConnectToken;
1999
- this.payableWalletLister = payableWalletLister || prepareHostedPaymentWallets;
2000
- // Production always uses the explicit two-step consent screen. Existing
2001
- // test/custom minters keep their legacy single-step behavior unless they
2002
- // explicitly opt in, so this remains an additive integration surface.
2003
- this.requirePaymentAuthorization =
2004
- requirePaymentAuthorization ?? !connectTokenMinter;
505
+ // Injectable so tests can bypass the network call to the gateway.
506
+ this.exchangePortalCode = exchangePortalCode || defaultExchangePortalCode;
507
+ this.refreshPortalToken = refreshPortalToken || defaultRefreshPortalToken;
2005
508
  this.clientsStore = new ApioskOAuthClientsStore(secret);
509
+ this.callbackUrl = new URL("/authorize/callback", this.issuerUrl).href;
2006
510
  // Audiences we honour on an access token. A client that connected via
2007
511
  // /sse (ChatGPT) requests resource=<origin>/sse; one via /mcp requests
2008
512
  // <origin>/mcp. The origin root is accepted for clients that omit the
@@ -2021,206 +525,164 @@ class ApioskHostedOAuthProvider {
2021
525
  return this.allowedResources.has(normalized) || this.allowedResources.has(resourceHref);
2022
526
  }
2023
527
 
528
+ /**
529
+ * Start the handoff to the agent gateway, which approves at
530
+ * `app.apiosk.com/connect`.
531
+ *
532
+ * Identity, funding and spending limits all live there — this server never
533
+ * renders a sign-in page and never sees a wallet key. It starts an OAuth 2.0
534
+ * authorization code request with PKCE of its own and stashes the ORIGINAL
535
+ * request (from Claude, ChatGPT, ...) in a signed `state` so
536
+ * `completePortalCallback` can pick the flow back up when the browser comes
537
+ * back.
538
+ *
539
+ * `name` rather than `app_name`: that is what `startAuthorization` reads and
540
+ * carries to the approval screen as the suggested connection name, which the
541
+ * person can edit there.
542
+ */
2024
543
  async authorize(client, params, res) {
2025
- const req = res.req;
2026
- const submittedAction = trimString(req?.body?.action);
2027
- const renderPage = (status, options = {}) =>
2028
- res
2029
- .status(status)
2030
- .setHeader("content-type", "text/html; charset=utf-8")
2031
- .send(
2032
- createAuthorizePage({
2033
- actionPath: new URL("/authorize", this.issuerUrl).pathname,
2034
- appName: this.appName,
2035
- clientName: client,
2036
- oauthParams: params,
2037
- walletEnabled: isMcpWalletAuthConfigured(this.env),
2038
- walletConnectProjectId: resolveWalletConnectProjectId(this.env),
2039
- ...options,
2040
- })
2041
- );
2042
- const renderPaymentPage = (status, options) =>
544
+ const verifier = crypto.randomBytes(48).toString("base64url");
545
+ const challenge = crypto.createHash("sha256").update(verifier).digest("base64url");
546
+ // Display the runtime that owns the validated callback, rather than the
547
+ // shared Apiosk transport client. Client-supplied names are not identity.
548
+ const callback = new URL(params.redirectUri);
549
+ const provider = callback.protocol === "https:" ? ({
550
+ "claude.ai": "anthropic",
551
+ "chatgpt.com": "openai",
552
+ "chat.openai.com": "openai",
553
+ })[callback.hostname] : undefined;
554
+
555
+ const handoffState = buildIssuedToken(
556
+ this.secret,
557
+ "portal_handoff",
558
+ {
559
+ verifier,
560
+ clientId: client.client_id,
561
+ redirectUri: params.redirectUri,
562
+ codeChallenge: params.codeChallenge,
563
+ state: params.state || null,
564
+ scopes: params.scopes?.length ? params.scopes : [DEFAULT_SCOPE, OFFLINE_ACCESS_SCOPE],
565
+ resource: params.resource ? params.resource.href : this.mcpServerUrl.href,
566
+ },
567
+ PORTAL_HANDOFF_TTL_SECONDS
568
+ ).token;
569
+
570
+ // Appended, not resolved as a root-relative path: the agent gateway lives
571
+ // UNDER a path (`/functions/v1/agent-gateway`), and `new URL("/v1/...",
572
+ // base)` would throw that path away and address the project root.
573
+ const portalUrl = buildRedirectUri(
574
+ `${resolveAuthorizeBaseUrl(this.env)}/v1/oauth/authorize`,
575
+ {
576
+ client_id: PORTAL_CLIENT_ID,
577
+ redirect_uri: this.callbackUrl,
578
+ response_type: "code",
579
+ code_challenge: challenge,
580
+ // Required, and refused rather than assumed: `startAuthorization`
581
+ // answers `plain` with an error instead of downgrading.
582
+ code_challenge_method: "S256",
583
+ state: handoffState,
584
+ name: trimString(client.client_name) || undefined,
585
+ provider,
586
+ }
587
+ );
588
+
589
+ res.redirect(302, portalUrl);
590
+ }
591
+
592
+ /**
593
+ * GET /authorize/callback — buy.apiosk.com sends the browser back here once
594
+ * the buyer signs in, funds a wallet, sets limits and consents. Exchanges
595
+ * the one-time code for a connect token server side (RFC 6749 §4.1.3), so
596
+ * the token never enters this browser's address bar or its history.
597
+ */
598
+ async completePortalCallback(req, res) {
599
+ const query = req.query || {};
600
+ const stateToken = trimString(query.state);
601
+
602
+ let handoff;
603
+ try {
604
+ handoff = parseSignedToken(this.secret, stateToken);
605
+ if (handoff.typ !== "portal_handoff") {
606
+ throw new Error("Invalid state");
607
+ }
608
+ } catch {
2043
609
  res
2044
- .status(status)
2045
- .setHeader("content-type", "text/html; charset=utf-8")
2046
- .send(
2047
- createPaymentAuthorizationPage({
2048
- actionPath: new URL("/authorize", this.issuerUrl).pathname,
2049
- disclosureUrl: new URL(SETTLEMENT_DISCLOSURE_PATH, this.issuerUrl).href,
2050
- appName: this.appName,
2051
- clientName: client,
2052
- oauthParams: params,
2053
- ...options,
2054
- })
2055
- );
2056
-
2057
- if (!req || req.method !== "POST" || !submittedAction) {
2058
- renderPage(200);
610
+ .status(400)
611
+ .type("text/plain")
612
+ .send("This sign-in link is invalid or has expired. Start again from your MCP client.");
613
+ return;
614
+ }
615
+
616
+ const client = await this.clientsStore.getClient(handoff.clientId);
617
+ if (!client) {
618
+ res.status(400).type("text/plain").send("Unknown client. Start again from your MCP client.");
2059
619
  return;
2060
620
  }
2061
621
 
2062
- if (submittedAction === "cancel") {
622
+ const params = {
623
+ redirectUri: handoff.redirectUri,
624
+ codeChallenge: handoff.codeChallenge,
625
+ state: handoff.state,
626
+ scopes: handoff.scopes,
627
+ resource: handoff.resource ? new URL(handoff.resource) : new URL(this.mcpServerUrl.href),
628
+ };
629
+
630
+ const errorCode = trimString(query.error);
631
+ if (errorCode) {
2063
632
  res.redirect(
2064
633
  302,
2065
634
  buildRedirectUri(params.redirectUri, {
2066
- error: "access_denied",
2067
- error_description: "The user cancelled authorization.",
635
+ error: errorCode,
636
+ error_description:
637
+ trimString(query.error_description) || "The buyer did not complete the connection.",
2068
638
  state: params.state,
2069
639
  })
2070
640
  );
2071
641
  return;
2072
642
  }
2073
643
 
2074
- if (submittedAction === "wallet_sign_in") {
2075
- try {
2076
- // The wallet signs a multi-line message joined with "\n", but an
2077
- // application/x-www-form-urlencoded POST normalizes every "\n" in a
2078
- // control's value to "\r\n" (HTML form-submission spec). Those extra
2079
- // bytes change the EIP-191 digest, so server-side signature recovery
2080
- // returns a different address and the wallet-auth function rejects it
2081
- // with "Signature does not match the wallet." To keep the signed bytes
2082
- // intact across the POST, the client base64url-encodes the message and
2083
- // we decode it here before verification.
2084
- const message =
2085
- trimString(req.body.wallet_message_encoding).toLowerCase() === "base64url"
2086
- ? fromBase64Url(trimString(req.body.wallet_message))
2087
- : req.body.wallet_message;
2088
- const session = await verifyWalletDashboardSession(this.env, {
2089
- address: req.body.wallet_address,
2090
- message,
2091
- signature: req.body.wallet_signature,
2092
- method: req.body.wallet_method,
2093
- });
2094
- if (!this.requirePaymentAuthorization) {
2095
- await this.finishAuthorization(res, client, params, session);
2096
- return;
2097
- }
2098
- const wallets = await this.payableWalletLister({
2099
- env: this.env,
2100
- sessionToken: trimString(session.session_token),
2101
- userId: trimString(session.user_id),
2102
- connectedAddress: trimString(session.wallet_address),
2103
- });
2104
- if (!wallets.length) {
2105
- renderPage(400, {
2106
- errorMessage:
2107
- "Your identity is verified, but this account has no active managed payment wallet. Create and fund a managed Apiosk wallet, then connect again. A browser wallet alone cannot authorize unattended payments.",
2108
- });
2109
- return;
2110
- }
2111
- const pendingAuthorization = buildIssuedToken(
2112
- this.secret,
2113
- "payment_authorization",
2114
- {
2115
- clientId: client.client_id,
2116
- redirectUri: params.redirectUri,
2117
- session,
2118
- },
2119
- AUTHORIZATION_CODE_TTL_SECONDS,
2120
- normalizeSessionExpiry(session.expires_at)
2121
- ).token;
2122
- const settlement = hostedSettlementPublicConfig(this.env);
2123
- if (!settlement && wallets.some((wallet) => wallet.requiresApproval)) {
2124
- throw statusError("Connected-wallet payments are not configured on this server.", 503);
2125
- }
2126
- renderPaymentPage(200, { pendingAuthorization, wallets, settlement });
2127
- } catch (error) {
2128
- renderPage(error.status && error.status >= 400 ? error.status : 400, {
2129
- errorMessage:
2130
- error instanceof Error
2131
- ? error.message
2132
- : "Could not sign in with this wallet. Try again.",
2133
- });
2134
- }
644
+ const code = trimString(query.code);
645
+ if (!code) {
646
+ res.status(400).type("text/plain").send("The portal did not return a code.");
2135
647
  return;
2136
648
  }
2137
649
 
2138
- if (submittedAction === "authorize_payment") {
2139
- let pendingAuthorization = trimString(req.body.pending_authorization);
2140
- let wallets = [];
2141
- try {
2142
- const pending = parseSignedToken(this.secret, pendingAuthorization);
2143
- if (
2144
- pending.typ !== "payment_authorization" ||
2145
- pending.clientId !== client.client_id ||
2146
- pending.redirectUri !== params.redirectUri ||
2147
- !pending.session
2148
- ) {
2149
- throw statusError("Payment authorization is invalid or expired. Start again.", 400);
2150
- }
2151
- if (trimString(req.body.payment_consent) !== "yes") {
2152
- throw statusError("Confirm the payment authorization to continue.", 400);
2153
- }
2154
- wallets = await this.payableWalletLister({
2155
- env: this.env,
2156
- sessionToken: trimString(pending.session.session_token),
2157
- userId: trimString(pending.session.user_id),
2158
- connectedAddress: trimString(pending.session.wallet_address),
2159
- });
2160
- const mintedConnect = await this.connectTokenMinter({
2161
- env: this.env,
2162
- sessionToken: trimString(pending.session.session_token),
2163
- userId: trimString(pending.session.user_id),
2164
- walletId: trimString(req.body.managed_wallet_id),
2165
- dailyLimitUsdc: req.body.daily_limit_usdc,
2166
- perTxLimitUsdc: req.body.per_tx_limit_usdc,
2167
- approvalTxHash: req.body.approval_tx_hash,
2168
- strict: true,
2169
- });
2170
- if (!mintedConnect?.connectToken) {
2171
- throw statusError("Could not create the scoped Apiosk payment token. Try again.", 502);
2172
- }
2173
- await this.finishAuthorization(res, client, params, pending.session, mintedConnect, {
2174
- successPage: true,
2175
- });
2176
- } catch (error) {
2177
- if (!wallets.length) {
2178
- renderPage(error.status && error.status >= 400 ? error.status : 400, {
2179
- errorMessage: error instanceof Error ? error.message : "Payment authorization failed.",
2180
- });
2181
- } else {
2182
- renderPaymentPage(error.status && error.status >= 400 ? error.status : 400, {
2183
- pendingAuthorization,
2184
- wallets,
2185
- settlement: hostedSettlementPublicConfig(this.env),
2186
- errorMessage: error instanceof Error ? error.message : "Payment authorization failed.",
2187
- });
2188
- }
2189
- }
650
+ let exchange;
651
+ try {
652
+ exchange = await this.exchangePortalCode(this.env, {
653
+ code,
654
+ codeVerifier: handoff.verifier,
655
+ redirectUri: this.callbackUrl,
656
+ });
657
+ } catch (error) {
658
+ res
659
+ .status(error?.status && error.status >= 400 ? error.status : 502)
660
+ .type("text/plain")
661
+ .send(error instanceof Error ? error.message : "Could not finish connecting to Apiosk.");
2190
662
  return;
2191
663
  }
2192
664
 
2193
- renderPage(400, {
2194
- errorMessage: "Sign in with a wallet to continue.",
2195
- });
665
+ await this.finishAuthorization(res, client, params, exchange);
2196
666
  }
2197
667
 
2198
- async finishAuthorization(
2199
- res,
2200
- client,
2201
- params,
2202
- session,
2203
- authorizedConnect = null,
2204
- { successPage = false } = {}
2205
- ) {
2206
- const sessionToken = trimString(session.session_token);
2207
- const normalizedSessionExpiry = normalizeSessionExpiry(session.expires_at);
2208
-
2209
- // Bridge the wallet sign-in into a payable connect token: mint one for the
2210
- // user's managed wallet so the gateway can settle paid calls autonomously
2211
- // (a browser wallet cannot be settled from server-side). Best-effort — if
2212
- // the user has no managed wallet, or minting fails, sign-in still completes
2213
- // and paid calls fall back to the same 402 as before.
2214
- const mintedConnect = authorizedConnect || (sessionToken
2215
- ? await this.connectTokenMinter({
2216
- env: this.env,
2217
- sessionToken,
2218
- userId: trimString(session.user_id),
2219
- })
2220
- : null);
2221
- const apioskConnectToken = trimString(mintedConnect?.connectToken) || undefined;
2222
- const apioskConnectWalletAddress =
2223
- trimString(mintedConnect?.walletAddress) || undefined;
668
+ async finishAuthorization(res, client, params, exchange) {
669
+ const connectToken = trimString(exchange.connectToken);
670
+ const upstreamRefresh = trimString(exchange.refreshToken);
671
+ const upstreamExpiresAt = Number.isFinite(exchange.expiresInSeconds)
672
+ ? getIssuedAtSeconds() + exchange.expiresInSeconds
673
+ : null;
674
+
675
+ /**
676
+ * The cap, and when there is not one.
677
+ *
678
+ * This server's tokens may never outlive what they carry, so an upstream
679
+ * access token with no way to renew it caps everything issued from it.
680
+ * HOLDING A REFRESH TOKEN REMOVES THE CAP: the upstream access token
681
+ * expiring is then a thing this server fixes on the next refresh rather
682
+ * than a thing the person fixes by approving again. Without this the whole
683
+ * connection died every 24 hours, which is the agent gateway's access TTL.
684
+ */
685
+ const maxExpiry = upstreamRefresh ? null : upstreamExpiresAt;
2224
686
 
2225
687
  const authorizationCode = buildIssuedToken(
2226
688
  this.secret,
@@ -2231,16 +693,12 @@ class ApioskHostedOAuthProvider {
2231
693
  codeChallenge: params.codeChallenge,
2232
694
  scopes: params.scopes?.length ? params.scopes : [DEFAULT_SCOPE, OFFLINE_ACCESS_SCOPE],
2233
695
  resource: params.resource ? params.resource.href : this.mcpServerUrl.href,
2234
- dashboardSessionToken: sessionToken,
2235
- dashboardSessionExpiresAt: normalizedSessionExpiry || undefined,
2236
- userId: trimString(session.user_id),
2237
- email: trimString(session.email),
2238
- walletAddress: trimString(session.wallet_address) || undefined,
2239
- apioskConnectToken,
2240
- apioskConnectWalletAddress,
696
+ apioskConnectToken: connectToken || undefined,
697
+ apioskConnectTokenExpiresAt: upstreamExpiresAt || undefined,
698
+ apioskRefreshToken: upstreamRefresh || undefined,
2241
699
  },
2242
700
  AUTHORIZATION_CODE_TTL_SECONDS,
2243
- normalizedSessionExpiry
701
+ maxExpiry
2244
702
  ).token;
2245
703
 
2246
704
  const redirectTarget = buildRedirectUri(params.redirectUri, {
@@ -2248,52 +706,31 @@ class ApioskHostedOAuthProvider {
2248
706
  state: params.state,
2249
707
  });
2250
708
 
2251
- if (successPage) {
2252
- // After the on-chain USDC approval, land on an explicit confirmation
2253
- // instead of bouncing straight to the client: the page still delivers
2254
- // the authorization code (auto-continue + manual link) because the
2255
- // OAuth client only completes the connection once its callback runs.
2256
- res
2257
- .status(200)
2258
- .setHeader("content-type", "text/html; charset=utf-8")
2259
- .send(
2260
- createConnectionCompletePage({
2261
- appName: this.appName,
2262
- clientName: client,
2263
- redirectTarget,
2264
- })
2265
- );
2266
- return;
2267
- }
2268
-
709
+ // Complete the host's OAuth session using a real HTTP redirect. An HTML
710
+ // timer leaves mobile authentication sessions waiting for JavaScript and
711
+ // can race the meta refresh into a second use of a one-time code.
712
+ res.setHeader("Cache-Control", "no-store");
713
+ res.setHeader("Referrer-Policy", "no-referrer");
2269
714
  res.redirect(302, redirectTarget);
2270
715
  }
2271
716
 
2272
717
  async challengeForAuthorizationCode(client, authorizationCode) {
2273
- const payload = parseSignedToken(this.secret, authorizationCode);
2274
- if (payload.typ !== "code") {
2275
- throw new Error("Invalid authorization code");
2276
- }
2277
- if (payload.clientId !== client.client_id) {
2278
- throw new Error("Authorization code was not issued to this client");
2279
- }
718
+ const payload = parseOAuthGrant(this.secret, authorizationCode, "code", client);
2280
719
  return payload.codeChallenge;
2281
720
  }
2282
721
 
2283
722
  async exchangeAuthorizationCode(client, authorizationCode, _codeVerifier, redirectUri, resource) {
2284
- const payload = parseSignedToken(this.secret, authorizationCode);
2285
- if (payload.typ !== "code") {
2286
- throw new Error("Invalid authorization code");
2287
- }
2288
- if (payload.clientId !== client.client_id) {
2289
- throw new Error("Authorization code was not issued to this client");
2290
- }
723
+ const payload = parseOAuthGrant(this.secret, authorizationCode, "code", client);
2291
724
  if (redirectUri && payload.redirectUri !== redirectUri) {
2292
- throw new Error("redirect_uri does not match the authorization code");
725
+ throw new InvalidGrantError("redirect_uri does not match the authorization code");
2293
726
  }
2294
727
 
2295
728
  const requestedResource = resource ? resource.href : payload.resource || this.mcpServerUrl.href;
2296
- const maxExpiry = Number.isFinite(payload.dashboardSessionExpiresAt) ? payload.dashboardSessionExpiresAt : null;
729
+ // Uncapped when a refresh token came with it. See `finishAuthorization`.
730
+ const maxExpiry =
731
+ !payload.apioskRefreshToken && Number.isFinite(payload.apioskConnectTokenExpiresAt)
732
+ ? payload.apioskConnectTokenExpiresAt
733
+ : null;
2297
734
  const tokenPayload = {
2298
735
  clientId: client.client_id,
2299
736
  scopes:
@@ -2301,13 +738,9 @@ class ApioskHostedOAuthProvider {
2301
738
  payload.scopes :
2302
739
  [DEFAULT_SCOPE, OFFLINE_ACCESS_SCOPE],
2303
740
  resource: requestedResource,
2304
- dashboardSessionToken: payload.dashboardSessionToken,
2305
- dashboardSessionExpiresAt: payload.dashboardSessionExpiresAt,
2306
- userId: payload.userId,
2307
- email: payload.email,
2308
- walletAddress: payload.walletAddress,
2309
741
  apioskConnectToken: payload.apioskConnectToken,
2310
- apioskConnectWalletAddress: payload.apioskConnectWalletAddress,
742
+ apioskConnectTokenExpiresAt: payload.apioskConnectTokenExpiresAt,
743
+ apioskRefreshToken: payload.apioskRefreshToken,
2311
744
  };
2312
745
 
2313
746
  const accessToken = buildIssuedToken(
@@ -2335,42 +768,87 @@ class ApioskHostedOAuthProvider {
2335
768
  }
2336
769
 
2337
770
  async exchangeRefreshToken(client, refreshToken, scopes, resource) {
2338
- const payload = parseSignedToken(this.secret, refreshToken);
2339
- if (payload.typ !== "refresh") {
2340
- throw new Error("Invalid refresh token");
2341
- }
2342
- if (payload.clientId !== client.client_id) {
2343
- throw new Error("Refresh token was not issued to this client");
2344
- }
771
+ const payload = parseOAuthGrant(this.secret, refreshToken, "refresh", client);
2345
772
 
2346
773
  const grantedScopes =
2347
774
  Array.isArray(scopes) && scopes.length ?
2348
775
  scopes.filter((scope) => Array.isArray(payload.scopes) && payload.scopes.includes(scope)) :
2349
776
  payload.scopes;
2350
777
  const requestedResource = resource ? resource.href : payload.resource || this.mcpServerUrl.href;
2351
- const maxExpiry = Number.isFinite(payload.dashboardSessionExpiresAt) ? payload.dashboardSessionExpiresAt : null;
778
+
779
+ /**
780
+ * Renew the upstream token when it is spent, before minting one that
781
+ * carries it.
782
+ *
783
+ * THIS IS WHERE A CONNECTION SURVIVES ITS SECOND DAY. The agent gateway's
784
+ * access tokens live 24 hours and this server's live one, so a client that
785
+ * refreshes hourly reaches a point where every token it is handed carries
786
+ * an upstream credential that is already dead — and every tool call fails
787
+ * with a 401 the person can do nothing about except approve again.
788
+ *
789
+ * Renewed a little BEFORE expiry, not at it: a token that dies between
790
+ * being minted here and being used by the next tool call is the same
791
+ * failure arriving less often, which is worse to diagnose than one that
792
+ * arrives reliably.
793
+ *
794
+ * A refusal upstream is not fatal here. The old token may still have life
795
+ * in it, and if it does not, the tool call is where that is reported - with
796
+ * the gateway's own words, to a caller that is asking for something rather
797
+ * than to one that is only renewing.
798
+ */
799
+ let upstreamToken = payload.apioskConnectToken;
800
+ let upstreamRefresh = payload.apioskRefreshToken;
801
+ let upstreamExpiresAt = payload.apioskConnectTokenExpiresAt;
802
+ const spent =
803
+ Number.isFinite(upstreamExpiresAt) &&
804
+ upstreamExpiresAt - getIssuedAtSeconds() < UPSTREAM_RENEWAL_MARGIN_SECONDS;
805
+
806
+ if (upstreamRefresh && spent) {
807
+ try {
808
+ const renewed = await this.refreshPortalToken(this.env, { refreshToken: upstreamRefresh });
809
+ upstreamToken = trimString(renewed.connectToken) || upstreamToken;
810
+ // Rotating: the one just spent is dead, so keeping it would guarantee
811
+ // the next renewal fails.
812
+ upstreamRefresh = trimString(renewed.refreshToken) || null;
813
+ upstreamExpiresAt = Number.isFinite(renewed.expiresInSeconds)
814
+ ? getIssuedAtSeconds() + renewed.expiresInSeconds
815
+ : null;
816
+ } catch {
817
+ // Left as it was. See above.
818
+ }
819
+ }
820
+
821
+ const tokenPayload = {
822
+ clientId: client.client_id,
823
+ scopes: grantedScopes,
824
+ resource: requestedResource,
825
+ apioskConnectToken: upstreamToken,
826
+ apioskConnectTokenExpiresAt: upstreamExpiresAt,
827
+ apioskRefreshToken: upstreamRefresh,
828
+ };
829
+ // Uncapped when a refresh token remains. See `finishAuthorization`.
830
+ const maxExpiry =
831
+ !upstreamRefresh && Number.isFinite(upstreamExpiresAt) ? upstreamExpiresAt : null;
2352
832
  const accessToken = buildIssuedToken(
2353
833
  this.secret,
2354
834
  "access",
2355
- {
2356
- clientId: client.client_id,
2357
- scopes: grantedScopes,
2358
- resource: requestedResource,
2359
- dashboardSessionToken: payload.dashboardSessionToken,
2360
- dashboardSessionExpiresAt: payload.dashboardSessionExpiresAt,
2361
- userId: payload.userId,
2362
- email: payload.email,
2363
- walletAddress: payload.walletAddress,
2364
- apioskConnectToken: payload.apioskConnectToken,
2365
- apioskConnectWalletAddress: payload.apioskConnectWalletAddress,
2366
- },
835
+ tokenPayload,
2367
836
  ACCESS_TOKEN_TTL_SECONDS,
2368
837
  maxExpiry
2369
838
  );
2370
839
 
2371
840
  return {
2372
841
  access_token: accessToken.token,
2373
- refresh_token: refreshToken,
842
+ // Reissued rather than returned, because the upstream token it carries
843
+ // may have just changed. Handing back the one that came in would hand
844
+ // back a rotated-away upstream refresh token with it.
845
+ refresh_token: buildIssuedToken(
846
+ this.secret,
847
+ "refresh",
848
+ tokenPayload,
849
+ REFRESH_TOKEN_TTL_SECONDS,
850
+ maxExpiry
851
+ ).token,
2374
852
  token_type: "bearer",
2375
853
  expires_in: Math.max(1, accessToken.expiresAt - getIssuedAtSeconds()),
2376
854
  scope: Array.isArray(grantedScopes) ? grantedScopes.join(" ") : DEFAULT_SCOPE,
@@ -2389,15 +867,6 @@ class ApioskHostedOAuthProvider {
2389
867
  return this.verifyConnectTokenAccess(trimmed);
2390
868
  }
2391
869
 
2392
- // Accept Apiosk provider API keys (sk_live_…) as bearer-equivalent. This
2393
- // is the publisher path: a coding agent configures
2394
- // `Authorization: Bearer sk_live_…` in its MCP client and can publish
2395
- // x402 routes without an interactive OAuth handshake. Verified against
2396
- // the provider_api_keys table (verify_provider_api_key RPC).
2397
- if (isProviderApiKey(trimmed)) {
2398
- return this.verifyProviderKeyAccess(trimmed);
2399
- }
2400
-
2401
870
  const payload = parseSignedToken(this.secret, token);
2402
871
  if (payload.typ !== "access") {
2403
872
  throw new Error("Invalid access token");
@@ -2414,45 +883,11 @@ class ApioskHostedOAuthProvider {
2414
883
  expiresAt: payload.exp,
2415
884
  resource: payload.resource ? new URL(payload.resource) : new URL(this.mcpServerUrl.href),
2416
885
  extra: {
2417
- dashboardSessionToken: payload.dashboardSessionToken,
2418
- dashboard_session_token: payload.dashboardSessionToken,
2419
- dashboardSessionExpiresAt: payload.dashboardSessionExpiresAt,
2420
- userId: payload.userId,
2421
- email: payload.email,
2422
- walletAddress: payload.walletAddress,
2423
- // Managed-wallet connect token minted at sign-in. The runtime threads
2424
- // this to the gateway as X-Apiosk-Connect-Token so paid calls settle
2425
- // autonomously from the buyer's managed wallet (runtime getClient reads
2426
- // extra.apiosk_connect_token).
886
+ // Connect token minted by the buyer portal's OAuth handoff. The
887
+ // runtime threads this to the gateway as X-Apiosk-Connect-Token so
888
+ // paid calls settle from the buyer's own wallet (runtime getClient
889
+ // reads extra.apiosk_connect_token).
2427
890
  apiosk_connect_token: payload.apioskConnectToken,
2428
- apiosk_connect_wallet_address: payload.apioskConnectWalletAddress,
2429
- },
2430
- };
2431
- }
2432
-
2433
- async verifyProviderKeyAccess(providerKey) {
2434
- // verifyProviderKey caches successful lookups for 60s, so bursts of tool
2435
- // calls don't hammer the verify RPC while revocations in the provider
2436
- // portal still take effect within a minute.
2437
- let context;
2438
- try {
2439
- context = await verifyProviderKey(providerKey, { env: this.env });
2440
- } catch (error) {
2441
- throw new Error(
2442
- error instanceof Error ? error.message : "Invalid Apiosk provider token"
2443
- );
2444
- }
2445
-
2446
- return {
2447
- token: providerKey,
2448
- clientId: `provider:${context.ownerId}`,
2449
- scopes: [DEFAULT_SCOPE],
2450
- expiresAt: Math.floor(Date.now() / 1000) + 60,
2451
- resource: new URL(this.mcpServerUrl.href),
2452
- extra: {
2453
- apiosk_provider_key: providerKey,
2454
- apiosk_provider_owner_id: context.ownerId,
2455
- apiosk_provider_key_label: context.label,
2456
891
  },
2457
892
  };
2458
893
  }
@@ -2464,11 +899,7 @@ class ApioskHostedOAuthProvider {
2464
899
  return cached.auth;
2465
900
  }
2466
901
 
2467
- const gatewayBase =
2468
- trimString(this.env?.APIOSK_GATEWAY_URL) ||
2469
- trimString(this.env?.APIOSK_GATEWAY_BASE_URL) ||
2470
- "https://gateway.apiosk.com";
2471
- const url = new URL("/v1/me", gatewayBase.replace(/\/+$/, "/")).href;
902
+ const url = new URL("/v1/me", `${resolveGatewayBaseUrl(this.env)}/`).href;
2472
903
 
2473
904
  let response;
2474
905
  try {
@@ -2643,9 +1074,8 @@ export function createHostedOAuthSupport({
2643
1074
  mcpServerUrl,
2644
1075
  appName = "Apiosk",
2645
1076
  resourceName = "Apiosk MCP",
2646
- connectTokenMinter,
2647
- payableWalletLister,
2648
- requirePaymentAuthorization,
1077
+ exchangePortalCode,
1078
+ refreshPortalToken,
2649
1079
  } = {}) {
2650
1080
  const secret = resolveOAuthSecret(env);
2651
1081
  const provider = new ApioskHostedOAuthProvider({
@@ -2655,9 +1085,8 @@ export function createHostedOAuthSupport({
2655
1085
  mcpServerUrl,
2656
1086
  appName,
2657
1087
  resourceName,
2658
- connectTokenMinter,
2659
- payableWalletLister,
2660
- requirePaymentAuthorization,
1088
+ exchangePortalCode,
1089
+ refreshPortalToken,
2661
1090
  });
2662
1091
 
2663
1092
  const oauthMetadata = createOAuthMetadata({
@@ -2668,7 +1097,12 @@ export function createHostedOAuthSupport({
2668
1097
  resourceName,
2669
1098
  serviceDocumentationUrl: new URL("https://apiosk.com"),
2670
1099
  });
2671
- oauthMetadata.client_id_metadata_document_supported = true;
1100
+ // Claude prefers CIMD when advertised. Its metadata origin currently
1101
+ // challenges Fly egress with HTTP 403, which makes a healthy MCP endpoint
1102
+ // fail sign-in as invalid_client. DCR is local, durable through signed client
1103
+ // IDs, and supported by both hosts. Only advertise CIMD after deployment-
1104
+ // side reachability has been verified.
1105
+ oauthMetadata.client_id_metadata_document_supported = env.APIOSK_MCP_CIMD_ENABLED === "1";
2672
1106
 
2673
1107
  const serviceDocumentationUrl = new URL("https://apiosk.com");
2674
1108
 
@@ -2717,6 +1151,12 @@ export function createHostedOAuthSupport({
2717
1151
  authorizationRouter: authorizationHandler({ provider }),
2718
1152
  tokenRouter: tokenHandler({ provider }),
2719
1153
  registrationRouter: clientRegistrationHandler({ clientsStore: provider.clientsStore }),
1154
+ // GET /authorize/callback — buy.apiosk.com's return leg. Not part of the
1155
+ // SDK's authorizationHandler (that only ever starts a flow); this ends
1156
+ // one that started on this server and continued at the portal.
1157
+ async handlePortalCallback(req, res) {
1158
+ await provider.completePortalCallback(req, res);
1159
+ },
2720
1160
  createMcpAuthMiddleware(runtime) {
2721
1161
  return async (req, res, next) => {
2722
1162
  const challengeResourceMetadataUrl = resolveResourceMetadataUrl(req);
@@ -2738,6 +1178,19 @@ export function createHostedOAuthSupport({
2738
1178
 
2739
1179
  const requestBody = req.body;
2740
1180
  const method = trimString(requestBody?.method);
1181
+ // Every tool uses the connected account. Challenge during the
1182
+ // initial handshake too, so hosts do not install it as an anonymous
1183
+ // connector and discover the sign-in requirement only on first use.
1184
+ if (method && !req.auth) {
1185
+ writeAuthChallenge(res, {
1186
+ status: 401,
1187
+ code: "invalid_token",
1188
+ message: "Connect your Apiosk account once to use this connector.",
1189
+ resourceMetadataUrl: challengeResourceMetadataUrl,
1190
+ });
1191
+ return;
1192
+ }
1193
+
2741
1194
  if (method !== "tools/call") {
2742
1195
  next();
2743
1196
  return;