@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/main.js
CHANGED
|
@@ -584,13 +584,15 @@ function truncateIp(ip) {
|
|
|
584
584
|
}
|
|
585
585
|
return `${v6.slice(0, 3).map((g) => g.toString(16)).join(":")}::/48`;
|
|
586
586
|
}
|
|
587
|
+
function errorName(error) {
|
|
588
|
+
return error instanceof Error ? error.name : typeof error;
|
|
589
|
+
}
|
|
587
590
|
function installCrashLogging(logger, exit = (code) => process.exit(code)) {
|
|
588
|
-
const name = (error) => error instanceof Error ? error.name : typeof error;
|
|
589
591
|
const onRejection = (error) => {
|
|
590
|
-
logger.error("process.unhandled_rejection", { error:
|
|
592
|
+
logger.error("process.unhandled_rejection", { error: errorName(error) });
|
|
591
593
|
};
|
|
592
594
|
const onException = (error) => {
|
|
593
|
-
logger.error("process.uncaught_exception", { error:
|
|
595
|
+
logger.error("process.uncaught_exception", { error: errorName(error) });
|
|
594
596
|
exit(1);
|
|
595
597
|
};
|
|
596
598
|
process.on("unhandledRejection", onRejection);
|
|
@@ -601,7 +603,7 @@ function installCrashLogging(logger, exit = (code) => process.exit(code)) {
|
|
|
601
603
|
};
|
|
602
604
|
}
|
|
603
605
|
// package.json
|
|
604
|
-
var version = "0.
|
|
606
|
+
var version = "0.4.0";
|
|
605
607
|
|
|
606
608
|
// src/address.ts
|
|
607
609
|
var BASE32_ALPHABET = "abcdefghijklmnopqrstuvwxyz234567";
|
|
@@ -651,7 +653,8 @@ var RelayErrorCode = {
|
|
|
651
653
|
tooLarge: -31006,
|
|
652
654
|
badResponse: -31007,
|
|
653
655
|
storeUnavailable: -31008,
|
|
654
|
-
signedOut: -31009
|
|
656
|
+
signedOut: -31009,
|
|
657
|
+
restarting: -31010
|
|
655
658
|
};
|
|
656
659
|
var encoder = new TextEncoder;
|
|
657
660
|
var NULL_BODY_STATUSES = new Set([204, 205, 304]);
|
|
@@ -680,6 +683,11 @@ function rpcErrorBody(rpcId, code, message, data) {
|
|
|
680
683
|
error["data"] = data;
|
|
681
684
|
return JSON.stringify({ jsonrpc: "2.0", id: rpcId ?? null, error });
|
|
682
685
|
}
|
|
686
|
+
var TAB_STARTED = new Set([
|
|
687
|
+
"tab_stream",
|
|
688
|
+
"relay_passed",
|
|
689
|
+
"relay_wrapped"
|
|
690
|
+
]);
|
|
683
691
|
|
|
684
692
|
class Exchange {
|
|
685
693
|
id;
|
|
@@ -691,9 +699,6 @@ class Exchange {
|
|
|
691
699
|
#options;
|
|
692
700
|
#resolve;
|
|
693
701
|
#state = "waiting";
|
|
694
|
-
#relayStarted = false;
|
|
695
|
-
#tabStarted = false;
|
|
696
|
-
#wrap = false;
|
|
697
702
|
#wrapped = "";
|
|
698
703
|
#controller;
|
|
699
704
|
#atBoundary = true;
|
|
@@ -719,7 +724,7 @@ class Exchange {
|
|
|
719
724
|
return this.#state === "done";
|
|
720
725
|
}
|
|
721
726
|
onResponse(msg) {
|
|
722
|
-
if (this.#
|
|
727
|
+
if (TAB_STARTED.has(this.#state)) {
|
|
723
728
|
this.#badResponse("http.response after http.response.start");
|
|
724
729
|
return;
|
|
725
730
|
}
|
|
@@ -742,7 +747,7 @@ class Exchange {
|
|
|
742
747
|
}
|
|
743
748
|
this.#respond(response, msg.status);
|
|
744
749
|
this.#finish("ok");
|
|
745
|
-
} else if (this.#state === "
|
|
750
|
+
} else if (this.#state === "relay_stream") {
|
|
746
751
|
if (!this.#account(msg.body))
|
|
747
752
|
return;
|
|
748
753
|
if (msg.body !== "")
|
|
@@ -751,11 +756,17 @@ class Exchange {
|
|
|
751
756
|
}
|
|
752
757
|
}
|
|
753
758
|
onStart(msg) {
|
|
754
|
-
if (this.#
|
|
759
|
+
if (this.#state === "done")
|
|
760
|
+
return;
|
|
761
|
+
if (TAB_STARTED.has(this.#state)) {
|
|
755
762
|
this.#badResponse("http.response.start sent twice");
|
|
756
763
|
return;
|
|
757
764
|
}
|
|
758
|
-
|
|
765
|
+
const eventStream = isEventStream(msg.headers);
|
|
766
|
+
if (this.isListen && !eventStream) {
|
|
767
|
+
this.#badResponse("a listen response must be an event stream");
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
759
770
|
if (this.#state === "waiting") {
|
|
760
771
|
if (NULL_BODY_STATUSES.has(msg.status)) {
|
|
761
772
|
this.#badResponse(`status ${msg.status} cannot be streamed`);
|
|
@@ -768,25 +779,29 @@ class Exchange {
|
|
|
768
779
|
this.#badResponse("the response headers are invalid");
|
|
769
780
|
return;
|
|
770
781
|
}
|
|
771
|
-
this.#openStream(msg.status, headers);
|
|
772
|
-
} else
|
|
773
|
-
this.#
|
|
782
|
+
this.#openStream(msg.status, headers, "tab_stream");
|
|
783
|
+
} else {
|
|
784
|
+
this.#state = eventStream ? "relay_passed" : "relay_wrapped";
|
|
774
785
|
}
|
|
775
786
|
}
|
|
776
787
|
onChunk(data) {
|
|
777
|
-
if (this.#
|
|
788
|
+
if (!this.#streaming)
|
|
789
|
+
return;
|
|
790
|
+
if (this.#state === "relay_stream") {
|
|
791
|
+
this.#badResponse("http.response.chunk before http.response.start");
|
|
778
792
|
return;
|
|
793
|
+
}
|
|
779
794
|
if (!this.#account(data))
|
|
780
795
|
return;
|
|
781
|
-
if (this.#
|
|
796
|
+
if (this.#state === "relay_wrapped")
|
|
782
797
|
this.#wrapped += data;
|
|
783
798
|
else
|
|
784
799
|
this.#write(data);
|
|
785
800
|
}
|
|
786
801
|
onEnd() {
|
|
787
|
-
if (this.#
|
|
802
|
+
if (!this.#streaming)
|
|
788
803
|
return;
|
|
789
|
-
if (this.#
|
|
804
|
+
if (this.#state === "relay_wrapped" && this.#wrapped !== "")
|
|
790
805
|
this.#write(sseEvent(this.#wrapped));
|
|
791
806
|
this.#finish("ok");
|
|
792
807
|
}
|
|
@@ -808,6 +823,11 @@ class Exchange {
|
|
|
808
823
|
this.#finish("page_gone_retry");
|
|
809
824
|
return;
|
|
810
825
|
}
|
|
826
|
+
if (reply !== undefined && "error" in reply) {
|
|
827
|
+
const e = reply.error;
|
|
828
|
+
this.fail(e.status, e.code, e.message, false, e.headers);
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
811
831
|
const body = reply?.retry === false ? reply.body : undefined;
|
|
812
832
|
if (body === undefined) {
|
|
813
833
|
this.fail(503, RelayErrorCode.tabGone, "The page closed its connection to the relay.", false);
|
|
@@ -823,11 +843,11 @@ class Exchange {
|
|
|
823
843
|
}
|
|
824
844
|
this.#finish("page_gone");
|
|
825
845
|
}
|
|
826
|
-
fail(status, code, message, cancelTab) {
|
|
846
|
+
fail(status, code, message, cancelTab, headers = {}) {
|
|
827
847
|
if (this.#state === "done")
|
|
828
848
|
return;
|
|
829
849
|
if (this.#state === "waiting") {
|
|
830
|
-
this.#respond(this.#errorResponse(status, code, message), status);
|
|
850
|
+
this.#respond(this.#errorResponse(status, code, message, headers), status);
|
|
831
851
|
} else if (this.#atBoundary && this.#options.rpcId !== undefined) {
|
|
832
852
|
this.#write(sseEvent(rpcErrorBody(this.#options.rpcId, code, message)));
|
|
833
853
|
}
|
|
@@ -841,7 +861,8 @@ class Exchange {
|
|
|
841
861
|
}
|
|
842
862
|
#account(data) {
|
|
843
863
|
this.#bodyBytes += Buffer.byteLength(data);
|
|
844
|
-
|
|
864
|
+
const unbounded = this.isListen && (this.#state === "tab_stream" || this.#state === "relay_passed");
|
|
865
|
+
if (unbounded || this.#bodyBytes <= this.#options.maxBodyBytes) {
|
|
845
866
|
return true;
|
|
846
867
|
}
|
|
847
868
|
this.fail(502, RelayErrorCode.tooLarge, "The page sent a response that is too large.", true);
|
|
@@ -856,10 +877,9 @@ class Exchange {
|
|
|
856
877
|
#startRelayStream() {
|
|
857
878
|
if (this.#state !== "waiting")
|
|
858
879
|
return;
|
|
859
|
-
this.#
|
|
860
|
-
this.#openStream(200, this.#headers({ "content-type": "text/event-stream" }));
|
|
880
|
+
this.#openStream(200, this.#headers({ "content-type": "text/event-stream" }), "relay_stream");
|
|
861
881
|
}
|
|
862
|
-
#openStream(status, headers) {
|
|
882
|
+
#openStream(status, headers, state) {
|
|
863
883
|
const stream = new ReadableStream({
|
|
864
884
|
start: (controller) => {
|
|
865
885
|
this.#controller = controller;
|
|
@@ -880,12 +900,12 @@ class Exchange {
|
|
|
880
900
|
this.#badResponse("the response could not be built");
|
|
881
901
|
return;
|
|
882
902
|
}
|
|
883
|
-
this.#state =
|
|
903
|
+
this.#state = state;
|
|
884
904
|
this.#respond(response, status);
|
|
885
905
|
this.#scheduleKeepAlive();
|
|
886
906
|
}
|
|
887
907
|
#write(text) {
|
|
888
|
-
if (this.#
|
|
908
|
+
if (!this.#streaming || this.#controller === undefined || text === "")
|
|
889
909
|
return;
|
|
890
910
|
try {
|
|
891
911
|
this.#controller.enqueue(encoder.encode(text));
|
|
@@ -902,16 +922,19 @@ class Exchange {
|
|
|
902
922
|
}
|
|
903
923
|
this.#scheduleKeepAlive();
|
|
904
924
|
}
|
|
925
|
+
get #streaming() {
|
|
926
|
+
return this.#state !== "waiting" && this.#state !== "done";
|
|
927
|
+
}
|
|
905
928
|
#scheduleKeepAlive() {
|
|
906
929
|
clearTimeout(this.#keepAliveTimer);
|
|
907
|
-
if (this.#
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
930
|
+
if (!this.#streaming)
|
|
931
|
+
return;
|
|
932
|
+
if (this.#state === "relay_wrapped") {
|
|
933
|
+
this.#keepAliveTimer = setTimeout(() => {
|
|
934
|
+
this.#write(`: keep-alive
|
|
911
935
|
|
|
912
936
|
`);
|
|
913
|
-
|
|
914
|
-
}
|
|
937
|
+
}, this.#options.keepAliveMs);
|
|
915
938
|
return;
|
|
916
939
|
}
|
|
917
940
|
this.#keepAliveTimer = setTimeout(() => {
|
|
@@ -931,8 +954,10 @@ class Exchange {
|
|
|
931
954
|
}
|
|
932
955
|
return headers;
|
|
933
956
|
}
|
|
934
|
-
#errorResponse(status, code, message) {
|
|
957
|
+
#errorResponse(status, code, message, extra) {
|
|
935
958
|
const headers = this.#headers({ "content-type": "application/json" });
|
|
959
|
+
for (const [k, v] of Object.entries(extra))
|
|
960
|
+
headers.set(k, v);
|
|
936
961
|
return new Response(rpcErrorBody(this.#options.rpcId, code, message), {
|
|
937
962
|
status,
|
|
938
963
|
headers
|
|
@@ -946,7 +971,7 @@ class Exchange {
|
|
|
946
971
|
#finish(outcome, closeStream = true) {
|
|
947
972
|
if (this.#state === "done")
|
|
948
973
|
return;
|
|
949
|
-
const wasStreaming = this.#
|
|
974
|
+
const wasStreaming = this.#streaming;
|
|
950
975
|
this.#state = "done";
|
|
951
976
|
this.outcome = outcome;
|
|
952
977
|
clearTimeout(this.#startTimer);
|
|
@@ -1267,9 +1292,11 @@ class TabAssertionVerifier {
|
|
|
1267
1292
|
#seen = new Map;
|
|
1268
1293
|
#perPerson = new Map;
|
|
1269
1294
|
#sweptAt = Number.NEGATIVE_INFINITY;
|
|
1270
|
-
|
|
1295
|
+
#maxSeen;
|
|
1296
|
+
constructor(oidc, keys, maxSeen = MAX_SEEN_JTI) {
|
|
1271
1297
|
this.#oidc = oidc;
|
|
1272
1298
|
this.#keys = keys ?? new JwksCache(oidc.assertionJwksUrl);
|
|
1299
|
+
this.#maxSeen = maxSeen;
|
|
1273
1300
|
}
|
|
1274
1301
|
async verify(token, address, now = Date.now()) {
|
|
1275
1302
|
const { payload } = await verifyJwt(token, this.#keys, {
|
|
@@ -1315,15 +1342,18 @@ ${jti}`;
|
|
|
1315
1342
|
throw new JwtError("replayed", "the assertion was already used");
|
|
1316
1343
|
}
|
|
1317
1344
|
const mine = this.#perPerson.get(key) ?? 0;
|
|
1318
|
-
if (mine >= MAX_JTI_PER_PERSON
|
|
1345
|
+
if (mine >= MAX_JTI_PER_PERSON) {
|
|
1319
1346
|
throw new JwtError("busy", "too many assertions at once");
|
|
1320
1347
|
}
|
|
1348
|
+
if (this.#seen.size >= this.#maxSeen) {
|
|
1349
|
+
throw new JwtError("relay_busy", "the relay is checking too many assertions; try again shortly");
|
|
1350
|
+
}
|
|
1321
1351
|
this.#seen.set(seenKey, exp + CLOCK_LEEWAY_MS);
|
|
1322
1352
|
this.#perPerson.set(key, mine + 1);
|
|
1323
1353
|
return key;
|
|
1324
1354
|
}
|
|
1325
1355
|
#sweep(now) {
|
|
1326
|
-
if (now - this.#sweptAt < 1000 && this.#seen.size <
|
|
1356
|
+
if (now - this.#sweptAt < 1000 && this.#seen.size < this.#maxSeen)
|
|
1327
1357
|
return;
|
|
1328
1358
|
this.#sweptAt = now;
|
|
1329
1359
|
for (const [seenKey, until] of this.#seen) {
|
|
@@ -1533,19 +1563,28 @@ function notOpenText(appName) {
|
|
|
1533
1563
|
function signedOutText(appName) {
|
|
1534
1564
|
return `The page is open, but nobody is signed in to it. Ask the user to sign in to ${pageName(appName)}, then try again.`;
|
|
1535
1565
|
}
|
|
1566
|
+
function restartingText() {
|
|
1567
|
+
return "The relay is restarting. Try again in a few seconds.";
|
|
1568
|
+
}
|
|
1536
1569
|
function phaseText(phase, appName) {
|
|
1570
|
+
if (phase === "restarting" || phase === "restarting_gone") {
|
|
1571
|
+
return restartingText();
|
|
1572
|
+
}
|
|
1537
1573
|
return phase === "signed_out" ? signedOutText(appName) : notOpenText(appName);
|
|
1538
1574
|
}
|
|
1539
1575
|
function goneDuringCallText(appName) {
|
|
1540
1576
|
return `The page closed while the tool was running, so it may or may not have finished. Ask the user to open ${pageName(appName)} and keep it in front (on a phone, in split screen), then check the result before trying again.`;
|
|
1541
1577
|
}
|
|
1578
|
+
function restartedDuringCallText() {
|
|
1579
|
+
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.";
|
|
1580
|
+
}
|
|
1542
1581
|
function notOpenAnswer(body, rpcId, appName, phase, relay) {
|
|
1543
1582
|
if (rpcId === undefined)
|
|
1544
1583
|
return;
|
|
1545
1584
|
const id = rpcId;
|
|
1546
1585
|
const method = body["method"];
|
|
1547
1586
|
if (method === "tools/call") {
|
|
1548
|
-
const text = phase === "gone" ? goneDuringCallText(appName) : phaseText(phase, appName);
|
|
1587
|
+
const text = phase === "gone" ? goneDuringCallText(appName) : phase === "restarting_gone" ? restartedDuringCallText() : phaseText(phase, appName);
|
|
1549
1588
|
return JSON.stringify({
|
|
1550
1589
|
jsonrpc: "2.0",
|
|
1551
1590
|
id,
|
|
@@ -1580,7 +1619,7 @@ function notOpenAnswer(body, rpcId, appName, phase, relay) {
|
|
|
1580
1619
|
jsonrpc: "2.0",
|
|
1581
1620
|
id,
|
|
1582
1621
|
error: {
|
|
1583
|
-
code: phase === "gone" ? RelayErrorCode.tabGone : RelayErrorCode.pageNotOpen,
|
|
1622
|
+
code: phase === "gone" ? RelayErrorCode.tabGone : phase === "restarting" || phase === "restarting_gone" ? RelayErrorCode.restarting : RelayErrorCode.pageNotOpen,
|
|
1584
1623
|
message: phaseText(phase, appName)
|
|
1585
1624
|
}
|
|
1586
1625
|
});
|
|
@@ -1620,6 +1659,13 @@ class RateLimiter {
|
|
|
1620
1659
|
bucket.tokens -= 1;
|
|
1621
1660
|
return true;
|
|
1622
1661
|
}
|
|
1662
|
+
available(key, now = Date.now()) {
|
|
1663
|
+
const bucket = this.#buckets.get(key);
|
|
1664
|
+
if (bucket === undefined)
|
|
1665
|
+
return this.#capacity >= 1;
|
|
1666
|
+
const elapsed = Math.max(0, now - bucket.updatedAt) / 1000;
|
|
1667
|
+
return Math.min(this.#capacity, bucket.tokens + elapsed * this.#refill) >= 1;
|
|
1668
|
+
}
|
|
1623
1669
|
sweep(now = Date.now()) {
|
|
1624
1670
|
for (const [key, b] of this.#buckets) {
|
|
1625
1671
|
const elapsed = Math.max(0, now - b.updatedAt) / 1000;
|
|
@@ -1705,7 +1751,7 @@ async function defaultLookup(hostname) {
|
|
|
1705
1751
|
const records = await Bun.dns.lookup(hostname, {});
|
|
1706
1752
|
return records.map((r) => r.address);
|
|
1707
1753
|
}
|
|
1708
|
-
var INVISIBLE = /[\u034F\u115F\u1160\u17B4\u17B5\u2800\u3164\uFFA0]/gu;
|
|
1754
|
+
var INVISIBLE = /[\u034F\u115F\u1160\u17B4\u17B5\u2800\u3164\uFFA0]|\p{Variation_Selector}/gu;
|
|
1709
1755
|
function displayName(raw) {
|
|
1710
1756
|
let cleaned = raw.normalize("NFC").replace(/[\p{Cc}\p{Zl}\p{Zp}]/gu, " ").replace(/\p{Cf}/gu, "").replace(INVISIBLE, "");
|
|
1711
1757
|
cleaned = cleaned.replace(/\s+/g, " ").trim();
|
|
@@ -1728,10 +1774,17 @@ function isIpLiteral(hostname) {
|
|
|
1728
1774
|
const h = hostname.replace(/^\[|\]$/g, "");
|
|
1729
1775
|
return parseIpv4(h) !== undefined || parseIpv6(h) !== undefined;
|
|
1730
1776
|
}
|
|
1777
|
+
function limitKey(hostname) {
|
|
1778
|
+
const h = hostname.replace(/^\[|\]$/g, "").replace(/\.$/, "");
|
|
1779
|
+
if (isIpLiteral(h))
|
|
1780
|
+
return h;
|
|
1781
|
+
return h.split(".").slice(-2).join(".");
|
|
1782
|
+
}
|
|
1731
1783
|
|
|
1732
1784
|
class ClientMetadataFetcher {
|
|
1733
1785
|
#cache = new Map;
|
|
1734
1786
|
#perHost = new RateLimiter({ capacity: 5, refillPerSecond: 0.2 });
|
|
1787
|
+
#perDomain = new RateLimiter({ capacity: 15, refillPerSecond: 0.6 });
|
|
1735
1788
|
#global = new RateLimiter({ capacity: 30, refillPerSecond: 1 });
|
|
1736
1789
|
#allowedHosts;
|
|
1737
1790
|
#logger;
|
|
@@ -1755,22 +1808,28 @@ class ClientMetadataFetcher {
|
|
|
1755
1808
|
const url = this.#checkUrl(clientId);
|
|
1756
1809
|
const now = this.#now();
|
|
1757
1810
|
const cached = this.#cache.get(clientId);
|
|
1758
|
-
if (cached !== undefined && cached.until > now)
|
|
1811
|
+
if (cached !== undefined && cached.until > now) {
|
|
1812
|
+
this.#cache.delete(clientId);
|
|
1813
|
+
this.#cache.set(clientId, cached);
|
|
1759
1814
|
return cached.meta;
|
|
1760
|
-
|
|
1815
|
+
}
|
|
1816
|
+
const key = limitKey(url.hostname);
|
|
1817
|
+
if (!this.#perHost.available(url.host, now) || !this.#perDomain.available(key, now)) {
|
|
1761
1818
|
throw new ClientMetadataError("rate_limited", "Too many requests for this AI app's details. Try again in a minute.");
|
|
1762
1819
|
}
|
|
1763
1820
|
if (!this.#global.take("all", now)) {
|
|
1764
1821
|
throw new ClientMetadataError("rate_limited", "The relay is busy. Try again in a minute.");
|
|
1765
1822
|
}
|
|
1823
|
+
this.#perHost.take(url.host, now);
|
|
1824
|
+
this.#perDomain.take(key, now);
|
|
1766
1825
|
const address = await this.#vettedAddress(url);
|
|
1767
1826
|
const { meta, ttlMs } = await this.#fetchDocument(url, clientId, address);
|
|
1827
|
+
this.#cache.delete(clientId);
|
|
1768
1828
|
if (this.#cache.size >= MAX_CACHE_ENTRIES) {
|
|
1769
1829
|
const oldest = this.#cache.keys().next().value;
|
|
1770
1830
|
if (oldest !== undefined)
|
|
1771
1831
|
this.#cache.delete(oldest);
|
|
1772
1832
|
}
|
|
1773
|
-
this.#cache.delete(clientId);
|
|
1774
1833
|
this.#cache.set(clientId, { meta, until: now + ttlMs });
|
|
1775
1834
|
return meta;
|
|
1776
1835
|
}
|
|
@@ -1905,6 +1964,27 @@ class ClientMetadataFetcher {
|
|
|
1905
1964
|
}
|
|
1906
1965
|
}
|
|
1907
1966
|
}
|
|
1967
|
+
var DOMAIN_SHAPED = /(?:[\p{L}\p{N}](?:[\p{L}\p{N}-]*[\p{L}\p{N}])?\.)+\p{L}{2,63}(?![\p{L}\p{N}])/gu;
|
|
1968
|
+
function comparable(name) {
|
|
1969
|
+
return name.normalize("NFKC").replace(/\p{M}/gu, "").replace(/[\u3002\uFF0E\uFF61]/g, ".").toLowerCase();
|
|
1970
|
+
}
|
|
1971
|
+
function asciiHost(token) {
|
|
1972
|
+
try {
|
|
1973
|
+
return new URL(`https://${token}/`).hostname;
|
|
1974
|
+
} catch {
|
|
1975
|
+
return token;
|
|
1976
|
+
}
|
|
1977
|
+
}
|
|
1978
|
+
function foreignDomain(name, hostname) {
|
|
1979
|
+
const host = hostname.toLowerCase();
|
|
1980
|
+
for (const [match] of comparable(name).matchAll(DOMAIN_SHAPED)) {
|
|
1981
|
+
const token = asciiHost(match);
|
|
1982
|
+
const own = token === host || host.endsWith(`.${token}`) || token.endsWith(`.${host}`);
|
|
1983
|
+
if (!own)
|
|
1984
|
+
return true;
|
|
1985
|
+
}
|
|
1986
|
+
return false;
|
|
1987
|
+
}
|
|
1908
1988
|
function validateDocument(doc, clientId, url) {
|
|
1909
1989
|
const invalid = (message) => new ClientMetadataError("invalid_metadata", message);
|
|
1910
1990
|
if (typeof doc !== "object" || doc === null || Array.isArray(doc)) {
|
|
@@ -1921,9 +2001,12 @@ function validateDocument(doc, clientId, url) {
|
|
|
1921
2001
|
if (typeof name !== "string" || displayName(name) === "") {
|
|
1922
2002
|
throw invalid("The AI app's details have no client_name.");
|
|
1923
2003
|
}
|
|
1924
|
-
if (/\(\s*[^()\s]*[a-z0-9-]\.[a-z]{2,}[^()]*\)/i.test(displayName(name))) {
|
|
2004
|
+
if (/\(\s*[^()\s]*[a-z0-9-]\.[a-z]{2,}[^()]*\)/i.test(comparable(displayName(name)))) {
|
|
1925
2005
|
throw invalid("The AI app's name contains a web address in parentheses, which this relay does not show.");
|
|
1926
2006
|
}
|
|
2007
|
+
if (foreignDomain(displayName(name), url.hostname)) {
|
|
2008
|
+
throw invalid("The AI app's name contains a web address other than its own, which this relay does not show.");
|
|
2009
|
+
}
|
|
1927
2010
|
const uris = d["redirect_uris"];
|
|
1928
2011
|
if (!Array.isArray(uris) || uris.length === 0 || uris.length > MAX_REDIRECT_URIS || !uris.every((u) => typeof u === "string" && isRedirectUri(u))) {
|
|
1929
2012
|
throw invalid("The AI app's details have no valid redirect_uris.");
|
|
@@ -2138,14 +2221,17 @@ class ProviderDiscovery {
|
|
|
2138
2221
|
class OidcPairing {
|
|
2139
2222
|
kind = "oidc";
|
|
2140
2223
|
#config;
|
|
2224
|
+
#maxFlows;
|
|
2141
2225
|
#publicUrl;
|
|
2142
2226
|
#logger;
|
|
2143
2227
|
#discovery;
|
|
2144
2228
|
#jwks;
|
|
2145
2229
|
#flows = new Map;
|
|
2230
|
+
#flowOf = new Map;
|
|
2146
2231
|
#mismatched = new Set;
|
|
2147
2232
|
constructor(options) {
|
|
2148
2233
|
this.#config = options.config;
|
|
2234
|
+
this.#maxFlows = options.maxFlows ?? MAX_FLOWS;
|
|
2149
2235
|
this.#publicUrl = options.publicUrl;
|
|
2150
2236
|
this.#logger = options.logger;
|
|
2151
2237
|
this.#discovery = new ProviderDiscovery(options.config.issuer, options.development);
|
|
@@ -2182,7 +2268,7 @@ class OidcPairing {
|
|
|
2182
2268
|
}
|
|
2183
2269
|
return `<p>When you click Connect, sign in at <strong>${host}</strong> with the account you use in the page. No code is needed.</p>`;
|
|
2184
2270
|
}
|
|
2185
|
-
async check(target, _form,
|
|
2271
|
+
async check(target, _form, admit, context) {
|
|
2186
2272
|
if (!target.pairingOpen) {
|
|
2187
2273
|
return {
|
|
2188
2274
|
ok: false,
|
|
@@ -2207,16 +2293,25 @@ class OidcPairing {
|
|
|
2207
2293
|
return this.#unavailable(error);
|
|
2208
2294
|
}
|
|
2209
2295
|
this.#sweep();
|
|
2210
|
-
|
|
2296
|
+
this.#dropFlow(context.requestId);
|
|
2297
|
+
if (this.#flows.size >= this.#maxFlows) {
|
|
2211
2298
|
return {
|
|
2212
2299
|
ok: false,
|
|
2213
2300
|
message: "Too many sign-ins are open right now. Wait a few minutes and try again.",
|
|
2214
2301
|
reason: "busy"
|
|
2215
2302
|
};
|
|
2216
2303
|
}
|
|
2304
|
+
if (!admit()) {
|
|
2305
|
+
return {
|
|
2306
|
+
ok: false,
|
|
2307
|
+
message: "Too many attempts for this page. Wait a few minutes and try again.",
|
|
2308
|
+
reason: "rate_limited"
|
|
2309
|
+
};
|
|
2310
|
+
}
|
|
2217
2311
|
const state = random2(24);
|
|
2218
2312
|
const nonce = random2(24);
|
|
2219
2313
|
const verifier = random2(32);
|
|
2314
|
+
this.#flowOf.set(context.requestId, state);
|
|
2220
2315
|
this.#flows.set(state, {
|
|
2221
2316
|
requestId: context.requestId,
|
|
2222
2317
|
browserId: context.browserId,
|
|
@@ -2256,6 +2351,7 @@ class OidcPairing {
|
|
|
2256
2351
|
}
|
|
2257
2352
|
this.#flows.delete(state);
|
|
2258
2353
|
const requestId = flow.requestId;
|
|
2354
|
+
this.#flowOf.delete(requestId);
|
|
2259
2355
|
const fail = (reason, message) => ({
|
|
2260
2356
|
requestId,
|
|
2261
2357
|
result: { ok: false, message, reason }
|
|
@@ -2382,12 +2478,25 @@ class OidcPairing {
|
|
|
2382
2478
|
reason: "provider_unavailable"
|
|
2383
2479
|
};
|
|
2384
2480
|
}
|
|
2481
|
+
forget(requestId) {
|
|
2482
|
+
this.#dropFlow(requestId);
|
|
2483
|
+
this.#mismatched.delete(requestId);
|
|
2484
|
+
}
|
|
2485
|
+
#dropFlow(requestId) {
|
|
2486
|
+
const state = this.#flowOf.get(requestId);
|
|
2487
|
+
if (state !== undefined)
|
|
2488
|
+
this.#flows.delete(state);
|
|
2489
|
+
this.#flowOf.delete(requestId);
|
|
2490
|
+
}
|
|
2385
2491
|
#sweep() {
|
|
2386
2492
|
const now = Date.now();
|
|
2387
2493
|
for (const [state, flow] of this.#flows) {
|
|
2388
2494
|
if (flow.expiresAt > now)
|
|
2389
2495
|
break;
|
|
2390
2496
|
this.#flows.delete(state);
|
|
2497
|
+
if (this.#flowOf.get(flow.requestId) === state) {
|
|
2498
|
+
this.#flowOf.delete(flow.requestId);
|
|
2499
|
+
}
|
|
2391
2500
|
}
|
|
2392
2501
|
}
|
|
2393
2502
|
}
|
|
@@ -2525,6 +2634,8 @@ var AUTH_CODE_TTL_MS = 60000;
|
|
|
2525
2634
|
var MAX_PENDING = 1e4;
|
|
2526
2635
|
var MAX_PENDING_PER_IP = 10;
|
|
2527
2636
|
var MAX_PENDING_PER_ADDRESS = 20;
|
|
2637
|
+
var MAX_PENDING_PER_ADDRESS_IP = 5;
|
|
2638
|
+
var REFRESH_REUSE_WINDOW_MS = 24 * 60 * 60000;
|
|
2528
2639
|
var MAX_CODES = 1e4;
|
|
2529
2640
|
var MAX_FORM_BYTES = 8192;
|
|
2530
2641
|
var MAX_STATE_LENGTH = 1024;
|
|
@@ -2605,6 +2716,10 @@ class AuthorizationServer {
|
|
|
2605
2716
|
refillPerSecond: 1 / 30
|
|
2606
2717
|
});
|
|
2607
2718
|
#tokenPerIp = new RateLimiter({ capacity: 30, refillPerSecond: 1 });
|
|
2719
|
+
#refreshesPerGrant = new RateLimiter({
|
|
2720
|
+
capacity: 20,
|
|
2721
|
+
refillPerSecond: 1 / 60
|
|
2722
|
+
});
|
|
2608
2723
|
constructor(host, testMode) {
|
|
2609
2724
|
this.#host = host;
|
|
2610
2725
|
this.#authCodeTtlMs = testMode?.authCodeTtlMs ?? AUTH_CODE_TTL_MS;
|
|
@@ -2616,7 +2731,8 @@ class AuthorizationServer {
|
|
|
2616
2731
|
config: oidc,
|
|
2617
2732
|
publicUrl: () => host.publicUrl(),
|
|
2618
2733
|
logger: host.logger,
|
|
2619
|
-
development: host.config.development
|
|
2734
|
+
development: host.config.development,
|
|
2735
|
+
...testMode?.oidcMaxFlows === undefined ? {} : { maxFlows: testMode.oidcMaxFlows }
|
|
2620
2736
|
})
|
|
2621
2737
|
},
|
|
2622
2738
|
...testMode?.pairingMethods
|
|
@@ -2782,7 +2898,7 @@ class AuthorizationServer {
|
|
|
2782
2898
|
if (error instanceof ClientMetadataError) {
|
|
2783
2899
|
this.#host.logger.info("oauth.client_refused", {
|
|
2784
2900
|
reason: error.reason,
|
|
2785
|
-
host: hostOf(clientId).slice(0, 100)
|
|
2901
|
+
host: URL.canParse(clientId) ? hostOf(clientId).slice(0, 100) : "(not a URL)"
|
|
2786
2902
|
});
|
|
2787
2903
|
return this.#errorPage(400, error.message);
|
|
2788
2904
|
}
|
|
@@ -2828,10 +2944,11 @@ class AuthorizationServer {
|
|
|
2828
2944
|
if (scopes === undefined) {
|
|
2829
2945
|
return fail("invalid_scope", `the only scope is "${SCOPE}"`);
|
|
2830
2946
|
}
|
|
2831
|
-
|
|
2832
|
-
|
|
2947
|
+
const session = this.#host.session(addressId);
|
|
2948
|
+
if (session === undefined || !session.pairingOpen) {
|
|
2949
|
+
return this.#offlinePage(client, `/authorize${new URL(req.url).search}`, this.#errorUrl(redirectUri, state, "access_denied", "the user cancelled the connection"), session !== undefined);
|
|
2833
2950
|
}
|
|
2834
|
-
const method = this.#methods[
|
|
2951
|
+
const method = this.#methods[session.site.pairing];
|
|
2835
2952
|
if (method?.prepare !== undefined) {
|
|
2836
2953
|
try {
|
|
2837
2954
|
await method.prepare();
|
|
@@ -2843,9 +2960,10 @@ class AuthorizationServer {
|
|
|
2843
2960
|
}
|
|
2844
2961
|
}
|
|
2845
2962
|
this.#sweepPending();
|
|
2846
|
-
if (this.#pending.size >= MAX_PENDING || (this.#pendingPerIp.get(ip) ?? 0) >= MAX_PENDING_PER_IP
|
|
2963
|
+
if (this.#pending.size >= MAX_PENDING || (this.#pendingPerIp.get(ip) ?? 0) >= MAX_PENDING_PER_IP) {
|
|
2847
2964
|
return this.#errorPage(429, "Too many connection requests are open right now. Wait a few minutes and try again.");
|
|
2848
2965
|
}
|
|
2966
|
+
this.#makeRoom(addressId, ip);
|
|
2849
2967
|
const pending = {
|
|
2850
2968
|
id: random3(24),
|
|
2851
2969
|
expiresAt: Date.now() + PENDING_TTL_MS,
|
|
@@ -2867,6 +2985,36 @@ class AuthorizationServer {
|
|
|
2867
2985
|
this.#pendingPerIp.set(p.ip, (this.#pendingPerIp.get(p.ip) ?? 0) + 1);
|
|
2868
2986
|
this.#pendingPerAddress.set(p.addressId, (this.#pendingPerAddress.get(p.addressId) ?? 0) + 1);
|
|
2869
2987
|
}
|
|
2988
|
+
#makeRoom(addressId, ip) {
|
|
2989
|
+
const count = this.#pendingPerAddress.get(addressId) ?? 0;
|
|
2990
|
+
if (count < MAX_PENDING_PER_ADDRESS_IP)
|
|
2991
|
+
return;
|
|
2992
|
+
const byIp = new Map;
|
|
2993
|
+
for (const p of this.#pending.values()) {
|
|
2994
|
+
if (p.addressId !== addressId)
|
|
2995
|
+
continue;
|
|
2996
|
+
const list = byIp.get(p.ip);
|
|
2997
|
+
if (list === undefined)
|
|
2998
|
+
byIp.set(p.ip, [p]);
|
|
2999
|
+
else
|
|
3000
|
+
list.push(p);
|
|
3001
|
+
}
|
|
3002
|
+
const own = byIp.get(ip) ?? [];
|
|
3003
|
+
if (own.length >= MAX_PENDING_PER_ADDRESS_IP) {
|
|
3004
|
+
if (own[0] !== undefined)
|
|
3005
|
+
this.#dropPending(own[0]);
|
|
3006
|
+
return;
|
|
3007
|
+
}
|
|
3008
|
+
if (count < MAX_PENDING_PER_ADDRESS)
|
|
3009
|
+
return;
|
|
3010
|
+
let most = [];
|
|
3011
|
+
for (const list of byIp.values()) {
|
|
3012
|
+
if (list.length >= most.length)
|
|
3013
|
+
most = list;
|
|
3014
|
+
}
|
|
3015
|
+
if (most[0] !== undefined)
|
|
3016
|
+
this.#dropPending(most[0]);
|
|
3017
|
+
}
|
|
2870
3018
|
#dropPending(p) {
|
|
2871
3019
|
if (!this.#pending.delete(p.id))
|
|
2872
3020
|
return;
|
|
@@ -2879,18 +3027,22 @@ class AuthorizationServer {
|
|
|
2879
3027
|
};
|
|
2880
3028
|
dec(this.#pendingPerIp, p.ip);
|
|
2881
3029
|
dec(this.#pendingPerAddress, p.addressId);
|
|
2882
|
-
|
|
2883
|
-
|
|
2884
|
-
|
|
2885
|
-
|
|
2886
|
-
${
|
|
2887
|
-
|
|
2888
|
-
|
|
3030
|
+
for (const method of Object.values(this.#methods))
|
|
3031
|
+
method?.forget?.(p.id);
|
|
3032
|
+
}
|
|
3033
|
+
#offlinePage(client, retryUrl, cancelUrl, connected = false) {
|
|
3034
|
+
const who = `<strong>${escapeHtml(client.name)}</strong> (${escapeHtml(client.host)})`;
|
|
3035
|
+
const title = connected ? "Open \u201CConnect your AI app\u201D first" : "Open the page first";
|
|
3036
|
+
const intro = connected ? `<p>${who} wants to use the tools of a web page, but that page is not connecting right now.</p>
|
|
3037
|
+
<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>
|
|
3038
|
+
<p>Open the page, click \u201CConnect your AI app\u201D, then try again here.</p>`;
|
|
3039
|
+
const body = `<h1>${title}</h1>
|
|
3040
|
+
${intro}
|
|
2889
3041
|
<div class="actions">
|
|
2890
3042
|
<a class="button primary" href="${escapeHtml(retryUrl)}">Try again</a>
|
|
2891
3043
|
<a class="button" href="${escapeHtml(cancelUrl)}">Cancel</a>
|
|
2892
3044
|
</div>`;
|
|
2893
|
-
return this.#page(200,
|
|
3045
|
+
return this.#page(200, title, body);
|
|
2894
3046
|
}
|
|
2895
3047
|
async authorizeSubmit(req, ip) {
|
|
2896
3048
|
const origin = req.headers.get("origin");
|
|
@@ -2969,7 +3121,7 @@ ${errorHtml}
|
|
|
2969
3121
|
client: pending.client.host,
|
|
2970
3122
|
reason: result.reason
|
|
2971
3123
|
});
|
|
2972
|
-
return this.#pairingPage(pending, browser.id, result.message, result.reason === "rate_limited" ? 429 : 400);
|
|
3124
|
+
return this.#pairingPage(pending, browser.id, result.message, result.reason === "rate_limited" || result.reason === "busy" ? 429 : 400);
|
|
2973
3125
|
}
|
|
2974
3126
|
return this.#paired(pending, method.kind, result);
|
|
2975
3127
|
}
|
|
@@ -3313,7 +3465,10 @@ ${cancel}
|
|
|
3313
3465
|
throw new OAuthError(400, "invalid_scope", "scope exceeds the grant");
|
|
3314
3466
|
}
|
|
3315
3467
|
}
|
|
3316
|
-
if (!
|
|
3468
|
+
if (!this.#refreshesPerGrant.take(record.grantId, now)) {
|
|
3469
|
+
throw new OAuthError(429, "temporarily_unavailable", "too many refreshes for this connection; try again in a minute");
|
|
3470
|
+
}
|
|
3471
|
+
if (!await this.#host.store.markUsed(hash, now, now + REFRESH_REUSE_WINDOW_MS)) {
|
|
3317
3472
|
await this.#reuseDetected(record);
|
|
3318
3473
|
throw invalid("the refresh token was already used; the connection was revoked");
|
|
3319
3474
|
}
|
|
@@ -3422,6 +3577,7 @@ ${cancel}
|
|
|
3422
3577
|
this.#guessesPerAddressIp.sweep(now);
|
|
3423
3578
|
this.#guessesPerAddress.sweep(now);
|
|
3424
3579
|
this.#tokenPerIp.sweep(now);
|
|
3580
|
+
this.#refreshesPerGrant.sweep(now);
|
|
3425
3581
|
}
|
|
3426
3582
|
}
|
|
3427
3583
|
|
|
@@ -3444,11 +3600,8 @@ function clientHost(clientId) {
|
|
|
3444
3600
|
return "";
|
|
3445
3601
|
}
|
|
3446
3602
|
}
|
|
3447
|
-
function
|
|
3448
|
-
|
|
3449
|
-
return "id" in parsed.message ? parsed.message.id : undefined;
|
|
3450
|
-
}
|
|
3451
|
-
return parsed.id;
|
|
3603
|
+
function frameId(data) {
|
|
3604
|
+
return /"id"\s*:\s*"([^"\\]{1,64})"/.exec(data.slice(0, 256))?.[1];
|
|
3452
3605
|
}
|
|
3453
3606
|
|
|
3454
3607
|
class TabSession {
|
|
@@ -3461,7 +3614,9 @@ class TabSession {
|
|
|
3461
3614
|
appName;
|
|
3462
3615
|
codes;
|
|
3463
3616
|
identity;
|
|
3617
|
+
identityUnchecked = false;
|
|
3464
3618
|
pending = new Map;
|
|
3619
|
+
#listenGrants = new Map;
|
|
3465
3620
|
#ws;
|
|
3466
3621
|
#host;
|
|
3467
3622
|
#state = "new";
|
|
@@ -3526,15 +3681,14 @@ class TabSession {
|
|
|
3526
3681
|
this.#protocolError("bad_message", "binary frames are not allowed");
|
|
3527
3682
|
return;
|
|
3528
3683
|
}
|
|
3529
|
-
|
|
3530
|
-
|
|
3531
|
-
if (tooLarge) {
|
|
3532
|
-
const id = messageId(parsed);
|
|
3684
|
+
if (Buffer.byteLength(data) > this.limits.maxMessageBytes) {
|
|
3685
|
+
const id = frameId(data);
|
|
3533
3686
|
this.#sendError("too_large", "message exceeds maxMessageBytes", id);
|
|
3534
3687
|
const exchange = id === undefined ? undefined : this.pending.get(id);
|
|
3535
3688
|
exchange?.fail(502, RelayErrorCode.tooLarge, "The page sent a response that is too large.", true);
|
|
3536
3689
|
return;
|
|
3537
3690
|
}
|
|
3691
|
+
const parsed = parseTabMessage(data);
|
|
3538
3692
|
if (!parsed.ok) {
|
|
3539
3693
|
if (this.#state === "new") {
|
|
3540
3694
|
this.#protocolError("bad_message", parsed.error);
|
|
@@ -3663,7 +3817,7 @@ class TabSession {
|
|
|
3663
3817
|
} catch (error) {
|
|
3664
3818
|
this.#host.logger.error("store.error", {
|
|
3665
3819
|
op: "listGrants",
|
|
3666
|
-
|
|
3820
|
+
error: errorName(error)
|
|
3667
3821
|
});
|
|
3668
3822
|
}
|
|
3669
3823
|
if (this.#state !== "hello")
|
|
@@ -3695,7 +3849,7 @@ class TabSession {
|
|
|
3695
3849
|
library: `${library.name}@${library.version}`,
|
|
3696
3850
|
replaced: replaced !== undefined
|
|
3697
3851
|
});
|
|
3698
|
-
this.#host.ready
|
|
3852
|
+
this.#host.ready(this);
|
|
3699
3853
|
}
|
|
3700
3854
|
#serialIdentity(run) {
|
|
3701
3855
|
const next = this.#identityChain.then(run);
|
|
@@ -3716,6 +3870,13 @@ class TabSession {
|
|
|
3716
3870
|
this.#missedPongs++;
|
|
3717
3871
|
this.send({ type: "ping", t: Date.now() });
|
|
3718
3872
|
}
|
|
3873
|
+
listenStreams(grantId) {
|
|
3874
|
+
let n = 0;
|
|
3875
|
+
for (const g of this.#listenGrants.values())
|
|
3876
|
+
if (g === grantId)
|
|
3877
|
+
n++;
|
|
3878
|
+
return n;
|
|
3879
|
+
}
|
|
3719
3880
|
forward(input) {
|
|
3720
3881
|
this.#lastRequestAt = Date.now();
|
|
3721
3882
|
const id = `r${++this.#nextRequest}`;
|
|
@@ -3737,16 +3898,19 @@ class TabSession {
|
|
|
3737
3898
|
},
|
|
3738
3899
|
onDone: (e) => {
|
|
3739
3900
|
this.pending.delete(id);
|
|
3901
|
+
this.#listenGrants.delete(id);
|
|
3740
3902
|
this.#lastRequestAt = Date.now();
|
|
3741
3903
|
input.onDone(e);
|
|
3742
3904
|
},
|
|
3743
3905
|
...input.pageGone === undefined ? {} : { pageGone: input.pageGone }
|
|
3744
3906
|
});
|
|
3745
3907
|
this.pending.set(id, exchange);
|
|
3908
|
+
if (input.isListen)
|
|
3909
|
+
this.#listenGrants.set(id, input.grant.grantId);
|
|
3746
3910
|
const sent = this.send({
|
|
3747
3911
|
type: "http.request",
|
|
3748
3912
|
id,
|
|
3749
|
-
method:
|
|
3913
|
+
method: "POST",
|
|
3750
3914
|
path: "/mcp",
|
|
3751
3915
|
headers: input.headers,
|
|
3752
3916
|
body: input.body,
|
|
@@ -3848,11 +4012,12 @@ class MemoryTokenStore {
|
|
|
3848
4012
|
const r = this.#records.get(hash);
|
|
3849
4013
|
return r === undefined ? undefined : copy(r);
|
|
3850
4014
|
}
|
|
3851
|
-
async markUsed(hash, now) {
|
|
4015
|
+
async markUsed(hash, now, keepUntil) {
|
|
3852
4016
|
const r = this.#records.get(hash);
|
|
3853
4017
|
if (r === undefined || r.usedAt !== undefined)
|
|
3854
4018
|
return false;
|
|
3855
4019
|
r.usedAt = now;
|
|
4020
|
+
r.expiresAt = Math.min(r.expiresAt, keepUntil);
|
|
3856
4021
|
return true;
|
|
3857
4022
|
}
|
|
3858
4023
|
async revoke(selector, options) {
|
|
@@ -3905,7 +4070,8 @@ class MemoryTokenStore {
|
|
|
3905
4070
|
|
|
3906
4071
|
// src/store/sqlite.ts
|
|
3907
4072
|
import { Database } from "bun:sqlite";
|
|
3908
|
-
var SCHEMA_VERSION =
|
|
4073
|
+
var SCHEMA_VERSION = 4;
|
|
4074
|
+
var REUSE_WINDOW_MS = 86400000;
|
|
3909
4075
|
function toRecord(row) {
|
|
3910
4076
|
const record = {
|
|
3911
4077
|
hash: row.hash,
|
|
@@ -3968,6 +4134,8 @@ class SqliteTokenStore {
|
|
|
3968
4134
|
this.#db.run("CREATE INDEX IF NOT EXISTS tokens_grant ON tokens (grant_id)");
|
|
3969
4135
|
this.#db.run("CREATE INDEX IF NOT EXISTS tokens_address ON tokens (address_id)");
|
|
3970
4136
|
this.#db.run("CREATE INDEX IF NOT EXISTS tokens_expires ON tokens (expires_at)");
|
|
4137
|
+
this.#db.query(`UPDATE tokens SET expires_at = min(expires_at, used_at + $window)
|
|
4138
|
+
WHERE kind = 'refresh' AND used_at IS NOT NULL`).run({ window: REUSE_WINDOW_MS });
|
|
3971
4139
|
this.#db.run(`PRAGMA user_version = ${SCHEMA_VERSION}`);
|
|
3972
4140
|
})();
|
|
3973
4141
|
}
|
|
@@ -4008,8 +4176,9 @@ class SqliteTokenStore {
|
|
|
4008
4176
|
const row = this.#db.query("SELECT * FROM tokens WHERE hash = $hash").get({ hash });
|
|
4009
4177
|
return row === null ? undefined : toRecord(row);
|
|
4010
4178
|
}
|
|
4011
|
-
async markUsed(hash, now) {
|
|
4012
|
-
const result = this.#db.query(
|
|
4179
|
+
async markUsed(hash, now, keepUntil) {
|
|
4180
|
+
const result = this.#db.query(`UPDATE tokens SET used_at = $now, expires_at = min(expires_at, $keep_until)
|
|
4181
|
+
WHERE hash = $hash AND used_at IS NULL`).run({ hash, now, keep_until: keepUntil });
|
|
4013
4182
|
return result.changes === 1;
|
|
4014
4183
|
}
|
|
4015
4184
|
async revoke(selector, options) {
|
|
@@ -4048,6 +4217,16 @@ var VERSION = version;
|
|
|
4048
4217
|
var RELAY_INFO = { name: "tab-mcp", version };
|
|
4049
4218
|
var RETRY = Symbol("retry");
|
|
4050
4219
|
var CLAUDE_MISMATCHED_HEADER = "2025-11-25";
|
|
4220
|
+
var MAX_LISTEN_STREAMS_PER_GRANT = 2;
|
|
4221
|
+
var FRAME_HEADROOM = 4;
|
|
4222
|
+
var TRANSIENT_REFUSALS = new Set([
|
|
4223
|
+
"jwks_unavailable",
|
|
4224
|
+
"store_unavailable",
|
|
4225
|
+
"relay_busy",
|
|
4226
|
+
"error"
|
|
4227
|
+
]);
|
|
4228
|
+
var UNCHECKED_TEXT = "The relay cannot check the page's user right now. Try again shortly.";
|
|
4229
|
+
var RETRY_AFTER_S = "5";
|
|
4051
4230
|
var FORWARDED_HEADERS = new Set([
|
|
4052
4231
|
"content-type",
|
|
4053
4232
|
"accept",
|
|
@@ -4109,10 +4288,10 @@ function startRelay(options, hooks) {
|
|
|
4109
4288
|
const perIp = new Map;
|
|
4110
4289
|
const waiters = new Map;
|
|
4111
4290
|
let waitingCount = 0;
|
|
4112
|
-
const
|
|
4291
|
+
const lastSeen = new Map;
|
|
4113
4292
|
let claudeWorkarounds = 0;
|
|
4114
4293
|
let publicUrl = config.publicUrl ?? "";
|
|
4115
|
-
const assertions = config.oidc === undefined ? undefined : new TabAssertionVerifier(config.oidc, hooks?.assertionKeys);
|
|
4294
|
+
const assertions = config.oidc === undefined ? undefined : new TabAssertionVerifier(config.oidc, hooks?.assertionKeys, hooks?.maxSeenAssertions);
|
|
4116
4295
|
const corsHeaders = config.cors ? {
|
|
4117
4296
|
"access-control-allow-origin": "*",
|
|
4118
4297
|
"access-control-expose-headers": "www-authenticate, mcp-protocol-version"
|
|
@@ -4174,9 +4353,15 @@ function startRelay(options, hooks) {
|
|
|
4174
4353
|
}
|
|
4175
4354
|
};
|
|
4176
4355
|
const refuse = (reason, message) => {
|
|
4356
|
+
const transient = TRANSIENT_REFUSALS.has(reason);
|
|
4357
|
+
session.identityUnchecked = transient;
|
|
4177
4358
|
if (session.identity !== undefined) {
|
|
4178
4359
|
session.identity = undefined;
|
|
4179
|
-
|
|
4360
|
+
if (transient) {
|
|
4361
|
+
failPending(session, UNCHECKED_TEXT, true);
|
|
4362
|
+
} else {
|
|
4363
|
+
failPending(session, "The page's user is no longer signed in.");
|
|
4364
|
+
}
|
|
4180
4365
|
}
|
|
4181
4366
|
logger.info("identity.refused", {
|
|
4182
4367
|
address: shortAddress(addressId),
|
|
@@ -4213,7 +4398,7 @@ function startRelay(options, hooks) {
|
|
|
4213
4398
|
} catch (error) {
|
|
4214
4399
|
logger.error("store.error", {
|
|
4215
4400
|
op: "identity",
|
|
4216
|
-
|
|
4401
|
+
error: errorName(error)
|
|
4217
4402
|
});
|
|
4218
4403
|
refuse("store_unavailable", "the relay cannot check grants right now");
|
|
4219
4404
|
return;
|
|
@@ -4221,6 +4406,7 @@ function startRelay(options, hooks) {
|
|
|
4221
4406
|
if (!session.ready)
|
|
4222
4407
|
return;
|
|
4223
4408
|
session.identity = key;
|
|
4409
|
+
session.identityUnchecked = false;
|
|
4224
4410
|
answer({ type: "identity.bound", ...echo });
|
|
4225
4411
|
logger.info("identity.bound", {
|
|
4226
4412
|
address: shortAddress(addressId),
|
|
@@ -4242,6 +4428,7 @@ function startRelay(options, hooks) {
|
|
|
4242
4428
|
return;
|
|
4243
4429
|
}
|
|
4244
4430
|
session.identity = undefined;
|
|
4431
|
+
session.identityUnchecked = false;
|
|
4245
4432
|
failPending(session, "The page's user signed out.");
|
|
4246
4433
|
try {
|
|
4247
4434
|
const grants = await store.listGrants(addressId, Date.now());
|
|
@@ -4253,10 +4440,7 @@ function startRelay(options, hooks) {
|
|
|
4253
4440
|
for (const g of grants)
|
|
4254
4441
|
grantRemoved(addressId, g.grantId, "revoked");
|
|
4255
4442
|
} catch (error) {
|
|
4256
|
-
logger.error("store.error", {
|
|
4257
|
-
op: "signOut",
|
|
4258
|
-
message: error instanceof Error ? error.message : "unknown"
|
|
4259
|
-
});
|
|
4443
|
+
logger.error("store.error", { op: "signOut", error: errorName(error) });
|
|
4260
4444
|
}
|
|
4261
4445
|
},
|
|
4262
4446
|
claimAddress(session) {
|
|
@@ -4278,14 +4462,16 @@ function startRelay(options, hooks) {
|
|
|
4278
4462
|
const id = session.addressId;
|
|
4279
4463
|
if (id === undefined)
|
|
4280
4464
|
return;
|
|
4281
|
-
|
|
4282
|
-
|
|
4283
|
-
|
|
4284
|
-
|
|
4285
|
-
|
|
4286
|
-
|
|
4287
|
-
|
|
4288
|
-
|
|
4465
|
+
const appName = session.appName ?? lastSeen.get(id)?.appName;
|
|
4466
|
+
lastSeen.delete(id);
|
|
4467
|
+
lastSeen.set(id, {
|
|
4468
|
+
appName,
|
|
4469
|
+
maxConcurrentRequests: session.limits.maxConcurrentRequests
|
|
4470
|
+
});
|
|
4471
|
+
if (lastSeen.size > config.maxSessions * 2) {
|
|
4472
|
+
const oldest = lastSeen.keys().next().value;
|
|
4473
|
+
if (oldest !== undefined)
|
|
4474
|
+
lastSeen.delete(oldest);
|
|
4289
4475
|
}
|
|
4290
4476
|
const waiting = waiters.get(id);
|
|
4291
4477
|
if (waiting !== undefined)
|
|
@@ -4293,9 +4479,11 @@ function startRelay(options, hooks) {
|
|
|
4293
4479
|
wake(session);
|
|
4294
4480
|
}
|
|
4295
4481
|
};
|
|
4296
|
-
function failPending(session, message) {
|
|
4482
|
+
function failPending(session, message, transient = false) {
|
|
4483
|
+
const addressId = session.addressId;
|
|
4484
|
+
const headers = transient ? { "retry-after": RETRY_AFTER_S } : addressId === undefined ? {} : { "www-authenticate": wwwAuthenticate(addressId, true) };
|
|
4297
4485
|
for (const exchange of [...session.pending.values()]) {
|
|
4298
|
-
exchange.fail(401, RelayErrorCode.signedOut, message, true);
|
|
4486
|
+
exchange.fail(transient ? 503 : 401, RelayErrorCode.signedOut, message, true, headers);
|
|
4299
4487
|
}
|
|
4300
4488
|
}
|
|
4301
4489
|
function readySession(addressId, usable = () => true) {
|
|
@@ -4309,11 +4497,12 @@ function startRelay(options, hooks) {
|
|
|
4309
4497
|
const now = readySession(addressId, usable);
|
|
4310
4498
|
if (now !== undefined)
|
|
4311
4499
|
return Promise.resolve(now);
|
|
4312
|
-
if (waitMs === 0 ||
|
|
4500
|
+
if (waitMs === 0 || closing) {
|
|
4313
4501
|
return Promise.resolve(undefined);
|
|
4314
4502
|
}
|
|
4315
4503
|
const set = waiters.get(addressId) ?? new Set;
|
|
4316
|
-
|
|
4504
|
+
const limit = sessions.get(addressId)?.limits.maxConcurrentRequests ?? lastSeen.get(addressId)?.maxConcurrentRequests ?? config.maxConcurrentRequests;
|
|
4505
|
+
if (set.size >= limit || waitingCount >= config.maxSessions) {
|
|
4317
4506
|
return Promise.resolve(undefined);
|
|
4318
4507
|
}
|
|
4319
4508
|
return new Promise((resolve) => {
|
|
@@ -4442,10 +4631,7 @@ function startRelay(options, hooks) {
|
|
|
4442
4631
|
try {
|
|
4443
4632
|
record = await store.get(hashToken(token));
|
|
4444
4633
|
} catch (error) {
|
|
4445
|
-
logger.error("store.error", {
|
|
4446
|
-
op: "get",
|
|
4447
|
-
message: error instanceof Error ? error.message : "unknown"
|
|
4448
|
-
});
|
|
4634
|
+
logger.error("store.error", { op: "get", error: errorName(error) });
|
|
4449
4635
|
return "unavailable";
|
|
4450
4636
|
}
|
|
4451
4637
|
if (record === undefined || record.kind !== "access" || record.addressId !== addressId || record.expiresAt <= Date.now() || !record.scopes.includes("tools")) {
|
|
@@ -4466,8 +4652,12 @@ function startRelay(options, hooks) {
|
|
|
4466
4652
|
const deadline = new Promise((resolve) => {
|
|
4467
4653
|
timer = setTimeout(() => resolve("timeout"), config.bodyTimeoutMs);
|
|
4468
4654
|
});
|
|
4469
|
-
|
|
4470
|
-
|
|
4655
|
+
let result;
|
|
4656
|
+
try {
|
|
4657
|
+
result = await Promise.race([reading, deadline]);
|
|
4658
|
+
} finally {
|
|
4659
|
+
clearTimeout(timer);
|
|
4660
|
+
}
|
|
4471
4661
|
if (result === "timeout") {
|
|
4472
4662
|
return rpcError(408, undefined, -32600, "The request body did not arrive in time.", { ...corsHeaders, connection: "close" });
|
|
4473
4663
|
}
|
|
@@ -4537,7 +4727,7 @@ function startRelay(options, hooks) {
|
|
|
4537
4727
|
const auth = await authenticate(req, addressId);
|
|
4538
4728
|
if (auth === "unavailable") {
|
|
4539
4729
|
logDone(503, method, { reason: "store_unavailable" });
|
|
4540
|
-
return rpcError(503, rpcId, RelayErrorCode.storeUnavailable, "The relay cannot check tokens right now. Try again shortly.", { ...corsHeaders, "retry-after":
|
|
4730
|
+
return rpcError(503, rpcId, RelayErrorCode.storeUnavailable, "The relay cannot check tokens right now. Try again shortly.", { ...corsHeaders, "retry-after": RETRY_AFTER_S });
|
|
4541
4731
|
}
|
|
4542
4732
|
if (typeof auth === "string") {
|
|
4543
4733
|
logDone(401, method);
|
|
@@ -4569,12 +4759,9 @@ function startRelay(options, hooks) {
|
|
|
4569
4759
|
logDone(499, method, { outcome: "client_closed" });
|
|
4570
4760
|
return new Response(null, { status: 499 });
|
|
4571
4761
|
};
|
|
4762
|
+
const idle = (on) => server.timeout(req, on ? config.httpIdleTimeoutS : 0);
|
|
4572
4763
|
const usable = usableFor(auth.subject);
|
|
4573
|
-
|
|
4574
|
-
server.timeout(req, 0);
|
|
4575
|
-
}
|
|
4576
|
-
let retried = false;
|
|
4577
|
-
for (;; ) {
|
|
4764
|
+
const admit = async () => {
|
|
4578
4765
|
const found = await waitForPage(addressId, req.signal, waitMs, usable);
|
|
4579
4766
|
if (found === "aborted" || req.signal.aborted)
|
|
4580
4767
|
return closed();
|
|
@@ -4597,14 +4784,30 @@ function startRelay(options, hooks) {
|
|
|
4597
4784
|
logDone(429, method);
|
|
4598
4785
|
return rpcError(429, rpcId, RelayErrorCode.overloaded, "Too many requests at once for this page.", corsHeaders);
|
|
4599
4786
|
}
|
|
4600
|
-
|
|
4787
|
+
if (isListen && session.listenStreams(auth.grantId) >= MAX_LISTEN_STREAMS_PER_GRANT) {
|
|
4788
|
+
logDone(429, method, { reason: "listen_streams" });
|
|
4789
|
+
return rpcError(429, rpcId, RelayErrorCode.overloaded, "Too many listen streams at once for this AI app.", corsHeaders);
|
|
4790
|
+
}
|
|
4791
|
+
return session;
|
|
4792
|
+
};
|
|
4793
|
+
if (readySession(addressId, usable) === undefined && waitMs > 0) {
|
|
4794
|
+
idle(false);
|
|
4795
|
+
}
|
|
4796
|
+
let retried = false;
|
|
4797
|
+
for (;; ) {
|
|
4798
|
+
const admitted = await admit();
|
|
4799
|
+
if (admitted instanceof Response) {
|
|
4800
|
+
idle(true);
|
|
4801
|
+
return admitted;
|
|
4802
|
+
}
|
|
4803
|
+
const session = admitted;
|
|
4804
|
+
idle(false);
|
|
4601
4805
|
let retry = () => {};
|
|
4602
4806
|
const retrying = new Promise((resolve) => {
|
|
4603
4807
|
retry = () => resolve(RETRY);
|
|
4604
4808
|
});
|
|
4605
4809
|
const mayRetry = !retried && waitMs > 0 && isRetryable(body.value["method"]);
|
|
4606
4810
|
const exchange = session.forward({
|
|
4607
|
-
method: "POST",
|
|
4608
4811
|
headers,
|
|
4609
4812
|
body: body.text,
|
|
4610
4813
|
rpcId,
|
|
@@ -4618,6 +4821,7 @@ function startRelay(options, hooks) {
|
|
|
4618
4821
|
onDone: (e) => {
|
|
4619
4822
|
if (e.outcome === "page_gone_retry")
|
|
4620
4823
|
return;
|
|
4824
|
+
idle(true);
|
|
4621
4825
|
logDone(e.status, method, {
|
|
4622
4826
|
session: session.sessionId,
|
|
4623
4827
|
client: clientHostName,
|
|
@@ -4630,8 +4834,18 @@ function startRelay(options, hooks) {
|
|
|
4630
4834
|
retry();
|
|
4631
4835
|
return { retry: true };
|
|
4632
4836
|
}
|
|
4633
|
-
const answer = notOpenAnswer(body.value, rpcId, session.appName, "gone", RELAY_INFO);
|
|
4634
|
-
|
|
4837
|
+
const answer = notOpenAnswer(body.value, rpcId, session.appName, closing ? "restarting_gone" : "gone", RELAY_INFO);
|
|
4838
|
+
if (answer !== undefined)
|
|
4839
|
+
return { retry: false, body: answer };
|
|
4840
|
+
return !closing ? { retry: false } : {
|
|
4841
|
+
retry: false,
|
|
4842
|
+
error: {
|
|
4843
|
+
status: 503,
|
|
4844
|
+
code: RelayErrorCode.restarting,
|
|
4845
|
+
message: "The relay is restarting.",
|
|
4846
|
+
headers: { "retry-after": RETRY_AFTER_S }
|
|
4847
|
+
}
|
|
4848
|
+
};
|
|
4635
4849
|
}
|
|
4636
4850
|
});
|
|
4637
4851
|
const onAbort = () => exchange.clientClosed();
|
|
@@ -4645,13 +4859,21 @@ function startRelay(options, hooks) {
|
|
|
4645
4859
|
}
|
|
4646
4860
|
function pageNotOpen(value, rpcId, addressId, logDone, method) {
|
|
4647
4861
|
const open = sessions.get(addressId);
|
|
4648
|
-
|
|
4649
|
-
|
|
4862
|
+
if (!closing && open?.ready === true && open.identity === undefined && open.identityUnchecked) {
|
|
4863
|
+
logDone(503, method, { reason: "identity_unchecked" });
|
|
4864
|
+
return rpcError(503, rpcId, RelayErrorCode.signedOut, UNCHECKED_TEXT, {
|
|
4865
|
+
...corsHeaders,
|
|
4866
|
+
"retry-after": RETRY_AFTER_S
|
|
4867
|
+
});
|
|
4868
|
+
}
|
|
4869
|
+
const phase = closing ? "restarting" : open?.ready === true && open.identity === undefined && open.site.pairing === "oidc" ? "signed_out" : "not_open";
|
|
4870
|
+
const reason = phase === "restarting" ? "restarting" : "page_not_open";
|
|
4871
|
+
const answer = notOpenAnswer(value, rpcId, open?.appName ?? lastSeen.get(addressId)?.appName, phase, RELAY_INFO);
|
|
4650
4872
|
if (answer === undefined) {
|
|
4651
|
-
logDone(503, method, { reason
|
|
4652
|
-
return rpcError(503, rpcId, RelayErrorCode.pageNotOpen, "The page is not open.", corsHeaders);
|
|
4873
|
+
logDone(503, method, { reason });
|
|
4874
|
+
return phase === "restarting" ? rpcError(503, rpcId, RelayErrorCode.restarting, "The relay is restarting.", { ...corsHeaders, "retry-after": RETRY_AFTER_S }) : rpcError(503, rpcId, RelayErrorCode.pageNotOpen, "The page is not open.", corsHeaders);
|
|
4653
4875
|
}
|
|
4654
|
-
logDone(200, method, { reason
|
|
4876
|
+
logDone(200, method, { reason });
|
|
4655
4877
|
return new Response(answer, {
|
|
4656
4878
|
status: 200,
|
|
4657
4879
|
headers: { ...corsHeaders, "content-type": "application/json" }
|
|
@@ -4746,7 +4968,7 @@ function startRelay(options, hooks) {
|
|
|
4746
4968
|
fetch: () => new Response("Not found", { status: 404 }),
|
|
4747
4969
|
websocket: {
|
|
4748
4970
|
data: {},
|
|
4749
|
-
maxPayloadLength: largest *
|
|
4971
|
+
maxPayloadLength: largest * FRAME_HEADROOM,
|
|
4750
4972
|
backpressureLimit: config.backpressureLimitBytes,
|
|
4751
4973
|
closeOnBackpressureLimit: false,
|
|
4752
4974
|
idleTimeout: 120,
|
|
@@ -4766,6 +4988,7 @@ function startRelay(options, hooks) {
|
|
|
4766
4988
|
}
|
|
4767
4989
|
});
|
|
4768
4990
|
let stopping;
|
|
4991
|
+
let closing = false;
|
|
4769
4992
|
const port = server.port ?? config.port;
|
|
4770
4993
|
if (publicUrl === "")
|
|
4771
4994
|
publicUrl = `http://localhost:${port}`;
|
|
@@ -4776,7 +4999,7 @@ function startRelay(options, hooks) {
|
|
|
4776
4999
|
logger.debug("store.swept", { deleted: n });
|
|
4777
5000
|
}).catch((error) => logger.error("store.error", {
|
|
4778
5001
|
op: "deleteExpired",
|
|
4779
|
-
|
|
5002
|
+
error: errorName(error)
|
|
4780
5003
|
}));
|
|
4781
5004
|
}, config.sweepIntervalMs);
|
|
4782
5005
|
const siteCount = config.allowedOrigins === "*" ? "*" : config.allowedOrigins.length + config.sites.length;
|
|
@@ -4805,6 +5028,7 @@ function startRelay(options, hooks) {
|
|
|
4805
5028
|
return allSessions.size;
|
|
4806
5029
|
},
|
|
4807
5030
|
stop() {
|
|
5031
|
+
closing = true;
|
|
4808
5032
|
stopping ??= (async () => {
|
|
4809
5033
|
clearInterval(sweep);
|
|
4810
5034
|
for (const set of [...waiters.values()]) {
|
|
@@ -4916,5 +5140,5 @@ var shutdown = () => {
|
|
|
4916
5140
|
process.on("SIGTERM", shutdown);
|
|
4917
5141
|
process.on("SIGINT", shutdown);
|
|
4918
5142
|
|
|
4919
|
-
//# debugId=
|
|
5143
|
+
//# debugId=07B48995F9433DFC64756E2164756E21
|
|
4920
5144
|
//# sourceMappingURL=main.js.map
|