@fyresmith/hive-server 2.3.2 → 2.4.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.
@@ -3,15 +3,26 @@ import { resolve, join, sep, extname, relative, dirname } from 'path';
3
3
  import { readFile, writeFile as fsWriteFile, rename, unlink, mkdir, stat, readdir } from 'fs/promises';
4
4
  import { existsSync } from 'fs';
5
5
  import chokidar from 'chokidar';
6
+ import { getMetadataAllowlist, isMetadataPathAllowed } from './adapterRegistry.js';
6
7
 
7
8
  // ---------------------------------------------------------------------------
8
9
  // Deny / allow lists
9
10
  // ---------------------------------------------------------------------------
10
11
 
11
- const DENY_PREFIXES = ['.obsidian', 'Attachments', '.git'];
12
+ const DENY_PREFIXES = ['.obsidian', '.hive-history', 'Attachments', '.git'];
12
13
  const DENY_FILES = ['.DS_Store', 'Thumbs.db'];
13
14
  // Keep synced content text-based to ensure hash/read/write semantics stay safe.
14
15
  const ALLOW_EXTS = new Set(['.md', '.canvas']);
16
+ const METADATA_ALLOWLIST = new Set(getMetadataAllowlist());
17
+ const HISTORY_DIR_NAME = '.hive-history';
18
+ const HISTORY_FILES_DIR = 'files';
19
+ const DEFAULT_HISTORY_LIMIT = 50;
20
+ const MAX_HISTORY_LIMIT = 500;
21
+ const HISTORY_RETENTION_PER_FILE = Math.max(
22
+ 1,
23
+ parseInt(process.env.HIVE_HISTORY_RETENTION_PER_FILE ?? '200', 10) || 200,
24
+ );
25
+ const VERSION_ID_RE = /^\d{13}-[a-z0-9]+$/;
15
26
 
16
27
  // ---------------------------------------------------------------------------
17
28
  // Internals
