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