@sleep2agi/commhub-server 0.7.0-preview.0 → 0.8.0-preview.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sleep2agi/commhub-server",
3
- "version": "0.7.0-preview.0",
3
+ "version": "0.8.0-preview.0",
4
4
  "description": "CommHub Server — AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and 18 MCP tools.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/auth.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  * V3 Auth module — user registration, login, token management
3
3
  */
4
4
  import { db, generateId, hashPassword, hashToken, generateToken, generateUserToken, generateNetworkToken, uuidv4 } from "./db.js";
5
+ import { WEAK_PASSWORDS } from "./password-dict.js";
5
6
 
6
7
  export interface AuthUser {
7
8
  user_id: string;
@@ -20,10 +21,17 @@ export interface AuthResult {
20
21
  network_id?: string;
21
22
  }
22
23
 
24
+ function validatePasswordStrength(password: string, label = "password"): string | null {
25
+ if (!password || password.length < 8) return `${label} must be at least 8 characters`;
26
+ if (WEAK_PASSWORDS.has(password.toLowerCase())) return `${label} is too common`;
27
+ return null;
28
+ }
29
+
23
30
  export function register(username: string, password: string, email?: string, displayName?: string): AuthResult {
24
31
  if (!username || username.length < 2) return { ok: false, error: "username must be at least 2 characters" };
25
32
  if (username.length > 50) return { ok: false, error: "username too long (max 50)" };
26
- if (!password || password.length < 6) return { ok: false, error: "password must be at least 6 characters" };
33
+ const passwordError = validatePasswordStrength(password);
34
+ if (passwordError) return { ok: false, error: passwordError };
27
35
  if (!/^[a-zA-Z0-9_\-\u4e00-\u9fff]+$/.test(username)) return { ok: false, error: "username contains invalid characters" };
28
36
 
29
37
  const existing = db.get<any>("SELECT user_id FROM users WHERE username = ?1", username);
@@ -126,10 +134,10 @@ export function createNetworkTokenForNode(userId: string, networkId: string, nod
126
134
  return { ok: true, token };
127
135
  }
128
136
 
129
- export function resolveToken(token: string): { user: AuthUser; networkId: string | null; tokenName: string | null } | null {
137
+ export function resolveToken(token: string): { user: AuthUser; networkId: string | null; tokenName: string | null; tokenId: string | null } | null {
130
138
  const tHash = hashToken(token);
131
139
  const row = db.get<any>(
132
- `SELECT t.user_id, t.network_id, t.scope, t.name AS token_name,
140
+ `SELECT t.token_id, t.user_id, t.network_id, t.scope, t.name AS token_name,
133
141
  u.username, u.display_name, u.email, u.role
134
142
  FROM api_tokens t JOIN users u ON t.user_id = u.user_id
135
143
  WHERE t.token_hash = ?1 AND (t.expires_at IS NULL OR t.expires_at > datetime('now'))`,
@@ -143,6 +151,7 @@ export function resolveToken(token: string): { user: AuthUser; networkId: string
143
151
  return {
144
152
  user: { user_id: row.user_id, username: row.username, display_name: row.display_name, email: row.email, role: row.role },
145
153
  networkId: row.network_id,
154
+ tokenId: row.token_id || null,
146
155
  // tokenName carries the binding identity. For node-scoped ntok_, it's
147
156
  // 'node:<alias>'; we strip the prefix and use it as the default
148
157
  // from_session for any MCP send_task / send_message / etc, so peer
@@ -239,13 +248,47 @@ export function revokeToken(userId: string, tokenId: string): { ok: boolean; err
239
248
  return result.changes > 0 ? { ok: true } : { ok: false, error: "token not found" };
240
249
  }
241
250
 
242
- export function changePassword(userId: string, oldPassword: string, newPassword: string): { ok: boolean; error?: string } {
243
- if (!newPassword || newPassword.length < 6) return { ok: false, error: "new password must be at least 6 characters" };
251
+ export function issueUserToken(userId: string, name = "user-login"): { token: string; token_id: string } {
252
+ const token = generateUserToken();
253
+ const tokenId = generateId("tok");
254
+ db.run(
255
+ "INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope) VALUES (?1, ?2, ?3, NULL, ?4, 'user')",
256
+ [tokenId, hashToken(token), userId, name]
257
+ );
258
+ return { token, token_id: tokenId };
259
+ }
260
+
261
+ export function revokeOtherUserTokens(userId: string, exceptTokenId?: string | null): number {
262
+ const result = exceptTokenId
263
+ ? db.run("DELETE FROM api_tokens WHERE user_id = ?1 AND network_id IS NULL AND token_id != ?2", [userId, exceptTokenId])
264
+ : db.run("DELETE FROM api_tokens WHERE user_id = ?1 AND network_id IS NULL", [userId]);
265
+ return result.changes;
266
+ }
267
+
268
+ export function changePassword(userId: string, oldPassword: string, newPassword: string, currentTokenId?: string | null): { ok: boolean; error?: string; revoked?: number } {
269
+ const passwordError = validatePasswordStrength(newPassword, "new password");
270
+ if (passwordError) return { ok: false, error: passwordError };
244
271
  const user = db.get<any>("SELECT password_hash FROM users WHERE user_id = ?1", userId);
245
272
  if (!user) return { ok: false, error: "user not found" };
246
273
  if (user.password_hash !== hashPassword(oldPassword)) return { ok: false, error: "incorrect current password" };
247
274
  db.run("UPDATE users SET password_hash = ?1, updated_at = datetime('now') WHERE user_id = ?2", [hashPassword(newPassword), userId]);
248
- return { ok: true };
275
+ const revoked = revokeOtherUserTokens(userId, currentTokenId);
276
+ return { ok: true, revoked };
277
+ }
278
+
279
+ export function resetUserPassword(targetUsername: string, callerIsHubAdmin: boolean): { ok: boolean; error?: string; username?: string; user_id?: string; password?: string; token?: string; token_id?: string; revoked?: number } {
280
+ if (!callerIsHubAdmin) return { ok: false, error: "hub admin required" };
281
+ const user = db.get<any>("SELECT user_id, username FROM users WHERE username = ?1", targetUsername);
282
+ if (!user) return { ok: false, error: "user not found" };
283
+ const password = `anet-${crypto.randomUUID().replace(/-/g, "").slice(0, 18)}`;
284
+ db.run("UPDATE users SET password_hash = ?1, updated_at = datetime('now') WHERE user_id = ?2", [hashPassword(password), user.user_id]);
285
+ const revoked = revokeOtherUserTokens(user.user_id, null);
286
+ const issued = issueUserToken(user.user_id, "admin-reset");
287
+ db.run(
288
+ "INSERT INTO audit_log (user_id, username, action, target_type, target_id, detail) VALUES (?1, ?2, 'password_reset_by_admin', 'user', ?3, ?4)",
289
+ [user.user_id, user.username, user.user_id, "local hub admin reset"]
290
+ );
291
+ return { ok: true, username: user.username, user_id: user.user_id, password, token: issued.token, token_id: issued.token_id, revoked };
249
292
  }
250
293
 
251
294
  // ══════════════════════════════════════
package/src/index.ts CHANGED
@@ -4,24 +4,24 @@ import { z } from "zod/v4";
4
4
  import { registerTools } from "./tools.js";
5
5
  import { db, logTaskEvent, logAudit } from "./db.js";
6
6
  import { createSSEStream, pushEvent, getSSEStats } from "./push.js";
7
- import { register, login, resolveToken, getUserNetworks, getUserAllNetworks, createNetwork, deleteNetwork, renameNetwork, changePassword, listTokens, createToken, revokeToken, getNetworkMembers, getUserNetworkRole, addNetworkMember, updateMemberRole, removeNetworkMember, createInvite, joinByInvite, createNetworkTokenForNode, type AuthUser } from "./auth.js";
7
+ import { register, login, resolveToken, getUserNetworks, getUserAllNetworks, createNetwork, deleteNetwork, renameNetwork, changePassword, issueUserToken, listTokens, createToken, revokeToken, getNetworkMembers, getUserNetworkRole, addNetworkMember, updateMemberRole, removeNetworkMember, createInvite, joinByInvite, createNetworkTokenForNode, type AuthUser } from "./auth.js";
8
8
 
9
9
  const PORT = Number(process.env.PORT) || 9200;
10
10
  const HOST = process.env.HOST || "127.0.0.1";
11
11
  const AUTH_TOKEN = process.env.COMMHUB_AUTH_TOKEN;
12
12
  const DEV_OPEN = process.argv.includes("--dev-open") || process.env.COMMHUB_DEV_OPEN === "1";
13
13
  const TMUX_ENABLED = process.env.COMMHUB_ENABLE_TMUX === "1";
14
- const SECURITY_LABEL = AUTH_TOKEN ? "🔒 secured" : "⚠️ DEV OPEN MODE";
14
+ const SECURITY_LABEL = DEV_OPEN ? "⚠️ DEV OPEN MODE" : "🔒 secured";
15
15
  const TMUX_ALLOWLIST = new Set(
16
16
  (process.env.COMMHUB_TMUX_ALLOWLIST || "")
17
17
  .split(",")
18
18
  .map((s) => s.trim())
19
19
  .filter(Boolean)
20
20
  );
21
+ let masterTokenDeprecationLogged = false;
21
22
 
22
- if (!AUTH_TOKEN && !DEV_OPEN) {
23
- console.error("[commhub] refusing to start without COMMHUB_AUTH_TOKEN. Use --dev-open or COMMHUB_DEV_OPEN=1 for local-only development.");
24
- process.exit(1);
23
+ if (AUTH_TOKEN) {
24
+ console.warn("[commhub] COMMHUB_AUTH_TOKEN is deprecated and will be removed in v1.0. See RFC-001.");
25
25
  }
26
26
 
27
27
  // Read version from package.json so banners and /health stay in sync.
@@ -106,7 +106,16 @@ function requireAuth(req: Request): Response | null {
106
106
 
107
107
  // Legacy: check global COMMHUB_AUTH_TOKEN
108
108
  if (!AUTH_TOKEN && DEV_OPEN) return null; // explicit local/dev open mode
109
- if (token === AUTH_TOKEN) return null;
109
+ if (token === AUTH_TOKEN) {
110
+ const u = new URL(req.url);
111
+ const readOnlyApi = u.pathname.startsWith("/api/") && (req.method === "GET" || req.method === "HEAD" || req.method === "OPTIONS");
112
+ if (!readOnlyApi) return Response.json({ ok: false, error: "master-token auth is deprecated; use admin utok_" }, { status: 401 });
113
+ if (!masterTokenDeprecationLogged) {
114
+ console.warn("[commhub] master-token auth is deprecated and will be removed in v1.0. See RFC-001.");
115
+ masterTokenDeprecationLogged = true;
116
+ }
117
+ return null;
118
+ }
110
119
 
111
120
  return Response.json({ error: "unauthorized" }, { status: 401 });
112
121
  }
@@ -472,9 +481,14 @@ Bun.serve({
472
481
  if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
473
482
  try {
474
483
  const body = await req.json() as any;
475
- const result = changePassword(resolved.user.user_id, body.old_password, body.new_password);
476
- if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "password_changed", "user", resolved.user.user_id);
477
- return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
484
+ const result = changePassword(resolved.user.user_id, body.old_password, body.new_password, resolved.tokenId);
485
+ if (result.ok) {
486
+ const issued = issueUserToken(resolved.user.user_id, "password-change");
487
+ if (resolved.tokenId) revokeToken(resolved.user.user_id, resolved.tokenId);
488
+ logAudit(resolved.user.user_id, resolved.user.username, "password_changed", "user", resolved.user.user_id);
489
+ return withCors(req, Response.json({ ...result, token: issued.token, token_id: issued.token_id }));
490
+ }
491
+ return withCors(req, Response.json(result, { status: 400 }));
478
492
  } catch (e: any) {
479
493
  return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
480
494
  }
@@ -706,8 +720,8 @@ Bun.serve({
706
720
  sessions_count: count?.cnt ?? 0,
707
721
  sse_connections: sse.total,
708
722
  sse_sessions: sse.sessions,
709
- auth: AUTH_TOKEN ? "enabled" : "dev-open",
710
- security: AUTH_TOKEN ? "secured" : "dev-open",
723
+ auth: DEV_OPEN ? "dev-open" : "user-token",
724
+ security: DEV_OPEN ? "dev-open" : "secured",
711
725
  tmux: TMUX_ENABLED ? "enabled" : "disabled",
712
726
  v3_auth: true,
713
727
  multi_network: true,
@@ -0,0 +1,100 @@
1
+ const WEAK_PASSWORDS_RAW = `
2
+ 123456
3
+ password
4
+ 123456789
5
+ 12345
6
+ 12345678
7
+ qwerty
8
+ 1234567
9
+ 111111
10
+ 1234567890
11
+ 123123
12
+ abc123
13
+ 1234
14
+ password1
15
+ iloveyou
16
+ 1q2w3e4r
17
+ 000000
18
+ qwerty123
19
+ zaq12wsx
20
+ dragon
21
+ sunshine
22
+ princess
23
+ letmein
24
+ monkey
25
+ football
26
+ baseball
27
+ welcome
28
+ admin
29
+ login
30
+ master
31
+ hello
32
+ freedom
33
+ whatever
34
+ trustno1
35
+ qazwsx
36
+ 654321
37
+ superman
38
+ batman
39
+ passw0rd
40
+ password123
41
+ asdfgh
42
+ zxcvbnm
43
+ qwertyuiop
44
+ 1qaz2wsx
45
+ lovely
46
+ flower
47
+ hunter
48
+ shadow
49
+ buster
50
+ soccer
51
+ hockey
52
+ killer
53
+ george
54
+ charlie
55
+ andrew
56
+ michael
57
+ jessica
58
+ michelle
59
+ pepper
60
+ jordan
61
+ harley
62
+ ranger
63
+ ginger
64
+ joshua
65
+ maggie
66
+ mustang
67
+ computer
68
+ internet
69
+ secret
70
+ summer
71
+ winter
72
+ spring
73
+ autumn
74
+ orange
75
+ banana
76
+ cookie
77
+ coffee
78
+ matrix
79
+ starwars
80
+ pokemon
81
+ naruto
82
+ 888888
83
+ 666666
84
+ 5201314
85
+ 121212
86
+ 112233
87
+ 159753
88
+ 987654321
89
+ `;
90
+
91
+ const weak = new Set(WEAK_PASSWORDS_RAW.trim().split(/\s+/).map((p) => p.toLowerCase()));
92
+
93
+ for (let i = 0; i <= 999; i++) {
94
+ weak.add(String(i).padStart(6, "0"));
95
+ weak.add(`password${i}`);
96
+ weak.add(`qwerty${i}`);
97
+ }
98
+
99
+ export const WEAK_PASSWORDS = weak;
100
+