@sleep2agi/commhub-server 0.7.0-preview.0 → 0.8.0-preview.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/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.1",
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,18 +21,31 @@ 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" };
27
33
  if (!/^[a-zA-Z0-9_\-\u4e00-\u9fff]+$/.test(username)) return { ok: false, error: "username contains invalid characters" };
28
34
 
29
35
  const existing = db.get<any>("SELECT user_id FROM users WHERE username = ?1", username);
30
36
  if (existing) return { ok: false, error: "username already taken" };
31
37
 
32
- // First user → auto admin
38
+ // First user → auto admin. Bootstrap admin may use a weak/memorable default
39
+ // password (e.g. "anethub") for quick start; they should rotate via
40
+ // `anet passwd` afterwards. Subsequent users must meet full strength.
33
41
  const userCount = db.get<{ cnt: number }>("SELECT COUNT(*) as cnt FROM users");
34
42
  const isFirstUser = !userCount || userCount.cnt === 0;
43
+ if (isFirstUser) {
44
+ if (!password || password.length < 4) return { ok: false, error: "password must be at least 4 characters" };
45
+ } else {
46
+ const passwordError = validatePasswordStrength(password);
47
+ if (passwordError) return { ok: false, error: passwordError };
48
+ }
35
49
 
36
50
  const userId = generateId("u");
37
51
  const pwHash = hashPassword(password);
@@ -126,10 +140,10 @@ export function createNetworkTokenForNode(userId: string, networkId: string, nod
126
140
  return { ok: true, token };
127
141
  }
128
142
 
129
- export function resolveToken(token: string): { user: AuthUser; networkId: string | null; tokenName: string | null } | null {
143
+ export function resolveToken(token: string): { user: AuthUser; networkId: string | null; tokenName: string | null; tokenId: string | null } | null {
130
144
  const tHash = hashToken(token);
131
145
  const row = db.get<any>(
132
- `SELECT t.user_id, t.network_id, t.scope, t.name AS token_name,
146
+ `SELECT t.token_id, t.user_id, t.network_id, t.scope, t.name AS token_name,
133
147
  u.username, u.display_name, u.email, u.role
134
148
  FROM api_tokens t JOIN users u ON t.user_id = u.user_id
135
149
  WHERE t.token_hash = ?1 AND (t.expires_at IS NULL OR t.expires_at > datetime('now'))`,
@@ -143,6 +157,7 @@ export function resolveToken(token: string): { user: AuthUser; networkId: string
143
157
  return {
144
158
  user: { user_id: row.user_id, username: row.username, display_name: row.display_name, email: row.email, role: row.role },
145
159
  networkId: row.network_id,
160
+ tokenId: row.token_id || null,
146
161
  // tokenName carries the binding identity. For node-scoped ntok_, it's
147
162
  // 'node:<alias>'; we strip the prefix and use it as the default
148
163
  // from_session for any MCP send_task / send_message / etc, so peer
@@ -239,13 +254,47 @@ export function revokeToken(userId: string, tokenId: string): { ok: boolean; err
239
254
  return result.changes > 0 ? { ok: true } : { ok: false, error: "token not found" };
240
255
  }
241
256
 
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" };
257
+ export function issueUserToken(userId: string, name = "user-login"): { token: string; token_id: string } {
258
+ const token = generateUserToken();
259
+ const tokenId = generateId("tok");
260
+ db.run(
261
+ "INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope) VALUES (?1, ?2, ?3, NULL, ?4, 'user')",
262
+ [tokenId, hashToken(token), userId, name]
263
+ );
264
+ return { token, token_id: tokenId };
265
+ }
266
+
267
+ export function revokeOtherUserTokens(userId: string, exceptTokenId?: string | null): number {
268
+ const result = exceptTokenId
269
+ ? db.run("DELETE FROM api_tokens WHERE user_id = ?1 AND network_id IS NULL AND token_id != ?2", [userId, exceptTokenId])
270
+ : db.run("DELETE FROM api_tokens WHERE user_id = ?1 AND network_id IS NULL", [userId]);
271
+ return result.changes;
272
+ }
273
+
274
+ export function changePassword(userId: string, oldPassword: string, newPassword: string, currentTokenId?: string | null): { ok: boolean; error?: string; revoked?: number } {
275
+ const passwordError = validatePasswordStrength(newPassword, "new password");
276
+ if (passwordError) return { ok: false, error: passwordError };
244
277
  const user = db.get<any>("SELECT password_hash FROM users WHERE user_id = ?1", userId);
245
278
  if (!user) return { ok: false, error: "user not found" };
246
279
  if (user.password_hash !== hashPassword(oldPassword)) return { ok: false, error: "incorrect current password" };
247
280
  db.run("UPDATE users SET password_hash = ?1, updated_at = datetime('now') WHERE user_id = ?2", [hashPassword(newPassword), userId]);
248
- return { ok: true };
281
+ const revoked = revokeOtherUserTokens(userId, currentTokenId);
282
+ return { ok: true, revoked };
283
+ }
284
+
285
+ 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 } {
286
+ if (!callerIsHubAdmin) return { ok: false, error: "hub admin required" };
287
+ const user = db.get<any>("SELECT user_id, username FROM users WHERE username = ?1", targetUsername);
288
+ if (!user) return { ok: false, error: "user not found" };
289
+ const password = `anet-${crypto.randomUUID().replace(/-/g, "").slice(0, 18)}`;
290
+ db.run("UPDATE users SET password_hash = ?1, updated_at = datetime('now') WHERE user_id = ?2", [hashPassword(password), user.user_id]);
291
+ const revoked = revokeOtherUserTokens(user.user_id, null);
292
+ const issued = issueUserToken(user.user_id, "admin-reset");
293
+ db.run(
294
+ "INSERT INTO audit_log (user_id, username, action, target_type, target_id, detail) VALUES (?1, ?2, 'password_reset_by_admin', 'user', ?3, ?4)",
295
+ [user.user_id, user.username, user.user_id, "local hub admin reset"]
296
+ );
297
+ return { ok: true, username: user.username, user_id: user.user_id, password, token: issued.token, token_id: issued.token_id, revoked };
249
298
  }
250
299
 
251
300
  // ══════════════════════════════════════
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
+