@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.
@@ -41,6 +41,12 @@ var UPLOADS_DIR = path.join(CONFIG_DIR, "uploads");
41
41
  var WS_PATH_CHANNEL = "/ws/channel";
42
42
  var WS_PATH_DASHBOARD = "/ws/dashboard";
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;
@@ -747,8 +761,66 @@ function logStartup(context) {
747
761
  `);
748
762
  }
749
763
 
764
+ // src/hub/auth.ts
765
+ import crypto from "crypto";
766
+ var SESSION_COOKIE = "ca_session";
767
+ var COOKIE_MAX_AGE = 30 * 24 * 60 * 60;
768
+ function safeEqual(a, b) {
769
+ if (typeof a !== "string") return false;
770
+ const x = Buffer.from(a);
771
+ const y = Buffer.from(b);
772
+ return x.length === y.length && crypto.timingSafeEqual(x, y);
773
+ }
774
+ function dashboardCookieValue(token) {
775
+ return crypto.createHmac("sha256", token).update("claude-alarm-dashboard").digest("hex");
776
+ }
777
+ function parseCookies(header) {
778
+ const out = {};
779
+ for (const part of (header ?? "").split(";")) {
780
+ const i = part.indexOf("=");
781
+ if (i <= 0) continue;
782
+ const raw = part.slice(i + 1).trim();
783
+ let value = raw;
784
+ try {
785
+ value = decodeURIComponent(raw);
786
+ } catch {
787
+ }
788
+ out[part.slice(0, i).trim()] = value;
789
+ }
790
+ return out;
791
+ }
792
+ function sessionCookieHeader(token, secure) {
793
+ return `${SESSION_COOKIE}=${dashboardCookieValue(token)}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${COOKIE_MAX_AGE}${secure ? "; Secure" : ""}`;
794
+ }
795
+ function isAuthorized(req, token, allowQueryToken) {
796
+ const auth = req.headers.authorization;
797
+ if (auth?.startsWith("Bearer ") && safeEqual(auth.slice(7), token)) return true;
798
+ if (safeEqual(parseCookies(req.headers.cookie)[SESSION_COOKIE], dashboardCookieValue(token))) return true;
799
+ if (allowQueryToken) {
800
+ const q = new URL(req.url ?? "/", "http://hub").searchParams.get("token");
801
+ if (safeEqual(q, token)) return true;
802
+ }
803
+ return false;
804
+ }
805
+ function isCrossOrigin(req) {
806
+ const origin = req.headers.origin;
807
+ if (!origin) return false;
808
+ try {
809
+ return new URL(origin).host !== req.headers.host;
810
+ } catch {
811
+ return true;
812
+ }
813
+ }
814
+ function isJsonRequest(req) {
815
+ return String(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json");
816
+ }
817
+ function isSecureRequest(req) {
818
+ return req.headers["x-forwarded-proto"] === "https";
819
+ }
820
+
750
821
  // src/hub/server.ts
751
822
  var __dirname = path4.dirname(fileURLToPath(import.meta.url));
823
+ var MAX_WS_PAYLOAD = 16 * 1024 * 1024;
752
824
  var HubServer = class {
753
825
  httpServer;
754
826
  wssChannel;
@@ -766,6 +838,7 @@ var HubServer = class {
766
838
  heartbeatInterval;
767
839
  channelAlive = /* @__PURE__ */ new Map();
768
840
  // sessionId -> alive flag
841
+ socketOwners = /* @__PURE__ */ new WeakMap();
769
842
  host;
770
843
  port;
771
844
  token;
@@ -788,22 +861,23 @@ var HubServer = class {
788
861
  this.initTelegram(fullConfig.telegram);
789
862
  }
790
863
  this.httpServer = http.createServer((req, res) => this.handleHttp(req, res));
791
- this.wssChannel = new WebSocketServer({ noServer: true });
864
+ this.wssChannel = new WebSocketServer({ noServer: true, maxPayload: MAX_WS_PAYLOAD });
792
865
  this.wssChannel.on("connection", (ws, req) => this.handleChannelConnection(ws, req));
793
866
  this.wssChannel.on("error", (err) => logger.warn(`Channel WebSocket server error: ${err.message}`));
794
- this.wssDashboard = new WebSocketServer({ noServer: true });
867
+ this.wssDashboard = new WebSocketServer({ noServer: true, maxPayload: MAX_WS_PAYLOAD });
795
868
  this.wssDashboard.on("connection", (ws) => this.handleDashboardConnection(ws));
796
869
  this.wssDashboard.on("error", (err) => logger.warn(`Dashboard WebSocket server error: ${err.message}`));
797
870
  this.httpServer.on("upgrade", (req, socket, head) => {
798
871
  socket.on("error", (err) => logger.debug(`Upgrade socket error: ${err.message}`));
799
- const url = new URL(req.url, `http://${req.headers.host}`);
872
+ const url = new URL(req.url ?? "/", "http://hub");
800
873
  const pathname = url.pathname;
801
- if (this.token && !this.isLocalRequest(req)) {
802
- const wsToken = url.searchParams.get("token");
803
- if (wsToken !== this.token) {
804
- socket.destroy();
805
- return;
806
- }
874
+ if (isCrossOrigin(req)) {
875
+ this.rejectUpgrade(socket, 403, "Forbidden", pathname, req);
876
+ return;
877
+ }
878
+ if (!this.authorized(req, true)) {
879
+ this.rejectUpgrade(socket, 401, "Unauthorized", pathname, req);
880
+ return;
807
881
  }
808
882
  if (pathname === WS_PATH_CHANNEL) {
809
883
  this.wssChannel.handleUpgrade(req, socket, head, (ws) => {
@@ -855,31 +929,28 @@ var HubServer = class {
855
929
  }
856
930
  // --- HTTP Handler ---
857
931
  handleHttp(req, res) {
858
- const url = new URL(req.url, `http://${req.headers.host}`);
859
- const origin = req.headers.origin;
860
- if (origin && (origin.includes("127.0.0.1") || origin.includes("localhost"))) {
861
- res.setHeader("Access-Control-Allow-Origin", origin);
862
- }
863
- res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
864
- res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
865
- if (req.method === "OPTIONS") {
866
- res.writeHead(204);
867
- res.end();
932
+ const url = new URL(req.url ?? "/", "http://hub");
933
+ if (isCrossOrigin(req)) {
934
+ this.jsonResponse(res, 403, { error: "Cross-origin request rejected" });
868
935
  return;
869
936
  }
870
- if (url.pathname !== "/" && this.token) {
871
- if (!this.isLocalRequest(req)) {
872
- const authHeader = req.headers["authorization"];
873
- const bearerToken = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null;
874
- if (bearerToken !== this.token) {
875
- this.jsonResponse(res, 401, { error: "Unauthorized" });
876
- return;
877
- }
878
- }
937
+ if (req.method === "POST" && !isJsonRequest(req)) {
938
+ this.jsonResponse(res, 415, { error: "Content-Type must be application/json" });
939
+ return;
879
940
  }
880
941
  if (url.pathname === "/" && req.method === "GET") {
881
- this.serveDashboard(res);
882
- } else if (url.pathname === "/api/sessions" && req.method === "GET") {
942
+ this.serveDashboard(req, res, url);
943
+ return;
944
+ }
945
+ if (url.pathname === "/api/login" && req.method === "POST") {
946
+ this.handleLogin(req, res);
947
+ return;
948
+ }
949
+ if (!this.authorized(req, false)) {
950
+ this.jsonResponse(res, 401, { error: "Unauthorized" });
951
+ return;
952
+ }
953
+ if (url.pathname === "/api/sessions" && req.method === "GET") {
883
954
  this.jsonResponse(res, 200, { sessions: this.sessions.getAll() });
884
955
  } else if (url.pathname === "/api/status" && req.method === "GET") {
885
956
  this.jsonResponse(res, 200, {
@@ -930,7 +1001,13 @@ var HubServer = class {
930
1001
  this.jsonResponse(res, 404, { error: "Not found" });
931
1002
  }
932
1003
  }
933
- serveDashboard(res) {
1004
+ serveDashboard(req, res, url) {
1005
+ const linkToken = url.searchParams.get("token");
1006
+ if (linkToken !== null && this.token && safeEqual(linkToken, this.token)) {
1007
+ res.writeHead(302, { Location: "/", "Set-Cookie": sessionCookieHeader(this.token, isSecureRequest(req)) });
1008
+ res.end();
1009
+ return;
1010
+ }
934
1011
  const candidates = [
935
1012
  path4.join(__dirname, "..", "dashboard", "index.html"),
936
1013
  // from dist/hub/
@@ -957,6 +1034,20 @@ var HubServer = class {
957
1034
  res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
958
1035
  res.end("<html><body><h1>claude-alarm</h1><p>Dashboard HTML not found. Reinstall the package.</p></body></html>");
959
1036
  }
1037
+ async handleLogin(req, res) {
1038
+ const body = await this.readBody(req);
1039
+ if (!this.token) {
1040
+ res.writeHead(204);
1041
+ res.end();
1042
+ return;
1043
+ }
1044
+ if (typeof body?.token !== "string" || !safeEqual(body.token, this.token)) {
1045
+ this.jsonResponse(res, 401, { error: "Unauthorized" });
1046
+ return;
1047
+ }
1048
+ res.writeHead(204, { "Set-Cookie": sessionCookieHeader(this.token, isSecureRequest(req)) });
1049
+ res.end();
1050
+ }
960
1051
  async handleApiSend(req, res) {
961
1052
  const body = await this.readBody(req);
962
1053
  if (!body) {
@@ -1033,6 +1124,22 @@ var HubServer = class {
1033
1124
  });
1034
1125
  }
1035
1126
  handleChannelMessage(ws, isLocal, msg) {
1127
+ if (msg.type === "register") {
1128
+ const id = msg.session.id;
1129
+ const owned = this.socketOwners.get(ws);
1130
+ if (owned && owned !== id) {
1131
+ logger.warn(`Rejected register for ${id}: connection already owns ${owned}`);
1132
+ return;
1133
+ }
1134
+ const holder = this.channelSockets.get(id);
1135
+ if (holder && holder !== ws) {
1136
+ this.socketOwners.delete(holder);
1137
+ holder.terminate();
1138
+ }
1139
+ this.socketOwners.set(ws, id);
1140
+ } else if ("sessionId" in msg && this.socketOwners.get(ws) !== msg.sessionId) {
1141
+ return;
1142
+ }
1036
1143
  switch (msg.type) {
1037
1144
  case "register": {
1038
1145
  const session = msg.session;
@@ -1041,7 +1148,7 @@ var HubServer = class {
1041
1148
  this.sessions.register(session);
1042
1149
  this.channelSockets.set(session.id, ws);
1043
1150
  if (isLocal) this.localChannels.add(session.id);
1044
- logger.info(`Session registered: ${session.id} (${session.name}, channel: ${session.channelEnabled ?? false})`);
1151
+ logger.info(`Session registered: ${session.id} (${session.name}, channel: ${session.channelEnabled ?? false}, peer: ${session.peerName ?? "-"})`);
1045
1152
  this.broadcastToDashboards({
1046
1153
  type: isReregister ? "session_updated" : "session_connected",
1047
1154
  session
@@ -1055,6 +1162,14 @@ var HubServer = class {
1055
1162
  }
1056
1163
  break;
1057
1164
  }
1165
+ case "peer_name": {
1166
+ const updated = this.sessions.setPeerName(msg.sessionId, msg.peerName);
1167
+ if (updated) {
1168
+ logger.info(`Peer name for ${msg.sessionId}: ${msg.peerName ?? "-"}`);
1169
+ this.broadcastToDashboards({ type: "session_updated", session: updated });
1170
+ }
1171
+ break;
1172
+ }
1058
1173
  case "notify": {
1059
1174
  this.sessions.updateActivity(msg.sessionId);
1060
1175
  const notifySession = this.sessions.get(msg.sessionId);
@@ -1375,6 +1490,18 @@ var HubServer = class {
1375
1490
  res.writeHead(status, { "Content-Type": "application/json" });
1376
1491
  res.end(JSON.stringify(body));
1377
1492
  }
1493
+ authorized(req, allowQueryToken) {
1494
+ return !this.token || isAuthorized(req, this.token, allowQueryToken);
1495
+ }
1496
+ rejectUpgrade(socket, status, text, pathname, req) {
1497
+ logger.warn(`Rejected ${pathname} upgrade (${status}) from ${req.socket.remoteAddress}`);
1498
+ socket.write(`HTTP/1.1 ${status} ${text}\r
1499
+ Connection: close\r
1500
+ Content-Length: 0\r
1501
+ \r
1502
+ `);
1503
+ socket.destroy();
1504
+ }
1378
1505
  isLocalRequest(req) {
1379
1506
  const addr = req.socket.remoteAddress;
1380
1507
  return addr === "127.0.0.1" || addr === "::1" || addr === "::ffff:127.0.0.1";