Архітектура ProjectK
Мапа системи: з чого вона складається, куди йде запит і де що шукати. Правила («як писати новий код») — у CONTRIBUTING.md; цей файл описує, що вже є.
Що це за система
Section titled “Що це за система”Застосунок для управління пластовим куренем: членство, провід (діловодства), реєстр складу, календар і планування, проби та вмілості, онбординг нових людей.
Ключова відмінність від типової рольової системи: доступ визначається діловодством, яке людина
тримає, а не роллю акаунта. Акаунт знає лише, адміністратор це чи ні. Усе решта — «чи може ця
людина редагувати цього члена» — виводиться з діловодства. Тому LeadershipController змінює не довідник,
а права.
Складові
Section titled “Складові”Frontend/projectk-frontend Angular 22, standalone, signals, optimus-ui 2 + TailwindBackend/ProjectK.Backend .NET 10, ASP.NET Core, EF Core 10, MediatRdocker/ одна збірка образу на всі середовищаscripts/ dev.sh / dev.ps1 — підняти будь-яке середовище локальноProjectK.Optimization (закритий двигун підбору дати табору в плануванні — CampDateSolver) живе
як зовнішній пакет, не як проєкт у солюшені. Каталог вмілостей, проб і відзначень Лілейка бере із
сервісу каталогу по API (GET /v1/catalog, секція Catalog в appsettings). Каталоги читаються,
а не редагуються через застосунок — тому BadgesCatalogController і ProbesCatalogController не
мають операцій запису.
Бекенд: чотири проєкти
Section titled “Бекенд: чотири проєкти”ProjectK.Common → (нічого)ProjectK.Infrastructure → CommonProjectK.BusinessLogic → CommonProjectK.API → Common, BusinessLogic, InfrastructureInfrastructure і BusinessLogic не знають одне про одного. Коли бізнес-логіці потрібен
зовнішній світ, інтерфейс оголошується в Common, а реалізується в Infrastructure:
IKurinReportSource, ISpreadsheetWriter, ISpreadsheetReader, IAppUserRepository, IUnitOfWork.
Розподіл відповідальності — у таблиці в CONTRIBUTING.md.
Модулі
Section titled “Модулі”Однакові по обидва боки межі — і в BusinessLogic/Modules/, і в API/Controllers/:
| Модуль | Про що |
|---|---|
AuthModule |
вхід, MFA, онбординг, скидання пароля, налаштування інстансу, первинна настройка |
UsersModule |
акаунти: безпека, профіль акаунта, збережені розкладки дашборда |
KurinModule |
курінь, гуртки, члени, провід, календар, планування, відзнаки, перестороги |
ProbesAndBadgesModule |
каталоги проб і вмілостей; які проби склав і які вмілості здобув конкретний член |
InfrastructureModule |
сповіщення |
DuesModule |
вкладка: ставки, нарахування, операції гурткової й курінної каси, баланс людини |
ScoreModule |
точкування: присутність на подіях, бали вручну й автоматично, таблиця гуртків |
MeModule |
api/me для головної: людина з усіх поточних куренів — події, задачі, проба й вмілості, вкладка, бали, справи проводу. Тільки читає інші модулі через їхні контракти; права в кожному курені рахує з ролей у ньому, а не з токена |
Член ≠ користувач. Член — це людина; користувач — спосіб увійти. Одне може існувати без іншого: людину заводять до того, як вона отримає акаунт, і не кожна його отримує.
Людина ≠ курінь. Member — це людина сама по собі: імʼя, дата народження, проби, вмілості,
рівні, відзнаки, перестороги. Де вона належить, каже окремий запис — Membership (курінь, гурток,
вид членства, коли прийшла й коли пішла). Тому людина може бути в кількох куренях одночасно, а вихід
із куреня чи його розпуск не стирає нічого з того, що вона здобула: історія належить їй, а курінь на
цих записах — лише штамп «де це сталося».
Межі модулів
Section titled “Межі модулів”Модулі не читають таблиць одне одного. Усе, що один модуль питає в іншого, проходить через контракт
у Common/Interfaces/Modules/ — рівно ті питання, які колись можна буде поставити по мережі:
| Контракт | Хто відповідає | На що |
|---|---|---|
IMemberDirectory |
мембер | хто ця людина; пошук за публічним кодом; її ступені |
IMembershipDirectory |
курінь | де людина належить і належала; хто стоїть у курені (для вкладки) |
IOfficeDirectory |
курінь | які діловодства акаунт тримає в цьому курені |
IMemberProgressDirectory |
проби й вмілості | як далеко людина зайшла, підписані точки проби; що в курені підтверджено й підписано (для точкування) |
IDuesDirectory |
вкладка | які квартали закриті без боргу (для точкування) |
Межі тримає не домовленість, а ProjectK.Architecture.Tests: правила читають IL, тож звернення до
чужого репозиторію чи чужої сутності валить тест навіть тоді, коли воно сховане всередині методу й
ніде не видно в сигнатурах. Там, де борг ще є, він занесений у baseline правила — тобто
перелічений поіменно, а не непомічений.
Вкладка має свій фасад IDuesUnitOfWork, як мембер — IMemberUnitOfWork: до грошових таблиць
нема дороги з IUnitOfWork, тож жоден інший модуль їх не прочитає. Гуртки, курені й членства вона
тримає ключами без зовнішніх ключів — історія грошей переживає видалений гурток.
Точкування влаштоване так само: фасад IScoreUnitOfWork, ключі без зовнішніх ключів. Правила
«лише раз» (одна присутність на людину й дату, одна тарифна позиція на подію) — унікальні індекси в
базі, а не перевірки в обробнику. Переходи між гуртками модуль записує сам із події
MembershipMovedToGroup, бо членство памʼятає лише теперішній гурток.
Мембера будували так, щоб його можна було винести в окремий сервіс: контракт замість репозиторію, подія замість виклику, ключ замість навігації. Виносити його в 0.20.0 ніхто не збирався — але кожне з цих правил уже перевіряється.
Шлях запиту
Section titled “Шлях запиту”HTTP ↓ Serilog request logging, forwarded headers, rate limiting ↓ автентифікація JWT (access у заголовку, refresh у httpOnly-cookie) ↓ сесія = рядок у UserRefreshTokens; акаунт може бути в кількох місцях одночасно ↓ другий фактор: пароль → короткий mfaToken (5 хв, окрема audience) → код + mfaToken; ↓ код без mfaToken відхиляється — другий фактор ніколи не буває єдиним ↓ гейт MFA для проводу читає клейм amr з токена, не акаунт (PrivilegedMfaEnforcementMiddleware) ↓ політика авторизації AuthorizationPolicies.* — «якого рівня має бути викликач» ↓ ResourceAuthorize IResourceAccessService — «чи саме цей об'єкт йому доступний» ↓ контролер: жодних рішень, лише _mediator.Send(...) ↓ MediatR pipeline: валідація (FluentValidation) → хендлер ↓ хендлер: доменне рішення; дані бере через репозиторії з Common-інтерфейсів ↓ IUnitOfWork.SaveChangesAsync ↓ ServiceResult<T> ↓ ToActionResult(this) → HTTPДві сходинки авторизації розділені навмисно. Політика відповідає на питання «чи має ця людина
взагалі право на такі дії» (RequireGroupLeadership), а ResourceAuthorize — «чи цей конкретний
член / гурток / курінь у її сфері» (AccessScope: Own, OwnGroups, KurinWide).
Політика лишається лише там, де вона вужча за ресурсну перевірку — тобто справді щось вирішує.
Там, де вона просто повторювала мапу дозволів, її знято: дві копії правил розходяться, і вигравала
суворіша, даючи 403, якого ніщо не пояснює. PolicyMatchesPermissionMapTests валиться, якщо читання
запису колись стане суворішим за його зміну.
ServiceResult<T> — єдина форма відповіді. Помилка завжди { error, message }; перетворення
в HTTP живе в одному місці — ServiceResultExtensions.ToActionResult.
Авторизація
Section titled “Авторизація”активний курінь у токені у якому курені людина зараз діє ↓ IOfficeDirectory діловодства цього акаунта саме в цьому куреніLeadershipRole + LeadershipType діловодство, яке людина тримає ↓ SystemRole.ForOffice()SystemRole роль доступу, дзеркало діловодства ↓ RolePermissionMapPermission напр. Group:Manage:KurinWideДіловодства — джерело; ролі доступу виводяться з них на кожен вхід, а не зберігаються на акаунті.
Другого списку «хто керує куренем» немає: усе виводиться з RolePermissionMap.
Ключове тут — слово «активний». Ролі рахуються для одного куреня за раз (IAccessContextResolver),
бо та сама людина може бути виховником в одному курені й звичайним членом у другому. Доки ролі
писалися в Identity як глобальні, друге членство протягло б права виховника й туди — тому
LeadershipRoleSyncService знято, а в сховищі акаунтів лишились тільки Admin і Member.
Перемикання куреня (POST api/auth/kurin-scope) — це не фільтр списку, а перевидача токена; воно
відкрите будь-кому, хто має активне членство в тому курені, і лише адміністратор може стояти
поза всіма.
Авторизація навмисно не знає про мембера: усе, що їй потрібно, вона читає через членства й діловодства за
ключем акаунта. Це не побажання, а правило Authorization_ShouldNotKnowAboutMember в арх-тестах.
Політики оголошені один раз — AuthorizationPolicies.AddProjectPolicies(); і хост, і тестові
хости беруть їх звідти. Матриця AuthorizationBaselineMatrixTests тримає очікувану політику для
кожного ендпоінта: новий ендпоінт без запису в матриці валить тест.
Дані та зовнішні системи
Section titled “Дані та зовнішні системи”| Що | Чим | Де код |
|---|---|---|
| База | SQL Server, EF Core 10, міграції в збірці | Infrastructure/DbContexts, Infrastructure/Migrations |
| Сесії | рядок на кожен вхід (IRefreshTokenStore); вихід обриває одну, зміна пароля й призупинення акаунта адміністратором — усі; призупинений (OnboardingStatus.Suspended) не проходить ні вхід, ні другий фактор, ні оновлення токена. Перемикач акаунтів тримає сесії інших акаунтів цього браузера в httpOnly-cookie savedSessions (SavedSessionsCookie, до 5, по одній на акаунт): login?addAccount=true відкладає поточну, auth/accounts/switch міняє місцями, вихід обриває всі |
Infrastructure/Repositories/AuthModule |
| Доступ до даних | репозиторії поверх BaseEntityRepository<T>, транзакції через IUnitOfWork |
Infrastructure/Repositories |
| Файли | Azure Blob Storage (локально — Azurite) | Infrastructure/Services/BlobStorageService |
| Пошта | запрошення, скидання пароля, сповіщення | Infrastructure/Services |
QuestPDF; звіт куреня збирається з IKurinReportSource |
Infrastructure, BusinessLogic |
|
| Логи | Serilog: файл, Application Insights, Telegram-сінк для дев-алертів (події безпеки й помилки; читабельний HTML, однотипні за 5 хв — одним зведенням, черга замість відкидання) | API/Program.cs, Infrastructure/Logging/TelegramDevAlerts |
| Зворотний звʼязок | «Повідомити про проблему» → GitHub issue токеном сервера (Feedback:GitHub), без токена — в лог; скриншоти в feedback-screenshots того ж контейнера |
Infrastructure/Services/Feedback, API/Controllers/InfrastructureModule/FeedbackController |
Фонові служби: прибирання аудиту (AuditCleanupBackgroundService), закінчення строку пересторог
(MemberWarningExpiryBackgroundService), прибирання осиротілих фото
(OrphanPhotoCleanupService), архів задач — зроблені в архів і давні архівні геть, за строками
кожного куреня (AgendaArchiveBackgroundService).
Фронтенд
Section titled “Фронтенд”src/app/features/ authModule/ вхід, онбординг, скидання пароля, guard-и adminModule/ адміністрування, користувачі, заявки dashboardModule/ головна `/`: плитки з `api/me` kurinModule/ курінь, гуртки, члени, календар, проби та вмілості duesModule/ вкладка: каса гуртка, каса куреня scoreModule/ точкування: таблиця, гурток, аркуш присутності, налаштування, книга КВ notificationsModule/ інбокс systemModule/ налаштування, службові екраниAngular 22, standalone-компоненти, signals (декораторів не лишилось). Доступ до маршрутів —
capabilityGuard з RouteCapability (admin | kurinManagement | groupLeadership), тобто ті самі
поняття, що й на бекенді, а не назви ролей.
Візуальна система описана в BRANDBOOK.md; іменування файлів і класів — у CONTRIBUTING.md.
Середовища
Section titled “Середовища”Один образ, середовище обирається в рантаймі через ASPNETCORE_ENVIRONMENT:
| Середовище | Для чого |
|---|---|
Development |
локальна розробка; Swagger увімкнено; сідер заводить демо-курінь і [email protected] |
E2E |
стек під Playwright; лише тут у застосунку існує E2ETestController з фікстурами — в інших середовищах його прибирає з моделі E2EOnlyControllerFeatureProvider |
SelfHost |
самостійне розгортання; сідер не заводить нікого |
Staging |
перевірка релізу; Swagger увімкнено; демо-даних немає, дані переживають перезапуск |
Tailscale |
закритий доступ через tailnet — стенд для людей, не демо: сідер його не чіпає, дані переживають перезапуск |
Production |
прод; Swagger вимкнено, LoadTestLoginKey порожній; демо-акаунтів немає |
Майстер первинної настройки (api/auth/setup) відкритий у кожному середовищі, доки в базі
немає жодного адміністратора, і зачиняється, щойно він зʼявився. Тому середовища з сідером його
ніколи не бачать, а свіжий Production чи SelfHost отримує першого адміністратора саме тут — а
не з пароля, записаного в репозиторії, як було до 1.0.
Підняти будь-яке: ./scripts/dev.sh up <env>. Деталі — у docker/README.md.
Демо-курінь і демо-акаунти сідер заводить (і стирає при кожному старті) лише в Development і
E2E. Раніше це стосувалося й Staging та Tailscale, і стенд, який показували людям, губив усе
введене при наступному запуску.
Дев-інструменти. На Development, E2E і Tailscale в застосунку є DevToolsController
(api/dev/impersonate, api/dev/impersonate/member, api/dev/return) і перемикач ролей на правому
краю екрана: адміністратор заходить як той, хто тримає обране діловодство у курені на екрані (Звʼязковий,
впорядник, курінний, скарбник), як юнак без діловодства або як та людина, чия картка відкрита, і
повертається за 12-годинним квитком (IJwtService, окрема audience). Перемикання з однієї позиченої
ролі в іншу йде через повернення: фронт спершу віддає квиток (api/dev/return), потім позичає знову,
бо позичена сесія не адмін і сама позичати не може. На решті тирів DevOnlyControllerFeatureProvider знімає контролер з моделі, а фронт не
рендерить перемикач у production-збірці.
Довірений пристрій для MFA. Після вдалого другого кроку (mfa/login-verify) API кладе
HttpOnly-cookie mfaTrust (MfaTrustCookie, шлях /api/auth) з JWT-квитком окремої audience
mfa-trust на Security:MfaTrustDays (7). Квиток несе security stamp акаунта; LoginUserCommandHandler
пропускає другий крок, лише коли квиток чинний, виданий цьому акаунту і stamp не змінився — зміна
пароля чи скидання MFA ротує stamp і знімає довіру з усіх пристроїв. Вихід cookie не чіпає.
Активація акаунта. POST onboarding/activate відповідає LoginUserResponse і ставить refresh-cookie:
людина, яка щойно обрала пароль, потрапляє одразу в застосунок, а не на форму входу.
Сайт. site/ — візитка, довідка і статичне демо на Astro + Starlight, окрема статика на
Cloudflare Pages. Довідка не пишеться на сайті: «Для користувача» — docs/user/* (початок, ролі,
функції, покрокові how-to); «Для розробника» — docs/dev/* (основи з діаграмами архітектури,
гайди, довідник, історія, DevLog) плюс кореневі ARCHITECTURE, CONTRIBUTING, BRANDBOOK,
SECURITY, docs/self-host/*, які site/scripts/sync-docs.mjs збирає в site/src/content/docs/
перед dev і build, дописуючи frontmatter і переписуючи посилання між ними. Дерево розробника в
бічній панелі згорнуте, пряма адреса — /dev/. Діаграми — Mermaid у markdown, рендер у браузері
з кнопкою «На весь екран». Стиль — site/src/styles/brand.css з тими ж токенами, що й
lileyka-theme.css.
Адреса відвідувача. Рейт-ліміт входу, гео-блок і журнал зміни IP читають
Connection.RemoteIpAddress. Звідки він береться — Security:ClientIp: Header називає заголовок,
який пише єдиний проксі попереду (CF-Connecting-IP за Cloudflare у Production/Staging,
X-Real-IP від nginx у self-host bundle), і ClientIpMiddleware кладе його значення в адресу
зʼєднання одразу після UseForwardedHeaders; TrustedProxies — адреси або мережі, чий
X-Forwarded-For приймається. Без Header адреса — те, що лишив X-Forwarded-For, а він
приймається від будь-кого, поки список довірених проксі порожній. Запуск відмовляється від
Jwt:Key, коротшого за 32 символи або з шаблонним словом із прикладів (JwtKeyRules).
Опис API
Section titled “Опис API”Swagger віддає повний опис поверхні в Development і Staging: /swagger. Кожен ендпоінт має
<summary> (навіщо він) і, де це неочевидно, <remarks> (хто може викликати, що ще станеться).
Форма помилки описана один раз — UnifiedErrorResponsesFilter, а не повторюється на кожній дії.
Перевірити, що документ узагалі збирається, можна без запуску застосунку:
swagger tofile --output swagger.json Backend/ProjectK.Backend/ProjectK.API/bin/Release/net10.0/ProjectK.API.dll v1| Проєкт | Що покриває |
|---|---|
ProjectK.BusinessLogic.Tests |
хендлери: доменні рішення |
ProjectK.Infrastructure.Tests |
репозиторії, сховища |
ProjectK.API.Tests |
контролери, серіалізація, і вся авторизація — матриця політик, ресурсні перевірки, анонімний доступ |
ProjectK.Architecture.Tests |
межі: залежності шарів, чужі репозиторії, події замість прямих викликів |
ProjectK.LoadTests |
навантаження |
Frontend/.../e2e |
Playwright проти docker-стека |
Бейслайн і команди перевірки — у CONTRIBUTING.md.