ma7engine: ядро, шаблон панели, скаффолдер и доктор

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LeBkZLiQrvdECf5nfgiMz
This commit is contained in:
2026-08-26 20:39:37 +03:00
co-authored by Claude Opus 5
commit 18bd05bcbb
50 changed files with 8152 additions and 0 deletions
+86
View File
@@ -0,0 +1,86 @@
// Мидлвары авторизации: «пускать ли вообще» и «пускать ли к этому разделу».
//
// Проверка прав на фронте (permission в metadata.json вкладки) — это только
// UX: не показывать то, что всё равно отклонит сервер. Настоящая граница здесь,
// и роут без requirePermission открыт всем залогиненным независимо от того, как
// размечена вкладка.
//
// Откуда берутся права — развилка без единственно верного ответа, поэтому она
// вынесена в конфиг (loadPermissions):
// из клеймов токена — дёшево, но снятое право действует к истечению TTL;
// из БД на запрос — дороже, зато отзыв мгновенный.
import type { NextFunction, Request, Response } from 'express';
import type { SessionClaims, SessionStore } from './session';
export const OWNER_PERMISSION = '*';
// Ключ на объекте запроса. Symbol, а не req.session, чтобы не столкнуться с
// express-session и другими мидлварами, занимающими это имя.
const SESSION = Symbol.for('ma7engine.session');
export type Middleware = (req: Request, res: Response, next: NextFunction) => void;
export interface AuthOptions {
sessions: SessionStore;
/**
* Права на конкретный запрос. По умолчанию — из клеймов токена.
* Верни свежие права из БД, если нужен мгновенный отзыв.
*/
loadPermissions?: (claims: SessionClaims, req: Request) => string[] | Promise<string[]>;
/** Тексты отказов — панели отдают ошибки как {message}. */
messages?: { unauthorized?: string; forbidden?: string };
}
export interface Auth {
/** Пускает только с живым токеном; кладёт клеймы в запрос. */
requireAuth: Middleware;
/** Пускает только с указанным правом (или '*'). Ставится после requireAuth. */
requirePermission: (permission: string) => Middleware;
/** Клеймы текущего запроса. Осмысленны только после requireAuth. */
getSession: (req: Request) => SessionClaims;
/** Bearer-токен запроса — нужен, например, чтобы отозвать его на логауте. */
bearerToken: (req: Request) => string;
}
export function bearerToken(req: Request): string {
const header = req.headers.authorization ?? '';
return header.startsWith('Bearer ') ? header.slice(7) : '';
}
export function createAuth(options: AuthOptions): Auth {
const { sessions, loadPermissions } = options;
const unauthorized = options.messages?.unauthorized ?? 'Не авторизован';
const forbidden = options.messages?.forbidden ?? 'Недостаточно прав';
const getSession = (req: Request): SessionClaims =>
(req as Request & { [SESSION]?: SessionClaims })[SESSION] as SessionClaims;
const requireAuth: Middleware = (req, res, next) => {
const claims = sessions.verify(bearerToken(req));
if (!claims) {
res.status(401).json({ message: unauthorized });
return;
}
(req as Request & { [SESSION]?: SessionClaims })[SESSION] = claims;
next();
};
const requirePermission = (permission: string): Middleware => (req, res, next) => {
const claims = getSession(req);
if (!claims) {
res.status(401).json({ message: unauthorized });
return;
}
Promise.resolve(loadPermissions ? loadPermissions(claims, req) : claims.permissions)
.then((permissions) => {
if (permissions.includes(OWNER_PERMISSION) || permissions.includes(permission)) {
next();
return;
}
res.status(403).json({ message: forbidden });
})
.catch(next);
};
return { requireAuth, requirePermission, getSession, bearerToken };
}