@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/lib/managedState.js
CHANGED
|
@@ -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,
|
|
4
|
+
import { dirname, join, resolve } from 'path';
|
|
5
5
|
|
|
6
|
-
const STATE_VERSION =
|
|
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
|
|
14
|
-
|
|
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
|
-
|
|
19
|
-
|
|
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')
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
if (
|
|
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
|
|
30
|
-
const
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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 =
|
|
97
|
-
|
|
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
|
-
|
|
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:
|
|
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,
|
|
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.
|
|
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
|
|
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 (
|
|
397
|
+
if (userId === state.ownerId) {
|
|
192
398
|
throw new Error('Cannot remove owner');
|
|
193
399
|
}
|
|
194
|
-
const existing = state.members?.[
|
|
400
|
+
const existing = state.members?.[userId];
|
|
195
401
|
if (!existing) {
|
|
196
402
|
return { removed: false, state };
|
|
197
403
|
}
|
|
198
|
-
delete state.members[
|
|
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
|
-
|
|
429
|
+
ownerId: state.ownerId,
|
|
223
430
|
memberCount: Object.keys(state.members ?? {}).length,
|
|
224
431
|
initializedAt: state.initializedAt,
|
|
225
432
|
};
|
|
@@ -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
|
@@ -204,10 +204,9 @@ export async function forceCloseRoom(relPath) {
|
|
|
204
204
|
await closeRoom(docName, { closeClients: true, reason: 'forced' });
|
|
205
205
|
}
|
|
206
206
|
|
|
207
|
-
export function startYjsServer(broadcastFileUpdated) {
|
|
207
|
+
export function startYjsServer(httpServer, broadcastFileUpdated) {
|
|
208
208
|
broadcastRef = broadcastFileUpdated;
|
|
209
|
-
const
|
|
210
|
-
const wss = new WebSocketServer({ port });
|
|
209
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
211
210
|
|
|
212
211
|
wss.on('connection', async (conn, req) => {
|
|
213
212
|
const url = new URL(req.url, 'http://localhost');
|
|
@@ -253,14 +252,17 @@ export function startYjsServer(broadcastFileUpdated) {
|
|
|
253
252
|
// it already populated and send that content as sync step 1, so clients
|
|
254
253
|
// never see an empty doc and Y.Text never gets double-initialized.
|
|
255
254
|
const ydoc = getYDoc(rawDocName, true);
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
|
261
265
|
}
|
|
262
|
-
} catch {
|
|
263
|
-
// File doesn't exist yet — start with empty document
|
|
264
266
|
}
|
|
265
267
|
}
|
|
266
268
|
|
|
@@ -274,6 +276,5 @@ export function startYjsServer(broadcastFileUpdated) {
|
|
|
274
276
|
}
|
|
275
277
|
});
|
|
276
278
|
|
|
277
|
-
console.log(`[yjs] WebSocket server listening on port ${port}`);
|
|
278
279
|
return wss;
|
|
279
280
|
}
|
package/package.json
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fyresmith/hive-server",
|
|
3
|
-
"version": "
|
|
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": {
|