@fyresmith/hive-server 1.0.1-3
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 +143 -0
- package/bin/hive.js +4 -0
- package/cli/checks.js +28 -0
- package/cli/config.js +33 -0
- package/cli/constants.js +45 -0
- package/cli/env-file.js +141 -0
- package/cli/errors.js +11 -0
- package/cli/exec.js +12 -0
- package/cli/main.js +730 -0
- package/cli/output.js +21 -0
- package/cli/service.js +360 -0
- package/cli/tunnel.js +238 -0
- package/index.js +129 -0
- package/lib/auth.js +50 -0
- package/lib/socketHandler.js +226 -0
- package/lib/vaultManager.js +258 -0
- package/lib/yjsServer.js +277 -0
- package/package.json +52 -0
- package/routes/auth.js +99 -0
package/lib/yjsServer.js
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import { createRequire } from 'module';
|
|
2
|
+
import { readFileSync } from 'fs';
|
|
3
|
+
import { WebSocketServer } from 'ws';
|
|
4
|
+
import * as vault from './vaultManager.js';
|
|
5
|
+
import * as auth from './auth.js';
|
|
6
|
+
|
|
7
|
+
// Load both y-websocket and yjs via CJS require so they share the same
|
|
8
|
+
// module instance — avoids the "Yjs was already imported" duplicate warning.
|
|
9
|
+
const require = createRequire(import.meta.url);
|
|
10
|
+
const { setupWSConnection, docs, getYDoc } = require('y-websocket/bin/utils');
|
|
11
|
+
|
|
12
|
+
const PERSIST_DEBOUNCE_MS = 250;
|
|
13
|
+
const IDLE_ROOM_TTL_MS = 15000;
|
|
14
|
+
|
|
15
|
+
/** @type {Map<string, {
|
|
16
|
+
* docName: string,
|
|
17
|
+
* relPath: string,
|
|
18
|
+
* clients: Set<any>,
|
|
19
|
+
* observed: boolean,
|
|
20
|
+
* closed: boolean,
|
|
21
|
+
* dirty: boolean,
|
|
22
|
+
* flushing: boolean,
|
|
23
|
+
* persistTimer: any,
|
|
24
|
+
* closeTimer: any,
|
|
25
|
+
* lastPersistAt: number|null,
|
|
26
|
+
* lastPersistHash: string|null,
|
|
27
|
+
* lastPersistError: string|null
|
|
28
|
+
* }>}
|
|
29
|
+
*/
|
|
30
|
+
const roomStates = new Map();
|
|
31
|
+
|
|
32
|
+
/** @type {null | ((relPath: string, hash: string, excludeSocketId: string|null) => void)} */
|
|
33
|
+
let broadcastRef = null;
|
|
34
|
+
|
|
35
|
+
function getOrCreateRoomState(docName, relPath) {
|
|
36
|
+
let state = roomStates.get(docName);
|
|
37
|
+
if (!state) {
|
|
38
|
+
state = {
|
|
39
|
+
docName,
|
|
40
|
+
relPath,
|
|
41
|
+
clients: new Set(),
|
|
42
|
+
observed: false,
|
|
43
|
+
closed: false,
|
|
44
|
+
dirty: false,
|
|
45
|
+
flushing: false,
|
|
46
|
+
persistTimer: null,
|
|
47
|
+
closeTimer: null,
|
|
48
|
+
lastPersistAt: null,
|
|
49
|
+
lastPersistHash: null,
|
|
50
|
+
lastPersistError: null,
|
|
51
|
+
};
|
|
52
|
+
roomStates.set(docName, state);
|
|
53
|
+
} else {
|
|
54
|
+
state.relPath = relPath;
|
|
55
|
+
}
|
|
56
|
+
return state;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function clearTimers(state) {
|
|
60
|
+
if (state.persistTimer) {
|
|
61
|
+
clearTimeout(state.persistTimer);
|
|
62
|
+
state.persistTimer = null;
|
|
63
|
+
}
|
|
64
|
+
if (state.closeTimer) {
|
|
65
|
+
clearTimeout(state.closeTimer);
|
|
66
|
+
state.closeTimer = null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function flushRoomState(state) {
|
|
71
|
+
if (state.flushing) return;
|
|
72
|
+
state.flushing = true;
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
while (state.dirty) {
|
|
76
|
+
state.dirty = false;
|
|
77
|
+
const ydoc = docs.get(state.docName);
|
|
78
|
+
if (!ydoc) break;
|
|
79
|
+
|
|
80
|
+
const text = ydoc.getText('content').toString();
|
|
81
|
+
try {
|
|
82
|
+
await vault.writeFile(state.relPath, text);
|
|
83
|
+
const hash = vault.hashContent(text);
|
|
84
|
+
state.lastPersistAt = Date.now();
|
|
85
|
+
state.lastPersistHash = hash;
|
|
86
|
+
state.lastPersistError = null;
|
|
87
|
+
broadcastRef?.(state.relPath, hash, null);
|
|
88
|
+
console.log(`[yjs] Persisted: ${state.relPath}`);
|
|
89
|
+
} catch (err) {
|
|
90
|
+
state.lastPersistError = err instanceof Error ? err.message : String(err);
|
|
91
|
+
state.dirty = true;
|
|
92
|
+
console.error(`[yjs] Persist error for ${state.relPath}:`, err);
|
|
93
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
} finally {
|
|
97
|
+
state.flushing = false;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function scheduleRoomPersist(state) {
|
|
102
|
+
if (state.closed) return;
|
|
103
|
+
state.dirty = true;
|
|
104
|
+
if (state.persistTimer) clearTimeout(state.persistTimer);
|
|
105
|
+
state.persistTimer = setTimeout(() => {
|
|
106
|
+
state.persistTimer = null;
|
|
107
|
+
void flushRoomState(state);
|
|
108
|
+
}, PERSIST_DEBOUNCE_MS);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function observeRoom(state) {
|
|
112
|
+
if (state.observed) return;
|
|
113
|
+
const ydoc = docs.get(state.docName);
|
|
114
|
+
if (!ydoc) return;
|
|
115
|
+
|
|
116
|
+
state.observed = true;
|
|
117
|
+
ydoc.getText('content').observe(() => {
|
|
118
|
+
if (state.closed) return;
|
|
119
|
+
scheduleRoomPersist(state);
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function scheduleIdleClose(state) {
|
|
124
|
+
if (state.closeTimer) clearTimeout(state.closeTimer);
|
|
125
|
+
state.closeTimer = setTimeout(() => {
|
|
126
|
+
state.closeTimer = null;
|
|
127
|
+
if (state.clients.size > 0) return;
|
|
128
|
+
void closeRoom(state.docName, { closeClients: false, reason: 'idle' });
|
|
129
|
+
}, IDLE_ROOM_TTL_MS);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function closeRoom(docName, { closeClients, reason }) {
|
|
133
|
+
const state = roomStates.get(docName);
|
|
134
|
+
if (!state) return;
|
|
135
|
+
|
|
136
|
+
state.closed = true;
|
|
137
|
+
clearTimers(state);
|
|
138
|
+
|
|
139
|
+
if (closeClients) {
|
|
140
|
+
for (const conn of state.clients) {
|
|
141
|
+
try {
|
|
142
|
+
conn.close(4004, 'Room closed');
|
|
143
|
+
} catch {
|
|
144
|
+
// ignore close failures
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
state.clients.clear();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
await flushRoomState(state);
|
|
151
|
+
|
|
152
|
+
const ydoc = docs.get(docName);
|
|
153
|
+
if (ydoc) {
|
|
154
|
+
try {
|
|
155
|
+
ydoc.destroy();
|
|
156
|
+
} catch {
|
|
157
|
+
// ignore destroy failures
|
|
158
|
+
}
|
|
159
|
+
docs.delete(docName);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
roomStates.delete(docName);
|
|
163
|
+
console.log(`[yjs] Room closed (${reason}): ${state.relPath}`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function trackRoomClient(state, conn) {
|
|
167
|
+
if (state.closeTimer) {
|
|
168
|
+
clearTimeout(state.closeTimer);
|
|
169
|
+
state.closeTimer = null;
|
|
170
|
+
}
|
|
171
|
+
state.clients.add(conn);
|
|
172
|
+
|
|
173
|
+
conn.on('close', () => {
|
|
174
|
+
if (state.closed) return;
|
|
175
|
+
state.clients.delete(conn);
|
|
176
|
+
if (state.clients.size === 0) {
|
|
177
|
+
scheduleIdleClose(state);
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
// Public API
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
export function getActiveRooms() {
|
|
187
|
+
return new Set(docs.keys());
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function getRoomStatus() {
|
|
191
|
+
return [...roomStates.values()].map((state) => ({
|
|
192
|
+
relPath: state.relPath,
|
|
193
|
+
clients: state.clients.size,
|
|
194
|
+
dirty: state.dirty,
|
|
195
|
+
lastPersistAt: state.lastPersistAt,
|
|
196
|
+
lastPersistHash: state.lastPersistHash,
|
|
197
|
+
lastPersistError: state.lastPersistError,
|
|
198
|
+
}));
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export async function forceCloseRoom(relPath) {
|
|
202
|
+
const docName = encodeURIComponent(relPath);
|
|
203
|
+
if (!roomStates.has(docName) && !docs.has(docName)) return;
|
|
204
|
+
await closeRoom(docName, { closeClients: true, reason: 'forced' });
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function startYjsServer(broadcastFileUpdated) {
|
|
208
|
+
broadcastRef = broadcastFileUpdated;
|
|
209
|
+
const port = parseInt(process.env.YJS_PORT ?? '3001', 10);
|
|
210
|
+
const wss = new WebSocketServer({ port });
|
|
211
|
+
|
|
212
|
+
wss.on('connection', (conn, req) => {
|
|
213
|
+
const url = new URL(req.url, 'http://localhost');
|
|
214
|
+
|
|
215
|
+
// Strip /yjs/ prefix added by Cloudflare Tunnel routing
|
|
216
|
+
const rawDocName = url.pathname
|
|
217
|
+
.replace(/^\/yjs\//, '')
|
|
218
|
+
.replace(/^\//, '');
|
|
219
|
+
|
|
220
|
+
if (!rawDocName) {
|
|
221
|
+
conn.close(4000, 'Missing room name');
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const token = url.searchParams.get('token');
|
|
226
|
+
try {
|
|
227
|
+
auth.verifyWsToken(token);
|
|
228
|
+
} catch {
|
|
229
|
+
conn.close(4001, 'Unauthorized');
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
let relPath;
|
|
234
|
+
try {
|
|
235
|
+
relPath = decodeURIComponent(rawDocName);
|
|
236
|
+
} catch {
|
|
237
|
+
conn.close(4002, 'Invalid room path');
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (!vault.isAllowed(relPath)) {
|
|
242
|
+
conn.close(4003, 'Forbidden path');
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const isNewRoom = !docs.has(rawDocName);
|
|
247
|
+
|
|
248
|
+
if (isNewRoom) {
|
|
249
|
+
// Pre-create the WSSharedDoc with file content synchronously.
|
|
250
|
+
// getYDoc registers it in the docs map — setupWSConnection will find
|
|
251
|
+
// it already populated and send that content as sync step 1, so clients
|
|
252
|
+
// never see an empty doc and Y.Text never gets double-initialized.
|
|
253
|
+
const ydoc = getYDoc(rawDocName, true);
|
|
254
|
+
try {
|
|
255
|
+
const absPath = vault.safePath(relPath);
|
|
256
|
+
const content = readFileSync(absPath, 'utf-8');
|
|
257
|
+
if (content) {
|
|
258
|
+
ydoc.getText('content').insert(0, content);
|
|
259
|
+
}
|
|
260
|
+
} catch {
|
|
261
|
+
// File doesn't exist yet — start with empty document
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
setupWSConnection(conn, req, { docName: rawDocName, gc: true });
|
|
266
|
+
const state = getOrCreateRoomState(rawDocName, relPath);
|
|
267
|
+
observeRoom(state);
|
|
268
|
+
trackRoomClient(state, conn);
|
|
269
|
+
|
|
270
|
+
if (isNewRoom) {
|
|
271
|
+
console.log(`[yjs] Room opened: ${relPath}`);
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
console.log(`[yjs] WebSocket server listening on port ${port}`);
|
|
276
|
+
return wss;
|
|
277
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@fyresmith/hive-server",
|
|
3
|
+
"version": "1.0.1-3",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Collaborative Obsidian vault server",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"files": [
|
|
8
|
+
"bin",
|
|
9
|
+
"cli",
|
|
10
|
+
"lib",
|
|
11
|
+
"routes",
|
|
12
|
+
"index.js",
|
|
13
|
+
"README.md"
|
|
14
|
+
],
|
|
15
|
+
"bin": {
|
|
16
|
+
"hive": "bin/hive.js"
|
|
17
|
+
},
|
|
18
|
+
"publishConfig": {
|
|
19
|
+
"access": "public"
|
|
20
|
+
},
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": ">=18"
|
|
23
|
+
},
|
|
24
|
+
"scripts": {
|
|
25
|
+
"start": "node index.js",
|
|
26
|
+
"dev": "node --watch index.js",
|
|
27
|
+
"build": "npm run verify && npm pack --dry-run",
|
|
28
|
+
"status": "node bin/hive.js status",
|
|
29
|
+
"verify": "node scripts/verify.mjs",
|
|
30
|
+
"install-hive": "node scripts/install-hive.mjs",
|
|
31
|
+
"test": "npm run verify"
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"chalk": "^5.6.2",
|
|
35
|
+
"chokidar": "^3.6.0",
|
|
36
|
+
"commander": "^13.1.0",
|
|
37
|
+
"discord-oauth2": "^2.12.0",
|
|
38
|
+
"dotenv": "^16.4.5",
|
|
39
|
+
"execa": "^9.6.0",
|
|
40
|
+
"express": "^4.19.2",
|
|
41
|
+
"jsonwebtoken": "^9.0.2",
|
|
42
|
+
"prompts": "^2.4.2",
|
|
43
|
+
"socket.io": "^4.7.5",
|
|
44
|
+
"which": "^6.0.0",
|
|
45
|
+
"ws": "^8.17.1",
|
|
46
|
+
"y-websocket": "^1.5.4",
|
|
47
|
+
"yjs": "^13.6.18"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"socket.io-client": "^4.8.3"
|
|
51
|
+
}
|
|
52
|
+
}
|
package/routes/auth.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import DiscordOauth2 from 'discord-oauth2';
|
|
3
|
+
import jwt from 'jsonwebtoken';
|
|
4
|
+
import { randomBytes } from 'crypto';
|
|
5
|
+
|
|
6
|
+
const router = Router();
|
|
7
|
+
const oauth = new DiscordOauth2();
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* CSRF state map: state → { timestamp }
|
|
11
|
+
* Entries expire after 10 minutes.
|
|
12
|
+
* @type {Map<string, {ts: number}>}
|
|
13
|
+
*/
|
|
14
|
+
const stateMap = new Map();
|
|
15
|
+
const STATE_TTL_MS = 10 * 60 * 1000;
|
|
16
|
+
|
|
17
|
+
function pruneStates() {
|
|
18
|
+
const now = Date.now();
|
|
19
|
+
for (const [key, val] of stateMap) {
|
|
20
|
+
if (now - val.ts > STATE_TTL_MS) stateMap.delete(key);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// GET /auth/login
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
router.get('/login', (req, res) => {
|
|
28
|
+
pruneStates();
|
|
29
|
+
const state = randomBytes(16).toString('hex');
|
|
30
|
+
stateMap.set(state, { ts: Date.now() });
|
|
31
|
+
|
|
32
|
+
const url = oauth.generateAuthUrl({
|
|
33
|
+
clientId: process.env.DISCORD_CLIENT_ID,
|
|
34
|
+
scope: ['identify', 'guilds'],
|
|
35
|
+
redirectUri: process.env.DISCORD_REDIRECT_URI,
|
|
36
|
+
state,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
res.redirect(url);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// GET /auth/callback
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
router.get('/callback', async (req, res) => {
|
|
46
|
+
const { code, state } = req.query;
|
|
47
|
+
|
|
48
|
+
if (!state || !stateMap.has(state)) {
|
|
49
|
+
return res.status(400).send('Invalid or expired state parameter.');
|
|
50
|
+
}
|
|
51
|
+
stateMap.delete(state);
|
|
52
|
+
|
|
53
|
+
if (!code) {
|
|
54
|
+
return res.status(400).send('Missing authorization code.');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
// Exchange code for access token
|
|
59
|
+
const tokenData = await oauth.tokenRequest({
|
|
60
|
+
clientId: process.env.DISCORD_CLIENT_ID,
|
|
61
|
+
clientSecret: process.env.DISCORD_CLIENT_SECRET,
|
|
62
|
+
redirectUri: process.env.DISCORD_REDIRECT_URI,
|
|
63
|
+
code,
|
|
64
|
+
scope: ['identify', 'guilds'],
|
|
65
|
+
grantType: 'authorization_code',
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const accessToken = tokenData.access_token;
|
|
69
|
+
|
|
70
|
+
// Verify guild membership
|
|
71
|
+
const guilds = await oauth.getUserGuilds(accessToken);
|
|
72
|
+
const isMember = guilds.some((g) => g.id === process.env.DISCORD_GUILD_ID);
|
|
73
|
+
if (!isMember) {
|
|
74
|
+
return res.status(403).send('Access denied: you are not a member of the required Discord server.');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Fetch user profile
|
|
78
|
+
const user = await oauth.getUser(accessToken);
|
|
79
|
+
const { id, username, avatar } = user;
|
|
80
|
+
const avatarUrl = avatar
|
|
81
|
+
? `https://cdn.discordapp.com/avatars/${id}/${avatar}.png`
|
|
82
|
+
: `https://cdn.discordapp.com/embed/avatars/${parseInt(id) % 5}.png`;
|
|
83
|
+
|
|
84
|
+
// Sign JWT
|
|
85
|
+
const token = jwt.sign(
|
|
86
|
+
{ id, username, avatarUrl },
|
|
87
|
+
process.env.JWT_SECRET,
|
|
88
|
+
{ expiresIn: '30d' }
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
// Redirect to Obsidian URI handler
|
|
92
|
+
res.redirect(`obsidian://hive-auth?token=${encodeURIComponent(token)}`);
|
|
93
|
+
} catch (err) {
|
|
94
|
+
console.error('[auth] OAuth callback error:', err);
|
|
95
|
+
res.status(500).send('Authentication failed. Please try again.');
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
export default router;
|