Administrator
  • Joined on 2025-04-12

Ava3v.WebApi.Core (0.8.6)

Published 2026-09-16 20:47:31 +03:00 by Administrator

Installation

dotnet nuget add source --name Administrator --username your_username --password your_token https://git.ava3v.ru/api/packages/Administrator/nuget/index.json
dotnet add package --source Administrator --version 0.8.6 Ava3v.WebApi.Core

About this package

Общее веб-ядро ava3v: minimal-API → MediatR (CQRS) с билдерами (GET/POST/PUT/DELETE/file/form-data), базовые команды/запросы/хендлеры, ValidationBehavior (FluentValidation), единый маппинг ошибок в HTTP, локализация, контексты (correlation/current-user), JWT. Извлечено из WareHub.

Ava3vLibs

Общие .NET-библиотеки ava3v для бэкендов WareHub, Issues, Pfl, Licensing, Cargo. Пять NuGet-пакетов одной версией, приватный фид Gitea. Здесь — как этим пользоваться: от пустого Program.cs до сессий, аудита и серверного грида. История изменений — CHANGELOG.md, сводка ломающих изменений для переезда — docs/ava3v-libs-changes-0.3-0.8.md.

Требования: .NET 10 (Abstractions и Components.Contract собираются и под net8.0), EF Core 10, Postgres 14+ (Npgsql). Sqlite и InMemory — только для тестов.

Содержание

  1. Из чего состоит
  2. Подключение
  3. Быстрый старт: пустое приложение
  4. CQRS и minimal API
  5. Аутентификация: JWT
  6. Сессии и refresh-токены
  7. API-ключи для интеграций
  8. Обслуживание: очистка и retention
  9. Доступ к данным: Ava3v.DataAccess
  10. Аудит: Ava3v.Audit
  11. Серверный грид и дерево: Ava3v.Components.Contract
  12. Мелочи: health, Mapster, корреляция
  13. Справочник appsettings
  14. Справочник кодов ошибок
  15. Грабли
  16. Тесты
  17. Релиз

1. Из чего состоит

Пакет Зависит от Что внутри
Ava3v.Abstractions ничего Контракты без реализации: маркеры сущностей (IHasId, IHasCreated, IHasDeleted, …), EntityBase, PagedResponse<T>, BusinessLogicException/ErrorResponse, ICurrentUserService, права (RequirePermissionAttribute, IAva3vPermissionService), контракты сессий, API-ключей, аудита и обслуживания
Ava3v.DataAccess Abstractions, EF Core Ava3vDbContextBase (метаданные, мягкое удаление, период действия, авторство, snake_case), Ava3vEntityConfiguration<T>, таблицы refresh-токенов и API-ключей
Ava3v.WebApi.Core Abstractions, ASP.NET Core, MediatR, FluentValidation CQRS-базы и маппинг minimal API → MediatR, ошибки, локализация, права, JWT, сессии, CSRF, CORS, API-ключи, обслуживание, health
Ava3v.Components.Contract Abstractions, EF Core Серверная сторона грида @ava3v/ui-vue: фильтры, сортировка, поиск, группировка, уникальные значения, массовые операции; загрузчик деревьев
Ava3v.Audit WebApi.Core, DataAccess Журнал изменений по полям и снимки версий в транзакции сохранения, API истории, retention

Зависимости строго вниз: Audit → WebApi.Core, DataAccess → Abstractions. Продукт обычно берёт все пять; минимальный набор для API без базы — WebApi.Core.

2. Подключение

nuget.config продукта (один раз; чтение фида анонимное, токен не нужен):

<configuration>
  <packageSources>
    <add key="ava3v" value="https://git.ava3v.ru/api/packages/Administrator/nuget/index.json" />
  </packageSources>
  <packageSourceMapping>
    <packageSource key="ava3v"><package pattern="Ava3v.*" /></packageSource>
    <packageSource key="nuget.org"><package pattern="*" /></packageSource>
  </packageSourceMapping>
</configuration>

Ссылки — одной версией на все пакеты (почему — §17):

<PackageReference Include="Ava3v.Abstractions" Version="0.8.6" />
<PackageReference Include="Ava3v.DataAccess" Version="0.8.6" />
<PackageReference Include="Ava3v.WebApi.Core" Version="0.8.6" />
<PackageReference Include="Ava3v.Components.Contract" Version="0.8.6" />
<PackageReference Include="Ava3v.Audit" Version="0.8.6" />

Страницы пакетов в Gitea: https://git.ava3v.ru/Administrator/-/packages/nuget/<Имя>/<версия>.

3. Быстрый старт: пустое приложение

Полный Program.cs с сессиями, аудитом, гридом и правами. Порядок в конвейере важен, он прокомментирован.

using Ava3v.Abstractions.Sessions;
using Ava3v.Audit;
using Ava3v.Components.Contract;
using Ava3v.DataAccess.Sessions;
using Ava3v.WebApi.Core.Application;
using Ava3v.WebApi.Core.Localization.Extensions;
using Ava3v.WebApi.Core.Web.Extensions;
using Ava3v.WebApi.Core.Web.Extensions.MinimalApi;
using Ava3v.WebApi.Core.Web.Middlewares;
using Ava3v.WebApi.Core.Web.Sessions;

var builder = WebApplication.CreateBuilder(args);
var services = builder.Services;
var configuration = builder.Configuration;

// --- Ядро: локализация, JWT, текущий пользователь, корреляция, IHandlerContext
services.AddAva3vLocalization(configuration);           // Localization:DefaultCulture / SupportedCultures
services.AddAva3vCoreWebApi(configuration);             // секция Jwt; включает AddAva3vJwtAuthentication (повтор безопасен)
services.AddAva3vApplicationAssemblyContaining<CreateItemCommandInfo.Command>(); // MediatR + FluentValidation + ValidationBehavior
services.AddAuthorization();

// --- База: контекст на Ava3vDbContextBase, аудит интерсептором, таблицы либы в OnModelCreating
services.AddAva3vAudit<AppDbContext>(o => o.Schema = "audit");
services.AddDbContext<AppDbContext>((sp, o) => o
    .UseNpgsql(configuration.GetConnectionString("Db"))
    .UseAva3vAudit(sp));                                // НЕ AddDbContextPool — §10
services.AddAva3vRefreshTokenStore<AppDbContext>();
services.AddAva3vApiKeyStore<AppDbContext>();

// --- Сессии, ключи, обслуживание, грид, права, CORS
services.AddAva3vSessions(configuration);               // Jwt:Sessions + AuthCookie
services.AddScoped<IAva3vClaimsProvider, AppClaimsProvider>();       // обязательно: клеймы продукта (§6.2)
services.AddScoped<IAva3vUserStateProvider, AppUserStateProvider>(); // по желанию: блокировка/активность
services.AddAva3vApiKeys(configuration);                // после AddAva3vCoreWebApi
services.AddAva3vMaintenance(configuration);            // без него таблицы токенов и аудита растут
services.AddAva3vComponentsContract();                  // IDataSourceLoaderAdapter для грида
services.AddAva3vPermissionAuthorization<AppPermissionService>();    // §4.7
services.AddAva3vCors(configuration);                   // только если фронт на другом хосте (§6.6)
services.AddStackExchangeRedisCache(o => o.Configuration = configuration["Redis"]); // несколько экземпляров API
services.AddAva3vHealthChecks();                        // + свои проверки с тегом HealthCheckExtensions.ReadyTag

