feat(api): ручки Фаз(ы) 1 — профиль, серверы, роли, админ инстанса

REST-слой Фазы 1 на huma (OpenAPI 3.1 генерируется из кода):

- профиль: GET/PATCH /users/@me, смена пароля со step-up, публичный профиль,
  завершение онбординга (новая миграция 00003 с onboarding_completed_at);
- серверы: создание/изменение/удаление, join/leave, список серверов
  пользователя, журнал действий;
- комнаты: список с учётом прав, создание/изменение/удаление;
- участники: список с профилями и ролями, никнейм, тайм-аут, исключение;
- роли: CRUD, выдача/снятие с проверкой иерархии и запретом выдачи прав выше
  собственных;
- админ инстанса: публичная информация, настройки, серверы, пользователи,
  аудит, выдача прав администратора со step-up; обход лимитов фиксируется в
  аудите отдельной записью limits.bypass;
- движок прав: участие в сервере стало обязательным условием (IsMember),
  не участник не получает прав роли @user; калькулятор прав общий для API и
  Gateway, инвалидация кэша после изменений;
- Gateway: браузерный клиент аутентифицируется cookie на рукопожатии, IDENTIFY
  без токена использует её; события GUILD/CHANNEL/MEMBER/ROLE рассылаются из
  ручек, USER_UPDATE — адресно;
- ошибки huma отдаются в едином конверте {"error":{"code","message"}}.

Тесты: 8 сценариев API (профиль, жизненный цикл сервера и права, лимиты и
обход админом, иерархия ролей, тайм-аут, скрытие комнаты оверрайдом,
членство в движке прав, cookie-идентификация Gateway).
This commit is contained in:
2026-09-19 21:50:06 +03:00
parent 86dff94a02
commit 1b1a679827
63 changed files with 8477 additions and 187 deletions
+90
View File
@@ -0,0 +1,90 @@
/** Инициалы для аватара-заглушки: до двух первых букв значимых слов. */
export function initials(name: string): string {
const parts = name
.trim()
.split(/\s+/u)
.filter((part) => part.length > 0);
if (parts.length === 0) {
return '?';
}
const first = parts[0] ?? '';
if (parts.length === 1) {
return [...first].slice(0, 2).join('').toLocaleUpperCase();
}
const second = parts[1] ?? '';
return `${[...first][0] ?? ''}${[...second][0] ?? ''}`.toLocaleUpperCase();
}
/** Стабильный оттенок для заглушки сервера/пользователя. */
export function hueFromId(id: string): number {
let hash = 0;
for (let index = 0; index < id.length; index += 1) {
hash = (hash * 31 + id.charCodeAt(index)) % 360;
}
return hash;
}
/**
* Ссылка на файл аватара. Файловый сервис отдаётся тем же origin
* (`/files/{id}`), поэтому отдельный хост не нужен.
*/
export function avatarUrl(fileId: string | undefined): string | null {
return fileId === undefined || fileId === '' ? null : `/files/${encodeURIComponent(fileId)}`;
}
/** Разбирает число прав из строковой битовой маски, не падая на мусоре. */
export function parseBigInt(value: string | number | undefined): bigint {
if (value === undefined) {
return 0n;
}
try {
return BigInt(value);
} catch {
return 0n;
}
}
/** Бит ADMINISTRATOR в маске прав (совпадает с серверной константой). */
export const PERMISSION_ADMINISTRATOR = 1n << 3n;
export const PERMISSION_MANAGE_GUILD = 1n << 5n;
export const PERMISSION_MANAGE_CHANNELS = 1n << 6n;
export const PERMISSION_OWNER = 1n << 62n;
/** Проверяет право в списке масок прав текущего пользователя. */
export function hasPermission(permissions: readonly string[], mask: bigint): boolean {
for (const value of permissions) {
const parsed = parseBigInt(value);
if (parsed === PERMISSION_OWNER || (parsed & mask) !== 0n) {
return true;
}
}
return false;
}
/** Может ли пользователь управлять сервером (создавать комнаты и т.п.). */
export function canManageGuild(
permissions: readonly string[],
ownerId: string,
userId: string | undefined,
): boolean {
if (userId !== undefined && userId === ownerId) {
return true;
}
return (
hasPermission(permissions, PERMISSION_ADMINISTRATOR) ||
hasPermission(permissions, PERMISSION_MANAGE_GUILD) ||
hasPermission(permissions, PERMISSION_MANAGE_CHANNELS)
);
}
/** Человекочитаемая дата/время для списков (сессии, аудит). */
export function formatDateTime(value: string, locale: string): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
return new Intl.DateTimeFormat(locale, {
dateStyle: 'medium',
timeStyle: 'short',
}).format(date);
}