@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.
@@ -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;