@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.
@@ -871,7 +871,7 @@
871
871
  <div class="token-overlay hidden" id="tokenOverlay">
872
872
  <div class="token-form">
873
873
  <h2>Authentication Required</h2>
874
- <p>Enter the hub token to connect. Find it by running: <code>claude-alarm token</code></p>
874
+ <p>Enter the hub token to connect. Run <code>claude-alarm token</code>, or open the login link printed by <code>claude-alarm hub start</code>.</p>
875
875
  <input type="text" id="tokenInput" placeholder="Paste token here..." autocomplete="off">
876
876
  <button id="tokenSubmit">Connect</button>
877
877
  <div class="token-error" id="tokenError">Connection failed. Check your token.</div>
@@ -1026,7 +1026,6 @@
1026
1026
  selectedSession: null,
1027
1027
  messages: {},
1028
1028
  notifications: [],
1029
- token: null,
1030
1029
  pendingImage: null,
1031
1030
  unread: {},
1032
1031
  waitingReply: {},
@@ -1291,25 +1290,28 @@
1291
1290
  initTheme();
1292
1291
 
1293
1292
  // --- Token handling ---
1294
- function getToken() {
1295
- const params = new URLSearchParams(location.search);
1296
- const urlToken = params.get('token');
1297
- if (urlToken) {
1298
- sessionStorage.setItem('claude-alarm-token', urlToken);
1299
- return urlToken;
1293
+ async function checkAuth() {
1294
+ try {
1295
+ return (await fetch('/api/status')).status !== 401;
1296
+ } catch {
1297
+ return true;
1300
1298
  }
1301
- return sessionStorage.getItem('claude-alarm-token');
1302
1299
  }
1303
1300
 
1304
1301
  function showTokenForm() { $('#tokenOverlay').classList.remove('hidden'); }
1305
1302
  function hideTokenForm() { $('#tokenOverlay').classList.add('hidden'); }
1306
1303
 
1307
- $('#tokenSubmit').addEventListener('click', () => {
1304
+ $('#tokenSubmit').addEventListener('click', async () => {
1308
1305
  const token = $('#tokenInput').value.trim();
1309
1306
  if (!token) return;
1310
- state.token = token;
1311
- sessionStorage.setItem('claude-alarm-token', token);
1307
+ const res = await fetch('/api/login', {
1308
+ method: 'POST',
1309
+ headers: { 'Content-Type': 'application/json' },
1310
+ body: JSON.stringify({ token }),
1311
+ }).catch(() => null);
1312
+ if (!res || !res.ok) { $('#tokenError').style.display = 'block'; return; }
1312
1313
  $('#tokenError').style.display = 'none';
1314
+ $('#tokenInput').value = '';
1313
1315
  hideTokenForm();
1314
1316
  connect();
1315
1317
  });
@@ -1320,25 +1322,23 @@
1320
1322
  // --- WebSocket ---
1321
1323
  function connect() {
1322
1324
  const proto = location.protocol === 'https:' ? 'wss' : 'ws';
1323
- const tokenQuery = state.token ? `?token=${encodeURIComponent(state.token)}` : '';
1324
- const ws = new WebSocket(`${proto}://${location.host}/ws/dashboard${tokenQuery}`);
1325
+ const ws = new WebSocket(`${proto}://${location.host}/ws/dashboard`);
1326
+ let opened = false;
1325
1327
 
1326
1328
  ws.onopen = () => {
1329
+ opened = true;
1327
1330
  state.ws = ws;
1328
1331
  hideTokenForm();
1329
1332
  $('#connDot').classList.add('connected');
1330
1333
  $('#connLabel').textContent = 'Connected';
1331
1334
  };
1332
- ws.onclose = () => {
1335
+ ws.onclose = async () => {
1333
1336
  state.ws = null;
1334
1337
  $('#connDot').classList.remove('connected');
1335
1338
  $('#connLabel').textContent = 'Disconnected';
1339
+ if (!opened && !(await checkAuth())) { showTokenForm(); return; }
1336
1340
  setTimeout(connect, 3000);
1337
1341
  };
1338
- ws.onerror = () => {
1339
- if (!state.token) { showTokenForm(); } else { $('#tokenError').style.display = 'block'; showTokenForm(); }
1340
- ws.close();
1341
- };
1342
1342
  ws.onmessage = (e) => { try { handleMessage(JSON.parse(e.data)); } catch {} };
1343
1343
  }
1344
1344
 
@@ -2128,16 +2128,14 @@
2128
2128
  $('#settingsToggle').addEventListener('click', async () => {
2129
2129
  // Load webhook data
2130
2130
  try {
2131
- const tokenQuery = state.token ? `?token=${encodeURIComponent(state.token)}` : '';
2132
- const res = await fetch(`/api/webhooks${tokenQuery}`);
2131
+ const res = await fetch(`/api/webhooks`);
2133
2132
  const data = await res.json();
2134
2133
  webhookData = data.webhooks || [];
2135
2134
  } catch { webhookData = []; }
2136
2135
  renderWebhooks();
2137
2136
  // Load telegram data
2138
2137
  try {
2139
- const tokenQuery = state.token ? `?token=${encodeURIComponent(state.token)}` : '';
2140
- const res = await fetch(`/api/telegram${tokenQuery}`);
2138
+ const res = await fetch(`/api/telegram`);
2141
2139
  const data = await res.json();
2142
2140
  const tg = data.telegram || {};
2143
2141
  if (!$('#tgBotToken').value || $('#tgBotToken').value.includes('...')) {
@@ -2172,8 +2170,7 @@
2172
2170
  $('#webhookSave').addEventListener('click', async () => {
2173
2171
  const valid = webhookData.filter(w => w.url.trim());
2174
2172
  try {
2175
- const tokenQuery = state.token ? `?token=${encodeURIComponent(state.token)}` : '';
2176
- await fetch(`/api/webhooks${tokenQuery}`, {
2173
+ await fetch(`/api/webhooks`, {
2177
2174
  method: 'POST',
2178
2175
  headers: { 'Content-Type': 'application/json' },
2179
2176
  body: JSON.stringify({ webhooks: valid }),
@@ -2248,8 +2245,7 @@
2248
2245
  $('#tgDetectStatus').textContent = 'Detecting...';
2249
2246
  $('#tgChatList').innerHTML = '';
2250
2247
  try {
2251
- const tokenQuery = state.token ? `?token=${encodeURIComponent(state.token)}` : '';
2252
- const res = await fetch(`/api/telegram/detect${tokenQuery}`, {
2248
+ const res = await fetch(`/api/telegram/detect`, {
2253
2249
  method: 'POST',
2254
2250
  headers: { 'Content-Type': 'application/json' },
2255
2251
  body: JSON.stringify({ botToken }),
@@ -2286,8 +2282,7 @@
2286
2282
  const chatId = $('#tgChatId').value.trim();
2287
2283
  if (!botToken || !chatId) { showTgStatus('Bot Token and Chat ID are required.', false); return; }
2288
2284
  try {
2289
- const tokenQuery = state.token ? `?token=${encodeURIComponent(state.token)}` : '';
2290
- const res = await fetch(`/api/telegram/test${tokenQuery}`, {
2285
+ const res = await fetch(`/api/telegram/test`, {
2291
2286
  method: 'POST',
2292
2287
  headers: { 'Content-Type': 'application/json' },
2293
2288
  body: JSON.stringify({ botToken, chatId }),
@@ -2306,8 +2301,7 @@
2306
2301
  if (!botToken || !chatId) { showTgStatus('Bot Token and Chat ID are required.', false); return; }
2307
2302
  if (botToken.includes('...')) { showTgStatus('Bot Token is masked. Please enter the full token.', false); return; }
2308
2303
  try {
2309
- const tokenQuery = state.token ? `?token=${encodeURIComponent(state.token)}` : '';
2310
- await fetch(`/api/telegram${tokenQuery}`, {
2304
+ await fetch(`/api/telegram`, {
2311
2305
  method: 'POST',
2312
2306
  headers: { 'Content-Type': 'application/json' },
2313
2307
  body: JSON.stringify({ telegram: { botToken, chatId, enabled } }),
@@ -2335,8 +2329,7 @@
2335
2329
  document.querySelector('.sessions-panel').classList.add('mobile-active');
2336
2330
  }
2337
2331
 
2338
- state.token = getToken();
2339
- connect();
2332
+ checkAuth().then((ok) => (ok ? connect() : showTokenForm()));
2340
2333
  })();
2341
2334
  </script>
2342
2335
  </body>
@@ -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;
@@ -760,8 +761,66 @@ function logStartup(context) {
760
761
  `);
761
762
  }
762
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
+
763
821
  // src/hub/server.ts
764
822
  var __dirname = path4.dirname(fileURLToPath(import.meta.url));
823
+ var MAX_WS_PAYLOAD = 16 * 1024 * 1024;
765
824
  var HubServer = class {
766
825
  httpServer;
767
826
  wssChannel;
@@ -779,6 +838,7 @@ var HubServer = class {
779
838
  heartbeatInterval;
780
839
  channelAlive = /* @__PURE__ */ new Map();
781
840
  // sessionId -> alive flag
841
+ socketOwners = /* @__PURE__ */ new WeakMap();
782
842
  host;
783
843
  port;
784
844
  token;
@@ -801,22 +861,23 @@ var HubServer = class {
801
861
  this.initTelegram(fullConfig.telegram);
802
862
  }
803
863
  this.httpServer = http.createServer((req, res) => this.handleHttp(req, res));
804
- this.wssChannel = new WebSocketServer({ noServer: true });
864
+ this.wssChannel = new WebSocketServer({ noServer: true, maxPayload: MAX_WS_PAYLOAD });
805
865
  this.wssChannel.on("connection", (ws, req) => this.handleChannelConnection(ws, req));
806
866
  this.wssChannel.on("error", (err) => logger.warn(`Channel WebSocket server error: ${err.message}`));
807
- this.wssDashboard = new WebSocketServer({ noServer: true });
867
+ this.wssDashboard = new WebSocketServer({ noServer: true, maxPayload: MAX_WS_PAYLOAD });
808
868
  this.wssDashboard.on("connection", (ws) => this.handleDashboardConnection(ws));
809
869
  this.wssDashboard.on("error", (err) => logger.warn(`Dashboard WebSocket server error: ${err.message}`));
810
870
  this.httpServer.on("upgrade", (req, socket, head) => {
811
871
  socket.on("error", (err) => logger.debug(`Upgrade socket error: ${err.message}`));
812
- const url = new URL(req.url, `http://${req.headers.host}`);
872
+ const url = new URL(req.url ?? "/", "http://hub");
813
873
  const pathname = url.pathname;
814
- if (this.token && !this.isLocalRequest(req)) {
815
- const wsToken = url.searchParams.get("token");
816
- if (wsToken !== this.token) {
817
- socket.destroy();
818
- return;
819
- }
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;
820
881
  }
821
882
  if (pathname === WS_PATH_CHANNEL) {
822
883
  this.wssChannel.handleUpgrade(req, socket, head, (ws) => {
@@ -868,31 +929,28 @@ var HubServer = class {
868
929
  }
869
930
  // --- HTTP Handler ---
870
931
  handleHttp(req, res) {
871
- const url = new URL(req.url, `http://${req.headers.host}`);
872
- const origin = req.headers.origin;
873
- if (origin && (origin.includes("127.0.0.1") || origin.includes("localhost"))) {
874
- res.setHeader("Access-Control-Allow-Origin", origin);
875
- }
876
- res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
877
- res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
878
- if (req.method === "OPTIONS") {
879
- res.writeHead(204);
880
- 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" });
881
935
  return;
882
936
  }
883
- if (url.pathname !== "/" && this.token) {
884
- if (!this.isLocalRequest(req)) {
885
- const authHeader = req.headers["authorization"];
886
- const bearerToken = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null;
887
- if (bearerToken !== this.token) {
888
- this.jsonResponse(res, 401, { error: "Unauthorized" });
889
- return;
890
- }
891
- }
937
+ if (req.method === "POST" && !isJsonRequest(req)) {
938
+ this.jsonResponse(res, 415, { error: "Content-Type must be application/json" });
939
+ return;
892
940
  }
893
941
  if (url.pathname === "/" && req.method === "GET") {
894
- this.serveDashboard(res);
895
- } 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") {
896
954
  this.jsonResponse(res, 200, { sessions: this.sessions.getAll() });
897
955
  } else if (url.pathname === "/api/status" && req.method === "GET") {
898
956
  this.jsonResponse(res, 200, {
@@ -943,7 +1001,13 @@ var HubServer = class {
943
1001
  this.jsonResponse(res, 404, { error: "Not found" });
944
1002
  }
945
1003
  }
946
- 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
+ }
947
1011
  const candidates = [
948
1012
  path4.join(__dirname, "..", "dashboard", "index.html"),
949
1013
  // from dist/hub/
@@ -970,6 +1034,20 @@ var HubServer = class {
970
1034
  res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
971
1035
  res.end("<html><body><h1>claude-alarm</h1><p>Dashboard HTML not found. Reinstall the package.</p></body></html>");
972
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
+ }
973
1051
  async handleApiSend(req, res) {
974
1052
  const body = await this.readBody(req);
975
1053
  if (!body) {
@@ -1046,6 +1124,22 @@ var HubServer = class {
1046
1124
  });
1047
1125
  }
1048
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
+ }
1049
1143
  switch (msg.type) {
1050
1144
  case "register": {
1051
1145
  const session = msg.session;
@@ -1069,7 +1163,6 @@ var HubServer = class {
1069
1163
  break;
1070
1164
  }
1071
1165
  case "peer_name": {
1072
- if (this.channelSockets.get(msg.sessionId) !== ws) break;
1073
1166
  const updated = this.sessions.setPeerName(msg.sessionId, msg.peerName);
1074
1167
  if (updated) {
1075
1168
  logger.info(`Peer name for ${msg.sessionId}: ${msg.peerName ?? "-"}`);
@@ -1397,6 +1490,18 @@ var HubServer = class {
1397
1490
  res.writeHead(status, { "Content-Type": "application/json" });
1398
1491
  res.end(JSON.stringify(body));
1399
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
+ }
1400
1505
  isLocalRequest(req) {
1401
1506
  const addr = req.socket.remoteAddress;
1402
1507
  return addr === "127.0.0.1" || addr === "::1" || addr === "::ffff:127.0.0.1";