@sleep2agi/commhub-server 0.5.0-preview.8 → 0.5.0-preview.9

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.5.0-preview.8",
3
+ "version": "0.5.0-preview.9",
4
4
  "description": "CommHub MCP Server — AI Agent communication hub with SSE push, MCP protocol, and REST API",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/auth.ts ADDED
@@ -0,0 +1,134 @@
1
+ /**
2
+ * V3 Auth module — user registration, login, token management
3
+ */
4
+ import { db, generateId, hashPassword, hashToken, generateToken, uuidv4 } from "./db.js";
5
+
6
+ export interface AuthUser {
7
+ user_id: string;
8
+ username: string;
9
+ display_name: string | null;
10
+ email: string | null;
11
+ role: string;
12
+ }
13
+
14
+ export interface AuthResult {
15
+ ok: boolean;
16
+ error?: string;
17
+ user?: AuthUser;
18
+ token?: string;
19
+ }
20
+
21
+ export function register(username: string, password: string, email?: string, displayName?: string): AuthResult {
22
+ if (!username || username.length < 2) return { ok: false, error: "username must be at least 2 characters" };
23
+ if (!password || password.length < 6) return { ok: false, error: "password must be at least 6 characters" };
24
+ if (!/^[a-zA-Z0-9_\-\u4e00-\u9fff]+$/.test(username)) return { ok: false, error: "username contains invalid characters" };
25
+
26
+ const existing = db.query<any, [string]>("SELECT user_id FROM users WHERE username = ?1").get(username);
27
+ if (existing) return { ok: false, error: "username already taken" };
28
+
29
+ const userId = generateId("u");
30
+ const pwHash = hashPassword(password);
31
+
32
+ db.run(
33
+ "INSERT INTO users (user_id, username, password_hash, email, display_name) VALUES (?1, ?2, ?3, ?4, ?5)",
34
+ [userId, username, pwHash, email || null, displayName || username]
35
+ );
36
+
37
+ // Auto-create default network
38
+ const networkId = generateId("net");
39
+ db.run(
40
+ "INSERT INTO networks (network_id, network_name, owner_id, description) VALUES (?1, ?2, ?3, ?4)",
41
+ [networkId, "default", userId, "Auto-created default network"]
42
+ );
43
+
44
+ // Auto-create API token
45
+ const token = generateToken();
46
+ const tokenId = generateId("tok");
47
+ db.run(
48
+ "INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
49
+ [tokenId, hashToken(token), userId, networkId, "default", "full"]
50
+ );
51
+
52
+ return {
53
+ ok: true,
54
+ user: { user_id: userId, username, display_name: displayName || username, email: email || null, role: "user" },
55
+ token,
56
+ };
57
+ }
58
+
59
+ export function login(username: string, password: string): AuthResult {
60
+ const user = db.query<any, [string]>(
61
+ "SELECT user_id, username, password_hash, display_name, email, role FROM users WHERE username = ?1"
62
+ ).get(username);
63
+
64
+ if (!user) return { ok: false, error: "invalid username or password" };
65
+ if (user.password_hash !== hashPassword(password)) return { ok: false, error: "invalid username or password" };
66
+
67
+ // Find or create token
68
+ let tokenRow = db.query<any, [string]>(
69
+ "SELECT token_id FROM api_tokens WHERE user_id = ?1 ORDER BY created_at DESC LIMIT 1"
70
+ ).get(user.user_id);
71
+
72
+ let token: string;
73
+ if (tokenRow) {
74
+ // Generate new token (rotate)
75
+ token = generateToken();
76
+ db.run("UPDATE api_tokens SET token_hash = ?1, last_used_at = datetime('now') WHERE token_id = ?2",
77
+ [hashToken(token), tokenRow.token_id]);
78
+ } else {
79
+ token = generateToken();
80
+ const tokenId = generateId("tok");
81
+ const networkId = db.query<any, [string]>(
82
+ "SELECT network_id FROM networks WHERE owner_id = ?1 LIMIT 1"
83
+ ).get(user.user_id)?.network_id;
84
+ db.run(
85
+ "INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name) VALUES (?1, ?2, ?3, ?4, ?5)",
86
+ [tokenId, hashToken(token), user.user_id, networkId || null, "login"]
87
+ );
88
+ }
89
+
90
+ return {
91
+ ok: true,
92
+ user: { user_id: user.user_id, username: user.username, display_name: user.display_name, email: user.email, role: user.role },
93
+ token,
94
+ };
95
+ }
96
+
97
+ export function resolveToken(token: string): { user: AuthUser; networkId: string | null } | null {
98
+ const tHash = hashToken(token);
99
+ const row = db.query<any, [string]>(
100
+ `SELECT t.user_id, t.network_id, t.scope, u.username, u.display_name, u.email, u.role
101
+ FROM api_tokens t JOIN users u ON t.user_id = u.user_id
102
+ WHERE t.token_hash = ?1 AND (t.expires_at IS NULL OR t.expires_at > datetime('now'))`
103
+ ).get(tHash);
104
+
105
+ if (!row) return null;
106
+
107
+ // Update last_used
108
+ db.run("UPDATE api_tokens SET last_used_at = datetime('now') WHERE token_hash = ?1", [tHash]);
109
+
110
+ return {
111
+ user: { user_id: row.user_id, username: row.username, display_name: row.display_name, email: row.email, role: row.role },
112
+ networkId: row.network_id,
113
+ };
114
+ }
115
+
116
+ export function getUserNetworks(userId: string) {
117
+ return db.query<any, [string]>(
118
+ "SELECT * FROM networks WHERE owner_id = ?1 ORDER BY created_at"
119
+ ).all(userId);
120
+ }
121
+
122
+ export function createNetwork(userId: string, name: string, description?: string) {
123
+ const existing = db.query<any, [string, string]>(
124
+ "SELECT network_id FROM networks WHERE owner_id = ?1 AND network_name = ?2"
125
+ ).get(userId, name);
126
+ if (existing) return { ok: false, error: "network name already exists" };
127
+
128
+ const networkId = generateId("net");
129
+ db.run(
130
+ "INSERT INTO networks (network_id, network_name, owner_id, description) VALUES (?1, ?2, ?3, ?4)",
131
+ [networkId, name, userId, description || null]
132
+ );
133
+ return { ok: true, network_id: networkId, network_name: name };
134
+ }
package/src/db.ts CHANGED
@@ -151,11 +151,85 @@ db.exec(`
151
151
  CREATE INDEX IF NOT EXISTS idx_task_events_created ON task_events(created_at);
152
152
  `);
