@ni-c/mcp-hub 0.9.2 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/CHANGELOG.md +619 -0
  2. package/README.md +189 -64
  3. package/dist/admin.js +232 -8
  4. package/dist/admin.js.map +1 -1
  5. package/dist/auth/address.js +89 -0
  6. package/dist/auth/address.js.map +1 -0
  7. package/dist/auth/api-tokens.js +27 -0
  8. package/dist/auth/api-tokens.js.map +1 -0
  9. package/dist/auth/cimd.js +367 -0
  10. package/dist/auth/cimd.js.map +1 -0
  11. package/dist/auth/consent-page.js +4 -7
  12. package/dist/auth/consent-page.js.map +1 -1
  13. package/dist/auth/headers.js +22 -1
  14. package/dist/auth/headers.js.map +1 -1
  15. package/dist/auth/login-page.js +4 -7
  16. package/dist/auth/login-page.js.map +1 -1
  17. package/dist/auth/oidc/adapter.js +135 -0
  18. package/dist/auth/oidc/adapter.js.map +1 -0
  19. package/dist/auth/oidc/interactions.js +187 -0
  20. package/dist/auth/oidc/interactions.js.map +1 -0
  21. package/dist/auth/oidc/mount.js +144 -0
  22. package/dist/auth/oidc/mount.js.map +1 -0
  23. package/dist/auth/oidc/provider.js +440 -0
  24. package/dist/auth/oidc/provider.js.map +1 -0
  25. package/dist/auth/oidc/quirks.js +234 -0
  26. package/dist/auth/oidc/quirks.js.map +1 -0
  27. package/dist/auth/oidc/verifier.js +121 -0
  28. package/dist/auth/oidc/verifier.js.map +1 -0
  29. package/dist/auth/page.js +22 -0
  30. package/dist/auth/page.js.map +1 -1
  31. package/dist/auth/pinned-fetch.js +129 -0
  32. package/dist/auth/pinned-fetch.js.map +1 -0
  33. package/dist/auth/protected-resource.js +41 -0
  34. package/dist/auth/protected-resource.js.map +1 -0
  35. package/dist/auth/rate-limit.js +156 -0
  36. package/dist/auth/rate-limit.js.map +1 -0
  37. package/dist/auth/redirect-uri.js +90 -0
  38. package/dist/auth/redirect-uri.js.map +1 -0
  39. package/dist/auth/registration.js +146 -0
  40. package/dist/auth/registration.js.map +1 -0
  41. package/dist/auth/session.js +43 -0
  42. package/dist/auth/session.js.map +1 -0
  43. package/dist/auth/signed-token.js +49 -0
  44. package/dist/auth/signed-token.js.map +1 -0
  45. package/dist/auth/store.js +516 -5
  46. package/dist/auth/store.js.map +1 -1
  47. package/dist/auth/text.js +51 -0
  48. package/dist/auth/text.js.map +1 -0
  49. package/dist/config.js +184 -5
  50. package/dist/config.js.map +1 -1
  51. package/dist/docker-proxy/policy.js +1 -0
  52. package/dist/docker-proxy/policy.js.map +1 -1
  53. package/dist/docker-proxy/server.js +1 -0
  54. package/dist/docker-proxy/server.js.map +1 -1
  55. package/dist/elicitation.js +0 -0
  56. package/dist/elicitation.js.map +1 -0
  57. package/dist/forward.js +0 -0
  58. package/dist/forward.js.map +1 -0
  59. package/dist/health.js +22 -3
  60. package/dist/health.js.map +1 -1
  61. package/dist/hub.js +337 -29
  62. package/dist/hub.js.map +1 -1
  63. package/dist/index.js +218 -23
  64. package/dist/index.js.map +1 -1
  65. package/dist/limits.js +13 -1
  66. package/dist/limits.js.map +1 -1
  67. package/dist/mcp-limits.js +14 -2
  68. package/dist/mcp-limits.js.map +1 -1
  69. package/dist/proxy.js +262 -34
  70. package/dist/proxy.js.map +1 -1
  71. package/dist/stdio.js +97 -7
  72. package/dist/stdio.js.map +1 -1
  73. package/dist/subscriptions.js +236 -0
  74. package/dist/subscriptions.js.map +1 -0
  75. package/dist/supervisor.js +472 -28
  76. package/dist/supervisor.js.map +1 -1
  77. package/dist/timings.js +61 -0
  78. package/dist/timings.js.map +1 -0
  79. package/dist/tool-filter.js +59 -0
  80. package/dist/tool-filter.js.map +1 -0
  81. package/dist/transports/docker.js.map +1 -1
  82. package/dist/transports/stream.js +33 -20
  83. package/dist/transports/stream.js.map +1 -1
  84. package/dist/upstream/auth.js +435 -0
  85. package/dist/upstream/auth.js.map +1 -0
  86. package/dist/upstream/login.js +94 -0
  87. package/dist/upstream/login.js.map +1 -0
  88. package/dist/upstream/provider.js +286 -0
  89. package/dist/upstream/provider.js.map +1 -0
  90. package/dist/upstream/routes.js +97 -0
  91. package/dist/upstream/routes.js.map +1 -0
  92. package/package.json +23 -7
  93. package/dist/auth/provider.js +0 -380
  94. package/dist/auth/provider.js.map +0 -1
  95. package/dist/auth/routes.js +0 -223
  96. package/dist/auth/routes.js.map +0 -1