var app = builder.Build();

app.ConfigureExceptionHandler();                        // BusinessLogicException → статус + ErrorResponse; остальное → 500
app.UseAva3vRequestLocalization(configuration);         // CurrentUICulture по Accept-Language
app.UseRouting();
app.UseAva3vCors();                                     // до аутентификации: отказы 401/403 тоже получают CORS-заголовки
app.UseMiddleware<CorrelationIdMiddleware>();           // X-Correlation-Id в ответе и в логах
app.UseAuthentication();
app.UseAva3vSessions();                                 // необязательно: только предупреждения старта
app.UseAuthorization();
app.UseAva3vPermissionAuthorization();                  // RequirePermission на эндпоинтах, после UseAuthorization

app.MapAva3vHealthChecks();                             // /health/live, /health/ready
app.MapAva3vCsrf();                                     // GET api/auth/csrf (анонимный)
app.MapAva3vAudit();                                    // GET api/audit/getShort
app.RegisterMinimalApiControllers(typeof(ItemsController).Assembly);

app.Run();

Минимальный appsettings.json для этого файла:

{
  "ConnectionStrings": { "Db": "Host=localhost;Database=app;Username=app;Password=app" },
  "Localization": { "DefaultCulture": "ru-RU", "SupportedCultures": ["ru-RU", "en-US"] },
  "Jwt": { "Key": "<не короче 32 байт, из секретов>", "Issuer": "App", "Audience": "AppClient",
           "AccessTokenLifetimeMinutes": 15, "ExpiredHeaderName": "IS-TOKEN-EXPIRED" },
  "AuthCookie": { "SameSite": "Strict", "Secure": true },
  "Maintenance": { "IntervalMinutes": 60 }
}

Что должен написать продукт, чтобы это заработало: контекст (§9.1), AppClaimsProvider (§6.2), AppPermissionService (§4.7), контроллер с командой (§4). Полный справочник настроек — §13.

4. CQRS и minimal API

Идея: HTTP-эндпоинт — одна строка маппинга, вся логика — в MediatR-хендлере. MVC-контроллеров нет. Swagger-описание берётся из XML-комментариев команды и её свойств, поэтому саммари пишутся содержательно.

4.1 Команда, запрос, хендлер

/// <summary>Информация команды создания номенклатуры</summary>
public static class CreateItemCommandInfo
{
    /// <summary>Создать позицию номенклатуры</summary>
    public sealed class Command : CommandBase<long>            // CommandBase (без результата) / CommandBase<T> / QueryBase<T>
    {
        /// <summary>Наименование</summary>
        [Display(Name = "Наименование")]
        public string Name { get; set; } = string.Empty;

        /// <summary>Артикул</summary>
        public string? Sku { get; set; }
    }

    internal sealed class Validator : AbstractValidator<Command>   // FluentValidation, находится по сборке
    {
        public Validator() => RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
    }

    internal sealed class Handler : CommandHandlerBase<Command, long, AppDbContext>
    {
        public Handler(IHandlerContext handlerContext, AppDbContext context) : base(handlerContext, context) { }

        public override async Task<long> Handle(Command request, CancellationToken cancellationToken)
        {
            if (await Context.Items.AnyAsync(x => x.Sku == request.Sku, cancellationToken))
            {
                // Ошибка бизнес-логики: статус + сообщение + машинный код для фронта
                throw new BusinessLogicException(HttpStatusCode.Conflict,
                    HandlerContext.Localizer.GetStringOrDefault("Item_SkuExists", "Артикул уже используется"),
                    "item_sku_exists");
            }

            var item = new Item { Name = request.Name, Sku = request.Sku };   // CreatedBy/CreatedAt поставит контекст
            Context.Items.Add(item);
            await Context.SaveChangesAsync(cancellationToken);
            return item.Id;
        }
    }
}

Базовые типы:

Тип Когда
CommandBase / ICommandBase команда без результата (ответ 200 без тела)
CommandBase<T> / ICommandBase<T> команда с результатом
QueryBase<T> / IQueryBase<T> запрос (GET, параметры из query и маршрута)
RegistryQuery<TRow> запрос списка: Search, Skip, Take, ответ PagedResponse<TRow>
TreeRegistryQuery<TRow> то же для дерева: плюс ParentId, ParentIds
CommandHandlerBase<TCommand, TResponse, TContext> хендлер: свойства Context и HandlerContext
CommandHandlerVoidBase<TCommand, TContext> хендлер команды без результата
QueryHandlerBase<TQuery, TResponse, TContext> хендлер запроса

IHandlerContext даёт CurrentUserService (кто вызывает) и Localizer (строки SharedResources). Регистрация всей сборки: services.AddAva3vApplicationAssembly(assembly) или AddAva3vApplicationAssemblyContaining<TMarker>() — MediatR, валидаторы и ValidationBehavior. Невалидная команда до хендлера не доходит: 400 с ValidationErrorResponse (errors: { поле: [сообщения] }, ключи в camelCase).

4.2 Контроллер: одна статическая функция

Контроллер — наследник CustomController с public static void Register(IEndpointRouteBuilder app). RegisterMinimalApiControllers(assemblies) находит их и вызывает Register; контроллер без такого метода — исключение на старте (раньше опечатка молча оставляла модуль без роутов).

public sealed class ItemsController : CustomController
{
    public static void Register(IEndpointRouteBuilder app)
    {
        app.MapGetMediatrRequest<GetItemsQueryInfo.Query, PagedResponse<ItemDto>>(cfg => cfg
            .WithRoute("api/items")
            .WithPermission(ItemsPermissions.Read, ItemsPermissions.Module));

        app.MapPostMediatrRequest<CreateItemCommandInfo.Command, long>(cfg => cfg
            .WithRoute("api/items")
            .WithPermission(ItemsPermissions.Manage, ItemsPermissions.Module));

        // Параметр в пути + тело: перегрузка с TRequestParams, одноимённые свойства переносятся в команду
        app.MapPutMediatrCommandWithParams<UpdateItemCommandInfo.Params, UpdateItemCommandInfo.Command>(cfg => cfg
            .WithRoute("api/items/{id}"));

        app.MapDeleteMediatrRequest<DeleteItemCommandInfo.Command>(cfg => cfg
            .WithRoute("api/items/{id}"));

        app.MapPostMediatrFileRequest<ExportItemsCommandInfo.Params, ExportItemsCommandInfo.Command, FileDto>(cfg => cfg
            .WithRoute("api/items/export"));
    }
}

4.3 Какой маппинг брать

