@fyresmith/hive-server 4.0.0 → 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/routes/auth.js CHANGED
@@ -1,98 +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 url = oauth.generateAuthUrl({
33
- clientId: process.env.DISCORD_CLIENT_ID,
34
- scope: ['identify', 'guilds'],
35
- redirectUri: process.env.DISCORD_REDIRECT_URI,
36
- state,
37
- });
38
-
39
- 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
+ }
40
248
  });
41
249
 
42
- // ---------------------------------------------------------------------------
43
- // GET /auth/callback
44
- // ---------------------------------------------------------------------------
45
- router.get('/callback', async (req, res) => {
46
- 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();
47
255
 
48
- if (!state || !stateMap.has(state)) {
49
- 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)));
50
269
  }
51
- 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 ?? '');
52
276
 
53
- if (!code) {
54
- 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.'));
55
297
  }
56
298
 
57
299
  try {
58
- // Exchange code for access token
59
- const tokenData = await oauth.tokenRequest({
60
- clientId: process.env.DISCORD_CLIENT_ID,
61
- clientSecret: process.env.DISCORD_CLIENT_SECRET,
62
- 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(),
63
310
  code,
64
- scope: ['identify', 'guilds'],
65
- grantType: 'authorization_code',
311
+ user: {
312
+ id: account.id,
313
+ username: account.displayName,
314
+ avatarUrl: '',
315
+ },
66
316
  });
67
317
 
68
- const accessToken = tokenData.access_token;
69
-
70
- // Verify guild membership
71
- const guilds = await oauth.getUserGuilds(accessToken);
72
- const isMember = guilds.some((g) => g.id === process.env.DISCORD_GUILD_ID);
73
- if (!isMember) {
74
- return res.status(403).send('Access denied: you are not a member of the required Discord server.');
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.'));
75
320
  }
76
321
 
77
- // Fetch user profile
78
- const user = await oauth.getUser(accessToken);
79
- const { id, username, avatar } = user;
80
- const avatarUrl = avatar
81
- ? `https://cdn.discordapp.com/avatars/${id}/${avatar}.png`
82
- : `https://cdn.discordapp.com/embed/avatars/${parseInt(id) % 5}.png`;
83
-
84
- // Sign JWT
85
- const token = jwt.sign(
86
- { id, username, avatarUrl },
87
- process.env.JWT_SECRET,
88
- { expiresIn: '30d' }
89
- );
90
-
91
- // Redirect to Obsidian URI handler
92
- 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
+ });
93
418
  } catch (err) {
94
- console.error('[auth] OAuth callback error:', err);
95
- 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 });
96
421
  }
97
422
  });
98
423