239c41f935
Persist code-run gates in admin DB and expose them via /auth/status so H5 can honor runtime policy without VITE rebuilds. Co-authored-by: Cursor <cursoragent@cursor.com>
211 lines
7.7 KiB
JavaScript
211 lines
7.7 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,
|
|
mindSearchConfigService: services.mindSearchConfigService,
|
|
skillRuntimeConfigService: services.skillRuntimeConfigService,
|
|
agentCodeRunPolicyService: services.agentCodeRunPolicyService,
|
|
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);
|
|
});
|