@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 +1 -1
- package/src/auth.ts +56 -7
- package/src/index.ts +25 -11
- package/src/password-dict.ts +100 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sleep2agi/commhub-server",
|
|
3
|
-
"version": "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
|
|
243
|
-
|
|
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
|
-
|
|
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 =
|
|
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 (
|
|
23
|
-
console.
|
|
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)
|
|
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)
|
|
477
|
-
|
|
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:
|
|
710
|
-
security:
|
|
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
|
+
|