@apiosk/mcp 1.7.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +83 -612
- package/assets/brand/apiosk-a-20260921.png +0 -0
- package/assets/brand/apple-touch-icon.png +0 -0
- package/assets/brand/favicon-dark.ico +0 -0
- package/assets/brand/favicon-light.ico +0 -0
- package/assets/brand/favicon.ico +0 -0
- package/assets/brand/icon-192.png +0 -0
- package/assets/brand/icon-512.png +0 -0
- package/assets/brand/icon-maskable-512.png +0 -0
- package/assets/brand/inter-OFL.txt +93 -0
- package/assets/brand/inter-latin-400-normal.woff2 +0 -0
- package/assets/brand/inter-latin-500-normal.woff2 +0 -0
- package/assets/brand/inter-latin-600-normal.woff2 +0 -0
- package/assets/brand/mark-20260905-transparent.svg +1 -0
- package/assets/brand/mark-20260918.svg +1 -0
- package/assets/brand/mark-black-20260918.svg +1 -0
- package/assets/brand/mark-dark-20260905-transparent.png +0 -0
- package/assets/brand/mark-dark-20260918.png +0 -0
- package/assets/brand/mark-dark-96.png +0 -0
- package/assets/brand/mark-light-20260905-transparent.png +0 -0
- package/assets/brand/mark-light-20260918.png +0 -0
- package/assets/brand/mark-light-96.png +0 -0
- package/assets/brand/wordmark-black-320.png +0 -0
- package/assets/brand/wordmark-white-320.png +0 -0
- package/docs/branding.md +11 -0
- package/docs/marketplace-submission-2026-04-08.md +4 -0
- package/docs/marketplace-submission-2026-08-20.md +157 -0
- package/docs/openai-plugin-submission-2026-09-05.md +143 -0
- package/dxt.json +26 -22
- package/index.mjs +3 -1
- package/logo-optimized-light.png +0 -0
- package/package.json +14 -14
- package/plugin/apiosk/.codex-plugin/plugin.json +45 -0
- package/plugin/apiosk/.mcp.json +8 -0
- package/plugin/apiosk/assets/icon-dark.png +0 -0
- package/plugin/apiosk/assets/icon.png +0 -0
- package/plugin/apiosk/assets/icon.svg +1 -0
- package/plugin/apiosk/assets/logo.png +0 -0
- package/plugin/apiosk/skills/apiosk/SKILL.md +33 -0
- package/plugin/apiosk/skills/apiosk/agents/openai.yaml +12 -0
- package/server.json +58 -24
- package/server.mjs +136 -151
- package/src/approval-feedback.mjs +21 -0
- package/src/brand-routes.mjs +28 -0
- package/src/create-server.mjs +160 -46
- package/src/display-money.mjs +20 -0
- package/src/display-text.mjs +67 -0
- package/src/gateway-client.mjs +42 -0
- package/src/gateway-v2-ask.mjs +50 -0
- package/src/gateway-v2-card-account.mjs +23 -0
- package/src/gateway-v2-card-actions.mjs +67 -0
- package/src/gateway-v2-card-answer-text.mjs +130 -0
- package/src/gateway-v2-card-answer.mjs +68 -0
- package/src/gateway-v2-card-blocks.mjs +166 -0
- package/src/gateway-v2-card-body.mjs +259 -0
- package/src/gateway-v2-card-budget.mjs +21 -0
- package/src/gateway-v2-card-cbs.mjs +56 -0
- package/src/gateway-v2-card-choices.mjs +4 -0
- package/src/gateway-v2-card-clarification.mjs +25 -0
- package/src/gateway-v2-card-compact.mjs +34 -0
- package/src/gateway-v2-card-events.mjs +41 -0
- package/src/gateway-v2-card-presentation.mjs +125 -0
- package/src/gateway-v2-card-research.mjs +63 -0
- package/src/gateway-v2-card-result.mjs +32 -0
- package/src/gateway-v2-card-search.mjs +39 -0
- package/src/gateway-v2-card-sources.mjs +32 -0
- package/src/gateway-v2-card-style.mjs +76 -0
- package/src/gateway-v2-card-verdict.mjs +66 -0
- package/src/gateway-v2-card.mjs +91 -0
- package/src/gateway-v2-contracts.json +66 -0
- package/src/gateway-v2-instructions.md +113 -0
- package/src/gateway-v2-recovery.mjs +18 -0
- package/src/gateway-v2-report-links.mjs +14 -0
- package/src/gateway-v2-workflows.mjs +17 -0
- package/src/gateway-v2.mjs +179 -0
- package/src/oauth.mjs +402 -1949
- package/src/observability.mjs +19 -3
- package/src/result-presentation.mjs +9 -0
- package/src/runtime.mjs +24 -3670
- package/src/source-groups.mjs +91 -0
- package/src/source-value-format.mjs +25 -0
- package/src/tool-result.mjs +20 -0
- package/src/ui-bridge.mjs +185 -0
- package/src/well-known-routes.mjs +132 -0
- package/src/assets/wallet-accounts.mjs +0 -20513
- package/src/assets/walletconnect-provider.mjs +0 -6319
- package/src/discovery.mjs +0 -929
- package/src/external-fetch.mjs +0 -203
- package/src/funding-options.mjs +0 -255
- package/src/gateway-management.mjs +0 -107
- package/src/hosted-payment.mjs +0 -552
- package/src/hosted-wallets.mjs +0 -530
- package/src/listing-metadata.mjs +0 -287
- package/src/local-config.mjs +0 -256
- package/src/payment-guidance.mjs +0 -298
- package/src/publisher.mjs +0 -1288
- package/src/result-canvas.mjs +0 -16
- package/src/source-registry.mjs +0 -215
- package/src/wallet-store.mjs +0 -476
- package/src/x402-inspect.mjs +0 -361
package/src/oauth.mjs
CHANGED
|
@@ -5,19 +5,11 @@ import express from "express";
|
|
|
5
5
|
import { OAuthClientMetadataSchema } from "@modelcontextprotocol/sdk/shared/auth.js";
|
|
6
6
|
import { createOAuthMetadata, getOAuthProtectedResourceMetadataUrl } from "@modelcontextprotocol/sdk/server/auth/router.js";
|
|
7
7
|
import { authorizationHandler } from "@modelcontextprotocol/sdk/server/auth/handlers/authorize.js";
|
|
8
|
-
import { SETTLEMENT_DISCLOSURE_PATH } from "./settlement-disclosure.mjs";
|
|
9
8
|
import { tokenHandler } from "@modelcontextprotocol/sdk/server/auth/handlers/token.js";
|
|
9
|
+
import { InvalidGrantError } from "@modelcontextprotocol/sdk/server/auth/errors.js";
|
|
10
10
|
import { clientRegistrationHandler } from "@modelcontextprotocol/sdk/server/auth/handlers/register.js";
|
|
11
11
|
import { metadataHandler } from "@modelcontextprotocol/sdk/server/auth/handlers/metadata.js";
|
|
12
12
|
|
|
13
|
-
import { isProviderApiKey, verifyProviderKey } from "./publisher.mjs";
|
|
14
|
-
import {
|
|
15
|
-
hostedSettlementPublicConfig,
|
|
16
|
-
listHostedPayableWallets,
|
|
17
|
-
mintHostedConnectToken,
|
|
18
|
-
prepareHostedPaymentWallets,
|
|
19
|
-
} from "./hosted-payment.mjs";
|
|
20
|
-
|
|
21
13
|
const ACCESS_TOKEN_TTL_SECONDS = 60 * 60;
|
|
22
14
|
const AUTHORIZATION_CODE_TTL_SECONDS = 10 * 60;
|
|
23
15
|
const REFRESH_TOKEN_TTL_SECONDS = 30 * 24 * 60 * 60;
|
|
@@ -25,8 +17,6 @@ const CLIENT_ID_TTL_SECONDS = 20 * 365 * 24 * 60 * 60;
|
|
|
25
17
|
const DEFAULT_SCOPE = "mcp:tools";
|
|
26
18
|
const OFFLINE_ACCESS_SCOPE = "offline_access";
|
|
27
19
|
const SUPPORTED_SCOPES = [DEFAULT_SCOPE, OFFLINE_ACCESS_SCOPE];
|
|
28
|
-
const DEFAULT_SUPABASE_URL = "https://jgjoiyqdyypouskftzeq.supabase.co";
|
|
29
|
-
const EVM_ADDRESS_PATTERN = /^0x[a-fA-F0-9]{40}$/;
|
|
30
20
|
// Every transport surface an MCP client may connect to and treat as the
|
|
31
21
|
// OAuth "resource". Streamable HTTP clients target /mcp; the legacy HTTP+SSE
|
|
32
22
|
// transport (ChatGPT's connector) opens /sse and posts to /messages. We
|
|
@@ -36,6 +26,39 @@ const TRANSPORT_RESOURCE_PATHS = ["/mcp", "/sse", "/messages"];
|
|
|
36
26
|
const UUID_LIKE_CLIENT_ID_PATTERN =
|
|
37
27
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
38
28
|
|
|
29
|
+
// The client_id this server authorizes as. One client for Claude, ChatGPT and
|
|
30
|
+
// Cursor alike — they arrive with an optional app_name shown beside it, which
|
|
31
|
+
// the agent gateway passes to the approval screen as `name`.
|
|
32
|
+
//
|
|
33
|
+
// It matches `^[a-z][a-z0-9-]{0,63}$`, which is what `startAuthorization` in
|
|
34
|
+
// the agent gateway's oauth.ts requires of a client_id.
|
|
35
|
+
const PORTAL_CLIENT_ID = "apiosk-mcp";
|
|
36
|
+
/**
|
|
37
|
+
* The agent gateway. ONE approval screen, and this is how this server reaches
|
|
38
|
+
* it.
|
|
39
|
+
*
|
|
40
|
+
* It used to send the browser to `https://buy.apiosk.com/connect` and redeem
|
|
41
|
+
* the resulting code at `https://gateway.apiosk.com/v1/connect/oauth/token`.
|
|
42
|
+
* That was a second approval screen, in a second frontend, backed by a second
|
|
43
|
+
* token system — beside the one the pasted-invitation flow already used, which
|
|
44
|
+
* approves at `app.apiosk.com/connect`.
|
|
45
|
+
*
|
|
46
|
+
* Now this server starts an ordinary authorization request at the agent
|
|
47
|
+
* gateway, which validates it and redirects to that same screen. The person
|
|
48
|
+
* approving a connection from Claude sees the page they see approving one from
|
|
49
|
+
* a pasted prompt, and sets the limits in the same place.
|
|
50
|
+
*/
|
|
51
|
+
const DEFAULT_GATEWAY_URL = "https://api.apiosk.com/functions/v1/agent-gateway";
|
|
52
|
+
// Same window as the authorization code it feeds into — the round trip to the
|
|
53
|
+
// portal and back happens in one browser session, not across a coffee break.
|
|
54
|
+
const PORTAL_HANDOFF_TTL_SECONDS = AUTHORIZATION_CODE_TTL_SECONDS;
|
|
55
|
+
/**
|
|
56
|
+
* How close to expiry an upstream access token is renewed rather than passed
|
|
57
|
+
* on. Comfortably longer than this server's own access token life, so a token
|
|
58
|
+
* minted here never carries an upstream credential that dies before it does.
|
|
59
|
+
*/
|
|
60
|
+
const UPSTREAM_RENEWAL_MARGIN_SECONDS = 2 * ACCESS_TOKEN_TTL_SECONDS;
|
|
61
|
+
|
|
39
62
|
function trimString(value) {
|
|
40
63
|
return typeof value === "string" ? value.trim() : "";
|
|
41
64
|
}
|
|
@@ -54,72 +77,6 @@ function escapeHtml(value) {
|
|
|
54
77
|
.replaceAll("'", "'");
|
|
55
78
|
}
|
|
56
79
|
|
|
57
|
-
function jsStringLiteral(value) {
|
|
58
|
-
return JSON.stringify(String(value)).replaceAll("<", "\\u003c");
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
// Inline brand marks so the wallet options always show a recognizable logo,
|
|
62
|
-
// even before an injected wallet announces its own EIP-6963 icon (and for the
|
|
63
|
-
// "install" state where no provider exists yet). Encoded as base64 data URIs so
|
|
64
|
-
// they can be handed to the client as plain <img src> strings without any
|
|
65
|
-
// template-literal escaping concerns.
|
|
66
|
-
function svgDataUri(svg) {
|
|
67
|
-
return `data:image/svg+xml;base64,${Buffer.from(svg.trim()).toString("base64")}`;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
// Official MetaMask fox mark.
|
|
71
|
-
const METAMASK_ICON = svgDataUri(`
|
|
72
|
-
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 212 189" width="212" height="189">
|
|
73
|
-
<g fill="none" fill-rule="evenodd">
|
|
74
|
-
<polygon fill="#CDBDB2" points="60.75 173.25 88.313 180.563 88.313 171 90.563 168.75 106.313 168.75 106.313 187.875 89.438 187.875 68.625 178.875"/>
|
|
75
|
-
<polygon fill="#CDBDB2" points="151.25 173.25 123.75 180.563 123.75 171 121.5 168.75 105.75 168.75 105.75 187.875 122.688 187.875 143.438 178.875"/>
|
|
76
|
-
<polygon fill="#393939" points="90.563 152.438 88.313 171 91.125 168.75 120.938 168.75 123.75 171 121.5 152.438 117 149.625 94.5 150.188"/>
|
|
77
|
-
<polygon fill="#F89C35" points="75.375 27 88.875 58.5 95.063 150.188 117 150.188 123.75 58.5 136.125 27"/>
|
|
78
|
-
<polygon fill="#F89D35" points="16.313 96.188 .563 141.75 39.938 139.5 65.25 139.5 65.25 119.813 64.125 79.313 58.5 83.813"/>
|
|
79
|
-
<polygon fill="#D87C30" points="46.125 101.25 92.25 102.375 87.188 126 65.25 120.375"/>
|
|
80
|
-
<polygon fill="#EA8D3A" points="46.125 101.813 65.25 119.813 65.25 137.813"/>
|
|
81
|
-
<polygon fill="#F89D35" points="65.25 120.375 87.75 126 95.063 150.188 90 153 65.25 138.375"/>
|
|
82
|
-
<polygon fill="#EB8F35" points="65.25 138.375 60.75 173.25 90.563 152.438"/>
|
|
83
|
-
<polygon fill="#EA8E3A" points="92.25 102.375 95.063 150.188 86.625 125.719"/>
|
|
84
|
-
<polygon fill="#D87C30" points="39.375 138.938 65.25 138.375 60.75 173.25"/>
|
|
85
|
-
<polygon fill="#EB8F35" points="12.938 188.438 60.75 173.25 39.375 138.938 .563 141.75"/>
|
|
86
|
-
<polygon fill="#E8821E" points="88.875 58.5 64.688 78.75 46.125 101.25 92.25 102.938"/>
|
|
87
|
-
<polygon fill="#DFCEC3" points="60.75 173.25 90.563 152.438 88.313 170.438 88.313 180.563 68.063 176.625"/>
|
|
88
|
-
<polygon fill="#E88F35" points="12.375 .563 88.875 58.5 75.938 27"/>
|
|
89
|
-
<path fill="#8E5A30" d="M12.375.563L2.25 31.5l5.625 33.75-3.938 2.25 5.625 5.063-4.5 3.938 6.188 5.625-3.938 3.375 8.813 11.25 41.063-12.75c20.063-16.125 29.925-24.375 29.588-24.75-.337-.375-29.925-22.688-88.762-66.938z"/>
|
|
90
|
-
<polygon fill="#F89D35" points="195.188 96.188 210.938 141.75 171.563 139.5 146.25 139.5 146.25 119.813 147.375 79.313 153 83.813"/>
|
|
91
|
-
<polygon fill="#D87C30" points="165.375 101.25 119.25 102.375 124.313 126 146.25 120.375"/>
|
|
92
|
-
<polygon fill="#EA8D3A" points="165.375 101.813 146.25 119.813 146.25 137.813"/>
|
|
93
|
-
<polygon fill="#F89D35" points="146.25 120.375 123.75 126 116.438 150.188 121.5 153 146.25 138.375"/>
|
|
94
|
-
<polygon fill="#EB8F35" points="146.25 138.375 150.75 173.25 121.5 152.438"/>
|
|
95
|
-
<polygon fill="#D87C30" points="172.125 138.938 146.25 138.375 150.75 173.25"/>
|
|
96
|
-
<polygon fill="#EB8F35" points="198.563 188.438 150.75 173.25 172.125 138.938 210.938 141.75"/>
|
|
97
|
-
<polygon fill="#E8821E" points="122.625 58.5 146.813 78.75 165.375 101.25 119.25 102.938"/>
|
|
98
|
-
<polygon fill="#E88F35" points="199.125 .563 122.625 58.5 135.563 27"/>
|
|
99
|
-
<path fill="#8E5A30" d="M199.125.563L209.25 31.5l-5.625 33.75 3.938 2.25-5.625 5.063 4.5 3.938-6.188 5.625 3.938 3.375-8.813 11.25-41.063-12.75c-20.063-16.125-29.925-24.375-29.588-24.75.337-.375 29.925-22.688 88.762-66.938z"/>
|
|
100
|
-
</g></svg>`);
|
|
101
|
-
|
|
102
|
-
// WalletConnect blue tile + signal wave.
|
|
103
|
-
const WALLETCONNECT_ICON = svgDataUri(`
|
|
104
|
-
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40" width="40" height="40">
|
|
105
|
-
<rect width="40" height="40" rx="11" fill="#3396FF"/>
|
|
106
|
-
<path fill="#fff" d="M12.4 15.6c4.2-4.1 11-4.1 15.2 0l.5.5c.21.2.21.55 0 .75l-1.73 1.7c-.1.1-.27.1-.38 0l-.7-.68c-2.93-2.87-7.68-2.87-10.6 0l-.75.73c-.1.1-.28.1-.38 0l-1.73-1.7c-.2-.2-.2-.55 0-.75l.75-.55zm18.77 3.5 1.54 1.5c.2.2.2.55 0 .76l-6.94 6.8c-.2.2-.54.2-.75 0l-4.93-4.83a.135.135 0 0 0-.19 0l-4.92 4.83c-.2.2-.55.2-.76 0l-6.94-6.8c-.2-.2-.2-.55 0-.76l1.54-1.5c.2-.2.55-.2.75 0l4.93 4.83c.05.05.14.05.19 0l4.92-4.83c.2-.2.55-.2.76 0l4.93 4.83c.05.05.13.05.19 0l4.92-4.83c.21-.2.55-.2.76 0z"/>
|
|
107
|
-
</svg>`);
|
|
108
|
-
|
|
109
|
-
// Coinbase Wallet blue mark.
|
|
110
|
-
const COINBASE_ICON = svgDataUri(`
|
|
111
|
-
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40" width="40" height="40">
|
|
112
|
-
<rect width="40" height="40" rx="11" fill="#0052FF"/>
|
|
113
|
-
<path fill="#fff" d="M20 8a12 12 0 1 0 0 24 12 12 0 0 0 0-24zm-3.4 8.1c0-.83.67-1.5 1.5-1.5h3.8c.83 0 1.5.67 1.5 1.5v7.8c0 .83-.67 1.5-1.5 1.5h-3.8c-.83 0-1.5-.67-1.5-1.5v-7.8z"/>
|
|
114
|
-
</svg>`);
|
|
115
|
-
|
|
116
|
-
// Generic browser-wallet glyph for injected wallets with no announced icon.
|
|
117
|
-
const GENERIC_WALLET_ICON = svgDataUri(`
|
|
118
|
-
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40" width="40" height="40">
|
|
119
|
-
<rect width="40" height="40" rx="11" fill="#6b38d4"/>
|
|
120
|
-
<path fill="#fff" d="M11 14.5A2.5 2.5 0 0 1 13.5 12h13a2.5 2.5 0 0 1 2.5 2.5V16h-2v-1.5a.5.5 0 0 0-.5-.5h-13a.5.5 0 0 0-.5.5v11a.5.5 0 0 0 .5.5h13a.5.5 0 0 0 .5-.5V24h2v1.5a2.5 2.5 0 0 1-2.5 2.5h-13A2.5 2.5 0 0 1 11 25.5v-11zM24 18h6v4h-6a2 2 0 0 1 0-4zm2 1.4a.6.6 0 1 0 0 1.2.6.6 0 0 0 0-1.2z"/>
|
|
121
|
-
</svg>`);
|
|
122
|
-
|
|
123
80
|
function toBase64Url(value) {
|
|
124
81
|
return Buffer.from(value).toString("base64url");
|
|
125
82
|
}
|
|
@@ -181,11 +138,26 @@ function resolveEffectiveExpiry(requestedExpiry, upperBound = null) {
|
|
|
181
138
|
return requestedExpiry;
|
|
182
139
|
}
|
|
183
140
|
|
|
141
|
+
// OAuth clients need invalid_grant to prompt reconnection. Plain Errors are
|
|
142
|
+
// converted by the SDK into HTTP 500 and retried as service outages.
|
|
143
|
+
function parseOAuthGrant(secret, token, type, client) {
|
|
144
|
+
let payload;
|
|
145
|
+
try {
|
|
146
|
+
payload = parseSignedToken(secret, token);
|
|
147
|
+
} catch {
|
|
148
|
+
throw new InvalidGrantError("Invalid or expired grant. Reconnect your Apiosk account.");
|
|
149
|
+
}
|
|
150
|
+
if (payload.typ !== type || payload.clientId !== client.client_id) {
|
|
151
|
+
throw new InvalidGrantError("Invalid or expired grant. Reconnect your Apiosk account.");
|
|
152
|
+
}
|
|
153
|
+
return payload;
|
|
154
|
+
}
|
|
155
|
+
|
|
184
156
|
function buildIssuedToken(secret, type, payload, ttlSeconds, maxExpiry = null) {
|
|
185
157
|
const issuedAt = getIssuedAtSeconds();
|
|
186
158
|
const exp = resolveEffectiveExpiry(issuedAt + ttlSeconds, maxExpiry);
|
|
187
159
|
if (!Number.isFinite(exp) || exp <= issuedAt) {
|
|
188
|
-
throw new
|
|
160
|
+
throw new InvalidGrantError("Session has expired. Re-authorize the Apiosk app and retry.");
|
|
189
161
|
}
|
|
190
162
|
|
|
191
163
|
return {
|
|
@@ -210,13 +182,6 @@ function buildRedirectUri(baseRedirectUri, params) {
|
|
|
210
182
|
return redirectUrl.toString();
|
|
211
183
|
}
|
|
212
184
|
|
|
213
|
-
function normalizeSessionExpiry(value) {
|
|
214
|
-
const sessionExpiry = Number(value);
|
|
215
|
-
return Number.isFinite(sessionExpiry) && sessionExpiry > getIssuedAtSeconds()
|
|
216
|
-
? sessionExpiry
|
|
217
|
-
: null;
|
|
218
|
-
}
|
|
219
|
-
|
|
220
185
|
function responseMessage(body, fallback) {
|
|
221
186
|
if (body && typeof body === "object") {
|
|
222
187
|
return trimString(body.message) || trimString(body.error) || fallback;
|
|
@@ -280,1476 +245,42 @@ function restoreSignedClient(secret, clientId) {
|
|
|
280
245
|
}
|
|
281
246
|
}
|
|
282
247
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
errorMessage = "",
|
|
288
|
-
infoMessage = "",
|
|
289
|
-
oauthParams,
|
|
290
|
-
walletEnabled = true,
|
|
291
|
-
walletNoncePath = "/api/auth/mcp-wallet-nonce",
|
|
292
|
-
walletConnectProjectId = "",
|
|
293
|
-
}) {
|
|
294
|
-
const scope = Array.isArray(oauthParams.scopes) ? oauthParams.scopes.join(" ") : "";
|
|
295
|
-
const resource = oauthParams.resource ? oauthParams.resource.href : "";
|
|
296
|
-
|
|
297
|
-
const hiddenInputs = [
|
|
298
|
-
["client_id", clientName.client_id],
|
|
299
|
-
["redirect_uri", oauthParams.redirectUri],
|
|
300
|
-
["response_type", "code"],
|
|
301
|
-
["code_challenge", oauthParams.codeChallenge],
|
|
302
|
-
["code_challenge_method", "S256"],
|
|
303
|
-
["scope", scope],
|
|
304
|
-
["state", oauthParams.state || ""],
|
|
305
|
-
["resource", resource],
|
|
306
|
-
]
|
|
307
|
-
.map(
|
|
308
|
-
([name, value]) =>
|
|
309
|
-
`<input type="hidden" name="${escapeHtml(name)}" value="${escapeHtml(value)}" />`
|
|
310
|
-
)
|
|
311
|
-
.join("\n");
|
|
312
|
-
|
|
313
|
-
return `<!doctype html>
|
|
314
|
-
<html lang="en">
|
|
315
|
-
<head>
|
|
316
|
-
<meta charset="utf-8" />
|
|
317
|
-
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
318
|
-
<meta name="color-scheme" content="light dark" />
|
|
319
|
-
<meta name="theme-color" content="#f8f9fb" media="(prefers-color-scheme: light)" />
|
|
320
|
-
<meta name="theme-color" content="#0e1013" media="(prefers-color-scheme: dark)" />
|
|
321
|
-
<title>Connect ${escapeHtml(appName)}</title>
|
|
322
|
-
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDI0IDEwMjQiPjxkZWZzPjxsaW5lYXJHcmFkaWVudCBpZD0iZyIgeDE9IjE1OCIgeTE9IjEyMCIgeDI9Ijg2NiIgeTI9IjkwNiIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiPjxzdG9wIG9mZnNldD0iMCIgc3RvcC1jb2xvcj0iIzhiNWNmNiIvPjxzdG9wIG9mZnNldD0iLjUyIiBzdG9wLWNvbG9yPSIjNmIzOGQ0Ii8+PHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSIjNTUxNmJlIi8+PC9saW5lYXJHcmFkaWVudD48L2RlZnM+PGcgZmlsbD0ibm9uZSIgc3Ryb2tlPSJ1cmwoI2cpIiBzdHJva2Utd2lkdGg9IjQzIj48cGF0aCBkPSJNNTExLjUgMTkwLjV2MzIxIi8+PHBhdGggZD0iTTIzMSAzMzVsMjgwLjUgMTc2LjUiLz48cGF0aCBkPSJNNzkyLjUgMzM1TDUxMS41IDUxMS41Ii8+PHBhdGggZD0iTTIzMSA2NzVsMjgwLjUtMTYzLjUiLz48cGF0aCBkPSJNNzkyLjUgNjc1TDUxMS41IDUxMS41Ii8+PHBhdGggZD0iTTUxMS41IDgyNS41di0zMTQiLz48L2c+PGcgZmlsbD0idXJsKCNnKSI+PGNpcmNsZSBjeD0iNTExLjUiIGN5PSIxOTAuNSIgcj0iODAuNSIvPjxjaXJjbGUgY3g9IjIzMSIgY3k9IjMzNSIgcj0iODAiLz48Y2lyY2xlIGN4PSI3OTIuNSIgY3k9IjMzNSIgcj0iODAiLz48Y2lyY2xlIGN4PSI1MTEuNSIgY3k9IjUxMS41IiByPSIxMzUiLz48Y2lyY2xlIGN4PSIyMzEiIGN5PSI2NzUiIHI9IjgwIi8+PGNpcmNsZSBjeD0iNzkyLjUiIGN5PSI2NzUiIHI9IjgwIi8+PGNpcmNsZSBjeD0iNTExLjUiIGN5PSI4MjUuNSIgcj0iODAuNSIvPjwvZz48L3N2Zz4K" />
|
|
323
|
-
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
324
|
-
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
325
|
-
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" />
|
|
326
|
-
<style>
|
|
327
|
-
:root {
|
|
328
|
-
color-scheme: light dark;
|
|
329
|
-
--background: #f8f9fb;
|
|
330
|
-
--foreground: #191c1e;
|
|
331
|
-
--card: #ffffff;
|
|
332
|
-
--border: #e5e3ec;
|
|
333
|
-
--muted: #5d5a68;
|
|
334
|
-
--muted-surface: #f2f4f6;
|
|
335
|
-
--primary: #6b38d4;
|
|
336
|
-
--primary-strong: #5516be;
|
|
337
|
-
/* Accent for text/icons. Split from --primary because --primary doubles
|
|
338
|
-
as a filled-button background under white label text: dark mode needs
|
|
339
|
-
the button deep and the accent light, and one token can't be both. */
|
|
340
|
-
--primary-accent: #6b38d4;
|
|
341
|
-
--primary-soft: #f3effd;
|
|
342
|
-
--danger: #ba1a1a;
|
|
343
|
-
--danger-soft: #ffdad6;
|
|
344
|
-
--success: #1f8a5b;
|
|
345
|
-
--success-soft: #e6f4ee;
|
|
346
|
-
--shadow: 0 24px 48px -16px rgba(60, 30, 120, 0.22), 0 8px 20px -12px rgba(26, 35, 53, 0.12);
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
/* Follows the OS/browser preference — no toggle, no persisted choice. */
|
|
350
|
-
@media (prefers-color-scheme: dark) {
|
|
351
|
-
:root {
|
|
352
|
-
--background: #0e1013;
|
|
353
|
-
--foreground: #e8e8ec;
|
|
354
|
-
--card: #16181d;
|
|
355
|
-
--border: #2a2d35;
|
|
356
|
-
--muted: #a1a1ae;
|
|
357
|
-
--muted-surface: #1e2128;
|
|
358
|
-
--primary: #6d3fd9;
|
|
359
|
-
--primary-strong: #8b5cf6;
|
|
360
|
-
--primary-accent: #b9a4fb;
|
|
361
|
-
--primary-soft: #201b33;
|
|
362
|
-
--danger: #ffb4ab;
|
|
363
|
-
--danger-soft: #3b1512;
|
|
364
|
-
--success: #6fd8a6;
|
|
365
|
-
--success-soft: #10281f;
|
|
366
|
-
--shadow: 0 24px 48px -16px rgba(0, 0, 0, 0.62), 0 8px 20px -12px rgba(0, 0, 0, 0.5);
|
|
367
|
-
}
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
* {
|
|
371
|
-
box-sizing: border-box;
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
body {
|
|
375
|
-
margin: 0;
|
|
376
|
-
min-height: 100vh;
|
|
377
|
-
font-family: "Inter", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
378
|
-
background:
|
|
379
|
-
radial-gradient(900px 520px at 88% -12%, color-mix(in srgb, var(--primary) 16%, transparent), transparent 60%),
|
|
380
|
-
radial-gradient(760px 520px at -8% 112%, color-mix(in srgb, #8b5cf6 13%, transparent), transparent 55%),
|
|
381
|
-
var(--background);
|
|
382
|
-
background-attachment: fixed;
|
|
383
|
-
color: var(--foreground);
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
.shell {
|
|
387
|
-
width: 100%;
|
|
388
|
-
min-height: 100vh;
|
|
389
|
-
display: grid;
|
|
390
|
-
grid-template-rows: auto 1fr auto;
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
.topbar,
|
|
394
|
-
footer {
|
|
395
|
-
width: 100%;
|
|
396
|
-
display: flex;
|
|
397
|
-
align-items: center;
|
|
398
|
-
justify-content: space-between;
|
|
399
|
-
padding: 20px clamp(20px, 5vw, 42px);
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
.brand {
|
|
403
|
-
display: inline-flex;
|
|
404
|
-
align-items: center;
|
|
405
|
-
gap: 10px;
|
|
406
|
-
color: var(--foreground);
|
|
407
|
-
text-decoration: none;
|
|
408
|
-
font-weight: 700;
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
.brand-mark {
|
|
412
|
-
width: 36px;
|
|
413
|
-
height: 36px;
|
|
414
|
-
display: inline-grid;
|
|
415
|
-
place-items: center;
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
.brand-mark svg {
|
|
419
|
-
width: 100%;
|
|
420
|
-
height: 100%;
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
.page {
|
|
424
|
-
width: 100%;
|
|
425
|
-
display: grid;
|
|
426
|
-
place-items: center;
|
|
427
|
-
padding: 24px clamp(20px, 5vw, 42px) 40px;
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
.layout {
|
|
431
|
-
width: min(100%, 960px);
|
|
432
|
-
display: grid;
|
|
433
|
-
grid-template-columns: 1fr;
|
|
434
|
-
justify-items: center;
|
|
435
|
-
gap: 36px;
|
|
436
|
-
align-items: center;
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
.hero {
|
|
440
|
-
display: none;
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
@media (min-width: 900px) {
|
|
444
|
-
.layout {
|
|
445
|
-
grid-template-columns: 1.02fr 0.98fr;
|
|
446
|
-
gap: 56px;
|
|
447
|
-
justify-items: stretch;
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
.hero {
|
|
451
|
-
display: grid;
|
|
452
|
-
gap: 22px;
|
|
453
|
-
align-content: center;
|
|
454
|
-
}
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
.hero-eyebrow {
|
|
458
|
-
color: var(--primary-accent);
|
|
459
|
-
font-weight: 700;
|
|
460
|
-
font-size: 0.85rem;
|
|
461
|
-
letter-spacing: 0.01em;
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
.hero h2 {
|
|
465
|
-
margin: 6px 0 0;
|
|
466
|
-
font-size: clamp(1.9rem, 2.6vw, 2.55rem);
|
|
467
|
-
line-height: 1.08;
|
|
468
|
-
letter-spacing: -0.02em;
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
.hero .lead {
|
|
472
|
-
font-size: 1.02rem;
|
|
473
|
-
line-height: 1.55;
|
|
474
|
-
max-width: 42ch;
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
.value-list {
|
|
478
|
-
display: grid;
|
|
479
|
-
gap: 16px;
|
|
480
|
-
margin-top: 4px;
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
.value {
|
|
484
|
-
display: flex;
|
|
485
|
-
gap: 13px;
|
|
486
|
-
align-items: flex-start;
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
.value-icon {
|
|
490
|
-
flex: 0 0 auto;
|
|
491
|
-
width: 40px;
|
|
492
|
-
height: 40px;
|
|
493
|
-
border-radius: 11px;
|
|
494
|
-
display: grid;
|
|
495
|
-
place-items: center;
|
|
496
|
-
background: var(--primary-soft);
|
|
497
|
-
color: var(--primary-accent);
|
|
498
|
-
border: 1px solid color-mix(in srgb, var(--primary) 16%, transparent);
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
.value-icon svg {
|
|
502
|
-
width: 20px;
|
|
503
|
-
height: 20px;
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
.value-title {
|
|
507
|
-
font-weight: 600;
|
|
508
|
-
font-size: 0.96rem;
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
.value-desc {
|
|
512
|
-
color: var(--muted);
|
|
513
|
-
font-size: 0.9rem;
|
|
514
|
-
line-height: 1.45;
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
main {
|
|
518
|
-
width: 100%;
|
|
519
|
-
max-width: 440px;
|
|
520
|
-
justify-self: center;
|
|
521
|
-
border: 1px solid var(--border);
|
|
522
|
-
background: var(--card);
|
|
523
|
-
border-radius: 18px;
|
|
524
|
-
padding: 28px;
|
|
525
|
-
box-shadow: var(--shadow);
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
h1 {
|
|
529
|
-
margin: 0 0 8px;
|
|
530
|
-
font-size: 1.35rem;
|
|
531
|
-
line-height: 1.2;
|
|
532
|
-
letter-spacing: 0;
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
p {
|
|
536
|
-
margin: 0;
|
|
537
|
-
color: var(--muted);
|
|
538
|
-
line-height: 1.55;
|
|
539
|
-
}
|
|
540
|
-
|
|
541
|
-
.stack {
|
|
542
|
-
display: grid;
|
|
543
|
-
gap: 14px;
|
|
544
|
-
}
|
|
545
|
-
|
|
546
|
-
.header {
|
|
547
|
-
gap: 8px;
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
.eyebrow {
|
|
551
|
-
margin-bottom: 6px;
|
|
552
|
-
color: var(--primary-accent);
|
|
553
|
-
font-size: 0.83rem;
|
|
554
|
-
font-weight: 700;
|
|
555
|
-
}
|
|
556
|
-
|
|
557
|
-
.message {
|
|
558
|
-
border-radius: 8px;
|
|
559
|
-
padding: 12px 14px;
|
|
560
|
-
font-size: 0.9rem;
|
|
561
|
-
}
|
|
562
|
-
|
|
563
|
-
.message.error {
|
|
564
|
-
border: 1px solid color-mix(in srgb, var(--danger) 30%, transparent);
|
|
565
|
-
background: var(--danger-soft);
|
|
566
|
-
color: var(--danger);
|
|
567
|
-
}
|
|
568
|
-
|
|
569
|
-
.message.info {
|
|
570
|
-
border: 1px solid color-mix(in srgb, var(--success) 26%, transparent);
|
|
571
|
-
background: var(--success-soft);
|
|
572
|
-
color: var(--success);
|
|
573
|
-
}
|
|
574
|
-
|
|
575
|
-
.panel {
|
|
576
|
-
display: grid;
|
|
577
|
-
gap: 10px;
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
button {
|
|
581
|
-
appearance: none;
|
|
582
|
-
border: 1px solid transparent;
|
|
583
|
-
border-radius: 8px;
|
|
584
|
-
padding: 12px 14px;
|
|
585
|
-
font-size: 0.98rem;
|
|
586
|
-
font-weight: 600;
|
|
587
|
-
cursor: pointer;
|
|
588
|
-
min-height: 40px;
|
|
589
|
-
transition: background-color 140ms ease, border-color 140ms ease, color 140ms ease, opacity 140ms ease;
|
|
590
|
-
}
|
|
591
|
-
|
|
592
|
-
button:disabled {
|
|
593
|
-
cursor: not-allowed;
|
|
594
|
-
opacity: 0.58;
|
|
595
|
-
}
|
|
596
|
-
|
|
597
|
-
button.primary {
|
|
598
|
-
background: var(--primary);
|
|
599
|
-
color: #fff;
|
|
600
|
-
}
|
|
601
|
-
|
|
602
|
-
button.primary:hover:not(:disabled) {
|
|
603
|
-
background: var(--primary-strong);
|
|
604
|
-
}
|
|
605
|
-
|
|
606
|
-
button.secondary {
|
|
607
|
-
background: transparent;
|
|
608
|
-
border-color: var(--border);
|
|
609
|
-
color: var(--foreground);
|
|
610
|
-
}
|
|
611
|
-
|
|
612
|
-
button.secondary:hover:not(:disabled) {
|
|
613
|
-
background: var(--muted-surface);
|
|
614
|
-
}
|
|
615
|
-
|
|
616
|
-
.wallet-list {
|
|
617
|
-
display: grid;
|
|
618
|
-
gap: 8px;
|
|
619
|
-
}
|
|
620
|
-
|
|
621
|
-
.wallet-option {
|
|
622
|
-
width: 100%;
|
|
623
|
-
display: flex;
|
|
624
|
-
align-items: center;
|
|
625
|
-
justify-content: space-between;
|
|
626
|
-
gap: 12px;
|
|
627
|
-
padding: 13px 16px;
|
|
628
|
-
min-height: 58px;
|
|
629
|
-
border-radius: 12px;
|
|
630
|
-
background: var(--card);
|
|
631
|
-
border: 1px solid var(--border);
|
|
632
|
-
color: var(--foreground);
|
|
633
|
-
text-align: left;
|
|
634
|
-
text-decoration: none;
|
|
635
|
-
font-weight: 600;
|
|
636
|
-
cursor: pointer;
|
|
637
|
-
transition: border-color 140ms ease, background-color 140ms ease, box-shadow 140ms ease, transform 140ms ease;
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
.wallet-option:hover:not(:disabled) {
|
|
641
|
-
border-color: color-mix(in srgb, var(--primary) 45%, var(--border));
|
|
642
|
-
background: var(--primary-soft);
|
|
643
|
-
box-shadow: 0 6px 16px -10px color-mix(in srgb, var(--primary) 60%, transparent);
|
|
644
|
-
transform: translateY(-1px);
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
.wallet-name {
|
|
648
|
-
min-width: 0;
|
|
649
|
-
display: inline-flex;
|
|
650
|
-
align-items: center;
|
|
651
|
-
gap: 8px;
|
|
652
|
-
}
|
|
653
|
-
|
|
654
|
-
.wallet-icon {
|
|
655
|
-
width: 24px;
|
|
656
|
-
height: 24px;
|
|
657
|
-
border-radius: 6px;
|
|
658
|
-
flex: 0 0 auto;
|
|
659
|
-
object-fit: contain;
|
|
660
|
-
background: var(--card);
|
|
661
|
-
}
|
|
662
|
-
|
|
663
|
-
.wallet-name-text {
|
|
664
|
-
min-width: 0;
|
|
665
|
-
overflow: hidden;
|
|
666
|
-
text-overflow: ellipsis;
|
|
667
|
-
white-space: nowrap;
|
|
668
|
-
font-size: 0.95rem;
|
|
669
|
-
font-weight: 500;
|
|
670
|
-
}
|
|
671
|
-
|
|
672
|
-
.wallet-action {
|
|
673
|
-
flex: 0 0 auto;
|
|
674
|
-
display: inline-flex;
|
|
675
|
-
align-items: center;
|
|
676
|
-
gap: 4px;
|
|
677
|
-
color: var(--muted);
|
|
678
|
-
font-size: 0.78rem;
|
|
679
|
-
}
|
|
680
|
-
|
|
681
|
-
.wallet-option.install {
|
|
682
|
-
border-style: dashed;
|
|
683
|
-
}
|
|
684
|
-
|
|
685
|
-
.wallet-option.busy {
|
|
686
|
-
opacity: 0.6;
|
|
687
|
-
}
|
|
688
|
-
|
|
689
|
-
.wallet-connect-option .wallet-action {
|
|
690
|
-
color: var(--primary);
|
|
691
|
-
font-weight: 600;
|
|
692
|
-
}
|
|
693
|
-
|
|
694
|
-
.spinner {
|
|
695
|
-
width: 15px;
|
|
696
|
-
height: 15px;
|
|
697
|
-
border-radius: 999px;
|
|
698
|
-
border: 2px solid color-mix(in srgb, var(--primary) 30%, transparent);
|
|
699
|
-
border-top-color: var(--primary);
|
|
700
|
-
animation: spin 0.7s linear infinite;
|
|
701
|
-
}
|
|
702
|
-
|
|
703
|
-
@keyframes spin {
|
|
704
|
-
to {
|
|
705
|
-
transform: rotate(360deg);
|
|
706
|
-
}
|
|
707
|
-
}
|
|
708
|
-
|
|
709
|
-
.divider {
|
|
710
|
-
display: flex;
|
|
711
|
-
align-items: center;
|
|
712
|
-
gap: 12px;
|
|
713
|
-
color: var(--muted);
|
|
714
|
-
font-size: 0.76rem;
|
|
715
|
-
text-transform: uppercase;
|
|
716
|
-
letter-spacing: 0.06em;
|
|
717
|
-
}
|
|
718
|
-
|
|
719
|
-
.divider::before,
|
|
720
|
-
.divider::after {
|
|
721
|
-
content: "";
|
|
722
|
-
flex: 1;
|
|
723
|
-
height: 1px;
|
|
724
|
-
background: var(--border);
|
|
725
|
-
}
|
|
726
|
-
|
|
727
|
-
.cancel-row {
|
|
728
|
-
display: flex;
|
|
729
|
-
justify-content: center;
|
|
730
|
-
margin: 0;
|
|
731
|
-
}
|
|
732
|
-
|
|
733
|
-
.cancel-link {
|
|
734
|
-
appearance: none;
|
|
735
|
-
background: none;
|
|
736
|
-
border: none;
|
|
737
|
-
box-shadow: none;
|
|
738
|
-
min-height: auto;
|
|
739
|
-
padding: 4px 8px;
|
|
740
|
-
color: var(--muted);
|
|
741
|
-
font-size: 0.86rem;
|
|
742
|
-
font-weight: 500;
|
|
743
|
-
cursor: pointer;
|
|
744
|
-
}
|
|
745
|
-
|
|
746
|
-
.cancel-link:hover {
|
|
747
|
-
color: var(--foreground);
|
|
748
|
-
text-decoration: underline;
|
|
749
|
-
text-underline-offset: 3px;
|
|
750
|
-
}
|
|
751
|
-
|
|
752
|
-
.meta {
|
|
753
|
-
font-size: 0.84rem;
|
|
754
|
-
color: var(--muted);
|
|
755
|
-
padding-top: 2px;
|
|
756
|
-
}
|
|
757
|
-
|
|
758
|
-
.grant {
|
|
759
|
-
margin-top: 2px;
|
|
760
|
-
border-top: 1px solid var(--border);
|
|
761
|
-
padding-top: 14px;
|
|
762
|
-
font-size: 0.8rem;
|
|
763
|
-
line-height: 1.7;
|
|
764
|
-
color: var(--muted);
|
|
765
|
-
}
|
|
766
|
-
|
|
767
|
-
code {
|
|
768
|
-
font-family: ui-monospace, SFMono-Regular, SFMono-Regular, Menlo, monospace;
|
|
769
|
-
color: var(--foreground);
|
|
770
|
-
word-break: break-word;
|
|
771
|
-
}
|
|
772
|
-
|
|
773
|
-
footer {
|
|
774
|
-
justify-content: center;
|
|
775
|
-
gap: 18px;
|
|
776
|
-
color: var(--muted);
|
|
777
|
-
font-size: 0.78rem;
|
|
778
|
-
}
|
|
779
|
-
|
|
780
|
-
footer a {
|
|
781
|
-
color: inherit;
|
|
782
|
-
text-decoration: none;
|
|
783
|
-
}
|
|
784
|
-
|
|
785
|
-
footer a:hover {
|
|
786
|
-
color: var(--foreground);
|
|
787
|
-
}
|
|
788
|
-
|
|
789
|
-
.hidden {
|
|
790
|
-
display: none;
|
|
791
|
-
}
|
|
792
|
-
|
|
793
|
-
/* Connect / Create segmented tabs — mirrors the provider portal's
|
|
794
|
-
wallet-connection toggle so the two sign-in surfaces feel like one. */
|
|
795
|
-
.mode-tabs {
|
|
796
|
-
display: grid;
|
|
797
|
-
grid-template-columns: 1fr 1fr;
|
|
798
|
-
gap: 4px;
|
|
799
|
-
padding: 4px;
|
|
800
|
-
border: 1px solid var(--border);
|
|
801
|
-
border-radius: 10px;
|
|
802
|
-
background: var(--muted-surface);
|
|
803
|
-
}
|
|
804
|
-
|
|
805
|
-
.mode-tab {
|
|
806
|
-
appearance: none;
|
|
807
|
-
border: none;
|
|
808
|
-
border-radius: 8px;
|
|
809
|
-
background: transparent;
|
|
810
|
-
color: var(--muted);
|
|
811
|
-
font-size: 0.92rem;
|
|
812
|
-
font-weight: 600;
|
|
813
|
-
padding: 9px 12px;
|
|
814
|
-
min-height: auto;
|
|
815
|
-
cursor: pointer;
|
|
816
|
-
}
|
|
817
|
-
|
|
818
|
-
.mode-tab.active {
|
|
819
|
-
background: var(--primary);
|
|
820
|
-
color: #fff;
|
|
821
|
-
}
|
|
822
|
-
|
|
823
|
-
.mode-tab:not(.active):hover {
|
|
824
|
-
color: var(--foreground);
|
|
825
|
-
}
|
|
826
|
-
|
|
827
|
-
.create-panel {
|
|
828
|
-
display: grid;
|
|
829
|
-
gap: 12px;
|
|
830
|
-
}
|
|
831
|
-
|
|
832
|
-
/* .create-panel's display:grid would otherwise win over .hidden (same
|
|
833
|
-
specificity, declared later), leaving the Create pane visible on the
|
|
834
|
-
Connect tab. */
|
|
835
|
-
.create-panel.hidden {
|
|
836
|
-
display: none;
|
|
837
|
-
}
|
|
838
|
-
|
|
839
|
-
.create-note {
|
|
840
|
-
font-size: 0.84rem;
|
|
841
|
-
color: var(--muted);
|
|
842
|
-
line-height: 1.55;
|
|
843
|
-
border: 1px solid var(--border);
|
|
844
|
-
border-radius: 8px;
|
|
845
|
-
padding: 10px 12px;
|
|
846
|
-
}
|
|
847
|
-
|
|
848
|
-
.create-address {
|
|
849
|
-
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
850
|
-
font-size: 0.8rem;
|
|
851
|
-
word-break: break-all;
|
|
852
|
-
color: var(--foreground);
|
|
853
|
-
border: 1px solid var(--border);
|
|
854
|
-
border-radius: 8px;
|
|
855
|
-
padding: 10px 12px;
|
|
856
|
-
background: var(--muted-surface);
|
|
857
|
-
}
|
|
858
|
-
|
|
859
|
-
.phrase-grid {
|
|
860
|
-
display: grid;
|
|
861
|
-
grid-template-columns: repeat(3, 1fr);
|
|
862
|
-
gap: 6px;
|
|
863
|
-
border: 1px solid var(--border);
|
|
864
|
-
border-radius: 8px;
|
|
865
|
-
padding: 12px;
|
|
866
|
-
background: var(--muted-surface);
|
|
867
|
-
}
|
|
868
|
-
|
|
869
|
-
.phrase-grid.blurred .phrase-word-text {
|
|
870
|
-
filter: blur(5px);
|
|
871
|
-
}
|
|
872
|
-
|
|
873
|
-
.phrase-word {
|
|
874
|
-
display: flex;
|
|
875
|
-
gap: 6px;
|
|
876
|
-
align-items: baseline;
|
|
877
|
-
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
878
|
-
font-size: 0.8rem;
|
|
879
|
-
color: var(--foreground);
|
|
880
|
-
}
|
|
881
|
-
|
|
882
|
-
.phrase-word .phrase-index {
|
|
883
|
-
color: var(--muted);
|
|
884
|
-
font-size: 0.7rem;
|
|
885
|
-
min-width: 16px;
|
|
886
|
-
text-align: right;
|
|
887
|
-
}
|
|
888
|
-
|
|
889
|
-
.create-actions {
|
|
890
|
-
display: grid;
|
|
891
|
-
grid-template-columns: 1fr 1fr 1fr;
|
|
892
|
-
gap: 8px;
|
|
893
|
-
}
|
|
894
|
-
|
|
895
|
-
.create-actions button {
|
|
896
|
-
background: transparent;
|
|
897
|
-
border: 1px solid var(--border);
|
|
898
|
-
color: var(--foreground);
|
|
899
|
-
font-size: 0.84rem;
|
|
900
|
-
padding: 8px 10px;
|
|
901
|
-
min-height: auto;
|
|
902
|
-
}
|
|
903
|
-
|
|
904
|
-
.create-actions button:hover:not(:disabled) {
|
|
905
|
-
background: var(--muted-surface);
|
|
906
|
-
}
|
|
907
|
-
|
|
908
|
-
.checkbox-row {
|
|
909
|
-
display: flex;
|
|
910
|
-
gap: 10px;
|
|
911
|
-
align-items: flex-start;
|
|
912
|
-
font-size: 0.84rem;
|
|
913
|
-
color: var(--muted);
|
|
914
|
-
line-height: 1.5;
|
|
915
|
-
cursor: pointer;
|
|
916
|
-
}
|
|
917
|
-
|
|
918
|
-
.checkbox-row input {
|
|
919
|
-
width: auto;
|
|
920
|
-
margin-top: 2px;
|
|
921
|
-
accent-color: var(--primary);
|
|
922
|
-
}
|
|
923
|
-
|
|
924
|
-
button.primary-wide {
|
|
925
|
-
background: var(--primary);
|
|
926
|
-
border: 1px solid transparent;
|
|
927
|
-
color: #fff;
|
|
928
|
-
font-weight: 600;
|
|
929
|
-
width: 100%;
|
|
930
|
-
}
|
|
931
|
-
|
|
932
|
-
button.primary-wide:hover:not(:disabled) {
|
|
933
|
-
background: var(--primary-strong);
|
|
934
|
-
}
|
|
935
|
-
|
|
936
|
-
@media (max-width: 560px) {
|
|
937
|
-
.topbar {
|
|
938
|
-
padding: 16px 20px;
|
|
939
|
-
}
|
|
940
|
-
|
|
941
|
-
.page {
|
|
942
|
-
padding: 16px;
|
|
943
|
-
place-items: start center;
|
|
944
|
-
}
|
|
945
|
-
|
|
946
|
-
main {
|
|
947
|
-
padding: 20px;
|
|
948
|
-
}
|
|
949
|
-
}
|
|
950
|
-
</style>
|
|
951
|
-
</head>
|
|
952
|
-
<body>
|
|
953
|
-
<div class="shell">
|
|
954
|
-
<header class="topbar">
|
|
955
|
-
<a class="brand" href="https://apiosk.com" rel="noreferrer">
|
|
956
|
-
<span class="brand-mark" aria-hidden="true">
|
|
957
|
-
<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Apiosk">
|
|
958
|
-
<defs>
|
|
959
|
-
<linearGradient id="apiosk-brand-mark" x1="158" y1="120" x2="866" y2="906" gradientUnits="userSpaceOnUse">
|
|
960
|
-
<stop offset="0%" stop-color="#8b5cf6" />
|
|
961
|
-
<stop offset="52%" stop-color="#6b38d4" />
|
|
962
|
-
<stop offset="100%" stop-color="#5516be" />
|
|
963
|
-
</linearGradient>
|
|
964
|
-
</defs>
|
|
965
|
-
<g fill="none" stroke="url(#apiosk-brand-mark)" stroke-width="43" stroke-linecap="butt">
|
|
966
|
-
<line x1="511.5" y1="190.5" x2="511.5" y2="511.5" />
|
|
967
|
-
<line x1="231" y1="335" x2="511.5" y2="511.5" />
|
|
968
|
-
<line x1="792.5" y1="335" x2="511.5" y2="511.5" />
|
|
969
|
-
<line x1="231" y1="675" x2="511.5" y2="511.5" />
|
|
970
|
-
<line x1="792.5" y1="675" x2="511.5" y2="511.5" />
|
|
971
|
-
<line x1="511.5" y1="825.5" x2="511.5" y2="511.5" />
|
|
972
|
-
</g>
|
|
973
|
-
<g fill="url(#apiosk-brand-mark)">
|
|
974
|
-
<circle cx="511.5" cy="190.5" r="80.5" />
|
|
975
|
-
<circle cx="231" cy="335" r="80" />
|
|
976
|
-
<circle cx="792.5" cy="335" r="80" />
|
|
977
|
-
<circle cx="511.5" cy="511.5" r="135" />
|
|
978
|
-
<circle cx="231" cy="675" r="80" />
|
|
979
|
-
<circle cx="792.5" cy="675" r="80" />
|
|
980
|
-
<circle cx="511.5" cy="825.5" r="80.5" />
|
|
981
|
-
</g>
|
|
982
|
-
</svg>
|
|
983
|
-
</span>
|
|
984
|
-
<span>Apiosk</span>
|
|
985
|
-
</a>
|
|
986
|
-
</header>
|
|
987
|
-
<div class="page">
|
|
988
|
-
<div class="layout">
|
|
989
|
-
<section class="hero" aria-hidden="true">
|
|
990
|
-
<div>
|
|
991
|
-
<p class="hero-eyebrow">Apiosk payment infrastructure</p>
|
|
992
|
-
<h2>Pay for any API, straight from your agent</h2>
|
|
993
|
-
</div>
|
|
994
|
-
<p class="lead">Sign in once to let ${escapeHtml(clientName.client_name || "your AI app")} discover and call APIs through Apiosk. Paid calls use USDC from the wallet you explicitly authorize.</p>
|
|
995
|
-
<div class="value-list">
|
|
996
|
-
<div class="value">
|
|
997
|
-
<span class="value-icon">
|
|
998
|
-
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="6" /><path d="M18.09 10.37A6 6 0 1 1 10.34 18" /><path d="M7 6h1v4" /><path d="m16.71 13.88.7.71-2.82 2.82" /></svg>
|
|
999
|
-
</span>
|
|
1000
|
-
<div>
|
|
1001
|
-
<div class="value-title">Pay per call, automatically</div>
|
|
1002
|
-
<div class="value-desc">USDC over x402 on Base. No invoices, no API-key juggling.</div>
|
|
1003
|
-
</div>
|
|
1004
|
-
</div>
|
|
1005
|
-
<div class="value">
|
|
1006
|
-
<span class="value-icon">
|
|
1007
|
-
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 7V5a2 2 0 0 0-2-2H5a2 2 0 0 0 0 4h14a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5" /><path d="M16 12h.01" /></svg>
|
|
1008
|
-
</span>
|
|
1009
|
-
<div>
|
|
1010
|
-
<div class="value-title">One secure connection</div>
|
|
1011
|
-
<div class="value-desc">Your identity signature signs you in; a separate, limited USDC approval authorizes automatic payments.</div>
|
|
1012
|
-
</div>
|
|
1013
|
-
</div>
|
|
1014
|
-
<div class="value">
|
|
1015
|
-
<span class="value-icon">
|
|
1016
|
-
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="7" height="7" x="3" y="3" rx="1" /><rect width="7" height="7" x="14" y="3" rx="1" /><rect width="7" height="7" x="14" y="14" rx="1" /><rect width="7" height="7" x="3" y="14" rx="1" /></svg>
|
|
1017
|
-
</span>
|
|
1018
|
-
<div>
|
|
1019
|
-
<div class="value-title">One catalog, one contract</div>
|
|
1020
|
-
<div class="value-desc">Discover APIs and datasets and call them without leaving your chat.</div>
|
|
1021
|
-
</div>
|
|
1022
|
-
</div>
|
|
1023
|
-
</div>
|
|
1024
|
-
</section>
|
|
1025
|
-
<main class="stack">
|
|
1026
|
-
<header class="stack header">
|
|
1027
|
-
<div>
|
|
1028
|
-
<p class="eyebrow">${escapeHtml(clientName.client_name || "Remote MCP client")}</p>
|
|
1029
|
-
<h1>Connect ${escapeHtml(appName)}</h1>
|
|
1030
|
-
</div>
|
|
1031
|
-
<p>Connect a wallet to sign in without a password. You can then authorize limited USDC payments from that same wallet on Base.</p>
|
|
1032
|
-
</header>
|
|
1033
|
-
${errorMessage ? `<div class="message error">${escapeHtml(errorMessage)}</div>` : ""}
|
|
1034
|
-
${infoMessage ? `<div class="message info">${escapeHtml(infoMessage)}</div>` : ""}
|
|
1035
|
-
<section class="panel" aria-label="Wallet sign in">
|
|
1036
|
-
${
|
|
1037
|
-
walletEnabled
|
|
1038
|
-
? `<div class="mode-tabs" role="tablist" aria-label="Wallet sign-in mode">
|
|
1039
|
-
<button id="mode-connect" class="mode-tab active" type="button" role="tab" aria-selected="true">Connect</button>
|
|
1040
|
-
<button id="mode-create" class="mode-tab" type="button" role="tab" aria-selected="false">Create</button>
|
|
1041
|
-
</div>`
|
|
1042
|
-
: ""
|
|
1043
|
-
}
|
|
1044
|
-
<div id="connect-panel">
|
|
1045
|
-
<div id="wallet-list" class="wallet-list" role="list">
|
|
1046
|
-
${
|
|
1047
|
-
walletEnabled
|
|
1048
|
-
? `<button class="wallet-option" type="button" disabled>
|
|
1049
|
-
<span class="wallet-name">
|
|
1050
|
-
<img class="wallet-icon" src="${METAMASK_ICON}" alt="" />
|
|
1051
|
-
<span class="wallet-name-text">Detecting wallets…</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
|
-
|
|
248
|
+
// The interstitial shown after the round trip to buy.apiosk.com finishes,
|
|
249
|
+
// right before bouncing back to the MCP client. The client only completes the
|
|
250
|
+
// connection once its own callback runs, so this still carries the
|
|
251
|
+
// authorization code (auto-continue + manual link) rather than replacing it.
|
|
1687
252
|
function createConnectionCompletePage({ appName, clientName, redirectTarget }) {
|
|
1688
253
|
const clientLabel = clientName.client_name || "the app";
|
|
1689
254
|
return `<!doctype html><html lang="en"><head><meta charset="utf-8" />
|
|
1690
255
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
|
1691
|
-
<meta name="color-scheme" content="light
|
|
256
|
+
<meta name="color-scheme" content="only light" />
|
|
1692
257
|
<meta http-equiv="refresh" content="2;url=${escapeHtml(redirectTarget)}" />
|
|
1693
258
|
<title>Connected · ${escapeHtml(appName)}</title>
|
|
1694
259
|
<style>
|
|
1695
|
-
|
|
1696
|
-
|
|
260
|
+
@font-face{font-family:Inter;src:url("/brand/inter-latin-500-normal.woff2") format("woff2");font-weight:500;font-display:swap}@font-face{font-family:Inter;src:url("/brand/inter-latin-600-normal.woff2") format("woff2");font-weight:600;font-display:swap}
|
|
261
|
+
:root{font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color-scheme:light dark;--background:#f8f8fb;--foreground:#1f2028;--card:#fff;--border:#e7e3ef;--muted:#676371;--primary:#6349db;--primary-fg:#fff;--glow:rgb(99 73 219/.07);--success:#057857;--shadow:0 22px 54px -30px rgba(48,28,100,.3);color:var(--foreground);background:var(--background);font-weight:500;letter-spacing:-.011em}
|
|
262
|
+
@media (prefers-color-scheme:dark){:root{--background:#0d0f13;--foreground:#ecebf2;--card:#15171d;--border:#262a34;--muted:#a5a2b0;--primary:#c3a0ff;--primary-fg:#25153c;--glow:rgb(195 160 255/.12);--success:#6ee7b7;--shadow:0 24px 52px -26px rgba(0,0,0,.72)}}
|
|
1697
263
|
*{box-sizing:border-box}body{margin:0;min-height:100vh;background:radial-gradient(820px 440px at 88% -10%,var(--glow),transparent 62%),var(--background);-webkit-font-smoothing:antialiased}
|
|
1698
|
-
.page{min-height:100vh;display:grid;place-items:center;padding:
|
|
1699
|
-
main{width:min(
|
|
1700
|
-
.
|
|
1701
|
-
.check
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
264
|
+
.page{min-height:100vh;display:grid;place-items:center;padding:28px 18px}
|
|
265
|
+
main{width:min(430px,100%);background:color-mix(in srgb,var(--card) 97%,transparent);border:1px solid var(--border);border-radius:20px;padding:28px;box-shadow:var(--shadow);text-align:center}
|
|
266
|
+
.brand{display:block;width:92px;height:30px;margin:0 auto 20px}.brand img{display:block;width:92px;height:30px;object-fit:contain}
|
|
267
|
+
.check{width:38px;height:38px;margin:0 auto 15px;border-radius:50%;background:color-mix(in srgb,var(--success) 13%,transparent);display:grid;place-items:center}
|
|
268
|
+
.check svg{width:19px;height:19px;stroke:var(--success)}
|
|
269
|
+
h1{margin:0 0 8px;font-size:24px;font-weight:600;letter-spacing:-.03em}p{color:var(--muted);font-size:14px;line-height:1.55;margin:0 0 20px}
|
|
270
|
+
.steps{display:flex;gap:6px;margin:0 0 20px}.step{height:4px;flex:1;border-radius:99px;background:var(--success)}
|
|
271
|
+
a.continue{display:block;border-radius:11px;padding:12px 15px;font-weight:600;letter-spacing:-.016em;background:var(--primary);color:var(--primary-fg);text-decoration:none;box-shadow:0 10px 22px -16px var(--primary)}
|
|
1705
272
|
.note{font-size:12px;color:var(--muted);margin:14px 0 0}
|
|
1706
|
-
|
|
273
|
+
:root{color-scheme:only light;--background:#f8f8fb;--foreground:#1f2028;--card:#fff;--border:#e5e7eb;--muted:#676371;--primary:#6349db;--primary-fg:#fff;--success:#057857}body{background:var(--background)}main{background:#fff;border-radius:12px;box-shadow:none}.steps{display:none}a.continue{box-shadow:none}
|
|
274
|
+
</style></head><body><div class="page"><main><picture class="brand"><img src="/brand/wordmark-black-320.png" alt="Apiosk" width="320" height="103"></picture>
|
|
1707
275
|
<div class="check"><svg viewBox="0 0 24 24" fill="none" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg></div>
|
|
1708
276
|
<h1>You're connected</h1>
|
|
1709
|
-
<p>
|
|
277
|
+
<p>Your Apiosk account is connected. Finishing securely in ${escapeHtml(clientLabel)}.</p>
|
|
1710
278
|
<div class="steps" aria-label="Authorization complete"><span class="step"></span><span class="step"></span><span class="step"></span></div>
|
|
1711
279
|
<a class="continue" id="continue" href="${escapeHtml(redirectTarget)}">Continue to ${escapeHtml(clientLabel)}</a>
|
|
1712
280
|
<p class="note">Returning you to ${escapeHtml(clientLabel)} automatically…</p>
|
|
1713
281
|
</main></div><script>setTimeout(()=>{window.location.replace(${JSON.stringify(redirectTarget).replaceAll("<", "\\u003c")})},1200);</script></body></html>`;
|
|
1714
282
|
}
|
|
1715
283
|
|
|
1716
|
-
function resolveMcpWalletAuthConfig(env = process.env) {
|
|
1717
|
-
const supabaseUrl = normalizeBaseUrl(
|
|
1718
|
-
env.APIOSK_SUPABASE_URL || env.SUPABASE_URL,
|
|
1719
|
-
DEFAULT_SUPABASE_URL
|
|
1720
|
-
);
|
|
1721
|
-
const key =
|
|
1722
|
-
trimString(env.APIOSK_SUPABASE_SERVICE_ROLE_KEY) ||
|
|
1723
|
-
trimString(env.SUPABASE_SERVICE_ROLE_KEY) ||
|
|
1724
|
-
trimString(env.SUPABASE_SERVICE_KEY) ||
|
|
1725
|
-
trimString(env.APIOSK_SUPABASE_PUBLISHABLE_KEY) ||
|
|
1726
|
-
trimString(env.APIOSK_SUPABASE_ANON_KEY) ||
|
|
1727
|
-
trimString(env.SUPABASE_PUBLISHABLE_KEY) ||
|
|
1728
|
-
trimString(env.SUPABASE_ANON_KEY);
|
|
1729
|
-
const walletAuthUrl = normalizeBaseUrl(
|
|
1730
|
-
env.APIOSK_WALLET_AUTH_URL || env.APIOSK_WALLET_AUTH_BASE_URL,
|
|
1731
|
-
`${supabaseUrl}/functions/v1/wallet-auth`
|
|
1732
|
-
);
|
|
1733
|
-
|
|
1734
|
-
return {
|
|
1735
|
-
configured: Boolean(supabaseUrl && key && walletAuthUrl),
|
|
1736
|
-
key,
|
|
1737
|
-
supabaseUrl,
|
|
1738
|
-
walletAuthUrl,
|
|
1739
|
-
};
|
|
1740
|
-
}
|
|
1741
|
-
|
|
1742
|
-
export function isMcpWalletAuthConfigured(env = process.env) {
|
|
1743
|
-
return resolveMcpWalletAuthConfig(env).configured;
|
|
1744
|
-
}
|
|
1745
|
-
|
|
1746
|
-
function resolveWalletConnectProjectId(env = process.env) {
|
|
1747
|
-
return (
|
|
1748
|
-
trimString(env.APIOSK_MCP_WALLETCONNECT_PROJECT_ID) ||
|
|
1749
|
-
trimString(env.WALLETCONNECT_PROJECT_ID)
|
|
1750
|
-
);
|
|
1751
|
-
}
|
|
1752
|
-
|
|
1753
284
|
async function fetchJsonWithBody(url, options) {
|
|
1754
285
|
const response = await fetch(url, options);
|
|
1755
286
|
const text = await response.text();
|
|
@@ -1767,119 +298,98 @@ async function fetchJsonWithBody(url, options) {
|
|
|
1767
298
|
};
|
|
1768
299
|
}
|
|
1769
300
|
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
const response = await fetchJsonWithBody(`${config.walletAuthUrl}/${path}`, {
|
|
1777
|
-
method: "POST",
|
|
1778
|
-
headers: {
|
|
1779
|
-
accept: "application/json",
|
|
1780
|
-
apikey: config.key,
|
|
1781
|
-
authorization: `Bearer ${config.key}`,
|
|
1782
|
-
"content-type": "application/json",
|
|
1783
|
-
},
|
|
1784
|
-
body: JSON.stringify(payload),
|
|
1785
|
-
cache: "no-store",
|
|
1786
|
-
});
|
|
1787
|
-
|
|
1788
|
-
if (!response.ok) {
|
|
1789
|
-
throw statusError(
|
|
1790
|
-
responseMessage(response.body, "Wallet sign-in is temporarily unavailable."),
|
|
1791
|
-
response.status
|
|
1792
|
-
);
|
|
1793
|
-
}
|
|
1794
|
-
|
|
1795
|
-
return response.body && typeof response.body === "object" ? response.body : {};
|
|
301
|
+
function resolveGatewayBaseUrl(env = process.env) {
|
|
302
|
+
return normalizeBaseUrl(
|
|
303
|
+
env?.APIOSK_GATEWAY_URL || env?.APIOSK_GATEWAY_BASE_URL,
|
|
304
|
+
DEFAULT_GATEWAY_URL
|
|
305
|
+
);
|
|
1796
306
|
}
|
|
1797
307
|
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
308
|
+
/**
|
|
309
|
+
* Where the browser is sent to approve: the agent gateway's own authorize
|
|
310
|
+
* endpoint, which validates the request and redirects on to
|
|
311
|
+
* `app.apiosk.com/connect`.
|
|
312
|
+
*
|
|
313
|
+
* IT IS THE GATEWAY BASE AND NOTHING ELSE. `APIOSK_BUYER_PORTAL_URL` used to
|
|
314
|
+
* name a frontend here and is deliberately no longer read: a deploy that still
|
|
315
|
+
* sets it to `buy.apiosk.com` would otherwise be sent to
|
|
316
|
+
* `buy.apiosk.com/v1/oauth/authorize`, which is not a page, and a wrong host
|
|
317
|
+
* that answers 404 is worse than one that was never consulted. A deploy that
|
|
318
|
+
* needs to move this moves `APIOSK_GATEWAY_URL`, which moves the token
|
|
319
|
+
* endpoint with it - and those two must agree or the code will not redeem.
|
|
320
|
+
*/
|
|
321
|
+
function resolveAuthorizeBaseUrl(env = process.env) {
|
|
322
|
+
return resolveGatewayBaseUrl(env);
|
|
1805
323
|
}
|
|
1806
324
|
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
325
|
+
/**
|
|
326
|
+
* One request to the agent gateway's token endpoint, and the reading of its
|
|
327
|
+
* answer.
|
|
328
|
+
*
|
|
329
|
+
* The endpoint takes a form or JSON (`readForm` in the gateway's oauth.ts
|
|
330
|
+
* accepts both), and answers the same shape for both grants this server uses.
|
|
331
|
+
* No wallet key, no on-chain transaction and no x402 payload is ever
|
|
332
|
+
* constructed here — settlement belongs to the gateway.
|
|
333
|
+
*
|
|
334
|
+
* THE REFRESH TOKEN IS THE PART THAT IS NEW AND THE PART THAT MATTERS. The
|
|
335
|
+
* agent gateway's access tokens live 24 hours; this server's own live one.
|
|
336
|
+
* Kept only the access token, a connection would work for a day and then ask
|
|
337
|
+
* the person to approve something they had already approved. The refresh token
|
|
338
|
+
* rotates on every use, so what is held is at most a day old.
|
|
339
|
+
*/
|
|
340
|
+
async function requestUpstreamToken(env, grant, failureMessage) {
|
|
341
|
+
const url = `${resolveGatewayBaseUrl(env)}/v1/oauth/token`;
|
|
342
|
+
const response = await fetchJsonWithBody(url, {
|
|
1814
343
|
method: "POST",
|
|
1815
|
-
headers: {
|
|
1816
|
-
|
|
1817
|
-
apikey: config.key,
|
|
1818
|
-
authorization: `Bearer ${config.key}`,
|
|
1819
|
-
"content-type": "application/json",
|
|
1820
|
-
},
|
|
1821
|
-
body: JSON.stringify({
|
|
1822
|
-
token_hash: tokenHash,
|
|
1823
|
-
type: "magiclink",
|
|
1824
|
-
}),
|
|
1825
|
-
cache: "no-store",
|
|
344
|
+
headers: { accept: "application/json", "content-type": "application/json" },
|
|
345
|
+
body: JSON.stringify({ ...grant, client_id: PORTAL_CLIENT_ID }),
|
|
1826
346
|
});
|
|
1827
347
|
|
|
1828
348
|
if (!response.ok) {
|
|
1829
349
|
throw statusError(
|
|
1830
|
-
responseMessage(response.body,
|
|
1831
|
-
response.status
|
|
350
|
+
responseMessage(response.body, failureMessage),
|
|
351
|
+
response.status >= 400 ? response.status : 502
|
|
1832
352
|
);
|
|
1833
353
|
}
|
|
1834
354
|
|
|
1835
355
|
const body = response.body && typeof response.body === "object" ? response.body : {};
|
|
1836
|
-
const
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
if (!sessionToken) {
|
|
1840
|
-
throw statusError("Wallet sign-in did not return a dashboard session.", 502);
|
|
356
|
+
const connectToken = trimString(body.access_token);
|
|
357
|
+
if (!connectToken) {
|
|
358
|
+
throw statusError("The gateway did not return an access token.", 502);
|
|
1841
359
|
}
|
|
1842
360
|
|
|
1843
|
-
const expiresAt =
|
|
1844
|
-
Number(session.expires_at) ||
|
|
1845
|
-
(Number.isFinite(Number(session.expires_in))
|
|
1846
|
-
? getIssuedAtSeconds() + Number(session.expires_in)
|
|
1847
|
-
: null);
|
|
1848
|
-
|
|
1849
361
|
return {
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
email: trimString(user.email),
|
|
362
|
+
connectToken,
|
|
363
|
+
refreshToken: trimString(body.refresh_token) || null,
|
|
364
|
+
expiresInSeconds: Number.isFinite(Number(body.expires_in)) ? Number(body.expires_in) : null,
|
|
1854
365
|
};
|
|
1855
366
|
}
|
|
1856
367
|
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
throw statusError("Wallet sign-in did not return a session token.", 502);
|
|
1875
|
-
}
|
|
368
|
+
/**
|
|
369
|
+
* Redeem the one-time code the approval screen produced, server side.
|
|
370
|
+
*
|
|
371
|
+
* Injectable so tests can bypass the network call.
|
|
372
|
+
*/
|
|
373
|
+
async function defaultExchangePortalCode(env, { code, codeVerifier, redirectUri }) {
|
|
374
|
+
return await requestUpstreamToken(
|
|
375
|
+
env,
|
|
376
|
+
{
|
|
377
|
+
grant_type: "authorization_code",
|
|
378
|
+
code,
|
|
379
|
+
code_verifier: codeVerifier,
|
|
380
|
+
redirect_uri: redirectUri,
|
|
381
|
+
},
|
|
382
|
+
"Could not exchange the approval code for an access token."
|
|
383
|
+
);
|
|
384
|
+
}
|
|
1876
385
|
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
386
|
+
/** Trade a rotating upstream refresh token for a fresh pair. */
|
|
387
|
+
async function defaultRefreshPortalToken(env, { refreshToken }) {
|
|
388
|
+
return await requestUpstreamToken(
|
|
389
|
+
env,
|
|
390
|
+
{ grant_type: "refresh_token", refresh_token: refreshToken },
|
|
391
|
+
"Could not refresh the Apiosk connection."
|
|
392
|
+
);
|
|
1883
393
|
}
|
|
1884
394
|
|
|
1885
395
|
class ApioskOAuthClientsStore {
|
|
@@ -1983,9 +493,8 @@ class ApioskHostedOAuthProvider {
|
|
|
1983
493
|
mcpServerUrl,
|
|
1984
494
|
appName,
|
|
1985
495
|
resourceName,
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
requirePaymentAuthorization,
|
|
496
|
+
exchangePortalCode,
|
|
497
|
+
refreshPortalToken,
|
|
1989
498
|
}) {
|
|
1990
499
|
this.env = env;
|
|
1991
500
|
this.secret = secret;
|
|
@@ -1993,16 +502,11 @@ class ApioskHostedOAuthProvider {
|
|
|
1993
502
|
this.mcpServerUrl = mcpServerUrl;
|
|
1994
503
|
this.appName = appName;
|
|
1995
504
|
this.resourceName = resourceName;
|
|
1996
|
-
// Injectable so tests can bypass the
|
|
1997
|
-
|
|
1998
|
-
this.
|
|
1999
|
-
this.payableWalletLister = payableWalletLister || prepareHostedPaymentWallets;
|
|
2000
|
-
// Production always uses the explicit two-step consent screen. Existing
|
|
2001
|
-
// test/custom minters keep their legacy single-step behavior unless they
|
|
2002
|
-
// explicitly opt in, so this remains an additive integration surface.
|
|
2003
|
-
this.requirePaymentAuthorization =
|
|
2004
|
-
requirePaymentAuthorization ?? !connectTokenMinter;
|
|
505
|
+
// Injectable so tests can bypass the network call to the gateway.
|
|
506
|
+
this.exchangePortalCode = exchangePortalCode || defaultExchangePortalCode;
|
|
507
|
+
this.refreshPortalToken = refreshPortalToken || defaultRefreshPortalToken;
|
|
2005
508
|
this.clientsStore = new ApioskOAuthClientsStore(secret);
|
|
509
|
+
this.callbackUrl = new URL("/authorize/callback", this.issuerUrl).href;
|
|
2006
510
|
// Audiences we honour on an access token. A client that connected via
|
|
2007
511
|
// /sse (ChatGPT) requests resource=<origin>/sse; one via /mcp requests
|
|
2008
512
|
// <origin>/mcp. The origin root is accepted for clients that omit the
|
|
@@ -2021,206 +525,164 @@ class ApioskHostedOAuthProvider {
|
|
|
2021
525
|
return this.allowedResources.has(normalized) || this.allowedResources.has(resourceHref);
|
|
2022
526
|
}
|
|
2023
527
|
|
|
528
|
+
/**
|
|
529
|
+
* Start the handoff to the agent gateway, which approves at
|
|
530
|
+
* `app.apiosk.com/connect`.
|
|
531
|
+
*
|
|
532
|
+
* Identity, funding and spending limits all live there — this server never
|
|
533
|
+
* renders a sign-in page and never sees a wallet key. It starts an OAuth 2.0
|
|
534
|
+
* authorization code request with PKCE of its own and stashes the ORIGINAL
|
|
535
|
+
* request (from Claude, ChatGPT, ...) in a signed `state` so
|
|
536
|
+
* `completePortalCallback` can pick the flow back up when the browser comes
|
|
537
|
+
* back.
|
|
538
|
+
*
|
|
539
|
+
* `name` rather than `app_name`: that is what `startAuthorization` reads and
|
|
540
|
+
* carries to the approval screen as the suggested connection name, which the
|
|
541
|
+
* person can edit there.
|
|
542
|
+
*/
|
|
2024
543
|
async authorize(client, params, res) {
|
|
2025
|
-
const
|
|
2026
|
-
const
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
544
|
+
const verifier = crypto.randomBytes(48).toString("base64url");
|
|
545
|
+
const challenge = crypto.createHash("sha256").update(verifier).digest("base64url");
|
|
546
|
+
// Display the runtime that owns the validated callback, rather than the
|
|
547
|
+
// shared Apiosk transport client. Client-supplied names are not identity.
|
|
548
|
+
const callback = new URL(params.redirectUri);
|
|
549
|
+
const provider = callback.protocol === "https:" ? ({
|
|
550
|
+
"claude.ai": "anthropic",
|
|
551
|
+
"chatgpt.com": "openai",
|
|
552
|
+
"chat.openai.com": "openai",
|
|
553
|
+
})[callback.hostname] : undefined;
|
|
554
|
+
|
|
555
|
+
const handoffState = buildIssuedToken(
|
|
556
|
+
this.secret,
|
|
557
|
+
"portal_handoff",
|
|
558
|
+
{
|
|
559
|
+
verifier,
|
|
560
|
+
clientId: client.client_id,
|
|
561
|
+
redirectUri: params.redirectUri,
|
|
562
|
+
codeChallenge: params.codeChallenge,
|
|
563
|
+
state: params.state || null,
|
|
564
|
+
scopes: params.scopes?.length ? params.scopes : [DEFAULT_SCOPE, OFFLINE_ACCESS_SCOPE],
|
|
565
|
+
resource: params.resource ? params.resource.href : this.mcpServerUrl.href,
|
|
566
|
+
},
|
|
567
|
+
PORTAL_HANDOFF_TTL_SECONDS
|
|
568
|
+
).token;
|
|
569
|
+
|
|
570
|
+
// Appended, not resolved as a root-relative path: the agent gateway lives
|
|
571
|
+
// UNDER a path (`/functions/v1/agent-gateway`), and `new URL("/v1/...",
|
|
572
|
+
// base)` would throw that path away and address the project root.
|
|
573
|
+
const portalUrl = buildRedirectUri(
|
|
574
|
+
`${resolveAuthorizeBaseUrl(this.env)}/v1/oauth/authorize`,
|
|
575
|
+
{
|
|
576
|
+
client_id: PORTAL_CLIENT_ID,
|
|
577
|
+
redirect_uri: this.callbackUrl,
|
|
578
|
+
response_type: "code",
|
|
579
|
+
code_challenge: challenge,
|
|
580
|
+
// Required, and refused rather than assumed: `startAuthorization`
|
|
581
|
+
// answers `plain` with an error instead of downgrading.
|
|
582
|
+
code_challenge_method: "S256",
|
|
583
|
+
state: handoffState,
|
|
584
|
+
name: trimString(client.client_name) || undefined,
|
|
585
|
+
provider,
|
|
586
|
+
}
|
|
587
|
+
);
|
|
588
|
+
|
|
589
|
+
res.redirect(302, portalUrl);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* GET /authorize/callback — buy.apiosk.com sends the browser back here once
|
|
594
|
+
* the buyer signs in, funds a wallet, sets limits and consents. Exchanges
|
|
595
|
+
* the one-time code for a connect token server side (RFC 6749 §4.1.3), so
|
|
596
|
+
* the token never enters this browser's address bar or its history.
|
|
597
|
+
*/
|
|
598
|
+
async completePortalCallback(req, res) {
|
|
599
|
+
const query = req.query || {};
|
|
600
|
+
const stateToken = trimString(query.state);
|
|
601
|
+
|
|
602
|
+
let handoff;
|
|
603
|
+
try {
|
|
604
|
+
handoff = parseSignedToken(this.secret, stateToken);
|
|
605
|
+
if (handoff.typ !== "portal_handoff") {
|
|
606
|
+
throw new Error("Invalid state");
|
|
607
|
+
}
|
|
608
|
+
} catch {
|
|
2043
609
|
res
|
|
2044
|
-
.status(
|
|
2045
|
-
.
|
|
2046
|
-
.send(
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
...options,
|
|
2054
|
-
})
|
|
2055
|
-
);
|
|
2056
|
-
|
|
2057
|
-
if (!req || req.method !== "POST" || !submittedAction) {
|
|
2058
|
-
renderPage(200);
|
|
610
|
+
.status(400)
|
|
611
|
+
.type("text/plain")
|
|
612
|
+
.send("This sign-in link is invalid or has expired. Start again from your MCP client.");
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
const client = await this.clientsStore.getClient(handoff.clientId);
|
|
617
|
+
if (!client) {
|
|
618
|
+
res.status(400).type("text/plain").send("Unknown client. Start again from your MCP client.");
|
|
2059
619
|
return;
|
|
2060
620
|
}
|
|
2061
621
|
|
|
2062
|
-
|
|
622
|
+
const params = {
|
|
623
|
+
redirectUri: handoff.redirectUri,
|
|
624
|
+
codeChallenge: handoff.codeChallenge,
|
|
625
|
+
state: handoff.state,
|
|
626
|
+
scopes: handoff.scopes,
|
|
627
|
+
resource: handoff.resource ? new URL(handoff.resource) : new URL(this.mcpServerUrl.href),
|
|
628
|
+
};
|
|
629
|
+
|
|
630
|
+
const errorCode = trimString(query.error);
|
|
631
|
+
if (errorCode) {
|
|
2063
632
|
res.redirect(
|
|
2064
633
|
302,
|
|
2065
634
|
buildRedirectUri(params.redirectUri, {
|
|
2066
|
-
error:
|
|
2067
|
-
error_description:
|
|
635
|
+
error: errorCode,
|
|
636
|
+
error_description:
|
|
637
|
+
trimString(query.error_description) || "The buyer did not complete the connection.",
|
|
2068
638
|
state: params.state,
|
|
2069
639
|
})
|
|
2070
640
|
);
|
|
2071
641
|
return;
|
|
2072
642
|
}
|
|
2073
643
|
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
// application/x-www-form-urlencoded POST normalizes every "\n" in a
|
|
2078
|
-
// control's value to "\r\n" (HTML form-submission spec). Those extra
|
|
2079
|
-
// bytes change the EIP-191 digest, so server-side signature recovery
|
|
2080
|
-
// returns a different address and the wallet-auth function rejects it
|
|
2081
|
-
// with "Signature does not match the wallet." To keep the signed bytes
|
|
2082
|
-
// intact across the POST, the client base64url-encodes the message and
|
|
2083
|
-
// we decode it here before verification.
|
|
2084
|
-
const message =
|
|
2085
|
-
trimString(req.body.wallet_message_encoding).toLowerCase() === "base64url"
|
|
2086
|
-
? fromBase64Url(trimString(req.body.wallet_message))
|
|
2087
|
-
: req.body.wallet_message;
|
|
2088
|
-
const session = await verifyWalletDashboardSession(this.env, {
|
|
2089
|
-
address: req.body.wallet_address,
|
|
2090
|
-
message,
|
|
2091
|
-
signature: req.body.wallet_signature,
|
|
2092
|
-
method: req.body.wallet_method,
|
|
2093
|
-
});
|
|
2094
|
-
if (!this.requirePaymentAuthorization) {
|
|
2095
|
-
await this.finishAuthorization(res, client, params, session);
|
|
2096
|
-
return;
|
|
2097
|
-
}
|
|
2098
|
-
const wallets = await this.payableWalletLister({
|
|
2099
|
-
env: this.env,
|
|
2100
|
-
sessionToken: trimString(session.session_token),
|
|
2101
|
-
userId: trimString(session.user_id),
|
|
2102
|
-
connectedAddress: trimString(session.wallet_address),
|
|
2103
|
-
});
|
|
2104
|
-
if (!wallets.length) {
|
|
2105
|
-
renderPage(400, {
|
|
2106
|
-
errorMessage:
|
|
2107
|
-
"Your identity is verified, but this account has no active managed payment wallet. Create and fund a managed Apiosk wallet, then connect again. A browser wallet alone cannot authorize unattended payments.",
|
|
2108
|
-
});
|
|
2109
|
-
return;
|
|
2110
|
-
}
|
|
2111
|
-
const pendingAuthorization = buildIssuedToken(
|
|
2112
|
-
this.secret,
|
|
2113
|
-
"payment_authorization",
|
|
2114
|
-
{
|
|
2115
|
-
clientId: client.client_id,
|
|
2116
|
-
redirectUri: params.redirectUri,
|
|
2117
|
-
session,
|
|
2118
|
-
},
|
|
2119
|
-
AUTHORIZATION_CODE_TTL_SECONDS,
|
|
2120
|
-
normalizeSessionExpiry(session.expires_at)
|
|
2121
|
-
).token;
|
|
2122
|
-
const settlement = hostedSettlementPublicConfig(this.env);
|
|
2123
|
-
if (!settlement && wallets.some((wallet) => wallet.requiresApproval)) {
|
|
2124
|
-
throw statusError("Connected-wallet payments are not configured on this server.", 503);
|
|
2125
|
-
}
|
|
2126
|
-
renderPaymentPage(200, { pendingAuthorization, wallets, settlement });
|
|
2127
|
-
} catch (error) {
|
|
2128
|
-
renderPage(error.status && error.status >= 400 ? error.status : 400, {
|
|
2129
|
-
errorMessage:
|
|
2130
|
-
error instanceof Error
|
|
2131
|
-
? error.message
|
|
2132
|
-
: "Could not sign in with this wallet. Try again.",
|
|
2133
|
-
});
|
|
2134
|
-
}
|
|
644
|
+
const code = trimString(query.code);
|
|
645
|
+
if (!code) {
|
|
646
|
+
res.status(400).type("text/plain").send("The portal did not return a code.");
|
|
2135
647
|
return;
|
|
2136
648
|
}
|
|
2137
649
|
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
)
|
|
2149
|
-
|
|
2150
|
-
}
|
|
2151
|
-
if (trimString(req.body.payment_consent) !== "yes") {
|
|
2152
|
-
throw statusError("Confirm the payment authorization to continue.", 400);
|
|
2153
|
-
}
|
|
2154
|
-
wallets = await this.payableWalletLister({
|
|
2155
|
-
env: this.env,
|
|
2156
|
-
sessionToken: trimString(pending.session.session_token),
|
|
2157
|
-
userId: trimString(pending.session.user_id),
|
|
2158
|
-
connectedAddress: trimString(pending.session.wallet_address),
|
|
2159
|
-
});
|
|
2160
|
-
const mintedConnect = await this.connectTokenMinter({
|
|
2161
|
-
env: this.env,
|
|
2162
|
-
sessionToken: trimString(pending.session.session_token),
|
|
2163
|
-
userId: trimString(pending.session.user_id),
|
|
2164
|
-
walletId: trimString(req.body.managed_wallet_id),
|
|
2165
|
-
dailyLimitUsdc: req.body.daily_limit_usdc,
|
|
2166
|
-
perTxLimitUsdc: req.body.per_tx_limit_usdc,
|
|
2167
|
-
approvalTxHash: req.body.approval_tx_hash,
|
|
2168
|
-
strict: true,
|
|
2169
|
-
});
|
|
2170
|
-
if (!mintedConnect?.connectToken) {
|
|
2171
|
-
throw statusError("Could not create the scoped Apiosk payment token. Try again.", 502);
|
|
2172
|
-
}
|
|
2173
|
-
await this.finishAuthorization(res, client, params, pending.session, mintedConnect, {
|
|
2174
|
-
successPage: true,
|
|
2175
|
-
});
|
|
2176
|
-
} catch (error) {
|
|
2177
|
-
if (!wallets.length) {
|
|
2178
|
-
renderPage(error.status && error.status >= 400 ? error.status : 400, {
|
|
2179
|
-
errorMessage: error instanceof Error ? error.message : "Payment authorization failed.",
|
|
2180
|
-
});
|
|
2181
|
-
} else {
|
|
2182
|
-
renderPaymentPage(error.status && error.status >= 400 ? error.status : 400, {
|
|
2183
|
-
pendingAuthorization,
|
|
2184
|
-
wallets,
|
|
2185
|
-
settlement: hostedSettlementPublicConfig(this.env),
|
|
2186
|
-
errorMessage: error instanceof Error ? error.message : "Payment authorization failed.",
|
|
2187
|
-
});
|
|
2188
|
-
}
|
|
2189
|
-
}
|
|
650
|
+
let exchange;
|
|
651
|
+
try {
|
|
652
|
+
exchange = await this.exchangePortalCode(this.env, {
|
|
653
|
+
code,
|
|
654
|
+
codeVerifier: handoff.verifier,
|
|
655
|
+
redirectUri: this.callbackUrl,
|
|
656
|
+
});
|
|
657
|
+
} catch (error) {
|
|
658
|
+
res
|
|
659
|
+
.status(error?.status && error.status >= 400 ? error.status : 502)
|
|
660
|
+
.type("text/plain")
|
|
661
|
+
.send(error instanceof Error ? error.message : "Could not finish connecting to Apiosk.");
|
|
2190
662
|
return;
|
|
2191
663
|
}
|
|
2192
664
|
|
|
2193
|
-
|
|
2194
|
-
errorMessage: "Sign in with a wallet to continue.",
|
|
2195
|
-
});
|
|
665
|
+
await this.finishAuthorization(res, client, params, exchange);
|
|
2196
666
|
}
|
|
2197
667
|
|
|
2198
|
-
async finishAuthorization(
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
env: this.env,
|
|
2217
|
-
sessionToken,
|
|
2218
|
-
userId: trimString(session.user_id),
|
|
2219
|
-
})
|
|
2220
|
-
: null);
|
|
2221
|
-
const apioskConnectToken = trimString(mintedConnect?.connectToken) || undefined;
|
|
2222
|
-
const apioskConnectWalletAddress =
|
|
2223
|
-
trimString(mintedConnect?.walletAddress) || undefined;
|
|
668
|
+
async finishAuthorization(res, client, params, exchange) {
|
|
669
|
+
const connectToken = trimString(exchange.connectToken);
|
|
670
|
+
const upstreamRefresh = trimString(exchange.refreshToken);
|
|
671
|
+
const upstreamExpiresAt = Number.isFinite(exchange.expiresInSeconds)
|
|
672
|
+
? getIssuedAtSeconds() + exchange.expiresInSeconds
|
|
673
|
+
: null;
|
|
674
|
+
|
|
675
|
+
/**
|
|
676
|
+
* The cap, and when there is not one.
|
|
677
|
+
*
|
|
678
|
+
* This server's tokens may never outlive what they carry, so an upstream
|
|
679
|
+
* access token with no way to renew it caps everything issued from it.
|
|
680
|
+
* HOLDING A REFRESH TOKEN REMOVES THE CAP: the upstream access token
|
|
681
|
+
* expiring is then a thing this server fixes on the next refresh rather
|
|
682
|
+
* than a thing the person fixes by approving again. Without this the whole
|
|
683
|
+
* connection died every 24 hours, which is the agent gateway's access TTL.
|
|
684
|
+
*/
|
|
685
|
+
const maxExpiry = upstreamRefresh ? null : upstreamExpiresAt;
|
|
2224
686
|
|
|
2225
687
|
const authorizationCode = buildIssuedToken(
|
|
2226
688
|
this.secret,
|
|
@@ -2231,16 +693,12 @@ class ApioskHostedOAuthProvider {
|
|
|
2231
693
|
codeChallenge: params.codeChallenge,
|
|
2232
694
|
scopes: params.scopes?.length ? params.scopes : [DEFAULT_SCOPE, OFFLINE_ACCESS_SCOPE],
|
|
2233
695
|
resource: params.resource ? params.resource.href : this.mcpServerUrl.href,
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
email: trimString(session.email),
|
|
2238
|
-
walletAddress: trimString(session.wallet_address) || undefined,
|
|
2239
|
-
apioskConnectToken,
|
|
2240
|
-
apioskConnectWalletAddress,
|
|
696
|
+
apioskConnectToken: connectToken || undefined,
|
|
697
|
+
apioskConnectTokenExpiresAt: upstreamExpiresAt || undefined,
|
|
698
|
+
apioskRefreshToken: upstreamRefresh || undefined,
|
|
2241
699
|
},
|
|
2242
700
|
AUTHORIZATION_CODE_TTL_SECONDS,
|
|
2243
|
-
|
|
701
|
+
maxExpiry
|
|
2244
702
|
).token;
|
|
2245
703
|
|
|
2246
704
|
const redirectTarget = buildRedirectUri(params.redirectUri, {
|
|
@@ -2248,52 +706,31 @@ class ApioskHostedOAuthProvider {
|
|
|
2248
706
|
state: params.state,
|
|
2249
707
|
});
|
|
2250
708
|
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
res
|
|
2257
|
-
.status(200)
|
|
2258
|
-
.setHeader("content-type", "text/html; charset=utf-8")
|
|
2259
|
-
.send(
|
|
2260
|
-
createConnectionCompletePage({
|
|
2261
|
-
appName: this.appName,
|
|
2262
|
-
clientName: client,
|
|
2263
|
-
redirectTarget,
|
|
2264
|
-
})
|
|
2265
|
-
);
|
|
2266
|
-
return;
|
|
2267
|
-
}
|
|
2268
|
-
|
|
709
|
+
// Complete the host's OAuth session using a real HTTP redirect. An HTML
|
|
710
|
+
// timer leaves mobile authentication sessions waiting for JavaScript and
|
|
711
|
+
// can race the meta refresh into a second use of a one-time code.
|
|
712
|
+
res.setHeader("Cache-Control", "no-store");
|
|
713
|
+
res.setHeader("Referrer-Policy", "no-referrer");
|
|
2269
714
|
res.redirect(302, redirectTarget);
|
|
2270
715
|
}
|
|
2271
716
|
|
|
2272
717
|
async challengeForAuthorizationCode(client, authorizationCode) {
|
|
2273
|
-
const payload =
|
|
2274
|
-
if (payload.typ !== "code") {
|
|
2275
|
-
throw new Error("Invalid authorization code");
|
|
2276
|
-
}
|
|
2277
|
-
if (payload.clientId !== client.client_id) {
|
|
2278
|
-
throw new Error("Authorization code was not issued to this client");
|
|
2279
|
-
}
|
|
718
|
+
const payload = parseOAuthGrant(this.secret, authorizationCode, "code", client);
|
|
2280
719
|
return payload.codeChallenge;
|
|
2281
720
|
}
|
|
2282
721
|
|
|
2283
722
|
async exchangeAuthorizationCode(client, authorizationCode, _codeVerifier, redirectUri, resource) {
|
|
2284
|
-
const payload =
|
|
2285
|
-
if (payload.typ !== "code") {
|
|
2286
|
-
throw new Error("Invalid authorization code");
|
|
2287
|
-
}
|
|
2288
|
-
if (payload.clientId !== client.client_id) {
|
|
2289
|
-
throw new Error("Authorization code was not issued to this client");
|
|
2290
|
-
}
|
|
723
|
+
const payload = parseOAuthGrant(this.secret, authorizationCode, "code", client);
|
|
2291
724
|
if (redirectUri && payload.redirectUri !== redirectUri) {
|
|
2292
|
-
throw new
|
|
725
|
+
throw new InvalidGrantError("redirect_uri does not match the authorization code");
|
|
2293
726
|
}
|
|
2294
727
|
|
|
2295
728
|
const requestedResource = resource ? resource.href : payload.resource || this.mcpServerUrl.href;
|
|
2296
|
-
|
|
729
|
+
// Uncapped when a refresh token came with it. See `finishAuthorization`.
|
|
730
|
+
const maxExpiry =
|
|
731
|
+
!payload.apioskRefreshToken && Number.isFinite(payload.apioskConnectTokenExpiresAt)
|
|
732
|
+
? payload.apioskConnectTokenExpiresAt
|
|
733
|
+
: null;
|
|
2297
734
|
const tokenPayload = {
|
|
2298
735
|
clientId: client.client_id,
|
|
2299
736
|
scopes:
|
|
@@ -2301,13 +738,9 @@ class ApioskHostedOAuthProvider {
|
|
|
2301
738
|
payload.scopes :
|
|
2302
739
|
[DEFAULT_SCOPE, OFFLINE_ACCESS_SCOPE],
|
|
2303
740
|
resource: requestedResource,
|
|
2304
|
-
dashboardSessionToken: payload.dashboardSessionToken,
|
|
2305
|
-
dashboardSessionExpiresAt: payload.dashboardSessionExpiresAt,
|
|
2306
|
-
userId: payload.userId,
|
|
2307
|
-
email: payload.email,
|
|
2308
|
-
walletAddress: payload.walletAddress,
|
|
2309
741
|
apioskConnectToken: payload.apioskConnectToken,
|
|
2310
|
-
|
|
742
|
+
apioskConnectTokenExpiresAt: payload.apioskConnectTokenExpiresAt,
|
|
743
|
+
apioskRefreshToken: payload.apioskRefreshToken,
|
|
2311
744
|
};
|
|
2312
745
|
|
|
2313
746
|
const accessToken = buildIssuedToken(
|
|
@@ -2335,42 +768,87 @@ class ApioskHostedOAuthProvider {
|
|
|
2335
768
|
}
|
|
2336
769
|
|
|
2337
770
|
async exchangeRefreshToken(client, refreshToken, scopes, resource) {
|
|
2338
|
-
const payload =
|
|
2339
|
-
if (payload.typ !== "refresh") {
|
|
2340
|
-
throw new Error("Invalid refresh token");
|
|
2341
|
-
}
|
|
2342
|
-
if (payload.clientId !== client.client_id) {
|
|
2343
|
-
throw new Error("Refresh token was not issued to this client");
|
|
2344
|
-
}
|
|
771
|
+
const payload = parseOAuthGrant(this.secret, refreshToken, "refresh", client);
|
|
2345
772
|
|
|
2346
773
|
const grantedScopes =
|
|
2347
774
|
Array.isArray(scopes) && scopes.length ?
|
|
2348
775
|
scopes.filter((scope) => Array.isArray(payload.scopes) && payload.scopes.includes(scope)) :
|
|
2349
776
|
payload.scopes;
|
|
2350
777
|
const requestedResource = resource ? resource.href : payload.resource || this.mcpServerUrl.href;
|
|
2351
|
-
|
|
778
|
+
|
|
779
|
+
/**
|
|
780
|
+
* Renew the upstream token when it is spent, before minting one that
|
|
781
|
+
* carries it.
|
|
782
|
+
*
|
|
783
|
+
* THIS IS WHERE A CONNECTION SURVIVES ITS SECOND DAY. The agent gateway's
|
|
784
|
+
* access tokens live 24 hours and this server's live one, so a client that
|
|
785
|
+
* refreshes hourly reaches a point where every token it is handed carries
|
|
786
|
+
* an upstream credential that is already dead — and every tool call fails
|
|
787
|
+
* with a 401 the person can do nothing about except approve again.
|
|
788
|
+
*
|
|
789
|
+
* Renewed a little BEFORE expiry, not at it: a token that dies between
|
|
790
|
+
* being minted here and being used by the next tool call is the same
|
|
791
|
+
* failure arriving less often, which is worse to diagnose than one that
|
|
792
|
+
* arrives reliably.
|
|
793
|
+
*
|
|
794
|
+
* A refusal upstream is not fatal here. The old token may still have life
|
|
795
|
+
* in it, and if it does not, the tool call is where that is reported - with
|
|
796
|
+
* the gateway's own words, to a caller that is asking for something rather
|
|
797
|
+
* than to one that is only renewing.
|
|
798
|
+
*/
|
|
799
|
+
let upstreamToken = payload.apioskConnectToken;
|
|
800
|
+
let upstreamRefresh = payload.apioskRefreshToken;
|
|
801
|
+
let upstreamExpiresAt = payload.apioskConnectTokenExpiresAt;
|
|
802
|
+
const spent =
|
|
803
|
+
Number.isFinite(upstreamExpiresAt) &&
|
|
804
|
+
upstreamExpiresAt - getIssuedAtSeconds() < UPSTREAM_RENEWAL_MARGIN_SECONDS;
|
|
805
|
+
|
|
806
|
+
if (upstreamRefresh && spent) {
|
|
807
|
+
try {
|
|
808
|
+
const renewed = await this.refreshPortalToken(this.env, { refreshToken: upstreamRefresh });
|
|
809
|
+
upstreamToken = trimString(renewed.connectToken) || upstreamToken;
|
|
810
|
+
// Rotating: the one just spent is dead, so keeping it would guarantee
|
|
811
|
+
// the next renewal fails.
|
|
812
|
+
upstreamRefresh = trimString(renewed.refreshToken) || null;
|
|
813
|
+
upstreamExpiresAt = Number.isFinite(renewed.expiresInSeconds)
|
|
814
|
+
? getIssuedAtSeconds() + renewed.expiresInSeconds
|
|
815
|
+
: null;
|
|
816
|
+
} catch {
|
|
817
|
+
// Left as it was. See above.
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
const tokenPayload = {
|
|
822
|
+
clientId: client.client_id,
|
|
823
|
+
scopes: grantedScopes,
|
|
824
|
+
resource: requestedResource,
|
|
825
|
+
apioskConnectToken: upstreamToken,
|
|
826
|
+
apioskConnectTokenExpiresAt: upstreamExpiresAt,
|
|
827
|
+
apioskRefreshToken: upstreamRefresh,
|
|
828
|
+
};
|
|
829
|
+
// Uncapped when a refresh token remains. See `finishAuthorization`.
|
|
830
|
+
const maxExpiry =
|
|
831
|
+
!upstreamRefresh && Number.isFinite(upstreamExpiresAt) ? upstreamExpiresAt : null;
|
|
2352
832
|
const accessToken = buildIssuedToken(
|
|
2353
833
|
this.secret,
|
|
2354
834
|
"access",
|
|
2355
|
-
|
|
2356
|
-
clientId: client.client_id,
|
|
2357
|
-
scopes: grantedScopes,
|
|
2358
|
-
resource: requestedResource,
|
|
2359
|
-
dashboardSessionToken: payload.dashboardSessionToken,
|
|
2360
|
-
dashboardSessionExpiresAt: payload.dashboardSessionExpiresAt,
|
|
2361
|
-
userId: payload.userId,
|
|
2362
|
-
email: payload.email,
|
|
2363
|
-
walletAddress: payload.walletAddress,
|
|
2364
|
-
apioskConnectToken: payload.apioskConnectToken,
|
|
2365
|
-
apioskConnectWalletAddress: payload.apioskConnectWalletAddress,
|
|
2366
|
-
},
|
|
835
|
+
tokenPayload,
|
|
2367
836
|
ACCESS_TOKEN_TTL_SECONDS,
|
|
2368
837
|
maxExpiry
|
|
2369
838
|
);
|
|
2370
839
|
|
|
2371
840
|
return {
|
|
2372
841
|
access_token: accessToken.token,
|
|
2373
|
-
|
|
842
|
+
// Reissued rather than returned, because the upstream token it carries
|
|
843
|
+
// may have just changed. Handing back the one that came in would hand
|
|
844
|
+
// back a rotated-away upstream refresh token with it.
|
|
845
|
+
refresh_token: buildIssuedToken(
|
|
846
|
+
this.secret,
|
|
847
|
+
"refresh",
|
|
848
|
+
tokenPayload,
|
|
849
|
+
REFRESH_TOKEN_TTL_SECONDS,
|
|
850
|
+
maxExpiry
|
|
851
|
+
).token,
|
|
2374
852
|
token_type: "bearer",
|
|
2375
853
|
expires_in: Math.max(1, accessToken.expiresAt - getIssuedAtSeconds()),
|
|
2376
854
|
scope: Array.isArray(grantedScopes) ? grantedScopes.join(" ") : DEFAULT_SCOPE,
|
|
@@ -2389,15 +867,6 @@ class ApioskHostedOAuthProvider {
|
|
|
2389
867
|
return this.verifyConnectTokenAccess(trimmed);
|
|
2390
868
|
}
|
|
2391
869
|
|
|
2392
|
-
// Accept Apiosk provider API keys (sk_live_…) as bearer-equivalent. This
|
|
2393
|
-
// is the publisher path: a coding agent configures
|
|
2394
|
-
// `Authorization: Bearer sk_live_…` in its MCP client and can publish
|
|
2395
|
-
// x402 routes without an interactive OAuth handshake. Verified against
|
|
2396
|
-
// the provider_api_keys table (verify_provider_api_key RPC).
|
|
2397
|
-
if (isProviderApiKey(trimmed)) {
|
|
2398
|
-
return this.verifyProviderKeyAccess(trimmed);
|
|
2399
|
-
}
|
|
2400
|
-
|
|
2401
870
|
const payload = parseSignedToken(this.secret, token);
|
|
2402
871
|
if (payload.typ !== "access") {
|
|
2403
872
|
throw new Error("Invalid access token");
|
|
@@ -2414,45 +883,11 @@ class ApioskHostedOAuthProvider {
|
|
|
2414
883
|
expiresAt: payload.exp,
|
|
2415
884
|
resource: payload.resource ? new URL(payload.resource) : new URL(this.mcpServerUrl.href),
|
|
2416
885
|
extra: {
|
|
2417
|
-
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
email: payload.email,
|
|
2422
|
-
walletAddress: payload.walletAddress,
|
|
2423
|
-
// Managed-wallet connect token minted at sign-in. The runtime threads
|
|
2424
|
-
// this to the gateway as X-Apiosk-Connect-Token so paid calls settle
|
|
2425
|
-
// autonomously from the buyer's managed wallet (runtime getClient reads
|
|
2426
|
-
// extra.apiosk_connect_token).
|
|
886
|
+
// Connect token minted by the buyer portal's OAuth handoff. The
|
|
887
|
+
// runtime threads this to the gateway as X-Apiosk-Connect-Token so
|
|
888
|
+
// paid calls settle from the buyer's own wallet (runtime getClient
|
|
889
|
+
// reads extra.apiosk_connect_token).
|
|
2427
890
|
apiosk_connect_token: payload.apioskConnectToken,
|
|
2428
|
-
apiosk_connect_wallet_address: payload.apioskConnectWalletAddress,
|
|
2429
|
-
},
|
|
2430
|
-
};
|
|
2431
|
-
}
|
|
2432
|
-
|
|
2433
|
-
async verifyProviderKeyAccess(providerKey) {
|
|
2434
|
-
// verifyProviderKey caches successful lookups for 60s, so bursts of tool
|
|
2435
|
-
// calls don't hammer the verify RPC while revocations in the provider
|
|
2436
|
-
// portal still take effect within a minute.
|
|
2437
|
-
let context;
|
|
2438
|
-
try {
|
|
2439
|
-
context = await verifyProviderKey(providerKey, { env: this.env });
|
|
2440
|
-
} catch (error) {
|
|
2441
|
-
throw new Error(
|
|
2442
|
-
error instanceof Error ? error.message : "Invalid Apiosk provider token"
|
|
2443
|
-
);
|
|
2444
|
-
}
|
|
2445
|
-
|
|
2446
|
-
return {
|
|
2447
|
-
token: providerKey,
|
|
2448
|
-
clientId: `provider:${context.ownerId}`,
|
|
2449
|
-
scopes: [DEFAULT_SCOPE],
|
|
2450
|
-
expiresAt: Math.floor(Date.now() / 1000) + 60,
|
|
2451
|
-
resource: new URL(this.mcpServerUrl.href),
|
|
2452
|
-
extra: {
|
|
2453
|
-
apiosk_provider_key: providerKey,
|
|
2454
|
-
apiosk_provider_owner_id: context.ownerId,
|
|
2455
|
-
apiosk_provider_key_label: context.label,
|
|
2456
891
|
},
|
|
2457
892
|
};
|
|
2458
893
|
}
|
|
@@ -2464,11 +899,7 @@ class ApioskHostedOAuthProvider {
|
|
|
2464
899
|
return cached.auth;
|
|
2465
900
|
}
|
|
2466
901
|
|
|
2467
|
-
const
|
|
2468
|
-
trimString(this.env?.APIOSK_GATEWAY_URL) ||
|
|
2469
|
-
trimString(this.env?.APIOSK_GATEWAY_BASE_URL) ||
|
|
2470
|
-
"https://gateway.apiosk.com";
|
|
2471
|
-
const url = new URL("/v1/me", gatewayBase.replace(/\/+$/, "/")).href;
|
|
902
|
+
const url = new URL("/v1/me", `${resolveGatewayBaseUrl(this.env)}/`).href;
|
|
2472
903
|
|
|
2473
904
|
let response;
|
|
2474
905
|
try {
|
|
@@ -2643,9 +1074,8 @@ export function createHostedOAuthSupport({
|
|
|
2643
1074
|
mcpServerUrl,
|
|
2644
1075
|
appName = "Apiosk",
|
|
2645
1076
|
resourceName = "Apiosk MCP",
|
|
2646
|
-
|
|
2647
|
-
|
|
2648
|
-
requirePaymentAuthorization,
|
|
1077
|
+
exchangePortalCode,
|
|
1078
|
+
refreshPortalToken,
|
|
2649
1079
|
} = {}) {
|
|
2650
1080
|
const secret = resolveOAuthSecret(env);
|
|
2651
1081
|
const provider = new ApioskHostedOAuthProvider({
|
|
@@ -2655,9 +1085,8 @@ export function createHostedOAuthSupport({
|
|
|
2655
1085
|
mcpServerUrl,
|
|
2656
1086
|
appName,
|
|
2657
1087
|
resourceName,
|
|
2658
|
-
|
|
2659
|
-
|
|
2660
|
-
requirePaymentAuthorization,
|
|
1088
|
+
exchangePortalCode,
|
|
1089
|
+
refreshPortalToken,
|
|
2661
1090
|
});
|
|
2662
1091
|
|
|
2663
1092
|
const oauthMetadata = createOAuthMetadata({
|
|
@@ -2668,7 +1097,12 @@ export function createHostedOAuthSupport({
|
|
|
2668
1097
|
resourceName,
|
|
2669
1098
|
serviceDocumentationUrl: new URL("https://apiosk.com"),
|
|
2670
1099
|
});
|
|
2671
|
-
|
|
1100
|
+
// Claude prefers CIMD when advertised. Its metadata origin currently
|
|
1101
|
+
// challenges Fly egress with HTTP 403, which makes a healthy MCP endpoint
|
|
1102
|
+
// fail sign-in as invalid_client. DCR is local, durable through signed client
|
|
1103
|
+
// IDs, and supported by both hosts. Only advertise CIMD after deployment-
|
|
1104
|
+
// side reachability has been verified.
|
|
1105
|
+
oauthMetadata.client_id_metadata_document_supported = env.APIOSK_MCP_CIMD_ENABLED === "1";
|
|
2672
1106
|
|
|
2673
1107
|
const serviceDocumentationUrl = new URL("https://apiosk.com");
|
|
2674
1108
|
|
|
@@ -2717,6 +1151,12 @@ export function createHostedOAuthSupport({
|
|
|
2717
1151
|
authorizationRouter: authorizationHandler({ provider }),
|
|
2718
1152
|
tokenRouter: tokenHandler({ provider }),
|
|
2719
1153
|
registrationRouter: clientRegistrationHandler({ clientsStore: provider.clientsStore }),
|
|
1154
|
+
// GET /authorize/callback — buy.apiosk.com's return leg. Not part of the
|
|
1155
|
+
// SDK's authorizationHandler (that only ever starts a flow); this ends
|
|
1156
|
+
// one that started on this server and continued at the portal.
|
|
1157
|
+
async handlePortalCallback(req, res) {
|
|
1158
|
+
await provider.completePortalCallback(req, res);
|
|
1159
|
+
},
|
|
2720
1160
|
createMcpAuthMiddleware(runtime) {
|
|
2721
1161
|
return async (req, res, next) => {
|
|
2722
1162
|
const challengeResourceMetadataUrl = resolveResourceMetadataUrl(req);
|
|
@@ -2738,6 +1178,19 @@ export function createHostedOAuthSupport({
|
|
|
2738
1178
|
|
|
2739
1179
|
const requestBody = req.body;
|
|
2740
1180
|
const method = trimString(requestBody?.method);
|
|
1181
|
+
// Every tool uses the connected account. Challenge during the
|
|
1182
|
+
// initial handshake too, so hosts do not install it as an anonymous
|
|
1183
|
+
// connector and discover the sign-in requirement only on first use.
|
|
1184
|
+
if (method && !req.auth) {
|
|
1185
|
+
writeAuthChallenge(res, {
|
|
1186
|
+
status: 401,
|
|
1187
|
+
code: "invalid_token",
|
|
1188
|
+
message: "Connect your Apiosk account once to use this connector.",
|
|
1189
|
+
resourceMetadataUrl: challengeResourceMetadataUrl,
|
|
1190
|
+
});
|
|
1191
|
+
return;
|
|
1192
|
+
}
|
|
1193
|
+
|
|
2741
1194
|
if (method !== "tools/call") {
|
|
2742
1195
|
next();
|
|
2743
1196
|
return;
|