@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.
Files changed (121) hide show
  1. package/cli.js +12 -0
  2. package/index.js +10 -2
  3. package/local-run.js +218 -0
  4. package/local-runtime/server/.env.example +54 -0
  5. package/local-runtime/server/api.js +111 -0
  6. package/local-runtime/server/auth/aad.js +235 -0
  7. package/local-runtime/server/auth/login.js +156 -0
  8. package/local-runtime/server/auth/oauth-flow.js +277 -0
  9. package/local-runtime/server/auth/password-reset.js +134 -0
  10. package/local-runtime/server/auth/providers/alipay.js +125 -0
  11. package/local-runtime/server/auth/providers/github.js +82 -0
  12. package/local-runtime/server/auth/providers/google.js +60 -0
  13. package/local-runtime/server/auth/providers/microsoft.js +71 -0
  14. package/local-runtime/server/auth/providers/types.js +57 -0
  15. package/local-runtime/server/auth/providers/wechat.js +68 -0
  16. package/local-runtime/server/auth/register.js +91 -0
  17. package/local-runtime/server/auth/session-store.js +57 -0
  18. package/local-runtime/server/auth/token.js +85 -0
  19. package/local-runtime/server/auth/totp-auth.js +133 -0
  20. package/local-runtime/server/auth/utils.js +42 -0
  21. package/local-runtime/server/auth.js +8 -0
  22. package/local-runtime/server/check-node-version.js +74 -0
  23. package/local-runtime/server/config.js +298 -0
  24. package/local-runtime/server/context.js +140 -0
  25. package/local-runtime/server/create-user.js +59 -0
  26. package/local-runtime/server/database.js +12 -0
  27. package/local-runtime/server/db/connection.js +963 -0
  28. package/local-runtime/server/db/expert-db.js +171 -0
  29. package/local-runtime/server/db/identity-db.js +92 -0
  30. package/local-runtime/server/db/invitation-db.js +38 -0
  31. package/local-runtime/server/db/message-db.js +257 -0
  32. package/local-runtime/server/db/session-db.js +118 -0
  33. package/local-runtime/server/db/user-db.js +165 -0
  34. package/local-runtime/server/db/user-stats-db.js +185 -0
  35. package/local-runtime/server/db/yeaft-session-db.js +258 -0
  36. package/local-runtime/server/email.js +96 -0
  37. package/local-runtime/server/encryption.js +105 -0
  38. package/local-runtime/server/handlers/agent-conversation.js +347 -0
  39. package/local-runtime/server/handlers/agent-file-terminal.js +99 -0
  40. package/local-runtime/server/handlers/agent-output.js +854 -0
  41. package/local-runtime/server/handlers/agent-sync.js +399 -0
  42. package/local-runtime/server/handlers/agent-work-center.js +27 -0
  43. package/local-runtime/server/handlers/client-conversation.js +1182 -0
  44. package/local-runtime/server/handlers/client-misc.js +254 -0
  45. package/local-runtime/server/handlers/client-work-center.js +269 -0
  46. package/local-runtime/server/handlers/client-workbench.js +146 -0
  47. package/local-runtime/server/handlers/session-pin-router.js +61 -0
  48. package/local-runtime/server/heartbeat-policy.js +46 -0
  49. package/local-runtime/server/index.js +275 -0
  50. package/local-runtime/server/package.json +55 -0
  51. package/local-runtime/server/perf-trace.js +154 -0
  52. package/local-runtime/server/proxy.js +273 -0
  53. package/local-runtime/server/routes/admin-routes.js +207 -0
  54. package/local-runtime/server/routes/auth-routes.js +322 -0
  55. package/local-runtime/server/routes/expert-routes.js +117 -0
  56. package/local-runtime/server/routes/invitation-routes.js +60 -0
  57. package/local-runtime/server/routes/session-routes.js +112 -0
  58. package/local-runtime/server/routes/upload-routes.js +109 -0
  59. package/local-runtime/server/routes/user-routes.js +241 -0
  60. package/local-runtime/server/totp.js +74 -0
  61. package/local-runtime/server/work-item-attachment-policy.js +56 -0
  62. package/local-runtime/server/ws-agent.js +319 -0
  63. package/local-runtime/server/ws-client.js +214 -0
  64. package/local-runtime/server/ws-utils.js +394 -0
  65. package/local-runtime/server/yeaft-asset-store.js +339 -0
  66. package/local-runtime/version.json +1 -0
  67. package/local-runtime/web/app.bundle.js +7673 -0
  68. package/local-runtime/web/app.bundle.js.gz +0 -0
  69. package/local-runtime/web/assets/avatars/README.md +34 -0
  70. package/local-runtime/web/assets/avatars/ada.svg +1 -0
  71. package/local-runtime/web/assets/avatars/alan.svg +1 -0
  72. package/local-runtime/web/assets/avatars/alice.svg +1 -0
  73. package/local-runtime/web/assets/avatars/anders.svg +1 -0
  74. package/local-runtime/web/assets/avatars/bezos.svg +1 -0
  75. package/local-runtime/web/assets/avatars/borges.svg +1 -0
  76. package/local-runtime/web/assets/avatars/buffett.svg +1 -0
  77. package/local-runtime/web/assets/avatars/clausewitz.svg +1 -0
  78. package/local-runtime/web/assets/avatars/dalio.svg +1 -0
  79. package/local-runtime/web/assets/avatars/dieter.svg +1 -0
  80. package/local-runtime/web/assets/avatars/drucker.svg +1 -0
  81. package/local-runtime/web/assets/avatars/einstein.svg +1 -0
  82. package/local-runtime/web/assets/avatars/grace.svg +1 -0
  83. package/local-runtime/web/assets/avatars/harari.svg +1 -0
  84. package/local-runtime/web/assets/avatars/jung.svg +1 -0
  85. package/local-runtime/web/assets/avatars/kahneman.svg +1 -0
  86. package/local-runtime/web/assets/avatars/ken.svg +1 -0
  87. package/local-runtime/web/assets/avatars/kongzi.svg +1 -0
  88. package/local-runtime/web/assets/avatars/kubrick.svg +1 -0
  89. package/local-runtime/web/assets/avatars/linus.svg +1 -0
  90. package/local-runtime/web/assets/avatars/luxun.svg +1 -0
  91. package/local-runtime/web/assets/avatars/margaret.svg +1 -0
  92. package/local-runtime/web/assets/avatars/martin.svg +1 -0
  93. package/local-runtime/web/assets/avatars/miyazaki.svg +1 -0
  94. package/local-runtime/web/assets/avatars/munger.svg +1 -0
  95. package/local-runtime/web/assets/avatars/nietzsche.svg +1 -0
  96. package/local-runtime/web/assets/avatars/norman.svg +1 -0
  97. package/local-runtime/web/assets/avatars/shannon.svg +1 -0
  98. package/local-runtime/web/assets/avatars/simaqian.svg +1 -0
  99. package/local-runtime/web/assets/avatars/socrates.svg +1 -0
  100. package/local-runtime/web/assets/avatars/steve.svg +1 -0
  101. package/local-runtime/web/assets/avatars/sudongpo.svg +1 -0
  102. package/local-runtime/web/assets/avatars/sunzi.svg +1 -0
  103. package/local-runtime/web/docx-preview.min.js +2 -0
  104. package/local-runtime/web/docx-preview.min.js.gz +0 -0
  105. package/local-runtime/web/html-to-image.min.js +2 -0
  106. package/local-runtime/web/html-to-image.min.js.gz +0 -0
  107. package/local-runtime/web/index.html +21 -0
  108. package/local-runtime/web/jszip.min.js +13 -0
  109. package/local-runtime/web/jszip.min.js.gz +0 -0
  110. package/local-runtime/web/mermaid.min.js +2843 -0
  111. package/local-runtime/web/mermaid.min.js.gz +0 -0
  112. package/local-runtime/web/msal-browser.min.js +69 -0
  113. package/local-runtime/web/msal-browser.min.js.gz +0 -0
  114. package/local-runtime/web/style.bundle.css +10 -0
  115. package/local-runtime/web/style.bundle.css.gz +0 -0
  116. package/local-runtime/web/vendor.bundle.js +1522 -0
  117. package/local-runtime/web/vendor.bundle.js.gz +0 -0
  118. package/local-runtime/web/xlsx.min.js +24 -0
  119. package/local-runtime/web/xlsx.min.js.gz +0 -0
  120. package/package.json +13 -3
  121. package/scripts/prepare-local-runtime.js +25 -0
