@yeaft/webchat-agent 1.0.188 → 1.0.189
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/cli.js +12 -0
- package/index.js +10 -2
- package/local-run.js +218 -0
- package/local-runtime/server/.env.example +54 -0
- package/local-runtime/server/api.js +111 -0
- package/local-runtime/server/auth/aad.js +235 -0
- package/local-runtime/server/auth/login.js +156 -0
- package/local-runtime/server/auth/oauth-flow.js +277 -0
- package/local-runtime/server/auth/password-reset.js +134 -0
- package/local-runtime/server/auth/providers/alipay.js +125 -0
- package/local-runtime/server/auth/providers/github.js +82 -0
- package/local-runtime/server/auth/providers/google.js +60 -0
- package/local-runtime/server/auth/providers/microsoft.js +71 -0
- package/local-runtime/server/auth/providers/types.js +57 -0
- package/local-runtime/server/auth/providers/wechat.js +68 -0
- package/local-runtime/server/auth/register.js +91 -0
- package/local-runtime/server/auth/session-store.js +57 -0
- package/local-runtime/server/auth/token.js +85 -0
- package/local-runtime/server/auth/totp-auth.js +133 -0
- package/local-runtime/server/auth/utils.js +42 -0
- package/local-runtime/server/auth.js +8 -0
- package/local-runtime/server/check-node-version.js +74 -0
- package/local-runtime/server/config.js +298 -0
- package/local-runtime/server/context.js +140 -0
- package/local-runtime/server/create-user.js +59 -0
- package/local-runtime/server/database.js +12 -0
- package/local-runtime/server/db/connection.js +963 -0
- package/local-runtime/server/db/expert-db.js +171 -0
- package/local-runtime/server/db/identity-db.js +92 -0
- package/local-runtime/server/db/invitation-db.js +38 -0
- package/local-runtime/server/db/message-db.js +257 -0
- package/local-runtime/server/db/session-db.js +118 -0
- package/local-runtime/server/db/user-db.js +165 -0
- package/local-runtime/server/db/user-stats-db.js +185 -0
- package/local-runtime/server/db/yeaft-session-db.js +258 -0
- package/local-runtime/server/email.js +96 -0
- package/local-runtime/server/encryption.js +105 -0
- package/local-runtime/server/handlers/agent-conversation.js +347 -0
- package/local-runtime/server/handlers/agent-file-terminal.js +99 -0
- package/local-runtime/server/handlers/agent-output.js +854 -0
- package/local-runtime/server/handlers/agent-sync.js +399 -0
- package/local-runtime/server/handlers/agent-work-center.js +27 -0
- package/local-runtime/server/handlers/client-conversation.js +1182 -0
- package/local-runtime/server/handlers/client-misc.js +254 -0
- package/local-runtime/server/handlers/client-work-center.js +269 -0
- package/local-runtime/server/handlers/client-workbench.js +146 -0
- package/local-runtime/server/handlers/session-pin-router.js +61 -0
- package/local-runtime/server/heartbeat-policy.js +46 -0
- package/local-runtime/server/index.js +275 -0
- package/local-runtime/server/package.json +55 -0
- package/local-runtime/server/perf-trace.js +154 -0
- package/local-runtime/server/proxy.js +273 -0
- package/local-runtime/server/routes/admin-routes.js +207 -0
- package/local-runtime/server/routes/auth-routes.js +322 -0
- package/local-runtime/server/routes/expert-routes.js +117 -0
- package/local-runtime/server/routes/invitation-routes.js +60 -0
- package/local-runtime/server/routes/session-routes.js +112 -0
- package/local-runtime/server/routes/upload-routes.js +109 -0
- package/local-runtime/server/routes/user-routes.js +241 -0
- package/local-runtime/server/totp.js +74 -0
- package/local-runtime/server/work-item-attachment-policy.js +56 -0
- package/local-runtime/server/ws-agent.js +319 -0
- package/local-runtime/server/ws-client.js +214 -0
- package/local-runtime/server/ws-utils.js +394 -0
- package/local-runtime/server/yeaft-asset-store.js +339 -0
- package/local-runtime/version.json +1 -0
- package/local-runtime/web/app.bundle.js +7673 -0
- package/local-runtime/web/app.bundle.js.gz +0 -0
- package/local-runtime/web/assets/avatars/README.md +34 -0
- package/local-runtime/web/assets/avatars/ada.svg +1 -0
- package/local-runtime/web/assets/avatars/alan.svg +1 -0
- package/local-runtime/web/assets/avatars/alice.svg +1 -0
- package/local-runtime/web/assets/avatars/anders.svg +1 -0
- package/local-runtime/web/assets/avatars/bezos.svg +1 -0
- package/local-runtime/web/assets/avatars/borges.svg +1 -0
- package/local-runtime/web/assets/avatars/buffett.svg +1 -0
- package/local-runtime/web/assets/avatars/clausewitz.svg +1 -0
- package/local-runtime/web/assets/avatars/dalio.svg +1 -0
- package/local-runtime/web/assets/avatars/dieter.svg +1 -0
- package/local-runtime/web/assets/avatars/drucker.svg +1 -0
- package/local-runtime/web/assets/avatars/einstein.svg +1 -0
- package/local-runtime/web/assets/avatars/grace.svg +1 -0
- package/local-runtime/web/assets/avatars/harari.svg +1 -0
- package/local-runtime/web/assets/avatars/jung.svg +1 -0
- package/local-runtime/web/assets/avatars/kahneman.svg +1 -0
- package/local-runtime/web/assets/avatars/ken.svg +1 -0
- package/local-runtime/web/assets/avatars/kongzi.svg +1 -0
- package/local-runtime/web/assets/avatars/kubrick.svg +1 -0
- package/local-runtime/web/assets/avatars/linus.svg +1 -0
- package/local-runtime/web/assets/avatars/luxun.svg +1 -0
- package/local-runtime/web/assets/avatars/margaret.svg +1 -0
- package/local-runtime/web/assets/avatars/martin.svg +1 -0
- package/local-runtime/web/assets/avatars/miyazaki.svg +1 -0
- package/local-runtime/web/assets/avatars/munger.svg +1 -0
- package/local-runtime/web/assets/avatars/nietzsche.svg +1 -0
- package/local-runtime/web/assets/avatars/norman.svg +1 -0
- package/local-runtime/web/assets/avatars/shannon.svg +1 -0
- package/local-runtime/web/assets/avatars/simaqian.svg +1 -0
- package/local-runtime/web/assets/avatars/socrates.svg +1 -0
- package/local-runtime/web/assets/avatars/steve.svg +1 -0
- package/local-runtime/web/assets/avatars/sudongpo.svg +1 -0
- package/local-runtime/web/assets/avatars/sunzi.svg +1 -0
- package/local-runtime/web/docx-preview.min.js +2 -0
- package/local-runtime/web/docx-preview.min.js.gz +0 -0
- package/local-runtime/web/html-to-image.min.js +2 -0
- package/local-runtime/web/html-to-image.min.js.gz +0 -0
- package/local-runtime/web/index.html +21 -0
- package/local-runtime/web/jszip.min.js +13 -0
- package/local-runtime/web/jszip.min.js.gz +0 -0
- package/local-runtime/web/mermaid.min.js +2843 -0
- package/local-runtime/web/mermaid.min.js.gz +0 -0
- package/local-runtime/web/msal-browser.min.js +69 -0
- package/local-runtime/web/msal-browser.min.js.gz +0 -0
- package/local-runtime/web/style.bundle.css +10 -0
- package/local-runtime/web/style.bundle.css.gz +0 -0
- package/local-runtime/web/vendor.bundle.js +1522 -0
- package/local-runtime/web/vendor.bundle.js.gz +0 -0
- package/local-runtime/web/xlsx.min.js +24 -0
- package/local-runtime/web/xlsx.min.js.gz +0 -0
- package/package.json +13 -3
- package/scripts/prepare-local-runtime.js +25 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import jwt from 'jsonwebtoken';
|
|
2
|
+
import { CONFIG, getUserByUsername, isEmailConfigured, updateUserTotp } from '../config.js';
|
|
3
|
+
import { sendVerificationCode } from '../email.js';
|
|
4
|
+
import { verifyTotpCode } from '../totp.js';
|
|
5
|
+
import { pendingVerifications, pendingTotpVerifications, pendingTotpSetup } from './session-store.js';
|
|
6
|
+
import { completeLogin } from './login.js';
|
|
7
|
+
import { generateVerificationCode, maskEmail } from './utils.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Verify TOTP code (for returning users with TOTP enabled)
|
|
11
|
+
*/
|
|
12
|
+
export async function verifyTotpStep(tempToken, totpCode) {
|
|
13
|
+
const pending = pendingTotpVerifications.get(tempToken);
|
|
14
|
+
|
|
15
|
+
if (!pending) {
|
|
16
|
+
return { success: false, error: 'Invalid or expired TOTP verification token' };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (Date.now() > pending.expiresAt) {
|
|
20
|
+
pendingTotpVerifications.delete(tempToken);
|
|
21
|
+
return { success: false, error: 'TOTP verification has expired' };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const user = getUserByUsername(pending.username);
|
|
25
|
+
if (!user || !user.totpSecret) {
|
|
26
|
+
pendingTotpVerifications.delete(tempToken);
|
|
27
|
+
return { success: false, error: 'User TOTP configuration not found' };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const isValid = verifyTotpCode(totpCode, user.totpSecret);
|
|
31
|
+
if (!isValid) {
|
|
32
|
+
return { success: false, error: 'Invalid TOTP code' };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
pendingTotpVerifications.delete(tempToken);
|
|
36
|
+
|
|
37
|
+
// Check if email verification is needed
|
|
38
|
+
if (isEmailConfigured()) {
|
|
39
|
+
const code = generateVerificationCode();
|
|
40
|
+
const newTempToken = jwt.sign({ username: pending.username, type: 'temp' }, CONFIG.jwtSecret, { expiresIn: CONFIG.tempTokenExpiresIn });
|
|
41
|
+
|
|
42
|
+
pendingVerifications.set(newTempToken, {
|
|
43
|
+
username: pending.username,
|
|
44
|
+
code,
|
|
45
|
+
email: user.email,
|
|
46
|
+
sessionKey: pending.sessionKey,
|
|
47
|
+
role: pending.role,
|
|
48
|
+
expiresAt: Date.now() + CONFIG.emailCodeExpiresIn
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
await sendVerificationCode(user.email, code, pending.username);
|
|
53
|
+
} catch (err) {
|
|
54
|
+
console.error('Failed to send verification email:', err.message);
|
|
55
|
+
pendingVerifications.delete(newTempToken);
|
|
56
|
+
return { success: false, error: 'Failed to send verification email' };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
success: true,
|
|
61
|
+
tempToken: newTempToken,
|
|
62
|
+
needEmailCode: true,
|
|
63
|
+
emailHint: maskEmail(user.email)
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return completeLogin(pending.username, pending.sessionKey, pending.role);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Complete TOTP setup (for first-time TOTP configuration)
|
|
72
|
+
*/
|
|
73
|
+
export async function completeTotpSetup(setupToken, totpCode) {
|
|
74
|
+
const pending = pendingTotpSetup.get(setupToken);
|
|
75
|
+
|
|
76
|
+
if (!pending) {
|
|
77
|
+
return { success: false, error: 'Invalid or expired TOTP setup token' };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (Date.now() > pending.expiresAt) {
|
|
81
|
+
pendingTotpSetup.delete(setupToken);
|
|
82
|
+
return { success: false, error: 'TOTP setup has expired' };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const isValid = verifyTotpCode(totpCode, pending.secret);
|
|
86
|
+
if (!isValid) {
|
|
87
|
+
return { success: false, error: 'Invalid TOTP code. Please try again.' };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const saved = await updateUserTotp(pending.username, {
|
|
91
|
+
totpSecret: pending.secret,
|
|
92
|
+
totpEnabled: true
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
if (!saved) {
|
|
96
|
+
return { success: false, error: 'Failed to save TOTP settings' };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
pendingTotpSetup.delete(setupToken);
|
|
100
|
+
|
|
101
|
+
const user = getUserByUsername(pending.username);
|
|
102
|
+
|
|
103
|
+
if (isEmailConfigured() && user) {
|
|
104
|
+
const code = generateVerificationCode();
|
|
105
|
+
const tempToken = jwt.sign({ username: pending.username, type: 'temp' }, CONFIG.jwtSecret, { expiresIn: CONFIG.tempTokenExpiresIn });
|
|
106
|
+
|
|
107
|
+
pendingVerifications.set(tempToken, {
|
|
108
|
+
username: pending.username,
|
|
109
|
+
code,
|
|
110
|
+
email: user.email,
|
|
111
|
+
sessionKey: pending.sessionKey,
|
|
112
|
+
role: pending.role,
|
|
113
|
+
expiresAt: Date.now() + CONFIG.emailCodeExpiresIn
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
await sendVerificationCode(user.email, code, pending.username);
|
|
118
|
+
} catch (err) {
|
|
119
|
+
console.error('Failed to send verification email:', err.message);
|
|
120
|
+
pendingVerifications.delete(tempToken);
|
|
121
|
+
return { success: false, error: 'Failed to send verification email' };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
success: true,
|
|
126
|
+
tempToken,
|
|
127
|
+
needEmailCode: true,
|
|
128
|
+
emailHint: maskEmail(user.email)
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return completeLogin(pending.username, pending.sessionKey, pending.role);
|
|
133
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { randomInt } from 'crypto';
|
|
2
|
+
import bcrypt from 'bcrypt';
|
|
3
|
+
import { CONFIG } from '../config.js';
|
|
4
|
+
import { generateSessionKey, encodeKey } from '../encryption.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Generate a random verification code
|
|
8
|
+
*/
|
|
9
|
+
export function generateVerificationCode() {
|
|
10
|
+
const length = CONFIG.emailCodeLength;
|
|
11
|
+
let code = '';
|
|
12
|
+
for (let i = 0; i < length; i++) {
|
|
13
|
+
code += randomInt(10).toString();
|
|
14
|
+
}
|
|
15
|
+
return code;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Mask email for display (e.g., a***@example.com)
|
|
20
|
+
*/
|
|
21
|
+
export function maskEmail(email) {
|
|
22
|
+
const [local, domain] = email.split('@');
|
|
23
|
+
if (local.length <= 2) {
|
|
24
|
+
return `${local[0]}***@${domain}`;
|
|
25
|
+
}
|
|
26
|
+
return `${local[0]}${'*'.repeat(Math.min(local.length - 1, 3))}@${domain}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Hash a password for storage
|
|
31
|
+
*/
|
|
32
|
+
export async function hashPassword(password) {
|
|
33
|
+
return bcrypt.hash(password, 10);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Generate session key for skip-auth mode
|
|
38
|
+
*/
|
|
39
|
+
export function generateSkipAuthSession() {
|
|
40
|
+
const sessionKey = generateSessionKey();
|
|
41
|
+
return { sessionKey, sessionKeyEncoded: encodeKey(sessionKey) };
|
|
42
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Re-export entry point — preserves all existing import paths.
|
|
2
|
+
// Actual implementations are in server/auth/*.js sub-modules.
|
|
3
|
+
export { loginStep1, loginStep2 } from './auth/login.js';
|
|
4
|
+
export { verifyTotpStep, completeTotpSetup } from './auth/totp-auth.js';
|
|
5
|
+
export { verifyToken, logout, maybeRenewToken } from './auth/token.js';
|
|
6
|
+
export { verifyAgent, register } from './auth/register.js';
|
|
7
|
+
export { hashPassword, generateSkipAuthSession } from './auth/utils.js';
|
|
8
|
+
export { loginWithAad } from './auth/aad.js';
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* check-node-version.js — Runtime guard for Node.js minimum version.
|
|
3
|
+
*
|
|
4
|
+
* The agent and server use `node:sqlite` (DatabaseSync) which is built-in
|
|
5
|
+
* to Node ≥ 22.5.0. On older Node the import fails with the cryptic
|
|
6
|
+
* `No such built-in module: node:sqlite`. We replace that with a clear
|
|
7
|
+
* actionable message and exit early.
|
|
8
|
+
*
|
|
9
|
+
* Why this exists even though `package.json` has `"engines": { "node": ">=22.5.0" }`:
|
|
10
|
+
* - npm only WARNS on engines mismatch by default (not strict).
|
|
11
|
+
* - `nvm use 22` selects whichever 22.x is already installed; if the
|
|
12
|
+
* user has 22.0–22.4, they pass the major check but fail node:sqlite.
|
|
13
|
+
* - Globally-installed CLIs (`@yeaft/webchat-agent`) bypass package.json
|
|
14
|
+
* engines entirely on the user's system.
|
|
15
|
+
*
|
|
16
|
+
* Call `assertNodeVersion()` at the very top of every entry point (before
|
|
17
|
+
* any import that transitively touches `node:sqlite`).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const MIN_MAJOR = 22;
|
|
21
|
+
const MIN_MINOR = 5;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Parse `process.version` ("v22.5.1") into [major, minor, patch].
|
|
25
|
+
* Returns null on unrecognized input rather than throwing — we never
|
|
26
|
+
* want this guard to break startup itself.
|
|
27
|
+
*/
|
|
28
|
+
function parseNodeVersion(v) {
|
|
29
|
+
if (typeof v !== 'string') return null;
|
|
30
|
+
const m = /^v?(\d+)\.(\d+)\.(\d+)/.exec(v);
|
|
31
|
+
if (!m) return null;
|
|
32
|
+
return [Number(m[1]), Number(m[2]), Number(m[3])];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @returns {boolean} true if current Node satisfies the minimum.
|
|
37
|
+
*/
|
|
38
|
+
export function isSupportedNodeVersion(versionString = process.version) {
|
|
39
|
+
const parsed = parseNodeVersion(versionString);
|
|
40
|
+
if (!parsed) return true; // unknown — don't block
|
|
41
|
+
const [major, minor] = parsed;
|
|
42
|
+
if (major > MIN_MAJOR) return true;
|
|
43
|
+
if (major < MIN_MAJOR) return false;
|
|
44
|
+
return minor >= MIN_MINOR;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Print a clear error and exit(1) if Node is too old.
|
|
49
|
+
*
|
|
50
|
+
* @param {{ component?: string }} [opts]
|
|
51
|
+
*/
|
|
52
|
+
export function assertNodeVersion(opts = {}) {
|
|
53
|
+
if (isSupportedNodeVersion()) return;
|
|
54
|
+
const component = opts.component || 'yeaft';
|
|
55
|
+
const required = `${MIN_MAJOR}.${MIN_MINOR}.0`;
|
|
56
|
+
const lines = [
|
|
57
|
+
'',
|
|
58
|
+
`✖ ${component} requires Node.js ≥ ${required}`,
|
|
59
|
+
` Current: ${process.version}`,
|
|
60
|
+
'',
|
|
61
|
+
' This project uses the built-in `node:sqlite` module which was',
|
|
62
|
+
` added in Node ${required}.`,
|
|
63
|
+
'',
|
|
64
|
+
' Fix:',
|
|
65
|
+
' # if you use nvm:',
|
|
66
|
+
' nvm install 22 # installs the latest 22.x (currently > 22.5)',
|
|
67
|
+
' nvm use 22',
|
|
68
|
+
'',
|
|
69
|
+
' # or upgrade Node from https://nodejs.org/',
|
|
70
|
+
'',
|
|
71
|
+
];
|
|
72
|
+
process.stderr.write(lines.join('\n'));
|
|
73
|
+
process.exit(1);
|
|
74
|
+
}
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import 'dotenv/config';
|
|
2
|
+
import { readFileSync, existsSync, writeFileSync } from 'fs';
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
|
+
import { dirname, join } from 'path';
|
|
5
|
+
import { userDb } from './database.js';
|
|
6
|
+
|
|
7
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
8
|
+
const __dirname = dirname(__filename);
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Load users from environment variable or users.json file
|
|
12
|
+
* Format for AUTH_USERS: username:passwordHash:email
|
|
13
|
+
* TOTP settings are loaded from SQLite database
|
|
14
|
+
*
|
|
15
|
+
* On startup, users are migrated into SQLite with role='admin'.
|
|
16
|
+
* After migration, all runtime queries go through the database.
|
|
17
|
+
*/
|
|
18
|
+
function loadUsers() {
|
|
19
|
+
const users = [];
|
|
20
|
+
|
|
21
|
+
// Try loading from environment variable first
|
|
22
|
+
if (process.env.AUTH_USERS) {
|
|
23
|
+
const userEntries = process.env.AUTH_USERS.split(',');
|
|
24
|
+
for (const entry of userEntries) {
|
|
25
|
+
const parts = entry.split(':');
|
|
26
|
+
const [username, passwordHash, email] = parts;
|
|
27
|
+
if (username && passwordHash && email) {
|
|
28
|
+
const trimmedUsername = username.trim();
|
|
29
|
+
// Load TOTP settings from database
|
|
30
|
+
const totpData = userDb.getTotp(trimmedUsername);
|
|
31
|
+
users.push({
|
|
32
|
+
username: trimmedUsername,
|
|
33
|
+
passwordHash: passwordHash.trim(),
|
|
34
|
+
email: email.trim(),
|
|
35
|
+
totpSecret: totpData?.totpSecret || null,
|
|
36
|
+
totpEnabled: totpData?.totpEnabled || false
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Try loading from users.json file (fallback for backward compatibility)
|
|
43
|
+
const usersFile = join(__dirname, 'users.json');
|
|
44
|
+
if (existsSync(usersFile)) {
|
|
45
|
+
try {
|
|
46
|
+
const fileContent = JSON.parse(readFileSync(usersFile, 'utf-8'));
|
|
47
|
+
if (Array.isArray(fileContent.users)) {
|
|
48
|
+
for (const u of fileContent.users) {
|
|
49
|
+
// Skip if user already loaded from env
|
|
50
|
+
if (users.find(existing => existing.username === u.username)) continue;
|
|
51
|
+
// Load TOTP settings from database
|
|
52
|
+
const totpData = userDb.getTotp(u.username);
|
|
53
|
+
users.push({
|
|
54
|
+
...u,
|
|
55
|
+
totpSecret: totpData?.totpSecret || u.totpSecret || null,
|
|
56
|
+
totpEnabled: totpData?.totpEnabled || u.totpEnabled || false
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
} catch (err) {
|
|
61
|
+
console.error('Failed to load users.json:', err.message);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Migrate all loaded users into SQLite (idempotent)
|
|
66
|
+
let migrated = 0;
|
|
67
|
+
for (const u of users) {
|
|
68
|
+
const result = userDb.migrateUser(u.username, u.passwordHash, u.email, 'admin');
|
|
69
|
+
if (result) migrated++;
|
|
70
|
+
}
|
|
71
|
+
if (migrated > 0) {
|
|
72
|
+
console.log(`[Migration] Synced ${migrated} users from AUTH_USERS/users.json to database`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return users;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Default secrets - these MUST be changed in production
|
|
79
|
+
const DEFAULT_JWT_SECRET = 'default-secret-change-in-production';
|
|
80
|
+
const DEFAULT_AGENT_SECRET = 'agent-shared-secret';
|
|
81
|
+
|
|
82
|
+
export const CONFIG = {
|
|
83
|
+
// Server settings
|
|
84
|
+
port: parseInt(process.env.PORT, 10) || 3456,
|
|
85
|
+
host: process.env.SERVER_HOST || undefined,
|
|
86
|
+
|
|
87
|
+
// Authentication
|
|
88
|
+
skipAuth: process.env.SKIP_AUTH === 'true',
|
|
89
|
+
users: loadUsers(),
|
|
90
|
+
|
|
91
|
+
// JWT settings
|
|
92
|
+
jwtSecret: process.env.JWT_SECRET || DEFAULT_JWT_SECRET,
|
|
93
|
+
jwtExpiresIn: process.env.JWT_EXPIRES_IN || '3d',
|
|
94
|
+
// Sliding renew: when an inbound request's token has less than this many ms
|
|
95
|
+
// remaining, requireAuth issues a fresh token via the X-New-Token response
|
|
96
|
+
// header. 1 day means an active user never sees an unexpected logout.
|
|
97
|
+
jwtRenewThresholdMs: parseInt(process.env.JWT_RENEW_THRESHOLD_MS, 10) || 24 * 60 * 60 * 1000,
|
|
98
|
+
tempTokenExpiresIn: process.env.TEMP_TOKEN_EXPIRES_IN || '10m',
|
|
99
|
+
|
|
100
|
+
// Email verification
|
|
101
|
+
emailCodeLength: parseInt(process.env.EMAIL_CODE_LENGTH, 10) || 6,
|
|
102
|
+
emailCodeExpiresIn: parseInt(process.env.EMAIL_CODE_EXPIRES_IN, 10) || 300000, // 5 minutes in ms
|
|
103
|
+
|
|
104
|
+
// SMTP settings
|
|
105
|
+
smtp: {
|
|
106
|
+
host: process.env.SMTP_HOST || '',
|
|
107
|
+
port: parseInt(process.env.SMTP_PORT, 10) || 587,
|
|
108
|
+
secure: process.env.SMTP_SECURE === 'true',
|
|
109
|
+
user: process.env.SMTP_USER || '',
|
|
110
|
+
pass: process.env.SMTP_PASS || '',
|
|
111
|
+
from: process.env.SMTP_FROM || 'WebChat <noreply@example.com>'
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
// Agent authentication (global fallback — per-user agent_secret is preferred)
|
|
115
|
+
agentSecret: process.env.AGENT_SECRET || DEFAULT_AGENT_SECRET,
|
|
116
|
+
|
|
117
|
+
// File upload settings
|
|
118
|
+
maxFileSize: parseInt(process.env.MAX_FILE_SIZE, 10) || 50 * 1024 * 1024, // 50MB
|
|
119
|
+
fileCleanupInterval: parseInt(process.env.FILE_CLEANUP_INTERVAL, 10) || 600000, // 10 minutes
|
|
120
|
+
|
|
121
|
+
// TOTP (Two-Factor Authentication) settings
|
|
122
|
+
totp: {
|
|
123
|
+
enabled: process.env.TOTP_ENABLED !== 'false', // Enable by default
|
|
124
|
+
issuer: process.env.TOTP_ISSUER || 'Claude Web Chat',
|
|
125
|
+
window: parseInt(process.env.TOTP_WINDOW, 10) || 1 // Allow 1 step before/after
|
|
126
|
+
},
|
|
127
|
+
|
|
128
|
+
// Azure AD (Microsoft Entra ID) SSO settings
|
|
129
|
+
aad: {
|
|
130
|
+
enabled: process.env.AAD_ENABLED === 'true',
|
|
131
|
+
clientId: process.env.AAD_CLIENT_ID || '',
|
|
132
|
+
tenantId: process.env.AAD_TENANT_ID || '',
|
|
133
|
+
autoCreateUser: process.env.AAD_AUTO_CREATE_USER !== 'false', // Auto-create local user on first AAD login
|
|
134
|
+
defaultRole: process.env.AAD_DEFAULT_ROLE || 'pro' // Default role for auto-created AAD users
|
|
135
|
+
},
|
|
136
|
+
|
|
137
|
+
// Multi-provider SSO settings (server-driven OAuth code flow)
|
|
138
|
+
// Each provider follows the same shape: { enabled, clientId/secret, callbackUrl, autoCreateUser, defaultRole }
|
|
139
|
+
// Microsoft re-uses the existing aad.* block above and is included here for the unified UI listing only.
|
|
140
|
+
sso: {
|
|
141
|
+
github: {
|
|
142
|
+
enabled: process.env.SSO_GITHUB_ENABLED === 'true',
|
|
143
|
+
clientId: process.env.SSO_GITHUB_CLIENT_ID || '',
|
|
144
|
+
clientSecret: process.env.SSO_GITHUB_CLIENT_SECRET || '',
|
|
145
|
+
callbackUrl: process.env.SSO_GITHUB_CALLBACK_URL || '',
|
|
146
|
+
autoCreateUser: process.env.SSO_GITHUB_AUTO_CREATE_USER !== 'false',
|
|
147
|
+
defaultRole: process.env.SSO_GITHUB_DEFAULT_ROLE || 'pro'
|
|
148
|
+
},
|
|
149
|
+
google: {
|
|
150
|
+
enabled: process.env.SSO_GOOGLE_ENABLED === 'true',
|
|
151
|
+
clientId: process.env.SSO_GOOGLE_CLIENT_ID || '',
|
|
152
|
+
clientSecret: process.env.SSO_GOOGLE_CLIENT_SECRET || '',
|
|
153
|
+
callbackUrl: process.env.SSO_GOOGLE_CALLBACK_URL || '',
|
|
154
|
+
autoCreateUser: process.env.SSO_GOOGLE_AUTO_CREATE_USER !== 'false',
|
|
155
|
+
defaultRole: process.env.SSO_GOOGLE_DEFAULT_ROLE || 'pro'
|
|
156
|
+
},
|
|
157
|
+
wechat: {
|
|
158
|
+
// PC web 扫码 (Open Platform 网页扫码) — open.weixin.qq.com/connect/qrconnect
|
|
159
|
+
enabled: process.env.SSO_WECHAT_ENABLED === 'true',
|
|
160
|
+
appId: process.env.SSO_WECHAT_APP_ID || '',
|
|
161
|
+
appSecret: process.env.SSO_WECHAT_APP_SECRET || '',
|
|
162
|
+
callbackUrl: process.env.SSO_WECHAT_CALLBACK_URL || '',
|
|
163
|
+
autoCreateUser: process.env.SSO_WECHAT_AUTO_CREATE_USER !== 'false',
|
|
164
|
+
defaultRole: process.env.SSO_WECHAT_DEFAULT_ROLE || 'pro'
|
|
165
|
+
},
|
|
166
|
+
alipay: {
|
|
167
|
+
// 支付宝网页授权 — oauth2/publicAppAuthorize.htm (RSA2 signing)
|
|
168
|
+
enabled: process.env.SSO_ALIPAY_ENABLED === 'true',
|
|
169
|
+
appId: process.env.SSO_ALIPAY_APP_ID || '',
|
|
170
|
+
privateKey: process.env.SSO_ALIPAY_PRIVATE_KEY || '',
|
|
171
|
+
alipayPublicKey: process.env.SSO_ALIPAY_PUBLIC_KEY || '',
|
|
172
|
+
callbackUrl: process.env.SSO_ALIPAY_CALLBACK_URL || '',
|
|
173
|
+
autoCreateUser: process.env.SSO_ALIPAY_AUTO_CREATE_USER !== 'false',
|
|
174
|
+
defaultRole: process.env.SSO_ALIPAY_DEFAULT_ROLE || 'pro'
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Check if email is configured
|
|
181
|
+
*/
|
|
182
|
+
export function isEmailConfigured() {
|
|
183
|
+
return !!(CONFIG.smtp.host && CONFIG.smtp.user && CONFIG.smtp.pass);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Get user by username — queries database first, falls back to CONFIG.users
|
|
188
|
+
* @param {string} username
|
|
189
|
+
* @returns {object|null} Normalized user object with { username, passwordHash, email, totpSecret, totpEnabled, role, id }
|
|
190
|
+
*/
|
|
191
|
+
export function getUserByUsername(username) {
|
|
192
|
+
// Query database first (includes migrated, registered, and SSO-only users).
|
|
193
|
+
const dbUser = userDb.getByUsername(username);
|
|
194
|
+
if (dbUser) {
|
|
195
|
+
return {
|
|
196
|
+
username: dbUser.username,
|
|
197
|
+
passwordHash: dbUser.password_hash || null,
|
|
198
|
+
email: dbUser.email,
|
|
199
|
+
totpSecret: dbUser.totp_secret,
|
|
200
|
+
totpEnabled: !!dbUser.totp_enabled,
|
|
201
|
+
role: dbUser.role === 'admin' ? 'admin' : 'pro',
|
|
202
|
+
id: dbUser.id
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
// Fallback to CONFIG.users (only relevant before first migration)
|
|
206
|
+
return CONFIG.users.find(u => u.username === username) || null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Update user TOTP settings in SQLite database
|
|
211
|
+
* @param {string} username
|
|
212
|
+
* @param {{totpSecret: string, totpEnabled: boolean}} data
|
|
213
|
+
* @returns {Promise<boolean>}
|
|
214
|
+
*/
|
|
215
|
+
export async function updateUserTotp(username, data) {
|
|
216
|
+
try {
|
|
217
|
+
userDb.updateTotp(username, data.totpSecret, data.totpEnabled);
|
|
218
|
+
|
|
219
|
+
// Update in-memory user data (CONFIG.users) for backward compat
|
|
220
|
+
const user = CONFIG.users.find(u => u.username === username);
|
|
221
|
+
if (user) {
|
|
222
|
+
user.totpSecret = data.totpSecret;
|
|
223
|
+
user.totpEnabled = data.totpEnabled;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
console.log(`TOTP settings saved to database for user: ${username}`);
|
|
227
|
+
return true;
|
|
228
|
+
} catch (err) {
|
|
229
|
+
console.error('Failed to save TOTP settings:', err.message);
|
|
230
|
+
return false;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Check if TOTP is globally enabled
|
|
236
|
+
* @returns {boolean}
|
|
237
|
+
*/
|
|
238
|
+
export function isTotpEnabled() {
|
|
239
|
+
return CONFIG.totp?.enabled !== false;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Validate that required secrets are configured in production mode
|
|
244
|
+
* Throws an error if default secrets are used in production
|
|
245
|
+
*/
|
|
246
|
+
export function validateProductionConfig() {
|
|
247
|
+
if (CONFIG.skipAuth) {
|
|
248
|
+
// Development mode - skip validation
|
|
249
|
+
return { valid: true };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const errors = [];
|
|
253
|
+
const warnings = [];
|
|
254
|
+
|
|
255
|
+
// Check JWT_SECRET
|
|
256
|
+
if (CONFIG.jwtSecret === DEFAULT_JWT_SECRET) {
|
|
257
|
+
errors.push('JWT_SECRET must be set to a secure value in production mode');
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Check that at least one user with a password exists (in DB or config)
|
|
261
|
+
// Only warn (don't block startup) — allows first-time setup via create-user.js
|
|
262
|
+
const dbUsers = userDb.getAll();
|
|
263
|
+
const hasUserWithPassword = dbUsers.some(u => u.password_hash) || CONFIG.users.length > 0;
|
|
264
|
+
if (!hasUserWithPassword) {
|
|
265
|
+
warnings.push('No users configured. Create one with: node server/create-user.js <username> <password> [email]');
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (errors.length > 0) {
|
|
269
|
+
return { valid: false, errors };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return { valid: true, warnings: warnings.length > 0 ? warnings : undefined };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Check if Azure AD SSO is configured
|
|
277
|
+
* @returns {boolean}
|
|
278
|
+
*/
|
|
279
|
+
export function isAadEnabled() {
|
|
280
|
+
return CONFIG.aad?.enabled && !!CONFIG.aad.clientId && !!CONFIG.aad.tenantId;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Return the set of enabled SSO providers (excluding Microsoft, which is exposed via aadEnabled).
|
|
285
|
+
* Used by /api/auth/mode to drive the LoginPage button list.
|
|
286
|
+
* @returns {{ github: boolean, google: boolean, wechat: boolean, alipay: boolean }}
|
|
287
|
+
*/
|
|
288
|
+
export function getEnabledSsoProviders() {
|
|
289
|
+
const sso = CONFIG.sso || {};
|
|
290
|
+
return {
|
|
291
|
+
github: !!(sso.github?.enabled && sso.github.clientId && sso.github.clientSecret && sso.github.callbackUrl),
|
|
292
|
+
google: !!(sso.google?.enabled && sso.google.clientId && sso.google.clientSecret && sso.google.callbackUrl),
|
|
293
|
+
wechat: !!(sso.wechat?.enabled && sso.wechat.appId && sso.wechat.appSecret && sso.wechat.callbackUrl),
|
|
294
|
+
alipay: !!(sso.alipay?.enabled && sso.alipay.appId && sso.alipay.privateKey && sso.alipay.callbackUrl)
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export default CONFIG;
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { WebSocket } from 'ws';
|
|
2
|
+
|
|
3
|
+
// 存储所有连接的 agents
|
|
4
|
+
// agentId -> { ws, name, workDir, conversations: Map<convId, {workDir, claudeSessionId}>, sessionKey, isAlive, capabilities }
|
|
5
|
+
export const agents = new Map();
|
|
6
|
+
|
|
7
|
+
// 存储所有 web 客户端
|
|
8
|
+
// clientId -> { ws, authenticated, currentAgent, currentConversation, sessionKey, isAlive }
|
|
9
|
+
export const webClients = new Map();
|
|
10
|
+
|
|
11
|
+
// 临时文件存储: fileId -> { name, mimeType, buffer, uploadedAt, userId }
|
|
12
|
+
export const pendingFiles = new Map();
|
|
13
|
+
|
|
14
|
+
// Port proxy
|
|
15
|
+
export const pendingProxyRequests = new Map(); // requestId → { res, timeout, streaming }
|
|
16
|
+
export const proxyWsConnections = new Map(); // proxyWsId → { browserWs, agentId }
|
|
17
|
+
|
|
18
|
+
// Store pending agent connections (waiting for auth message)
|
|
19
|
+
// tempId -> { ws, agentId, agentName, workDir, timeout }
|
|
20
|
+
export const pendingAgentConnections = new Map();
|
|
21
|
+
|
|
22
|
+
// ★ Phase 3: Server-side message queues
|
|
23
|
+
// conversationId → [{id, prompt, workDir, userId, clientId, queuedAt, files}]
|
|
24
|
+
export const serverMessageQueues = new Map();
|
|
25
|
+
|
|
26
|
+
// ★ Phase 4: Directory listing cache
|
|
27
|
+
// key: `${agentId}:${normalizedDirPath}` → { entries, timestamp }
|
|
28
|
+
export const directoryCache = new Map();
|
|
29
|
+
export const DIR_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
|
30
|
+
export const DIR_CACHE_MAX_SIZE = 500;
|
|
31
|
+
|
|
32
|
+
// ★ Phase 5: File Tab state storage
|
|
33
|
+
// key: `${userId}:${agentId}` → { files: [{path}], activeIndex, timestamp }
|
|
34
|
+
export const userFileTabs = new Map();
|
|
35
|
+
|
|
36
|
+
// Preview file cache for binary file preview (Office/PDF/Image)
|
|
37
|
+
// fileId → { buffer, mimeType, filename, createdAt, token }
|
|
38
|
+
export const previewFiles = new Map();
|
|
39
|
+
|
|
40
|
+
// Admin dashboard usage stats.
|
|
41
|
+
// userId → { requests, bytesSent, bytesReceived, messages, sessions }
|
|
42
|
+
// `messages` is user turn count. bytesSent/bytesReceived are message traffic
|
|
43
|
+
// only; heartbeat/control frames are deliberately excluded.
|
|
44
|
+
export const userStatsDeltas = new Map();
|
|
45
|
+
|
|
46
|
+
const HEARTBEAT_MESSAGE_TYPES = new Set([
|
|
47
|
+
'ping',
|
|
48
|
+
'pong',
|
|
49
|
+
'ping_session',
|
|
50
|
+
'pong_session',
|
|
51
|
+
'client_hello'
|
|
52
|
+
]);
|
|
53
|
+
|
|
54
|
+
const OUTBOUND_MESSAGE_TRAFFIC_TYPES = new Set([
|
|
55
|
+
'claude_output',
|
|
56
|
+
'yeaft_output',
|
|
57
|
+
'btw_stream',
|
|
58
|
+
'btw_done',
|
|
59
|
+
'btw_error',
|
|
60
|
+
'context_usage',
|
|
61
|
+
'ask_user_question'
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Get or initialize a stats delta entry for a user.
|
|
66
|
+
*/
|
|
67
|
+
function getOrCreateDelta(userId) {
|
|
68
|
+
let delta = userStatsDeltas.get(userId);
|
|
69
|
+
if (!delta) {
|
|
70
|
+
delta = {
|
|
71
|
+
requests: 0,
|
|
72
|
+
bytesSent: 0,
|
|
73
|
+
bytesReceived: 0,
|
|
74
|
+
messages: 0,
|
|
75
|
+
sessions: 0,
|
|
76
|
+
};
|
|
77
|
+
userStatsDeltas.set(userId, delta);
|
|
78
|
+
}
|
|
79
|
+
return delta;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function isHeartbeatMessageType(type) {
|
|
83
|
+
return HEARTBEAT_MESSAGE_TYPES.has(type);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function isOutboundMessageTraffic(type) {
|
|
87
|
+
return OUTBOUND_MESSAGE_TRAFFIC_TYPES.has(type);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Record a non-heartbeat WS request received from a user. This is kept for
|
|
92
|
+
* operator diagnostics; dashboard traffic comes from user-turn/message bytes.
|
|
93
|
+
*/
|
|
94
|
+
export function trackRequest(userId, bytesReceived, messageType = '') {
|
|
95
|
+
if (!userId || isHeartbeatMessageType(messageType)) return;
|
|
96
|
+
const delta = getOrCreateDelta(userId);
|
|
97
|
+
delta.requests++;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Record outbound message bytes sent to a user via WS.
|
|
102
|
+
*/
|
|
103
|
+
export function trackMessageBytesSent(userId, bytesSent, messageType = '') {
|
|
104
|
+
if (!userId || !bytesSent || !isOutboundMessageTraffic(messageType)) return;
|
|
105
|
+
const delta = getOrCreateDelta(userId);
|
|
106
|
+
delta.bytesSent += bytesSent;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Backward-compatible alias. Only message output frames are counted.
|
|
111
|
+
*/
|
|
112
|
+
export function trackBytesSent(userId, bytesSent, messageType = '') {
|
|
113
|
+
trackMessageBytesSent(userId, bytesSent, messageType);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Record a user turn and the inbound message payload bytes for it.
|
|
118
|
+
*/
|
|
119
|
+
export function trackUserTurn(userId, bytesReceived = 0) {
|
|
120
|
+
if (!userId) return;
|
|
121
|
+
const delta = getOrCreateDelta(userId);
|
|
122
|
+
delta.messages++;
|
|
123
|
+
delta.bytesReceived += Math.max(0, Number(bytesReceived) || 0);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Legacy name: a tracked "message" is now a user turn.
|
|
128
|
+
*/
|
|
129
|
+
export function trackMessage(userId, bytesReceived = 0) {
|
|
130
|
+
trackUserTurn(userId, bytesReceived);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Record a new session created by a user.
|
|
135
|
+
*/
|
|
136
|
+
export function trackSession(userId) {
|
|
137
|
+
if (!userId) return;
|
|
138
|
+
const delta = getOrCreateDelta(userId);
|
|
139
|
+
delta.sessions++;
|
|
140
|
+
}
|