Метод HTTP Откуда биндится запрос Ограничения
MapGetMediatrRequest<TRequest, TResponse> GET query и маршрут ([AsParameters]) TRequest : IQueryBase<TResponse>, new()
MapPostMediatrRequest<TRequest> POST JSON-тело ICommandBase; плейсхолдер в маршруте — ошибка на старте
MapPostMediatrRequest<TRequest, TResponse> POST JSON-тело ICommandBase<TResponse>; плейсхолдер — ошибка на старте
MapPostMediatrRequest<TRequestParams, TRequest, TResponse> POST маршрут/query в TRequestParams + тело; одноимённые свойства копируются в команду
MapPostMediatrCommandWithParams<TRequestParams, TRequest> POST то же, команда без результата
MapPostMediatrParameterRequest<TRequest, TResponse> POST только параметры, без тела
MapPostMediatrFormRequest<TRequest, TResponse> POST multipart/form-data (файлы + поля)
MapPostMediatrFileRequest<TRequestParams, TRequest, TResponse> POST параметры + тело, ответ — файл TResponse : FileDto
MapPutMediatrRequest<TRequest, TResponse> PUT JSON-тело плейсхолдер — ошибка на старте
MapPutMediatrRequest<TRequestParams, TRequest, TResponse> PUT параметры + тело
MapPutMediatrCommandWithParams<TRequestParams, TRequest> PUT параметры + тело, без результата
MapDeleteMediatrRequest<TRequest> DELETE маршрут/query ICommandBase, new()

Правило: в маршруте есть {id} — перегрузка с TRequestParams. Двухдженериковые POST/PUT биндят только тело; раньше {id} молча оставался default, с 0.4.0 это ошибка на старте с подсказкой.

Класс параметров — обычный класс с [FromRoute]/[FromQuery]:

public sealed class Params { [FromRoute] public long Id { get; set; } }
public sealed class Command : CommandBase { public long Id { get; set; } public string Name { get; set; } = ""; }

Нестандартное (стрим файла, SSE) — обычный app.MapGet(...) рядом, с .RequireAuthorization() и .WithMetadata(new RequirePermissionAttribute(...)) руками, как RegisterDownload в WareHub.

4.4 Опции эндпоинта

cfg — MinimalApiEndpointOptions. Имя и тег выводятся из пространства имён команды, саммари — из XML.

Метод Действие
WithRoute("api/...") обязателен
WithName, WithTag, WithTagPrefix, WithSummary переопределить выведенное
IsAnonymous() без RequireAuthorization (по умолчанию все эндпоинты требуют аутентификацию)
IsHidden() не показывать в Swagger
WithPermission(code, module?, feature?) RequirePermissionAttribute в метаданные, проверяет §4.7

Каждый эндпоинт получает Produces для 400 (ValidationErrorResponse), 404/409/500 (ErrorResponse).

4.5 Ошибки

Единый контракт ответа с ошибкой — ErrorResponse { message, code, correlationId }:

  • BusinessLogicException(message) → 400; (HttpStatusCode, message, code) → любой статус; (HttpStatusCode, body) — своё тело как есть, когда фронту нужны структурные данные.
  • Всё остальное → 500 internal_error; текст исключения наружу не уходит, correlationId — для логов.
  • Включается app.ConfigureExceptionHandler() первым в конвейере.
  • X-Correlation-Id: CorrelationIdMiddleware берёт из запроса или генерирует, кладёт в ответ и в scope логгера.

4.6 Локализация

Localization:DefaultCulture (по умолчанию ru-RU) и SupportedCultures (["ru-RU","en-US"]). Культура запроса — по Accept-Language и стандартным провайдерам UseRequestLocalization. Строки либы — в SharedResources (en нейтральная, ru), ключи — константы LocalizationKeys (Common_*, Auth_*, Session_*, ApiKey_*, Audit_*, Permission_*, Validation_*). В хендлере: HandlerContext.Localizer[key]; безопасно — Localizer.GetStringOrDefault(key, fallback). Enumeration<T> локализуется ключом Enum_{Тип}_{Значение} через Localizer.LocalizeEnum(value). Свои строки продукт держит в своём resx и своём IStringLocalizer<T>, механизм тот же. Тексты 400 из грида (Components.Contract) локализованы отдельно по CurrentUICulture без DI.

4.7 Права

Библиотека проверяет, продукт хранит. Продукт реализует один интерфейс:

public sealed class AppPermissionService(AppDbContext db) : IAva3vPermissionService
{
    public Task<bool> HasPermissionAsync(long userId, string permissionCode, string? moduleCode, string? featureCode, CancellationToken ct = default)
        => db.UserPermissions.AnyAsync(x => x.UserId == userId && x.Code == permissionCode
                                            && (moduleCode == null || x.Module == moduleCode), ct);
}

services.AddAva3vPermissionAuthorization<AppPermissionService>() + app.UseAva3vPermissionAuthorization() после UseAuthorization(). На эндпоинте — cfg.WithPermission(...) или RequirePermissionAttribute в метаданных (несколько — все должны пройти). Не аутентифицирован → 401 auth_required, нет права → 403 access_denied. Решения кешируются в пределах запроса. Описания прав для админки — IPermissionDefinitionProvider и записи ModuleDefinition/FeatureDefinition/PermissionDefinition.

4.8 Текущий пользователь

ICurrentUserService (регистрирует AddAva3vCoreWebApi): UserId, UserKind, ContragentId, IsAuthenticated, IsInternalUser/IsExternalUser/IsSystemUser. Вне HTTP (фоновые задачи, сидеры) — ICurrentUserContext.BeginScope(new CurrentUserContextState(userId, kind, contragentId)) или BeginSystemScope(): контекст базы возьмёт автора оттуда, иначе SystemConstants.SystemUserId.

5. Аутентификация: JWT

AddAva3vCoreWebApi включает AddAva3vJwtAuthentication (повторный вызов безопасен). Секция Jwt:

Jwt:Mode Кто выпускает Кто проверяет Нужно
Symmetric (по умолчанию) сам сервис (IAva3vTokenIssuer, HS256) сам сервис Key (≥ 32 байт), Issuer, Audience
Jwks внешний провайдер по JWKS провайдера External:Authority (или MetadataAddress), External:Audience
Both и то, и другое по iss токена: свой → Symmetric, чужой → схема External:SchemeName всё выше

Откуда берётся access-токен, по убыванию приоритета: заголовок Authorization: Bearer → httpOnly-кука AuthCookie:AccessTokenName → ?access_token= на путях Jwt:QueryTokenPaths (SignalR без кук). Заголовок и кука в одном запросе — считается заголовок.

IAva3vTokenIssuer.IssueAccessToken(claims, lifetime?) выпускает токен теми же параметрами, что и проверка; GenerateRefreshToken() — случайная строка. Для логина напрямую он не нужен — это делает сервис сессий (§6).

Настройки сверх обязательных: ClockSkewSeconds (60), AccessTokenLifetimeMinutes (15), ExpiredHeaderName (при 401 token_expired добавляется заголовок <имя>: true; фронты исторически смотрят IS-TOKEN-EXPIRED), External:RequireHttpsMetadata (выключать только в dev).

