@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/README.md CHANGED
@@ -179,15 +179,40 @@ When Claude wants to run a tool (Bash, Write, Edit, etc.), a permission request
179
179
  - Local terminal prompt stays open; whichever answer arrives first (local or dashboard) is applied
180
180
  - Parsed previews: Bash commands show `$ command`, file operations show file paths
181
181
 
182
+ ## Authentication
183
+
184
+ Every connection to the hub needs the hub token — including ones from the same machine.
185
+
186
+ - **Channel servers** read it from `~/.claude-alarm/config.json` automatically (or `CLAUDE_ALARM_HUB_TOKEN`).
187
+ - **Dashboard**: open the login link printed by `claude-alarm hub start` (`http://127.0.0.1:7900/?token=…`), or paste the token from `claude-alarm token` into the login form. The browser keeps an HttpOnly cookie for 30 days, and the token is removed from the URL.
188
+ - **CLI / scripts**: send `Authorization: Bearer <token>`.
189
+
190
+ > Upgrading from 0.9.x: a local session that sets `CLAUDE_ALARM_HUB_TOKEN` to a different value than the hub's token no longer connects. Scripts that called the local API without a token now get `401`.
191
+
182
192
  ## Remote Access
183
193
 
184
194
  <p align="center">
185
195
  <img src="https://raw.githubusercontent.com/delt96/delt-claude-alarm/main/docs/remote-access.svg" alt="Remote Access" width="600">
186
196
  </p>
187
197
 
188
- 1. Set host to `0.0.0.0` in `~/.claude-alarm/config.json`
189
- 2. Open port 7900 in your firewall
190
- 3. On remote machine: `claude-alarm init` → select remote hub (Y)
198
+ Pick one:
199
+
200
+ 1. **Tailscale (recommended)** — set `host` to the hub machine's Tailscale IP and use that address from other machines. Traffic is encrypted and never exposed to the internet.
201
+ 2. **Cloudflare Tunnel / nginx with HTTPS** — keep `host: "127.0.0.1"` and point the tunnel or proxy at `http://127.0.0.1:7900`. The proxy must keep the browser's `Host` header (the hub rejects requests whose `Origin` doesn't match it), pass WebSocket upgrades, and forward `X-Forwarded-Proto` so the dashboard cookie is marked `Secure`. For nginx:
202
+
203
+ ```nginx
204
+ location / {
205
+ proxy_pass http://127.0.0.1:7900;
206
+ proxy_http_version 1.1;
207
+ proxy_set_header Host $host;
208
+ proxy_set_header Upgrade $http_upgrade;
209
+ proxy_set_header Connection "upgrade";
210
+ proxy_set_header X-Forwarded-Proto $scheme;
211
+ }
212
+ ```
213
+ 3. **Direct `0.0.0.0`** — set `host` to `0.0.0.0` and open port 7900. Traffic, including the token, is plain HTTP: use only on networks you trust.
214
+
215
+ On the remote machine run `claude-alarm init` → select remote hub (Y), or configure:
191
216
 
192
217
  ```json
193
218
  {
@@ -196,7 +221,7 @@ When Claude wants to run a tool (Bash, Write, Edit, etc.), a permission request
196
221
  "command": "npx",
197
222
  "args": ["-y", "@delt/claude-alarm", "serve"],
198
223
  "env": {
199
- "CLAUDE_ALARM_HUB_HOST": "your-server-ip",
224
+ "CLAUDE_ALARM_HUB_HOST": "your-hub-address",
200
225
  "CLAUDE_ALARM_HUB_PORT": "7900",
201
226
  "CLAUDE_ALARM_HUB_TOKEN": "your-token"
202
227
  }
package/dist/cli.js CHANGED
@@ -696,6 +696,7 @@ ${previewHtml}${truncNote}`;
696
696
  }
