@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/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 -13
- package/cli/core/context.js +3 -22
- package/cli/env-file.js +15 -33
- package/cli/flows/doctor.js +1 -6
- package/cli/flows/setup.js +106 -33
- package/cli/tunnel.js +1 -4
- package/index.js +92 -39
- 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 +12 -11
- package/package.json +3 -2
- package/routes/auth.js +403 -78
- package/routes/dashboard.js +590 -0
- package/routes/managed.js +0 -163
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 {
|
|
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
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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('&', '&')
|
|
45
|
+
.replaceAll('<', '<')
|
|
46
|
+
.replaceAll('>', '>')
|
|
47
|
+
.replaceAll('"', '"')
|
|
48
|
+
.replaceAll("'", ''');
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
const
|
|
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
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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
|
-
|
|
54
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
65
|
-
|
|
311
|
+
user: {
|
|
312
|
+
id: account.id,
|
|
313
|
+
username: account.displayName,
|
|
314
|
+
avatarUrl: '',
|
|
315
|
+
},
|
|
66
316
|
});
|
|
67
317
|
|
|
68
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
:
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
95
|
-
res.status(
|
|
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
|
|