@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.
@@ -0,0 +1,76 @@
1
+ import { createOwnerAccount } from './accountState.js';
2
+ import { initManagedState } from './managedState.js';
3
+ import { join, resolve } from 'path';
4
+ import { mkdir, readdir, writeFile } from 'fs/promises';
5
+ import { existsSync } from 'fs';
6
+
7
+ function slugifyVaultName(name) {
8
+ return String(name ?? '')
9
+ .trim()
10
+ .toLowerCase()
11
+ .replace(/[^a-z0-9]+/g, '-')
12
+ .replace(/^-+|-+$/g, '')
13
+ .slice(0, 80) || 'hive-vault';
14
+ }
15
+
16
+ async function ensureDirectory(path) {
17
+ await mkdir(path, { recursive: true });
18
+ }
19
+
20
+ async function assertDirectoryEmpty(path) {
21
+ if (!existsSync(path)) return;
22
+ const entries = await readdir(path);
23
+ if (entries.length > 0) {
24
+ throw new Error(`Target vault folder is not empty: ${path}`);
25
+ }
26
+ }
27
+
28
+ export async function createVaultAtParent({
29
+ parentPath,
30
+ vaultName,
31
+ }) {
32
+ const base = String(parentPath ?? '').trim();
33
+ if (!base) {
34
+ throw new Error('Vault parent folder is required');
35
+ }
36
+
37
+ const parent = resolve(base);
38
+ await ensureDirectory(parent);
39
+
40
+ const vaultDir = join(parent, slugifyVaultName(vaultName));
41
+ await assertDirectoryEmpty(vaultDir);
42
+ await ensureDirectory(vaultDir);
43
+
44
+ const welcomePath = join(vaultDir, 'Welcome.md');
45
+ await writeFile(
46
+ welcomePath,
47
+ `# ${String(vaultName ?? 'Hive Vault').trim() || 'Hive Vault'}\n\nThis vault was initialized by Hive setup.\n`,
48
+ 'utf-8',
49
+ );
50
+
51
+ return vaultDir;
52
+ }
53
+
54
+ export async function initializeOwnerManagedVault({
55
+ vaultPath,
56
+ vaultName,
57
+ ownerEmail,
58
+ ownerDisplayName,
59
+ ownerPassword,
60
+ }) {
61
+ const account = await createOwnerAccount({
62
+ vaultPath,
63
+ email: ownerEmail,
64
+ displayName: ownerDisplayName,
65
+ password: ownerPassword,
66
+ });
67
+
68
+ const state = await initManagedState({
69
+ vaultPath,
70
+ ownerId: account.id,
71
+ ownerUser: { username: account.displayName },
72
+ vaultName,
73
+ });
74
+
75
+ return { account, state };
76
+ }
package/lib/yjsServer.js CHANGED
@@ -252,14 +252,17 @@ export function startYjsServer(httpServer, broadcastFileUpdated) {
252
252
  // it already populated and send that content as sync step 1, so clients
253
253
  // never see an empty doc and Y.Text never gets double-initialized.
254
254
  const ydoc = getYDoc(rawDocName, true);
255
- try {
256
- const absPath = vault.safePath(relPath);
257
- const content = readFileSync(absPath, 'utf-8');
258
- if (content) {
259
- ydoc.getText('content').insert(0, content);
255
+ const yText = ydoc.getText('content');
256
+ if (yText.length === 0) { // guard: skip if doc already has content
257
+ try {
258
+ const absPath = vault.safePath(relPath);
259
+ const content = readFileSync(absPath, 'utf-8');
260
+ if (content) {
261
+ yText.insert(0, content);
262
+ }
263
+ } catch {
264
+ // File doesn't exist yet — start with empty document
260
265
  }
261
- } catch {
262
- // File doesn't exist yet — start with empty document
263
266
  }
264
267
  }
265
268
 
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "@fyresmith/hive-server",
3
- "version": "4.0.1",
3
+ "version": "5.0.0",
4
4
  "type": "module",
5
5
  "description": "Collaborative Obsidian vault server",
6
6
  "main": "index.js",
7
7
  "files": [
8
8
  "bin",
9
9
  "cli",
10
+ "assets",
10
11
  "lib",
11
12
  "routes",
12
13
  "index.js",
@@ -34,7 +35,6 @@
34
35
  "chalk": "^5.6.2",
35
36
  "chokidar": "^3.6.0",
36
37
  "commander": "^13.1.0",
37
- "discord-oauth2": "^2.12.0",
38
38
  "dotenv": "^16.4.5",
39
39
  "execa": "^9.6.0",
40
40
  "express": "^4.19.2",
@@ -44,6 +44,7 @@
44
44
  "which": "^6.0.0",
45
45
  "ws": "^8.17.1",
46
46
  "y-websocket": "^1.5.4",
47
+ "yazl": "^2.5.1",
47
48
  "yjs": "^13.6.18"
48
49
  },
49
50
  "devDependencies": {
package/routes/auth.js CHANGED
@@ -1,102 +1,423 @@
1
- import { Router } from 'express';
2
- import DiscordOauth2 from 'discord-oauth2';
1
+ import { Router, urlencoded } from 'express';
3
2
  import jwt from 'jsonwebtoken';
4
- import { randomBytes } from 'crypto';
3
+ import {
4
+ authenticateAccount,
5
+ createAccount,
6
+ getAccountById,
7
+ } from '../lib/accountState.js';
8
+ import {
9
+ hashToken,
10
+ issueBootstrapToken,
11
+ issueDownloadTicket,
12
+ signClaimSessionToken,
13
+ verifyClaimSessionToken,
14
+ } from '../lib/authTokens.js';
15
+ import { sendInviteShellBundle } from '../lib/bundleBuilder.js';
16
+ import {
17
+ consumeInviteDownloadTicket,
18
+ consumeMemberBootstrapSecretByToken,
19
+ getInvite,
20
+ loadManagedState,
21
+ pairMember,
22
+ setInviteDownloadTicket,
23
+ setMemberBootstrapSecret,
24
+ } from '../lib/managedState.js';
5
25
 
6
26
  const router = Router();
7
- const oauth = new DiscordOauth2();
8
-
9
- /**
10
- * CSRF state map: state → { timestamp }
11
- * Entries expire after 10 minutes.
12
- * @type {Map<string, {ts: number}>}
13
- */
14
- const stateMap = new Map();
15
- const STATE_TTL_MS = 10 * 60 * 1000;
16
-
17
- function pruneStates() {
18
- const now = Date.now();
19
- for (const [key, val] of stateMap) {
20
- if (now - val.ts > STATE_TTL_MS) stateMap.delete(key);
21
- }
22
- }
23
-
24
- // ---------------------------------------------------------------------------
25
- // GET /auth/login
26
- // ---------------------------------------------------------------------------
27
- router.get('/login', (req, res) => {
28
- pruneStates();
29
- const state = randomBytes(16).toString('hex');
30
- stateMap.set(state, { ts: Date.now() });
31
-
32
- const scope = process.env.DISCORD_GUILD_ID ? ['identify', 'guilds'] : ['identify'];
33
- const url = oauth.generateAuthUrl({
34
- clientId: process.env.DISCORD_CLIENT_ID,
35
- scope,
36
- redirectUri: process.env.DISCORD_REDIRECT_URI,
37
- state,
38
- });
39
-
40
- res.redirect(url);
27
+ const CLAIM_SESSION_COOKIE = 'hive_claim_session';
28
+ const DOWNLOAD_TICKET_COOKIE = 'hive_bundle_ticket';
29
+
30
+ router.use(urlencoded({ extended: false }));
31
+
32
+ function getVaultPath() {
33
+ const value = String(process.env.VAULT_PATH ?? '').trim();
34
+ if (!value) throw new Error('VAULT_PATH env var is required');
35
+ return value;
36
+ }
37
+
38
+ function getServerUrl(req) {
39
+ return process.env.HIVE_SERVER_URL?.trim() || `${req.protocol}://${req.get('host')}`;
40
+ }
41
+
42
+ function escapeHtml(value) {
43
+ return String(value ?? '')
44
+ .replaceAll('&', '&amp;')
45
+ .replaceAll('<', '&lt;')
46
+ .replaceAll('>', '&gt;')
47
+ .replaceAll('"', '&quot;')
48
+ .replaceAll("'", '&#39;');
49
+ }
50
+
51
+ function errorPage(message) {
52
+ return `<!DOCTYPE html>
53
+ <html lang="en">
54
+ <head><meta charset="UTF-8"><title>Hive — Error</title>
55
+ <style>body{font-family:system-ui,sans-serif;max-width:560px;margin:80px auto;padding:0 16px;color:#333}
56
+ h1{color:#c0392b}p{line-height:1.6}.muted{color:#666}</style></head>
57
+ <body><h1>Error</h1><p>${escapeHtml(message)}</p></body></html>`;
58
+ }
59
+
60
+ function infoPage(title, bodyHtml) {
61
+ return `<!DOCTYPE html>
62
+ <html lang="en">
63
+ <head><meta charset="UTF-8"><title>Hive — ${escapeHtml(title)}</title>
64
+ <style>
65
+ body{font-family:system-ui,sans-serif;max-width:680px;margin:80px auto;padding:0 16px;color:#222}
66
+ h1{margin:0 0 12px}p{line-height:1.6;color:#444}
67
+ a.button,button{display:inline-block;background:#2d6cdf;color:#fff;border:none;padding:10px 16px;border-radius:8px;text-decoration:none;cursor:pointer}
68
+ .card{border:1px solid #ddd;border-radius:10px;padding:16px;margin:14px 0;background:#fafafa}
69
+ label{display:block;font-weight:600;margin:0 0 6px}
70
+ input{width:100%;box-sizing:border-box;padding:10px;border:1px solid #ccc;border-radius:8px;margin-bottom:12px}
71
+ .muted{color:#666}
72
+ .grid{display:grid;gap:16px;grid-template-columns:1fr 1fr}
73
+ @media (max-width:700px){.grid{grid-template-columns:1fr}}
74
+ </style></head>
75
+ <body>
76
+ <h1>${escapeHtml(title)}</h1>
77
+ ${bodyHtml}
78
+ </body></html>`;
79
+ }
80
+
81
+ function parseCookies(req) {
82
+ const raw = String(req.headers.cookie ?? '');
83
+ const cookies = {};
84
+ for (const chunk of raw.split(';')) {
85
+ const [name, ...rest] = chunk.split('=');
86
+ const key = String(name ?? '').trim();
87
+ if (!key) continue;
88
+ cookies[key] = decodeURIComponent(rest.join('=').trim());
89
+ }
90
+ return cookies;
91
+ }
92
+
93
+ function isSecureRequest(req) {
94
+ if (req.secure) return true;
95
+ const xf = String(req.headers['x-forwarded-proto'] ?? '').toLowerCase();
96
+ return xf.includes('https');
97
+ }
98
+
99
+ function setClaimSessionCookie(req, res, token) {
100
+ const secure = isSecureRequest(req) ? '; Secure' : '';
101
+ res.setHeader(
102
+ 'Set-Cookie',
103
+ `${CLAIM_SESSION_COOKIE}=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=604800${secure}`,
104
+ );
105
+ }
106
+
107
+ function clearClaimSessionCookie(req, res) {
108
+ const secure = isSecureRequest(req) ? '; Secure' : '';
109
+ res.setHeader(
110
+ 'Set-Cookie',
111
+ `${CLAIM_SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0${secure}`,
112
+ );
113
+ }
114
+
115
+ function setDownloadTicketCookie(req, res, ticket) {
116
+ const secure = isSecureRequest(req) ? '; Secure' : '';
117
+ res.setHeader(
118
+ 'Set-Cookie',
119
+ `${DOWNLOAD_TICKET_COOKIE}=${encodeURIComponent(ticket)}; Path=/auth; HttpOnly; SameSite=Lax; Max-Age=1200${secure}`,
120
+ );
121
+ }
122
+
123
+ function clearDownloadTicketCookie(req, res) {
124
+ const secure = isSecureRequest(req) ? '; Secure' : '';
125
+ res.setHeader(
126
+ 'Set-Cookie',
127
+ `${DOWNLOAD_TICKET_COOKIE}=; Path=/auth; HttpOnly; SameSite=Lax; Max-Age=0${secure}`,
128
+ );
129
+ }
130
+
131
+ function getClaimSession(req) {
132
+ const cookies = parseCookies(req);
133
+ const token = String(cookies[CLAIM_SESSION_COOKIE] ?? '').trim();
134
+ if (!token) return null;
135
+ try {
136
+ const decoded = verifyClaimSessionToken(token);
137
+ return {
138
+ accountId: String(decoded.accountId ?? ''),
139
+ displayName: String(decoded.displayName ?? ''),
140
+ emailNorm: String(decoded.emailNorm ?? ''),
141
+ };
142
+ } catch {
143
+ return null;
144
+ }
145
+ }
146
+
147
+ function getDownloadTicket(req) {
148
+ const cookies = parseCookies(req);
149
+ return String(cookies[DOWNLOAD_TICKET_COOKIE] ?? '').trim();
150
+ }
151
+
152
+ async function loadInviteOrThrow(code) {
153
+ const inviteCode = String(code ?? '').trim();
154
+ if (!inviteCode) {
155
+ throw new Error('Missing invite code.');
156
+ }
157
+
158
+ let state;
159
+ try {
160
+ state = await loadManagedState(getVaultPath());
161
+ } catch {
162
+ throw new Error('Server error loading vault state.');
163
+ }
164
+ if (!state) {
165
+ throw new Error('Managed vault is not initialized.');
166
+ }
167
+
168
+ const invite = await getInvite(getVaultPath(), inviteCode);
169
+ if (!invite) throw new Error('Invite code not found.');
170
+ if (invite.revokedAt) throw new Error('This invite code has been revoked.');
171
+ if (invite.usedAt) throw new Error('This invite code has already been used.');
172
+
173
+ return { state, inviteCode, invite };
174
+ }
175
+
176
+ function renderInviteClaimPage({ code, session, vaultName }) {
177
+ const displayName = escapeHtml(vaultName || 'Hive Vault');
178
+ const title = `Join ${displayName}`;
179
+
180
+ if (session) {
181
+ return infoPage(title, `
182
+ <p>You've been invited to join <strong>${displayName}</strong>.</p>
183
+ <p>You are signed in as <strong>${escapeHtml(session.displayName)}</strong>.</p>
184
+ <p>Continue to pair this identity and generate a downloadable vault package.</p>
185
+ <form method="POST" action="/auth/claim/complete">
186
+ <input type="hidden" name="code" value="${escapeHtml(code)}">
187
+ <button type="submit">Join + Continue</button>
188
+ </form>
189
+ <p class="muted">This invite is single-use.</p>
190
+ `);
191
+ }
192
+
193
+ return infoPage(title, `
194
+ <p>You've been invited to join <strong>${displayName}</strong>. Sign in or create an account to claim this invite and download your managed vault package.</p>
195
+ <div class="grid">
196
+ <div class="card">
197
+ <h3>Create Account</h3>
198
+ <form method="POST" action="/auth/claim/signup">
199
+ <input type="hidden" name="code" value="${escapeHtml(code)}">
200
+ <label>Email</label>
201
+ <input type="email" name="email" required placeholder="you@example.com">
202
+ <label>Display Name</label>
203
+ <input type="text" name="displayName" minlength="1" maxlength="32" required placeholder="Your name">
204
+ <label>Password</label>
205
+ <input type="password" name="password" minlength="8" required>
206
+ <button type="submit">Create & Continue</button>
207
+ </form>
208
+ </div>
209
+ <div class="card">
210
+ <h3>Sign In</h3>
211
+ <form method="POST" action="/auth/claim/signin">
212
+ <input type="hidden" name="code" value="${escapeHtml(code)}">
213
+ <label>Email</label>
214
+ <input type="email" name="email" required placeholder="you@example.com">
215
+ <label>Password</label>
216
+ <input type="password" name="password" minlength="8" required>
217
+ <button type="submit">Sign In & Continue</button>
218
+ </form>
219
+ </div>
220
+ </div>
221
+ `);
222
+ }
223
+
224
+ function renderClaimSuccessPage(vaultName) {
225
+ const displayName = escapeHtml(vaultName || 'Hive Vault');
226
+ return infoPage(`${displayName} is Ready`, `
227
+ <p>Your invite has been claimed. Download the preconfigured vault package, extract it, then open the folder in Obsidian desktop.</p>
228
+ <p><a class="button" href="/auth/bundle">Download ${displayName}.zip</a></p>
229
+ <div class="card">
230
+ <p><strong>After download:</strong></p>
231
+ <p>1) Extract zip to a folder</p>
232
+ <p>2) Open that folder as a vault in Obsidian desktop</p>
233
+ <p>3) Hive will finish bootstrap and sync on first open</p>
234
+ </div>
235
+ <p class="muted">The download link is single-use and expires quickly.</p>
236
+ `);
237
+ }
238
+
239
+ router.get('/claim', async (req, res) => {
240
+ const code = String(req.query.code ?? '').trim();
241
+ try {
242
+ const { state } = await loadInviteOrThrow(code);
243
+ const session = getClaimSession(req);
244
+ return res.send(renderInviteClaimPage({ code, session, vaultName: state.vaultName }));
245
+ } catch (err) {
246
+ return res.status(400).send(errorPage(err instanceof Error ? err.message : String(err)));
247
+ }
41
248
  });
42
249
 
43
- // ---------------------------------------------------------------------------
44
- // GET /auth/callback
45
- // ---------------------------------------------------------------------------
46
- router.get('/callback', async (req, res) => {
47
- const { code, state } = req.query;
250
+ router.post('/claim/signup', async (req, res) => {
251
+ const code = String(req.body?.code ?? '').trim();
252
+ const email = String(req.body?.email ?? '').trim();
253
+ const password = String(req.body?.password ?? '');
254
+ const displayName = String(req.body?.displayName ?? '').trim();
48
255
 
49
- if (!state || !stateMap.has(state)) {
50
- return res.status(400).send('Invalid or expired state parameter.');
256
+ try {
257
+ await loadInviteOrThrow(code);
258
+ const account = await createAccount({
259
+ vaultPath: getVaultPath(),
260
+ email,
261
+ password,
262
+ displayName,
263
+ });
264
+ const sessionToken = signClaimSessionToken(account);
265
+ setClaimSessionCookie(req, res, sessionToken);
266
+ return res.redirect(`/auth/claim?code=${encodeURIComponent(code)}`);
267
+ } catch (err) {
268
+ return res.status(400).send(errorPage(err instanceof Error ? err.message : String(err)));
51
269
  }
52
- stateMap.delete(state);
270
+ });
271
+
272
+ router.post('/claim/signin', async (req, res) => {
273
+ const code = String(req.body?.code ?? '').trim();
274
+ const email = String(req.body?.email ?? '').trim();
275
+ const password = String(req.body?.password ?? '');
53
276
 
54
- if (!code) {
55
- return res.status(400).send('Missing authorization code.');
277
+ try {
278
+ await loadInviteOrThrow(code);
279
+ const account = await authenticateAccount({
280
+ vaultPath: getVaultPath(),
281
+ email,
282
+ password,
283
+ });
284
+ const sessionToken = signClaimSessionToken(account);
285
+ setClaimSessionCookie(req, res, sessionToken);
286
+ return res.redirect(`/auth/claim?code=${encodeURIComponent(code)}`);
287
+ } catch (err) {
288
+ return res.status(400).send(errorPage(err instanceof Error ? err.message : String(err)));
289
+ }
290
+ });
291
+
292
+ router.post('/claim/complete', async (req, res) => {
293
+ const code = String(req.body?.code ?? '').trim();
294
+ const session = getClaimSession(req);
295
+ if (!session?.accountId) {
296
+ return res.status(401).send(errorPage('Sign in is required before claiming this invite.'));
56
297
  }
57
298
 
58
299
  try {
59
- // Exchange code for access token
60
- const scope = process.env.DISCORD_GUILD_ID ? ['identify', 'guilds'] : ['identify'];
61
- const tokenData = await oauth.tokenRequest({
62
- clientId: process.env.DISCORD_CLIENT_ID,
63
- clientSecret: process.env.DISCORD_CLIENT_SECRET,
64
- redirectUri: process.env.DISCORD_REDIRECT_URI,
300
+ await loadInviteOrThrow(code);
301
+
302
+ const account = await getAccountById(getVaultPath(), session.accountId);
303
+ if (!account) {
304
+ clearClaimSessionCookie(req, res);
305
+ return res.status(401).send(errorPage('Sign in session is invalid. Please sign in again.'));
306
+ }
307
+
308
+ const result = await pairMember({
309
+ vaultPath: getVaultPath(),
65
310
  code,
66
- scope,
67
- grantType: 'authorization_code',
311
+ user: {
312
+ id: account.id,
313
+ username: account.displayName,
314
+ avatarUrl: '',
315
+ },
68
316
  });
69
317
 
70
- const accessToken = tokenData.access_token;
71
-
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
- }
318
+ if (!result.paired) {
319
+ return res.status(400).send(errorPage('This account is already paired. Ask your owner for a new invite if needed.'));
79
320
  }
80
321
 
81
- // Fetch user profile
82
- const user = await oauth.getUser(accessToken);
83
- const { id, username, avatar } = user;
84
- const avatarUrl = avatar
85
- ? `https://cdn.discordapp.com/avatars/${id}/${avatar}.png`
86
- : `https://cdn.discordapp.com/embed/avatars/${parseInt(id) % 5}.png`;
87
-
88
- // Sign JWT
89
- const token = jwt.sign(
90
- { id, username, avatarUrl },
91
- process.env.JWT_SECRET,
92
- { expiresIn: '30d' }
93
- );
94
-
95
- // Redirect to Obsidian URI handler
96
- res.redirect(`obsidian://hive-auth?token=${encodeURIComponent(token)}`);
322
+ const ticket = issueDownloadTicket();
323
+ await setInviteDownloadTicket({
324
+ vaultPath: getVaultPath(),
325
+ code,
326
+ memberId: account.id,
327
+ ticketHash: ticket.tokenHash,
328
+ expiresAt: ticket.expiresAt,
329
+ });
330
+ setDownloadTicketCookie(req, res, ticket.token);
331
+ return res.redirect('/auth/claim/success');
332
+ } catch (err) {
333
+ return res.status(400).send(errorPage(err instanceof Error ? err.message : String(err)));
334
+ }
335
+ });
336
+
337
+ router.get('/claim/success', async (req, res) => {
338
+ const ticket = getDownloadTicket(req);
339
+ if (!ticket) {
340
+ return res.status(400).send(errorPage('No active download ticket. Please claim an invite again.'));
341
+ }
342
+ try {
343
+ const state = await loadManagedState(getVaultPath());
344
+ return res.send(renderClaimSuccessPage(state?.vaultName));
345
+ } catch (err) {
346
+ return res.status(400).send(errorPage(err instanceof Error ? err.message : String(err)));
347
+ }
348
+ });
349
+
350
+ router.get('/bundle', async (req, res) => {
351
+ const ticket = getDownloadTicket(req);
352
+ if (!ticket) {
353
+ return res.status(400).send(errorPage('Missing download ticket.'));
354
+ }
355
+
356
+ try {
357
+ const consumed = await consumeInviteDownloadTicket({
358
+ vaultPath: getVaultPath(),
359
+ ticketHash: hashToken(ticket),
360
+ });
361
+ clearDownloadTicketCookie(req, res);
362
+
363
+ const bootstrap = issueBootstrapToken({
364
+ memberId: consumed.memberId,
365
+ vaultId: consumed.vaultId,
366
+ });
367
+
368
+ await setMemberBootstrapSecret({
369
+ vaultPath: getVaultPath(),
370
+ userId: consumed.memberId,
371
+ tokenHash: hashToken(bootstrap.token),
372
+ expiresAt: bootstrap.expiresAt,
373
+ });
374
+
375
+ const bundleState = await loadManagedState(getVaultPath());
376
+
377
+ await sendInviteShellBundle(res, {
378
+ serverUrl: getServerUrl(req),
379
+ vaultId: consumed.vaultId,
380
+ bootstrapToken: bootstrap.token,
381
+ vaultName: bundleState?.vaultName,
382
+ });
383
+ } catch (err) {
384
+ const message = err instanceof Error ? err.message : String(err);
385
+ return res.status(400).send(errorPage(message));
386
+ }
387
+ });
388
+
389
+ router.post('/bootstrap/exchange', async (req, res) => {
390
+ const bootstrapToken = String(req.body?.bootstrapToken ?? '').trim();
391
+ const vaultId = String(req.body?.vaultId ?? '').trim();
392
+
393
+ if (!bootstrapToken || !vaultId) {
394
+ return res.status(400).json({ ok: false, error: 'bootstrapToken and vaultId are required' });
395
+ }
396
+
397
+ try {
398
+ const consumed = await consumeMemberBootstrapSecretByToken({
399
+ vaultPath: getVaultPath(),
400
+ tokenHash: hashToken(bootstrapToken),
401
+ vaultId,
402
+ });
403
+
404
+ const user = {
405
+ id: consumed.member.id,
406
+ username: consumed.member.username,
407
+ avatarUrl: '',
408
+ };
409
+
410
+ const token = jwt.sign(user, process.env.JWT_SECRET, { expiresIn: '30d' });
411
+
412
+ return res.json({
413
+ ok: true,
414
+ token,
415
+ user,
416
+ serverUrl: getServerUrl(req),
417
+ });
97
418
  } catch (err) {
98
- console.error('[auth] OAuth callback error:', err);
99
- res.status(500).send('Authentication failed. Please try again.');
419
+ const message = err instanceof Error ? err.message : String(err);
420
+ return res.status(400).json({ ok: false, error: message });
100
421
  }
101
422
  });
102
423