@@ -34,6 +45,137 @@ function getVaultRoot() {
34
45
  */
35
46
  const manifestCache = new Map();
36
47
 
48
+ function getHistoryRoot() {
49
+ const custom = process.env.HIVE_HISTORY_PATH?.trim();
50
+ if (custom) return resolve(custom);
51
+ return join(getVaultRoot(), HISTORY_DIR_NAME);
52
+ }
53
+
54
+ function encodeHistoryKey(relPath) {
55
+ return Buffer.from(relPath, 'utf-8').toString('base64url');
56
+ }
57
+
58
+ function historyDirForPath(relPath) {
59
+ return join(getHistoryRoot(), HISTORY_FILES_DIR, encodeHistoryKey(relPath));
60
+ }
61
+
62
+ function normalizeHistoryActor(actor) {
63
+ if (!actor || typeof actor !== 'object') return null;
64
+ const id = typeof actor.id === 'string' ? actor.id : null;
65
+ const username = typeof actor.username === 'string' ? actor.username : null;
66
+ if (!id && !username) return null;
67
+ return { id, username };
68
+ }
69
+
70
+ function normalizeHistorySource(source) {
71
+ return typeof source === 'string' && source.length > 0 ? source : null;
72
+ }
73
+
74
+ function clampLimit(value, fallback) {
75
+ if (!Number.isFinite(value)) return fallback;
76
+ const n = Math.trunc(value);
77
+ if (n < 1) return 1;
78
+ if (n > MAX_HISTORY_LIMIT) return MAX_HISTORY_LIMIT;
79
+ return n;
80
+ }
81
+
82
+ async function listHistoryFiles(relPath) {
83
+ const dir = historyDirForPath(relPath);
84
+ let names;
85
+ try {
86
+ names = await readdir(dir);
87
+ } catch (err) {
88
+ if (err?.code === 'ENOENT') return [];
89
+ throw err;
90
+ }
91
+
92
+ return names
93
+ .filter((name) => name.endsWith('.json'))
94
+ .sort();
95
+ }
96
+
97
+ async function readHistoryRecord(absPath) {
98
+ const raw = await readFile(absPath, 'utf-8');
99
+ return JSON.parse(raw);
100
+ }
101
+
102
+ function toHistoryMeta(record) {
103
+ return {
104
+ versionId: record.versionId,
105
+ relPath: record.relPath,
106
+ ts: record.ts,
107
+ action: record.action,
108
+ hash: record.hash,
109
+ size: record.size,
110
+ actor: record.actor ?? null,
111
+ source: record.source ?? null,
112
+ };
113
+ }
114
+
115
+ async function pruneHistory(relPath) {
116
+ const files = await listHistoryFiles(relPath);
117
+ if (files.length <= HISTORY_RETENTION_PER_FILE) return;
118
+
119
+ const dir = historyDirForPath(relPath);
120
+ const trimCount = files.length - HISTORY_RETENTION_PER_FILE;
121
+ for (const name of files.slice(0, trimCount)) {
122
+ try {
123
+ await unlink(join(dir, name));
124
+ } catch {
125
+ // Best effort; skip failures and continue pruning.
126
+ }
127
+ }
128
+ }
129
+
130
+ async function writeHistoryRecord(relPath, content, history = {}) {
131
+ if (!isAllowed(relPath)) return;
132
+ const action = typeof history.action === 'string' && history.action.length > 0
133
+ ? history.action
134
+ : 'write';
135
+
136
+ const dir = historyDirForPath(relPath);
137
+ await mkdir(dir, { recursive: true });
138
+
139
+ const hash = hashContent(content);
140
+ const existing = await listHistoryFiles(relPath);
141
+ if (existing.length > 0) {
142
+ const lastFile = existing[existing.length - 1];
143
+ try {
144
+ const latest = await readHistoryRecord(join(dir, lastFile));
145
+ if (latest?.hash === hash && latest?.action === action) {
146
+ return;
147
+ }
148
+ } catch {
149
+ // Ignore malformed historical entry and continue writing a new one.
150
+ }
151
+ }
152
+
153
+ const ts = Date.now();
154
+ const versionId = `${ts}-${Math.random().toString(36).slice(2, 10)}`;
155
+ const record = {
156
+ versionId,
157
+ relPath,
158
+ ts,
159
+ action,
160
+ hash,
161
+ size: Buffer.byteLength(content, 'utf-8'),
162
+ actor: normalizeHistoryActor(history.actor),
163
+ source: normalizeHistorySource(history.source),
164
+ content,
165
+ };
166
+
167
+ await fsWriteFile(join(dir, `${versionId}.json`), JSON.stringify(record), 'utf-8');
168
+ await pruneHistory(relPath);
169
+ }
170
+
171
+ async function writeHistoryRecordBestEffort(relPath, content, history = {}) {
172
+ try {
173
+ await writeHistoryRecord(relPath, content, history);
174
+ } catch (err) {
175
+ console.warn(`[history] Failed to record version for ${relPath}:`, err?.message ?? err);
176
+ }
177
+ }
178
+
37
179
  // ---------------------------------------------------------------------------
38
180
  // Path helpers
39
181
  // ---------------------------------------------------------------------------
@@ -58,6 +200,15 @@ export function safePath(relPath) {
58
200
  */
59
201
  export function isDenied(relPath) {
60
202
  const normalised = relPath.replace(/\\/g, '/');
203
+ if (isMetadataPathAllowed(normalised)) return false;
204
+
205
+ if (normalised === '.obsidian/' || normalised === '.obsidian') {
206
+ // Allow descending into .obsidian only when we explicitly whitelist files inside.
207
+ if ([...METADATA_ALLOWLIST].some((entry) => entry.startsWith('.obsidian/'))) {
208
+ return false;
209
+ }
210
+ }
211
+
61
212
  const parts = normalised.split('/');
62
213
  const base = parts[parts.length - 1];
63
214
  if (DENY_FILES.includes(base)) return true;
@@ -72,6 +223,7 @@ export function isDenied(relPath) {
72
223
  * @param {string} relPath
73
224
  */
74
225
  export function isAllowed(relPath) {
226
+ if (isMetadataPathAllowed(relPath)) return true;
75
227
  if (isDenied(relPath)) return false;
76
228
  return ALLOW_EXTS.has(extname(relPath).toLowerCase());
77
229
  }
@@ -192,7 +344,7 @@ export { readFile_ as readFile };
192
344
  * @param {string} relPath
193
345
  * @param {string} content
194
346
  */
195
- export async function writeFile(relPath, content) {
347
+ export async function writeFile(relPath, content, options = {}) {
196
348
  const abs = safePath(relPath);
197
349
  const tmp = abs + '.tmp';
198
350
  await mkdir(dirname(abs), { recursive: true });
@@ -200,16 +352,22 @@ export async function writeFile(relPath, content) {
200
352
  await rename(tmp, abs);
201
353
  // Invalidate cache entry so next manifest reflects new content
202
354
  manifestCache.delete(relPath);
355
+ await writeHistoryRecordBestEffort(relPath, content, options.history);
203
356
  }
204
357
 
205
358
  /**
206
359
  * Delete a file.
207
360
  * @param {string} relPath
208
361
  */
209
- export async function deleteFile(relPath) {
362
+ export async function deleteFile(relPath, options = {}) {
210
363
  const abs = safePath(relPath);
364
+ const previous = await readFile(abs, 'utf-8');
211
365
  await unlink(abs);
212
366
  manifestCache.delete(relPath);
367
+ await writeHistoryRecordBestEffort(relPath, previous, {
368
+ ...(options.history ?? {}),
369
+ action: options.history?.action ?? 'delete',
370
+ });
213
371
  }
214
372
 
215
373
  /**
@@ -217,13 +375,71 @@ export async function deleteFile(relPath) {
217
375
  * @param {string} oldRelPath
218
376
  * @param {string} newRelPath
219
377
  */
220
- export async function renameFile(oldRelPath, newRelPath) {
378
+ export async function renameFile(oldRelPath, newRelPath, options = {}) {
221
379
  const oldAbs = safePath(oldRelPath);
222
380
  const newAbs = safePath(newRelPath);
381
+ const previous = await readFile(oldAbs, 'utf-8');
223
382
  await mkdir(dirname(newAbs), { recursive: true });
224
383
  await rename(oldAbs, newAbs);
225
384
  manifestCache.delete(oldRelPath);
226
385
  manifestCache.delete(newRelPath);
386
+
387
+ const history = options.history ?? {};
388
+ await writeHistoryRecordBestEffort(oldRelPath, previous, {
389
+ ...history,
390
+ action: 'rename-out',
391
+ });
392
+ await writeHistoryRecordBestEffort(newRelPath, previous, {
393
+ ...history,
394
+ action: 'rename-in',
395
+ });
396
+ }
397
+
398
+ /**
399
+ * List historical versions for a file (newest first).
400
+ * @param {string} relPath
401
+ * @param {number} [limit]
402
+ */
403
+ export async function listFileHistory(relPath, limit = DEFAULT_HISTORY_LIMIT) {
404
+ if (!isAllowed(relPath)) return [];
405
+ const clampedLimit = clampLimit(Number(limit), DEFAULT_HISTORY_LIMIT);
406
+ const dir = historyDirForPath(relPath);
407
+ const names = await listHistoryFiles(relPath);
408
+ const out = [];
409
+
410
+ for (const name of names.reverse()) {
411
+ try {
412
+ const record = await readHistoryRecord(join(dir, name));
413
+ if (record?.relPath !== relPath) continue;
414
+ out.push(toHistoryMeta(record));
415
+ if (out.length >= clampedLimit) break;
416
+ } catch {
417
+ // Skip malformed entries.
418
+ }
419
+ }
420
+
421
+ return out;
422
+ }
423
+
424
+ /**
425
+ * Read a historical file version.
426
+ * @param {string} relPath
427
+ * @param {string} versionId
428
+ */
429
+ export async function readFileHistoryVersion(relPath, versionId) {
430
+ if (!isAllowed(relPath)) {
431
+ throw new Error('Path not allowed');
432
+ }
433
+ if (typeof versionId !== 'string' || !VERSION_ID_RE.test(versionId)) {
434
+ throw new Error('Invalid version ID');
435
+ }
436
+
437
+ const abs = join(historyDirForPath(relPath), `${versionId}.json`);
438
+ const record = await readHistoryRecord(abs);
439
+ if (record?.relPath !== relPath) {
440
+ throw new Error('Version/path mismatch');
441
+ }
442
+ return record;
227
443
  }
228
444
 
229
445
  // ---------------------------------------------------------------------------
package/lib/yjsServer.js CHANGED
@@ -79,7 +79,12 @@ async function flushRoomState(state) {
79
79
 
80
80
  const text = ydoc.getText('content').toString();
81
81
  try {
82
- await vault.writeFile(state.relPath, text);
82
+ await vault.writeFile(state.relPath, text, {
83
+ history: {
84
+ action: 'yjs-write',
85
+ source: 'yjs',
86
+ },
87
+ });
83
88
  const hash = vault.hashContent(text);
84
89
  state.lastPersistAt = Date.now();
85
90
  state.lastPersistHash = hash;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fyresmith/hive-server",
3
- "version": "2.3.2",
3
+ "version": "2.4.0",
4
4
  "type": "module",
5
5
  "description": "Collaborative Obsidian vault server",
6
6
  "main": "index.js",
@@ -27,11 +27,11 @@
27
27
  "build": "npm run verify && npm pack --dry-run",
28
28
  "status": "node bin/hive.js status",
29
29
  "verify": "node scripts/verify.mjs",
30
+ "test:unit": "node --test test/**/*.test.js",
30
31
  "install-hive": "node scripts/install-hive.mjs",
31
- "test": "npm run verify"
32
+ "test": "npm run test:unit && npm run verify"
32
33
  },
33
34
  "dependencies": {
34
- "@fyresmith/hive-server": "^2.2.0",
35
35
  "chalk": "^5.6.2",
36
36
  "chokidar": "^3.6.0",
37
37
  "commander": "^13.1.0",