6. Сессии и refresh-токены

6.1 Что это даёт

Короткий access (15 минут) плюс одноразовый refresh с ротацией. Сессия — sid, один на всю цепочку refresh. В базе только SHA-256 отпечаток refresh. Отзыв сессии или всех сессий пользователя гасит и access-токены мгновенно (список отозванных sid в IDistributedCache). Пользователь — пара Ava3vUserKey(Kind, Id): сотрудник и клиент с одинаковым Id — разные пользователи, у каждого вида своя политика сроков.

Протокол refresh: токен одноразовый; повтор в окне гонки (вкладки обновились одновременно) — обе получают пару под тем же sid; повтор вне окна — кража или очень старая вкладка, реакция по политике: RevokeAllSessions (по умолчанию) или RevokeSession. Абсолютный предел сессии при ротации не сбрасывается.

6.2 Подключение

services.AddAva3vCoreWebApi(configuration);
services.AddAva3vRefreshTokenStore<AppDbContext>();     // + modelBuilder.ApplyAva3vRefreshTokens("auth") в OnModelCreating
services.AddAva3vSessions(configuration);
services.AddScoped<IAva3vClaimsProvider, AppClaimsProvider>();
services.AddScoped<IAva3vUserStateProvider, AppUserStateProvider>();   // по желанию
services.AddScoped<IAva3vSessionEvents, SecurityJournal>();            // по желанию
services.AddAva3vMaintenance(configuration);

Обязательный IAva3vClaimsProvider — только клеймы продукта; sid, NameIdentifier и user_kind либа ставит сама и перезапишет одноимённые:

internal sealed class AppClaimsProvider(AppDbContext db) : IAva3vClaimsProvider
{
    public async Task<IReadOnlyList<Claim>> GetClaimsAsync(Ava3vUserKey user, CancellationToken ct = default)
    {
        var account = await db.Users.AsNoTracking().Where(x => x.Id == user.Id)
            .Select(x => new { x.Login, x.ContragentId }).FirstOrDefaultAsync(ct);
        if (account is null) return [];
        var claims = new List<Claim> { new(ClaimTypes.Name, account.Login) };
        if (account.ContragentId is { } cid) claims.Add(new Claim("contragent_id", cid.ToString()));
        return claims;
    }
}

IAva3vUserStateProvider.GetAsync(user) → Ava3vUserState(Exists, IsActive, IsBlocked). Нужен при Jwt:Sessions:CheckUserStateOnEachRequest (с кешем UserStateCacheSeconds) и ApiKeys:CheckOwnerState. IAva3vSessionEvents — хуки OnRefreshReuseDetectedAsync и OnSessionRevokedAsync для журнала безопасности.

Несколько экземпляров API — обязательно AddStackExchangeRedisCache: без него список отозванных sid живёт в памяти процесса, и UseAva3vSessions() пишет об этом предупреждение на старте.

6.3 Эндпоинты продукта

Логин библиотека не маппит: пароль, попытки, блокировки — дело продукта. Скелет:

public sealed class AuthController : CustomController
{
    public static void Register(IEndpointRouteBuilder app)
    {
        app.MapPostMediatrRequest<LoginCommandInfo.Command, AuthResponseDto>(cfg => cfg.WithRoute("api/auth/login").IsAnonymous());
        app.MapPostMediatrRequest<RefreshCommandInfo.Command, AuthResponseDto>(cfg => cfg.WithRoute("api/auth/refresh").IsAnonymous());
        app.MapPostMediatrRequest<LogoutCommandInfo.Command>(cfg => cfg.WithRoute("api/auth/logout").IsAnonymous());
        app.MapGetMediatrRequest<MeQueryInfo.Query, UserInfoDto>(cfg => cfg.WithRoute("api/auth/me"));
    }
}

// Логин: хендлер получает IAva3vSessionService sessions, IAva3vSessionResponder responder,
// IAva3vAuthCookieService cookies, IHttpContextAccessor
var request = httpContext.Request;
var transport = responder.DetectTransport(request);                 // Cookie для браузера, Body для программного клиента
var tokens = await sessions.StartSessionAsync(new Ava3vUserKey("Internal", user.Id),
    Ava3vSessionClient.FromRequest(request), cancellationToken: ct);
await responder.WriteAsync(httpContext.Response, tokens, transport, ct);
// Cookie: httpOnly access (/) + refresh (AuthCookie:RefreshPath) + не-httpOnly ava3v_csrf; в теле — сроки, sid, csrfToken
// Body:   { accessToken, accessExpiresAt, refreshToken, refreshExpiresAt, sid }

// Refresh: значение из тела (программный клиент) или из куки (браузер, с проверкой CSRF)
var refresh = Ava3vSessionTokenReader.ReadRefresh(request, cookies, body?.RefreshToken);
var access = Ava3vSessionTokenReader.ReadAccess(request, cookies);   // для сверки sid; истёкший допустим
var pair = await sessions.RefreshAsync(refresh ?? string.Empty, access, Ava3vSessionClient.FromRequest(request), ct);
await responder.WriteAsync(httpContext.Response, pair, responder.DetectTransport(request), ct);

// Выход
await sessions.LogoutAsync(cookies.ReadRefreshToken(request), Ava3vSessionClient.FromRequest(request), ct);
cookies.Clear(httpContext.Response);

// «Текущий пользователь»: заодно отдать CSRF-значение (для фронта на другом хосте — в теле)
dto.CsrfToken = cookies.IssueCsrfToken(httpContext.Response);

// Блокировка, смена пароля, удаление — гасим всё
await sessions.RevokeAllSessionsAsync(user, "password_changed", ct);
// Админка «активные сессии»: sessions.ListSessionsAsync(user), sessions.RevokeSessionAsync(user, sid, reason)

Ava3vSessionException из сервиса — 401 с кодом (§14). Полный рабочий пример — LoginCommandInfo в WareHub.

6.4 Браузер: как это выглядит с фронта

  1. POST /api/auth/login → куки поставлены, в теле csrfToken. Фронт держит csrfToken в памяти.
  2. Любой запрос: кука access уходит сама. На POST/PUT/PATCH/DELETE фронт добавляет X-CSRF-Token: <csrfToken>.
  3. 401 token_expired (или заголовок IS-TOKEN-EXPIRED: true) → POST /api/auth/refresh с кукой и CSRF-заголовком → новая пара, csrfToken прежний (не ротируется, чтобы вторая вкладка не сломалась) → повтор запроса.
  4. Перезагрузка страницы: csrfToken в памяти потерян. Тот же origin — прочитать куку ava3v_csrf; другой хост — GET /api/auth/csrf (анонимный, app.MapAva3vCsrf()) либо csrfToken из ответа /me.
  5. 401 session_revoked, user_blocked, user_inactive, refresh_* → на страницу входа, refresh не поможет.
  6. Выход — POST /api/auth/logout с CSRF-заголовком.

