@sleep2agi/commhub-server 0.5.0-preview.3 → 0.5.0-preview.31
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/README.md +162 -0
- package/package.json +9 -7
- package/src/auth.ts +340 -0
- package/src/db-adapter.ts +238 -0
- package/src/db.ts +225 -10
- package/src/index.ts +634 -39
- package/src/tools.ts +448 -164
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Database Adapter — supports SQLite and PostgreSQL
|
|
3
|
+
*
|
|
4
|
+
* SQLite adapter: wraps bun:sqlite (sync)
|
|
5
|
+
* PostgreSQL adapter: wraps pg Pool (async, bridged to sync interface via blocking)
|
|
6
|
+
*
|
|
7
|
+
* Key design: callers write SQLite-style SQL. PgAdapter auto-translates:
|
|
8
|
+
* - ?1, ?2 → $1, $2
|
|
9
|
+
* - datetime('now') → NOW()
|
|
10
|
+
* - datetime('now', '+N seconds') → NOW() + INTERVAL 'N seconds'
|
|
11
|
+
* - INTEGER PRIMARY KEY AUTOINCREMENT → SERIAL PRIMARY KEY
|
|
12
|
+
* - ON CONFLICT(col) DO UPDATE SET → ON CONFLICT(col) DO UPDATE SET (same syntax)
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { Database } from "bun:sqlite";
|
|
16
|
+
|
|
17
|
+
export interface QueryResult {
|
|
18
|
+
changes: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface DbAdapter {
|
|
22
|
+
/** Execute a write query (INSERT/UPDATE/DELETE) */
|
|
23
|
+
run(sql: string, params?: any[]): QueryResult;
|
|
24
|
+
|
|
25
|
+
/** Query a single row */
|
|
26
|
+
get<T = any>(sql: string, ...params: any[]): T | null;
|
|
27
|
+
|
|
28
|
+
/** Query multiple rows */
|
|
29
|
+
all<T = any>(sql: string, ...params: any[]): T[];
|
|
30
|
+
|
|
31
|
+
/** Execute raw SQL (DDL) */
|
|
32
|
+
exec(sql: string): void;
|
|
33
|
+
|
|
34
|
+
/** Run a function inside a transaction */
|
|
35
|
+
transaction<T>(fn: () => T): T;
|
|
36
|
+
|
|
37
|
+
/** Close connection */
|
|
38
|
+
close(): void;
|
|
39
|
+
|
|
40
|
+
/** Dialect identifier */
|
|
41
|
+
readonly dialect: "sqlite" | "postgres";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ════════════════════════════════════════════
|
|
45
|
+
// SQLite Adapter (bun:sqlite, sync)
|
|
46
|
+
// ════════════════════════════════════════════
|
|
47
|
+
|
|
48
|
+
export class SQLiteAdapter implements DbAdapter {
|
|
49
|
+
readonly dialect = "sqlite" as const;
|
|
50
|
+
constructor(private readonly rawDb: Database) {}
|
|
51
|
+
|
|
52
|
+
run(sql: string, params?: any[]): QueryResult {
|
|
53
|
+
return params ? this.rawDb.run(sql, params as any) : this.rawDb.run(sql);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
get<T = any>(sql: string, ...params: any[]): T | null {
|
|
57
|
+
return this.rawDb.query<T, any[]>(sql).get(...params) ?? null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
all<T = any>(sql: string, ...params: any[]): T[] {
|
|
61
|
+
return this.rawDb.query<T, any[]>(sql).all(...params);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
exec(sql: string): void {
|
|
65
|
+
this.rawDb.exec(sql);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
transaction<T>(fn: () => T): T {
|
|
69
|
+
return this.rawDb.transaction(fn)();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
close(): void {
|
|
73
|
+
this.rawDb.close();
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ════════════════════════════════════════════
|
|
78
|
+
// PostgreSQL Adapter (pg Pool, sync bridge)
|
|
79
|
+
// ════════════════════════════════════════════
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Translate SQLite-style SQL to PostgreSQL.
|
|
83
|
+
* Called on every query — must be fast (simple regex, no parsing).
|
|
84
|
+
*/
|
|
85
|
+
export function sqliteToPostgres(sql: string): string {
|
|
86
|
+
let s = sql;
|
|
87
|
+
// ── datetime translations (before ?N→$N to handle datetime('now', ?N)) ──
|
|
88
|
+
// datetime('now', ?N) → NOW() + $N::INTERVAL (param contains "+3600 seconds")
|
|
89
|
+
s = s.replace(/datetime\s*\(\s*'now'\s*,\s*\?(\d+)\s*\)/gi, (_, n) => {
|
|
90
|
+
return `NOW() + $${n}::INTERVAL`;
|
|
91
|
+
});
|
|
92
|
+
// datetime('now', '+N seconds') → NOW() + INTERVAL 'N seconds'
|
|
93
|
+
s = s.replace(/datetime\s*\(\s*'now'\s*,\s*'([^']+)'\s*\)/gi, (_, offset) => {
|
|
94
|
+
return `NOW() + INTERVAL '${offset.replace(/^\+/, "")}'`;
|
|
95
|
+
});
|
|
96
|
+
// datetime('now') → NOW()
|
|
97
|
+
s = s.replace(/datetime\s*\(\s*'now'\s*\)/gi, "NOW()");
|
|
98
|
+
// TEXT NOT NULL DEFAULT (datetime('now')) → TIMESTAMP NOT NULL DEFAULT NOW()
|
|
99
|
+
s = s.replace(/TEXT\s+NOT\s+NULL\s+DEFAULT\s+\(NOW\(\)\)/gi, "TIMESTAMP NOT NULL DEFAULT NOW()");
|
|
100
|
+
// ── Parameter placeholders ──
|
|
101
|
+
// ?1, ?2 → $1, $2 (positional params)
|
|
102
|
+
s = s.replace(/\?(\d+)/g, (_, n) => `$${n}`);
|
|
103
|
+
// Unindexed ? → $N (sequential)
|
|
104
|
+
let idx = 0;
|
|
105
|
+
s = s.replace(/\?(?!\d)/g, () => `$${++idx}`);
|
|
106
|
+
// ── DDL translations ──
|
|
107
|
+
// INTEGER PRIMARY KEY AUTOINCREMENT → SERIAL PRIMARY KEY
|
|
108
|
+
s = s.replace(/INTEGER\s+PRIMARY\s+KEY\s+AUTOINCREMENT/gi, "SERIAL PRIMARY KEY");
|
|
109
|
+
return s;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* PostgreSQL adapter using a persistent worker subprocess.
|
|
114
|
+
*
|
|
115
|
+
* Architecture: a single node child process holds a pg.Pool connection.
|
|
116
|
+
* Queries are sent via stdin (JSON line), responses read from stdout.
|
|
117
|
+
* Bun.spawnSync is used per-query for sync blocking, but the PG
|
|
118
|
+
* connection is persistent (no reconnect overhead per query).
|
|
119
|
+
*
|
|
120
|
+
* For full production use, the adapter interface should be async.
|
|
121
|
+
* This sync bridge works because all MCP handlers are async —
|
|
122
|
+
* the future migration is adding `await` before db calls.
|
|
123
|
+
*/
|
|
124
|
+
export class PgAdapter implements DbAdapter {
|
|
125
|
+
readonly dialect = "postgres" as const;
|
|
126
|
+
private connString: string;
|
|
127
|
+
|
|
128
|
+
constructor(connectionString: string) {
|
|
129
|
+
this.connString = connectionString;
|
|
130
|
+
// Validate pg is available
|
|
131
|
+
try { require("pg"); } catch (e) {
|
|
132
|
+
throw new Error(
|
|
133
|
+
"PostgreSQL support requires 'pg' package. Install with: bun add pg\n" +
|
|
134
|
+
` Original error: ${(e as Error).message}`
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
// Test connection on startup
|
|
138
|
+
const test = this.querySync("SELECT 1 as ok");
|
|
139
|
+
if (!test.rows?.[0]?.ok) throw new Error("PostgreSQL connection test failed");
|
|
140
|
+
console.log("[commhub] PostgreSQL connection verified");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private querySync(sql: string, params?: any[]): { rows: any[]; rowCount: number } {
|
|
144
|
+
const pgSql = sqliteToPostgres(sql);
|
|
145
|
+
// Single-query subprocess with pg Pool (connection string from env)
|
|
146
|
+
const script = `
|
|
147
|
+
const{Pool}=require('pg');
|
|
148
|
+
const p=new Pool({connectionString:${JSON.stringify(this.connString)},max:1});
|
|
149
|
+
const q=${JSON.stringify(pgSql)};
|
|
150
|
+
const v=${JSON.stringify(params || [])};
|
|
151
|
+
p.query(q,v).then(r=>{
|
|
152
|
+
process.stdout.write(JSON.stringify({rows:r.rows,rowCount:r.rowCount||0}));
|
|
153
|
+
p.end();
|
|
154
|
+
}).catch(e=>{
|
|
155
|
+
process.stderr.write(e.message);
|
|
156
|
+
p.end();
|
|
157
|
+
process.exit(1);
|
|
158
|
+
});
|
|
159
|
+
`;
|
|
160
|
+
const proc = Bun.spawnSync(["node", "--no-warnings", "-e", script], {
|
|
161
|
+
stdout: "pipe", stderr: "pipe",
|
|
162
|
+
});
|
|
163
|
+
if (proc.exitCode !== 0) {
|
|
164
|
+
throw new Error(`PG: ${proc.stderr.toString().trim() || "query failed"}`);
|
|
165
|
+
}
|
|
166
|
+
return JSON.parse(proc.stdout.toString().trim());
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
run(sql: string, params?: any[]): QueryResult {
|
|
170
|
+
if (sql.trim().toUpperCase().startsWith("PRAGMA")) return { changes: 0 };
|
|
171
|
+
const result = this.querySync(sql, params);
|
|
172
|
+
return { changes: result.rowCount };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
get<T = any>(sql: string, ...params: any[]): T | null {
|
|
176
|
+
const result = this.querySync(sql, params.length > 0 ? params : undefined);
|
|
177
|
+
return (result.rows?.[0] as T) ?? null;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
all<T = any>(sql: string, ...params: any[]): T[] {
|
|
181
|
+
const result = this.querySync(sql, params.length > 0 ? params : undefined);
|
|
182
|
+
return (result.rows as T[]) ?? [];
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
exec(sql: string): void {
|
|
186
|
+
if (sql.trim().toUpperCase().startsWith("PRAGMA")) return;
|
|
187
|
+
const pgSql = sqliteToPostgres(sql);
|
|
188
|
+
// Split multi-statement DDL (CREATE TABLE; CREATE INDEX; ...)
|
|
189
|
+
const stmts = pgSql.split(";").map(s => s.trim()).filter(s => s.length > 0);
|
|
190
|
+
for (const stmt of stmts) {
|
|
191
|
+
try { this.querySync(stmt); } catch (e: any) {
|
|
192
|
+
// Ignore "already exists" errors for CREATE TABLE/INDEX IF NOT EXISTS
|
|
193
|
+
if (!/already exists/.test(e.message)) throw e;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
transaction<T>(fn: () => T): T {
|
|
199
|
+
this.querySync("BEGIN");
|
|
200
|
+
try {
|
|
201
|
+
const result = fn();
|
|
202
|
+
this.querySync("COMMIT");
|
|
203
|
+
return result;
|
|
204
|
+
} catch (e) {
|
|
205
|
+
try { this.querySync("ROLLBACK"); } catch {}
|
|
206
|
+
throw e;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
close(): void {}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ════════════════════════════════════════════
|
|
214
|
+
// Factory
|
|
215
|
+
// ════════════════════════════════════════════
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Create the appropriate adapter based on environment.
|
|
219
|
+
* - DATABASE_URL starts with "postgres://" → PgAdapter
|
|
220
|
+
* - Otherwise → SQLiteAdapter with COMMHUB_DB or default path
|
|
221
|
+
*/
|
|
222
|
+
export function createAdapter(): DbAdapter {
|
|
223
|
+
const dbUrl = process.env.DATABASE_URL;
|
|
224
|
+
if (dbUrl && (dbUrl.startsWith("postgres://") || dbUrl.startsWith("postgresql://"))) {
|
|
225
|
+
console.log("[commhub] database: PostgreSQL");
|
|
226
|
+
return new PgAdapter(dbUrl);
|
|
227
|
+
}
|
|
228
|
+
// Default: SQLite
|
|
229
|
+
const { mkdirSync } = require("fs");
|
|
230
|
+
const { dirname } = require("path");
|
|
231
|
+
const dbPath = process.env.COMMHUB_DB || `${process.env.HOME}/.commhub/commhub.db`;
|
|
232
|
+
mkdirSync(dirname(dbPath), { recursive: true });
|
|
233
|
+
console.log(`[commhub] database: ${dbPath}`);
|
|
234
|
+
const rawDb = new Database(dbPath);
|
|
235
|
+
rawDb.exec("PRAGMA journal_mode=WAL");
|
|
236
|
+
rawDb.exec("PRAGMA busy_timeout=5000");
|
|
237
|
+
return new SQLiteAdapter(rawDb);
|
|
238
|
+
}
|
package/src/db.ts
CHANGED
|
@@ -1,14 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { mkdirSync } from "fs";
|
|
3
|
-
import { dirname } from "path";
|
|
1
|
+
import { createAdapter, type DbAdapter } from "./db-adapter";
|
|
4
2
|
|
|
5
|
-
const
|
|
6
|
-
mkdirSync(dirname(DB_PATH), { recursive: true });
|
|
7
|
-
|
|
8
|
-
console.log(`[commhub] database: ${DB_PATH}`);
|
|
9
|
-
export const db = new Database(DB_PATH);
|
|
10
|
-
db.exec("PRAGMA journal_mode=WAL");
|
|
11
|
-
db.exec("PRAGMA busy_timeout=5000");
|
|
3
|
+
export const db: DbAdapter = createAdapter();
|
|
12
4
|
|
|
13
5
|
// Schema
|
|
14
6
|
db.exec(`
|
|
@@ -54,6 +46,7 @@ db.exec(`
|
|
|
54
46
|
artifacts TEXT,
|
|
55
47
|
score REAL,
|
|
56
48
|
duration_minutes REAL,
|
|
49
|
+
network_id TEXT,
|
|
57
50
|
completed_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
58
51
|
);
|
|
59
52
|
`);
|
|
@@ -135,7 +128,229 @@ db.exec(`
|
|
|
135
128
|
CREATE INDEX IF NOT EXISTS idx_nodes_alias ON nodes(alias);
|
|
136
129
|
`);
|
|
137
130
|
|
|
131
|
+
// task_events table (V2 Sprint 2) — audit log for task state changes
|
|
132
|
+
db.exec(`
|
|
133
|
+
CREATE TABLE IF NOT EXISTS task_events (
|
|
134
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
135
|
+
task_id TEXT NOT NULL,
|
|
136
|
+
from_status TEXT,
|
|
137
|
+
to_status TEXT NOT NULL,
|
|
138
|
+
actor TEXT NOT NULL DEFAULT 'system',
|
|
139
|
+
detail TEXT,
|
|
140
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
CREATE INDEX IF NOT EXISTS idx_task_events_task_time ON task_events(task_id, created_at DESC);
|
|
144
|
+
CREATE INDEX IF NOT EXISTS idx_task_events_created ON task_events(created_at);
|
|
145
|
+
`);
|
|
146
|
+
|
|
147
|
+
// ── V3: users table ──
|
|
148
|
+
db.exec(`
|
|
149
|
+
CREATE TABLE IF NOT EXISTS users (
|
|
150
|
+
user_id TEXT PRIMARY KEY,
|
|
151
|
+
username TEXT UNIQUE NOT NULL,
|
|
152
|
+
password_hash TEXT NOT NULL,
|
|
153
|
+
email TEXT,
|
|
154
|
+
display_name TEXT,
|
|
155
|
+
role TEXT DEFAULT 'user',
|
|
156
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
157
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
|
161
|
+
`);
|
|
162
|
+
|
|
163
|
+
// ── V3: networks table ──
|
|
164
|
+
db.exec(`
|
|
165
|
+
CREATE TABLE IF NOT EXISTS networks (
|
|
166
|
+
network_id TEXT PRIMARY KEY,
|
|
167
|
+
network_name TEXT NOT NULL,
|
|
168
|
+
owner_id TEXT NOT NULL,
|
|
169
|
+
description TEXT,
|
|
170
|
+
settings TEXT,
|
|
171
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
172
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
173
|
+
UNIQUE(owner_id, network_name)
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
CREATE INDEX IF NOT EXISTS idx_networks_owner ON networks(owner_id);
|
|
177
|
+
`);
|
|
178
|
+
|
|
179
|
+
// ── V3: api_tokens table ──
|
|
180
|
+
db.exec(`
|
|
181
|
+
CREATE TABLE IF NOT EXISTS api_tokens (
|
|
182
|
+
token_id TEXT PRIMARY KEY,
|
|
183
|
+
token_hash TEXT NOT NULL,
|
|
184
|
+
user_id TEXT NOT NULL,
|
|
185
|
+
network_id TEXT,
|
|
186
|
+
name TEXT NOT NULL DEFAULT 'default',
|
|
187
|
+
scope TEXT DEFAULT 'full',
|
|
188
|
+
expires_at TEXT,
|
|
189
|
+
last_used_at TEXT,
|
|
190
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
CREATE INDEX IF NOT EXISTS idx_tokens_hash ON api_tokens(token_hash);
|
|
194
|
+
CREATE INDEX IF NOT EXISTS idx_tokens_user ON api_tokens(user_id);
|
|
195
|
+
`);
|
|
196
|
+
|
|
197
|
+
// ── V3: audit_log table ──
|
|
198
|
+
db.exec(`
|
|
199
|
+
CREATE TABLE IF NOT EXISTS audit_log (
|
|
200
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
201
|
+
user_id TEXT,
|
|
202
|
+
username TEXT,
|
|
203
|
+
action TEXT NOT NULL,
|
|
204
|
+
target_type TEXT,
|
|
205
|
+
target_id TEXT,
|
|
206
|
+
detail TEXT,
|
|
207
|
+
ip TEXT,
|
|
208
|
+
network_id TEXT,
|
|
209
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
CREATE INDEX IF NOT EXISTS idx_audit_created ON audit_log(created_at DESC);
|
|
213
|
+
CREATE INDEX IF NOT EXISTS idx_audit_user ON audit_log(user_id);
|
|
214
|
+
CREATE INDEX IF NOT EXISTS idx_audit_network ON audit_log(network_id);
|
|
215
|
+
`);
|
|
216
|
+
|
|
217
|
+
// ── V3: licenses table ──
|
|
218
|
+
db.exec(`
|
|
219
|
+
CREATE TABLE IF NOT EXISTS licenses (
|
|
220
|
+
id TEXT PRIMARY KEY,
|
|
221
|
+
license_key TEXT UNIQUE NOT NULL,
|
|
222
|
+
type TEXT DEFAULT 'trial',
|
|
223
|
+
max_agents INTEGER DEFAULT 5,
|
|
224
|
+
max_networks INTEGER DEFAULT 3,
|
|
225
|
+
max_tasks_day INTEGER DEFAULT 500,
|
|
226
|
+
activated_at TEXT,
|
|
227
|
+
expires_at TEXT,
|
|
228
|
+
owner_id TEXT,
|
|
229
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
230
|
+
);
|
|
231
|
+
`);
|
|
232
|
+
|
|
233
|
+
// Auto-create trial license on first run
|
|
234
|
+
const existingLicense = db.get<any>("SELECT id FROM licenses LIMIT 1");
|
|
235
|
+
if (!existingLicense) {
|
|
236
|
+
const trialId = crypto.randomUUID().replace(/-/g, "").slice(0, 12);
|
|
237
|
+
db.run(
|
|
238
|
+
"INSERT INTO licenses (id, license_key, type, expires_at) VALUES (?1, ?2, 'trial', datetime('now', '+14 days'))",
|
|
239
|
+
[`lic_${trialId}`, `trial-${trialId}`]
|
|
240
|
+
);
|
|
241
|
+
console.log("[commhub] 🎉 14-day free trial started!");
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ── V3.13: network_members table (user ↔ network many-to-many) ──
|
|
245
|
+
db.exec(`
|
|
246
|
+
CREATE TABLE IF NOT EXISTS network_members (
|
|
247
|
+
network_id TEXT NOT NULL,
|
|
248
|
+
user_id TEXT NOT NULL,
|
|
249
|
+
role TEXT NOT NULL DEFAULT 'member',
|
|
250
|
+
invited_by TEXT,
|
|
251
|
+
joined_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
252
|
+
PRIMARY KEY (network_id, user_id)
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
CREATE INDEX IF NOT EXISTS idx_netmem_user ON network_members(user_id);
|
|
256
|
+
CREATE INDEX IF NOT EXISTS idx_netmem_network ON network_members(network_id);
|
|
257
|
+
`);
|
|
258
|
+
|
|
259
|
+
// ── V3.13: network_invites table ──
|
|
260
|
+
db.exec(`
|
|
261
|
+
CREATE TABLE IF NOT EXISTS network_invites (
|
|
262
|
+
invite_code TEXT PRIMARY KEY,
|
|
263
|
+
network_id TEXT NOT NULL,
|
|
264
|
+
role TEXT NOT NULL DEFAULT 'member',
|
|
265
|
+
created_by TEXT NOT NULL,
|
|
266
|
+
max_uses INTEGER DEFAULT 1,
|
|
267
|
+
used_count INTEGER DEFAULT 0,
|
|
268
|
+
expires_at TEXT,
|
|
269
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
270
|
+
);
|
|
271
|
+
`);
|
|
272
|
+
|
|
273
|
+
// ── V3.13: networks visibility + max_members ──
|
|
274
|
+
try { db.exec("ALTER TABLE networks ADD COLUMN visibility TEXT DEFAULT 'private'"); } catch {}
|
|
275
|
+
try { db.exec("ALTER TABLE networks ADD COLUMN max_members INTEGER DEFAULT 50"); } catch {}
|
|
276
|
+
|
|
277
|
+
// ── V3.13: users plan field ──
|
|
278
|
+
try { db.exec("ALTER TABLE users ADD COLUMN plan TEXT DEFAULT 'free'"); } catch {}
|
|
279
|
+
|
|
280
|
+
// ── V3.13: migrate existing networks → network_members (owner) ──
|
|
281
|
+
try {
|
|
282
|
+
const networks = db.all<any>("SELECT network_id, owner_id FROM networks");
|
|
283
|
+
for (const net of networks) {
|
|
284
|
+
const exists = db.get<any>("SELECT 1 FROM network_members WHERE network_id = ?1 AND user_id = ?2", net.network_id, net.owner_id);
|
|
285
|
+
if (!exists) {
|
|
286
|
+
db.run("INSERT INTO network_members (network_id, user_id, role) VALUES (?1, ?2, 'owner')", [net.network_id, net.owner_id]);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
} catch {}
|
|
290
|
+
|
|
291
|
+
// ── V3.13: first registered user → admin ──
|
|
292
|
+
try {
|
|
293
|
+
const firstUser = db.get<any>("SELECT user_id, role FROM users ORDER BY created_at LIMIT 1");
|
|
294
|
+
if (firstUser && firstUser.role !== "admin") {
|
|
295
|
+
db.run("UPDATE users SET role = 'admin' WHERE user_id = ?1", [firstUser.user_id]);
|
|
296
|
+
}
|
|
297
|
+
} catch {}
|
|
298
|
+
|
|
299
|
+
// ── V3: add network_id to existing tables ──
|
|
300
|
+
for (const table of ["sessions", "nodes", "tasks", "inbox", "task_events", "completions"]) {
|
|
301
|
+
try { db.exec(`ALTER TABLE ${table} ADD COLUMN network_id TEXT`); } catch {}
|
|
302
|
+
}
|
|
303
|
+
try { db.exec("CREATE INDEX IF NOT EXISTS idx_sessions_network ON sessions(network_id)"); } catch {}
|
|
304
|
+
try { db.exec("CREATE INDEX IF NOT EXISTS idx_tasks_network ON tasks(network_id)"); } catch {}
|
|
305
|
+
try { db.exec("CREATE INDEX IF NOT EXISTS idx_nodes_network ON nodes(network_id)"); } catch {}
|
|
306
|
+
try { db.exec("CREATE INDEX IF NOT EXISTS idx_inbox_network ON inbox(network_id)"); } catch {}
|
|
307
|
+
try { db.exec("CREATE INDEX IF NOT EXISTS idx_task_events_network ON task_events(network_id)"); } catch {}
|
|
308
|
+
try { db.exec("CREATE INDEX IF NOT EXISTS idx_completions_network ON completions(network_id)"); } catch {}
|
|
309
|
+
|
|
138
310
|
// Helpers
|
|
139
311
|
export function uuidv4(): string {
|
|
140
312
|
return crypto.randomUUID();
|
|
141
313
|
}
|
|
314
|
+
|
|
315
|
+
export function generateId(prefix: string): string {
|
|
316
|
+
return `${prefix}_${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export function hashPassword(password: string): string {
|
|
320
|
+
return new Bun.CryptoHasher("sha256").update(`anet:${password}`).digest("hex");
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export function hashToken(token: string): string {
|
|
324
|
+
return new Bun.CryptoHasher("sha256").update(token).digest("hex");
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export function generateToken(): string {
|
|
328
|
+
return `atok_${crypto.randomUUID().replace(/-/g, "")}`;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export function generateUserToken(): string {
|
|
332
|
+
return `utok_${crypto.randomUUID().replace(/-/g, "")}`;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export function generateNetworkToken(): string {
|
|
336
|
+
return `ntok_${crypto.randomUUID().replace(/-/g, "")}`;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export function logAudit(userId: string | null, username: string | null, action: string, targetType?: string, targetId?: string, detail?: string, ip?: string, networkId?: string) {
|
|
340
|
+
try {
|
|
341
|
+
db.run(
|
|
342
|
+
"INSERT INTO audit_log (user_id, username, action, target_type, target_id, detail, ip, network_id) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
|
343
|
+
[userId, username, action, targetType ?? null, targetId ?? null, detail ?? null, ip ?? null, networkId ?? null]
|
|
344
|
+
);
|
|
345
|
+
} catch {}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
export function logTaskEvent(taskId: string, fromStatus: string | null, toStatus: string, actor: string, detail?: string) {
|
|
349
|
+
try {
|
|
350
|
+
db.run(
|
|
351
|
+
`INSERT INTO task_events (task_id, from_status, to_status, actor, detail, network_id)
|
|
352
|
+
VALUES (?1, ?2, ?3, ?4, ?5, (SELECT network_id FROM tasks WHERE task_id = ?1))`,
|
|
353
|
+
[taskId, fromStatus, toStatus, actor, detail ?? null]
|
|
354
|
+
);
|
|
355
|
+
} catch {}
|
|
356
|
+
}
|