@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 +13 -0
- package/cli/commands/managed.js +142 -0
- package/cli/constants.js +1 -3
- package/cli/core/app.js +2 -0
- package/cli/env-file.js +1 -4
- package/cli/flows/doctor.js +20 -0
- package/cli/flows/setup.js +0 -2
- package/cli/tunnel.js +1 -4
- package/index.js +28 -6
- package/lib/auth.js +55 -7
- package/lib/managedState.js +226 -0
- package/lib/vaultManager.js +1 -1
- package/lib/yjsServer.js +8 -8
- package/package.json +1 -1
- package/routes/auth.js +11 -7
- package/routes/managed.js +174 -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
|
@@ -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
|
-
'
|
|
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: '
|
|
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
|
|
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/cli/flows/setup.js
CHANGED
|
@@ -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
|
|
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
|
-
'
|
|
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
|
-
|
|
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/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
|
@@ -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
|
|
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.
|
|
228
|
-
} catch {
|
|
229
|
-
|
|
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
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
|
|
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
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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;
|