@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/index.js CHANGED
@@ -751,9 +751,11 @@ class TabAssertionVerifier {
751
751
  #seen = new Map;
752
752
  #perPerson = new Map;
753
753
  #sweptAt = Number.NEGATIVE_INFINITY;
754
- constructor(oidc, keys) {
754
+ #maxSeen;
755
+ constructor(oidc, keys, maxSeen = MAX_SEEN_JTI) {
755
756
  this.#oidc = oidc;
756
757
  this.#keys = keys ?? new JwksCache(oidc.assertionJwksUrl);
758
+ this.#maxSeen = maxSeen;
757
759
  }
758
760
  async verify(token, address, now = Date.now()) {
759
761
  const { payload } = await verifyJwt(token, this.#keys, {
@@ -799,15 +801,18 @@ ${jti}`;
799
801
  throw new JwtError("replayed", "the assertion was already used");
800
802
  }
801
803
  const mine = this.#perPerson.get(key) ?? 0;
802
- if (mine >= MAX_JTI_PER_PERSON || this.#seen.size >= MAX_SEEN_JTI) {
804
+ if (mine >= MAX_JTI_PER_PERSON) {
803
805
  throw new JwtError("busy", "too many assertions at once");
804
806
  }
807
+ if (this.#seen.size >= this.#maxSeen) {
808
+ throw new JwtError("relay_busy", "the relay is checking too many assertions; try again shortly");
809
+ }
805
810
  this.#seen.set(seenKey, exp + CLOCK_LEEWAY_MS);
806
811
  this.#perPerson.set(key, mine + 1);
807
812
  return key;
808
813
  }
809
814
  #sweep(now) {
810
- if (now - this.#sweptAt < 1000 && this.#seen.size < MAX_SEEN_JTI)
815
+ if (now - this.#sweptAt < 1000 && this.#seen.size < this.#maxSeen)
811
816
  return;
812
817
  this.#sweptAt = now;
813
818
  for (const [seenKey, until] of this.#seen) {
@@ -997,13 +1002,15 @@ function truncateIp(ip) {
997
1002
  }
998
1003
  return `${v6.slice(0, 3).map((g) => g.toString(16)).join(":")}::/48`;
999
1004
  }
1005
+ function errorName(error) {
1006
+ return error instanceof Error ? error.name : typeof error;
1007
+ }
1000
1008
  function installCrashLogging(logger, exit = (code) => process.exit(code)) {
1001
- const name = (error) => error instanceof Error ? error.name : typeof error;
1002
1009
  const onRejection = (error) => {
1003
- logger.error("process.unhandled_rejection", { error: name(error) });
1010
+ logger.error("process.unhandled_rejection", { error: errorName(error) });
1004
1011
  };
1005
1012
  const onException = (error) => {
1006
- logger.error("process.uncaught_exception", { error: name(error) });
1013
+ logger.error("process.uncaught_exception", { error: errorName(error) });
1007
1014
  exit(1);
1008
1015
  };
1009
1016
  process.on("unhandledRejection", onRejection);
@@ -1026,7 +1033,8 @@ var CloseCode = {
1026
1033
  heartbeatLost: 4003,
1027
1034
  shuttingDown: 4004,
1028
1035
  replaced: 4005,
1029
- rateLimited: 4008
1036
+ rateLimited: 4008,
1037
+ addressMismatch: 4009
1030
1038
  };
1031
1039
  var MAX_TITLE = 300;
1032
1040
  var MAX_NAME = 200;
@@ -1088,6 +1096,10 @@ function parseTabMessage(text) {
1088
1096
  if (app !== undefined && (!isObject(app) || !isAppName(app["name"]))) {
1089
1097
  return bad(`hello.app.name must be 1-${MAX_APP_NAME} characters`);
1090
1098
  }
1099
+ const revokeAll = v["revokeAll"];
1100
+ if (revokeAll !== undefined && typeof revokeAll !== "boolean") {
1101
+ return bad("hello.revokeAll must be a boolean");
1102
+ }
1091
1103
  const hello = {
1092
1104
  type,
1093
1105
  library: { name: library["name"], version: library["version"] },
@@ -1097,6 +1109,8 @@ function parseTabMessage(text) {
1097
1109
  if (isObject(app) && isAppName(app["name"])) {
1098
1110
  hello.app = { name: app["name"] };
1099
1111
  }
1112
+ if (revokeAll === true)
1113
+ hello.revokeAll = true;
1100
1114
  return { ok: true, message: hello };
1101
1115
  }
1102
1116
  case "pong": {
@@ -1191,7 +1205,7 @@ function parseTabMessage(text) {
1191
1205
  }
1192
1206
  }
1193
1207
  // package.json
1194
- var version = "0.3.1";
1208
+ var version = "0.5.0";
1195
1209
 
1196
1210
  // src/exchange.ts
1197
1211
  var RelayErrorCode = {
@@ -1203,7 +1217,8 @@ var RelayErrorCode = {
1203
1217
  tooLarge: -31006,
1204
1218
  badResponse: -31007,
1205
1219
  storeUnavailable: -31008,
1206
- signedOut: -31009
1220
+ signedOut: -31009,
1221
+ restarting: -31010
1207
1222
  };
1208
1223
  var encoder = new TextEncoder;
1209
1224
  var NULL_BODY_STATUSES = new Set([204, 205, 304]);
@@ -1232,6 +1247,11 @@ function rpcErrorBody(rpcId, code, message, data) {
1232
1247
  error["data"] = data;
1233
1248
  return JSON.stringify({ jsonrpc: "2.0", id: rpcId ?? null, error });
1234
1249
  }
1250
+ var TAB_STARTED = new Set([
1251
+ "tab_stream",
1252
+ "relay_passed",
1253
+ "relay_wrapped"
1254
+ ]);
1235
1255
 
1236
1256
  class Exchange {
1237
1257
  id;
@@ -1243,9 +1263,6 @@ class Exchange {
1243
1263
  #options;
1244
1264
  #resolve;
1245
1265
  #state = "waiting";
1246
- #relayStarted = false;
1247
- #tabStarted = false;
1248
- #wrap = false;
1249
1266
  #wrapped = "";
1250
1267
  #controller;
1251
1268
  #atBoundary = true;
@@ -1271,7 +1288,7 @@ class Exchange {
1271
1288
  return this.#state === "done";
1272
1289
  }
1273
1290
  onResponse(msg) {
1274
- if (this.#tabStarted) {
1291
+ if (TAB_STARTED.has(this.#state)) {
1275
1292
  this.#badResponse("http.response after http.response.start");
1276
1293
  return;
1277
1294
  }
@@ -1294,7 +1311,7 @@ class Exchange {
1294
1311
  }
1295
1312
  this.#respond(response, msg.status);
1296
1313
  this.#finish("ok");
1297
- } else if (this.#state === "streaming" && this.#relayStarted) {
1314
+ } else if (this.#state === "relay_stream") {
1298
1315
  if (!this.#account(msg.body))
1299
1316
  return;
1300
1317
  if (msg.body !== "")
@@ -1303,11 +1320,17 @@ class Exchange {
1303
1320
  }
1304
1321
  }
1305
1322
  onStart(msg) {
1306
- if (this.#tabStarted) {
1323
+ if (this.#state === "done")
1324
+ return;
1325
+ if (TAB_STARTED.has(this.#state)) {
1307
1326
  this.#badResponse("http.response.start sent twice");
1308
1327
  return;
1309
1328
  }
1310
- this.#tabStarted = true;
1329
+ const eventStream = isEventStream(msg.headers);
1330
+ if (this.isListen && !eventStream) {
1331
+ this.#badResponse("a listen response must be an event stream");
1332
+ return;
1333
+ }
1311
1334
  if (this.#state === "waiting") {
1312
1335
  if (NULL_BODY_STATUSES.has(msg.status)) {
1313
1336
  this.#badResponse(`status ${msg.status} cannot be streamed`);
@@ -1320,25 +1343,29 @@ class Exchange {
1320
1343
  this.#badResponse("the response headers are invalid");
1321
1344
  return;
1322
1345
  }
1323
- this.#openStream(msg.status, headers);
1324
- } else if (this.#state === "streaming" && this.#relayStarted) {
1325
- this.#wrap = !isEventStream(msg.headers);
1346
+ this.#openStream(msg.status, headers, "tab_stream");
1347
+ } else {
1348
+ this.#state = eventStream ? "relay_passed" : "relay_wrapped";
1326
1349
  }
1327
1350
  }
1328
1351
  onChunk(data) {
1329
- if (this.#state !== "streaming")
1352
+ if (!this.#streaming)
1330
1353
  return;
1354
+ if (this.#state === "relay_stream") {
1355
+ this.#badResponse("http.response.chunk before http.response.start");
1356
+ return;
1357
+ }
1331
1358
  if (!this.#account(data))
1332
1359
  return;
1333
- if (this.#wrap)
1360
+ if (this.#state === "relay_wrapped")
1334
1361
  this.#wrapped += data;
1335
1362
  else
1336
1363
  this.#write(data);
1337
1364
  }
1338
1365
  onEnd() {
1339
- if (this.#state !== "streaming")
1366
+ if (!this.#streaming)
1340
1367
  return;
1341
- if (this.#wrap && this.#wrapped !== "")
1368
+ if (this.#state === "relay_wrapped" && this.#wrapped !== "")
1342
1369
  this.#write(sseEvent(this.#wrapped));
1343
1370
  this.#finish("ok");
1344
1371
  }
@@ -1360,6 +1387,11 @@ class Exchange {
1360
1387
  this.#finish("page_gone_retry");
1361
1388
  return;
1362
1389
  }
1390
+ if (reply !== undefined && "error" in reply) {
1391
+ const e = reply.error;
1392
+ this.fail(e.status, e.code, e.message, false, e.headers);
1393
+ return;
1394
+ }
1363
1395
  const body = reply?.retry === false ? reply.body : undefined;
1364
1396
  if (body === undefined) {
1365
1397
  this.fail(503, RelayErrorCode.tabGone, "The page closed its connection to the relay.", false);
@@ -1375,11 +1407,11 @@ class Exchange {
1375
1407
  }
1376
1408
  this.#finish("page_gone");
1377
1409
  }
1378
- fail(status, code, message, cancelTab) {
1410
+ fail(status, code, message, cancelTab, headers = {}) {
1379
1411
  if (this.#state === "done")
1380
1412
  return;
1381
1413
  if (this.#state === "waiting") {
1382
- this.#respond(this.#errorResponse(status, code, message), status);
1414
+ this.#respond(this.#errorResponse(status, code, message, headers), status);
1383
1415
  } else if (this.#atBoundary && this.#options.rpcId !== undefined) {
1384
1416
  this.#write(sseEvent(rpcErrorBody(this.#options.rpcId, code, message)));
1385
1417
  }
@@ -1393,7 +1425,8 @@ class Exchange {
1393
1425
  }
1394
1426
  #account(data) {
1395
1427
  this.#bodyBytes += Buffer.byteLength(data);
1396
- if (this.isListen || this.#bodyBytes <= this.#options.maxBodyBytes) {
1428
+ const unbounded = this.isListen && (this.#state === "tab_stream" || this.#state === "relay_passed");
1429
+ if (unbounded || this.#bodyBytes <= this.#options.maxBodyBytes) {
1397
1430
  return true;
1398
1431
  }
1399
1432
  this.fail(502, RelayErrorCode.tooLarge, "The page sent a response that is too large.", true);
@@ -1408,10 +1441,9 @@ class Exchange {
1408
1441
  #startRelayStream() {
1409
1442
  if (this.#state !== "waiting")
1410
1443
  return;
1411
- this.#relayStarted = true;
1412
- this.#openStream(200, this.#headers({ "content-type": "text/event-stream" }));
1444
+ this.#openStream(200, this.#headers({ "content-type": "text/event-stream" }), "relay_stream");
1413
1445
  }
1414
- #openStream(status, headers) {
1446
+ #openStream(status, headers, state) {
1415
1447
  const stream = new ReadableStream({
1416
1448
  start: (controller) => {
1417
1449
  this.#controller = controller;
@@ -1432,12 +1464,12 @@ class Exchange {
1432
1464
  this.#badResponse("the response could not be built");
1433
1465
  return;
1434
1466
  }
1435
- this.#state = "streaming";
1467
+ this.#state = state;
1436
1468
  this.#respond(response, status);
1437
1469
  this.#scheduleKeepAlive();
1438
1470
  }
1439
1471
  #write(text) {
1440
- if (this.#state !== "streaming" || this.#controller === undefined || text === "")
1472
+ if (!this.#streaming || this.#controller === undefined || text === "")
1441
1473
  return;
1442
1474
  try {
1443
1475
  this.#controller.enqueue(encoder.encode(text));
@@ -1454,16 +1486,19 @@ class Exchange {
1454
1486
  }
1455
1487
  this.#scheduleKeepAlive();
1456
1488
  }
1489
+ get #streaming() {
1490
+ return this.#state !== "waiting" && this.#state !== "done";
1491
+ }
1457
1492
  #scheduleKeepAlive() {
1458
1493
  clearTimeout(this.#keepAliveTimer);
1459
- if (this.#state !== "streaming" || this.#wrap) {
1460
- if (this.#state === "streaming") {
1461
- this.#keepAliveTimer = setTimeout(() => {
1462
- this.#write(`: keep-alive
1494
+ if (!this.#streaming)
1495
+ return;
1496
+ if (this.#state === "relay_wrapped") {
1497
+ this.#keepAliveTimer = setTimeout(() => {
1498
+ this.#write(`: keep-alive
1463
1499
 
1464
1500
  `);
1465
- }, this.#options.keepAliveMs);
1466
- }
1501
+ }, this.#options.keepAliveMs);
1467
1502
  return;
1468
1503
  }
1469
1504
  this.#keepAliveTimer = setTimeout(() => {
@@ -1483,8 +1518,10 @@ class Exchange {
1483
1518
  }
1484
1519
  return headers;
1485
1520
  }
1486
- #errorResponse(status, code, message) {
1521
+ #errorResponse(status, code, message, extra) {
1487
1522
  const headers = this.#headers({ "content-type": "application/json" });
1523
+ for (const [k, v] of Object.entries(extra))
1524
+ headers.set(k, v);
1488
1525
  return new Response(rpcErrorBody(this.#options.rpcId, code, message), {
1489
1526
  status,
1490
1527
  headers
@@ -1498,7 +1535,7 @@ class Exchange {
1498
1535
  #finish(outcome, closeStream = true) {
1499
1536
  if (this.#state === "done")
1500
1537
  return;
1501
- const wasStreaming = this.#state === "streaming";
1538
+ const wasStreaming = this.#streaming;
1502
1539
  this.#state = "done";
1503
1540
  this.outcome = outcome;
1504
1541
  clearTimeout(this.#startTimer);
@@ -1527,19 +1564,28 @@ function notOpenText(appName) {
1527
1564
  function signedOutText(appName) {
1528
1565
  return `The page is open, but nobody is signed in to it. Ask the user to sign in to ${pageName(appName)}, then try again.`;
1529
1566
  }
1567
+ function restartingText() {
1568
+ return "The relay is restarting. Try again in a few seconds.";
1569
+ }
1530
1570
  function phaseText(phase, appName) {
1571
+ if (phase === "restarting" || phase === "restarting_gone") {
1572
+ return restartingText();
1573
+ }
1531
1574
  return phase === "signed_out" ? signedOutText(appName) : notOpenText(appName);
1532
1575
  }
1533
1576
  function goneDuringCallText(appName) {
1534
1577
  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.`;
1535
1578
  }
1579
+ function restartedDuringCallText() {
1580
+ 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.";
1581
+ }
1536
1582
  function notOpenAnswer(body, rpcId, appName, phase, relay) {
1537
1583
  if (rpcId === undefined)
1538
1584
  return;
1539
1585
  const id = rpcId;
1540
1586
  const method = body["method"];
1541
1587
  if (method === "tools/call") {
1542
- const text = phase === "gone" ? goneDuringCallText(appName) : phaseText(phase, appName);
1588
+ const text = phase === "gone" ? goneDuringCallText(appName) : phase === "restarting_gone" ? restartedDuringCallText() : phaseText(phase, appName);
1543
1589
  return JSON.stringify({
1544
1590
  jsonrpc: "2.0",
1545
1591
  id,
@@ -1574,7 +1620,7 @@ function notOpenAnswer(body, rpcId, appName, phase, relay) {
1574
1620
  jsonrpc: "2.0",
1575
1621
  id,
1576
1622
  error: {
1577
- code: phase === "gone" ? RelayErrorCode.tabGone : RelayErrorCode.pageNotOpen,
1623
+ code: phase === "gone" ? RelayErrorCode.tabGone : phase === "restarting" || phase === "restarting_gone" ? RelayErrorCode.restarting : RelayErrorCode.pageNotOpen,
1578
1624
  message: phaseText(phase, appName)
1579
1625
  }
1580
1626
  });
@@ -1614,6 +1660,13 @@ class RateLimiter {
1614
1660
  bucket.tokens -= 1;
1615
1661
  return true;
1616
1662
  }
1663
+ available(key, now = Date.now()) {
1664
+ const bucket = this.#buckets.get(key);
1665
+ if (bucket === undefined)
1666
+ return this.#capacity >= 1;
1667
+ const elapsed = Math.max(0, now - bucket.updatedAt) / 1000;
1668
+ return Math.min(this.#capacity, bucket.tokens + elapsed * this.#refill) >= 1;
1669
+ }
1617
1670
  sweep(now = Date.now()) {
1618
1671
  for (const [key, b] of this.#buckets) {
1619
1672
  const elapsed = Math.max(0, now - b.updatedAt) / 1000;
@@ -1699,7 +1752,7 @@ async function defaultLookup(hostname) {
1699
1752
  const records = await Bun.dns.lookup(hostname, {});
1700
1753
  return records.map((r) => r.address);
1701
1754
  }
1702
- var INVISIBLE = /[\u034F\u115F\u1160\u17B4\u17B5\u2800\u3164\uFFA0]/gu;
1755
+ var INVISIBLE = /[\u034F\u115F\u1160\u17B4\u17B5\u2800\u3164\uFFA0]|\p{Variation_Selector}/gu;
1703
1756
  function displayName(raw) {
1704
1757
  let cleaned = raw.normalize("NFC").replace(/[\p{Cc}\p{Zl}\p{Zp}]/gu, " ").replace(/\p{Cf}/gu, "").replace(INVISIBLE, "");
1705
1758
  cleaned = cleaned.replace(/\s+/g, " ").trim();
@@ -1722,10 +1775,17 @@ function isIpLiteral(hostname) {
1722
1775
  const h = hostname.replace(/^\[|\]$/g, "");
1723
1776
  return parseIpv4(h) !== undefined || parseIpv6(h) !== undefined;
1724
1777
  }
1778
+ function limitKey(hostname) {
1779
+ const h = hostname.replace(/^\[|\]$/g, "").replace(/\.$/, "");
1780
+ if (isIpLiteral(h))
1781
+ return h;
1782
+ return h.split(".").slice(-2).join(".");
1783
+ }
1725
1784
 
1726
1785
  class ClientMetadataFetcher {
1727
1786
  #cache = new Map;
1728
1787
  #perHost = new RateLimiter({ capacity: 5, refillPerSecond: 0.2 });
1788
+ #perDomain = new RateLimiter({ capacity: 15, refillPerSecond: 0.6 });
1729
1789
  #global = new RateLimiter({ capacity: 30, refillPerSecond: 1 });
1730
1790
  #allowedHosts;
1731
1791
  #logger;
@@ -1749,22 +1809,28 @@ class ClientMetadataFetcher {
1749
1809
  const url = this.#checkUrl(clientId);
1750
1810
  const now = this.#now();
1751
1811
  const cached = this.#cache.get(clientId);
1752
- if (cached !== undefined && cached.until > now)
1812
+ if (cached !== undefined && cached.until > now) {
1813
+ this.#cache.delete(clientId);
1814
+ this.#cache.set(clientId, cached);
1753
1815
  return cached.meta;
1754
- if (!this.#perHost.take(url.host, now)) {
1816
+ }
1817
+ const key = limitKey(url.hostname);
1818
+ if (!this.#perHost.available(url.host, now) || !this.#perDomain.available(key, now)) {
1755
1819
  throw new ClientMetadataError("rate_limited", "Too many requests for this AI app's details. Try again in a minute.");
1756
1820
  }
1757
1821
  if (!this.#global.take("all", now)) {
1758
1822
  throw new ClientMetadataError("rate_limited", "The relay is busy. Try again in a minute.");
1759
1823
  }
1824
+ this.#perHost.take(url.host, now);
1825
+ this.#perDomain.take(key, now);
1760
1826
  const address = await this.#vettedAddress(url);
1761
1827
  const { meta, ttlMs } = await this.#fetchDocument(url, clientId, address);
1828
+ this.#cache.delete(clientId);
1762
1829
  if (this.#cache.size >= MAX_CACHE_ENTRIES) {
1763
1830
  const oldest = this.#cache.keys().next().value;
1764
1831
  if (oldest !== undefined)
1765
1832
  this.#cache.delete(oldest);
1766
1833
  }
1767
- this.#cache.delete(clientId);
1768
1834
  this.#cache.set(clientId, { meta, until: now + ttlMs });
1769
1835
  return meta;
1770
1836
  }
@@ -1899,6 +1965,27 @@ class ClientMetadataFetcher {
1899
1965
  }
1900
1966
  }
1901
1967
  }
1968
+ var DOMAIN_SHAPED = /(?:[\p{L}\p{N}](?:[\p{L}\p{N}-]*[\p{L}\p{N}])?\.)+\p{L}{2,63}(?![\p{L}\p{N}])/gu;
1969
+ function comparable(name) {
1970
+ return name.normalize("NFKC").replace(/\p{M}/gu, "").replace(/[\u3002\uFF0E\uFF61]/g, ".").toLowerCase();
1971
+ }
1972
+ function asciiHost(token) {
1973
+ try {
1974
+ return new URL(`https://${token}/`).hostname;
1975
+ } catch {
1976
+ return token;
1977
+ }
1978
+ }
1979
+ function foreignDomain(name, hostname) {
1980
+ const host = hostname.toLowerCase();
1981
+ for (const [match] of comparable(name).matchAll(DOMAIN_SHAPED)) {
1982
+ const token = asciiHost(match);
1983
+ const own = token === host || host.endsWith(`.${token}`) || token.endsWith(`.${host}`);
1984
+ if (!own)
1985
+ return true;
1986
+ }
1987
+ return false;
1988
+ }
1902
1989
  function validateDocument(doc, clientId, url) {
1903
1990
  const invalid = (message) => new ClientMetadataError("invalid_metadata", message);
1904
1991
  if (typeof doc !== "object" || doc === null || Array.isArray(doc)) {
@@ -1915,9 +2002,12 @@ function validateDocument(doc, clientId, url) {
1915
2002
  if (typeof name !== "string" || displayName(name) === "") {
1916
2003
  throw invalid("The AI app's details have no client_name.");
1917
2004
  }
1918
- if (/\(\s*[^()\s]*[a-z0-9-]\.[a-z]{2,}[^()]*\)/i.test(displayName(name))) {
2005
+ if (/\(\s*[^()\s]*[a-z0-9-]\.[a-z]{2,}[^()]*\)/i.test(comparable(displayName(name)))) {
1919
2006
  throw invalid("The AI app's name contains a web address in parentheses, which this relay does not show.");
1920
2007
  }
2008
+ if (foreignDomain(displayName(name), url.hostname)) {
2009
+ throw invalid("The AI app's name contains a web address other than its own, which this relay does not show.");
2010
+ }
1921
2011
  const uris = d["redirect_uris"];
1922
2012
  if (!Array.isArray(uris) || uris.length === 0 || uris.length > MAX_REDIRECT_URIS || !uris.every((u) => typeof u === "string" && isRedirectUri(u))) {
1923
2013
  throw invalid("The AI app's details have no valid redirect_uris.");
@@ -2132,14 +2222,17 @@ class ProviderDiscovery {
2132
2222
  class OidcPairing {
2133
2223
  kind = "oidc";
2134
2224
  #config;
2225
+ #maxFlows;
2135
2226
  #publicUrl;
2136
2227
  #logger;
2137
2228
  #discovery;
2138
2229
  #jwks;
2139
2230
  #flows = new Map;
2231
+ #flowOf = new Map;
2140
2232
  #mismatched = new Set;
2141
2233
  constructor(options) {
2142
2234
  this.#config = options.config;
2235
+ this.#maxFlows = options.maxFlows ?? MAX_FLOWS;
2143
2236
  this.#publicUrl = options.publicUrl;
2144
2237
  this.#logger = options.logger;
2145
2238
  this.#discovery = new ProviderDiscovery(options.config.issuer, options.development);
@@ -2176,7 +2269,7 @@ class OidcPairing {
2176
2269
  }
2177
2270
  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>`;
2178
2271
  }
2179
- async check(target, _form, _admit, context) {
2272
+ async check(target, _form, admit, context) {
2180
2273
  if (!target.pairingOpen) {
2181
2274
  return {
2182
2275
  ok: false,
@@ -2201,16 +2294,25 @@ class OidcPairing {
2201
2294
  return this.#unavailable(error);
2202
2295
  }
2203
2296
  this.#sweep();
2204
- if (this.#flows.size >= MAX_FLOWS) {
2297
+ this.#dropFlow(context.requestId);
2298
+ if (this.#flows.size >= this.#maxFlows) {
2205
2299
  return {
2206
2300
  ok: false,
2207
2301
  message: "Too many sign-ins are open right now. Wait a few minutes and try again.",
2208
2302
  reason: "busy"
2209
2303
  };
2210
2304
  }
2211
- const state = random2(24);
2305
+ if (!admit()) {
2306
+ return {
2307
+ ok: false,
2308
+ message: "Too many attempts for this page. Wait a few minutes and try again.",
2309
+ reason: "rate_limited"
2310
+ };
2311
+ }
2312
+ const state = `${context.addressId}.${random2(24)}`;
2212
2313
  const nonce = random2(24);
2213
2314
  const verifier = random2(32);
2315
+ this.#flowOf.set(context.requestId, state);
2214
2316
  this.#flows.set(state, {
2215
2317
  requestId: context.requestId,
2216
2318
  browserId: context.browserId,
@@ -2250,6 +2352,7 @@ class OidcPairing {
2250
2352
  }
2251
2353
  this.#flows.delete(state);
2252
2354
  const requestId = flow.requestId;
2355
+ this.#flowOf.delete(requestId);
2253
2356
  const fail = (reason, message) => ({
2254
2357
  requestId,
2255
2358
  result: { ok: false, message, reason }
@@ -2376,12 +2479,25 @@ class OidcPairing {
2376
2479
  reason: "provider_unavailable"
2377
2480
  };
2378
2481
  }
2482
+ forget(requestId) {
2483
+ this.#dropFlow(requestId);
2484
+ this.#mismatched.delete(requestId);
2485
+ }
2486
+ #dropFlow(requestId) {
2487
+ const state = this.#flowOf.get(requestId);
2488
+ if (state !== undefined)
2489
+ this.#flows.delete(state);
2490
+ this.#flowOf.delete(requestId);
2491
+ }
2379
2492
  #sweep() {
2380
2493
  const now = Date.now();
2381
2494
  for (const [state, flow] of this.#flows) {
2382
2495
  if (flow.expiresAt > now)
2383
2496
  break;
2384
2497
  this.#flows.delete(state);
2498
+ if (this.#flowOf.get(flow.requestId) === state) {
2499
+ this.#flowOf.delete(flow.requestId);
2500
+ }
2385
2501
  }
2386
2502
  }
2387
2503
  }
@@ -2519,6 +2635,8 @@ var AUTH_CODE_TTL_MS = 60000;
2519
2635
  var MAX_PENDING = 1e4;
2520
2636
  var MAX_PENDING_PER_IP = 10;
2521
2637
  var MAX_PENDING_PER_ADDRESS = 20;
2638
+ var MAX_PENDING_PER_ADDRESS_IP = 5;
2639
+ var REFRESH_REUSE_WINDOW_MS = 24 * 60 * 60000;
2522
2640
  var MAX_CODES = 1e4;
2523
2641
  var MAX_FORM_BYTES = 8192;
2524
2642
  var MAX_STATE_LENGTH = 1024;
@@ -2599,6 +2717,10 @@ class AuthorizationServer {
2599
2717
  refillPerSecond: 1 / 30
2600
2718
  });
2601
2719
  #tokenPerIp = new RateLimiter({ capacity: 30, refillPerSecond: 1 });
2720
+ #refreshesPerGrant = new RateLimiter({
2721
+ capacity: 20,
2722
+ refillPerSecond: 1 / 60
2723
+ });
2602
2724
  constructor(host, testMode) {
2603
2725
  this.#host = host;
2604
2726
  this.#authCodeTtlMs = testMode?.authCodeTtlMs ?? AUTH_CODE_TTL_MS;
@@ -2610,7 +2732,8 @@ class AuthorizationServer {
2610
2732
  config: oidc,
2611
2733
  publicUrl: () => host.publicUrl(),
2612
2734
  logger: host.logger,
2613
- development: host.config.development
2735
+ development: host.config.development,
2736
+ ...testMode?.oidcMaxFlows === undefined ? {} : { maxFlows: testMode.oidcMaxFlows }
2614
2737
  })
2615
2738
  },
2616
2739
  ...testMode?.pairingMethods
@@ -2776,7 +2899,7 @@ class AuthorizationServer {
2776
2899
  if (error instanceof ClientMetadataError) {
2777
2900
  this.#host.logger.info("oauth.client_refused", {
2778
2901
  reason: error.reason,
2779
- host: hostOf(clientId).slice(0, 100)
2902
+ host: URL.canParse(clientId) ? hostOf(clientId).slice(0, 100) : "(not a URL)"
2780
2903
  });
2781
2904
  return this.#errorPage(400, error.message);
2782
2905
  }
@@ -2822,10 +2945,11 @@ class AuthorizationServer {
2822
2945
  if (scopes === undefined) {
2823
2946
  return fail("invalid_scope", `the only scope is "${SCOPE}"`);
2824
2947
  }
2825
- if (this.#host.session(addressId) === undefined) {
2826
- return this.#offlinePage(client, `/authorize${new URL(req.url).search}`, this.#errorUrl(redirectUri, state, "access_denied", "the user cancelled the connection"));
2948
+ const session = this.#host.session(addressId);
2949
+ if (session === undefined || !session.pairingOpen) {
2950
+ return this.#offlinePage(client, `/authorize${new URL(req.url).search}`, this.#errorUrl(redirectUri, state, "access_denied", "the user cancelled the connection"), session !== undefined);
2827
2951
  }
2828
- const method = this.#methods[this.#host.session(addressId)?.site.pairing ?? "code"];
2952
+ const method = this.#methods[session.site.pairing];
2829
2953
  if (method?.prepare !== undefined) {
2830
2954
  try {
2831
2955
  await method.prepare();
@@ -2837,9 +2961,10 @@ class AuthorizationServer {
2837
2961
  }
2838
2962
  }
2839
2963
  this.#sweepPending();
2840
- if (this.#pending.size >= MAX_PENDING || (this.#pendingPerIp.get(ip) ?? 0) >= MAX_PENDING_PER_IP || (this.#pendingPerAddress.get(addressId) ?? 0) >= MAX_PENDING_PER_ADDRESS) {
2964
+ if (this.#pending.size >= MAX_PENDING || (this.#pendingPerIp.get(ip) ?? 0) >= MAX_PENDING_PER_IP) {
2841
2965
  return this.#errorPage(429, "Too many connection requests are open right now. Wait a few minutes and try again.");
2842
2966
  }
2967
+ this.#makeRoom(addressId, ip);
2843
2968
  const pending = {
2844
2969
  id: random3(24),
2845
2970
  expiresAt: Date.now() + PENDING_TTL_MS,
@@ -2861,6 +2986,36 @@ class AuthorizationServer {
2861
2986
  this.#pendingPerIp.set(p.ip, (this.#pendingPerIp.get(p.ip) ?? 0) + 1);
2862
2987
  this.#pendingPerAddress.set(p.addressId, (this.#pendingPerAddress.get(p.addressId) ?? 0) + 1);
2863
2988
  }
2989
+ #makeRoom(addressId, ip) {
2990
+ const count = this.#pendingPerAddress.get(addressId) ?? 0;
2991
+ if (count < MAX_PENDING_PER_ADDRESS_IP)
2992
+ return;
2993
+ const byIp = new Map;
2994
+ for (const p of this.#pending.values()) {
2995
+ if (p.addressId !== addressId)
2996
+ continue;
2997
+ const list = byIp.get(p.ip);
2998
+ if (list === undefined)
2999
+ byIp.set(p.ip, [p]);
3000
+ else
3001
+ list.push(p);
3002
+ }
3003
+ const own = byIp.get(ip) ?? [];
3004
+ if (own.length >= MAX_PENDING_PER_ADDRESS_IP) {
3005
+ if (own[0] !== undefined)
3006
+ this.#dropPending(own[0]);
3007
+ return;
3008
+ }
3009
+ if (count < MAX_PENDING_PER_ADDRESS)
3010
+ return;
3011
+ let most = [];
3012
+ for (const list of byIp.values()) {
3013
+ if (list.length >= most.length)
3014
+ most = list;
3015
+ }
3016
+ if (most[0] !== undefined)
3017
+ this.#dropPending(most[0]);
3018
+ }
2864
3019
  #dropPending(p) {
2865
3020
  if (!this.#pending.delete(p.id))
2866
3021
  return;
@@ -2873,18 +3028,22 @@ class AuthorizationServer {
2873
3028
  };
2874
3029
  dec(this.#pendingPerIp, p.ip);
2875
3030
  dec(this.#pendingPerAddress, p.addressId);
2876
- }
2877
- #offlinePage(client, retryUrl, cancelUrl, error) {
2878
- const errorHtml = error === undefined ? "" : `<p class="error" role="alert">${escapeHtml(error)}</p>`;
2879
- const body = `<h1>Open the page first</h1>
2880
- ${errorHtml}
2881
- <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>
2882
- <p>Open the page, click \u201CConnect your AI app\u201D, then try again here.</p>
3031
+ for (const method of Object.values(this.#methods))
3032
+ method?.forget?.(p.id);
3033
+ }
3034
+ #offlinePage(client, retryUrl, cancelUrl, connected = false) {
3035
+ const who = `<strong>${escapeHtml(client.name)}</strong> (${escapeHtml(client.host)})`;
3036
+ const title = connected ? "Open \u201CConnect your AI app\u201D first" : "Open the page first";
3037
+ const intro = connected ? `<p>${who} wants to use the tools of a web page, but that page is not connecting right now.</p>
3038
+ <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>
3039
+ <p>Open the page, click \u201CConnect your AI app\u201D, then try again here.</p>`;
3040
+ const body = `<h1>${title}</h1>
3041
+ ${intro}
2883
3042
  <div class="actions">
2884
3043
  <a class="button primary" href="${escapeHtml(retryUrl)}">Try again</a>
2885
3044
  <a class="button" href="${escapeHtml(cancelUrl)}">Cancel</a>
2886
3045
  </div>`;
2887
- return this.#page(200, "Open the page first", body);
3046
+ return this.#page(200, title, body);
2888
3047
  }
2889
3048
  async authorizeSubmit(req, ip) {
2890
3049
  const origin = req.headers.get("origin");
@@ -2940,7 +3099,8 @@ ${errorHtml}
2940
3099
  const admit = () => this.#guessesPerAddressIp.take(`${pending.addressId}|${ip}`) && this.#guessesPerAddress.take(pending.addressId);
2941
3100
  const result = await method.check(session, form, admit, {
2942
3101
  requestId: pending.id,
2943
- browserId: browser.id
3102
+ browserId: browser.id,
3103
+ addressId: pending.addressId
2944
3104
  });
2945
3105
  if (result.ok === "redirect") {
2946
3106
  this.#host.logger.info("oauth.pairing_redirect", {
@@ -2963,13 +3123,13 @@ ${errorHtml}
2963
3123
  client: pending.client.host,
2964
3124
  reason: result.reason
2965
3125
  });
2966
- return this.#pairingPage(pending, browser.id, result.message, result.reason === "rate_limited" ? 429 : 400);
3126
+ return this.#pairingPage(pending, browser.id, result.message, result.reason === "rate_limited" || result.reason === "busy" ? 429 : 400);
2967
3127
  }
2968
3128
  return this.#paired(pending, method.kind, result);
2969
3129
  }
2970
3130
  #paired(pending, kind, result) {
2971
3131
  this.#dropPending(pending);
2972
- const code = random3(32);
3132
+ const code = `${pending.addressId}.${random3(32)}`;
2973
3133
  if (this.#codes.size >= MAX_CODES)
2974
3134
  this.#sweepCodes(true);
2975
3135
  const { id: _id, expiresAt: _expires, ip: _ip, ...request } = pending;
@@ -3089,7 +3249,7 @@ ${errorHtml}
3089
3249
  ${errorHtml}
3090
3250
  <p>${clientLabel} wants to use the tools of a web page, but that page is not open right now.</p>
3091
3251
  <p>Open the page, click \u201CConnect your AI app\u201D, then try again here.</p>
3092
- <form method="post" action="/authorize">
3252
+ <form method="post" action="/authorize?a=${pending.addressId}">
3093
3253
  ${hidden}
3094
3254
  <div class="actions">
3095
3255
  <button type="submit" class="primary" name="action" value="retry">Try again</button>
@@ -3110,7 +3270,7 @@ ${cancel}
3110
3270
  <p>You're connecting ${clientLabel} to ${target}.</p>
3111
3271
  <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>
3112
3272
  ${errorHtml}
3113
- <form method="post" action="/authorize">
3273
+ <form method="post" action="/authorize?a=${pending.addressId}">
3114
3274
  ${hidden}
3115
3275
  ${method.render(session)}
3116
3276
  <div class="actions">
@@ -3307,7 +3467,10 @@ ${cancel}
3307
3467
  throw new OAuthError(400, "invalid_scope", "scope exceeds the grant");
3308
3468
  }
3309
3469
  }
3310
- if (!await this.#host.store.markUsed(hash, now)) {
3470
+ if (!this.#refreshesPerGrant.take(record.grantId, now)) {
3471
+ throw new OAuthError(429, "temporarily_unavailable", "too many refreshes for this connection; try again in a minute");
3472
+ }
3473
+ if (!await this.#host.store.markUsed(hash, now, now + REFRESH_REUSE_WINDOW_MS)) {
3311
3474
  await this.#reuseDetected(record);
3312
3475
  throw invalid("the refresh token was already used; the connection was revoked");
3313
3476
  }
@@ -3416,6 +3579,7 @@ ${cancel}
3416
3579
  this.#guessesPerAddressIp.sweep(now);
3417
3580
  this.#guessesPerAddress.sweep(now);
3418
3581
  this.#tokenPerIp.sweep(now);
3582
+ this.#refreshesPerGrant.sweep(now);
3419
3583
  }
3420
3584
  }
3421
3585
 
@@ -3438,11 +3602,8 @@ function clientHost(clientId) {
3438
3602
  return "";
3439
3603
  }
3440
3604
  }
3441
- function messageId(parsed) {
3442
- if ("message" in parsed) {
3443
- return "id" in parsed.message ? parsed.message.id : undefined;
3444
- }
3445
- return parsed.id;
3605
+ function frameId(data) {
3606
+ return /"id"\s*:\s*"([^"\\]{1,64})"/.exec(data.slice(0, 256))?.[1];
3446
3607
  }
3447
3608
 
3448
3609
  class TabSession {
@@ -3451,11 +3612,14 @@ class TabSession {
3451
3612
  origin;
3452
3613
  site;
3453
3614
  openedAt = Date.now();
3615
+ expectedAddress;
3454
3616
  addressId;
3455
3617
  appName;
3456
3618
  codes;
3457
3619
  identity;
3620
+ identityUnchecked = false;
3458
3621
  pending = new Map;
3622
+ #listenGrants = new Map;
3459
3623
  #ws;
3460
3624
  #host;
3461
3625
  #state = "new";
@@ -3467,12 +3631,13 @@ class TabSession {
3467
3631
  #helloTimer;
3468
3632
  #heartbeatTimer;
3469
3633
  #lifetimeTimer;
3470
- constructor(ws, ip, origin, site, host) {
3634
+ constructor(ws, ip, origin, site, host, expectedAddress) {
3471
3635
  this.#ws = ws;
3472
3636
  this.ip = ip;
3473
3637
  this.origin = origin;
3474
3638
  this.site = site;
3475
3639
  this.#host = host;
3640
+ this.expectedAddress = expectedAddress;
3476
3641
  this.codes = new PairingCodes((code, expiresAt) => {
3477
3642
  this.send({
3478
3643
  type: "pairing.code",
@@ -3520,15 +3685,14 @@ class TabSession {
3520
3685
  this.#protocolError("bad_message", "binary frames are not allowed");
3521
3686
  return;
3522
3687
  }
3523
- const tooLarge = Buffer.byteLength(data) > this.limits.maxMessageBytes;
3524
- const parsed = parseTabMessage(data);
3525
- if (tooLarge) {
3526
- const id = messageId(parsed);
3688
+ if (Buffer.byteLength(data) > this.limits.maxMessageBytes) {
3689
+ const id = frameId(data);
3527
3690
  this.#sendError("too_large", "message exceeds maxMessageBytes", id);
3528
3691
  const exchange = id === undefined ? undefined : this.pending.get(id);
3529
3692
  exchange?.fail(502, RelayErrorCode.tooLarge, "The page sent a response that is too large.", true);
3530
3693
  return;
3531
3694
  }
3695
+ const parsed = parseTabMessage(data);
3532
3696
  if (!parsed.ok) {
3533
3697
  if (this.#state === "new") {
3534
3698
  this.#protocolError("bad_message", parsed.error);
@@ -3562,7 +3726,7 @@ class TabSession {
3562
3726
  this.#protocolError("bad_message", "hello expected");
3563
3727
  return;
3564
3728
  }
3565
- await this.#hello(msg.secret, msg.library, msg.app?.name);
3729
+ await this.#hello(msg.secret, msg.library, msg.app?.name, msg.revokeAll === true);
3566
3730
  return;
3567
3731
  }
3568
3732
  if (this.#state === "hello")
@@ -3629,19 +3793,38 @@ class TabSession {
3629
3793
  }
3630
3794
  }
3631
3795
  }
3632
- async#hello(secret, library, appName) {
3796
+ async#hello(secret, library, appName, revokeAll = false) {
3633
3797
  if (!isWellFormedSecret(secret)) {
3634
3798
  this.#protocolError("bad_message", "secret must be 43 base64url characters");
3635
3799
  return;
3636
3800
  }
3801
+ const addressId = addressIdFromSecret(secret);
3802
+ if (this.expectedAddress !== undefined && this.expectedAddress !== addressId) {
3803
+ this.#host.logger.warn("tab.refused", { reason: "address_mismatch" });
3804
+ this.#sendError("address_mismatch", "the address id in the URL is not the one of the secret");
3805
+ this.end(CloseCode.addressMismatch, "address mismatch");
3806
+ return;
3807
+ }
3637
3808
  clearTimeout(this.#helloTimer);
3638
3809
  this.#state = "hello";
3639
- this.addressId = addressIdFromSecret(secret);
3810
+ this.addressId = addressId;
3640
3811
  this.appName = this.site.name ?? appName;
3641
3812
  const replaced = this.#host.claimAddress(this);
3642
3813
  if (replaced !== undefined) {
3643
3814
  replaced.end(CloseCode.replaced, "replaced by a newer session", "replaced");
3644
3815
  }
3816
+ if (revokeAll) {
3817
+ try {
3818
+ await this.#host.revokeAll(this);
3819
+ } catch (error) {
3820
+ this.#host.logger.error("store.error", {
3821
+ op: "revokeAll",
3822
+ error: errorName(error)
3823
+ });
3824
+ this.end(CloseCode.policyViolation, "could not revoke the grants");
3825
+ return;
3826
+ }
3827
+ }
3645
3828
  let grants = [];
3646
3829
  try {
3647
3830
  const list = await this.#host.store.listGrants(this.addressId, Date.now());
@@ -3657,7 +3840,7 @@ class TabSession {
3657
3840
  } catch (error) {
3658
3841
  this.#host.logger.error("store.error", {
3659
3842
  op: "listGrants",
3660
- message: error instanceof Error ? error.message : "unknown"
3843
+ error: errorName(error)
3661
3844
  });
3662
3845
  }
3663
3846
  if (this.#state !== "hello")
@@ -3689,7 +3872,7 @@ class TabSession {
3689
3872
  library: `${library.name}@${library.version}`,
3690
3873
  replaced: replaced !== undefined
3691
3874
  });
3692
- this.#host.ready?.(this);
3875
+ this.#host.ready(this);
3693
3876
  }
3694
3877
  #serialIdentity(run) {
3695
3878
  const next = this.#identityChain.then(run);
@@ -3710,6 +3893,13 @@ class TabSession {
3710
3893
  this.#missedPongs++;
3711
3894
  this.send({ type: "ping", t: Date.now() });
3712
3895
  }
3896
+ listenStreams(grantId) {
3897
+ let n = 0;
3898
+ for (const g of this.#listenGrants.values())
3899
+ if (g === grantId)
3900
+ n++;
3901
+ return n;
3902
+ }
3713
3903
  forward(input) {
3714
3904
  this.#lastRequestAt = Date.now();
3715
3905
  const id = `r${++this.#nextRequest}`;
@@ -3731,16 +3921,19 @@ class TabSession {
3731
3921
  },
3732
3922
  onDone: (e) => {
3733
3923
  this.pending.delete(id);
3924
+ this.#listenGrants.delete(id);
3734
3925
  this.#lastRequestAt = Date.now();
3735
3926
  input.onDone(e);
3736
3927
  },
3737
3928
  ...input.pageGone === undefined ? {} : { pageGone: input.pageGone }
3738
3929
  });
3739
3930
  this.pending.set(id, exchange);
3931
+ if (input.isListen)
3932
+ this.#listenGrants.set(id, input.grant.grantId);
3740
3933
  const sent = this.send({
3741
3934
  type: "http.request",
3742
3935
  id,
3743
- method: input.method,
3936
+ method: "POST",
3744
3937
  path: "/mcp",
3745
3938
  headers: input.headers,
3746
3939
  body: input.body,
@@ -3842,11 +4035,12 @@ class MemoryTokenStore {
3842
4035
  const r = this.#records.get(hash);
3843
4036
  return r === undefined ? undefined : copy(r);
3844
4037
  }
3845
- async markUsed(hash, now) {
4038
+ async markUsed(hash, now, keepUntil) {
3846
4039
  const r = this.#records.get(hash);
3847
4040
  if (r === undefined || r.usedAt !== undefined)
3848
4041
  return false;
3849
4042
  r.usedAt = now;
4043
+ r.expiresAt = Math.min(r.expiresAt, keepUntil);
3850
4044
  return true;
3851
4045
  }
3852
4046
  async revoke(selector, options) {
@@ -3899,7 +4093,8 @@ class MemoryTokenStore {
3899
4093
 
3900
4094
  // src/store/sqlite.ts
3901
4095
  import { Database } from "bun:sqlite";
3902
- var SCHEMA_VERSION = 3;
4096
+ var SCHEMA_VERSION = 4;
4097
+ var REUSE_WINDOW_MS = 86400000;
3903
4098
  function toRecord(row) {
3904
4099
  const record = {
3905
4100
  hash: row.hash,
@@ -3962,6 +4157,8 @@ class SqliteTokenStore {
3962
4157
  this.#db.run("CREATE INDEX IF NOT EXISTS tokens_grant ON tokens (grant_id)");
3963
4158
  this.#db.run("CREATE INDEX IF NOT EXISTS tokens_address ON tokens (address_id)");
3964
4159
  this.#db.run("CREATE INDEX IF NOT EXISTS tokens_expires ON tokens (expires_at)");
4160
+ this.#db.query(`UPDATE tokens SET expires_at = min(expires_at, used_at + $window)
4161
+ WHERE kind = 'refresh' AND used_at IS NOT NULL`).run({ window: REUSE_WINDOW_MS });
3965
4162
  this.#db.run(`PRAGMA user_version = ${SCHEMA_VERSION}`);
3966
4163
  })();
3967
4164
  }
@@ -4002,8 +4199,9 @@ class SqliteTokenStore {
4002
4199
  const row = this.#db.query("SELECT * FROM tokens WHERE hash = $hash").get({ hash });
4003
4200
  return row === null ? undefined : toRecord(row);
4004
4201
  }
4005
- async markUsed(hash, now) {
4006
- const result = this.#db.query("UPDATE tokens SET used_at = $now WHERE hash = $hash AND used_at IS NULL").run({ hash, now });
4202
+ async markUsed(hash, now, keepUntil) {
4203
+ const result = this.#db.query(`UPDATE tokens SET used_at = $now, expires_at = min(expires_at, $keep_until)
4204
+ WHERE hash = $hash AND used_at IS NULL`).run({ hash, now, keep_until: keepUntil });
4007
4205
  return result.changes === 1;
4008
4206
  }
4009
4207
  async revoke(selector, options) {
@@ -4042,6 +4240,16 @@ var VERSION = version;
4042
4240
  var RELAY_INFO = { name: "tab-mcp", version };
4043
4241
  var RETRY = Symbol("retry");
4044
4242
  var CLAUDE_MISMATCHED_HEADER = "2025-11-25";
4243
+ var MAX_LISTEN_STREAMS_PER_GRANT = 2;
4244
+ var FRAME_HEADROOM = 4;
4245
+ var TRANSIENT_REFUSALS = new Set([
4246
+ "jwks_unavailable",
4247
+ "store_unavailable",
4248
+ "relay_busy",
4249
+ "error"
4250
+ ]);
4251
+ var UNCHECKED_TEXT = "The relay cannot check the page's user right now. Try again shortly.";
4252
+ var RETRY_AFTER_S = "5";
4045
4253
  var FORWARDED_HEADERS = new Set([
4046
4254
  "content-type",
4047
4255
  "accept",
@@ -4050,6 +4258,33 @@ var FORWARDED_HEADERS = new Set([
4050
4258
  "traceparent",
4051
4259
  "tracestate"
4052
4260
  ]);
4261
+ var HEALTH_STORE_TIMEOUT_MS = 1000;
4262
+ var HEALTH_REUSE_MS = 1000;
4263
+ var HEALTH_HUNG_RETRY_MS = 30000;
4264
+ var HEALTH_PROBE_HASH = hashToken("tab-mcp /healthz probe");
4265
+ function rawAddressParam(search) {
4266
+ let found;
4267
+ for (const pair of search.replace(/^\?/, "").split("&")) {
4268
+ if (pair === "")
4269
+ continue;
4270
+ const eq = pair.indexOf("=");
4271
+ const rawName = eq < 0 ? pair : pair.slice(0, eq);
4272
+ let name;
4273
+ try {
4274
+ name = decodeURIComponent(rawName.replaceAll("+", " "));
4275
+ } catch {
4276
+ continue;
4277
+ }
4278
+ if (name !== "a")
4279
+ continue;
4280
+ const value = eq < 0 ? "" : pair.slice(eq + 1);
4281
+ if (rawName !== "a" || found !== undefined || !isAddressId(value)) {
4282
+ return false;
4283
+ }
4284
+ found = value;
4285
+ }
4286
+ return found;
4287
+ }
4053
4288
  function json(status, value, headers) {
4054
4289
  const h = new Headers(headers);
4055
4290
  h.set("content-type", "application/json");
@@ -4103,10 +4338,10 @@ function startRelay(options, hooks) {
4103
4338
  const perIp = new Map;
4104
4339
  const waiters = new Map;
4105
4340
  let waitingCount = 0;
4106
- const appNames = new Map;
4341
+ const lastSeen = new Map;
4107
4342
  let claudeWorkarounds = 0;
4108
4343
  let publicUrl = config.publicUrl ?? "";
4109
- const assertions = config.oidc === undefined ? undefined : new TabAssertionVerifier(config.oidc, hooks?.assertionKeys);
4344
+ const assertions = config.oidc === undefined ? undefined : new TabAssertionVerifier(config.oidc, hooks?.assertionKeys, hooks?.maxSeenAssertions);
4110
4345
  const corsHeaders = config.cors ? {
4111
4346
  "access-control-allow-origin": "*",
4112
4347
  "access-control-expose-headers": "www-authenticate, mcp-protocol-version"
@@ -4168,9 +4403,15 @@ function startRelay(options, hooks) {
4168
4403
  }
4169
4404
  };
4170
4405
  const refuse = (reason, message) => {
4406
+ const transient = TRANSIENT_REFUSALS.has(reason);
4407
+ session.identityUnchecked = transient;
4171
4408
  if (session.identity !== undefined) {
4172
4409
  session.identity = undefined;
4173
- failPending(session, "The page's user is no longer signed in.");
4410
+ if (transient) {
4411
+ failPending(session, UNCHECKED_TEXT, true);
4412
+ } else {
4413
+ failPending(session, "The page's user is no longer signed in.");
4414
+ }
4174
4415
  }
4175
4416
  logger.info("identity.refused", {
4176
4417
  address: shortAddress(addressId),
@@ -4207,7 +4448,7 @@ function startRelay(options, hooks) {
4207
4448
  } catch (error) {
4208
4449
  logger.error("store.error", {
4209
4450
  op: "identity",
4210
- message: error instanceof Error ? error.message : "unknown"
4451
+ error: errorName(error)
4211
4452
  });
4212
4453
  refuse("store_unavailable", "the relay cannot check grants right now");
4213
4454
  return;
@@ -4215,6 +4456,7 @@ function startRelay(options, hooks) {
4215
4456
  if (!session.ready)
4216
4457
  return;
4217
4458
  session.identity = key;
4459
+ session.identityUnchecked = false;
4218
4460
  answer({ type: "identity.bound", ...echo });
4219
4461
  logger.info("identity.bound", {
4220
4462
  address: shortAddress(addressId),
@@ -4236,6 +4478,7 @@ function startRelay(options, hooks) {
4236
4478
  return;
4237
4479
  }
4238
4480
  session.identity = undefined;
4481
+ session.identityUnchecked = false;
4239
4482
  failPending(session, "The page's user signed out.");
4240
4483
  try {
4241
4484
  const grants = await store.listGrants(addressId, Date.now());
@@ -4247,10 +4490,7 @@ function startRelay(options, hooks) {
4247
4490
  for (const g of grants)
4248
4491
  grantRemoved(addressId, g.grantId, "revoked");
4249
4492
  } catch (error) {
4250
- logger.error("store.error", {
4251
- op: "signOut",
4252
- message: error instanceof Error ? error.message : "unknown"
4253
- });
4493
+ logger.error("store.error", { op: "signOut", error: errorName(error) });
4254
4494
  }
4255
4495
  },
4256
4496
  claimAddress(session) {
@@ -4272,14 +4512,16 @@ function startRelay(options, hooks) {
4272
4512
  const id = session.addressId;
4273
4513
  if (id === undefined)
4274
4514
  return;
4275
- if (session.appName !== undefined) {
4276
- appNames.delete(id);
4277
- appNames.set(id, session.appName);
4278
- if (appNames.size > config.maxSessions * 2) {
4279
- const oldest = appNames.keys().next().value;
4280
- if (oldest !== undefined)
4281
- appNames.delete(oldest);
4282
- }
4515
+ const appName = session.appName ?? lastSeen.get(id)?.appName;
4516
+ lastSeen.delete(id);
4517
+ lastSeen.set(id, {
4518
+ appName,
4519
+ maxConcurrentRequests: session.limits.maxConcurrentRequests
4520
+ });
4521
+ if (lastSeen.size > config.maxSessions * 2) {
4522
+ const oldest = lastSeen.keys().next().value;
4523
+ if (oldest !== undefined)
4524
+ lastSeen.delete(oldest);
4283
4525
  }
4284
4526
  const waiting = waiters.get(id);
4285
4527
  if (waiting !== undefined)
@@ -4287,9 +4529,11 @@ function startRelay(options, hooks) {
4287
4529
  wake(session);
4288
4530
  }
4289
4531
  };
4290
- function failPending(session, message) {
4532
+ function failPending(session, message, transient = false) {
4533
+ const addressId = session.addressId;
4534
+ const headers = transient ? { "retry-after": RETRY_AFTER_S } : addressId === undefined ? {} : { "www-authenticate": wwwAuthenticate(addressId, true) };
4291
4535
  for (const exchange of [...session.pending.values()]) {
4292
- exchange.fail(401, RelayErrorCode.signedOut, message, true);
4536
+ exchange.fail(transient ? 503 : 401, RelayErrorCode.signedOut, message, true, headers);
4293
4537
  }
4294
4538
  }
4295
4539
  function readySession(addressId, usable = () => true) {
@@ -4303,11 +4547,12 @@ function startRelay(options, hooks) {
4303
4547
  const now = readySession(addressId, usable);
4304
4548
  if (now !== undefined)
4305
4549
  return Promise.resolve(now);
4306
- if (waitMs === 0 || stopping !== undefined) {
4550
+ if (waitMs === 0 || closing) {
4307
4551
  return Promise.resolve(undefined);
4308
4552
  }
4309
4553
  const set = waiters.get(addressId) ?? new Set;
4310
- if (set.size >= config.maxConcurrentRequests || waitingCount >= config.maxSessions) {
4554
+ const limit = sessions.get(addressId)?.limits.maxConcurrentRequests ?? lastSeen.get(addressId)?.maxConcurrentRequests ?? config.maxConcurrentRequests;
4555
+ if (set.size >= limit || waitingCount >= config.maxSessions) {
4311
4556
  return Promise.resolve(undefined);
4312
4557
  }
4313
4558
  return new Promise((resolve) => {
@@ -4395,6 +4640,11 @@ function startRelay(options, hooks) {
4395
4640
  supported: [SUBPROTOCOL]
4396
4641
  });
4397
4642
  }
4643
+ const address = rawAddressParam(new URL(req.url).search);
4644
+ if (address === false) {
4645
+ logger.info("tab.refused", { reason: "bad_address" });
4646
+ return json(400, { error: "bad_address" });
4647
+ }
4398
4648
  const realIp = clientIp(req, server);
4399
4649
  const ip = ipPrefixKey(realIp);
4400
4650
  if (allSessions.size >= config.maxSessions) {
@@ -4411,7 +4661,12 @@ function startRelay(options, hooks) {
4411
4661
  }
4412
4662
  perIp.set(ip, count + 1);
4413
4663
  const ok = server.upgrade(req, {
4414
- data: { ip, origin: origin ?? "", site },
4664
+ data: {
4665
+ ip,
4666
+ origin: origin ?? "",
4667
+ site,
4668
+ ...address === undefined ? {} : { address }
4669
+ },
4415
4670
  headers: { "Sec-WebSocket-Protocol": SUBPROTOCOL }
4416
4671
  });
4417
4672
  if (ok)
@@ -4436,10 +4691,7 @@ function startRelay(options, hooks) {
4436
4691
  try {
4437
4692
  record = await store.get(hashToken(token));
4438
4693
  } catch (error) {
4439
- logger.error("store.error", {
4440
- op: "get",
4441
- message: error instanceof Error ? error.message : "unknown"
4442
- });
4694
+ logger.error("store.error", { op: "get", error: errorName(error) });
4443
4695
  return "unavailable";
4444
4696
  }
4445
4697
  if (record === undefined || record.kind !== "access" || record.addressId !== addressId || record.expiresAt <= Date.now() || !record.scopes.includes("tools")) {
@@ -4460,8 +4712,12 @@ function startRelay(options, hooks) {
4460
4712
  const deadline = new Promise((resolve) => {
4461
4713
  timer = setTimeout(() => resolve("timeout"), config.bodyTimeoutMs);
4462
4714
  });
4463
- const result = await Promise.race([reading, deadline]);
4464
- clearTimeout(timer);
4715
+ let result;
4716
+ try {
4717
+ result = await Promise.race([reading, deadline]);
4718
+ } finally {
4719
+ clearTimeout(timer);
4720
+ }
4465
4721
  if (result === "timeout") {
4466
4722
  return rpcError(408, undefined, -32600, "The request body did not arrive in time.", { ...corsHeaders, connection: "close" });
4467
4723
  }
@@ -4531,7 +4787,7 @@ function startRelay(options, hooks) {
4531
4787
  const auth = await authenticate(req, addressId);
4532
4788
  if (auth === "unavailable") {
4533
4789
  logDone(503, method, { reason: "store_unavailable" });
4534
- return rpcError(503, rpcId, RelayErrorCode.storeUnavailable, "The relay cannot check tokens right now. Try again shortly.", { ...corsHeaders, "retry-after": "5" });
4790
+ return rpcError(503, rpcId, RelayErrorCode.storeUnavailable, "The relay cannot check tokens right now. Try again shortly.", { ...corsHeaders, "retry-after": RETRY_AFTER_S });
4535
4791
  }
4536
4792
  if (typeof auth === "string") {
4537
4793
  logDone(401, method);
@@ -4563,12 +4819,9 @@ function startRelay(options, hooks) {
4563
4819
  logDone(499, method, { outcome: "client_closed" });
4564
4820
  return new Response(null, { status: 499 });
4565
4821
  };
4822
+ const idle = (on) => server.timeout(req, on ? config.httpIdleTimeoutS : 0);
4566
4823
  const usable = usableFor(auth.subject);
4567
- if (readySession(addressId, usable) === undefined && waitMs > 0) {
4568
- server.timeout(req, 0);
4569
- }
4570
- let retried = false;
4571
- for (;; ) {
4824
+ const admit = async () => {
4572
4825
  const found = await waitForPage(addressId, req.signal, waitMs, usable);
4573
4826
  if (found === "aborted" || req.signal.aborted)
4574
4827
  return closed();
@@ -4591,14 +4844,30 @@ function startRelay(options, hooks) {
4591
4844
  logDone(429, method);
4592
4845
  return rpcError(429, rpcId, RelayErrorCode.overloaded, "Too many requests at once for this page.", corsHeaders);
4593
4846
  }
4594
- server.timeout(req, 0);
4847
+ if (isListen && session.listenStreams(auth.grantId) >= MAX_LISTEN_STREAMS_PER_GRANT) {
4848
+ logDone(429, method, { reason: "listen_streams" });
4849
+ return rpcError(429, rpcId, RelayErrorCode.overloaded, "Too many listen streams at once for this AI app.", corsHeaders);
4850
+ }
4851
+ return session;
4852
+ };
4853
+ if (readySession(addressId, usable) === undefined && waitMs > 0) {
4854
+ idle(false);
4855
+ }
4856
+ let retried = false;
4857
+ for (;; ) {
4858
+ const admitted = await admit();
4859
+ if (admitted instanceof Response) {
4860
+ idle(true);
4861
+ return admitted;
4862
+ }
4863
+ const session = admitted;
4864
+ idle(false);
4595
4865
  let retry = () => {};
4596
4866
  const retrying = new Promise((resolve) => {
4597
4867
  retry = () => resolve(RETRY);
4598
4868
  });
4599
4869
  const mayRetry = !retried && waitMs > 0 && isRetryable(body.value["method"]);
4600
4870
  const exchange = session.forward({
4601
- method: "POST",
4602
4871
  headers,
4603
4872
  body: body.text,
4604
4873
  rpcId,
@@ -4612,6 +4881,7 @@ function startRelay(options, hooks) {
4612
4881
  onDone: (e) => {
4613
4882
  if (e.outcome === "page_gone_retry")
4614
4883
  return;
4884
+ idle(true);
4615
4885
  logDone(e.status, method, {
4616
4886
  session: session.sessionId,
4617
4887
  client: clientHostName,
@@ -4624,8 +4894,18 @@ function startRelay(options, hooks) {
4624
4894
  retry();
4625
4895
  return { retry: true };
4626
4896
  }
4627
- const answer = notOpenAnswer(body.value, rpcId, session.appName, "gone", RELAY_INFO);
4628
- return answer === undefined ? { retry: false } : { retry: false, body: answer };
4897
+ const answer = notOpenAnswer(body.value, rpcId, session.appName, closing ? "restarting_gone" : "gone", RELAY_INFO);
4898
+ if (answer !== undefined)
4899
+ return { retry: false, body: answer };
4900
+ return !closing ? { retry: false } : {
4901
+ retry: false,
4902
+ error: {
4903
+ status: 503,
4904
+ code: RelayErrorCode.restarting,
4905
+ message: "The relay is restarting.",
4906
+ headers: { "retry-after": RETRY_AFTER_S }
4907
+ }
4908
+ };
4629
4909
  }
4630
4910
  });
4631
4911
  const onAbort = () => exchange.clientClosed();
@@ -4639,13 +4919,21 @@ function startRelay(options, hooks) {
4639
4919
  }
4640
4920
  function pageNotOpen(value, rpcId, addressId, logDone, method) {
4641
4921
  const open = sessions.get(addressId);
4642
- const phase = open?.ready === true && open.identity === undefined && open.site.pairing === "oidc" ? "signed_out" : "not_open";
4643
- const answer = notOpenAnswer(value, rpcId, open?.appName ?? appNames.get(addressId), phase, RELAY_INFO);
4922
+ if (!closing && open?.ready === true && open.identity === undefined && open.identityUnchecked) {
4923
+ logDone(503, method, { reason: "identity_unchecked" });
4924
+ return rpcError(503, rpcId, RelayErrorCode.signedOut, UNCHECKED_TEXT, {
4925
+ ...corsHeaders,
4926
+ "retry-after": RETRY_AFTER_S
4927
+ });
4928
+ }
4929
+ const phase = closing ? "restarting" : open?.ready === true && open.identity === undefined && open.site.pairing === "oidc" ? "signed_out" : "not_open";
4930
+ const reason = phase === "restarting" ? "restarting" : "page_not_open";
4931
+ const answer = notOpenAnswer(value, rpcId, open?.appName ?? lastSeen.get(addressId)?.appName, phase, RELAY_INFO);
4644
4932
  if (answer === undefined) {
4645
- logDone(503, method, { reason: "page_not_open" });
4646
- return rpcError(503, rpcId, RelayErrorCode.pageNotOpen, "The page is not open.", corsHeaders);
4933
+ logDone(503, method, { reason });
4934
+ 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);
4647
4935
  }
4648
- logDone(200, method, { reason: "page_not_open" });
4936
+ logDone(200, method, { reason });
4649
4937
  return new Response(answer, {
4650
4938
  status: 200,
4651
4939
  headers: { ...corsHeaders, "content-type": "application/json" }
@@ -4670,6 +4958,56 @@ function startRelay(options, hooks) {
4670
4958
  headers: { ...corsHeaders, allow: "POST" }
4671
4959
  });
4672
4960
  const postOnly = () => new Response(null, { status: 405, headers: { allow: "POST, OPTIONS" } });
4961
+ const healthRetryMs = hooks?.healthRetryMs ?? HEALTH_HUNG_RETRY_MS;
4962
+ let probe;
4963
+ let probed;
4964
+ let hungReads = 0;
4965
+ let lastHungAt = 0;
4966
+ async function runProbe() {
4967
+ let timer;
4968
+ const deadline = new Promise((resolve) => {
4969
+ timer = setTimeout(() => resolve("store_timeout"), HEALTH_STORE_TIMEOUT_MS);
4970
+ });
4971
+ const read = Promise.resolve().then(() => store.get(HEALTH_PROBE_HASH)).then(() => "ok");
4972
+ read.catch(() => {});
4973
+ try {
4974
+ const result = await Promise.race([read, deadline]);
4975
+ if (result === "store_timeout") {
4976
+ logger.warn("health.store", { error: "timeout" });
4977
+ hungReads++;
4978
+ lastHungAt = Date.now();
4979
+ const settled = () => {
4980
+ hungReads--;
4981
+ };
4982
+ read.then(settled, settled);
4983
+ }
4984
+ return result;
4985
+ } catch (error) {
4986
+ logger.warn("health.store", { error: errorName(error) });
4987
+ return "store_unavailable";
4988
+ } finally {
4989
+ clearTimeout(timer);
4990
+ }
4991
+ }
4992
+ function storeHealth() {
4993
+ if (probe !== undefined)
4994
+ return probe;
4995
+ const now = Date.now();
4996
+ if (probed !== undefined && now - probed.at < HEALTH_REUSE_MS) {
4997
+ return Promise.resolve(probed.result);
4998
+ }
4999
+ if (hungReads > 0 && now - lastHungAt < healthRetryMs) {
5000
+ return Promise.resolve("store_timeout");
5001
+ }
5002
+ const current = runProbe().catch(() => "store_unavailable");
5003
+ probe = current;
5004
+ current.then((result) => {
5005
+ probed = { at: Date.now(), result };
5006
+ if (probe === current)
5007
+ probe = undefined;
5008
+ });
5009
+ return current;
5010
+ }
4673
5011
  const largest = largestMessageBytes(config);
4674
5012
  const ipOf = (req, srv) => ipPrefixKey(clientIp(req, srv));
4675
5013
  const server = Bun.serve({
@@ -4680,11 +5018,17 @@ function startRelay(options, hooks) {
4680
5018
  idleTimeout: config.httpIdleTimeoutS,
4681
5019
  routes: {
4682
5020
  "/healthz": {
4683
- GET: () => json(200, {
4684
- status: "ok",
4685
- version: VERSION,
4686
- counters: { claudeHeaderWorkaround: claudeWorkarounds }
4687
- }, { "cache-control": "no-store" })
5021
+ GET: async () => {
5022
+ const store = await storeHealth();
5023
+ if (store !== "ok") {
5024
+ return json(503, { status: "unhealthy", reason: store, version: VERSION }, { "cache-control": "no-store" });
5025
+ }
5026
+ return json(200, {
5027
+ status: "ok",
5028
+ version: VERSION,
5029
+ counters: { claudeHeaderWorkaround: claudeWorkarounds }
5030
+ }, { "cache-control": "no-store" });
5031
+ }
4688
5032
  },
4689
5033
  "/": {
4690
5034
  GET: () => new Response(INDEX_HTML, {
@@ -4740,14 +5084,14 @@ function startRelay(options, hooks) {
4740
5084
  fetch: () => new Response("Not found", { status: 404 }),
4741
5085
  websocket: {
4742
5086
  data: {},
4743
- maxPayloadLength: largest * 4,
5087
+ maxPayloadLength: largest * FRAME_HEADROOM,
4744
5088
  backpressureLimit: config.backpressureLimitBytes,
4745
5089
  closeOnBackpressureLimit: false,
4746
5090
  idleTimeout: 120,
4747
5091
  sendPings: true,
4748
5092
  perMessageDeflate: false,
4749
5093
  open(ws) {
4750
- const session = new TabSession(ws, ws.data.ip, ws.data.origin, ws.data.site, host);
5094
+ const session = new TabSession(ws, ws.data.ip, ws.data.origin, ws.data.site, host, ws.data.address);
4751
5095
  ws.data.session = session;
4752
5096
  allSessions.add(session);
4753
5097
  },
@@ -4760,6 +5104,7 @@ function startRelay(options, hooks) {
4760
5104
  }
4761
5105
  });
4762
5106
  let stopping;
5107
+ let closing = false;
4763
5108
  const port = server.port ?? config.port;
4764
5109
  if (publicUrl === "")
4765
5110
  publicUrl = `http://localhost:${port}`;
@@ -4770,7 +5115,7 @@ function startRelay(options, hooks) {
4770
5115
  logger.debug("store.swept", { deleted: n });
4771
5116
  }).catch((error) => logger.error("store.error", {
4772
5117
  op: "deleteExpired",
4773
- message: error instanceof Error ? error.message : "unknown"
5118
+ error: errorName(error)
4774
5119
  }));
4775
5120
  }, config.sweepIntervalMs);
4776
5121
  const siteCount = config.allowedOrigins === "*" ? "*" : config.allowedOrigins.length + config.sites.length;
@@ -4799,6 +5144,7 @@ function startRelay(options, hooks) {
4799
5144
  return allSessions.size;
4800
5145
  },
4801
5146
  stop() {
5147
+ closing = true;
4802
5148
  stopping ??= (async () => {
4803
5149
  clearInterval(sweep);
4804
5150
  for (const set of [...waiters.values()]) {
@@ -4834,5 +5180,5 @@ export {
4834
5180
  resolveConfig
4835
5181
  };
4836
5182
 
4837
- //# debugId=00790963557D76A664756E2164756E21
5183
+ //# debugId=C9E94078D468D4A064756E2164756E21
4838
5184
  //# sourceMappingURL=index.js.map