@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.
@@ -64,16 +64,11 @@ export async function runDoctorChecks({ envFile, includeCloudflared = true }) {
64
64
  }
65
65
  }
66
66
 
67
- if (env.VAULT_PATH && env.OWNER_DISCORD_ID) {
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
  }
@@ -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 { inferDomainFromRedirect, loadEnvFile, normalizeEnv, promptForEnv, validateEnvValues, writeEnvFile } from '../env-file.js';
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 || importedLegacy) {
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
- let domain = requiredOrFallback(
73
- options.domain,
74
- inferDomainFromRedirect(envValues.DISCORD_REDIRECT_URI) || nextConfig.domain
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: 'domain',
81
- message: 'Public domain for Hive server',
82
- initial: domain,
138
+ name: 'url',
139
+ message: 'Public URL for Hive server (optional, used for invite links)',
140
+ initial: hiveServerUrl || '',
83
141
  });
84
- if (response.domain !== undefined) {
85
- domain = String(response.domain).trim();
142
+ if (response.url !== undefined) {
143
+ hiveServerUrl = String(response.url).trim();
86
144
  }
87
145
  }
88
146
 
89
- if (!validateDomain(domain)) {
90
- throw new CliError(`Invalid domain: ${domain}`);
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
- envValues = await setRedirectUriForDomain({ envFile, env: envValues, domain, yes });
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 managedRoutes from './routes/managed.js';
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 { assertOwnerConsistency } from './lib/managedState.js';
12
-
13
- const REQUIRED = [
14
- 'VAULT_PATH',
15
- 'JWT_SECRET',
16
- 'DISCORD_CLIENT_ID',
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
- return { port };
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
- const { port } = validateEnv();
52
- await assertOwnerConsistency(process.env.VAULT_PATH, process.env.OWNER_DISCORD_ID);
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('/managed', managedRoutes);
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
- attachHandlers(io, getActiveRooms, broadcastFileUpdated, forceCloseRoom);
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
- });
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
- // Chokidar watch for external (non-plugin) changes
108
- vault.initWatcher((relPath, event) => {
109
- const docName = encodeURIComponent(relPath);
110
- if (getActiveRooms().has(docName)) {
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] Ignoring external change to active room: ${relPath}`);
141
+ console.log(`[chokidar] External ${event}: ${relPath}`);
113
142
  }
114
- return;
115
- }
116
- if (!quiet) {
117
- console.log(`[chokidar] External ${event}: ${relPath}`);
118
- }
119
- io.emit('external-update', { relPath, event });
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
- console.log(`[server] Vault: ${process.env.VAULT_PATH}`);
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
+ }