@fyresmith/hive-server 3.1.0 → 4.0.1

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 CHANGED
@@ -92,6 +92,19 @@ hive env check
92
92
  hive env print
93
93
  ```
94
94
 
95
+ Managed mode requires `OWNER_DISCORD_ID` in the env file. This Discord user is the only account allowed to initialize managed state and issue invites.
96
+
97
+ Managed operations:
98
+
99
+ ```bash
100
+ hive managed status
101
+ hive managed invite create
102
+ hive managed invite list
103
+ hive managed invite revoke <code>
104
+ hive managed member list
105
+ hive managed member remove <discordId>
106
+ ```
107
+
95
108
  ## Tunnel Operations
96
109
 
97
110
  ```bash
@@ -0,0 +1,142 @@
1
+ import { CliError } from '../errors.js';
2
+ import { EXIT } from '../constants.js';
3
+ import { loadValidatedEnv, resolveContext } from '../core/context.js';
4
+ import {
5
+ createInvite,
6
+ describeManagedStatus,
7
+ listInvites,
8
+ loadManagedState,
9
+ removeMember,
10
+ revokeInvite,
11
+ } from '../../lib/managedState.js';
12
+ import { section, success } from '../output.js';
13
+
14
+ async function resolveManagedInputs(options) {
15
+ const { envFile } = await resolveContext(options);
16
+ const { env, issues } = await loadValidatedEnv(envFile, { requireFile: true });
17
+ if (issues.length > 0) {
18
+ throw new CliError(`Env validation failed: ${issues.join(', ')}`, EXIT.FAIL);
19
+ }
20
+ return {
21
+ vaultPath: env.VAULT_PATH,
22
+ ownerDiscordId: env.OWNER_DISCORD_ID,
23
+ envFile,
24
+ };
25
+ }
26
+
27
+ function assertInitialized(state) {
28
+ if (!state) {
29
+ throw new CliError('Managed vault is not initialized. Run pair/init flow first.', EXIT.FAIL);
30
+ }
31
+ }
32
+
33
+ export function registerManagedCommands(program) {
34
+ const managed = program.command('managed').description('Manage Hive managed-vault state');
35
+
36
+ managed
37
+ .command('status')
38
+ .description('Show managed vault status')
39
+ .option('--env-file <path>', 'env file path')
40
+ .action(async (options) => {
41
+ const { vaultPath, ownerDiscordId, envFile } = await resolveManagedInputs(options);
42
+ const state = await loadManagedState(vaultPath);
43
+ section('Managed Status');
44
+ console.log(`Env: ${envFile}`);
45
+ if (!state) {
46
+ console.log('Initialized: no');
47
+ console.log(`Configured owner: ${ownerDiscordId}`);
48
+ return;
49
+ }
50
+ const status = describeManagedStatus(state, ownerDiscordId);
51
+ console.log('Initialized: yes');
52
+ console.log(`Vault ID: ${status.vaultId}`);
53
+ console.log(`Owner: ${state.ownerDiscordId}`);
54
+ console.log(`Configured owner: ${ownerDiscordId}`);
55
+ console.log(`Owner matches env: ${state.ownerDiscordId === ownerDiscordId ? 'yes' : 'no'}`);
56
+ console.log(`Members: ${status.memberCount}`);
57
+ console.log(`Invites: ${Object.keys(state.invites ?? {}).length}`);
58
+ });
59
+
60
+ const invite = managed.command('invite').description('Manage invite codes');
61
+
62
+ invite
63
+ .command('create')
64
+ .description('Create a single-use invite code')
65
+ .option('--env-file <path>', 'env file path')
66
+ .action(async (options) => {
67
+ const { vaultPath, ownerDiscordId } = await resolveManagedInputs(options);
68
+ const created = await createInvite({
69
+ vaultPath,
70
+ ownerDiscordId,
71
+ createdBy: ownerDiscordId,
72
+ });
73
+ success(`Invite created: ${created.code}`);
74
+ });
75
+
76
+ invite
77
+ .command('list')
78
+ .description('List invite codes')
79
+ .option('--env-file <path>', 'env file path')
80
+ .action(async (options) => {
81
+ const { vaultPath } = await resolveManagedInputs(options);
82
+ const invites = await listInvites(vaultPath);
83
+ section('Invites');
84
+ if (invites.length === 0) {
85
+ console.log('(none)');
86
+ return;
87
+ }
88
+ for (const inviteRow of invites) {
89
+ const status = inviteRow.revokedAt
90
+ ? 'revoked'
91
+ : inviteRow.usedAt
92
+ ? `used by ${inviteRow.usedBy}`
93
+ : 'active';
94
+ console.log(`${inviteRow.code} ${status} created ${inviteRow.createdAt}`);
95
+ }
96
+ });
97
+
98
+ invite
99
+ .command('revoke <code>')
100
+ .description('Revoke an unused invite code')
101
+ .option('--env-file <path>', 'env file path')
102
+ .action(async (code, options) => {
103
+ const { vaultPath } = await resolveManagedInputs(options);
104
+ const revoked = await revokeInvite({ vaultPath, code });
105
+ success(`Invite revoked: ${revoked.code}`);
106
+ });
107
+
108
+ const member = managed.command('member').description('Manage members');
109
+
110
+ member
111
+ .command('list')
112
+ .description('List paired members')
113
+ .option('--env-file <path>', 'env file path')
114
+ .action(async (options) => {
115
+ const { vaultPath } = await resolveManagedInputs(options);
116
+ const state = await loadManagedState(vaultPath);
117
+ assertInitialized(state);
118
+ section('Members');
119
+ const members = Object.values(state.members ?? {});
120
+ if (members.length === 0) {
121
+ console.log('(none)');
122
+ return;
123
+ }
124
+ for (const row of members) {
125
+ const ownerMark = row.id === state.ownerDiscordId ? ' (owner)' : '';
126
+ console.log(`${row.id}${ownerMark} @${row.username} added ${row.addedAt}`);
127
+ }
128
+ });
129
+
130
+ member
131
+ .command('remove <discordId>')
132
+ .description('Remove a paired member')
133
+ .option('--env-file <path>', 'env file path')
134
+ .action(async (discordId, options) => {
135
+ const { vaultPath } = await resolveManagedInputs(options);
136
+ const result = await removeMember({ vaultPath, discordId });
137
+ if (!result.removed) {
138
+ throw new CliError(`Member not found: ${discordId}`, EXIT.FAIL);
139
+ }
140
+ success(`Removed member: ${discordId}`);
141
+ });
142
+ }
package/cli/constants.js CHANGED
@@ -18,16 +18,14 @@ export const REQUIRED_ENV_KEYS = [
18
18
  'DISCORD_CLIENT_ID',
19
19
  'DISCORD_CLIENT_SECRET',
20
20
  'DISCORD_REDIRECT_URI',
21
- 'DISCORD_GUILD_ID',
21
+ 'OWNER_DISCORD_ID',
22
22
  'JWT_SECRET',
23
23
  'VAULT_PATH',
24
24
  'PORT',
25
- 'YJS_PORT',
26
25
  ];
