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