@delt/claude-alarm 0.9.0 → 0.9.1

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/dist/index.js CHANGED
@@ -521,6 +521,7 @@ ${previewHtml}${truncNote}`;
521
521
  }
522
522
  async handleCallbackQuery(query) {
523
523
  if (!query.data) return;
524
+ if (String(query.message?.chat.id) !== String(this.config.chatId)) return;
524
525
  if (query.data.startsWith("sess:")) {
525
526
  await this.handleSessionSelectCallback(query);
526
527
  return;
@@ -784,8 +785,66 @@ function logStartup(context) {
784
785
  `);
785
786
  }
786
787
 
788
+ // src/hub/auth.ts
789
+ import crypto from "crypto";
790
+ var SESSION_COOKIE = "ca_session";
791
+ var COOKIE_MAX_AGE = 30 * 24 * 60 * 60;
792
+ function safeEqual(a, b) {
793
+ if (typeof a !== "string") return false;
794
+ const x = Buffer.from(a);
795
+ const y = Buffer.from(b);
796
+ return x.length === y.length && crypto.timingSafeEqual(x, y);
797
+ }
798
+ function dashboardCookieValue(token) {
799
+ return crypto.createHmac("sha256", token).update("claude-alarm-dashboard").digest("hex");
800
+ }
801
+ function parseCookies(header) {
802
+ const out = {};
803
+ for (const part of (header ?? "").split(";")) {
804
+ const i = part.indexOf("=");
805
+ if (i <= 0) continue;
806
+ const raw = part.slice(i + 1).trim();
807
+ let value = raw;
808
+ try {
809
+ value = decodeURIComponent(raw);
810
+ } catch {
811
+ }
812
+ out[part.slice(0, i).trim()] = value;
813
+ }
814
+ return out;
815
+ }
816
+ function sessionCookieHeader(token, secure) {
817
+ return `${SESSION_COOKIE}=${dashboardCookieValue(token)}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${COOKIE_MAX_AGE}${secure ? "; Secure" : ""}`;
818
+ }
819
+ function isAuthorized(req, token, allowQueryToken) {
820
+ const auth = req.headers.authorization;
821
+ if (auth?.startsWith("Bearer ") && safeEqual(auth.slice(7), token)) return true;
822
+ if (safeEqual(parseCookies(req.headers.cookie)[SESSION_COOKIE], dashboardCookieValue(token))) return true;
823
+ if (allowQueryToken) {
824
+ const q = new URL(req.url ?? "/", "http://hub").searchParams.get("token");
825
+ if (safeEqual(q, token)) return true;
826
+ }
827
+ return false;
828
+ }
829
+ function isCrossOrigin(req) {
830
+ const origin = req.headers.origin;
831
+ if (!origin) return false;
832
+ try {
833
+ return new URL(origin).host !== req.headers.host;
834
+ } catch {
835
+ return true;
836
+ }
837
+ }
838
+ function isJsonRequest(req) {
839
+ return String(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json");
840
+ }
841
+ function isSecureRequest(req) {
842
+ return req.headers["x-forwarded-proto"] === "https";
843
+ }
844
+
787
845
  // src/hub/server.ts
788
846
  var __dirname = path4.dirname(fileURLToPath(import.meta.url));
847
+ var MAX_WS_PAYLOAD = 16 * 1024 * 1024;
789
848
  var HubServer = class {
790
849
  httpServer;
791
850
  wssChannel;
@@ -803,6 +862,7 @@ var HubServer = class {
803
862
  heartbeatInterval;
804
863
  channelAlive = /* @__PURE__ */ new Map();
805
864
  // sessionId -> alive flag
865
+ socketOwners = /* @__PURE__ */ new WeakMap();
806
866
  host;
807
867
  port;
808
868
  token;
@@ -825,22 +885,23 @@ var HubServer = class {
825
885
  this.initTelegram(fullConfig.telegram);
826
886
  }
827
887
  this.httpServer = http.createServer((req, res) => this.handleHttp(req, res));
828
- this.wssChannel = new WebSocketServer({ noServer: true });
888
+ this.wssChannel = new WebSocketServer({ noServer: true, maxPayload: MAX_WS_PAYLOAD });
829
889
  this.wssChannel.on("connection", (ws, req) => this.handleChannelConnection(ws, req));
830
890
  this.wssChannel.on("error", (err) => logger.warn(`Channel WebSocket server error: ${err.message}`));
831
- this.wssDashboard = new WebSocketServer({ noServer: true });
891
+ this.wssDashboard = new WebSocketServer({ noServer: true, maxPayload: MAX_WS_PAYLOAD });
832
892
  this.wssDashboard.on("connection", (ws) => this.handleDashboardConnection(ws));
833
893
  this.wssDashboard.on("error", (err) => logger.warn(`Dashboard WebSocket server error: ${err.message}`));
834
894
  this.httpServer.on("upgrade", (req, socket, head) => {
835
895
  socket.on("error", (err) => logger.debug(`Upgrade socket error: ${err.message}`));
836
- const url = new URL(req.url, `http://${req.headers.host}`);
896
+ const url = new URL(req.url ?? "/", "http://hub");
837
897
  const pathname = url.pathname;
838
- if (this.token && !this.isLocalRequest(req)) {
839
- const wsToken = url.searchParams.get("token");
840
- if (wsToken !== this.token) {
841
- socket.destroy();
842
- return;
843
- }
898
+ if (isCrossOrigin(req)) {
899
+ this.rejectUpgrade(socket, 403, "Forbidden", pathname, req);
900
+ return;
901
+ }
902
+ if (!this.authorized(req, true)) {
903
+ this.rejectUpgrade(socket, 401, "Unauthorized", pathname, req);
904
+ return;
844
905
  }
845
906
  if (pathname === WS_PATH_CHANNEL) {
846
907
  this.wssChannel.handleUpgrade(req, socket, head, (ws) => {
@@ -892,31 +953,28 @@ var HubServer = class {
892
953
  }
893
954
  // --- HTTP Handler ---
894
955
  handleHttp(req, res) {
895
- const url = new URL(req.url, `http://${req.headers.host}`);
896
- const origin = req.headers.origin;
897
- if (origin && (origin.includes("127.0.0.1") || origin.includes("localhost"))) {
898
- res.setHeader("Access-Control-Allow-Origin", origin);
899
- }
900
- res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
901
- res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
902
- if (req.method === "OPTIONS") {
903
- res.writeHead(204);
904
- res.end();
956
+ const url = new URL(req.url ?? "/", "http://hub");
957
+ if (isCrossOrigin(req)) {
958
+ this.jsonResponse(res, 403, { error: "Cross-origin request rejected" });
905
959
  return;
906
960
  }
907
- if (url.pathname !== "/" && this.token) {
908
- if (!this.isLocalRequest(req)) {
909
- const authHeader = req.headers["authorization"];
910
- const bearerToken = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null;
911
- if (bearerToken !== this.token) {
912
- this.jsonResponse(res, 401, { error: "Unauthorized" });
913
- return;
914
- }
915
- }
961
+ if (req.method === "POST" && !isJsonRequest(req)) {
962
+ this.jsonResponse(res, 415, { error: "Content-Type must be application/json" });
963
+ return;
916
964
  }
917
965
  if (url.pathname === "/" && req.method === "GET") {
918
- this.serveDashboard(res);
919
- } else if (url.pathname === "/api/sessions" && req.method === "GET") {
966
+ this.serveDashboard(req, res, url);
967
+ return;
968
+ }
969
+ if (url.pathname === "/api/login" && req.method === "POST") {
970
+ this.handleLogin(req, res);
971
+ return;
972
+ }
973
+ if (!this.authorized(req, false)) {
974
+ this.jsonResponse(res, 401, { error: "Unauthorized" });
975
+ return;
976
+ }
977
+ if (url.pathname === "/api/sessions" && req.method === "GET") {
920
978
  this.jsonResponse(res, 200, { sessions: this.sessions.getAll() });
921
979
  } else if (url.pathname === "/api/status" && req.method === "GET") {
922
980
  this.jsonResponse(res, 200, {
@@ -967,7 +1025,13 @@ var HubServer = class {
967
1025
  this.jsonResponse(res, 404, { error: "Not found" });
968
1026
  }
969
1027
  }
970
- serveDashboard(res) {
1028
+ serveDashboard(req, res, url) {
1029
+ const linkToken = url.searchParams.get("token");
1030
+ if (linkToken !== null && this.token && safeEqual(linkToken, this.token)) {
1031
+ res.writeHead(302, { Location: "/", "Set-Cookie": sessionCookieHeader(this.token, isSecureRequest(req)) });
1032
+ res.end();
1033
+ return;
1034
+ }
971
1035
  const candidates = [
972
1036
  path4.join(__dirname, "..", "dashboard", "index.html"),
973
1037
  // from dist/hub/
@@ -994,6 +1058,20 @@ var HubServer = class {
994
1058
  res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
995
1059
  res.end("<html><body><h1>claude-alarm</h1><p>Dashboard HTML not found. Reinstall the package.</p></body></html>");
996
1060
  }
1061
+ async handleLogin(req, res) {
1062
+ const body = await this.readBody(req);
1063
+ if (!this.token) {
1064
+ res.writeHead(204);
1065
+ res.end();
1066
+ return;
1067
+ }
1068
+ if (typeof body?.token !== "string" || !safeEqual(body.token, this.token)) {
1069
+ this.jsonResponse(res, 401, { error: "Unauthorized" });
1070
+ return;
1071
+ }
1072
+ res.writeHead(204, { "Set-Cookie": sessionCookieHeader(this.token, isSecureRequest(req)) });
1073
+ res.end();
1074
+ }
997
1075
  async handleApiSend(req, res) {
998
1076
  const body = await this.readBody(req);
999
1077
  if (!body) {
@@ -1070,6 +1148,22 @@ var HubServer = class {
1070
1148
  });
1071
1149
  }
1072
1150
  handleChannelMessage(ws, isLocal, msg) {
1151
+ if (msg.type === "register") {
1152
+ const id = msg.session.id;
1153
+ const owned = this.socketOwners.get(ws);
1154
+ if (owned && owned !== id) {
1155
+ logger.warn(`Rejected register for ${id}: connection already owns ${owned}`);
1156
+ return;
1157
+ }
1158
+ const holder = this.channelSockets.get(id);
1159
+ if (holder && holder !== ws) {
1160
+ this.socketOwners.delete(holder);
1161
+ holder.terminate();
1162
+ }
1163
+ this.socketOwners.set(ws, id);
1164
+ } else if ("sessionId" in msg && this.socketOwners.get(ws) !== msg.sessionId) {
1165
+ return;
1166
+ }
1073
1167
  switch (msg.type) {
1074
1168
  case "register": {
1075
1169
  const session = msg.session;
@@ -1093,7 +1187,6 @@ var HubServer = class {
1093
1187
  break;
1094
1188
  }
1095
1189
  case "peer_name": {
1096
- if (this.channelSockets.get(msg.sessionId) !== ws) break;
1097
1190
  const updated = this.sessions.setPeerName(msg.sessionId, msg.peerName);
1098
1191
  if (updated) {
1099
1192
  logger.info(`Peer name for ${msg.sessionId}: ${msg.peerName ?? "-"}`);
@@ -1421,6 +1514,18 @@ var HubServer = class {
1421
1514
  res.writeHead(status, { "Content-Type": "application/json" });
1422
1515
  res.end(JSON.stringify(body));
1423
1516
  }
1517
+ authorized(req, allowQueryToken) {
1518
+ return !this.token || isAuthorized(req, this.token, allowQueryToken);
1519
+ }
1520
+ rejectUpgrade(socket, status, text, pathname, req) {
1521
+ logger.warn(`Rejected ${pathname} upgrade (${status}) from ${req.socket.remoteAddress}`);
1522
+ socket.write(`HTTP/1.1 ${status} ${text}\r
1523
+ Connection: close\r
1524
+ Content-Length: 0\r
1525
+ \r
1526
+ `);
1527
+ socket.destroy();
1528
+ }
1424
1529
  isLocalRequest(req) {
1425
1530
  const addr = req.socket.remoteAddress;
1426
1531
  return addr === "127.0.0.1" || addr === "::1" || addr === "::ffff:127.0.0.1";