@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.
- 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 -10
- package/cli/core/context.js +3 -22
- package/cli/env-file.js +15 -29
- package/cli/flows/doctor.js +1 -6
- package/cli/flows/setup.js +106 -31
- package/index.js +81 -45
- 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 +10 -7
- package/package.json +3 -2
- package/routes/auth.js +403 -82
- package/routes/dashboard.js +590 -0
- package/routes/managed.js +0 -174
|
@@ -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
|
+
}
|
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
|
};
|