Порядок проверок для запроса с кукой: access истёк → 401 token_expired; сессия отозвана → 401 session_revoked; токен без sid (выпущен до сессий) → 401 token_expired; всё в порядке, но нет CSRF на изменяющем методе → 403 csrf_failed.

6.5 CSRF: где проверяется и почему так

Куки браузер шлёт сам, поэтому запрос с кукой на изменяющий метод должен доказать, что его отправил фронт, а не чужая страница: double-submit — значение не-httpOnly куки ava3v_csrf повторяется в заголовке X-CSRF-Token. Проверка выполняется там, где кука становится полномочием:

  • в аутентификации, для токена из куки, после того как сам токен принят (истёкшая кука даёт свой 401, а не 403);
  • в IAva3vAuthCookieService.ReadRefreshToken/ReadAccessToken — для refresh и выхода.

Отдельного middleware нет. Анонимные эндпоинты, которые куки не читают (вход), CSRF не касается: старая кука в браузере ничего не блокирует. Отказ уходит после UseCors — с CORS-заголовками. Свой эндпоинт, читающий куки напрямую, зовёт cookies.IsCsrfValid(Request). Authorization: Bearer и безопасные методы (GET/HEAD/OPTIONS) не проверяются. AuthCookie:SameSite=None без CSRF — исключение на старте. CSRF-значение живёт, пока живёт кука; /me и /csrf с живой кукой ничего не переставляют.

6.6 Фронт и API на разных хостах

warehub.ava3v.ru ↔ api-warehub.ava3v.ru без прокси:

  • services.AddAva3vCors(configuration) + app.UseAva3vCors() до UseAuthentication; Cors:AllowedOrigins со схемой. Политика с credentials, любыми методами и заголовками, exposed X-CSRF-Token и Jwt:ExpiredHeaderName. Нужны свои exposed-заголовки (Content-Disposition) — своя политика AddCors, как в WareHub.
  • Фронт: credentials: 'include'; CSRF-значение берёт из тела логина/refresh/me/csrf или заголовка ответа X-CSRF-Token, не из куки (чужой хост её не отдаст).
  • Хосты должны быть same-site (поддомены одного домена), иначе SameSite=Strict не пропустит куки. Dev с фронтом на localhost:5173 и API на localhost:8081 — same-site, работает.
  • AuthCookie:Secure=true в проде явно: за прокси по http автоопределение даст куки без Secure.

6.7 Программные клиенты

Логин с заголовком X-Token-Transport: body (или уже с Authorization: Bearer) → токены в JSON. Дальше Authorization: Bearer, refresh — значением в теле. Куки не ставятся, CSRF не проверяется. Для интеграций лучше API-ключи (§7): без ротации и сроков.

6.8 Политики, отзыв, переезд

"Jwt": { "Sessions": {
  "DefaultPolicy": { "IdleMinutes": 720, "AbsoluteMinutes": 43200, "RaceWindowSeconds": 60, "ReuseReaction": "RevokeAllSessions" },
  "Policies": { "External": { "IdleMinutes": 120, "AbsoluteMinutes": 480 },
                "Integration": { "ReuseReaction": "RevokeSession" } },
  "CheckUserStateOnEachRequest": false, "UserStateCacheSeconds": 60,
  "AllowTokensWithoutSid": false, "RevokedSessionSkewSeconds": 120 } }

Незаданное поле политики вида — из DefaultPolicy. IdleMinutes — простой (переносится вперёд при ротации), AbsoluteMinutes — предел сессии.

Переезд с прежней схемы: new Ava3vRefreshTokenImport(store).ImportAsync(rows) один раз при выкатке — живые refresh становятся записями с отпечатком и новым sid; старый access без sid даёт 401 token_expired, фронт делает refresh и получает нормальную пару. Долгоживущие интеграционные токены без sid при включённых сессиях отвергаются: переведите интеграции на API-ключи, на время переезда AllowTokensWithoutSid: true.

6.9 SignalR

Хаб с кукой работает как есть (negotiate — POST, фронт добавляет X-CSRF-Token через withUrl(..., { headers })). Клиенты без кук передают ?access_token= — разрешите путь: Jwt:QueryTokenPaths: ["/hubs"].

7. API-ключи для интеграций

services.AddAva3vApiKeyStore<AppDbContext>();     // + modelBuilder.ApplyAva3vApiKeys("auth")
services.AddAva3vApiKeys(configuration);          // после AddAva3vCoreWebApi; секция ApiKeys

// Выдача из админки: ключ показывается один раз
var issued = await apiKeys.CreateAsync(new Ava3vUserKey("Service", account.Id), "1c-sync", TimeSpan.FromDays(365), ct);
return issued.Key;                                 // ak_{prefix}_{secret}
// apiKeys.ListAsync(owner), RevokeAsync(owner, id, reason), RevokeAllForOwnerAsync(owner, reason)

Запрос интеграции: X-Api-Key: ak_…. Схема ставится селектором перед JWT: есть заголовок — ключ, нет — обычная аутентификация. Владелец — сервисная учётка (вид, id), клеймы — из того же IAva3vClaimsProvider плюс api_key_id/api_key_name; блокировка владельца гасит ключ (ApiKeys:CheckOwnerState). Лимит живых ключей ApiKeys:MaxActivePerOwner (3): ротация — выдать новый, перевести клиента, отозвать старый. Отказы: 401 api_key_invalid / api_key_revoked / api_key_expired; превышение лимита при выдаче — api_key_limit. События — IAva3vApiKeyEvents.

8. Обслуживание: очистка и retention

services.AddAva3vMaintenance(configuration);   // секция Maintenance

Хост раз в IntervalMinutes (60; первый запуск через InitialDelaySeconds) выполняет задачи IAva3vMaintenanceTask в своём scope: отозванные/истёкшие refresh старше RefreshTokenRetentionDays (30), API-ключи старше ApiKeyRetentionDays (90), журнал аудита по AuditOptions.RetentionDays/SnapshotRetentionDays (null — вечно). Задачи регистрируют AddAva3vSessions/AddAva3vApiKeys/AddAva3vAudit; своя — AddAva3vMaintenanceTask<T>(). Ручной запуск — Ava3vMaintenanceHostedService.RunOnceAsync(). Без хоста таблицы растут.

9. Доступ к данным: Ava3v.DataAccess

9.1 Контекст

public sealed class AppDbContext(DbContextOptions<AppDbContext> options, ICurrentUserService currentUser)
    : Ava3vDbContextBase(options, currentUser), IAuditSuppressible
{
    public DbSet<Item> Items => Set<Item>();
    public bool SuppressAudit { get; set; }

    protected override void OnModelCreating(ModelBuilder b)
    {
        base.OnModelCreating(b);
        b.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
        b.ApplyAva3vRefreshTokens("auth");
        b.ApplyAva3vApiKeys("auth");
        b.ApplyAva3vAudit(new AuditOptions { Schema = "audit" });
    }
}

Что делает Ava3vDbContextBase при SaveChanges:

