total-recall-brain 3.4.0 → 3.6.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/bin/antigravity.mjs +19 -8
- package/bin/total-recall.mjs +3 -0
- package/extension/content-script.js +354 -11
- package/extension/popup/popup.html +5 -0
- package/extension/popup/popup.js +25 -0
- package/frontend/src/App.tsx +29 -0
- package/frontend/src/api.ts +40 -0
- package/frontend/src/pages/ChatPage.tsx +101 -8
- package/frontend/src/pages/CollabPage.tsx +470 -0
- package/frontend/src/pages/GraphPage.tsx +62 -0
- package/frontend/src/pages/HealthPage.tsx +185 -8
- package/frontend/src/pages/HelpPage.tsx +141 -0
- package/frontend/src/pages/SettingsPage.tsx +197 -2
- package/frontend/src/types.ts +16 -0
- package/package.json +3 -2
- package/scaffold/.agent/skills/total-recall/research-queue.jsonl +1 -0
- package/src/cli/collab.mjs +49 -0
- package/src/cli/help.mjs +1 -1
- package/src/core/dream.mjs +41 -0
- package/src/core/runtime.mjs +30 -7
- package/src/core/schema.mjs +481 -0
- package/src/core/source-adapters.mjs +14 -6
- package/src/server/index.mjs +21 -1
- package/src/server/rest.mjs +158 -3
- package/src/server/route-manifest.json +12 -0
- package/src/server/routes/collab.mjs +286 -0
package/src/server/rest.mjs
CHANGED
|
@@ -944,12 +944,19 @@ router.get('/api/extension/status', requireAuth, requireScope('config:read'), as
|
|
|
944
944
|
try {
|
|
945
945
|
const extDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../extension');
|
|
946
946
|
const available = fs.existsSync(path.join(extDir, 'manifest.json'));
|
|
947
|
+
let version = '0.0.0';
|
|
948
|
+
if (available) {
|
|
949
|
+
try {
|
|
950
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(extDir, 'manifest.json'), 'utf8'));
|
|
951
|
+
version = manifest.version || '0.0.0';
|
|
952
|
+
} catch {}
|
|
953
|
+
}
|
|
947
954
|
|
|
948
955
|
// Check if extension has ever connected by looking for the marker file
|
|
949
956
|
const markerPath = path.join(BRAIN_DIR, 'config', '.extension-connected');
|
|
950
957
|
const connected = fs.existsSync(markerPath);
|
|
951
958
|
|
|
952
|
-
res.json({ available, connected });
|
|
959
|
+
res.json({ available, connected, version });
|
|
953
960
|
} catch (err) { serverError(res, err); }
|
|
954
961
|
});
|
|
955
962
|
|
|
@@ -1371,10 +1378,12 @@ router.get('/api/config-json', requireAuth, requireScope('config:read'), (req, r
|
|
|
1371
1378
|
const securityPath = path.join(CONFIG_DIR, 'security.yml');
|
|
1372
1379
|
const budgetPath = path.join(CONFIG_DIR, 'budget.yml');
|
|
1373
1380
|
const brainPath = path.join(CONFIG_DIR, 'brain.json');
|
|
1381
|
+
const secretsPath = path.join(AGENT_DIR, 'secrets.enc');
|
|
1374
1382
|
|
|
1375
1383
|
let security = {};
|
|
1376
1384
|
let budget = {};
|
|
1377
1385
|
let brain = {};
|
|
1386
|
+
let secrets = {};
|
|
1378
1387
|
|
|
1379
1388
|
if (fs.existsSync(securityPath)) {
|
|
1380
1389
|
try {
|
|
@@ -1391,6 +1400,11 @@ router.get('/api/config-json', requireAuth, requireScope('config:read'), (req, r
|
|
|
1391
1400
|
brain = JSON.parse(fs.readFileSync(brainPath, 'utf8')) || {};
|
|
1392
1401
|
} catch {}
|
|
1393
1402
|
}
|
|
1403
|
+
if (fs.existsSync(secretsPath)) {
|
|
1404
|
+
try {
|
|
1405
|
+
secrets = JSON.parse(fs.readFileSync(secretsPath, 'utf8')) || {};
|
|
1406
|
+
} catch {}
|
|
1407
|
+
}
|
|
1394
1408
|
|
|
1395
1409
|
const safeBrain = { ...brain };
|
|
1396
1410
|
if (safeBrain.token) {
|
|
@@ -1398,16 +1412,25 @@ router.get('/api/config-json', requireAuth, requireScope('config:read'), (req, r
|
|
|
1398
1412
|
delete safeBrain.token;
|
|
1399
1413
|
}
|
|
1400
1414
|
|
|
1401
|
-
|
|
1415
|
+
const allowedKeys = ['google_api_key', 'anthropic_api_key', 'openai_api_key', 'tavily_api_key', 'brave_api_key', 'exa_api_key', 'serper_api_key', 'github_token'];
|
|
1416
|
+
const safeSecrets = {};
|
|
1417
|
+
for (const key of allowedKeys) {
|
|
1418
|
+
if (secrets[key] !== undefined) {
|
|
1419
|
+
safeSecrets[key] = secrets[key];
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
res.json({ security, budget, brain: safeBrain, secrets: safeSecrets });
|
|
1402
1424
|
} catch (err) { serverError(res, err); }
|
|
1403
1425
|
});
|
|
1404
1426
|
|
|
1405
1427
|
router.post('/api/config-json', requireAuth, requireScope('config:write'), (req, res) => {
|
|
1406
1428
|
try {
|
|
1407
|
-
const { security, budget, brain } = req.body;
|
|
1429
|
+
const { security, budget, brain, secrets } = req.body;
|
|
1408
1430
|
const securityPath = path.join(CONFIG_DIR, 'security.yml');
|
|
1409
1431
|
const budgetPath = path.join(CONFIG_DIR, 'budget.yml');
|
|
1410
1432
|
const brainPath = path.join(CONFIG_DIR, 'brain.json');
|
|
1433
|
+
const secretsPath = path.join(AGENT_DIR, 'secrets.enc');
|
|
1411
1434
|
|
|
1412
1435
|
if (!fs.existsSync(CONFIG_DIR)) {
|
|
1413
1436
|
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
@@ -1433,6 +1456,25 @@ router.post('/api/config-json', requireAuth, requireScope('config:write'), (req,
|
|
|
1433
1456
|
delete nextBrain.has_token;
|
|
1434
1457
|
fs.writeFileSync(brainPath, JSON.stringify(nextBrain, null, 2), { encoding: 'utf8', mode: 0o600 });
|
|
1435
1458
|
}
|
|
1459
|
+
if (secrets) {
|
|
1460
|
+
let existingSecrets = {};
|
|
1461
|
+
if (fs.existsSync(secretsPath)) {
|
|
1462
|
+
try {
|
|
1463
|
+
existingSecrets = JSON.parse(fs.readFileSync(secretsPath, 'utf8')) || {};
|
|
1464
|
+
} catch {}
|
|
1465
|
+
}
|
|
1466
|
+
const allowedKeys = ['google_api_key', 'anthropic_api_key', 'openai_api_key', 'tavily_api_key', 'brave_api_key', 'exa_api_key', 'serper_api_key', 'github_token'];
|
|
1467
|
+
for (const key of allowedKeys) {
|
|
1468
|
+
if (secrets[key] !== undefined) {
|
|
1469
|
+
if (secrets[key] === '') {
|
|
1470
|
+
delete existingSecrets[key];
|
|
1471
|
+
} else {
|
|
1472
|
+
existingSecrets[key] = secrets[key];
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
fs.writeFileSync(secretsPath, JSON.stringify(existingSecrets, null, 2), { encoding: 'utf8', mode: 0o600 });
|
|
1477
|
+
}
|
|
1436
1478
|
|
|
1437
1479
|
res.json({ success: true });
|
|
1438
1480
|
} catch (err) { serverError(res, err); }
|
|
@@ -1960,4 +2002,117 @@ function getLastModified(filePath) {
|
|
|
1960
2002
|
return null;
|
|
1961
2003
|
}
|
|
1962
2004
|
|
|
2005
|
+
/**
|
|
2006
|
+
* GET /api/update/check
|
|
2007
|
+
*/
|
|
2008
|
+
router.get('/api/update/check', requireAuth, async (req, res) => {
|
|
2009
|
+
try {
|
|
2010
|
+
const localPkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8'));
|
|
2011
|
+
const currentVersion = localPkg.version || '0.0.0';
|
|
2012
|
+
|
|
2013
|
+
const registryRes = await fetch('https://registry.npmjs.org/total-recall-brain/latest');
|
|
2014
|
+
if (!registryRes.ok) {
|
|
2015
|
+
throw new Error(`Failed to fetch latest version from npm: ${registryRes.status}`);
|
|
2016
|
+
}
|
|
2017
|
+
const registryData = await registryRes.json();
|
|
2018
|
+
const latestVersion = registryData.version || '0.0.0';
|
|
2019
|
+
|
|
2020
|
+
const updateAvailable = latestVersion !== currentVersion;
|
|
2021
|
+
|
|
2022
|
+
res.json({
|
|
2023
|
+
currentVersion,
|
|
2024
|
+
latestVersion,
|
|
2025
|
+
updateAvailable
|
|
2026
|
+
});
|
|
2027
|
+
} catch (err) {
|
|
2028
|
+
serverError(res, err);
|
|
2029
|
+
}
|
|
2030
|
+
});
|
|
2031
|
+
|
|
2032
|
+
/**
|
|
2033
|
+
* POST /api/update/run
|
|
2034
|
+
*/
|
|
2035
|
+
router.post('/api/update/run', requireAuth, async (req, res) => {
|
|
2036
|
+
try {
|
|
2037
|
+
res.json({ success: true, message: 'Update started. Server is updating and will restart shortly.' });
|
|
2038
|
+
|
|
2039
|
+
setImmediate(async () => {
|
|
2040
|
+
logger.info('update', 'Starting auto-update process...');
|
|
2041
|
+
try {
|
|
2042
|
+
const { exec } = await import('node:child_process');
|
|
2043
|
+
|
|
2044
|
+
const runCmd = (cmd, cwd) => new Promise((resolve, reject) => {
|
|
2045
|
+
logger.info('update', `Running: ${cmd} in ${cwd}`);
|
|
2046
|
+
exec(cmd, { cwd }, (err, stdout, stderr) => {
|
|
2047
|
+
if (err) {
|
|
2048
|
+
logger.error('update', `Command failed: ${cmd}. Error: ${err.message}`);
|
|
2049
|
+
return reject(err);
|
|
2050
|
+
}
|
|
2051
|
+
resolve({ stdout, stderr });
|
|
2052
|
+
});
|
|
2053
|
+
});
|
|
2054
|
+
|
|
2055
|
+
// 1. git pull
|
|
2056
|
+
await runCmd('git pull', ROOT);
|
|
2057
|
+
// 2. npm install
|
|
2058
|
+
await runCmd('npm install', ROOT);
|
|
2059
|
+
// 3. build/install frontend if package.json exists
|
|
2060
|
+
const frontendPath = path.join(ROOT, 'frontend');
|
|
2061
|
+
if (fs.existsSync(path.join(frontendPath, 'package.json'))) {
|
|
2062
|
+
await runCmd('npm install', frontendPath);
|
|
2063
|
+
await runCmd('npm run build', frontendPath);
|
|
2064
|
+
}
|
|
2065
|
+
|
|
2066
|
+
logger.info('update', 'Update successfully applied. Restarting server...');
|
|
2067
|
+
process.exit(0);
|
|
2068
|
+
} catch (err) {
|
|
2069
|
+
logger.error('update', `Auto-update failed: ${err.message}`);
|
|
2070
|
+
}
|
|
2071
|
+
});
|
|
2072
|
+
} catch (err) {
|
|
2073
|
+
serverError(res, err);
|
|
2074
|
+
}
|
|
2075
|
+
});
|
|
2076
|
+
|
|
2077
|
+
router.get('/api/help', requireAuth, (req, res) => {
|
|
2078
|
+
const { topic } = req.query;
|
|
2079
|
+
const docsDir = path.join(ROOT, 'docs');
|
|
2080
|
+
const referenceDir = path.join(docsDir, 'reference');
|
|
2081
|
+
|
|
2082
|
+
if (!topic) {
|
|
2083
|
+
return res.json({
|
|
2084
|
+
topics: [
|
|
2085
|
+
{ id: 'cli-reference', title: 'CLI Reference Guide', description: 'npx total-recall command catalog and flags' },
|
|
2086
|
+
{ id: 'ssss', title: 'SSSS Specifications', description: 'Structured Semantic Syntax System guide' },
|
|
2087
|
+
{ id: 'architecture', title: 'System Architecture', description: 'System topology and VFS structures' },
|
|
2088
|
+
{ id: 'collab', title: 'Collaboration Guide', description: 'Collaborative workspaces and team annotations' }
|
|
2089
|
+
]
|
|
2090
|
+
});
|
|
2091
|
+
}
|
|
2092
|
+
|
|
2093
|
+
try {
|
|
2094
|
+
let filePath = '';
|
|
2095
|
+
if (topic === 'cli-reference') {
|
|
2096
|
+
filePath = path.join(referenceDir, 'cli-reference.md');
|
|
2097
|
+
} else if (topic === 'ssss') {
|
|
2098
|
+
filePath = path.join(docsDir, 'SSSS.md');
|
|
2099
|
+
} else if (topic === 'architecture') {
|
|
2100
|
+
filePath = path.join(docsDir, 'ARCHITECTURE.md');
|
|
2101
|
+
} else if (topic === 'collab') {
|
|
2102
|
+
filePath = path.join(referenceDir, 'collab.md');
|
|
2103
|
+
} else {
|
|
2104
|
+
return res.status(404).json({ error: 'Help topic not found' });
|
|
2105
|
+
}
|
|
2106
|
+
|
|
2107
|
+
if (!fs.existsSync(filePath)) {
|
|
2108
|
+
return res.status(404).json({ error: 'Help topic document not found on server' });
|
|
2109
|
+
}
|
|
2110
|
+
|
|
2111
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
2112
|
+
res.json({ topic, content });
|
|
2113
|
+
} catch (err) {
|
|
2114
|
+
serverError(res, err);
|
|
2115
|
+
}
|
|
2116
|
+
});
|
|
2117
|
+
|
|
1963
2118
|
export { router as restRouter };
|
|
@@ -103,6 +103,10 @@
|
|
|
103
103
|
"method": "GET",
|
|
104
104
|
"path": "/api/graph"
|
|
105
105
|
},
|
|
106
|
+
{
|
|
107
|
+
"method": "GET",
|
|
108
|
+
"path": "/api/help"
|
|
109
|
+
},
|
|
106
110
|
{
|
|
107
111
|
"method": "GET",
|
|
108
112
|
"path": "/api/import/rules"
|
|
@@ -295,6 +299,14 @@
|
|
|
295
299
|
"method": "GET",
|
|
296
300
|
"path": "/api/tts/status"
|
|
297
301
|
},
|
|
302
|
+
{
|
|
303
|
+
"method": "GET",
|
|
304
|
+
"path": "/api/update/check"
|
|
305
|
+
},
|
|
306
|
+
{
|
|
307
|
+
"method": "POST",
|
|
308
|
+
"path": "/api/update/run"
|
|
309
|
+
},
|
|
298
310
|
{
|
|
299
311
|
"method": "GET",
|
|
300
312
|
"path": "/api/usage"
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
import bcrypt from 'bcrypt';
|
|
6
|
+
import jwt from 'jsonwebtoken';
|
|
7
|
+
import { WebSocket } from 'ws';
|
|
8
|
+
import { brainDir as configBrainDir } from '../../core/config.mjs';
|
|
9
|
+
|
|
10
|
+
const JWT_SECRET = process.env.JWT_SECRET || 'total-recall-collab-secret-key-1234';
|
|
11
|
+
|
|
12
|
+
// Setup database paths in sovereign brainDir
|
|
13
|
+
const DATA_DIR = path.join(configBrainDir, 'collab');
|
|
14
|
+
if (!fs.existsSync(DATA_DIR)) {
|
|
15
|
+
fs.mkdirSync(DATA_DIR, { recursive: true });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const USERS_FILE = path.join(DATA_DIR, 'users.json');
|
|
19
|
+
const GROUPS_FILE = path.join(DATA_DIR, 'groups.json');
|
|
20
|
+
const ANNOTATIONS_FILE = path.join(DATA_DIR, 'annotations.json');
|
|
21
|
+
|
|
22
|
+
function readJsonFile(filePath, defaultData = []) {
|
|
23
|
+
if (!fs.existsSync(filePath)) return defaultData;
|
|
24
|
+
try {
|
|
25
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
26
|
+
} catch {
|
|
27
|
+
return defaultData;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function writeJsonFile(filePath, data) {
|
|
32
|
+
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf8');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ─── Database Helpers ────────────────────────────────────────────────────────
|
|
36
|
+
function loadUsers() { return readJsonFile(USERS_FILE, []); }
|
|
37
|
+
function saveUsers(users) { writeJsonFile(USERS_FILE, users); }
|
|
38
|
+
function getUser(username) {
|
|
39
|
+
return loadUsers().find(u => u.username.toLowerCase() === username.toLowerCase());
|
|
40
|
+
}
|
|
41
|
+
function createUser(username, passwordHash) {
|
|
42
|
+
const users = loadUsers();
|
|
43
|
+
if (getUser(username)) throw new Error('User already exists');
|
|
44
|
+
const user = { username, passwordHash, created_at: new Date().toISOString() };
|
|
45
|
+
users.push(user);
|
|
46
|
+
saveUsers(users);
|
|
47
|
+
return user;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function loadGroups() { return readJsonFile(GROUPS_FILE, []); }
|
|
51
|
+
function saveGroups(groups) { writeJsonFile(GROUPS_FILE, groups); }
|
|
52
|
+
function createGroup(name, owner) {
|
|
53
|
+
const groups = loadGroups();
|
|
54
|
+
const code = crypto.randomBytes(4).toString('hex');
|
|
55
|
+
const newGroup = {
|
|
56
|
+
id: crypto.randomUUID(),
|
|
57
|
+
name,
|
|
58
|
+
code,
|
|
59
|
+
owner,
|
|
60
|
+
members: [owner],
|
|
61
|
+
created_at: new Date().toISOString()
|
|
62
|
+
};
|
|
63
|
+
groups.push(newGroup);
|
|
64
|
+
saveGroups(groups);
|
|
65
|
+
return newGroup;
|
|
66
|
+
}
|
|
67
|
+
function joinGroup(code, username) {
|
|
68
|
+
const groups = loadGroups();
|
|
69
|
+
const group = groups.find(g => g.code.toLowerCase() === code.toLowerCase());
|
|
70
|
+
if (!group) throw new Error('Group not found');
|
|
71
|
+
if (group.members.includes(username)) return group;
|
|
72
|
+
group.members.push(username);
|
|
73
|
+
saveGroups(groups);
|
|
74
|
+
return group;
|
|
75
|
+
}
|
|
76
|
+
function getUserGroups(username) {
|
|
77
|
+
return loadGroups().filter(g => g.members.includes(username));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function loadAnnotations() { return readJsonFile(ANNOTATIONS_FILE, []); }
|
|
81
|
+
function saveAnnotations(annotations) { writeJsonFile(ANNOTATIONS_FILE, annotations); }
|
|
82
|
+
function addAnnotation(url, groupCode, author, text, excerpt = '') {
|
|
83
|
+
const annotations = loadAnnotations();
|
|
84
|
+
const cleanUrl = url.split('#')[0];
|
|
85
|
+
const newAnnotation = {
|
|
86
|
+
id: crypto.randomUUID(),
|
|
87
|
+
url: cleanUrl,
|
|
88
|
+
groupCode,
|
|
89
|
+
author,
|
|
90
|
+
text,
|
|
91
|
+
excerpt,
|
|
92
|
+
created_at: new Date().toISOString()
|
|
93
|
+
};
|
|
94
|
+
annotations.push(newAnnotation);
|
|
95
|
+
saveAnnotations(annotations);
|
|
96
|
+
return newAnnotation;
|
|
97
|
+
}
|
|
98
|
+
function getAnnotations(url, userGroups = []) {
|
|
99
|
+
const cleanUrl = url.split('#')[0];
|
|
100
|
+
const groupCodes = userGroups.map(g => g.code);
|
|
101
|
+
return loadAnnotations().filter(a => a.url === cleanUrl && groupCodes.includes(a.groupCode));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ─── Middleware ──────────────────────────────────────────────────────────────
|
|
105
|
+
function requireCollabAuth(req, res, next) {
|
|
106
|
+
const authHeader = req.headers.authorization;
|
|
107
|
+
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
|
108
|
+
return res.status(401).json({ error: 'Authorization token required' });
|
|
109
|
+
}
|
|
110
|
+
const token = authHeader.split(' ')[1];
|
|
111
|
+
try {
|
|
112
|
+
const payload = jwt.verify(token, JWT_SECRET);
|
|
113
|
+
req.user = payload;
|
|
114
|
+
next();
|
|
115
|
+
} catch (err) {
|
|
116
|
+
return res.status(401).json({ error: 'Invalid or expired token' });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ─── Router ──────────────────────────────────────────────────────────────────
|
|
121
|
+
export const collabRouter = express.Router();
|
|
122
|
+
|
|
123
|
+
collabRouter.post('/api/collab/auth/register', (req, res) => {
|
|
124
|
+
const { username, password } = req.body;
|
|
125
|
+
if (!username || !password) {
|
|
126
|
+
return res.status(400).json({ error: 'Username and password required' });
|
|
127
|
+
}
|
|
128
|
+
try {
|
|
129
|
+
const passwordHash = bcrypt.hashSync(password, 10);
|
|
130
|
+
const user = createUser(username, passwordHash);
|
|
131
|
+
const group = createGroup(`${username}'s Workspace`, username);
|
|
132
|
+
const token = jwt.sign({ username: user.username }, JWT_SECRET, { expiresIn: '7d' });
|
|
133
|
+
res.json({ token, username: user.username, defaultGroupCode: group.code });
|
|
134
|
+
} catch (err) {
|
|
135
|
+
res.status(400).json({ error: err.message });
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
collabRouter.post('/api/collab/auth/login', (req, res) => {
|
|
140
|
+
const { username, password } = req.body;
|
|
141
|
+
if (!username || !password) {
|
|
142
|
+
return res.status(400).json({ error: 'Username and password required' });
|
|
143
|
+
}
|
|
144
|
+
const user = getUser(username);
|
|
145
|
+
if (!user || !bcrypt.compareSync(password, user.passwordHash)) {
|
|
146
|
+
return res.status(401).json({ error: 'Invalid credentials' });
|
|
147
|
+
}
|
|
148
|
+
const token = jwt.sign({ username: user.username }, JWT_SECRET, { expiresIn: '7d' });
|
|
149
|
+
res.json({ token, username: user.username });
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
collabRouter.get('/api/collab/groups', requireCollabAuth, (req, res) => {
|
|
153
|
+
try {
|
|
154
|
+
const groups = getUserGroups(req.user.username);
|
|
155
|
+
res.json(groups);
|
|
156
|
+
} catch (err) {
|
|
157
|
+
res.status(500).json({ error: err.message });
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
collabRouter.post('/api/collab/groups', requireCollabAuth, (req, res) => {
|
|
162
|
+
const { name } = req.body;
|
|
163
|
+
if (!name) return res.status(400).json({ error: 'Group name required' });
|
|
164
|
+
try {
|
|
165
|
+
const group = createGroup(name, req.user.username);
|
|
166
|
+
res.json(group);
|
|
167
|
+
} catch (err) {
|
|
168
|
+
res.status(500).json({ error: err.message });
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
collabRouter.post('/api/collab/groups/join', requireCollabAuth, (req, res) => {
|
|
173
|
+
const { code } = req.body;
|
|
174
|
+
if (!code) return res.status(400).json({ error: 'Invite code required' });
|
|
175
|
+
try {
|
|
176
|
+
const group = joinGroup(code, req.user.username);
|
|
177
|
+
res.json({ success: true, group });
|
|
178
|
+
} catch (err) {
|
|
179
|
+
res.status(400).json({ error: err.message });
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
collabRouter.get('/api/collab/annotations', requireCollabAuth, (req, res) => {
|
|
184
|
+
const { url } = req.query;
|
|
185
|
+
if (!url) return res.status(400).json({ error: 'URL query parameter required' });
|
|
186
|
+
try {
|
|
187
|
+
const userGroups = getUserGroups(req.user.username);
|
|
188
|
+
const list = getAnnotations(url, userGroups);
|
|
189
|
+
res.json(list);
|
|
190
|
+
} catch (err) {
|
|
191
|
+
res.status(500).json({ error: err.message });
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
collabRouter.post('/api/collab/annotations', requireCollabAuth, (req, res) => {
|
|
196
|
+
const { url, groupCode, text, excerpt } = req.body;
|
|
197
|
+
if (!url || !groupCode || !text) {
|
|
198
|
+
return res.status(400).json({ error: 'URL, groupCode, and text required' });
|
|
199
|
+
}
|
|
200
|
+
try {
|
|
201
|
+
const userGroups = getUserGroups(req.user.username);
|
|
202
|
+
if (!userGroups.some(g => g.code === groupCode)) {
|
|
203
|
+
return res.status(403).json({ error: 'You are not a member of this group' });
|
|
204
|
+
}
|
|
205
|
+
const note = addAnnotation(url, groupCode, req.user.username, text, excerpt);
|
|
206
|
+
broadcastToUrl(url, { type: 'ANNOTATION_ADDED', annotation: note });
|
|
207
|
+
res.json(note);
|
|
208
|
+
} catch (err) {
|
|
209
|
+
res.status(500).json({ error: err.message });
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
// ─── Live WS Presence / Chat Sync Registry ────────────────────────────────────
|
|
214
|
+
const clients = new Map();
|
|
215
|
+
|
|
216
|
+
export function handleCollabUpgrade(request, socket, head, wss) {
|
|
217
|
+
const url = new URL(request.url, `http://${request.headers.host}`);
|
|
218
|
+
const token = url.searchParams.get('token');
|
|
219
|
+
|
|
220
|
+
if (!token) {
|
|
221
|
+
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
|
|
222
|
+
socket.destroy();
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
try {
|
|
227
|
+
const payload = jwt.verify(token, JWT_SECRET);
|
|
228
|
+
wss.handleUpgrade(request, socket, head, (ws) => {
|
|
229
|
+
clients.set(ws, { username: payload.username, currentUrl: null });
|
|
230
|
+
|
|
231
|
+
ws.on('message', (message) => {
|
|
232
|
+
try {
|
|
233
|
+
const data = JSON.parse(message);
|
|
234
|
+
const clientInfo = clients.get(ws);
|
|
235
|
+
|
|
236
|
+
if (data.type === 'SUBSCRIBE') {
|
|
237
|
+
const cleanUrl = data.url.split('#')[0];
|
|
238
|
+
clientInfo.currentUrl = cleanUrl;
|
|
239
|
+
broadcastToUrl(cleanUrl, {
|
|
240
|
+
type: 'USER_JOINED',
|
|
241
|
+
username: clientInfo.username,
|
|
242
|
+
url: cleanUrl
|
|
243
|
+
}, ws);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (data.type === 'CHAT_MESSAGE') {
|
|
247
|
+
if (!clientInfo.currentUrl) return;
|
|
248
|
+
broadcastToUrl(clientInfo.currentUrl, {
|
|
249
|
+
type: 'CHAT_MESSAGE',
|
|
250
|
+
username: clientInfo.username,
|
|
251
|
+
text: data.text,
|
|
252
|
+
created_at: new Date().toISOString()
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
} catch (err) {
|
|
256
|
+
ws.send(JSON.stringify({ type: 'ERROR', error: 'Invalid frame' }));
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
ws.on('close', () => {
|
|
261
|
+
const clientInfo = clients.get(ws);
|
|
262
|
+
if (clientInfo && clientInfo.currentUrl) {
|
|
263
|
+
broadcastToUrl(clientInfo.currentUrl, {
|
|
264
|
+
type: 'USER_LEFT',
|
|
265
|
+
username: clientInfo.username
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
clients.delete(ws);
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
} catch (err) {
|
|
272
|
+
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
|
|
273
|
+
socket.destroy();
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function broadcastToUrl(url, messageObj, excludeWs = null) {
|
|
278
|
+
const cleanUrl = url.split('#')[0];
|
|
279
|
+
const payload = JSON.stringify(messageObj);
|
|
280
|
+
|
|
281
|
+
for (const [ws, info] of clients.entries()) {
|
|
282
|
+
if (ws !== excludeWs && info.currentUrl === cleanUrl && ws.readyState === WebSocket.OPEN) {
|
|
283
|
+
ws.send(payload);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|