@fyresmith/hive-server 2.4.0 → 3.1.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,26 +3,15 @@ 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';
7
6
 
8
7
  // ---------------------------------------------------------------------------
9
8
  // Deny / allow lists
10
9
  // ---------------------------------------------------------------------------
11
10
 
12
- const DENY_PREFIXES = ['.obsidian', '.hive-history', 'Attachments', '.git'];
11
+ const DENY_PREFIXES = ['.obsidian', 'Attachments', '.git'];
13
12
  const DENY_FILES = ['.DS_Store', 'Thumbs.db'];
14
13
  // Keep synced content text-based to ensure hash/read/write semantics stay safe.
15
14
  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]+$/;
26
15
 
27
16
  // ---------------------------------------------------------------------------
28
17
  // Internals
@@ -45,137 +34,6 @@ function getVaultRoot() {
45
34
  */
46
35
  const manifestCache = new Map();
47
36
 
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
-
179
37
  // ---------------------------------------------------------------------------
180
38
  // Path helpers
181
39
  // ---------------------------------------------------------------------------
@@ -200,15 +58,6 @@ export function safePath(relPath) {
200
58
  */
201
59
  export function isDenied(relPath) {
202
60
  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
-
212
61
  const parts = normalised.split('/');
213
62
  const base = parts[parts.length - 1];
214
63
  if (DENY_FILES.includes(base)) return true;
@@ -223,7 +72,6 @@ export function isDenied(relPath) {
223
72
  * @param {string} relPath
224
73
  */
225
74
  export function isAllowed(relPath) {
226
- if (isMetadataPathAllowed(relPath)) return true;
227
75
  if (isDenied(relPath)) return false;
228
76
  return ALLOW_EXTS.has(extname(relPath).toLowerCase());
229
77
  }
@@ -344,7 +192,7 @@ export { readFile_ as readFile };
344
192
  * @param {string} relPath
345
193
  * @param {string} content
346
194
  */
347
- export async function writeFile(relPath, content, options = {}) {
195
+ export async function writeFile(relPath, content) {
348
196
  const abs = safePath(relPath);
349
197
  const tmp = abs + '.tmp';
350
198
  await mkdir(dirname(abs), { recursive: true });
@@ -352,22 +200,16 @@ export async function writeFile(relPath, content, options = {}) {
352
200
  await rename(tmp, abs);
353
201
  // Invalidate cache entry so next manifest reflects new content
354
202
  manifestCache.delete(relPath);
355
- await writeHistoryRecordBestEffort(relPath, content, options.history);
356
203
  }
357
204
 
358
205
  /**
359
206
  * Delete a file.
360
207
  * @param {string} relPath
361
208
  */
362
- export async function deleteFile(relPath, options = {}) {
209
+ export async function deleteFile(relPath) {
363
210
  const abs = safePath(relPath);
364
- const previous = await readFile(abs, 'utf-8');
365
211
  await unlink(abs);
366
212
  manifestCache.delete(relPath);
367
- await writeHistoryRecordBestEffort(relPath, previous, {
368
- ...(options.history ?? {}),
369
- action: options.history?.action ?? 'delete',
370
- });
371
213
  }
372
214
 
373
215
  /**
@@ -375,71 +217,13 @@ export async function deleteFile(relPath, options = {}) {
375
217
  * @param {string} oldRelPath
376
218
  * @param {string} newRelPath
377
219
  */
378
- export async function renameFile(oldRelPath, newRelPath, options = {}) {
220
+ export async function renameFile(oldRelPath, newRelPath) {
379
221
  const oldAbs = safePath(oldRelPath);
380
222
  const newAbs = safePath(newRelPath);
381
- const previous = await readFile(oldAbs, 'utf-8');
382
223
  await mkdir(dirname(newAbs), { recursive: true });
383
224
  await rename(oldAbs, newAbs);
384
225
  manifestCache.delete(oldRelPath);
385
226
  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;
443
227
  }
444
228
 
445
229
  // ---------------------------------------------------------------------------
package/lib/yjsServer.js CHANGED
@@ -79,12 +79,7 @@ async function flushRoomState(state) {
79
79
 
80
80
  const text = ydoc.getText('content').toString();
81
81
  try {
82
- await vault.writeFile(state.relPath, text, {
83
- history: {
84
- action: 'yjs-write',
85
- source: 'yjs',
86
- },
87
- });
82
+ await vault.writeFile(state.relPath, text);
88
83
  const hash = vault.hashContent(text);
89
84
  state.lastPersistAt = Date.now();
90
85
  state.lastPersistHash = hash;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fyresmith/hive-server",
3
- "version": "2.4.0",
3
+ "version": "3.1.0",
4
4
  "type": "module",
5
5
  "description": "Collaborative Obsidian vault server",
6
6
  "main": "index.js",
@@ -27,9 +27,8 @@
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",
31
30
  "install-hive": "node scripts/install-hive.mjs",
32
- "test": "npm run test:unit && npm run verify"
31
+ "test": "npm run verify"
33
32
  },
34
33
  "dependencies": {
35
34
  "chalk": "^5.6.2",
@@ -1,152 +0,0 @@
1
- const SAFE_METADATA_DEFAULTS = [
2
- '.obsidian/appearance.json',
3
- '.obsidian/community-plugins.json',
4
- '.obsidian/core-plugins.json',
5
- '.obsidian/hotkeys.json',
6
- ];
7
-
8
- function normalizePath(path) {
9
- if (typeof path !== 'string') return '';
10
- return path.replace(/\\/g, '/').replace(/^\//, '');
11
- }
12
-
13
- function parseMetadataAllowlist(raw) {
14
- if (!raw) return [...SAFE_METADATA_DEFAULTS];
15
- try {
16
- const parsed = JSON.parse(raw);
17
- if (!Array.isArray(parsed)) return [...SAFE_METADATA_DEFAULTS];
18
- const out = parsed
19
- .map((entry) => normalizePath(String(entry)))
20
- .filter((entry) => entry.startsWith('.obsidian/') && entry.endsWith('.json'));
21
- return out.length > 0 ? [...new Set(out)] : [...SAFE_METADATA_DEFAULTS];
22
- } catch {
23
- return [...SAFE_METADATA_DEFAULTS];
24
- }
25
- }
26
-
27
- const metadataAllowlist = parseMetadataAllowlist(process.env.HIVE_METADATA_ALLOWLIST_JSON);
28
-
29
- function isMarkdownPath(path) {
30
- return normalizePath(path).toLowerCase().endsWith('.md');
31
- }
32
-
33
- function isCanvasPath(path) {
34
- return normalizePath(path).toLowerCase().endsWith('.canvas');
35
- }
36
-
37
- export function isMetadataPathAllowed(path) {
38
- const normalized = normalizePath(path);
39
- return metadataAllowlist.includes(normalized);
40
- }
41
-
42
- function isMetadataPath(path) {
43
- return isMetadataPathAllowed(path);
44
- }
45
-
46
- function isValidJson(content) {
47
- if (typeof content !== 'string') return false;
48
- try {
49
- JSON.parse(content);
50
- return true;
51
- } catch {
52
- return false;
53
- }
54
- }
55
-
56
- class AdapterRegistry {
57
- constructor() {
58
- this.adapters = new Map();
59
- }
60
-
61
- register(adapter) {
62
- if (!adapter || typeof adapter.adapterId !== 'string') {
63
- throw new Error('Adapter must define adapterId');
64
- }
65
- if (typeof adapter.version !== 'string' || adapter.version.length === 0) {
66
- throw new Error(`Adapter ${adapter.adapterId} must define version`);
67
- }
68
- const normalized = {
69
- ...adapter,
70
- capabilities: Array.isArray(adapter.capabilities)
71
- ? [...new Set(adapter.capabilities.map((c) => String(c)))]
72
- : [],
73
- validateContent: typeof adapter.validateContent === 'function'
74
- ? adapter.validateContent
75
- : () => true,
76
- supportsPath: typeof adapter.supportsPath === 'function'
77
- ? adapter.supportsPath
78
- : () => false,
79
- };
80
-
81
- this.adapters.set(normalized.adapterId, normalized);
82
- return () => {
83
- this.adapters.delete(normalized.adapterId);
84
- };
85
- }
86
-
87
- getById(adapterId) {
88
- return this.adapters.get(adapterId) ?? null;
89
- }
90
-
91
- getByPath(path) {
92
- for (const adapter of this.adapters.values()) {
93
- if (adapter.supportsPath(path)) return adapter;
94
- }
95
- return null;
96
- }
97
-
98
- listCapabilities() {
99
- const out = {};
100
- for (const adapter of this.adapters.values()) {
101
- out[adapter.adapterId] = {
102
- version: adapter.version,
103
- capabilities: [...adapter.capabilities],
104
- };
105
- }
106
- return out;
107
- }
108
-
109
- listDescriptors() {
110
- return [...this.adapters.values()].map((adapter) => ({
111
- adapterId: adapter.adapterId,
112
- version: adapter.version,
113
- capabilities: [...adapter.capabilities],
114
- }));
115
- }
116
- }
117
-
118
- export function createServerAdapterRegistry() {
119
- const registry = new AdapterRegistry();
120
-
121
- registry.register({
122
- adapterId: 'markdown',
123
- version: '1.0.0',
124
- capabilities: ['yjs_text', 'awareness', 'cas'],
125
- supportsPath: isMarkdownPath,
126
- validateContent: (content) => typeof content === 'string',
127
- });
128
-
129
- registry.register({
130
- adapterId: 'canvas',
131
- version: '2.0.0',
132
- capabilities: ['structured_model', 'legacy_text_bridge', 'deterministic_order'],
133
- supportsPath: isCanvasPath,
134
- validateContent: (content) => isValidJson(content),
135
- });
136
-
137
- registry.register({
138
- adapterId: 'metadata',
139
- version: '1.0.0',
140
- capabilities: ['whitelist_policy', 'validation'],
141
- supportsPath: isMetadataPath,
142
- validateContent: (content) => isValidJson(content),
143
- });
144
-
145
- return registry;
146
- }
147
-
148
- export const serverAdapterRegistry = createServerAdapterRegistry();
149
-
150
- export function getMetadataAllowlist() {
151
- return [...metadataAllowlist];
152
- }
@@ -1,25 +0,0 @@
1
- export const PROTOCOL_V1 = 1;
2
- export const PROTOCOL_V2 = 2;
3
-
4
- export const SERVER_CAPABILITIES = [
5
- 'presence_heartbeat',
6
- 'activity_feed',
7
- 'threads',
8
- 'tasks',
9
- 'notifications',
10
- 'adapter_negotiation',
11
- 'canvas_structured',
12
- 'metadata_whitelist',
13
- ];
14
-
15
- export function negotiateProtocol(payload, adapterRegistry) {
16
- const requested = Number(payload?.protocolVersion);
17
- const wantsV2 = Number.isInteger(requested) && requested >= PROTOCOL_V2;
18
- const negotiatedProtocol = wantsV2 ? PROTOCOL_V2 : PROTOCOL_V1;
19
-
20
- return {
21
- negotiatedProtocol,
22
- serverCapabilities: [...SERVER_CAPABILITIES],
23
- adapterCapabilities: adapterRegistry.listCapabilities(),
24
- };
25
- }