Files
memind/user-auth.mjs
T
John 6ee6fd64dd Add MindSpace page live edit, chat skills, and H5 deploy tooling.
Introduce page edit sessions with draft preview and patch API, chat skill picker, user memory profile, h5ApiBase resolution, voice WAV transport, and scripts for 105/g2 deployment and Plaza local dev.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-15 22:09:38 -07:00

2232 lines
71 KiB
JavaScript

import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { computeDeltaCostCents, loadBillingConfig, normalizeTokenState } from './billing.mjs';
import { buildInsufficientBalancePayload, loadRechargeConfig } from './billing-recharge.mjs';
import {
buildAgentExtensionPolicy,
CAPABILITY_CATALOG,
catalogKeys,
clampUserCapabilities,
DEFAULT_USER_CAPABILITIES,
isValidCapabilityKey,
normalizeCapabilityPatch,
USER_NON_GRANTABLE_CAPABILITIES,
} from './capabilities.mjs';
import {
applyPoliciesToCapabilities,
DEFAULT_USER_POLICIES,
normalizePolicyPatch,
POLICY_CATALOG,
policyKeys,
resolvePolicies,
} from './policies.mjs';
import {
ensurePublishSkillInstalled,
ensureUserPublishLayout,
PUBLISH_ROOT_DIR,
PUBLISH_SKILL_NAME,
resolvePublicBaseUrl,
} from './user-publish.mjs';
import {
ensureUserSpaceLayout,
isPathInsideUserWorkspace,
resolveMindspaceStorageRoot,
} from './user-space.mjs';
import { ensureUserMemoryProfile } from './user-memory-profile.mjs';
import {
applySkillGrantsToCapabilities,
DEFAULT_USER_SKILLS,
grantedSkillNames,
listPlatformSkillCatalog,
normalizeSkillPatch,
resolveSkillMap,
syncSkillsToWorkspace,
} from './skills-registry.mjs';
import { initializeDefaultSpace } from './mindspace.mjs';
export const USER_COOKIE = 'tkmind_user_session';
function safeEqual(left, right) {
const a = Buffer.from(left);
const b = Buffer.from(right);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
const PASSWORD_ALGORITHM_PBKDF2 = 'pbkdf2-sha512';
const PASSWORD_ALGORITHM_ARGON2ID = 'argon2id';
const ARGON2_MEMORY = 64 * 1024;
const ARGON2_PASSES = 3;
const ARGON2_PARALLELISM = 1;
const ARGON2_TAG_LENGTH = 32;
function hashPasswordPbkdf2(password, salt) {
return crypto.pbkdf2Sync(password, salt, 100000, 64, 'sha512').toString('hex');
}
function hashPasswordArgon2id(password, salt) {
return crypto
.argon2Sync(PASSWORD_ALGORITHM_ARGON2ID, {
message: password,
nonce: Buffer.from(salt, 'hex'),
parallelism: ARGON2_PARALLELISM,
tagLength: ARGON2_TAG_LENGTH,
memory: ARGON2_MEMORY,
passes: ARGON2_PASSES,
})
.toString('hex');
}
function createPasswordRecord(password, algorithm = PASSWORD_ALGORITHM_ARGON2ID) {
const salt = crypto.randomBytes(16).toString('hex');
if (algorithm === PASSWORD_ALGORITHM_ARGON2ID) {
return {
salt,
passwordHash: hashPasswordArgon2id(password, salt),
passwordAlgorithm: PASSWORD_ALGORITHM_ARGON2ID,
};
}
return {
salt,
passwordHash: hashPasswordPbkdf2(password, salt),
passwordAlgorithm: PASSWORD_ALGORITHM_PBKDF2,
};
}
function verifyPassword(password, row) {
const algorithm = row.password_algorithm || PASSWORD_ALGORITHM_PBKDF2;
if (algorithm === PASSWORD_ALGORITHM_ARGON2ID) {
return safeEqual(hashPasswordArgon2id(password, row.salt), row.password_hash);
}
return safeEqual(hashPasswordPbkdf2(password, row.salt), row.password_hash);
}
function normalizeUsername(username) {
return username.trim().toLowerCase();
}
function isValidUsername(username) {
return /^[a-z0-9_]{2,32}$/.test(username);
}
function isValidEmail(email) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
function hashSessionToken(token) {
return crypto.createHash('sha256').update(token).digest('hex');
}
export function createUserAuth(pool, options = {}) {
const usersRoot = path.resolve(options.usersRoot ?? '/tmp/tkmind_go_users');
const h5Root = path.resolve(options.h5Root ?? path.join(usersRoot, '..'));
const env = options.env ?? process.env;
const storageRoot = resolveMindspaceStorageRoot(h5Root, env);
const publicBaseUrl = resolvePublicBaseUrl(env);
const skillCatalog = listPlatformSkillCatalog(h5Root);
const defaultSignupBalanceCents = Number(options.defaultSignupBalanceCents ?? 500);
const sessionTtlMs = Number(options.sessionTtlMs ?? 7 * 24 * 60 * 60 * 1000);
const loginMaxFailures = Number(options.loginMaxFailures ?? 5);
const loginFailureWindowMs = Number(options.loginFailureWindowMs ?? 5 * 60 * 1000);
const persistSessions = options.persistSessions !== false && Boolean(pool);
const sessions = new Map();
const loginFailures = new Map();
const pruneLoginFailures = (now = Date.now()) => {
for (const [key, state] of loginFailures) {
if (state.resetAt <= now) loginFailures.delete(key);
}
};
const pruneSessions = (now = Date.now()) => {
for (const [token, session] of sessions) {
if (session.expiresAt <= now) sessions.delete(token);
}
};
const storeSession = async (userId, role, token, now = Date.now()) => {
const expiresAt = now + sessionTtlMs;
sessions.set(token, { userId, role, expiresAt });
if (!persistSessions) return expiresAt;
await pool.query(
`INSERT INTO h5_login_sessions (id, user_id, token_hash, expires_at, created_at)
VALUES (?, ?, ?, ?, ?)`,
[crypto.randomUUID(), userId, hashSessionToken(token), expiresAt, now],
);
return expiresAt;
};
const revokeAllSessionsForUser = async (userId, now = Date.now()) => {
for (const [token, session] of sessions) {
if (session.userId === userId) sessions.delete(token);
}
if (!persistSessions) return;
await pool.query(
`UPDATE h5_login_sessions SET revoked_at = ? WHERE user_id = ? AND revoked_at IS NULL`,
[now, userId],
);
};
const ensureWorkspace = (workspaceRoot) => {
fs.mkdirSync(workspaceRoot, { recursive: true });
};
const resolveAdminWorkspaceRoot = () => {
const configured = env.H5_ADMIN_WORKSPACE_ROOT?.trim();
if (configured) return path.resolve(configured);
return path.dirname(usersRoot);
};
const isAdminRole = (user) => user?.role === 'admin';
const getUserById = async (userId) => {
const [rows] = await pool.query(
`SELECT u.id, u.username, u.slug, u.email, u.display_name, u.role, u.status,
u.plan_type, u.workspace_root,
w.balance_cents, w.tokens_used,
(SELECT COALESCE(SUM(ABS(amount_cents)), 0)
FROM h5_billing_ledger l
WHERE l.user_id = u.id AND l.type = 'deduct') AS spent_cents
FROM h5_users u
LEFT JOIN h5_user_wallets w ON w.user_id = u.id
WHERE u.id = ?`,
[userId],
);
return rows[0] ?? null;
};
const publicUser = (row) => {
const balanceCents = Number(row.balance_cents ?? 0);
const spentCents = Number(row.spent_cents ?? 0);
const base = {
id: row.id,
username: row.username,
slug: row.slug ?? row.username,
email: row.email ?? null,
displayName: row.display_name,
role: row.role,
status: row.status,
planType: row.plan_type ?? 'free',
workspaceRoot: row.workspace_root,
balanceCents,
totalCreditCents: balanceCents + spentCents,
tokensUsed: Number(row.tokens_used ?? 0),
};
if (row.role === 'admin') return base;
const publishKey = row.id;
return {
...base,
publishSlug: publishKey,
publishUrl: `${publicBaseUrl}/${PUBLISH_ROOT_DIR}/${encodeURIComponent(publishKey)}/`,
publishSkillName: PUBLISH_SKILL_NAME,
};
};
const publishLayoutFor = async (user, { migrateLegacy = true } = {}) => {
const web = ensureUserPublishLayout({
h5Root,
publicBaseUrl,
user,
legacyUsersRoot: migrateLegacy ? usersRoot : null,
});
const space = await ensureUserSpaceLayout({
pool,
storageRoot,
userId: user.id,
username: user.username ?? web.slug,
displayName: user.displayName,
publicBaseUrl,
slug: web.slug,
workspaceRoot: web.publishDir,
});
ensurePublishSkillInstalled(web.publishDir, {
slug: web.slug,
username: user.username ?? web.username,
displayName: user.displayName,
publicBaseUrl,
publishDir: web.publishDir,
});
ensureUserMemoryProfile(web.publishDir, {
userId: user.id,
displayName: user.displayName ?? user.display_name,
username: user.username ?? web.username,
slug: web.slug,
});
return {
...web,
...space,
publishDir: web.publishDir,
constraints: web.constraints,
};
};
const listSkillGrants = async (subjectType, subjectId) => {
const [rows] = await pool.query(
`SELECT skill_name, enabled
FROM h5_user_skill_grants
WHERE subject_type = ? AND subject_id = ?`,
[subjectType, subjectId],
);
return Object.fromEntries(rows.map((row) => [row.skill_name, Boolean(row.enabled)]));
};
const resolveUserSkillMap = async (user) => {
if (!user || user.role === 'admin') {
return Object.fromEntries(skillCatalog.map((item) => [item.name, true]));
}
const roleDefaults = await listSkillGrants('role', 'user');
const userOverrides = await listSkillGrants('user', user.id);
return resolveSkillMap(roleDefaults, userOverrides, skillCatalog);
};
const syncUserSkillsForUser = async (user) => {
if (!user || user.role === 'admin') return;
const layout = await syncUserPublishWorkspace(user);
const skillMap = await resolveUserSkillMap(user);
syncSkillsToWorkspace({
h5Root,
publishDir: layout?.publishDir,
skillMap,
catalog: skillCatalog,
user,
publicBaseUrl,
});
};
const syncUserPublishWorkspace = async (user) => {
if (!user || user.role === 'admin') return null;
const layout = await publishLayoutFor(user);
const current = path.resolve(user.workspace_root);
const target = path.resolve(layout.publishDir);
if (current !== target) {
const now = Date.now();
await pool.query(`UPDATE h5_users SET workspace_root = ?, updated_at = ? WHERE id = ?`, [
layout.publishDir,
now,
user.id,
]);
await pool.query(`DELETE FROM h5_user_path_grants WHERE user_id = ?`, [user.id]);
await pool.query(
`INSERT INTO h5_user_path_grants (user_id, path, mode) VALUES (?, ?, 'readwrite')`,
[user.id, layout.publishDir],
);
}
return layout;
};
const recordSignupBonus = async (conn, userId, amountCents, now) => {
const amount = Number(amountCents);
if (!Number.isFinite(amount) || amount <= 0) return;
await conn.query(
`INSERT INTO h5_billing_ledger
(user_id, type, amount_cents, tokens, note, operator_id, created_at)
VALUES (?, 'adjust', ?, 0, '新用户赠送', NULL, ?)`,
[userId, amount, now],
);
};
const register = async ({ username, password, displayName, email }) => {
const normalized = normalizeUsername(username);
if (!isValidUsername(normalized)) {
return { ok: false, message: '用户名仅支持 2-32 位小写字母、数字、下划线' };
}
if (!password || password.length < 6) {
return { ok: false, message: '密码至少 6 位' };
}
if (!email || !isValidEmail(email.trim())) {
return { ok: false, message: '请输入有效邮箱' };
}
const { salt, passwordHash, passwordAlgorithm } = createPasswordRecord(password);
const userId = crypto.randomUUID();
const layout = await publishLayoutFor({ id: userId, username: normalized });
const workspaceRoot = layout.publishDir;
const now = Date.now();
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
await conn.query(
`INSERT INTO h5_users
(id, username, slug, email, display_name, salt, password_hash, password_algorithm,
role, status, plan_type, workspace_root, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'user', 'active', 'free', ?, ?, ?)`,
[
userId,
normalized,
normalized,
email?.trim().toLowerCase() || null,
displayName?.trim() || normalized,
salt,
passwordHash,
passwordAlgorithm,
workspaceRoot,
now,
now,
],
);
await conn.query(
`INSERT INTO h5_user_wallets (user_id, balance_cents, tokens_used, updated_at)
VALUES (?, ?, 0, ?)`,
[userId, defaultSignupBalanceCents, now],
);
await recordSignupBonus(conn, userId, defaultSignupBalanceCents, now);
await conn.query(
`INSERT INTO h5_user_path_grants (user_id, path, mode) VALUES (?, ?, 'readwrite')`,
[userId, workspaceRoot],
);
await initializeDefaultSpace(conn, userId, {
quotaBytes: Number(process.env.MINDSPACE_FREE_QUOTA_BYTES ?? 5 * 1024 * 1024),
now,
});
await conn.commit();
ensureWorkspace(workspaceRoot);
ensureUserMemoryProfile(workspaceRoot, {
userId,
displayName: displayName?.trim() || normalized,
username: normalized,
slug: normalized,
});
const user = await getUserById(userId);
return { ok: true, user: publicUser(user) };
} catch (err) {
await conn.rollback();
if (err?.code === 'ER_DUP_ENTRY') {
return { ok: false, message: '用户名、主页地址或邮箱已存在' };
}
throw err;
} finally {
conn.release();
}
};
const login = async ({ username, password, ip = 'unknown', now = Date.now() }) => {
pruneLoginFailures(now);
const normalized = normalizeUsername(username);
const failureKey = `${ip}:${normalized}`;
const failure = loginFailures.get(failureKey);
if (failure && failure.count >= loginMaxFailures && failure.resetAt > now) {
return {
ok: false,
message: '尝试次数过多,请稍后再试',
retryAfterMs: failure.resetAt - now,
};
}
const [rows] = await pool.query(
`SELECT u.id, u.username, u.slug, u.email, u.display_name, u.role, u.status,
u.plan_type, u.workspace_root,
u.salt, u.password_hash, u.password_algorithm, w.balance_cents, w.tokens_used
FROM h5_users u
LEFT JOIN h5_user_wallets w ON w.user_id = u.id
WHERE u.username = ?`,
[normalized],
);
const row = rows[0];
if (!row) {
const current =
failure && failure.resetAt > now
? failure
: { count: 0, resetAt: now + loginFailureWindowMs };
current.count += 1;
loginFailures.set(failureKey, current);
return { ok: false, message: '用户名或密码错误' };
}
if (!verifyPassword(password, row)) {
const current =
failure && failure.resetAt > now
? failure
: { count: 0, resetAt: now + loginFailureWindowMs };
current.count += 1;
loginFailures.set(failureKey, current);
return { ok: false, message: '用户名或密码错误' };
}
if (row.status === 'disabled') {
return { ok: false, message: '账户已禁用,请联系管理员' };
}
if ((row.password_algorithm || PASSWORD_ALGORITHM_PBKDF2) !== PASSWORD_ALGORITHM_ARGON2ID) {
const nextPassword = createPasswordRecord(password);
await pool.query(
`UPDATE h5_users
SET salt = ?, password_hash = ?, password_algorithm = ?, updated_at = ?
WHERE id = ?`,
[nextPassword.salt, nextPassword.passwordHash, nextPassword.passwordAlgorithm, now, row.id],
);
row.salt = nextPassword.salt;
row.password_hash = nextPassword.passwordHash;
row.password_algorithm = nextPassword.passwordAlgorithm;
}
loginFailures.delete(failureKey);
const token = crypto.randomBytes(32).toString('base64url');
await storeSession(row.id, row.role, token, now);
return { ok: true, token, user: publicUser(row) };
};
const resetPassword = async ({ username, email, password }) => {
const normalized = normalizeUsername(username);
if (!isValidUsername(normalized)) {
return { ok: false, message: '用户名或邮箱不正确' };
}
if (!email || !isValidEmail(email.trim())) {
return { ok: false, message: '请输入有效邮箱' };
}
if (!password || password.length < 6) {
return { ok: false, message: '新密码至少 6 位' };
}
const [rows] = await pool.query(
`SELECT id, email, status FROM h5_users WHERE username = ? LIMIT 1`,
[normalized],
);
const row = rows[0];
const normalizedEmail = email.trim().toLowerCase();
if (!row || (row.email ?? '').toLowerCase() !== normalizedEmail) {
return { ok: false, message: '用户名或邮箱不正确' };
}
if (row.status === 'disabled') {
return { ok: false, message: '账户已禁用,请联系管理员' };
}
const { salt, passwordHash, passwordAlgorithm } = createPasswordRecord(password);
const now = Date.now();
await pool.query(
`UPDATE h5_users SET salt = ?, password_hash = ?, password_algorithm = ?, updated_at = ? WHERE id = ?`,
[salt, passwordHash, passwordAlgorithm, now, row.id],
);
await revokeAllSessionsForUser(row.id, now);
return { ok: true };
};
const verify = async (token, now = Date.now()) => {
if (!token) return null;
pruneSessions(now);
const cached = sessions.get(token);
if (cached) {
if (cached.expiresAt <= now) {
sessions.delete(token);
return null;
}
const user = await getUserById(cached.userId);
if (!user || user.status === 'disabled') {
await revoke(token, now);
return null;
}
cached.expiresAt = now + sessionTtlMs;
if (persistSessions) {
await pool.query(
`UPDATE h5_login_sessions
SET expires_at = ?
WHERE token_hash = ? AND revoked_at IS NULL`,
[cached.expiresAt, hashSessionToken(token)],
);
}
return cached;
}
if (!persistSessions) return null;
const tokenHash = hashSessionToken(token);
const [rows] = await pool.query(
`SELECT s.user_id, s.expires_at, u.role, u.status
FROM h5_login_sessions s
JOIN h5_users u ON u.id = s.user_id
WHERE s.token_hash = ? AND s.revoked_at IS NULL
LIMIT 1`,
[tokenHash],
);
const row = rows[0];
if (!row || Number(row.expires_at ?? 0) <= now || row.status === 'disabled') {
if (row) await revoke(token, now);
return null;
}
const expiresAt = now + sessionTtlMs;
await pool.query(
`UPDATE h5_login_sessions SET expires_at = ? WHERE token_hash = ? AND revoked_at IS NULL`,
[expiresAt, tokenHash],
);
const session = { userId: row.user_id, role: row.role, expiresAt };
sessions.set(token, session);
return session;
};
const revoke = async (token, now = Date.now()) => {
if (!token) return;
sessions.delete(token);
if (!persistSessions) return;
await pool.query(
`UPDATE h5_login_sessions SET revoked_at = ? WHERE token_hash = ? AND revoked_at IS NULL`,
[now, hashSessionToken(token)],
);
};
const getMe = async (token) => {
const session = await verify(token);
if (!session) return null;
const user = await getUserById(session.userId);
if (!user) return null;
return publicUser(user);
};
const listPathGrants = async (userId) => {
const [rows] = await pool.query(
`SELECT path, mode FROM h5_user_path_grants WHERE user_id = ? ORDER BY path`,
[userId],
);
return rows.map((row) => ({ path: row.path, mode: row.mode }));
};
const resolveWorkingDir = async (userId) => {
const user = await getUserById(userId);
if (!user) throw new Error('用户不存在');
if (isAdminRole(user)) {
const root = resolveAdminWorkspaceRoot();
ensureWorkspace(root);
return root;
}
const layout = await syncUserPublishWorkspace(user);
return layout.publishDir;
};
const getUserPublishLayout = async (userId) => {
const user = await getUserById(userId);
if (!user || user.role === 'admin') return null;
return syncUserPublishWorkspace(user);
};
const isPathAllowed = async (userId, requestedPath) => {
const user = await getUserById(userId);
if (!user) return false;
if (isAdminRole(user)) return true;
const layout = await publishLayoutFor(user, { migrateLegacy: false });
return isPathInsideUserWorkspace(layout.publishDir, requestedPath);
};
const repairAllUserPublishDirs = async () => {
const [rows] = await pool.query(
`SELECT id, username, role, workspace_root FROM h5_users WHERE role = 'user'`,
);
for (const row of rows) {
await syncUserSkillsForUser(row);
}
};
const seedRoleSkillDefaults = async () => {
const now = Date.now();
for (const [name, enabled] of Object.entries(DEFAULT_USER_SKILLS)) {
await pool.query(
`INSERT INTO h5_user_skill_grants (subject_type, subject_id, skill_name, enabled, updated_at)
VALUES ('role', 'user', ?, ?, ?)
ON DUPLICATE KEY UPDATE skill_name = skill_name`,
[name, enabled ? 1 : 0, now],
);
}
};
const registerAgentSession = async (userId, agentSessionId) => {
await pool.query(
`INSERT INTO h5_user_sessions (agent_session_id, user_id, created_at)
VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE user_id = VALUES(user_id)`,
[agentSessionId, userId, Date.now()],
);
};
const ownsSession = async (userId, agentSessionId) => {
const [rows] = await pool.query(
`SELECT 1 FROM h5_user_sessions WHERE agent_session_id = ? AND user_id = ? LIMIT 1`,
[agentSessionId, userId],
);
return rows.length > 0;
};
const listOwnedSessionIds = async (userId) => {
const [rows] = await pool.query(
`SELECT agent_session_id FROM h5_user_sessions WHERE user_id = ?`,
[userId],
);
return new Set(rows.map((row) => row.agent_session_id));
};
const unregisterAgentSession = async (userId, agentSessionId) => {
await pool.query(
`DELETE FROM h5_user_sessions WHERE agent_session_id = ? AND user_id = ?`,
[agentSessionId, userId],
);
};
const canUseChat = async (userId) => {
const user = await getUserById(userId);
if (!user) return { ok: false, message: '用户不存在' };
if (user.status === 'disabled') {
return { ok: false, message: '账户已禁用' };
}
if (user.status === 'suspended' && !isAdminRole(user)) {
return { ok: false, message: '账户已暂停' };
}
if (isAdminRole(user)) {
return { ok: true, balanceCents: Number(user.balance_cents ?? 0) };
}
const balanceCents = Number(user.balance_cents ?? 0);
if (balanceCents <= 0) {
return {
ok: false,
message: '余额不足,请充值后继续使用',
...buildInsufficientBalancePayload(balanceCents, loadRechargeConfig()),
};
}
return { ok: true, balanceCents };
};
const listUsers = async () => {
const [rows] = await pool.query(
`SELECT u.id, u.username, u.slug, u.email, u.display_name, u.role, u.status,
u.plan_type, u.workspace_root,
u.created_at, u.updated_at, w.balance_cents, w.tokens_used
FROM h5_users u
LEFT JOIN h5_user_wallets w ON w.user_id = u.id
ORDER BY u.created_at DESC`,
);
return rows.map((row) => ({
...publicUser(row),
createdAt: Number(row.created_at),
updatedAt: Number(row.updated_at),
}));
};
const createUser = async ({
username,
password,
displayName,
workspaceRoot,
balanceCents,
role = 'user',
email,
}) => {
const normalized = normalizeUsername(username);
if (!isValidUsername(normalized)) {
return { ok: false, message: '用户名格式无效' };
}
if (!password || password.length < 6) {
return { ok: false, message: '密码至少 6 位' };
}
const isAdmin = role === 'admin';
const userId = crypto.randomUUID();
const root = isAdmin
? path.resolve(workspaceRoot || path.join(usersRoot, normalized))
: (await publishLayoutFor({ id: userId, username: normalized })).publishDir;
const { salt, passwordHash, passwordAlgorithm } = createPasswordRecord(password);
const now = Date.now();
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
await conn.query(
`INSERT INTO h5_users
(id, username, slug, email, display_name, salt, password_hash, password_algorithm,
role, status, plan_type, workspace_root, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', 'free', ?, ?, ?)`,
[
userId,
normalized,
normalized,
email?.trim().toLowerCase() || null,
displayName?.trim() || normalized,
salt,
passwordHash,
passwordAlgorithm,
isAdmin ? 'admin' : 'user',
root,
now,
now,
],
);
await conn.query(
`INSERT INTO h5_user_wallets (user_id, balance_cents, tokens_used, updated_at)
VALUES (?, ?, 0, ?)`,
[userId, Number(balanceCents ?? defaultSignupBalanceCents), now],
);
await conn.query(
`INSERT INTO h5_user_path_grants (user_id, path, mode) VALUES (?, ?, 'readwrite')`,
[userId, root],
);
if (!isAdmin) {
await initializeDefaultSpace(conn, userId, {
quotaBytes: Number(process.env.MINDSPACE_FREE_QUOTA_BYTES ?? 5 * 1024 * 1024),
now,
});
}
await conn.commit();
ensureWorkspace(root);
const user = await getUserById(userId);
return { ok: true, user: publicUser(user) };
} catch (err) {
await conn.rollback();
if (err?.code === 'ER_DUP_ENTRY') {
return { ok: false, message: '用户名已存在' };
}
throw err;
} finally {
conn.release();
}
};
const updateUser = async (userId, patch) => {
const user = await getUserById(userId);
if (!user) return { ok: false, message: '用户不存在' };
const now = Date.now();
const fields = [];
const values = [];
if (patch.displayName !== undefined) {
fields.push('display_name = ?');
values.push(patch.displayName.trim() || user.username);
}
if (patch.status !== undefined) {
fields.push('status = ?');
values.push(patch.status);
}
if (patch.workspaceRoot !== undefined) {
const root = path.resolve(patch.workspaceRoot);
fields.push('workspace_root = ?');
values.push(root);
ensureWorkspace(root);
await pool.query(`DELETE FROM h5_user_path_grants WHERE user_id = ?`, [userId]);
await pool.query(
`INSERT INTO h5_user_path_grants (user_id, path, mode) VALUES (?, ?, 'readwrite')`,
[userId, root],
);
}
if (patch.role !== undefined) {
fields.push('role = ?');
values.push(patch.role === 'admin' ? 'admin' : 'user');
}
if (fields.length > 0) {
fields.push('updated_at = ?');
values.push(now, userId);
await pool.query(`UPDATE h5_users SET ${fields.join(', ')} WHERE id = ?`, values);
}
if (patch.status === 'disabled' || patch.status === 'suspended') {
await revokeAllSessionsForUser(userId, now);
}
if (patch.balanceCents !== undefined) {
await pool.query(
`INSERT INTO h5_user_wallets (user_id, balance_cents, tokens_used, updated_at)
VALUES (?, ?, 0, ?)
ON DUPLICATE KEY UPDATE balance_cents = VALUES(balance_cents), updated_at = VALUES(updated_at)`,
[userId, Number(patch.balanceCents), now],
);
}
const updated = await getUserById(userId);
return { ok: true, user: publicUser(updated) };
};
const recharge = async (userId, amountCents, operatorId, note = '', options = {}) => {
const amount = Number(amountCents);
if (!Number.isFinite(amount) || amount <= 0) {
return { ok: false, message: '充值金额无效' };
}
const user = await getUserById(userId);
if (!user) return { ok: false, message: '用户不存在' };
const paymentOrderId = options.paymentOrderId ?? null;
const ledgerNote = paymentOrderId
? `order:${paymentOrderId}`
: note || (operatorId ? '管理员充值' : '账户充值');
const now = Date.now();
const ownsConnection = !options.conn;
const conn = options.conn ?? (await pool.getConnection());
try {
if (ownsConnection) await conn.beginTransaction();
await conn.query(
`INSERT INTO h5_user_wallets (user_id, balance_cents, tokens_used, updated_at)
VALUES (?, ?, 0, ?)
ON DUPLICATE KEY UPDATE
balance_cents = balance_cents + VALUES(balance_cents),
updated_at = VALUES(updated_at)`,
[userId, amount, now],
);
await conn.query(
`INSERT INTO h5_billing_ledger
(user_id, type, amount_cents, tokens, note, operator_id, created_at)
VALUES (?, 'recharge', ?, 0, ?, ?, ?)`,
[userId, amount, ledgerNote, operatorId, now],
);
if (user.status === 'suspended') {
await conn.query(`UPDATE h5_users SET status = 'active', updated_at = ? WHERE id = ?`, [
now,
userId,
]);
}
if (ownsConnection) await conn.commit();
const updated = await getUserById(userId);
return { ok: true, user: publicUser(updated) };
} catch (err) {
if (ownsConnection) await conn.rollback();
throw err;
} finally {
if (ownsConnection) conn.release();
}
};
const getBillingState = async (agentSessionId) => {
const [rows] = await pool.query(
`SELECT agent_session_id, user_id, last_accumulated_cost, last_input_tokens,
last_output_tokens, updated_at
FROM h5_session_billing_state
WHERE agent_session_id = ?`,
[agentSessionId],
);
const row = rows[0];
if (!row) return null;
return {
agentSessionId: row.agent_session_id,
userId: row.user_id,
lastAccumulatedCost: row.last_accumulated_cost,
lastInputTokens: Number(row.last_input_tokens ?? 0),
lastOutputTokens: Number(row.last_output_tokens ?? 0),
updatedAt: Number(row.updated_at),
};
};
const billSessionUsage = async (userId, agentSessionId, tokenStateRaw, requestId = null) => {
const user = await getUserById(userId);
if (isAdminRole(user)) {
return {
ok: true,
costCents: 0,
balanceCents: Number(user?.balance_cents ?? 0),
tokensUsed: Number(user?.tokens_used ?? 0),
deltaInputTokens: 0,
deltaOutputTokens: 0,
};
}
const tokenState = normalizeTokenState(tokenStateRaw);
const previous = await getBillingState(agentSessionId);
if (
previous &&
tokenState.accumulatedInputTokens <= Number(previous.lastInputTokens ?? 0) &&
tokenState.accumulatedOutputTokens <= Number(previous.lastOutputTokens ?? 0)
) {
const user = await getUserById(userId);
return {
ok: true,
costCents: 0,
balanceCents: user ? Number(user.balance_cents) : null,
tokensUsed: user ? Number(user.tokens_used ?? 0) : null,
deltaInputTokens: 0,
deltaOutputTokens: 0,
};
}
const config = loadBillingConfig();
const costCents = computeDeltaCostCents(previous, tokenState, config);
const deltaIn = Math.max(
0,
tokenState.accumulatedInputTokens - Number(previous?.lastInputTokens ?? 0),
);
const deltaOut = Math.max(
0,
tokenState.accumulatedOutputTokens - Number(previous?.lastOutputTokens ?? 0),
);
const deltaTokens = deltaIn + deltaOut;
const now = Date.now();
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
await conn.query(
`INSERT INTO h5_session_billing_state
(agent_session_id, user_id, last_accumulated_cost, last_input_tokens, last_output_tokens, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
last_accumulated_cost = VALUES(last_accumulated_cost),
last_input_tokens = VALUES(last_input_tokens),
last_output_tokens = VALUES(last_output_tokens),
updated_at = VALUES(updated_at)`,
[
agentSessionId,
userId,
tokenState.accumulatedCost,
tokenState.accumulatedInputTokens,
tokenState.accumulatedOutputTokens,
now,
],
);
let balanceAfter = null;
let tokensUsedAfter = null;
if (costCents > 0) {
const [walletRows] = await conn.query(
`SELECT balance_cents, tokens_used FROM h5_user_wallets WHERE user_id = ? FOR UPDATE`,
[userId],
);
const wallet = walletRows[0];
if (!wallet) {
await conn.rollback();
return { ok: false, message: '钱包不存在', costCents: 0 };
}
const currentBalance = Number(wallet.balance_cents ?? 0);
const nextBalance = Math.max(0, currentBalance - costCents);
balanceAfter = nextBalance;
tokensUsedAfter = Number(wallet.tokens_used ?? 0) + deltaTokens;
await conn.query(
`UPDATE h5_user_wallets
SET balance_cents = ?, tokens_used = tokens_used + ?, updated_at = ?
WHERE user_id = ?`,
[nextBalance, deltaTokens, now, userId],
);
await conn.query(
`INSERT INTO h5_usage_records
(user_id, agent_session_id, request_id, input_tokens, output_tokens, cost_cents, balance_after_cents, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[userId, agentSessionId, requestId, deltaIn, deltaOut, costCents, nextBalance, now],
);
await conn.query(
`INSERT INTO h5_billing_ledger
(user_id, type, amount_cents, tokens, session_id, note, operator_id, created_at)
VALUES (?, 'deduct', ?, ?, ?, ?, NULL, ?)`,
[
userId,
-costCents,
deltaTokens,
agentSessionId,
`对话扣费 input=${deltaIn} output=${deltaOut}`,
now,
],
);
if (nextBalance <= 0) {
await conn.query(`UPDATE h5_users SET status = 'suspended', updated_at = ? WHERE id = ?`, [
now,
userId,
]);
}
} else {
const user = await getUserById(userId);
balanceAfter = user ? Number(user.balance_cents) : null;
tokensUsedAfter = user ? Number(user.tokens_used ?? 0) : null;
}
await conn.commit();
return {
ok: true,
costCents,
balanceCents: balanceAfter,
tokensUsed: tokensUsedAfter,
deltaInputTokens: deltaIn,
deltaOutputTokens: deltaOut,
};
} catch (err) {
await conn.rollback();
throw err;
} finally {
conn.release();
}
};
const listUsageRecords = async ({ userId = null, limit = 50 } = {}) => {
const safeLimit = Math.min(Math.max(Number(limit) || 50, 1), 200);
const params = [];
let where = '';
if (userId) {
where = 'WHERE r.user_id = ?';
params.push(userId);
}
const [rows] = await pool.query(
`SELECT r.id, r.user_id, u.username, r.agent_session_id, r.request_id,
r.input_tokens, r.output_tokens, r.cost_cents, r.balance_after_cents, r.created_at
FROM h5_usage_records r
JOIN h5_users u ON u.id = r.user_id
${where}
ORDER BY r.created_at DESC
LIMIT ${safeLimit}`,
params,
);
return rows.map((row) => ({
id: Number(row.id),
userId: row.user_id,
username: row.username,
agentSessionId: row.agent_session_id,
requestId: row.request_id,
inputTokens: Number(row.input_tokens),
outputTokens: Number(row.output_tokens),
costCents: Number(row.cost_cents),
balanceAfterCents: Number(row.balance_after_cents),
createdAt: Number(row.created_at),
}));
};
const listBillingLedger = async ({ userId = null, limit = 50, types = null } = {}) => {
const safeLimit = Math.min(Math.max(Number(limit) || 50, 1), 200);
const params = [];
const clauses = [];
if (userId) {
clauses.push('l.user_id = ?');
params.push(userId);
}
if (Array.isArray(types) && types.length) {
clauses.push(`l.type IN (${types.map(() => '?').join(', ')})`);
params.push(...types);
}
const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
const [rows] = await pool.query(
`SELECT l.id, l.user_id, u.username, l.type, l.amount_cents, l.tokens,
l.session_id, l.note, l.created_at
FROM h5_billing_ledger l
JOIN h5_users u ON u.id = l.user_id
${where}
ORDER BY l.created_at DESC
LIMIT ${safeLimit}`,
params,
);
return rows.map((row) => ({
id: Number(row.id),
userId: row.user_id,
username: row.username,
type: row.type,
amountCents: Number(row.amount_cents),
tokens: Number(row.tokens),
sessionId: row.session_id,
note: row.note,
createdAt: Number(row.created_at),
}));
};
const getAdminSummary = async () => {
const since24h = Date.now() - 24 * 60 * 60 * 1000;
const [userRows] = await pool.query(
`SELECT u.id, u.username, u.display_name, u.role, u.status,
COALESCE(w.balance_cents, 0) AS balance_cents
FROM h5_users u
LEFT JOIN h5_user_wallets w ON w.user_id = u.id`,
);
let total = 0;
let active = 0;
let lowBalance = 0;
let totalBalanceCents = 0;
const lowBalanceUsers = [];
for (const row of userRows) {
total += 1;
if (row.status === 'active') active += 1;
const balanceCents = Number(row.balance_cents);
totalBalanceCents += balanceCents;
if (row.role === 'user' && balanceCents <= 0) {
lowBalance += 1;
if (lowBalanceUsers.length < 8) {
lowBalanceUsers.push({
id: row.id,
username: row.username,
displayName: row.display_name,
balanceCents,
});
}
}
}
const [[usage24h]] = await pool.query(
`SELECT COUNT(*) AS count, COALESCE(SUM(cost_cents), 0) AS cost_cents
FROM h5_usage_records
WHERE created_at >= ?`,
[since24h],
);
const recentUsage = await listUsageRecords({ limit: 8 });
const recentLedger = await listBillingLedger({ limit: 8 });
return {
users: { total, active, lowBalance, totalBalanceCents },
usage24h: {
count: Number(usage24h.count),
costCents: Number(usage24h.cost_cents),
},
lowBalanceUsers,
recentUsage,
recentLedger,
};
};
const syncAdminPassword = async () => {
const adminUsername = normalizeUsername(process.env.H5_ADMIN_USERNAME ?? 'admin');
const adminPassword = process.env.H5_ADMIN_PASSWORD;
if (!adminPassword) return;
const [rows] = await pool.query(
`SELECT id FROM h5_users WHERE username = ? AND role = 'admin' LIMIT 1`,
[adminUsername],
);
if (rows.length === 0) return;
const { salt, passwordHash, passwordAlgorithm } = createPasswordRecord(adminPassword);
const now = Date.now();
await pool.query(
`UPDATE h5_users SET salt = ?, password_hash = ?, password_algorithm = ?, updated_at = ? WHERE id = ?`,
[salt, passwordHash, passwordAlgorithm, now, rows[0].id],
);
};
const seedRoleCapabilityDefaults = async () => {
const now = Date.now();
for (const [key, allowed] of Object.entries(DEFAULT_USER_CAPABILITIES)) {
await pool.query(
`INSERT INTO h5_capability_grants (subject_type, subject_id, capability_key, allowed, updated_at)
VALUES ('role', 'user', ?, ?, ?)
ON DUPLICATE KEY UPDATE capability_key = capability_key`,
[key, allowed ? 1 : 0, now],
);
}
};
/** Enable L3 memory_store for existing role defaults without touching per-user overrides. */
const upgradeMemoryStoreCapability = async () => {
const now = Date.now();
await pool.query(
`INSERT INTO h5_capability_grants (subject_type, subject_id, capability_key, allowed, updated_at)
VALUES ('role', 'user', 'memory_store', 1, ?)
ON DUPLICATE KEY UPDATE allowed = 1, updated_at = VALUES(updated_at)`,
[now],
);
};
/** Enable platform skill loading + chat recall for existing role defaults. */
const upgradeDefaultUserCapabilities = async () => {
const now = Date.now();
for (const key of ['skills', 'chat_recall']) {
if (!DEFAULT_USER_CAPABILITIES[key]) continue;
await pool.query(
`INSERT INTO h5_capability_grants (subject_type, subject_id, capability_key, allowed, updated_at)
VALUES ('role', 'user', ?, 1, ?)
ON DUPLICATE KEY UPDATE allowed = 1, updated_at = VALUES(updated_at)`,
[key, now],
);
}
};
/** Enable default platform skills for existing role defaults. */
const upgradeDefaultUserSkills = async () => {
const now = Date.now();
for (const [name, enabled] of Object.entries(DEFAULT_USER_SKILLS)) {
if (!enabled) continue;
await pool.query(
`INSERT INTO h5_user_skill_grants (subject_type, subject_id, skill_name, enabled, updated_at)
VALUES ('role', 'user', ?, 1, ?)
ON DUPLICATE KEY UPDATE enabled = 1, updated_at = VALUES(updated_at)`,
[name, now],
);
}
};
const serializePolicyValue = (key, value) => {
const def = POLICY_CATALOG.find((item) => item.key === key);
if (def?.type === 'boolean') return value ? 'true' : 'false';
return String(value);
};
const parsePolicyValue = (key, raw) => {
const def = POLICY_CATALOG.find((item) => item.key === key);
if (def?.type === 'boolean') return raw === 'true' || raw === '1';
return raw;
};
const seedRolePolicyDefaults = async () => {
const now = Date.now();
for (const [key, value] of Object.entries(DEFAULT_USER_POLICIES)) {
await pool.query(
`INSERT INTO h5_user_policies (subject_type, subject_id, policy_key, policy_value, updated_at)
VALUES ('role', 'user', ?, ?, ?)
ON DUPLICATE KEY UPDATE policy_key = policy_key`,
[key, serializePolicyValue(key, value), now],
);
}
};
const listPolicyEntries = async (subjectType, subjectId) => {
const [rows] = await pool.query(
`SELECT policy_key, policy_value
FROM h5_user_policies
WHERE subject_type = ? AND subject_id = ?`,
[subjectType, subjectId],
);
return Object.fromEntries(
rows.map((row) => [row.policy_key, parsePolicyValue(row.policy_key, row.policy_value)]),
);
};
const resolveUserPolicies = async (user) => {
if (!user || user.role === 'admin') {
return { unrestricted: true, policies: {} };
}
const roleDefaults = await listPolicyEntries('role', 'user');
const userOverrides = await listPolicyEntries('user', user.id);
return {
unrestricted: false,
policies: resolvePolicies(roleDefaults, userOverrides),
};
};
const listCapabilityGrants = async (subjectType, subjectId) => {
const [rows] = await pool.query(
`SELECT capability_key, allowed
FROM h5_capability_grants
WHERE subject_type = ? AND subject_id = ?`,
[subjectType, subjectId],
);
return Object.fromEntries(
rows.map((row) => [row.capability_key, Boolean(row.allowed)]),
);
};
const resolveUserCapabilities = async (user) => {
if (!user) return { unrestricted: true, capabilities: {} };
if (user.role === 'admin') {
return {
unrestricted: true,
capabilities: Object.fromEntries(catalogKeys().map((key) => [key, true])),
};
}
const roleDefaults = await listCapabilityGrants('role', 'user');
const userOverrides = await listCapabilityGrants('user', user.id);
const capabilities = {};
for (const key of catalogKeys()) {
if (key in userOverrides) {
capabilities[key] = userOverrides[key];
} else if (key in roleDefaults) {
capabilities[key] = roleDefaults[key];
} else {
capabilities[key] = DEFAULT_USER_CAPABILITIES[key] ?? false;
}
}
const skillMap = await resolveUserSkillMap(user);
const withSkills = applySkillGrantsToCapabilities(capabilities, skillMap);
return {
unrestricted: false,
capabilities: clampUserCapabilities(withSkills),
skills: skillMap,
grantedSkills: grantedSkillNames(skillMap),
};
};
const getAgentSessionPolicy = async (userId) => {
const user = await getUserById(userId);
if (!user) throw new Error('用户不存在');
const capabilityState = await resolveUserCapabilities(user);
const policyState = await resolveUserPolicies(user);
await syncUserSkillsForUser(user);
if (capabilityState.unrestricted) {
return {
...buildAgentExtensionPolicy(capabilityState.capabilities, { unrestricted: true }),
policies: {},
unrestricted: true,
};
}
const effectiveCapabilities = applyPoliciesToCapabilities(
capabilityState.capabilities,
policyState.policies,
);
return {
...buildAgentExtensionPolicy(effectiveCapabilities, {
unrestricted: false,
policies: policyState.policies,
}),
policies: policyState.policies,
unrestricted: false,
};
};
const getRoleCapabilities = async (role = 'user') => {
const roleDefaults = await listCapabilityGrants('role', role);
const capabilities = {};
for (const key of catalogKeys()) {
capabilities[key] =
key in roleDefaults ? roleDefaults[key] : (DEFAULT_USER_CAPABILITIES[key] ?? false);
}
return { role, capabilities: clampUserCapabilities(capabilities) };
};
const setRoleCapabilities = async (role, patch) => {
if (role !== 'user') {
return { ok: false, message: '仅支持配置普通用户角色默认权限' };
}
const normalized = normalizeCapabilityPatch(patch);
const now = Date.now();
for (const [key, allowed] of Object.entries(normalized)) {
const effectiveAllowed = USER_NON_GRANTABLE_CAPABILITIES.has(key) ? false : allowed;
await pool.query(
`INSERT INTO h5_capability_grants (subject_type, subject_id, capability_key, allowed, updated_at)
VALUES ('role', ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE allowed = VALUES(allowed), updated_at = VALUES(updated_at)`,
[role, key, effectiveAllowed ? 1 : 0, now],
);
}
return { ok: true, ...(await getRoleCapabilities(role)) };
};
const getUserCapabilities = async (userId) => {
const user = await getUserById(userId);
if (!user) return { ok: false, message: '用户不存在' };
const resolved = await resolveUserCapabilities(user);
const overrides = await listCapabilityGrants('user', userId);
return {
ok: true,
userId,
role: user.role,
unrestricted: resolved.unrestricted,
capabilities: resolved.capabilities,
overrides,
};
};
const setUserCapabilities = async (userId, patch) => {
const user = await getUserById(userId);
if (!user) return { ok: false, message: '用户不存在' };
if (user.role === 'admin') {
return { ok: false, message: '管理员不受能力限制' };
}
const normalized = normalizeCapabilityPatch(patch);
const now = Date.now();
for (const [key, allowed] of Object.entries(normalized)) {
if (!isValidCapabilityKey(key)) continue;
const effectiveAllowed = USER_NON_GRANTABLE_CAPABILITIES.has(key) ? false : allowed;
await pool.query(
`INSERT INTO h5_capability_grants (subject_type, subject_id, capability_key, allowed, updated_at)
VALUES ('user', ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE allowed = VALUES(allowed), updated_at = VALUES(updated_at)`,
[userId, key, effectiveAllowed ? 1 : 0, now],
);
}
return getUserCapabilities(userId);
};
const clearUserCapabilityOverrides = async (userId) => {
await pool.query(
`DELETE FROM h5_capability_grants WHERE subject_type = 'user' AND subject_id = ?`,
[userId],
);
return getUserCapabilities(userId);
};
const getRolePolicies = async (role = 'user') => {
const roleDefaults = await listPolicyEntries('role', role);
const policies = resolvePolicies(roleDefaults, {});
return { role, policies };
};
const setRolePolicies = async (role, patch) => {
if (role !== 'user') {
return { ok: false, message: '仅支持配置普通用户角色默认策略' };
}
const normalized = normalizePolicyPatch(patch);
const now = Date.now();
for (const [key, value] of Object.entries(normalized)) {
await pool.query(
`INSERT INTO h5_user_policies (subject_type, subject_id, policy_key, policy_value, updated_at)
VALUES ('role', ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE policy_value = VALUES(policy_value), updated_at = VALUES(updated_at)`,
[role, key, serializePolicyValue(key, value), now],
);
}
return { ok: true, ...(await getRolePolicies(role)) };
};
const getUserPolicies = async (userId) => {
const user = await getUserById(userId);
if (!user) return { ok: false, message: '用户不存在' };
const policyState = await resolveUserPolicies(user);
const overrides = await listPolicyEntries('user', userId);
return {
ok: true,
userId,
role: user.role,
unrestricted: policyState.unrestricted,
policies: policyState.policies,
overrides,
};
};
const setUserPolicies = async (userId, patch) => {
const user = await getUserById(userId);
if (!user) return { ok: false, message: '用户不存在' };
if (user.role === 'admin') {
return { ok: false, message: '管理员不受策略限制' };
}
const normalized = normalizePolicyPatch(patch);
const now = Date.now();
for (const [key, value] of Object.entries(normalized)) {
if (!policyKeys().includes(key)) continue;
await pool.query(
`INSERT INTO h5_user_policies (subject_type, subject_id, policy_key, policy_value, updated_at)
VALUES ('user', ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE policy_value = VALUES(policy_value), updated_at = VALUES(updated_at)`,
[userId, key, serializePolicyValue(key, value), now],
);
}
return getUserPolicies(userId);
};
const clearUserPolicyOverrides = async (userId) => {
await pool.query(`DELETE FROM h5_user_policies WHERE subject_type = 'user' AND subject_id = ?`, [
userId,
]);
return getUserPolicies(userId);
};
const getRoleSkills = async (role = 'user') => {
const roleDefaults = await listSkillGrants('role', role);
const skills = resolveSkillMap(roleDefaults, {}, skillCatalog);
return { role, skills };
};
const setRoleSkills = async (role, patch) => {
if (role !== 'user') {
return { ok: false, message: '仅支持配置普通用户角色默认技能' };
}
const normalized = normalizeSkillPatch(skillCatalog, patch);
const now = Date.now();
for (const [name, enabled] of Object.entries(normalized)) {
await pool.query(
`INSERT INTO h5_user_skill_grants (subject_type, subject_id, skill_name, enabled, updated_at)
VALUES ('role', ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE enabled = VALUES(enabled), updated_at = VALUES(updated_at)`,
[role, name, enabled ? 1 : 0, now],
);
}
const [users] = await pool.query(`SELECT id, username, role, workspace_root FROM h5_users WHERE role = 'user'`);
for (const row of users) {
await syncUserSkillsForUser(row);
}
return { ok: true, ...(await getRoleSkills(role)) };
};
const getUserSkills = async (userId) => {
const user = await getUserById(userId);
if (!user) return { ok: false, message: '用户不存在' };
const skills = await resolveUserSkillMap(user);
const overrides = await listSkillGrants('user', userId);
return {
ok: true,
userId,
role: user.role,
skills,
grantedSkills: grantedSkillNames(skills),
overrides,
};
};
const setUserSkills = async (userId, patch) => {
const user = await getUserById(userId);
if (!user) return { ok: false, message: '用户不存在' };
if (user.role === 'admin') {
return { ok: false, message: '管理员不受技能限制' };
}
const normalized = normalizeSkillPatch(skillCatalog, patch);
const now = Date.now();
for (const [name, enabled] of Object.entries(normalized)) {
await pool.query(
`INSERT INTO h5_user_skill_grants (subject_type, subject_id, skill_name, enabled, updated_at)
VALUES ('user', ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE enabled = VALUES(enabled), updated_at = VALUES(updated_at)`,
[userId, name, enabled ? 1 : 0, now],
);
}
await syncUserSkillsForUser(user);
return getUserSkills(userId);
};
const clearUserSkillOverrides = async (userId) => {
await pool.query(`DELETE FROM h5_user_skill_grants WHERE subject_type = 'user' AND subject_id = ?`, [
userId,
]);
const user = await getUserById(userId);
if (user) await syncUserSkillsForUser(user);
return getUserSkills(userId);
};
const ensureAdminUser = async () => {
const adminUsername = normalizeUsername(process.env.H5_ADMIN_USERNAME ?? 'admin');
const adminPassword = process.env.H5_ADMIN_PASSWORD;
if (!adminPassword) return;
const [rows] = await pool.query(`SELECT id FROM h5_users WHERE username = ? LIMIT 1`, [
adminUsername,
]);
const adminWorkspace = resolveAdminWorkspaceRoot();
if (rows.length === 0) {
await createUser({
username: adminUsername,
password: adminPassword,
displayName: '管理员',
workspaceRoot: adminWorkspace,
balanceCents: 9_999_999_99,
role: 'admin',
});
} else {
const adminId = rows[0].id;
const now = Date.now();
await pool.query(`UPDATE h5_users SET workspace_root = ?, updated_at = ? WHERE id = ?`, [
adminWorkspace,
now,
adminId,
]);
await pool.query(`DELETE FROM h5_user_path_grants WHERE user_id = ?`, [adminId]);
await pool.query(
`INSERT INTO h5_user_path_grants (user_id, path, mode) VALUES (?, ?, 'readwrite')`,
[adminId, adminWorkspace],
);
await pool.query(
`UPDATE h5_user_wallets SET balance_cents = GREATEST(balance_cents, ?), updated_at = ? WHERE user_id = ?`,
[9_999_999_99, now, adminId],
);
}
await syncAdminPassword();
await seedRoleCapabilityDefaults();
await upgradeMemoryStoreCapability();
await upgradeDefaultUserCapabilities();
await seedRolePolicyDefaults();
await seedRoleSkillDefaults();
await upgradeDefaultUserSkills();
await repairAllUserPublishDirs();
};
const PENDING_BIND_TTL_MS = 15 * 60 * 1000;
const issueUserSession = async (userId, role, now = Date.now()) => {
const token = crypto.randomBytes(32).toString('base64url');
await storeSession(userId, role, token, now);
return token;
};
const findBindingByOpenid = async (appId, openid) => {
const [rows] = await pool.query(
`SELECT wi.user_id, u.status
FROM h5_user_wechat_identities wi
JOIN h5_users u ON u.id = wi.user_id
WHERE wi.app_id = ? AND wi.openid = ?
LIMIT 1`,
[appId, openid],
);
return rows[0] ?? null;
};
const findBindingByUnionid = async (unionid) => {
if (!unionid) return null;
const [rows] = await pool.query(
`SELECT wi.user_id, wi.app_id, u.status
FROM h5_user_wechat_identities wi
JOIN h5_users u ON u.id = wi.user_id
WHERE wi.unionid = ?
LIMIT 1`,
[unionid],
);
return rows[0] ?? null;
};
const getWechatBindingForUser = async (userId, appId) => {
const [rows] = await pool.query(
`SELECT id, nickname, avatar_url, last_login_at, created_at
FROM h5_user_wechat_identities
WHERE user_id = ? AND app_id = ?
LIMIT 1`,
[userId, appId],
);
return rows[0] ?? null;
};
const bindWechatToUser = async ({
userId,
appId,
openid,
unionid,
nickname,
avatarUrl,
now = Date.now(),
}) => {
const existingOpenid = await findBindingByOpenid(appId, openid);
if (existingOpenid && existingOpenid.user_id !== userId) {
return { ok: false, message: '该微信已绑定其他账号,请先用该账号登录' };
}
const existingUserBind = await getWechatBindingForUser(userId, appId);
if (existingUserBind) {
return { ok: false, message: '你的账号已绑定其他微信,需先解绑后再试' };
}
try {
await pool.query(
`INSERT INTO h5_user_wechat_identities
(id, user_id, app_id, openid, unionid, nickname, avatar_url, last_login_at, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
crypto.randomUUID(),
userId,
appId,
openid,
unionid,
nickname,
avatarUrl,
now,
now,
now,
],
);
return { ok: true };
} catch (err) {
if (err?.code === 'ER_DUP_ENTRY') {
return { ok: false, message: '微信绑定冲突,请重试' };
}
throw err;
}
};
const touchWechatIdentity = async ({
appId,
openid,
unionid,
nickname,
avatarUrl,
now = Date.now(),
}) => {
await pool.query(
`UPDATE h5_user_wechat_identities
SET nickname = COALESCE(?, nickname),
avatar_url = COALESCE(?, avatar_url),
unionid = COALESCE(?, unionid),
last_login_at = ?,
updated_at = ?
WHERE app_id = ? AND openid = ?`,
[nickname, avatarUrl, unionid, now, now, appId, openid],
);
};
const pruneWechatPendingBinds = async (now = Date.now()) => {
await pool.query(`DELETE FROM h5_wechat_pending_binds WHERE expires_at <= ?`, [now]);
};
const createWechatPendingBind = async ({
appId,
openid,
unionid,
nickname,
avatarUrl,
returnTo = '/',
utmSource = null,
utmMedium = null,
utmCampaign = null,
now = Date.now(),
}) => {
await pruneWechatPendingBinds(now);
const token = crypto.randomBytes(24).toString('base64url');
await pool.query(
`INSERT INTO h5_wechat_pending_binds
(token, app_id, openid, unionid, nickname, avatar_url, return_to,
utm_source, utm_medium, utm_campaign, expires_at, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
token,
appId,
openid,
unionid,
nickname,
avatarUrl,
returnTo,
utmSource,
utmMedium,
utmCampaign,
now + PENDING_BIND_TTL_MS,
now,
],
);
return token;
};
const getWechatPendingBind = async (token, now = Date.now()) => {
if (!token) return null;
await pruneWechatPendingBinds(now);
const [rows] = await pool.query(
`SELECT token, app_id, openid, unionid, nickname, avatar_url, return_to,
utm_source, utm_medium, utm_campaign, expires_at
FROM h5_wechat_pending_binds
WHERE token = ?
LIMIT 1`,
[token],
);
const row = rows[0];
if (!row || Number(row.expires_at) <= now) return null;
return row;
};
const consumeWechatPendingBind = async (token) => {
await pool.query(`DELETE FROM h5_wechat_pending_binds WHERE token = ?`, [token]);
};
const loginBoundWechatUser = async ({
userId,
appId,
openid,
unionid,
nickname,
avatarUrl,
now = Date.now(),
}) => {
await touchWechatIdentity({ appId, openid, unionid, nickname, avatarUrl, now });
const user = await getUserById(userId);
if (!user || user.status === 'disabled') {
return { ok: false, message: '账户已禁用,请联系管理员' };
}
const token = await issueUserSession(user.id, user.role, now);
return { ok: true, token, user: publicUser(user), isNewUser: false };
};
const generateWechatUsername = async (openid) => {
const cleaned = String(openid).replace(/[^a-zA-Z0-9]/g, '').toLowerCase();
const suffix = cleaned.slice(-8) || crypto.randomBytes(4).toString('hex');
let candidate = `wx_${suffix}`.slice(0, 32);
if (!isValidUsername(candidate)) {
candidate = `wx_${crypto.randomBytes(4).toString('hex')}`;
}
for (let attempt = 0; attempt < 8; attempt += 1) {
const [rows] = await pool.query(`SELECT id FROM h5_users WHERE username = ? LIMIT 1`, [
candidate,
]);
if (!rows[0]) return candidate;
candidate = `wx_${suffix.slice(0, Math.max(1, 8 - attempt))}${crypto.randomBytes(2).toString('hex')}`.slice(
0,
32,
);
}
return `wx_${crypto.randomBytes(6).toString('hex')}`.slice(0, 32);
};
const registerViaWechat = async ({
appId,
openid,
unionid,
nickname,
avatarUrl,
now = Date.now(),
}) => {
const normalized = await generateWechatUsername(openid);
const randomPassword = crypto.randomBytes(24).toString('base64url');
const { salt, passwordHash, passwordAlgorithm } = createPasswordRecord(randomPassword);
const userId = crypto.randomUUID();
const layout = await publishLayoutFor({ id: userId, username: normalized });
const workspaceRoot = layout.publishDir;
const displayName = nickname?.trim() || '微信用户';
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
await conn.query(
`INSERT INTO h5_users
(id, username, slug, email, display_name, salt, password_hash, password_algorithm,
role, status, plan_type, workspace_root, signup_source, created_at, updated_at)
VALUES (?, ?, ?, NULL, ?, ?, ?, ?, 'user', 'active', 'free', ?, 'wechat', ?, ?)`,
[
userId,
normalized,
normalized,
displayName,
salt,
passwordHash,
passwordAlgorithm,
workspaceRoot,
now,
now,
],
);
await conn.query(
`INSERT INTO h5_user_wallets (user_id, balance_cents, tokens_used, updated_at)
VALUES (?, ?, 0, ?)`,
[userId, defaultSignupBalanceCents, now],
);
await recordSignupBonus(conn, userId, defaultSignupBalanceCents, now);
await conn.query(
`INSERT INTO h5_user_path_grants (user_id, path, mode) VALUES (?, ?, 'readwrite')`,
[userId, workspaceRoot],
);
await conn.query(
`INSERT INTO h5_user_wechat_identities
(id, user_id, app_id, openid, unionid, nickname, avatar_url, last_login_at, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
crypto.randomUUID(),
userId,
appId,
openid,
unionid,
nickname,
avatarUrl,
now,
now,
now,
],
);
await initializeDefaultSpace(conn, userId, {
quotaBytes: Number(process.env.MINDSPACE_FREE_QUOTA_BYTES ?? 5 * 1024 * 1024),
now,
});
await conn.commit();
ensureWorkspace(workspaceRoot);
ensureUserMemoryProfile(workspaceRoot, {
userId,
displayName,
username: normalized,
slug: normalized,
});
const user = await getUserById(userId);
return { ok: true, user: publicUser(user) };
} catch (err) {
await conn.rollback();
if (err?.code === 'ER_DUP_ENTRY') {
return { ok: false, message: '微信账号注册冲突,请重试' };
}
throw err;
} finally {
conn.release();
}
};
const resolveWechatAuth = async ({
appId,
openid,
unionid,
nickname,
avatarUrl,
intent = 'login',
bindUserId = null,
returnTo = '/',
utmSource = null,
utmMedium = null,
utmCampaign = null,
now = Date.now(),
}) => {
let binding = await findBindingByOpenid(appId, openid);
if (!binding && unionid) {
const unionBinding = await findBindingByUnionid(unionid);
if (unionBinding) {
const linked = await bindWechatToUser({
userId: unionBinding.user_id,
appId,
openid,
unionid,
nickname,
avatarUrl,
now,
});
if (!linked.ok) return linked;
binding = { user_id: unionBinding.user_id, status: unionBinding.status };
}
}
if (binding) {
if (binding.status === 'disabled') {
return { ok: false, message: '账户已禁用,请联系管理员' };
}
return loginBoundWechatUser({
userId: binding.user_id,
appId,
openid,
unionid,
nickname,
avatarUrl,
now,
}).then((result) => (result.ok ? { ...result, action: 'login' } : result));
}
if (intent === 'bind' && bindUserId) {
const user = await getUserById(bindUserId);
if (!user) return { ok: false, message: '用户不存在' };
if (user.status === 'disabled') {
return { ok: false, message: '账户已禁用,请联系管理员' };
}
const bound = await bindWechatToUser({
userId: bindUserId,
appId,
openid,
unionid,
nickname,
avatarUrl,
now,
});
if (!bound.ok) return bound;
const token = await issueUserSession(user.id, user.role, now);
return {
ok: true,
action: 'login',
token,
user: publicUser(user),
isNewUser: false,
bound: true,
};
}
if (intent === 'register') {
const registered = await registerViaWechat({
appId,
openid,
unionid,
nickname,
avatarUrl,
now,
});
if (!registered.ok) return registered;
const token = await issueUserSession(registered.user.id, registered.user.role, now);
return {
ok: true,
action: 'login',
token,
user: registered.user,
isNewUser: true,
};
}
const pendingToken = await createWechatPendingBind({
appId,
openid,
unionid,
nickname,
avatarUrl,
returnTo,
utmSource,
utmMedium,
utmCampaign,
now,
});
return {
ok: true,
action: 'binding_gate',
pendingToken,
wechatProfile: {
nickname: nickname ?? null,
avatarUrl: avatarUrl ?? null,
},
returnTo,
utmSource,
utmMedium,
utmCampaign,
};
};
const completeWechatRegister = async ({ pendingToken, now = Date.now() }) => {
const pending = await getWechatPendingBind(pendingToken, now);
if (!pending) {
return { ok: false, message: '绑定会话已过期,请重新微信登录' };
}
const registered = await registerViaWechat({
appId: pending.app_id,
openid: pending.openid,
unionid: pending.unionid,
nickname: pending.nickname,
avatarUrl: pending.avatar_url,
now,
});
if (!registered.ok) return registered;
await consumeWechatPendingBind(pendingToken);
const token = await issueUserSession(registered.user.id, registered.user.role, now);
return {
ok: true,
token,
user: registered.user,
isNewUser: true,
returnTo: pending.return_to || '/',
utmSource: pending.utm_source,
utmMedium: pending.utm_medium,
utmCampaign: pending.utm_campaign,
};
};
const completeWechatBindAccount = async ({
pendingToken,
username,
password,
ip = 'unknown',
now = Date.now(),
}) => {
const pending = await getWechatPendingBind(pendingToken, now);
if (!pending) {
return { ok: false, message: '绑定会话已过期,请重新微信登录' };
}
const loginResult = await login({ username, password, ip, now });
if (!loginResult.ok) return loginResult;
const bound = await bindWechatToUser({
userId: loginResult.user.id,
appId: pending.app_id,
openid: pending.openid,
unionid: pending.unionid,
nickname: pending.nickname,
avatarUrl: pending.avatar_url,
now,
});
if (!bound.ok) return bound;
await consumeWechatPendingBind(pendingToken);
return {
ok: true,
token: loginResult.token,
user: loginResult.user,
isNewUser: false,
bound: true,
returnTo: pending.return_to || '/',
};
};
const getWechatBindingStatus = async (userId, appId) => {
const row = await getWechatBindingForUser(userId, appId);
if (!row) return { bound: false };
return {
bound: true,
nickname: row.nickname,
avatarUrl: row.avatar_url,
lastLoginAt: Number(row.last_login_at),
boundAt: Number(row.created_at),
};
};
const loginByWechat = async (params) => {
const result = await resolveWechatAuth({ ...params, intent: 'login' });
if (!result.ok) return result;
if (result.action === 'binding_gate') {
return { ok: false, message: '需要完成账号绑定' };
}
return result;
};
return {
USER_COOKIE,
register,
login,
loginByWechat,
resolveWechatAuth,
completeWechatRegister,
completeWechatBindAccount,
getWechatPendingBind,
getWechatBindingStatus,
resetPassword,
verify,
revoke,
revokeAllSessionsForUser,
getMe,
listPathGrants,
resolveWorkingDir,
getUserPublishLayout,
isPathAllowed,
repairAllUserPublishDirs,
registerAgentSession,
unregisterAgentSession,
ownsSession,
listOwnedSessionIds,
canUseChat,
listUsers,
createUser,
updateUser,
recharge,
billSessionUsage,
listUsageRecords,
listBillingLedger,
getAdminSummary,
ensureAdminUser,
seedRoleCapabilityDefaults,
resolveUserCapabilities,
getAgentSessionPolicy,
getRoleCapabilities,
setRoleCapabilities,
getUserCapabilities,
setUserCapabilities,
clearUserCapabilityOverrides,
resolveUserPolicies,
getRolePolicies,
setRolePolicies,
getUserPolicies,
setUserPolicies,
clearUserPolicyOverrides,
getRoleSkills,
setRoleSkills,
getUserSkills,
setUserSkills,
clearUserSkillOverrides,
syncUserSkillsForUser,
capabilityCatalog: CAPABILITY_CATALOG,
policyCatalog: POLICY_CATALOG,
skillCatalog,
publicUser,
getUserById,
};
}
function buildUserSessionCookie(token, secure, { domain, maxAge }) {
const parts = [
`${USER_COOKIE}=${encodeURIComponent(token)}`,
'Path=/',
'HttpOnly',
'SameSite=Lax',
`Max-Age=${maxAge}`,
];
if (domain) parts.push(`Domain=${domain}`);
if (secure) parts.push('Secure');
return parts.join('; ');
}
export function userSessionCookie(token, secure, domain = resolveCookieDomain()) {
return buildUserSessionCookie(token, secure, {
domain,
maxAge: 7 * 24 * 60 * 60,
});
}
export function clearUserSessionCookie(secure, domain = resolveCookieDomain()) {
return buildUserSessionCookie('', secure, { domain, maxAge: 0 });
}
/** Set shared-domain session and drop legacy host-only cookie from before H5_COOKIE_DOMAIN. */
export function userLoginCookies(token, secure, domain = resolveCookieDomain()) {
const cookies = [userSessionCookie(token, secure, domain)];
if (domain) {
cookies.push(clearUserSessionCookie(secure, null));
}
return cookies;
}
/** Share login cookie across MindSpace + Plaza subdomains (e.g. .tkmind.cn). */
export function resolveCookieDomain() {
const explicit = String(process.env.H5_COOKIE_DOMAIN ?? '').trim();
if (explicit) return explicit;
try {
const base = String(process.env.H5_PUBLIC_BASE_URL ?? '').trim();
if (!base) return null;
const hostname = new URL(base).hostname.toLowerCase();
if (hostname === 'tkmind.cn' || hostname.endsWith('.tkmind.cn')) {
return '.tkmind.cn';
}
} catch {
// ignore invalid base URL
}
return null;
}
/** 本地 localhost 开发时不设置 Domain,否则浏览器不会保存跨域 cookie。 */
export function resolveCookieDomainForRequest(req) {
const hostHeader = req?.get?.('host') ?? req?.hostname ?? '';
const hostname = String(hostHeader).split(':')[0].toLowerCase();
if (!hostname || hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') {
return null;
}
return resolveCookieDomain();
}