Маркер сущности Поведение
IHasCreated CreatedAt = UtcNow, CreatedBy = текущий пользователь — всегда, значение из DTO игнорируется
IHasUpdated UpdatedAt/UpdatedBy при изменении
IHasDeleted Remove() превращается в мягкое удаление: IsDeleted, DeletedAt, DeletedBy; query-фильтр прячет удалённые
IHasValidityPeriod ValidTo = now при удалении; query-фильтр «действует сейчас», момент — свойство ValidityMomentUtc контекста (параметр запроса, а не now() транзакции)
IHasActive, IHasBlocked дефолты true/false в конфигурации
IHasVersioning аудит пишет снимки версий
INotAuditable, [AuditIgnore] не в журнал аудита

Автор без HTTP-контекста — ICurrentUserContext.BeginScope, иначе SystemConstants.SystemUserId. Сохранить авторство из данных (сидеры, импорт, синхронизация) — только явно: using (context.PreserveAuthorship()) { … await context.SaveChangesAsync(); } (CreatedBy = 0 всё равно заменяется). Удалённые и недействующие записи читаются через IgnoreQueryFilters(); период действия на дату — query.ActualAt(date).

9.2 Сущность и конфигурация

public sealed class Item : EntityBase, IHasCreated, IHasUpdated, IHasDeleted, IHasVersioning
{
    public string Name { get; set; } = string.Empty;
    public long CreatedBy { get; set; } public DateTime CreatedAt { get; set; }
    public long? UpdatedBy { get; set; } public DateTime? UpdatedAt { get; set; }
    public bool IsDeleted { get; set; } public long? DeletedBy { get; set; } public DateTime? DeletedAt { get; set; }
}

internal sealed class ItemConfiguration : Ava3vEntityConfiguration<Item>
{
    protected override string GetSchemaName() => "core";        // по умолчанию core
    public override void Configure(EntityTypeBuilder<Item> b)
    {
        base.Configure(b);                                       // таблица item в snake_case, ключ, дефолты, query-фильтры
        b.Property(x => x.Name).IsRequired().HasMaxLength(200);
    }
}

Ключ сущности — long Id (EntityBase): это условие аудита, грида и сессий.

9.3 snake_case

Конвенция Ava3vSnakeCaseConvention включена в Ava3vDbContextBase: таблицы (через ToTableInSnakeCase в базовой конфигурации), колонки, ключи, внешние ключи, индексы. Явные HasColumnName/HasName/ToTable не перебиваются; owned-типы получают только колонки {навигация}_{свойство}; TPT-наследники получают свою snake_case-таблицу, TPH — нет. Отключить: protected override bool UseSnakeCaseNaming => false.

Переезд с EFCore.NamingConventions: та библиотека переименовывала и __EFMigrationsHistory (migration_id/product_version), наша — модельная и историю миграций не трогает. На существующей базе старт падает 42703: column "MigrationId" does not exist. Один раз руками, без новой миграции:

ALTER TABLE "__EFMigrationsHistory" RENAME COLUMN migration_id TO "MigrationId";
ALTER TABLE "__EFMigrationsHistory" RENAME COLUMN product_version TO "ProductVersion";
ALTER TABLE "__EFMigrationsHistory" RENAME CONSTRAINT pk___ef_migrations_history TO "PK___EFMigrationsHistory";

9.4 Таблицы библиотеки и миграции

ApplyAva3vRefreshTokens(schema) → auth.ava3v_refresh_token; ApplyAva3vApiKeys(schema) → auth.ava3v_api_key; ApplyAva3vAudit(options) → audit.audit_log, audit.entity_snapshot. Миграции генерирует и применяет продукт своим обычным dotnet ef migrations add — библиотека их не поставляет.

10. Аудит: Ava3v.Audit

Журнал изменений по полям (Added/Modified/Deleted, старое и новое значение) и JSON-снимки версий сущностей с IHasVersioning — в той же транзакции, что и сами изменения.

services.AddAva3vAudit<AppDbContext>(o => { o.Schema = "audit"; o.WriteSnapshots = true; o.RetentionDays = 730; });
services.AddDbContext<AppDbContext>((sp, o) => o.UseNpgsql(cs).UseAva3vAudit(sp));
// По желанию: подписи полей, резолв ссылок, форматтеры, скоуп тенанта, политика чтения, имена авторов
services.AddScoped<IAuditFieldDescriptorProvider, ItemAuditFields>();
services.AddScoped<IAuditReferenceResolver, UserReferenceResolver>();   // читать через IgnoreQueryFilters
services.AddScoped<IAuditAccessPolicy, EntityAuditAccessPolicy>();      // без неё — любому аутентифицированному
services.AddScoped<IAuditUserNameResolver, UserNameResolver>();
app.MapAva3vAudit();   // GET api/audit/getShort?entityType=Item&entityId=5&field=&skip=0&take=20 → PagedResponse<AuditShortDto>
// Программно: IAuditLogService.GetShortAsync(...), IEntitySnapshotService.GetAsOfAsync<Item>(id, momentUtc)

Правила и ограничения:

  • AddDbContext, не AddDbContextPool: интерсептор scoped и хранит буферы одного сохранения.
  • Журнал пишется вторым SaveChanges того же контекста в той же транзакции. Без явной транзакции интерсептор открывает свою (UseOwnTransaction). Откат, ошибка, отмена — журнала нет.
  • EnableRetryOnFailure: сохранения с аудитом — только внутри CreateExecutionStrategy().ExecuteAsync(...), иначе интерсептор бросает понятную ошибку. Либо UseOwnTransaction = false и своя транзакция.
  • ExecuteUpdate/ExecuteDelete обходят метаданные, мягкое удаление и журнал. Держите в проекте архитектурный тест с разрешённым списком таких вызовов.
  • Отключить на время (миграции данных, массовая загрузка): context.SuppressAudit = true (IAuditSuppressible).
  • Автор записи — текущий пользователь, не CreatedBy сущности; lifecycle-поля не диффятся; значения режутся до MaxValueLength (4000).
  • Снимок на момент: GetAsOfAsync<T>(id, moment) — версия с ValidFrom ≤ moment < ValidTo.

11. Серверный грид и дерево: Ava3v.Components.Contract

Серверная половина Ava3vDataGrid из @ava3v/ui-vue и ui-react. Фронт шлёт GridDataSourceLoadOptions, бэк применяет их к IQueryable<TRow> и отдаёт GridLoadResponse. Формат запроса с фронта описан в docs/datagrid-backend-contract.md репозитория VueComponents.

11.1 Реестр

public sealed class Command : CommandBase<GridLoadResponse>
{
    /// <summary>Параметры загрузки грида (тело getBackendRequest() с фронта)</summary>
    public GridDataSourceLoadOptions? GridLoadOptions { get; set; }
    /// <summary>Фильтры продукта поверх грида</summary>
    public long? WarehouseId { get; set; }
}

