@ractive-ch/tab-mcp 0.3.1 → 0.5.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/main.js CHANGED
@@ -584,13 +584,15 @@ function truncateIp(ip) {
584
584
  }
585
585
  return `${v6.slice(0, 3).map((g) => g.toString(16)).join(":")}::/48`;
586
586
  }
587
+ function errorName(error) {
588
+ return error instanceof Error ? error.name : typeof error;
589
+ }
587
590
  function installCrashLogging(logger, exit = (code) => process.exit(code)) {
588
- const name = (error) => error instanceof Error ? error.name : typeof error;
589
591
  const onRejection = (error) => {
590
- logger.error("process.unhandled_rejection", { error: name(error) });
592
+ logger.error("process.unhandled_rejection", { error: errorName(error) });
591
593
  };
592
594
  const onException = (error) => {
593
- logger.error("process.uncaught_exception", { error: name(error) });
595
+ logger.error("process.uncaught_exception", { error: errorName(error) });
594
596
  exit(1);
595
597
  };
596
598
  process.on("unhandledRejection", onRejection);
@@ -601,7 +603,7 @@ function installCrashLogging(logger, exit = (code) => process.exit(code)) {
601
603
  };
602
604
  }
603
605
  // package.json
604
- var version = "0.3.1";
606
+ var version = "0.5.0";
605
607
 
606
608
  // src/address.ts
607
609
  var BASE32_ALPHABET = "abcdefghijklmnopqrstuvwxyz234567";
@@ -651,7 +653,8 @@ var RelayErrorCode = {
651
653
  tooLarge: -31006,
652
654
  badResponse: -31007,
653
655
  storeUnavailable: -31008,
654
- signedOut: -31009
656
+ signedOut: -31009,
657
+ restarting: -31010
655
658
  };
656
659
  var encoder = new TextEncoder;
657
660
  var NULL_BODY_STATUSES = new Set([204, 205, 304]);
@@ -680,6 +683,11 @@ function rpcErrorBody(rpcId, code, message, data) {
680
683
  error["data"] = data;
681
684
  return JSON.stringify({ jsonrpc: "2.0", id: rpcId ?? null, error });
682
685
  }
686
+ var TAB_STARTED = new Set([
687
+ "tab_stream",
688
+ "relay_passed",
689
+ "relay_wrapped"
690
+ ]);
683
691
 
