@ractive-ch/tab-mcp 0.3.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/exchange.d.ts +12 -3
- package/identity.d.ts +2 -1
- package/index.js +347 -123
- package/index.js.map +16 -16
- package/log.d.ts +5 -0
- package/main.js +347 -123
- package/main.js.map +16 -16
- package/notopen.d.ts +8 -2
- package/oauth/cimd.d.ts +15 -1
- package/oauth/oidc.d.ts +5 -1
- package/oauth/pairing.d.ts +2 -0
- package/oauth/server.d.ts +2 -0
- package/package.json +1 -1
- package/protocol.d.ts +1 -0
- package/ratelimit.d.ts +2 -0
- package/relay.d.ts +2 -0
- package/session.d.ts +10 -3
- package/store/memory.d.ts +1 -1
- package/store/sqlite.d.ts +1 -1
- package/store/types.d.ts +12 -4
- package/testing.js +27 -5
- package/testing.js.map +3 -3
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
|
-
|
|
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
|
|
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 <
|
|
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:
|
|
1010
|
+
logger.error("process.unhandled_rejection", { error: errorName(error) });
|
|
1004
1011
|
};
|
|
1005
1012
|
const onException = (error) => {
|
|
1006
|
-
logger.error("process.uncaught_exception", { error:
|
|
1013
|
+
logger.error("process.uncaught_exception", { error: errorName(error) });
|
|
1007
1014
|
exit(1);
|
|
1008
1015
|
};
|
|
1009
1016
|
process.on("unhandledRejection", onRejection);
|
|
@@ -1191,7 +1198,7 @@ function parseTabMessage(text) {
|
|
|
1191
1198
|
}
|
|
1192
1199
|
}
|
|
1193
1200
|
// package.json
|
|
1194
|
-
var version = "0.
|
|
1201
|
+
var version = "0.4.0";
|
|
1195
1202
|
|
|
1196
1203
|
// src/exchange.ts
|
|
1197
1204
|
var RelayErrorCode = {
|
|
@@ -1203,7 +1210,8 @@ var RelayErrorCode = {
|
|
|
1203
1210
|
tooLarge: -31006,
|
|
1204
1211
|
badResponse: -31007,
|
|
1205
1212
|
storeUnavailable: -31008,
|
|
1206
|
-
signedOut: -31009
|
|
1213
|
+
signedOut: -31009,
|
|
1214
|
+
restarting: -31010
|
|
1207
1215
|
};
|
|
1208
1216
|
var encoder = new TextEncoder;
|
|
1209
1217
|
var NULL_BODY_STATUSES = new Set([204, 205, 304]);
|
|
@@ -1232,6 +1240,11 @@ function rpcErrorBody(rpcId, code, message, data) {
|
|
|
1232
1240
|
error["data"] = data;
|
|
1233
1241
|
return JSON.stringify({ jsonrpc: "2.0", id: rpcId ?? null, error });
|
|
1234
1242
|
}
|
|
1243
|
+
var TAB_STARTED = new Set([
|
|
1244
|
+
"tab_stream",
|
|
1245
|
+
"relay_passed",
|
|
1246
|
+
"relay_wrapped"
|
|
1247
|
+
]);
|
|
1235
1248
|
|
|
1236
1249
|
class Exchange {
|
|
1237
1250
|
id;
|
|
@@ -1243,9 +1256,6 @@ class Exchange {
|
|
|
1243
1256
|
#options;
|
|
1244
1257
|
#resolve;
|
|
1245
1258
|
#state = "waiting";
|
|
1246
|
-
#relayStarted = false;
|
|
1247
|
-
#tabStarted = false;
|
|
1248
|
-
#wrap = false;
|
|
1249
1259
|
#wrapped = "";
|
|
1250
1260
|
#controller;
|
|
1251
1261
|
#atBoundary = true;
|
|
@@ -1271,7 +1281,7 @@ class Exchange {
|
|
|
1271
1281
|
return this.#state === "done";
|
|
1272
1282
|
}
|
|
1273
1283
|
onResponse(msg) {
|
|
1274
|
-
if (this.#
|
|
1284
|
+
if (TAB_STARTED.has(this.#state)) {
|
|
1275
1285
|
this.#badResponse("http.response after http.response.start");
|
|
1276
1286
|
return;
|
|
1277
1287
|
}
|
|
@@ -1294,7 +1304,7 @@ class Exchange {
|
|
|
1294
1304
|
}
|
|
1295
1305
|
this.#respond(response, msg.status);
|
|
1296
1306
|
this.#finish("ok");
|
|
1297
|
-
} else if (this.#state === "
|
|
1307
|
+
} else if (this.#state === "relay_stream") {
|
|
1298
1308
|
if (!this.#account(msg.body))
|
|
1299
1309
|
return;
|
|
1300
1310
|
if (msg.body !== "")
|
|
@@ -1303,11 +1313,17 @@ class Exchange {
|
|
|
1303
1313
|
}
|
|
1304
1314
|
}
|
|
1305
1315
|
onStart(msg) {
|
|
1306
|
-
if (this.#
|
|
1316
|
+
if (this.#state === "done")
|
|
1317
|
+
return;
|
|
1318
|
+
if (TAB_STARTED.has(this.#state)) {
|
|
1307
1319
|
this.#badResponse("http.response.start sent twice");
|
|
1308
1320
|
return;
|
|
1309
1321
|
}
|
|
1310
|
-
|
|
1322
|
+
const eventStream = isEventStream(msg.headers);
|
|
1323
|
+
if (this.isListen && !eventStream) {
|
|
1324
|
+
this.#badResponse("a listen response must be an event stream");
|
|
1325
|
+
return;
|
|
1326
|
+
}
|
|
1311
1327
|
if (this.#state === "waiting") {
|
|
1312
1328
|
if (NULL_BODY_STATUSES.has(msg.status)) {
|
|
1313
1329
|
this.#badResponse(`status ${msg.status} cannot be streamed`);
|
|
@@ -1320,25 +1336,29 @@ class Exchange {
|
|
|
1320
1336
|
this.#badResponse("the response headers are invalid");
|
|
1321
1337
|
return;
|
|
1322
1338
|
}
|
|
1323
|
-
this.#openStream(msg.status, headers);
|
|
1324
|
-
} else
|
|
1325
|
-
this.#
|
|
1339
|
+
this.#openStream(msg.status, headers, "tab_stream");
|
|
1340
|
+
} else {
|
|
1341
|
+
this.#state = eventStream ? "relay_passed" : "relay_wrapped";
|
|
1326
1342
|
}
|
|
1327
1343
|
}
|
|
1328
1344
|
onChunk(data) {
|
|
1329
|
-
if (this.#
|
|
1345
|
+
if (!this.#streaming)
|
|
1346
|
+
return;
|
|
1347
|
+
if (this.#state === "relay_stream") {
|
|
1348
|
+
this.#badResponse("http.response.chunk before http.response.start");
|
|
1330
1349
|
return;
|
|
1350
|
+
}
|
|
1331
1351
|
if (!this.#account(data))
|
|
1332
1352
|
return;
|
|
1333
|
-
if (this.#
|
|
1353
|
+
if (this.#state === "relay_wrapped")
|
|
1334
1354
|
this.#wrapped += data;
|
|
1335
1355
|
else
|
|
1336
1356
|
this.#write(data);
|
|
1337
1357
|
}
|
|
1338
1358
|
onEnd() {
|
|
1339
|
-
if (this.#
|
|
1359
|
+
if (!this.#streaming)
|
|
1340
1360
|
return;
|
|
1341
|
-
if (this.#
|
|
1361
|
+
if (this.#state === "relay_wrapped" && this.#wrapped !== "")
|
|
1342
1362
|
this.#write(sseEvent(this.#wrapped));
|
|
1343
1363
|
this.#finish("ok");
|
|
1344
1364
|
}
|
|
@@ -1360,6 +1380,11 @@ class Exchange {
|
|
|
1360
1380
|
this.#finish("page_gone_retry");
|
|
1361
1381
|
return;
|
|
1362
1382
|
}
|
|
1383
|
+
if (reply !== undefined && "error" in reply) {
|
|
1384
|
+
const e = reply.error;
|
|
1385
|
+
this.fail(e.status, e.code, e.message, false, e.headers);
|
|
1386
|
+
return;
|
|
1387
|
+
}
|
|
1363
1388
|
const body = reply?.retry === false ? reply.body : undefined;
|
|
1364
1389
|
if (body === undefined) {
|
|
1365
1390
|
this.fail(503, RelayErrorCode.tabGone, "The page closed its connection to the relay.", false);
|
|
@@ -1375,11 +1400,11 @@ class Exchange {
|
|
|
1375
1400
|
}
|
|
1376
1401
|
this.#finish("page_gone");
|
|
1377
1402
|
}
|
|
1378
|
-
fail(status, code, message, cancelTab) {
|
|
1403
|
+
fail(status, code, message, cancelTab, headers = {}) {
|
|
1379
1404
|
if (this.#state === "done")
|
|
1380
1405
|
return;
|
|
1381
1406
|
if (this.#state === "waiting") {
|
|
1382
|
-
this.#respond(this.#errorResponse(status, code, message), status);
|
|
1407
|
+
this.#respond(this.#errorResponse(status, code, message, headers), status);
|
|
1383
1408
|
} else if (this.#atBoundary && this.#options.rpcId !== undefined) {
|
|
1384
1409
|
this.#write(sseEvent(rpcErrorBody(this.#options.rpcId, code, message)));
|
|
1385
1410
|
}
|
|
@@ -1393,7 +1418,8 @@ class Exchange {
|
|
|
1393
1418
|
}
|
|
1394
1419
|
#account(data) {
|
|
1395
1420
|
this.#bodyBytes += Buffer.byteLength(data);
|
|
1396
|
-
|
|
1421
|
+
const unbounded = this.isListen && (this.#state === "tab_stream" || this.#state === "relay_passed");
|
|
1422
|
+
if (unbounded || this.#bodyBytes <= this.#options.maxBodyBytes) {
|
|
1397
1423
|
return true;
|
|
1398
1424
|
}
|
|
1399
1425
|
this.fail(502, RelayErrorCode.tooLarge, "The page sent a response that is too large.", true);
|
|
@@ -1408,10 +1434,9 @@ class Exchange {
|
|
|
1408
1434
|
#startRelayStream() {
|
|
1409
1435
|
if (this.#state !== "waiting")
|
|
1410
1436
|
return;
|
|
1411
|
-
this.#
|
|
1412
|
-
this.#openStream(200, this.#headers({ "content-type": "text/event-stream" }));
|
|
1437
|
+
this.#openStream(200, this.#headers({ "content-type": "text/event-stream" }), "relay_stream");
|
|
1413
1438
|
}
|
|
1414
|
-
#openStream(status, headers) {
|
|
1439
|
+
#openStream(status, headers, state) {
|
|
1415
1440
|
const stream = new ReadableStream({
|
|
1416
1441
|
start: (controller) => {
|
|
1417
1442
|
this.#controller = controller;
|
|
@@ -1432,12 +1457,12 @@ class Exchange {
|
|
|
1432
1457
|
this.#badResponse("the response could not be built");
|
|
1433
1458
|
return;
|
|
1434
1459
|
}
|
|
1435
|
-
this.#state =
|
|
1460
|
+
this.#state = state;
|
|
1436
1461
|
this.#respond(response, status);
|
|
1437
1462
|
this.#scheduleKeepAlive();
|
|
1438
1463
|
}
|
|
1439
1464
|
#write(text) {
|
|
1440
|
-
if (this.#
|
|
1465
|
+
if (!this.#streaming || this.#controller === undefined || text === "")
|
|
1441
1466
|
return;
|
|
1442
1467
|
try {
|
|
1443
1468
|
this.#controller.enqueue(encoder.encode(text));
|
|
@@ -1454,16 +1479,19 @@ class Exchange {
|
|
|
1454
1479
|
}
|
|
1455
1480
|
this.#scheduleKeepAlive();
|
|
1456
1481
|
}
|
|
1482
|
+
get #streaming() {
|
|
1483
|
+
return this.#state !== "waiting" && this.#state !== "done";
|
|
1484
|
+
}
|
|
1457
1485
|
#scheduleKeepAlive() {
|
|
1458
1486
|
clearTimeout(this.#keepAliveTimer);
|
|
1459
|
-
if (this.#
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1487
|
+
if (!this.#streaming)
|
|
1488
|
+
return;
|
|
1489
|
+
if (this.#state === "relay_wrapped") {
|
|
1490
|
+
this.#keepAliveTimer = setTimeout(() => {
|
|
1491
|
+
this.#write(`: keep-alive
|
|
1463
1492
|
|
|
1464
1493
|
`);
|
|
1465
|
-
|
|
1466
|
-
}
|
|
1494
|
+
}, this.#options.keepAliveMs);
|
|
1467
1495
|
return;
|
|
1468
1496
|
}
|
|
1469
1497
|
this.#keepAliveTimer = setTimeout(() => {
|
|
@@ -1483,8 +1511,10 @@ class Exchange {
|
|
|
1483
1511
|
}
|
|
1484
1512
|
return headers;
|
|
1485
1513
|
}
|
|
1486
|
-
#errorResponse(status, code, message) {
|
|
1514
|
+
#errorResponse(status, code, message, extra) {
|
|
1487
1515
|
const headers = this.#headers({ "content-type": "application/json" });
|
|
1516
|
+
for (const [k, v] of Object.entries(extra))
|
|
1517
|
+
headers.set(k, v);
|
|
1488
1518
|
return new Response(rpcErrorBody(this.#options.rpcId, code, message), {
|
|
1489
1519
|
status,
|
|
1490
1520
|
headers
|
|
@@ -1498,7 +1528,7 @@ class Exchange {
|
|
|
1498
1528
|
#finish(outcome, closeStream = true) {
|
|
1499
1529
|
if (this.#state === "done")
|
|
1500
1530
|
return;
|
|
1501
|
-
const wasStreaming = this.#
|
|
1531
|
+
const wasStreaming = this.#streaming;
|
|
1502
1532
|
this.#state = "done";
|
|
1503
1533
|
this.outcome = outcome;
|
|
1504
1534
|
clearTimeout(this.#startTimer);
|
|
@@ -1527,19 +1557,28 @@ function notOpenText(appName) {
|
|
|
1527
1557
|
function signedOutText(appName) {
|
|
1528
1558
|
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
1559
|
}
|
|
1560
|
+
function restartingText() {
|
|
1561
|
+
return "The relay is restarting. Try again in a few seconds.";
|
|
1562
|
+
}
|
|
1530
1563
|
function phaseText(phase, appName) {
|
|
1564
|
+
if (phase === "restarting" || phase === "restarting_gone") {
|
|
1565
|
+
return restartingText();
|
|
1566
|
+
}
|
|
1531
1567
|
return phase === "signed_out" ? signedOutText(appName) : notOpenText(appName);
|
|
1532
1568
|
}
|
|
1533
1569
|
function goneDuringCallText(appName) {
|
|
1534
1570
|
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
1571
|
}
|
|
1572
|
+
function restartedDuringCallText() {
|
|
1573
|
+
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.";
|
|
1574
|
+
}
|
|
1536
1575
|
function notOpenAnswer(body, rpcId, appName, phase, relay) {
|
|
1537
1576
|
if (rpcId === undefined)
|
|
1538
1577
|
return;
|
|
1539
1578
|
const id = rpcId;
|
|
1540
1579
|
const method = body["method"];
|
|
1541
1580
|
if (method === "tools/call") {
|
|
1542
|
-
const text = phase === "gone" ? goneDuringCallText(appName) : phaseText(phase, appName);
|
|
1581
|
+
const text = phase === "gone" ? goneDuringCallText(appName) : phase === "restarting_gone" ? restartedDuringCallText() : phaseText(phase, appName);
|
|
1543
1582
|
return JSON.stringify({
|
|
1544
1583
|
jsonrpc: "2.0",
|
|
1545
1584
|
id,
|
|
@@ -1574,7 +1613,7 @@ function notOpenAnswer(body, rpcId, appName, phase, relay) {
|
|
|
1574
1613
|
jsonrpc: "2.0",
|
|
1575
1614
|
id,
|
|
1576
1615
|
error: {
|
|
1577
|
-
code: phase === "gone" ? RelayErrorCode.tabGone : RelayErrorCode.pageNotOpen,
|
|
1616
|
+
code: phase === "gone" ? RelayErrorCode.tabGone : phase === "restarting" || phase === "restarting_gone" ? RelayErrorCode.restarting : RelayErrorCode.pageNotOpen,
|
|
1578
1617
|
message: phaseText(phase, appName)
|
|
1579
1618
|
}
|
|
1580
1619
|
});
|
|
@@ -1614,6 +1653,13 @@ class RateLimiter {
|
|
|
1614
1653
|
bucket.tokens -= 1;
|
|
1615
1654
|
return true;
|
|
1616
1655
|
}
|
|
1656
|
+
available(key, now = Date.now()) {
|
|
1657
|
+
const bucket = this.#buckets.get(key);
|
|
1658
|
+
if (bucket === undefined)
|
|
1659
|
+
return this.#capacity >= 1;
|
|
1660
|
+
const elapsed = Math.max(0, now - bucket.updatedAt) / 1000;
|
|
1661
|
+
return Math.min(this.#capacity, bucket.tokens + elapsed * this.#refill) >= 1;
|
|
1662
|
+
}
|
|
1617
1663
|
sweep(now = Date.now()) {
|
|
1618
1664
|
for (const [key, b] of this.#buckets) {
|
|
1619
1665
|
const elapsed = Math.max(0, now - b.updatedAt) / 1000;
|
|
@@ -1699,7 +1745,7 @@ async function defaultLookup(hostname) {
|
|
|
1699
1745
|
const records = await Bun.dns.lookup(hostname, {});
|
|
1700
1746
|
return records.map((r) => r.address);
|
|
1701
1747
|
}
|
|
1702
|
-
var INVISIBLE = /[\u034F\u115F\u1160\u17B4\u17B5\u2800\u3164\uFFA0]/gu;
|
|
1748
|
+
var INVISIBLE = /[\u034F\u115F\u1160\u17B4\u17B5\u2800\u3164\uFFA0]|\p{Variation_Selector}/gu;
|
|
1703
1749
|
function displayName(raw) {
|
|
1704
1750
|
let cleaned = raw.normalize("NFC").replace(/[\p{Cc}\p{Zl}\p{Zp}]/gu, " ").replace(/\p{Cf}/gu, "").replace(INVISIBLE, "");
|
|
1705
1751
|
cleaned = cleaned.replace(/\s+/g, " ").trim();
|
|
@@ -1722,10 +1768,17 @@ function isIpLiteral(hostname) {
|
|
|
1722
1768
|
const h = hostname.replace(/^\[|\]$/g, "");
|
|
1723
1769
|
return parseIpv4(h) !== undefined || parseIpv6(h) !== undefined;
|
|
1724
1770
|
}
|
|
1771
|
+
function limitKey(hostname) {
|
|
1772
|
+
const h = hostname.replace(/^\[|\]$/g, "").replace(/\.$/, "");
|
|
1773
|
+
if (isIpLiteral(h))
|
|
1774
|
+
return h;
|
|
1775
|
+
return h.split(".").slice(-2).join(".");
|
|
1776
|
+
}
|
|
1725
1777
|
|
|
1726
1778
|
class ClientMetadataFetcher {
|
|
1727
1779
|
#cache = new Map;
|
|
1728
1780
|
#perHost = new RateLimiter({ capacity: 5, refillPerSecond: 0.2 });
|
|
1781
|
+
#perDomain = new RateLimiter({ capacity: 15, refillPerSecond: 0.6 });
|
|
1729
1782
|
#global = new RateLimiter({ capacity: 30, refillPerSecond: 1 });
|
|
1730
1783
|
#allowedHosts;
|
|
1731
1784
|
#logger;
|
|
@@ -1749,22 +1802,28 @@ class ClientMetadataFetcher {
|
|
|
1749
1802
|
const url = this.#checkUrl(clientId);
|
|
1750
1803
|
const now = this.#now();
|
|
1751
1804
|
const cached = this.#cache.get(clientId);
|
|
1752
|
-
if (cached !== undefined && cached.until > now)
|
|
1805
|
+
if (cached !== undefined && cached.until > now) {
|
|
1806
|
+
this.#cache.delete(clientId);
|
|
1807
|
+
this.#cache.set(clientId, cached);
|
|
1753
1808
|
return cached.meta;
|
|
1754
|
-
|
|
1809
|
+
}
|
|
1810
|
+
const key = limitKey(url.hostname);
|
|
1811
|
+
if (!this.#perHost.available(url.host, now) || !this.#perDomain.available(key, now)) {
|
|
1755
1812
|
throw new ClientMetadataError("rate_limited", "Too many requests for this AI app's details. Try again in a minute.");
|
|
1756
1813
|
}
|
|
1757
1814
|
if (!this.#global.take("all", now)) {
|
|
1758
1815
|
throw new ClientMetadataError("rate_limited", "The relay is busy. Try again in a minute.");
|
|
1759
1816
|
}
|
|
1817
|
+
this.#perHost.take(url.host, now);
|
|
1818
|
+
this.#perDomain.take(key, now);
|
|
1760
1819
|
const address = await this.#vettedAddress(url);
|
|
1761
1820
|
const { meta, ttlMs } = await this.#fetchDocument(url, clientId, address);
|
|
1821
|
+
this.#cache.delete(clientId);
|
|
1762
1822
|
if (this.#cache.size >= MAX_CACHE_ENTRIES) {
|
|
1763
1823
|
const oldest = this.#cache.keys().next().value;
|
|
1764
1824
|
if (oldest !== undefined)
|
|
1765
1825
|
this.#cache.delete(oldest);
|
|
1766
1826
|
}
|
|
1767
|
-
this.#cache.delete(clientId);
|
|
1768
1827
|
this.#cache.set(clientId, { meta, until: now + ttlMs });
|
|
1769
1828
|
return meta;
|
|
1770
1829
|
}
|
|
@@ -1899,6 +1958,27 @@ class ClientMetadataFetcher {
|
|
|
1899
1958
|
}
|
|
1900
1959
|
}
|
|
1901
1960
|
}
|
|
1961
|
+
var DOMAIN_SHAPED = /(?:[\p{L}\p{N}](?:[\p{L}\p{N}-]*[\p{L}\p{N}])?\.)+\p{L}{2,63}(?![\p{L}\p{N}])/gu;
|
|
1962
|
+
function comparable(name) {
|
|
1963
|
+
return name.normalize("NFKC").replace(/\p{M}/gu, "").replace(/[\u3002\uFF0E\uFF61]/g, ".").toLowerCase();
|
|
1964
|
+
}
|
|
1965
|
+
function asciiHost(token) {
|
|
1966
|
+
try {
|
|
1967
|
+
return new URL(`https://${token}/`).hostname;
|
|
1968
|
+
} catch {
|
|
1969
|
+
return token;
|
|
1970
|
+
}
|
|
1971
|
+
}
|
|
1972
|
+
function foreignDomain(name, hostname) {
|
|
1973
|
+
const host = hostname.toLowerCase();
|
|
1974
|
+
for (const [match] of comparable(name).matchAll(DOMAIN_SHAPED)) {
|
|
1975
|
+
const token = asciiHost(match);
|
|
1976
|
+
const own = token === host || host.endsWith(`.${token}`) || token.endsWith(`.${host}`);
|
|
1977
|
+
if (!own)
|
|
1978
|
+
return true;
|
|
1979
|
+
}
|
|
1980
|
+
return false;
|
|
1981
|
+
}
|
|
1902
1982
|
function validateDocument(doc, clientId, url) {
|
|
1903
1983
|
const invalid = (message) => new ClientMetadataError("invalid_metadata", message);
|
|
1904
1984
|
if (typeof doc !== "object" || doc === null || Array.isArray(doc)) {
|
|
@@ -1915,9 +1995,12 @@ function validateDocument(doc, clientId, url) {
|
|
|
1915
1995
|
if (typeof name !== "string" || displayName(name) === "") {
|
|
1916
1996
|
throw invalid("The AI app's details have no client_name.");
|
|
1917
1997
|
}
|
|
1918
|
-
if (/\(\s*[^()\s]*[a-z0-9-]\.[a-z]{2,}[^()]*\)/i.test(displayName(name))) {
|
|
1998
|
+
if (/\(\s*[^()\s]*[a-z0-9-]\.[a-z]{2,}[^()]*\)/i.test(comparable(displayName(name)))) {
|
|
1919
1999
|
throw invalid("The AI app's name contains a web address in parentheses, which this relay does not show.");
|
|
1920
2000
|
}
|
|
2001
|
+
if (foreignDomain(displayName(name), url.hostname)) {
|
|
2002
|
+
throw invalid("The AI app's name contains a web address other than its own, which this relay does not show.");
|
|
2003
|
+
}
|
|
1921
2004
|
const uris = d["redirect_uris"];
|
|
1922
2005
|
if (!Array.isArray(uris) || uris.length === 0 || uris.length > MAX_REDIRECT_URIS || !uris.every((u) => typeof u === "string" && isRedirectUri(u))) {
|
|
1923
2006
|
throw invalid("The AI app's details have no valid redirect_uris.");
|
|
@@ -2132,14 +2215,17 @@ class ProviderDiscovery {
|
|
|
2132
2215
|
class OidcPairing {
|
|
2133
2216
|
kind = "oidc";
|
|
2134
2217
|
#config;
|
|
2218
|
+
#maxFlows;
|
|
2135
2219
|
#publicUrl;
|
|
2136
2220
|
#logger;
|
|
2137
2221
|
#discovery;
|
|
2138
2222
|
#jwks;
|
|
2139
2223
|
#flows = new Map;
|
|
2224
|
+
#flowOf = new Map;
|
|
2140
2225
|
#mismatched = new Set;
|
|
2141
2226
|
constructor(options) {
|
|
2142
2227
|
this.#config = options.config;
|
|
2228
|
+
this.#maxFlows = options.maxFlows ?? MAX_FLOWS;
|
|
2143
2229
|
this.#publicUrl = options.publicUrl;
|
|
2144
2230
|
this.#logger = options.logger;
|
|
2145
2231
|
this.#discovery = new ProviderDiscovery(options.config.issuer, options.development);
|
|
@@ -2176,7 +2262,7 @@ class OidcPairing {
|
|
|
2176
2262
|
}
|
|
2177
2263
|
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
2264
|
}
|
|
2179
|
-
async check(target, _form,
|
|
2265
|
+
async check(target, _form, admit, context) {
|
|
2180
2266
|
if (!target.pairingOpen) {
|
|
2181
2267
|
return {
|
|
2182
2268
|
ok: false,
|
|
@@ -2201,16 +2287,25 @@ class OidcPairing {
|
|
|
2201
2287
|
return this.#unavailable(error);
|
|
2202
2288
|
}
|
|
2203
2289
|
this.#sweep();
|
|
2204
|
-
|
|
2290
|
+
this.#dropFlow(context.requestId);
|
|
2291
|
+
if (this.#flows.size >= this.#maxFlows) {
|
|
2205
2292
|
return {
|
|
2206
2293
|
ok: false,
|
|
2207
2294
|
message: "Too many sign-ins are open right now. Wait a few minutes and try again.",
|
|
2208
2295
|
reason: "busy"
|
|
2209
2296
|
};
|
|
2210
2297
|
}
|
|
2298
|
+
if (!admit()) {
|
|
2299
|
+
return {
|
|
2300
|
+
ok: false,
|
|
2301
|
+
message: "Too many attempts for this page. Wait a few minutes and try again.",
|
|
2302
|
+
reason: "rate_limited"
|
|
2303
|
+
};
|
|
2304
|
+
}
|
|
2211
2305
|
const state = random2(24);
|
|
2212
2306
|
const nonce = random2(24);
|
|
2213
2307
|
const verifier = random2(32);
|
|
2308
|
+
this.#flowOf.set(context.requestId, state);
|
|
2214
2309
|
this.#flows.set(state, {
|
|
2215
2310
|
requestId: context.requestId,
|
|
2216
2311
|
browserId: context.browserId,
|
|
@@ -2250,6 +2345,7 @@ class OidcPairing {
|
|
|
2250
2345
|
}
|
|
2251
2346
|
this.#flows.delete(state);
|
|
2252
2347
|
const requestId = flow.requestId;
|
|
2348
|
+
this.#flowOf.delete(requestId);
|
|
2253
2349
|
const fail = (reason, message) => ({
|
|
2254
2350
|
requestId,
|
|
2255
2351
|
result: { ok: false, message, reason }
|
|
@@ -2376,12 +2472,25 @@ class OidcPairing {
|
|
|
2376
2472
|
reason: "provider_unavailable"
|
|
2377
2473
|
};
|
|
2378
2474
|
}
|
|
2475
|
+
forget(requestId) {
|
|
2476
|
+
this.#dropFlow(requestId);
|
|
2477
|
+
this.#mismatched.delete(requestId);
|
|
2478
|
+
}
|
|
2479
|
+
#dropFlow(requestId) {
|
|
2480
|
+
const state = this.#flowOf.get(requestId);
|
|
2481
|
+
if (state !== undefined)
|
|
2482
|
+
this.#flows.delete(state);
|
|
2483
|
+
this.#flowOf.delete(requestId);
|
|
2484
|
+
}
|
|
2379
2485
|
#sweep() {
|
|
2380
2486
|
const now = Date.now();
|
|
2381
2487
|
for (const [state, flow] of this.#flows) {
|
|
2382
2488
|
if (flow.expiresAt > now)
|
|
2383
2489
|
break;
|
|
2384
2490
|
this.#flows.delete(state);
|
|
2491
|
+
if (this.#flowOf.get(flow.requestId) === state) {
|
|
2492
|
+
this.#flowOf.delete(flow.requestId);
|
|
2493
|
+
}
|
|
2385
2494
|
}
|
|
2386
2495
|
}
|
|
2387
2496
|
}
|
|
@@ -2519,6 +2628,8 @@ var AUTH_CODE_TTL_MS = 60000;
|
|
|
2519
2628
|
var MAX_PENDING = 1e4;
|
|
2520
2629
|
var MAX_PENDING_PER_IP = 10;
|
|
2521
2630
|
var MAX_PENDING_PER_ADDRESS = 20;
|
|
2631
|
+
var MAX_PENDING_PER_ADDRESS_IP = 5;
|
|
2632
|
+
var REFRESH_REUSE_WINDOW_MS = 24 * 60 * 60000;
|
|
2522
2633
|
var MAX_CODES = 1e4;
|
|
2523
2634
|
var MAX_FORM_BYTES = 8192;
|
|
2524
2635
|
var MAX_STATE_LENGTH = 1024;
|
|
@@ -2599,6 +2710,10 @@ class AuthorizationServer {
|
|
|
2599
2710
|
refillPerSecond: 1 / 30
|
|
2600
2711
|
});
|
|
2601
2712
|
#tokenPerIp = new RateLimiter({ capacity: 30, refillPerSecond: 1 });
|
|
2713
|
+
#refreshesPerGrant = new RateLimiter({
|
|
2714
|
+
capacity: 20,
|
|
2715
|
+
refillPerSecond: 1 / 60
|
|
2716
|
+
});
|
|
2602
2717
|
constructor(host, testMode) {
|
|
2603
2718
|
this.#host = host;
|
|
2604
2719
|
this.#authCodeTtlMs = testMode?.authCodeTtlMs ?? AUTH_CODE_TTL_MS;
|
|
@@ -2610,7 +2725,8 @@ class AuthorizationServer {
|
|
|
2610
2725
|
config: oidc,
|
|
2611
2726
|
publicUrl: () => host.publicUrl(),
|
|
2612
2727
|
logger: host.logger,
|
|
2613
|
-
development: host.config.development
|
|
2728
|
+
development: host.config.development,
|
|
2729
|
+
...testMode?.oidcMaxFlows === undefined ? {} : { maxFlows: testMode.oidcMaxFlows }
|
|
2614
2730
|
})
|
|
2615
2731
|
},
|
|
2616
2732
|
...testMode?.pairingMethods
|
|
@@ -2776,7 +2892,7 @@ class AuthorizationServer {
|
|
|
2776
2892
|
if (error instanceof ClientMetadataError) {
|
|
2777
2893
|
this.#host.logger.info("oauth.client_refused", {
|
|
2778
2894
|
reason: error.reason,
|
|
2779
|
-
host: hostOf(clientId).slice(0, 100)
|
|
2895
|
+
host: URL.canParse(clientId) ? hostOf(clientId).slice(0, 100) : "(not a URL)"
|
|
2780
2896
|
});
|
|
2781
2897
|
return this.#errorPage(400, error.message);
|
|
2782
2898
|
}
|
|
@@ -2822,10 +2938,11 @@ class AuthorizationServer {
|
|
|
2822
2938
|
if (scopes === undefined) {
|
|
2823
2939
|
return fail("invalid_scope", `the only scope is "${SCOPE}"`);
|
|
2824
2940
|
}
|
|
2825
|
-
|
|
2826
|
-
|
|
2941
|
+
const session = this.#host.session(addressId);
|
|
2942
|
+
if (session === undefined || !session.pairingOpen) {
|
|
2943
|
+
return this.#offlinePage(client, `/authorize${new URL(req.url).search}`, this.#errorUrl(redirectUri, state, "access_denied", "the user cancelled the connection"), session !== undefined);
|
|
2827
2944
|
}
|
|
2828
|
-
const method = this.#methods[
|
|
2945
|
+
const method = this.#methods[session.site.pairing];
|
|
2829
2946
|
if (method?.prepare !== undefined) {
|
|
2830
2947
|
try {
|
|
2831
2948
|
await method.prepare();
|
|
@@ -2837,9 +2954,10 @@ class AuthorizationServer {
|
|
|
2837
2954
|
}
|
|
2838
2955
|
}
|
|
2839
2956
|
this.#sweepPending();
|
|
2840
|
-
if (this.#pending.size >= MAX_PENDING || (this.#pendingPerIp.get(ip) ?? 0) >= MAX_PENDING_PER_IP
|
|
2957
|
+
if (this.#pending.size >= MAX_PENDING || (this.#pendingPerIp.get(ip) ?? 0) >= MAX_PENDING_PER_IP) {
|
|
2841
2958
|
return this.#errorPage(429, "Too many connection requests are open right now. Wait a few minutes and try again.");
|
|
2842
2959
|
}
|
|
2960
|
+
this.#makeRoom(addressId, ip);
|
|
2843
2961
|
const pending = {
|
|
2844
2962
|
id: random3(24),
|
|
2845
2963
|
expiresAt: Date.now() + PENDING_TTL_MS,
|
|
@@ -2861,6 +2979,36 @@ class AuthorizationServer {
|
|
|
2861
2979
|
this.#pendingPerIp.set(p.ip, (this.#pendingPerIp.get(p.ip) ?? 0) + 1);
|
|
2862
2980
|
this.#pendingPerAddress.set(p.addressId, (this.#pendingPerAddress.get(p.addressId) ?? 0) + 1);
|
|
2863
2981
|
}
|
|
2982
|
+
#makeRoom(addressId, ip) {
|
|
2983
|
+
const count = this.#pendingPerAddress.get(addressId) ?? 0;
|
|
2984
|
+
if (count < MAX_PENDING_PER_ADDRESS_IP)
|
|
2985
|
+
return;
|
|
2986
|
+
const byIp = new Map;
|
|
2987
|
+
for (const p of this.#pending.values()) {
|
|
2988
|
+
if (p.addressId !== addressId)
|
|
2989
|
+
continue;
|
|
2990
|
+
const list = byIp.get(p.ip);
|
|
2991
|
+
if (list === undefined)
|
|
2992
|
+
byIp.set(p.ip, [p]);
|
|
2993
|
+
else
|
|
2994
|
+
list.push(p);
|
|
2995
|
+
}
|
|
2996
|
+
const own = byIp.get(ip) ?? [];
|
|
2997
|
+
if (own.length >= MAX_PENDING_PER_ADDRESS_IP) {
|
|
2998
|
+
if (own[0] !== undefined)
|
|
2999
|
+
this.#dropPending(own[0]);
|
|
3000
|
+
return;
|
|
3001
|
+
}
|
|
3002
|
+
if (count < MAX_PENDING_PER_ADDRESS)
|
|
3003
|
+
return;
|
|
3004
|
+
let most = [];
|
|
3005
|
+
for (const list of byIp.values()) {
|
|
3006
|
+
if (list.length >= most.length)
|
|
3007
|
+
most = list;
|
|
3008
|
+
}
|
|
3009
|
+
if (most[0] !== undefined)
|
|
3010
|
+
this.#dropPending(most[0]);
|
|
3011
|
+
}
|
|
2864
3012
|
#dropPending(p) {
|
|
2865
3013
|
if (!this.#pending.delete(p.id))
|
|
2866
3014
|
return;
|
|
@@ -2873,18 +3021,22 @@ class AuthorizationServer {
|
|
|
2873
3021
|
};
|
|
2874
3022
|
dec(this.#pendingPerIp, p.ip);
|
|
2875
3023
|
dec(this.#pendingPerAddress, p.addressId);
|
|
2876
|
-
|
|
2877
|
-
|
|
2878
|
-
|
|
2879
|
-
|
|
2880
|
-
${
|
|
2881
|
-
|
|
2882
|
-
|
|
3024
|
+
for (const method of Object.values(this.#methods))
|
|
3025
|
+
method?.forget?.(p.id);
|
|
3026
|
+
}
|
|
3027
|
+
#offlinePage(client, retryUrl, cancelUrl, connected = false) {
|
|
3028
|
+
const who = `<strong>${escapeHtml(client.name)}</strong> (${escapeHtml(client.host)})`;
|
|
3029
|
+
const title = connected ? "Open \u201CConnect your AI app\u201D first" : "Open the page first";
|
|
3030
|
+
const intro = connected ? `<p>${who} wants to use the tools of a web page, but that page is not connecting right now.</p>
|
|
3031
|
+
<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>
|
|
3032
|
+
<p>Open the page, click \u201CConnect your AI app\u201D, then try again here.</p>`;
|
|
3033
|
+
const body = `<h1>${title}</h1>
|
|
3034
|
+
${intro}
|
|
2883
3035
|
<div class="actions">
|
|
2884
3036
|
<a class="button primary" href="${escapeHtml(retryUrl)}">Try again</a>
|
|
2885
3037
|
<a class="button" href="${escapeHtml(cancelUrl)}">Cancel</a>
|
|
2886
3038
|
</div>`;
|
|
2887
|
-
return this.#page(200,
|
|
3039
|
+
return this.#page(200, title, body);
|
|
2888
3040
|
}
|
|
2889
3041
|
async authorizeSubmit(req, ip) {
|
|
2890
3042
|
const origin = req.headers.get("origin");
|
|
@@ -2963,7 +3115,7 @@ ${errorHtml}
|
|
|
2963
3115
|
client: pending.client.host,
|
|
2964
3116
|
reason: result.reason
|
|
2965
3117
|
});
|
|
2966
|
-
return this.#pairingPage(pending, browser.id, result.message, result.reason === "rate_limited" ? 429 : 400);
|
|
3118
|
+
return this.#pairingPage(pending, browser.id, result.message, result.reason === "rate_limited" || result.reason === "busy" ? 429 : 400);
|
|
2967
3119
|
}
|
|
2968
3120
|
return this.#paired(pending, method.kind, result);
|
|
2969
3121
|
}
|
|
@@ -3307,7 +3459,10 @@ ${cancel}
|
|
|
3307
3459
|
throw new OAuthError(400, "invalid_scope", "scope exceeds the grant");
|
|
3308
3460
|
}
|
|
3309
3461
|
}
|
|
3310
|
-
if (!
|
|
3462
|
+
if (!this.#refreshesPerGrant.take(record.grantId, now)) {
|
|
3463
|
+
throw new OAuthError(429, "temporarily_unavailable", "too many refreshes for this connection; try again in a minute");
|
|
3464
|
+
}
|
|
3465
|
+
if (!await this.#host.store.markUsed(hash, now, now + REFRESH_REUSE_WINDOW_MS)) {
|
|
3311
3466
|
await this.#reuseDetected(record);
|
|
3312
3467
|
throw invalid("the refresh token was already used; the connection was revoked");
|
|
3313
3468
|
}
|
|
@@ -3416,6 +3571,7 @@ ${cancel}
|
|
|
3416
3571
|
this.#guessesPerAddressIp.sweep(now);
|
|
3417
3572
|
this.#guessesPerAddress.sweep(now);
|
|
3418
3573
|
this.#tokenPerIp.sweep(now);
|
|
3574
|
+
this.#refreshesPerGrant.sweep(now);
|
|
3419
3575
|
}
|
|
3420
3576
|
}
|
|
3421
3577
|
|
|
@@ -3438,11 +3594,8 @@ function clientHost(clientId) {
|
|
|
3438
3594
|
return "";
|
|
3439
3595
|
}
|
|
3440
3596
|
}
|
|
3441
|
-
function
|
|
3442
|
-
|
|
3443
|
-
return "id" in parsed.message ? parsed.message.id : undefined;
|
|
3444
|
-
}
|
|
3445
|
-
return parsed.id;
|
|
3597
|
+
function frameId(data) {
|
|
3598
|
+
return /"id"\s*:\s*"([^"\\]{1,64})"/.exec(data.slice(0, 256))?.[1];
|
|
3446
3599
|
}
|
|
3447
3600
|
|
|
3448
3601
|
class TabSession {
|
|
@@ -3455,7 +3608,9 @@ class TabSession {
|
|
|
3455
3608
|
appName;
|
|
3456
3609
|
codes;
|
|
3457
3610
|
identity;
|
|
3611
|
+
identityUnchecked = false;
|
|
3458
3612
|
pending = new Map;
|
|
3613
|
+
#listenGrants = new Map;
|
|
3459
3614
|
#ws;
|
|
3460
3615
|
#host;
|
|
3461
3616
|
#state = "new";
|
|
@@ -3520,15 +3675,14 @@ class TabSession {
|
|
|
3520
3675
|
this.#protocolError("bad_message", "binary frames are not allowed");
|
|
3521
3676
|
return;
|
|
3522
3677
|
}
|
|
3523
|
-
|
|
3524
|
-
|
|
3525
|
-
if (tooLarge) {
|
|
3526
|
-
const id = messageId(parsed);
|
|
3678
|
+
if (Buffer.byteLength(data) > this.limits.maxMessageBytes) {
|
|
3679
|
+
const id = frameId(data);
|
|
3527
3680
|
this.#sendError("too_large", "message exceeds maxMessageBytes", id);
|
|
3528
3681
|
const exchange = id === undefined ? undefined : this.pending.get(id);
|
|
3529
3682
|
exchange?.fail(502, RelayErrorCode.tooLarge, "The page sent a response that is too large.", true);
|
|
3530
3683
|
return;
|
|
3531
3684
|
}
|
|
3685
|
+
const parsed = parseTabMessage(data);
|
|
3532
3686
|
if (!parsed.ok) {
|
|
3533
3687
|
if (this.#state === "new") {
|
|
3534
3688
|
this.#protocolError("bad_message", parsed.error);
|
|
@@ -3657,7 +3811,7 @@ class TabSession {
|
|
|
3657
3811
|
} catch (error) {
|
|
3658
3812
|
this.#host.logger.error("store.error", {
|
|
3659
3813
|
op: "listGrants",
|
|
3660
|
-
|
|
3814
|
+
error: errorName(error)
|
|
3661
3815
|
});
|
|
3662
3816
|
}
|
|
3663
3817
|
if (this.#state !== "hello")
|
|
@@ -3689,7 +3843,7 @@ class TabSession {
|
|
|
3689
3843
|
library: `${library.name}@${library.version}`,
|
|
3690
3844
|
replaced: replaced !== undefined
|
|
3691
3845
|
});
|
|
3692
|
-
this.#host.ready
|
|
3846
|
+
this.#host.ready(this);
|
|
3693
3847
|
}
|
|
3694
3848
|
#serialIdentity(run) {
|
|
3695
3849
|
const next = this.#identityChain.then(run);
|
|
@@ -3710,6 +3864,13 @@ class TabSession {
|
|
|
3710
3864
|
this.#missedPongs++;
|
|
3711
3865
|
this.send({ type: "ping", t: Date.now() });
|
|
3712
3866
|
}
|
|
3867
|
+
listenStreams(grantId) {
|
|
3868
|
+
let n = 0;
|
|
3869
|
+
for (const g of this.#listenGrants.values())
|
|
3870
|
+
if (g === grantId)
|
|
3871
|
+
n++;
|
|
3872
|
+
return n;
|
|
3873
|
+
}
|
|
3713
3874
|
forward(input) {
|
|
3714
3875
|
this.#lastRequestAt = Date.now();
|
|
3715
3876
|
const id = `r${++this.#nextRequest}`;
|
|
@@ -3731,16 +3892,19 @@ class TabSession {
|
|
|
3731
3892
|
},
|
|
3732
3893
|
onDone: (e) => {
|
|
3733
3894
|
this.pending.delete(id);
|
|
3895
|
+
this.#listenGrants.delete(id);
|
|
3734
3896
|
this.#lastRequestAt = Date.now();
|
|
3735
3897
|
input.onDone(e);
|
|
3736
3898
|
},
|
|
3737
3899
|
...input.pageGone === undefined ? {} : { pageGone: input.pageGone }
|
|
3738
3900
|
});
|
|
3739
3901
|
this.pending.set(id, exchange);
|
|
3902
|
+
if (input.isListen)
|
|
3903
|
+
this.#listenGrants.set(id, input.grant.grantId);
|
|
3740
3904
|
const sent = this.send({
|
|
3741
3905
|
type: "http.request",
|
|
3742
3906
|
id,
|
|
3743
|
-
method:
|
|
3907
|
+
method: "POST",
|
|
3744
3908
|
path: "/mcp",
|
|
3745
3909
|
headers: input.headers,
|
|
3746
3910
|
body: input.body,
|
|
@@ -3842,11 +4006,12 @@ class MemoryTokenStore {
|
|
|
3842
4006
|
const r = this.#records.get(hash);
|
|
3843
4007
|
return r === undefined ? undefined : copy(r);
|
|
3844
4008
|
}
|
|
3845
|
-
async markUsed(hash, now) {
|
|
4009
|
+
async markUsed(hash, now, keepUntil) {
|
|
3846
4010
|
const r = this.#records.get(hash);
|
|
3847
4011
|
if (r === undefined || r.usedAt !== undefined)
|
|
3848
4012
|
return false;
|
|
3849
4013
|
r.usedAt = now;
|
|
4014
|
+
r.expiresAt = Math.min(r.expiresAt, keepUntil);
|
|
3850
4015
|
return true;
|
|
3851
4016
|
}
|
|
3852
4017
|
async revoke(selector, options) {
|
|
@@ -3899,7 +4064,8 @@ class MemoryTokenStore {
|
|
|
3899
4064
|
|
|
3900
4065
|
// src/store/sqlite.ts
|
|
3901
4066
|
import { Database } from "bun:sqlite";
|
|
3902
|
-
var SCHEMA_VERSION =
|
|
4067
|
+
var SCHEMA_VERSION = 4;
|
|
4068
|
+
var REUSE_WINDOW_MS = 86400000;
|
|
3903
4069
|
function toRecord(row) {
|
|
3904
4070
|
const record = {
|
|
3905
4071
|
hash: row.hash,
|
|
@@ -3962,6 +4128,8 @@ class SqliteTokenStore {
|
|
|
3962
4128
|
this.#db.run("CREATE INDEX IF NOT EXISTS tokens_grant ON tokens (grant_id)");
|
|
3963
4129
|
this.#db.run("CREATE INDEX IF NOT EXISTS tokens_address ON tokens (address_id)");
|
|
3964
4130
|
this.#db.run("CREATE INDEX IF NOT EXISTS tokens_expires ON tokens (expires_at)");
|
|
4131
|
+
this.#db.query(`UPDATE tokens SET expires_at = min(expires_at, used_at + $window)
|
|
4132
|
+
WHERE kind = 'refresh' AND used_at IS NOT NULL`).run({ window: REUSE_WINDOW_MS });
|
|
3965
4133
|
this.#db.run(`PRAGMA user_version = ${SCHEMA_VERSION}`);
|
|
3966
4134
|
})();
|
|
3967
4135
|
}
|
|
@@ -4002,8 +4170,9 @@ class SqliteTokenStore {
|
|
|
4002
4170
|
const row = this.#db.query("SELECT * FROM tokens WHERE hash = $hash").get({ hash });
|
|
4003
4171
|
return row === null ? undefined : toRecord(row);
|
|
4004
4172
|
}
|
|
4005
|
-
async markUsed(hash, now) {
|
|
4006
|
-
const result = this.#db.query(
|
|
4173
|
+
async markUsed(hash, now, keepUntil) {
|
|
4174
|
+
const result = this.#db.query(`UPDATE tokens SET used_at = $now, expires_at = min(expires_at, $keep_until)
|
|
4175
|
+
WHERE hash = $hash AND used_at IS NULL`).run({ hash, now, keep_until: keepUntil });
|
|
4007
4176
|
return result.changes === 1;
|
|
4008
4177
|
}
|
|
4009
4178
|
async revoke(selector, options) {
|
|
@@ -4042,6 +4211,16 @@ var VERSION = version;
|
|
|
4042
4211
|
var RELAY_INFO = { name: "tab-mcp", version };
|
|
4043
4212
|
var RETRY = Symbol("retry");
|
|
4044
4213
|
var CLAUDE_MISMATCHED_HEADER = "2025-11-25";
|
|
4214
|
+
var MAX_LISTEN_STREAMS_PER_GRANT = 2;
|
|
4215
|
+
var FRAME_HEADROOM = 4;
|
|
4216
|
+
var TRANSIENT_REFUSALS = new Set([
|
|
4217
|
+
"jwks_unavailable",
|
|
4218
|
+
"store_unavailable",
|
|
4219
|
+
"relay_busy",
|
|
4220
|
+
"error"
|
|
4221
|
+
]);
|
|
4222
|
+
var UNCHECKED_TEXT = "The relay cannot check the page's user right now. Try again shortly.";
|
|
4223
|
+
var RETRY_AFTER_S = "5";
|
|
4045
4224
|
var FORWARDED_HEADERS = new Set([
|
|
4046
4225
|
"content-type",
|
|
4047
4226
|
"accept",
|
|
@@ -4103,10 +4282,10 @@ function startRelay(options, hooks) {
|
|
|
4103
4282
|
const perIp = new Map;
|
|
4104
4283
|
const waiters = new Map;
|
|
4105
4284
|
let waitingCount = 0;
|
|
4106
|
-
const
|
|
4285
|
+
const lastSeen = new Map;
|
|
4107
4286
|
let claudeWorkarounds = 0;
|
|
4108
4287
|
let publicUrl = config.publicUrl ?? "";
|
|
4109
|
-
const assertions = config.oidc === undefined ? undefined : new TabAssertionVerifier(config.oidc, hooks?.assertionKeys);
|
|
4288
|
+
const assertions = config.oidc === undefined ? undefined : new TabAssertionVerifier(config.oidc, hooks?.assertionKeys, hooks?.maxSeenAssertions);
|
|
4110
4289
|
const corsHeaders = config.cors ? {
|
|
4111
4290
|
"access-control-allow-origin": "*",
|
|
4112
4291
|
"access-control-expose-headers": "www-authenticate, mcp-protocol-version"
|
|
@@ -4168,9 +4347,15 @@ function startRelay(options, hooks) {
|
|
|
4168
4347
|
}
|
|
4169
4348
|
};
|
|
4170
4349
|
const refuse = (reason, message) => {
|
|
4350
|
+
const transient = TRANSIENT_REFUSALS.has(reason);
|
|
4351
|
+
session.identityUnchecked = transient;
|
|
4171
4352
|
if (session.identity !== undefined) {
|
|
4172
4353
|
session.identity = undefined;
|
|
4173
|
-
|
|
4354
|
+
if (transient) {
|
|
4355
|
+
failPending(session, UNCHECKED_TEXT, true);
|
|
4356
|
+
} else {
|
|
4357
|
+
failPending(session, "The page's user is no longer signed in.");
|
|
4358
|
+
}
|
|
4174
4359
|
}
|
|
4175
4360
|
logger.info("identity.refused", {
|
|
4176
4361
|
address: shortAddress(addressId),
|
|
@@ -4207,7 +4392,7 @@ function startRelay(options, hooks) {
|
|
|
4207
4392
|
} catch (error) {
|
|
4208
4393
|
logger.error("store.error", {
|
|
4209
4394
|
op: "identity",
|
|
4210
|
-
|
|
4395
|
+
error: errorName(error)
|
|
4211
4396
|
});
|
|
4212
4397
|
refuse("store_unavailable", "the relay cannot check grants right now");
|
|
4213
4398
|
return;
|
|
@@ -4215,6 +4400,7 @@ function startRelay(options, hooks) {
|
|
|
4215
4400
|
if (!session.ready)
|
|
4216
4401
|
return;
|
|
4217
4402
|
session.identity = key;
|
|
4403
|
+
session.identityUnchecked = false;
|
|
4218
4404
|
answer({ type: "identity.bound", ...echo });
|
|
4219
4405
|
logger.info("identity.bound", {
|
|
4220
4406
|
address: shortAddress(addressId),
|
|
@@ -4236,6 +4422,7 @@ function startRelay(options, hooks) {
|
|
|
4236
4422
|
return;
|
|
4237
4423
|
}
|
|
4238
4424
|
session.identity = undefined;
|
|
4425
|
+
session.identityUnchecked = false;
|
|
4239
4426
|
failPending(session, "The page's user signed out.");
|
|
4240
4427
|
try {
|
|
4241
4428
|
const grants = await store.listGrants(addressId, Date.now());
|
|
@@ -4247,10 +4434,7 @@ function startRelay(options, hooks) {
|
|
|
4247
4434
|
for (const g of grants)
|
|
4248
4435
|
grantRemoved(addressId, g.grantId, "revoked");
|
|
4249
4436
|
} catch (error) {
|
|
4250
|
-
logger.error("store.error", {
|
|
4251
|
-
op: "signOut",
|
|
4252
|
-
message: error instanceof Error ? error.message : "unknown"
|
|
4253
|
-
});
|
|
4437
|
+
logger.error("store.error", { op: "signOut", error: errorName(error) });
|
|
4254
4438
|
}
|
|
4255
4439
|
},
|
|
4256
4440
|
claimAddress(session) {
|
|
@@ -4272,14 +4456,16 @@ function startRelay(options, hooks) {
|
|
|
4272
4456
|
const id = session.addressId;
|
|
4273
4457
|
if (id === undefined)
|
|
4274
4458
|
return;
|
|
4275
|
-
|
|
4276
|
-
|
|
4277
|
-
|
|
4278
|
-
|
|
4279
|
-
|
|
4280
|
-
|
|
4281
|
-
|
|
4282
|
-
|
|
4459
|
+
const appName = session.appName ?? lastSeen.get(id)?.appName;
|
|
4460
|
+
lastSeen.delete(id);
|
|
4461
|
+
lastSeen.set(id, {
|
|
4462
|
+
appName,
|
|
4463
|
+
maxConcurrentRequests: session.limits.maxConcurrentRequests
|
|
4464
|
+
});
|
|
4465
|
+
if (lastSeen.size > config.maxSessions * 2) {
|
|
4466
|
+
const oldest = lastSeen.keys().next().value;
|
|
4467
|
+
if (oldest !== undefined)
|
|
4468
|
+
lastSeen.delete(oldest);
|
|
4283
4469
|
}
|
|
4284
4470
|
const waiting = waiters.get(id);
|
|
4285
4471
|
if (waiting !== undefined)
|
|
@@ -4287,9 +4473,11 @@ function startRelay(options, hooks) {
|
|
|
4287
4473
|
wake(session);
|
|
4288
4474
|
}
|
|
4289
4475
|
};
|
|
4290
|
-
function failPending(session, message) {
|
|
4476
|
+
function failPending(session, message, transient = false) {
|
|
4477
|
+
const addressId = session.addressId;
|
|
4478
|
+
const headers = transient ? { "retry-after": RETRY_AFTER_S } : addressId === undefined ? {} : { "www-authenticate": wwwAuthenticate(addressId, true) };
|
|
4291
4479
|
for (const exchange of [...session.pending.values()]) {
|
|
4292
|
-
exchange.fail(401, RelayErrorCode.signedOut, message, true);
|
|
4480
|
+
exchange.fail(transient ? 503 : 401, RelayErrorCode.signedOut, message, true, headers);
|
|
4293
4481
|
}
|
|
4294
4482
|
}
|
|
4295
4483
|
function readySession(addressId, usable = () => true) {
|
|
@@ -4303,11 +4491,12 @@ function startRelay(options, hooks) {
|
|
|
4303
4491
|
const now = readySession(addressId, usable);
|
|
4304
4492
|
if (now !== undefined)
|
|
4305
4493
|
return Promise.resolve(now);
|
|
4306
|
-
if (waitMs === 0 ||
|
|
4494
|
+
if (waitMs === 0 || closing) {
|
|
4307
4495
|
return Promise.resolve(undefined);
|
|
4308
4496
|
}
|
|
4309
4497
|
const set = waiters.get(addressId) ?? new Set;
|
|
4310
|
-
|
|
4498
|
+
const limit = sessions.get(addressId)?.limits.maxConcurrentRequests ?? lastSeen.get(addressId)?.maxConcurrentRequests ?? config.maxConcurrentRequests;
|
|
4499
|
+
if (set.size >= limit || waitingCount >= config.maxSessions) {
|
|
4311
4500
|
return Promise.resolve(undefined);
|
|
4312
4501
|
}
|
|
4313
4502
|
return new Promise((resolve) => {
|
|
@@ -4436,10 +4625,7 @@ function startRelay(options, hooks) {
|
|
|
4436
4625
|
try {
|
|
4437
4626
|
record = await store.get(hashToken(token));
|
|
4438
4627
|
} catch (error) {
|
|
4439
|
-
logger.error("store.error", {
|
|
4440
|
-
op: "get",
|
|
4441
|
-
message: error instanceof Error ? error.message : "unknown"
|
|
4442
|
-
});
|
|
4628
|
+
logger.error("store.error", { op: "get", error: errorName(error) });
|
|
4443
4629
|
return "unavailable";
|
|
4444
4630
|
}
|
|
4445
4631
|
if (record === undefined || record.kind !== "access" || record.addressId !== addressId || record.expiresAt <= Date.now() || !record.scopes.includes("tools")) {
|
|
@@ -4460,8 +4646,12 @@ function startRelay(options, hooks) {
|
|
|
4460
4646
|
const deadline = new Promise((resolve) => {
|
|
4461
4647
|
timer = setTimeout(() => resolve("timeout"), config.bodyTimeoutMs);
|
|
4462
4648
|
});
|
|
4463
|
-
|
|
4464
|
-
|
|
4649
|
+
let result;
|
|
4650
|
+
try {
|
|
4651
|
+
result = await Promise.race([reading, deadline]);
|
|
4652
|
+
} finally {
|
|
4653
|
+
clearTimeout(timer);
|
|
4654
|
+
}
|
|
4465
4655
|
if (result === "timeout") {
|
|
4466
4656
|
return rpcError(408, undefined, -32600, "The request body did not arrive in time.", { ...corsHeaders, connection: "close" });
|
|
4467
4657
|
}
|
|
@@ -4531,7 +4721,7 @@ function startRelay(options, hooks) {
|
|
|
4531
4721
|
const auth = await authenticate(req, addressId);
|
|
4532
4722
|
if (auth === "unavailable") {
|
|
4533
4723
|
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":
|
|
4724
|
+
return rpcError(503, rpcId, RelayErrorCode.storeUnavailable, "The relay cannot check tokens right now. Try again shortly.", { ...corsHeaders, "retry-after": RETRY_AFTER_S });
|
|
4535
4725
|
}
|
|
4536
4726
|
if (typeof auth === "string") {
|
|
4537
4727
|
logDone(401, method);
|
|
@@ -4563,12 +4753,9 @@ function startRelay(options, hooks) {
|
|
|
4563
4753
|
logDone(499, method, { outcome: "client_closed" });
|
|
4564
4754
|
return new Response(null, { status: 499 });
|
|
4565
4755
|
};
|
|
4756
|
+
const idle = (on) => server.timeout(req, on ? config.httpIdleTimeoutS : 0);
|
|
4566
4757
|
const usable = usableFor(auth.subject);
|
|
4567
|
-
|
|
4568
|
-
server.timeout(req, 0);
|
|
4569
|
-
}
|
|
4570
|
-
let retried = false;
|
|
4571
|
-
for (;; ) {
|
|
4758
|
+
const admit = async () => {
|
|
4572
4759
|
const found = await waitForPage(addressId, req.signal, waitMs, usable);
|
|
4573
4760
|
if (found === "aborted" || req.signal.aborted)
|
|
4574
4761
|
return closed();
|
|
@@ -4591,14 +4778,30 @@ function startRelay(options, hooks) {
|
|
|
4591
4778
|
logDone(429, method);
|
|
4592
4779
|
return rpcError(429, rpcId, RelayErrorCode.overloaded, "Too many requests at once for this page.", corsHeaders);
|
|
4593
4780
|
}
|
|
4594
|
-
|
|
4781
|
+
if (isListen && session.listenStreams(auth.grantId) >= MAX_LISTEN_STREAMS_PER_GRANT) {
|
|
4782
|
+
logDone(429, method, { reason: "listen_streams" });
|
|
4783
|
+
return rpcError(429, rpcId, RelayErrorCode.overloaded, "Too many listen streams at once for this AI app.", corsHeaders);
|
|
4784
|
+
}
|
|
4785
|
+
return session;
|
|
4786
|
+
};
|
|
4787
|
+
if (readySession(addressId, usable) === undefined && waitMs > 0) {
|
|
4788
|
+
idle(false);
|
|
4789
|
+
}
|
|
4790
|
+
let retried = false;
|
|
4791
|
+
for (;; ) {
|
|
4792
|
+
const admitted = await admit();
|
|
4793
|
+
if (admitted instanceof Response) {
|
|
4794
|
+
idle(true);
|
|
4795
|
+
return admitted;
|
|
4796
|
+
}
|
|
4797
|
+
const session = admitted;
|
|
4798
|
+
idle(false);
|
|
4595
4799
|
let retry = () => {};
|
|
4596
4800
|
const retrying = new Promise((resolve) => {
|
|
4597
4801
|
retry = () => resolve(RETRY);
|
|
4598
4802
|
});
|
|
4599
4803
|
const mayRetry = !retried && waitMs > 0 && isRetryable(body.value["method"]);
|
|
4600
4804
|
const exchange = session.forward({
|
|
4601
|
-
method: "POST",
|
|
4602
4805
|
headers,
|
|
4603
4806
|
body: body.text,
|
|
4604
4807
|
rpcId,
|
|
@@ -4612,6 +4815,7 @@ function startRelay(options, hooks) {
|
|
|
4612
4815
|
onDone: (e) => {
|
|
4613
4816
|
if (e.outcome === "page_gone_retry")
|
|
4614
4817
|
return;
|
|
4818
|
+
idle(true);
|
|
4615
4819
|
logDone(e.status, method, {
|
|
4616
4820
|
session: session.sessionId,
|
|
4617
4821
|
client: clientHostName,
|
|
@@ -4624,8 +4828,18 @@ function startRelay(options, hooks) {
|
|
|
4624
4828
|
retry();
|
|
4625
4829
|
return { retry: true };
|
|
4626
4830
|
}
|
|
4627
|
-
const answer = notOpenAnswer(body.value, rpcId, session.appName, "gone", RELAY_INFO);
|
|
4628
|
-
|
|
4831
|
+
const answer = notOpenAnswer(body.value, rpcId, session.appName, closing ? "restarting_gone" : "gone", RELAY_INFO);
|
|
4832
|
+
if (answer !== undefined)
|
|
4833
|
+
return { retry: false, body: answer };
|
|
4834
|
+
return !closing ? { retry: false } : {
|
|
4835
|
+
retry: false,
|
|
4836
|
+
error: {
|
|
4837
|
+
status: 503,
|
|
4838
|
+
code: RelayErrorCode.restarting,
|
|
4839
|
+
message: "The relay is restarting.",
|
|
4840
|
+
headers: { "retry-after": RETRY_AFTER_S }
|
|
4841
|
+
}
|
|
4842
|
+
};
|
|
4629
4843
|
}
|
|
4630
4844
|
});
|
|
4631
4845
|
const onAbort = () => exchange.clientClosed();
|
|
@@ -4639,13 +4853,21 @@ function startRelay(options, hooks) {
|
|
|
4639
4853
|
}
|
|
4640
4854
|
function pageNotOpen(value, rpcId, addressId, logDone, method) {
|
|
4641
4855
|
const open = sessions.get(addressId);
|
|
4642
|
-
|
|
4643
|
-
|
|
4856
|
+
if (!closing && open?.ready === true && open.identity === undefined && open.identityUnchecked) {
|
|
4857
|
+
logDone(503, method, { reason: "identity_unchecked" });
|
|
4858
|
+
return rpcError(503, rpcId, RelayErrorCode.signedOut, UNCHECKED_TEXT, {
|
|
4859
|
+
...corsHeaders,
|
|
4860
|
+
"retry-after": RETRY_AFTER_S
|
|
4861
|
+
});
|
|
4862
|
+
}
|
|
4863
|
+
const phase = closing ? "restarting" : open?.ready === true && open.identity === undefined && open.site.pairing === "oidc" ? "signed_out" : "not_open";
|
|
4864
|
+
const reason = phase === "restarting" ? "restarting" : "page_not_open";
|
|
4865
|
+
const answer = notOpenAnswer(value, rpcId, open?.appName ?? lastSeen.get(addressId)?.appName, phase, RELAY_INFO);
|
|
4644
4866
|
if (answer === undefined) {
|
|
4645
|
-
logDone(503, method, { reason
|
|
4646
|
-
return rpcError(503, rpcId, RelayErrorCode.pageNotOpen, "The page is not open.", corsHeaders);
|
|
4867
|
+
logDone(503, method, { reason });
|
|
4868
|
+
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
4869
|
}
|
|
4648
|
-
logDone(200, method, { reason
|
|
4870
|
+
logDone(200, method, { reason });
|
|
4649
4871
|
return new Response(answer, {
|
|
4650
4872
|
status: 200,
|
|
4651
4873
|
headers: { ...corsHeaders, "content-type": "application/json" }
|
|
@@ -4740,7 +4962,7 @@ function startRelay(options, hooks) {
|
|
|
4740
4962
|
fetch: () => new Response("Not found", { status: 404 }),
|
|
4741
4963
|
websocket: {
|
|
4742
4964
|
data: {},
|
|
4743
|
-
maxPayloadLength: largest *
|
|
4965
|
+
maxPayloadLength: largest * FRAME_HEADROOM,
|
|
4744
4966
|
backpressureLimit: config.backpressureLimitBytes,
|
|
4745
4967
|
closeOnBackpressureLimit: false,
|
|
4746
4968
|
idleTimeout: 120,
|
|
@@ -4760,6 +4982,7 @@ function startRelay(options, hooks) {
|
|
|
4760
4982
|
}
|
|
4761
4983
|
});
|
|
4762
4984
|
let stopping;
|
|
4985
|
+
let closing = false;
|
|
4763
4986
|
const port = server.port ?? config.port;
|
|
4764
4987
|
if (publicUrl === "")
|
|
4765
4988
|
publicUrl = `http://localhost:${port}`;
|
|
@@ -4770,7 +4993,7 @@ function startRelay(options, hooks) {
|
|
|
4770
4993
|
logger.debug("store.swept", { deleted: n });
|
|
4771
4994
|
}).catch((error) => logger.error("store.error", {
|
|
4772
4995
|
op: "deleteExpired",
|
|
4773
|
-
|
|
4996
|
+
error: errorName(error)
|
|
4774
4997
|
}));
|
|
4775
4998
|
}, config.sweepIntervalMs);
|
|
4776
4999
|
const siteCount = config.allowedOrigins === "*" ? "*" : config.allowedOrigins.length + config.sites.length;
|
|
@@ -4799,6 +5022,7 @@ function startRelay(options, hooks) {
|
|
|
4799
5022
|
return allSessions.size;
|
|
4800
5023
|
},
|
|
4801
5024
|
stop() {
|
|
5025
|
+
closing = true;
|
|
4802
5026
|
stopping ??= (async () => {
|
|
4803
5027
|
clearInterval(sweep);
|
|
4804
5028
|
for (const set of [...waiters.values()]) {
|
|
@@ -4834,5 +5058,5 @@ export {
|
|
|
4834
5058
|
resolveConfig
|
|
4835
5059
|
};
|
|
4836
5060
|
|
|
4837
|
-
//# debugId=
|
|
5061
|
+
//# debugId=8481FBCB87B247C864756E2164756E21
|
|
4838
5062
|
//# sourceMappingURL=index.js.map
|