Files
maenekoandClaude Opus 5 18bd05bcbb ma7engine: ядро, шаблон панели, скаффолдер и доктор
Первый коммит существующего состояния. Ядро кладётся под git ради того,
чтобы половины разъезжались в продуктовые репозитории через git subtree,
а не копированием: subtree делает настоящий merge, хранит историю и умеет
отдавать правки обратно.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LeBkZLiQrvdECf5nfgiMz
2026-08-26 20:39:37 +03:00

129 lines
6.4 KiB
TypeScript

// Серверная половина ядра панели: логин, сессия, права, рейт-лимит и раздача
// собранного фронта. Всё, что во всех панелях было одинаковым, — здесь;
// продуктовые маршруты остаются в самой панели.
//
// Авторизация есть всегда и приезжает готовой: задай
// auth: { user, password | passwordHash, secret } — и панель сама выпускает и
// проверяет токен, монтирует /login, /logout и /api/session. Своей авторизации
// панель не пишет.
//
// Панель, у которой собственных учёток нет вовсе (логин форвардится в основной
// продукт, где живут настоящие учётки), createPanelServer не использует —
// она собирает express сама из кусков ядра: createLoginRateLimit на маршрут
// логина, свои прокси-маршруты, serveSpa последним. См. раздел про
// панель-прокси в README.
//
// Порядок монтирования фиксирован и потому не может быть нарушен снаружи:
// json → логин/сессия → маршруты панели → статика с fallback. Fallback ловит
// любой GET, поэтому маршрут после него уже не сработал бы.
import express, { type Express, type Request, type Response } from 'express';
import { createLoginRateLimit, type LoginRateLimit, type RateLimitOptions } from './ratelimit';
import { createPasswordCheck, type PasswordOptions } from './password';
import { createSessionStore, type SessionClaims, type SessionOptions, type SessionStore } from './session';
import { createAuth, type Auth } from './auth';
import { forwardError } from './errors';
import { serveSpa } from './spa';
const LOGIN_PATH = '/login';
const LOGOUT_PATH = '/logout';
const SESSION_PATH = '/api/session';
export interface LocalAuthOptions extends PasswordOptions, Omit<SessionOptions, 'secret'> {
/** Логин единственной учётки панели. */
user: string;
/** Секрет подписи сессий — свой, отдельный от секретов основного продукта. */
secret: string;
/**
* Клеймы, которые получит вошедший. По умолчанию — владелец: permissions
* ['*'], то есть доступ ко всем вкладкам. Появится несколько учёток —
* здесь и появится разметка.
*/
claims?: Partial<Omit<SessionClaims, 'user'>>;
}
export interface PanelServerOptions {
/** Учётка панели. Обязательна: панель без авторизации ядром не собирается. */
auth: LocalAuthOptions;
/** Каталог со сборкой фронта. Без него статика не монтируется. */
staticDir?: string;
rateLimit?: RateLimitOptions;
/** Маршруты самой панели. Монтируются после логина и до статики. */
mountRoutes?: (app: Express, ctx: PanelServerContext) => void;
}
export interface PanelServerContext {
app: Express;
rateLimit: LoginRateLimit;
sessions: SessionStore;
auth: Auth;
forwardError: typeof forwardError;
}
export function createPanelServer(options: PanelServerOptions): Express {
const app = express();
const cfg = options.auth;
app.use(express.json());
const rateLimit = createLoginRateLimit(options.rateLimit);
// Обе проверки бросают при негодной конфигурации — панель не поднимется
// с паролем по умолчанию или с известным секретом.
const password = createPasswordCheck(cfg);
const sessions = createSessionStore({
secret: cfg.secret,
ttl: cfg.ttl,
revocable: cfg.revocable,
revocationStore: cfg.revocationStore,
});
const auth = createAuth({ sessions });
app.post(LOGIN_PATH, rateLimit.middleware, (req: Request, res: Response) => {
const { user, pass } = (req.body ?? {}) as { user?: string; pass?: string };
// Пароль проверяется всегда, даже когда логин уже не совпал: иначе
// по времени ответа отличается «нет такого пользователя» от
// «пароль неверный».
const passOk = password.verify(pass ?? '');
const userOk = typeof user === 'string' && user === cfg.user;
if (!userOk || !passOk) {
res.status(401).json({ message: 'Неверный логин или пароль' });
return;
}
rateLimit.clear(req);
res.json({
token: sessions.issue({
user: cfg.user,
userId: cfg.claims?.userId ?? '1',
role: cfg.claims?.role ?? 'admin',
permissions: cfg.claims?.permissions ?? ['*'],
}),
});
});
app.post(LOGOUT_PATH, auth.requireAuth, (req: Request, res: Response) => {
sessions.revoke(auth.bearerToken(req));
res.status(204).end();
});
app.get(SESSION_PATH, auth.requireAuth, (req: Request, res: Response) => {
res.json(auth.getSession(req));
});
options.mountRoutes?.(app, { app, rateLimit, sessions, auth, forwardError });
if (options.staticDir) serveSpa(app, options.staticDir);
return app;
}
export { createLoginRateLimit, clientIp } from './ratelimit';
export { createPasswordCheck, hashPassword, timingSafeEquals } from './password';
export { createSessionStore } from './session';
export { createAuth, bearerToken, OWNER_PERMISSION } from './auth';
export { forwardError } from './errors';
export { serveSpa } from './spa';
export type { RateLimitOptions, LoginRateLimit } from './ratelimit';
export type { PasswordOptions, PasswordCheck } from './password';
export type { SessionClaims, SessionOptions, SessionStore, RevocationStore } from './session';
export type { Auth, AuthOptions, Middleware } from './auth';