684
692
  class Exchange {
685
693
  id;
@@ -691,9 +699,6 @@ class Exchange {
691
699
  #options;
692
700
  #resolve;
693
701
  #state = "waiting";
694
- #relayStarted = false;
695
- #tabStarted = false;
696
- #wrap = false;
697
702
  #wrapped = "";
698
703
  #controller;
699
704
  #atBoundary = true;
@@ -719,7 +724,7 @@ class Exchange {
719
724
  return this.#state === "done";
720
725
  }
721
726
  onResponse(msg) {
722
- if (this.#tabStarted) {
727
+ if (TAB_STARTED.has(this.#state)) {
723
728
  this.#badResponse("http.response after http.response.start");
724
729
  return;
725
730
  }
@@ -742,7 +747,7 @@ class Exchange {
742
747
  }
743
748
  this.#respond(response, msg.status);
744
749
  this.#finish("ok");
745
- } else if (this.#state === "streaming" && this.#relayStarted) {
750
+ } else if (this.#state === "relay_stream") {
746
751
  if (!this.#account(msg.body))
747
752
  return;
748
753
  if (msg.body !== "")
@@ -751,11 +756,17 @@ class Exchange {
751
756
  }
752
757
  }
753
758
  onStart(msg) {
754
- if (this.#tabStarted) {
759
+ if (this.#state === "done")
760
+ return;
761
+ if (TAB_STARTED.has(this.#state)) {
755
762
  this.#badResponse("http.response.start sent twice");
756
763
  return;
757
764
  }
758
- this.#tabStarted = true;
765
+ const eventStream = isEventStream(msg.headers);
766
+ if (this.isListen && !eventStream) {
767
+ this.#badResponse("a listen response must be an event stream");
768
+ return;
769
+ }
759
770
  if (this.#state === "waiting") {
760
771
  if (NULL_BODY_STATUSES.has(msg.status)) {
761
772
  this.#badResponse(`status ${msg.status} cannot be streamed`);
@@ -768,25 +779,29 @@ class Exchange {
768
779
  this.#badResponse("the response headers are invalid");
769
780
  return;
770
781
  }
771
- this.#openStream(msg.status, headers);
772
- } else if (this.#state === "streaming" && this.#relayStarted) {
773
- this.#wrap = !isEventStream(msg.headers);
782
+ this.#openStream(msg.status, headers, "tab_stream");
783
+ } else {
784
+ this.#state = eventStream ? "relay_passed" : "relay_wrapped";
774
785
  }
775
786
  }
776
787
  onChunk(data) {
777
- if (this.#state !== "streaming")
788
+ if (!this.#streaming)
778
789
  return;
790
+ if (this.#state === "relay_stream") {
791
+ this.#badResponse("http.response.chunk before http.response.start");
792
+ return;
793
+ }
779
794
  if (!this.#account(data))
780
795
  return;
781
- if (this.#wrap)
796
+ if (this.#state === "relay_wrapped")
782
797
  this.#wrapped += data;
783
798
  else
784
799
  this.#write(data);
785
800
  }
786
801
  onEnd() {
787
- if (this.#state !== "streaming")
802
+ if (!this.#streaming)
788
803
  return;
789
- if (this.#wrap && this.#wrapped !== "")
804
+ if (this.#state === "relay_wrapped" && this.#wrapped !== "")
790
805
  this.#write(sseEvent(this.#wrapped));
791
806
  this.#finish("ok");
792
807
  }
@@ -808,6 +823,11 @@ class Exchange {
808
823
  this.#finish("page_gone_retry");
809
824
  return;
810
825
  }
826
+ if (reply !== undefined && "error" in reply) {
827
+ const e = reply.error;
828
+ this.fail(e.status, e.code, e.message, false, e.headers);
829
+ return;
830
+ }
811
831
  const body = reply?.retry === false ? reply.body : undefined;
812
832
  if (body === undefined) {
813
833
  this.fail(503, RelayErrorCode.tabGone, "The page closed its connection to the relay.", false);
@@ -823,11 +843,11 @@ class Exchange {
823
843
  }
824
844
  this.#finish("page_gone");
825
845
  }
826
- fail(status, code, message, cancelTab) {
846
+ fail(status, code, message, cancelTab, headers = {}) {
827
847
  if (this.#state === "done")
828
848
  return;
829
849
  if (this.#state === "waiting") {
830
- this.#respond(this.#errorResponse(status, code, message), status);
850
+ this.#respond(this.#errorResponse(status, code, message, headers), status);
831
851
  } else if (this.#atBoundary && this.#options.rpcId !== undefined) {
832
852
  this.#write(sseEvent(rpcErrorBody(this.#options.rpcId, code, message)));
833
853
  }
@@ -841,7 +861,8 @@ class Exchange {
841
861
  }
842
862
  #account(data) {
843
863
  this.#bodyBytes += Buffer.byteLength(data);
844
- if (this.isListen || this.#bodyBytes <= this.#options.maxBodyBytes) {
864
+ const unbounded = this.isListen && (this.#state === "tab_stream" || this.#state === "relay_passed");
865
+ if (unbounded || this.#bodyBytes <= this.#options.maxBodyBytes) {
845
866
  return true;
846
867
  }
847
868
  this.fail(502, RelayErrorCode.tooLarge, "The page sent a response that is too large.", true);
@@ -856,10 +877,9 @@ class Exchange {
856
877
  #startRelayStream() {
857
878
  if (this.#state !== "waiting")
858
879
  return;
859
- this.#relayStarted = true;
860
- this.#openStream(200, this.#headers({ "content-type": "text/event-stream" }));
880
+ this.#openStream(200, this.#headers({ "content-type": "text/event-stream" }), "relay_stream");
861
881
  }
862
- #openStream(status, headers) {
882
+ #openStream(status, headers, state) {
863
883
  const stream = new ReadableStream({
864
884
  start: (controller) => {
865
885
  this.#controller = controller;
@@ -880,12 +900,12 @@ class Exchange {
880
900
  this.#badResponse("the response could not be built");
881
901
  return;
882
902
  }
883
- this.#state = "streaming";
903
+ this.#state = state;
884
904
  this.#respond(response, status);
885
905
  this.#scheduleKeepAlive();
886
906
  }
887
907
  #write(text) {
888
- if (this.#state !== "streaming" || this.#controller === undefined || text === "")
908
+ if (!this.#streaming || this.#controller === undefined || text === "")
889
909
  return;
890
910
  try {
891
911
  this.#controller.enqueue(encoder.encode(text));
@@ -902,16 +922,19 @@ class Exchange {
902
922
  }
903
923
  this.#scheduleKeepAlive();
904
924
  }
925
+ get #streaming() {
926
+ return this.#state !== "waiting" && this.#state !== "done";
927
+ }
905
928
  #scheduleKeepAlive() {
906
929
  clearTimeout(this.#keepAliveTimer);
907
- if (this.#state !== "streaming" || this.#wrap) {
908
- if (this.#state === "streaming") {
909
- this.#keepAliveTimer = setTimeout(() => {
910
- this.#write(`: keep-alive
930
+ if (!this.#streaming)
931
+ return;
932
+ if (this.#state === "relay_wrapped") {
933
+ this.#keepAliveTimer = setTimeout(() => {
934
+ this.#write(`: keep-alive
911
935
 
912
936
  `);
913
- }, this.#options.keepAliveMs);
914
- }
937
+ }, this.#options.keepAliveMs);
915
938
  return;
916
939
  }
917
940
  this.#keepAliveTimer = setTimeout(() => {
@@ -931,8 +954,10 @@ class Exchange {
931
954
  }
932
955
  return headers;
933
956
  }
934
- #errorResponse(status, code, message) {
957
+ #errorResponse(status, code, message, extra) {
935
958
  const headers = this.#headers({ "content-type": "application/json" });
959
+ for (const [k, v] of Object.entries(extra))
960
+ headers.set(k, v);
936
961
  return new Response(rpcErrorBody(this.#options.rpcId, code, message), {
937
962
  status,
938
963
  headers
@@ -946,7 +971,7 @@ class Exchange {
946
971
  #finish(outcome, closeStream = true) {
947
972
  if (this.#state === "done")
948
973
  return;
949
- const wasStreaming = this.#state === "streaming";
974
+ const wasStreaming = this.#streaming;
950
975
  this.#state = "done";
951
976
  this.outcome = outcome;
952
977
  clearTimeout(this.#startTimer);
@@ -1267,9 +1292,11 @@ class TabAssertionVerifier {
1267
1292
  #seen = new Map;
1268
1293
  #perPerson = new Map;
1269
1294
  #sweptAt = Number.NEGATIVE_INFINITY;
1270
- constructor(oidc, keys) {
1295
+ #maxSeen;
1296
+ constructor(oidc, keys, maxSeen = MAX_SEEN_JTI) {
1271
1297
  this.#oidc = oidc;
1272
1298
  this.#keys = keys ?? new JwksCache(oidc.assertionJwksUrl);
1299
+ this.#maxSeen = maxSeen;
1273
1300
  }
1274
1301
  async verify(token, address, now = Date.now()) {
1275
1302
  const { payload } = await verifyJwt(token, this.#keys, {
@@ -1315,15 +1342,18 @@ ${jti}`;
1315
1342
  throw new JwtError("replayed", "the assertion was already used");
1316
1343
  }
1317
1344
  const mine = this.#perPerson.get(key) ?? 0;
1318
- if (mine >= MAX_JTI_PER_PERSON || this.#seen.size >= MAX_SEEN_JTI) {
1345
+ if (mine >= MAX_JTI_PER_PERSON) {
1319
1346
  throw new JwtError("busy", "too many assertions at once");
1320
1347
  }
1348
+ if (this.#seen.size >= this.#maxSeen) {
1349
+ throw new JwtError("relay_busy", "the relay is checking too many assertions; try again shortly");
1350
+ }
1321
1351
  this.#seen.set(seenKey, exp + CLOCK_LEEWAY_MS);
1322
1352
  this.#perPerson.set(key, mine + 1);
1323
1353
  return key;
1324
1354
  }
1325
1355
  #sweep(now) {
1326
- if (now - this.#sweptAt < 1000 && this.#seen.size < MAX_SEEN_JTI)
1356
+ if (now - this.#sweptAt < 1000 && this.#seen.size < this.#maxSeen)
1327
1357
  return;
1328
1358
  this.#sweptAt = now;
1329
1359
  for (const [seenKey, until] of this.#seen) {
@@ -1354,7 +1384,8 @@ var CloseCode = {
1354
1384
  heartbeatLost: 4003,
1355
1385
  shuttingDown: 4004,
1356
1386
  replaced: 4005,
1357
- rateLimited: 4008
1387
+ rateLimited: 4008,
1388
+ addressMismatch: 4009
1358
1389
  };
1359
1390
  var MAX_TITLE = 300;
1360
1391
  var MAX_NAME = 200;
@@ -1416,6 +1447,10 @@ function parseTabMessage(text) {
1416
1447
  if (app !== undefined && (!isObject(app) || !isAppName(app["name"]))) {
1417
1448
  return bad(`hello.app.name must be 1-${MAX_APP_NAME} characters`);
1418
1449
  }
1450
+ const revokeAll = v["revokeAll"];
1451
+ if (revokeAll !== undefined && typeof revokeAll !== "boolean") {
1452
+ return bad("hello.revokeAll must be a boolean");
1453
+ }
1419
1454
  const hello = {
1420
1455
  type,
1421
1456
  library: { name: library["name"], version: library["version"] },
@@ -1425,6 +1460,8 @@ function parseTabMessage(text) {
1425
1460
  if (isObject(app) && isAppName(app["name"])) {
1426
1461
  hello.app = { name: app["name"] };
1427
1462
  }
1463
+ if (revokeAll === true)
1464
+ hello.revokeAll = true;
1428
1465
  return { ok: true, message: hello };
1429
1466
  }
1430
1467
  case "pong": {
@@ -1533,19 +1570,28 @@ function notOpenText(appName) {
1533
1570
  function signedOutText(appName) {
1534
1571
  return `The page is open, but nobody is signed in to it. Ask the user to sign in to ${pageName(appName)}, then try again.`;
1535
1572
  }
1573
+ function restartingText() {
1574
+ return "The relay is restarting. Try again in a few seconds.";
1575
+ }
1536
1576
  function phaseText(phase, appName) {
1577
+ if (phase === "restarting" || phase === "restarting_gone") {
1578
+ return restartingText();
1579
+ }
1537
1580
  return phase === "signed_out" ? signedOutText(appName) : notOpenText(appName);
1538
1581
  }
1539
1582
  function goneDuringCallText(appName) {
1540
1583
  return `The page closed while the tool was running, so it may or may not have finished. Ask the user to open ${pageName(appName)} and keep it in front (on a phone, in split screen), then check the result before trying again.`;
1541
1584
  }
1585
+ function restartedDuringCallText() {
1586
+ return "The relay restarted while the tool was running, so it may or may not have finished. Check the result before trying again in a few seconds.";
1587
+ }
1542
1588
  function notOpenAnswer(body, rpcId, appName, phase, relay) {
1543
1589
  if (rpcId === undefined)
1544
1590
  return;
1545
1591
  const id = rpcId;
1546
1592
  const method = body["method"];
1547
1593
  if (method === "tools/call") {
1548
- const text = phase === "gone" ? goneDuringCallText(appName) : phaseText(phase, appName);
1594
+ const text = phase === "gone" ? goneDuringCallText(appName) : phase === "restarting_gone" ? restartedDuringCallText() : phaseText(phase, appName);
1549
1595
  return JSON.stringify({
1550
1596
  jsonrpc: "2.0",
1551
1597
  id,
@@ -1580,7 +1626,7 @@ function notOpenAnswer(body, rpcId, appName, phase, relay) {
1580
1626
  jsonrpc: "2.0",
1581
1627
  id,
1582
1628
  error: {
1583
- code: phase === "gone" ? RelayErrorCode.tabGone : RelayErrorCode.pageNotOpen,
1629
+ code: phase === "gone" ? RelayErrorCode.tabGone : phase === "restarting" || phase === "restarting_gone" ? RelayErrorCode.restarting : RelayErrorCode.pageNotOpen,
1584
1630
  message: phaseText(phase, appName)
1585
1631
  }
1586
1632
  });
@@ -1620,6 +1666,13 @@ class RateLimiter {
1620
1666
  bucket.tokens -= 1;
1621
1667
  return true;
1622
1668
  }
1669
+ available(key, now = Date.now()) {
1670
+ const bucket = this.#buckets.get(key);
1671
+ if (bucket === undefined)
1672
+ return this.#capacity >= 1;
1673
+ const elapsed = Math.max(0, now - bucket.updatedAt) / 1000;
1674
+ return Math.min(this.#capacity, bucket.tokens + elapsed * this.#refill) >= 1;
1675
+ }
1623
1676
  sweep(now = Date.now()) {
1624
1677
  for (const [key, b] of this.#buckets) {
1625
1678
  const elapsed = Math.max(0, now - b.updatedAt) / 1000;
@@ -1705,7 +1758,7 @@ async function defaultLookup(hostname) {
1705
1758
  const records = await Bun.dns.lookup(hostname, {});
1706
1759
  return records.map((r) => r.address);
1707
1760
  }
1708
- var INVISIBLE = /[\u034F\u115F\u1160\u17B4\u17B5\u2800\u3164\uFFA0]/gu;
1761
+ var INVISIBLE = /[\u034F\u115F\u1160\u17B4\u17B5\u2800\u3164\uFFA0]|\p{Variation_Selector}/gu;
1709
1762
  function displayName(raw) {
1710
1763
  let cleaned = raw.normalize("NFC").replace(/[\p{Cc}\p{Zl}\p{Zp}]/gu, " ").replace(/\p{Cf}/gu, "").replace(INVISIBLE, "");
1711
1764
  cleaned = cleaned.replace(/\s+/g, " ").trim();
@@ -1728,10 +1781,17 @@ function isIpLiteral(hostname) {
1728
1781
  const h = hostname.replace(/^\[|\]$/g, "");
1729
1782
  return parseIpv4(h) !== undefined || parseIpv6(h) !== undefined;
1730
1783
  }
1784
+ function limitKey(hostname) {
1785
+ const h = hostname.replace(/^\[|\]$/g, "").replace(/\.$/, "");
1786
+ if (isIpLiteral(h))
1787
+ return h;
1788
+ return h.split(".").slice(-2).join(".");
1789
+ }
1731
1790
 
1732
1791
  class ClientMetadataFetcher {
1733
1792
  #cache = new Map;
1734
1793
  #perHost = new RateLimiter({ capacity: 5, refillPerSecond: 0.2 });
1794
+ #perDomain = new RateLimiter({ capacity: 15, refillPerSecond: 0.6 });
1735
1795
  #global = new RateLimiter({ capacity: 30, refillPerSecond: 1 });
1736
1796
  #allowedHosts;
1737
1797
  #logger;
@@ -1755,22 +1815,28 @@ class ClientMetadataFetcher {
1755
1815
  const url = this.#checkUrl(clientId);
1756
1816
  const now = this.#now();
1757
1817
  const cached = this.#cache.get(clientId);
1758
- if (cached !== undefined && cached.until > now)
1818
+ if (cached !== undefined && cached.until > now) {
1819
+ this.#cache.delete(clientId);
1820
+ this.#cache.set(clientId, cached);
1759
1821
  return cached.meta;
1760
- if (!this.#perHost.take(url.host, now)) {
1822
+ }
1823
+ const key = limitKey(url.hostname);
1824
+ if (!this.#perHost.available(url.host, now) || !this.#perDomain.available(key, now)) {
1761
1825
  throw new ClientMetadataError("rate_limited", "Too many requests for this AI app's details. Try again in a minute.");
1762
1826
  }
1763
1827
  if (!this.#global.take("all", now)) {
1764
1828
  throw new ClientMetadataError("rate_limited", "The relay is busy. Try again in a minute.");
1765
1829
  }
1830
+ this.#perHost.take(url.host, now);
1831
+ this.#perDomain.take(key, now);
1766
1832
  const address = await this.#vettedAddress(url);
1767
1833
  const { meta, ttlMs } = await this.#fetchDocument(url, clientId, address);
1834
+ this.#cache.delete(clientId);
1768
1835
  if (this.#cache.size >= MAX_CACHE_ENTRIES) {
1769
1836
  const oldest = this.#cache.keys().next().value;
1770
1837
  if (oldest !== undefined)
1771
1838
  this.#cache.delete(oldest);
1772
1839
  }
1773
- this.#cache.delete(clientId);
1774
1840
  this.#cache.set(clientId, { meta, until: now + ttlMs });
1775
1841
  return meta;
1776
1842
  }
@@ -1905,6 +1971,27 @@ class ClientMetadataFetcher {
1905
1971
  }
1906
1972
  }
1907
1973
  }
1974
+ var DOMAIN_SHAPED = /(?:[\p{L}\p{N}](?:[\p{L}\p{N}-]*[\p{L}\p{N}])?\.)+\p{L}{2,63}(?![\p{L}\p{N}])/gu;
1975
+ function comparable(name) {
1976
+ return name.normalize("NFKC").replace(/\p{M}/gu, "").replace(/[\u3002\uFF0E\uFF61]/g, ".").toLowerCase();
1977
+ }
1978
+ function asciiHost(token) {
1979
+ try {
1980
+ return new URL(`https://${token}/`).hostname;
1981
+ } catch {
1982
+ return token;
1983
+ }
1984
+ }
1985
+ function foreignDomain(name, hostname) {
1986
+ const host = hostname.toLowerCase();
1987
+ for (const [match] of comparable(name).matchAll(DOMAIN_SHAPED)) {
1988
+ const token = asciiHost(match);
1989
+ const own = token === host || host.endsWith(`.${token}`) || token.endsWith(`.${host}`);
1990
+ if (!own)
1991
+ return true;
1992
+ }
1993
+ return false;
1994
+ }
1908
1995
  function validateDocument(doc, clientId, url) {
1909
1996
  const invalid = (message) => new ClientMetadataError("invalid_metadata", message);
1910
1997
  if (typeof doc !== "object" || doc === null || Array.isArray(doc)) {
@@ -1921,9 +2008,12 @@ function validateDocument(doc, clientId, url) {
1921
2008
  if (typeof name !== "string" || displayName(name) === "") {
1922
2009
  throw invalid("The AI app's details have no client_name.");
1923
2010
  }
1924
- if (/\(\s*[^()\s]*[a-z0-9-]\.[a-z]{2,}[^()]*\)/i.test(displayName(name))) {
2011
+ if (/\(\s*[^()\s]*[a-z0-9-]\.[a-z]{2,}[^()]*\)/i.test(comparable(displayName(name)))) {
1925
2012
  throw invalid("The AI app's name contains a web address in parentheses, which this relay does not show.");
1926
2013
  }
2014
+ if (foreignDomain(displayName(name), url.hostname)) {
2015
+ throw invalid("The AI app's name contains a web address other than its own, which this relay does not show.");
2016
+ }
1927
2017
  const uris = d["redirect_uris"];
1928
2018
  if (!Array.isArray(uris) || uris.length === 0 || uris.length > MAX_REDIRECT_URIS || !uris.every((u) => typeof u === "string" && isRedirectUri(u))) {
1929
2019
  throw invalid("The AI app's details have no valid redirect_uris.");
@@ -2138,14 +2228,17 @@ class ProviderDiscovery {
2138
2228
  class OidcPairing {
2139
2229
  kind = "oidc";
2140
2230
  #config;
2231
+ #maxFlows;
2141
2232
  #publicUrl;
2142
2233
  #logger;
2143
2234
  #discovery;
2144
2235
  #jwks;
2145
2236
  #flows = new Map;
2237
+ #flowOf = new Map;
2146
2238
  #mismatched = new Set;
2147
2239
  constructor(options) {
2148
2240
  this.#config = options.config;
2241
+ this.#maxFlows = options.maxFlows ?? MAX_FLOWS;
2149
2242
  this.#publicUrl = options.publicUrl;
2150
2243
  this.#logger = options.logger;
2151
2244
  this.#discovery = new ProviderDiscovery(options.config.issuer, options.development);
@@ -2182,7 +2275,7 @@ class OidcPairing {
2182
2275
  }
2183
2276
  return `<p>When you click Connect, sign in at <strong>${host}</strong> with the account you use in the page. No code is needed.</p>`;
2184
2277
  }
2185
- async check(target, _form, _admit, context) {
2278
+ async check(target, _form, admit, context) {
2186
2279
  if (!target.pairingOpen) {
2187
2280
  return {
2188
2281
  ok: false,
@@ -2207,16 +2300,25 @@ class OidcPairing {
2207
2300
  return this.#unavailable(error);
2208
2301
  }
2209
2302
  this.#sweep();
2210
- if (this.#flows.size >= MAX_FLOWS) {
2303
+ this.#dropFlow(context.requestId);
2304
+ if (this.#flows.size >= this.#maxFlows) {
2211
2305
  return {
2212
2306
  ok: false,
2213
2307
  message: "Too many sign-ins are open right now. Wait a few minutes and try again.",
2214
2308
  reason: "busy"
2215
2309
  };
2216
2310
  }
2217
- const state = random2(24);
2311
+ if (!admit()) {
2312
+ return {
2313
+ ok: false,
2314
+ message: "Too many attempts for this page. Wait a few minutes and try again.",
2315
+ reason: "rate_limited"
2316
+ };
2317
+ }
2318
+ const state = `${context.addressId}.${random2(24)}`;
2218
2319
  const nonce = random2(24);
2219
2320
  const verifier = random2(32);
2321
+ this.#flowOf.set(context.requestId, state);
2220
2322
  this.#flows.set(state, {
2221
2323
  requestId: context.requestId,
2222
2324
  browserId: context.browserId,
@@ -2256,6 +2358,7 @@ class OidcPairing {
2256
2358
  }
2257
2359
  this.#flows.delete(state);
2258
2360
  const requestId = flow.requestId;
2361
+ this.#flowOf.delete(requestId);
2259
2362
  const fail = (reason, message) => ({
2260
2363
  requestId,
2261
2364
  result: { ok: false, message, reason }
@@ -2382,12 +2485,25 @@ class OidcPairing {
2382
2485
  reason: "provider_unavailable"
2383
2486
  };
2384
2487
  }
2488
+ forget(requestId) {
2489
+ this.#dropFlow(requestId);
2490
+ this.#mismatched.delete(requestId);
2491
+ }
2492
+ #dropFlow(requestId) {
2493
+ const state = this.#flowOf.get(requestId);
2494
+ if (state !== undefined)
2495
+ this.#flows.delete(state);
2496
+ this.#flowOf.delete(requestId);
2497
+ }
2385
2498
  #sweep() {
2386
2499
  const now = Date.now();
2387
2500
  for (const [state, flow] of this.#flows) {
2388
2501
  if (flow.expiresAt > now)
2389
2502
  break;
2390
2503
  this.#flows.delete(state);
2504
+ if (this.#flowOf.get(flow.requestId) === state) {
2505
+ this.#flowOf.delete(flow.requestId);
2506
+ }
2391
2507
  }
2392
2508
  }
2393
2509
  }
@@ -2525,6 +2641,8 @@ var AUTH_CODE_TTL_MS = 60000;
2525
2641
  var MAX_PENDING = 1e4;
2526
2642
  var MAX_PENDING_PER_IP = 10;
2527
2643
  var MAX_PENDING_PER_ADDRESS = 20;
2644
+ var MAX_PENDING_PER_ADDRESS_IP = 5;
2645
+ var REFRESH_REUSE_WINDOW_MS = 24 * 60 * 60000;
2528
2646
  var MAX_CODES = 1e4;
2529
2647
  var MAX_FORM_BYTES = 8192;
2530
2648
  var MAX_STATE_LENGTH = 1024;
@@ -2605,6 +2723,10 @@ class AuthorizationServer {
2605
2723
  refillPerSecond: 1 / 30
2606
2724
  });
2607
2725
  #tokenPerIp = new RateLimiter({ capacity: 30, refillPerSecond: 1 });
2726
+ #refreshesPerGrant = new RateLimiter({
2727
+ capacity: 20,
2728
+ refillPerSecond: 1 / 60
2729
+ });
2608
2730
  constructor(host, testMode) {
2609
2731
  this.#host = host;
2610
2732
  this.#authCodeTtlMs = testMode?.authCodeTtlMs ?? AUTH_CODE_TTL_MS;
@@ -2616,7 +2738,8 @@ class AuthorizationServer {
2616
2738
  config: oidc,
2617
2739
  publicUrl: () => host.publicUrl(),
2618
2740
  logger: host.logger,
2619
- development: host.config.development
2741
+ development: host.config.development,
2742
+ ...testMode?.oidcMaxFlows === undefined ? {} : { maxFlows: testMode.oidcMaxFlows }
2620
2743
  })
2621
2744
  },
2622
2745
  ...testMode?.pairingMethods
@@ -2782,7 +2905,7 @@ class AuthorizationServer {
2782
2905
  if (error instanceof ClientMetadataError) {
2783
2906
  this.#host.logger.info("oauth.client_refused", {
2784
2907
  reason: error.reason,
2785
- host: hostOf(clientId).slice(0, 100)
2908
+ host: URL.canParse(clientId) ? hostOf(clientId).slice(0, 100) : "(not a URL)"
2786
2909
  });
2787
2910
  return this.#errorPage(400, error.message);
2788
2911
  }
@@ -2828,10 +2951,11 @@ class AuthorizationServer {
2828
2951
  if (scopes === undefined) {
2829
2952
  return fail("invalid_scope", `the only scope is "${SCOPE}"`);
2830
2953
  }
2831
- if (this.#host.session(addressId) === undefined) {
2832
- return this.#offlinePage(client, `/authorize${new URL(req.url).search}`, this.#errorUrl(redirectUri, state, "access_denied", "the user cancelled the connection"));
2954
+ const session = this.#host.session(addressId);
2955
+ if (session === undefined || !session.pairingOpen) {
2956
+ return this.#offlinePage(client, `/authorize${new URL(req.url).search}`, this.#errorUrl(redirectUri, state, "access_denied", "the user cancelled the connection"), session !== undefined);
2833
2957
  }
2834
- const method = this.#methods[this.#host.session(addressId)?.site.pairing ?? "code"];
2958
+ const method = this.#methods[session.site.pairing];
2835
2959
  if (method?.prepare !== undefined) {
2836
2960
  try {
2837
2961
  await method.prepare();
@@ -2843,9 +2967,10 @@ class AuthorizationServer {
2843
2967
  }
2844
2968
  }
2845
2969
  this.#sweepPending();
2846
- if (this.#pending.size >= MAX_PENDING || (this.#pendingPerIp.get(ip) ?? 0) >= MAX_PENDING_PER_IP || (this.#pendingPerAddress.get(addressId) ?? 0) >= MAX_PENDING_PER_ADDRESS) {
2970
+ if (this.#pending.size >= MAX_PENDING || (this.#pendingPerIp.get(ip) ?? 0) >= MAX_PENDING_PER_IP) {
2847
2971
  return this.#errorPage(429, "Too many connection requests are open right now. Wait a few minutes and try again.");
2848
2972
  }
2973
+ this.#makeRoom(addressId, ip);
2849
2974
  const pending = {
2850
2975
  id: random3(24),
2851
2976
  expiresAt: Date.now() + PENDING_TTL_MS,
@@ -2867,6 +2992,36 @@ class AuthorizationServer {
2867
2992
  this.#pendingPerIp.set(p.ip, (this.#pendingPerIp.get(p.ip) ?? 0) + 1);
2868
2993
  this.#pendingPerAddress.set(p.addressId, (this.#pendingPerAddress.get(p.addressId) ?? 0) + 1);
2869
2994
  }
2995
+ #makeRoom(addressId, ip) {
2996
+ const count = this.#pendingPerAddress.get(addressId) ?? 0;
2997
+ if (count < MAX_PENDING_PER_ADDRESS_IP)
2998
+ return;
2999
+ const byIp = new Map;
3000
+ for (const p of this.#pending.values()) {
3001
+ if (p.addressId !== addressId)
3002
+ continue;
3003
+ const list = byIp.get(p.ip);
3004
+ if (list === undefined)
3005
+ byIp.set(p.ip, [p]);
3006
+ else
3007
+ list.push(p);
3008
+ }
3009
+ const own = byIp.get(ip) ?? [];
3010
+ if (own.length >= MAX_PENDING_PER_ADDRESS_IP) {
3011
+ if (own[0] !== undefined)
3012
+ this.#dropPending(own[0]);
3013
+ return;
3014
+ }
3015
+ if (count < MAX_PENDING_PER_ADDRESS)
3016
+ return;
3017
+ let most = [];
3018
+ for (const list of byIp.values()) {
3019
+ if (list.length >= most.length)
3020
+ most = list;
3021
+ }
3022
+ if (most[0] !== undefined)
3023
+ this.#dropPending(most[0]);
3024
+ }
2870
3025
  #dropPending(p) {
2871
3026
  if (!this.#pending.delete(p.id))
2872
3027
  return;
@@ -2879,18 +3034,22 @@ class AuthorizationServer {
2879
3034
  };
2880
3035
  dec(this.#pendingPerIp, p.ip);
2881
3036
  dec(this.#pendingPerAddress, p.addressId);
2882
- }
2883
- #offlinePage(client, retryUrl, cancelUrl, error) {
2884
- const errorHtml = error === undefined ? "" : `<p class="error" role="alert">${escapeHtml(error)}</p>`;
2885
- const body = `<h1>Open the page first</h1>
2886
- ${errorHtml}
2887
- <p><strong>${escapeHtml(client.name)}</strong> (${escapeHtml(client.host)}) wants to use the tools of a web page, but that page is not open right now.</p>
2888
- <p>Open the page, click \u201CConnect your AI app\u201D, then try again here.</p>
3037
+ for (const method of Object.values(this.#methods))
3038
+ method?.forget?.(p.id);
3039
+ }
3040
+ #offlinePage(client, retryUrl, cancelUrl, connected = false) {
3041
+ const who = `<strong>${escapeHtml(client.name)}</strong> (${escapeHtml(client.host)})`;
3042
+ const title = connected ? "Open \u201CConnect your AI app\u201D first" : "Open the page first";
3043
+ const intro = connected ? `<p>${who} wants to use the tools of a web page, but that page is not connecting right now.</p>
3044
+ <p>In the page, click \u201CConnect your AI app\u201D, then try again here.</p>` : `<p>${who} wants to use the tools of a web page, but that page is not open right now.</p>
3045
+ <p>Open the page, click \u201CConnect your AI app\u201D, then try again here.</p>`;
3046
+ const body = `<h1>${title}</h1>
3047
+ ${intro}
2889
3048
  <div class="actions">
2890
3049
  <a class="button primary" href="${escapeHtml(retryUrl)}">Try again</a>
2891
3050
  <a class="button" href="${escapeHtml(cancelUrl)}">Cancel</a>
2892
3051
  </div>`;
2893
- return this.#page(200, "Open the page first", body);
3052
+ return this.#page(200, title, body);
2894
3053
  }
2895
3054
  async authorizeSubmit(req, ip) {
2896
3055
  const origin = req.headers.get("origin");
@@ -2946,7 +3105,8 @@ ${errorHtml}
2946
3105
  const admit = () => this.#guessesPerAddressIp.take(`${pending.addressId}|${ip}`) && this.#guessesPerAddress.take(pending.addressId);
2947
3106
  const result = await method.check(session, form, admit, {
2948
3107
  requestId: pending.id,
2949
- browserId: browser.id
3108
+ browserId: browser.id,
3109
+ addressId: pending.addressId
2950
3110
  });
2951
3111
  if (result.ok === "redirect") {
2952
3112
  this.#host.logger.info("oauth.pairing_redirect", {
@@ -2969,13 +3129,13 @@ ${errorHtml}
2969
3129
  client: pending.client.host,
2970
3130
  reason: result.reason
2971
3131
  });
2972
- return this.#pairingPage(pending, browser.id, result.message, result.reason === "rate_limited" ? 429 : 400);
3132
+ return this.#pairingPage(pending, browser.id, result.message, result.reason === "rate_limited" || result.reason === "busy" ? 429 : 400);
2973
3133
  }
2974
3134
  return this.#paired(pending, method.kind, result);
2975
3135
  }
2976
3136
  #paired(pending, kind, result) {
2977
3137
  this.#dropPending(pending);
2978
- const code = random3(32);
3138
+ const code = `${pending.addressId}.${random3(32)}`;
2979
3139
  if (this.#codes.size >= MAX_CODES)
2980
3140
  this.#sweepCodes(true);
2981
3141
  const { id: _id, expiresAt: _expires, ip: _ip, ...request } = pending;
@@ -3095,7 +3255,7 @@ ${errorHtml}
3095
3255
  ${errorHtml}
3096
3256
  <p>${clientLabel} wants to use the tools of a web page, but that page is not open right now.</p>
3097
3257
  <p>Open the page, click \u201CConnect your AI app\u201D, then try again here.</p>
3098
- <form method="post" action="/authorize">
3258
+ <form method="post" action="/authorize?a=${pending.addressId}">
3099
3259
  ${hidden}
3100
3260
  <div class="actions">
3101
3261
  <button type="submit" class="primary" name="action" value="retry">Try again</button>
@@ -3116,7 +3276,7 @@ ${cancel}
3116
3276
  <p>You're connecting ${clientLabel} to ${target}.</p>
3117
3277
  <p>${escapeHtml(client.name)} can then use this page's tools while the page is open. You can disconnect it in the page at any time.</p>
3118
3278
  ${errorHtml}
3119
- <form method="post" action="/authorize">
3279
+ <form method="post" action="/authorize?a=${pending.addressId}">
3120
3280
  ${hidden}
3121
3281
  ${method.render(session)}
3122
3282
  <div class="actions">
@@ -3313,7 +3473,10 @@ ${cancel}
3313
3473
  throw new OAuthError(400, "invalid_scope", "scope exceeds the grant");
3314
3474
  }
3315
3475
  }
3316
- if (!await this.#host.store.markUsed(hash, now)) {
3476
+ if (!this.#refreshesPerGrant.take(record.grantId, now)) {
3477
+ throw new OAuthError(429, "temporarily_unavailable", "too many refreshes for this connection; try again in a minute");
3478
+ }
3479
+ if (!await this.#host.store.markUsed(hash, now, now + REFRESH_REUSE_WINDOW_MS)) {
3317
3480
  await this.#reuseDetected(record);
3318
3481
  throw invalid("the refresh token was already used; the connection was revoked");
3319
3482
  }
@@ -3422,6 +3585,7 @@ ${cancel}
3422
3585
  this.#guessesPerAddressIp.sweep(now);
3423
3586
  this.#guessesPerAddress.sweep(now);
3424
3587
  this.#tokenPerIp.sweep(now);
3588
+ this.#refreshesPerGrant.sweep(now);
3425
3589
  }
3426
3590
  }
3427
3591
 
@@ -3444,11 +3608,8 @@ function clientHost(clientId) {
3444
3608
  return "";
3445
3609
  }
3446
3610
  }
3447
- function messageId(parsed) {
3448
- if ("message" in parsed) {
3449
- return "id" in parsed.message ? parsed.message.id : undefined;
3450
- }
3451
- return parsed.id;
3611
+ function frameId(data) {
3612
+ return /"id"\s*:\s*"([^"\\]{1,64})"/.exec(data.slice(0, 256))?.[1];
3452
3613
  }
3453
3614
 
3454
3615
  class TabSession {
@@ -3457,11 +3618,14 @@ class TabSession {
3457
3618
  origin;
3458
3619
  site;
3459
3620
  openedAt = Date.now();
3621
+ expectedAddress;
3460
3622
  addressId;
3461
3623
  appName;
3462
3624
  codes;
3463
3625
  identity;
3626
+ identityUnchecked = false;
3464
3627
  pending = new Map;
3628
+ #listenGrants = new Map;
3465
3629
  #ws;
3466
3630
  #host;
3467
3631
  #state = "new";
@@ -3473,12 +3637,13 @@ class TabSession {
3473
3637
  #helloTimer;
3474
3638
  #heartbeatTimer;
3475
3639
  #lifetimeTimer;
3476
- constructor(ws, ip, origin, site, host) {
3640
+ constructor(ws, ip, origin, site, host, expectedAddress) {
3477
3641
  this.#ws = ws;
3478
3642
  this.ip = ip;
3479
3643
  this.origin = origin;
3480
3644
  this.site = site;
3481
3645
  this.#host = host;
3646
+ this.expectedAddress = expectedAddress;
3482
3647
  this.codes = new PairingCodes((code, expiresAt) => {
3483
3648
  this.send({
3484
3649
  type: "pairing.code",
@@ -3526,15 +3691,14 @@ class TabSession {
3526
3691
  this.#protocolError("bad_message", "binary frames are not allowed");
3527
3692
  return;
3528
3693
  }
3529
- const tooLarge = Buffer.byteLength(data) > this.limits.maxMessageBytes;
3530
- const parsed = parseTabMessage(data);
3531
- if (tooLarge) {
3532
- const id = messageId(parsed);
3694
+ if (Buffer.byteLength(data) > this.limits.maxMessageBytes) {
3695
+ const id = frameId(data);
3533
3696
  this.#sendError("too_large", "message exceeds maxMessageBytes", id);
3534
3697
  const exchange = id === undefined ? undefined : this.pending.get(id);
3535
3698
  exchange?.fail(502, RelayErrorCode.tooLarge, "The page sent a response that is too large.", true);
3536
3699
  return;
3537
3700
  }
3701
+ const parsed = parseTabMessage(data);
3538
3702
  if (!parsed.ok) {
3539
3703
  if (this.#state === "new") {
3540
3704
  this.#protocolError("bad_message", parsed.error);
@@ -3568,7 +3732,7 @@ class TabSession {
3568
3732
  this.#protocolError("bad_message", "hello expected");
3569
3733
  return;
3570
3734
  }
3571
- await this.#hello(msg.secret, msg.library, msg.app?.name);
3735
+ await this.#hello(msg.secret, msg.library, msg.app?.name, msg.revokeAll === true);
3572
3736
  return;
3573
3737
  }
3574
3738
  if (this.#state === "hello")
@@ -3635,19 +3799,38 @@ class TabSession {
3635
3799
  }
3636
3800
  }
3637
3801
  }
3638
- async#hello(secret, library, appName) {
3802
+ async#hello(secret, library, appName, revokeAll = false) {
3639
3803
  if (!isWellFormedSecret(secret)) {
3640
3804
  this.#protocolError("bad_message", "secret must be 43 base64url characters");
3641
3805
  return;
3642
3806
  }
3807
+ const addressId = addressIdFromSecret(secret);
3808
+ if (this.expectedAddress !== undefined && this.expectedAddress !== addressId) {
3809
+ this.#host.logger.warn("tab.refused", { reason: "address_mismatch" });
3810
+ this.#sendError("address_mismatch", "the address id in the URL is not the one of the secret");
3811
+ this.end(CloseCode.addressMismatch, "address mismatch");
3812
+ return;
3813
+ }
3643
3814
  clearTimeout(this.#helloTimer);
3644
3815
  this.#state = "hello";
3645
- this.addressId = addressIdFromSecret(secret);
3816
+ this.addressId = addressId;
3646
3817
  this.appName = this.site.name ?? appName;
3647
3818
  const replaced = this.#host.claimAddress(this);
3648
3819
  if (replaced !== undefined) {
3649
3820
  replaced.end(CloseCode.replaced, "replaced by a newer session", "replaced");
3650
3821
  }
3822
+ if (revokeAll) {
3823
+ try {
3824
+ await this.#host.revokeAll(this);
3825
+ } catch (error) {
3826
+ this.#host.logger.error("store.error", {
3827
+ op: "revokeAll",
3828
+ error: errorName(error)
3829
+ });
3830
+ this.end(CloseCode.policyViolation, "could not revoke the grants");
3831
+ return;
3832
+ }
3833
+ }
3651
3834
  let grants = [];
3652
3835
  try {
3653
3836
  const list = await this.#host.store.listGrants(this.addressId, Date.now());
@@ -3663,7 +3846,7 @@ class TabSession {
3663
3846
  } catch (error) {
3664
3847
  this.#host.logger.error("store.error", {
3665
3848
  op: "listGrants",
3666
- message: error instanceof Error ? error.message : "unknown"
3849
+ error: errorName(error)
3667
3850
  });
3668
3851
  }
3669
3852
  if (this.#state !== "hello")
@@ -3695,7 +3878,7 @@ class TabSession {
3695
3878
  library: `${library.name}@${library.version}`,
3696
3879
  replaced: replaced !== undefined
3697
3880
  });
3698
- this.#host.ready?.(this);
3881
+ this.#host.ready(this);
3699
3882
  }
3700
3883
  #serialIdentity(run) {
3701
3884
  const next = this.#identityChain.then(run);
@@ -3716,6 +3899,13 @@ class TabSession {
3716
3899
  this.#missedPongs++;
3717
3900
  this.send({ type: "ping", t: Date.now() });
3718
3901
  }
3902
+ listenStreams(grantId) {
3903
+ let n = 0;
3904
+ for (const g of this.#listenGrants.values())
3905
+ if (g === grantId)
3906
+ n++;
3907
+ return n;
3908
+ }
3719
3909
  forward(input) {
3720
3910
  this.#lastRequestAt = Date.now();
3721
3911
  const id = `r${++this.#nextRequest}`;
@@ -3737,16 +3927,19 @@ class TabSession {
3737
3927
  },
3738
3928
  onDone: (e) => {
3739
3929
  this.pending.delete(id);
3930
+ this.#listenGrants.delete(id);
3740
3931
  this.#lastRequestAt = Date.now();
3741
3932
  input.onDone(e);
3742
3933
  },
3743
3934
  ...input.pageGone === undefined ? {} : { pageGone: input.pageGone }
3744
3935
  });
3745
3936
  this.pending.set(id, exchange);
3937
+ if (input.isListen)
3938
+ this.#listenGrants.set(id, input.grant.grantId);
3746
3939
  const sent = this.send({
3747
3940
  type: "http.request",
3748
3941
  id,
3749
- method: input.method,
3942
+ method: "POST",
3750
3943
  path: "/mcp",
3751
3944
  headers: input.headers,
3752
3945
  body: input.body,
@@ -3848,11 +4041,12 @@ class MemoryTokenStore {
3848
4041
  const r = this.#records.get(hash);
3849
4042
  return r === undefined ? undefined : copy(r);
3850
4043
  }
3851
- async markUsed(hash, now) {
4044
+ async markUsed(hash, now, keepUntil) {
3852
4045
  const r = this.#records.get(hash);
3853
4046
  if (r === undefined || r.usedAt !== undefined)
3854
4047
  return false;
3855
4048
  r.usedAt = now;
4049
+ r.expiresAt = Math.min(r.expiresAt, keepUntil);
3856
4050
  return true;
3857
4051
  }
3858
4052
  async revoke(selector, options) {
@@ -3905,7 +4099,8 @@ class MemoryTokenStore {
3905
4099
 
3906
4100
  // src/store/sqlite.ts
3907
4101
  import { Database } from "bun:sqlite";
3908
- var SCHEMA_VERSION = 3;
4102
+ var SCHEMA_VERSION = 4;
4103
+ var REUSE_WINDOW_MS = 86400000;
3909
4104
  function toRecord(row) {
3910
4105
  const record = {
3911
4106
  hash: row.hash,
@@ -3968,6 +4163,8 @@ class SqliteTokenStore {
3968
4163
  this.#db.run("CREATE INDEX IF NOT EXISTS tokens_grant ON tokens (grant_id)");
3969
4164
  this.#db.run("CREATE INDEX IF NOT EXISTS tokens_address ON tokens (address_id)");
3970
4165
  this.#db.run("CREATE INDEX IF NOT EXISTS tokens_expires ON tokens (expires_at)");
4166
+ this.#db.query(`UPDATE tokens SET expires_at = min(expires_at, used_at + $window)
4167
+ WHERE kind = 'refresh' AND used_at IS NOT NULL`).run({ window: REUSE_WINDOW_MS });
3971
4168
  this.#db.run(`PRAGMA user_version = ${SCHEMA_VERSION}`);
3972
4169
  })();
3973
4170
  }
@@ -4008,8 +4205,9 @@ class SqliteTokenStore {
4008
4205
  const row = this.#db.query("SELECT * FROM tokens WHERE hash = $hash").get({ hash });
4009
4206
  return row === null ? undefined : toRecord(row);
4010
4207
  }
4011
- async markUsed(hash, now) {
4012
- const result = this.#db.query("UPDATE tokens SET used_at = $now WHERE hash = $hash AND used_at IS NULL").run({ hash, now });
4208
+ async markUsed(hash, now, keepUntil) {
4209
+ const result = this.#db.query(`UPDATE tokens SET used_at = $now, expires_at = min(expires_at, $keep_until)
4210
+ WHERE hash = $hash AND used_at IS NULL`).run({ hash, now, keep_until: keepUntil });
4013
4211
  return result.changes === 1;
4014
4212
  }
4015
4213
  async revoke(selector, options) {
@@ -4048,6 +4246,16 @@ var VERSION = version;
4048
4246
  var RELAY_INFO = { name: "tab-mcp", version };
4049
4247
  var RETRY = Symbol("retry");
4050
4248
  var CLAUDE_MISMATCHED_HEADER = "2025-11-25";
4249
+ var MAX_LISTEN_STREAMS_PER_GRANT = 2;
4250
+ var FRAME_HEADROOM = 4;
4251
+ var TRANSIENT_REFUSALS = new Set([
4252
+ "jwks_unavailable",
4253
+ "store_unavailable",
4254
+ "relay_busy",
4255
+ "error"
4256
+ ]);
4257
+ var UNCHECKED_TEXT = "The relay cannot check the page's user right now. Try again shortly.";
4258
+ var RETRY_AFTER_S = "5";
4051
4259
  var FORWARDED_HEADERS = new Set([
4052
4260
  "content-type",
4053
4261
  "accept",
@@ -4056,6 +4264,33 @@ var FORWARDED_HEADERS = new Set([
4056
4264
  "traceparent",
4057
4265
  "tracestate"
4058
4266
  ]);
4267
+ var HEALTH_STORE_TIMEOUT_MS = 1000;
4268
+ var HEALTH_REUSE_MS = 1000;
4269
+ var HEALTH_HUNG_RETRY_MS = 30000;
4270
+ var HEALTH_PROBE_HASH = hashToken("tab-mcp /healthz probe");
4271
+ function rawAddressParam(search) {
4272
+ let found;
4273
+ for (const pair of search.replace(/^\?/, "").split("&")) {
4274
+ if (pair === "")
4275
+ continue;
4276
+ const eq = pair.indexOf("=");
4277
+ const rawName = eq < 0 ? pair : pair.slice(0, eq);
4278
+ let name;
4279
+ try {
4280
+ name = decodeURIComponent(rawName.replaceAll("+", " "));
4281
+ } catch {
4282
+ continue;
4283
+ }
4284
+ if (name !== "a")
4285
+ continue;
4286
+ const value = eq < 0 ? "" : pair.slice(eq + 1);
4287
+ if (rawName !== "a" || found !== undefined || !isAddressId(value)) {
4288
+ return false;
4289
+ }
4290
+ found = value;
4291
+ }
4292
+ return found;
4293
+ }
4059
4294
  function json(status, value, headers) {
4060
4295
  const h = new Headers(headers);
4061
4296
  h.set("content-type", "application/json");
@@ -4109,10 +4344,10 @@ function startRelay(options, hooks) {
4109
4344
  const perIp = new Map;
4110
4345
  const waiters = new Map;
4111
4346
  let waitingCount = 0;
4112
- const appNames = new Map;
4347
+ const lastSeen = new Map;
4113
4348
  let claudeWorkarounds = 0;
4114
4349
  let publicUrl = config.publicUrl ?? "";
4115
- const assertions = config.oidc === undefined ? undefined : new TabAssertionVerifier(config.oidc, hooks?.assertionKeys);
4350
+ const assertions = config.oidc === undefined ? undefined : new TabAssertionVerifier(config.oidc, hooks?.assertionKeys, hooks?.maxSeenAssertions);
4116
4351
  const corsHeaders = config.cors ? {
4117
4352
  "access-control-allow-origin": "*",
4118
4353
  "access-control-expose-headers": "www-authenticate, mcp-protocol-version"
@@ -4174,9 +4409,15 @@ function startRelay(options, hooks) {
4174
4409
  }
4175
4410
  };
4176
4411
  const refuse = (reason, message) => {
4412
+ const transient = TRANSIENT_REFUSALS.has(reason);
4413
+ session.identityUnchecked = transient;
4177
4414
  if (session.identity !== undefined) {
4178
4415
  session.identity = undefined;
4179
- failPending(session, "The page's user is no longer signed in.");
4416
+ if (transient) {
4417
+ failPending(session, UNCHECKED_TEXT, true);
4418
+ } else {
4419
+ failPending(session, "The page's user is no longer signed in.");
4420
+ }
4180
4421
  }
4181
4422
  logger.info("identity.refused", {
4182
4423
  address: shortAddress(addressId),
@@ -4213,7 +4454,7 @@ function startRelay(options, hooks) {
4213
4454
  } catch (error) {
4214
4455
  logger.error("store.error", {
4215
4456
  op: "identity",
4216
- message: error instanceof Error ? error.message : "unknown"
4457
+ error: errorName(error)
4217
4458
  });
4218
4459
  refuse("store_unavailable", "the relay cannot check grants right now");
4219
4460
  return;
@@ -4221,6 +4462,7 @@ function startRelay(options, hooks) {
4221
4462
  if (!session.ready)
4222
4463
  return;
4223
4464
  session.identity = key;
4465
+ session.identityUnchecked = false;
4224
4466
  answer({ type: "identity.bound", ...echo });
4225
4467
  logger.info("identity.bound", {
4226
4468
  address: shortAddress(addressId),
@@ -4242,6 +4484,7 @@ function startRelay(options, hooks) {
4242
4484
  return;
4243
4485
  }
4244
4486
  session.identity = undefined;
4487
+ session.identityUnchecked = false;
4245
4488
  failPending(session, "The page's user signed out.");
4246
4489
  try {
4247
4490
  const grants = await store.listGrants(addressId, Date.now());
@@ -4253,10 +4496,7 @@ function startRelay(options, hooks) {
4253
4496
  for (const g of grants)
4254
4497
  grantRemoved(addressId, g.grantId, "revoked");
4255
4498
  } catch (error) {
4256
- logger.error("store.error", {
4257
- op: "signOut",
4258
- message: error instanceof Error ? error.message : "unknown"
4259
- });
4499
+ logger.error("store.error", { op: "signOut", error: errorName(error) });
4260
4500
  }
4261
4501
  },
4262
4502
  claimAddress(session) {
@@ -4278,14 +4518,16 @@ function startRelay(options, hooks) {
4278
4518
  const id = session.addressId;
4279
4519
  if (id === undefined)
4280
4520
  return;
4281
- if (session.appName !== undefined) {
4282
- appNames.delete(id);
4283
- appNames.set(id, session.appName);
4284
- if (appNames.size > config.maxSessions * 2) {
4285
- const oldest = appNames.keys().next().value;
4286
- if (oldest !== undefined)
4287
- appNames.delete(oldest);
4288
- }
4521
+ const appName = session.appName ?? lastSeen.get(id)?.appName;
4522
+ lastSeen.delete(id);
4523
+ lastSeen.set(id, {
4524
+ appName,
4525
+ maxConcurrentRequests: session.limits.maxConcurrentRequests
4526
+ });
4527
+ if (lastSeen.size > config.maxSessions * 2) {
4528
+ const oldest = lastSeen.keys().next().value;
4529
+ if (oldest !== undefined)
4530
+ lastSeen.delete(oldest);
4289
4531
  }
4290
4532
  const waiting = waiters.get(id);
4291
4533
  if (waiting !== undefined)
@@ -4293,9 +4535,11 @@ function startRelay(options, hooks) {
4293
4535
  wake(session);
4294
4536
  }
4295
4537
  };
4296
- function failPending(session, message) {
4538
+ function failPending(session, message, transient = false) {
4539
+ const addressId = session.addressId;
4540
+ const headers = transient ? { "retry-after": RETRY_AFTER_S } : addressId === undefined ? {} : { "www-authenticate": wwwAuthenticate(addressId, true) };
4297
4541
  for (const exchange of [...session.pending.values()]) {
4298
- exchange.fail(401, RelayErrorCode.signedOut, message, true);
4542
+ exchange.fail(transient ? 503 : 401, RelayErrorCode.signedOut, message, true, headers);
4299
4543
  }
4300
4544
  }
4301
4545
  function readySession(addressId, usable = () => true) {
@@ -4309,11 +4553,12 @@ function startRelay(options, hooks) {
4309
4553
  const now = readySession(addressId, usable);
4310
4554
  if (now !== undefined)
4311
4555
  return Promise.resolve(now);
4312
- if (waitMs === 0 || stopping !== undefined) {
4556
+ if (waitMs === 0 || closing) {
4313
4557
  return Promise.resolve(undefined);
4314
4558
  }
4315
4559
  const set = waiters.get(addressId) ?? new Set;
4316
- if (set.size >= config.maxConcurrentRequests || waitingCount >= config.maxSessions) {
4560
+ const limit = sessions.get(addressId)?.limits.maxConcurrentRequests ?? lastSeen.get(addressId)?.maxConcurrentRequests ?? config.maxConcurrentRequests;
4561
+ if (set.size >= limit || waitingCount >= config.maxSessions) {
4317
4562
  return Promise.resolve(undefined);
4318
4563
  }
4319
4564
  return new Promise((resolve) => {
@@ -4401,6 +4646,11 @@ function startRelay(options, hooks) {
4401
4646
  supported: [SUBPROTOCOL]
4402
4647
  });
4403
4648
  }
4649
+ const address = rawAddressParam(new URL(req.url).search);
4650
+ if (address === false) {
4651
+ logger.info("tab.refused", { reason: "bad_address" });
4652
+ return json(400, { error: "bad_address" });
4653
+ }
4404
4654
  const realIp = clientIp(req, server);
4405
4655
  const ip = ipPrefixKey(realIp);
4406
4656
  if (allSessions.size >= config.maxSessions) {
@@ -4417,7 +4667,12 @@ function startRelay(options, hooks) {
4417
4667
  }
4418
4668
  perIp.set(ip, count + 1);
4419
4669
  const ok = server.upgrade(req, {
4420
- data: { ip, origin: origin ?? "", site },
4670
+ data: {
4671
+ ip,
4672
+ origin: origin ?? "",
4673
+ site,
4674
+ ...address === undefined ? {} : { address }
4675
+ },
4421
4676
  headers: { "Sec-WebSocket-Protocol": SUBPROTOCOL }
4422
4677
  });
4423
4678
  if (ok)
@@ -4442,10 +4697,7 @@ function startRelay(options, hooks) {
4442
4697
  try {
4443
4698
  record = await store.get(hashToken(token));
4444
4699
  } catch (error) {
4445
- logger.error("store.error", {
4446
- op: "get",
4447
- message: error instanceof Error ? error.message : "unknown"
4448
- });
4700
+ logger.error("store.error", { op: "get", error: errorName(error) });
4449
4701
  return "unavailable";
4450
4702
  }
4451
4703
  if (record === undefined || record.kind !== "access" || record.addressId !== addressId || record.expiresAt <= Date.now() || !record.scopes.includes("tools")) {
@@ -4466,8 +4718,12 @@ function startRelay(options, hooks) {
4466
4718
  const deadline = new Promise((resolve) => {
4467
4719
  timer = setTimeout(() => resolve("timeout"), config.bodyTimeoutMs);
4468
4720
  });
4469
- const result = await Promise.race([reading, deadline]);
4470
- clearTimeout(timer);
4721
+ let result;
4722
+ try {
4723
+ result = await Promise.race([reading, deadline]);
4724
+ } finally {
4725
+ clearTimeout(timer);
4726
+ }
4471
4727
  if (result === "timeout") {
4472
4728
  return rpcError(408, undefined, -32600, "The request body did not arrive in time.", { ...corsHeaders, connection: "close" });
4473
4729
  }
@@ -4537,7 +4793,7 @@ function startRelay(options, hooks) {
4537
4793
  const auth = await authenticate(req, addressId);
4538
4794
  if (auth === "unavailable") {
4539
4795
  logDone(503, method, { reason: "store_unavailable" });
4540
- return rpcError(503, rpcId, RelayErrorCode.storeUnavailable, "The relay cannot check tokens right now. Try again shortly.", { ...corsHeaders, "retry-after": "5" });
4796
+ return rpcError(503, rpcId, RelayErrorCode.storeUnavailable, "The relay cannot check tokens right now. Try again shortly.", { ...corsHeaders, "retry-after": RETRY_AFTER_S });
4541
4797
  }
4542
4798
  if (typeof auth === "string") {
4543
4799
  logDone(401, method);
@@ -4569,12 +4825,9 @@ function startRelay(options, hooks) {
4569
4825
  logDone(499, method, { outcome: "client_closed" });
4570
4826
  return new Response(null, { status: 499 });
4571
4827
  };
4828
+ const idle = (on) => server.timeout(req, on ? config.httpIdleTimeoutS : 0);
4572
4829
  const usable = usableFor(auth.subject);
4573
- if (readySession(addressId, usable) === undefined && waitMs > 0) {
4574
- server.timeout(req, 0);
4575
- }
4576
- let retried = false;
4577
- for (;; ) {
4830
+ const admit = async () => {
4578
4831
  const found = await waitForPage(addressId, req.signal, waitMs, usable);
4579
4832
  if (found === "aborted" || req.signal.aborted)
4580
4833
  return closed();
@@ -4597,14 +4850,30 @@ function startRelay(options, hooks) {
4597
4850
  logDone(429, method);
4598
4851
  return rpcError(429, rpcId, RelayErrorCode.overloaded, "Too many requests at once for this page.", corsHeaders);
4599
4852
  }
4600
- server.timeout(req, 0);
4853
+ if (isListen && session.listenStreams(auth.grantId) >= MAX_LISTEN_STREAMS_PER_GRANT) {
4854
+ logDone(429, method, { reason: "listen_streams" });
4855
+ return rpcError(429, rpcId, RelayErrorCode.overloaded, "Too many listen streams at once for this AI app.", corsHeaders);
4856
+ }
4857
+ return session;
4858
+ };
4859
+ if (readySession(addressId, usable) === undefined && waitMs > 0) {
4860
+ idle(false);
4861
+ }
4862
+ let retried = false;
4863
+ for (;; ) {
4864
+ const admitted = await admit();
4865
+ if (admitted instanceof Response) {
4866
+ idle(true);
4867
+ return admitted;
4868
+ }
4869
+ const session = admitted;
4870
+ idle(false);
4601
4871
  let retry = () => {};
4602
4872
  const retrying = new Promise((resolve) => {
4603
4873
  retry = () => resolve(RETRY);
4604
4874
  });
4605
4875
  const mayRetry = !retried && waitMs > 0 && isRetryable(body.value["method"]);
4606
4876
  const exchange = session.forward({
4607
- method: "POST",
4608
4877
  headers,
4609
4878
  body: body.text,
4610
4879
  rpcId,
@@ -4618,6 +4887,7 @@ function startRelay(options, hooks) {
4618
4887
  onDone: (e) => {
4619
4888
  if (e.outcome === "page_gone_retry")
4620
4889
  return;
4890
+ idle(true);
4621
4891
  logDone(e.status, method, {
4622
4892
  session: session.sessionId,
4623
4893
  client: clientHostName,
@@ -4630,8 +4900,18 @@ function startRelay(options, hooks) {
4630
4900
  retry();
4631
4901
  return { retry: true };
4632
4902
  }
4633
- const answer = notOpenAnswer(body.value, rpcId, session.appName, "gone", RELAY_INFO);
4634
- return answer === undefined ? { retry: false } : { retry: false, body: answer };
4903
+ const answer = notOpenAnswer(body.value, rpcId, session.appName, closing ? "restarting_gone" : "gone", RELAY_INFO);
4904
+ if (answer !== undefined)
4905
+ return { retry: false, body: answer };
4906
+ return !closing ? { retry: false } : {
4907
+ retry: false,
4908
+ error: {
4909
+ status: 503,
4910
+ code: RelayErrorCode.restarting,
4911
+ message: "The relay is restarting.",
4912
+ headers: { "retry-after": RETRY_AFTER_S }
4913
+ }
4914
+ };
4635
4915
  }
4636
4916
  });
4637
4917
  const onAbort = () => exchange.clientClosed();
@@ -4645,13 +4925,21 @@ function startRelay(options, hooks) {
4645
4925
  }
4646
4926
  function pageNotOpen(value, rpcId, addressId, logDone, method) {
4647
4927
  const open = sessions.get(addressId);
4648
- const phase = open?.ready === true && open.identity === undefined && open.site.pairing === "oidc" ? "signed_out" : "not_open";
4649
- const answer = notOpenAnswer(value, rpcId, open?.appName ?? appNames.get(addressId), phase, RELAY_INFO);
4928
+ if (!closing && open?.ready === true && open.identity === undefined && open.identityUnchecked) {
4929
+ logDone(503, method, { reason: "identity_unchecked" });
4930
+ return rpcError(503, rpcId, RelayErrorCode.signedOut, UNCHECKED_TEXT, {
4931
+ ...corsHeaders,
4932
+ "retry-after": RETRY_AFTER_S
4933
+ });
4934
+ }
4935
+ const phase = closing ? "restarting" : open?.ready === true && open.identity === undefined && open.site.pairing === "oidc" ? "signed_out" : "not_open";
4936
+ const reason = phase === "restarting" ? "restarting" : "page_not_open";
4937
+ const answer = notOpenAnswer(value, rpcId, open?.appName ?? lastSeen.get(addressId)?.appName, phase, RELAY_INFO);
4650
4938
  if (answer === undefined) {
4651
- logDone(503, method, { reason: "page_not_open" });
4652
- return rpcError(503, rpcId, RelayErrorCode.pageNotOpen, "The page is not open.", corsHeaders);
4939
+ logDone(503, method, { reason });
4940
+ return phase === "restarting" ? rpcError(503, rpcId, RelayErrorCode.restarting, "The relay is restarting.", { ...corsHeaders, "retry-after": RETRY_AFTER_S }) : rpcError(503, rpcId, RelayErrorCode.pageNotOpen, "The page is not open.", corsHeaders);
4653
4941
  }
4654
- logDone(200, method, { reason: "page_not_open" });
4942
+ logDone(200, method, { reason });
4655
4943
  return new Response(answer, {
4656
4944
  status: 200,
4657
4945
  headers: { ...corsHeaders, "content-type": "application/json" }
@@ -4676,6 +4964,56 @@ function startRelay(options, hooks) {
4676
4964
  headers: { ...corsHeaders, allow: "POST" }
4677
4965
  });
4678
4966
  const postOnly = () => new Response(null, { status: 405, headers: { allow: "POST, OPTIONS" } });
4967
+ const healthRetryMs = hooks?.healthRetryMs ?? HEALTH_HUNG_RETRY_MS;
4968
+ let probe;
4969
+ let probed;
4970
+ let hungReads = 0;
4971
+ let lastHungAt = 0;
4972
+ async function runProbe() {
4973
+ let timer;
4974
+ const deadline = new Promise((resolve) => {
4975
+ timer = setTimeout(() => resolve("store_timeout"), HEALTH_STORE_TIMEOUT_MS);
4976
+ });
4977
+ const read = Promise.resolve().then(() => store.get(HEALTH_PROBE_HASH)).then(() => "ok");
4978
+ read.catch(() => {});
4979
+ try {
4980
+ const result = await Promise.race([read, deadline]);
4981
+ if (result === "store_timeout") {
4982
+ logger.warn("health.store", { error: "timeout" });
4983
+ hungReads++;
4984
+ lastHungAt = Date.now();
4985
+ const settled = () => {
4986
+ hungReads--;
4987
+ };
4988
+ read.then(settled, settled);
4989
+ }
4990
+ return result;
4991
+ } catch (error) {
4992
+ logger.warn("health.store", { error: errorName(error) });
4993
+ return "store_unavailable";
4994
+ } finally {
4995
+ clearTimeout(timer);
4996
+ }
4997
+ }
4998
+ function storeHealth() {
4999
+ if (probe !== undefined)
5000
+ return probe;
5001
+ const now = Date.now();
5002
+ if (probed !== undefined && now - probed.at < HEALTH_REUSE_MS) {
5003
+ return Promise.resolve(probed.result);
5004
+ }
5005
+ if (hungReads > 0 && now - lastHungAt < healthRetryMs) {
5006
+ return Promise.resolve("store_timeout");
5007
+ }
5008
+ const current = runProbe().catch(() => "store_unavailable");
5009
+ probe = current;
5010
+ current.then((result) => {
5011
+ probed = { at: Date.now(), result };
5012
+ if (probe === current)
5013
+ probe = undefined;
5014
+ });
5015
+ return current;
5016
+ }
4679
5017
  const largest = largestMessageBytes(config);
4680
5018
  const ipOf = (req, srv) => ipPrefixKey(clientIp(req, srv));
4681
5019
  const server = Bun.serve({
@@ -4686,11 +5024,17 @@ function startRelay(options, hooks) {
4686
5024
  idleTimeout: config.httpIdleTimeoutS,
4687
5025
  routes: {
4688
5026
  "/healthz": {
4689
- GET: () => json(200, {
4690
- status: "ok",
4691
- version: VERSION,
4692
- counters: { claudeHeaderWorkaround: claudeWorkarounds }
4693
- }, { "cache-control": "no-store" })
5027
+ GET: async () => {
5028
+ const store = await storeHealth();
5029
+ if (store !== "ok") {
5030
+ return json(503, { status: "unhealthy", reason: store, version: VERSION }, { "cache-control": "no-store" });
5031
+ }
5032
+ return json(200, {
5033
+ status: "ok",
5034
+ version: VERSION,
5035
+ counters: { claudeHeaderWorkaround: claudeWorkarounds }
5036
+ }, { "cache-control": "no-store" });
5037
+ }
4694
5038
  },
4695
5039
  "/": {
4696
5040
  GET: () => new Response(INDEX_HTML, {
@@ -4746,14 +5090,14 @@ function startRelay(options, hooks) {
4746
5090
  fetch: () => new Response("Not found", { status: 404 }),
4747
5091
  websocket: {
4748
5092
  data: {},
4749
- maxPayloadLength: largest * 4,
5093
+ maxPayloadLength: largest * FRAME_HEADROOM,
4750
5094
  backpressureLimit: config.backpressureLimitBytes,
4751
5095
  closeOnBackpressureLimit: false,
4752
5096
  idleTimeout: 120,
4753
5097
  sendPings: true,
4754
5098
  perMessageDeflate: false,
4755
5099
  open(ws) {
4756
- const session = new TabSession(ws, ws.data.ip, ws.data.origin, ws.data.site, host);
5100
+ const session = new TabSession(ws, ws.data.ip, ws.data.origin, ws.data.site, host, ws.data.address);
4757
5101
  ws.data.session = session;
4758
5102
  allSessions.add(session);
4759
5103
  },
@@ -4766,6 +5110,7 @@ function startRelay(options, hooks) {
4766
5110
  }
4767
5111
  });
4768
5112
  let stopping;
5113
+ let closing = false;
4769
5114
  const port = server.port ?? config.port;
4770
5115
  if (publicUrl === "")
4771
5116
  publicUrl = `http://localhost:${port}`;
@@ -4776,7 +5121,7 @@ function startRelay(options, hooks) {
4776
5121
  logger.debug("store.swept", { deleted: n });
4777
5122
  }).catch((error) => logger.error("store.error", {
4778
5123
  op: "deleteExpired",
4779
- message: error instanceof Error ? error.message : "unknown"
5124
+ error: errorName(error)
4780
5125
  }));
4781
5126
  }, config.sweepIntervalMs);
4782
5127
  const siteCount = config.allowedOrigins === "*" ? "*" : config.allowedOrigins.length + config.sites.length;
@@ -4805,6 +5150,7 @@ function startRelay(options, hooks) {
4805
5150
  return allSessions.size;
4806
5151
  },
4807
5152
  stop() {
5153
+ closing = true;
4808
5154
  stopping ??= (async () => {
4809
5155
  clearInterval(sweep);
4810
5156
  for (const set of [...waiters.values()]) {
@@ -4916,5 +5262,5 @@ var shutdown = () => {
4916
5262
  process.on("SIGTERM", shutdown);
4917
5263
  process.on("SIGINT", shutdown);
4918
5264
 
4919
- //# debugId=AF054075C7A2C5E764756E2164756E21
5265
+ //# debugId=ED4BD668E1904D7C64756E2164756E21
4920
5266
  //# sourceMappingURL=main.js.map