27
26
 
28
27
  export const DEFAULT_ENV_VALUES = {
29
28
  PORT: '3000',
30
- YJS_PORT: '3001',
31
29
  };
32
30
 
33
31
  export const DEFAULT_CONFIG = {
package/cli/core/app.js CHANGED
@@ -3,6 +3,7 @@ import { Command, CommanderError } from 'commander';
3
3
  import { EXIT } from '../constants.js';
4
4
  import { CliError } from '../errors.js';
5
5
  import { registerEnvCommands } from '../commands/env.js';
6
+ import { registerManagedCommands } from '../commands/managed.js';
6
7
  import { registerRootCommands } from '../commands/root.js';
7
8
  import { registerServiceCommands } from '../commands/service.js';
8
9
  import { registerTunnelCommands } from '../commands/tunnel.js';
@@ -23,6 +24,7 @@ export class HiveCliApp {
23
24
 
24
25
  registerRootCommands(program);
25
26
  registerEnvCommands(program);
27
+ registerManagedCommands(program);
26
28
  registerTunnelCommands(program);
27
29
  registerServiceCommands(program);
28
30
 
package/cli/env-file.js CHANGED
@@ -76,9 +76,7 @@ export function validateEnvValues(values) {
76
76
  }
77
77
 
78
78
  const port = parseInt(values.PORT ?? '', 10);
79
- const yjsPort = parseInt(values.YJS_PORT ?? '', 10);
80
79
  if (!Number.isInteger(port) || port <= 0) issues.push('PORT must be a positive integer');
81
- if (!Number.isInteger(yjsPort) || yjsPort <= 0) issues.push('YJS_PORT must be a positive integer');
82
80
 
83
81
  try {
84
82
  const uri = new URL(values.DISCORD_REDIRECT_URI ?? '');
@@ -113,11 +111,10 @@ export async function promptForEnv({ envFile, existing, yes = false, preset = {}
113
111
  const questions = [
114
112
  { name: 'DISCORD_CLIENT_ID', message: 'Discord Client ID' },
115
113
  { name: 'DISCORD_CLIENT_SECRET', message: 'Discord Client Secret', secret: true },
116
- { name: 'DISCORD_GUILD_ID', message: 'Discord Guild ID' },
114
+ { name: 'OWNER_DISCORD_ID', message: 'Managed vault owner Discord ID' },
117
115
  { name: 'JWT_SECRET', message: 'JWT secret', secret: true },
118
116
  { name: 'VAULT_PATH', message: 'Vault absolute path' },
119
117
  { name: 'PORT', message: 'HTTP port' },
120
- { name: 'YJS_PORT', message: 'Yjs WS port' },
121
118
  { name: 'DISCORD_REDIRECT_URI', message: 'Discord redirect URI' },
122
119
  ];
123
120
 
@@ -8,6 +8,7 @@ import { run } from '../exec.js';
8
8
  import { getCloudflaredPath } from '../tunnel.js';
9
9
  import { fail, info, section, success, warn } from '../output.js';
10
10
  import { loadValidatedEnv } from '../core/context.js';
11
+ import { loadManagedState } from '../../lib/managedState.js';
11
12
 
12
13
  export async function runDoctorChecks({ envFile, includeCloudflared = true }) {
13
14
  section('Hive Doctor');
@@ -63,6 +64,25 @@ export async function runDoctorChecks({ envFile, includeCloudflared = true }) {
63
64
  }
64
65
  }
65
66
 
67
+ if (env.VAULT_PATH && env.OWNER_DISCORD_ID) {
68
+ try {
69
+ const managedState = await loadManagedState(env.VAULT_PATH);
70
+ if (!managedState) {
71
+ info('Managed state not initialized yet');
72
+ } else if (managedState.ownerDiscordId !== env.OWNER_DISCORD_ID) {
73
+ fail(
74
+ `Managed owner mismatch: state=${managedState.ownerDiscordId} env=${env.OWNER_DISCORD_ID}`,
75
+ );
76
+ failures += 1;
77
+ } else {
78
+ success(`Managed state OK (vaultId ${managedState.vaultId})`);
79
+ }
80
+ } catch (err) {
81
+ fail(`Managed state error: ${err instanceof Error ? err.message : String(err)}`);
82
+ failures += 1;
83
+ }
84
+ }
85
+
66
86
  const port = parseInt(env.PORT, 10);
67
87
  const yjsPort = parseInt(env.YJS_PORT, 10);
68
88
  if (Number.isInteger(port) && port > 0) {
@@ -95,7 +95,6 @@ export async function runSetupWizard(options) {
95
95
  const shouldSetupTunnel = await promptConfirm('Configure Cloudflare Tunnel now?', yes, true);
96
96
  if (shouldSetupTunnel) {
97
97
  const port = parseInteger(envValues.PORT, 'PORT');
98
- const yjsPort = parseInteger(envValues.YJS_PORT, 'YJS_PORT');
99
98
  const tunnelName = requiredOrFallback(options.tunnelName, nextConfig.tunnelName || DEFAULT_TUNNEL_NAME);
100
99
  const cloudflaredConfigFile = requiredOrFallback(
101
100
  options.cloudflaredConfigFile,
@@ -109,7 +108,6 @@ export async function runSetupWizard(options) {
109
108
  configFile: cloudflaredConfigFile,
110
109
  certPath: DEFAULT_CLOUDFLARED_CERT,
111
110
  port,
112
- yjsPort,
113
111
  yes,
114
112
  installService: tunnelService,
115
113
  });
package/cli/tunnel.js CHANGED
@@ -138,10 +138,9 @@ export async function writeCloudflaredConfig({
138
138
  credentialsFile,
139
139
  domain,
140
140
  port,
141
- yjsPort,
142
141
  }) {
143
142
  await mkdir(dirname(configFile), { recursive: true });
144
- const yaml = `tunnel: ${tunnelId}\ncredentials-file: ${credentialsFile}\n\ningress:\n - hostname: ${domain}\n path: /yjs/*\n service: http://localhost:${yjsPort}\n\n - hostname: ${domain}\n service: http://localhost:${port}\n\n - service: http_status:404\n`;
143
+ const yaml = `tunnel: ${tunnelId}\ncredentials-file: ${credentialsFile}\n\ningress:\n - hostname: ${domain}\n service: http://localhost:${port}\n\n - service: http_status:404\n`;
145
144
  await writeFile(configFile, yaml, 'utf-8');
146
145
  return yaml;
147
146
  }
@@ -358,7 +357,6 @@ export async function setupTunnel({
358
357
  configFile,
359
358
  certPath,
360
359
  port,
361
- yjsPort,
362
360
  yes = false,
363
361
  installService = false,
364
362
  }) {
@@ -379,7 +377,6 @@ export async function setupTunnel({
379
377
  credentialsFile,
380
378
  domain,
381
379
  port,
382
- yjsPort,
383
380
  });
384
381
 
385
382
  info(`Ensuring DNS route for ${domain}`);
package/index.js CHANGED
@@ -4,9 +4,11 @@ import { fileURLToPath } from 'url';
4
4
  import express from 'express';
5
5
  import { Server } from 'socket.io';
6
6
  import authRoutes from './routes/auth.js';
7
+ import managedRoutes from './routes/managed.js';
7
8
  import * as vault from './lib/vaultManager.js';
8
9
  import { attachHandlers } from './lib/socketHandler.js';
9
10
  import { startYjsServer, getActiveRooms, forceCloseRoom, getRoomStatus } from './lib/yjsServer.js';
11
+ import { assertOwnerConsistency } from './lib/managedState.js';
10
12
 
11
13
  const REQUIRED = [
12
14
  'VAULT_PATH',
@@ -14,7 +16,7 @@ const REQUIRED = [
14
16
  'DISCORD_CLIENT_ID',
15
17
  'DISCORD_CLIENT_SECRET',
16
18
  'DISCORD_REDIRECT_URI',
17
- 'DISCORD_GUILD_ID',
19
+ 'OWNER_DISCORD_ID',
18
20
  ];
19
21
 
20
22
  function validateEnv() {
@@ -25,13 +27,9 @@ function validateEnv() {
25
27
  }
26
28
 
27
29
  const port = parseInt(process.env.PORT ?? '3000', 10);
28
- const yjsPort = parseInt(process.env.YJS_PORT ?? '3001', 10);
29
30
  if (!Number.isInteger(port) || port <= 0) {
30
31
  throw new Error('[startup] PORT must be a positive integer');
31
32
  }
32
- if (!Number.isInteger(yjsPort) || yjsPort <= 0) {
33
- throw new Error('[startup] YJS_PORT must be a positive integer');
34
- }
35
33
 
36
34
  return { port };
37
35
  }
@@ -51,6 +49,7 @@ export async function startHiveServer(options = {}) {
51
49
  );
52
50
 
53
51
  const { port } = validateEnv();
52
+ await assertOwnerConsistency(process.env.VAULT_PATH, process.env.OWNER_DISCORD_ID);
54
53
 
55
54
  const app = express();
56
55
  const httpServer = createServer(app);
@@ -64,8 +63,21 @@ export async function startHiveServer(options = {}) {
64
63
 
65
64
  app.use(express.json());
66
65
 
66
+ // Allow desktop plugin fetch calls (Authorization header triggers preflight).
67
+ app.use((req, res, next) => {
68
+ res.setHeader('Access-Control-Allow-Origin', '*');
69
+ res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,PATCH,DELETE,OPTIONS');
70
+ res.setHeader('Access-Control-Allow-Headers', 'Authorization,Content-Type');
71
+ if (req.method === 'OPTIONS') {
72
+ res.sendStatus(204);
73
+ return;
74
+ }
75
+ next();
76
+ });
77
+
67
78
  // Auth routes
68
79
  app.use('/auth', authRoutes);
80
+ app.use('/managed', managedRoutes);
69
81
 
70
82
  // Health check
71
83
  app.get('/health', (req, res) => res.json({ ok: true, ts: Date.now() }));
@@ -80,7 +92,17 @@ export async function startHiveServer(options = {}) {
80
92
  }
81
93
 
82
94
  attachHandlers(io, getActiveRooms, broadcastFileUpdated, forceCloseRoom);
83
- startYjsServer(broadcastFileUpdated);
95
+ const yjsWss = startYjsServer(httpServer, broadcastFileUpdated);
96
+
97
+ httpServer.on('upgrade', (req, socket, head) => {
98
+ const { pathname } = new URL(req.url, 'http://localhost');
99
+ if (pathname.startsWith('/yjs')) {
100
+ yjsWss.handleUpgrade(req, socket, head, (ws) => {
101
+ yjsWss.emit('connection', ws, req);
102
+ });
103
+ }
104
+ // Socket.IO handles /socket.io upgrades automatically via its own listener
105
+ });
84
106
 
85
107
  // Chokidar watch for external (non-plugin) changes
86
108
  vault.initWatcher((relPath, event) => {
package/lib/auth.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import jwt from 'jsonwebtoken';
2
+ import { describeManagedStatus, isMember, loadManagedState } from './managedState.js';
2
3
 
3
4
  /**
4
5
  * Verify a JWT and return the decoded payload.
@@ -10,6 +11,30 @@ export function verifyToken(token) {
10
11
  return jwt.verify(token, process.env.JWT_SECRET);
11
12
  }
12
13
 
14
+ function getVaultPath() {
15
+ const value = String(process.env.VAULT_PATH ?? '').trim();
16
+ if (!value) throw new Error('VAULT_PATH env var is required');
17
+ return value;
18
+ }
19
+
20
+ export async function getManagedContextForUser(user, vaultId) {
21
+ const state = await loadManagedState(getVaultPath());
22
+ if (!state) {
23
+ throw new Error('Managed vault is not initialized');
24
+ }
25
+ if (!vaultId || vaultId !== state.vaultId) {
26
+ throw new Error('Invalid vault ID');
27
+ }
28
+ if (!isMember(state, user.id)) {
29
+ throw new Error('User is not paired with this managed vault');
30
+ }
31
+
32
+ return {
33
+ state,
34
+ status: describeManagedStatus(state, user.id),
35
+ };
36
+ }
37
+
13
38
  /**
14
39
  * Express middleware — reads Authorization: Bearer <token> header.
15
40
  */
@@ -31,13 +56,31 @@ export function expressMiddleware(req, res, next) {
31
56
  */
32
57
  export function socketMiddleware(socket, next) {
33
58
  const token = socket.handshake.auth?.token;
59
+ const vaultId = socket.handshake.auth?.vaultId;
34
60
  if (!token) return next(new Error('No token'));
35
- try {
36
- socket.user = verifyToken(token);
37
- next();
38
- } catch (err) {
39
- next(new Error('Invalid token'));
40
- }
61
+ (async () => {
62
+ try {
63
+ socket.user = verifyToken(token);
64
+ socket.managed = await getManagedContextForUser(socket.user, vaultId);
65
+ next();
66
+ } catch (err) {
67
+ const message = err instanceof Error ? err.message : 'Invalid token';
68
+ next(new Error(message));
69
+ }
70
+ })();
71
+ }
72
+
73
+ /**
74
+ * Verify a JWT for the y-websocket WS handshake + managed vault membership.
75
+ * @param {string} token
76
+ * @param {string} vaultId
77
+ * @returns {Promise<object>} decoded payload
78
+ */
79
+ export async function verifyManagedWsAccess(token, vaultId) {
80
+ if (!token) throw new Error('No token');
81
+ const user = verifyToken(token);
82
+ await getManagedContextForUser(user, vaultId);
83
+ return user;
41
84
  }
42
85
 
43
86
  /**
@@ -46,5 +89,10 @@ export function socketMiddleware(socket, next) {
46
89
  * @returns {object} decoded payload
47
90
  */
48
91
  export function verifyWsToken(token) {
49
- return verifyToken(token);
92
+ if (!token) throw new Error('No token');
93
+ try {
94
+ return verifyToken(token);
95
+ } catch (err) {
96
+ throw new Error('Invalid token');
97
+ }
50
98
  }
@@ -0,0 +1,226 @@
1
+ import { randomBytes } from 'crypto';
2
+ import { existsSync } from 'fs';
3
+ import { mkdir, readFile, writeFile } from 'fs/promises';
4
+ import { dirname, resolve, join } from 'path';
5
+
6
+ const STATE_VERSION = 1;
7
+ const STATE_REL_PATH = join('.hive', 'managed-state.json');
8
+
9
+ function nowIso() {
10
+ return new Date().toISOString();
11
+ }
12
+
13
+ function normalizeOwnerId(value) {
14
+ return String(value ?? '').trim();
15
+ }
16
+
17
+ export function getManagedStatePath(vaultPath) {
18
+ const root = resolve(vaultPath);
19
+ return join(root, STATE_REL_PATH);
20
+ }
21
+
22
+ function normalizeState(raw) {
23
+ if (!raw || typeof raw !== 'object') return null;
24
+ const ownerDiscordId = normalizeOwnerId(raw.ownerDiscordId);
25
+ const vaultId = String(raw.vaultId ?? '').trim();
26
+ const initializedAt = String(raw.initializedAt ?? '').trim();
27
+ if (!ownerDiscordId || !vaultId || !initializedAt) return null;
28
+
29
+ const members = raw.members && typeof raw.members === 'object' ? raw.members : {};
30
+ const invites = raw.invites && typeof raw.invites === 'object' ? raw.invites : {};
31
+
32
+ return {
33
+ version: STATE_VERSION,
34
+ managed: true,
35
+ ownerDiscordId,
36
+ vaultId,
37
+ initializedAt,
38
+ members,
39
+ invites,
40
+ };
41
+ }
42
+
43
+ export async function loadManagedState(vaultPath) {
44
+ const filePath = getManagedStatePath(vaultPath);
45
+ if (!existsSync(filePath)) return null;
46
+ const raw = await readFile(filePath, 'utf-8');
47
+ const parsed = JSON.parse(raw);
48
+ return normalizeState(parsed);
49
+ }
50
+
51
+ export async function saveManagedState(vaultPath, state) {
52
+ const filePath = getManagedStatePath(vaultPath);
53
+ const normalized = normalizeState(state);
54
+ if (!normalized) {
55
+ throw new Error('Invalid managed state payload');
56
+ }
57
+ await mkdir(dirname(filePath), { recursive: true });
58
+ await writeFile(filePath, `${JSON.stringify(normalized, null, 2)}\n`, 'utf-8');
59
+ return normalized;
60
+ }
61
+
62
+ export function getRole(state, userId) {
63
+ if (!state) return 'none';
64
+ if (userId === state.ownerDiscordId) return 'owner';
65
+ if (state.members?.[userId]) return 'member';
66
+ return 'none';
67
+ }
68
+
69
+ export function isMember(state, userId) {
70
+ return getRole(state, userId) !== 'none';
71
+ }
72
+
73
+ export async function assertOwnerConsistency(vaultPath, ownerDiscordId) {
74
+ const state = await loadManagedState(vaultPath);
75
+ if (!state) return null;
76
+ const configuredOwner = normalizeOwnerId(ownerDiscordId);
77
+ if (!configuredOwner) {
78
+ throw new Error('OWNER_DISCORD_ID is required');
79
+ }
80
+ if (state.ownerDiscordId !== configuredOwner) {
81
+ throw new Error(
82
+ `[managed] owner mismatch: state=${state.ownerDiscordId} env=${configuredOwner}. Update OWNER_DISCORD_ID or reset managed state.`,
83
+ );
84
+ }
85
+ return state;
86
+ }
87
+
88
+ function nextInviteCode() {
89
+ return randomBytes(6).toString('hex');
90
+ }
91
+
92
+ export async function initManagedState({ vaultPath, ownerDiscordId, ownerUser }) {
93
+ const existing = await loadManagedState(vaultPath);
94
+ if (existing) return existing;
95
+
96
+ const ownerId = normalizeOwnerId(ownerDiscordId);
97
+ if (!ownerId) throw new Error('OWNER_DISCORD_ID is required');
98
+
99
+ const state = {
100
+ version: STATE_VERSION,
101
+ managed: true,
102
+ ownerDiscordId: ownerId,
103
+ vaultId: randomBytes(16).toString('hex'),
104
+ initializedAt: nowIso(),
105
+ members: {},
106
+ invites: {},
107
+ };
108
+
109
+ state.members[ownerId] = {
110
+ id: ownerId,
111
+ username: ownerUser?.username || ownerId,
112
+ addedAt: nowIso(),
113
+ addedBy: ownerId,
114
+ };
115
+
116
+ return saveManagedState(vaultPath, state);
117
+ }
118
+
119
+ export async function createInvite({ vaultPath, ownerDiscordId, createdBy }) {
120
+ const state = await loadManagedState(vaultPath);
121
+ if (!state) throw new Error('Managed vault is not initialized');
122
+ if (state.ownerDiscordId !== normalizeOwnerId(ownerDiscordId)) {
123
+ throw new Error('Owner mismatch');
124
+ }
125
+
126
+ let code = nextInviteCode();
127
+ while (state.invites[code]) {
128
+ code = nextInviteCode();
129
+ }
130
+
131
+ state.invites[code] = {
132
+ code,
133
+ createdAt: nowIso(),
134
+ createdBy,
135
+ usedAt: null,
136
+ usedBy: null,
137
+ revokedAt: null,
138
+ };
139
+ await saveManagedState(vaultPath, state);
140
+ return state.invites[code];
141
+ }
142
+
143
+ export async function listInvites(vaultPath) {
144
+ const state = await loadManagedState(vaultPath);
145
+ if (!state) throw new Error('Managed vault is not initialized');
146
+ return Object.values(state.invites).sort((a, b) => a.createdAt.localeCompare(b.createdAt));
147
+ }
148
+
149
+ export async function revokeInvite({ vaultPath, code }) {
150
+ const state = await loadManagedState(vaultPath);
151
+ if (!state) throw new Error('Managed vault is not initialized');
152
+ const invite = state.invites[code];
153
+ if (!invite) throw new Error('Invite not found');
154
+ if (invite.usedAt) throw new Error('Invite already used');
155
+ if (invite.revokedAt) return invite;
156
+ invite.revokedAt = nowIso();
157
+ await saveManagedState(vaultPath, state);
158
+ return invite;
159
+ }
160
+
161
+ export async function pairMember({ vaultPath, code, user }) {
162
+ const state = await loadManagedState(vaultPath);
163
+ if (!state) throw new Error('Managed vault is not initialized');
164
+
165
+ if (isMember(state, user.id)) {
166
+ return { state, paired: false, reason: 'already-member' };
167
+ }
168
+
169
+ const invite = state.invites[code];
170
+ if (!invite) throw new Error('Invite not found');
171
+ if (invite.revokedAt) throw new Error('Invite revoked');
172
+ if (invite.usedAt) throw new Error('Invite already used');
173
+
174
+ invite.usedAt = nowIso();
175
+ invite.usedBy = user.id;
176
+
177
+ state.members[user.id] = {
178
+ id: user.id,
179
+ username: user.username,
180
+ addedAt: nowIso(),
181
+ addedBy: invite.createdBy || state.ownerDiscordId,
182
+ };
183
+
184
+ await saveManagedState(vaultPath, state);
185
+ return { state, paired: true, reason: 'paired' };
186
+ }
187
+
188
+ export async function removeMember({ vaultPath, discordId }) {
189
+ const state = await loadManagedState(vaultPath);
190
+ if (!state) throw new Error('Managed vault is not initialized');
191
+ if (discordId === state.ownerDiscordId) {
192
+ throw new Error('Cannot remove owner');
193
+ }
194
+ const existing = state.members?.[discordId];
195
+ if (!existing) {
196
+ return { removed: false, state };
197
+ }
198
+ delete state.members[discordId];
199
+ await saveManagedState(vaultPath, state);
200
+ return { removed: true, state, member: existing };
201
+ }
202
+
203
+ export function describeManagedStatus(state, userId) {
204
+ if (!state) {
205
+ return {
206
+ managedInitialized: false,
207
+ vaultId: null,
208
+ role: 'none',
209
+ isOwner: false,
210
+ isMember: false,
211
+ memberCount: 0,
212
+ };
213
+ }
214
+
215
+ const role = getRole(state, userId);
216
+ return {
217
+ managedInitialized: true,
218
+ vaultId: state.vaultId,
219
+ role,
220
+ isOwner: role === 'owner',
221
+ isMember: role === 'owner' || role === 'member',
222
+ ownerDiscordId: state.ownerDiscordId,
223
+ memberCount: Object.keys(state.members ?? {}).length,
224
+ initializedAt: state.initializedAt,
225
+ };
226
+ }
@@ -8,7 +8,7 @@ import chokidar from 'chokidar';
8
8
  // Deny / allow lists
9
9
  // ---------------------------------------------------------------------------
10
10
 
11
- const DENY_PREFIXES = ['.obsidian', 'Attachments', '.git'];
11
+ const DENY_PREFIXES = ['.obsidian', 'Attachments', '.git', '.hive', '.hive-quarantine'];
12
12
  const DENY_FILES = ['.DS_Store', 'Thumbs.db'];
13
13
  // Keep synced content text-based to ensure hash/read/write semantics stay safe.
14
14
  const ALLOW_EXTS = new Set(['.md', '.canvas']);
package/lib/yjsServer.js CHANGED
@@ -204,12 +204,11 @@ export async function forceCloseRoom(relPath) {
204
204
  await closeRoom(docName, { closeClients: true, reason: 'forced' });
205
205
  }
206
206
 
207
- export function startYjsServer(broadcastFileUpdated) {
207
+ export function startYjsServer(httpServer, broadcastFileUpdated) {
208
208
  broadcastRef = broadcastFileUpdated;
209
- const port = parseInt(process.env.YJS_PORT ?? '3001', 10);
210
- const wss = new WebSocketServer({ port });
209
+ const wss = new WebSocketServer({ noServer: true });
211
210
 
212
- wss.on('connection', (conn, req) => {
211
+ wss.on('connection', async (conn, req) => {
213
212
  const url = new URL(req.url, 'http://localhost');
214
213
 
215
214
  // Strip /yjs/ prefix added by Cloudflare Tunnel routing
@@ -223,10 +222,12 @@ export function startYjsServer(broadcastFileUpdated) {
223
222
  }
224
223
 
225
224
  const token = url.searchParams.get('token');
225
+ const vaultId = url.searchParams.get('vaultId');
226
226
  try {
227
- auth.verifyWsToken(token);
228
- } catch {
229
- conn.close(4001, 'Unauthorized');
227
+ await auth.verifyManagedWsAccess(token, vaultId);
228
+ } catch (err) {
229
+ const message = err instanceof Error ? err.message : 'Unauthorized';
230
+ conn.close(4001, message);
230
231
  return;
231
232
  }
232
233
 
@@ -272,6 +273,5 @@ export function startYjsServer(broadcastFileUpdated) {
272
273
  }
273
274
  });
274
275
 
275
- console.log(`[yjs] WebSocket server listening on port ${port}`);
276
276
  return wss;
277
277
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fyresmith/hive-server",
3
- "version": "3.1.0",
3
+ "version": "4.0.1",
4
4
  "type": "module",
5
5
  "description": "Collaborative Obsidian vault server",
6
6
  "main": "index.js",
package/routes/auth.js CHANGED
@@ -29,9 +29,10 @@ router.get('/login', (req, res) => {
29
29
  const state = randomBytes(16).toString('hex');
30
30
  stateMap.set(state, { ts: Date.now() });
31
31
 
32
+ const scope = process.env.DISCORD_GUILD_ID ? ['identify', 'guilds'] : ['identify'];
32
33
  const url = oauth.generateAuthUrl({
33
34
  clientId: process.env.DISCORD_CLIENT_ID,
34
- scope: ['identify', 'guilds'],
35
+ scope,
35
36
  redirectUri: process.env.DISCORD_REDIRECT_URI,
36
37
  state,
37
38
  });
@@ -56,22 +57,25 @@ router.get('/callback', async (req, res) => {
56
57
 
57
58
  try {
58
59
  // Exchange code for access token
60
+ const scope = process.env.DISCORD_GUILD_ID ? ['identify', 'guilds'] : ['identify'];
59
61
  const tokenData = await oauth.tokenRequest({
60
62
  clientId: process.env.DISCORD_CLIENT_ID,
61
63
  clientSecret: process.env.DISCORD_CLIENT_SECRET,
62
64
  redirectUri: process.env.DISCORD_REDIRECT_URI,
63
65
  code,
64
- scope: ['identify', 'guilds'],
66
+ scope,
65
67
  grantType: 'authorization_code',
66
68
  });
67
69
 
68
70
  const accessToken = tokenData.access_token;
69
71
 
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.');
72
+ // Verify guild membership (only when DISCORD_GUILD_ID is configured)
73
+ if (process.env.DISCORD_GUILD_ID) {
74
+ const guilds = await oauth.getUserGuilds(accessToken);
75
+ const isMember = guilds.some((g) => g.id === process.env.DISCORD_GUILD_ID);
76
+ if (!isMember) {
77
+ return res.status(403).send('Access denied: you are not a member of the required Discord server.');
78
+ }
75
79
  }
76
80
 
77
81
  // Fetch user profile
@@ -0,0 +1,174 @@
1
+ import { Router } from 'express';
2
+ import { expressMiddleware } from '../lib/auth.js';
3
+ import {
4
+ createInvite,
5
+ describeManagedStatus,
6
+ initManagedState,
7
+ listInvites,
8
+ loadManagedState,
9
+ pairMember,
10
+ removeMember,
11
+ revokeInvite,
12
+ } from '../lib/managedState.js';
13
+
14
+ const router = Router();
15
+
16
+ router.use((req, res, next) => {
17
+ res.setHeader('Access-Control-Allow-Origin', '*');
18
+ res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,PATCH,DELETE,OPTIONS');
19
+ res.setHeader('Access-Control-Allow-Headers', 'Authorization,Content-Type');
20
+ if (req.method === 'OPTIONS') {
21
+ res.sendStatus(204);
22
+ return;
23
+ }
24
+ next();
25
+ });
26
+
27
+ function getVaultPath() {
28
+ const value = String(process.env.VAULT_PATH ?? '').trim();
29
+ if (!value) throw new Error('VAULT_PATH env var is required');
30
+ return value;
31
+ }
32
+
33
+ function getOwnerDiscordId() {
34
+ return String(process.env.OWNER_DISCORD_ID ?? '').trim();
35
+ }
36
+
37
+ function assertOwner(req, res) {
38
+ const ownerId = getOwnerDiscordId();
39
+ if (!ownerId) {
40
+ res.status(500).json({ ok: false, error: 'OWNER_DISCORD_ID is not configured' });
41
+ return false;
42
+ }
43
+ if (req.user?.id !== ownerId) {
44
+ res.status(403).json({ ok: false, error: 'Owner access required' });
45
+ return false;
46
+ }
47
+ return true;
48
+ }
49
+
50
+ router.get('/status', expressMiddleware, async (req, res) => {
51
+ try {
52
+ const state = await loadManagedState(getVaultPath());
53
+ let status = describeManagedStatus(state, req.user.id);
54
+ if (!state) {
55
+ const isOwner = req.user.id === getOwnerDiscordId();
56
+ status = {
57
+ ...status,
58
+ role: isOwner ? 'owner' : 'none',
59
+ isOwner,
60
+ };
61
+ }
62
+ res.json({
63
+ ok: true,
64
+ ...status,
65
+ user: { id: req.user.id, username: req.user.username },
66
+ });
67
+ } catch (err) {
68
+ res.status(500).json({ ok: false, error: err instanceof Error ? err.message : String(err) });
69
+ }
70
+ });
71
+
72
+ router.post('/init', expressMiddleware, async (req, res) => {
73
+ if (!assertOwner(req, res)) return;
74
+ try {
75
+ const state = await initManagedState({
76
+ vaultPath: getVaultPath(),
77
+ ownerDiscordId: getOwnerDiscordId(),
78
+ ownerUser: req.user,
79
+ });
80
+ res.json({
81
+ ok: true,
82
+ managedInitialized: true,
83
+ vaultId: state.vaultId,
84
+ ownerDiscordId: state.ownerDiscordId,
85
+ memberCount: Object.keys(state.members ?? {}).length,
86
+ });
87
+ } catch (err) {
88
+ res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) });
89
+ }
90
+ });
91
+
92
+ router.post('/pair', expressMiddleware, async (req, res) => {
93
+ const code = String(req.body?.code ?? '').trim();
94
+ if (!code) {
95
+ return res.status(400).json({ ok: false, error: 'Invite code is required' });
96
+ }
97
+
98
+ try {
99
+ const result = await pairMember({
100
+ vaultPath: getVaultPath(),
101
+ code,
102
+ user: req.user,
103
+ });
104
+
105
+ res.json({
106
+ ok: true,
107
+ paired: result.paired,
108
+ reason: result.reason,
109
+ vaultId: result.state.vaultId,
110
+ memberCount: Object.keys(result.state.members ?? {}).length,
111
+ });
112
+ } catch (err) {
113
+ res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) });
114
+ }
115
+ });
116
+
117
+ router.post('/unpair', expressMiddleware, async (req, res) => {
118
+ if (!assertOwner(req, res)) return;
119
+ const discordId = String(req.body?.discordId ?? '').trim();
120
+ if (!discordId) {
121
+ return res.status(400).json({ ok: false, error: 'discordId is required' });
122
+ }
123
+ try {
124
+ const result = await removeMember({ vaultPath: getVaultPath(), discordId });
125
+ res.json({
126
+ ok: true,
127
+ removed: result.removed,
128
+ discordId,
129
+ memberCount: Object.keys(result.state.members ?? {}).length,
130
+ });
131
+ } catch (err) {
132
+ res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) });
133
+ }
134
+ });
135
+
136
+ router.post('/invite/create', expressMiddleware, async (req, res) => {
137
+ if (!assertOwner(req, res)) return;
138
+ try {
139
+ const invite = await createInvite({
140
+ vaultPath: getVaultPath(),
141
+ ownerDiscordId: getOwnerDiscordId(),
142
+ createdBy: req.user.id,
143
+ });
144
+ res.json({ ok: true, invite });
145
+ } catch (err) {
146
+ res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) });
147
+ }
148
+ });
149
+
150
+ router.get('/invite/list', expressMiddleware, async (req, res) => {
151
+ if (!assertOwner(req, res)) return;
152
+ try {
153
+ const invites = await listInvites(getVaultPath());
154
+ res.json({ ok: true, invites });
155
+ } catch (err) {
156
+ res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) });
157
+ }
158
+ });
159
+
160
+ router.post('/invite/revoke', expressMiddleware, async (req, res) => {
161
+ if (!assertOwner(req, res)) return;
162
+ const code = String(req.body?.code ?? '').trim();
163
+ if (!code) {
164
+ return res.status(400).json({ ok: false, error: 'code is required' });
165
+ }
166
+ try {
167
+ const invite = await revokeInvite({ vaultPath: getVaultPath(), code });
168
+ res.json({ ok: true, invite });
169
+ } catch (err) {
170
+ res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) });
171
+ }
172
+ });
173
+
174
+ export default router;