internal sealed class Handler(IHandlerContext hc, AppDbContext db, IDataSourceLoaderAdapter grid)
    : CommandHandlerBase<Command, GridLoadResponse, AppDbContext>(hc, db)
{
    public override async Task<GridLoadResponse> Handle(Command request, CancellationToken ct)
    {
        IQueryable<ItemRowDto> query = Context.Items
            .Where(x => request.WarehouseId == null || x.WarehouseId == request.WarehouseId)
            .Select(x => new ItemRowDto { Id = x.Id, Name = x.Name, Qty = x.Qty, CreatedAt = x.CreatedAt });

        var result = await grid.LoadGridAsync(query, request.GridLoadOptions ?? new(), defaultSort: "Id", ct);
        return result.ToResponse();
    }
}

Проецируйте в DTO строки реестра до передачи в лоадер: фильтры и сортировки фронта приходят по именам полей DTO, а массовая операция «всё по фильтру» должна применяться к тому же запросу (§11.4). defaultSort — стабильный порядок, когда фронт сортировку не прислал; для страниц обязателен. services.AddAva3vComponentsContract() регистрирует адаптер.

11.2 Что умеет лоадер

Возможность Поле опций Как выполняется
Страница Skip, Take, RequireTotalCount OFFSET/FETCH и COUNT
Сортировка Sort[] { Field, Desc } по именам свойств, вложенные a.b
Фильтр колонки (хедер-фильтр) ColumnFilters[] { Field, Values, Mode: include/exclude, GroupInterval } IN/NOT IN; строки без регистра; даты с интервалом — полуинтервалы (ниже)
Дерево условий (конструктор фильтра) Filter { Type: condition/group, Field, Operation, Value, Operator, Items } операции eq neq gt gte lt lte between contains startswith endswith; null в Value с eq/neq — IS NULL/IS NOT NULL
Поиск Search { Fields, Operation, Value } LIKE по полям; даты по компонентам; GridDataLoaderSettings.StringSearchMode = IndexOf для Oracle 12c
Уникальные значения (дерево хедер-фильтра) UniqueValuesField, UniqueValues { Field, GroupInterval, Skip, Take, Search } SELECT DISTINCT в SQL, сырые значения (для lookup — ключи, не тексты)
Группировка Group[] { Selector, Desc }, ExpandedGroupKeys, RequireGroupCount GROUP BY в SQL до 4 уровней; свёрнутые группы одной строкой, раскрытые — окно данных
Итоги GroupSummary[], TotalSummary[] { Selector, SummaryType: count/sum/min/max/avg/distinctCount, Name } в SQL
Экспорт options.ToFlatExport() те же фильтры, без страниц, группы → сортировка

Даты с GroupInterval (year/quarter/month/day): поля DateTime, DateTimeOffset, DateOnly и nullable. Уникальные значения — токены 2026, 2026-Q3, 2026-09, 2026-09-16; фильтр — [start, next) по UTC; null в значениях — «пусто». Усечение и границы транслируются в SQL (Postgres: timestamptz и date). Другой тип поля — 400 grid_date_interval_unsupported, некалендарный токен — 400 grid_date_interval_invalid_token.

Ответ GridLoadResponse { data, totalCount, groupCount?, summary? }; при группировке data — смесь GridGroupHeaderEntry/GridDataEntry/GridGroupFooterEntry (kind: group/data/groupFooter). Типизированно строки достаются response.RowsOf<ItemRowDto>().

11.3 Простой список без грида

Для селектов и лукапов — SimpleDataSourceLoadOptions { Skip, Take, Search, Sort }: применяете сами (Where(x => x.Name.Contains(search)), Skip/Take), ответ тот же GridLoadResponse. В WareHub одна команда принимает и GridLoadOptions, и SimpleLoadOptions, и ветвится.

11.4 Массовые операции по выделению

Грид с включённым «выделить всё по фильтру» шлёт не ключи, а GridSelectionRequest: AllMatchingSelected, ExcludedKeys, Filter (тело getBackendRequest()), либо SelectedKeys.

public sealed class Command : CommandBase { public GridSelectionRequest Selection { get; set; } = new(); public long StatusId { get; set; } }

var rows = await ItemRegistryQuery.Project(Context, Context.Items)          // тот же запрос, что видит реестр
    .ResolveAsync(request.Selection, x => x.Id, maxAffectedRows: 10_000, ct);  // > лимита → 400 BulkLimitExceeded ДО изменений
var ids = await rows.Select(x => x.Id).ToArrayAsync(ct);
await foreach (var item in Context.Items.Where(x => ids.Contains(x.Id)).AsAsyncEnumerable().WithCancellation(ct))
{
    item.StatusId = request.StatusId;
}
await Context.SaveChangesAsync(ct);                                          // через SaveChanges: аудит и метаданные

ApplySelection — только запрос, EnsureWithinLimitAsync — только проверка (считает до лимита + 1, не весь набор). Лимит держите равным фронтовому selectAllMatchingLimit (10 000).

11.5 Дерево

TreeDataLoader пагинирует корни в SQL и раскрывает поддеревья по уровням; HasChildren считается одним GROUP BY; поиск отдаёт только пути до совпадений.

PagedResponse<IssueTypeDto> page = await TreeDataLoader.LoadAsync(
    Context.IssueTypes.AsNoTracking(),
    x => new IssueTypeDto { Id = x.Id, ParentId = x.ParentId, Name = x.Name },   // DTO : IHasId, IHasParentId, IHasChildrenInfo
    new TreeLoadOptions
    {
        Skip = request.Skip, Take = request.Take, ParentIds = request.ParentIds,
        Search = request.GridLoadOptions?.Search, ColumnFilters = request.GridLoadOptions?.ColumnFilters,
    },
    searchText: x => x.Name, ct);

TreeRegistryPageBuilder.BuildPage(...) — то же в памяти для небольших справочников.

12. Мелочи: health, Mapster, корреляция

  • services.AddAva3vHealthChecks() + app.MapAva3vHealthChecks(): /health/live (процесс жив) и /health/ready (проверки с тегом HealthCheckExtensions.ReadyTag, например базы) — для докера и балансировщика.
  • TypeAdapterConfig.GlobalSettings.RegisterMapsterConfigurations(assemblies) — все IRegister сборок.
  • CorrelationIdMiddleware — §4.5.

13. Справочник appsettings

