Extract memind_adm admin server, add local dev tooling, and remove image-generation.

Split platform admin and ops APIs into standalone admin-server.mjs with network guards; simplify billing to RMB token pricing, refactor user auth, and add rsync deploy plus local-test scripts and docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Your Name
2026-06-17 16:39:39 -07:00
parent ab0718938e
commit b0f5d6a51c
98 changed files with 5394 additions and 3010 deletions
+193 -217
View File
@@ -1,7 +1,6 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import argon2 from 'argon2';
import { computeDeltaCostCents, loadBillingConfig, normalizeTokenState } from './billing.mjs';
import { buildInsufficientBalancePayload, loadRechargeConfig } from './billing-recharge.mjs';
import {
@@ -65,38 +64,25 @@ function hashPasswordPbkdf2(password, salt) {
return crypto.pbkdf2Sync(password, salt, 100000, 64, 'sha512').toString('hex');
}
async function hashPasswordArgon2id(password, salt) {
if (typeof crypto.argon2Sync === 'function') {
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');
}
return (
await argon2.hash(password, {
type: argon2.argon2id,
salt: Buffer.from(salt, 'hex'),
memoryCost: ARGON2_MEMORY,
timeCost: ARGON2_PASSES,
function hashPasswordArgon2id(password, salt) {
return crypto
.argon2Sync(PASSWORD_ALGORITHM_ARGON2ID, {
message: password,
nonce: Buffer.from(salt, 'hex'),
parallelism: ARGON2_PARALLELISM,
hashLength: ARGON2_TAG_LENGTH,
raw: true,
tagLength: ARGON2_TAG_LENGTH,
memory: ARGON2_MEMORY,
passes: ARGON2_PASSES,
})
).toString('hex');
.toString('hex');
}
async function createPasswordRecord(password, algorithm = PASSWORD_ALGORITHM_ARGON2ID) {
function createPasswordRecord(password, algorithm = PASSWORD_ALGORITHM_ARGON2ID) {
const salt = crypto.randomBytes(16).toString('hex');
if (algorithm === PASSWORD_ALGORITHM_ARGON2ID) {
return {
salt,
passwordHash: await hashPasswordArgon2id(password, salt),
passwordHash: hashPasswordArgon2id(password, salt),
passwordAlgorithm: PASSWORD_ALGORITHM_ARGON2ID,
};
}
@@ -107,10 +93,10 @@ async function createPasswordRecord(password, algorithm = PASSWORD_ALGORITHM_ARG
};
}
async function verifyPassword(password, row) {
function verifyPassword(password, row) {
const algorithm = row.password_algorithm || PASSWORD_ALGORITHM_PBKDF2;
if (algorithm === PASSWORD_ALGORITHM_ARGON2ID) {
return safeEqual(await hashPasswordArgon2id(password, row.salt), row.password_hash);
return safeEqual(hashPasswordArgon2id(password, row.salt), row.password_hash);
}
return safeEqual(hashPasswordPbkdf2(password, row.salt), row.password_hash);
}
@@ -351,7 +337,7 @@ export function createUserAuth(pool, options = {}) {
return { ok: false, message: '请输入有效邮箱' };
}
const { salt, passwordHash, passwordAlgorithm } = await createPasswordRecord(password);
const { salt, passwordHash, passwordAlgorithm } = createPasswordRecord(password);
const userId = crypto.randomUUID();
const layout = await publishLayoutFor({ id: userId, username: normalized });
const workspaceRoot = layout.publishDir;
@@ -447,7 +433,7 @@ export function createUserAuth(pool, options = {}) {
return { ok: false, message: '用户名或密码错误' };
}
if (!(await verifyPassword(password, row))) {
if (!verifyPassword(password, row)) {
const current =
failure && failure.resetAt > now
? failure
@@ -461,7 +447,7 @@ export function createUserAuth(pool, options = {}) {
}
if ((row.password_algorithm || PASSWORD_ALGORITHM_PBKDF2) !== PASSWORD_ALGORITHM_ARGON2ID) {
const nextPassword = await createPasswordRecord(password);
const nextPassword = createPasswordRecord(password);
await pool.query(
`UPDATE h5_users
SET salt = ?, password_hash = ?, password_algorithm = ?, updated_at = ?
@@ -504,7 +490,7 @@ export function createUserAuth(pool, options = {}) {
return { ok: false, message: '账户已禁用,请联系管理员' };
}
const { salt, passwordHash, passwordAlgorithm } = await createPasswordRecord(password);
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 = ?`,
@@ -696,20 +682,40 @@ export function createUserAuth(pool, options = {}) {
return { ok: true, balanceCents };
};
const listUsers = async () => {
const listUsers = async ({ page = 1, pageSize = 20, search = '', role = '', status = '' } = {}) => {
const safePageSize = Math.min(Math.max(Number(pageSize) || 20, 1), 100);
const safePage = Math.max(Number(page) || 1, 1);
const offset = (safePage - 1) * safePageSize;
const params = [];
const clauses = [];
if (search) {
clauses.push('(u.username LIKE ? OR u.display_name LIKE ?)');
params.push(`%${search}%`, `%${search}%`);
}
if (role) { clauses.push('u.role = ?'); params.push(role); }
if (status) { clauses.push('u.status = ?'); params.push(status); }
const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
const [[{ total }]] = await pool.query(
`SELECT COUNT(*) AS total FROM h5_users u ${where}`,
params,
);
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`,
${where}
ORDER BY u.created_at DESC
LIMIT ${safePageSize} OFFSET ${offset}`,
params,
);
return rows.map((row) => ({
...publicUser(row),
createdAt: Number(row.created_at),
updatedAt: Number(row.updated_at),
}));
return {
users: rows.map((row) => ({ ...publicUser(row), createdAt: Number(row.created_at), updatedAt: Number(row.updated_at) })),
total: Number(total),
page: safePage,
pageSize: safePageSize,
};
};
const createUser = async ({
@@ -734,7 +740,7 @@ export function createUserAuth(pool, options = {}) {
const root = isAdmin
? path.resolve(workspaceRoot || path.join(usersRoot, normalized))
: (await publishLayoutFor({ id: userId, username: normalized })).publishDir;
const { salt, passwordHash, passwordAlgorithm } = await createPasswordRecord(password);
const { salt, passwordHash, passwordAlgorithm } = createPasswordRecord(password);
const now = Date.now();
const conn = await pool.getConnection();
@@ -926,65 +932,55 @@ export function createUserAuth(pool, options = {}) {
};
}
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();
// Serialize concurrent Finish handlers for the same session.
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 (?, ?, NULL, 0, 0, ?)
ON DUPLICATE KEY UPDATE agent_session_id = agent_session_id`,
[agentSessionId, userId, now],
);
const [stateRows] = await conn.query(
`SELECT last_accumulated_cost, last_input_tokens, last_output_tokens
FROM h5_session_billing_state
WHERE agent_session_id = ?
FOR UPDATE`,
[agentSessionId],
);
const stateRow = stateRows[0];
const previous = {
lastAccumulatedCost: stateRow?.last_accumulated_cost ?? null,
lastInputTokens: Number(stateRow?.last_input_tokens ?? 0),
lastOutputTokens: Number(stateRow?.last_output_tokens ?? 0),
};
if (
tokenState.accumulatedInputTokens <= previous.lastInputTokens &&
tokenState.accumulatedOutputTokens <= previous.lastOutputTokens
) {
await conn.commit();
const fresh = await getUserById(userId);
return {
ok: true,
costCents: 0,
balanceCents: fresh ? Number(fresh.balance_cents) : null,
tokensUsed: fresh ? Number(fresh.tokens_used ?? 0) : null,
deltaInputTokens: 0,
deltaOutputTokens: 0,
};
}
const costCents = computeDeltaCostCents(previous, tokenState, config);
const deltaIn = Math.max(0, tokenState.accumulatedInputTokens - previous.lastInputTokens);
const deltaOut = Math.max(0, tokenState.accumulatedOutputTokens - previous.lastOutputTokens);
const deltaTokens = deltaIn + deltaOut;
await conn.query(
`UPDATE h5_session_billing_state
SET last_accumulated_cost = ?, last_input_tokens = ?, last_output_tokens = ?, updated_at = ?
WHERE agent_session_id = ?`,
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,
agentSessionId,
],
);
@@ -1063,152 +1059,89 @@ export function createUserAuth(pool, options = {}) {
}
};
const getUsageSummary = async ({ userId = null } = {}) => {
const since24h = Date.now() - 24 * 60 * 60 * 1000;
if (userId) {
const user = await getUserById(userId);
if (!user) return null;
const [[usage24h]] = await pool.query(
`SELECT COUNT(*) AS request_count,
COALESCE(SUM(input_tokens), 0) AS input_tokens,
COALESCE(SUM(output_tokens), 0) AS output_tokens,
COALESCE(SUM(cost_cents), 0) AS cost_cents
FROM h5_usage_records
WHERE user_id = ? AND created_at >= ?`,
[userId, since24h],
const listUsageRecords = async ({ userId = null, page = 1, pageSize = 20, limit = null } = {}) => {
// legacy: if limit is passed (from dashboard summary), skip pagination
if (limit !== null) {
const safeLimit = Math.min(Math.max(Number(limit) || 50, 1), 200);
const params = [];
const where = userId ? 'WHERE r.user_id = ?' : '';
if (userId) 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,
);
const [[deductAll]] = await pool.query(
`SELECT COUNT(*) AS request_count,
COALESCE(SUM(ABS(amount_cents)), 0) AS cost_cents
FROM h5_billing_ledger
WHERE user_id = ? AND type = 'deduct'`,
[userId],
);
return {
allTime: {
requestCount: Number(deductAll.request_count),
inputTokens: null,
outputTokens: null,
totalTokens: Number(user.tokens_used ?? 0),
costCents: Number(user.spent_cents ?? deductAll.cost_cents),
},
last24h: {
requestCount: Number(usage24h.request_count),
inputTokens: Number(usage24h.input_tokens),
outputTokens: Number(usage24h.output_tokens),
totalTokens:
Number(usage24h.input_tokens) + Number(usage24h.output_tokens),
costCents: Number(usage24h.cost_cents),
},
};
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 [[wallet]] = await pool.query(
`SELECT COALESCE(SUM(tokens_used), 0) AS total_tokens FROM h5_user_wallets`,
);
const [[deductAll]] = await pool.query(
`SELECT COUNT(*) AS request_count,
COALESCE(SUM(ABS(amount_cents)), 0) AS cost_cents
FROM h5_billing_ledger
WHERE type = 'deduct'`,
);
const [[deduct24h]] = await pool.query(
`SELECT COUNT(*) AS request_count,
COALESCE(SUM(ABS(amount_cents)), 0) AS cost_cents,
COALESCE(SUM(tokens), 0) AS total_tokens
FROM h5_billing_ledger
WHERE type = 'deduct' AND created_at >= ?`,
[since24h],
);
return {
allTime: {
requestCount: Number(deductAll.request_count),
inputTokens: null,
outputTokens: null,
totalTokens: Number(wallet.total_tokens),
costCents: Number(deductAll.cost_cents),
},
last24h: {
requestCount: Number(deduct24h.request_count),
inputTokens: null,
outputTokens: null,
totalTokens: Number(deduct24h.total_tokens),
costCents: Number(deduct24h.cost_cents),
},
};
};
const listUsageRecords = async ({ userId = null, limit = 50 } = {}) => {
const safeLimit = Math.min(Math.max(Number(limit) || 50, 1), 200);
const safePageSize = Math.min(Math.max(Number(pageSize) || 50, 1), 200);
const safePage = Math.max(Number(page) || 1, 1);
const offset = (safePage - 1) * safePageSize;
const params = [];
let where = '';
if (userId) {
where = 'WHERE r.user_id = ?';
params.push(userId);
}
const where = userId ? 'WHERE r.user_id = ?' : '';
if (userId) params.push(userId);
const [[{ total }]] = await pool.query(
`SELECT COUNT(*) AS total FROM h5_usage_records r ${where}`,
params,
);
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
FROM h5_usage_records r JOIN h5_users u ON u.id = r.user_id
${where}
ORDER BY r.created_at DESC
LIMIT ${safeLimit}`,
LIMIT ${safePageSize} OFFSET ${offset}`,
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),
}));
return {
records: 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) })),
total: Number(total),
page: safePage,
pageSize: safePageSize,
};
};
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);
const listBillingLedger = async ({ userId = null, page = 1, pageSize = 20, limit = null, types = null } = {}) => {
const buildWhere = (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);
}
return clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
};
const mapRow = (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) });
// legacy: if limit is passed (from dashboard summary), skip pagination
if (limit !== null) {
const safeLimit = Math.min(Math.max(Number(limit) || 50, 1), 200);
const params = [];
const where = buildWhere(params);
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(mapRow);
}
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 safePageSize = Math.min(Math.max(Number(pageSize) || 50, 1), 200);
const safePage = Math.max(Number(page) || 1, 1);
const offset = (safePage - 1) * safePageSize;
const countParams = [];
const where = buildWhere(countParams);
const [[{ total }]] = await pool.query(
`SELECT COUNT(*) AS total FROM h5_billing_ledger l ${where}`,
countParams,
);
const dataParams = [];
const dataWhere = buildWhere(dataParams);
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}
FROM h5_billing_ledger l JOIN h5_users u ON u.id = l.user_id
${dataWhere}
ORDER BY l.created_at DESC
LIMIT ${safeLimit}`,
params,
LIMIT ${safePageSize} OFFSET ${offset}`,
dataParams,
);
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),
}));
return { entries: rows.map(mapRow), total: Number(total), page: safePage, pageSize: safePageSize };
};
const getAdminSummary = async () => {
@@ -1277,7 +1210,7 @@ export function createUserAuth(pool, options = {}) {
);
if (rows.length === 0) return;
const { salt, passwordHash, passwordAlgorithm } = await createPasswordRecord(adminPassword);
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 = ?`,
@@ -1444,11 +1377,27 @@ export function createUserAuth(pool, options = {}) {
capabilityState.capabilities,
policyState.policies,
);
// For static_publish users, wire up the sandbox MCP so file operations are
// enforced at the OS level rather than relying on text prompt constraints.
let sandboxMcp = null;
if (effectiveCapabilities.static_publish) {
try {
const layout = await publishLayoutFor(user, { migrateLegacy: false });
sandboxMcp = {
serverPath: path.join(h5Root, 'mindspace-sandbox-mcp.mjs'),
sandboxRoot: layout.publishDir,
};
} catch (err) {
console.warn('[getAgentSessionPolicy] sandbox MCP setup failed, falling back:', err?.message);
}
}
return {
...buildAgentExtensionPolicy(effectiveCapabilities, {
unrestricted: false,
policies: policyState.policies,
sandboxMcp,
}),
capabilities: effectiveCapabilities,
policies: policyState.policies,
unrestricted: false,
};
@@ -1937,7 +1886,7 @@ export function createUserAuth(pool, options = {}) {
}) => {
const normalized = await generateWechatUsername(openid);
const randomPassword = crypto.randomBytes(24).toString('base64url');
const { salt, passwordHash, passwordAlgorithm } = await createPasswordRecord(randomPassword);
const { salt, passwordHash, passwordAlgorithm } = createPasswordRecord(randomPassword);
const userId = crypto.randomUUID();
const layout = await publishLayoutFor({ id: userId, username: normalized });
const workspaceRoot = layout.publishDir;
@@ -2255,7 +2204,6 @@ export function createUserAuth(pool, options = {}) {
recharge,
billSessionUsage,
listUsageRecords,
getUsageSummary,
listBillingLedger,
getAdminSummary,
ensureAdminUser,
@@ -2328,6 +2276,9 @@ export function resolveCookieDomain() {
const base = String(process.env.H5_PUBLIC_BASE_URL ?? '').trim();
if (!base) return null;
const hostname = new URL(base).hostname.toLowerCase();
if (hostname === 'localhost' || hostname.endsWith('.localhost')) {
return '.localhost';
}
if (hostname === 'tkmind.cn' || hostname.endsWith('.tkmind.cn')) {
return '.tkmind.cn';
}
@@ -2339,8 +2290,33 @@ export function resolveCookieDomain() {
/** 本地 localhost 开发时不设置 Domain,否则浏览器不会保存跨域 cookie。 */
export function resolveCookieDomainForRequest(req) {
const hostHeader = req?.get?.('host') ?? req?.hostname ?? '';
const hostname = String(hostHeader).split(':')[0].toLowerCase();
const explicit = String(process.env.H5_COOKIE_DOMAIN ?? '').trim();
if (explicit) return explicit;
const hostCandidates = [
req?.get?.('x-forwarded-host'),
req?.get?.('host'),
req?.hostname,
];
const origin = req?.get?.('origin');
if (origin) {
try {
hostCandidates.push(new URL(origin).host);
} catch {
// ignore invalid origin
}
}
for (const raw of hostCandidates) {
const hostname = String(raw ?? '').split(':')[0].toLowerCase();
if (hostname.endsWith('.localhost')) {
return '.localhost';
}
}
const hostname = String(req?.get?.('host') ?? req?.hostname ?? '')
.split(':')[0]
.toLowerCase();
if (!hostname || hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') {
return null;
}