153
153
 
154
+ // ── V3: users table ──
155
+ db.exec(`
156
+ CREATE TABLE IF NOT EXISTS users (
157
+ user_id TEXT PRIMARY KEY,
158
+ username TEXT UNIQUE NOT NULL,
159
+ password_hash TEXT NOT NULL,
160
+ email TEXT,
161
+ display_name TEXT,
162
+ role TEXT DEFAULT 'user',
163
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
164
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
165
+ );
166
+
167
+ CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
168
+ `);
169
+
170
+ // ── V3: networks table ──
171
+ db.exec(`
172
+ CREATE TABLE IF NOT EXISTS networks (
173
+ network_id TEXT PRIMARY KEY,
174
+ network_name TEXT NOT NULL,
175
+ owner_id TEXT NOT NULL,
176
+ description TEXT,
177
+ settings TEXT,
178
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
179
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
180
+ UNIQUE(owner_id, network_name)
181
+ );
182
+
183
+ CREATE INDEX IF NOT EXISTS idx_networks_owner ON networks(owner_id);
184
+ `);
185
+
186
+ // ── V3: api_tokens table ──
187
+ db.exec(`
188
+ CREATE TABLE IF NOT EXISTS api_tokens (
189
+ token_id TEXT PRIMARY KEY,
190
+ token_hash TEXT NOT NULL,
191
+ user_id TEXT NOT NULL,
192
+ network_id TEXT,
193
+ name TEXT NOT NULL DEFAULT 'default',
194
+ scope TEXT DEFAULT 'full',
195
+ expires_at TEXT,
196
+ last_used_at TEXT,
197
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
198
+ );
199
+
200
+ CREATE INDEX IF NOT EXISTS idx_tokens_hash ON api_tokens(token_hash);
201
+ CREATE INDEX IF NOT EXISTS idx_tokens_user ON api_tokens(user_id);
202
+ `);
203
+
204
+ // ── V3: add network_id to existing tables ──
205
+ for (const table of ["sessions", "nodes", "tasks", "inbox", "task_events"]) {
206
+ try { db.exec(`ALTER TABLE ${table} ADD COLUMN network_id TEXT`); } catch {}
207
+ }
208
+ try { db.exec("CREATE INDEX IF NOT EXISTS idx_sessions_network ON sessions(network_id)"); } catch {}
209
+ try { db.exec("CREATE INDEX IF NOT EXISTS idx_tasks_network ON tasks(network_id)"); } catch {}
210
+ try { db.exec("CREATE INDEX IF NOT EXISTS idx_nodes_network ON nodes(network_id)"); } catch {}
211
+
154
212
  // Helpers
