@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,169 @@
1
+ import { existsSync } from 'fs';
2
+ import { readdir, readFile } from 'fs/promises';
3
+ import { createRequire } from 'module';
4
+ import { fileURLToPath } from 'url';
5
+ import { basename, join, resolve } from 'path';
6
+
7
+ const require = createRequire(import.meta.url);
8
+ const { ZipFile } = require('yazl');
9
+
10
+ const PLUGIN_ID = 'hive';
11
+ const DEFAULT_DENY_PATHS = ['.git', '.hive', '.hive-quarantine', '.DS_Store', 'Thumbs.db'];
12
+
13
+ const ASSETS_ROOT = resolve(fileURLToPath(new URL('../assets/', import.meta.url)));
14
+ const TEMPLATE_ROOT = join(ASSETS_ROOT, 'template-vault');
15
+ const PLUGIN_ROOT = join(ASSETS_ROOT, 'plugin', PLUGIN_ID);
16
+
17
+ function requireAssetPath(path, label) {
18
+ if (!existsSync(path)) {
19
+ throw new Error(`Missing packaged ${label} at ${path}`);
20
+ }
21
+ return path;
22
+ }
23
+
24
+ async function readPluginAsset(filename) {
25
+ const root = requireAssetPath(PLUGIN_ROOT, 'plugin assets');
26
+ const abs = join(root, filename);
27
+ if (!existsSync(abs)) {
28
+ throw new Error(`Missing plugin asset at ${abs}`);
29
+ }
30
+ return readFile(abs);
31
+ }
32
+
33
+ async function readTemplateFiles() {
34
+ const root = requireAssetPath(TEMPLATE_ROOT, 'template vault');
35
+ const files = [];
36
+
37
+ async function walk(currentAbs, relPrefix) {
38
+ const entries = await readdir(currentAbs, { withFileTypes: true });
39
+ for (const entry of entries) {
40
+ const abs = join(currentAbs, entry.name);
41
+ const rel = relPrefix ? `${relPrefix}/${entry.name}` : entry.name;
42
+ if (entry.isDirectory()) {
43
+ await walk(abs, rel);
44
+ } else if (entry.isFile()) {
45
+ const content = await readFile(abs);
46
+ files.push({ relPath: rel.replace(/\\/g, '/'), content });
47
+ }
48
+ }
49
+ }
50
+
51
+ await walk(root, '');
52
+ return files;
53
+ }
54
+
55
+ function sanitizeVaultName(name) {
56
+ const safe = String(name ?? '').trim().replace(/[/\\:*?"<>|]/g, '').replace(/\s+/g, ' ').trim();
57
+ return safe || 'Hive Vault';
58
+ }
59
+
60
+ function renderReadme(serverUrl, vaultName) {
61
+ return [
62
+ `# ${sanitizeVaultName(vaultName)}`,
63
+ '',
64
+ '1. Extract this zip to a folder.',
65
+ '2. Open that folder as a vault in Obsidian desktop.',
66
+ '3. Hive will complete secure first-run bootstrap and sync automatically.',
67
+ '',
68
+ `Hive server: ${serverUrl}`,
69
+ ].join('\n');
70
+ }
71
+
72
+ function getBundleDenyPaths() {
73
+ const custom = String(process.env.HIVE_BUNDLE_DENY_PATHS ?? '')
74
+ .split(',')
75
+ .map((value) => value.trim())
76
+ .filter(Boolean);
77
+ return new Set([...DEFAULT_DENY_PATHS, ...custom]);
78
+ }
79
+
80
+ function isDeniedPath(path, denyPaths) {
81
+ const normalized = String(path ?? '').replace(/\\/g, '/');
82
+ for (const deny of denyPaths) {
83
+ if (normalized === deny || normalized.startsWith(`${deny}/`)) {
84
+ return true;
85
+ }
86
+ }
87
+ return false;
88
+ }
89
+
90
+ export async function sendInviteShellBundle(res, {
91
+ serverUrl,
92
+ vaultId,
93
+ bootstrapToken,
94
+ vaultName,
95
+ }) {
96
+ const templateFiles = await readTemplateFiles();
97
+ const mainJs = await readPluginAsset('main.js');
98
+ const manifestJson = await readPluginAsset('manifest.json');
99
+ const stylesCss = await readPluginAsset('styles.css');
100
+
101
+ const zip = new ZipFile();
102
+ const denyPaths = getBundleDenyPaths();
103
+
104
+ const safeName = sanitizeVaultName(vaultName);
105
+ const rootPrefix = `${safeName}/`;
106
+
107
+ const binding = {
108
+ version: 1,
109
+ managed: true,
110
+ serverUrl,
111
+ vaultId,
112
+ createdAt: new Date().toISOString(),
113
+ };
114
+
115
+ const pluginData = {
116
+ serverUrl,
117
+ token: null,
118
+ bootstrapToken,
119
+ user: null,
120
+ };
121
+
122
+ const appConfig = {
123
+ communityPluginEnabled: true,
124
+ };
125
+
126
+ const pluginRoot = `${rootPrefix}.obsidian/plugins/${PLUGIN_ID}`;
127
+
128
+ const addBuffer = (buffer, targetPath) => {
129
+ if (isDeniedPath(targetPath, denyPaths)) {
130
+ throw new Error(`Bundle output path denied by policy: ${targetPath}`);
131
+ }
132
+ zip.addBuffer(buffer, targetPath);
133
+ };
134
+
135
+ const overwritePaths = new Set([
136
+ '.obsidian/app.json',
137
+ '.obsidian/community-plugins.json',
138
+ '.obsidian/hive-managed.json',
139
+ 'README.md',
140
+ ]);
141
+
142
+ for (const file of templateFiles) {
143
+ if (overwritePaths.has(file.relPath)) continue;
144
+ const target = `${rootPrefix}${file.relPath}`;
145
+ addBuffer(file.content, target);
146
+ }
147
+
148
+ addBuffer(Buffer.from(`${JSON.stringify(binding, null, 2)}\n`, 'utf-8'), `${rootPrefix}.obsidian/hive-managed.json`);
149
+ addBuffer(mainJs, `${pluginRoot}/main.js`);
150
+ addBuffer(manifestJson, `${pluginRoot}/manifest.json`);
151
+ addBuffer(stylesCss, `${pluginRoot}/styles.css`);
152
+ addBuffer(Buffer.from(`${JSON.stringify(pluginData, null, 2)}\n`, 'utf-8'), `${pluginRoot}/data.json`);
153
+ addBuffer(Buffer.from(`${JSON.stringify([PLUGIN_ID], null, 2)}\n`, 'utf-8'), `${rootPrefix}.obsidian/community-plugins.json`);
154
+ addBuffer(Buffer.from(`${JSON.stringify(appConfig, null, 2)}\n`, 'utf-8'), `${rootPrefix}.obsidian/app.json`);
155
+ addBuffer(Buffer.from(`${renderReadme(serverUrl, vaultName)}\n`, 'utf-8'), `${rootPrefix}README.md`);
156
+
157
+ const name = `${safeName}.zip`;
158
+ res.setHeader('Content-Type', 'application/zip');
159
+ res.setHeader('Content-Disposition', `attachment; filename="${basename(name)}"`);
160
+
161
+ await new Promise((resolvePromise, rejectPromise) => {
162
+ zip.outputStream.on('error', rejectPromise);
163
+ res.on('error', rejectPromise);
164
+ res.on('finish', resolvePromise);
165
+
166
+ zip.outputStream.pipe(res);
167
+ zip.end();
168
+ });
169
+ }
@@ -0,0 +1,80 @@
1
+ import jwt from 'jsonwebtoken';
2
+
3
+ export const COOKIE_NAME = 'hive_dashboard_session';
4
+ const PURPOSE = 'dashboard-session';
5
+
6
+ function getJwtSecret() {
7
+ const secret = String(process.env.JWT_SECRET ?? '').trim();
8
+ if (!secret) throw new Error('JWT_SECRET is required');
9
+ return secret;
10
+ }
11
+
12
+ function parseCookies(req) {
13
+ const raw = String(req.headers.cookie ?? '');
14
+ const cookies = {};
15
+ for (const chunk of raw.split(';')) {
16
+ const [name, ...rest] = chunk.split('=');
17
+ const key = String(name ?? '').trim();
18
+ if (!key) continue;
19
+ cookies[key] = decodeURIComponent(rest.join('=').trim());
20
+ }
21
+ return cookies;
22
+ }
23
+
24
+ function isSecureRequest(req) {
25
+ if (req.secure) return true;
26
+ const xf = String(req.headers['x-forwarded-proto'] ?? '').toLowerCase();
27
+ return xf.includes('https');
28
+ }
29
+
30
+ export function signDashboardSessionToken(accountId) {
31
+ return jwt.sign(
32
+ { purpose: PURPOSE, accountId, role: 'owner' },
33
+ getJwtSecret(),
34
+ { expiresIn: '24h' },
35
+ );
36
+ }
37
+
38
+ export function verifyDashboardSessionToken(token) {
39
+ const decoded = jwt.verify(String(token ?? ''), getJwtSecret());
40
+ if (!decoded || decoded.purpose !== PURPOSE) {
41
+ throw new Error('Invalid dashboard session token');
42
+ }
43
+ return decoded;
44
+ }
45
+
46
+ export function getDashboardSession(req) {
47
+ const cookies = parseCookies(req);
48
+ const token = String(cookies[COOKIE_NAME] ?? '').trim();
49
+ if (!token) return null;
50
+ try {
51
+ return verifyDashboardSessionToken(token);
52
+ } catch {
53
+ return null;
54
+ }
55
+ }
56
+
57
+ export function setDashboardCookie(req, res, token) {
58
+ const secure = isSecureRequest(req) ? '; Secure' : '';
59
+ res.setHeader(
60
+ 'Set-Cookie',
61
+ `${COOKIE_NAME}=${encodeURIComponent(token)}; Path=/dashboard; HttpOnly; SameSite=Lax; Max-Age=86400${secure}`,
62
+ );
63
+ }
64
+
65
+ export function clearDashboardCookie(req, res) {
66
+ const secure = isSecureRequest(req) ? '; Secure' : '';
67
+ res.setHeader(
68
+ 'Set-Cookie',
69
+ `${COOKIE_NAME}=; Path=/dashboard; HttpOnly; SameSite=Lax; Max-Age=0${secure}`,
70
+ );
71
+ }
72
+
73
+ export function requireDashboardAuth(req, res, next) {
74
+ const session = getDashboardSession(req);
75
+ if (!session?.accountId) {
76
+ return res.redirect('/dashboard/login');
77
+ }
78
+ req.dashboardSession = session;
79
+ next();
80
+ }
@@ -1,40 +1,130 @@
1
- import { randomBytes } from 'crypto';
1
+ import { randomBytes, timingSafeEqual } from 'crypto';
2
2
  import { existsSync } from 'fs';
3
3
  import { mkdir, readFile, writeFile } from 'fs/promises';
4
- import { dirname, resolve, join } from 'path';
4
+ import { dirname, join, resolve } from 'path';
5
5
 
6
- const STATE_VERSION = 1;
6
+ const STATE_VERSION = 2;
7
7
  const STATE_REL_PATH = join('.hive', 'managed-state.json');
8
8
 
9
9
  function nowIso() {
10
10
  return new Date().toISOString();
11
11
  }
12
12
 
13
- function normalizeOwnerId(value) {
14
- return String(value ?? '').trim();
13
+ function compareHexConstantTime(expectedHex, actualHex) {
14
+ const expected = Buffer.from(String(expectedHex ?? ''), 'hex');
15
+ const actual = Buffer.from(String(actualHex ?? ''), 'hex');
16
+ if (expected.length === 0 || actual.length === 0) return false;
17
+ if (expected.length !== actual.length) return false;
18
+ return timingSafeEqual(expected, actual);
19
+ }
20
+
21
+ function assertNonEmptyString(value, fieldName) {
22
+ const out = String(value ?? '').trim();
23
+ if (!out) {
24
+ throw new Error(`[managed] Missing required field: ${fieldName}`);
25
+ }
26
+ return out;
27
+ }
28
+
29
+ function assertIsoDate(value, fieldName) {
30
+ const out = assertNonEmptyString(value, fieldName);
31
+ const ts = Date.parse(out);
32
+ if (!Number.isFinite(ts)) {
33
+ throw new Error(`[managed] Invalid ISO timestamp in ${fieldName}`);
34
+ }
35
+ return out;
36
+ }
37
+
38
+ function normalizeMember(raw) {
39
+ if (!raw || typeof raw !== 'object') {
40
+ throw new Error('[managed] Invalid member record');
41
+ }
42
+
43
+ return {
44
+ id: assertNonEmptyString(raw.id, 'members[].id'),
45
+ username: assertNonEmptyString(raw.username, 'members[].username'),
46
+ addedAt: assertIsoDate(raw.addedAt, 'members[].addedAt'),
47
+ addedBy: assertNonEmptyString(raw.addedBy, 'members[].addedBy'),
48
+ pendingBootstrapHash: String(raw.pendingBootstrapHash ?? '').trim() || null,
49
+ pendingBootstrapIssuedAt: String(raw.pendingBootstrapIssuedAt ?? '').trim() || null,
50
+ pendingBootstrapExpiresAt: String(raw.pendingBootstrapExpiresAt ?? '').trim() || null,
51
+ };
52
+ }
53
+
54
+ function normalizeInvite(raw) {
55
+ if (!raw || typeof raw !== 'object') {
56
+ throw new Error('[managed] Invalid invite record');
57
+ }
58
+
59
+ return {
60
+ code: assertNonEmptyString(raw.code, 'invites[].code'),
61
+ createdAt: assertIsoDate(raw.createdAt, 'invites[].createdAt'),
62
+ createdBy: assertNonEmptyString(raw.createdBy, 'invites[].createdBy'),
63
+ usedAt: String(raw.usedAt ?? '').trim() || null,
64
+ usedBy: String(raw.usedBy ?? '').trim() || null,
65
+ revokedAt: String(raw.revokedAt ?? '').trim() || null,
66
+ downloadTicketHash: String(raw.downloadTicketHash ?? '').trim() || null,
67
+ downloadTicketIssuedAt: String(raw.downloadTicketIssuedAt ?? '').trim() || null,
68
+ downloadTicketExpiresAt: String(raw.downloadTicketExpiresAt ?? '').trim() || null,
69
+ downloadTicketUsedAt: String(raw.downloadTicketUsedAt ?? '').trim() || null,
70
+ };
15
71
  }
16
72
 
17
73
  export function getManagedStatePath(vaultPath) {
18
- const root = resolve(vaultPath);
19
- return join(root, STATE_REL_PATH);
74
+ if (process.env.HIVE_STATE_PATH) {
75
+ return join(resolve(process.env.HIVE_STATE_PATH), 'managed-state.json');
76
+ }
77
+ return join(resolve(vaultPath), STATE_REL_PATH);
20
78
  }
21
79
 
22
80
  function normalizeState(raw) {
23
- if (!raw || typeof raw !== 'object') return null;
24
- const ownerDiscordId = normalizeOwnerId(raw.ownerDiscordId);
25
- const vaultId = String(raw.vaultId ?? '').trim();
26
- const initializedAt = String(raw.initializedAt ?? '').trim();
27
- if (!ownerDiscordId || !vaultId || !initializedAt) return null;
81
+ if (!raw || typeof raw !== 'object') {
82
+ throw new Error('[managed] Invalid state payload');
83
+ }
84
+
85
+ if (raw.version !== STATE_VERSION) {
86
+ throw new Error(
87
+ `[managed] Unsupported managed state version (${String(raw.version ?? 'unknown')}). This build requires a fresh setup.`,
88
+ );
89
+ }
28
90
 
29
- const members = raw.members && typeof raw.members === 'object' ? raw.members : {};
30
- const invites = raw.invites && typeof raw.invites === 'object' ? raw.invites : {};
91
+ const ownerId = assertNonEmptyString(raw.ownerId, 'ownerId');
92
+ const vaultId = assertNonEmptyString(raw.vaultId, 'vaultId');
93
+ const initializedAt = assertIsoDate(raw.initializedAt, 'initializedAt');
94
+ const vaultName = String(raw.vaultName ?? '').trim() || null;
95
+
96
+ const membersRaw = raw.members;
97
+ if (!membersRaw || typeof membersRaw !== 'object' || Array.isArray(membersRaw)) {
98
+ throw new Error('[managed] Invalid members object');
99
+ }
100
+ const invitesRaw = raw.invites;
101
+ if (!invitesRaw || typeof invitesRaw !== 'object' || Array.isArray(invitesRaw)) {
102
+ throw new Error('[managed] Invalid invites object');
103
+ }
104
+
105
+ const members = {};
106
+ for (const [id, value] of Object.entries(membersRaw)) {
107
+ const member = normalizeMember(value);
108
+ members[id] = member;
109
+ }
110
+
111
+ const invites = {};
112
+ for (const [code, value] of Object.entries(invitesRaw)) {
113
+ const invite = normalizeInvite(value);
114
+ invites[code] = invite;
115
+ }
116
+
117
+ if (!members[ownerId]) {
118
+ throw new Error('[managed] Owner must exist in members map');
119
+ }
31
120
 
32
121
  return {
33
122
  version: STATE_VERSION,
34
123
  managed: true,
35
- ownerDiscordId,
124
+ ownerId,
36
125
  vaultId,
37
126
  initializedAt,
127
+ vaultName,
38
128
  members,
39
129
  invites,
40
130
  };
@@ -51,9 +141,6 @@ export async function loadManagedState(vaultPath) {
51
141
  export async function saveManagedState(vaultPath, state) {
52
142
  const filePath = getManagedStatePath(vaultPath);
53
143
  const normalized = normalizeState(state);
54
- if (!normalized) {
55
- throw new Error('Invalid managed state payload');
56
- }
57
144
  await mkdir(dirname(filePath), { recursive: true });
58
145
  await writeFile(filePath, `${JSON.stringify(normalized, null, 2)}\n`, 'utf-8');
59
146
  return normalized;
@@ -61,7 +148,7 @@ export async function saveManagedState(vaultPath, state) {
61
148
 
62
149
  export function getRole(state, userId) {
63
150
  if (!state) return 'none';
64
- if (userId === state.ownerDiscordId) return 'owner';
151
+ if (userId === state.ownerId) return 'owner';
65
152
  if (state.members?.[userId]) return 'member';
66
153
  return 'none';
67
154
  }
@@ -70,58 +157,52 @@ export function isMember(state, userId) {
70
157
  return getRole(state, userId) !== 'none';
71
158
  }
72
159
 
73
- export async function assertOwnerConsistency(vaultPath, ownerDiscordId) {
74
- const state = await loadManagedState(vaultPath);
75
- if (!state) return null;
76
- const configuredOwner = normalizeOwnerId(ownerDiscordId);
77
- if (!configuredOwner) {
78
- throw new Error('OWNER_DISCORD_ID is required');
79
- }
80
- if (state.ownerDiscordId !== configuredOwner) {
81
- throw new Error(
82
- `[managed] owner mismatch: state=${state.ownerDiscordId} env=${configuredOwner}. Update OWNER_DISCORD_ID or reset managed state.`,
83
- );
84
- }
85
- return state;
86
- }
87
-
88
160
  function nextInviteCode() {
89
161
  return randomBytes(6).toString('hex');
90
162
  }
91
163
 
92
- export async function initManagedState({ vaultPath, ownerDiscordId, ownerUser }) {
164
+ function getValidInviteOrThrow(state, code) {
165
+ const invite = state.invites?.[code];
166
+ if (!invite) throw new Error('Invite not found');
167
+ if (invite.revokedAt) throw new Error('Invite revoked');
168
+ if (invite.usedAt) throw new Error('Invite already used');
169
+ return invite;
170
+ }
171
+
172
+ export async function initManagedState({ vaultPath, ownerId: ownerIdParam, ownerUser, vaultName }) {
93
173
  const existing = await loadManagedState(vaultPath);
94
174
  if (existing) return existing;
95
175
 
96
- const ownerId = normalizeOwnerId(ownerDiscordId);
97
- if (!ownerId) throw new Error('OWNER_DISCORD_ID is required');
176
+ const ownerId = assertNonEmptyString(ownerIdParam, 'ownerId');
177
+ const ownerUsername = String(ownerUser?.username ?? '').trim() || ownerId;
98
178
 
99
179
  const state = {
100
180
  version: STATE_VERSION,
101
181
  managed: true,
102
- ownerDiscordId: ownerId,
182
+ ownerId,
103
183
  vaultId: randomBytes(16).toString('hex'),
104
184
  initializedAt: nowIso(),
185
+ vaultName: String(vaultName ?? '').trim() || null,
105
186
  members: {},
106
187
  invites: {},
107
188
  };
108
189
 
109
190
  state.members[ownerId] = {
110
191
  id: ownerId,
111
- username: ownerUser?.username || ownerId,
192
+ username: ownerUsername,
112
193
  addedAt: nowIso(),
113
194
  addedBy: ownerId,
195
+ pendingBootstrapHash: null,
196
+ pendingBootstrapIssuedAt: null,
197
+ pendingBootstrapExpiresAt: null,
114
198
  };
115
199
 
116
200
  return saveManagedState(vaultPath, state);
117
201
  }
118
202
 
119
- export async function createInvite({ vaultPath, ownerDiscordId, createdBy }) {
203
+ export async function createInvite({ vaultPath, createdBy }) {
120
204
  const state = await loadManagedState(vaultPath);
121
205
  if (!state) throw new Error('Managed vault is not initialized');
122
- if (state.ownerDiscordId !== normalizeOwnerId(ownerDiscordId)) {
123
- throw new Error('Owner mismatch');
124
- }
125
206
 
126
207
  let code = nextInviteCode();
127
208
  while (state.invites[code]) {
@@ -131,10 +212,14 @@ export async function createInvite({ vaultPath, ownerDiscordId, createdBy }) {
131
212
  state.invites[code] = {
132
213
  code,
133
214
  createdAt: nowIso(),
134
- createdBy,
215
+ createdBy: assertNonEmptyString(createdBy, 'createdBy'),
135
216
  usedAt: null,
136
217
  usedBy: null,
137
218
  revokedAt: null,
219
+ downloadTicketHash: null,
220
+ downloadTicketIssuedAt: null,
221
+ downloadTicketExpiresAt: null,
222
+ downloadTicketUsedAt: null,
138
223
  };
139
224
  await saveManagedState(vaultPath, state);
140
225
  return state.invites[code];
@@ -146,6 +231,14 @@ export async function listInvites(vaultPath) {
146
231
  return Object.values(state.invites).sort((a, b) => a.createdAt.localeCompare(b.createdAt));
147
232
  }
148
233
 
234
+ export async function getInvite(vaultPath, code) {
235
+ const state = await loadManagedState(vaultPath);
236
+ if (!state) throw new Error('Managed vault is not initialized');
237
+ const invite = state.invites?.[code];
238
+ if (!invite) return null;
239
+ return invite;
240
+ }
241
+
149
242
  export async function revokeInvite({ vaultPath, code }) {
150
243
  const state = await loadManagedState(vaultPath);
151
244
  if (!state) throw new Error('Managed vault is not initialized');
@@ -162,15 +255,12 @@ export async function pairMember({ vaultPath, code, user }) {
162
255
  const state = await loadManagedState(vaultPath);
163
256
  if (!state) throw new Error('Managed vault is not initialized');
164
257
 
258
+ const invite = getValidInviteOrThrow(state, code);
259
+
165
260
  if (isMember(state, user.id)) {
166
261
  return { state, paired: false, reason: 'already-member' };
167
262
  }
168
263
 
169
- const invite = state.invites[code];
170
- if (!invite) throw new Error('Invite not found');
171
- if (invite.revokedAt) throw new Error('Invite revoked');
172
- if (invite.usedAt) throw new Error('Invite already used');
173
-
174
264
  invite.usedAt = nowIso();
175
265
  invite.usedBy = user.id;
176
266
 
@@ -178,24 +268,140 @@ export async function pairMember({ vaultPath, code, user }) {
178
268
  id: user.id,
179
269
  username: user.username,
180
270
  addedAt: nowIso(),
181
- addedBy: invite.createdBy || state.ownerDiscordId,
271
+ addedBy: invite.createdBy || state.ownerId,
272
+ pendingBootstrapHash: null,
273
+ pendingBootstrapIssuedAt: null,
274
+ pendingBootstrapExpiresAt: null,
182
275
  };
183
276
 
184
277
  await saveManagedState(vaultPath, state);
185
278
  return { state, paired: true, reason: 'paired' };
186
279
  }
187
280
 
188
- export async function removeMember({ vaultPath, discordId }) {
281
+ export async function setInviteDownloadTicket({
282
+ vaultPath,
283
+ code,
284
+ memberId,
285
+ ticketHash,
286
+ expiresAt,
287
+ }) {
288
+ const state = await loadManagedState(vaultPath);
289
+ if (!state) throw new Error('Managed vault is not initialized');
290
+
291
+ const invite = state.invites?.[code];
292
+ if (!invite) throw new Error('Invite not found');
293
+ if (invite.revokedAt) throw new Error('Invite revoked');
294
+ if (!invite.usedAt || !invite.usedBy) throw new Error('Invite has not been claimed yet');
295
+ if (invite.usedBy !== memberId) throw new Error('Invite does not belong to this member');
296
+
297
+ invite.downloadTicketHash = assertNonEmptyString(ticketHash, 'ticketHash');
298
+ invite.downloadTicketIssuedAt = nowIso();
299
+ invite.downloadTicketExpiresAt = assertIsoDate(expiresAt, 'expiresAt');
300
+ invite.downloadTicketUsedAt = null;
301
+
302
+ await saveManagedState(vaultPath, state);
303
+ return invite;
304
+ }
305
+
306
+ export async function consumeInviteDownloadTicket({ vaultPath, ticketHash }) {
307
+ const state = await loadManagedState(vaultPath);
308
+ if (!state) throw new Error('Managed vault is not initialized');
309
+
310
+ const normalizedHash = assertNonEmptyString(ticketHash, 'ticketHash');
311
+ const invite = Object.values(state.invites).find((row) =>
312
+ compareHexConstantTime(String(row.downloadTicketHash ?? ''), normalizedHash),
313
+ );
314
+ if (!invite) {
315
+ throw new Error('Download ticket is invalid');
316
+ }
317
+ if (!invite.usedAt || !invite.usedBy) {
318
+ throw new Error('Invite has not been claimed yet');
319
+ }
320
+ if (invite.downloadTicketUsedAt) {
321
+ throw new Error('Download ticket already used');
322
+ }
323
+ const expiresAt = String(invite.downloadTicketExpiresAt ?? '').trim();
324
+ if (!expiresAt) {
325
+ throw new Error('Download ticket is invalid');
326
+ }
327
+ const expiresMs = Date.parse(expiresAt);
328
+ if (!Number.isFinite(expiresMs) || Date.now() > expiresMs) {
329
+ throw new Error('Download ticket expired');
330
+ }
331
+
332
+ invite.downloadTicketUsedAt = nowIso();
333
+ await saveManagedState(vaultPath, state);
334
+ return {
335
+ invite,
336
+ memberId: invite.usedBy,
337
+ vaultId: state.vaultId,
338
+ };
339
+ }
340
+
341
+ export async function setMemberBootstrapSecret({ vaultPath, userId, tokenHash, expiresAt }) {
342
+ const state = await loadManagedState(vaultPath);
343
+ if (!state) throw new Error('Managed vault is not initialized');
344
+
345
+ const member = state.members?.[userId];
346
+ if (!member) {
347
+ throw new Error('Member not found');
348
+ }
349
+
350
+ member.pendingBootstrapHash = assertNonEmptyString(tokenHash, 'tokenHash');
351
+ member.pendingBootstrapIssuedAt = nowIso();
352
+ member.pendingBootstrapExpiresAt = assertIsoDate(expiresAt, 'expiresAt');
353
+
354
+ await saveManagedState(vaultPath, state);
355
+ return { state, member };
356
+ }
357
+
358
+ export async function consumeMemberBootstrapSecretByToken({ vaultPath, tokenHash, vaultId }) {
359
+ const state = await loadManagedState(vaultPath);
360
+ if (!state) throw new Error('Managed vault is not initialized');
361
+ if (String(vaultId ?? '') !== state.vaultId) {
362
+ throw new Error('Invalid vault ID');
363
+ }
364
+
365
+ const incomingHash = String(tokenHash ?? '').trim();
366
+ if (!incomingHash) {
367
+ throw new Error('Invalid bootstrap token');
368
+ }
369
+
370
+ const member = Object.values(state.members ?? {}).find((candidate) =>
371
+ compareHexConstantTime(String(candidate.pendingBootstrapHash ?? ''), incomingHash),
372
+ );
373
+ if (!member) {
374
+ throw new Error('Invalid bootstrap token');
375
+ }
376
+
377
+ const expiresAt = String(member.pendingBootstrapExpiresAt ?? '').trim();
378
+ if (!expiresAt) {
379
+ throw new Error('No pending bootstrap token for member');
380
+ }
381
+ const expiresMs = Date.parse(expiresAt);
382
+ if (!Number.isFinite(expiresMs) || Date.now() > expiresMs) {
383
+ throw new Error('Bootstrap token expired');
384
+ }
385
+
386
+ member.pendingBootstrapHash = null;
387
+ member.pendingBootstrapIssuedAt = null;
388
+ member.pendingBootstrapExpiresAt = null;
389
+
390
+ await saveManagedState(vaultPath, state);
391
+ return { state, member };
392
+ }
393
+
394
+ export async function removeMember({ vaultPath, userId }) {
189
395
  const state = await loadManagedState(vaultPath);
190
396
  if (!state) throw new Error('Managed vault is not initialized');
191
- if (discordId === state.ownerDiscordId) {
397
+ if (userId === state.ownerId) {
192
398
  throw new Error('Cannot remove owner');
193
399
  }
194
- const existing = state.members?.[discordId];
400
+ const existing = state.members?.[userId];
195
401
  if (!existing) {
196
402
  return { removed: false, state };
197
403
  }
198
- delete state.members[discordId];
404
+ delete state.members[userId];
199
405
  await saveManagedState(vaultPath, state);
200
406
  return { removed: true, state, member: existing };
201
407
  }
@@ -216,10 +422,11 @@ export function describeManagedStatus(state, userId) {
216
422
  return {
217
423
  managedInitialized: true,
218
424
  vaultId: state.vaultId,
425
+ vaultName: state.vaultName ?? null,
219
426
  role,
220
427
  isOwner: role === 'owner',
221
428
  isMember: role === 'owner' || role === 'member',
222
- ownerDiscordId: state.ownerDiscordId,
429
+ ownerId: state.ownerId,
223
430
  memberCount: Object.keys(state.members ?? {}).length,
224
431
  initializedAt: state.initializedAt,
225
432
  };