@apiosk/mcp 1.3.0 → 1.7.0

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