155
213
  export function uuidv4(): string {
156
214
  return crypto.randomUUID();
157
215
  }
158
216
 
217
+ export function generateId(prefix: string): string {
218
+ return `${prefix}_${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`;
219
+ }
220
+
221
+ export function hashPassword(password: string): string {
222
+ return new Bun.CryptoHasher("sha256").update(`anet:${password}`).digest("hex");
223
+ }
224
+
225
+ export function hashToken(token: string): string {
226
+ return new Bun.CryptoHasher("sha256").update(token).digest("hex");
227
+ }
228
+
229
+ export function generateToken(): string {
230
+ return `atok_${crypto.randomUUID().replace(/-/g, "")}`;
231
+ }
232
+
159
233
  export function logTaskEvent(taskId: string, fromStatus: string | null, toStatus: string, actor: string, detail?: string) {
160
234
  try {
161
235
  db.run(
package/src/index.ts CHANGED
@@ -4,6 +4,7 @@ import { z } from "zod/v4";
4
4
  import { registerTools } from "./tools.js";
5
5
  import { db, logTaskEvent } from "./db.js";
6
6
  import { createSSEStream, pushEvent, pushBroadcast, getSSEStats } from "./push.js";
7
+ import { register, login, resolveToken, getUserNetworks, createNetwork, type AuthUser } from "./auth.js";
7
8
 
8
9
  const PORT = Number(process.env.PORT) || 9200;
9
10
  const AUTH_TOKEN = process.env.COMMHUB_AUTH_TOKEN;
@@ -140,6 +141,60 @@ Bun.serve({
140
141
  return createSSEStream(sessionName);
141
142
  }
142
143
 
144
+ // ── V3: Auth endpoints (public) ──
145
+ if (url.pathname === "/api/auth/register" && req.method === "POST") {
146
+ try {
147
+ const body = await req.json() as any;
148
+ const result = register(body.username, body.password, body.email, body.display_name);
149
+ return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
150
+ } catch (e: any) {
151
+ return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
152
+ }
153
+ }
154
+
155
+ if (url.pathname === "/api/auth/login" && req.method === "POST") {
156
+ try {
157
+ const body = await req.json() as any;
158
+ const result = login(body.username, body.password);
159
+ return withCors(req, Response.json(result, { status: result.ok ? 200 : 401 }));
160
+ } catch (e: any) {
161
+ return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
162
+ }
163
+ }
164
+
165
+ if (url.pathname === "/api/auth/me" && req.method === "GET") {
166
+ const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
167
+ if (!token) return withCors(req, Response.json({ ok: false, error: "token required" }, { status: 401 }));
168
+ const resolved = resolveToken(token);
169
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
170
+ const networks = getUserNetworks(resolved.user.user_id);
171
+ return withCors(req, Response.json({ ok: true, user: resolved.user, networks, current_network: resolved.networkId }));
172
+ }
173
+
174
+ // ── V3: Network management ──
175
+ if (url.pathname === "/api/networks" && req.method === "GET") {
176
+ const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
177
+ if (!token) return withCors(req, Response.json({ ok: false, error: "token required" }, { status: 401 }));
178
+ const resolved = resolveToken(token);
179
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
180
+ const networks = getUserNetworks(resolved.user.user_id);
181
+ return withCors(req, Response.json({ ok: true, networks }));
182
+ }
183
+
184
+ if (url.pathname === "/api/networks" && req.method === "POST") {
185
+ const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
186
+ if (!token) return withCors(req, Response.json({ ok: false, error: "token required" }, { status: 401 }));
187
+ const resolved = resolveToken(token);
188
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
189
+ try {
190
+ const body = await req.json() as any;
191
+ const result = createNetwork(resolved.user.user_id, body.name, body.description);
192
+ return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
193
+ } catch (e: any) {
194
+ return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
195
+ }
196
+ }
197
+
143
198
  // ── REST: health (public, no auth) ──
144
199
  if (url.pathname === "/health") {
145
200
  const count = db.query<{ cnt: number }, []>("SELECT COUNT(*) as cnt FROM sessions").get();