alvin-bot 5.2.0 → 5.4.0

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.
@@ -0,0 +1,162 @@
1
+ /**
2
+ * SSRF Guard — rejects requests to private/internal network destinations.
3
+ *
4
+ * Blocks:
5
+ * - Non-http(s) schemes (file://, ftp://, etc.)
6
+ * - IPv4 loopback (127.0.0.0/8), link-local (169.254.0.0/16 — incl. cloud
7
+ * metadata endpoint 169.254.169.254), RFC-1918 private ranges
8
+ * (10/8, 172.16/12, 192.168/16), and the catch-all 0.0.0.0
9
+ * - IPv6 loopback (::1), ULA (fc00::/7), link-local (fe80::/10)
10
+ *
11
+ * Opt-out: set ALLOW_PRIVATE_FETCH=1 to disable blocking (for local dev /
12
+ * self-hosted setups). Default = blocked.
13
+ *
14
+ * No new runtime dependencies — uses Node's built-in `dns` and `net` modules.
15
+ */
16
+ import dns from "dns";
17
+ import net from "net";
18
+ export class SsrfBlockedError extends Error {
19
+ url;
20
+ constructor(url, reason) {
21
+ super(`SSRF blocked: ${reason} (url: ${url})`);
22
+ this.name = "SsrfBlockedError";
23
+ this.url = url;
24
+ }
25
+ }
26
+ /**
27
+ * Return true if the given IPv4 address string falls into a private/reserved
28
+ * range (RFC-1918, loopback, link-local, unspecified).
29
+ *
30
+ * Ranges blocked:
31
+ * 0.0.0.0/8 — "this" network
32
+ * 10.0.0.0/8 — RFC-1918 class A
33
+ * 127.0.0.0/8 — loopback
34
+ * 169.254.0.0/16 — link-local (incl. IMDS 169.254.169.254)
35
+ * 172.16.0.0/12 — RFC-1918 class B (172.16–172.31)
36
+ * 192.168.0.0/16 — RFC-1918 class C
37
+ */
38
+ function isPrivateIPv4(ip) {
39
+ const parts = ip.split(".").map(Number);
40
+ if (parts.length !== 4 || parts.some(p => isNaN(p) || p < 0 || p > 255)) {
41
+ return false; // not a valid IPv4 — let DNS resolve decide
42
+ }
43
+ const [a, b] = parts;
44
+ if (a === 0)
45
+ return true; // 0.0.0.0/8
46
+ if (a === 10)
47
+ return true; // 10.0.0.0/8
48
+ if (a === 127)
49
+ return true; // 127.0.0.0/8 loopback
50
+ if (a === 169 && b === 254)
51
+ return true; // 169.254.0.0/16 link-local
52
+ if (a === 172 && b >= 16 && b <= 31)
53
+ return true; // 172.16.0.0/12
54
+ if (a === 192 && b === 168)
55
+ return true; // 192.168.0.0/16
56
+ return false;
57
+ }
58
+ /**
59
+ * Return true if the given IPv6 address string falls into a blocked range.
60
+ *
61
+ * Ranges blocked:
62
+ * ::1 — loopback
63
+ * fc00::/7 — ULA (fc00:: – fdff::)
64
+ * fe80::/10 — link-local
65
+ */
66
+ function isPrivateIPv6(ip) {
67
+ // Node net.isIPv6 normalises the address but we need the raw string
68
+ // for prefix checks. Use the compressed form from net.
69
+ const normalized = ip.toLowerCase().replace(/^\[|\]$/g, "");
70
+ // Loopback
71
+ if (normalized === "::1")
72
+ return true;
73
+ // For prefix checks, expand just the first 16-bit group
74
+ const firstGroup = normalized.split(":")[0];
75
+ const value = parseInt(firstGroup || "0", 16);
76
+ // fc00::/7 — fc00 to fdff (bit 7 of first byte = 1, bit 6 = 1, bit 5 doesn't matter… easier: 0xfc00–0xfdff range for the first 16 bits)
77
+ // The /7 prefix means: binary prefix 1111110x — so 0xfc00 to 0xfdff
78
+ if (value >= 0xfc00 && value <= 0xfdff)
79
+ return true;
80
+ // fe80::/10 — fe80 to febf
81
+ if (value >= 0xfe80 && value <= 0xfebf)
82
+ return true;
83
+ return false;
84
+ }
85
+ /**
86
+ * Check whether a raw IP string (v4 or v6) is a private/internal address.
87
+ */
88
+ function isPrivateIP(ip) {
89
+ const stripped = ip.replace(/^\[|\]$/g, ""); // strip IPv6 brackets
90
+ if (net.isIPv4(stripped))
91
+ return isPrivateIPv4(stripped);
92
+ if (net.isIPv6(stripped))
93
+ return isPrivateIPv6(stripped);
94
+ return false;
95
+ }
96
+ /**
97
+ * Check the hostname from a URL — if it's a literal IP, classify immediately.
98
+ * If it's a hostname, resolve it via DNS and check every returned address.
99
+ *
100
+ * Throws SsrfBlockedError if any resolved address is private.
101
+ * Resolves void if all addresses are public (or DNS fails — fail-open on DNS
102
+ * errors so a temporary resolver blip doesn't DoS the bot; the risk is low
103
+ * because the per-host literal-IP checks run before DNS).
104
+ */
105
+ async function checkHost(hostname, url) {
106
+ const bare = hostname.replace(/^\[|\]$/g, ""); // strip IPv6 brackets for net.isIP
107
+ // Literal IP — no DNS needed
108
+ if (net.isIP(bare)) {
109
+ if (isPrivateIP(bare)) {
110
+ throw new SsrfBlockedError(url, `destination ${bare} is a private/loopback/link-local address`);
111
+ }
112
+ return;
113
+ }
114
+ // Named hostname — also catch obvious loopback hostnames without DNS
115
+ const lower = bare.toLowerCase();
116
+ if (lower === "localhost" || lower.endsWith(".localhost") || lower === "::1") {
117
+ throw new SsrfBlockedError(url, `destination hostname '${bare}' resolves to loopback`);
118
+ }
119
+ // DNS resolution — check every returned address
120
+ try {
121
+ const { promises: dnsPromises } = dns;
122
+ const addresses = await dnsPromises.resolve(bare);
123
+ for (const addr of addresses) {
124
+ if (isPrivateIP(addr)) {
125
+ throw new SsrfBlockedError(url, `destination hostname '${bare}' resolves to private address ${addr}`);
126
+ }
127
+ }
128
+ }
129
+ catch (err) {
130
+ // Re-throw our own error; swallow DNS failures (fail-open)
131
+ if (err instanceof SsrfBlockedError)
132
+ throw err;
133
+ // DNS error (ENOTFOUND, etc.) — let the actual fetch fail naturally
134
+ }
135
+ }
136
+ /**
137
+ * Assert that `url` is safe to fetch (not SSRF-risky).
138
+ *
139
+ * - Rejects non-http(s) schemes immediately (synchronous check).
140
+ * - Resolves hostnames to detect private IP destinations.
141
+ * - Respects ALLOW_PRIVATE_FETCH=1 for operator opt-out.
142
+ *
143
+ * Throws SsrfBlockedError when blocked.
144
+ * Resolves void when safe.
145
+ */
146
+ export async function assertSsrfSafe(url) {
147
+ // Opt-out for trusted local / self-hosted environments
148
+ if (process.env.ALLOW_PRIVATE_FETCH === "1")
149
+ return;
150
+ let parsed;
151
+ try {
152
+ parsed = new URL(url);
153
+ }
154
+ catch {
155
+ throw new SsrfBlockedError(url, "invalid URL");
156
+ }
157
+ const scheme = parsed.protocol; // includes trailing ':'
158
+ if (scheme !== "http:" && scheme !== "https:") {
159
+ throw new SsrfBlockedError(url, `scheme '${parsed.protocol}' is not allowed (only http/https)`);
160
+ }
161
+ await checkHost(parsed.hostname, url);
162
+ }
@@ -0,0 +1,46 @@
1
+ const DEFAULT_CAP = 20;
2
+ export class SteerChannel {
3
+ cap;
4
+ buf = [];
5
+ closed = false;
6
+ resolveNext = null;
7
+ constructor(cap = DEFAULT_CAP) {
8
+ this.cap = cap;
9
+ }
10
+ /** Push a message into the channel.
11
+ * Returns true if the message was accepted, false if it was dropped
12
+ * (channel closed or buffer cap reached). Callers must check the
13
+ * return value to decide whether to send a 📨 ack or a bufferFull notice. */
14
+ push(text) {
15
+ if (this.closed)
16
+ return false;
17
+ if (this.buf.length >= this.cap) {
18
+ console.warn(`[steer-channel] cap ${this.cap} reached — dropping steer message`);
19
+ return false;
20
+ }
21
+ this.buf.push({ type: "user", message: { role: "user", content: text }, parent_tool_use_id: null });
22
+ const r = this.resolveNext;
23
+ this.resolveNext = null;
24
+ r?.();
25
+ return true;
26
+ }
27
+ close() {
28
+ if (this.closed)
29
+ return;
30
+ this.closed = true;
31
+ const r = this.resolveNext;
32
+ this.resolveNext = null;
33
+ r?.();
34
+ }
35
+ async *[Symbol.asyncIterator]() {
36
+ while (true) {
37
+ if (this.buf.length > 0) {
38
+ yield this.buf.shift();
39
+ continue;
40
+ }
41
+ if (this.closed)
42
+ return;
43
+ await new Promise((res) => { this.resolveNext = res; });
44
+ }
45
+ }
46
+ }
@@ -21,9 +21,6 @@ export async function transcribeAudio(audioPath) {
21
21
  const bodyEnd = Buffer.from(`\r\n--${boundary}\r\n` +
22
22
  `Content-Disposition: form-data; name="model"\r\n\r\n` +
23
23
  `whisper-large-v3-turbo\r\n` +
24
- `--${boundary}\r\n` +
25
- `Content-Disposition: form-data; name="language"\r\n\r\n` +
26
- `de\r\n` +
27
24
  `--${boundary}--\r\n`, "utf-8");
28
25
  const fullBody = Buffer.concat([bodyStart, fileBuffer, bodyEnd]);
29
26
  return new Promise((resolve, reject) => {
@@ -77,6 +77,18 @@ function checkAuth(req) {
77
77
  const token = cookie.match(/alvinbot_token=([a-f0-9]+)/)?.[1];
78
78
  return token ? activeSessions.has(token) : false;
79
79
  }
80
+ /** Returns true when the server is exposed to non-loopback addresses but
81
+ * WEB_PASSWORD is empty. In that state every mutating / exec endpoint is
82
+ * hard-refused regardless of any session cookie. */
83
+ function isExposedWithoutPassword() {
84
+ if (WEB_PASSWORD)
85
+ return false; // password set → normal auth path
86
+ const h = config.webHost;
87
+ // loopback addresses are safe even without a password
88
+ if (!h || h === "127.0.0.1" || h === "::1" || h === "localhost")
89
+ return false;
90
+ return true; // non-loopback + no password = exposed
91
+ }
80
92
  // ── REST API ────────────────────────────────────────────
81
93
  async function handleAPI(req, res, urlPath, body) {
82
94
  res.setHeader("Content-Type", "application/json");
@@ -84,7 +96,14 @@ async function handleAPI(req, res, urlPath, body) {
84
96
  if (urlPath === "/api/login" && req.method === "POST") {
85
97
  try {
86
98
  const { password } = JSON.parse(body);
87
- if (!WEB_PASSWORD || password === WEB_PASSWORD) {
99
+ // v5.x refuse login entirely when WEB_PASSWORD is not set.
100
+ // Previously `!WEB_PASSWORD || password === WEB_PASSWORD` minted a
101
+ // session for ANY input (including "") when the env var was absent,
102
+ // effectively making every unauthenticated request a valid session.
103
+ // timingSafeBearerMatch already rejects empty expectedToken → reuse it
104
+ // by wrapping the provided password as a synthetic "Bearer <pw>" header.
105
+ const authOk = timingSafeBearerMatch(`Bearer ${password}`, WEB_PASSWORD);
106
+ if (authOk) {
88
107
  const token = generateToken();
89
108
  activeSessions.add(token);
90
109
  res.setHeader("Set-Cookie", `alvinbot_token=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400`);
@@ -141,6 +160,66 @@ async function handleAPI(req, res, urlPath, body) {
141
160
  res.end(JSON.stringify({ error: "Not authenticated" }));
142
161
  return;
143
162
  }
163
+ // Hard gate: when the web server is reachable from non-loopback addresses
164
+ // and WEB_PASSWORD is empty, mutating/exec endpoints are refused outright.
165
+ // Local dev (127.0.0.1 + no password) is intentionally unaffected.
166
+ // Endpoints that write files, execute commands, or modify configuration
167
+ // are in scope; read-only status/config endpoints are not gated.
168
+ const EXPOSED_DANGEROUS_ROUTES = new Set([
169
+ "/api/terminal",
170
+ "/api/skills/create",
171
+ "/api/skills/update",
172
+ "/api/skills/delete",
173
+ "/api/files/save",
174
+ "/api/files/delete",
175
+ "/api/env/set",
176
+ "/api/setup-wizard",
177
+ "/api/soul/save",
178
+ "/api/memory/save",
179
+ "/api/memory/delete",
180
+ "/api/session/reset",
181
+ // cron — /api/cron/add was wrong; real route is /api/cron/create
182
+ "/api/cron/create",
183
+ "/api/cron/update",
184
+ "/api/cron/delete",
185
+ "/api/cron/toggle",
186
+ "/api/cron/run",
187
+ "/api/plugin/install",
188
+ "/api/plugin/uninstall",
189
+ // shell / process exec
190
+ "/api/tools/execute",
191
+ "/api/sudo/exec",
192
+ "/api/sudo/setup",
193
+ "/api/sudo/admin-dialog",
194
+ "/api/sudo/revoke",
195
+ // key / env writes
196
+ "/api/providers/set-key",
197
+ "/api/providers/set-primary",
198
+ "/api/providers/set-fallbacks",
199
+ "/api/providers/add-custom",
200
+ "/api/providers/remove-custom",
201
+ // platform config writes (writes ENV vars / runs npm install)
202
+ "/api/platforms/configure",
203
+ "/api/platforms/install-deps",
204
+ // provider / fallback order writes
205
+ "/api/fallback",
206
+ "/api/fallback/move",
207
+ // MCP config writes
208
+ "/api/mcp/add",
209
+ "/api/mcp/remove",
210
+ // process restart (DoS vector)
211
+ "/api/restart",
212
+ // model switching (affects all users)
213
+ "/api/models/switch",
214
+ // WhatsApp auth / config writes
215
+ "/api/whatsapp/disconnect",
216
+ "/api/whatsapp/group-rules",
217
+ ]);
218
+ if (isExposedWithoutPassword() && EXPOSED_DANGEROUS_ROUTES.has(urlPath)) {
219
+ res.statusCode = 403;
220
+ res.end(JSON.stringify({ error: "refused: web exposed without WEB_PASSWORD" }));
221
+ return;
222
+ }
144
223
  // ── Setup APIs (platforms + models) ─────────────────
145
224
  const handled = await handleSetupAPI(req, res, urlPath, body);
146
225
  if (handled)
@@ -201,6 +280,9 @@ async function handleAPI(req, res, urlPath, body) {
201
280
  const envPath = ENV_FILE;
202
281
  let content = fs.existsSync(envPath) ? fs.readFileSync(envPath, "utf-8") : "";
203
282
  const setEnv = (key, value) => {
283
+ // M6 (centralized): reject values containing newline characters
284
+ if (/[\n\r]/.test(value))
285
+ throw new Error("env value must not contain newline characters");
204
286
  const regex = new RegExp(`^${key}=.*$`, "m");
205
287
  if (regex.test(content)) {
206
288
  content = content.replace(regex, `${key}=${value}`);
@@ -509,6 +591,12 @@ async function handleAPI(req, res, urlPath, body) {
509
591
  }
510
592
  // DELETE /api/users/:id — Kill session + delete user data
511
593
  if (urlPath.startsWith("/api/users/") && req.method === "DELETE") {
594
+ // Pattern route — gate here since EXPOSED_DANGEROUS_ROUTES can't express :id
595
+ if (isExposedWithoutPassword()) {
596
+ res.statusCode = 403;
597
+ res.end(JSON.stringify({ error: "refused: web exposed without WEB_PASSWORD" }));
598
+ return;
599
+ }
512
600
  const userId = parseInt(urlPath.split("/").pop() || "0");
513
601
  if (!userId) {
514
602
  res.statusCode = 400;
@@ -701,8 +789,27 @@ async function handleAPI(req, res, urlPath, body) {
701
789
  res.end(JSON.stringify({ error: "id and name required" }));
702
790
  return;
703
791
  }
792
+ // Security: reject id values that could escape SKILLS_DIR.
793
+ // Mirror the resolve+startsWith containment pattern used in /api/files/save (server.ts ~:921).
794
+ // Also guard against ids with path separators or absolute paths before
795
+ // resolve() ever runs, so the raw string never reaches the filesystem.
796
+ if (typeof id !== "string" ||
797
+ id.includes("..") ||
798
+ id.includes("/") ||
799
+ id.includes("\\") ||
800
+ path.isAbsolute(id)) {
801
+ res.statusCode = 400;
802
+ res.end(JSON.stringify({ error: "Invalid skill id" }));
803
+ return;
804
+ }
704
805
  const skillsDir = SKILLS_DIR;
705
806
  const skillDir = resolve(skillsDir, id);
807
+ // Belt-and-suspenders: resolved path must still be inside SKILLS_DIR
808
+ if (!skillDir.startsWith(skillsDir)) {
809
+ res.statusCode = 400;
810
+ res.end(JSON.stringify({ error: "Invalid skill id" }));
811
+ return;
812
+ }
706
813
  if (!fs.existsSync(skillDir))
707
814
  fs.mkdirSync(skillDir, { recursive: true });
708
815
  const frontmatter = [
@@ -730,6 +837,22 @@ async function handleAPI(req, res, urlPath, body) {
730
837
  if (urlPath === "/api/skills/update" && req.method === "POST") {
731
838
  try {
732
839
  const { id, content } = JSON.parse(body);
840
+ // Security: same id containment as /api/skills/create
841
+ if (typeof id !== "string" ||
842
+ id.includes("..") ||
843
+ id.includes("/") ||
844
+ id.includes("\\") ||
845
+ path.isAbsolute(id)) {
846
+ res.statusCode = 400;
847
+ res.end(JSON.stringify({ error: "Invalid skill id" }));
848
+ return;
849
+ }
850
+ // Belt-and-suspenders: resolved path must still be inside SKILLS_DIR
851
+ if (!resolve(SKILLS_DIR, id).startsWith(SKILLS_DIR)) {
852
+ res.statusCode = 400;
853
+ res.end(JSON.stringify({ error: "Invalid skill id" }));
854
+ return;
855
+ }
733
856
  const skillPath = resolve(SKILLS_DIR, id, "SKILL.md");
734
857
  if (!fs.existsSync(skillPath)) {
735
858
  // Try flat file
@@ -760,6 +883,16 @@ async function handleAPI(req, res, urlPath, body) {
760
883
  if (urlPath === "/api/skills/delete" && req.method === "POST") {
761
884
  try {
762
885
  const { id } = JSON.parse(body);
886
+ // Security: same raw-string id containment as /api/skills/create + /api/skills/update
887
+ if (typeof id !== "string" ||
888
+ id.includes("..") ||
889
+ id.includes("/") ||
890
+ id.includes("\\") ||
891
+ path.isAbsolute(id)) {
892
+ res.statusCode = 400;
893
+ res.end(JSON.stringify({ error: "Invalid skill id" }));
894
+ return;
895
+ }
763
896
  const skillDir = resolve(SKILLS_DIR, id);
764
897
  const flatFile = resolve(SKILLS_DIR, id + ".md");
765
898
  if (fs.existsSync(skillDir)) {
@@ -1036,6 +1169,13 @@ async function handleAPI(req, res, urlPath, body) {
1036
1169
  res.end(JSON.stringify({ error: "Invalid key name" }));
1037
1170
  return;
1038
1171
  }
1172
+ // M6: reject values containing newline characters — they allow injecting
1173
+ // extra .env lines (e.g. value="good\nEVIL=injected").
1174
+ if (typeof value === "string" && /[\n\r]/.test(value)) {
1175
+ res.statusCode = 400;
1176
+ res.end(JSON.stringify({ error: "Invalid value: newline characters not allowed" }));
1177
+ return;
1178
+ }
1039
1179
  let envContent = fs.existsSync(ENV_FILE) ? fs.readFileSync(ENV_FILE, "utf-8") : "";
1040
1180
  const regex = new RegExp(`^${key}=.*$`, "m");
1041
1181
  if (regex.test(envContent)) {
@@ -1182,6 +1322,12 @@ async function handleAPI(req, res, urlPath, body) {
1182
1322
  }
1183
1323
  // DELETE /api/whatsapp/group-rules/:id — delete a group rule
1184
1324
  if (urlPath.match(/^\/api\/whatsapp\/group-rules\//) && req.method === "DELETE") {
1325
+ // Pattern route — gate here since EXPOSED_DANGEROUS_ROUTES can't express :id
1326
+ if (isExposedWithoutPassword()) {
1327
+ res.statusCode = 403;
1328
+ res.end(JSON.stringify({ error: "refused: web exposed without WEB_PASSWORD" }));
1329
+ return;
1330
+ }
1185
1331
  const groupId = decodeURIComponent(urlPath.split("/").slice(4).join("/"));
1186
1332
  const { deleteGroupRule } = await import("../platforms/whatsapp.js");
1187
1333
  const ok = deleteGroupRule(groupId);
@@ -1498,7 +1644,7 @@ function handleWebRequest(req, res) {
1498
1644
  */
1499
1645
  export function startWebServer() {
1500
1646
  stopRequested = false;
1501
- scheduleBindAttempt(WEB_PORT, 0);
1647
+ scheduleBindAttempt(parseInt(process.env.WEB_PORT || "3100", 10), 0);
1502
1648
  }
1503
1649
  function scheduleBindAttempt(port, attempt) {
1504
1650
  if (stopRequested)
@@ -1598,9 +1744,13 @@ function scheduleBindAttempt(port, attempt) {
1598
1744
  if (actualWebPort !== originalPort) {
1599
1745
  console.log(` (Port ${originalPort} was busy, using ${actualWebPort} instead)`);
1600
1746
  }
1601
- if (bindHost === "0.0.0.0" && !process.env.WEB_PASSWORD) {
1602
- console.warn("⚠️ Web UI is bound to 0.0.0.0 but WEB_PASSWORD is empty — anyone on the LAN can log in. " +
1603
- "Set WEB_PASSWORD in ~/.alvin-bot/.env or set WEB_HOST=127.0.0.1.");
1747
+ if (isExposedWithoutPassword()) {
1748
+ // Upgrade from warn to a clear one-time log: the hard gate (above in
1749
+ // handleAPI) already blocks every mutating/exec route in this state,
1750
+ // so this message explains why those calls are being refused.
1751
+ console.log("[web] Non-loopback host with no WEB_PASSWORD: mutating/exec endpoints are disabled " +
1752
+ "(hard-refused 403). Set WEB_PASSWORD in ~/.alvin-bot/.env to unlock them, " +
1753
+ "or restrict to loopback with WEB_HOST=127.0.0.1.");
1604
1754
  }
1605
1755
  });
1606
1756
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "alvin-bot",
3
- "version": "5.2.0",
3
+ "version": "5.4.0",
4
4
  "description": "Alvin Bot — Your personal AI agent on Telegram, WhatsApp, Discord, Signal, and Web.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -166,11 +166,9 @@
166
166
  "url": "git+https://github.com/alvbln/Alvin-Bot.git"
167
167
  },
168
168
  "dependencies": {
169
- "@anthropic-ai/claude-agent-sdk": "^0.2.97",
169
+ "@anthropic-ai/claude-agent-sdk": "0.3.142",
170
170
  "@hapi/boom": "^10.0.1",
171
171
  "@slack/bolt": "^4.6.0",
172
- "@types/node": "^22.0.0",
173
- "@types/ws": "^8.18.1",
174
172
  "@whiskeysockets/baileys": "^6.7.21",
175
173
  "better-sqlite3": "^12.9.0",
176
174
  "dotenv": "^16.4.0",
@@ -179,16 +177,18 @@
179
177
  "node-edge-tts": "^1.2.10",
180
178
  "pino": "^10.3.1",
181
179
  "playwright": "^1.58.2",
182
- "typescript": "^5.9.3",
183
180
  "whatsapp-web.js": "^1.34.6",
184
181
  "ws": "^8.19.0"
185
182
  },
186
183
  "devDependencies": {
187
184
  "@types/better-sqlite3": "^7.6.13",
185
+ "@types/node": "^22.0.0",
186
+ "@types/ws": "^8.18.1",
188
187
  "@vitest/ui": "^4.1.4",
189
188
  "electron": "^35.7.5",
190
189
  "electron-builder": "^26.8.1",
191
190
  "tsx": "^4.19.0",
191
+ "typescript": "^5.9.3",
192
192
  "vitest": "^4.1.4"
193
193
  },
194
194
  "homepage": "https://github.com/alvbln/Alvin-Bot",
@@ -196,9 +196,10 @@
196
196
  "node": ">=18.0.0"
197
197
  },
198
198
  "bugs": {
199
- "url": "https://github.com/alvbln/alvin-bot/issues"
199
+ "url": "https://github.com/alvbln/Alvin-Bot/issues"
200
200
  },
201
201
  "overrides": {
202
- "axios": "^1.15.0"
202
+ "axios": ">=1.8.2",
203
+ "protobufjs": ">=7.5.6"
203
204
  }
204
205
  }