package/cli.js CHANGED
@@ -49,6 +49,14 @@ if (command === 'doctor') {
49
49
  await handleDoctorCommand();
50
50
  } else if (command === 'llm') {
51
51
  await handleLlmCommand(subArgs);
52
+ } else if (command === 'local') {
53
+ try {
54
+ const { runLocal } = await import('./local-run.js');
55
+ await runLocal(subArgs);
56
+ } catch (error) {
57
+ console.error(`Local run failed: ${error.message}`);
58
+ process.exit(1);
59
+ }
52
60
  } else if (command === 'upgrade') {
53
61
  upgrade();
54
62
  } else if (command === '--version' || command === '-v') {
@@ -68,6 +76,7 @@ function printHelp() {
68
76
 
69
77
  Usage:
70
78
  yeaft-agent [options] Run agent in foreground
79
+ yeaft-agent local --name <name> Run local Web UI, server, and agent
71
80
  yeaft-agent install [options] Install as system service
72
81
  yeaft-agent uninstall [options] Remove system service
73
82
  yeaft-agent start [options] Start installed service
@@ -84,6 +93,7 @@ function printHelp() {
84
93
  --instance <id> Deprecated alias for the local service instance id
85
94
  --server <url> WebSocket server URL (default: ws://localhost:3456)
86
95
  --name <name> Agent name and instance id (letters, numbers, ._-)
96
+ --port <port> Local server port (local command only; default: 6868)
87
97
  --secret <secret> Agent secret for authentication
88
98
  --work-dir <dir> Default working directory (default: cwd)
89
99
  --yeaft-dir <dir> Yeaft data directory for this instance
@@ -98,6 +108,8 @@ function printHelp() {
98
108
  YEAFT_DIR Yeaft data directory
99
109
 
100
110
  Examples:
111
+ yeaft-agent local --name my-worker
112
+ yeaft-agent local --name my-worker --port 7000
101
113
  yeaft-agent --server wss://your-server.com --name my-worker --secret xxx
102
114
  yeaft-agent install --server wss://your-server.com --name my-worker --secret xxx
103
115
  yeaft-agent install --server wss://your-server.com --name my-worker-2 --secret xxx
package/index.js CHANGED
@@ -25,6 +25,7 @@ ctx.pkgName = pkg.name;
25
25
 
26
26
  // 配置文件路径(向后兼容:先查当前目录 .claude-agent.json)
27
27
  const LOCAL_CONFIG_FILE = join(process.cwd(), '.claude-agent.json');
28
+ const IS_LOCAL_RUN = process.env.YEAFT_LOCAL_RUN === 'true';
28
29
 
29
30
  // 加载或创建配置
30
31
  function loadConfig() {
@@ -36,6 +37,10 @@ function loadConfig() {
36
37
  agentSecret: 'agent-shared-secret'
37
38
  };
38
39
 
40
+ // Local run receives its complete connection identity from the launcher and
41
+ // must not inherit a remote agent's persisted configuration.
42
+ if (IS_LOCAL_RUN) return defaults;
43
+
39
44
  // Priority 1: Local .claude-agent.json (backward compat)
40
45
  if (existsSync(LOCAL_CONFIG_FILE)) {
41
46
  try {
@@ -57,6 +62,7 @@ function loadConfig() {
57
62
  }
58
63
 
59
64
  function saveConfig(config) {
65
+ if (IS_LOCAL_RUN) return;
60
66
  writeFileSync(LOCAL_CONFIG_FILE, JSON.stringify(config, null, 2));
61
67
  }
62
68
 
@@ -336,8 +342,10 @@ process.on('SIGTERM', async () => {
336
342
 
337
343
  // 启动 - 先确保依赖,再检测能力,预热 models.dev 缓存,再连接
338
344
  (async () => {
339
- await ensureDependencies();
340
- await ensureYeaftSkills();
345
+ if (process.env.YEAFT_SKIP_STARTUP_INSTALLS !== 'true') {
346
+ await ensureDependencies();
347
+ await ensureYeaftSkills();
348
+ }
341
349
  ctx.agentCapabilities = await detectCapabilities();
342
350
  // Prime the models.dev community catalog so the Yeaft engine's *synchronous*
343
351
  // hot path (engine.js / config.js / cli.js all read context-window inline)
package/local-run.js ADDED
@@ -0,0 +1,218 @@
1
+ import { spawn } from 'child_process';
2
+ import { existsSync } from 'fs';
3
+ import { homedir } from 'os';
4
+ import { createServer } from 'net';
5
+ import { dirname, join } from 'path';
6
+ import { fileURLToPath } from 'url';
7
+ import { WebSocket } from 'ws';
8
+
9
+ const DEFAULT_PORT = 6868;
10
+ const LOCAL_HOST = '127.0.0.1';
11
+
12
+ export function parseLocalArgs(args) {
13
+ const options = { name: '', port: DEFAULT_PORT };
14
+ for (let i = 0; i < args.length; i++) {
15
+ const arg = args[i];
16
+ const value = args[i + 1];
17
+ if (arg === '--name') {
18
+ if (!value || value.startsWith('-')) throw new Error('--name requires a value');
19
+ options.name = value;
20
+ i++;
21
+ } else if (arg === '--port') {
22
+ if (!value || value.startsWith('-')) throw new Error('--port requires a value');
23
+ if (!/^\d+$/.test(value)) throw new Error(`Invalid port: ${value}`);
24
+ options.port = Number(value);
25
+ if (options.port < 1 || options.port > 65535) throw new Error(`Invalid port: ${value}`);
26
+ i++;
27
+ } else {
28
+ throw new Error(`Unknown local option: ${arg}`);
29
+ }
30
+ }
31
+ if (!options.name) throw new Error('local requires --name <name>');
32
+ if (!/^[A-Za-z0-9._-]+$/.test(options.name)) {
33
+ throw new Error('Invalid name: use only letters, numbers, dot, underscore, or hyphen');
34
+ }
35
+ return options;
36
+ }
37
+
38
+ function runtimePaths() {
39
+ const agentDir = dirname(fileURLToPath(import.meta.url));
40
+ const packaged = join(agentDir, 'local-runtime');
41
+ if (existsSync(packaged)) {
42
+ return {
43
+ serverEntry: join(packaged, 'server', 'index.js'),
44
+ webDir: join(packaged, 'web'),
45
+ };
46
+ }
47
+ return {
48
+ serverEntry: join(agentDir, '..', 'server', 'index.js'),
49
+ webDir: join(agentDir, '..', 'web'),
50
+ };
51
+ }
52
+
53
+ async function assertPortAvailable(port) {
54
+ const probe = createServer();
55
+ await new Promise((resolve, reject) => {
56
+ probe.once('error', reject);
57
+ probe.listen(port, LOCAL_HOST, resolve);
58
+ }).catch(error => {
59
+ if (error.code === 'EADDRINUSE') {
60
+ throw new Error(`Port ${port} is already in use on ${LOCAL_HOST}`);
61
+ }
62
+ throw error;
63
+ });
64
+ await new Promise((resolve, reject) => probe.close(error => error ? reject(error) : resolve()));
65
+ }
66
+
67
+ async function waitForServer(url, server, timeoutMs = 15000) {
68
+ const deadline = Date.now() + timeoutMs;
69
+ while (Date.now() < deadline) {
70
+ if (server.exitCode !== null) throw new Error(`Local server exited with code ${server.exitCode}`);
71
+ try {
72
+ const response = await fetch(`${url}/api/auth/mode`);
73
+ if (response.ok) {
74
+ const mode = await response.json();
75
+ if (!mode.skipAuth) throw new Error('Local server did not start in no-auth mode');
76
+ return;
77
+ }
78
+ } catch (error) {
79
+ if (error.message === 'Local server did not start in no-auth mode') throw error;
80
+ // Server is still starting.
81
+ }
82
+ await new Promise(resolve => setTimeout(resolve, 100));
83
+ }
84
+ throw new Error(`Local server did not become ready at ${url}`);
85
+ }
86
+
87
+ async function waitForAgent(wsUrl, name, agent, timeoutMs = 15000) {
88
+ const deadline = Date.now() + timeoutMs;
89
+ while (Date.now() < deadline) {
90
+ if (agent.exitCode !== null) throw new Error(`Local agent exited with code ${agent.exitCode}`);
91
+ try {
92
+ const agents = await readAgentList(wsUrl);
93
+ if (agents.some(item => item.name === name || item.agentName === name)) return;
94
+ } catch {
95
+ // Agent is still connecting.
96
+ }
97
+ await new Promise(resolve => setTimeout(resolve, 100));
98
+ }
99
+ throw new Error(`Local agent did not register as ${name}`);
100
+ }
101
+
102
+ function readAgentList(wsUrl) {
103
+ return new Promise((resolve, reject) => {
104
+ const ws = new WebSocket(`${wsUrl}?type=web`);
105
+ const timer = setTimeout(() => {
106
+ ws.terminate();
107
+ reject(new Error('Timed out waiting for agent list'));
108
+ }, 1000);
109
+ ws.on('open', () => ws.send(JSON.stringify({ type: 'client_hello', plaintextOk: true })));
110
+ ws.on('message', data => {
111
+ try {
112
+ const message = JSON.parse(data.toString());
113
+ if (message.type !== 'agent_list') return;
114
+ clearTimeout(timer);
115
+ ws.close();
116
+ resolve(message.agents || []);
117
+ } catch (error) {
118
+ clearTimeout(timer);
119
+ ws.terminate();
120
+ reject(error);
121
+ }
122
+ });
123
+ ws.on('error', error => {
124
+ clearTimeout(timer);
125
+ ws.terminate();
126
+ reject(error);
127
+ });
128
+ });
129
+ }
130
+
131
+ export async function runLocal(args, options = {}) {
132
+ const config = parseLocalArgs(args);
133
+ const paths = options.paths || runtimePaths();
134
+ const dataDir = options.dataDir || join(homedir(), '.yeaft', 'server');
135
+ const url = `http://${LOCAL_HOST}:${config.port}`;
136
+ const children = new Set();
137
+ const signalHandlers = new Map();
138
+ let stopping = false;
139
+ let stopPromise = null;
140
+
141
+ const stop = (exitCode = 0) => {
142
+ if (stopPromise) return stopPromise;
143
+ stopping = true;
144
+ stopPromise = (async () => {
145
+ for (const [signal, handler] of signalHandlers) process.removeListener(signal, handler);
146
+ for (const child of children) {
147
+ if (child.exitCode === null && !child.killed) child.kill('SIGTERM');
148
+ }
149
+ await Promise.all([...children].map(child => new Promise(resolve => {
150
+ if (child.exitCode !== null) return resolve();
151
+ const timer = setTimeout(() => {
152
+ if (child.exitCode === null) child.kill('SIGKILL');
153
+ resolve();
154
+ }, 6000);
155
+ child.once('exit', () => { clearTimeout(timer); resolve(); });
156
+ })));
157
+ if (options.exit !== false) process.exit(exitCode);
158
+ })();
159
+ return stopPromise;
160
+ };
161
+
162
+ await assertPortAvailable(config.port);
163
+
164
+ signalHandlers.set('SIGINT', () => void stop(0));
165
+ signalHandlers.set('SIGTERM', () => void stop(0));
166
+ for (const [signal, handler] of signalHandlers) process.once(signal, handler);
167
+
168
+ const spawnChild = options.spawn || spawn;
169
+ try {
170
+ const server = spawnChild(process.execPath, [paths.serverEntry], {
171
+ stdio: 'inherit',
172
+ env: {
173
+ ...process.env,
174
+ PORT: String(config.port),
175
+ SERVER_HOST: LOCAL_HOST,
176
+ SERVER_DATA_DIR: dataDir,
177
+ PERF_TRACE_DIR: join(dataDir, 'perf-traces'),
178
+ SKIP_AUTH: 'true',
179
+ WEB_DIR: paths.webDir,
180
+ },
181
+ });
182
+ children.add(server);
183
+
184
+ await (options.waitForServer || waitForServer)(url, server);
185
+
186
+ const agentDir = dirname(fileURLToPath(import.meta.url));
187
+ const agent = spawnChild(process.execPath, [join(agentDir, 'index.js')], {
188
+ stdio: 'inherit',
189
+ env: {
190
+ ...process.env,
191
+ SERVER_URL: `ws://${LOCAL_HOST}:${config.port}`,
192
+ AGENT_NAME: config.name,
193
+ YEAFT_AGENT_INSTANCE: config.name,
194
+ AGENT_SECRET: '',
195
+ YEAFT_LOCAL_RUN: 'true',
196
+ YEAFT_SKIP_STARTUP_INSTALLS: 'true',
197
+ },
198
+ });
199
+ children.add(agent);
200
+
201
+ const fail = childName => code => {
202
+ if (!stopping) {
203
+ console.error(`${childName} exited unexpectedly with code ${code}`);
204
+ void stop(code || 1);
205
+ }
206
+ };
207
+ server.once('exit', fail('Local server'));
208
+ agent.once('exit', fail('Local agent'));
209
+
210
+ await waitForAgent(`ws://${LOCAL_HOST}:${config.port}`, config.name, agent);
211
+
212
+ console.log(`Yeaft local is available at ${url}`);
213
+ return { url, server, agent, stop };
214
+ } catch (error) {
215
+ await stop(1);
216
+ throw error;
217
+ }
218
+ }
@@ -0,0 +1,54 @@
1
+ # WebChat Server Configuration
2
+
3
+ # Server port
4
+ PORT=3456
5
+
6
+ # Skip authentication (development mode)
7
+ # Set to "true" to bypass login
8
+ SKIP_AUTH=false
9
+
10
+ # User configuration (two options)
11
+ #
12
+ # Option 1: Environment variable
13
+ # Format: username:bcryptHash:email:totpSecret:totpEnabled
14
+ # Multiple users separated by comma
15
+ # Generate hash with: node -e "require('bcrypt').hash('password', 10).then(console.log)"
16
+ # AUTH_USERS=admin:$2b$10$xxxxx:admin@example.com::false
17
+ #
18
+ # Option 2: users.json file (recommended)
19
+ # See users.example.json for format
20
+
21
+ # SMTP configuration for email verification
22
+ # If not configured, email verification will be skipped
23
+ SMTP_HOST=smtp.gmail.com
24
+ SMTP_PORT=587
25
+ SMTP_SECURE=false
26
+ SMTP_USER=your@gmail.com
27
+ SMTP_PASS=your-app-password
28
+ SMTP_FROM=WebChat <noreply@example.com>
29
+
30
+ # JWT configuration
31
+ JWT_SECRET=your-random-secret-key-change-this
32
+ JWT_EXPIRES_IN=3d
33
+ # Sliding renew threshold (ms). When a request's token has less than this much
34
+ # life left, the server issues a refreshed token via the X-New-Token response
35
+ # header. Default: 1 day.
36
+ JWT_RENEW_THRESHOLD_MS=86400000
37
+ TEMP_TOKEN_EXPIRES_IN=10m
38
+
39
+ # Email verification code settings
40
+ EMAIL_CODE_LENGTH=6
41
+ EMAIL_CODE_EXPIRES_IN=300000
42
+
43
+ # Agent authentication
44
+ # Agents must provide this secret to connect
45
+ AGENT_SECRET=agent-shared-secret
46
+
47
+ # File upload settings
48
+ MAX_FILE_SIZE=52428800
49
+ FILE_CLEANUP_INTERVAL=600000
50
+
51
+ # TOTP (Two-Factor Authentication) settings
52
+ TOTP_ENABLED=true
53
+ TOTP_ISSUER=Claude Web Chat
54
+ TOTP_WINDOW=1
@@ -0,0 +1,111 @@
1
+ import { readFileSync } from 'fs';
2
+ import { dirname, join } from 'path';
3
+ import { fileURLToPath } from 'url';
4
+ import { CONFIG } from './config.js';
5
+ import { verifyToken, maybeRenewToken } from './auth.js';
6
+ import { registerAuthRoutes } from './routes/auth-routes.js';
7
+ import { registerInvitationRoutes } from './routes/invitation-routes.js';
8
+ import { registerUserRoutes } from './routes/user-routes.js';
9
+ import { registerSessionRoutes } from './routes/session-routes.js';
10
+ import { registerUploadRoutes } from './routes/upload-routes.js';
11
+ import { registerAdminRoutes } from './routes/admin-routes.js';
12
+ import { registerExpertRoutes } from './routes/expert-routes.js';
13
+
14
+ // 登录速率限制: IP -> { attempts, resetAt }
15
+ const loginAttempts = new Map();
16
+ const LOGIN_MAX_ATTEMPTS = 30;
17
+ const LOGIN_WINDOW_MS = 5 * 60 * 1000; // 5 分钟窗口
18
+
19
+ function checkRateLimit(ip) {
20
+ const now = Date.now();
21
+ const record = loginAttempts.get(ip);
22
+ if (!record || now > record.resetAt) {
23
+ loginAttempts.set(ip, { attempts: 1, resetAt: now + LOGIN_WINDOW_MS });
24
+ return true;
25
+ }
26
+ record.attempts++;
27
+ return record.attempts <= LOGIN_MAX_ATTEMPTS;
28
+ }
29
+
30
+ // 定期清理过期的速率限制记录
31
+ setInterval(() => {
32
+ const now = Date.now();
33
+ for (const [ip, record] of loginAttempts) {
34
+ if (now > record.resetAt) loginAttempts.delete(ip);
35
+ }
36
+ }, 5 * 60 * 1000);
37
+
38
+ /**
39
+ * Middleware to verify JWT token for protected API routes
40
+ */
41
+ function requireAuth(req, res, next) {
42
+ if (CONFIG.skipAuth) {
43
+ req.user = { username: 'dev-user', role: 'admin' };
44
+ return next();
45
+ }
46
+
47
+ const authHeader = req.headers.authorization;
48
+ if (!authHeader || !authHeader.startsWith('Bearer ')) {
49
+ return res.status(401).json({ error: 'Authentication required' });
50
+ }
51
+
52
+ const token = authHeader.replace('Bearer ', '');
53
+ const result = verifyToken(token);
54
+
55
+ if (!result.valid) {
56
+ return res.status(401).json({ error: 'Invalid or expired token' });
57
+ }
58
+
59
+ // Sliding renewal: if the token is in the last `jwtRenewThresholdMs` of its
60
+ // life, mint a fresh one and surface it to the client via X-New-Token. The
61
+ // browser fetch wrapper picks this up and swaps localStorage transparently.
62
+ // Skip renewal for non-session tokens (temp/totp/totp-setup) — those have
63
+ // their own short-lived semantics and must not be promoted to full sessions.
64
+ if (!result.type) {
65
+ const fresh = maybeRenewToken(token, result.exp, result.username);
66
+ if (fresh) {
67
+ res.setHeader('X-New-Token', fresh);
68
+ }
69
+ }
70
+
71
+ req.user = { username: result.username, role: result.role === 'admin' ? 'admin' : 'pro' };
72
+ next();
73
+ }
74
+
75
+ /**
76
+ * Middleware to require admin role
77
+ */
78
+ function requireAdmin(req, res, next) {
79
+ if (CONFIG.skipAuth) return next();
80
+ if (req.user.role !== 'admin') {
81
+ return res.status(403).json({ error: 'Admin access required' });
82
+ }
83
+ next();
84
+ }
85
+
86
+ // Read version from version.json (injected at Docker build time)
87
+ const __apiDirname = dirname(fileURLToPath(import.meta.url));
88
+ let serverVersion = 'dev';
89
+ try {
90
+ const versionFile = JSON.parse(readFileSync(join(__apiDirname, '../version.json'), 'utf-8'));
91
+ serverVersion = versionFile.version || 'dev';
92
+ } catch {}
93
+
94
+ // Shared middleware/helpers passed to sub-route modules
95
+ const shared = { requireAuth, requireAdmin, checkRateLimit };
96
+
97
+ export function registerApiRoutes(app) {
98
+ // Version API
99
+ app.get('/api/version', (req, res) => {
100
+ res.json({ version: serverVersion });
101
+ });
102
+
103
+ // Delegate to sub-route modules
104
+ registerAuthRoutes(app, shared);
105
+ registerInvitationRoutes(app, shared);
106
+ registerUserRoutes(app, shared);
107
+ registerSessionRoutes(app, shared);
108
+ registerUploadRoutes(app, shared);
109
+ registerAdminRoutes(app, shared);
110
+ registerExpertRoutes(app, shared);
111
+ }
@@ -0,0 +1,235 @@
1
+ import jwt from 'jsonwebtoken';
2
+ import { CONFIG, isAadEnabled, getUserByUsername } from '../config.js';
3
+ import { generateSessionKey, encodeKey } from '../encryption.js';
4
+ import { userDb, identityDb } from '../database.js';
5
+ import { completeLogin } from './login.js';
6
+
7
+ /**
8
+ * Microsoft OIDC public keys cache
9
+ * Keys are fetched from Microsoft's JWKS endpoint and cached.
10
+ */
11
+ let _jwksCache = null;
12
+ let _jwksCacheExpiry = 0;
13
+ const JWKS_CACHE_TTL = 3600000; // 1 hour
14
+
15
+ /**
16
+ * Fetch Microsoft's OIDC public signing keys (JWKS)
17
+ * @returns {Promise<Array>} Array of JWK key objects
18
+ */
19
+ async function getSigningKeys() {
20
+ const now = Date.now();
21
+ if (_jwksCache && now < _jwksCacheExpiry) {
22
+ return _jwksCache;
23
+ }
24
+
25
+ const tenantId = CONFIG.aad.tenantId;
26
+ const jwksUrl = `https://login.microsoftonline.com/${tenantId}/discovery/v2.0/keys`;
27
+
28
+ const response = await fetch(jwksUrl);
29
+ if (!response.ok) {
30
+ throw new Error(`Failed to fetch JWKS: ${response.status}`);
31
+ }
32
+
33
+ const data = await response.json();
34
+ _jwksCache = data.keys;
35
+ _jwksCacheExpiry = now + JWKS_CACHE_TTL;
36
+ return _jwksCache;
37
+ }
38
+
39
+ /**
40
+ * Convert a JWK RSA key to PEM format for jwt.verify()
41
+ * @param {Object} jwk - JWK key object with n (modulus) and e (exponent)
42
+ * @returns {string} PEM-formatted public key
43
+ */
44
+ function jwkToPem(jwk) {
45
+ // Base64url decode
46
+ const base64UrlToBuffer = (str) => {
47
+ let base64 = str.replace(/-/g, '+').replace(/_/g, '/');
48
+ while (base64.length % 4 !== 0) base64 += '=';
49
+ return Buffer.from(base64, 'base64');
50
+ };
51
+
52
+ const n = base64UrlToBuffer(jwk.n);
53
+ const e = base64UrlToBuffer(jwk.e);
54
+
55
+ // DER encode RSA public key
56
+ const encodeDerLength = (length) => {
57
+ if (length < 0x80) return Buffer.from([length]);
58
+ if (length < 0x100) return Buffer.from([0x81, length]);
59
+ return Buffer.from([0x82, (length >> 8) & 0xff, length & 0xff]);
60
+ };
61
+
62
+ const encodeDerInteger = (buf) => {
63
+ // Prepend 0x00 if high bit set (to ensure positive integer)
64
+ const needsPad = buf[0] & 0x80;
65
+ const content = needsPad ? Buffer.concat([Buffer.from([0x00]), buf]) : buf;
66
+ return Buffer.concat([Buffer.from([0x02]), encodeDerLength(content.length), content]);
67
+ };
68
+
69
+ const nDer = encodeDerInteger(n);
70
+ const eDer = encodeDerInteger(e);
71
+
72
+ // SEQUENCE { n INTEGER, e INTEGER }
73
+ const rsaKeyBody = Buffer.concat([nDer, eDer]);
74
+ const rsaKeySeq = Buffer.concat([Buffer.from([0x30]), encodeDerLength(rsaKeyBody.length), rsaKeyBody]);
75
+
76
+ // BIT STRING wrapper
77
+ const bitString = Buffer.concat([Buffer.from([0x03]), encodeDerLength(rsaKeySeq.length + 1), Buffer.from([0x00]), rsaKeySeq]);
78
+
79
+ // AlgorithmIdentifier: OID 1.2.840.113549.1.1.1 (rsaEncryption) + NULL
80
+ const algorithmId = Buffer.from([
81
+ 0x30, 0x0d,
82
+ 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01,
83
+ 0x05, 0x00
84
+ ]);
85
+
86
+ // SubjectPublicKeyInfo SEQUENCE
87
+ const spkiBody = Buffer.concat([algorithmId, bitString]);
88
+ const spki = Buffer.concat([Buffer.from([0x30]), encodeDerLength(spkiBody.length), spkiBody]);
89
+
90
+ // PEM encode
91
+ const base64 = spki.toString('base64');
92
+ const lines = base64.match(/.{1,64}/g).join('\n');
93
+ return `-----BEGIN PUBLIC KEY-----\n${lines}\n-----END PUBLIC KEY-----`;
94
+ }
95
+
96
+ /**
97
+ * Verify a Microsoft id_token
98
+ * @param {string} idToken - The id_token from MSAL.js
99
+ * @returns {Promise<Object>} Decoded token payload with user info
100
+ */
101
+ async function verifyIdToken(idToken) {
102
+ // Decode header to get kid (key ID)
103
+ const headerB64 = idToken.split('.')[0];
104
+ const header = JSON.parse(Buffer.from(headerB64, 'base64url').toString());
105
+
106
+ if (!header.kid) {
107
+ throw new Error('Token missing kid header');
108
+ }
109
+
110
+ // Find matching signing key
111
+ const keys = await getSigningKeys();
112
+ const signingKey = keys.find(k => k.kid === header.kid);
113
+ if (!signingKey) {
114
+ // Invalidate cache and retry once
115
+ _jwksCache = null;
116
+ const freshKeys = await getSigningKeys();
117
+ const freshKey = freshKeys.find(k => k.kid === header.kid);
118
+ if (!freshKey) {
119
+ throw new Error('No matching signing key found');
120
+ }
121
+ return verifyWithKey(idToken, freshKey);
122
+ }
123
+
124
+ return verifyWithKey(idToken, signingKey);
125
+ }
126
+
127
+ /**
128
+ * Verify token with a specific JWK key
129
+ */
130
+ function verifyWithKey(idToken, jwk) {
131
+ const pem = jwkToPem(jwk);
132
+ const tenantId = CONFIG.aad.tenantId;
133
+
134
+ const decoded = jwt.verify(idToken, pem, {
135
+ algorithms: ['RS256'],
136
+ audience: CONFIG.aad.clientId,
137
+ issuer: `https://login.microsoftonline.com/${tenantId}/v2.0`
138
+ });
139
+
140
+ return decoded;
141
+ }
142
+
143
+ /**
144
+ * Handle AAD login: verify id_token, find or create user, return JWT
145
+ * @param {string} idToken - Microsoft id_token from frontend MSAL.js
146
+ * @returns {Promise<Object>} Login result with token, sessionKey, role
147
+ */
148
+ export async function loginWithAad(idToken) {
149
+ if (!isAadEnabled()) {
150
+ return { success: false, error: 'Azure AD login is not enabled' };
151
+ }
152
+
153
+ if (!idToken) {
154
+ return { success: false, error: 'id_token is required' };
155
+ }
156
+
157
+ let decoded;
158
+ try {
159
+ decoded = await verifyIdToken(idToken);
160
+ } catch (err) {
161
+ console.error('[AAD] Token verification failed:', err.message);
162
+ return { success: false, error: 'Invalid Microsoft token' };
163
+ }
164
+
165
+ // Extract user info from id_token
166
+ const aadOid = decoded.oid || decoded.sub; // Object ID (unique per user per tenant)
167
+ const email = decoded.preferred_username || decoded.email || decoded.upn;
168
+ const name = decoded.name || email?.split('@')[0] || 'aad-user';
169
+
170
+ if (!aadOid) {
171
+ return { success: false, error: 'Token missing user identifier (oid)' };
172
+ }
173
+
174
+ // 1. Try to find existing user by AAD OID
175
+ let user = userDb.getByAadOid(aadOid);
176
+
177
+ // 2. If not found by OID, try to match by email/username (link existing account)
178
+ if (!user && email) {
179
+ const existingByEmail = userDb.getByUsername(email.split('@')[0]);
180
+ if (existingByEmail && existingByEmail.email === email) {
181
+ // Link existing user to AAD
182
+ userDb.updateAadOid(existingByEmail.id, aadOid);
183
+ user = existingByEmail;
184
+ console.log(`[AAD] Linked existing user '${existingByEmail.username}' to AAD OID ${aadOid}`);
185
+ }
186
+ }
187
+
188
+ // 3. Auto-create user if configured
189
+ if (!user) {
190
+ if (!CONFIG.aad.autoCreateUser) {
191
+ return { success: false, error: 'No matching local account found. Contact your admin.' };
192
+ }
193
+
194
+ // Generate unique username from email or name
195
+ let username = email ? email.split('@')[0] : name;
196
+ // Sanitize username: only allow letters, numbers, hyphens, underscores
197
+ username = username.replace(/[^a-zA-Z0-9_-]/g, '_');
198
+
199
+ // Ensure uniqueness
200
+ let candidate = username;
201
+ let suffix = 1;
202
+ while (userDb.getByUsername(candidate)) {
203
+ candidate = `${username}_${suffix++}`;
204
+ }
205
+
206
+ const role = CONFIG.aad.defaultRole || 'pro';
207
+ user = userDb.createFromAad(candidate, email, aadOid, role);
208
+ console.log(`[AAD] Auto-created user '${candidate}' (email: ${email}, role: ${role})`);
209
+ }
210
+
211
+ // 4. Complete login (same as password login)
212
+ const sessionKey = generateSessionKey();
213
+ const role = user.role === 'admin' ? 'admin' : 'pro';
214
+
215
+ // Update last login
216
+ if (user.id) {
217
+ userDb.updateLogin(user.id);
218
+ }
219
+
220
+ // Mirror this AAD login into the unified user_identities table so the
221
+ // multi-provider binding view works for legacy AAD users too.
222
+ try {
223
+ identityDb.upsert({
224
+ userId: user.id,
225
+ provider: 'microsoft',
226
+ subject: aadOid,
227
+ email,
228
+ displayName: name
229
+ });
230
+ } catch (e) {
231
+ console.warn('[AAD] failed to upsert user_identities row:', e.message);
232
+ }
233
+
234
+ return completeLogin(user.username, sessionKey, role);
235
+ }