@ractive-ch/tab-mcp 0.4.0 → 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/main.js CHANGED
@@ -603,7 +603,7 @@ function installCrashLogging(logger, exit = (code) => process.exit(code)) {
603
603
  };
604
604
  }
605
605
  // package.json
606
- var version = "0.4.0";
606
+ var version = "0.5.0";
607
607
 
608
608
  // src/address.ts
609
609
  var BASE32_ALPHABET = "abcdefghijklmnopqrstuvwxyz234567";
@@ -1384,7 +1384,8 @@ var CloseCode = {
1384
1384
  heartbeatLost: 4003,
1385
1385
  shuttingDown: 4004,
1386
1386
  replaced: 4005,
1387
- rateLimited: 4008
1387
+ rateLimited: 4008,
1388
+ addressMismatch: 4009
1388
1389
  };
1389
1390
  var MAX_TITLE = 300;
1390
1391
  var MAX_NAME = 200;
@@ -1446,6 +1447,10 @@ function parseTabMessage(text) {
1446
1447
  if (app !== undefined && (!isObject(app) || !isAppName(app["name"]))) {
1447
1448
  return bad(`hello.app.name must be 1-${MAX_APP_NAME} characters`);
1448
1449
  }
1450
+ const revokeAll = v["revokeAll"];
1451
+ if (revokeAll !== undefined && typeof revokeAll !== "boolean") {
1452
+ return bad("hello.revokeAll must be a boolean");
1453
+ }
1449
1454
  const hello = {
1450
1455
  type,
1451
1456
  library: { name: library["name"], version: library["version"] },
@@ -1455,6 +1460,8 @@ function parseTabMessage(text) {
1455
1460
  if (isObject(app) && isAppName(app["name"])) {
1456
1461
  hello.app = { name: app["name"] };
1457
1462
  }
1463
+ if (revokeAll === true)
1464
+ hello.revokeAll = true;
1458
1465
  return { ok: true, message: hello };
1459
1466
  }
1460
1467
  case "pong": {
@@ -2308,7 +2315,7 @@ class OidcPairing {
2308
2315
  reason: "rate_limited"
2309
2316
  };
2310
2317
  }
2311
- const state = random2(24);
2318
+ const state = `${context.addressId}.${random2(24)}`;
2312
2319
  const nonce = random2(24);
2313
2320
  const verifier = random2(32);
2314
2321
  this.#flowOf.set(context.requestId, state);
@@ -3098,7 +3105,8 @@ ${intro}
3098
3105
  const admit = () => this.#guessesPerAddressIp.take(`${pending.addressId}|${ip}`) && this.#guessesPerAddress.take(pending.addressId);
3099
3106
  const result = await method.check(session, form, admit, {
3100
3107
  requestId: pending.id,
3101
- browserId: browser.id
3108
+ browserId: browser.id,
3109
+ addressId: pending.addressId
3102
3110
  });
3103
3111
  if (result.ok === "redirect") {
3104
3112
  this.#host.logger.info("oauth.pairing_redirect", {
@@ -3127,7 +3135,7 @@ ${intro}
3127
3135
  }
3128
3136
  #paired(pending, kind, result) {
3129
3137
  this.#dropPending(pending);
3130
- const code = random3(32);
3138
+ const code = `${pending.addressId}.${random3(32)}`;
3131
3139
  if (this.#codes.size >= MAX_CODES)
3132
3140
  this.#sweepCodes(true);
3133
3141
  const { id: _id, expiresAt: _expires, ip: _ip, ...request } = pending;
@@ -3247,7 +3255,7 @@ ${intro}
3247
3255
  ${errorHtml}
3248
3256
  <p>${clientLabel} wants to use the tools of a web page, but that page is not open right now.</p>
3249
3257
  <p>Open the page, click \u201CConnect your AI app\u201D, then try again here.</p>
3250
- <form method="post" action="/authorize">
3258
+ <form method="post" action="/authorize?a=${pending.addressId}">
3251
3259
  ${hidden}
3252
3260
  <div class="actions">
3253
3261
  <button type="submit" class="primary" name="action" value="retry">Try again</button>
@@ -3268,7 +3276,7 @@ ${cancel}
3268
3276
  <p>You're connecting ${clientLabel} to ${target}.</p>
3269
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>
3270
3278
  ${errorHtml}
3271
- <form method="post" action="/authorize">
3279
+ <form method="post" action="/authorize?a=${pending.addressId}">
3272
3280
  ${hidden}
3273
3281
  ${method.render(session)}
3274
3282
  <div class="actions">
@@ -3610,6 +3618,7 @@ class TabSession {
3610
3618
  origin;
3611
3619
  site;
3612
3620
  openedAt = Date.now();
3621
+ expectedAddress;
3613
3622
  addressId;
3614
3623
  appName;
3615
3624
  codes;
@@ -3628,12 +3637,13 @@ class TabSession {
3628
3637
  #helloTimer;
3629
3638
  #heartbeatTimer;
3630
3639
  #lifetimeTimer;
3631
- constructor(ws, ip, origin, site, host) {
3640
+ constructor(ws, ip, origin, site, host, expectedAddress) {
3632
3641
  this.#ws = ws;
3633
3642
  this.ip = ip;
3634
3643
  this.origin = origin;
3635
3644
  this.site = site;
3636
3645
  this.#host = host;
3646
+ this.expectedAddress = expectedAddress;
3637
3647
  this.codes = new PairingCodes((code, expiresAt) => {
3638
3648
  this.send({
3639
3649
  type: "pairing.code",
@@ -3722,7 +3732,7 @@ class TabSession {
3722
3732
  this.#protocolError("bad_message", "hello expected");
3723
3733
  return;
3724
3734
  }
3725
- await this.#hello(msg.secret, msg.library, msg.app?.name);
3735
+ await this.#hello(msg.secret, msg.library, msg.app?.name, msg.revokeAll === true);
3726
3736
  return;
3727
3737
  }
3728
3738
  if (this.#state === "hello")
@@ -3789,19 +3799,38 @@ class TabSession {
3789
3799
  }
3790
3800
  }
3791
3801
  }
3792
- async#hello(secret, library, appName) {
3802
+ async#hello(secret, library, appName, revokeAll = false) {
3793
3803
  if (!isWellFormedSecret(secret)) {
3794
3804
  this.#protocolError("bad_message", "secret must be 43 base64url characters");
3795
3805
  return;
3796
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
+ }
3797
3814
  clearTimeout(this.#helloTimer);
3798
3815
  this.#state = "hello";
3799
- this.addressId = addressIdFromSecret(secret);
3816
+ this.addressId = addressId;
3800
3817
  this.appName = this.site.name ?? appName;
3801
3818
  const replaced = this.#host.claimAddress(this);
3802
3819
  if (replaced !== undefined) {
3803
3820
  replaced.end(CloseCode.replaced, "replaced by a newer session", "replaced");
3804
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
+ }
3805
3834
  let grants = [];
3806
3835
  try {
3807
3836
  const list = await this.#host.store.listGrants(this.addressId, Date.now());
@@ -4235,6 +4264,33 @@ var FORWARDED_HEADERS = new Set([
4235
4264
  "traceparent",
4236
4265
  "tracestate"
4237
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
+ }
4238
4294
  function json(status, value, headers) {
4239
4295
  const h = new Headers(headers);
4240
4296
  h.set("content-type", "application/json");
@@ -4590,6 +4646,11 @@ function startRelay(options, hooks) {
4590
4646
  supported: [SUBPROTOCOL]
4591
4647
  });
4592
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
+ }
4593
4654
  const realIp = clientIp(req, server);
4594
4655
  const ip = ipPrefixKey(realIp);
4595
4656
  if (allSessions.size >= config.maxSessions) {
@@ -4606,7 +4667,12 @@ function startRelay(options, hooks) {
4606
4667
  }
4607
4668
  perIp.set(ip, count + 1);
4608
4669
  const ok = server.upgrade(req, {
4609
- data: { ip, origin: origin ?? "", site },
4670
+ data: {
4671
+ ip,
4672
+ origin: origin ?? "",
4673
+ site,
4674
+ ...address === undefined ? {} : { address }
4675
+ },
4610
4676
  headers: { "Sec-WebSocket-Protocol": SUBPROTOCOL }
4611
4677
  });
4612
4678
  if (ok)
@@ -4898,6 +4964,56 @@ function startRelay(options, hooks) {
4898
4964
  headers: { ...corsHeaders, allow: "POST" }
4899
4965
  });
4900
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
+ }
4901
5017
  const largest = largestMessageBytes(config);
4902
5018
  const ipOf = (req, srv) => ipPrefixKey(clientIp(req, srv));
4903
5019
  const server = Bun.serve({
@@ -4908,11 +5024,17 @@ function startRelay(options, hooks) {
4908
5024
  idleTimeout: config.httpIdleTimeoutS,
4909
5025
  routes: {
4910
5026
  "/healthz": {
4911
- GET: () => json(200, {
4912
- status: "ok",
4913
- version: VERSION,
4914
- counters: { claudeHeaderWorkaround: claudeWorkarounds }
4915
- }, { "cache-control": "no-store" })
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
+ }
4916
5038
  },
4917
5039
  "/": {
4918
5040
  GET: () => new Response(INDEX_HTML, {
@@ -4975,7 +5097,7 @@ function startRelay(options, hooks) {
4975
5097
  sendPings: true,
4976
5098
  perMessageDeflate: false,
4977
5099
  open(ws) {
4978
- 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);
4979
5101
  ws.data.session = session;
4980
5102
  allSessions.add(session);
4981
5103
  },
@@ -5140,5 +5262,5 @@ var shutdown = () => {
5140
5262
  process.on("SIGTERM", shutdown);
5141
5263
  process.on("SIGINT", shutdown);
5142
5264
 
5143
- //# debugId=07B48995F9433DFC64756E2164756E21
5265
+ //# debugId=ED4BD668E1904D7C64756E2164756E21
5144
5266
  //# sourceMappingURL=main.js.map