Files
memind/admin-server.mjs
T
john fde6503bdf
Memind CI / Test, build, and release guards (push) Has been cancelled
feat(mindspace): add SEO/GEO delivery, page template catalog, and admin hooks
Enable optional SEO/GEO injection and discovery routes for confirmed public pages while keeping private pages noindex. Add premium page template skills, portal catalog API, template shop UI, and Baidu push gated by memind_adm config.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 08:06:12 +08:00

218 lines
8.2 KiB
JavaScript

// 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,
assetGatewayConfigService: services.assetGatewayConfigService,
imageMakeAdminConfigService: services.imageMakeAdminConfigService,
memoryV2ConfigService: services.memoryV2ConfigService,
memoryV2AdminOpsService: services.memoryV2AdminOpsService,
goalRunAdminOpsService: services.goalRunAdminOpsService,
orchestratorConfigService: services.orchestratorConfigService,
orchestratorObservabilityService: services.orchestratorObservabilityService,
mindSearchConfigService: services.mindSearchConfigService,
skillRuntimeConfigService: services.skillRuntimeConfigService,
systemDisclosurePolicyService: services.systemDisclosurePolicyService,
agentCodeRunPolicyService: services.agentCodeRunPolicyService,
adminSystemTestService: services.adminSystemTestService,
plazaPosts: services.plazaPosts,
plazaOps: services.plazaOps,
wechatAdmin: services.wechatAdmin,
wechatIntentRouterConfigService: services.wechatIntentRouterConfigService,
subscriptionService: services.subscriptionService,
templateCatalogService: services.templateCatalogService,
}),
},
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);
});