@@ -0,0 +1,156 @@
1
+ import net from 'node:net';
2
+ /**
3
+ * The unit a budget is counted against.
4
+ *
5
+ * Not the address. One IPv6 address is not one caller: the smallest block
6
+ * handed to a single subscriber is a /64, and a residential line usually gets a
7
+ * /56 or /48 on top of that. A limiter keyed on the full address therefore
8
+ * counts a single host as billions of distinct callers, and every per-address
9
+ * budget in this file becomes decorative — a /64 walks around all of them
10
+ * without any infrastructure at all.
11
+ *
12
+ * IPv4 keeps its own address, where one address really is roughly one caller,
13
+ * and an IPv4-mapped form (`::ffff:1.2.3.4`, what a dual-stack listener reports
14
+ * for an IPv4 peer) is folded back onto it so the same client is not counted in
15
+ * two places depending on how the socket was opened.
16
+ *
17
+ * Only the KEY is bucketed. Log lines keep the full address — fail2ban bans
18
+ * what it is given, and it should be given the host that actually connected.
19
+ */
20
+ export function rateLimitKey(ip) {
21
+ const bare = ip.replace(/^\[|\]$/g, '').split('%')[0];
22
+ if (net.isIPv4(bare))
23
+ return bare;
24
+ if (!net.isIPv6(bare))
25
+ return ip; // 'unknown', or something we cannot parse
26
+ const mapped = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i.exec(bare);
27
+ if (mapped)
28
+ return mapped[1];
29
+ return `${expandIPv6(bare).slice(0, 4).join(':')}::/64`;
30
+ }
31
+ /** The eight hextets of an IPv6 address, with `::` filled back in. */
32
+ function expandIPv6(address) {
33
+ const [head, tail] = address.toLowerCase().split('::');
34
+ const left = head ? head.split(':') : [];
35
+ const right = tail ? tail.split(':') : [];
36
+ const missing = 8 - left.length - right.length;
37
+ const groups = [...left, ...Array.from({ length: Math.max(0, missing) }, () => '0'), ...right];
38
+ // Leading zeros are not part of the value; without this `2001:0db8:…` and
39
+ // `2001:db8:…` would be two different keys for one network.
40
+ return groups.map(group => (Number.parseInt(group, 16) || 0).toString(16));
41
+ }
42
+ /**
43
+ * A per-caller request budget that rejects before any body is read.
44
+ *
45
+ * The SDK applies its own limits to the OAuth endpoints, but only after body
46
+ * parsing — which is the expensive part. This runs first, and refuses without
47
+ * inserting the offending address into its own table, so a flood of distinct
48
+ * source addresses cannot grow the map that is supposed to be bounding it.
49
+ */
50
+ export function earlyRateLimit(windowMs, maxPerIp, maxTotal) {
51
+ const byIp = new Map();
52
+ let total = { count: 0, resetAt: 0 };
53
+ return (req, res, next) => {
54
+ const now = Date.now();
55
+ if (total.resetAt <= now)
56
+ total = { count: 0, resetAt: now + windowMs };
57
+ const ip = rateLimitKey(req.ip ?? 'unknown');
58
+ let entry = byIp.get(ip);
59
+ if (entry && entry.resetAt <= now) {
60
+ byIp.delete(ip);
61
+ entry = undefined;
62
+ }
63
+ // Reject before inserting anything: a flood of distinct rejected IPs must
64
+ // not grow the map. Accepted requests bound it at maxTotal per window.
65
+ if ((entry?.count ?? 0) >= maxPerIp || total.count >= maxTotal) {
66
+ res.set('Retry-After', String(Math.max(1, Math.ceil((Math.min(entry?.resetAt ?? Infinity, total.resetAt) - now) / 1000))));
67
+ res.status(429).json({ error: 'too_many_requests', error_description: 'Request rate limit exceeded' });
68
+ return;
69
+ }
70
+ if (!entry) {
71
+ if (byIp.size >= maxTotal) {
72
+ for (const [candidateIp, candidate] of byIp)
73
+ if (candidate.resetAt <= now)
74
+ byIp.delete(candidateIp);
75
+ }
76
+ entry = { count: 0, resetAt: now + windowMs };
77
+ byIp.set(ip, entry);
78
+ }
79
+ entry.count++;
80
+ total.count++;
81
+ next();
82
+ };
83
+ }
84
+ const LOGIN_MAX_ATTEMPTS = 10;
85
+ const LOGIN_WINDOW_MS = 15 * 60_000;
86
+ /** A global ceiling as well, so a distributed guess cannot spend the per-address
87
+ * budget many times over. */
88
+ const LOGIN_MAX_ATTEMPTS_TOTAL = 100;
89
+ /**
90
+ * Per-caller lockout for the password form.
91
+ *
92
+ * Separate from `earlyRateLimit` because it counts FAILURES, not requests: a
93
+ * correct login resets the counter, so someone who knows the password is never
94
+ * locked out by someone else guessing from the same address.
95
+ */
96
+ export class LoginRateLimiter {
97
+ attempts = new Map();
98
+ total = { count: 0, resetAt: 0 };
99
+ /**
100
+ * Whether this caller has spent its budget.
101
+ *
102
+ * The global ceiling refuses the callers that are doing the guessing, and
103
+ * only those. It used to refuse everyone, which turned an attacker's cheapest
104
+ * possible traffic into a lockout of the only administrative way in: a
105
+ * hundred wrong passwords — under a second of work, and renewable every
106
+ * fifteen minutes — left the operator holding the correct password and
107
+ * getting 429 from an address that had never been near the form.
108
+ *
109
+ * What the ceiling is for survives the change. A caller that guesses is
110
+ * counted, and once the hub as a whole is under a distributed attempt that
111
+ * caller is refused on its FIRST failure instead of its tenth — so the total
112
+ * number of guesses still collapses, and it collapses hardest for exactly the
113
+ * addresses doing the guessing. What no longer happens is that they can spend
114
+ * somebody else's budget. Repeated failures remain a fail2ban matter; that is
115
+ * what the log line exists for.
116
+ */
117
+ isBlocked(ip) {
118
+ const key = rateLimitKey(ip);
119
+ const entry = this.attempts.get(key);
120
+ const live = entry && entry.resetAt >= Date.now() ? entry : undefined;
121
+ if (this.total.resetAt > Date.now() && this.total.count >= LOGIN_MAX_ATTEMPTS_TOTAL && live !== undefined)
122
+ return true;
123
+ return (live?.count ?? 0) >= LOGIN_MAX_ATTEMPTS;
124
+ }
125
+ recordFailure(ip) {
126
+ this.sweepExpired(); // entries are otherwise only dropped on a successful login from that exact caller
127
+ const now = Date.now();
128
+ // Behind a reverse proxy req.ip is the proxy for every request, and a
129
+ // spoofable X-Forwarded-For makes the per-caller counter meaningless — this
130
+ // caps the total either way.
131
+ if (this.total.resetAt < now)
132
+ this.total = { count: 1, resetAt: now + LOGIN_WINDOW_MS };
133
+ else
134
+ this.total.count++;
135
+ const key = rateLimitKey(ip);
136
+ const entry = this.attempts.get(key);
137
+ if (!entry || entry.resetAt < now) {
138
+ this.attempts.set(key, { count: 1, resetAt: now + LOGIN_WINDOW_MS });
139
+ }
140
+ else {
141
+ entry.count++;
142
+ }
143
+ }
144
+ sweepExpired() {
145
+ const now = Date.now();
146
+ for (const [ip, entry] of this.attempts) {
147
+ if (entry.resetAt < now)
148
+ this.attempts.delete(ip);
149
+ }
150
+ }
151
+ reset(ip) {
152
+ this.attempts.delete(rateLimitKey(ip));
153
+ this.total = { count: 0, resetAt: 0 };
154
+ }
155
+ }
156
+ //# sourceMappingURL=rate-limit.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rate-limit.js","sourceRoot":"","sources":["../../src/auth/rate-limit.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Where an authorization code is allowed to land.
3
+ *
4
+ * Both registration mechanisms end up handing a code to a URL the client chose,
5
+ * so both have to agree on which URLs are acceptable — they did not before:
6
+ * a metadata document was held to https-or-loopback while a dynamically
7
+ * registered client could name any scheme the SDK's three-entry denylist did
8
+ * not happen to cover, plain `http://` to a remote host included.
9
+ */
10
+ const LOOPBACK_HOSTNAMES = new Set(['localhost', '127.0.0.1', '[::1]', '::1']);
11
+ /** Schemes a browser or the operating system would treat as executable content
12
+ * or local file access. Never a legitimate redirect target. */
13
+ const DANGEROUS_SCHEMES = new Set(['javascript:', 'data:', 'vbscript:', 'file:', 'blob:']);
14
+ function isLoopbackHostname(hostname) {
15
+ return LOOPBACK_HOSTNAMES.has(hostname);
16
+ }
17
+ /**
18
+ * https anywhere, plain http only on this machine, and — where the policy
19
+ * allows it — a private-use scheme for a native client.
20
+ *
21
+ * The point of refusing remote `http://` is that the code travels in the clear
22
+ * on the final redirect: anyone on the path between the browser and the client
23
+ * reads it, and a public client has nothing else to prove itself with.
24
+ */
25
+ export function isSafeRedirectUri(uri, policy) {
26
+ let parsed;
27
+ try {
28
+ parsed = new URL(uri);
29
+ }
30
+ catch {
31
+ return false;
32
+ }
33
+ if (DANGEROUS_SCHEMES.has(parsed.protocol))
34
+ return false;
35
+ if (parsed.protocol === 'https:')
36
+ return true;
37
+ if (parsed.protocol === 'http:')
38
+ return isLoopbackHostname(parsed.hostname);
39
+ return policy.allowPrivateUseSchemes;
40
+ }
41
+ /**
42
+ * Whether a requested redirect URI is covered by a registered one.
43
+ *
44
+ * RFC 8252 §7.3: an authorization server MUST allow any port on a loopback
45
+ * redirect URI, because a native client is handed an ephemeral one by the
46
+ * operating system and cannot register it in advance. Everything else is an
47
+ * exact match — a prefix or origin comparison here is the classic open-redirect
48
+ * hole.
49
+ *
50
+ * Lives here rather than being imported because SDK v2 dropped it: the helper
51
+ * belonged to the authorization-server half, which the SDK no longer ships. It
52
+ * is a dozen lines, and keeping the loopback set shared with the checks above
53
+ * is worth more than the import was.
54
+ *
55
+ * @see https://datatracker.ietf.org/doc/html/rfc8252#section-7.3
56
+ */
57
+ export function redirectUriMatches(requested, registered) {
58
+ if (requested === registered)
59
+ return true;
60
+ let req;
61
+ let reg;
62
+ try {
63
+ req = new URL(requested);
64
+ reg = new URL(registered);
65
+ }
66
+ catch {
67
+ return false;
68
+ }
69
+ if (!isLoopbackHostname(req.hostname) || !isLoopbackHostname(reg.hostname))
70
+ return false;
71
+ return (req.protocol === reg.protocol && req.hostname === reg.hostname && req.pathname === reg.pathname && req.search === reg.search);
72
+ }
73
+ /**
74
+ * True when every redirect URI points at this machine — the case the MCP
75
+ * security considerations single out, because any local program could be the
76
+ * one asking. Used to warn on the login and consent pages.
77
+ */
78
+ export function isLoopbackOnly(redirectUris) {
79
+ if (!redirectUris?.length)
80
+ return false;
81
+ return redirectUris.every(uri => {
82
+ try {
83
+ return isLoopbackHostname(new URL(uri).hostname);
84
+ }
85
+ catch {
86
+ return false;
87
+ }
88
+ });
89
+ }
90
+ //# sourceMappingURL=redirect-uri.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"redirect-uri.js","sourceRoot":"","sources":["../../src/auth/redirect-uri.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,MAAM,kBAAkB,GAAG,IAAI,GAAG,CAAC,CAAC,WAAW,EAAE,WAAW,EAAE,OAAO,EAAE,KAAK,CAAC,CAAC,CAAC;AAE/E;gEACgE;AAChE,MAAM,iBAAiB,GAAG,IAAI,GAAG,CAAC,CAAC,aAAa,EAAE,OAAO,EAAE,WAAW,EAAE,OAAO,EAAE,OAAO,CAAC,CAAC,CAAC;AAE3F,SAAS,kBAAkB,CAAC,QAAgB;IAC1C,OAAO,kBAAkB,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;AAC1C,CAAC;AAYD;;;;;;;GAOG;AACH,MAAM,UAAU,iBAAiB,CAAC,GAAW,EAAE,MAAyB;IACtE,IAAI,MAAW,CAAC;IAChB,IAAI,CAAC;QACH,MAAM,GAAG,IAAI,GAAG,CAAC,GAAG,CAAC,CAAC;IACxB,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,KAAK,CAAC;IACf,CAAC;IACD,IAAI,iBAAiB,CAAC,GAAG,CAAC,MAAM,CAAC,QAAQ,CAAC;QAAE,OAAO,KAAK,CAAC;IACzD,IAAI,MAAM,CAAC,QAAQ,KAAK,QAAQ;QAAE,OAAO,IAAI,CAAC;IAC9C,IAAI,MAAM,CAAC,QAAQ,KAAK,OAAO;QAAE,OAAO,kBAAkB,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC;IAC5E,OAAO,MAAM,CAAC,sBAAsB,CAAC;AACvC,CAAC;AAED;;;;;;;;;;;;;;;GAeG;AACH,MAAM,UAAU,kBAAkB,CAAC,SAAiB,EAAE,UAAkB;IACtE,IAAI,SAAS,KAAK,UAAU;QAAE,OAAO,IAAI,CAAC;IAC1C,IAAI,GAAQ,CAAC;IACb,IAAI,GAAQ,CAAC;IACb,IAAI,CAAC;QACH,GAAG,GAAG,IAAI,GAAG,CAAC,SAAS,CAAC,CAAC;QACzB,GAAG,GAAG,IAAI,GAAG,CAAC,UAAU,CAAC,CAAC;IAC5B,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,KAAK,CAAC;IACf,CAAC;IACD,IAAI,CAAC,kBAAkB,CAAC,GAAG,CAAC,QAAQ,CAAC,IAAI,CAAC,kBAAkB,CAAC,GAAG,CAAC,QAAQ,CAAC;QAAE,OAAO,KAAK,CAAC;IACzF,OAAO,CACL,GAAG,CAAC,QAAQ,KAAK,GAAG,CAAC,QAAQ,IAAI,GAAG,CAAC,QAAQ,KAAK,GAAG,CAAC,QAAQ,IAAI,GAAG,CAAC,QAAQ,KAAK,GAAG,CAAC,QAAQ,IAAI,GAAG,CAAC,MAAM,KAAK,GAAG,CAAC,MAAM,CAC7H,CAAC;AACJ,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,cAAc,CAAC,YAAkC;IAC/D,IAAI,CAAC,YAAY,EAAE,MAAM;QAAE,OAAO,KAAK,CAAC;IACxC,OAAO,YAAY,CAAC,KAAK,CAAC,GAAG,CAAC,EAAE;QAC9B,IAAI,CAAC;YACH,OAAO,kBAAkB,CAAC,IAAI,GAAG,CAAC,GAAG,CAAC,CAAC,QAAQ,CAAC,CAAC;QACnD,CAAC;QAAC,MAAM,CAAC;YACP,OAAO,KAAK,CAAC;QACf,CAAC;IACH,CAAC,CAAC,CAAC;AACL,CAAC"}
@@ -0,0 +1,146 @@
1
+ import express, { Router } from 'express';
2
+ import { OAuthClientMetadataSchema } from '@modelcontextprotocol/core';
3
+ import { isSafeRedirectUri } from './redirect-uri.js';
4
+ import { earlyRateLimit } from './rate-limit.js';
5
+ import { clampDisplayName, logSafe } from './text.js';
6
+ /**
7
+ * RFC 7592, the management half of dynamic client registration: a client that
8
+ * registered itself can read, change and — the point of the exercise — delete
9
+ * its own registration, without the operator having to do it for them.
10
+ *
11
+ * None of this comes from the SDK. Its registration router accepts `POST` and
12
+ * nothing else (`allowedMethods(['POST'])`), and it knows nothing about
13
+ * `registration_access_token`. These routes therefore have to be mounted ahead
14
+ * of it, or the SDK answers `405` for every verb below.
15
+ *
16
+ * The credential is minted once at registration and only its hash is stored,
17
+ * so it is not recoverable from the state file — losing it means asking the
18
+ * operator to remove the registration.
19
+ */
20
+ /** Where the client manages the registration it was just given. */
21
+ export function registrationClientUri(externalUrl, clientId) {
22
+ return new URL(`register/${encodeURIComponent(clientId)}`, externalUrl).href;
23
+ }
24
+ /** The body limit is far above any real client metadata document and well
25
+ * below anything that would matter to the process. */
26
+ const MAX_METADATA_BYTES = '64kb';
27
+ export function createRegistrationManagementRoutes(options) {
28
+ const { store, externalUrl } = options;
29
+ const router = Router();
30
+ const path = '/register/:clientId';
31
+ // Its own budget rather than the 20-per-hour one guarding POST /register.
32
+ // These are authenticated and cheap, and a client that reads and updates its
33
+ // registration should not thereby lose the ability to register again.
34
+ const limit = earlyRateLimit(15 * 60_000, 60, 600);
35
+ const present = (client) => ({
36
+ ...client,
37
+ registration_client_uri: registrationClientUri(externalUrl, client.client_id)
38
+ });
39
+ // The SDK gives POST /register an open CORS policy so browser-based clients
40
+ // can register; these routes sit in front of it and need their own. Opening
41
+ // them is safe: the credential is a bearer token in a header, never a cookie,
42
+ // so no browser attaches it to a cross-site request on its own.
43
+ const allowCrossOrigin = (res) => {
44
+ res.set('Access-Control-Allow-Origin', '*');
45
+ };
46
+ router.options(path, limit, (_req, res) => {
47
+ allowCrossOrigin(res);
48
+ res.set({
49
+ 'Access-Control-Allow-Methods': 'GET, PUT, DELETE, OPTIONS',
50
+ 'Access-Control-Allow-Headers': 'Authorization, Content-Type',
51
+ 'Access-Control-Max-Age': '600'
52
+ });
53
+ res.status(204).end();
54
+ });
55
+ /**
56
+ * Authenticates before anything else runs — the body parser included, so an
57
+ * unauthenticated caller never gets to hand us a document to parse.
58
+ *
59
+ * An unknown client and a wrong token get the same answer. Telling them apart
60
+ * would turn this into a way to find out which client_ids exist.
61
+ */
62
+ const requireRegistrationToken = (req, res, next) => {
63
+ allowCrossOrigin(res);
64
+ const clientId = String(req.params.clientId);
65
+ const header = req.headers.authorization;
66
+ const token = typeof header === 'string' && header.startsWith('Bearer ') ? header.slice(7).trim() : '';
67
+ const client = token.length > 0 && store.verifyRegistrationToken(clientId, token) ? store.getClient(clientId) : undefined;
68
+ if (!client) {
69
+ console.warn(`mcp-hub: refused registration management for ${logSafe(clientId)}`);
70
+ res.set('WWW-Authenticate', 'Bearer error="invalid_token"');
71
+ res.status(401).json({ error: 'invalid_token', error_description: 'Invalid registration access token' });
72
+ return;
73
+ }
74
+ req.registrationClient = client;
75
+ next();
76
+ };
77
+ router.get(path, limit, requireRegistrationToken, (req, res) => {
78
+ res.json(present(req.registrationClient));
79
+ });
80
+ router.put(path, limit, requireRegistrationToken, express.json({ limit: MAX_METADATA_BYTES }), (req, res) => {
81
+ const existing = req.registrationClient;
82
+ const refuse = (description) => {
83
+ res.status(400).json({ error: 'invalid_client_metadata', error_description: description });
84
+ };
85
+ const body = (req.body ?? {});
86
+ // RFC 7592 §2.2: the client has to say which registration it means, and it
87
+ // may not use this to change its own credentials.
88
+ if (body.client_id !== existing.client_id) {
89
+ refuse('client_id must be present and match the registration being updated');
90
+ return;
91
+ }
92
+ // Only meaningful for a client that actually holds a secret. A public one
93
+ // was handed a throwaway secret in its registration response that was never
94
+ // stored (the accommodation ChatGPT needs), and echoing that back here must
95
+ // not be treated as an attempt to change anything.
96
+ if (existing.client_secret !== undefined && body.client_secret !== undefined && body.client_secret !== existing.client_secret) {
97
+ refuse('client_secret cannot be changed here');
98
+ return;
99
+ }
100
+ const parsed = OAuthClientMetadataSchema.safeParse(body);
101
+ if (!parsed.success) {
102
+ refuse('The submitted client metadata is not valid');
103
+ return;
104
+ }
105
+ const metadata = parsed.data;
106
+ for (const uri of metadata.redirect_uris ?? []) {
107
+ if (!isSafeRedirectUri(uri, { allowPrivateUseSchemes: true })) {
108
+ refuse(`redirect_uri ${uri} must be https, a loopback address or a private-use scheme`);
109
+ return;
110
+ }
111
+ }
112
+ const redirectsChanged = !sameUris(existing.redirect_uris, metadata.redirect_uris);
113
+ const updated = {
114
+ ...metadata,
115
+ client_name: clampDisplayName(metadata.client_name),
116
+ // Identity and credentials are the hub's to assign, not the client's.
117
+ client_id: existing.client_id,
118
+ client_id_issued_at: existing.client_id_issued_at,
119
+ ...(existing.client_secret !== undefined
120
+ ? { client_secret: existing.client_secret, client_secret_expires_at: existing.client_secret_expires_at }
121
+ : {})
122
+ };
123
+ // Consent was given for a destination. A client that moves the destination
124
+ // afterwards has to be asked again, or the approval would be transferable
125
+ // to somewhere the user never saw.
126
+ store.updateClient(updated, { resetApproval: redirectsChanged });
127
+ if (redirectsChanged) {
128
+ console.log(`mcp-hub: client ${logSafe(existing.client_id)} changed its redirect URIs; its approval was withdrawn`);
129
+ }
130
+ res.json(present(updated));
131
+ });
132
+ router.delete(path, limit, requireRegistrationToken, (req, res) => {
133
+ const clientId = req.registrationClient.client_id;
134
+ store.deleteClient(clientId);
135
+ console.log(`mcp-hub: client ${logSafe(clientId)} deleted its own registration`);
136
+ res.status(204).end();
137
+ });
138
+ return router;
139
+ }
140
+ /** Order is not part of the meaning of a redirect URI list. */
141
+ function sameUris(before, after) {
142
+ const a = [...(before ?? [])].sort();
143
+ const b = [...(after ?? [])].sort();
144
+ return a.length === b.length && a.every((uri, index) => uri === b[index]);
145
+ }
146
+ //# sourceMappingURL=registration.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"registration.js","sourceRoot":"","sources":["../../src/auth/registration.ts"],"names":[],"mappings":"AAAA,OAAO,OAAO,EAAE,EAAE,MAAM,EAAE,MAAM,SAAS,CAAC;AAG1C,OAAO,EAAE,yBAAyB,EAAE,MAAM,4BAA4B,CAAC;AAEvE,OAAO,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAC;AACtD,OAAO,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AACjD,OAAO,EAAE,gBAAgB,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAEtD;;;;;;;;;;;;;GAaG;AAEH,mEAAmE;AACnE,MAAM,UAAU,qBAAqB,CAAC,WAAmB,EAAE,QAAgB;IACzE,OAAO,IAAI,GAAG,CAAC,YAAY,kBAAkB,CAAC,QAAQ,CAAC,EAAE,EAAE,WAAW,CAAC,CAAC,IAAI,CAAC;AAC/E,CAAC;AAYD;uDACuD;AACvD,MAAM,kBAAkB,GAAG,MAAM,CAAC;AAElC,MAAM,UAAU,kCAAkC,CAAC,OAAsC;IACvF,MAAM,EAAE,KAAK,EAAE,WAAW,EAAE,GAAG,OAAO,CAAC;IACvC,MAAM,MAAM,GAAG,MAAM,EAAE,CAAC;IACxB,MAAM,IAAI,GAAG,qBAAqB,CAAC;IACnC,0EAA0E;IAC1E,6EAA6E;IAC7E,sEAAsE;IACtE,MAAM,KAAK,GAAG,cAAc,CAAC,EAAE,GAAG,MAAM,EAAE,EAAE,EAAE,GAAG,CAAC,CAAC;IAEnD,MAAM,OAAO,GAAG,CAAC,MAAkC,EAA2B,EAAE,CAAC,CAAC;QAChF,GAAG,MAAM;QACT,uBAAuB,EAAE,qBAAqB,CAAC,WAAW,EAAE,MAAM,CAAC,SAAS,CAAC;KAC9E,CAAC,CAAC;IAEH,4EAA4E;IAC5E,4EAA4E;IAC5E,8EAA8E;IAC9E,gEAAgE;IAChE,MAAM,gBAAgB,GAAG,CAAC,GAAa,EAAQ,EAAE;QAC/C,GAAG,CAAC,GAAG,CAAC,6BAA6B,EAAE,GAAG,CAAC,CAAC;IAC9C,CAAC,CAAC;IAEF,MAAM,CAAC,OAAO,CAAC,IAAI,EAAE,KAAK,EAAE,CAAC,IAAI,EAAE,GAAG,EAAE,EAAE;QACxC,gBAAgB,CAAC,GAAG,CAAC,CAAC;QACtB,GAAG,CAAC,GAAG,CAAC;YACN,8BAA8B,EAAE,2BAA2B;YAC3D,8BAA8B,EAAE,6BAA6B;YAC7D,wBAAwB,EAAE,KAAK;SAChC,CAAC,CAAC;QACH,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,CAAC;IACxB,CAAC,CAAC,CAAC;IAEH;;;;;;OAMG;IACH,MAAM,wBAAwB,GAAG,CAAC,GAAwB,EAAE,GAAa,EAAE,IAAkB,EAAQ,EAAE;QACrG,gBAAgB,CAAC,GAAG,CAAC,CAAC;QACtB,MAAM,QAAQ,GAAG,MAAM,CAAC,GAAG,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC;QAC7C,MAAM,MAAM,GAAG,GAAG,CAAC,OAAO,CAAC,aAAa,CAAC;QACzC,MAAM,KAAK,GAAG,OAAO,MAAM,KAAK,QAAQ,IAAI,MAAM,CAAC,UAAU,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QACvG,MAAM,MAAM,GAAG,KAAK,CAAC,MAAM,GAAG,CAAC,IAAI,KAAK,CAAC,uBAAuB,CAAC,QAAQ,EAAE,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,SAAS,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC;QAC1H,IAAI,CAAC,MAAM,EAAE,CAAC;YACZ,OAAO,CAAC,IAAI,CAAC,gDAAgD,OAAO,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC;YAClF,GAAG,CAAC,GAAG,CAAC,kBAAkB,EAAE,8BAA8B,CAAC,CAAC;YAC5D,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,eAAe,EAAE,iBAAiB,EAAE,mCAAmC,EAAE,CAAC,CAAC;YACzG,OAAO;QACT,CAAC;QACD,GAAG,CAAC,kBAAkB,GAAG,MAAM,CAAC;QAChC,IAAI,EAAE,CAAC;IACT,CAAC,CAAC;IAEF,MAAM,CAAC,GAAG,CAAC,IAAI,EAAE,KAAK,EAAE,wBAAwB,EAAE,CAAC,GAAwB,EAAE,GAAG,EAAE,EAAE;QAClF,GAAG,CAAC,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,kBAAmB,CAAC,CAAC,CAAC;IAC7C,CAAC,CAAC,CAAC;IAEH,MAAM,CAAC,GAAG,CAAC,IAAI,EAAE,KAAK,EAAE,wBAAwB,EAAE,OAAO,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,kBAAkB,EAAE,CAAC,EAAE,CAAC,GAAwB,EAAE,GAAG,EAAE,EAAE;QAC/H,MAAM,QAAQ,GAAG,GAAG,CAAC,kBAAmB,CAAC;QACzC,MAAM,MAAM,GAAG,CAAC,WAAmB,EAAQ,EAAE;YAC3C,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,yBAAyB,EAAE,iBAAiB,EAAE,WAAW,EAAE,CAAC,CAAC;QAC7F,CAAC,CAAC;QACF,MAAM,IAAI,GAAG,CAAC,GAAG,CAAC,IAAI,IAAI,EAAE,CAA4B,CAAC;QACzD,2EAA2E;QAC3E,kDAAkD;QAClD,IAAI,IAAI,CAAC,SAAS,KAAK,QAAQ,CAAC,SAAS,EAAE,CAAC;YAC1C,MAAM,CAAC,oEAAoE,CAAC,CAAC;YAC7E,OAAO;QACT,CAAC;QACD,0EAA0E;QAC1E,4EAA4E;QAC5E,4EAA4E;QAC5E,mDAAmD;QACnD,IAAI,QAAQ,CAAC,aAAa,KAAK,SAAS,IAAI,IAAI,CAAC,aAAa,KAAK,SAAS,IAAI,IAAI,CAAC,aAAa,KAAK,QAAQ,CAAC,aAAa,EAAE,CAAC;YAC9H,MAAM,CAAC,sCAAsC,CAAC,CAAC;YAC/C,OAAO;QACT,CAAC;QACD,MAAM,MAAM,GAAG,yBAAyB,CAAC,SAAS,CAAC,IAAI,CAAC,CAAC;QACzD,IAAI,CAAC,MAAM,CAAC,OAAO,EAAE,CAAC;YACpB,MAAM,CAAC,4CAA4C,CAAC,CAAC;YACrD,OAAO;QACT,CAAC;QACD,MAAM,QAAQ,GAAG,MAAM,CAAC,IAAI,CAAC;QAC7B,KAAK,MAAM,GAAG,IAAI,QAAQ,CAAC,aAAa,IAAI,EAAE,EAAE,CAAC;YAC/C,IAAI,CAAC,iBAAiB,CAAC,GAAG,EAAE,EAAE,sBAAsB,EAAE,IAAI,EAAE,CAAC,EAAE,CAAC;gBAC9D,MAAM,CAAC,gBAAgB,GAAG,4DAA4D,CAAC,CAAC;gBACxF,OAAO;YACT,CAAC;QACH,CAAC;QACD,MAAM,gBAAgB,GAAG,CAAC,QAAQ,CAAC,QAAQ,CAAC,aAAa,EAAE,QAAQ,CAAC,aAAa,CAAC,CAAC;QACnF,MAAM,OAAO,GAAG;YACd,GAAG,QAAQ;YACX,WAAW,EAAE,gBAAgB,CAAC,QAAQ,CAAC,WAAW,CAAC;YACnD,sEAAsE;YACtE,SAAS,EAAE,QAAQ,CAAC,SAAS;YAC7B,mBAAmB,EAAE,QAAQ,CAAC,mBAAmB;YACjD,GAAG,CAAC,QAAQ,CAAC,aAAa,KAAK,SAAS;gBACtC,CAAC,CAAC,EAAE,aAAa,EAAE,QAAQ,CAAC,aAAa,EAAE,wBAAwB,EAAE,QAAQ,CAAC,wBAAwB,EAAE;gBACxG,CAAC,CAAC,EAAE,CAAC;SACsB,CAAC;QAChC,2EAA2E;QAC3E,0EAA0E;QAC1E,mCAAmC;QACnC,KAAK,CAAC,YAAY,CAAC,OAAO,EAAE,EAAE,aAAa,EAAE,gBAAgB,EAAE,CAAC,CAAC;QACjE,IAAI,gBAAgB,EAAE,CAAC;YACrB,OAAO,CAAC,GAAG,CAAC,mBAAmB,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAC,wDAAwD,CAAC,CAAC;QACtH,CAAC;QACD,GAAG,CAAC,IAAI,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC,CAAC;IAC7B,CAAC,CAAC,CAAC;IAEH,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,KAAK,EAAE,wBAAwB,EAAE,CAAC,GAAwB,EAAE,GAAG,EAAE,EAAE;QACrF,MAAM,QAAQ,GAAG,GAAG,CAAC,kBAAmB,CAAC,SAAS,CAAC;QACnD,KAAK,CAAC,YAAY,CAAC,QAAQ,CAAC,CAAC;QAC7B,OAAO,CAAC,GAAG,CAAC,mBAAmB,OAAO,CAAC,QAAQ,CAAC,+BAA+B,CAAC,CAAC;QACjF,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,CAAC;IACxB,CAAC,CAAC,CAAC;IAEH,OAAO,MAAM,CAAC;AAChB,CAAC;AAED,+DAA+D;AAC/D,SAAS,QAAQ,CAAC,MAA4B,EAAE,KAA2B;IACzE,MAAM,CAAC,GAAG,CAAC,GAAG,CAAC,MAAM,IAAI,EAAE,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;IACrC,MAAM,CAAC,GAAG,CAAC,GAAG,CAAC,KAAK,IAAI,EAAE,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;IACpC,OAAO,CAAC,CAAC,MAAM,KAAK,CAAC,CAAC,MAAM,IAAI,CAAC,CAAC,KAAK,CAAC,CAAC,GAAG,EAAE,KAAK,EAAE,EAAE,CAAC,GAAG,KAAK,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC;AAC5E,CAAC"}
@@ -0,0 +1,43 @@
1
+ import { sign, signatureMatches } from './signed-token.js';
2
+ /** Deliberately short: it only has to outlive a connector's authorization. */
3
+ export const SESSION_TTL_MS = 30 * 60_000;
4
+ export const SESSION_COOKIE = 'mcp_hub_session';
5
+ /**
6
+ * The operator's browser session, carried entirely by the client.
7
+ *
8
+ * `"<expiresMs>.<HMAC>"` and nothing else — there is no session table, which is
9
+ * what lets the hub stay stateless while still recognising someone who has
10
+ * already typed the password. The value doubles as the handle the consent
11
+ * form's CSRF token is bound to.
12
+ *
13
+ * Shared rather than reimplemented: `hasValidSession` is read outside the auth
14
+ * layer (the upstream OAuth callback in `src/upstream/routes.ts`), so two
15
+ * copies of this format drifting apart would break a flow that neither of them
16
+ * looks like it owns.
17
+ */
18
+ export function createSessionCookie(secret) {
19
+ const expires = String(Date.now() + SESSION_TTL_MS);
20
+ return `${expires}.${sign(expires, secret)}`;
21
+ }
22
+ /** The verified cookie value, or undefined when absent, forged or expired. */
23
+ export function readSessionCookie(cookieHeader, secret) {
24
+ const match = cookieHeader?.match(new RegExp(`(?:^|;\\s*)${SESSION_COOKIE}=([^;]+)`));
25
+ if (!match)
26
+ return undefined;
27
+ const value = decodeURIComponent(match[1]);
28
+ const [expires, signature] = value.split('.');
29
+ if (!expires || !signature)
30
+ return undefined;
31
+ if (!signatureMatches(expires, signature, secret))
32
+ return undefined;
33
+ return Number(expires) > Date.now() ? value : undefined;
34
+ }
35
+ export function csrfToken(sessionValue, secret) {
36
+ return sign(`csrf:${sessionValue}`, secret);
37
+ }
38
+ export function verifyCsrfToken(sessionValue, token, secret) {
39
+ if (typeof token !== 'string')
40
+ return false;
41
+ return signatureMatches(`csrf:${sessionValue}`, token, secret);
42
+ }
43
+ //# sourceMappingURL=session.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"session.js","sourceRoot":"","sources":["../../src/auth/session.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAC;AAE3D,8EAA8E;AAC9E,MAAM,CAAC,MAAM,cAAc,GAAG,EAAE,GAAG,MAAM,CAAC;AAC1C,MAAM,CAAC,MAAM,cAAc,GAAG,iBAAiB,CAAC;AAEhD;;;;;;;;;;;;GAYG;AACH,MAAM,UAAU,mBAAmB,CAAC,MAAc;IAChD,MAAM,OAAO,GAAG,MAAM,CAAC,IAAI,CAAC,GAAG,EAAE,GAAG,cAAc,CAAC,CAAC;IACpD,OAAO,GAAG,OAAO,IAAI,IAAI,CAAC,OAAO,EAAE,MAAM,CAAC,EAAE,CAAC;AAC/C,CAAC;AAED,8EAA8E;AAC9E,MAAM,UAAU,iBAAiB,CAAC,YAAgC,EAAE,MAAc;IAChF,MAAM,KAAK,GAAG,YAAY,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,cAAc,cAAc,UAAU,CAAC,CAAC,CAAC;IACtF,IAAI,CAAC,KAAK;QAAE,OAAO,SAAS,CAAC;IAC7B,MAAM,KAAK,GAAG,kBAAkB,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC;IAC3C,MAAM,CAAC,OAAO,EAAE,SAAS,CAAC,GAAG,KAAK,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;IAC9C,IAAI,CAAC,OAAO,IAAI,CAAC,SAAS;QAAE,OAAO,SAAS,CAAC;IAC7C,IAAI,CAAC,gBAAgB,CAAC,OAAO,EAAE,SAAS,EAAE,MAAM,CAAC;QAAE,OAAO,SAAS,CAAC;IACpE,OAAO,MAAM,CAAC,OAAO,CAAC,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,SAAS,CAAC;AAC1D,CAAC;AAED,MAAM,UAAU,SAAS,CAAC,YAAoB,EAAE,MAAc;IAC5D,OAAO,IAAI,CAAC,QAAQ,YAAY,EAAE,EAAE,MAAM,CAAC,CAAC;AAC9C,CAAC;AAED,MAAM,UAAU,eAAe,CAAC,YAAoB,EAAE,KAAc,EAAE,MAAc;IAClF,IAAI,OAAO,KAAK,KAAK,QAAQ;QAAE,OAAO,KAAK,CAAC;IAC5C,OAAO,gBAAgB,CAAC,QAAQ,YAAY,EAAE,EAAE,KAAK,EAAE,MAAM,CAAC,CAAC;AACjE,CAAC"}
@@ -0,0 +1,49 @@
1
+ import crypto from 'node:crypto';
2
+ /**
3
+ * Short-lived values the hub hands to a browser and expects back unchanged.
4
+ *
5
+ * Used for the login/consent form token, the session cookie, the consent CSRF
6
+ * token and the `state` of an outbound OAuth login. All of them share one
7
+ * property: the hub must recognise its own value without storing it, and must
8
+ * not be talked into accepting someone else's.
9
+ *
10
+ * The key throughout is `AuthStore.cookieSecret`.
11
+ */
12
+ export function sign(value, secret) {
13
+ return crypto.createHmac('sha256', secret).update(value).digest('base64url');
14
+ }
15
+ /**
16
+ * Constant-time comparison against the expected signature.
17
+ *
18
+ * The length check is not an optimisation — `timingSafeEqual` throws on
19
+ * mismatched lengths, so a value of the wrong length would otherwise be an
20
+ * exception rather than a rejection.
21
+ */
22
+ export function signatureMatches(value, signature, secret) {
23
+ const expected = sign(value, secret);
24
+ if (signature.length !== expected.length)
25
+ return false;
26
+ return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
27
+ }
28
+ /** `base64url(json).signature` — everything the caller needs, carried by the
29
+ * browser instead of by the hub. */
30
+ export function signPayload(payload, secret) {
31
+ const encoded = Buffer.from(JSON.stringify(payload)).toString('base64url');
32
+ return `${encoded}.${sign(encoded, secret)}`;
33
+ }
34
+ /** Undefined for anything that was not signed with this secret, or is not the
35
+ * shape it claims. Never throws on malformed input. */
36
+ export function readSignedPayload(token, secret) {
37
+ const [encoded, signature] = token.split('.');
38
+ if (!encoded || !signature)
39
+ return undefined;
40
+ if (!signatureMatches(encoded, signature, secret))
41
+ return undefined;
42
+ try {
43
+ return JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8'));
44
+ }
45
+ catch {
46
+ return undefined;
47
+ }
48
+ }
49
+ //# sourceMappingURL=signed-token.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"signed-token.js","sourceRoot":"","sources":["../../src/auth/signed-token.ts"],"names":[],"mappings":"AAAA,OAAO,MAAM,MAAM,aAAa,CAAC;AAEjC;;;;;;;;;GASG;AAEH,MAAM,UAAU,IAAI,CAAC,KAAa,EAAE,MAAc;IAChD,OAAO,MAAM,CAAC,UAAU,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,MAAM,CAAC,WAAW,CAAC,CAAC;AAC/E,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,gBAAgB,CAAC,KAAa,EAAE,SAAiB,EAAE,MAAc;IAC/E,MAAM,QAAQ,GAAG,IAAI,CAAC,KAAK,EAAE,MAAM,CAAC,CAAC;IACrC,IAAI,SAAS,CAAC,MAAM,KAAK,QAAQ,CAAC,MAAM;QAAE,OAAO,KAAK,CAAC;IACvD,OAAO,MAAM,CAAC,eAAe,CAAC,MAAM,CAAC,IAAI,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC,CAAC;AAC/E,CAAC;AAED;qCACqC;AACrC,MAAM,UAAU,WAAW,CAAC,OAAgB,EAAE,MAAc;IAC1D,MAAM,OAAO,GAAG,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,SAAS,CAAC,OAAO,CAAC,CAAC,CAAC,QAAQ,CAAC,WAAW,CAAC,CAAC;IAC3E,OAAO,GAAG,OAAO,IAAI,IAAI,CAAC,OAAO,EAAE,MAAM,CAAC,EAAE,CAAC;AAC/C,CAAC;AAED;wDACwD;AACxD,MAAM,UAAU,iBAAiB,CAAI,KAAa,EAAE,MAAc;IAChE,MAAM,CAAC,OAAO,EAAE,SAAS,CAAC,GAAG,KAAK,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;IAC9C,IAAI,CAAC,OAAO,IAAI,CAAC,SAAS;QAAE,OAAO,SAAS,CAAC;IAC7C,IAAI,CAAC,gBAAgB,CAAC,OAAO,EAAE,SAAS,EAAE,MAAM,CAAC;QAAE,OAAO,SAAS,CAAC;IACpE,IAAI,CAAC;QACH,OAAO,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,IAAI,CAAC,OAAO,EAAE,WAAW,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAM,CAAC;IAC7E,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,SAAS,CAAC;IACnB,CAAC;AACH,CAAC"}