Секция Ключ По умолчанию Смысл
Localization DefaultCulture ru-RU должна входить в SupportedCultures
SupportedCultures ["ru-RU","en-US"]
Jwt Mode Symmetric Symmetric / Jwks / Both
Key — обязателен для Symmetric/Both, ≥ 32 байт, из секретов
Issuer, Audience — издатель и аудитория своих токенов
ClockSkewSeconds 60
AccessTokenLifetimeMinutes 15
ExpiredHeaderName — например IS-TOKEN-EXPIRED
QueryTokenPaths [] пути, где принимается ?access_token=
Jwt:External Authority, MetadataAddress, Audience, RequireHttpsMetadata (true), SchemeName (ExternalBearer) для Jwks/Both
Jwt:Sessions DefaultPolicy.IdleMinutes 720 простой
DefaultPolicy.AbsoluteMinutes 43200 предел сессии
DefaultPolicy.RaceWindowSeconds 60 окно гонки вкладок
DefaultPolicy.ReuseReaction RevokeAllSessions или RevokeSession
Policies:{UserKind}.* из DefaultPolicy переопределение по виду
CheckUserStateOnEachRequest false нужен IAva3vUserStateProvider
UserStateCacheSeconds 60
AllowTokensWithoutSid false переходный режим (§6.8)
RevokedSessionSkewSeconds 120 запас хранения отозванного sid
AuthCookie AccessTokenName ava3v_access_token
RefreshTokenName ava3v_refresh_token
AccessPath / RefreshPath / / /api/auth refresh-кука уходит только на эти пути
Domain —
SameSite Strict None требует CSRF
Secure по схеме запроса в проде за прокси — true явно
AuthCookie:Csrf Enabled true
CookieName / HeaderName ava3v_csrf / X-CSRF-Token
ApiKeys HeaderName X-Api-Key
MaxActivePerOwner 3
CheckOwnerState true нужен IAva3vUserStateProvider
Maintenance IntervalMinutes 60
InitialDelaySeconds 60
RefreshTokenRetentionDays 30
ApiKeyRetentionDays 90
Cors AllowedOrigins [] со схемой, без завершающего /

AuditOptions настраивается кодом в AddAva3vAudit: Schema (audit), WriteSnapshots (true), UseOwnTransaction (true), MaxValueLength (4000), EndpointRoute (api/audit/getShort), RetentionDays/SnapshotRetentionDays (null — вечно).

14. Справочник кодов ошибок

Тело всегда ErrorResponse { message, code, correlationId } (валидация — ValidationErrorResponse с errors).

Код Статус Когда Что делать фронту
auth_required 401 нет токена; не аутентифицирован на эндпоинте с правом на вход
token_expired 401 access истёк; собственный токен без sid при сессиях; плюс заголовок Jwt:ExpiredHeaderName refresh и повтор
session_revoked 401 сессия отозвана на вход
user_blocked, user_inactive 401 состояние пользователя на вход
invalid_refresh, refresh_expired, refresh_reused, session_mismatch 401 refresh не принят на вход
csrf_failed 403 кука без CSRF-заголовка на изменяющем методе взять CSRF (/csrf, /me) и повторить
access_denied 403 нет права показать отказ
api_key_invalid, api_key_revoked, api_key_expired 401 ключ интеграции
api_key_limit 401 лимит живых ключей при выдаче отозвать старый
BulkLimitExceeded 400 массовая операция шире лимита уточнить фильтр
grid_date_interval_unsupported, grid_date_interval_invalid_token 400 дата-фильтр грида
internal_error 500 необработанное исключение показать correlationId
(без кода) 400 FluentValidation подсветить errors

15. Грабли

  • AddDbContextPool несовместим с аудитом. Только AddDbContext.
  • EnableRetryOnFailure: сохранения с аудитом — внутри ExecutionStrategy; ротация refresh это учитывает сама.
  • ExecuteUpdate/ExecuteDelete обходят метаданные, мягкое удаление и аудит.
  • Postgres и даты: timestamptz требует DateTime с Kind=Utc и DateTimeOffset со смещением 0 при записи. Сравнивать и хранить в UTC.
  • Один экземпляр без Redis: отзыв сессий не общий между экземплярами. Предупреждение в логе не игнорировать.
  • Secure за прокси: по http автоопределение даст куки без Secure; в проде AuthCookie:Secure=true явно.
  • Разнесённые хосты: same-site обязателен; CSRF-значение фронт берёт из тела или заголовка, не из куки; UseCors до UseAuthentication.
  • Порядок регистрации: AddAva3vApiKeys после AddAva3vCoreWebApi; UseAva3vPermissionAuthorization после UseAuthorization; ConfigureExceptionHandler первым.
  • Плейсхолдер в маршруте у двухдженерикового POST/PUT — ошибка на старте; берите перегрузку с TRequestParams.
  • Контроллер без static Register — исключение на старте.
  • snake_case и __EFMigrationsHistory при переезде с EFCore.NamingConventions — SQL в §9.3.
  • Sqlite в тестах не транслирует DateTimeOffset и без ICU понижает регистр только ASCII; продукты на Postgres.
  • PreserveAuthorship только для сидеров и импорта; через API автором всегда становится вызвавший.

16. Тесты

dotnet test Ava3vLibs.slnx -c Release

Восемь тест-проектов, около 210 тестов. Грид — на Sqlite в памяти (трансляция в SQL проверяется), сессии и JWT — TestServer с Sqlite, три проекта на настоящем Postgres 14 через Testcontainers (аудит в транзакциях, гонка refresh между экземплярами, интервалы дат для timestamptz/date). Без Docker локально Postgres-тесты помечаются Skipped; в CI (CI=true или AVA3V_REQUIRE_POSTGRES=1) их отсутствие роняет прогон; готовая база — TEST_POSTGRES_CONNECTION. Общая инфраструктура — tests/Ava3v.TestInfrastructure.

17. Релиз

Версия одна на все пять пакетов — <Version> в Directory.Build.props; версии зависимостей — в Directory.Packages.props. Это решение, не долг: потребители обновляют пять ссылок одной цифрой одним скриптом, матрицы совместимости между пакетами нет. Пересматриваем, когда появится внешний потребитель одного пакета, обновляющийся вразнобой. Цена — пустые бампы в CHANGELOG.

cd ~/Ava3vLibs
dotnet test Ava3vLibs.slnx -c Release
dotnet pack Ava3vLibs.slnx -c Release
dotnet nuget push "src/*/bin/Release/*.0.8.6.nupkg" \
  --source https://git.ava3v.ru/api/packages/Administrator/nuget/index.json \
  --api-key <gitea-токен с write:package> --skip-duplicate

Порядок пакетов важен только на пустом фиде (Abstractions первым). Одну и ту же версию Gitea второй раз не примет. После push: поднять версии в csproj потребителей, dotnet nuget locals http-cache --clear, собрать все бэки. Запись в CHANGELOG.md обязательна; ломающие изменения — с инструкцией миграции и готовым текстом для диалогов продуктов. Чек-лист и команды — docs/publishing.md.

Dependencies

ID Version Target Framework
Ava3v.Abstractions 0.8.6 net10.0
FluentValidation 12.1.1 net10.0
FluentValidation.DependencyInjectionExtensions 12.1.1 net10.0
Mapster 10.0.8 net10.0
MediatR 12.4.1 net10.0
Microsoft.AspNetCore.Authentication.JwtBearer 10.0.9 net10.0
Microsoft.AspNetCore.OpenApi 10.0.9 net10.0
Microsoft.EntityFrameworkCore 10.0.9 net10.0
Microsoft.OpenApi 2.9.0 net10.0
Details
NuGet
2026-09-16 20:47:31 +03:00
4
ava3v
108 KiB
Assets (2)
Versions (67) View all
0.11.0 2026-10-22
0.10.3 2026-10-05
0.10.2 2026-10-05
0.10.1 2026-09-26
0.10.0 2026-09-21