@apiosk/mcp 1.3.1 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +85 -9
- package/docs/sepa-rail.md +13 -13
- package/dxt.json +4 -4
- package/logo-optimized-light.png +0 -0
- package/package.json +6 -4
- package/server.json +58 -3
- package/server.mjs +213 -11
- package/src/assets/wallet-accounts.mjs +20513 -0
- package/src/assets/walletconnect-provider.mjs +6319 -0
- package/src/create-server.mjs +47 -2
- package/src/discovery.mjs +929 -0
- package/src/external-fetch.mjs +203 -0
- package/src/hosted-payment.mjs +552 -0
- package/src/hosted-wallets.mjs +530 -0
- package/src/oauth.mjs +2011 -225
- package/src/observability.mjs +194 -0
- package/src/payment-guidance.mjs +15 -24
- package/src/publisher.mjs +1288 -0
- package/src/result-canvas.mjs +16 -0
- package/src/runtime.mjs +609 -30
- package/src/settlement-disclosure.mjs +26 -0
- package/src/source-registry.mjs +215 -0
- package/src/x402-inspect.mjs +361 -0
package/src/oauth.mjs
CHANGED
|
@@ -1,10 +1,22 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
2
|
|
|
3
|
+
import express from "express";
|
|
4
|
+
|
|
3
5
|
import { OAuthClientMetadataSchema } from "@modelcontextprotocol/sdk/shared/auth.js";
|
|
4
|
-
import { createOAuthMetadata, getOAuthProtectedResourceMetadataUrl
|
|
6
|
+
import { createOAuthMetadata, getOAuthProtectedResourceMetadataUrl } from "@modelcontextprotocol/sdk/server/auth/router.js";
|
|
5
7
|
import { authorizationHandler } from "@modelcontextprotocol/sdk/server/auth/handlers/authorize.js";
|
|
8
|
+
import { SETTLEMENT_DISCLOSURE_PATH } from "./settlement-disclosure.mjs";
|
|
6
9
|
import { tokenHandler } from "@modelcontextprotocol/sdk/server/auth/handlers/token.js";
|
|
7
10
|
import { clientRegistrationHandler } from "@modelcontextprotocol/sdk/server/auth/handlers/register.js";
|
|
11
|
+
import { metadataHandler } from "@modelcontextprotocol/sdk/server/auth/handlers/metadata.js";
|
|
12
|
+
|
|
13
|
+
import { isProviderApiKey, verifyProviderKey } from "./publisher.mjs";
|
|
14
|
+
import {
|
|
15
|
+
hostedSettlementPublicConfig,
|
|
16
|
+
listHostedPayableWallets,
|
|
17
|
+
mintHostedConnectToken,
|
|
18
|
+
prepareHostedPaymentWallets,
|
|
19
|
+
} from "./hosted-payment.mjs";
|
|
8
20
|
|
|
9
21
|
const ACCESS_TOKEN_TTL_SECONDS = 60 * 60;
|
|
10
22
|
const AUTHORIZATION_CODE_TTL_SECONDS = 10 * 60;
|
|
@@ -13,6 +25,14 @@ const CLIENT_ID_TTL_SECONDS = 20 * 365 * 24 * 60 * 60;
|
|
|
13
25
|
const DEFAULT_SCOPE = "mcp:tools";
|
|
14
26
|
const OFFLINE_ACCESS_SCOPE = "offline_access";
|
|
15
27
|
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
|
+
// Every transport surface an MCP client may connect to and treat as the
|
|
31
|
+
// OAuth "resource". Streamable HTTP clients target /mcp; the legacy HTTP+SSE
|
|
32
|
+
// transport (ChatGPT's connector) opens /sse and posts to /messages. We
|
|
33
|
+
// publish protected-resource metadata for each, plus the origin root, so a
|
|
34
|
+
// client's discovery probe succeeds no matter which surface it connected to.
|
|
35
|
+
const TRANSPORT_RESOURCE_PATHS = ["/mcp", "/sse", "/messages"];
|
|
16
36
|
const UUID_LIKE_CLIENT_ID_PATTERN =
|
|
17
37
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
18
38
|
|
|
@@ -34,6 +54,72 @@ function escapeHtml(value) {
|
|
|
34
54
|
.replaceAll("'", "'");
|
|
35
55
|
}
|
|
36
56
|
|
|
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
|
+
|
|
37
123
|
function toBase64Url(value) {
|
|
38
124
|
return Buffer.from(value).toString("base64url");
|
|
39
125
|
}
|
|
@@ -124,6 +210,26 @@ function buildRedirectUri(baseRedirectUri, params) {
|
|
|
124
210
|
return redirectUrl.toString();
|
|
125
211
|
}
|
|
126
212
|
|
|
213
|
+
function normalizeSessionExpiry(value) {
|
|
214
|
+
const sessionExpiry = Number(value);
|
|
215
|
+
return Number.isFinite(sessionExpiry) && sessionExpiry > getIssuedAtSeconds()
|
|
216
|
+
? sessionExpiry
|
|
217
|
+
: null;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function responseMessage(body, fallback) {
|
|
221
|
+
if (body && typeof body === "object") {
|
|
222
|
+
return trimString(body.message) || trimString(body.error) || fallback;
|
|
223
|
+
}
|
|
224
|
+
return trimString(body) || fallback;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function statusError(message, status = 500) {
|
|
228
|
+
const error = new Error(message);
|
|
229
|
+
error.status = status;
|
|
230
|
+
return error;
|
|
231
|
+
}
|
|
232
|
+
|
|
127
233
|
function deriveClientSecret(secret, clientId) {
|
|
128
234
|
return crypto.createHmac("sha256", secret).update(`client-secret:${clientId}`).digest("hex");
|
|
129
235
|
}
|
|
@@ -178,10 +284,12 @@ function createAuthorizePage({
|
|
|
178
284
|
actionPath,
|
|
179
285
|
appName,
|
|
180
286
|
clientName,
|
|
181
|
-
email = "",
|
|
182
287
|
errorMessage = "",
|
|
183
288
|
infoMessage = "",
|
|
184
289
|
oauthParams,
|
|
290
|
+
walletEnabled = true,
|
|
291
|
+
walletNoncePath = "/api/auth/mcp-wallet-nonce",
|
|
292
|
+
walletConnectProjectId = "",
|
|
185
293
|
}) {
|
|
186
294
|
const scope = Array.isArray(oauthParams.scopes) ? oauthParams.scopes.join(" ") : "";
|
|
187
295
|
const resource = oauthParams.resource ? oauthParams.resource.href : "";
|
|
@@ -207,19 +315,56 @@ function createAuthorizePage({
|
|
|
207
315
|
<head>
|
|
208
316
|
<meta charset="utf-8" />
|
|
209
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)" />
|
|
210
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" />
|
|
211
326
|
<style>
|
|
212
327
|
:root {
|
|
213
|
-
color-scheme: dark;
|
|
214
|
-
--
|
|
215
|
-
--
|
|
216
|
-
--
|
|
217
|
-
--
|
|
218
|
-
--muted: #
|
|
219
|
-
--
|
|
220
|
-
--
|
|
221
|
-
--
|
|
222
|
-
--
|
|
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
|
+
}
|
|
223
368
|
}
|
|
224
369
|
|
|
225
370
|
* {
|
|
@@ -229,30 +374,162 @@ function createAuthorizePage({
|
|
|
229
374
|
body {
|
|
230
375
|
margin: 0;
|
|
231
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;
|
|
232
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;
|
|
233
415
|
place-items: center;
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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;
|
|
241
515
|
}
|
|
242
516
|
|
|
243
517
|
main {
|
|
244
|
-
width:
|
|
518
|
+
width: 100%;
|
|
519
|
+
max-width: 440px;
|
|
520
|
+
justify-self: center;
|
|
245
521
|
border: 1px solid var(--border);
|
|
246
|
-
background: var(--
|
|
247
|
-
border-radius:
|
|
522
|
+
background: var(--card);
|
|
523
|
+
border-radius: 18px;
|
|
248
524
|
padding: 28px;
|
|
249
|
-
box-shadow:
|
|
525
|
+
box-shadow: var(--shadow);
|
|
250
526
|
}
|
|
251
527
|
|
|
252
528
|
h1 {
|
|
253
529
|
margin: 0 0 8px;
|
|
254
|
-
font-size: 1.
|
|
255
|
-
line-height: 1.
|
|
530
|
+
font-size: 1.35rem;
|
|
531
|
+
line-height: 1.2;
|
|
532
|
+
letter-spacing: 0;
|
|
256
533
|
}
|
|
257
534
|
|
|
258
535
|
p {
|
|
@@ -261,141 +538,1347 @@ function createAuthorizePage({
|
|
|
261
538
|
line-height: 1.55;
|
|
262
539
|
}
|
|
263
540
|
|
|
264
|
-
.stack {
|
|
265
|
-
display: grid;
|
|
266
|
-
gap:
|
|
267
|
-
}
|
|
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…</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’ve securely saved my recovery phrase. I understand it’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
|
+
|
|
1687
|
+
function createConnectionCompletePage({ appName, clientName, redirectTarget }) {
|
|
1688
|
+
const clientLabel = clientName.client_name || "the app";
|
|
1689
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8" />
|
|
1690
|
+
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
|
1691
|
+
<meta name="color-scheme" content="light dark" />
|
|
1692
|
+
<meta http-equiv="refresh" content="2;url=${escapeHtml(redirectTarget)}" />
|
|
1693
|
+
<title>Connected · ${escapeHtml(appName)}</title>
|
|
1694
|
+
<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)}}
|
|
1697
|
+
*{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)}
|
|
1705
|
+
.note{font-size:12px;color:var(--muted);margin:14px 0 0}
|
|
1706
|
+
</style></head><body><div class="page"><main>
|
|
1707
|
+
<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
|
+
<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>
|
|
1710
|
+
<div class="steps" aria-label="Authorization complete"><span class="step"></span><span class="step"></span><span class="step"></span></div>
|
|
1711
|
+
<a class="continue" id="continue" href="${escapeHtml(redirectTarget)}">Continue to ${escapeHtml(clientLabel)}</a>
|
|
1712
|
+
<p class="note">Returning you to ${escapeHtml(clientLabel)} automatically…</p>
|
|
1713
|
+
</main></div><script>setTimeout(()=>{window.location.replace(${JSON.stringify(redirectTarget).replaceAll("<", "\\u003c")})},1200);</script></body></html>`;
|
|
1714
|
+
}
|
|
268
1715
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
+
);
|
|
274
1733
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
1734
|
+
return {
|
|
1735
|
+
configured: Boolean(supabaseUrl && key && walletAuthUrl),
|
|
1736
|
+
key,
|
|
1737
|
+
supabaseUrl,
|
|
1738
|
+
walletAuthUrl,
|
|
1739
|
+
};
|
|
1740
|
+
}
|
|
280
1741
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
color: var(--success);
|
|
285
|
-
}
|
|
1742
|
+
export function isMcpWalletAuthConfigured(env = process.env) {
|
|
1743
|
+
return resolveMcpWalletAuthConfig(env).configured;
|
|
1744
|
+
}
|
|
286
1745
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
1746
|
+
function resolveWalletConnectProjectId(env = process.env) {
|
|
1747
|
+
return (
|
|
1748
|
+
trimString(env.APIOSK_MCP_WALLETCONNECT_PROJECT_ID) ||
|
|
1749
|
+
trimString(env.WALLETCONNECT_PROJECT_ID)
|
|
1750
|
+
);
|
|
1751
|
+
}
|
|
293
1752
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
1753
|
+
async function fetchJsonWithBody(url, options) {
|
|
1754
|
+
const response = await fetch(url, options);
|
|
1755
|
+
const text = await response.text();
|
|
1756
|
+
let body = null;
|
|
1757
|
+
try {
|
|
1758
|
+
body = text ? JSON.parse(text) : null;
|
|
1759
|
+
} catch {
|
|
1760
|
+
body = text || null;
|
|
1761
|
+
}
|
|
303
1762
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
font-weight: 600;
|
|
311
|
-
cursor: pointer;
|
|
312
|
-
}
|
|
1763
|
+
return {
|
|
1764
|
+
ok: response.ok,
|
|
1765
|
+
status: response.status,
|
|
1766
|
+
body,
|
|
1767
|
+
};
|
|
1768
|
+
}
|
|
313
1769
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
+
}
|
|
318
1775
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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
|
+
});
|
|
323
1787
|
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
1788
|
+
if (!response.ok) {
|
|
1789
|
+
throw statusError(
|
|
1790
|
+
responseMessage(response.body, "Wallet sign-in is temporarily unavailable."),
|
|
1791
|
+
response.status
|
|
1792
|
+
);
|
|
1793
|
+
}
|
|
328
1794
|
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
color: var(--muted);
|
|
332
|
-
}
|
|
1795
|
+
return response.body && typeof response.body === "object" ? response.body : {};
|
|
1796
|
+
}
|
|
333
1797
|
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
<main class="stack">
|
|
342
|
-
<header class="stack">
|
|
343
|
-
<div>
|
|
344
|
-
<p>${escapeHtml(clientName.client_name || "Remote MCP client")}</p>
|
|
345
|
-
<h1>Connect ${escapeHtml(appName)}</h1>
|
|
346
|
-
</div>
|
|
347
|
-
<p>Sign in with your Apiosk dashboard account to unlock paid gateway calls, managed wallets, and credit-backed execution from this MCP app.</p>
|
|
348
|
-
</header>
|
|
349
|
-
${errorMessage ? `<div class="message error">${escapeHtml(errorMessage)}</div>` : ""}
|
|
350
|
-
${infoMessage ? `<div class="message info">${escapeHtml(infoMessage)}</div>` : ""}
|
|
351
|
-
<form method="post" action="${escapeHtml(actionPath)}" class="stack">
|
|
352
|
-
${hiddenInputs}
|
|
353
|
-
<label>
|
|
354
|
-
Email
|
|
355
|
-
<input type="email" name="email" autocomplete="username" value="${escapeHtml(email)}" required />
|
|
356
|
-
</label>
|
|
357
|
-
<label>
|
|
358
|
-
Password
|
|
359
|
-
<input type="password" name="password" autocomplete="current-password" required />
|
|
360
|
-
</label>
|
|
361
|
-
<div class="actions">
|
|
362
|
-
<button class="primary" type="submit" name="action" value="sign_in">Sign in and continue</button>
|
|
363
|
-
<button class="secondary" type="submit" name="action" value="sign_up">Create account</button>
|
|
364
|
-
<button class="secondary" type="submit" name="action" value="cancel">Cancel</button>
|
|
365
|
-
</div>
|
|
366
|
-
</form>
|
|
367
|
-
<div class="meta">
|
|
368
|
-
Requested scope: <code>${escapeHtml(scope || DEFAULT_SCOPE)}</code><br />
|
|
369
|
-
Resource: <code>${escapeHtml(resource || "default")}</code>
|
|
370
|
-
</div>
|
|
371
|
-
</main>
|
|
372
|
-
</body>
|
|
373
|
-
</html>`;
|
|
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;
|
|
374
1805
|
}
|
|
375
1806
|
|
|
376
|
-
async function
|
|
377
|
-
const
|
|
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`, {
|
|
378
1814
|
method: "POST",
|
|
379
1815
|
headers: {
|
|
380
1816
|
accept: "application/json",
|
|
1817
|
+
apikey: config.key,
|
|
1818
|
+
authorization: `Bearer ${config.key}`,
|
|
381
1819
|
"content-type": "application/json",
|
|
382
1820
|
},
|
|
383
|
-
body: JSON.stringify(
|
|
1821
|
+
body: JSON.stringify({
|
|
1822
|
+
token_hash: tokenHash,
|
|
1823
|
+
type: "magiclink",
|
|
1824
|
+
}),
|
|
384
1825
|
cache: "no-store",
|
|
385
1826
|
});
|
|
386
1827
|
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
1828
|
+
if (!response.ok) {
|
|
1829
|
+
throw statusError(
|
|
1830
|
+
responseMessage(response.body, "Could not establish an Apiosk dashboard session."),
|
|
1831
|
+
response.status
|
|
1832
|
+
);
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
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);
|
|
393
1841
|
}
|
|
394
1842
|
|
|
1843
|
+
const expiresAt =
|
|
1844
|
+
Number(session.expires_at) ||
|
|
1845
|
+
(Number.isFinite(Number(session.expires_in))
|
|
1846
|
+
? getIssuedAtSeconds() + Number(session.expires_in)
|
|
1847
|
+
: null);
|
|
1848
|
+
|
|
395
1849
|
return {
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
1850
|
+
session_token: sessionToken,
|
|
1851
|
+
expires_at: expiresAt,
|
|
1852
|
+
user_id: trimString(user.id),
|
|
1853
|
+
email: trimString(user.email),
|
|
1854
|
+
};
|
|
1855
|
+
}
|
|
1856
|
+
|
|
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
|
+
}
|
|
1876
|
+
|
|
1877
|
+
const session = await verifySupabaseTokenHash(env, tokenHash);
|
|
1878
|
+
return {
|
|
1879
|
+
...session,
|
|
1880
|
+
email: session.email || trimString(walletAuth.email),
|
|
1881
|
+
wallet_address: normalizedAddress.toLowerCase(),
|
|
399
1882
|
};
|
|
400
1883
|
}
|
|
401
1884
|
|
|
@@ -493,26 +1976,57 @@ class ApioskOAuthClientsStore {
|
|
|
493
1976
|
}
|
|
494
1977
|
|
|
495
1978
|
class ApioskHostedOAuthProvider {
|
|
496
|
-
constructor({
|
|
1979
|
+
constructor({
|
|
1980
|
+
env,
|
|
1981
|
+
secret,
|
|
1982
|
+
issuerUrl,
|
|
1983
|
+
mcpServerUrl,
|
|
1984
|
+
appName,
|
|
1985
|
+
resourceName,
|
|
1986
|
+
connectTokenMinter,
|
|
1987
|
+
payableWalletLister,
|
|
1988
|
+
requirePaymentAuthorization,
|
|
1989
|
+
}) {
|
|
497
1990
|
this.env = env;
|
|
498
1991
|
this.secret = secret;
|
|
499
|
-
this.controlPlaneBaseUrl = controlPlaneBaseUrl;
|
|
500
1992
|
this.issuerUrl = issuerUrl;
|
|
501
1993
|
this.mcpServerUrl = mcpServerUrl;
|
|
502
1994
|
this.appName = appName;
|
|
503
1995
|
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;
|
|
504
2005
|
this.clientsStore = new ApioskOAuthClientsStore(secret);
|
|
2006
|
+
// Audiences we honour on an access token. A client that connected via
|
|
2007
|
+
// /sse (ChatGPT) requests resource=<origin>/sse; one via /mcp requests
|
|
2008
|
+
// <origin>/mcp. The origin root is accepted for clients that omit the
|
|
2009
|
+
// path. All map to the same underlying Apiosk MCP server.
|
|
2010
|
+
this.allowedResources = new Set(
|
|
2011
|
+
[
|
|
2012
|
+
...TRANSPORT_RESOURCE_PATHS.map((path) => new URL(path, this.mcpServerUrl).href),
|
|
2013
|
+
new URL("/", this.mcpServerUrl).href,
|
|
2014
|
+
this.mcpServerUrl.href,
|
|
2015
|
+
].map((href) => href.replace(/\/+$/, "") || href)
|
|
2016
|
+
);
|
|
2017
|
+
}
|
|
2018
|
+
|
|
2019
|
+
isAllowedResource(resourceHref) {
|
|
2020
|
+
const normalized = String(resourceHref || "").replace(/\/+$/, "");
|
|
2021
|
+
return this.allowedResources.has(normalized) || this.allowedResources.has(resourceHref);
|
|
505
2022
|
}
|
|
506
2023
|
|
|
507
2024
|
async authorize(client, params, res) {
|
|
508
2025
|
const req = res.req;
|
|
509
2026
|
const submittedAction = trimString(req?.body?.action);
|
|
510
|
-
const
|
|
511
|
-
const password = trimString(req?.body?.password);
|
|
512
|
-
|
|
513
|
-
if (!req || req.method !== "POST" || !submittedAction) {
|
|
2027
|
+
const renderPage = (status, options = {}) =>
|
|
514
2028
|
res
|
|
515
|
-
.status(
|
|
2029
|
+
.status(status)
|
|
516
2030
|
.setHeader("content-type", "text/html; charset=utf-8")
|
|
517
2031
|
.send(
|
|
518
2032
|
createAuthorizePage({
|
|
@@ -520,8 +2034,28 @@ class ApioskHostedOAuthProvider {
|
|
|
520
2034
|
appName: this.appName,
|
|
521
2035
|
clientName: client,
|
|
522
2036
|
oauthParams: params,
|
|
2037
|
+
walletEnabled: isMcpWalletAuthConfigured(this.env),
|
|
2038
|
+
walletConnectProjectId: resolveWalletConnectProjectId(this.env),
|
|
2039
|
+
...options,
|
|
2040
|
+
})
|
|
2041
|
+
);
|
|
2042
|
+
const renderPaymentPage = (status, options) =>
|
|
2043
|
+
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,
|
|
523
2054
|
})
|
|
524
2055
|
);
|
|
2056
|
+
|
|
2057
|
+
if (!req || req.method !== "POST" || !submittedAction) {
|
|
2058
|
+
renderPage(200);
|
|
525
2059
|
return;
|
|
526
2060
|
}
|
|
527
2061
|
|
|
@@ -537,74 +2071,156 @@ class ApioskHostedOAuthProvider {
|
|
|
537
2071
|
return;
|
|
538
2072
|
}
|
|
539
2073
|
|
|
540
|
-
if (
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
.
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
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
|
+
}
|
|
554
2135
|
return;
|
|
555
2136
|
}
|
|
556
2137
|
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
.
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
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
|
+
}
|
|
584
2190
|
return;
|
|
585
2191
|
}
|
|
586
2192
|
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
"Could not sign in to Apiosk. Check your credentials and try again.";
|
|
2193
|
+
renderPage(400, {
|
|
2194
|
+
errorMessage: "Sign in with a wallet to continue.",
|
|
2195
|
+
});
|
|
2196
|
+
}
|
|
592
2197
|
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
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;
|
|
608
2224
|
|
|
609
2225
|
const authorizationCode = buildIssuedToken(
|
|
610
2226
|
this.secret,
|
|
@@ -617,20 +2233,40 @@ class ApioskHostedOAuthProvider {
|
|
|
617
2233
|
resource: params.resource ? params.resource.href : this.mcpServerUrl.href,
|
|
618
2234
|
dashboardSessionToken: sessionToken,
|
|
619
2235
|
dashboardSessionExpiresAt: normalizedSessionExpiry || undefined,
|
|
620
|
-
userId: trimString(
|
|
621
|
-
email: trimString(
|
|
2236
|
+
userId: trimString(session.user_id),
|
|
2237
|
+
email: trimString(session.email),
|
|
2238
|
+
walletAddress: trimString(session.wallet_address) || undefined,
|
|
2239
|
+
apioskConnectToken,
|
|
2240
|
+
apioskConnectWalletAddress,
|
|
622
2241
|
},
|
|
623
2242
|
AUTHORIZATION_CODE_TTL_SECONDS,
|
|
624
2243
|
normalizedSessionExpiry
|
|
625
2244
|
).token;
|
|
626
2245
|
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
2246
|
+
const redirectTarget = buildRedirectUri(params.redirectUri, {
|
|
2247
|
+
code: authorizationCode,
|
|
2248
|
+
state: params.state,
|
|
2249
|
+
});
|
|
2250
|
+
|
|
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
|
+
|
|
2269
|
+
res.redirect(302, redirectTarget);
|
|
634
2270
|
}
|
|
635
2271
|
|
|
636
2272
|
async challengeForAuthorizationCode(client, authorizationCode) {
|
|
@@ -669,6 +2305,9 @@ class ApioskHostedOAuthProvider {
|
|
|
669
2305
|
dashboardSessionExpiresAt: payload.dashboardSessionExpiresAt,
|
|
670
2306
|
userId: payload.userId,
|
|
671
2307
|
email: payload.email,
|
|
2308
|
+
walletAddress: payload.walletAddress,
|
|
2309
|
+
apioskConnectToken: payload.apioskConnectToken,
|
|
2310
|
+
apioskConnectWalletAddress: payload.apioskConnectWalletAddress,
|
|
672
2311
|
};
|
|
673
2312
|
|
|
674
2313
|
const accessToken = buildIssuedToken(
|
|
@@ -721,6 +2360,9 @@ class ApioskHostedOAuthProvider {
|
|
|
721
2360
|
dashboardSessionExpiresAt: payload.dashboardSessionExpiresAt,
|
|
722
2361
|
userId: payload.userId,
|
|
723
2362
|
email: payload.email,
|
|
2363
|
+
walletAddress: payload.walletAddress,
|
|
2364
|
+
apioskConnectToken: payload.apioskConnectToken,
|
|
2365
|
+
apioskConnectWalletAddress: payload.apioskConnectWalletAddress,
|
|
724
2366
|
},
|
|
725
2367
|
ACCESS_TOKEN_TTL_SECONDS,
|
|
726
2368
|
maxExpiry
|
|
@@ -741,18 +2383,27 @@ class ApioskHostedOAuthProvider {
|
|
|
741
2383
|
// can mint a connect token in the buyer portal and call the hosted MCP
|
|
742
2384
|
// straight away, no interactive OAuth handshake. The gateway is the
|
|
743
2385
|
// authoritative store for connect tokens, so we validate by calling
|
|
744
|
-
// its /v1/me endpoint
|
|
2386
|
+
// its /v1/me endpoint, one source of truth, no shared secret.
|
|
745
2387
|
const trimmed = typeof token === "string" ? token.trim() : "";
|
|
746
2388
|
if (/^aw_(live|test)_/i.test(trimmed)) {
|
|
747
2389
|
return this.verifyConnectTokenAccess(trimmed);
|
|
748
2390
|
}
|
|
749
2391
|
|
|
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
|
+
|
|
750
2401
|
const payload = parseSignedToken(this.secret, token);
|
|
751
2402
|
if (payload.typ !== "access") {
|
|
752
2403
|
throw new Error("Invalid access token");
|
|
753
2404
|
}
|
|
754
2405
|
|
|
755
|
-
if (payload.resource && payload.resource
|
|
2406
|
+
if (payload.resource && !this.isAllowedResource(payload.resource)) {
|
|
756
2407
|
throw new Error("Token was issued for a different resource");
|
|
757
2408
|
}
|
|
758
2409
|
|
|
@@ -768,6 +2419,40 @@ class ApioskHostedOAuthProvider {
|
|
|
768
2419
|
dashboardSessionExpiresAt: payload.dashboardSessionExpiresAt,
|
|
769
2420
|
userId: payload.userId,
|
|
770
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).
|
|
2427
|
+
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,
|
|
771
2456
|
},
|
|
772
2457
|
};
|
|
773
2458
|
}
|
|
@@ -824,7 +2509,7 @@ class ApioskHostedOAuthProvider {
|
|
|
824
2509
|
}
|
|
825
2510
|
// Cache for 60s. Long enough to absorb a burst of tool calls, short
|
|
826
2511
|
// enough that a revocation in the buyer portal takes effect within a
|
|
827
|
-
// minute
|
|
2512
|
+
// minute, same TTL the dashboard uses for similar permission caches.
|
|
828
2513
|
this.connectTokenCache.set(connectToken, {
|
|
829
2514
|
auth,
|
|
830
2515
|
expiresAt: now + 60_000,
|
|
@@ -870,9 +2555,17 @@ function extractBearerToken(req) {
|
|
|
870
2555
|
}
|
|
871
2556
|
|
|
872
2557
|
function writeAuthChallenge(res, { status, code, message, resourceMetadataUrl }) {
|
|
2558
|
+
// HTTP header values must be ASCII: strip quotes and replace any
|
|
2559
|
+
// non-printable/non-ASCII characters so an upstream error message (which
|
|
2560
|
+
// may contain arrows, em-dashes, etc.) can never crash setHeader.
|
|
2561
|
+
const headerSafeMessage = String(message)
|
|
2562
|
+
.replaceAll('"', "'")
|
|
2563
|
+
.replace(/[^\x20-\x7e]/g, " ")
|
|
2564
|
+
.replace(/\s+/g, " ")
|
|
2565
|
+
.trim();
|
|
873
2566
|
const parts = [
|
|
874
2567
|
`Bearer error="${code}"`,
|
|
875
|
-
`error_description="${
|
|
2568
|
+
`error_description="${headerSafeMessage}"`,
|
|
876
2569
|
`scope="${DEFAULT_SCOPE}"`,
|
|
877
2570
|
];
|
|
878
2571
|
|
|
@@ -887,23 +2580,84 @@ function writeAuthChallenge(res, { status, code, message, resourceMetadataUrl })
|
|
|
887
2580
|
});
|
|
888
2581
|
}
|
|
889
2582
|
|
|
2583
|
+
function protectedResourceMetadataPath(resourceUrl) {
|
|
2584
|
+
const rsPath = new URL(resourceUrl.href).pathname;
|
|
2585
|
+
return `/.well-known/oauth-protected-resource${rsPath === "/" ? "" : rsPath}`;
|
|
2586
|
+
}
|
|
2587
|
+
|
|
2588
|
+
// One router that serves protected-resource metadata (RFC 9728) for the
|
|
2589
|
+
// origin root AND every transport surface (/mcp, /sse, /messages), so an MCP
|
|
2590
|
+
// client's discovery probe resolves regardless of which URL it connected to.
|
|
2591
|
+
// Longer paths are registered first because express `use()` matches by prefix
|
|
2592
|
+
// and the root path would otherwise shadow the transport-specific documents.
|
|
2593
|
+
function buildResourceMetadataRouter({
|
|
2594
|
+
oauthMetadata,
|
|
2595
|
+
resourceUrls,
|
|
2596
|
+
scopesSupported,
|
|
2597
|
+
resourceName,
|
|
2598
|
+
serviceDocumentationUrl,
|
|
2599
|
+
}) {
|
|
2600
|
+
checkResourceRouterIssuer(oauthMetadata.issuer);
|
|
2601
|
+
const router = express.Router();
|
|
2602
|
+
|
|
2603
|
+
const sorted = [...resourceUrls].sort(
|
|
2604
|
+
(a, b) => new URL(b.href).pathname.length - new URL(a.href).pathname.length
|
|
2605
|
+
);
|
|
2606
|
+
|
|
2607
|
+
for (const resourceUrl of sorted) {
|
|
2608
|
+
const document = {
|
|
2609
|
+
resource: resourceUrl.href,
|
|
2610
|
+
authorization_servers: [oauthMetadata.issuer],
|
|
2611
|
+
scopes_supported: scopesSupported,
|
|
2612
|
+
resource_name: resourceName,
|
|
2613
|
+
resource_documentation: serviceDocumentationUrl?.href,
|
|
2614
|
+
};
|
|
2615
|
+
router.use(protectedResourceMetadataPath(resourceUrl), metadataHandler(document));
|
|
2616
|
+
}
|
|
2617
|
+
|
|
2618
|
+
// RFC 8414 authorization-server metadata, so clients that only speak the
|
|
2619
|
+
// AS-metadata discovery path still find the issuer.
|
|
2620
|
+
router.use("/.well-known/oauth-authorization-server", metadataHandler(oauthMetadata));
|
|
2621
|
+
|
|
2622
|
+
return router;
|
|
2623
|
+
}
|
|
2624
|
+
|
|
2625
|
+
function checkResourceRouterIssuer(issuer) {
|
|
2626
|
+
const issuerUrl = new URL(issuer);
|
|
2627
|
+
const allowInsecure =
|
|
2628
|
+
process.env.MCP_DANGEROUSLY_ALLOW_INSECURE_ISSUER_URL === "true" ||
|
|
2629
|
+
process.env.MCP_DANGEROUSLY_ALLOW_INSECURE_ISSUER_URL === "1";
|
|
2630
|
+
if (
|
|
2631
|
+
issuerUrl.protocol !== "https:" &&
|
|
2632
|
+
issuerUrl.hostname !== "localhost" &&
|
|
2633
|
+
issuerUrl.hostname !== "127.0.0.1" &&
|
|
2634
|
+
!allowInsecure
|
|
2635
|
+
) {
|
|
2636
|
+
throw new Error("Issuer URL must be HTTPS");
|
|
2637
|
+
}
|
|
2638
|
+
}
|
|
2639
|
+
|
|
890
2640
|
export function createHostedOAuthSupport({
|
|
891
2641
|
env = process.env,
|
|
892
|
-
controlPlaneBaseUrl,
|
|
893
2642
|
issuerUrl,
|
|
894
2643
|
mcpServerUrl,
|
|
895
2644
|
appName = "Apiosk",
|
|
896
2645
|
resourceName = "Apiosk MCP",
|
|
2646
|
+
connectTokenMinter,
|
|
2647
|
+
payableWalletLister,
|
|
2648
|
+
requirePaymentAuthorization,
|
|
897
2649
|
} = {}) {
|
|
898
2650
|
const secret = resolveOAuthSecret(env);
|
|
899
2651
|
const provider = new ApioskHostedOAuthProvider({
|
|
900
2652
|
env,
|
|
901
2653
|
secret,
|
|
902
|
-
controlPlaneBaseUrl,
|
|
903
2654
|
issuerUrl,
|
|
904
2655
|
mcpServerUrl,
|
|
905
2656
|
appName,
|
|
906
2657
|
resourceName,
|
|
2658
|
+
connectTokenMinter,
|
|
2659
|
+
payableWalletLister,
|
|
2660
|
+
requirePaymentAuthorization,
|
|
907
2661
|
});
|
|
908
2662
|
|
|
909
2663
|
const oauthMetadata = createOAuthMetadata({
|
|
@@ -916,24 +2670,56 @@ export function createHostedOAuthSupport({
|
|
|
916
2670
|
});
|
|
917
2671
|
oauthMetadata.client_id_metadata_document_supported = true;
|
|
918
2672
|
|
|
2673
|
+
const serviceDocumentationUrl = new URL("https://apiosk.com");
|
|
2674
|
+
|
|
2675
|
+
// Every transport surface published as its own OAuth resource, plus the
|
|
2676
|
+
// Streamable HTTP /mcp URL and the origin root.
|
|
2677
|
+
const resourceUrls = [
|
|
2678
|
+
...TRANSPORT_RESOURCE_PATHS.map((path) => new URL(path, mcpServerUrl)),
|
|
2679
|
+
mcpServerUrl,
|
|
2680
|
+
new URL("/", mcpServerUrl),
|
|
2681
|
+
];
|
|
2682
|
+
|
|
919
2683
|
const resourceMetadataUrl = getOAuthProtectedResourceMetadataUrl(mcpServerUrl);
|
|
2684
|
+
// PRM URL for the legacy HTTP+SSE transport, so a client that connected via
|
|
2685
|
+
// /sse (and posts to /messages) is handed metadata whose `resource` matches
|
|
2686
|
+
// the surface it is actually talking to.
|
|
2687
|
+
const sseResourceMetadataUrl = getOAuthProtectedResourceMetadataUrl(
|
|
2688
|
+
new URL("/sse", mcpServerUrl)
|
|
2689
|
+
);
|
|
2690
|
+
|
|
2691
|
+
// The tool-call challenge rides in on the transport the client chose. Point
|
|
2692
|
+
// it at that transport's protected-resource metadata so the `resource` the
|
|
2693
|
+
// client discovers matches the URL it connected to (RFC 9728 / RFC 8707).
|
|
2694
|
+
function resolveResourceMetadataUrl(req) {
|
|
2695
|
+
const pathname = String(req?.path || req?.originalUrl || "")
|
|
2696
|
+
.split("?")[0]
|
|
2697
|
+
.replace(/\/+$/, "");
|
|
2698
|
+
if (pathname === "/messages" || pathname === "/sse") {
|
|
2699
|
+
return sseResourceMetadataUrl;
|
|
2700
|
+
}
|
|
2701
|
+
return resourceMetadataUrl;
|
|
2702
|
+
}
|
|
920
2703
|
|
|
921
2704
|
return {
|
|
922
2705
|
provider,
|
|
923
2706
|
oauthMetadata,
|
|
924
2707
|
resourceMetadataUrl,
|
|
925
|
-
|
|
2708
|
+
sseResourceMetadataUrl,
|
|
2709
|
+
resourceUrls,
|
|
2710
|
+
metadataRouter: buildResourceMetadataRouter({
|
|
926
2711
|
oauthMetadata,
|
|
927
|
-
|
|
2712
|
+
resourceUrls,
|
|
928
2713
|
scopesSupported: SUPPORTED_SCOPES,
|
|
929
2714
|
resourceName,
|
|
930
|
-
serviceDocumentationUrl
|
|
2715
|
+
serviceDocumentationUrl,
|
|
931
2716
|
}),
|
|
932
2717
|
authorizationRouter: authorizationHandler({ provider }),
|
|
933
2718
|
tokenRouter: tokenHandler({ provider }),
|
|
934
2719
|
registrationRouter: clientRegistrationHandler({ clientsStore: provider.clientsStore }),
|
|
935
2720
|
createMcpAuthMiddleware(runtime) {
|
|
936
2721
|
return async (req, res, next) => {
|
|
2722
|
+
const challengeResourceMetadataUrl = resolveResourceMetadataUrl(req);
|
|
937
2723
|
const bearerToken = extractBearerToken(req);
|
|
938
2724
|
|
|
939
2725
|
if (bearerToken) {
|
|
@@ -944,7 +2730,7 @@ export function createHostedOAuthSupport({
|
|
|
944
2730
|
status: 401,
|
|
945
2731
|
code: "invalid_token",
|
|
946
2732
|
message: error instanceof Error ? error.message : "Invalid access token",
|
|
947
|
-
resourceMetadataUrl,
|
|
2733
|
+
resourceMetadataUrl: challengeResourceMetadataUrl,
|
|
948
2734
|
});
|
|
949
2735
|
return;
|
|
950
2736
|
}
|
|
@@ -970,7 +2756,7 @@ export function createHostedOAuthSupport({
|
|
|
970
2756
|
status: 401,
|
|
971
2757
|
code: "invalid_token",
|
|
972
2758
|
message: "This Apiosk tool requires sign-in before it can run.",
|
|
973
|
-
resourceMetadataUrl,
|
|
2759
|
+
resourceMetadataUrl: challengeResourceMetadataUrl,
|
|
974
2760
|
});
|
|
975
2761
|
return;
|
|
976
2762
|
}
|
|
@@ -980,7 +2766,7 @@ export function createHostedOAuthSupport({
|
|
|
980
2766
|
status: 403,
|
|
981
2767
|
code: "insufficient_scope",
|
|
982
2768
|
message: `This tool requires the ${DEFAULT_SCOPE} scope.`,
|
|
983
|
-
resourceMetadataUrl,
|
|
2769
|
+
resourceMetadataUrl: challengeResourceMetadataUrl,
|
|
984
2770
|
});
|
|
985
2771
|
return;
|
|
986
2772
|
}
|