From b5c600ad8165e4c3775952608373d5d74d806b5c Mon Sep 17 00:00:00 2001 From: john Date: Tue, 16 Jun 2026 20:55:23 +0800 Subject: [PATCH] Split back-office into standalone memind_adm service Carve the platform super-admin API (/admin-api) and plaza operations console API (/api/ops/v1) out of the public server.mjs into their own process, so the user-facing fleet can no longer be taken down or scaled by back-office traffic. Architecture (split-ready, single process for now): - admin-routes.mjs: createAdminApi / createOpsApi route factories (DI, single source of truth; the route logic moved verbatim out of server.mjs). - admin-bootstrap.mjs: lean service container (user-auth, LLM providers, minimal plaza graph) with no user-facing daemons. - admin-server.mjs: standalone entry with a console registry. ADMIN_CONSOLES selects which consoles a process mounts (default both), so splitting into two processes later is a config change, not a code change. - admin-guard.mjs (+ tests): per-console host / IP-CIDR allowlists, letting the super-admin surface be locked down harder than moderation. server.mjs no longer serves or mounts either surface. user-auth.mjs gains pagination on listUsers/listUsageRecords/listBillingLedger to back the admin dashboards. dev.mjs launches memind_adm; the ops SPA proxies /api to it while /auth stays on the portal. Sessions are read from the shared cookie (H5_COOKIE_DOMAIN=.tkmind.cn); login stays on the main domain. Co-Authored-By: Claude Opus 4.8 --- .env.example | 18 ++ admin-bootstrap.mjs | 90 ++++++++ admin-guard.mjs | 78 +++++++ admin-guard.test.mjs | 67 ++++++ admin-routes.mjs | 484 +++++++++++++++++++++++++++++++++++++++++++ admin-server.mjs | 194 +++++++++++++++++ ops/vite.config.ts | 9 +- package.json | 3 +- scripts/dev.mjs | 15 +- server.mjs | 440 +-------------------------------------- user-auth.mjs | 151 +++++++++----- vite.config.ts | 4 +- 12 files changed, 1051 insertions(+), 502 deletions(-) create mode 100644 admin-bootstrap.mjs create mode 100644 admin-guard.mjs create mode 100644 admin-guard.test.mjs create mode 100644 admin-routes.mjs create mode 100644 admin-server.mjs diff --git a/.env.example b/.env.example index 6b0a786..5b64153 100644 --- a/.env.example +++ b/.env.example @@ -19,6 +19,24 @@ H5_PUBLIC_BASE_URL=http://127.0.0.1:8081 # MindSpace 与 Plaza 跨子域共享登录(如 g2.tkmind.cn + plaza.tkmind.cn) # H5_COOKIE_DOMAIN=.tkmind.cn +# ===== memind_adm(独立后台服务)===== +# 平台超管 /admin-api/* 与广场运营 /api/ops/v1/* 已从公网服务拆出,独立进程运行。 +# 登录仍在主域完成;后台凭 H5_COOKIE_DOMAIN=.tkmind.cn 共享的会话 Cookie 鉴权(生产务必设置)。 +ADMIN_PORT=8082 +ADMIN_HOST=127.0.0.1 +ADMIN_PUBLIC_HOST=gadm.tkmind.cn + +# 本进程挂载哪些控制台(可拆分架构):admin=平台超管, ops=广场运营。 +# 默认两者合一;将来拆成两个进程时,A 实例设 admin、B 实例设 ops,路由代码无需改动。 +# ADMIN_CONSOLES=admin,ops + +# 每控制台独立网络护栏(逗号分隔;留空=放开。支持精确 IP 与 CIDR)。 +# 超管面建议收得最紧(仅内网/VPN),广场运营可放宽给审核团队网段。 +# ADMIN_API_ALLOWED_HOSTS=gadm.tkmind.cn +# ADMIN_API_IP_ALLOWLIST=10.0.0.0/8,127.0.0.1 +# OPS_API_ALLOWED_HOSTS=gadm.tkmind.cn +# OPS_API_IP_ALLOWLIST= + # 多用户系统(MySQL 配置后启用;未配置时回退到 H5_ACCESS_PASSWORD) DATABASE_URL=mysql://boot:password@localhost:3306/tkmind # 或分别设置 MYSQL_HOST / MYSQL_PORT / MYSQL_USER / MYSQL_PASSWORD / MYSQL_DATABASE diff --git a/admin-bootstrap.mjs b/admin-bootstrap.mjs new file mode 100644 index 0000000..f16a7ba --- /dev/null +++ b/admin-bootstrap.mjs @@ -0,0 +1,90 @@ +// admin-bootstrap.mjs +// +// Service container for the standalone memind_adm process. +// +// Builds only the domain services the back-office routes need (user-auth, LLM +// providers, and the minimal plaza graph behind the ops console). It deliberately +// does NOT start any user-facing daemons — no thumbnail watcher, no asset-sync +// watcher, no plaza hot-score scheduler, no goosed auto-sync. Those belong to the +// public server. Side-effecting actions (e.g. push selected LLM key to goosed) +// stay behind explicit admin routes. +// +// The same factory functions imported here are the ones server.mjs uses, so the +// domain logic has a single source of truth; only the wiring differs. +import path from 'node:path'; +import { createDbPool, isDatabaseConfigured } from './db.mjs'; +import { createUserAuth } from './user-auth.mjs'; +import { createLlmProviderService } from './llm-providers.mjs'; +import { createPlazaPostService, formatPostRow } from './plaza-posts.mjs'; +import { createPlazaInteractionService } from './plaza-interactions.mjs'; +import { createPlazaOpsService } from './plaza-ops.mjs'; +import { createNoopPlazaRedis } from './plaza-redis.mjs'; +import { ensureAlgorithmConfig, loadAlgorithmConfig } from './plaza-algorithm.mjs'; + +const noop = () => {}; + +/** + * Build the admin service container. + * @param {object} [env] + * @param {string} [env.h5Root] Repo root used for workspace path resolution. + * @param {string} [env.usersRoot] User workspace root. + * @param {string} [env.apiTarget] Goosed/relay API target. + * @param {string} [env.apiSecret] Goosed/relay API secret. + * @param {number} [env.defaultSignupBalanceCents] + * @param {boolean} [env.ensureAdminUser] Ensure an admin account exists on boot (default true). + * @returns {Promise<{pool, userAuth, llmProviderService, plazaPosts, plazaOps}>} + */ +export async function createAdminServices(env = {}) { + if (!isDatabaseConfigured()) { + throw new Error('admin-server requires a database (set DATABASE_URL or MYSQL_* env)'); + } + + const h5Root = env.h5Root ?? process.cwd(); + const usersRoot = env.usersRoot ?? process.env.H5_USERS_ROOT ?? path.join(h5Root, 'users'); + const apiTarget = env.apiTarget ?? process.env.TKMIND_API_TARGET ?? 'https://127.0.0.1:18006'; + const apiSecret = env.apiSecret ?? process.env.TKMIND_SERVER__SECRET_KEY ?? 'local-dev-secret'; + const defaultSignupBalanceCents = Number( + env.defaultSignupBalanceCents ?? process.env.H5_SIGNUP_BALANCE_CENTS ?? 500, + ); + + const pool = createDbPool(); + + // --- plaza graph (review queue, reports, featured, analytics, creators) --- + const plazaRedis = createNoopPlazaRedis(); + await ensureAlgorithmConfig(pool); + const algorithmConfig = await loadAlgorithmConfig(pool); + + const plazaInteractions = createPlazaInteractionService(pool, { formatPostRow, plazaRedis }); + + let plazaOps = null; + const plazaPosts = createPlazaPostService(pool, { + loadViewerReactions: (viewerId, postIds) => + plazaInteractions.loadViewerReactions(viewerId, postIds), + plazaRedis, + algorithmConfig, + onPostPublished: noop, + loadFeaturedPosts: async (viewerId) => { + if (!plazaOps) return { homepage_banner: [], trending: [], category_top: {} }; + return plazaOps.loadActiveFeaturedPosts(viewerId); + }, + }); + plazaOps = createPlazaOpsService(pool, { + formatPostRow, + reviewPost: (...args) => plazaPosts.reviewPost(...args), + invalidateFeedCaches: () => plazaRedis?.invalidateFeedCaches?.(), + }); + + // --- platform super-admin services --- + const userAuth = createUserAuth(pool, { + usersRoot, + h5Root, + defaultSignupBalanceCents, + }); + if (env.ensureAdminUser !== false) { + await userAuth.ensureAdminUser(); + } + + const llmProviderService = createLlmProviderService(pool, { apiTarget, apiSecret }); + + return { pool, userAuth, llmProviderService, plazaPosts, plazaOps }; +} diff --git a/admin-guard.mjs b/admin-guard.mjs new file mode 100644 index 0000000..3fe252b --- /dev/null +++ b/admin-guard.mjs @@ -0,0 +1,78 @@ +// admin-guard.mjs +// +// Pure, testable network-guard helpers for the memind_adm consoles. +// +// Each back-office console (platform super-admin, plaza operations) can be +// pinned to an allowlist of request hosts and/or client IPs, independently. +// This lets the super-admin surface be locked down harder than moderation +// while both still live in one process — and the same knobs keep working +// unchanged once the consoles are split into separate processes. + +export function parseList(value) { + return String(value ?? '') + .split(',') + .map((s) => s.trim()) + .filter(Boolean); +} + +export function hostAllowed(reqHost, allowedHosts) { + if (!allowedHosts.length) return true; + const hostname = String(reqHost ?? '').split(':')[0].toLowerCase(); + return allowedHosts.some((h) => h.toLowerCase() === hostname); +} + +function ipv4ToInt(ip) { + const parts = ip.split('.'); + if (parts.length !== 4) return null; + let acc = 0; + for (const part of parts) { + if (!/^\d{1,3}$/.test(part)) return null; + const n = Number(part); + if (n < 0 || n > 255) return null; + acc = (acc * 256 + n) >>> 0; + } + return acc >>> 0; +} + +// Strip an IPv4-mapped IPv6 prefix so "::ffff:127.0.0.1" matches "127.0.0.1". +function normalizeIp(ip) { + return String(ip ?? '').replace(/^::ffff:/i, ''); +} + +export function ipMatches(clientIp, rule) { + const ip = normalizeIp(clientIp); + if (rule.includes('/')) { + const [base, bitsStr] = rule.split('/'); + const bits = Number(bitsStr); + const ipInt = ipv4ToInt(ip); + const baseInt = ipv4ToInt(base); + if (ipInt === null || baseInt === null) return false; + if (!Number.isInteger(bits) || bits < 0 || bits > 32) return false; + if (bits === 0) return true; + const mask = bits === 32 ? 0xffffffff : (~((1 << (32 - bits)) - 1)) >>> 0; + return (ipInt & mask) === (baseInt & mask); + } + return ip === normalizeIp(rule); +} + +export function ipAllowed(clientIp, ipAllowlist) { + if (!ipAllowlist.length) return true; + return ipAllowlist.some((rule) => ipMatches(clientIp, rule)); +} + +/** + * Build an Express middleware enforcing host + IP allowlists for one console. + * Returns null when neither list is configured (no-op — common in dev). + * @param {object} opts + * @param {string[]} [opts.allowedHosts] + * @param {string[]} [opts.ipAllowlist] + * @param {(res, req, reason: 'host'|'ip') => unknown} opts.onDeny + */ +export function buildNetworkGuard({ allowedHosts = [], ipAllowlist = [], onDeny }) { + if (!allowedHosts.length && !ipAllowlist.length) return null; + return (req, res, next) => { + if (!hostAllowed(req.get('host'), allowedHosts)) return onDeny(res, req, 'host'); + if (!ipAllowed(req.ip, ipAllowlist)) return onDeny(res, req, 'ip'); + return next(); + }; +} diff --git a/admin-guard.test.mjs b/admin-guard.test.mjs new file mode 100644 index 0000000..5d51077 --- /dev/null +++ b/admin-guard.test.mjs @@ -0,0 +1,67 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + parseList, + hostAllowed, + ipMatches, + ipAllowed, + buildNetworkGuard, +} from './admin-guard.mjs'; + +test('parseList splits, trims, and drops empties', () => { + assert.deepEqual(parseList('a, b ,,c'), ['a', 'b', 'c']); + assert.deepEqual(parseList(''), []); + assert.deepEqual(parseList(undefined), []); +}); + +test('hostAllowed: empty list allows all, otherwise matches hostname ignoring port/case', () => { + assert.equal(hostAllowed('gadm.tkmind.cn:8082', []), true); + assert.equal(hostAllowed('GADM.tkmind.cn', ['gadm.tkmind.cn']), true); + assert.equal(hostAllowed('evil.example.com', ['gadm.tkmind.cn']), false); + assert.equal(hostAllowed('gadm.tkmind.cn:443', ['gadm.tkmind.cn', 'localhost']), true); +}); + +test('ipMatches: exact and IPv4-mapped IPv6', () => { + assert.equal(ipMatches('127.0.0.1', '127.0.0.1'), true); + assert.equal(ipMatches('::ffff:127.0.0.1', '127.0.0.1'), true); + assert.equal(ipMatches('10.0.0.5', '10.0.0.6'), false); +}); + +test('ipMatches: CIDR ranges', () => { + assert.equal(ipMatches('10.1.2.3', '10.0.0.0/8'), true); + assert.equal(ipMatches('11.1.2.3', '10.0.0.0/8'), false); + assert.equal(ipMatches('192.168.1.50', '192.168.1.0/24'), true); + assert.equal(ipMatches('192.168.2.50', '192.168.1.0/24'), false); + assert.equal(ipMatches('203.0.113.9', '0.0.0.0/0'), true); + assert.equal(ipMatches('not-an-ip', '10.0.0.0/8'), false); +}); + +test('ipAllowed: empty list allows all', () => { + assert.equal(ipAllowed('1.2.3.4', []), true); + assert.equal(ipAllowed('1.2.3.4', ['10.0.0.0/8']), false); + assert.equal(ipAllowed('10.9.9.9', ['10.0.0.0/8', '127.0.0.1']), true); +}); + +test('buildNetworkGuard returns null when nothing configured', () => { + assert.equal(buildNetworkGuard({ onDeny: () => {} }), null); +}); + +test('buildNetworkGuard denies on host then ip, allows when both pass', () => { + const guard = buildNetworkGuard({ + allowedHosts: ['gadm.tkmind.cn'], + ipAllowlist: ['10.0.0.0/8'], + onDeny: (_res, _req, reason) => `deny:${reason}`, + }); + const mk = (host, ip) => ({ get: () => host, ip }); + + let nexted = false; + const next = () => { + nexted = true; + }; + + assert.equal(guard(mk('evil.com', '10.1.1.1'), {}, next), 'deny:host'); + assert.equal(guard(mk('gadm.tkmind.cn', '8.8.8.8'), {}, next), 'deny:ip'); + assert.equal(nexted, false); + guard(mk('gadm.tkmind.cn', '10.1.1.1'), {}, next); + assert.equal(nexted, true); +}); diff --git a/admin-routes.mjs b/admin-routes.mjs new file mode 100644 index 0000000..e17499a --- /dev/null +++ b/admin-routes.mjs @@ -0,0 +1,484 @@ +// admin-routes.mjs +// +// Back-office HTTP route factories for the standalone memind_adm service. +// +// Route logic lives here exactly once. The public server (server.mjs) no longer +// serves these surfaces; admin-server.mjs is the sole consumer. Services are +// injected so the same router can be wired against any container (tests, future +// read-replica pool, etc.). +// +// createAdminApi -> /admin-api/* platform super-admin (role === 'admin') +// createOpsApi -> /api/ops/v1/* plaza operations console (ops_role) +import express from 'express'; +import { sendData, sendError } from './api-response.mjs'; +import { hasOpsRole } from './plaza-ops.mjs'; +import { mapPlazaError } from './plaza-posts.mjs'; + +function plazaRouteError(res, req, error) { + const status = mapPlazaError(error); + const code = error?.code ?? 'internal_error'; + const message = error instanceof Error ? error.message : 'Plaza 请求失败'; + return sendError(res, req, status, code, message, error?.details); +} + +/** + * Platform super-admin API (role === 'admin'). + * @param {object} deps + * @param {import('express').RequestHandler} deps.jsonBody JSON body parser middleware. + * @param {(req: import('express').Request) => (string|undefined)} deps.getToken Extract the user session token from a request. + * @param {Promise} [deps.ready] Optional bootstrap gate awaited before the first request resolves auth. + * @param {object} deps.userAuth + * @param {object|null} deps.llmProviderService + * @param {object|null} deps.plazaPosts + * @param {object|null} deps.plazaOps + */ +export function createAdminApi({ jsonBody, getToken, ready, userAuth, llmProviderService, plazaPosts, plazaOps }) { + function requireAdmin(req, res, next) { + if (!req.currentUser || req.currentUser.role !== 'admin') { + res.status(403).json({ message: '需要管理员权限' }); + return; + } + next(); + } + + const adminApi = express.Router(); + adminApi.use(jsonBody); + + adminApi.use(async (req, res, next) => { + if (ready) await ready; + if (!userAuth) return res.status(503).json({ message: '未启用用户系统' }); + const me = await userAuth.getMe(getToken(req)); + if (!me) return res.status(401).json({ message: '未登录' }); + req.currentUser = me; + next(); + }); + + adminApi.get('/users', requireAdmin, async (req, res) => { + const { page, pageSize, search, role, status } = req.query; + const result = await userAuth.listUsers({ page, pageSize, search, role, status }); + res.json(result); + }); + + adminApi.get('/summary', requireAdmin, async (_req, res) => { + const summary = await userAuth.getAdminSummary(); + let llm = null; + if (llmProviderService) { + const keys = await llmProviderService.listKeys(); + const global = await llmProviderService.getGlobalSettings(); + const selected = keys.find((key) => key.isSelected); + llm = { + keyCount: keys.length, + selectedKeyName: selected?.name ?? null, + globalModel: global?.model ?? null, + }; + } + res.json({ summary: { ...summary, llm } }); + }); + + adminApi.post('/users', requireAdmin, async (req, res) => { + const result = await userAuth.createUser(req.body ?? {}); + if (!result.ok) return res.status(400).json({ message: result.message }); + res.status(201).json({ user: result.user }); + }); + + adminApi.patch('/users/:userId', requireAdmin, async (req, res) => { + const result = await userAuth.updateUser(req.params.userId, req.body ?? {}); + if (!result.ok) return res.status(400).json({ message: result.message }); + res.json({ user: result.user }); + }); + + adminApi.post('/users/:userId/recharge', requireAdmin, async (req, res) => { + const amountCents = Number(req.body?.amountCents); + const note = typeof req.body?.note === 'string' ? req.body.note : ''; + const result = await userAuth.recharge( + req.params.userId, + amountCents, + req.currentUser.id, + note, + ); + if (!result.ok) return res.status(400).json({ message: result.message }); + res.json({ user: result.user }); + }); + + adminApi.get('/usage', requireAdmin, async (req, res) => { + const userId = typeof req.query.userId === 'string' ? req.query.userId : null; + const { page, pageSize } = req.query; + const result = await userAuth.listUsageRecords({ userId, page, pageSize }); + res.json(result); + }); + + adminApi.get('/capabilities/catalog', requireAdmin, (_req, res) => { + res.json({ catalog: userAuth.capabilityCatalog }); + }); + + adminApi.get('/capabilities/role/:role', requireAdmin, async (req, res) => { + const role = req.params.role === 'admin' ? 'admin' : 'user'; + if (role === 'admin') { + return res.json({ + role, + capabilities: Object.fromEntries( + userAuth.capabilityCatalog.map((item) => [item.key, true]), + ), + unrestricted: true, + }); + } + res.json(await userAuth.getRoleCapabilities('user')); + }); + + adminApi.put('/capabilities/role/:role', requireAdmin, async (req, res) => { + const role = req.params.role === 'admin' ? 'admin' : 'user'; + const result = await userAuth.setRoleCapabilities(role, req.body?.capabilities ?? {}); + if (!result.ok) return res.status(400).json({ message: result.message }); + res.json(result); + }); + + adminApi.get('/users/:userId/capabilities', requireAdmin, async (req, res) => { + const result = await userAuth.getUserCapabilities(req.params.userId); + if (!result.ok) return res.status(404).json({ message: result.message }); + res.json(result); + }); + + adminApi.put('/users/:userId/capabilities', requireAdmin, async (req, res) => { + const result = await userAuth.setUserCapabilities( + req.params.userId, + req.body?.capabilities ?? {}, + ); + if (!result.ok) return res.status(400).json({ message: result.message }); + res.json(result); + }); + + adminApi.delete('/users/:userId/capabilities', requireAdmin, async (req, res) => { + const result = await userAuth.clearUserCapabilityOverrides(req.params.userId); + if (!result.ok) return res.status(404).json({ message: result.message }); + res.json(result); + }); + + adminApi.get('/policies/catalog', requireAdmin, (_req, res) => { + res.json({ catalog: userAuth.policyCatalog }); + }); + + adminApi.get('/policies/role/:role', requireAdmin, async (req, res) => { + const role = req.params.role === 'admin' ? 'admin' : 'user'; + if (role === 'admin') { + return res.json({ role, policies: {}, unrestricted: true }); + } + res.json(await userAuth.getRolePolicies('user')); + }); + + adminApi.put('/policies/role/:role', requireAdmin, async (req, res) => { + const role = req.params.role === 'admin' ? 'admin' : 'user'; + const result = await userAuth.setRolePolicies(role, req.body?.policies ?? {}); + if (!result.ok) return res.status(400).json({ message: result.message }); + res.json(result); + }); + + adminApi.get('/users/:userId/policies', requireAdmin, async (req, res) => { + const result = await userAuth.getUserPolicies(req.params.userId); + if (!result.ok) return res.status(404).json({ message: result.message }); + res.json(result); + }); + + adminApi.put('/users/:userId/policies', requireAdmin, async (req, res) => { + const result = await userAuth.setUserPolicies(req.params.userId, req.body?.policies ?? {}); + if (!result.ok) return res.status(400).json({ message: result.message }); + res.json(result); + }); + + adminApi.delete('/users/:userId/policies', requireAdmin, async (req, res) => { + const result = await userAuth.clearUserPolicyOverrides(req.params.userId); + if (!result.ok) return res.status(404).json({ message: result.message }); + res.json(result); + }); + + adminApi.get('/skills/catalog', requireAdmin, (_req, res) => { + res.json({ catalog: userAuth.skillCatalog }); + }); + + adminApi.get('/skills/role/:role', requireAdmin, async (req, res) => { + const role = req.params.role === 'admin' ? 'admin' : 'user'; + if (role === 'admin') { + return res.json({ + role, + skills: Object.fromEntries(userAuth.skillCatalog.map((item) => [item.name, true])), + }); + } + res.json(await userAuth.getRoleSkills('user')); + }); + + adminApi.put('/skills/role/:role', requireAdmin, async (req, res) => { + const role = req.params.role === 'admin' ? 'admin' : 'user'; + const result = await userAuth.setRoleSkills(role, req.body?.skills ?? {}); + if (!result.ok) return res.status(400).json({ message: result.message }); + res.json(result); + }); + + adminApi.get('/users/:userId/skills', requireAdmin, async (req, res) => { + const result = await userAuth.getUserSkills(req.params.userId); + if (!result.ok) return res.status(404).json({ message: result.message }); + res.json(result); + }); + + adminApi.put('/users/:userId/skills', requireAdmin, async (req, res) => { + const result = await userAuth.setUserSkills(req.params.userId, req.body?.skills ?? {}); + if (!result.ok) return res.status(400).json({ message: result.message }); + res.json(result); + }); + + adminApi.delete('/users/:userId/skills', requireAdmin, async (req, res) => { + const result = await userAuth.clearUserSkillOverrides(req.params.userId); + if (!result.ok) return res.status(404).json({ message: result.message }); + res.json(result); + }); + + adminApi.get('/ledger', requireAdmin, async (req, res) => { + const userId = typeof req.query.userId === 'string' ? req.query.userId : null; + const { page, pageSize } = req.query; + const result = await userAuth.listBillingLedger({ userId, page, pageSize }); + res.json(result); + }); + + adminApi.get('/llm-providers/catalog', requireAdmin, (_req, res) => { + if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' }); + res.json({ catalog: llmProviderService.catalog }); + }); + + adminApi.get('/llm-providers/keys', requireAdmin, async (_req, res) => { + if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' }); + const keys = await llmProviderService.listKeys(); + res.json({ keys }); + }); + + adminApi.post('/llm-providers/keys', requireAdmin, async (req, res) => { + if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' }); + const result = await llmProviderService.createKey(req.body ?? {}); + if (!result.ok) return res.status(400).json({ message: result.message }); + res.status(201).json({ key: result.key }); + }); + + adminApi.patch('/llm-providers/keys/:keyId', requireAdmin, async (req, res) => { + if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' }); + const result = await llmProviderService.updateKey(req.params.keyId, req.body ?? {}); + if (!result.ok) return res.status(400).json({ message: result.message }); + res.json({ key: result.key }); + }); + + adminApi.post('/llm-providers/keys/:keyId/select', requireAdmin, async (req, res) => { + if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' }); + const result = await llmProviderService.selectKey(req.params.keyId); + if (!result.ok) return res.status(400).json({ message: result.message }); + res.json({ key: result.key }); + }); + + adminApi.delete('/llm-providers/keys/:keyId', requireAdmin, async (req, res) => { + if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' }); + const result = await llmProviderService.deleteKey(req.params.keyId); + if (!result.ok) return res.status(400).json({ message: result.message }); + res.json({ ok: true }); + }); + + adminApi.post('/llm-providers/sync', requireAdmin, async (_req, res) => { + if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' }); + try { + const result = await llmProviderService.syncSelectedToGoosed(); + res.json(result); + } catch (err) { + res.status(500).json({ + message: err instanceof Error ? err.message : '同步失败', + }); + } + }); + + adminApi.get('/llm-providers/global', requireAdmin, async (_req, res) => { + if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' }); + const global = await llmProviderService.getGlobalSettings(); + res.json({ global }); + }); + + adminApi.put('/llm-providers/global', requireAdmin, async (req, res) => { + if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' }); + const result = await llmProviderService.setGlobalModel(req.body?.model); + if (!result.ok) return res.status(400).json({ message: result.message }); + res.json(result); + }); + + adminApi.post('/llm-providers/test', requireAdmin, async (req, res) => { + if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' }); + try { + const result = await llmProviderService.testDraft(req.body ?? {}); + res.json(result); + } catch (err) { + res.status(500).json({ + ok: false, + message: err instanceof Error ? err.message : '联通测试失败', + }); + } + }); + + adminApi.post('/llm-providers/keys/:keyId/test', requireAdmin, async (req, res) => { + if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' }); + try { + const result = await llmProviderService.testKey(req.params.keyId, req.body?.model); + res.json(result); + } catch (err) { + res.status(500).json({ + ok: false, + message: err instanceof Error ? err.message : '联通测试失败', + }); + } + }); + + adminApi.get('/plaza/pending', requireAdmin, async (_req, res) => { + if (!plazaPosts) return res.status(503).json({ message: 'Plaza 未启用' }); + const posts = await plazaPosts.listPendingPosts(); + res.json({ data: { posts } }); + }); + + adminApi.post('/plaza/posts/:id/review', requireAdmin, async (req, res) => { + if (!plazaPosts) return res.status(503).json({ message: 'Plaza 未启用' }); + try { + const action = String(req.body?.action ?? 'approve'); + const reason = req.body?.reason ?? null; + const result = + plazaOps && req.currentUser?.id + ? await plazaOps.reviewPostAsOps(req.currentUser.id, req.params.id, action, { reason }) + : await plazaPosts.reviewPost(req.params.id, action, { reason }); + res.json({ data: result }); + } catch (error) { + const status = mapPlazaError(error); + res.status(status).json({ + error: { code: error?.code ?? 'review_failed', message: error.message }, + }); + } + }); + + return adminApi; +} + +/** + * Plaza operations console API (ops_role: reviewer < editor < ops_admin). + * @param {object} deps + * @param {import('express').RequestHandler} deps.jsonBody + * @param {object|null} deps.plazaOps + */ +export function createOpsApi({ jsonBody, plazaOps }) { + function makeRequireOps(minRole = 'reviewer') { + return async (req, res, next) => { + if (!plazaOps) return sendError(res, req, 503, 'plaza_unavailable', 'Plaza 未启用'); + if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录'); + const role = await plazaOps.loadOperatorRole(req.currentUser.id); + if (!hasOpsRole(role, minRole)) { + return sendError(res, req, 403, 'OPS_PERMISSION_DENIED', '无运营权限'); + } + req.opsRole = role; + return next(); + }; + } + + const opsApi = express.Router(); + opsApi.use(jsonBody); + opsApi.use(makeRequireOps('reviewer')); + + opsApi.get('/review/queue', async (req, res) => { + try { + const queue = await plazaOps.listReviewQueue({ + status: req.query.status ?? 'pending_review', + cursor: req.query.cursor ?? null, + limit: req.query.limit, + keyword: req.query.keyword ?? null, + }); + return sendData(res, req, queue); + } catch (error) { + return plazaRouteError(res, req, error); + } + }); + + opsApi.post('/review/posts/:id', async (req, res) => { + try { + const result = await plazaOps.reviewPostAsOps(req.currentUser.id, req.params.id, req.body?.action, { + reason: req.body?.reason ?? null, + }); + return sendData(res, req, { post: result }); + } catch (error) { + return plazaRouteError(res, req, error); + } + }); + + opsApi.post('/review/batch', makeRequireOps('editor'), async (req, res) => { + try { + const result = await plazaOps.batchReviewPosts(req.currentUser.id, req.body ?? {}); + return sendData(res, req, result); + } catch (error) { + return plazaRouteError(res, req, error); + } + }); + + opsApi.get('/reports', async (req, res) => { + try { + return sendData(res, req, await plazaOps.listReports({ status: req.query.status ?? 'pending' })); + } catch (error) { + return plazaRouteError(res, req, error); + } + }); + + opsApi.post('/reports/:id/process', async (req, res) => { + try { + const result = await plazaOps.processReport(req.currentUser.id, req.params.id, req.body ?? {}); + return sendData(res, req, { report: result }); + } catch (error) { + return plazaRouteError(res, req, error); + } + }); + + opsApi.get('/featured', makeRequireOps('editor'), async (req, res) => { + try { + return sendData(res, req, await plazaOps.listFeatured()); + } catch (error) { + return plazaRouteError(res, req, error); + } + }); + + opsApi.post('/featured', makeRequireOps('editor'), async (req, res) => { + try { + const result = await plazaOps.setFeatured(req.currentUser.id, req.body ?? {}); + return sendData(res, req, { featured: result }, 201); + } catch (error) { + return plazaRouteError(res, req, error); + } + }); + + opsApi.delete('/featured/:id', makeRequireOps('editor'), async (req, res) => { + try { + const result = await plazaOps.removeFeatured(req.currentUser.id, req.params.id); + return sendData(res, req, { featured: result }); + } catch (error) { + return plazaRouteError(res, req, error); + } + }); + + opsApi.get('/analytics/overview', makeRequireOps('ops_admin'), async (req, res) => { + try { + return sendData(res, req, await plazaOps.getAnalyticsOverview()); + } catch (error) { + return plazaRouteError(res, req, error); + } + }); + + opsApi.get('/creators', async (req, res) => { + try { + return sendData(res, req, await plazaOps.listCreators({ keyword: req.query.keyword ?? null })); + } catch (error) { + return plazaRouteError(res, req, error); + } + }); + + opsApi.patch('/creators/:userId', async (req, res) => { + try { + const result = await plazaOps.updateCreator(req.currentUser.id, req.params.userId, req.body ?? {}); + return sendData(res, req, { creator: result }); + } catch (error) { + return plazaRouteError(res, req, error); + } + }); + + return opsApi; +} diff --git a/admin-server.mjs b/admin-server.mjs new file mode 100644 index 0000000..7ed3edb --- /dev/null +++ b/admin-server.mjs @@ -0,0 +1,194 @@ +// admin-server.mjs +// +// Standalone memind_adm service — the back-office process. +// +// Serves the platform super-admin API (/admin-api/*) and the plaza operations +// console API (/api/ops/v1/*) on its own port, isolated from the public +// user-facing server (server.mjs). In production this is fronted by +// gadm.tkmind.cn; in dev the ops SPA (:3002) proxies to it directly. +// +// It trusts the shared session cookie (H5_COOKIE_DOMAIN=.tkmind.cn), so login +// continues to happen on the main app domain — this process only reads the +// session and authorizes admin/ops roles. No daemons, no public traffic. +import express from 'express'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parseCookies } from './auth.mjs'; +import { USER_COOKIE } from './user-auth.mjs'; +import { createAdminServices } from './admin-bootstrap.mjs'; +import { createAdminApi, createOpsApi } from './admin-routes.mjs'; +import { buildNetworkGuard, parseList } from './admin-guard.mjs'; +import { sendError } from './api-response.mjs'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +// Mirror server.mjs env loading so the admin service can run standalone. +function loadEnvFile(filePath) { + if (!fs.existsSync(filePath)) return; + for (const line of fs.readFileSync(filePath, 'utf8').split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eq = trimmed.indexOf('='); + if (eq < 0) continue; + const key = trimmed.slice(0, eq).trim(); + const value = trimmed.slice(eq + 1).trim(); + if (!process.env[key]) process.env[key] = value; + } +} + +loadEnvFile(path.join(__dirname, '../../.env.local')); +loadEnvFile(path.join(__dirname, '.env')); + +const PORT = Number(process.env.ADMIN_PORT ?? 8082); +const HOST = process.env.ADMIN_HOST ?? '127.0.0.1'; +const PUBLIC_HOST = process.env.ADMIN_PUBLIC_HOST ?? 'gadm.tkmind.cn'; + +const getToken = (req) => parseCookies(req.get('cookie'))[USER_COOKIE]; + +// Resolve the shared session cookie into req.currentUser, else 401. +function resolveSessionUser(userAuth) { + return async (req, res, next) => { + const token = getToken(req); + const me = token ? await userAuth.getMe(token) : null; + if (!me) return res.status(401).json({ message: '未授权,请重新登录' }); + req.currentUser = me; + next(); + }; +} + +// ---- Console registry ---------------------------------------------------- +// Each back-office console is an independently mountable unit. A process serves +// whichever subset ADMIN_CONSOLES names (default: all). Splitting into separate +// processes later is purely a config change — run one instance with +// ADMIN_CONSOLES=admin and another with ADMIN_CONSOLES=ops; no code changes. +const CONSOLES = { + admin: { + label: 'platform super-admin', + mountPath: '/admin-api', + routeHint: '/admin-api/*', + envPrefix: 'ADMIN_API', // ADMIN_API_ALLOWED_HOSTS / ADMIN_API_IP_ALLOWLIST + build: ({ jsonBody, services }) => + // The admin router gates currentUser itself, then requireAdmin per route. + createAdminApi({ + jsonBody, + getToken, + userAuth: services.userAuth, + llmProviderService: services.llmProviderService, + plazaPosts: services.plazaPosts, + plazaOps: services.plazaOps, + }), + }, + ops: { + label: 'plaza operations console', + mountPath: '/api', + routeHint: '/api/ops/v1/*', + envPrefix: 'OPS_API', // OPS_API_ALLOWED_HOSTS / OPS_API_IP_ALLOWLIST + build: ({ jsonBody, services }) => { + const router = express.Router(); + router.use(resolveSessionUser(services.userAuth)); + router.use('/ops/v1', createOpsApi({ jsonBody, plazaOps: services.plazaOps })); + return router; + }, + }, +}; + +function isLoopbackHostname(hostname) { + return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1'; +} + +// Same-origin / referer check for state-changing requests. Allows the public +// admin host, loopback (dev), and any host that matches the request host. +function csrfOriginCheck(req, res, next) { + if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return next(); + const host = req.get('host'); + const origin = req.get('origin'); + const referer = req.get('referer'); + if (!origin && !referer) return next(); + const requestHostname = (host ?? '').split(':')[0]; + const allowed = [origin, referer].some((value) => { + if (!value) return false; + try { + const source = new URL(value); + if (source.host === host) return true; + if (source.hostname === PUBLIC_HOST) return true; + return isLoopbackHostname(requestHostname) && isLoopbackHostname(source.hostname); + } catch { + return false; + } + }); + if (!allowed) { + return sendError(res, req, 403, 'csrf_failed', '来源校验失败'); + } + return next(); +} + +function resolveEnabledConsoles() { + const enabled = parseList(process.env.ADMIN_CONSOLES || 'admin,ops'); + if (!enabled.length) throw new Error('ADMIN_CONSOLES resolved to an empty set'); + const unknown = enabled.filter((key) => !CONSOLES[key]); + if (unknown.length) { + throw new Error( + `unknown ADMIN_CONSOLES: ${unknown.join(', ')} (valid: ${Object.keys(CONSOLES).join(', ')})`, + ); + } + return enabled; +} + +async function main() { + const enabled = resolveEnabledConsoles(); + const services = await createAdminServices({ h5Root: __dirname }); + + const app = express(); + app.set('trust proxy', true); + app.disable('x-powered-by'); + + const jsonBody = express.json({ limit: '1mb' }); + const ctx = { jsonBody, services }; + + app.get('/healthz', (_req, res) => + res.json({ ok: true, service: 'memind_adm', consoles: enabled }), + ); + + const summary = []; + for (const key of enabled) { + const console_ = CONSOLES[key]; + const allowedHosts = parseList(process.env[`${console_.envPrefix}_ALLOWED_HOSTS`]); + const ipAllowlist = parseList(process.env[`${console_.envPrefix}_IP_ALLOWLIST`]); + const guard = buildNetworkGuard({ + allowedHosts, + ipAllowlist, + onDeny: (res, req, reason) => + sendError( + res, + req, + 403, + 'forbidden_network', + reason === 'host' ? '主机不在白名单' : 'IP 不在白名单', + ), + }); + + app.use(console_.mountPath, csrfOriginCheck); + if (guard) app.use(console_.mountPath, guard); + app.use(console_.mountPath, console_.build(ctx)); + + summary.push({ key, console_, allowedHosts, ipAllowlist, guarded: Boolean(guard) }); + } + + app.use((req, res) => res.status(404).json({ message: 'not found' })); + + app.listen(PORT, HOST, () => { + console.log(`memind_adm @ http://${HOST}:${PORT} (public host: ${PUBLIC_HOST})`); + for (const { console_, allowedHosts, ipAllowlist, guarded } of summary) { + const fence = guarded + ? ` [hosts: ${allowedHosts.join(',') || '*'} | ip: ${ipAllowlist.join(',') || '*'}]` + : ''; + console.log(` ${console_.routeHint.padEnd(16)} ${console_.label}${fence}`); + } + }); +} + +main().catch((err) => { + console.error('admin-server failed to start:', err); + process.exit(1); +}); diff --git a/ops/vite.config.ts b/ops/vite.config.ts index 7b65f6f..8f7e98d 100644 --- a/ops/vite.config.ts +++ b/ops/vite.config.ts @@ -1,7 +1,10 @@ import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; -const apiProxy = process.env.OPS_API_PROXY ?? 'http://127.0.0.1:8081'; +// /auth (login + session status) is served by the public portal; the ops +// console API (/api/ops/v1) lives in the standalone memind_adm service. +const authProxy = process.env.OPS_API_PROXY ?? 'http://127.0.0.1:8081'; +const adminProxy = process.env.OPS_ADMIN_PROXY ?? 'http://127.0.0.1:8082'; export default defineConfig({ base: '/ops/', @@ -9,8 +12,8 @@ export default defineConfig({ server: { port: 3002, proxy: { - '/api': apiProxy, - '/auth': apiProxy, + '/api': adminProxy, + '/auth': authProxy, }, }, build: { diff --git a/package.json b/package.json index fe045d9..471ea1a 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "setup:plaza-tls": "node scripts/plaza-local-tls.mjs", "dev:vite": "vite", "dev:server": "node server.mjs", + "dev:adm": "node admin-server.mjs", "dev:plaza-express": "node plaza-server.mjs", "dev:ops": "cd ops && npm run dev", "ops:grant": "node scripts/grant-ops-role.mjs", @@ -25,7 +26,7 @@ "seed:plaza-covers": "node scripts/seed-plaza-covers.mjs", "enrich:plaza": "node scripts/enrich-plaza-posts.mjs", "build": "vite build", - "test": "node --test auth.test.mjs asr-proxy.test.mjs billing.test.mjs billing-recharge.test.mjs wechat-pay.test.mjs wechat-oauth.test.mjs capabilities.test.mjs policies.test.mjs chat-skills.test.mjs user-publish.test.mjs user-memory-profile.test.mjs skills-registry.test.mjs mindspace.test.mjs mindspace-scan.test.mjs mindspace-assets.test.mjs mindspace-pages.test.mjs mindspace-publications.test.mjs mindspace-chat-save.test.mjs mindspace-chat-context.test.mjs mindspace-content-scan.test.mjs mindspace-html-localize.test.mjs mindspace-visual-editor.test.mjs mindspace-cleanup.test.mjs mindspace-thumbnails.test.mjs mindspace-workspace-thumbnails.test.mjs mindspace-workspace-sync.test.mjs mindspace-asset-preview.test.mjs mindspace-agent-jobs.test.mjs mindspace-agent-runner.test.mjs message-stream.test.mjs plaza-posts.test.mjs plaza-interactions.test.mjs plaza-algorithm.test.mjs plaza-seo.test.mjs plaza-ops.test.mjs user-auth.test.mjs llm-providers.test.mjs", + "test": "node --test auth.test.mjs asr-proxy.test.mjs billing.test.mjs billing-recharge.test.mjs wechat-pay.test.mjs wechat-oauth.test.mjs capabilities.test.mjs policies.test.mjs chat-skills.test.mjs user-publish.test.mjs user-memory-profile.test.mjs skills-registry.test.mjs mindspace.test.mjs mindspace-scan.test.mjs mindspace-assets.test.mjs mindspace-pages.test.mjs mindspace-publications.test.mjs mindspace-chat-save.test.mjs mindspace-chat-context.test.mjs mindspace-content-scan.test.mjs mindspace-html-localize.test.mjs mindspace-visual-editor.test.mjs mindspace-cleanup.test.mjs mindspace-thumbnails.test.mjs mindspace-workspace-thumbnails.test.mjs mindspace-workspace-sync.test.mjs mindspace-asset-preview.test.mjs mindspace-agent-jobs.test.mjs mindspace-agent-runner.test.mjs message-stream.test.mjs plaza-posts.test.mjs plaza-interactions.test.mjs plaza-algorithm.test.mjs plaza-seo.test.mjs plaza-ops.test.mjs user-auth.test.mjs llm-providers.test.mjs admin-guard.test.mjs", "test:mindspace-e2e": "node scripts/mindspace-e2e.mjs", "test:mindspace-pages-e2e": "node scripts/mindspace-pages-e2e.mjs", "test:mindspace-publications-e2e": "node scripts/mindspace-publications-e2e.mjs", diff --git a/scripts/dev.mjs b/scripts/dev.mjs index b86ae5c..033ddc1 100755 --- a/scripts/dev.mjs +++ b/scripts/dev.mjs @@ -13,7 +13,9 @@ const portalPort = Number(process.env.H5_PORT ?? 8081); const vitePort = Number(process.env.VITE_PORT ?? 5173); const plazaPort = Number(process.env.PLAZA_PORT ?? 3001); const opsPort = Number(process.env.OPS_PORT ?? 3002); +const adminPort = Number(process.env.ADMIN_PORT ?? 8082); const portalUrl = `http://127.0.0.1:${portalPort}`; +const adminUrl = `http://127.0.0.1:${adminPort}`; const plazaHost = process.env.PLAZA_LOCAL_HOST ?? 'plaza.tkmind.cn'; const plazaPublicPort = Number(process.env.PLAZA_PUBLIC_PORT ?? 443); const plazaPublicScheme = String(process.env.PLAZA_PUBLIC_BASE ?? 'https://plaza.tkmind.cn').startsWith('http://') @@ -69,6 +71,7 @@ function spawnChild(command, args, label, cwd = root, extraEnv = {}) { } let server; +let admin; let plaza; let plazaProxy; let ops; @@ -79,6 +82,7 @@ function shutdown(code = 0) { if (stopping) return; stopping = true; server?.kill('SIGTERM'); + admin?.kill('SIGTERM'); plaza?.kill('SIGTERM'); plazaProxy?.kill('SIGTERM'); ops?.kill('SIGTERM'); @@ -121,7 +125,9 @@ const viteEnv = { }; const opsEnv = { + // /auth (login/session) stays on the public portal; /api (ops console) hits memind_adm. OPS_API_PROXY: portalUrl, + OPS_ADMIN_PROXY: adminUrl, VITE_PLAZA_BASE: plazaPublicBase, VITE_MINDSPACE_BASE: mindSpacePublicBase, }; @@ -210,8 +216,9 @@ function plazaHostConfigured(host) { } } -console.log(`==> 清理端口 ${portalPort} / ${vitePort} / ${plazaPort} / ${opsPort}...`); +console.log(`==> 清理端口 ${portalPort} / ${adminPort} / ${vitePort} / ${plazaPort} / ${opsPort}...`); freePort(portalPort); +freePort(adminPort); freePort(vitePort); freePort(plazaPort); freePort(opsPort); @@ -233,6 +240,11 @@ try { await waitFor(portalUrl, async (url) => (await fetch(`${url}/auth/status`)).ok, 'Portal'); console.log(`==> Portal 就绪: ${portalUrl}`); + console.log('==> 启动 memind_adm (admin-server.mjs)...'); + admin = spawnChild('node', ['admin-server.mjs'], 'admin'); + await waitFor(adminUrl, async (url) => (await fetch(`${url}/healthz`)).ok, 'Admin'); + console.log(`==> memind_adm 就绪: ${adminUrl}`); + console.log(`==> 启动 Plaza Next.js @ ${plazaPublicBase} (bind ${plazaHost})`); plaza = spawnChild( 'npm', @@ -288,6 +300,7 @@ try { console.log(` (setup) sudo pnpm setup:plaza-local && sudo pnpm dev:plaza-proxy`); console.log(` Ops 审核后台 http://localhost:${opsPort}/ops/`); console.log(` API / Portal ${portalUrl}`); + console.log(` memind_adm ${adminUrl} (gadm.tkmind.cn)`); console.log(''); console.log('首次使用 Ops:先登录 MindSpace,再执行 node scripts/grant-ops-role.mjs admin ops_admin'); } catch (err) { diff --git a/server.mjs b/server.mjs index cf4beeb..80a88d6 100644 --- a/server.mjs +++ b/server.mjs @@ -44,7 +44,7 @@ import { import { createPlazaRedis, createNoopPlazaRedis } from './plaza-redis.mjs'; import { startPlazaTasks, writebackPublications } from './plaza-tasks.mjs'; import { createPlazaSeoService } from './plaza-seo.mjs'; -import { createPlazaOpsService, hasOpsRole } from './plaza-ops.mjs'; +import { createPlazaOpsService } from './plaza-ops.mjs'; import { preparePublicationHtmlForEmbed, isPlazaEmbedRequest, @@ -736,324 +736,6 @@ app.post('/auth/logout', async (req, res) => { res.status(204).end(); }); -// ============ Admin API ============ - -function requireAdmin(req, res, next) { - if (!req.currentUser || req.currentUser.role !== 'admin') { - res.status(403).json({ message: '需要管理员权限' }); - return; - } - next(); -} - -const adminApi = express.Router(); -adminApi.use(jsonBody); - -adminApi.use(async (req, res, next) => { - await userAuthReady; - if (!userAuth) return res.status(503).json({ message: '未启用用户系统' }); - const me = await userAuth.getMe(userToken(req)); - if (!me) return res.status(401).json({ message: '未登录' }); - req.currentUser = me; - next(); -}); - -adminApi.get('/users', requireAdmin, async (_req, res) => { - const users = await userAuth.listUsers(); - res.json({ users }); -}); - -adminApi.get('/summary', requireAdmin, async (_req, res) => { - const summary = await userAuth.getAdminSummary(); - let llm = null; - if (llmProviderService) { - const keys = await llmProviderService.listKeys(); - const global = await llmProviderService.getGlobalSettings(); - const selected = keys.find((key) => key.isSelected); - llm = { - keyCount: keys.length, - selectedKeyName: selected?.name ?? null, - globalModel: global?.model ?? null, - }; - } - res.json({ summary: { ...summary, llm } }); -}); - -adminApi.post('/users', requireAdmin, async (req, res) => { - const result = await userAuth.createUser(req.body ?? {}); - if (!result.ok) return res.status(400).json({ message: result.message }); - res.status(201).json({ user: result.user }); -}); - -adminApi.patch('/users/:userId', requireAdmin, async (req, res) => { - const result = await userAuth.updateUser(req.params.userId, req.body ?? {}); - if (!result.ok) return res.status(400).json({ message: result.message }); - res.json({ user: result.user }); -}); - -adminApi.post('/users/:userId/recharge', requireAdmin, async (req, res) => { - const amountCents = Number(req.body?.amountCents); - const note = typeof req.body?.note === 'string' ? req.body.note : ''; - const result = await userAuth.recharge( - req.params.userId, - amountCents, - req.currentUser.id, - note, - ); - if (!result.ok) return res.status(400).json({ message: result.message }); - res.json({ user: result.user }); -}); - -adminApi.get('/usage', requireAdmin, async (req, res) => { - const userId = typeof req.query.userId === 'string' ? req.query.userId : null; - const limit = Number(req.query.limit ?? 50); - const records = await userAuth.listUsageRecords({ userId, limit }); - res.json({ records }); -}); - -adminApi.get('/capabilities/catalog', requireAdmin, (_req, res) => { - res.json({ catalog: userAuth.capabilityCatalog }); -}); - -adminApi.get('/capabilities/role/:role', requireAdmin, async (req, res) => { - const role = req.params.role === 'admin' ? 'admin' : 'user'; - if (role === 'admin') { - return res.json({ - role, - capabilities: Object.fromEntries( - userAuth.capabilityCatalog.map((item) => [item.key, true]), - ), - unrestricted: true, - }); - } - res.json(await userAuth.getRoleCapabilities('user')); -}); - -adminApi.put('/capabilities/role/:role', requireAdmin, async (req, res) => { - const role = req.params.role === 'admin' ? 'admin' : 'user'; - const result = await userAuth.setRoleCapabilities(role, req.body?.capabilities ?? {}); - if (!result.ok) return res.status(400).json({ message: result.message }); - res.json(result); -}); - -adminApi.get('/users/:userId/capabilities', requireAdmin, async (req, res) => { - const result = await userAuth.getUserCapabilities(req.params.userId); - if (!result.ok) return res.status(404).json({ message: result.message }); - res.json(result); -}); - -adminApi.put('/users/:userId/capabilities', requireAdmin, async (req, res) => { - const result = await userAuth.setUserCapabilities( - req.params.userId, - req.body?.capabilities ?? {}, - ); - if (!result.ok) return res.status(400).json({ message: result.message }); - res.json(result); -}); - -adminApi.delete('/users/:userId/capabilities', requireAdmin, async (req, res) => { - const result = await userAuth.clearUserCapabilityOverrides(req.params.userId); - if (!result.ok) return res.status(404).json({ message: result.message }); - res.json(result); -}); - -adminApi.get('/policies/catalog', requireAdmin, (_req, res) => { - res.json({ catalog: userAuth.policyCatalog }); -}); - -adminApi.get('/policies/role/:role', requireAdmin, async (req, res) => { - const role = req.params.role === 'admin' ? 'admin' : 'user'; - if (role === 'admin') { - return res.json({ role, policies: {}, unrestricted: true }); - } - res.json(await userAuth.getRolePolicies('user')); -}); - -adminApi.put('/policies/role/:role', requireAdmin, async (req, res) => { - const role = req.params.role === 'admin' ? 'admin' : 'user'; - const result = await userAuth.setRolePolicies(role, req.body?.policies ?? {}); - if (!result.ok) return res.status(400).json({ message: result.message }); - res.json(result); -}); - -adminApi.get('/users/:userId/policies', requireAdmin, async (req, res) => { - const result = await userAuth.getUserPolicies(req.params.userId); - if (!result.ok) return res.status(404).json({ message: result.message }); - res.json(result); -}); - -adminApi.put('/users/:userId/policies', requireAdmin, async (req, res) => { - const result = await userAuth.setUserPolicies(req.params.userId, req.body?.policies ?? {}); - if (!result.ok) return res.status(400).json({ message: result.message }); - res.json(result); -}); - -adminApi.delete('/users/:userId/policies', requireAdmin, async (req, res) => { - const result = await userAuth.clearUserPolicyOverrides(req.params.userId); - if (!result.ok) return res.status(404).json({ message: result.message }); - res.json(result); -}); - -adminApi.get('/skills/catalog', requireAdmin, (_req, res) => { - res.json({ catalog: userAuth.skillCatalog }); -}); - -adminApi.get('/skills/role/:role', requireAdmin, async (req, res) => { - const role = req.params.role === 'admin' ? 'admin' : 'user'; - if (role === 'admin') { - return res.json({ - role, - skills: Object.fromEntries(userAuth.skillCatalog.map((item) => [item.name, true])), - }); - } - res.json(await userAuth.getRoleSkills('user')); -}); - -adminApi.put('/skills/role/:role', requireAdmin, async (req, res) => { - const role = req.params.role === 'admin' ? 'admin' : 'user'; - const result = await userAuth.setRoleSkills(role, req.body?.skills ?? {}); - if (!result.ok) return res.status(400).json({ message: result.message }); - res.json(result); -}); - -adminApi.get('/users/:userId/skills', requireAdmin, async (req, res) => { - const result = await userAuth.getUserSkills(req.params.userId); - if (!result.ok) return res.status(404).json({ message: result.message }); - res.json(result); -}); - -adminApi.put('/users/:userId/skills', requireAdmin, async (req, res) => { - const result = await userAuth.setUserSkills(req.params.userId, req.body?.skills ?? {}); - if (!result.ok) return res.status(400).json({ message: result.message }); - res.json(result); -}); - -adminApi.delete('/users/:userId/skills', requireAdmin, async (req, res) => { - const result = await userAuth.clearUserSkillOverrides(req.params.userId); - if (!result.ok) return res.status(404).json({ message: result.message }); - res.json(result); -}); - -adminApi.get('/ledger', requireAdmin, async (req, res) => { - const userId = typeof req.query.userId === 'string' ? req.query.userId : null; - const limit = Number(req.query.limit ?? 50); - const entries = await userAuth.listBillingLedger({ userId, limit }); - res.json({ entries }); -}); - -adminApi.get('/llm-providers/catalog', requireAdmin, (_req, res) => { - if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' }); - res.json({ catalog: llmProviderService.catalog }); -}); - -adminApi.get('/llm-providers/keys', requireAdmin, async (_req, res) => { - if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' }); - const keys = await llmProviderService.listKeys(); - res.json({ keys }); -}); - -adminApi.post('/llm-providers/keys', requireAdmin, async (req, res) => { - if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' }); - const result = await llmProviderService.createKey(req.body ?? {}); - if (!result.ok) return res.status(400).json({ message: result.message }); - res.status(201).json({ key: result.key }); -}); - -adminApi.patch('/llm-providers/keys/:keyId', requireAdmin, async (req, res) => { - if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' }); - const result = await llmProviderService.updateKey(req.params.keyId, req.body ?? {}); - if (!result.ok) return res.status(400).json({ message: result.message }); - res.json({ key: result.key }); -}); - -adminApi.post('/llm-providers/keys/:keyId/select', requireAdmin, async (req, res) => { - if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' }); - const result = await llmProviderService.selectKey(req.params.keyId); - if (!result.ok) return res.status(400).json({ message: result.message }); - res.json({ key: result.key }); -}); - -adminApi.delete('/llm-providers/keys/:keyId', requireAdmin, async (req, res) => { - if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' }); - const result = await llmProviderService.deleteKey(req.params.keyId); - if (!result.ok) return res.status(400).json({ message: result.message }); - res.json({ ok: true }); -}); - -adminApi.post('/llm-providers/sync', requireAdmin, async (_req, res) => { - if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' }); - try { - const result = await llmProviderService.syncSelectedToGoosed(); - res.json(result); - } catch (err) { - res.status(500).json({ - message: err instanceof Error ? err.message : '同步失败', - }); - } -}); - -adminApi.get('/llm-providers/global', requireAdmin, async (_req, res) => { - if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' }); - const global = await llmProviderService.getGlobalSettings(); - res.json({ global }); -}); - -adminApi.put('/llm-providers/global', requireAdmin, async (req, res) => { - if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' }); - const result = await llmProviderService.setGlobalModel(req.body?.model); - if (!result.ok) return res.status(400).json({ message: result.message }); - res.json(result); -}); - -adminApi.post('/llm-providers/test', requireAdmin, async (req, res) => { - if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' }); - try { - const result = await llmProviderService.testDraft(req.body ?? {}); - res.json(result); - } catch (err) { - res.status(500).json({ - ok: false, - message: err instanceof Error ? err.message : '联通测试失败', - }); - } -}); - -adminApi.post('/llm-providers/keys/:keyId/test', requireAdmin, async (req, res) => { - if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' }); - try { - const result = await llmProviderService.testKey(req.params.keyId, req.body?.model); - res.json(result); - } catch (err) { - res.status(500).json({ - ok: false, - message: err instanceof Error ? err.message : '联通测试失败', - }); - } -}); - -adminApi.get('/plaza/pending', requireAdmin, async (_req, res) => { - if (!plazaPosts) return res.status(503).json({ message: 'Plaza 未启用' }); - const posts = await plazaPosts.listPendingPosts(); - res.json({ data: { posts } }); -}); - -adminApi.post('/plaza/posts/:id/review', requireAdmin, async (req, res) => { - if (!plazaPosts) return res.status(503).json({ message: 'Plaza 未启用' }); - try { - const action = String(req.body?.action ?? 'approve'); - const reason = req.body?.reason ?? null; - const result = - plazaOps && req.currentUser?.id - ? await plazaOps.reviewPostAsOps(req.currentUser.id, req.params.id, action, { reason }) - : await plazaPosts.reviewPost(req.params.id, action, { reason }); - res.json({ data: result }); - } catch (error) { - const status = mapPlazaError(error); - res.status(status).json({ - error: { code: error?.code ?? 'review_failed', message: error.message }, - }); - } -}); app.get('/auth/usage', async (req, res) => { await userAuthReady; @@ -1167,7 +849,6 @@ app.post('/webhooks/wechat-pay/notify', wechatNotifyBody, async (req, res) => { } }); -app.use('/admin-api', adminApi); // ============ Wiki API ============ @@ -1408,124 +1089,6 @@ function plazaClientIp(req) { return req.ip; } -function makeRequireOps(minRole = 'reviewer') { - return async (req, res, next) => { - if (!plazaOps) return sendError(res, req, 503, 'plaza_unavailable', 'Plaza 未启用'); - if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录'); - const role = await plazaOps.loadOperatorRole(req.currentUser.id); - if (!hasOpsRole(role, minRole)) { - return sendError(res, req, 403, 'OPS_PERMISSION_DENIED', '无运营权限'); - } - req.opsRole = role; - return next(); - }; -} - -const opsApi = express.Router(); -opsApi.use(jsonBody); -opsApi.use(makeRequireOps('reviewer')); - -opsApi.get('/review/queue', async (req, res) => { - try { - const queue = await plazaOps.listReviewQueue({ - status: req.query.status ?? 'pending_review', - cursor: req.query.cursor ?? null, - limit: req.query.limit, - keyword: req.query.keyword ?? null, - }); - return sendData(res, req, queue); - } catch (error) { - return plazaRouteError(res, req, error); - } -}); - -opsApi.post('/review/posts/:id', async (req, res) => { - try { - const result = await plazaOps.reviewPostAsOps(req.currentUser.id, req.params.id, req.body?.action, { - reason: req.body?.reason ?? null, - }); - return sendData(res, req, { post: result }); - } catch (error) { - return plazaRouteError(res, req, error); - } -}); - -opsApi.post('/review/batch', makeRequireOps('editor'), async (req, res) => { - try { - const result = await plazaOps.batchReviewPosts(req.currentUser.id, req.body ?? {}); - return sendData(res, req, result); - } catch (error) { - return plazaRouteError(res, req, error); - } -}); - -opsApi.get('/reports', async (req, res) => { - try { - return sendData(res, req, await plazaOps.listReports({ status: req.query.status ?? 'pending' })); - } catch (error) { - return plazaRouteError(res, req, error); - } -}); - -opsApi.post('/reports/:id/process', async (req, res) => { - try { - const result = await plazaOps.processReport(req.currentUser.id, req.params.id, req.body ?? {}); - return sendData(res, req, { report: result }); - } catch (error) { - return plazaRouteError(res, req, error); - } -}); - -opsApi.get('/featured', makeRequireOps('editor'), async (req, res) => { - try { - return sendData(res, req, await plazaOps.listFeatured()); - } catch (error) { - return plazaRouteError(res, req, error); - } -}); - -opsApi.post('/featured', makeRequireOps('editor'), async (req, res) => { - try { - const result = await plazaOps.setFeatured(req.currentUser.id, req.body ?? {}); - return sendData(res, req, { featured: result }, 201); - } catch (error) { - return plazaRouteError(res, req, error); - } -}); - -opsApi.delete('/featured/:id', makeRequireOps('editor'), async (req, res) => { - try { - const result = await plazaOps.removeFeatured(req.currentUser.id, req.params.id); - return sendData(res, req, { featured: result }); - } catch (error) { - return plazaRouteError(res, req, error); - } -}); - -opsApi.get('/analytics/overview', makeRequireOps('ops_admin'), async (req, res) => { - try { - return sendData(res, req, await plazaOps.getAnalyticsOverview()); - } catch (error) { - return plazaRouteError(res, req, error); - } -}); - -opsApi.get('/creators', async (req, res) => { - try { - return sendData(res, req, await plazaOps.listCreators({ keyword: req.query.keyword ?? null })); - } catch (error) { - return plazaRouteError(res, req, error); - } -}); - -opsApi.patch('/creators/:userId', async (req, res) => { - try { - const result = await plazaOps.updateCreator(req.currentUser.id, req.params.userId, req.body ?? {}); - return sendData(res, req, { creator: result }); - } catch (error) { - return plazaRouteError(res, req, error); - } -}); function mindSpaceError(res, req, error) { const statusByCode = { @@ -3183,7 +2746,6 @@ api.use(async (req, res, next) => { return tkmindProxy.proxyFallback(req, res); }); -api.use('/ops/v1', opsApi); api.use( createProxyMiddleware({ diff --git a/user-auth.mjs b/user-auth.mjs index 3b25940..89fa1aa 100644 --- a/user-auth.mjs +++ b/user-auth.mjs @@ -682,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 ({ @@ -1039,72 +1059,89 @@ export function createUserAuth(pool, options = {}) { } }; - 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 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, + ); + 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 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 = []; + 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 () => { diff --git a/vite.config.ts b/vite.config.ts index 61632b3..37ff11a 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -4,6 +4,8 @@ import react from '@vitejs/plugin-react'; export default defineConfig(({ mode }) => { const env = loadEnv(mode, process.cwd(), ''); const portalTarget = env.H5_DEV_PORTAL ?? 'http://127.0.0.1:8081'; + // Platform super-admin API now lives in the standalone memind_adm service. + const adminTarget = env.H5_DEV_ADMIN ?? 'http://127.0.0.1:8082'; return { plugins: [react()], @@ -14,7 +16,7 @@ export default defineConfig(({ mode }) => { proxy: { '/api': portalTarget, '/auth': portalTarget, - '/admin-api': portalTarget, + '/admin-api': adminTarget, '/MindSpace': portalTarget, '^/u/': portalTarget, '^/s/': portalTarget,