@delt/claude-alarm 0.8.2 → 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
@@ -41,6 +41,12 @@ var WS_PATH_DASHBOARD = "/ws/dashboard";
41
41
  var CHANNEL_SERVER_NAME = "claude-alarm";
42
42
  var CHANNEL_SERVER_VERSION = "0.1.0";
43
43
 
44
+ // src/shared/peer-name.ts
45
+ var SAFE_PEER_NAME = /^[^\u0000-\u001f\u007f"\\[\]]{1,128}$/;
46
+ function isValidPeerName(name) {
47
+ return typeof name === "string" && name.trim() !== "" && SAFE_PEER_NAME.test(name);
48
+ }
49
+
44
50
  // src/hub/session-manager.ts
45
51
  var SessionManager = class {
46
52
  sessions = /* @__PURE__ */ new Map();
@@ -64,7 +70,7 @@ var SessionManager = class {
64
70
  } else {
65
71
  session.displayName = baseName;
66
72
  }
67
- this.sessions.set(session.id, { ...session });
73
+ this.sessions.set(session.id, { ...session, peerName: isValidPeerName(session.peerName) ? session.peerName : void 0 });
68
74
  }
69
75
  unregister(sessionId) {
70
76
  const session = this.sessions.get(sessionId);
@@ -79,6 +85,13 @@ var SessionManager = class {
79
85
  }
80
86
  return session;
81
87
  }
88
+ setPeerName(sessionId, peerName) {
89
+ const session = this.sessions.get(sessionId);
90
+ if (session) {
91
+ session.peerName = isValidPeerName(peerName) ? peerName : void 0;
92
+ }
93
+ return session;
94
+ }
82
95
  updateActivity(sessionId) {
83
96
  const session = this.sessions.get(sessionId);
84
97
  if (session) {
@@ -508,6 +521,7 @@ ${previewHtml}${truncNote}`;
508
521
  }
509
522
  async handleCallbackQuery(query) {
510
523
  if (!query.data) return;
524
+ if (String(query.message?.chat.id) !== String(this.config.chatId)) return;
511
525
  if (query.data.startsWith("sess:")) {
512
526
  await this.handleSessionSelectCallback(query);
513
527
  return;
@@ -771,8 +785,66 @@ function logStartup(context) {
771
785
  `);
772
786
  }
773
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
+
774
845
  // src/hub/server.ts
775
846
  var __dirname = path4.dirname(fileURLToPath(import.meta.url));
847
+ var MAX_WS_PAYLOAD = 16 * 1024 * 1024;
776
848
  var HubServer = class {
777
849
  httpServer;
778
850
  wssChannel;
@@ -790,6 +862,7 @@ var HubServer = class {
790
862
  heartbeatInterval;
791
863
  channelAlive = /* @__PURE__ */ new Map();
792
864
  // sessionId -> alive flag
865
+ socketOwners = /* @__PURE__ */ new WeakMap();
793
866
  host;
794
867
  port;
795
868
  token;
@@ -812,22 +885,23 @@ var HubServer = class {
812
885
  this.initTelegram(fullConfig.telegram);
813
886
  }
814
887
  this.httpServer = http.createServer((req, res) => this.handleHttp(req, res));
815
- this.wssChannel = new WebSocketServer({ noServer: true });
888
+ this.wssChannel = new WebSocketServer({ noServer: true, maxPayload: MAX_WS_PAYLOAD });
816
889
  this.wssChannel.on("connection", (ws, req) => this.handleChannelConnection(ws, req));
817
890
  this.wssChannel.on("error", (err) => logger.warn(`Channel WebSocket server error: ${err.message}`));
818
- this.wssDashboard = new WebSocketServer({ noServer: true });
891
+ this.wssDashboard = new WebSocketServer({ noServer: true, maxPayload: MAX_WS_PAYLOAD });
819
892
  this.wssDashboard.on("connection", (ws) => this.handleDashboardConnection(ws));
820
893
  this.wssDashboard.on("error", (err) => logger.warn(`Dashboard WebSocket server error: ${err.message}`));
821
894
  this.httpServer.on("upgrade", (req, socket, head) => {
822
895
  socket.on("error", (err) => logger.debug(`Upgrade socket error: ${err.message}`));
823
- const url = new URL(req.url, `http://${req.headers.host}`);
896
+ const url = new URL(req.url ?? "/", "http://hub");
824
897
  const pathname = url.pathname;
825
- if (this.token && !this.isLocalRequest(req)) {
826
- const wsToken = url.searchParams.get("token");
827
- if (wsToken !== this.token) {
828
- socket.destroy();
829
- return;
830
- }
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;
831
905
  }
832
906
  if (pathname === WS_PATH_CHANNEL) {
833
907
  this.wssChannel.handleUpgrade(req, socket, head, (ws) => {
@@ -879,31 +953,28 @@ var HubServer = class {
879
953
  }
880
954
  // --- HTTP Handler ---
881
955
  handleHttp(req, res) {
882
- const url = new URL(req.url, `http://${req.headers.host}`);
883
- const origin = req.headers.origin;
884
- if (origin && (origin.includes("127.0.0.1") || origin.includes("localhost"))) {
885
- res.setHeader("Access-Control-Allow-Origin", origin);
886
- }
887
- res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
888
- res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
889
- if (req.method === "OPTIONS") {
890
- res.writeHead(204);
891
- 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" });
892
959
  return;
893
960
  }
894
- if (url.pathname !== "/" && this.token) {
895
- if (!this.isLocalRequest(req)) {
896
- const authHeader = req.headers["authorization"];
897
- const bearerToken = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null;
898
- if (bearerToken !== this.token) {
899
- this.jsonResponse(res, 401, { error: "Unauthorized" });
900
- return;
901
- }
902
- }
961
+ if (req.method === "POST" && !isJsonRequest(req)) {
962
+ this.jsonResponse(res, 415, { error: "Content-Type must be application/json" });
963
+ return;
903
964
  }
904
965
  if (url.pathname === "/" && req.method === "GET") {
905
- this.serveDashboard(res);
906
- } 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") {
907
978
  this.jsonResponse(res, 200, { sessions: this.sessions.getAll() });
908
979
  } else if (url.pathname === "/api/status" && req.method === "GET") {
909
980
  this.jsonResponse(res, 200, {
@@ -954,7 +1025,13 @@ var HubServer = class {
954
1025
  this.jsonResponse(res, 404, { error: "Not found" });
955
1026
  }
956
1027
  }
957
- 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
+ }
958
1035
  const candidates = [
959
1036
  path4.join(__dirname, "..", "dashboard", "index.html"),
960
1037
  // from dist/hub/
@@ -981,6 +1058,20 @@ var HubServer = class {
981
1058
  res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
982
1059
  res.end("<html><body><h1>claude-alarm</h1><p>Dashboard HTML not found. Reinstall the package.</p></body></html>");
983
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
+ }
984
1075
  async handleApiSend(req, res) {
985
1076
  const body = await this.readBody(req);
986
1077
  if (!body) {
@@ -1057,6 +1148,22 @@ var HubServer = class {
1057
1148
  });
1058
1149
  }
1059
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
+ }
1060
1167
  switch (msg.type) {
1061
1168
  case "register": {
1062
1169
  const session = msg.session;
@@ -1065,7 +1172,7 @@ var HubServer = class {
1065
1172
  this.sessions.register(session);
1066
1173
  this.channelSockets.set(session.id, ws);
1067
1174
  if (isLocal) this.localChannels.add(session.id);
1068
- logger.info(`Session registered: ${session.id} (${session.name}, channel: ${session.channelEnabled ?? false})`);
1175
+ logger.info(`Session registered: ${session.id} (${session.name}, channel: ${session.channelEnabled ?? false}, peer: ${session.peerName ?? "-"})`);
1069
1176
  this.broadcastToDashboards({
1070
1177
  type: isReregister ? "session_updated" : "session_connected",
1071
1178
  session
@@ -1079,6 +1186,14 @@ var HubServer = class {
1079
1186
  }
1080
1187
  break;
1081
1188
  }
1189
+ case "peer_name": {
1190
+ const updated = this.sessions.setPeerName(msg.sessionId, msg.peerName);
1191
+ if (updated) {
1192
+ logger.info(`Peer name for ${msg.sessionId}: ${msg.peerName ?? "-"}`);
1193
+ this.broadcastToDashboards({ type: "session_updated", session: updated });
1194
+ }
1195
+ break;
1196
+ }
1082
1197
  case "notify": {
1083
1198
  this.sessions.updateActivity(msg.sessionId);
1084
1199
  const notifySession = this.sessions.get(msg.sessionId);
@@ -1399,6 +1514,18 @@ var HubServer = class {
1399
1514
  res.writeHead(status, { "Content-Type": "application/json" });
1400
1515
  res.end(JSON.stringify(body));
1401
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
+ }
1402
1529
  isLocalRequest(req) {
1403
1530
  const addr = req.socket.remoteAddress;
1404
1531
  return addr === "127.0.0.1" || addr === "::1" || addr === "::ffff:127.0.0.1";
@@ -1445,12 +1572,13 @@ if (process.argv[1] && (process.argv[1].endsWith("hub/server.js") || process.arg
1445
1572
  // src/channel/hub-client.ts
1446
1573
  import WebSocket2 from "ws";
1447
1574
  var HubClient = class {
1448
- constructor(sessionId, sessionName, hubHost = DEFAULT_HUB_HOST, hubPort = DEFAULT_HUB_PORT, token) {
1575
+ constructor(sessionId, sessionName, hubHost = DEFAULT_HUB_HOST, hubPort = DEFAULT_HUB_PORT, token, getPeerName = () => void 0) {
1449
1576
  this.sessionId = sessionId;
1450
1577
  this.sessionName = sessionName;
1451
1578
  this.hubHost = hubHost;
1452
1579
  this.hubPort = hubPort;
1453
1580
  this.token = token;
1581
+ this.getPeerName = getPeerName;
1454
1582
  }
1455
1583
  ws = null;
1456
1584
  reconnectTimer = null;
@@ -1475,7 +1603,8 @@ var HubClient = class {
1475
1603
  connectedAt: Date.now(),
1476
1604
  lastActivity: Date.now(),
1477
1605
  cwd: process.cwd(),
1478
- channelEnabled: true
1606
+ channelEnabled: true,
1607
+ peerName: this.getPeerName()
1479
1608
  }
1480
1609
  };
1481
1610
  this.ws.send(JSON.stringify(registration));