@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
@@ -0,0 +1,82 @@
1
+ /**
2
+ * GitHub OAuth2 provider — server-side code flow.
3
+ * Endpoint: https://github.com/login/oauth/authorize and /access_token
4
+ * Profile API: https://api.github.com/user (requires User-Agent header)
5
+ */
6
+ import { CONFIG } from '../../config.js';
7
+
8
+ export const name = 'github';
9
+
10
+ export function isEnabled() {
11
+ const c = CONFIG.sso?.github;
12
+ return !!(c?.enabled && c.clientId && c.clientSecret && c.callbackUrl);
13
+ }
14
+
15
+ export function getAuthorizeUrl(state /* , intent */) {
16
+ const c = CONFIG.sso.github;
17
+ const url = new URL('https://github.com/login/oauth/authorize');
18
+ url.searchParams.set('client_id', c.clientId);
19
+ url.searchParams.set('redirect_uri', c.callbackUrl);
20
+ url.searchParams.set('scope', 'read:user user:email');
21
+ url.searchParams.set('state', state);
22
+ url.searchParams.set('allow_signup', 'true');
23
+ return url.toString();
24
+ }
25
+
26
+ export async function exchangeCode(code /* , state */) {
27
+ const c = CONFIG.sso.github;
28
+ // Step 1: code -> access_token
29
+ const tokRes = await fetch('https://github.com/login/oauth/access_token', {
30
+ method: 'POST',
31
+ headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
32
+ body: JSON.stringify({
33
+ client_id: c.clientId,
34
+ client_secret: c.clientSecret,
35
+ code,
36
+ redirect_uri: c.callbackUrl
37
+ })
38
+ });
39
+ if (!tokRes.ok) throw new Error(`GitHub token exchange failed: ${tokRes.status}`);
40
+ const tok = await tokRes.json();
41
+ if (!tok.access_token) throw new Error(`GitHub token response missing access_token: ${tok.error || ''}`);
42
+
43
+ // Step 2: access_token -> profile
44
+ const userRes = await fetch('https://api.github.com/user', {
45
+ headers: {
46
+ 'Authorization': `Bearer ${tok.access_token}`,
47
+ 'Accept': 'application/vnd.github+json',
48
+ 'User-Agent': 'yeaft-webchat'
49
+ }
50
+ });
51
+ if (!userRes.ok) throw new Error(`GitHub /user failed: ${userRes.status}`);
52
+ const profile = await userRes.json();
53
+ if (!profile.id) throw new Error('GitHub /user missing id');
54
+
55
+ let email = profile.email;
56
+ if (!email) {
57
+ // Email may be private — fetch /user/emails to find primary verified.
58
+ try {
59
+ const emailRes = await fetch('https://api.github.com/user/emails', {
60
+ headers: {
61
+ 'Authorization': `Bearer ${tok.access_token}`,
62
+ 'Accept': 'application/vnd.github+json',
63
+ 'User-Agent': 'yeaft-webchat'
64
+ }
65
+ });
66
+ if (emailRes.ok) {
67
+ const emails = await emailRes.json();
68
+ const primary = Array.isArray(emails) ? emails.find(e => e.primary && e.verified) : null;
69
+ if (primary) email = primary.email;
70
+ }
71
+ } catch { /* ignore */ }
72
+ }
73
+
74
+ return {
75
+ subject: String(profile.id),
76
+ email: email || null,
77
+ displayName: profile.name || profile.login || null,
78
+ raw: profile
79
+ };
80
+ }
81
+
82
+ export default { name, isEnabled, getAuthorizeUrl, exchangeCode };
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Google OIDC provider — server-side code flow.
3
+ * Endpoint: https://accounts.google.com/o/oauth2/v2/auth
4
+ * Token endpoint: https://oauth2.googleapis.com/token
5
+ * The id_token contains a stable `sub` (Google account id).
6
+ */
7
+ import { CONFIG } from '../../config.js';
8
+
9
+ export const name = 'google';
10
+
11
+ export function isEnabled() {
12
+ const c = CONFIG.sso?.google;
13
+ return !!(c?.enabled && c.clientId && c.clientSecret && c.callbackUrl);
14
+ }
15
+
16
+ export function getAuthorizeUrl(state /* , intent */) {
17
+ const c = CONFIG.sso.google;
18
+ const url = new URL('https://accounts.google.com/o/oauth2/v2/auth');
19
+ url.searchParams.set('client_id', c.clientId);
20
+ url.searchParams.set('redirect_uri', c.callbackUrl);
21
+ url.searchParams.set('response_type', 'code');
22
+ url.searchParams.set('scope', 'openid email profile');
23
+ url.searchParams.set('state', state);
24
+ url.searchParams.set('access_type', 'online');
25
+ url.searchParams.set('prompt', 'select_account');
26
+ return url.toString();
27
+ }
28
+
29
+ export async function exchangeCode(code /* , state */) {
30
+ const c = CONFIG.sso.google;
31
+ const body = new URLSearchParams({
32
+ code,
33
+ client_id: c.clientId,
34
+ client_secret: c.clientSecret,
35
+ redirect_uri: c.callbackUrl,
36
+ grant_type: 'authorization_code'
37
+ });
38
+ const tokRes = await fetch('https://oauth2.googleapis.com/token', {
39
+ method: 'POST',
40
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
41
+ body
42
+ });
43
+ if (!tokRes.ok) throw new Error(`Google token exchange failed: ${tokRes.status}`);
44
+ const tok = await tokRes.json();
45
+ if (!tok.id_token) throw new Error('Google token response missing id_token');
46
+
47
+ // Decode id_token payload (TLS to googleapis.com guarantees authenticity for
48
+ // this confidential-client server-side code flow).
49
+ const payload = JSON.parse(Buffer.from(tok.id_token.split('.')[1], 'base64url').toString());
50
+ if (!payload.sub) throw new Error('Google id_token missing sub');
51
+
52
+ return {
53
+ subject: payload.sub,
54
+ email: payload.email || null,
55
+ displayName: payload.name || payload.email || null,
56
+ raw: payload
57
+ };
58
+ }
59
+
60
+ export default { name, isEnabled, getAuthorizeUrl, exchangeCode };
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Microsoft (Azure AD / Entra ID) provider — server-side OIDC code flow.
3
+ *
4
+ * Note: the existing `loginWithAad()` route (POST /api/auth/aad) using MSAL.js
5
+ * client-side popup is preserved for backwards compatibility. This file exposes
6
+ * the same provider under the new unified provider abstraction so binding +
7
+ * server-driven start/callback can reuse one code path.
8
+ */
9
+ import { CONFIG, isAadEnabled } from '../../config.js';
10
+
11
+ export const name = 'microsoft';
12
+
13
+ export function isEnabled() {
14
+ return isAadEnabled();
15
+ }
16
+
17
+ function authority() {
18
+ return `https://login.microsoftonline.com/${CONFIG.aad.tenantId}`;
19
+ }
20
+
21
+ function callbackUrl() {
22
+ return process.env.SSO_MICROSOFT_CALLBACK_URL || `${CONFIG.aad.redirectBase || ''}/api/auth/sso/microsoft/callback`;
23
+ }
24
+
25
+ export function getAuthorizeUrl(state /* , intent */) {
26
+ const url = new URL(`${authority()}/oauth2/v2.0/authorize`);
27
+ url.searchParams.set('client_id', CONFIG.aad.clientId);
28
+ url.searchParams.set('response_type', 'code');
29
+ url.searchParams.set('redirect_uri', callbackUrl());
30
+ url.searchParams.set('response_mode', 'query');
31
+ url.searchParams.set('scope', 'openid profile email');
32
+ url.searchParams.set('state', state);
33
+ return url.toString();
34
+ }
35
+
36
+ export async function exchangeCode(code /* , state */) {
37
+ const tokenUrl = `${authority()}/oauth2/v2.0/token`;
38
+ const body = new URLSearchParams({
39
+ client_id: CONFIG.aad.clientId,
40
+ client_secret: process.env.AAD_CLIENT_SECRET || '',
41
+ code,
42
+ redirect_uri: callbackUrl(),
43
+ grant_type: 'authorization_code'
44
+ });
45
+ const tokRes = await fetch(tokenUrl, {
46
+ method: 'POST',
47
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
48
+ body
49
+ });
50
+ if (!tokRes.ok) {
51
+ throw new Error(`Microsoft token exchange failed: ${tokRes.status}`);
52
+ }
53
+ const tok = await tokRes.json();
54
+ if (!tok.id_token) throw new Error('Microsoft token response missing id_token');
55
+
56
+ // Decode payload (without verification — verification happens in aad.js for the
57
+ // legacy MSAL.js flow; for server-driven code flow we trust the token from the
58
+ // authority endpoint we just contacted over HTTPS).
59
+ const payload = JSON.parse(Buffer.from(tok.id_token.split('.')[1], 'base64url').toString());
60
+ const subject = payload.oid || payload.sub;
61
+ if (!subject) throw new Error('Microsoft id_token missing oid/sub');
62
+
63
+ return {
64
+ subject,
65
+ email: payload.preferred_username || payload.email || payload.upn || null,
66
+ displayName: payload.name || null,
67
+ raw: payload
68
+ };
69
+ }
70
+
71
+ export default { name, isEnabled, getAuthorizeUrl, exchangeCode };
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Provider abstraction shape (multi-provider SSO).
3
+ *
4
+ * Every provider implementation must export an object with this shape:
5
+ *
6
+ * {
7
+ * name: 'github', // unique identifier
8
+ * isEnabled() => boolean, // gate routes + UI
9
+ * getAuthorizeUrl(state, intent) => string,
10
+ * exchangeCode(code, state) => Promise<{ subject, email, displayName, raw }>
11
+ * }
12
+ *
13
+ * `subject` MUST be a stable provider-side user id (oid/sub/unionid/user_id).
14
+ * `state` is opaque to the provider — generated/validated by oauth-flow.js.
15
+ */
16
+
17
+ import * as microsoft from './microsoft.js';
18
+ import * as github from './github.js';
19
+ import * as google from './google.js';
20
+ import * as wechat from './wechat.js';
21
+ import * as alipay from './alipay.js';
22
+
23
+ const REGISTRY = {
24
+ microsoft: microsoft.default || microsoft,
25
+ github: github.default || github,
26
+ google: google.default || google,
27
+ wechat: wechat.default || wechat,
28
+ alipay: alipay.default || alipay
29
+ };
30
+
31
+ /**
32
+ * Look up a provider implementation by name.
33
+ * Returns null if the name is unknown.
34
+ */
35
+ export function getProvider(name) {
36
+ return REGISTRY[name] || null;
37
+ }
38
+
39
+ /**
40
+ * List all known provider names (whether enabled or not).
41
+ */
42
+ export function listProviderNames() {
43
+ return Object.keys(REGISTRY);
44
+ }
45
+
46
+ /**
47
+ * List the names of all currently-enabled providers.
48
+ */
49
+ export function listEnabledProviders() {
50
+ return Object.entries(REGISTRY)
51
+ .filter(([, p]) => {
52
+ try { return p.isEnabled && p.isEnabled(); } catch { return false; }
53
+ })
54
+ .map(([name]) => name);
55
+ }
56
+
57
+ export default REGISTRY;
@@ -0,0 +1,68 @@
1
+ /**
2
+ * WeChat Open Platform — PC web 扫码 (qrconnect) flow.
3
+ *
4
+ * Authorize URL: https://open.weixin.qq.com/connect/qrconnect
5
+ * Token URL: https://api.weixin.qq.com/sns/oauth2/access_token
6
+ * UserInfo URL: https://api.weixin.qq.com/sns/userinfo
7
+ *
8
+ * `subject` is `unionid` if available (stable across an open-platform account
9
+ * suite), otherwise `openid`. Note: WeChat's `state` round-trips back as a
10
+ * regular query param, same as standard OAuth2.
11
+ */
12
+ import { CONFIG } from '../../config.js';
13
+
14
+ export const name = 'wechat';
15
+
16
+ export function isEnabled() {
17
+ const c = CONFIG.sso?.wechat;
18
+ return !!(c?.enabled && c.appId && c.appSecret && c.callbackUrl);
19
+ }
20
+
21
+ export function getAuthorizeUrl(state /* , intent */) {
22
+ const c = CONFIG.sso.wechat;
23
+ const url = new URL('https://open.weixin.qq.com/connect/qrconnect');
24
+ url.searchParams.set('appid', c.appId);
25
+ url.searchParams.set('redirect_uri', c.callbackUrl);
26
+ url.searchParams.set('response_type', 'code');
27
+ url.searchParams.set('scope', 'snsapi_login');
28
+ url.searchParams.set('state', state);
29
+ // WeChat requires the fragment '#wechat_redirect' literally appended.
30
+ return `${url.toString()}#wechat_redirect`;
31
+ }
32
+
33
+ export async function exchangeCode(code /* , state */) {
34
+ const c = CONFIG.sso.wechat;
35
+ const tokenUrl = new URL('https://api.weixin.qq.com/sns/oauth2/access_token');
36
+ tokenUrl.searchParams.set('appid', c.appId);
37
+ tokenUrl.searchParams.set('secret', c.appSecret);
38
+ tokenUrl.searchParams.set('code', code);
39
+ tokenUrl.searchParams.set('grant_type', 'authorization_code');
40
+
41
+ const tokRes = await fetch(tokenUrl.toString());
42
+ if (!tokRes.ok) throw new Error(`WeChat token exchange failed: ${tokRes.status}`);
43
+ const tok = await tokRes.json();
44
+ if (tok.errcode) throw new Error(`WeChat token error ${tok.errcode}: ${tok.errmsg || ''}`);
45
+ if (!tok.access_token || !tok.openid) throw new Error('WeChat token response missing access_token/openid');
46
+
47
+ // Step 2: pull profile (returns nickname, headimgurl, unionid if linked)
48
+ const userUrl = new URL('https://api.weixin.qq.com/sns/userinfo');
49
+ userUrl.searchParams.set('access_token', tok.access_token);
50
+ userUrl.searchParams.set('openid', tok.openid);
51
+ userUrl.searchParams.set('lang', 'zh_CN');
52
+ const userRes = await fetch(userUrl.toString());
53
+ if (!userRes.ok) throw new Error(`WeChat /userinfo failed: ${userRes.status}`);
54
+ const profile = await userRes.json();
55
+ if (profile.errcode) throw new Error(`WeChat /userinfo error ${profile.errcode}: ${profile.errmsg || ''}`);
56
+
57
+ const subject = profile.unionid || profile.openid || tok.unionid || tok.openid;
58
+ if (!subject) throw new Error('WeChat profile missing unionid/openid');
59
+
60
+ return {
61
+ subject,
62
+ email: null, // WeChat does not provide email
63
+ displayName: profile.nickname || null,
64
+ raw: profile
65
+ };
66
+ }
67
+
68
+ export default { name, isEnabled, getAuthorizeUrl, exchangeCode };
@@ -0,0 +1,91 @@
1
+ import bcrypt from 'bcrypt';
2
+ import { CONFIG } from '../config.js';
3
+ import { generateSessionKey } from '../encryption.js';
4
+ import { userDb, invitationDb } from '../database.js';
5
+
6
+ /**
7
+ * Verify agent connection using per-user agent_secret or global fallback
8
+ */
9
+ export function verifyAgent(secret) {
10
+ // 1. Try per-user agent secret
11
+ const user = userDb.getUserByAgentSecret(secret);
12
+ if (user) {
13
+ const sessionKey = generateSessionKey();
14
+ return {
15
+ valid: true,
16
+ sessionKey,
17
+ userId: user.id,
18
+ username: user.username
19
+ };
20
+ }
21
+
22
+ // 2. Fallback: global AGENT_SECRET (backward compat)
23
+ if (CONFIG.agentSecret && secret === CONFIG.agentSecret) {
24
+ const sessionKey = generateSessionKey();
25
+ return { valid: true, sessionKey, userId: null, username: null };
26
+ }
27
+
28
+ return { valid: false };
29
+ }
30
+
31
+ /**
32
+ * Register a new user via invitation code
33
+ */
34
+ export async function register(username, password, email, invitationCode) {
35
+ if (CONFIG.skipAuth) {
36
+ return { success: false, error: 'Registration disabled in development mode' };
37
+ }
38
+
39
+ if (!username || username.length < 2 || username.length > 32) {
40
+ return { success: false, error: 'Username must be 2-32 characters' };
41
+ }
42
+ if (!/^[a-zA-Z0-9_-]+$/.test(username)) {
43
+ return { success: false, error: 'Username can only contain letters, numbers, hyphens and underscores' };
44
+ }
45
+ if (!password || password.length < 6) {
46
+ return { success: false, error: 'Password must be at least 6 characters' };
47
+ }
48
+ // TODO: restore invitation code requirement — currently disabled for open registration
49
+ let invitation = null;
50
+ if (invitationCode) {
51
+ invitation = invitationDb.get(invitationCode);
52
+ if (!invitation) {
53
+ return { success: false, error: 'Invalid invitation code' };
54
+ }
55
+ if (invitation.used_by) {
56
+ return { success: false, error: 'Invitation code already used' };
57
+ }
58
+ if (invitation.expires_at < Date.now()) {
59
+ return { success: false, error: 'Invitation code has expired' };
60
+ }
61
+ }
62
+
63
+ const existing = userDb.getByUsername(username);
64
+ if (existing && existing.password_hash) {
65
+ return { success: false, error: 'Username already exists' };
66
+ }
67
+
68
+ const passwordHash = await bcrypt.hash(password, 10);
69
+ // TODO: restore invitation-based role assignment — currently defaults to 'pro'
70
+ const role = invitation ? (invitation.role === 'admin' ? 'admin' : 'pro') : 'pro';
71
+
72
+ let user;
73
+ if (existing && !existing.password_hash) {
74
+ userDb.updatePassword(existing.id, passwordHash);
75
+ if (email) userDb.updateEmail(existing.id, email);
76
+ userDb.updateRole(existing.id, role);
77
+ if (!existing.agent_secret) {
78
+ userDb.resetAgentSecret(existing.id);
79
+ }
80
+ user = existing;
81
+ } else {
82
+ user = userDb.createFull(username, passwordHash, email || null, role);
83
+ }
84
+
85
+ // TODO: restore invitation code consumption — only consume if code was provided
86
+ if (invitationCode && invitation) {
87
+ invitationDb.use(invitationCode, user.id);
88
+ }
89
+
90
+ return { success: true, message: 'Registration successful' };
91
+ }
@@ -0,0 +1,57 @@
1
+ import jwt from 'jsonwebtoken';
2
+ import { CONFIG } from '../config.js';
3
+
4
+ // Store pending verifications (tempToken -> { username, code, expiresAt, sessionKey })
5
+ export const pendingVerifications = new Map();
6
+
7
+ // Store pending TOTP verifications (tempToken -> { username, sessionKey, expiresAt })
8
+ export const pendingTotpVerifications = new Map();
9
+
10
+ // Store pending TOTP setup (setupToken -> { username, secret, sessionKey, expiresAt })
11
+ export const pendingTotpSetup = new Map();
12
+
13
+ // Store active sessions (token -> { username, sessionKey })
14
+ export const activeSessions = new Map();
15
+
16
+ // Store revoked tokens
17
+ export const revokedTokens = new Set();
18
+
19
+ /**
20
+ * Clean up expired pending verifications
21
+ */
22
+ function cleanupPendingVerifications() {
23
+ const now = Date.now();
24
+ for (const [token, data] of pendingVerifications.entries()) {
25
+ if (data.expiresAt < now) {
26
+ pendingVerifications.delete(token);
27
+ }
28
+ }
29
+ for (const [token, data] of pendingTotpVerifications.entries()) {
30
+ if (data.expiresAt < now) {
31
+ pendingTotpVerifications.delete(token);
32
+ }
33
+ }
34
+ for (const [token, data] of pendingTotpSetup.entries()) {
35
+ if (data.expiresAt < now) {
36
+ pendingTotpSetup.delete(token);
37
+ }
38
+ }
39
+ // 清理过期的 activeSessions 和 revokedTokens(JWT 已过期的)
40
+ for (const [token] of activeSessions.entries()) {
41
+ try {
42
+ jwt.verify(token, CONFIG.jwtSecret);
43
+ } catch {
44
+ activeSessions.delete(token);
45
+ }
46
+ }
47
+ for (const token of revokedTokens) {
48
+ try {
49
+ jwt.verify(token, CONFIG.jwtSecret);
50
+ } catch {
51
+ revokedTokens.delete(token);
52
+ }
53
+ }
54
+ }
55
+
56
+ // Run cleanup every minute
57
+ setInterval(cleanupPendingVerifications, 60000);
@@ -0,0 +1,85 @@
1
+ import { randomUUID } from 'crypto';
2
+ import jwt from 'jsonwebtoken';
3
+ import { CONFIG, getUserByUsername } from '../config.js';
4
+ import { generateSessionKey } from '../encryption.js';
5
+ import { activeSessions, revokedTokens } from './session-store.js';
6
+
7
+ /**
8
+ * Verify JWT token and get session data.
9
+ *
10
+ * Returns `exp` (seconds since epoch, from JWT spec) so callers can decide
11
+ * whether to issue a sliding-renewal token.
12
+ */
13
+ export function issueSessionToken(username) {
14
+ return jwt.sign({ username, jti: randomUUID() }, CONFIG.jwtSecret, { expiresIn: CONFIG.jwtExpiresIn });
15
+ }
16
+
17
+ export function verifyToken(token) {
18
+ try {
19
+ const decoded = jwt.verify(token, CONFIG.jwtSecret);
20
+
21
+ if (revokedTokens.has(token)) {
22
+ return { valid: false };
23
+ }
24
+
25
+ let session = activeSessions.get(token);
26
+
27
+ // Token 有效但 session 不存在(如服务器重启后),重建 session
28
+ if (!session) {
29
+ const sessionKey = generateSessionKey();
30
+ session = { username: decoded.username, sessionKey };
31
+ activeSessions.set(token, session);
32
+ }
33
+
34
+ const user = getUserByUsername(decoded.username);
35
+
36
+ return {
37
+ valid: true,
38
+ username: decoded.username,
39
+ sessionKey: session.sessionKey,
40
+ role: user?.role === 'admin' ? 'admin' : 'pro',
41
+ exp: decoded.exp, // seconds since epoch
42
+ type: decoded.type // undefined for full session tokens; 'temp'/'totp'/'totp-setup' otherwise
43
+ };
44
+ } catch (err) {
45
+ return { valid: false };
46
+ }
47
+ }
48
+
49
+ /**
50
+ * If `currentToken`'s remaining lifetime is below CONFIG.jwtRenewThresholdMs,
51
+ * mint a fresh token for the same user, copy the session over, and return it.
52
+ * Returns `null` if the current token is still fresh enough to keep using.
53
+ *
54
+ * The old token is intentionally NOT revoked — browsers fire parallel
55
+ * requests with the same Authorization header, and revoking would 401 every
56
+ * sibling request that races the renewal. The old token expires naturally on
57
+ * its original timeline (< jwtRenewThresholdMs from now), so the replay
58
+ * window is bounded by the threshold.
59
+ *
60
+ * Designed to be idempotent: missing exp or far-future exp is a no-op.
61
+ */
62
+ export function maybeRenewToken(currentToken, expSeconds, username) {
63
+ if (!expSeconds) return null;
64
+ const remainingMs = expSeconds * 1000 - Date.now();
65
+ if (remainingMs >= CONFIG.jwtRenewThresholdMs) return null;
66
+ if (remainingMs <= 0) return null; // expired tokens shouldn't reach here
67
+
68
+ const newToken = issueSessionToken(username);
69
+ const session = activeSessions.get(currentToken);
70
+ if (session) {
71
+ activeSessions.set(newToken, session);
72
+ // Keep the old session entry intact so concurrent in-flight requests
73
+ // bearing the old token continue to find their session. It will be
74
+ // garbage-collected when the old token's natural exp passes.
75
+ }
76
+ return newToken;
77
+ }
78
+
79
+ /**
80
+ * Invalidate a session (logout)
81
+ */
82
+ export function logout(token) {
83
+ activeSessions.delete(token);
84
+ revokedTokens.add(token);
85
+ }