@fyresmith/hive-server 3.0.0 → 4.0.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.
- package/README.md +13 -0
- package/cli/commands/managed.js +142 -0
- package/cli/constants.js +1 -0
- package/cli/core/app.js +2 -0
- package/cli/env-file.js +1 -0
- package/cli/flows/doctor.js +20 -0
- package/index.js +5 -0
- package/lib/auth.js +55 -7
- package/lib/managedState.js +226 -0
- package/lib/socketHandler.js +47 -0
- package/lib/vaultManager.js +1 -1
- package/lib/yjsServer.js +6 -4
- package/package.json +1 -1
- package/routes/managed.js +163 -0
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
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
|
@@ -114,6 +114,7 @@ export async function promptForEnv({ envFile, existing, yes = false, preset = {}
|
|
|
114
114
|
{ name: 'DISCORD_CLIENT_ID', message: 'Discord Client ID' },
|
|
115
115
|
{ name: 'DISCORD_CLIENT_SECRET', message: 'Discord Client Secret', secret: true },
|
|
116
116
|
{ name: 'DISCORD_GUILD_ID', message: 'Discord Guild ID' },
|
|
117
|
+
{ name: 'OWNER_DISCORD_ID', message: 'Managed vault owner Discord ID' },
|
|
117
118
|
{ name: 'JWT_SECRET', message: 'JWT secret', secret: true },
|
|
118
119
|
{ name: 'VAULT_PATH', message: 'Vault absolute path' },
|
|
119
120
|
{ name: 'PORT', message: 'HTTP port' },
|
package/cli/flows/doctor.js
CHANGED
|
@@ -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) {
|
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',
|
|
@@ -15,6 +17,7 @@ const REQUIRED = [
|
|
|
15
17
|
'DISCORD_CLIENT_SECRET',
|
|
16
18
|
'DISCORD_REDIRECT_URI',
|
|
17
19
|
'DISCORD_GUILD_ID',
|
|
20
|
+
'OWNER_DISCORD_ID',
|
|
18
21
|
];
|
|
19
22
|
|
|
20
23
|
function validateEnv() {
|
|
@@ -51,6 +54,7 @@ export async function startHiveServer(options = {}) {
|
|
|
51
54
|
);
|
|
52
55
|
|
|
53
56
|
const { port } = validateEnv();
|
|
57
|
+
await assertOwnerConsistency(process.env.VAULT_PATH, process.env.OWNER_DISCORD_ID);
|
|
54
58
|
|
|
55
59
|
const app = express();
|
|
56
60
|
const httpServer = createServer(app);
|
|
@@ -66,6 +70,7 @@ export async function startHiveServer(options = {}) {
|
|
|
66
70
|
|
|
67
71
|
// Auth routes
|
|
68
72
|
app.use('/auth', authRoutes);
|
|
73
|
+
app.use('/managed', managedRoutes);
|
|
69
74
|
|
|
70
75
|
// Health check
|
|
71
76
|
app.get('/health', (req, res) => res.json({ ok: true, ts: Date.now() }));
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/lib/socketHandler.js
CHANGED
|
@@ -19,6 +19,12 @@ const socketToFiles = new Map();
|
|
|
19
19
|
*/
|
|
20
20
|
const userBySocket = new Map();
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* claimedFiles: file path → { socketId, userId, username, color }
|
|
24
|
+
* @type {Map<string, object>}
|
|
25
|
+
*/
|
|
26
|
+
const claimedFiles = new Map();
|
|
27
|
+
|
|
22
28
|
function respond(cb, payload) {
|
|
23
29
|
if (typeof cb === 'function') cb(payload);
|
|
24
30
|
}
|
|
@@ -184,6 +190,38 @@ export function attachHandlers(io, getActiveRooms, broadcastFileUpdated, forceCl
|
|
|
184
190
|
}
|
|
185
191
|
});
|
|
186
192
|
|
|
193
|
+
// -----------------------------------------------------------------------
|
|
194
|
+
// file-claim
|
|
195
|
+
// -----------------------------------------------------------------------
|
|
196
|
+
socket.on('file-claim', ({ relPath } = {}, cb) => {
|
|
197
|
+
if (!isAllowedPath(relPath)) return respond(cb, { ok: false, error: 'Not allowed' });
|
|
198
|
+
const claimUser = userBySocket.get(socket.id);
|
|
199
|
+
if (!claimUser) return;
|
|
200
|
+
claimedFiles.set(relPath, { socketId: socket.id, ...claimUser });
|
|
201
|
+
io.emit('file-claimed', { relPath, user: claimUser });
|
|
202
|
+
respond(cb, { ok: true });
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
// -----------------------------------------------------------------------
|
|
206
|
+
// file-unclaim
|
|
207
|
+
// -----------------------------------------------------------------------
|
|
208
|
+
socket.on('file-unclaim', ({ relPath } = {}, cb) => {
|
|
209
|
+
const claim = claimedFiles.get(relPath);
|
|
210
|
+
if (claim?.socketId !== socket.id) return respond(cb, { ok: false, error: 'Not your claim' });
|
|
211
|
+
claimedFiles.delete(relPath);
|
|
212
|
+
io.emit('file-unclaimed', { relPath, userId: userBySocket.get(socket.id)?.id });
|
|
213
|
+
respond(cb, { ok: true });
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
// -----------------------------------------------------------------------
|
|
217
|
+
// user-status-changed
|
|
218
|
+
// -----------------------------------------------------------------------
|
|
219
|
+
socket.on('user-status-changed', ({ status } = {}) => {
|
|
220
|
+
const statusUser = userBySocket.get(socket.id);
|
|
221
|
+
if (!statusUser) return;
|
|
222
|
+
socket.broadcast.emit('user-status-changed', { userId: statusUser.id, status });
|
|
223
|
+
});
|
|
224
|
+
|
|
187
225
|
// -----------------------------------------------------------------------
|
|
188
226
|
// presence-file-opened
|
|
189
227
|
// -----------------------------------------------------------------------
|
|
@@ -219,6 +257,15 @@ export function attachHandlers(io, getActiveRooms, broadcastFileUpdated, forceCl
|
|
|
219
257
|
socket.broadcast.emit('presence-file-closed', { relPath, user });
|
|
220
258
|
}
|
|
221
259
|
socketToFiles.delete(socket.id);
|
|
260
|
+
|
|
261
|
+
// Release all file claims held by this socket
|
|
262
|
+
for (const [relPath, claim] of claimedFiles) {
|
|
263
|
+
if (claim.socketId === socket.id) {
|
|
264
|
+
claimedFiles.delete(relPath);
|
|
265
|
+
io.emit('file-unclaimed', { relPath, userId: claim.id });
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
222
269
|
userBySocket.delete(socket.id);
|
|
223
270
|
socket.broadcast.emit('user-left', { user });
|
|
224
271
|
});
|
package/lib/vaultManager.js
CHANGED
|
@@ -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
|
@@ -209,7 +209,7 @@ export function startYjsServer(broadcastFileUpdated) {
|
|
|
209
209
|
const port = parseInt(process.env.YJS_PORT ?? '3001', 10);
|
|
210
210
|
const wss = new WebSocketServer({ port });
|
|
211
211
|
|
|
212
|
-
wss.on('connection', (conn, req) => {
|
|
212
|
+
wss.on('connection', async (conn, req) => {
|
|
213
213
|
const url = new URL(req.url, 'http://localhost');
|
|
214
214
|
|
|
215
215
|
// Strip /yjs/ prefix added by Cloudflare Tunnel routing
|
|
@@ -223,10 +223,12 @@ export function startYjsServer(broadcastFileUpdated) {
|
|
|
223
223
|
}
|
|
224
224
|
|
|
225
225
|
const token = url.searchParams.get('token');
|
|
226
|
+
const vaultId = url.searchParams.get('vaultId');
|
|
226
227
|
try {
|
|
227
|
-
auth.
|
|
228
|
-
} catch {
|
|
229
|
-
|
|
228
|
+
await auth.verifyManagedWsAccess(token, vaultId);
|
|
229
|
+
} catch (err) {
|
|
230
|
+
const message = err instanceof Error ? err.message : 'Unauthorized';
|
|
231
|
+
conn.close(4001, message);
|
|
230
232
|
return;
|
|
231
233
|
}
|
|
232
234
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,163 @@
|
|
|
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
|
+
function getVaultPath() {
|
|
17
|
+
const value = String(process.env.VAULT_PATH ?? '').trim();
|
|
18
|
+
if (!value) throw new Error('VAULT_PATH env var is required');
|
|
19
|
+
return value;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function getOwnerDiscordId() {
|
|
23
|
+
return String(process.env.OWNER_DISCORD_ID ?? '').trim();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function assertOwner(req, res) {
|
|
27
|
+
const ownerId = getOwnerDiscordId();
|
|
28
|
+
if (!ownerId) {
|
|
29
|
+
res.status(500).json({ ok: false, error: 'OWNER_DISCORD_ID is not configured' });
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
if (req.user?.id !== ownerId) {
|
|
33
|
+
res.status(403).json({ ok: false, error: 'Owner access required' });
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
router.get('/status', expressMiddleware, async (req, res) => {
|
|
40
|
+
try {
|
|
41
|
+
const state = await loadManagedState(getVaultPath());
|
|
42
|
+
let status = describeManagedStatus(state, req.user.id);
|
|
43
|
+
if (!state) {
|
|
44
|
+
const isOwner = req.user.id === getOwnerDiscordId();
|
|
45
|
+
status = {
|
|
46
|
+
...status,
|
|
47
|
+
role: isOwner ? 'owner' : 'none',
|
|
48
|
+
isOwner,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
res.json({
|
|
52
|
+
ok: true,
|
|
53
|
+
...status,
|
|
54
|
+
user: { id: req.user.id, username: req.user.username },
|
|
55
|
+
});
|
|
56
|
+
} catch (err) {
|
|
57
|
+
res.status(500).json({ ok: false, error: err instanceof Error ? err.message : String(err) });
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
router.post('/init', expressMiddleware, async (req, res) => {
|
|
62
|
+
if (!assertOwner(req, res)) return;
|
|
63
|
+
try {
|
|
64
|
+
const state = await initManagedState({
|
|
65
|
+
vaultPath: getVaultPath(),
|
|
66
|
+
ownerDiscordId: getOwnerDiscordId(),
|
|
67
|
+
ownerUser: req.user,
|
|
68
|
+
});
|
|
69
|
+
res.json({
|
|
70
|
+
ok: true,
|
|
71
|
+
managedInitialized: true,
|
|
72
|
+
vaultId: state.vaultId,
|
|
73
|
+
ownerDiscordId: state.ownerDiscordId,
|
|
74
|
+
memberCount: Object.keys(state.members ?? {}).length,
|
|
75
|
+
});
|
|
76
|
+
} catch (err) {
|
|
77
|
+
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) });
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
router.post('/pair', expressMiddleware, async (req, res) => {
|
|
82
|
+
const code = String(req.body?.code ?? '').trim();
|
|
83
|
+
if (!code) {
|
|
84
|
+
return res.status(400).json({ ok: false, error: 'Invite code is required' });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
const result = await pairMember({
|
|
89
|
+
vaultPath: getVaultPath(),
|
|
90
|
+
code,
|
|
91
|
+
user: req.user,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
res.json({
|
|
95
|
+
ok: true,
|
|
96
|
+
paired: result.paired,
|
|
97
|
+
reason: result.reason,
|
|
98
|
+
vaultId: result.state.vaultId,
|
|
99
|
+
memberCount: Object.keys(result.state.members ?? {}).length,
|
|
100
|
+
});
|
|
101
|
+
} catch (err) {
|
|
102
|
+
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) });
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
router.post('/unpair', expressMiddleware, async (req, res) => {
|
|
107
|
+
if (!assertOwner(req, res)) return;
|
|
108
|
+
const discordId = String(req.body?.discordId ?? '').trim();
|
|
109
|
+
if (!discordId) {
|
|
110
|
+
return res.status(400).json({ ok: false, error: 'discordId is required' });
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
const result = await removeMember({ vaultPath: getVaultPath(), discordId });
|
|
114
|
+
res.json({
|
|
115
|
+
ok: true,
|
|
116
|
+
removed: result.removed,
|
|
117
|
+
discordId,
|
|
118
|
+
memberCount: Object.keys(result.state.members ?? {}).length,
|
|
119
|
+
});
|
|
120
|
+
} catch (err) {
|
|
121
|
+
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) });
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
router.post('/invite/create', expressMiddleware, async (req, res) => {
|
|
126
|
+
if (!assertOwner(req, res)) return;
|
|
127
|
+
try {
|
|
128
|
+
const invite = await createInvite({
|
|
129
|
+
vaultPath: getVaultPath(),
|
|
130
|
+
ownerDiscordId: getOwnerDiscordId(),
|
|
131
|
+
createdBy: req.user.id,
|
|
132
|
+
});
|
|
133
|
+
res.json({ ok: true, invite });
|
|
134
|
+
} catch (err) {
|
|
135
|
+
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) });
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
router.get('/invite/list', expressMiddleware, async (req, res) => {
|
|
140
|
+
if (!assertOwner(req, res)) return;
|
|
141
|
+
try {
|
|
142
|
+
const invites = await listInvites(getVaultPath());
|
|
143
|
+
res.json({ ok: true, invites });
|
|
144
|
+
} catch (err) {
|
|
145
|
+
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) });
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
router.post('/invite/revoke', expressMiddleware, async (req, res) => {
|
|
150
|
+
if (!assertOwner(req, res)) return;
|
|
151
|
+
const code = String(req.body?.code ?? '').trim();
|
|
152
|
+
if (!code) {
|
|
153
|
+
return res.status(400).json({ ok: false, error: 'code is required' });
|
|
154
|
+
}
|
|
155
|
+
try {
|
|
156
|
+
const invite = await revokeInvite({ vaultPath: getVaultPath(), code });
|
|
157
|
+
res.json({ ok: true, invite });
|
|
158
|
+
} catch (err) {
|
|
159
|
+
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) });
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
export default router;
|