// 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'; import { isLocalDevHostname } from './scripts/local-test-config.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, memoryV2ConfigService: services.memoryV2ConfigService, adminSystemTestService: services.adminSystemTestService, plazaPosts: services.plazaPosts, plazaOps: services.plazaOps, wechatAdmin: services.wechatAdmin, subscriptionService: services.subscriptionService, }), }, 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 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 isLocalDevHostname(requestHostname) && isLocalDevHostname(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' })); if (services.subscriptionService) { const subExpiryTimer = setInterval(async () => { try { const n = await services.subscriptionService.expireStaleSubscriptions(); if (n > 0) console.log(`Expired ${n} stale subscription(s)`); } catch (err) { console.warn('Subscription expiry check failed:', err); } }, 60 * 60 * 1000); subExpiryTimer.unref?.(); } 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); });