697
697
  async handleCallbackQuery(query) {
698
698
  if (!query.data) return;
699
+ if (String(query.message?.chat.id) !== String(this.config.chatId)) return;
699
700
  if (query.data.startsWith("sess:")) {
700
701
  await this.handleSessionSelectCallback(query);
701
702
  return;
@@ -844,6 +845,69 @@ ${bodyLines.join("\n")}`;
844
845
  }
845
846
  });
846
847
 
848
+ // src/hub/auth.ts
849
+ import crypto from "crypto";
850
+ function safeEqual(a, b) {
851
+ if (typeof a !== "string") return false;
852
+ const x = Buffer.from(a);
853
+ const y = Buffer.from(b);
854
+ return x.length === y.length && crypto.timingSafeEqual(x, y);
855
+ }
856
+ function dashboardCookieValue(token) {
857
+ return crypto.createHmac("sha256", token).update("claude-alarm-dashboard").digest("hex");
858
+ }
859
+ function parseCookies(header) {
860
+ const out = {};
861
+ for (const part of (header ?? "").split(";")) {
862
+ const i = part.indexOf("=");
863
+ if (i <= 0) continue;
864
+ const raw = part.slice(i + 1).trim();
865
+ let value = raw;
866
+ try {
867
+ value = decodeURIComponent(raw);
868
+ } catch {
869
+ }
870
+ out[part.slice(0, i).trim()] = value;
871
+ }
872
+ return out;
873
+ }
874
+ function sessionCookieHeader(token, secure) {
875
+ return `${SESSION_COOKIE}=${dashboardCookieValue(token)}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${COOKIE_MAX_AGE}${secure ? "; Secure" : ""}`;
876
+ }
877
+ function isAuthorized(req, token, allowQueryToken) {
878
+ const auth = req.headers.authorization;
879
+ if (auth?.startsWith("Bearer ") && safeEqual(auth.slice(7), token)) return true;
880
+ if (safeEqual(parseCookies(req.headers.cookie)[SESSION_COOKIE], dashboardCookieValue(token))) return true;
881
+ if (allowQueryToken) {
882
+ const q = new URL(req.url ?? "/", "http://hub").searchParams.get("token");
883
+ if (safeEqual(q, token)) return true;
884
+ }
885
+ return false;
886
+ }
887
+ function isCrossOrigin(req) {
888
+ const origin = req.headers.origin;
889
+ if (!origin) return false;
890
+ try {
891
+ return new URL(origin).host !== req.headers.host;
892
+ } catch {
893
+ return true;
894
+ }
895
+ }
896
+ function isJsonRequest(req) {
897
+ return String(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json");
898
+ }
899
+ function isSecureRequest(req) {
900
+ return req.headers["x-forwarded-proto"] === "https";
901
+ }
902
+ var SESSION_COOKIE, COOKIE_MAX_AGE;
903
+ var init_auth = __esm({
904
+ "src/hub/auth.ts"() {
905
+ "use strict";
906
+ SESSION_COOKIE = "ca_session";
907
+ COOKIE_MAX_AGE = 30 * 24 * 60 * 60;
908
+ }
909
+ });
910
+
847
911
  // src/hub/server.ts
848
912
  var server_exports = {};
849
913
  __export(server_exports, {
@@ -855,7 +919,7 @@ import path4 from "path";
855
919
  import { fileURLToPath } from "url";
856
920
  import { WebSocketServer, WebSocket } from "ws";
857
921
  import { randomUUID as randomUUID3 } from "crypto";
858
- var __dirname, HubServer;
922
+ var __dirname, MAX_WS_PAYLOAD, HubServer;
859
923
  var init_server = __esm({
860
924
  "src/hub/server.ts"() {
861
925
  "use strict";
@@ -866,7 +930,9 @@ var init_server = __esm({
866
930
  init_telegram();
867
931
  init_config();
868
932
  init_crash_guard();
933
+ init_auth();
869
934
  __dirname = path4.dirname(fileURLToPath(import.meta.url));
935
+ MAX_WS_PAYLOAD = 16 * 1024 * 1024;
870
936
  HubServer = class {
871
937
  httpServer;
872
938
  wssChannel;
@@ -884,6 +950,7 @@ var init_server = __esm({
884
950
  heartbeatInterval;
885
951
  channelAlive = /* @__PURE__ */ new Map();
886
952
  // sessionId -> alive flag
953
+ socketOwners = /* @__PURE__ */ new WeakMap();
887
954
  host;
888
955
  port;
889
956
  token;
@@ -906,22 +973,23 @@ var init_server = __esm({
906
973
  this.initTelegram(fullConfig.telegram);
907
974
  }
908
975
  this.httpServer = http.createServer((req, res) => this.handleHttp(req, res));
909
- this.wssChannel = new WebSocketServer({ noServer: true });
976
+ this.wssChannel = new WebSocketServer({ noServer: true, maxPayload: MAX_WS_PAYLOAD });
910
977
  this.wssChannel.on("connection", (ws, req) => this.handleChannelConnection(ws, req));
911
978
  this.wssChannel.on("error", (err) => logger.warn(`Channel WebSocket server error: ${err.message}`));
912
- this.wssDashboard = new WebSocketServer({ noServer: true });
979
+ this.wssDashboard = new WebSocketServer({ noServer: true, maxPayload: MAX_WS_PAYLOAD });
913
980
  this.wssDashboard.on("connection", (ws) => this.handleDashboardConnection(ws));
914
981
  this.wssDashboard.on("error", (err) => logger.warn(`Dashboard WebSocket server error: ${err.message}`));
915
982
  this.httpServer.on("upgrade", (req, socket, head) => {
916
983
  socket.on("error", (err) => logger.debug(`Upgrade socket error: ${err.message}`));
917
- const url = new URL(req.url, `http://${req.headers.host}`);
984
+ const url = new URL(req.url ?? "/", "http://hub");
918
985
  const pathname = url.pathname;
919
- if (this.token && !this.isLocalRequest(req)) {
920
- const wsToken = url.searchParams.get("token");
921
- if (wsToken !== this.token) {
922
- socket.destroy();
923
- return;
924
- }
986
+ if (isCrossOrigin(req)) {
987
+ this.rejectUpgrade(socket, 403, "Forbidden", pathname, req);
988
+ return;
989
+ }
990
+ if (!this.authorized(req, true)) {
991
+ this.rejectUpgrade(socket, 401, "Unauthorized", pathname, req);
992
+ return;
925
993
  }
926
994
  if (pathname === WS_PATH_CHANNEL) {
927
995
  this.wssChannel.handleUpgrade(req, socket, head, (ws) => {
@@ -973,31 +1041,28 @@ var init_server = __esm({
973
1041
  }
974
1042
  // --- HTTP Handler ---
975
1043
  handleHttp(req, res) {
976
- const url = new URL(req.url, `http://${req.headers.host}`);
977
- const origin = req.headers.origin;
978
- if (origin && (origin.includes("127.0.0.1") || origin.includes("localhost"))) {
979
- res.setHeader("Access-Control-Allow-Origin", origin);
980
- }
981
- res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
982
- res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
983
- if (req.method === "OPTIONS") {
984
- res.writeHead(204);
985
- res.end();
1044
+ const url = new URL(req.url ?? "/", "http://hub");
1045
+ if (isCrossOrigin(req)) {
1046
+ this.jsonResponse(res, 403, { error: "Cross-origin request rejected" });
986
1047
  return;
987
1048
  }
988
- if (url.pathname !== "/" && this.token) {
989
- if (!this.isLocalRequest(req)) {
990
- const authHeader = req.headers["authorization"];
991
- const bearerToken = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null;
992
- if (bearerToken !== this.token) {
993
- this.jsonResponse(res, 401, { error: "Unauthorized" });
994
- return;
995
- }
996
- }
1049
+ if (req.method === "POST" && !isJsonRequest(req)) {
1050
+ this.jsonResponse(res, 415, { error: "Content-Type must be application/json" });
1051
+ return;
997
1052
  }
998
1053
  if (url.pathname === "/" && req.method === "GET") {
999
- this.serveDashboard(res);
1000
- } else if (url.pathname === "/api/sessions" && req.method === "GET") {
1054
+ this.serveDashboard(req, res, url);
1055
+ return;
1056
+ }
1057
+ if (url.pathname === "/api/login" && req.method === "POST") {
1058
+ this.handleLogin(req, res);
1059
+ return;
1060
+ }
1061
+ if (!this.authorized(req, false)) {
1062
+ this.jsonResponse(res, 401, { error: "Unauthorized" });
1063
+ return;
1064
+ }
1065
+ if (url.pathname === "/api/sessions" && req.method === "GET") {
1001
1066
  this.jsonResponse(res, 200, { sessions: this.sessions.getAll() });
1002
1067
  } else if (url.pathname === "/api/status" && req.method === "GET") {
1003
1068
  this.jsonResponse(res, 200, {
@@ -1048,7 +1113,13 @@ var init_server = __esm({
1048
1113
  this.jsonResponse(res, 404, { error: "Not found" });
1049
1114
  }
1050
1115
  }
1051
- serveDashboard(res) {
1116
+ serveDashboard(req, res, url) {
1117
+ const linkToken = url.searchParams.get("token");
1118
+ if (linkToken !== null && this.token && safeEqual(linkToken, this.token)) {
1119
+ res.writeHead(302, { Location: "/", "Set-Cookie": sessionCookieHeader(this.token, isSecureRequest(req)) });
1120
+ res.end();
1121
+ return;
1122
+ }
1052
1123
  const candidates = [
1053
1124
  path4.join(__dirname, "..", "dashboard", "index.html"),
1054
1125
  // from dist/hub/
@@ -1075,6 +1146,20 @@ var init_server = __esm({
1075
1146
  res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
1076
1147
  res.end("<html><body><h1>claude-alarm</h1><p>Dashboard HTML not found. Reinstall the package.</p></body></html>");
1077
1148
  }
1149
+ async handleLogin(req, res) {
1150
+ const body = await this.readBody(req);
1151
+ if (!this.token) {
1152
+ res.writeHead(204);
1153
+ res.end();
1154
+ return;
1155
+ }
1156
+ if (typeof body?.token !== "string" || !safeEqual(body.token, this.token)) {
1157
+ this.jsonResponse(res, 401, { error: "Unauthorized" });
1158
+ return;
1159
+ }
1160
+ res.writeHead(204, { "Set-Cookie": sessionCookieHeader(this.token, isSecureRequest(req)) });
1161
+ res.end();
1162
+ }
1078
1163
  async handleApiSend(req, res) {
1079
1164
  const body = await this.readBody(req);
1080
1165
  if (!body) {
@@ -1151,6 +1236,22 @@ var init_server = __esm({
1151
1236
  });
1152
1237
  }
1153
1238
  handleChannelMessage(ws, isLocal, msg) {
1239
+ if (msg.type === "register") {
1240
+ const id = msg.session.id;
1241
+ const owned = this.socketOwners.get(ws);
1242
+ if (owned && owned !== id) {
1243
+ logger.warn(`Rejected register for ${id}: connection already owns ${owned}`);
1244
+ return;
1245
+ }
1246
+ const holder = this.channelSockets.get(id);
1247
+ if (holder && holder !== ws) {
1248
+ this.socketOwners.delete(holder);
1249
+ holder.terminate();
1250
+ }
1251
+ this.socketOwners.set(ws, id);
1252
+ } else if ("sessionId" in msg && this.socketOwners.get(ws) !== msg.sessionId) {
1253
+ return;
1254
+ }
1154
1255
  switch (msg.type) {
1155
1256
  case "register": {
1156
1257
  const session = msg.session;
@@ -1174,7 +1275,6 @@ var init_server = __esm({
1174
1275
  break;
1175
1276
  }
1176
1277
  case "peer_name": {
1177
- if (this.channelSockets.get(msg.sessionId) !== ws) break;
1178
1278
  const updated = this.sessions.setPeerName(msg.sessionId, msg.peerName);
1179
1279
  if (updated) {
1180
1280
  logger.info(`Peer name for ${msg.sessionId}: ${msg.peerName ?? "-"}`);
@@ -1502,6 +1602,18 @@ var init_server = __esm({
1502
1602
  res.writeHead(status, { "Content-Type": "application/json" });
1503
1603
  res.end(JSON.stringify(body));
1504
1604
  }
1605
+ authorized(req, allowQueryToken) {
1606
+ return !this.token || isAuthorized(req, this.token, allowQueryToken);
1607
+ }
1608
+ rejectUpgrade(socket, status, text, pathname, req) {
1609
+ logger.warn(`Rejected ${pathname} upgrade (${status}) from ${req.socket.remoteAddress}`);
1610
+ socket.write(`HTTP/1.1 ${status} ${text}\r
1611
+ Connection: close\r
1612
+ Content-Length: 0\r
1613
+ \r
1614
+ `);
1615
+ socket.destroy();
1616
+ }
1505
1617
  isLocalRequest(req) {
1506
1618
  const addr = req.socket.remoteAddress;
1507
1619
  return addr === "127.0.0.1" || addr === "::1" || addr === "::ffff:127.0.0.1";
@@ -1967,6 +2079,12 @@ import path7 from "path";
1967
2079
  import readline from "readline";
1968
2080
  import { fileURLToPath as fileURLToPath2 } from "url";
1969
2081
  var __dirname2 = path7.dirname(fileURLToPath2(import.meta.url));
2082
+ function authHeaders(token) {
2083
+ return token ? { Authorization: `Bearer ${token}` } : {};
2084
+ }
2085
+ function loginLink(displayHost, port, token) {
2086
+ return `http://${displayHost}:${port}/${token ? `?token=${encodeURIComponent(token)}` : ""}`;
2087
+ }
1970
2088
  function printUsage() {
1971
2089
  console.log(`
1972
2090
  claude-alarm - Monitor Claude Code sessions with notifications
@@ -2014,7 +2132,7 @@ async function hubStart(daemon) {
2014
2132
  if (fs6.existsSync(PID_FILE)) {
2015
2133
  const pid = parseInt(fs6.readFileSync(PID_FILE, "utf-8").trim(), 10);
2016
2134
  if (isProcessRunning(pid)) {
2017
- console.log(`Hub is already running (PID: ${pid}) on http://${displayHost}:${port}`);
2135
+ console.log(`Hub is already running (PID: ${pid}). Dashboard: ${loginLink(displayHost, port, config2.hub.token)}`);
2018
2136
  return;
2019
2137
  }
2020
2138
  fs6.unlinkSync(PID_FILE);
@@ -2032,7 +2150,7 @@ async function hubStart(daemon) {
2032
2150
  fs6.writeFileSync(PID_FILE, String(child.pid), "utf-8");
2033
2151
  child.unref();
2034
2152
  console.log(`Hub started as daemon (PID: ${child.pid})`);
2035
- console.log(`Dashboard: http://${displayHost}:${port}`);
2153
+ console.log(`Dashboard: ${loginLink(displayHost, port, config2.hub.token)}`);
2036
2154
  console.log(`Token: ${config2.hub.token}`);
2037
2155
  console.log(`Logs: ${LOG_FILE}`);
2038
2156
  } else {
@@ -2041,6 +2159,7 @@ async function hubStart(daemon) {
2041
2159
  }
2042
2160
  } else {
2043
2161
  console.log(`Starting hub on http://${displayHost}:${port} (press Ctrl+C to stop)`);
2162
+ console.log(`Dashboard: ${loginLink(displayHost, port, config2.hub.token)}`);
2044
2163
  console.log(`Token: ${config2.hub.token}`);
2045
2164
  console.log(`Logs: ${LOG_FILE}`);
2046
2165
  installCrashGuard("hub foreground");
@@ -2089,7 +2208,7 @@ async function hubStatus() {
2089
2208
  }
2090
2209
  }
2091
2210
  try {
2092
- const res = await fetch(`http://${host}:${port}/api/status`);
2211
+ const res = await fetch(`http://${host}:${port}/api/status`, { headers: authHeaders(config2.hub.token) });
2093
2212
  if (res.ok) {
2094
2213
  const data = await res.json();
2095
2214
  console.log(`Hub: running (PID: ${data.pid})`);
@@ -2198,7 +2317,7 @@ claude-alarm init for "${projectName}"
2198
2317
  const displayHost = host === "0.0.0.0" ? "127.0.0.1" : host;
2199
2318
  let hubRunning = false;
2200
2319
  try {
2201
- const res = await fetch(`http://${host}:${port}/api/status`);
2320
+ const res = await fetch(`http://${host}:${port}/api/status`, { headers: authHeaders(config2.hub.token) });
2202
2321
  hubRunning = res.ok;
2203
2322
  } catch {
2204
2323
  }