@fyresmith/hive-server 4.0.1 → 5.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 +20 -17
- package/assets/plugin/hive/main.js +16384 -0
- package/assets/plugin/hive/manifest.json +9 -0
- package/assets/plugin/hive/styles.css +1040 -0
- package/assets/template-vault/.obsidian/app.json +1 -0
- package/assets/template-vault/.obsidian/appearance.json +1 -0
- package/assets/template-vault/.obsidian/core-plugins.json +33 -0
- package/assets/template-vault/.obsidian/graph.json +22 -0
- package/assets/template-vault/.obsidian/workspace.json +206 -0
- package/assets/template-vault/Welcome.md +5 -0
- package/cli/commands/env.js +4 -4
- package/cli/commands/managed.js +22 -17
- package/cli/commands/root.js +48 -2
- package/cli/commands/tunnel.js +1 -16
- package/cli/constants.js +1 -10
- package/cli/core/context.js +3 -22
- package/cli/env-file.js +15 -29
- package/cli/flows/doctor.js +1 -6
- package/cli/flows/setup.js +106 -31
- package/index.js +81 -45
- package/lib/accountState.js +189 -0
- package/lib/authTokens.js +75 -0
- package/lib/bundleBuilder.js +169 -0
- package/lib/dashboardAuth.js +80 -0
- package/lib/managedState.js +262 -55
- package/lib/setupOrchestrator.js +76 -0
- package/lib/yjsServer.js +10 -7
- package/package.json +3 -2
- package/routes/auth.js +403 -82
- package/routes/dashboard.js +590 -0
- package/routes/managed.js +0 -174
package/cli/flows/doctor.js
CHANGED
|
@@ -64,16 +64,11 @@ export async function runDoctorChecks({ envFile, includeCloudflared = true }) {
|
|
|
64
64
|
}
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
if (env.VAULT_PATH
|
|
67
|
+
if (env.VAULT_PATH) {
|
|
68
68
|
try {
|
|
69
69
|
const managedState = await loadManagedState(env.VAULT_PATH);
|
|
70
70
|
if (!managedState) {
|
|
71
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
72
|
} else {
|
|
78
73
|
success(`Managed state OK (vaultId ${managedState.vaultId})`);
|
|
79
74
|
}
|
package/cli/flows/setup.js
CHANGED
|
@@ -5,11 +5,11 @@ import {
|
|
|
5
5
|
DEFAULT_CLOUDFLARED_CONFIG,
|
|
6
6
|
DEFAULT_TUNNEL_NAME,
|
|
7
7
|
EXIT,
|
|
8
|
+
HIVE_HOME,
|
|
8
9
|
HIVE_CONFIG_FILE,
|
|
9
|
-
LEGACY_ENV_FILE,
|
|
10
10
|
} from '../constants.js';
|
|
11
11
|
import { CliError } from '../errors.js';
|
|
12
|
-
import {
|
|
12
|
+
import { loadEnvFile, normalizeEnv, promptForEnv, validateEnvValues, writeEnvFile } from '../env-file.js';
|
|
13
13
|
import { validateDomain } from '../checks.js';
|
|
14
14
|
import { updateHiveConfig } from '../config.js';
|
|
15
15
|
import { installHiveService } from '../service.js';
|
|
@@ -20,9 +20,11 @@ import {
|
|
|
20
20
|
promptConfirm,
|
|
21
21
|
requiredOrFallback,
|
|
22
22
|
resolveContext,
|
|
23
|
-
setRedirectUriForDomain,
|
|
24
23
|
} from '../core/context.js';
|
|
25
24
|
import { runDoctorChecks } from './doctor.js';
|
|
25
|
+
import { createVaultAtParent, initializeOwnerManagedVault } from '../../lib/setupOrchestrator.js';
|
|
26
|
+
import { loadManagedState } from '../../lib/managedState.js';
|
|
27
|
+
import { join } from 'path';
|
|
26
28
|
|
|
27
29
|
export async function runSetupWizard(options) {
|
|
28
30
|
const yes = Boolean(options.yes);
|
|
@@ -31,25 +33,10 @@ export async function runSetupWizard(options) {
|
|
|
31
33
|
|
|
32
34
|
const { config, envFile } = await resolveContext(options);
|
|
33
35
|
let nextConfig = { ...config, envFile };
|
|
34
|
-
let importedLegacy = false;
|
|
35
|
-
|
|
36
|
-
if (!existsSync(HIVE_CONFIG_FILE) && existsSync(LEGACY_ENV_FILE)) {
|
|
37
|
-
const shouldImportLegacy = await promptConfirm(
|
|
38
|
-
`Import existing legacy env from ${LEGACY_ENV_FILE}?`,
|
|
39
|
-
yes,
|
|
40
|
-
true
|
|
41
|
-
);
|
|
42
|
-
if (shouldImportLegacy) {
|
|
43
|
-
const legacyValues = await loadEnvFile(LEGACY_ENV_FILE);
|
|
44
|
-
await writeEnvFile(envFile, legacyValues);
|
|
45
|
-
importedLegacy = true;
|
|
46
|
-
success(`Imported legacy env into ${envFile}`);
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
36
|
|
|
50
37
|
const envExists = existsSync(envFile);
|
|
51
38
|
let envValues;
|
|
52
|
-
if (!envExists
|
|
39
|
+
if (!envExists) {
|
|
53
40
|
info(`Initializing env file at ${envFile}`);
|
|
54
41
|
const existing = await loadEnvFile(envFile);
|
|
55
42
|
envValues = await promptForEnv({ envFile, existing, yes });
|
|
@@ -63,34 +50,117 @@ export async function runSetupWizard(options) {
|
|
|
63
50
|
}
|
|
64
51
|
}
|
|
65
52
|
|
|
53
|
+
// Prompt for vault display name and owner account credentials
|
|
54
|
+
let vaultName = '';
|
|
55
|
+
let vaultParentPath = '';
|
|
56
|
+
let ownerEmail = '';
|
|
57
|
+
let ownerDisplayName = '';
|
|
58
|
+
let ownerPassword = '';
|
|
59
|
+
if (!yes) {
|
|
60
|
+
const nameResp = await prompts({
|
|
61
|
+
type: 'text',
|
|
62
|
+
name: 'name',
|
|
63
|
+
message: 'Vault display name (e.g. "Team Vault")',
|
|
64
|
+
validate: (v) => String(v).trim().length > 0 || 'Vault name is required',
|
|
65
|
+
});
|
|
66
|
+
vaultName = String(nameResp.name ?? '').trim();
|
|
67
|
+
|
|
68
|
+
if (!String(envValues.VAULT_PATH ?? '').trim()) {
|
|
69
|
+
const parentResp = await prompts({
|
|
70
|
+
type: 'text',
|
|
71
|
+
name: 'path',
|
|
72
|
+
message: 'Choose folder location for the generated vault (parent directory)',
|
|
73
|
+
validate: (v) => String(v).trim().length > 0 || 'Parent folder path is required',
|
|
74
|
+
});
|
|
75
|
+
vaultParentPath = String(parentResp.path ?? '').trim();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const acctResp = await prompts([
|
|
79
|
+
{ type: 'text', name: 'email', message: 'Owner email (for dashboard login)' },
|
|
80
|
+
{ type: 'text', name: 'displayName', message: 'Owner display name' },
|
|
81
|
+
{ type: 'password', name: 'password', message: 'Owner password' },
|
|
82
|
+
]);
|
|
83
|
+
ownerEmail = String(acctResp.email ?? '').trim();
|
|
84
|
+
ownerDisplayName = String(acctResp.displayName ?? '').trim() || 'Owner';
|
|
85
|
+
ownerPassword = String(acctResp.password ?? '');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const existingVaultPath = String(envValues.VAULT_PATH ?? '').trim();
|
|
89
|
+
if (!existingVaultPath) {
|
|
90
|
+
if (yes) {
|
|
91
|
+
vaultName = vaultName || 'Hive Vault';
|
|
92
|
+
vaultParentPath = vaultParentPath || join(HIVE_HOME, 'vaults');
|
|
93
|
+
}
|
|
94
|
+
if (vaultName && vaultParentPath) {
|
|
95
|
+
const generatedVaultPath = await createVaultAtParent({
|
|
96
|
+
parentPath: vaultParentPath,
|
|
97
|
+
vaultName,
|
|
98
|
+
});
|
|
99
|
+
envValues = { ...envValues, VAULT_PATH: generatedVaultPath };
|
|
100
|
+
await writeEnvFile(envFile, envValues);
|
|
101
|
+
success(`Generated vault at ${generatedVaultPath}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
66
105
|
const envIssues = validateEnvValues(envValues);
|
|
67
106
|
if (envIssues.length > 0) {
|
|
68
107
|
for (const issue of envIssues) fail(issue);
|
|
69
108
|
throw new CliError('Env configuration is invalid', EXIT.FAIL);
|
|
70
109
|
}
|
|
71
110
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
111
|
+
// Initialize owner account + managed vault when interactive setup provides credentials.
|
|
112
|
+
if (vaultName && ownerEmail && ownerPassword) {
|
|
113
|
+
const existingState = await loadManagedState(String(envValues.VAULT_PATH));
|
|
114
|
+
if (existingState) {
|
|
115
|
+
info('Managed vault already initialized; skipping owner and vault initialization.');
|
|
116
|
+
} else {
|
|
117
|
+
await initializeOwnerManagedVault({
|
|
118
|
+
vaultPath: envValues.VAULT_PATH,
|
|
119
|
+
vaultName,
|
|
120
|
+
ownerEmail,
|
|
121
|
+
ownerDisplayName,
|
|
122
|
+
ownerPassword,
|
|
123
|
+
});
|
|
124
|
+
success('Owner account created');
|
|
125
|
+
success(`Vault initialized: ${vaultName}`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Prompt for public server URL (used for invite claim redirects)
|
|
130
|
+
let hiveServerUrl = requiredOrFallback(
|
|
131
|
+
options.domain ? `https://${options.domain}` : '',
|
|
132
|
+
envValues.HIVE_SERVER_URL || (nextConfig.domain ? `https://${nextConfig.domain}` : ''),
|
|
75
133
|
);
|
|
76
134
|
|
|
77
135
|
if (!yes) {
|
|
78
136
|
const response = await prompts({
|
|
79
137
|
type: 'text',
|
|
80
|
-
name: '
|
|
81
|
-
message: 'Public
|
|
82
|
-
initial:
|
|
138
|
+
name: 'url',
|
|
139
|
+
message: 'Public URL for Hive server (optional, used for invite links)',
|
|
140
|
+
initial: hiveServerUrl || '',
|
|
83
141
|
});
|
|
84
|
-
if (response.
|
|
85
|
-
|
|
142
|
+
if (response.url !== undefined) {
|
|
143
|
+
hiveServerUrl = String(response.url).trim();
|
|
86
144
|
}
|
|
87
145
|
}
|
|
88
146
|
|
|
89
|
-
if (
|
|
90
|
-
|
|
91
|
-
|
|
147
|
+
if (hiveServerUrl) {
|
|
148
|
+
const updated = { ...envValues, HIVE_SERVER_URL: hiveServerUrl };
|
|
149
|
+
await writeEnvFile(envFile, updated);
|
|
150
|
+
envValues = updated;
|
|
151
|
+
|
|
152
|
+
// Derive domain for Cloudflare Tunnel config
|
|
153
|
+
let domain = nextConfig.domain;
|
|
154
|
+
try {
|
|
155
|
+
domain = new URL(hiveServerUrl).hostname;
|
|
156
|
+
} catch {
|
|
157
|
+
// ignore parse error
|
|
158
|
+
}
|
|
92
159
|
|
|
93
|
-
|
|
160
|
+
if (domain && validateDomain(domain)) {
|
|
161
|
+
nextConfig = { ...nextConfig, domain };
|
|
162
|
+
}
|
|
163
|
+
}
|
|
94
164
|
|
|
95
165
|
const shouldSetupTunnel = await promptConfirm('Configure Cloudflare Tunnel now?', yes, true);
|
|
96
166
|
if (shouldSetupTunnel) {
|
|
@@ -102,6 +172,11 @@ export async function runSetupWizard(options) {
|
|
|
102
172
|
);
|
|
103
173
|
const tunnelService = await promptConfirm('Install cloudflared as a service?', yes, true);
|
|
104
174
|
|
|
175
|
+
const domain = nextConfig.domain;
|
|
176
|
+
if (!domain || !validateDomain(domain)) {
|
|
177
|
+
throw new CliError(`Cannot configure tunnel: invalid or missing domain (${domain})`);
|
|
178
|
+
}
|
|
179
|
+
|
|
105
180
|
const tunnelResult = await setupTunnel({
|
|
106
181
|
tunnelName,
|
|
107
182
|
domain,
|
package/index.js
CHANGED
|
@@ -1,25 +1,20 @@
|
|
|
1
1
|
import dotenv from 'dotenv';
|
|
2
2
|
import { createServer } from 'http';
|
|
3
|
+
import { existsSync } from 'fs';
|
|
3
4
|
import { fileURLToPath } from 'url';
|
|
4
5
|
import express from 'express';
|
|
5
6
|
import { Server } from 'socket.io';
|
|
6
7
|
import authRoutes from './routes/auth.js';
|
|
7
|
-
import
|
|
8
|
+
import dashboardRoutes from './routes/dashboard.js';
|
|
8
9
|
import * as vault from './lib/vaultManager.js';
|
|
9
10
|
import { attachHandlers } from './lib/socketHandler.js';
|
|
10
11
|
import { startYjsServer, getActiveRooms, forceCloseRoom, getRoomStatus } from './lib/yjsServer.js';
|
|
11
|
-
import {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
'DISCORD_CLIENT_SECRET',
|
|
18
|
-
'DISCORD_REDIRECT_URI',
|
|
19
|
-
'OWNER_DISCORD_ID',
|
|
20
|
-
];
|
|
21
|
-
|
|
22
|
-
function validateEnv() {
|
|
12
|
+
import { loadManagedState } from './lib/managedState.js';
|
|
13
|
+
import { DEFAULT_ENV_FILE } from './cli/constants.js';
|
|
14
|
+
|
|
15
|
+
const REQUIRED = ['JWT_SECRET'];
|
|
16
|
+
|
|
17
|
+
function validateEnv({ allowSetupMode = false } = {}) {
|
|
23
18
|
for (const key of REQUIRED) {
|
|
24
19
|
if (!process.env[key]) {
|
|
25
20
|
throw new Error(`[startup] Missing required env var: ${key}`);
|
|
@@ -31,16 +26,27 @@ function validateEnv() {
|
|
|
31
26
|
throw new Error('[startup] PORT must be a positive integer');
|
|
32
27
|
}
|
|
33
28
|
|
|
34
|
-
|
|
29
|
+
// Ensure packaged onboarding assets are available on startup.
|
|
30
|
+
const assetsRoot = fileURLToPath(new URL('./assets', import.meta.url));
|
|
31
|
+
if (!existsSync(assetsRoot)) {
|
|
32
|
+
throw new Error('[startup] Missing packaged assets directory');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const hasVaultPath = Boolean(String(process.env.VAULT_PATH ?? '').trim());
|
|
36
|
+
if (!hasVaultPath && !allowSetupMode) {
|
|
37
|
+
throw new Error('[startup] Missing required env var: VAULT_PATH');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return { port, hasVaultPath };
|
|
35
41
|
}
|
|
36
42
|
|
|
37
43
|
/**
|
|
38
44
|
* Start Hive server runtime.
|
|
39
45
|
*
|
|
40
|
-
* @param {{ envFile?: string, quiet?: boolean }} [options]
|
|
46
|
+
* @param {{ envFile?: string, quiet?: boolean, allowSetupMode?: boolean }} [options]
|
|
41
47
|
*/
|
|
42
48
|
export async function startHiveServer(options = {}) {
|
|
43
|
-
const { envFile, quiet = false } = options;
|
|
49
|
+
const { envFile, quiet = false, allowSetupMode = false } = options;
|
|
44
50
|
|
|
45
51
|
dotenv.config(
|
|
46
52
|
envFile
|
|
@@ -48,11 +54,17 @@ export async function startHiveServer(options = {}) {
|
|
|
48
54
|
: undefined
|
|
49
55
|
);
|
|
50
56
|
|
|
51
|
-
|
|
52
|
-
|
|
57
|
+
process.env.HIVE_ENV_FILE = envFile || process.env.HIVE_ENV_FILE || DEFAULT_ENV_FILE;
|
|
58
|
+
|
|
59
|
+
const { port, hasVaultPath } = validateEnv({ allowSetupMode });
|
|
60
|
+
if (hasVaultPath) {
|
|
61
|
+
await loadManagedState(process.env.VAULT_PATH);
|
|
62
|
+
}
|
|
53
63
|
|
|
54
64
|
const app = express();
|
|
55
65
|
const httpServer = createServer(app);
|
|
66
|
+
app.locals.hiveEnvFile = process.env.HIVE_ENV_FILE;
|
|
67
|
+
app.locals.activateRealtime = null;
|
|
56
68
|
|
|
57
69
|
const io = new Server(httpServer, {
|
|
58
70
|
cors: {
|
|
@@ -75,9 +87,16 @@ export async function startHiveServer(options = {}) {
|
|
|
75
87
|
next();
|
|
76
88
|
});
|
|
77
89
|
|
|
90
|
+
const setupRequired = (req, res, next) => {
|
|
91
|
+
if (!String(process.env.VAULT_PATH ?? '').trim()) {
|
|
92
|
+
return res.status(503).json({ ok: false, error: 'Setup required: configure vault path first.' });
|
|
93
|
+
}
|
|
94
|
+
next();
|
|
95
|
+
};
|
|
96
|
+
|
|
78
97
|
// Auth routes
|
|
79
|
-
app.use('/auth', authRoutes);
|
|
80
|
-
app.use('/
|
|
98
|
+
app.use('/auth', setupRequired, authRoutes);
|
|
99
|
+
app.use('/dashboard', dashboardRoutes);
|
|
81
100
|
|
|
82
101
|
// Health check
|
|
83
102
|
app.get('/health', (req, res) => res.json({ ok: true, ts: Date.now() }));
|
|
@@ -91,33 +110,46 @@ export async function startHiveServer(options = {}) {
|
|
|
91
110
|
});
|
|
92
111
|
}
|
|
93
112
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
const
|
|
99
|
-
if (
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
113
|
+
let realtimeActive = false;
|
|
114
|
+
let yjsWss = null;
|
|
115
|
+
const activateRealtime = async () => {
|
|
116
|
+
if (realtimeActive) return;
|
|
117
|
+
const vaultPath = String(process.env.VAULT_PATH ?? '').trim();
|
|
118
|
+
if (!vaultPath) return;
|
|
119
|
+
await loadManagedState(vaultPath);
|
|
120
|
+
attachHandlers(io, getActiveRooms, broadcastFileUpdated, forceCloseRoom);
|
|
121
|
+
yjsWss = startYjsServer(httpServer, broadcastFileUpdated);
|
|
122
|
+
httpServer.on('upgrade', (req, socket, head) => {
|
|
123
|
+
const { pathname } = new URL(req.url, 'http://localhost');
|
|
124
|
+
if (pathname.startsWith('/yjs')) {
|
|
125
|
+
yjsWss.handleUpgrade(req, socket, head, (ws) => {
|
|
126
|
+
yjsWss.emit('connection', ws, req);
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
// Socket.IO handles /socket.io upgrades automatically via its own listener
|
|
130
|
+
});
|
|
106
131
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
132
|
+
vault.initWatcher((relPath, event) => {
|
|
133
|
+
const docName = encodeURIComponent(relPath);
|
|
134
|
+
if (getActiveRooms().has(docName)) {
|
|
135
|
+
if (!quiet) {
|
|
136
|
+
console.log(`[chokidar] Ignoring external change to active room: ${relPath}`);
|
|
137
|
+
}
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
111
140
|
if (!quiet) {
|
|
112
|
-
console.log(`[chokidar]
|
|
141
|
+
console.log(`[chokidar] External ${event}: ${relPath}`);
|
|
113
142
|
}
|
|
114
|
-
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
143
|
+
io.emit('external-update', { relPath, event });
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
realtimeActive = true;
|
|
147
|
+
};
|
|
148
|
+
app.locals.activateRealtime = activateRealtime;
|
|
149
|
+
|
|
150
|
+
if (hasVaultPath) {
|
|
151
|
+
await activateRealtime();
|
|
152
|
+
}
|
|
121
153
|
|
|
122
154
|
await new Promise((resolve, reject) => {
|
|
123
155
|
httpServer.once('error', reject);
|
|
@@ -126,7 +158,11 @@ export async function startHiveServer(options = {}) {
|
|
|
126
158
|
|
|
127
159
|
if (!quiet) {
|
|
128
160
|
console.log(`[server] Hive server listening on port ${port}`);
|
|
129
|
-
|
|
161
|
+
if (String(process.env.VAULT_PATH ?? '').trim()) {
|
|
162
|
+
console.log(`[server] Vault: ${process.env.VAULT_PATH}`);
|
|
163
|
+
} else {
|
|
164
|
+
console.log('[server] Setup mode: VAULT_PATH not configured yet');
|
|
165
|
+
}
|
|
130
166
|
}
|
|
131
167
|
|
|
132
168
|
return {
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { randomUUID, scrypt as scryptCb, timingSafeEqual } from 'crypto';
|
|
2
|
+
import { existsSync } from 'fs';
|
|
3
|
+
import { mkdir, readFile, writeFile } from 'fs/promises';
|
|
4
|
+
import { dirname, join, resolve } from 'path';
|
|
5
|
+
import { promisify } from 'util';
|
|
6
|
+
|
|
7
|
+
const scrypt = promisify(scryptCb);
|
|
8
|
+
|
|
9
|
+
const STATE_VERSION = 1;
|
|
10
|
+
const STATE_REL_PATH = join('.hive', 'accounts-state.json');
|
|
11
|
+
|
|
12
|
+
function nowIso() {
|
|
13
|
+
return new Date().toISOString();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function normalizeEmail(value) {
|
|
17
|
+
return String(value ?? '').trim().toLowerCase();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function parseEmail(value) {
|
|
21
|
+
const email = String(value ?? '').trim();
|
|
22
|
+
const emailNorm = normalizeEmail(email);
|
|
23
|
+
if (!emailNorm || !emailNorm.includes('@')) {
|
|
24
|
+
throw new Error('A valid email is required');
|
|
25
|
+
}
|
|
26
|
+
return { email, emailNorm };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function normalizeDisplayName(value) {
|
|
30
|
+
const displayName = String(value ?? '').trim();
|
|
31
|
+
if (!displayName || displayName.length > 32) {
|
|
32
|
+
throw new Error('Display name must be 1-32 characters');
|
|
33
|
+
}
|
|
34
|
+
return displayName;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function normalizeAccount(raw) {
|
|
38
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
39
|
+
const id = String(raw.id ?? '').trim();
|
|
40
|
+
const email = String(raw.email ?? '').trim();
|
|
41
|
+
const emailNorm = normalizeEmail(raw.emailNorm ?? email);
|
|
42
|
+
const displayName = String(raw.displayName ?? '').trim();
|
|
43
|
+
const passwordHash = String(raw.passwordHash ?? '').trim();
|
|
44
|
+
const passwordSalt = String(raw.passwordSalt ?? '').trim();
|
|
45
|
+
const createdAt = String(raw.createdAt ?? '').trim();
|
|
46
|
+
const updatedAt = String(raw.updatedAt ?? '').trim();
|
|
47
|
+
|
|
48
|
+
if (!id || !emailNorm || !displayName || !passwordHash || !passwordSalt || !createdAt || !updatedAt) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
id,
|
|
54
|
+
email,
|
|
55
|
+
emailNorm,
|
|
56
|
+
displayName,
|
|
57
|
+
passwordHash,
|
|
58
|
+
passwordSalt,
|
|
59
|
+
createdAt,
|
|
60
|
+
updatedAt,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function normalizeState(raw) {
|
|
65
|
+
const source = raw && typeof raw === 'object' ? raw : {};
|
|
66
|
+
const accountsRaw = source.accounts && typeof source.accounts === 'object' ? source.accounts : {};
|
|
67
|
+
|
|
68
|
+
const accounts = {};
|
|
69
|
+
for (const [id, value] of Object.entries(accountsRaw)) {
|
|
70
|
+
const normalized = normalizeAccount(value);
|
|
71
|
+
if (normalized) {
|
|
72
|
+
accounts[id] = normalized;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
version: STATE_VERSION,
|
|
78
|
+
accounts,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function hashPassword(password, salt) {
|
|
83
|
+
const input = String(password ?? '');
|
|
84
|
+
if (input.length < 8) {
|
|
85
|
+
throw new Error('Password must be at least 8 characters');
|
|
86
|
+
}
|
|
87
|
+
const derived = await scrypt(input, salt, 64);
|
|
88
|
+
return Buffer.from(derived).toString('hex');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function getAccountsStatePath(vaultPath) {
|
|
92
|
+
return join(resolve(vaultPath), STATE_REL_PATH);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function loadAccountsState(vaultPath) {
|
|
96
|
+
const filePath = getAccountsStatePath(vaultPath);
|
|
97
|
+
if (!existsSync(filePath)) {
|
|
98
|
+
return normalizeState(null);
|
|
99
|
+
}
|
|
100
|
+
const raw = await readFile(filePath, 'utf-8');
|
|
101
|
+
const parsed = JSON.parse(raw);
|
|
102
|
+
return normalizeState(parsed);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function saveAccountsState(vaultPath, state) {
|
|
106
|
+
const filePath = getAccountsStatePath(vaultPath);
|
|
107
|
+
const normalized = normalizeState(state);
|
|
108
|
+
await mkdir(dirname(filePath), { recursive: true });
|
|
109
|
+
await writeFile(filePath, `${JSON.stringify(normalized, null, 2)}\n`, 'utf-8');
|
|
110
|
+
return normalized;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function createAccount({ vaultPath, email, password, displayName }) {
|
|
114
|
+
const { emailNorm } = parseEmail(email);
|
|
115
|
+
const nextDisplayName = normalizeDisplayName(displayName);
|
|
116
|
+
|
|
117
|
+
const state = await loadAccountsState(vaultPath);
|
|
118
|
+
const existing = Object.values(state.accounts).find((row) => row.emailNorm === emailNorm);
|
|
119
|
+
if (existing) {
|
|
120
|
+
throw new Error('An account already exists for this email');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const id = randomUUID();
|
|
124
|
+
const passwordSalt = randomUUID();
|
|
125
|
+
const passwordHash = await hashPassword(password, passwordSalt);
|
|
126
|
+
const ts = nowIso();
|
|
127
|
+
|
|
128
|
+
state.accounts[id] = {
|
|
129
|
+
id,
|
|
130
|
+
email: String(email ?? '').trim(),
|
|
131
|
+
emailNorm,
|
|
132
|
+
displayName: nextDisplayName,
|
|
133
|
+
passwordHash,
|
|
134
|
+
passwordSalt,
|
|
135
|
+
createdAt: ts,
|
|
136
|
+
updatedAt: ts,
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
await saveAccountsState(vaultPath, state);
|
|
140
|
+
return toPublicAccount(state.accounts[id]);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export async function createOwnerAccount({ vaultPath, email, displayName, password }) {
|
|
144
|
+
return createAccount({
|
|
145
|
+
vaultPath,
|
|
146
|
+
email,
|
|
147
|
+
password,
|
|
148
|
+
displayName,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export async function authenticateAccount({ vaultPath, email, password }) {
|
|
153
|
+
const { emailNorm } = parseEmail(email);
|
|
154
|
+
const state = await loadAccountsState(vaultPath);
|
|
155
|
+
const account = Object.values(state.accounts).find((row) => row.emailNorm === emailNorm);
|
|
156
|
+
if (!account) {
|
|
157
|
+
throw new Error('Invalid email or password');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const derived = await hashPassword(password, account.passwordSalt);
|
|
161
|
+
const actual = Buffer.from(account.passwordHash, 'hex');
|
|
162
|
+
const incoming = Buffer.from(derived, 'hex');
|
|
163
|
+
if (actual.length !== incoming.length || !timingSafeEqual(actual, incoming)) {
|
|
164
|
+
throw new Error('Invalid email or password');
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return toPublicAccount(account);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export async function getAccountById(vaultPath, accountId) {
|
|
171
|
+
const id = String(accountId ?? '').trim();
|
|
172
|
+
if (!id) return null;
|
|
173
|
+
|
|
174
|
+
const state = await loadAccountsState(vaultPath);
|
|
175
|
+
const account = state.accounts[id];
|
|
176
|
+
if (!account) return null;
|
|
177
|
+
return toPublicAccount(account);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function toPublicAccount(account) {
|
|
181
|
+
return {
|
|
182
|
+
id: account.id,
|
|
183
|
+
email: account.email,
|
|
184
|
+
emailNorm: account.emailNorm,
|
|
185
|
+
displayName: account.displayName,
|
|
186
|
+
createdAt: account.createdAt,
|
|
187
|
+
updatedAt: account.updatedAt,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { createHash, randomBytes } from 'crypto';
|
|
2
|
+
import jwt from 'jsonwebtoken';
|
|
3
|
+
|
|
4
|
+
const PURPOSE_CLAIM_SESSION = 'claim-session';
|
|
5
|
+
|
|
6
|
+
function getJwtSecret() {
|
|
7
|
+
const secret = String(process.env.JWT_SECRET ?? '').trim();
|
|
8
|
+
if (!secret) {
|
|
9
|
+
throw new Error('JWT_SECRET is required');
|
|
10
|
+
}
|
|
11
|
+
return secret;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function parsePositiveInt(value, fallback) {
|
|
15
|
+
const parsed = parseInt(String(value ?? '').trim(), 10);
|
|
16
|
+
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
17
|
+
return fallback;
|
|
18
|
+
}
|
|
19
|
+
return parsed;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function downloadTicketTtlMinutes() {
|
|
23
|
+
return parsePositiveInt(process.env.HIVE_BUNDLE_GRANT_TTL_MINUTES, 15);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function bootstrapTokenTtlHours() {
|
|
27
|
+
return parsePositiveInt(process.env.HIVE_BOOTSTRAP_TOKEN_TTL_HOURS, 24);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function hashToken(token) {
|
|
31
|
+
return createHash('sha256').update(String(token ?? ''), 'utf-8').digest('hex');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function signClaimSessionToken(account) {
|
|
35
|
+
const payload = {
|
|
36
|
+
purpose: PURPOSE_CLAIM_SESSION,
|
|
37
|
+
accountId: account.id,
|
|
38
|
+
displayName: account.displayName,
|
|
39
|
+
emailNorm: account.emailNorm,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
return jwt.sign(payload, getJwtSecret(), { expiresIn: '7d' });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function verifyClaimSessionToken(token) {
|
|
46
|
+
const decoded = jwt.verify(String(token ?? ''), getJwtSecret());
|
|
47
|
+
if (!decoded || decoded.purpose !== PURPOSE_CLAIM_SESSION) {
|
|
48
|
+
throw new Error('Invalid session token');
|
|
49
|
+
}
|
|
50
|
+
return decoded;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function issueDownloadTicket() {
|
|
54
|
+
const ttlMinutes = downloadTicketTtlMinutes();
|
|
55
|
+
const ttlMs = ttlMinutes * 60 * 1000;
|
|
56
|
+
const token = randomBytes(32).toString('hex');
|
|
57
|
+
return {
|
|
58
|
+
token,
|
|
59
|
+
tokenHash: hashToken(token),
|
|
60
|
+
expiresAt: new Date(Date.now() + ttlMs).toISOString(),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function issueBootstrapToken({ memberId, vaultId }) {
|
|
65
|
+
const ttlHours = bootstrapTokenTtlHours();
|
|
66
|
+
const ttlMs = ttlHours * 60 * 60 * 1000;
|
|
67
|
+
const token = randomBytes(32).toString('hex');
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
token,
|
|
71
|
+
memberId,
|
|
72
|
+
vaultId,
|
|
73
|
+
expiresAt: new Date(Date.now() + ttlMs).toISOString(),
|
|
74
|
+
};
|
|
75
|
+
}
|