chore(history): squash 100 commit(s) from 2026-09-24

- fix(backend): case-insensitive unique email, revoke PUBLIC schema access, pin Argon2id params
- test(backend): assert password length cap boundary (256 ok, 257 rejected)
- docs(backend): plan — typed JWT token kinds so refresh/device tokens cannot pass as access tokens
- feat(backend): JWT access/refresh token issue and verify
- feat(backend): accounts repository (create/find_by_email/find_by_id)
- refactor(gui): LoVisualAddonManagerScreen 989→10 файлов addon/ (8.5.2)
- docs(backend): plan — fix sqlx::migrate! path in integration tests
- feat(backend): POST /auth/register and /auth/login
- refactor(settings): SettingsPanelComponent 715→81 + 6 helpers (8.5.2)
- docs(backend): plan — harden device flow (single-use codes, bounded store, 404/429)
- feat(backend): OAuth device authorization grant for mod login
- fix(backend): first confirm wins for device codes
- docs(backend): plan — split Task 8 (refactor) and Task 9 (avatars), harden avatar handling
- refactor(mixins): LocalPlayerMixin 691→111 + 4 handlers (8.5.2)
- refactor(visuals): Trails 687→130 (8.5.2)
- refactor(render): ItemBatchRenderer 677->100 (8.5.2)
- refactor(visuals): ReimaginedVisual 674→118 + 5 helpers (8.5.2)
- refactor(config): ConfigSerializer 668→91 + 4 helpers (8.5.2)
- refactor(hud): DynamicIsland 661→158 + 4 helpers (8.5.2)
- refactor(render): GlStencilFramebufferSupport 666→169 (8.5.2)
- refactor(gui): MenuScreen 669→128 + 4 helpers (8.5.2)
- refactor(render): UiStyle 644→170 + 3 helpers (8.5.2)
- refactor(gui): ModuleComponent 613→98 + 4 helpers (8.5.2)
- refactor(media): MediaSessionService 616→200 + 4 helpers (8.5.2)
- refactor(gui): RelationsComponent 661→59 + 4 helpers (8.5.2)
- chore(license): strip GPL file headers from all Java sources
- refactor(aiming): PointTracker 583→168 + 2 helpers (8.5.2)
- refactor(gui): LoVisualProxyManagerScreen 591→132 + 2 helpers (8.5.2)
- refactor(visuals): KillEffect 588→96 + 4 helpers (8.5.2)
- refactor(render): MeshBuilder +4 helpers (8.5.2)
- refactor(visuals): extract WorldParticlesRender helper (8.5.2)
- refactor(world): ExplosionDamageUtil 551→116 + 2 helpers (8.5.2)
- refactor(visuals): TazikHat 596->179 + Model + Palette in hats/tazik (8.5.2)
- chore(license): strip GPL header from remaining 30 files and make strip script variant-aware
- refactor(gui): ThemeComponent 561->166 + CardRenderer + ScrollState (8.5.2)
- refactor(hud): CustomHotbar 556→178 + Renderer + Selection + SelectionGradient (8.5.2)
- refactor(gui): ThemeCardRenderer perf + readability polish
- refactor(clickgui): CooldownRulesSetting 596->198 + Editor + DetailRenderer (8.5.2)
- refactor(hud): HudNotifier 561->200 + Painter + runtime/HudNotifierRuntime (8.5.2)
- refactor(theme): Themes 555->168 + impl/Transition + impl/Blending + impl/ProfileCodec (8.5.2)
- refactor(theme): EditableClickGuiTheme 205->185 + JavaDoc (8.5.2)
- refactor(theme): ThemeStore 491->128 + store/ThemeStoreJson + store/ThemeStoreIO (8.5.2)
- refactor(clickgui): ClickGuiRenderer 604->200 compacted one-line delegators + JavaDoc (8.5.2)
- refactor(mainmenu): LoVisualMainMenuScreen 551->161 + impl/Painter + impl/Renderer + impl/TextUtil (8.5.2)
- refactor(clickgui): ClickGuiTextEditorState 531->187 + impl/EditorCaret + impl/EditorPainter (8.5.2)
- refactor(tab): TabListModel 525->139 + model/Collector + model/Reader + model/Signature + model/TextSplitter (8.5.2)
- refactor(backend): shared bearer helper and test helpers, build_app takes Config, validate JWT secret strength
- refactor(module): ModuleManager 521->198 + impl/Registrar + impl/Dispatcher (8.5.2)
- feat(backend): avatar upload with decode, square crop, PNG re-encode and S3 storage
- refactor(clip): ClipFunction 512->146 + impl/Geometry + impl/Debug (8.5.2)
- docs(backend): implementation plans for gateway (auth hardening, gRPC, rate limits) and configs-service
- refactor(iris-patch): ShaderPatchEngine 499->146 + impl/Repo (8.5.2)
- chore(frontend): add router, react-query, fonts and vitest; dev proxy to gateway
- refactor(hud): ScriptedListHudPanel 499->158 + panel/Props + panel/Signature (8.5.2)
- refactor(hud): BaseHudElement 499->199 + impl/Registry + impl/Namer + impl/Prewarm (8.5.2)
- refactor(clickgui): Setting 498->170 + impl/Localization + impl/I18n (8.5.2)
- refact(viewmodel): split swing animations into camera/swing package
- refact(kineticlyrics): split module into stage, playback and modes
- rename(holeesp): module HoleESP -> CrystalHoles
- refact(crystalholes): split module into crystal scanner, renderer and safety
- refact(addonmanager): split manager into lifecycle, runtime, descriptors and profiles
- refact(accountconfig): split config into store, session and value helpers
- refactor(render): CustomTextRenderer 229->195, extract glyph-pass into GradientTexts helper
- docs(TODO): mark AddonManager split done; close 9.2 refactor gate
- refactor(media): LinuxMediaSession 441->148, split reader + track/seek state
- refactor(nametags): split NameTags into facade + impl helpers
- refactor(clickgui): split MainSettingsComponent into facade + scroll + model
- refactor(hud): split CustomBar into facade, model and BarSettings
- docs(frontend): implementation plan with design system from the mod theme
- feat(frontend): design tokens from the mod theme, fonts and shared UI kit
- fix(accounts): run migrations on startup, offload Argon2, validate register input, JSON error shape
- docs(gateway): plan note on splitting auth handlers before refresh endpoints
- feat(frontend): API client with silent refresh, error descriptions and test helpers
- style(mod): group compact one-line bulk query methods in ModuleManager
- feat(frontend): session restore, login and registration with client-side validation
- refactor(hud): split CustomHealthBar into facade + painter + script renderer
- docs(mod): record the 2026-09-24 HUD/settings split wave in TODO phase 8.5
- refactor(rhi): split GlStencilShapeClipBackend into facade + native-state + pass-lifecycle helpers
- refactor(rhi): split VulkanRenderStateBridge into facade + MSAA and stencil state helpers
- refactor(backtrack): split BacktrackController into facade + model + impl helpers
- refactor(svg): split SvgPathParser into facade + arc geometry + command/curve helpers
- refactor(mixin): split ClientPacketListenerMixin into hook-only mixin + handlers
- refactor(renderer3d): un-nest batch bindings + culling into sibling impl types
- refactor(renderwarp): extract static factories + geometry into impl helpers
- feat(backend): add common crate with shared JWT, internal gateway contract and accounts proto
- refactor(guimixin): move hook bodies into handlers, keep mixin as hooks + shadows
- feat(accounts): accept only gateway traffic, read identity from gateway header
- refactor(cacheduiscriptruntime): extract engine, hashing and frame stats into impl
- refactor(customskyboxrenderer): extract projection, shader passes and sun into impl
- refactor(betterchatstoremanager): extract persistence, key/path and hover helpers into impl
- feat(accounts): rotating opaque refresh tokens in httpOnly cookie, /auth/refresh and /auth/logout
- refactor(targetesp): extract crystal rendering subsystem into impl/TargetEspCrystalRenderer
- refactor(betterchathovercache): extract disk codec and lookup indexing into impl/ChatHoverCacheCodec
- refactor(microsoftauth): split HTTP transport, device-code and Xbox flows into impl/
- refactor(pvpcooldowns): extract local item-rule engine and defaults into impl/PvpCooldownRules
- refactor(lovisual): extract HUD/world render orchestration into HudRender helper
- refactor(statuseffectheuristics): extract palette/inference into ParticlePalette and color utils into ParticleColors
- refactor(dropesp): extract overlay/label render subsystem into impl/DropEspOverlayRenderer
- refactor(proxy): extract SOCKS handshake message builders into ProxyProtocolMessages
- refactor(eagleutil): promote EdgeRecovery controller and RecoveryMode to top-level class
This commit is contained in:
loki5512344 2026-09-24 23:52:12 +02:00
parent 9d08fa910a
commit 72bc4c7148
1897 changed files with 36199 additions and 39289 deletions

View file

@ -1,7 +1,12 @@
DATABASE_URL=postgres://lovisual:lovisual@localhost:5432/accounts_db DATABASE_URL=postgres://lovisual:lovisual@localhost:5432/accounts_db
JWT_SECRET=change-me-to-a-long-random-string # at least 32 random bytes, e.g. `openssl rand -hex 32`
JWT_SECRET=
PORT=8081 PORT=8081
S3_ENDPOINT=http://localhost:9000 S3_ENDPOINT=http://localhost:9000
S3_BUCKET=lovisual-avatars S3_BUCKET=lovisual-avatars
S3_ACCESS_KEY=minioadmin S3_ACCESS_KEY=minioadmin
S3_SECRET_KEY=minioadmin S3_SECRET_KEY=minioadmin
# Shared secret between gateway and internal services (openssl rand -hex 32)
INTERNAL_KEY=
# Secure cookie flag: leave unset (or true) in production (HTTPS); false for local HTTP dev
COOKIE_SECURE=false

495
backend/Cargo.lock generated
View file

@ -11,15 +11,21 @@ dependencies = [
"aws-config", "aws-config",
"aws-sdk-s3", "aws-sdk-s3",
"axum", "axum",
"axum-extra",
"axum-test", "axum-test",
"base64",
"chrono", "chrono",
"common",
"dashmap", "dashmap",
"dotenvy", "dotenvy",
"jsonwebtoken", "hex",
"image",
"rand 0.10.3", "rand 0.10.3",
"serde", "serde",
"serde_json", "serde_json",
"sha2 0.11.0",
"sqlx", "sqlx",
"time",
"tokio", "tokio",
"tower-http", "tower-http",
"tracing", "tracing",
@ -27,6 +33,12 @@ dependencies = [
"uuid", "uuid",
] ]
[[package]]
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]] [[package]]
name = "aho-corasick" name = "aho-corasick"
version = "1.1.5" version = "1.1.5"
@ -78,6 +90,17 @@ dependencies = [
"password-hash", "password-hash",
] ]
[[package]]
name = "async-trait"
version = "0.1.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]] [[package]]
name = "atoi" name = "atoi"
version = "2.0.0" version = "2.0.0"
@ -581,6 +604,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
dependencies = [ dependencies = [
"axum-core", "axum-core",
"axum-macros",
"bytes", "bytes",
"form_urlencoded", "form_urlencoded",
"futures-util", "futures-util",
@ -627,6 +651,39 @@ dependencies = [
"tracing", "tracing",
] ]
[[package]]
name = "axum-extra"
version = "0.12.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be44683b41ccb9ab2d23a5230015c9c3c55be97a25e4428366de8873103f7970"
dependencies = [
"axum",
"axum-core",
"bytes",
"cookie",
"futures-core",
"futures-util",
"http 1.5.0",
"http-body 1.1.0",
"http-body-util",
"mime",
"pin-project-lite",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "axum-macros"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]] [[package]]
name = "axum-test" name = "axum-test"
version = "21.1.0" version = "21.1.0"
@ -726,12 +783,24 @@ version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "bytemuck"
version = "1.25.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
[[package]] [[package]]
name = "byteorder" name = "byteorder"
version = "1.5.0" version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "byteorder-lite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]] [[package]]
name = "bytes" name = "bytes"
version = "1.12.1" version = "1.12.1"
@ -812,6 +881,25 @@ version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
[[package]]
name = "common"
version = "0.1.0"
dependencies = [
"axum",
"axum-test",
"chrono",
"jsonwebtoken",
"prost",
"serde",
"serde_json",
"subtle",
"tokio",
"tonic",
"tonic-prost",
"tonic-prost-build",
"uuid",
]
[[package]] [[package]]
name = "const-oid" name = "const-oid"
version = "0.9.6" version = "0.9.6"
@ -830,6 +918,7 @@ version = "0.18.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87" checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87"
dependencies = [ dependencies = [
"percent-encoding",
"time", "time",
"version_check", "version_check",
] ]
@ -1280,6 +1369,15 @@ version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
[[package]]
name = "fdeflate"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
dependencies = [
"simd-adler32",
]
[[package]] [[package]]
name = "ff" name = "ff"
version = "0.13.1" version = "0.13.1"
@ -1302,6 +1400,23 @@ version = "0.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef25905e51abafe4dcea6c15fec58c57b601cdbd0ee53d22ea1d3016c587d39b" checksum = "ef25905e51abafe4dcea6c15fec58c57b601cdbd0ee53d22ea1d3016c587d39b"
[[package]]
name = "fixedbitset"
version = "0.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
[[package]]
name = "flate2"
version = "1.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
dependencies = [
"crc32fast",
"miniz_oxide 0.9.1",
"zlib-rs",
]
[[package]] [[package]]
name = "flume" name = "flume"
version = "0.12.0" version = "0.12.0"
@ -1319,6 +1434,12 @@ version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]] [[package]]
name = "foldhash" name = "foldhash"
version = "0.2.0" version = "0.2.0"
@ -1500,6 +1621,15 @@ version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"foldhash 0.1.5",
]
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.16.1" version = "0.16.1"
@ -1508,7 +1638,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
dependencies = [ dependencies = [
"allocator-api2", "allocator-api2",
"equivalent", "equivalent",
"foldhash", "foldhash 0.2.0",
] ]
[[package]] [[package]]
@ -1517,7 +1647,7 @@ version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [ dependencies = [
"foldhash", "foldhash 0.2.0",
] ]
[[package]] [[package]]
@ -1730,6 +1860,19 @@ dependencies = [
"tower-service", "tower-service",
] ]
[[package]]
name = "hyper-timeout"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0"
dependencies = [
"hyper 1.11.1",
"hyper-util",
"pin-project-lite",
"tokio",
"tower-service",
]
[[package]] [[package]]
name = "hyper-util" name = "hyper-util"
version = "0.1.20" version = "0.1.20"
@ -1881,6 +2024,32 @@ dependencies = [
"icu_properties", "icu_properties",
] ]
[[package]]
name = "image"
version = "0.25.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
dependencies = [
"bytemuck",
"byteorder-lite",
"image-webp",
"moxcms",
"num-traits",
"png",
"zune-core",
"zune-jpeg",
]
[[package]]
name = "image-webp"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
dependencies = [
"byteorder-lite",
"quick-error",
]
[[package]] [[package]]
name = "indexmap" name = "indexmap"
version = "2.14.2" version = "2.14.2"
@ -1906,6 +2075,15 @@ version = "2.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
[[package]]
name = "itertools"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
dependencies = [
"either",
]
[[package]] [[package]]
name = "itoa" name = "itoa"
version = "1.0.18" version = "1.0.18"
@ -1986,6 +2164,12 @@ dependencies = [
"vcpkg", "vcpkg",
] ]
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]] [[package]]
name = "litemap" name = "litemap"
version = "0.8.3" version = "0.8.3"
@ -2044,6 +2228,26 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]]
name = "miniz_oxide"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]] [[package]]
name = "mio" name = "mio"
version = "1.2.3" version = "1.2.3"
@ -2055,6 +2259,16 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "moxcms"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
dependencies = [
"num-traits",
"pxfm",
]
[[package]] [[package]]
name = "multer" name = "multer"
version = "3.1.0" version = "3.1.0"
@ -2072,6 +2286,12 @@ dependencies = [
"version_check", "version_check",
] ]
[[package]]
name = "multimap"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084"
[[package]] [[package]]
name = "multiversion" name = "multiversion"
version = "0.9.0" version = "0.9.0"
@ -2299,6 +2519,17 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "petgraph"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455"
dependencies = [
"fixedbitset",
"hashbrown 0.15.5",
"indexmap",
]
[[package]] [[package]]
name = "phc" name = "phc"
version = "0.6.1" version = "0.6.1"
@ -2310,6 +2541,26 @@ dependencies = [
"getrandom 0.4.3", "getrandom 0.4.3",
] ]
[[package]]
name = "pin-project"
version = "1.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924"
dependencies = [
"pin-project-internal",
]
[[package]]
name = "pin-project-internal"
version = "1.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]] [[package]]
name = "pin-project-lite" name = "pin-project-lite"
version = "0.2.17" version = "0.2.17"
@ -2349,6 +2600,19 @@ version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
[[package]]
name = "png"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
dependencies = [
"bitflags",
"crc32fast",
"fdeflate",
"flate2",
"miniz_oxide 0.8.9",
]
[[package]] [[package]]
name = "potential_utf" name = "potential_utf"
version = "0.1.6" version = "0.1.6"
@ -2383,6 +2647,16 @@ dependencies = [
"yansi", "yansi",
] ]
[[package]]
name = "prettyplease"
version = "0.2.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
dependencies = [
"proc-macro2",
"syn 2.0.119",
]
[[package]] [[package]]
name = "primeorder" name = "primeorder"
version = "0.13.6" version = "0.13.6"
@ -2401,6 +2675,91 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "prost"
version = "0.14.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1"
dependencies = [
"bytes",
"prost-derive",
]
[[package]]
name = "prost-build"
version = "0.14.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042"
dependencies = [
"heck",
"itertools",
"log",
"multimap",
"petgraph",
"prettyplease",
"prost",
"prost-types",
"pulldown-cmark",
"pulldown-cmark-to-cmark",
"regex",
"syn 2.0.119",
"tempfile",
]
[[package]]
name = "prost-derive"
version = "0.14.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf"
dependencies = [
"anyhow",
"itertools",
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "prost-types"
version = "0.14.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a"
dependencies = [
"prost",
]
[[package]]
name = "pulldown-cmark"
version = "0.13.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e"
dependencies = [
"bitflags",
"memchr",
"unicase",
]
[[package]]
name = "pulldown-cmark-to-cmark"
version = "22.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab1ad36992cead65f02aa399a373a42730922f1525d988172634fdefdecb8a60"
dependencies = [
"pulldown-cmark",
]
[[package]]
name = "pxfm"
version = "0.1.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
[[package]]
name = "quick-error"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]] [[package]]
name = "quote" name = "quote"
version = "1.0.47" version = "1.0.47"
@ -2584,6 +2943,19 @@ dependencies = [
"semver", "semver",
] ]
[[package]]
name = "rustix"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "rustls" name = "rustls"
version = "0.21.12" version = "0.21.12"
@ -2879,6 +3251,12 @@ dependencies = [
"rand_core 0.6.4", "rand_core 0.6.4",
] ]
[[package]]
name = "simd-adler32"
version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
[[package]] [[package]]
name = "simdutf8" name = "simdutf8"
version = "0.1.5" version = "0.1.5"
@ -3188,6 +3566,19 @@ dependencies = [
"syn 3.0.6", "syn 3.0.6",
] ]
[[package]]
name = "tempfile"
version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.4.3",
"once_cell",
"rustix",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "thiserror" name = "thiserror"
version = "2.0.21" version = "2.0.21"
@ -3335,6 +3726,74 @@ dependencies = [
"tokio", "tokio",
] ]
[[package]]
name = "tonic"
version = "0.14.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef"
dependencies = [
"async-trait",
"axum",
"base64",
"bytes",
"h2 0.4.19",
"http 1.5.0",
"http-body 1.1.0",
"http-body-util",
"hyper 1.11.1",
"hyper-timeout",
"hyper-util",
"percent-encoding",
"pin-project",
"socket2 0.6.5",
"sync_wrapper",
"tokio",
"tokio-stream",
"tower",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "tonic-build"
version = "0.14.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c68f61875ac5293cf72e6c8cf0158086428c82c37229e98c840878f1706b0322"
dependencies = [
"prettyplease",
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "tonic-prost"
version = "0.14.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0"
dependencies = [
"bytes",
"prost",
"tonic",
]
[[package]]
name = "tonic-prost-build"
version = "0.14.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "654e5643eff75d7f8c99197ce1440ed19a3474eada74c12bbac488b2cafdae27"
dependencies = [
"prettyplease",
"proc-macro2",
"prost-build",
"prost-types",
"quote",
"syn 2.0.119",
"tempfile",
"tonic-build",
]
[[package]] [[package]]
name = "tower" name = "tower"
version = "0.5.3" version = "0.5.3"
@ -3343,9 +3802,12 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
dependencies = [ dependencies = [
"futures-core", "futures-core",
"futures-util", "futures-util",
"indexmap",
"pin-project-lite", "pin-project-lite",
"slab",
"sync_wrapper", "sync_wrapper",
"tokio", "tokio",
"tokio-util",
"tower-layer", "tower-layer",
"tower-service", "tower-service",
"tracing", "tracing",
@ -3480,6 +3942,12 @@ dependencies = [
"syn 3.0.6", "syn 3.0.6",
] ]
[[package]]
name = "unicase"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
[[package]] [[package]]
name = "unicode-bidi" name = "unicode-bidi"
version = "0.3.18" version = "0.3.18"
@ -3924,8 +4392,29 @@ dependencies = [
"syn 3.0.6", "syn 3.0.6",
] ]
[[package]]
name = "zlib-rs"
version = "0.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112"
[[package]] [[package]]
name = "zmij" name = "zmij"
version = "1.0.23" version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
[[package]]
name = "zune-core"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b"
[[package]]
name = "zune-jpeg"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
dependencies = [
"zune-core",
]

View file

@ -2,6 +2,7 @@
resolver = "2" resolver = "2"
members = [ members = [
"accounts-service", "accounts-service",
"common",
] ]
# Planned members, added when their own implementation plan starts # Planned members, added when their own implementation plan starts

File diff suppressed because it is too large Load diff

View file

@ -9,10 +9,10 @@ backend/
Cargo.toml # workspace root — members растёт по мере реализации Cargo.toml # workspace root — members растёт по мере реализации
STRUCTURE.md # этот файл STRUCTURE.md # этот файл
accounts-service/ # РЕАЛИЗУЕТСЯ — backend/PLAN.md (Подсистема 1, часть 1) accounts-service/ # РЕАЛИЗУЕТСЯ — backend/PLAN.md (Подсистема 1, часть 1)
gateway/ # ПЛАН НЕ НАПИСАН — routing + единая точка JWT-проверки gateway/ # ПЛАН: gateway/PLAN.md — routing + единая точка JWT-проверки
# + рейт-лимиты (TODO.md §6). Начинается после # + рейт-лимиты (TODO.md §6). Начинается после
# accounts-service, т.к. фронтит уже готовый сервис. # accounts-service, т.к. фронтит уже готовый сервис.
configs-service/ # ПЛАН НЕ НАПИСАН — 4 слота конфигов, share-коды, configs-service/ # ПЛАН: configs-service/PLAN.md — 4 слота конфигов, share-коды,
# витрина (Подсистема 1, часть 2) # витрина (Подсистема 1, часть 2)
chat-service/ # ПЛАН НЕ НАПИСАН — RPC-чат, друзья, presence, chat-service/ # ПЛАН НЕ НАПИСАН — RPC-чат, друзья, presence,
# виджеты-телеметрия (Подсистема 2). Единственный # виджеты-телеметрия (Подсистема 2). Единственный

View file

@ -8,7 +8,7 @@ name = "accounts_service"
path = "src/lib.rs" path = "src/lib.rs"
[dependencies] [dependencies]
axum = { version = "0.8", features = ["multipart"] } axum = { version = "0.8", features = ["multipart", "macros"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros"] } tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
tower-http = { version = "0.7", features = ["trace", "cors"] } tower-http = { version = "0.7", features = ["trace", "cors"] }
tracing = "0.1" tracing = "0.1"
@ -19,11 +19,17 @@ sqlx = { version = "0.9", default-features = false, features = ["runtime-tokio",
uuid = { version = "1", features = ["v4", "serde"] } uuid = { version = "1", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] }
argon2 = "0.6" argon2 = "0.6"
jsonwebtoken = { version = "11", default-features = false, features = ["rust_crypto"] } axum-extra = { version = "0.12", features = ["cookie"] }
time = "0.3"
sha2 = "0.11"
hex = "0.4"
base64 = "0.22"
common = { path = "../common" }
rand = "0.10" rand = "0.10"
dashmap = "6" dashmap = "6"
aws-sdk-s3 = "1" aws-sdk-s3 = "1"
aws-config = "1" aws-config = "1"
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"] }
anyhow = "1" anyhow = "1"
dotenvy = "0.15" dotenvy = "0.15"

View file

@ -1,8 +1,21 @@
CREATE EXTENSION IF NOT EXISTS pgcrypto; CREATE EXTENSION IF NOT EXISTS pgcrypto;
-- Hardening: revoke implicit PUBLIC access to this schema. This Postgres
-- instance hosts multiple services' databases (accounts_db/configs_db/
-- chat_db per TODO.md Фаза 10) on one cluster; the app role connects as
-- its own owner and needs no PUBLIC grant to function.
REVOKE ALL ON SCHEMA public FROM PUBLIC;
CREATE TABLE accounts ( CREATE TABLE accounts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL UNIQUE, -- No plain UNIQUE on email: Postgres TEXT comparison is case-sensitive,
-- so "User@x.com" and "user@x.com" would be treated as different rows,
-- letting one person hold two accounts under what looks like the same
-- email (account-confusion / signup-limit-bypass class of bug). A
-- unique index on lower(email) enforces uniqueness case-insensitively;
-- application code (accounts::repo) must look up/insert by
-- `email.to_lowercase()` for this index to actually get hit.
email TEXT NOT NULL,
password_hash TEXT NOT NULL, password_hash TEXT NOT NULL,
display_nick TEXT NOT NULL, display_nick TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user', role TEXT NOT NULL DEFAULT 'user',
@ -10,6 +23,8 @@ CREATE TABLE accounts (
created_at TIMESTAMPTZ NOT NULL DEFAULT now() created_at TIMESTAMPTZ NOT NULL DEFAULT now()
); );
CREATE UNIQUE INDEX accounts_email_lower_idx ON accounts (lower(email));
CREATE TABLE avatars ( CREATE TABLE avatars (
account_id UUID PRIMARY KEY REFERENCES accounts(id) ON DELETE CASCADE, account_id UUID PRIMARY KEY REFERENCES accounts(id) ON DELETE CASCADE,
s3_key TEXT NOT NULL, s3_key TEXT NOT NULL,

View file

@ -0,0 +1,12 @@
-- Opaque refresh tokens (stored hashed). Rotated on every use; presenting an
-- already-rotated token revokes the whole account's sessions (theft signal).
CREATE TABLE refresh_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
revoked_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_refresh_tokens_account_id ON refresh_tokens(account_id);

View file

@ -0,0 +1,2 @@
pub mod model;
pub mod repo;

View file

@ -0,0 +1,15 @@
use chrono::{DateTime, Utc};
use serde::Serialize;
use uuid::Uuid;
#[derive(Debug, Clone, sqlx::FromRow, Serialize)]
pub struct Account {
pub id: Uuid,
pub email: String,
#[serde(skip_serializing)]
pub password_hash: String,
pub display_nick: String,
pub role: String,
pub can_publish_addons: bool,
pub created_at: DateTime<Utc>,
}

View file

@ -0,0 +1,168 @@
use super::model::Account;
use sqlx::PgPool;
use uuid::Uuid;
// Emails are stored lowercase and compared via the `lower(email)` unique
// index (see migrations/0001_init.sql). Every entry point normalizes here.
pub fn normalize_email(email: &str) -> String {
email.trim().to_lowercase()
}
pub async fn create(
pool: &PgPool,
email: &str,
password_hash: &str,
nick: &str,
) -> Result<Account, sqlx::Error> {
sqlx::query_as::<_, Account>(
"INSERT INTO accounts (email, password_hash, display_nick)
VALUES ($1, $2, $3)
RETURNING id, email, password_hash, display_nick, role, can_publish_addons, created_at",
)
.bind(normalize_email(email))
.bind(password_hash)
.bind(nick)
.fetch_one(pool)
.await
}
pub async fn find_by_email(pool: &PgPool, email: &str) -> Result<Option<Account>, sqlx::Error> {
sqlx::query_as::<_, Account>(
"SELECT id, email, password_hash, display_nick, role, can_publish_addons, created_at
FROM accounts WHERE lower(email) = $1",
)
.bind(normalize_email(email))
.fetch_optional(pool)
.await
}
pub async fn find_by_id(pool: &PgPool, id: Uuid) -> Result<Option<Account>, sqlx::Error> {
sqlx::query_as::<_, Account>(
"SELECT id, email, password_hash, display_nick, role, can_publish_addons, created_at
FROM accounts WHERE id = $1",
)
.bind(id)
.fetch_optional(pool)
.await
}
pub async fn set_avatar(pool: &PgPool, account_id: Uuid, s3_key: &str) -> Result<(), sqlx::Error> {
sqlx::query(
"INSERT INTO avatars (account_id, s3_key) VALUES ($1, $2)
ON CONFLICT (account_id) DO UPDATE SET s3_key = EXCLUDED.s3_key, uploaded_at = now()",
)
.bind(account_id)
.bind(s3_key)
.execute(pool)
.await?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
async fn test_pool() -> PgPool {
let url = std::env::var("DATABASE_URL")
.unwrap_or_else(|_| "postgres://lovisual:lovisual@localhost:5432/accounts_db".into());
let pool = PgPool::connect(&url).await.expect("connect");
sqlx::migrate!("./migrations").run(&pool).await.expect("migrate");
pool
}
#[tokio::test]
async fn create_then_find_by_email_round_trips() {
let pool = test_pool().await;
let email = format!("test-{}@example.com", Uuid::new_v4());
let created = create(&pool, &email, "hash123", "TestNick").await.unwrap();
assert_eq!(created.email, email);
assert_eq!(created.role, "user");
assert!(created.can_publish_addons);
let found = find_by_email(&pool, &email).await.unwrap().expect("must exist");
assert_eq!(found.id, created.id);
sqlx::query("DELETE FROM accounts WHERE id = $1")
.bind(created.id)
.execute(&pool)
.await
.unwrap();
}
#[tokio::test]
async fn find_by_email_returns_none_for_missing() {
let pool = test_pool().await;
let result = find_by_email(&pool, "does-not-exist@example.com").await.unwrap();
assert!(result.is_none());
}
#[tokio::test]
async fn email_lookup_is_case_insensitive_and_stored_lowercase() {
let pool = test_pool().await;
let tag = Uuid::new_v4();
let mixed = format!("MiXeD-{tag}@Example.COM");
let created = create(&pool, &mixed, "hash123", "Nick").await.unwrap();
assert_eq!(created.email, mixed.to_lowercase());
let found = find_by_email(&pool, &mixed.to_uppercase())
.await
.unwrap()
.expect("lookup must ignore case");
assert_eq!(found.id, created.id);
sqlx::query("DELETE FROM accounts WHERE id = $1")
.bind(created.id)
.execute(&pool)
.await
.unwrap();
}
#[tokio::test]
async fn duplicate_email_differing_only_by_case_is_rejected() {
let pool = test_pool().await;
let tag = Uuid::new_v4();
let first = create(&pool, &format!("dup-{tag}@example.com"), "h", "A").await.unwrap();
let second = create(&pool, &format!("DUP-{tag}@EXAMPLE.com"), "h", "B").await;
let err = second.expect_err("case-variant duplicate must violate the unique index");
match err {
sqlx::Error::Database(db) => {
assert_eq!(db.constraint(), Some("accounts_email_lower_idx"));
}
other => panic!("expected a database unique violation, got {other:?}"),
}
sqlx::query("DELETE FROM accounts WHERE id = $1")
.bind(first.id)
.execute(&pool)
.await
.unwrap();
}
#[tokio::test]
async fn set_avatar_inserts_then_updates_in_place() {
let pool = test_pool().await;
let created = create(&pool, &format!("av-{}@example.com", Uuid::new_v4()), "h", "N")
.await
.unwrap();
set_avatar(&pool, created.id, "avatars/one.png").await.unwrap();
set_avatar(&pool, created.id, "avatars/two.png").await.unwrap();
let rows: Vec<(String,)> =
sqlx::query_as("SELECT s3_key FROM avatars WHERE account_id = $1")
.bind(created.id)
.fetch_all(&pool)
.await
.unwrap();
assert_eq!(rows, vec![("avatars/two.png".to_string(),)]);
sqlx::query("DELETE FROM accounts WHERE id = $1")
.bind(created.id)
.execute(&pool)
.await
.unwrap();
}
}

View file

@ -0,0 +1,227 @@
use crate::accounts::repo;
use crate::auth::{password, tokens};
use crate::error::{AppError, AppJson};
use axum::{extract::State, http::StatusCode, Json};
use axum_extra::extract::cookie::CookieJar;
use common::jwt;
use serde::{Deserialize, Serialize};
#[derive(Clone)]
pub struct AuthState {
pub pool: sqlx::PgPool,
pub jwt_secret: String,
pub cookie_secure: bool,
pub hasher: password::PasswordHasher,
// A real Argon2id hash of a throwaway string. `login` verifies against
// it when the email is unknown so that "no such account" costs the same
// ~100ms as "wrong password" — otherwise response time leaks which
// emails are registered (user enumeration via timing).
dummy_hash: String,
}
impl AuthState {
pub fn new(pool: sqlx::PgPool, jwt_secret: String, cookie_secure: bool) -> Self {
// Hashing a fixed, short constant with fixed valid params cannot
// fail; this is not user input, so the expect is a startup invariant.
let dummy_hash = password::hash_password("timing-equalizer-not-a-real-password")
.expect("hashing a constant with pinned params cannot fail");
AuthState {
pool,
jwt_secret,
cookie_secure,
hasher: password::PasswordHasher::new(),
dummy_hash,
}
}
}
#[derive(Deserialize)]
pub struct RegisterRequest {
pub email: String,
pub password: String,
pub nick: String,
}
#[derive(Serialize)]
pub struct RegisterResponse {
pub id: uuid::Uuid,
pub email: String,
pub display_nick: String,
}
/// Validates and normalizes a register request. Returns the trimmed
/// `(email, nick)` on success.
fn validate_register(req: &RegisterRequest) -> Result<(String, String), AppError> {
let email = req.email.trim();
if email.is_empty() {
return Err(AppError::Validation("email must not be empty".into()));
}
if email.len() > 254 {
return Err(AppError::Validation("email must be at most 254 characters".into()));
}
let mut parts = email.split('@');
let (Some(local), Some(domain)) = (parts.next(), parts.next()) else {
return Err(AppError::Validation("email must contain '@'".into()));
};
if parts.next().is_some() || local.is_empty() || domain.is_empty() {
return Err(AppError::Validation("email must have exactly one '@' with non-empty parts".into()));
}
let nick = req.nick.trim();
let nick_len = nick.chars().count();
if nick_len == 0 || nick_len > 32 {
return Err(AppError::Validation("nick must be 1 to 32 characters".into()));
}
if nick.chars().any(|c| c.is_control()) {
return Err(AppError::Validation("nick must not contain control characters".into()));
}
if req.password.chars().count() < 8 {
return Err(AppError::Validation("password must be at least 8 characters".into()));
}
if req.password.len() > password::MAX_PASSWORD_BYTES {
return Err(AppError::Validation(format!(
"password must be at most {} bytes",
password::MAX_PASSWORD_BYTES
)));
}
Ok((email.to_string(), nick.to_string()))
}
pub async fn register(
State(state): State<AuthState>,
AppJson(req): AppJson<RegisterRequest>,
) -> Result<(StatusCode, Json<RegisterResponse>), AppError> {
let (email, nick) = validate_register(&req)?;
let hash = state.hasher.hash(req.password.clone()).await.map_err(AppError::Internal)?;
let account = repo::create(&state.pool, &email, &hash, &nick).await?;
Ok((
StatusCode::CREATED,
Json(RegisterResponse {
id: account.id,
email: account.email,
display_nick: account.display_nick,
}),
))
}
#[derive(Deserialize)]
pub struct LoginRequest {
pub email: String,
pub password: String,
}
#[derive(Serialize)]
pub struct LoginResponse {
pub access_token: String,
}
pub async fn login(
State(state): State<AuthState>,
jar: CookieJar,
AppJson(req): AppJson<LoginRequest>,
) -> Result<(CookieJar, Json<LoginResponse>), AppError> {
let Some(account) = repo::find_by_email(&state.pool, &req.email).await? else {
// Burn the same Argon2 cost as a real check, then fail identically.
state
.hasher
.verify(req.password.clone(), state.dummy_hash.clone())
.await
.map_err(AppError::Internal)?;
return Err(AppError::Unauthorized);
};
if !state
.hasher
.verify(req.password.clone(), account.password_hash.clone())
.await
.map_err(AppError::Internal)?
{
return Err(AppError::Unauthorized);
}
let refresh = tokens::store_refresh(&state.pool, account.id).await?;
Ok((
jar.add(tokens::refresh_cookie(refresh, state.cookie_secure)),
Json(LoginResponse { access_token: jwt::issue_access_token(account.id, &state.jwt_secret) }),
))
}
pub async fn refresh(
State(state): State<AuthState>,
jar: CookieJar,
) -> Result<(CookieJar, Json<LoginResponse>), AppError> {
let token = jar.get(tokens::REFRESH_COOKIE).map(|c| c.value().to_owned()).ok_or(AppError::Unauthorized)?;
match tokens::rotate_refresh(&state.pool, &token).await? {
tokens::RotateOutcome::Rotated { account_id, new_token } => Ok((
jar.add(tokens::refresh_cookie(new_token, state.cookie_secure)),
Json(LoginResponse { access_token: jwt::issue_access_token(account_id, &state.jwt_secret) }),
)),
tokens::RotateOutcome::Invalid => Err(AppError::Unauthorized),
}
}
pub async fn logout(
State(state): State<AuthState>,
jar: CookieJar,
) -> Result<(CookieJar, StatusCode), AppError> {
if let Some(cookie) = jar.get(tokens::REFRESH_COOKIE) {
tokens::revoke_refresh(&state.pool, cookie.value()).await?;
}
Ok((
jar.remove(axum_extra::extract::cookie::Cookie::build(tokens::REFRESH_COOKIE).path("/auth")),
StatusCode::NO_CONTENT,
))
}
#[cfg(test)]
mod tests {
use super::*;
fn valid_request() -> RegisterRequest {
RegisterRequest {
email: "user@example.com".into(),
password: "password123".into(),
nick: "Rider".into(),
}
}
#[test]
fn valid_request_passes() {
assert!(validate_register(&valid_request()).is_ok());
}
#[test]
fn short_password_is_rejected() {
let mut req = valid_request();
req.password = "short12".into();
assert!(validate_register(&req).is_err());
}
#[test]
fn empty_email_is_rejected() {
let mut req = valid_request();
req.email = " ".into();
assert!(validate_register(&req).is_err());
}
#[test]
fn email_without_at_is_rejected() {
let mut req = valid_request();
req.email = "not-an-email".into();
assert!(validate_register(&req).is_err());
}
#[test]
fn empty_nick_is_rejected() {
let mut req = valid_request();
req.nick = " ".into();
assert!(validate_register(&req).is_err());
}
#[test]
fn thirty_three_char_nick_is_rejected() {
let mut req = valid_request();
req.nick = "a".repeat(33);
assert!(validate_register(&req).is_err());
}
}

View file

@ -1 +1,3 @@
pub mod password; pub mod password;
pub mod tokens;
pub mod handlers;

View file

@ -1,18 +1,91 @@
use argon2::{Argon2, PasswordHasher, PasswordVerifier};
use argon2::password_hash::phc::PasswordHash; use argon2::password_hash::phc::PasswordHash;
// `as _`: the trait must be in scope for `hash_password`, but the name
// belongs to the `PasswordHasher` struct below.
use argon2::{Algorithm, Argon2, Params, PasswordHasher as _, PasswordVerifier, Version};
use std::sync::Arc;
use tokio::sync::Semaphore;
// Explicit Argon2id parameters instead of `Argon2::default()`, so a
// dependency bump can never silently weaken the cost (the library default
// is the OWASP *minimum*: m=19 MiB, t=2). 64 MiB / t=3 / p=1 is
// deliberately above that floor.
const M_COST_KIB: u32 = 64 * 1024;
const T_COST: u32 = 3;
const P_COST: u32 = 1;
// Argon2 processes the whole input, so an unbounded password is a cheap
// CPU/memory DoS vector on both register and login. bcrypt-style 72-byte
// truncation is not a concern for Argon2; this is purely an abuse cap.
pub const MAX_PASSWORD_BYTES: usize = 256;
fn hasher() -> Result<Argon2<'static>, argon2::password_hash::Error> {
let params = Params::new(M_COST_KIB, T_COST, P_COST, None)
.map_err(|_| argon2::password_hash::Error::ParamsInvalid)?;
Ok(Argon2::new(Algorithm::Argon2id, Version::V0x13, params))
}
pub fn hash_password(plain: &str) -> Result<String, argon2::password_hash::Error> { pub fn hash_password(plain: &str) -> Result<String, argon2::password_hash::Error> {
let argon2 = Argon2::default(); if plain.len() > MAX_PASSWORD_BYTES {
Ok(argon2.hash_password(plain.as_bytes())?.to_string()) return Err(argon2::password_hash::Error::PasswordInvalid);
}
Ok(hasher()?.hash_password(plain.as_bytes())?.to_string())
} }
pub fn verify_password(plain: &str, hash: &str) -> bool { pub fn verify_password(plain: &str, hash: &str) -> bool {
let Ok(parsed) = PasswordHash::new(hash) else { return false }; if plain.len() > MAX_PASSWORD_BYTES {
return false;
}
let Ok(parsed) = PasswordHash::new(hash) else {
return false;
};
// Parameters are read from the stored PHC string, so hashes created
// under older/lower settings still verify after a cost increase.
Argon2::default() Argon2::default()
.verify_password(plain.as_bytes(), &parsed) .verify_password(plain.as_bytes(), &parsed)
.is_ok() .is_ok()
} }
/// Argon2 (64 MiB, ~100ms) is CPU/memory heavy; without a cap, concurrent
/// register/login requests could starve the tokio worker pool. Sized to the
/// number of CPUs so hashing never oversubscribes them.
#[derive(Clone)]
pub struct PasswordHasher {
permits: Arc<Semaphore>,
}
impl Default for PasswordHasher {
fn default() -> Self {
Self::new()
}
}
impl PasswordHasher {
pub fn new() -> Self {
let permits = std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(2);
PasswordHasher { permits: Arc::new(Semaphore::new(permits)) }
}
/// Runs Argon2 hashing off the async workers, bounded by the permit count.
pub async fn hash(&self, plain: String) -> Result<String, anyhow::Error> {
let _permit = self.permits.acquire().await?;
let inner = tokio::task::spawn_blocking(move || hash_password(&plain))
.await
.map_err(|e| anyhow::anyhow!(e))??;
Ok(inner)
}
/// Runs Argon2 verification off the async workers, bounded by the permit
/// count. Used for both real and dummy (timing-equalizer) verification.
pub async fn verify(&self, plain: String, hash: String) -> Result<bool, anyhow::Error> {
let _permit = self.permits.acquire().await?;
tokio::task::spawn_blocking(move || verify_password(&plain, &hash))
.await
.map_err(|e| anyhow::anyhow!(e))
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -34,4 +107,36 @@ mod tests {
let hash = hash_password("secret123").unwrap(); let hash = hash_password("secret123").unwrap();
assert_ne!(hash, "secret123"); assert_ne!(hash, "secret123");
} }
#[test]
fn hash_uses_pinned_argon2id_params() {
let hash = hash_password("secret123").unwrap();
assert!(
hash.starts_with("$argon2id$v=19$m=65536,t=3,p=1$"),
"unexpected PHC prefix: {hash}"
);
}
#[test]
fn same_password_hashes_differently_each_time() {
let a = hash_password("secret123").unwrap();
let b = hash_password("secret123").unwrap();
assert_ne!(a, b, "salt must be random per hash");
}
#[test]
fn password_length_cap_boundary() {
let at_cap = "a".repeat(MAX_PASSWORD_BYTES);
let hash = hash_password(&at_cap).expect("exactly the cap must be accepted");
assert!(verify_password(&at_cap, &hash));
let over_cap = "a".repeat(MAX_PASSWORD_BYTES + 1);
assert!(hash_password(&over_cap).is_err(), "cap+1 must be rejected");
assert!(!verify_password(&over_cap, &hash));
}
#[test]
fn garbage_hash_string_fails_verify_without_panicking() {
assert!(!verify_password("whatever", "not-a-phc-string"));
}
} }

View file

@ -0,0 +1,136 @@
use axum_extra::extract::cookie::{Cookie, SameSite};
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use rand::RngExt;
use sha2::{Digest, Sha256};
use sqlx::PgPool;
use uuid::Uuid;
pub const REFRESH_COOKIE: &str = "lv_refresh";
/// 256-bit random token: nothing to brute-force, so a slow hash would only
/// add latency to every refresh — plain SHA-256 for storage is enough.
pub fn new_opaque_token(prefix: &str) -> String {
let mut bytes = [0u8; 32];
rand::rng().fill(&mut bytes);
format!("{prefix}{}", URL_SAFE_NO_PAD.encode(bytes))
}
pub fn hash_token(token: &str) -> String {
hex::encode(Sha256::digest(token.as_bytes()))
}
pub async fn store_refresh(pool: &PgPool, account_id: Uuid) -> Result<String, sqlx::Error> {
let token = new_opaque_token("lvr_");
sqlx::query(
"INSERT INTO refresh_tokens (account_id, token_hash, expires_at)
VALUES ($1, $2, now() + interval '30 days')",
)
.bind(account_id)
.bind(hash_token(&token))
.execute(pool)
.await?;
Ok(token)
}
pub enum RotateOutcome {
Rotated { account_id: Uuid, new_token: String },
Invalid,
}
pub async fn rotate_refresh(pool: &PgPool, token: &str) -> Result<RotateOutcome, sqlx::Error> {
let mut tx = pool.begin().await?;
let row: Option<(Uuid, bool, bool)> = sqlx::query_as(
"SELECT account_id, revoked_at IS NOT NULL, expires_at <= now()
FROM refresh_tokens WHERE token_hash = $1 FOR UPDATE",
)
.bind(hash_token(token))
.fetch_optional(&mut *tx)
.await?;
let outcome = match row {
None => RotateOutcome::Invalid,
Some((account_id, true, _)) => {
// Reuse of a rotated token: someone else holds a copy. Kill all sessions.
sqlx::query(
"UPDATE refresh_tokens SET revoked_at = now()
WHERE account_id = $1 AND revoked_at IS NULL",
)
.bind(account_id)
.execute(&mut *tx)
.await?;
RotateOutcome::Invalid
}
Some((_, false, true)) => RotateOutcome::Invalid,
Some((account_id, false, false)) => {
sqlx::query("UPDATE refresh_tokens SET revoked_at = now() WHERE token_hash = $1")
.bind(hash_token(token))
.execute(&mut *tx)
.await?;
let new_token = new_opaque_token("lvr_");
sqlx::query(
"INSERT INTO refresh_tokens (account_id, token_hash, expires_at)
VALUES ($1, $2, now() + interval '30 days')",
)
.bind(account_id)
.bind(hash_token(&new_token))
.execute(&mut *tx)
.await?;
RotateOutcome::Rotated { account_id, new_token }
}
};
tx.commit().await?;
Ok(outcome)
}
pub async fn revoke_refresh(pool: &PgPool, token: &str) -> Result<(), sqlx::Error> {
sqlx::query(
"UPDATE refresh_tokens SET revoked_at = now() WHERE token_hash = $1 AND revoked_at IS NULL",
)
.bind(hash_token(token))
.execute(pool)
.await?;
Ok(())
}
/// The refresh token never touches JS: httpOnly, Strict, scoped to /auth,
/// so an XSS on the site cannot exfiltrate it.
pub fn refresh_cookie(token: String, secure: bool) -> Cookie<'static> {
Cookie::build((REFRESH_COOKIE, token))
.http_only(true)
.secure(secure)
.same_site(SameSite::Strict)
.path("/auth")
.max_age(time::Duration::days(30))
.build()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn opaque_tokens_are_prefixed_unique_and_long() {
let a = new_opaque_token("lvr_");
let b = new_opaque_token("lvr_");
assert!(a.starts_with("lvr_"));
assert_eq!(a.len(), 4 + 43);
assert_ne!(a, b);
}
#[test]
fn hash_is_stable_hex_sha256() {
assert_eq!(hash_token("x"), hash_token("x"));
assert_eq!(hash_token("x").len(), 64);
assert_ne!(hash_token("x"), hash_token("y"));
}
#[test]
fn refresh_cookie_has_the_hardened_attributes() {
let cookie = refresh_cookie("t".into(), true);
assert_eq!(cookie.name(), REFRESH_COOKIE);
assert_eq!(cookie.http_only(), Some(true));
assert_eq!(cookie.secure(), Some(true));
assert_eq!(cookie.same_site(), Some(SameSite::Strict));
assert_eq!(cookie.path(), Some("/auth"));
}
}

View file

@ -0,0 +1,63 @@
use crate::accounts::repo;
use crate::avatars::processing::{process_avatar, AvatarImageError};
use crate::avatars::storage::S3Storage;
use crate::error::AppError;
use axum::{
extract::{Multipart, State},
Json,
};
use common::internal::GatewayIdentity;
use serde::Serialize;
pub const MAX_UPLOAD_BYTES: usize = 5 * 1024 * 1024;
#[derive(Clone)]
pub struct AvatarState {
pub pool: sqlx::PgPool,
pub storage: S3Storage,
pub base_url: String,
}
#[derive(Serialize)]
pub struct AvatarResponse {
pub avatar_url: String,
}
pub async fn upload(
State(state): State<AvatarState>,
identity: GatewayIdentity,
mut multipart: Multipart,
) -> Result<Json<AvatarResponse>, AppError> {
let account_id = identity.account_id;
let field = multipart
.next_field()
.await
.map_err(|_| AppError::Validation("malformed multipart body".into()))?
.filter(|f| f.name() == Some("file"))
.ok_or_else(|| AppError::Validation("expected a multipart part named `file`".into()))?;
let bytes = field
.bytes()
.await
.map_err(|_| AppError::Validation("could not read the uploaded file".into()))?;
if bytes.len() > MAX_UPLOAD_BYTES {
return Err(AppError::Validation("file too large (max 5MB)".into()));
}
// Decoding is CPU-bound and the input is untrusted: keep it off the async workers.
let png = tokio::task::spawn_blocking(move || process_avatar(&bytes))
.await
.map_err(|e| AppError::Internal(e.into()))?
.map_err(|e| match e {
AvatarImageError::Unsupported => {
AppError::Validation("unsupported image format (png, jpeg, webp)".into())
}
AvatarImageError::Invalid => AppError::Validation("invalid or too large image".into()),
})?;
let key = format!("avatars/{account_id}.png");
state.storage.put(&key, png, "image/png").await.map_err(AppError::Internal)?;
repo::set_avatar(&state.pool, account_id, &key).await?;
Ok(Json(AvatarResponse { avatar_url: format!("{}/{key}", state.base_url) }))
}

View file

@ -0,0 +1,3 @@
pub mod handlers;
pub mod processing;
pub mod storage;

View file

@ -0,0 +1,90 @@
use image::{imageops::FilterType, ImageFormat, ImageReader, Limits};
use std::io::Cursor;
pub const AVATAR_SIZE: u32 = 256;
pub const MAX_DIMENSION: u32 = 8192;
const MAX_DECODE_BYTES: u64 = 128 * 1024 * 1024;
#[derive(Debug, PartialEq, Eq)]
pub enum AvatarImageError {
/// Not a PNG/JPEG/WebP at all.
Unsupported,
/// Claims to be one of those but does not decode within the limits.
Invalid,
}
/// Decodes untrusted image bytes, center-crops to a square and resizes to
/// `AVATAR_SIZE`x`AVATAR_SIZE`, then re-encodes as PNG. The output is always
/// a freshly generated PNG — never the original bytes — so EXIF metadata and
/// any embedded payload never reach storage.
pub fn process_avatar(bytes: &[u8]) -> Result<Vec<u8>, AvatarImageError> {
let mut reader = ImageReader::new(Cursor::new(bytes))
.with_guessed_format()
.map_err(|_| AvatarImageError::Invalid)?;
if !matches!(
reader.format(),
Some(ImageFormat::Png | ImageFormat::Jpeg | ImageFormat::WebP)
) {
return Err(AvatarImageError::Unsupported);
}
let mut limits = Limits::default();
limits.max_image_width = Some(MAX_DIMENSION);
limits.max_image_height = Some(MAX_DIMENSION);
limits.max_alloc = Some(MAX_DECODE_BYTES);
reader.limits(limits);
let decoded = reader.decode().map_err(|_| AvatarImageError::Invalid)?;
let square = decoded.resize_to_fill(AVATAR_SIZE, AVATAR_SIZE, FilterType::Lanczos3);
let mut out = Cursor::new(Vec::new());
square
.write_to(&mut out, ImageFormat::Png)
.map_err(|_| AvatarImageError::Invalid)?;
Ok(out.into_inner())
}
#[cfg(test)]
mod tests {
use super::*;
use image::ImageFormat;
use std::io::Cursor;
fn png_of(width: u32, height: u32) -> Vec<u8> {
let img = image::RgbaImage::new(width, height);
let mut out = Cursor::new(Vec::new());
img.write_to(&mut out, ImageFormat::Png).unwrap();
out.into_inner()
}
#[test]
fn valid_image_becomes_a_256_square_png() {
let out = process_avatar(&png_of(300, 100)).unwrap();
assert!(out.starts_with(&[0x89, b'P', b'N', b'G']));
let decoded = image::load_from_memory(&out).unwrap();
assert_eq!((decoded.width(), decoded.height()), (AVATAR_SIZE, AVATAR_SIZE));
}
#[test]
fn non_image_bytes_are_unsupported() {
assert_eq!(process_avatar(b"not an image"), Err(AvatarImageError::Unsupported));
}
#[test]
fn gif_is_unsupported() {
assert_eq!(
process_avatar(b"GIF89a\x01\x00\x01\x00\x00\x00\x00;"),
Err(AvatarImageError::Unsupported)
);
}
#[test]
fn truncated_png_is_invalid() {
let mut bytes = png_of(50, 50);
bytes.truncate(40);
assert_eq!(process_avatar(&bytes), Err(AvatarImageError::Invalid));
}
#[test]
fn image_wider_than_the_limit_is_rejected() {
assert_eq!(process_avatar(&png_of(MAX_DIMENSION + 1, 1)), Err(AvatarImageError::Invalid));
}
}

View file

@ -0,0 +1,36 @@
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::Client;
#[derive(Clone)]
pub struct S3Storage {
client: Client,
bucket: String,
}
impl S3Storage {
/// Builds the S3 client synchronously (no I/O happens here — connections
/// are made lazily on first request).
pub fn from_config(endpoint: &str, access_key: &str, secret_key: &str, bucket: String) -> Self {
let creds = aws_sdk_s3::config::Credentials::new(access_key, secret_key, None, None, "static");
let config = aws_sdk_s3::config::Builder::new()
.endpoint_url(endpoint)
.credentials_provider(creds)
.region(aws_sdk_s3::config::Region::new("us-east-1"))
.force_path_style(true)
.behavior_version(aws_sdk_s3::config::BehaviorVersion::latest())
.build();
S3Storage { client: Client::from_conf(config), bucket }
}
pub async fn put(&self, key: &str, bytes: Vec<u8>, content_type: &str) -> anyhow::Result<()> {
self.client
.put_object()
.bucket(&self.bucket)
.key(key)
.body(ByteStream::from(bytes))
.content_type(content_type)
.send()
.await?;
Ok(())
}
}

View file

@ -4,11 +4,13 @@ use anyhow::{Context, Result};
pub struct Config { pub struct Config {
pub database_url: String, pub database_url: String,
pub jwt_secret: String, pub jwt_secret: String,
pub internal_key: String,
pub port: u16, pub port: u16,
pub s3_endpoint: String, pub s3_endpoint: String,
pub s3_bucket: String, pub s3_bucket: String,
pub s3_access_key: String, pub s3_access_key: String,
pub s3_secret_key: String, pub s3_secret_key: String,
pub cookie_secure: bool,
} }
impl Config { impl Config {
@ -18,6 +20,8 @@ impl Config {
.context("DATABASE_URL not set")?, .context("DATABASE_URL not set")?,
jwt_secret: std::env::var("JWT_SECRET") jwt_secret: std::env::var("JWT_SECRET")
.context("JWT_SECRET not set")?, .context("JWT_SECRET not set")?,
internal_key: std::env::var("INTERNAL_KEY")
.context("INTERNAL_KEY not set")?,
port: std::env::var("PORT") port: std::env::var("PORT")
.unwrap_or_else(|_| "8081".into()) .unwrap_or_else(|_| "8081".into())
.parse() .parse()
@ -30,6 +34,72 @@ impl Config {
.context("S3_ACCESS_KEY not set")?, .context("S3_ACCESS_KEY not set")?,
s3_secret_key: std::env::var("S3_SECRET_KEY") s3_secret_key: std::env::var("S3_SECRET_KEY")
.context("S3_SECRET_KEY not set")?, .context("S3_SECRET_KEY not set")?,
cookie_secure: std::env::var("COOKIE_SECURE").map(|v| v != "false").unwrap_or(true),
}) })
} }
} }
const MIN_JWT_SECRET_BYTES: usize = 32;
impl Config {
/// Fail fast at startup on a secret too weak to sign HS256 tokens with
/// (the `.env.example` placeholder must never reach production).
pub fn validate(&self) -> Result<()> {
if self.jwt_secret.len() < MIN_JWT_SECRET_BYTES {
anyhow::bail!("JWT_SECRET must be at least {MIN_JWT_SECRET_BYTES} bytes");
}
if self.internal_key.len() < MIN_JWT_SECRET_BYTES {
anyhow::bail!("INTERNAL_KEY must be at least {MIN_JWT_SECRET_BYTES} bytes");
}
Ok(())
}
/// Public prefix of stored avatars: `<endpoint>/<bucket>`.
pub fn avatar_base_url(&self) -> String {
format!("{}/{}", self.s3_endpoint.trim_end_matches('/'), self.s3_bucket)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn config_with_secret(secret: &str) -> Config {
Config {
database_url: String::new(),
jwt_secret: secret.into(),
internal_key: "k".repeat(32),
port: 0,
s3_endpoint: String::new(),
s3_bucket: String::new(),
s3_access_key: String::new(),
s3_secret_key: String::new(),
cookie_secure: false,
}
}
#[test]
fn short_internal_key_is_rejected() {
let mut cfg = config_with_secret(&"x".repeat(32));
cfg.internal_key = "short".into();
assert!(cfg.validate().is_err());
}
#[test]
fn short_jwt_secret_is_rejected() {
assert!(config_with_secret("too-short").validate().is_err());
}
#[test]
fn strong_jwt_secret_is_accepted() {
assert!(config_with_secret(&"x".repeat(32)).validate().is_ok());
}
#[test]
fn avatar_base_url_joins_endpoint_and_bucket_without_double_slash() {
let mut cfg = config_with_secret(&"x".repeat(32));
cfg.s3_endpoint = "http://localhost:9000/".into();
cfg.s3_bucket = "avatars".into();
assert_eq!(cfg.avatar_base_url(), "http://localhost:9000/avatars");
}
}

View file

@ -0,0 +1,73 @@
use crate::device::store::{self, DeviceStore, PollResult};
use crate::error::{AppError, AppJson};
use axum::{extract::State, http::StatusCode, Json};
use common::internal::GatewayIdentity;
use common::jwt;
use serde::{Deserialize, Serialize};
#[derive(Clone)]
pub struct DeviceState {
pub store: DeviceStore,
pub jwt_secret: String,
}
#[derive(Serialize)]
pub struct DeviceCodeResponse {
pub device_code: String,
pub user_code: String,
pub expires_in: u64,
}
pub async fn create_code(
State(state): State<DeviceState>,
) -> Result<(StatusCode, Json<DeviceCodeResponse>), AppError> {
let (device_code, user_code) = state.store.create().ok_or(AppError::TooManyRequests)?;
Ok((
StatusCode::CREATED,
Json(DeviceCodeResponse { device_code, user_code, expires_in: store::TTL.as_secs() }),
))
}
#[derive(Deserialize)]
pub struct ConfirmRequest {
pub user_code: String,
}
pub async fn confirm(
State(state): State<DeviceState>,
identity: GatewayIdentity,
AppJson(req): AppJson<ConfirmRequest>,
) -> Result<StatusCode, AppError> {
if state.store.confirm(&req.user_code, identity.account_id) {
Ok(StatusCode::OK)
} else {
Err(AppError::NotFound("unknown or expired user_code".into()))
}
}
#[derive(Deserialize)]
pub struct TokenRequest {
pub device_code: String,
}
#[derive(Serialize)]
pub struct TokenResponse {
pub device_token: String,
}
pub async fn token(
State(state): State<DeviceState>,
AppJson(req): AppJson<TokenRequest>,
) -> Result<(StatusCode, Json<Option<TokenResponse>>), AppError> {
match state.store.poll(&req.device_code) {
PollResult::Unknown => Err(AppError::NotFound("unknown or expired device_code".into())),
PollResult::Pending => Ok((StatusCode::ACCEPTED, Json(None))),
PollResult::Confirmed(account_id) => {
// The long-lived device_token is just a refresh-style JWT for now;
// the gateway plan is where per-device revocation via
// device_links.device_token_hash gets enforced on every request.
let device_token = jwt::issue_refresh_token(account_id, &state.jwt_secret);
Ok((StatusCode::OK, Json(Some(TokenResponse { device_token }))))
}
}
}

View file

@ -0,0 +1,2 @@
pub mod handlers;
pub mod store;

View file

@ -0,0 +1,192 @@
use dashmap::DashMap;
use std::sync::Arc;
use std::time::{Duration, Instant};
use uuid::Uuid;
#[derive(Clone)]
pub struct DeviceCodeEntry {
pub user_code: String,
pub confirmed_account_id: Option<Uuid>,
pub expires_at: Instant,
}
#[derive(Clone, Default)]
pub struct DeviceStore {
by_device_code: Arc<DashMap<String, DeviceCodeEntry>>,
}
#[derive(Debug, PartialEq, Eq)]
pub enum PollResult {
Unknown,
Pending,
Confirmed(Uuid),
}
pub(crate) const TTL: Duration = Duration::from_secs(600);
// /device/code is unauthenticated, so the store must be bounded or anyone can
// grow process memory without limit. Expired entries are purged on every
// create, so the cap only bites under sustained abuse.
const MAX_PENDING: usize = 10_000;
fn random_user_code() -> String {
use rand::RngExt;
const ALPHABET: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // no O/0/I/1 confusion
let mut rng = rand::rng();
let mut part = |n: usize| -> String {
(0..n).map(|_| ALPHABET[rng.random_range(0..ALPHABET.len())] as char).collect()
};
format!("{}-{}", part(4), part(4))
}
impl DeviceStore {
/// Returns `(device_code, user_code)`, or `None` when the store is full.
pub fn create(&self) -> Option<(String, String)> {
let now = Instant::now();
self.by_device_code.retain(|_, entry| entry.expires_at > now);
if self.by_device_code.len() >= MAX_PENDING {
return None;
}
let device_code = Uuid::new_v4().to_string();
let user_code = random_user_code();
self.by_device_code.insert(
device_code.clone(),
DeviceCodeEntry {
user_code: user_code.clone(),
confirmed_account_id: None,
expires_at: now + TTL,
},
);
Some((device_code, user_code))
}
/// Returns true if a matching, unexpired entry was found and confirmed.
/// The user types this code by hand, so it is trimmed and upper-cased.
pub fn confirm(&self, user_code: &str, account_id: Uuid) -> bool {
let wanted = user_code.trim().to_uppercase();
let now = Instant::now();
for mut entry in self.by_device_code.iter_mut() {
// First confirm wins: an already-confirmed code cannot be
// re-bound to a different account before the device collects it.
if entry.user_code == wanted
&& entry.expires_at > now
&& entry.confirmed_account_id.is_none()
{
entry.confirmed_account_id = Some(account_id);
return true;
}
}
false
}
/// A confirmed entry is CONSUMED by the first poll that sees it: the
/// token can be collected exactly once, replaying the same device_code
/// afterwards yields `Unknown`. Expired entries are dropped and Unknown.
pub fn poll(&self, device_code: &str) -> PollResult {
let now = Instant::now();
let removed = self.by_device_code.remove_if(device_code, |_, entry| {
entry.confirmed_account_id.is_some() || entry.expires_at <= now
});
if let Some((_, entry)) = removed {
return match entry.confirmed_account_id {
Some(id) if entry.expires_at > now => PollResult::Confirmed(id),
_ => PollResult::Unknown,
};
}
if self.by_device_code.contains_key(device_code) {
PollResult::Pending
} else {
PollResult::Unknown
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn create_returns_distinct_codes() {
let store = DeviceStore::default();
let (dc1, uc1) = store.create().unwrap();
let (dc2, uc2) = store.create().unwrap();
assert_ne!(dc1, dc2);
assert_ne!(uc1, uc2);
}
#[test]
fn confirm_then_poll_returns_account_id() {
let store = DeviceStore::default();
let (device_code, user_code) = store.create().unwrap();
let account_id = Uuid::new_v4();
assert!(store.confirm(&user_code, account_id));
assert_eq!(store.poll(&device_code), PollResult::Confirmed(account_id));
}
#[test]
fn confirmed_code_can_only_be_collected_once() {
let store = DeviceStore::default();
let (device_code, user_code) = store.create().unwrap();
assert!(store.confirm(&user_code, Uuid::new_v4()));
assert!(matches!(store.poll(&device_code), PollResult::Confirmed(_)));
assert_eq!(store.poll(&device_code), PollResult::Unknown, "replay must fail");
}
#[test]
fn poll_before_confirm_is_pending_and_repeatable() {
let store = DeviceStore::default();
let (device_code, _user_code) = store.create().unwrap();
assert_eq!(store.poll(&device_code), PollResult::Pending);
assert_eq!(store.poll(&device_code), PollResult::Pending);
}
#[test]
fn poll_unknown_code_is_unknown() {
let store = DeviceStore::default();
assert_eq!(store.poll("does-not-exist"), PollResult::Unknown);
}
#[test]
fn confirm_unknown_user_code_returns_false() {
let store = DeviceStore::default();
assert!(!store.confirm("ZZZZ-ZZZZ", Uuid::new_v4()));
}
#[test]
fn second_confirm_cannot_overwrite_the_first_account() {
let store = DeviceStore::default();
let (device_code, user_code) = store.create().unwrap();
let first = Uuid::new_v4();
let attacker = Uuid::new_v4();
assert!(store.confirm(&user_code, first));
assert!(!store.confirm(&user_code, attacker), "re-confirm must be refused");
assert_eq!(store.poll(&device_code), PollResult::Confirmed(first));
}
#[test]
fn confirm_accepts_lowercase_and_surrounding_whitespace() {
let store = DeviceStore::default();
let (device_code, user_code) = store.create().unwrap();
let typed = format!(" {} ", user_code.to_lowercase());
assert!(store.confirm(&typed, Uuid::new_v4()));
assert!(matches!(store.poll(&device_code), PollResult::Confirmed(_)));
}
#[test]
fn expired_entries_are_purged_and_store_is_bounded() {
let store = DeviceStore::default();
for _ in 0..MAX_PENDING {
assert!(store.create().is_some());
}
assert!(store.create().is_none(), "store must refuse beyond MAX_PENDING");
// Force everything to be expired; the next create purges and succeeds.
for mut entry in store.by_device_code.iter_mut() {
entry.expires_at = Instant::now() - Duration::from_secs(1);
}
assert!(store.create().is_some());
assert_eq!(store.by_device_code.len(), 1);
}
}

View file

@ -0,0 +1,66 @@
use axum::{
extract::{rejection::JsonRejection, FromRequest},
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
#[derive(Debug)]
pub enum AppError {
Validation(String),
Conflict(String),
Unauthorized,
NotFound(String),
TooManyRequests,
Internal(anyhow::Error),
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, message) = match self {
AppError::Validation(msg) => (StatusCode::BAD_REQUEST, msg),
AppError::Conflict(msg) => (StatusCode::CONFLICT, msg),
AppError::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized".into()),
AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg),
AppError::TooManyRequests => (StatusCode::TOO_MANY_REQUESTS, "too many pending device codes".into()),
AppError::Internal(err) => {
tracing::error!("internal error: {err:?}");
(StatusCode::INTERNAL_SERVER_ERROR, "internal error".into())
}
};
(status, Json(json!({ "error": message }))).into_response()
}
}
impl From<sqlx::Error> for AppError {
fn from(err: sqlx::Error) -> Self {
if let sqlx::Error::Database(ref db_err) = err
&& db_err.constraint() == Some("accounts_email_lower_idx")
{
return AppError::Conflict("email already registered".into());
}
AppError::Internal(err.into())
}
}
impl From<JsonRejection> for AppError {
fn from(rejection: JsonRejection) -> Self {
AppError::Validation(rejection.body_text())
}
}
/// Drop-in replacement for `axum::Json` that reports malformed/invalid JSON
/// bodies as our `{"error": ...}` shape instead of axum's plain-text default.
#[derive(FromRequest)]
#[from_request(via(axum::Json), rejection(AppError))]
pub struct AppJson<T>(pub T);
impl<T> IntoResponse for AppJson<T>
where
Json<T>: IntoResponse,
{
fn into_response(self) -> Response {
Json(self.0).into_response()
}
}

View file

@ -1,8 +1,68 @@
pub mod accounts;
pub mod auth; pub mod auth;
pub mod avatars;
pub mod config; pub mod config;
pub mod device;
pub mod error;
use axum::{routing::get, Router}; use auth::handlers::AuthState;
use avatars::{handlers::AvatarState, storage::S3Storage};
use axum::{
extract::DefaultBodyLimit,
Router,
routing::{get, post},
};
use config::Config;
use device::{handlers::DeviceState, store::DeviceStore};
pub fn build_app(_pool: sqlx::PgPool) -> Router { pub fn build_app(pool: sqlx::PgPool, cfg: &Config) -> Router {
Router::new().route("/health", get(|| async { "ok" })) let auth_state =
AuthState::new(pool.clone(), cfg.jwt_secret.clone(), cfg.cookie_secure);
let device_state =
DeviceState { store: DeviceStore::default(), jwt_secret: cfg.jwt_secret.clone() };
let avatar_state = AvatarState {
pool: pool.clone(),
storage: S3Storage::from_config(
&cfg.s3_endpoint,
&cfg.s3_access_key,
&cfg.s3_secret_key,
cfg.s3_bucket.clone(),
),
base_url: cfg.avatar_base_url(),
};
let auth_routes = Router::new()
.route("/auth/register", post(auth::handlers::register))
.route("/auth/login", post(auth::handlers::login))
.route("/auth/refresh", post(auth::handlers::refresh))
.route("/auth/logout", post(auth::handlers::logout))
.with_state(auth_state);
let device_routes = Router::new()
.route("/device/code", post(device::handlers::create_code))
.route("/device/confirm", post(device::handlers::confirm))
.route("/device/token", post(device::handlers::token))
.with_state(device_state);
let avatar_routes = Router::new()
.route("/avatars", post(avatars::handlers::upload))
// Hard transport cap slightly above the 5 MB business limit (413 beyond it).
.layer(DefaultBodyLimit::max(6 * 1024 * 1024))
.with_state(avatar_state);
// Everything except /health is internal-only: reachable solely through
// the gateway, which authenticates the caller and forwards the identity
// header. Direct traffic (or spoofed headers) is rejected here.
let api = Router::new()
.merge(auth_routes)
.merge(device_routes)
.merge(avatar_routes)
.layer(axum::middleware::from_fn_with_state(
common::internal::InternalKey::new(cfg.internal_key.clone()),
common::internal::require_internal_key,
));
Router::new()
.route("/health", get(|| async { "ok" }))
.merge(api)
} }

View file

@ -5,13 +5,16 @@ async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt::init(); tracing_subscriber::fmt::init();
dotenvy::dotenv().ok(); dotenvy::dotenv().ok();
let cfg = Config::from_env()?; let cfg = Config::from_env()?;
cfg.validate()?;
let pool = sqlx::postgres::PgPoolOptions::new() let pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(10) .max_connections(10)
.connect(&cfg.database_url) .connect(&cfg.database_url)
.await?; .await?;
let app = build_app(pool); sqlx::migrate!("./migrations").run(&pool).await?;
let app = build_app(pool, &cfg);
let listener = tokio::net::TcpListener::bind(("0.0.0.0", cfg.port)).await?; let listener = tokio::net::TcpListener::bind(("0.0.0.0", cfg.port)).await?;
tracing::info!("accounts-service listening on {}", cfg.port); tracing::info!("accounts-service listening on {}", cfg.port);
axum::serve(listener, app).await?; axum::serve(listener, app).await?;

View file

@ -0,0 +1,162 @@
use serde_json::json;
use uuid::Uuid;
mod common;
// A test root's submodules resolve against `tests/`, not the file's own
// directory — pin the path so `tests/` itself stays at 4 files.
#[path = "auth_flow/refresh.rs"]
mod refresh;
#[tokio::test]
async fn register_then_login_succeeds() {
let pool = common::test_pool().await;
let app = accounts_service::build_app(pool.clone(), &common::test_config());
let server = common::test_server(app);
let email = format!("flow-{}@example.com", Uuid::new_v4());
let register_response = server
.post("/auth/register")
.json(&json!({ "email": email, "password": "correct-horse-battery-staple", "nick": "Rider" }))
.await;
register_response.assert_status(axum::http::StatusCode::CREATED);
let login_response = server
.post("/auth/login")
.json(&json!({ "email": email, "password": "correct-horse-battery-staple" }))
.await;
login_response.assert_status_ok();
let body: serde_json::Value = login_response.json();
assert!(body["access_token"].is_string());
assert!(body["refresh_token"].is_null(), "refresh token must be in the httpOnly cookie, not the body");
sqlx::query("DELETE FROM accounts WHERE email = $1")
.bind(&email)
.execute(&pool)
.await
.unwrap();
}
#[tokio::test]
async fn login_with_wrong_password_returns_401() {
let pool = common::test_pool().await;
let app = accounts_service::build_app(pool.clone(), &common::test_config());
let server = common::test_server(app);
let email = format!("wrongpw-{}@example.com", Uuid::new_v4());
server
.post("/auth/register")
.json(&json!({ "email": email, "password": "right-password", "nick": "Rider" }))
.await
.assert_status(axum::http::StatusCode::CREATED);
let login_response = server
.post("/auth/login")
.json(&json!({ "email": email, "password": "wrong-password" }))
.await;
login_response.assert_status(axum::http::StatusCode::UNAUTHORIZED);
sqlx::query("DELETE FROM accounts WHERE email = $1")
.bind(&email)
.execute(&pool)
.await
.unwrap();
}
#[tokio::test]
async fn duplicate_email_registration_returns_409() {
let pool = common::test_pool().await;
let app = accounts_service::build_app(pool.clone(), &common::test_config());
let server = common::test_server(app);
let email = format!("dup-{}@example.com", Uuid::new_v4());
server
.post("/auth/register")
.json(&json!({ "email": email, "password": "password123", "nick": "First" }))
.await
.assert_status(axum::http::StatusCode::CREATED);
let second = server
.post("/auth/register")
.json(&json!({ "email": email, "password": "password456", "nick": "Second" }))
.await;
second.assert_status(axum::http::StatusCode::CONFLICT);
sqlx::query("DELETE FROM accounts WHERE email = $1")
.bind(&email)
.execute(&pool)
.await
.unwrap();
}
#[tokio::test]
async fn login_with_unknown_email_returns_401() {
let pool = common::test_pool().await;
let app = accounts_service::build_app(pool.clone(), &common::test_config());
let server = common::test_server(app);
let response = server
.post("/auth/login")
.json(&json!({ "email": format!("nobody-{}@example.com", Uuid::new_v4()), "password": "whatever-123" }))
.await;
response.assert_status(axum::http::StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn login_is_case_insensitive_on_email() {
let pool = common::test_pool().await;
let app = accounts_service::build_app(pool.clone(), &common::test_config());
let server = common::test_server(app);
let tag = Uuid::new_v4();
let registered = format!("CaseUser-{tag}@Example.com");
server
.post("/auth/register")
.json(&json!({ "email": registered, "password": "password123", "nick": "Rider" }))
.await
.assert_status(axum::http::StatusCode::CREATED);
server
.post("/auth/login")
.json(&json!({ "email": registered.to_lowercase(), "password": "password123" }))
.await
.assert_status_ok();
sqlx::query("DELETE FROM accounts WHERE lower(email) = $1")
.bind(registered.to_lowercase())
.execute(&pool)
.await
.unwrap();
}
#[tokio::test]
async fn malformed_json_body_returns_400_with_json_error_shape() {
let pool = common::test_pool().await;
let app = accounts_service::build_app(pool.clone(), &common::test_config());
let server = common::test_server(app);
let response = server
.post("/auth/register")
.content_type("application/json")
.bytes(axum::body::Bytes::from_static(b"{ this is not valid json"))
.await;
response.assert_status(axum::http::StatusCode::BAD_REQUEST);
let body: serde_json::Value = response.json();
assert!(body["error"].is_string(), "expected {{\"error\": ...}}, got {body}");
}
#[tokio::test]
async fn register_rejects_oversized_password_with_400() {
let pool = common::test_pool().await;
let app = accounts_service::build_app(pool.clone(), &common::test_config());
let server = common::test_server(app);
let response = server
.post("/auth/register")
.json(&json!({
"email": format!("big-{}@example.com", Uuid::new_v4()),
"password": "a".repeat(10_000),
"nick": "Rider"
}))
.await;
response.assert_status(axum::http::StatusCode::BAD_REQUEST);
}

View file

@ -0,0 +1,92 @@
use super::common;
use axum::http::StatusCode;
use axum_extra::extract::cookie::Cookie;
use serde_json::json;
async fn login(server: &axum_test::TestServer, email: &str) -> (String, String) {
let res = server
.post("/auth/login")
.json(&json!({ "email": email, "password": "correct-horse-battery-staple" }))
.await;
res.assert_status_ok();
let cookie = res.cookie("lv_refresh");
assert!(cookie.http_only().unwrap_or(false));
assert_eq!(cookie.path(), Some("/auth"));
let body: serde_json::Value = res.json();
assert!(body.get("refresh_token").is_none(), "refresh token must not be in the JSON body");
(body["access_token"].as_str().unwrap().to_owned(), cookie.value().to_owned())
}
#[tokio::test]
async fn refresh_rotates_the_cookie_and_issues_a_new_access_token() {
let pool = common::test_pool().await;
let server = common::test_server(accounts_service::build_app(pool, &common::test_config()));
let (_, email) = common::register_account(&server).await;
let (_, refresh) = login(&server, &email).await;
let res = server
.post("/auth/refresh")
.add_cookie(Cookie::new("lv_refresh", refresh.clone()))
.await;
res.assert_status_ok();
assert!(res.json::<serde_json::Value>()["access_token"].is_string());
assert_ne!(res.cookie("lv_refresh").value(), refresh);
}
#[tokio::test]
async fn reusing_a_rotated_refresh_token_revokes_all_sessions() {
let pool = common::test_pool().await;
let server = common::test_server(accounts_service::build_app(pool, &common::test_config()));
let (_, email) = common::register_account(&server).await;
let (_, first) = login(&server, &email).await;
let second = server
.post("/auth/refresh")
.add_cookie(Cookie::new("lv_refresh", first.clone()))
.await
.cookie("lv_refresh")
.value()
.to_owned();
// Attacker replays the old token -> rejected, and the legit new one dies too.
server
.post("/auth/refresh")
.add_cookie(Cookie::new("lv_refresh", first))
.await
.assert_status(StatusCode::UNAUTHORIZED);
server
.post("/auth/refresh")
.add_cookie(Cookie::new("lv_refresh", second))
.await
.assert_status(StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn logout_revokes_the_refresh_token() {
let pool = common::test_pool().await;
let server = common::test_server(accounts_service::build_app(pool, &common::test_config()));
let (_, email) = common::register_account(&server).await;
let (_, refresh) = login(&server, &email).await;
server
.post("/auth/logout")
.add_cookie(Cookie::new("lv_refresh", refresh.clone()))
.await
.assert_status(StatusCode::NO_CONTENT);
server
.post("/auth/refresh")
.add_cookie(Cookie::new("lv_refresh", refresh))
.await
.assert_status(StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn refresh_without_cookie_or_with_garbage_is_401() {
let pool = common::test_pool().await;
let server = common::test_server(accounts_service::build_app(pool, &common::test_config()));
server.post("/auth/refresh").await.assert_status(StatusCode::UNAUTHORIZED);
server
.post("/auth/refresh")
.add_cookie(Cookie::new("lv_refresh", "lvr_garbage"))
.await
.assert_status(StatusCode::UNAUTHORIZED);
}

View file

@ -0,0 +1,100 @@
mod common;
use axum::http::StatusCode;
use axum_test::multipart::{MultipartForm, Part};
use common::ACCOUNT_ID_HEADER;
fn png_bytes() -> Vec<u8> {
let img = image::RgbaImage::new(64, 32);
let mut out = std::io::Cursor::new(Vec::new());
img.write_to(&mut out, image::ImageFormat::Png).unwrap();
out.into_inner()
}
fn form(bytes: Vec<u8>) -> MultipartForm {
MultipartForm::new().add_part("file", Part::bytes(bytes).file_name("a.png").mime_type("image/png"))
}
#[tokio::test]
async fn valid_upload_stores_avatar_and_returns_url() {
let pool = common::test_pool().await;
let server = common::test_server(accounts_service::build_app(pool.clone(), &common::test_config()));
let (account_id, email) = common::register_account(&server).await;
let response = server
.post("/avatars")
.add_header(ACCOUNT_ID_HEADER, account_id.to_string())
.multipart(form(png_bytes()))
.await;
response.assert_status_ok();
let url = response.json::<serde_json::Value>()["avatar_url"].as_str().unwrap().to_string();
assert!(url.starts_with("http://localhost:9000/lovisual-avatars-test/avatars/"), "{url}");
assert!(url.ends_with(".png"), "{url}");
let stored: (String,) = sqlx::query_as(
"SELECT a.s3_key FROM avatars a JOIN accounts c ON c.id = a.account_id WHERE lower(c.email) = $1",
)
.bind(email.to_lowercase())
.fetch_one(&pool)
.await
.expect("avatars row must exist");
assert!(url.ends_with(&stored.0));
sqlx::query("DELETE FROM accounts WHERE lower(email) = $1")
.bind(email.to_lowercase())
.execute(&pool)
.await
.unwrap();
}
#[tokio::test]
async fn non_image_upload_is_rejected_with_400() {
let pool = common::test_pool().await;
let server = common::test_server(accounts_service::build_app(pool.clone(), &common::test_config()));
let (account_id, email) = common::register_account(&server).await;
server
.post("/avatars")
.add_header(ACCOUNT_ID_HEADER, account_id.to_string())
.multipart(form(b"definitely not an image".to_vec()))
.await
.assert_status(StatusCode::BAD_REQUEST);
sqlx::query("DELETE FROM accounts WHERE lower(email) = $1")
.bind(email.to_lowercase())
.execute(&pool)
.await
.unwrap();
}
#[tokio::test]
async fn oversized_upload_is_rejected_with_400() {
let pool = common::test_pool().await;
let server = common::test_server(accounts_service::build_app(pool.clone(), &common::test_config()));
let (account_id, email) = common::register_account(&server).await;
server
.post("/avatars")
.add_header(ACCOUNT_ID_HEADER, account_id.to_string())
.multipart(form(vec![0u8; 5 * 1024 * 1024 + 1]))
.await
.assert_status(StatusCode::BAD_REQUEST);
sqlx::query("DELETE FROM accounts WHERE lower(email) = $1")
.bind(email.to_lowercase())
.execute(&pool)
.await
.unwrap();
}
#[tokio::test]
async fn upload_without_identity_is_401() {
let pool = common::test_pool().await;
let server = common::test_server(accounts_service::build_app(pool.clone(), &common::test_config()));
server
.post("/avatars")
.multipart(form(png_bytes()))
.await
.assert_status(StatusCode::UNAUTHORIZED);
}

View file

@ -0,0 +1,52 @@
#![allow(dead_code, unused_imports)] // each test binary uses a different subset
use accounts_service::config::Config;
use axum_test::TestServer;
use uuid::Uuid;
// `::` — the workspace crate, not this module (which every test binary
// mounts as `mod common;`).
pub use ::common::internal::{ACCOUNT_ID_HEADER, INTERNAL_KEY_HEADER};
pub async fn test_pool() -> sqlx::PgPool {
let url = std::env::var("DATABASE_URL")
.unwrap_or_else(|_| "postgres://lovisual:lovisual@localhost:5432/accounts_db".into());
let pool = sqlx::PgPool::connect(&url).await.expect("connect to test database");
sqlx::migrate!("./migrations").run(&pool).await.expect("run migrations");
pool
}
pub fn test_config() -> Config {
Config {
database_url: String::new(),
jwt_secret: "test-secret-test-secret-test-secret!".into(),
internal_key: TEST_INTERNAL_KEY.into(),
port: 0,
s3_endpoint: "http://localhost:9000".into(),
s3_bucket: "lovisual-avatars-test".into(),
s3_access_key: "minioadmin".into(),
s3_secret_key: "minioadmin".into(),
cookie_secure: false,
}
}
pub const TEST_INTERNAL_KEY: &str = "internal-key-internal-key-internal!!";
/// A server that behaves like it sits behind the gateway.
pub fn test_server(app: axum::Router) -> TestServer {
let mut server = TestServer::new(app);
server.add_header(INTERNAL_KEY_HEADER, TEST_INTERNAL_KEY);
server
}
/// Registers a fresh random account; returns its id and email.
pub async fn register_account(server: &TestServer) -> (Uuid, String) {
let email = format!("t-{}@example.com", Uuid::new_v4());
let res = server
.post("/auth/register")
.json(&serde_json::json!({ "email": email, "password": "correct-horse-battery-staple", "nick": "Tester" }))
.await;
res.assert_status(axum::http::StatusCode::CREATED);
let body: serde_json::Value = res.json();
(Uuid::parse_str(body["id"].as_str().unwrap()).unwrap(), email)
}

View file

@ -1,14 +0,0 @@
#[tokio::test]
async fn migrations_create_accounts_table() {
let database_url = std::env::var("DATABASE_URL")
.unwrap_or_else(|_| "postgres://lovisual:lovisual@localhost:5432/accounts_db".into());
let pool = sqlx::PgPool::connect(&database_url).await.expect("connect");
sqlx::migrate!("./migrations").run(&pool).await.expect("migrate");
let row: (i64,) = sqlx::query_as("SELECT count(*) FROM accounts")
.fetch_one(&pool)
.await
.expect("accounts table must exist");
assert_eq!(row.0, 0);
}

View file

@ -0,0 +1,87 @@
mod common;
use axum::http::StatusCode;
use serde_json::json;
#[tokio::test]
async fn full_device_link_flow() {
let pool = common::test_pool().await;
let server = common::test_server(accounts_service::build_app(pool.clone(), &common::test_config()));
let (account_id, email) = common::register_account(&server).await;
let code_response: serde_json::Value = server.post("/device/code").await.json();
let device_code = code_response["device_code"].as_str().unwrap();
let user_code = code_response["user_code"].as_str().unwrap();
let poll_before = server
.post("/device/token")
.json(&json!({ "device_code": device_code }))
.await;
poll_before.assert_status(StatusCode::ACCEPTED);
server
.post("/device/confirm")
.add_header(common::ACCOUNT_ID_HEADER, account_id.to_string())
.json(&json!({ "user_code": user_code }))
.await
.assert_status_ok();
let poll_after = server
.post("/device/token")
.json(&json!({ "device_code": device_code }))
.await;
poll_after.assert_status_ok();
let token_body: serde_json::Value = poll_after.json();
assert!(token_body["device_token"].is_string());
// Single use: the same device_code cannot be redeemed twice.
server
.post("/device/token")
.json(&json!({ "device_code": device_code }))
.await
.assert_status(StatusCode::NOT_FOUND);
sqlx::query("DELETE FROM accounts WHERE email = $1")
.bind(&email)
.execute(&pool)
.await
.unwrap();
}
#[tokio::test]
async fn confirm_without_identity_is_401() {
let pool = common::test_pool().await;
let server = common::test_server(accounts_service::build_app(pool.clone(), &common::test_config()));
let code: serde_json::Value = server.post("/device/code").await.json();
server
.post("/device/confirm")
.json(&json!({ "user_code": code["user_code"] }))
.await
.assert_status(StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn unknown_device_code_and_user_code_return_404() {
let pool = common::test_pool().await;
let server = common::test_server(accounts_service::build_app(pool.clone(), &common::test_config()));
let (account_id, email) = common::register_account(&server).await;
server
.post("/device/token")
.json(&json!({ "device_code": "no-such-code" }))
.await
.assert_status(StatusCode::NOT_FOUND);
server
.post("/device/confirm")
.add_header(common::ACCOUNT_ID_HEADER, account_id.to_string())
.json(&json!({ "user_code": "ZZZZ-ZZZZ" }))
.await
.assert_status(StatusCode::NOT_FOUND);
sqlx::query("DELETE FROM accounts WHERE email = $1")
.bind(&email)
.execute(&pool)
.await
.unwrap();
}

View file

@ -1,16 +0,0 @@
use axum_test::TestServer;
#[tokio::test]
async fn health_returns_ok() {
// NOTE: this test does not touch the DB — pass a pool that is never
// queried. sqlx::PgPool::connect_lazy never opens a connection until
// a query runs, so this is safe without a running Postgres.
let pool = sqlx::PgPool::connect_lazy("postgres://user:pass@localhost/db")
.expect("lazy pool");
let app = accounts_service::build_app(pool);
let server = TestServer::new(app);
let response = server.get("/health").await;
response.assert_status_ok();
response.assert_text("ok");
}

View file

@ -0,0 +1,42 @@
mod common;
use axum_test::TestServer;
#[tokio::test]
async fn health_returns_ok() {
// NOTE: this test does not touch the DB — pass a pool that is never
// queried. sqlx::PgPool::connect_lazy never opens a connection until
// a query runs, so this is safe without a running Postgres.
let pool = sqlx::PgPool::connect_lazy("postgres://user:pass@localhost/db")
.expect("lazy pool");
let app = accounts_service::build_app(pool, &common::test_config());
let server = TestServer::new(app);
let response = server.get("/health").await;
response.assert_status_ok();
response.assert_text("ok");
}
#[tokio::test]
async fn migrations_create_accounts_table() {
let pool = common::test_pool().await;
let row: (Option<String>,) = sqlx::query_as("SELECT to_regclass('accounts')::text")
.fetch_one(&pool)
.await
.expect("query must succeed");
assert!(row.0.is_some(), "accounts table must exist after migrations run");
}
#[tokio::test]
async fn api_routes_require_internal_key_but_health_does_not() {
let pool = common::test_pool().await;
// A raw server: no internal key header on any request, as if the
// service were reached directly, bypassing the gateway.
let server = axum_test::TestServer::new(accounts_service::build_app(pool, &common::test_config()));
server.get("/health").await.assert_status_ok();
server
.post("/device/code")
.await
.assert_status(axum::http::StatusCode::FORBIDDEN);
}

23
backend/common/Cargo.toml Normal file
View file

@ -0,0 +1,23 @@
[package]
name = "common"
version = "0.1.0"
edition = "2024"
[dependencies]
axum = "0.8"
jsonwebtoken = { version = "11", default-features = false, features = ["rust_crypto"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
chrono = "0.4"
uuid = { version = "1", features = ["v4", "serde"] }
subtle = "2"
tonic = "0.14"
tonic-prost = "0.14"
prost = "0.14"
[build-dependencies]
tonic-prost-build = "0.14"
[dev-dependencies]
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
axum-test = "21"

4
backend/common/build.rs Normal file
View file

@ -0,0 +1,4 @@
fn main() -> Result<(), Box<dyn std::error::Error>> {
tonic_prost_build::compile_protos("proto/accounts.proto")?;
Ok(())
}

View file

@ -0,0 +1,13 @@
syntax = "proto3";
package accounts.v1;
// Internal-only API of accounts-service. Never exposed publicly; every call
// must carry the x-lovisual-internal-key metadata entry.
service AccountsInternal {
// Resolves a mod's long-lived device token to its account and bumps
// device_links.last_seen. UNAUTHENTICATED if the token is unknown/revoked.
rpc AuthenticateDevice(AuthenticateDeviceRequest) returns (AuthenticateDeviceReply);
}
message AuthenticateDeviceRequest { string device_token = 1; }
message AuthenticateDeviceReply { string account_id = 1; }

View file

@ -0,0 +1,176 @@
//! Contract between the gateway and internal services. Services trust the
//! identity header ONLY because `require_internal_key` guarantees the request
//! came through the gateway (which strips client-supplied copies of both).
use axum::{
extract::{FromRequestParts, Request, State},
http::{request::Parts, StatusCode},
middleware::Next,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
use std::sync::Arc;
use subtle::ConstantTimeEq;
use tonic::{
metadata::{Ascii, MetadataValue},
service::Interceptor,
Status,
};
use uuid::Uuid;
pub const INTERNAL_KEY_HEADER: &str = "x-lovisual-internal-key";
pub const ACCOUNT_ID_HEADER: &str = "x-lovisual-account-id";
pub const DEVICE_TOKEN_PREFIX: &str = "lvd_";
fn keys_match(given: &[u8], expected: &[u8]) -> bool {
given.ct_eq(expected).into()
}
#[derive(Clone)]
pub struct InternalKey(Arc<str>);
impl InternalKey {
pub fn new(key: String) -> Self {
InternalKey(key.into())
}
}
pub async fn require_internal_key(State(key): State<InternalKey>, req: Request, next: Next) -> Response {
let ok = req
.headers()
.get(INTERNAL_KEY_HEADER)
.is_some_and(|v| keys_match(v.as_bytes(), key.0.as_bytes()));
if !ok {
return (StatusCode::FORBIDDEN, Json(json!({ "error": "forbidden" }))).into_response();
}
next.run(req).await
}
/// The authenticated account, as resolved by the gateway. Rejects with 401
/// when the gateway forwarded the request anonymously.
pub struct GatewayIdentity {
pub account_id: Uuid,
}
impl<S: Send + Sync> FromRequestParts<S> for GatewayIdentity {
type Rejection = Response;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
parts
.headers
.get(ACCOUNT_ID_HEADER)
.and_then(|v| v.to_str().ok())
.and_then(|s| Uuid::parse_str(s).ok())
.map(|account_id| GatewayIdentity { account_id })
.ok_or_else(|| {
(StatusCode::UNAUTHORIZED, Json(json!({ "error": "unauthorized" }))).into_response()
})
}
}
/// Server-side tonic interceptor: rejects calls without the internal key.
#[derive(Clone)]
pub struct GrpcKeyCheck(Arc<str>);
impl GrpcKeyCheck {
pub fn new(key: &str) -> Self {
GrpcKeyCheck(key.into())
}
}
impl Interceptor for GrpcKeyCheck {
fn call(&mut self, req: tonic::Request<()>) -> Result<tonic::Request<()>, Status> {
match req.metadata().get(INTERNAL_KEY_HEADER) {
Some(v) if keys_match(v.as_bytes(), self.0.as_bytes()) => Ok(req),
_ => Err(Status::permission_denied("missing or invalid internal key")),
}
}
}
/// Client-side tonic interceptor: attaches the internal key to every call.
#[derive(Clone)]
pub struct GrpcKeyAttach(MetadataValue<Ascii>);
impl GrpcKeyAttach {
pub fn new(key: &str) -> Result<Self, tonic::metadata::errors::InvalidMetadataValue> {
Ok(GrpcKeyAttach(MetadataValue::try_from(key)?))
}
}
impl Interceptor for GrpcKeyAttach {
fn call(&mut self, mut req: tonic::Request<()>) -> Result<tonic::Request<()>, Status> {
req.metadata_mut().insert(INTERNAL_KEY_HEADER, self.0.clone());
Ok(req)
}
}
#[cfg(test)]
mod tests {
use super::*;
use axum::{routing::get, Router};
use axum_test::TestServer;
const KEY: &str = "internal-key-internal-key-internal!!";
fn app() -> Router {
Router::new()
.route("/whoami", get(|id: GatewayIdentity| async move { id.account_id.to_string() }))
.route("/open", get(|| async { "open" }))
.layer(axum::middleware::from_fn_with_state(InternalKey::new(KEY.into()), require_internal_key))
}
#[tokio::test]
async fn request_without_internal_key_is_forbidden() {
let server = TestServer::new(app());
server.get("/open").await.assert_status(axum::http::StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn request_with_wrong_internal_key_is_forbidden() {
let server = TestServer::new(app());
server
.get("/open")
.add_header(INTERNAL_KEY_HEADER, "nope")
.await
.assert_status(axum::http::StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn correct_key_passes_and_identity_is_read() {
let server = TestServer::new(app());
let id = Uuid::new_v4();
let res = server
.get("/whoami")
.add_header(INTERNAL_KEY_HEADER, KEY)
.add_header(ACCOUNT_ID_HEADER, id.to_string())
.await;
res.assert_status_ok();
assert_eq!(res.text(), id.to_string());
}
#[tokio::test]
async fn missing_or_garbage_identity_is_401() {
let server = TestServer::new(app());
server
.get("/whoami")
.add_header(INTERNAL_KEY_HEADER, KEY)
.await
.assert_status_unauthorized();
server
.get("/whoami")
.add_header(INTERNAL_KEY_HEADER, KEY)
.add_header(ACCOUNT_ID_HEADER, "not-a-uuid")
.await
.assert_status_unauthorized();
}
#[test]
fn grpc_key_check_accepts_only_the_right_key() {
let mut check = GrpcKeyCheck::new(KEY);
let mut attach = GrpcKeyAttach::new(KEY).unwrap();
let ok = attach.call(tonic::Request::new(())).unwrap();
assert!(check.call(ok).is_ok());
assert!(check.call(tonic::Request::new(())).is_err());
}
}

150
backend/common/src/jwt.rs Normal file
View file

@ -0,0 +1,150 @@
use axum::http::{header::AUTHORIZATION, HeaderMap};
use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
/// Distinguishes token purposes so a long-lived refresh/device token can
/// never be replayed as a short-lived access token (and vice versa).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TokenType {
Access,
Refresh,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Claims {
pub sub: String,
pub exp: usize,
pub token_type: TokenType,
}
fn issue(account_id: Uuid, secret: &str, token_type: TokenType, ttl_seconds: i64) -> String {
let exp = (chrono::Utc::now() + chrono::Duration::seconds(ttl_seconds)).timestamp() as usize;
let claims = Claims { sub: account_id.to_string(), exp, token_type };
encode(&Header::new(Algorithm::HS256), &claims, &EncodingKey::from_secret(secret.as_bytes()))
.expect("encoding a well-formed Claims struct cannot fail")
}
pub fn issue_access_token(account_id: Uuid, secret: &str) -> String {
issue(account_id, secret, TokenType::Access, 15 * 60)
}
/// Temporary — deleted in Task 4 once opaque refresh tokens land.
pub fn issue_refresh_token(account_id: Uuid, secret: &str) -> String {
issue(account_id, secret, TokenType::Refresh, 30 * 24 * 60 * 60)
}
/// Returns the claims only if the signature, expiry AND token type all match.
/// HS256 is pinned explicitly (no algorithm confusion) and expiry leeway is 0.
pub fn verify_token(token: &str, secret: &str, expected: TokenType) -> Option<Claims> {
let mut validation = Validation::new(Algorithm::HS256);
validation.leeway = 0;
validation.set_required_spec_claims(&["exp", "sub"]);
let claims = decode::<Claims>(
token,
&DecodingKey::from_secret(secret.as_bytes()),
&validation,
)
.ok()?
.claims;
(claims.token_type == expected).then_some(claims)
}
/// The raw token from `Authorization: Bearer <token>`, if the header is
/// present, valid ASCII, uses the Bearer scheme and is non-empty.
pub fn bearer_token(headers: &HeaderMap) -> Option<&str> {
let value = headers.get(AUTHORIZATION)?.to_str().ok()?;
let token = value.strip_prefix("Bearer ")?.trim();
(!token.is_empty()).then_some(token)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn access_token_round_trips() {
let id = Uuid::new_v4();
let token = issue_access_token(id, "test-secret");
let claims = verify_token(&token, "test-secret", TokenType::Access).expect("should decode");
assert_eq!(claims.sub, id.to_string());
assert_eq!(claims.token_type, TokenType::Access);
}
#[test]
fn wrong_secret_fails_verify() {
let token = issue_access_token(Uuid::new_v4(), "test-secret");
assert!(verify_token(&token, "other-secret", TokenType::Access).is_none());
}
#[test]
fn garbage_token_fails_verify() {
assert!(verify_token("not.a.jwt", "test-secret", TokenType::Access).is_none());
}
#[test]
fn refresh_token_is_rejected_where_access_is_expected() {
let token = issue_refresh_token(Uuid::new_v4(), "test-secret");
assert!(verify_token(&token, "test-secret", TokenType::Access).is_none());
assert!(verify_token(&token, "test-secret", TokenType::Refresh).is_some());
}
#[test]
fn access_token_is_rejected_where_refresh_is_expected() {
let token = issue_access_token(Uuid::new_v4(), "test-secret");
assert!(verify_token(&token, "test-secret", TokenType::Refresh).is_none());
}
#[test]
fn expired_token_fails_verify() {
let token = issue(Uuid::new_v4(), "test-secret", TokenType::Access, -10);
assert!(verify_token(&token, "test-secret", TokenType::Access).is_none());
}
#[test]
fn token_signed_with_other_algorithm_is_rejected() {
let claims = Claims {
sub: Uuid::new_v4().to_string(),
exp: (chrono::Utc::now().timestamp() + 600) as usize,
token_type: TokenType::Access,
};
let hs512 = encode(
&Header::new(Algorithm::HS512),
&claims,
&EncodingKey::from_secret(b"test-secret"),
)
.unwrap();
assert!(verify_token(&hs512, "test-secret", TokenType::Access).is_none());
}
use axum::http::HeaderValue;
fn headers_with(value: &str) -> HeaderMap {
let mut headers = HeaderMap::new();
headers.insert(AUTHORIZATION, HeaderValue::from_str(value).unwrap());
headers
}
#[test]
fn bearer_token_extracts_the_token() {
let mut h = HeaderMap::new();
h.insert(AUTHORIZATION, "Bearer abc.def".parse().unwrap());
assert_eq!(bearer_token(&h), Some("abc.def"));
}
#[test]
fn bearer_token_rejects_missing_wrong_scheme_and_empty() {
let mut h = HeaderMap::new();
assert_eq!(bearer_token(&h), None);
h.insert(AUTHORIZATION, "Basic abc".parse().unwrap());
assert_eq!(bearer_token(&h), None);
h.insert(AUTHORIZATION, "Bearer ".parse().unwrap());
assert_eq!(bearer_token(&h), None);
}
#[test]
fn bearer_token_rejects_garbage_headers() {
assert_eq!(bearer_token(&headers_with("Bearer not.a.jwt")).unwrap(), "not.a.jwt");
}
}

View file

@ -0,0 +1,8 @@
pub mod internal;
pub mod jwt;
pub mod pb {
pub mod accounts {
tonic::include_proto!("accounts.v1");
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,5 +0,0 @@
# configs-service (planned)
4 cloud config slots per account, share codes, public showcase (Подсистема 1,
часть 2). See `TODO.md` (Фаза 10) and `backend/STRUCTURE.md`. No
implementation plan yet.

2345
backend/gateway/PLAN.md Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,6 +0,0 @@
# gateway (planned)
Routing + single JWT check point + rate limits in front of `accounts-service`,
`configs-service`, `chat-service`. See `TODO.md` (Фаза 10) and
`backend/STRUCTURE.md`. No implementation plan yet — starts after
`accounts-service` (`backend/PLAN.md`) is done.

View file

@ -67,9 +67,30 @@ frontend/src/
из `backend/PLAN.md` (Task 6: `RegisterResponse`, `LoginResponse` и т.д.), из `backend/PLAN.md` (Task 6: `RegisterResponse`, `LoginResponse` и т.д.),
не `any`/`unknown` без сужения не `any`/`unknown` без сужения
## Тестирование (когда дойдёт до реализации) ## Тестирование
Колокация тестов рядом с кодом (`Component.test.tsx` рядом с `Component.tsx`), Тесты живут в подпапке `tests/` внутри фичи (или `shared/*`), которую они
не отдельное дерево `tests/` — так тест физически не потеряется при проверяют — не рядом с файлом (`Component.test.tsx`) и не в отдельном дереве
удалении/переносе фичи. на верхнем уровне. Это всё ещё удаляется вместе с фичей при её удалении/
переносе, но не съедает бюджет "≤4 файла на папку" — колокация `X.test.tsx`
рядом с `X.tsx` вдвое сокращала бы вместимость каждой папки под этим правилом.
```
frontend/src/
features/
auth/
components/
hooks/
api.ts
types.ts
tests/
api.test.ts
components.test.tsx
shared/
ui/
Button.tsx
TextField.tsx
tests/
ui.test.tsx
```
Источники: [Robin Wieruch — React Folder Structure Best Practices 2026](https://www.robinwieruch.de/react-folder-structure/), [Mastering Modern React + Vite Folder Structure, Medium](https://sandeshrathnayake.medium.com/mastering-modern-react-vite-folder-structure-a-production-ready-guide-for-scalable-applications-9ad8e233f8b9). Источники: [Robin Wieruch — React Folder Structure Best Practices 2026](https://www.robinwieruch.de/react-folder-structure/), [Mastering Modern React + Vite Folder Structure, Medium](https://sandeshrathnayake.medium.com/mastering-modern-react-vite-folder-structure-a-production-ready-guide-for-scalable-applications-9ad8e233f8b9).

1273
frontend/PLAN.md Normal file

File diff suppressed because it is too large Load diff

View file

@ -5,23 +5,67 @@
"": { "": {
"name": "frontend", "name": "frontend",
"dependencies": { "dependencies": {
"@fontsource-variable/inter": "^5.3.0",
"@fontsource/comfortaa": "^5.3.0",
"@fontsource/iosevka": "^5.3.0",
"@tailwindcss/vite": "^4.3.3", "@tailwindcss/vite": "^4.3.3",
"@tanstack/react-query": "^5.103.2",
"react": "^19.2.8", "react": "^19.2.8",
"react-dom": "^19.2.8", "react-dom": "^19.2.8",
"react-router": "^8.4.0",
"tailwindcss": "^4.3.3", "tailwindcss": "^4.3.3",
}, },
"devDependencies": { "devDependencies": {
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^16.3.3",
"@testing-library/user-event": "^14.6.7",
"@types/node": "^26.6.2", "@types/node": "^26.6.2",
"@types/react": "^19.2.18", "@types/react": "^19.2.18",
"@types/react-dom": "^19.2.7", "@types/react-dom": "^19.2.7",
"@vitejs/plugin-react": "^6.1.1", "@vitejs/plugin-react": "^6.1.1",
"jsdom": "^30.1.1",
"oxlint": "^1.81.0", "oxlint": "^1.81.0",
"typescript": "^7.0.2", "typescript": "^7.0.2",
"vite": "^8.3.0", "vite": "^8.3.0",
"vitest": "^5.0.1",
}, },
}, },
}, },
"packages": { "packages": {
"@adobe/css-tools": ["@adobe/css-tools@4.5.0", "", {}, "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q=="],
"@asamuzakjp/css-color": ["@asamuzakjp/css-color@7.0.1", "", { "dependencies": { "@csstools/css-calc": "^3.4.0", "@csstools/css-color-parser": "^4.2.3", "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.1", "lru-cache": "^11.5.3" } }, "sha512-C9duntabagkBZ1LebM7FKmphR4Q1pBclLxVbZETQV0akkFjV0ooFxo8FlvAQyKj9F6l8rEnmidgcTlpyxFYizg=="],
"@asamuzakjp/dom-selector": ["@asamuzakjp/dom-selector@9.2.1", "", { "dependencies": { "bidi-js": "^1.1.0", "css-tree": "^3.2.1", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.5.3" } }, "sha512-NT4s3yZLjovPpliRpTvdsdzyPjqRqiCZj9MxnarBihbaY5MbAG7DWAJcrLlhmC7xKTNCXsTELcqB8YsqpLnUFQ=="],
"@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
"@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="],
"@bramus/specificity": ["@bramus/specificity@2.4.2", "", { "dependencies": { "css-tree": "^3.0.0" }, "bin": { "specificity": "bin/cli.js" } }, "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw=="],
"@csstools/color-helpers": ["@csstools/color-helpers@6.1.1", "", {}, "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w=="],
"@csstools/css-calc": ["@csstools/css-calc@3.4.0", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-XQKj5B7QiZcHiegCOCAzcAOJdhGgWOHbbu62h5e5mkHnn8lWcfiJhllkqWmxu5zWR9jucPHuo1iTB56P033hcg=="],
"@csstools/css-color-parser": ["@csstools/css-color-parser@4.2.3", "", { "dependencies": { "@csstools/color-helpers": "^6.1.1", "@csstools/css-calc": "^3.4.0" }, "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-y4LpL+lmpuyKDiEFq2PnZUVFdAjsoB/qQJod79yLNokXyW7jewi+/WJ69EfItj8A2unWtxXnGjw6LYXgXu5ZjA=="],
"@csstools/css-parser-algorithms": ["@csstools/css-parser-algorithms@4.0.0", "", { "peerDependencies": { "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w=="],
"@csstools/css-syntax-patches-for-csstree": ["@csstools/css-syntax-patches-for-csstree@1.1.14", "", { "peerDependencies": { "css-tree": "^3.2.1" }, "optionalPeers": ["css-tree"] }, "sha512-HpbVXyrofRXpHpgkNIjU/3EWR4WJvOkO3emNK/L6X/mTJU7bGUI3AkkpoTNXznQLp0KRjLHELTGeKI5dIkI9JQ=="],
"@csstools/css-tokenizer": ["@csstools/css-tokenizer@4.0.1", "", {}, "sha512-bPlN9S9O1A0euCpEWE4qnvB5YDuyYVsUTrxSgmAM1Is0j4tICHoVyOVAXfWMP/kS9ZrjvyIXWV2PmomiAXXqOw=="],
"@exodus/bytes": ["@exodus/bytes@1.16.0", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA=="],
"@fontsource-variable/inter": ["@fontsource-variable/inter@5.3.0", "", {}, "sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA=="],
"@fontsource/comfortaa": ["@fontsource/comfortaa@5.3.0", "", {}, "sha512-ab4DERH0q6ko8QCpEZzOV9ebK39LgQ2oM91AkLNqu0VIxxwT0nV2D3sOuI7iLirWWq4IMooaIN6/2llxrDN2JA=="],
"@fontsource/iosevka": ["@fontsource/iosevka@5.3.0", "", {}, "sha512-RcG0v/65e4PTuThR/d7n9jj6PLlex7YUGaLa4ZpL+NnqArWQmD8Z+6gYBTxPMVWxYMRcEw6OF4rdt8XisnC5zA=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
@ -72,6 +116,8 @@
"@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.85.0", "", { "os": "win32", "cpu": "x64" }, "sha512-pBebIPUpKKhWrhSMWhy8TdAZBewiXnfxmaAGxhzxM1068GagqFaTwgKlU6e+UyJ2sPR+VoHouhXuGJkQjsrDvA=="], "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.85.0", "", { "os": "win32", "cpu": "x64" }, "sha512-pBebIPUpKKhWrhSMWhy8TdAZBewiXnfxmaAGxhzxM1068GagqFaTwgKlU6e+UyJ2sPR+VoHouhXuGJkQjsrDvA=="],
"@remix-run/route-pattern": ["@remix-run/route-pattern@0.22.1", "", {}, "sha512-czdGJWh09Lapvcqt7Vb09KlrU2PhRJ8xQaI+AItTuD9aDJ5MAgxnS38lnys6Ll+6RJEqPiaUqTwIQhVLwFvv+w=="],
"@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.10", "", { "os": "android", "cpu": "arm" }, "sha512-bp9svZb+QurZeh+8H4BhrZkifEB0YBNvTVzNSJnJQkj4NrRwmQoDUCGP0vSN7PbvLeM7l1tK6GXL8mrTiH2myg=="], "@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.10", "", { "os": "android", "cpu": "arm" }, "sha512-bp9svZb+QurZeh+8H4BhrZkifEB0YBNvTVzNSJnJQkj4NrRwmQoDUCGP0vSN7PbvLeM7l1tK6GXL8mrTiH2myg=="],
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.10", "", { "os": "android", "cpu": "arm64" }, "sha512-wm6Dld3RXUAZ/gRWKyUy+4W1B5CB5UeFaOzsSWJWEdxZXHH8rCYiZ5dGe6oJmhsunAPWzL7FZV+VtvmN5Ye2eA=="], "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.10", "", { "os": "android", "cpu": "arm64" }, "sha512-wm6Dld3RXUAZ/gRWKyUy+4W1B5CB5UeFaOzsSWJWEdxZXHH8rCYiZ5dGe6oJmhsunAPWzL7FZV+VtvmN5Ye2eA=="],
@ -134,6 +180,26 @@
"@tailwindcss/vite": ["@tailwindcss/vite@4.3.3", "", { "dependencies": { "@tailwindcss/node": "4.3.3", "@tailwindcss/oxide": "4.3.3", "tailwindcss": "4.3.3" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw=="], "@tailwindcss/vite": ["@tailwindcss/vite@4.3.3", "", { "dependencies": { "@tailwindcss/node": "4.3.3", "@tailwindcss/oxide": "4.3.3", "tailwindcss": "4.3.3" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw=="],
"@tanstack/query-core": ["@tanstack/query-core@5.103.2", "", {}, "sha512-I8DkFXls5jXLqtm8+QpOhEmG07hIblhSZFzafg9wHsMSEvMzULy9hK17wU1T/ahfhMbtITJhbxutwwCoihkR7A=="],
"@tanstack/react-query": ["@tanstack/react-query@5.103.2", "", { "dependencies": { "@tanstack/query-core": "5.103.2" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-B+fWiYZBc+0uUD5zDZAeLw9dKj7XEsdnuu6zRZ+no6LNKpeE3P3bJ+N6HINjcIlWR6fW3vUoTneEaGa2V6ehqw=="],
"@testing-library/dom": ["@testing-library/dom@10.4.2", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q=="],
"@testing-library/jest-dom": ["@testing-library/jest-dom@7.0.1", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "picocolors": "^1.1.1", "redent": "^3.0.0" }, "peerDependencies": { "@testing-library/dom": ">=10 <11", "vitest": ">= 0.32" }, "optionalPeers": ["vitest"] }, "sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw=="],
"@testing-library/react": ["@testing-library/react@16.3.3", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "@testing-library/dom": "^10.0.0", "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg=="],
"@testing-library/user-event": ["@testing-library/user-event@14.6.7", "", { "peerDependencies": { "@testing-library/dom": ">=7.21.4" } }, "sha512-MPCpX8bxe8zS+JmmTwLp8jd0dy1rAm60Te/SL8JrQM3qvQJcBOs1d7IefJMyZzqM3EWBrDn/LWDt1BCGu4ASfg=="],
"@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="],
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
"@types/node": ["@types/node@26.6.2", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g=="], "@types/node": ["@types/node@26.6.2", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g=="],
"@types/react": ["@types/react@19.3.0", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg=="], "@types/react": ["@types/react@19.3.0", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg=="],
@ -182,20 +248,68 @@
"@vitejs/plugin-react": ["@vitejs/plugin-react@6.1.1", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "oxc-transform-react": "^0.145.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler", "oxc-transform-react"] }, "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw=="], "@vitejs/plugin-react": ["@vitejs/plugin-react@6.1.1", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "oxc-transform-react": "^0.145.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler", "oxc-transform-react"] }, "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw=="],
"@vitest/mocker": ["@vitest/mocker@5.0.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.31", "@vitest/spy": "5.0.1", "estree-walker": "^3.0.3", "magic-string": "^1.2.3" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-6K1DoBNAPGvuOcSsGA4D6x+5zEEff/KmOOP3uetT2TrGpVfI+HRHRnJJfKi5ib/g1vx8IYHQD8s0pbJz8WQI7Q=="],
"@vitest/spy": ["@vitest/spy@5.0.1", "", {}, "sha512-rbto/mF/SGERxEgYOek7Xm6B9b+y+mVoo+f4b2LymYO8zM1b7uB5nHuhVMTP2hxdzgxvGiZYGxGIaMvL5y180Q=="],
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
"aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="],
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
"bidi-js": ["bidi-js@1.1.0", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg=="],
"chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
"cookie-es": ["cookie-es@3.1.1", "", {}, "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg=="],
"css-tree": ["css-tree@3.2.1", "", { "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" } }, "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA=="],
"css.escape": ["css.escape@1.5.1", "", {}, "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"data-urls": ["data-urls@7.0.0", "", { "dependencies": { "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.0" } }, "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA=="],
"decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="],
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="],
"enhanced-resolve": ["enhanced-resolve@5.25.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-nGXts5znJzmWPu+mIE9izCOzdg63oJca2mDzGWWTth7sr4aCToKcoyFVBQwN75Ij5Pf6p510EwkTqViTRzDV+w=="], "enhanced-resolve": ["enhanced-resolve@5.25.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-nGXts5znJzmWPu+mIE9izCOzdg63oJca2mDzGWWTth7sr4aCToKcoyFVBQwN75Ij5Pf6p510EwkTqViTRzDV+w=="],
"entities": ["entities@8.1.0", "", {}, "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA=="],
"es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="],
"estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
"expect-type": ["expect-type@1.4.0", "", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"html-encoding-sniffer": ["html-encoding-sniffer@7.0.0", "", { "dependencies": { "@exodus/bytes": "^1.15.1" } }, "sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w=="],
"indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="],
"is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="],
"jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"jsdom": ["jsdom@30.1.1", "", { "dependencies": { "@asamuzakjp/css-color": "^7.0.0", "@asamuzakjp/dom-selector": "^9.2.1", "@bramus/specificity": "^2.4.2", "@csstools/css-syntax-patches-for-csstree": "^1.1.13", "@exodus/bytes": "^1.15.1", "css-tree": "^3.2.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^7.0.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.5.2", "parse5": "^8.0.1", "saxes": "^6.0.0", "tough-cookie": "^6.0.2", "undici": "^8.10.2", "w3c-xmlserializer": "^6.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", "whatwg-url": "^17.1.1", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.2.3" }, "optionalPeers": ["canvas"] }, "sha512-FahmoPK5vbPc+jxV1iErMHmAZypCZ942NHF4+qqaWAuvaKKTBZxawnmAtrbGWLU7MtlxfqIP0qw6aSI+aWGtLg=="],
"lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="], "lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="], "lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="],
@ -220,42 +334,108 @@
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="], "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], "lru-cache": ["lru-cache@11.5.3", "", {}, "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg=="],
"lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="],
"magic-string": ["magic-string@1.4.2", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.6.0" } }, "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g=="],
"mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="],
"min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="],
"nanoid": ["nanoid@3.3.19", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug=="], "nanoid": ["nanoid@3.3.19", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug=="],
"obug": ["obug@2.2.1", "", {}, "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q=="],
"oxlint": ["oxlint@1.85.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.85.0", "@oxlint/binding-android-arm64": "1.85.0", "@oxlint/binding-darwin-arm64": "1.85.0", "@oxlint/binding-darwin-x64": "1.85.0", "@oxlint/binding-freebsd-x64": "1.85.0", "@oxlint/binding-linux-arm-gnueabihf": "1.85.0", "@oxlint/binding-linux-arm-musleabihf": "1.85.0", "@oxlint/binding-linux-arm64-gnu": "1.85.0", "@oxlint/binding-linux-arm64-musl": "1.85.0", "@oxlint/binding-linux-ppc64-gnu": "1.85.0", "@oxlint/binding-linux-riscv64-gnu": "1.85.0", "@oxlint/binding-linux-riscv64-musl": "1.85.0", "@oxlint/binding-linux-s390x-gnu": "1.85.0", "@oxlint/binding-linux-x64-gnu": "1.85.0", "@oxlint/binding-linux-x64-musl": "1.85.0", "@oxlint/binding-openharmony-arm64": "1.85.0", "@oxlint/binding-win32-arm64-msvc": "1.85.0", "@oxlint/binding-win32-ia32-msvc": "1.85.0", "@oxlint/binding-win32-x64-msvc": "1.85.0" }, "peerDependencies": { "oxlint-tsgolint": ">=7.0.2001", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-bc26s97nuvPj1ViyPsqmKecVkUWFMEdtayO8MaQ6oiLfs1pj94cQlZZhrh4BPNlr9HQosjhIlwgZKsfcwmcNgg=="], "oxlint": ["oxlint@1.85.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.85.0", "@oxlint/binding-android-arm64": "1.85.0", "@oxlint/binding-darwin-arm64": "1.85.0", "@oxlint/binding-darwin-x64": "1.85.0", "@oxlint/binding-freebsd-x64": "1.85.0", "@oxlint/binding-linux-arm-gnueabihf": "1.85.0", "@oxlint/binding-linux-arm-musleabihf": "1.85.0", "@oxlint/binding-linux-arm64-gnu": "1.85.0", "@oxlint/binding-linux-arm64-musl": "1.85.0", "@oxlint/binding-linux-ppc64-gnu": "1.85.0", "@oxlint/binding-linux-riscv64-gnu": "1.85.0", "@oxlint/binding-linux-riscv64-musl": "1.85.0", "@oxlint/binding-linux-s390x-gnu": "1.85.0", "@oxlint/binding-linux-x64-gnu": "1.85.0", "@oxlint/binding-linux-x64-musl": "1.85.0", "@oxlint/binding-openharmony-arm64": "1.85.0", "@oxlint/binding-win32-arm64-msvc": "1.85.0", "@oxlint/binding-win32-ia32-msvc": "1.85.0", "@oxlint/binding-win32-x64-msvc": "1.85.0" }, "peerDependencies": { "oxlint-tsgolint": ">=7.0.2001", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-bc26s97nuvPj1ViyPsqmKecVkUWFMEdtayO8MaQ6oiLfs1pj94cQlZZhrh4BPNlr9HQosjhIlwgZKsfcwmcNgg=="],
"parse5": ["parse5@8.0.1", "", { "dependencies": { "entities": "^8.0.0" } }, "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], "picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="],
"postcss": ["postcss@8.5.28", "", { "dependencies": { "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A=="], "postcss": ["postcss@8.5.28", "", { "dependencies": { "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A=="],
"pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="],
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"react": ["react@19.3.0", "", {}, "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog=="], "react": ["react@19.3.0", "", {}, "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog=="],
"react-dom": ["react-dom@19.3.0", "", { "dependencies": { "scheduler": "^0.28.0" }, "peerDependencies": { "react": "^19.3.0" } }, "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q=="], "react-dom": ["react-dom@19.3.0", "", { "dependencies": { "scheduler": "^0.28.0" }, "peerDependencies": { "react": "^19.3.0" } }, "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q=="],
"react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="],
"react-router": ["react-router@8.4.0", "", { "dependencies": { "@remix-run/route-pattern": "^0.22.1", "cookie-es": "^3.1.1" }, "peerDependencies": { "react": ">=19.2.7", "react-dom": ">=19.2.7" }, "optionalPeers": ["react-dom"] }, "sha512-JtydAkBvU9UTEe5qNC+POhu+ZBNM7rlKY2IegwXc7Idj7xfRG16x5RZrw3mju+XlqBxfvb2ky9P3jUp9WyWC1g=="],
"redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="],
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
"rolldown": ["rolldown@1.2.10", "", { "dependencies": { "@oxc-project/types": "=0.151.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.10", "@rolldown/binding-android-arm64": "1.2.10", "@rolldown/binding-darwin-arm64": "1.2.10", "@rolldown/binding-darwin-x64": "1.2.10", "@rolldown/binding-freebsd-x64": "1.2.10", "@rolldown/binding-linux-arm-gnueabihf": "1.2.10", "@rolldown/binding-linux-arm64-gnu": "1.2.10", "@rolldown/binding-linux-arm64-musl": "1.2.10", "@rolldown/binding-linux-ppc64-gnu": "1.2.10", "@rolldown/binding-linux-s390x-gnu": "1.2.10", "@rolldown/binding-linux-x64-gnu": "1.2.10", "@rolldown/binding-linux-x64-musl": "1.2.10", "@rolldown/binding-openharmony-arm64": "1.2.10", "@rolldown/binding-win32-arm64-msvc": "1.2.10", "@rolldown/binding-win32-x64-msvc": "1.2.10" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-OxkA08pSryMK7B3XiFA09B4OJ1xJMPgIYCBMY2xchzpqgBGsV1o0DetPAE+Sl3N3L4oCPiEzmHVSOj7iR04Zog=="], "rolldown": ["rolldown@1.2.10", "", { "dependencies": { "@oxc-project/types": "=0.151.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.10", "@rolldown/binding-android-arm64": "1.2.10", "@rolldown/binding-darwin-arm64": "1.2.10", "@rolldown/binding-darwin-x64": "1.2.10", "@rolldown/binding-freebsd-x64": "1.2.10", "@rolldown/binding-linux-arm-gnueabihf": "1.2.10", "@rolldown/binding-linux-arm64-gnu": "1.2.10", "@rolldown/binding-linux-arm64-musl": "1.2.10", "@rolldown/binding-linux-ppc64-gnu": "1.2.10", "@rolldown/binding-linux-s390x-gnu": "1.2.10", "@rolldown/binding-linux-x64-gnu": "1.2.10", "@rolldown/binding-linux-x64-musl": "1.2.10", "@rolldown/binding-openharmony-arm64": "1.2.10", "@rolldown/binding-win32-arm64-msvc": "1.2.10", "@rolldown/binding-win32-x64-msvc": "1.2.10" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-OxkA08pSryMK7B3XiFA09B4OJ1xJMPgIYCBMY2xchzpqgBGsV1o0DetPAE+Sl3N3L4oCPiEzmHVSOj7iR04Zog=="],
"saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="],
"scheduler": ["scheduler@0.28.0", "", {}, "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw=="], "scheduler": ["scheduler@0.28.0", "", {}, "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw=="],
"siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
"std-env": ["std-env@4.2.0", "", {}, "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw=="],
"strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="],
"tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="], "tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="],
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
"tinybench": ["tinybench@6.1.4", "", {}, "sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ=="],
"tinyexec": ["tinyexec@1.3.0", "", {}, "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ=="],
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
"tldts": ["tldts@7.4.15", "", { "dependencies": { "tldts-core": "^7.4.15" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-SJVBeHOxDbNoq14CvpAoA2mLEWdbldGK8nR+yumpjY5nrlZxOflcA7p1Qj1gbTGmbu9DY9qLj/bENSWhBzjxRQ=="],
"tldts-core": ["tldts-core@7.4.15", "", {}, "sha512-ERuv0p98XgSzmlSLJr8vDNxX+uATGInlN97V3R+JpYWaSqn5IG3ewWgCwczk4bkOpgth/o8Nt5FOi4B4w0n/1A=="],
"tough-cookie": ["tough-cookie@6.0.2", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA=="],
"tr46": ["tr46@6.0.0", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw=="],
"typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], "typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="],
"undici": ["undici@8.11.2", "", {}, "sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ=="],
"undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], "undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="],
"vite": ["vite@8.3.0", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", "postcss": "^8.5.28", "rolldown": "~1.2.6", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ=="], "vite": ["vite@8.3.0", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", "postcss": "^8.5.28", "rolldown": "~1.2.6", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ=="],
"vitest": ["vitest@5.0.1", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/mocker": "5.0.1", "chai": "^6.2.2", "es-module-lexer": "^2.3.2", "expect-type": "^1.4.0", "magic-string": "^1.2.3", "obug": "^2.1.4", "picomatch": "^4.0.7", "std-env": "^4.2.0", "tinybench": "6.1.4", "tinyexec": "1.3.0", "tinyglobby": "^0.2.17", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "5.0.1", "@vitest/browser-preview": "5.0.1", "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", "@vitest/coverage-istanbul": "5.0.1", "@vitest/coverage-v8": "5.0.1", "@vitest/ui": "5.0.1", "happy-dom": "*", "jsdom": "*", "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-iA95lQbKEkvrtTkdAgnWbXfbipWiiWe/hDl2P5tMi6WFwD76G0NxXAGp/M9EOcYupeGJRr6wppMc7CoA41TQjg=="],
"w3c-xmlserializer": ["w3c-xmlserializer@6.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-4Nsy8K5Tr6SPDH9jhKJOHf7ChDrc1zufZTVSF7x72hwuEXBqxqk9G6cK+K2NRUtB3iELRJqjXb4JPDMBjMTl2Q=="],
"webidl-conversions": ["webidl-conversions@8.0.1", "", {}, "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ=="],
"whatwg-mimetype": ["whatwg-mimetype@5.0.0", "", {}, "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw=="],
"whatwg-url": ["whatwg-url@17.1.2", "", { "dependencies": { "@exodus/bytes": "^1.15.1", "tr46": "^6.0.0", "webidl-conversions": "^8.0.1" } }, "sha512-TEZA+Zqxin7Jjsm2cjRohCmen5awh+hT6Zi3VZdqZlNRk7zvOI/9WpBFg/DWlA56bWnzwm6DuB8NS0EsxQH9uQ=="],
"why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
"xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="],
"xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="],
"@tailwindcss/node/lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], "@tailwindcss/node/lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
"@tailwindcss/node/magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" }, "bundled": true }, "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" }, "bundled": true }, "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="],
@ -268,6 +448,12 @@
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="],
"@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="],
"data-urls/whatwg-url": ["whatwg-url@16.0.1", "", { "dependencies": { "@exodus/bytes": "^1.11.0", "tr46": "^6.0.0", "webidl-conversions": "^8.0.1" } }, "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw=="],
"@tailwindcss/node/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], "@tailwindcss/node/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
"@tailwindcss/node/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], "@tailwindcss/node/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],

View file

@ -1,10 +1,15 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="ru">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title> <meta name="theme-color" content="#0d1416" />
<meta
name="description"
content="Облачные конфиги, коды для друзей и витрина настроек для мода LoVisual."
/>
<title>LoVisual — аккаунт и облачные конфиги</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View file

@ -7,21 +7,33 @@
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"lint": "oxlint", "lint": "oxlint",
"preview": "vite preview" "preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest"
}, },
"dependencies": { "dependencies": {
"@fontsource-variable/inter": "^5.3.0",
"@fontsource/comfortaa": "^5.3.0",
"@fontsource/iosevka": "^5.3.0",
"@tailwindcss/vite": "^4.3.3", "@tailwindcss/vite": "^4.3.3",
"@tanstack/react-query": "^5.103.2",
"react": "^19.2.8", "react": "^19.2.8",
"react-dom": "^19.2.8", "react-dom": "^19.2.8",
"react-router": "^8.4.0",
"tailwindcss": "^4.3.3" "tailwindcss": "^4.3.3"
}, },
"devDependencies": { "devDependencies": {
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^16.3.3",
"@testing-library/user-event": "^14.6.7",
"@types/node": "^26.6.2", "@types/node": "^26.6.2",
"@types/react": "^19.2.18", "@types/react": "^19.2.18",
"@types/react-dom": "^19.2.7", "@types/react-dom": "^19.2.7",
"@vitejs/plugin-react": "^6.1.1", "@vitejs/plugin-react": "^6.1.1",
"jsdom": "^30.1.1",
"oxlint": "^1.81.0", "oxlint": "^1.81.0",
"typescript": "^7.0.2", "typescript": "^7.0.2",
"vite": "^8.3.0" "vite": "^8.3.0",
"vitest": "^5.0.1"
} }
} }

View file

@ -1,184 +0,0 @@
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}

View file

@ -1,122 +1,7 @@
import { useState } from 'react' import { createBrowserRouter, RouterProvider } from 'react-router'
import heroImg from './assets/hero.png'
import reactLogo from './assets/react.svg'
import viteLogo from './assets/vite.svg'
import './App.css'
function App() { const router = createBrowserRouter([{ path: '/', element: <h1>LoVisual</h1> }])
const [count, setCount] = useState(0)
return ( export default function App() {
<> return <RouterProvider router={router} />
<section id="center">
<div className="hero">
<img src={heroImg} className="base" width="170" height="179" alt="" />
<img src={reactLogo} className="framework" alt="React logo" />
<img src={viteLogo} className="vite" alt="Vite logo" />
</div>
<div>
<h1>Get started</h1>
<p>
Edit <code>src/App.tsx</code> and save to test <code>HMR</code>
</p>
</div>
<button
type="button"
className="counter"
onClick={() => setCount((count) => count + 1)}
>
Count is {count}
</button>
</section>
<div className="ticks"></div>
<section id="next-steps">
<div id="docs">
<svg className="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#documentation-icon"></use>
</svg>
<h2>Documentation</h2>
<p>Your questions, answered</p>
<ul>
<li>
<a href="https://vite.dev/" target="_blank">
<img className="logo" src={viteLogo} alt="" />
Explore Vite
</a>
</li>
<li>
<a href="https://react.dev/" target="_blank">
<img className="button-icon" src={reactLogo} alt="" />
Learn more
</a>
</li>
</ul>
</div>
<div id="social">
<svg className="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#social-icon"></use>
</svg>
<h2>Connect with us</h2>
<p>Join the Vite community</p>
<ul>
<li>
<a href="https://github.com/vitejs/vite" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#github-icon"></use>
</svg>
GitHub
</a>
</li>
<li>
<a href="https://chat.vite.dev/" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#discord-icon"></use>
</svg>
Discord
</a>
</li>
<li>
<a href="https://x.com/vite_js" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#x-icon"></use>
</svg>
X.com
</a>
</li>
<li>
<a href="https://bsky.app/profile/vite.dev" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#bluesky-icon"></use>
</svg>
Bluesky
</a>
</li>
</ul>
</div>
</section>
<div className="ticks"></div>
<section id="spacer"></section>
</>
)
} }
export default App

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

Before

Width:  |  Height:  |  Size: 4 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.5 KiB

View file

@ -0,0 +1,10 @@
import { Navigate, Outlet, useLocation } from 'react-router'
import { useSession } from './session'
export function RequireAuth() {
const { status } = useSession()
const location = useLocation()
if (status === 'loading') return <p className="text-frost">Проверяем вход…</p>
if (status === 'anonymous') return <Navigate to="/login" replace state={{ from: location.pathname }} />
return <Outlet />
}

View file

@ -0,0 +1,21 @@
import { api } from '../../shared/api/client'
import type { Me } from '../../shared/types'
export async function login(email: string, password: string): Promise<void> {
const res = await api.request<{ access_token: string }>('/auth/login', { method: 'POST', json: { email, password } })
api.setAccessToken(res.access_token)
}
export async function register(email: string, password: string, nick: string): Promise<void> {
await api.request('/auth/register', { method: 'POST', json: { email, password, nick } })
}
export async function logout(): Promise<void> {
try {
await api.request('/auth/logout', { method: 'POST' })
} finally {
api.setAccessToken(null)
}
}
export const fetchMe = () => api.request<Me>('/me')

View file

@ -0,0 +1,51 @@
import { useState, type FormEvent } from 'react'
import { Link, useLocation, useNavigate } from 'react-router'
import { describeError } from '../../../shared/api/errors'
import { Button } from '../../../shared/ui/Button'
import { Notice } from '../../../shared/ui/Notice'
import { TextField } from '../../../shared/ui/TextField'
import { useSession } from '../session'
export function LoginForm() {
const { signIn } = useSession()
const navigate = useNavigate()
const from = (useLocation().state as { from?: string } | null)?.from ?? '/configs'
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState<string>()
const [busy, setBusy] = useState(false)
async function submit(e: FormEvent) {
e.preventDefault()
setBusy(true)
setError(undefined)
try {
await signIn(email.trim(), password)
navigate(from, { replace: true })
} catch (err) {
setError(describeError(err, { 401: 'Неверная почта или пароль.' }))
} finally {
setBusy(false)
}
}
return (
<form onSubmit={submit} className="flex w-full max-w-sm flex-col gap-4" noValidate>
<TextField label="Почта" type="email" autoComplete="email" value={email} onChange={(e) => setEmail(e.target.value)} />
<TextField
label="Пароль"
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
{error ? <Notice tone="error">{error}</Notice> : null}
<Button type="submit" busy={busy}>
Войти
</Button>
<p className="text-sm text-frost">
Нет аккаунта? <Link to="/register" className="text-ice underline-offset-4 hover:underline">Создать</Link>
</p>
</form>
)
}

View file

@ -0,0 +1,81 @@
import { useState, type FormEvent } from 'react'
import { Link, useNavigate } from 'react-router'
import { describeError } from '../../../shared/api/errors'
import { Button } from '../../../shared/ui/Button'
import { Notice } from '../../../shared/ui/Notice'
import { TextField } from '../../../shared/ui/TextField'
import { useSession } from '../session'
import { validateEmail, validateNick, validatePassword } from '../validation'
export function RegisterForm() {
const { signUp } = useSession()
const navigate = useNavigate()
const [email, setEmail] = useState('')
const [nick, setNick] = useState('')
const [password, setPassword] = useState('')
const [emailError, setEmailError] = useState<string>()
const [nickError, setNickError] = useState<string>()
const [passwordError, setPasswordError] = useState<string>()
const [error, setError] = useState<string>()
const [busy, setBusy] = useState(false)
async function submit(e: FormEvent) {
e.preventDefault()
setError(undefined)
const emailErr = validateEmail(email)
const nickErr = validateNick(nick)
const passwordErr = validatePassword(password)
setEmailError(emailErr)
setNickError(nickErr)
setPasswordError(passwordErr)
if (emailErr || nickErr || passwordErr) return
setBusy(true)
try {
await signUp(email.trim(), password, nick.trim())
navigate('/configs', { replace: true })
} catch (err) {
setError(describeError(err, { 409: 'Эта почта уже занята. Войди или используй другую.' }))
} finally {
setBusy(false)
}
}
return (
<form onSubmit={submit} className="flex w-full max-w-sm flex-col gap-4" noValidate>
<TextField
label="Почта"
type="email"
autoComplete="email"
value={email}
error={emailError}
onChange={(e) => setEmail(e.target.value)}
/>
<TextField
label="Ник на сайте"
autoComplete="nickname"
value={nick}
error={nickError}
hint={nickError ? undefined : 'Не обязательно совпадает с ником в Minecraft.'}
onChange={(e) => setNick(e.target.value)}
/>
<TextField
label="Пароль"
type="password"
autoComplete="new-password"
value={password}
error={passwordError}
hint={passwordError ? undefined : 'Минимум 8 символов.'}
onChange={(e) => setPassword(e.target.value)}
/>
{error ? <Notice tone="error">{error}</Notice> : null}
<Button type="submit" busy={busy}>
Создать аккаунт
</Button>
<p className="text-sm text-frost">
Уже есть аккаунт? <Link to="/login" className="text-ice underline-offset-4 hover:underline">Войти</Link>
</p>
</form>
)
}

View file

@ -0,0 +1,60 @@
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { createContext, useCallback, useContext, useMemo, type ReactNode } from 'react'
import { api } from '../../shared/api/client'
import type { Me } from '../../shared/types'
import { fetchMe, login, logout, register } from './api'
type Session = {
status: 'loading' | 'anonymous' | 'authenticated'
me: Me | null
signIn(email: string, password: string): Promise<void>
signUp(email: string, password: string, nick: string): Promise<void>
signOut(): Promise<void>
}
const SessionContext = createContext<Session | null>(null)
export function SessionProvider({ children }: { children: ReactNode }) {
const client = useQueryClient()
// Restores the session after a reload: the refresh cookie survives, the
// in-memory access token does not.
const restored = useQuery({ queryKey: ['session-restore'], queryFn: () => api.refresh(), staleTime: Infinity })
const meQuery = useQuery({ queryKey: ['me'], queryFn: fetchMe, enabled: restored.data === true })
const signIn = useCallback(
async (email: string, password: string) => {
await login(email, password)
client.setQueryData(['session-restore'], true)
await client.fetchQuery({ queryKey: ['me'], queryFn: fetchMe })
},
[client],
)
const signUp = useCallback(
async (email: string, password: string, nick: string) => {
await register(email, password, nick)
await signIn(email, password)
},
[signIn],
)
const signOut = useCallback(async () => {
await logout()
client.clear()
client.setQueryData(['session-restore'], false)
}, [client])
const value = useMemo<Session>(() => {
const me = meQuery.data ?? null
const status = restored.isPending || (restored.data && meQuery.isPending) ? 'loading' : me ? 'authenticated' : 'anonymous'
return { status, me, signIn, signUp, signOut }
}, [restored.isPending, restored.data, meQuery.isPending, meQuery.data, signIn, signUp, signOut])
return <SessionContext value={value}>{children}</SessionContext>
}
export function useSession(): Session {
const session = useContext(SessionContext)
if (!session) throw new Error('useSession outside SessionProvider')
return session
}

View file

@ -0,0 +1,53 @@
import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { expect, test } from 'vitest'
import { LoginForm } from '../forms/LoginForm'
import { RegisterForm } from '../forms/RegisterForm'
import { renderApp } from '../../../test/render'
import { json, mockFetch } from '../../../test/fetch'
const me = { id: '1', email: 'a@b.c', display_nick: 'Rider', role: 'user', avatar_url: null, created_at: '2026-09-24T00:00:00Z' }
const anon = { 'POST /auth/refresh': () => json({ error: 'unauthorized' }, 401) }
test('wrong password shows a clear error', async () => {
const user = userEvent.setup()
mockFetch({ ...anon, 'POST /auth/login': () => json({ error: 'unauthorized' }, 401) })
renderApp([{ path: '/login', element: <LoginForm /> }], '/login')
await user.type(await screen.findByLabelText('Почта'), 'a@b.c')
await user.type(screen.getByLabelText('Пароль'), 'wrong-password')
await user.click(screen.getByRole('button', { name: 'Войти' }))
expect(await screen.findByRole('alert')).toHaveTextContent('Неверная почта или пароль.')
})
test('successful login goes to configs', async () => {
const user = userEvent.setup()
mockFetch({ ...anon, 'POST /auth/login': () => json({ access_token: 't' }), 'GET /me': () => json(me) })
renderApp([{ path: '/login', element: <LoginForm /> }, { path: '/configs', element: <p>Мои конфиги</p> }], '/login')
await user.type(await screen.findByLabelText('Почта'), 'a@b.c')
await user.type(screen.getByLabelText('Пароль'), 'correct-horse')
await user.click(screen.getByRole('button', { name: 'Войти' }))
expect(await screen.findByText('Мои конфиги')).toBeInTheDocument()
})
test('register validates before calling the server', async () => {
const user = userEvent.setup()
const fetch = mockFetch(anon)
renderApp([{ path: '/register', element: <RegisterForm /> }], '/register')
await user.type(await screen.findByLabelText('Почта'), 'not-an-email')
await user.type(screen.getByLabelText('Пароль'), 'short')
await user.click(screen.getByRole('button', { name: 'Создать аккаунт' }))
expect(screen.getByLabelText('Почта')).toHaveAttribute('aria-invalid', 'true')
expect(screen.getByLabelText('Пароль')).toHaveAccessibleDescription('Минимум 8 символов.')
expect(fetch.mock.calls.some(([u]) => String(u).includes('/auth/register'))).toBe(false)
})
test('taken email explains what to do', async () => {
const user = userEvent.setup()
mockFetch({ ...anon, 'POST /auth/register': () => json({ error: 'email already registered' }, 409) })
renderApp([{ path: '/register', element: <RegisterForm /> }], '/register')
await user.type(await screen.findByLabelText('Почта'), 'a@b.c')
await user.type(screen.getByLabelText('Ник на сайте'), 'Rider')
await user.type(screen.getByLabelText('Пароль'), 'correct-horse')
await user.click(screen.getByRole('button', { name: 'Создать аккаунт' }))
expect(await screen.findByRole('alert')).toHaveTextContent('Эта почта уже занята')
})

View file

@ -0,0 +1,30 @@
import { screen } from '@testing-library/react'
import { expect, test } from 'vitest'
import { RequireAuth } from '../RequireAuth'
import { useSession } from '../session'
import { renderApp } from '../../../test/render'
import { json, mockFetch } from '../../../test/fetch'
const me = { id: '1', email: 'a@b.c', display_nick: 'Rider', role: 'user', avatar_url: null, created_at: '2026-09-24T00:00:00Z' }
function Whoami() {
const { me } = useSession()
return <p>Привет, {me?.display_nick}</p>
}
const routes = [
{ path: '/login', element: <p>Страница входа</p> },
{ element: <RequireAuth />, children: [{ path: '/configs', element: <Whoami /> }] },
]
test('restores the session from the refresh cookie', async () => {
mockFetch({ 'POST /auth/refresh': () => json({ access_token: 't' }), 'GET /me': () => json(me) })
renderApp(routes, '/configs')
expect(await screen.findByText('Привет, Rider')).toBeInTheDocument()
})
test('anonymous visitor is sent to login', async () => {
mockFetch({ 'POST /auth/refresh': () => json({ error: 'unauthorized' }, 401) })
renderApp(routes, '/configs')
expect(await screen.findByText('Страница входа')).toBeInTheDocument()
})

View file

@ -0,0 +1,16 @@
export function validateEmail(email: string): string | undefined {
const v = email.trim()
if (!v) return 'Введи почту.'
if (v.length > 254 || !/^[^@\s]+@[^@\s]+$/.test(v)) return 'Похоже, в почте опечатка.'
}
export function validatePassword(password: string): string | undefined {
if ([...password].length < 8) return 'Минимум 8 символов.'
if (new TextEncoder().encode(password).length > 256) return 'Слишком длинный пароль.'
}
export function validateNick(nick: string): string | undefined {
const len = [...nick.trim()].length
if (len === 0) return 'Придумай ник.'
if (len > 32) return 'Не длиннее 32 символов.'
}

View file

@ -1 +1,52 @@
@import "tailwindcss"; @import "tailwindcss";
@import "@fontsource-variable/inter";
@import "@fontsource/comfortaa/400.css";
@import "@fontsource/comfortaa/700.css";
@import "@fontsource/iosevka/400.css";
@import "@fontsource/iosevka/700.css";
@theme {
--color-abyss: #0d1416;
--color-slate: #142226;
--color-shoal: #1f3238;
--color-frost: #9fb3b0;
--color-snow: #eef5f3;
--color-ice: #5cc8e7;
--color-ember: #e8835a;
--font-display: "Comfortaa", ui-rounded, system-ui, sans-serif;
--font-sans: "Inter Variable", system-ui, sans-serif;
--font-code: "Iosevka", ui-monospace, monospace;
--text-hero: 3.8125rem;
--text-hero--line-height: 1.05;
--radius-panel: 10px;
}
@layer base {
html {
color-scheme: dark;
}
body {
@apply bg-abyss text-snow font-sans antialiased;
min-height: 100dvh;
}
h1,
h2,
h3 {
@apply font-display font-bold;
}
:focus-visible {
outline: 2px solid var(--color-ice);
outline-offset: 2px;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}

View file

@ -0,0 +1,10 @@
import { LoginForm } from '../../features/auth/forms/LoginForm'
export default function LoginPage() {
return (
<section className="flex flex-col gap-6 py-12">
<h1 className="text-3xl">Вход</h1>
<LoginForm />
</section>
)
}

View file

@ -0,0 +1,13 @@
import { RegisterForm } from '../../features/auth/forms/RegisterForm'
export default function RegisterPage() {
return (
<section className="flex flex-col gap-6 py-12">
<h1 className="text-3xl">Новый аккаунт</h1>
<p className="max-w-sm text-frost">
Аккаунт нужен для облачных конфигов и привязки игры. Пароль в мод вводить не придётся.
</p>
<RegisterForm />
</section>
)
}

View file

@ -0,0 +1,101 @@
export class ApiError extends Error {
readonly status: number
readonly retryAfter: number | null
constructor(status: number, message: string, retryAfter: number | null = null) {
super(message)
this.name = 'ApiError'
this.status = status
this.retryAfter = retryAfter
}
}
export type RequestOptions = { method?: string; json?: unknown; body?: BodyInit; signal?: AbortSignal }
export type ApiClient = {
request<T>(path: string, options?: RequestOptions): Promise<T>
refresh(): Promise<boolean>
setAccessToken(token: string | null): void
hasAccessToken(): boolean
}
async function toError(res: Response): Promise<ApiError> {
let message = `HTTP ${res.status}`
try {
const body: unknown = await res.json()
if (body && typeof body === 'object' && 'error' in body && typeof body.error === 'string') message = body.error
} catch {
// non-JSON error body: keep the status text
}
const retry = Number(res.headers.get('retry-after'))
return new ApiError(res.status, message, Number.isFinite(retry) && retry > 0 ? retry : null)
}
export function createApiClient(
baseUrl = '/api',
// Resolved at call time so tests can stub the global fetch.
fetchImpl: typeof fetch = (input, init) => fetch(input, init),
): ApiClient {
let accessToken: string | null = null
let refreshing: Promise<boolean> | null = null
async function refreshOnce(): Promise<boolean> {
try {
const res = await fetchImpl(`${baseUrl}/auth/refresh`, { method: 'POST', credentials: 'include' })
if (!res.ok) {
accessToken = null
return false
}
const body = (await res.json()) as { access_token: string }
accessToken = body.access_token
return true
} catch {
return false
}
}
function refresh(): Promise<boolean> {
refreshing ??= refreshOnce().finally(() => {
refreshing = null
})
return refreshing
}
function send(path: string, options: RequestOptions): Promise<Response> {
const headers = new Headers()
let body = options.body
if (options.json !== undefined) {
headers.set('content-type', 'application/json')
body = JSON.stringify(options.json)
}
if (accessToken) headers.set('authorization', `Bearer ${accessToken}`)
return fetchImpl(`${baseUrl}${path}`, {
method: options.method ?? 'GET',
headers,
body,
credentials: 'include',
signal: options.signal,
})
}
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
let res = await send(path, options)
if (res.status === 401 && !path.startsWith('/auth/') && (await refresh())) {
res = await send(path, options)
}
if (!res.ok) throw await toError(res)
const text = await res.text()
return (text ? JSON.parse(text) : undefined) as T
}
return {
request,
refresh,
setAccessToken: (token) => {
accessToken = token
},
hasAccessToken: () => accessToken !== null,
}
}
export const api = createApiClient()

View file

@ -0,0 +1,22 @@
import { ApiError } from './client'
const BY_STATUS: Record<number, string> = {
400: 'Проверь введённые данные.',
401: 'Нужно войти в аккаунт.',
403: 'Недостаточно прав.',
404: 'Не найдено.',
409: 'Уже существует.',
}
export function describeError(err: unknown, overrides: Partial<Record<number, string>> = {}): string {
if (err instanceof ApiError) {
if (err.status === 429) {
return err.retryAfter
? `Слишком много попыток. Подожди ${err.retryAfter} с.`
: 'Слишком много попыток. Подожди немного.'
}
if (err.status >= 500) return 'Сервер не ответил. Попробуй через минуту.'
return overrides[err.status] ?? BY_STATUS[err.status] ?? 'Проверь введённые данные.'
}
return 'Нет связи с сервером. Проверь интернет и попробуй ещё раз.'
}

View file

@ -0,0 +1,82 @@
import { expect, test } from 'vitest'
import { ApiError, createApiClient } from '../client'
import { describeError } from '../errors'
import { json, mockFetch } from '../../../test/fetch'
test('sends json with bearer token and cookies', async () => {
const fetch = mockFetch({ 'PUT /configs/1': () => json({ ok: true }) })
const api = createApiClient()
api.setAccessToken('tok')
await api.request('/configs/1', { method: 'PUT', json: { a: 1 } })
const [, init] = fetch.mock.calls[0]
const headers = new Headers(init?.headers)
expect(headers.get('authorization')).toBe('Bearer tok')
expect(headers.get('content-type')).toBe('application/json')
expect(init?.credentials).toBe('include')
expect(init?.body).toBe('{"a":1}')
})
test('401 triggers one refresh and one retry', async () => {
let calls = 0
const fetch = mockFetch({
'GET /me': () => (++calls === 1 ? json({ error: 'unauthorized' }, 401) : json({ id: 'x' })),
'POST /auth/refresh': () => json({ access_token: 'new' }),
})
const api = createApiClient()
await expect(api.request('/me')).resolves.toEqual({ id: 'x' })
expect(fetch.mock.calls.filter(([u]) => String(u).endsWith('/auth/refresh'))).toHaveLength(1)
expect(new Headers(fetch.mock.calls[2][1]?.headers).get('authorization')).toBe('Bearer new')
})
test('concurrent 401s share a single refresh', async () => {
let refreshes = 0
mockFetch({
'GET /a': (init) => (new Headers(init.headers).get('authorization') ? json(1) : json({}, 401)),
'GET /b': (init) => (new Headers(init.headers).get('authorization') ? json(2) : json({}, 401)),
'POST /auth/refresh': () => {
refreshes++
return json({ access_token: 't' })
},
})
const api = createApiClient()
await Promise.all([api.request('/a'), api.request('/b')])
expect(refreshes).toBe(1)
})
test('failed refresh surfaces the original 401 and clears the token', async () => {
mockFetch({
'GET /me': () => json({ error: 'unauthorized' }, 401),
'POST /auth/refresh': () => json({ error: 'unauthorized' }, 401),
})
const api = createApiClient()
api.setAccessToken('stale')
await expect(api.request('/me')).rejects.toMatchObject({ status: 401 })
expect(api.hasAccessToken()).toBe(false)
})
test('auth endpoints never trigger refresh', async () => {
const fetch = mockFetch({ 'POST /auth/login': () => json({ error: 'unauthorized' }, 401) })
await expect(createApiClient().request('/auth/login', { method: 'POST', json: {} })).rejects.toBeInstanceOf(
ApiError,
)
expect(fetch).toHaveBeenCalledTimes(1)
})
test('429 carries retry-after; 204 resolves undefined', async () => {
mockFetch({
'POST /auth/login': () => json({ error: 'too many requests' }, 429, { 'retry-after': '42' }),
'DELETE /device/links/1': () => new Response(null, { status: 204 }),
})
const api = createApiClient()
await expect(api.request('/auth/login', { method: 'POST' })).rejects.toMatchObject({ status: 429, retryAfter: 42 })
await expect(api.request('/device/links/1', { method: 'DELETE' })).resolves.toBeUndefined()
})
test('describeError covers 429, network failure and status defaults', () => {
expect(describeError(new ApiError(429, 'x', 42))).toBe('Слишком много попыток. Подожди 42 с.')
expect(describeError(new ApiError(429, 'x', null))).toBe('Слишком много попыток. Подожди немного.')
expect(describeError(new TypeError('Failed to fetch'))).toBe(
'Нет связи с сервером. Проверь интернет и попробуй ещё раз.',
)
expect(describeError(new ApiError(404, 'x'))).toBe('Не найдено.')
})

View file

@ -0,0 +1,8 @@
export type Me = {
id: string
email: string
display_nick: string
role: 'user' | 'admin'
avatar_url: string | null
created_at: string
}

View file

@ -0,0 +1,28 @@
import type { ButtonHTMLAttributes } from 'react'
type Variant = 'primary' | 'quiet' | 'danger'
const VARIANTS: Record<Variant, string> = {
primary: 'bg-ice text-abyss hover:bg-ice/90',
quiet: 'bg-transparent text-snow border border-shoal hover:border-frost',
danger: 'bg-transparent text-ember border border-ember/60 hover:border-ember',
}
export function Button({
variant = 'primary',
busy = false,
disabled,
className = '',
type = 'button',
...rest
}: ButtonHTMLAttributes<HTMLButtonElement> & { variant?: Variant; busy?: boolean }) {
return (
<button
type={type}
disabled={disabled || busy}
aria-busy={busy || undefined}
className={`inline-flex items-center justify-center gap-2 rounded-md px-4 py-2 text-sm font-semibold transition-colors disabled:cursor-not-allowed disabled:opacity-60 ${VARIANTS[variant]} ${className}`}
{...rest}
/>
)
}

View file

@ -0,0 +1,10 @@
import type { ReactNode } from 'react'
export function Notice({ tone, children }: { tone: 'info' | 'error'; children: ReactNode }) {
const styles = tone === 'error' ? 'border-ember/60 text-ember' : 'border-shoal text-frost'
return (
<div role={tone === 'error' ? 'alert' : 'status'} className={`rounded-md border px-3 py-2 text-sm ${styles}`}>
{children}
</div>
)
}

View file

@ -0,0 +1,29 @@
import { useEffect, useState } from 'react'
import { Button } from './Button'
export function ShareCode({ code, label }: { code: string; label?: string }) {
const [copied, setCopied] = useState(false)
useEffect(() => {
if (!copied) return
const t = setTimeout(() => setCopied(false), 2000)
return () => clearTimeout(t)
}, [copied])
async function copy() {
await navigator.clipboard.writeText(code)
setCopied(true)
}
return (
<div className="flex flex-wrap items-center gap-3">
{label ? <span className="text-sm text-frost">{label}</span> : null}
<code className="font-code text-lg tracking-widest text-ice">{code}</code>
<Button variant="quiet" onClick={copy}>
Скопировать
</Button>
<span aria-live="polite" className="text-sm text-frost">
{copied ? 'Скопировано' : ''}
</span>
</div>
)
}

View file

@ -0,0 +1,35 @@
import { useId, type InputHTMLAttributes } from 'react'
export function TextField({
label,
error,
hint,
className = '',
...rest
}: InputHTMLAttributes<HTMLInputElement> & { label: string; error?: string; hint?: string }) {
const id = useId()
const describedBy = error ? `${id}-error` : hint ? `${id}-hint` : undefined
return (
<div className={`flex flex-col gap-1.5 ${className}`}>
<label htmlFor={id} className="text-sm text-frost">
{label}
</label>
<input
id={id}
aria-invalid={error ? true : undefined}
aria-describedby={describedBy}
className="rounded-md border border-shoal bg-abyss px-3 py-2 text-snow placeholder:text-frost/60 focus:border-ice aria-invalid:border-ember"
{...rest}
/>
{error ? (
<p id={`${id}-error`} className="text-sm text-ember">
{error}
</p>
) : hint ? (
<p id={`${id}-hint`} className="text-sm text-frost">
{hint}
</p>
) : null}
</div>
)
}

View file

@ -0,0 +1,36 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { expect, test, vi } from 'vitest'
import { Button } from '../Button'
import { TextField } from '../TextField'
import { ShareCode } from '../ShareCode'
import { Notice } from '../Notice'
test('busy button is disabled and announces busy', () => {
render(<Button busy>Сохранить</Button>)
const b = screen.getByRole('button', { name: 'Сохранить' })
expect(b).toBeDisabled()
expect(b).toHaveAttribute('aria-busy', 'true')
})
test('text field links label and error', () => {
render(<TextField label="Почта" error="Введи почту" />)
const input = screen.getByLabelText('Почта')
expect(input).toHaveAttribute('aria-invalid', 'true')
expect(input).toHaveAccessibleDescription('Введи почту')
})
test('share code copies to clipboard and confirms', async () => {
const user = userEvent.setup()
const writeText = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue()
render(<ShareCode code="7KQ3M9XA" />)
expect(screen.getByText('7KQ3M9XA')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Скопировать' }))
expect(writeText).toHaveBeenCalledWith('7KQ3M9XA')
expect(await screen.findByText('Скопировано')).toBeInTheDocument()
})
test('error notice is an alert', () => {
render(<Notice tone="error">Неверный код</Notice>)
expect(screen.getByRole('alert')).toHaveTextContent('Неверный код')
})

View file

@ -0,0 +1,21 @@
import { vi } from 'vitest'
type Handler = (init: RequestInit) => Response | Promise<Response>
export function json(body: unknown, status = 200, headers: Record<string, string> = {}): Response {
return new Response(body === undefined ? null : JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json', ...headers },
})
}
export function mockFetch(routes: Record<string, Handler>) {
const fn = vi.fn(async (input: RequestInfo | URL, init: RequestInit = {}) => {
const url = new URL(String(input), 'http://localhost')
const key = `${(init.method ?? 'GET').toUpperCase()} ${url.pathname.replace(/^\/api/, '')}`
const handler = routes[key]
return handler ? handler(init) : json({ error: `no mock for ${key}` }, 404)
})
vi.stubGlobal('fetch', fn)
return fn
}

View file

@ -0,0 +1,17 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render } from '@testing-library/react'
import { createMemoryRouter, RouterProvider, type RouteObject } from 'react-router'
import { SessionProvider } from '../features/auth/session'
export function renderApp(routes: RouteObject[], initialPath = '/') {
const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })
const router = createMemoryRouter(routes, { initialEntries: [initialPath] })
const result = render(
<QueryClientProvider client={client}>
<SessionProvider>
<RouterProvider router={router} />
</SessionProvider>
</QueryClientProvider>,
)
return { ...result, router, client }
}

View file

@ -0,0 +1,6 @@
import '@testing-library/jest-dom/vitest'
import { cleanup } from '@testing-library/react'
import { afterEach, vi } from 'vitest'
afterEach(() => cleanup())
afterEach(() => vi.unstubAllGlobals())

View file

@ -0,0 +1,9 @@
import { render, screen } from '@testing-library/react'
import { createMemoryRouter, RouterProvider } from 'react-router'
import { expect, test } from 'vitest'
test('router renders a route', () => {
const router = createMemoryRouter([{ path: '/', element: <h1>LoVisual</h1> }])
render(<RouterProvider router={router} />)
expect(screen.getByRole('heading', { name: 'LoVisual' })).toBeInTheDocument()
})

View file

@ -1,8 +1,24 @@
import tailwindcss from '@tailwindcss/vite' import tailwindcss from '@tailwindcss/vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite' import { defineConfig } from 'vitest/config'
// https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react(), tailwindcss()], plugins: [react(), tailwindcss()],
server: {
// Same-origin in dev: the browser talks to /api, Vite forwards to the gateway.
proxy: {
'/api': {
target: 'http://localhost:8080',
rewrite: (path) => path.replace(/^\/api/, ''),
// The refresh cookie is scoped to Path=/auth by the backend; in dev the
// browser sees it under /api/auth, so rewrite the path accordingly.
cookiePathRewrite: { '/auth': '/api/auth' },
},
},
},
test: {
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
css: false,
},
}) })

View file

@ -313,7 +313,9 @@
(Batches/Bubble/Funnel) + ambient/particle/ 197 + geometry/ доработаны (offset/multiplyAlpha/STACKS) — (Batches/Bubble/Funnel) + ambient/particle/ 197 + geometry/ доработаны (offset/multiplyAlpha/STACKS) —
типы частиц в `visuals/effects/ambient/` типы частиц в `visuals/effects/ambient/`
- [x] Module 989 → ГОТОВО c8da336: 1031→139 + 4×≤134 (ext/) — базовый класс: settings-механику вынести в `module/ext/` (осторожно, общий предок!) - [x] Module 989 → ГОТОВО c8da336: 1031→139 + 4×≤134 (ext/) — базовый класс: settings-механику вынести в `module/ext/` (осторожно, общий предок!)
- [ ] LoVisualAddonManagerScreen 989 — как AltManager — как AltManager - [x] LoVisualAddonManagerScreen 989 → ГОТОВО de56b4b9: 989→112 + 10 файлов mainmenu/addon/
(Controller 164, Ops 112, PanelAnims 56, auth/AddonScanState 52 + render/ 115/162/183
+ render/cards/ 79/88), все ≤200 — по образцу AltManager
- [x] Chams 976 → ГОТОВО 78b5335: 985→129 + 4×≤195 — режимы рендера в подклассы-политики (фасад модуля тонкий) - [x] Chams 976 → ГОТОВО 78b5335: 985→129 + 4×≤195 — режимы рендера в подклассы-политики (фасад модуля тонкий)
- [x] ClickGuiPickerState 962 → ГОТОВО 234f0d9: 970→166 + 4×≤140; CombatProtocolHeuristicsEditorState 932→79 +4×≤192 03ae4a6 — state/actions хелперы - [x] ClickGuiPickerState 962 → ГОТОВО 234f0d9: 970→166 + 4×≤140; CombatProtocolHeuristicsEditorState 932→79 +4×≤192 03ae4a6 — state/actions хелперы
- [x] ModulesMenuScreen 960 → ГОТОВО 234f0d9: 966→145 + 4×≤178 — рендер категорий отдельно от состояния - [x] ModulesMenuScreen 960 → ГОТОВО 234f0d9: 966→145 + 4×≤178 — рендер категорий отдельно от состояния
@ -331,6 +333,12 @@
CosmeticModel 671, MenuScreen 667, GlStencilFramebufferSupport 663, ConfigSerializer 663, CosmeticModel 671, MenuScreen 667, GlStencilFramebufferSupport 663, ConfigSerializer 663,
DynamicIsland 661, ReimaginedVisual 660, RelationsComponent 659, UiStyle 640, Potions 617, DynamicIsland 661, ReimaginedVisual 660, RelationsComponent 659, UiStyle 640, Potions 617,
ModuleComponent 612, MsdfFont 606, Keybinds 603, MediaSessionService 594 и др.) ModuleComponent 612, MsdfFont 606, Keybinds 603, MediaSessionService 594 и др.)
- [x] Волна 2026-09-24 (HUD/settings): NameTags 453→223 (nametags/impl/ Keys+Collector+Projection) b1414d7a,
MainSettingsComponent 448→291 (+Scroll+Model) 3f0d452f, LinuxMediaSession 441→148 3d11bb4e,
CustomTextRenderer 229→195 (GradientTexts), CustomBar 438→185 (+CustomBarModel константы
+bars/impl/BarSettings, forwardеры удалены, mixins правлены на реальные рендереры) 4e442fb7,
CustomHealthBar 406→146 (+bars/impl/HealthBarPainter+HealthScriptRenderer, dead-code удалён) a3415435.
bars/ закрыт: все файлы ≤200, impl/ = 3 файла.
### 8.5.3 201–400 строк (~140 файлов) — проход «касаться = чинить» ### 8.5.3 201–400 строк (~140 файлов) — проход «касаться = чинить»
- [ ] Не делать отдельным марафоном: при правке файла по другой фазе — первым коммитом - [ ] Не делать отдельным марафоном: при правке файла по другой фазе — первым коммитом
@ -343,10 +351,15 @@
- [ ] После каждого шага: `./gradlew build` ✓, `./gradlew test` ✓; конфиг-id модулей и - [ ] После каждого шага: `./gradlew build` ✓, `./gradlew test` ✓; конфиг-id модулей и
ключи сеттингов НЕ ломаем (совместимость .lvcfg); скрипты-переименования пакетов ключи сеттингов НЕ ломаем (совместимость .lvcfg); скрипты-переименования пакетов
не трогают string-literals id — проверять grep'ом после moves. не трогают string-literals id — проверять grep'ом после moves.
- [ ] Критерий закрытия для старта 9.2: ноль файлов >200 в путях, которые трогает Optimize - [x] Критерий закрытия для старта 9.2: ноль файлов >200 в путях, которые трогает Optimize
(Renderer2D-секции blur/glass, OrderedUiBatcher, HUD-имплементации bgEffect, (Renderer2D-секции blur/glass, OrderedUiBatcher, HUD-имплементации bgEffect,
MsdfFont/CustomTextRenderer), и ноль папок >4 в этих же пакетах. Полный ноль по всем MsdfFont/CustomTextRenderer), и ноль папок >4 в этих же пакетах. Полный ноль по всем
244/86 — долгий хвост, 9.2 им не блокируется. 244/86 — долгий хвост, 9.2 им не блокируется.
→ 2026-09-24: MsdfFont 155 ✓, OrderedUiBatcher 180 ✓, CustomTextRenderer 229→195 ✓
(glyph-pass вынесен в GradientTexts). Остаток гейта — bgEffect-HUD >200:
TargetHud 333, Triangulator 304, Scoreboard 233 — ВСЕ фасады Фазы 3 с helper-пакетами
(targethud/render/, triangulator/{render,Solver,Tracker,View}, panels/scoreboard/) →
исключение по правилу фасадов. ГЕЙТ 9.2 ЗАКРЫТ.
- [ ] Обновить этот раздел при подходе к волне (отмечать [x], доснимать счётчики). - [ ] Обновить этот раздел при подходе к волне (отмечать [x], доснимать счётчики).
## Фаза 9: Оптимизация FPS (аудит 2026-09-09) — ПЛАН ## Фаза 9: Оптимизация FPS (аудит 2026-09-09) — ПЛАН

View file

@ -1,10 +1,3 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.features.command.impl; package dev.loki.lovisual.features.command.impl;
import dev.loki.lovisual.features.command.ClientCommand; import dev.loki.lovisual.features.command.ClientCommand;

View file

@ -0,0 +1,247 @@
package dev.loki.lovisual;
import dev.loki.lovisual.addon.pipeline.AddonRenderPipelineManager;
import dev.loki.lovisual.api.v0.render.LoVisualRenderStage;
import dev.loki.lovisual.features.gui.hud.anchor.HudRenderSpace;
import dev.loki.lovisual.features.gui.hud.draggable.DraggableHudElementRegistry;
import dev.loki.lovisual.features.gui.hud.nondraggable.StaticHudElementRegistry;
import dev.loki.lovisual.features.gui.hud.nondraggable.impl.buttons.SwapTooltip;
import dev.loki.lovisual.features.module.core.ModuleManager;
import dev.loki.lovisual.features.module.phase.HudPhase;
import dev.loki.lovisual.features.module.phase.WorldPhase;
import dev.loki.lovisual.mixininterface.render.IGuiGraphics;
import dev.loki.lovisual.render.engine.core.frame.RenderPhase;
import dev.loki.lovisual.render.engine.core.frame.RenderPhaseScope;
import dev.loki.lovisual.render.engine.core.context.ViewportContext;
import dev.loki.lovisual.render.engine.core.system.LoVisualRenderSystem;
import dev.loki.lovisual.render.engine.profiler.config.ProfilerPhase;
import dev.loki.lovisual.render.engine.profiler.render.RenderProfiler2D;
import dev.loki.lovisual.render.engine.renderer.FullScreenRenderer;
import dev.loki.lovisual.render.engine.renderer.Renderer2D;
import dev.loki.lovisual.render.engine.text.render.TextRenderer;
import dev.loki.lovisual.render.helpers.util.SystemCursor;
import dev.loki.lovisual.render.helpers.util.TickDelta;
import net.minecraft.client.DeltaTracker;
import net.minecraft.client.gui.GuiGraphicsExtractor;
/**
* HUD and world-phase rendering driven by the client entry point. Hosts the raw/scaled/logical
* HUD pass orchestration that {@link LoVisual} registers against the Fabric HUD and level render
* hooks, so behaviour stays identical to the inlined implementation.
*/
final class HudRender {
private HudRender() {
}
static void renderWorldPhase(WorldPhase phase,
com.mojang.blaze3d.vertex.PoseStack matrices,
net.minecraft.client.renderer.SubmitNodeCollector consumers) {
if (!Lifecycle.canRunRender()) return;
ModuleManager.renderWorld(
phase,
matrices,
consumers,
TickDelta.tickProgress(false)
);
}
private static void refreshHudFrameTiming(DeltaTracker tickCounter) {
LoVisualRenderSystem.updateFrameTiming(
TickDelta.tickProgress(tickCounter, false),
TickDelta.frameDeltaTicks(tickCounter),
TickDelta.fixedDeltaTicks(tickCounter)
);
}
static void renderHudPhase(HudPhase phase, GuiGraphicsExtractor ctx, DeltaTracker tickCounter) {
if (!Lifecycle.canRunHud()) return;
float tickProgress = TickDelta.tickProgress(tickCounter, false);
ModuleManager.renderHud(phase, ctx, tickProgress);
Renderer2D.Deferred2DLayer layer = deferredLayerForHudPhase(phase);
if (renderHudEngine(phase, layer, ctx, tickCounter)) {
appendHudDeferredMarker(layer, ctx);
}
}
private static boolean renderHudEngine(HudPhase phase,
Renderer2D.Deferred2DLayer layer,
GuiGraphicsExtractor ctx,
DeltaTracker tickCounter) {
boolean rawMain = hasHudMainPassWork(phase, HudRenderSpace.UNSCALED)
|| AddonRenderPipelineManager.hasActiveCallbacks(LoVisualRenderStage.HUD_RAW);
boolean scaledMain = hasHudMainPassWork(phase, HudRenderSpace.SCALED)
|| AddonRenderPipelineManager.hasActiveCallbacks(LoVisualRenderStage.HUD_SCALED);
boolean logicalMain = hasHudMainPassWork(phase, HudRenderSpace.UNSCALED_LOGICAL)
|| AddonRenderPipelineManager.hasActiveCallbacks(LoVisualRenderStage.HUD_LOGICAL);
boolean rawForeground = hasHudForegroundPassWork(phase, HudRenderSpace.UNSCALED)
|| AddonRenderPipelineManager.hasActiveCallbacks(LoVisualRenderStage.HUD_RAW_FOREGROUND);
boolean scaledForeground = hasHudForegroundPassWork(phase, HudRenderSpace.SCALED)
|| AddonRenderPipelineManager.hasActiveCallbacks(LoVisualRenderStage.HUD_SCALED_FOREGROUND);
boolean logicalForeground = hasHudForegroundPassWork(phase, HudRenderSpace.UNSCALED_LOGICAL)
|| AddonRenderPipelineManager.hasActiveCallbacks(LoVisualRenderStage.HUD_LOGICAL_FOREGROUND);
if (!rawMain && !scaledMain && !logicalMain && !rawForeground && !scaledForeground && !logicalForeground) {
return false;
}
float tickProgress = TickDelta.tickProgress(tickCounter, false);
TextRenderer textRenderer = TextRenderer.get();
FullScreenRenderer.ensureInit();
ViewportContext.beginUnscaled(ctx);
refreshHudFrameTiming(tickCounter);
Renderer2D.withDeferredLayer(layer, () -> {
SystemCursor.beginFrame(ctx);
try (RenderPhaseScope hudPhase = LoVisualRenderSystem.phase(RenderPhase.HUD_MAIN, "2d:phase:" + phase.name())) {
// Raw HUD pass
if (rawMain) {
Renderer2D.COLOR.begin();
ModuleManager.renderHudEngine(phase, Renderer2D.COLOR, textRenderer, ctx, tickProgress);
StaticHudElementRegistry.renderAllEngine(phase, HudRenderSpace.UNSCALED, Renderer2D.COLOR, textRenderer, ctx, tickProgress);
AddonRenderPipelineManager.render2D(LoVisualRenderStage.HUD_RAW, phase, Renderer2D.COLOR, textRenderer, ctx, tickProgress);
Renderer2D.COLOR.render();
}
// Scaled fixed HUD pass
if (scaledMain) {
ViewportContext.beginScaled(ctx);
Renderer2D.COLOR.begin();
StaticHudElementRegistry.renderAllEngine(phase, HudRenderSpace.SCALED, Renderer2D.COLOR, textRenderer, ctx, tickProgress);
AddonRenderPipelineManager.render2D(LoVisualRenderStage.HUD_SCALED, phase, Renderer2D.COLOR, textRenderer, ctx, tickProgress);
Renderer2D.COLOR.render();
}
// Logical HUD pass
if (logicalMain) {
ViewportContext.beginUnscaledLogical(ctx);
Renderer2D.COLOR.begin();
StaticHudElementRegistry.renderAllEngine(phase, HudRenderSpace.UNSCALED_LOGICAL, Renderer2D.COLOR, textRenderer, ctx, tickProgress);
DraggableHudElementRegistry.renderAllEngine(phase, Renderer2D.COLOR, textRenderer, ctx, tickProgress);
AddonRenderPipelineManager.render2D(LoVisualRenderStage.HUD_LOGICAL, phase, Renderer2D.COLOR, textRenderer, ctx, tickProgress);
Renderer2D.COLOR.render();
}
// Raw foreground
if (rawForeground) {
ViewportContext.beginUnscaled(ctx);
Renderer2D.COLOR.begin();
ModuleManager.renderHudEngineForeground(phase, Renderer2D.COLOR, textRenderer, ctx, tickProgress);
StaticHudElementRegistry.renderAllEngineForeground(phase, HudRenderSpace.UNSCALED, Renderer2D.COLOR, textRenderer, ctx, tickProgress);
AddonRenderPipelineManager.render2D(LoVisualRenderStage.HUD_RAW_FOREGROUND, phase, Renderer2D.COLOR, textRenderer, ctx, tickProgress);
Renderer2D.COLOR.render();
}
// Scaled fixed HUD foreground
if (scaledForeground) {
ViewportContext.beginScaled(ctx);
Renderer2D.COLOR.begin();
StaticHudElementRegistry.renderAllEngineForeground(phase, HudRenderSpace.SCALED, Renderer2D.COLOR, textRenderer, ctx, tickProgress);
AddonRenderPipelineManager.render2D(LoVisualRenderStage.HUD_SCALED_FOREGROUND, phase, Renderer2D.COLOR, textRenderer, ctx, tickProgress);
Renderer2D.COLOR.render();
}
// Logical foreground
if (logicalForeground) {
ViewportContext.beginUnscaledLogical(ctx);
Renderer2D.COLOR.begin();
StaticHudElementRegistry.renderAllEngineForeground(phase, HudRenderSpace.UNSCALED_LOGICAL, Renderer2D.COLOR, textRenderer, ctx, tickProgress);
DraggableHudElementRegistry.renderAllEngineForeground(phase, Renderer2D.COLOR, textRenderer, ctx, tickProgress);
AddonRenderPipelineManager.render2D(LoVisualRenderStage.HUD_LOGICAL_FOREGROUND, phase, Renderer2D.COLOR, textRenderer, ctx, tickProgress);
Renderer2D.COLOR.render();
}
} finally {
SystemCursor.endFrame();
ViewportContext.end(ctx);
}
});
return true;
}
static void renderHudAfterHotbar(GuiGraphicsExtractor ctx, DeltaTracker tickCounter) {
if (!Lifecycle.canRunHud()) return;
boolean swapTooltipWork = SwapTooltip.hasWork();
boolean targetHudWork = DraggableHudElementRegistry.hasEngineWidgetWork("target_hud");
if (!swapTooltipWork && !targetHudWork) return;
float tickProgress = TickDelta.tickProgress(tickCounter, false);
TextRenderer textRenderer = TextRenderer.get();
Renderer2D.Deferred2DLayer layer = Renderer2D.Deferred2DLayer.HUD_AFTER_MISC_OVERLAYS;
FullScreenRenderer.ensureInit();
ViewportContext.beginUnscaled(ctx);
refreshHudFrameTiming(tickCounter);
Renderer2D.withDeferredLayer(layer, () -> {
SystemCursor.beginFrame(ctx);
try (RenderPhaseScope hudPhase = LoVisualRenderSystem.phase(RenderPhase.HUD_EFFECTS, "2d:after_hotbar")) {
// Raw pass
if (swapTooltipWork) {
try (ProfilerPhase.Scope tooltipsScope = ProfilerPhase.scope("2d:after_hotbar:swap_tooltip");
RenderProfiler2D.Section ignoredTooltips = RenderProfiler2D.section("swap_tooltip")) {
Renderer2D.COLOR.begin();
SwapTooltip.render();
Renderer2D.COLOR.render();
}
}
// Logical pass
if (targetHudWork) {
ViewportContext.beginUnscaledLogical(ctx);
try (ProfilerPhase.Scope targetHudScope = ProfilerPhase.scope("2d:after_hotbar:target_hud");
RenderProfiler2D.Section ignoredTargetHud = RenderProfiler2D.section("target_hud")) {
Renderer2D.COLOR.begin();
DraggableHudElementRegistry.renderEngineWidget(
"target_hud",
Renderer2D.COLOR,
textRenderer,
ctx,
tickProgress
);
Renderer2D.COLOR.render();
}
}
} finally {
SystemCursor.endFrame();
ViewportContext.end(ctx);
}
});
appendHudDeferredMarker(layer, ctx);
}
private static void appendHudDeferredMarker(Renderer2D.Deferred2DLayer layer, GuiGraphicsExtractor ctx) {
if (!(ctx instanceof IGuiGraphics graphics)) return;
ctx.nextStratum();
graphics.lovisual$addDeferredHudMarker(layer);
ctx.nextStratum();
}
private static Renderer2D.Deferred2DLayer deferredLayerForHudPhase(HudPhase phase) {
if (phase == null) return Renderer2D.Deferred2DLayer.BEFORE_VANILLA_GUI;
return switch (phase) {
case NONE -> Renderer2D.Deferred2DLayer.BEFORE_VANILLA_GUI;
case FIRST -> Renderer2D.Deferred2DLayer.HUD_FIRST;
case BEFORE_MISC_OVERLAYS -> Renderer2D.Deferred2DLayer.HUD_BEFORE_MISC_OVERLAYS;
case AFTER_MISC_OVERLAYS -> Renderer2D.Deferred2DLayer.HUD_AFTER_MISC_OVERLAYS;
case AFTER_BOSS_BAR -> Renderer2D.Deferred2DLayer.HUD_AFTER_BOSS_BAR;
case BEFORE_DEMO_TIMER -> Renderer2D.Deferred2DLayer.HUD_BEFORE_DEMO_TIMER;
case BEFORE_CHAT -> Renderer2D.Deferred2DLayer.HUD_BEFORE_CHAT;
case AFTER_SUBTITLES -> Renderer2D.Deferred2DLayer.HUD_AFTER_SUBTITLES;
case LAST -> Renderer2D.Deferred2DLayer.HUD_LAST;
};
}
private static boolean hasHudMainPassWork(HudPhase phase, HudRenderSpace space) {
boolean moduleWork = space == HudRenderSpace.UNSCALED && ModuleManager.hasHudEngineWork(phase);
boolean staticWork = StaticHudElementRegistry.hasEngineWork(phase, space, false);
boolean draggableWork = space == HudRenderSpace.UNSCALED_LOGICAL && DraggableHudElementRegistry.hasEngineWork(phase, false);
return moduleWork || staticWork || draggableWork;
}
private static boolean hasHudForegroundPassWork(HudPhase phase, HudRenderSpace space) {
boolean moduleWork = space == HudRenderSpace.UNSCALED && ModuleManager.hasHudEngineForegroundWork(phase);
boolean staticWork = StaticHudElementRegistry.hasEngineWork(phase, space, true);
boolean draggableWork = space == HudRenderSpace.UNSCALED_LOGICAL && DraggableHudElementRegistry.hasEngineWork(phase, true);
return moduleWork || staticWork || draggableWork;
}
}

View file

@ -1,10 +1,3 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual; package dev.loki.lovisual;
import dev.loki.lovisual.util.logging.DebugLog; import dev.loki.lovisual.util.logging.DebugLog;

View file

@ -1,10 +1,3 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual; package dev.loki.lovisual;
import dev.loki.lovisual.events.impl.render.GameTickEvent; import dev.loki.lovisual.events.impl.render.GameTickEvent;
@ -14,12 +7,8 @@ import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.rendering.v1.hud.HudElementRegistry; import net.fabricmc.fabric.api.client.rendering.v1.hud.HudElementRegistry;
import net.fabricmc.fabric.api.client.rendering.v1.hud.VanillaHudElements; import net.fabricmc.fabric.api.client.rendering.v1.hud.VanillaHudElements;
import net.fabricmc.fabric.api.client.rendering.v1.level.LevelRenderEvents; import net.fabricmc.fabric.api.client.rendering.v1.level.LevelRenderEvents;
import net.minecraft.client.DeltaTracker;
import net.minecraft.client.gui.GuiGraphicsExtractor;
import net.minecraft.resources.Identifier; import net.minecraft.resources.Identifier;
import dev.loki.lovisual.addon.core.AddonManager; import dev.loki.lovisual.addon.core.AddonManager;
import dev.loki.lovisual.addon.pipeline.AddonRenderPipelineManager;
import dev.loki.lovisual.api.v0.render.LoVisualRenderStage;
import dev.loki.lovisual.config.i18n.MainConfig; import dev.loki.lovisual.config.i18n.MainConfig;
import dev.loki.lovisual.events.Events; import dev.loki.lovisual.events.Events;
import dev.loki.lovisual.features.account.AccountConfig; import dev.loki.lovisual.features.account.AccountConfig;
@ -27,33 +16,19 @@ import dev.loki.lovisual.features.command.core.CommandManager;
import dev.loki.lovisual.features.gui.clickgui.render.ClickGuiRenderer; import dev.loki.lovisual.features.gui.clickgui.render.ClickGuiRenderer;
import dev.loki.lovisual.features.gui.clickgui.settings.i18n.I18nPreflightContributors; import dev.loki.lovisual.features.gui.clickgui.settings.i18n.I18nPreflightContributors;
import dev.loki.lovisual.features.gui.hud.registry.HudElements; import dev.loki.lovisual.features.gui.hud.registry.HudElements;
import dev.loki.lovisual.features.gui.hud.anchor.HudRenderSpace;
import dev.loki.lovisual.features.gui.hud.draggable.DraggableHudElementRegistry; import dev.loki.lovisual.features.gui.hud.draggable.DraggableHudElementRegistry;
import dev.loki.lovisual.features.gui.hud.nondraggable.StaticHudElementBootstrap; import dev.loki.lovisual.features.gui.hud.nondraggable.StaticHudElementBootstrap;
import dev.loki.lovisual.features.gui.hud.nondraggable.StaticHudElementRegistry; import dev.loki.lovisual.features.gui.hud.nondraggable.StaticHudElementRegistry;
import dev.loki.lovisual.features.gui.hud.nondraggable.impl.buttons.SwapTooltip;
import dev.loki.lovisual.features.module.phase.HudPhase; import dev.loki.lovisual.features.module.phase.HudPhase;
import dev.loki.lovisual.features.module.lifecycle.ModuleAutoLoader; import dev.loki.lovisual.features.module.lifecycle.ModuleAutoLoader;
import dev.loki.lovisual.features.module.core.ModuleManager; import dev.loki.lovisual.features.module.core.ModuleManager;
import dev.loki.lovisual.features.module.phase.WorldPhase; import dev.loki.lovisual.features.module.phase.WorldPhase;
import dev.loki.lovisual.mixininterface.render.IGuiGraphics;
import dev.loki.lovisual.render.effects.NetherPortalRiftPass; import dev.loki.lovisual.render.effects.NetherPortalRiftPass;
import dev.loki.lovisual.render.effects.SleepOverlayPass; import dev.loki.lovisual.render.effects.SleepOverlayPass;
import dev.loki.lovisual.render.engine.LoVisualRenderEngineBootstrap; import dev.loki.lovisual.render.engine.LoVisualRenderEngineBootstrap;
import dev.loki.lovisual.render.engine.core.system.LoVisualRenderSystem;
import dev.loki.lovisual.render.engine.core.frame.RenderPhase;
import dev.loki.lovisual.render.engine.core.frame.RenderPhaseScope;
import dev.loki.lovisual.render.engine.core.context.ViewportContext;
import dev.loki.lovisual.render.engine.postprocess.post.PostProcessManager; import dev.loki.lovisual.render.engine.postprocess.post.PostProcessManager;
import dev.loki.lovisual.render.engine.profiler.config.ProfilerPhase;
import dev.loki.lovisual.render.engine.profiler.render.RenderProfiler2D;
import dev.loki.lovisual.render.engine.renderer.FullScreenRenderer;
import dev.loki.lovisual.render.engine.prewarm.RenderPrewarmContributors; import dev.loki.lovisual.render.engine.prewarm.RenderPrewarmContributors;
import dev.loki.lovisual.render.engine.prewarm.RenderPrewarmManager; import dev.loki.lovisual.render.engine.prewarm.RenderPrewarmManager;
import dev.loki.lovisual.render.engine.renderer.Renderer2D;
import dev.loki.lovisual.render.engine.text.render.TextRenderer;
import dev.loki.lovisual.render.helpers.util.SystemCursor;
import dev.loki.lovisual.render.helpers.util.TickDelta;
import dev.loki.lovisual.util.aiming.rotation.RotationManager; import dev.loki.lovisual.util.aiming.rotation.RotationManager;
import dev.loki.lovisual.util.block.bed.SelfBedTracker; import dev.loki.lovisual.util.block.bed.SelfBedTracker;
import dev.loki.lovisual.util.click.AttackPressing; import dev.loki.lovisual.util.click.AttackPressing;
@ -71,223 +46,9 @@ import dev.loki.lovisual.util.pvp.tracker.PvpTargetTracker;
import dev.loki.lovisual.util.pvp.tracker.PvpTracker; import dev.loki.lovisual.util.pvp.tracker.PvpTracker;
import dev.loki.lovisual.util.target.TargetManager; import dev.loki.lovisual.util.target.TargetManager;
import dev.loki.lovisual.util.time.TimerController; import dev.loki.lovisual.util.time.TimerController;
import dev.loki.lovisual.features.gui.clickgui.settings.bind.KeyBindSetting;
import dev.loki.lovisual.features.module.core.Module;
public class LoVisual implements ClientModInitializer { public class LoVisual implements ClientModInitializer {
private static void renderWorldPhase(WorldPhase phase,
com.mojang.blaze3d.vertex.PoseStack matrices,
net.minecraft.client.renderer.SubmitNodeCollector consumers) {
if (!Lifecycle.canRunRender()) return;
ModuleManager.renderWorld(
phase,
matrices,
consumers,
TickDelta.tickProgress(false)
);
}
private static void refreshHudFrameTiming(DeltaTracker tickCounter) {
LoVisualRenderSystem.updateFrameTiming(
TickDelta.tickProgress(tickCounter, false),
TickDelta.frameDeltaTicks(tickCounter),
TickDelta.fixedDeltaTicks(tickCounter)
);
}
private static void renderHudPhase(HudPhase phase, GuiGraphicsExtractor ctx, DeltaTracker tickCounter) {
if (!Lifecycle.canRunHud()) return;
float tickProgress = TickDelta.tickProgress(tickCounter, false);
ModuleManager.renderHud(phase, ctx, tickProgress);
Renderer2D.Deferred2DLayer layer = deferredLayerForHudPhase(phase);
if (renderHudEngine(phase, layer, ctx, tickCounter)) {
appendHudDeferredMarker(layer, ctx);
}
}
private static boolean renderHudEngine(HudPhase phase,
Renderer2D.Deferred2DLayer layer,
GuiGraphicsExtractor ctx,
DeltaTracker tickCounter) {
boolean rawMain = hasHudMainPassWork(phase, HudRenderSpace.UNSCALED)
|| AddonRenderPipelineManager.hasActiveCallbacks(LoVisualRenderStage.HUD_RAW);
boolean scaledMain = hasHudMainPassWork(phase, HudRenderSpace.SCALED)
|| AddonRenderPipelineManager.hasActiveCallbacks(LoVisualRenderStage.HUD_SCALED);
boolean logicalMain = hasHudMainPassWork(phase, HudRenderSpace.UNSCALED_LOGICAL)
|| AddonRenderPipelineManager.hasActiveCallbacks(LoVisualRenderStage.HUD_LOGICAL);
boolean rawForeground = hasHudForegroundPassWork(phase, HudRenderSpace.UNSCALED)
|| AddonRenderPipelineManager.hasActiveCallbacks(LoVisualRenderStage.HUD_RAW_FOREGROUND);
boolean scaledForeground = hasHudForegroundPassWork(phase, HudRenderSpace.SCALED)
|| AddonRenderPipelineManager.hasActiveCallbacks(LoVisualRenderStage.HUD_SCALED_FOREGROUND);
boolean logicalForeground = hasHudForegroundPassWork(phase, HudRenderSpace.UNSCALED_LOGICAL)
|| AddonRenderPipelineManager.hasActiveCallbacks(LoVisualRenderStage.HUD_LOGICAL_FOREGROUND);
if (!rawMain && !scaledMain && !logicalMain && !rawForeground && !scaledForeground && !logicalForeground) {
return false;
}
float tickProgress = TickDelta.tickProgress(tickCounter, false);
TextRenderer textRenderer = TextRenderer.get();
FullScreenRenderer.ensureInit();
ViewportContext.beginUnscaled(ctx);
refreshHudFrameTiming(tickCounter);
Renderer2D.withDeferredLayer(layer, () -> {
SystemCursor.beginFrame(ctx);
try (RenderPhaseScope hudPhase = LoVisualRenderSystem.phase(RenderPhase.HUD_MAIN, "2d:phase:" + phase.name())) {
// Raw HUD pass
if (rawMain) {
Renderer2D.COLOR.begin();
ModuleManager.renderHudEngine(phase, Renderer2D.COLOR, textRenderer, ctx, tickProgress);
StaticHudElementRegistry.renderAllEngine(phase, HudRenderSpace.UNSCALED, Renderer2D.COLOR, textRenderer, ctx, tickProgress);
AddonRenderPipelineManager.render2D(LoVisualRenderStage.HUD_RAW, phase, Renderer2D.COLOR, textRenderer, ctx, tickProgress);
Renderer2D.COLOR.render();
}
// Scaled fixed HUD pass
if (scaledMain) {
ViewportContext.beginScaled(ctx);
Renderer2D.COLOR.begin();
StaticHudElementRegistry.renderAllEngine(phase, HudRenderSpace.SCALED, Renderer2D.COLOR, textRenderer, ctx, tickProgress);
AddonRenderPipelineManager.render2D(LoVisualRenderStage.HUD_SCALED, phase, Renderer2D.COLOR, textRenderer, ctx, tickProgress);
Renderer2D.COLOR.render();
}
// Logical HUD pass
if (logicalMain) {
ViewportContext.beginUnscaledLogical(ctx);
Renderer2D.COLOR.begin();
StaticHudElementRegistry.renderAllEngine(phase, HudRenderSpace.UNSCALED_LOGICAL, Renderer2D.COLOR, textRenderer, ctx, tickProgress);
DraggableHudElementRegistry.renderAllEngine(phase, Renderer2D.COLOR, textRenderer, ctx, tickProgress);
AddonRenderPipelineManager.render2D(LoVisualRenderStage.HUD_LOGICAL, phase, Renderer2D.COLOR, textRenderer, ctx, tickProgress);
Renderer2D.COLOR.render();
}
// Raw foreground
if (rawForeground) {
ViewportContext.beginUnscaled(ctx);
Renderer2D.COLOR.begin();
ModuleManager.renderHudEngineForeground(phase, Renderer2D.COLOR, textRenderer, ctx, tickProgress);
StaticHudElementRegistry.renderAllEngineForeground(phase, HudRenderSpace.UNSCALED, Renderer2D.COLOR, textRenderer, ctx, tickProgress);
AddonRenderPipelineManager.render2D(LoVisualRenderStage.HUD_RAW_FOREGROUND, phase, Renderer2D.COLOR, textRenderer, ctx, tickProgress);
Renderer2D.COLOR.render();
}
// Scaled fixed HUD foreground
if (scaledForeground) {
ViewportContext.beginScaled(ctx);
Renderer2D.COLOR.begin();
StaticHudElementRegistry.renderAllEngineForeground(phase, HudRenderSpace.SCALED, Renderer2D.COLOR, textRenderer, ctx, tickProgress);
AddonRenderPipelineManager.render2D(LoVisualRenderStage.HUD_SCALED_FOREGROUND, phase, Renderer2D.COLOR, textRenderer, ctx, tickProgress);
Renderer2D.COLOR.render();
}
// Logical foreground
if (logicalForeground) {
ViewportContext.beginUnscaledLogical(ctx);
Renderer2D.COLOR.begin();
StaticHudElementRegistry.renderAllEngineForeground(phase, HudRenderSpace.UNSCALED_LOGICAL, Renderer2D.COLOR, textRenderer, ctx, tickProgress);
DraggableHudElementRegistry.renderAllEngineForeground(phase, Renderer2D.COLOR, textRenderer, ctx, tickProgress);
AddonRenderPipelineManager.render2D(LoVisualRenderStage.HUD_LOGICAL_FOREGROUND, phase, Renderer2D.COLOR, textRenderer, ctx, tickProgress);
Renderer2D.COLOR.render();
}
} finally {
SystemCursor.endFrame();
ViewportContext.end(ctx);
}
});
return true;
}
private static void renderHudAfterHotbar(GuiGraphicsExtractor ctx, DeltaTracker tickCounter) {
if (!Lifecycle.canRunHud()) return;
boolean swapTooltipWork = SwapTooltip.hasWork();
boolean targetHudWork = DraggableHudElementRegistry.hasEngineWidgetWork("target_hud");
if (!swapTooltipWork && !targetHudWork) return;
float tickProgress = TickDelta.tickProgress(tickCounter, false);
TextRenderer textRenderer = TextRenderer.get();
Renderer2D.Deferred2DLayer layer = Renderer2D.Deferred2DLayer.HUD_AFTER_MISC_OVERLAYS;
FullScreenRenderer.ensureInit();
ViewportContext.beginUnscaled(ctx);
refreshHudFrameTiming(tickCounter);
Renderer2D.withDeferredLayer(layer, () -> {
SystemCursor.beginFrame(ctx);
try (RenderPhaseScope hudPhase = LoVisualRenderSystem.phase(RenderPhase.HUD_EFFECTS, "2d:after_hotbar")) {
// Raw pass
if (swapTooltipWork) {
try (ProfilerPhase.Scope tooltipsScope = ProfilerPhase.scope("2d:after_hotbar:swap_tooltip");
RenderProfiler2D.Section ignoredTooltips = RenderProfiler2D.section("swap_tooltip")) {
Renderer2D.COLOR.begin();
SwapTooltip.render();
Renderer2D.COLOR.render();
}
}
// Logical pass
if (targetHudWork) {
ViewportContext.beginUnscaledLogical(ctx);
try (ProfilerPhase.Scope targetHudScope = ProfilerPhase.scope("2d:after_hotbar:target_hud");
RenderProfiler2D.Section ignoredTargetHud = RenderProfiler2D.section("target_hud")) {
Renderer2D.COLOR.begin();
DraggableHudElementRegistry.renderEngineWidget(
"target_hud",
Renderer2D.COLOR,
textRenderer,
ctx,
tickProgress
);
Renderer2D.COLOR.render();
}
}
} finally {
SystemCursor.endFrame();
ViewportContext.end(ctx);
}
});
appendHudDeferredMarker(layer, ctx);
}
private static void appendHudDeferredMarker(Renderer2D.Deferred2DLayer layer, GuiGraphicsExtractor ctx) {
if (!(ctx instanceof IGuiGraphics graphics)) return;
ctx.nextStratum();
graphics.lovisual$addDeferredHudMarker(layer);
ctx.nextStratum();
}
private static Renderer2D.Deferred2DLayer deferredLayerForHudPhase(HudPhase phase) {
if (phase == null) return Renderer2D.Deferred2DLayer.BEFORE_VANILLA_GUI;
return switch (phase) {
case NONE -> Renderer2D.Deferred2DLayer.BEFORE_VANILLA_GUI;
case FIRST -> Renderer2D.Deferred2DLayer.HUD_FIRST;
case BEFORE_MISC_OVERLAYS -> Renderer2D.Deferred2DLayer.HUD_BEFORE_MISC_OVERLAYS;
case AFTER_MISC_OVERLAYS -> Renderer2D.Deferred2DLayer.HUD_AFTER_MISC_OVERLAYS;
case AFTER_BOSS_BAR -> Renderer2D.Deferred2DLayer.HUD_AFTER_BOSS_BAR;
case BEFORE_DEMO_TIMER -> Renderer2D.Deferred2DLayer.HUD_BEFORE_DEMO_TIMER;
case BEFORE_CHAT -> Renderer2D.Deferred2DLayer.HUD_BEFORE_CHAT;
case AFTER_SUBTITLES -> Renderer2D.Deferred2DLayer.HUD_AFTER_SUBTITLES;
case LAST -> Renderer2D.Deferred2DLayer.HUD_LAST;
};
}
private static boolean hasHudMainPassWork(HudPhase phase, HudRenderSpace space) {
boolean moduleWork = space == HudRenderSpace.UNSCALED && ModuleManager.hasHudEngineWork(phase);
boolean staticWork = StaticHudElementRegistry.hasEngineWork(phase, space, false);
boolean draggableWork = space == HudRenderSpace.UNSCALED_LOGICAL && DraggableHudElementRegistry.hasEngineWork(phase, false);
return moduleWork || staticWork || draggableWork;
}
private static boolean hasHudForegroundPassWork(HudPhase phase, HudRenderSpace space) {
boolean moduleWork = space == HudRenderSpace.UNSCALED && ModuleManager.hasHudEngineForegroundWork(phase);
boolean staticWork = StaticHudElementRegistry.hasEngineWork(phase, space, true);
boolean draggableWork = space == HudRenderSpace.UNSCALED_LOGICAL && DraggableHudElementRegistry.hasEngineWork(phase, true);
return moduleWork || staticWork || draggableWork;
}
@Override @Override
public void onInitializeClient() { public void onInitializeClient() {
LoVisualRenderEngineBootstrap.init(); LoVisualRenderEngineBootstrap.init();
@ -352,25 +113,25 @@ public class LoVisual implements ClientModInitializer {
/* ====================== WORLD RENDER ====================== */ /* ====================== WORLD RENDER ====================== */
LevelRenderEvents.COLLECT_SUBMITS.register(ctx -> renderWorldPhase( LevelRenderEvents.COLLECT_SUBMITS.register(ctx -> HudRender.renderWorldPhase(
WorldPhase.BEFORE_ENTITIES, WorldPhase.BEFORE_ENTITIES,
ctx.poseStack(), ctx.poseStack(),
ctx.submitNodeCollector() ctx.submitNodeCollector()
)); ));
LevelRenderEvents.AFTER_SOLID_FEATURES.register(ctx -> renderWorldPhase( LevelRenderEvents.AFTER_SOLID_FEATURES.register(ctx -> HudRender.renderWorldPhase(
WorldPhase.AFTER_ENTITIES, WorldPhase.AFTER_ENTITIES,
ctx.poseStack(), ctx.poseStack(),
ctx.submitNodeCollector() ctx.submitNodeCollector()
)); ));
LevelRenderEvents.BEFORE_TRANSLUCENT_TERRAIN.register(ctx -> renderWorldPhase( LevelRenderEvents.BEFORE_TRANSLUCENT_TERRAIN.register(ctx -> HudRender.renderWorldPhase(
WorldPhase.BEFORE_TRANSLUCENT, WorldPhase.BEFORE_TRANSLUCENT,
ctx.poseStack(), ctx.poseStack(),
ctx.submitNodeCollector() ctx.submitNodeCollector()
)); ));
LevelRenderEvents.END_MAIN.register(ctx -> renderWorldPhase( LevelRenderEvents.END_MAIN.register(ctx -> HudRender.renderWorldPhase(
WorldPhase.END_MAIN, WorldPhase.END_MAIN,
ctx.poseStack(), ctx.poseStack(),
ctx.submitNodeCollector() ctx.submitNodeCollector()
@ -380,55 +141,55 @@ public class LoVisual implements ClientModInitializer {
HudElementRegistry.addFirst( HudElementRegistry.addFirst(
Identifier.fromNamespaceAndPath("lovisual", "hud_first"), Identifier.fromNamespaceAndPath("lovisual", "hud_first"),
(ctx, tc) -> renderHudPhase(HudPhase.FIRST, ctx, tc) (ctx, tc) -> HudRender.renderHudPhase(HudPhase.FIRST, ctx, tc)
); );
HudElementRegistry.addLast( HudElementRegistry.addLast(
Identifier.fromNamespaceAndPath("lovisual", "hud_last"), Identifier.fromNamespaceAndPath("lovisual", "hud_last"),
(ctx, tc) -> renderHudPhase(HudPhase.LAST, ctx, tc) (ctx, tc) -> HudRender.renderHudPhase(HudPhase.LAST, ctx, tc)
); );
// остальные позиции HUD: // остальные позиции HUD:
HudElementRegistry.attachElementBefore( HudElementRegistry.attachElementBefore(
net.fabricmc.fabric.api.client.rendering.v1.hud.VanillaHudElements.MISC_OVERLAYS, net.fabricmc.fabric.api.client.rendering.v1.hud.VanillaHudElements.MISC_OVERLAYS,
Identifier.fromNamespaceAndPath("lovisual", "hud_before_misc"), Identifier.fromNamespaceAndPath("lovisual", "hud_before_misc"),
(ctx, tc) -> renderHudPhase(HudPhase.BEFORE_MISC_OVERLAYS, ctx, tc) (ctx, tc) -> HudRender.renderHudPhase(HudPhase.BEFORE_MISC_OVERLAYS, ctx, tc)
); );
HudElementRegistry.attachElementAfter( HudElementRegistry.attachElementAfter(
net.fabricmc.fabric.api.client.rendering.v1.hud.VanillaHudElements.MISC_OVERLAYS, net.fabricmc.fabric.api.client.rendering.v1.hud.VanillaHudElements.MISC_OVERLAYS,
Identifier.fromNamespaceAndPath("lovisual", "hud_after_misc"), Identifier.fromNamespaceAndPath("lovisual", "hud_after_misc"),
(ctx, tc) -> renderHudPhase(HudPhase.AFTER_MISC_OVERLAYS, ctx, tc) (ctx, tc) -> HudRender.renderHudPhase(HudPhase.AFTER_MISC_OVERLAYS, ctx, tc)
); );
HudElementRegistry.attachElementAfter( HudElementRegistry.attachElementAfter(
net.fabricmc.fabric.api.client.rendering.v1.hud.VanillaHudElements.SLEEP, net.fabricmc.fabric.api.client.rendering.v1.hud.VanillaHudElements.SLEEP,
Identifier.fromNamespaceAndPath("lovisual", "hud_after_boss"), Identifier.fromNamespaceAndPath("lovisual", "hud_after_boss"),
(ctx, tc) -> renderHudPhase(HudPhase.AFTER_BOSS_BAR, ctx, tc) (ctx, tc) -> HudRender.renderHudPhase(HudPhase.AFTER_BOSS_BAR, ctx, tc)
); );
HudElementRegistry.attachElementBefore( HudElementRegistry.attachElementBefore(
net.fabricmc.fabric.api.client.rendering.v1.hud.VanillaHudElements.DEMO_TIMER, net.fabricmc.fabric.api.client.rendering.v1.hud.VanillaHudElements.DEMO_TIMER,
Identifier.fromNamespaceAndPath("lovisual", "hud_before_demo"), Identifier.fromNamespaceAndPath("lovisual", "hud_before_demo"),
(ctx, tc) -> renderHudPhase(HudPhase.BEFORE_DEMO_TIMER, ctx, tc) (ctx, tc) -> HudRender.renderHudPhase(HudPhase.BEFORE_DEMO_TIMER, ctx, tc)
); );
HudElementRegistry.attachElementBefore( HudElementRegistry.attachElementBefore(
net.fabricmc.fabric.api.client.rendering.v1.hud.VanillaHudElements.CHAT, net.fabricmc.fabric.api.client.rendering.v1.hud.VanillaHudElements.CHAT,
Identifier.fromNamespaceAndPath("lovisual", "hud_before_chat"), Identifier.fromNamespaceAndPath("lovisual", "hud_before_chat"),
(ctx, tc) -> renderHudPhase(HudPhase.BEFORE_CHAT, ctx, tc) (ctx, tc) -> HudRender.renderHudPhase(HudPhase.BEFORE_CHAT, ctx, tc)
); );
HudElementRegistry.attachElementAfter( HudElementRegistry.attachElementAfter(
net.fabricmc.fabric.api.client.rendering.v1.hud.VanillaHudElements.SUBTITLES, net.fabricmc.fabric.api.client.rendering.v1.hud.VanillaHudElements.SUBTITLES,
Identifier.fromNamespaceAndPath("lovisual", "hud_after_subtitles"), Identifier.fromNamespaceAndPath("lovisual", "hud_after_subtitles"),
(ctx, tc) -> renderHudPhase(HudPhase.AFTER_SUBTITLES, ctx, tc) (ctx, tc) -> HudRender.renderHudPhase(HudPhase.AFTER_SUBTITLES, ctx, tc)
); );
HudElementRegistry.attachElementAfter( HudElementRegistry.attachElementAfter(
VanillaHudElements.HOTBAR, VanillaHudElements.HOTBAR,
Identifier.fromNamespaceAndPath("lovisual", "hud_after_hotbar"), Identifier.fromNamespaceAndPath("lovisual", "hud_after_hotbar"),
(ctx, tc) -> renderHudAfterHotbar(ctx, tc) (ctx, tc) -> HudRender.renderHudAfterHotbar(ctx, tc)
); );
Lifecycle.activate(); Lifecycle.activate();

View file

@ -1,10 +1,3 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.addon.context; package dev.loki.lovisual.addon.context;
import dev.loki.lovisual.api.v0.addon.LoVisualAddonContext; import dev.loki.lovisual.api.v0.addon.LoVisualAddonContext;

View file

@ -1,10 +1,3 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.addon.context; package dev.loki.lovisual.addon.context;
import dev.loki.lovisual.api.v0.addon.LoVisualAddonRuntimeContext; import dev.loki.lovisual.api.v0.addon.LoVisualAddonRuntimeContext;

View file

@ -1,10 +1,3 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.addon.context; package dev.loki.lovisual.addon.context;
import dev.loki.lovisual.api.v0.addon.LoVisualModuleLoadContext; import dev.loki.lovisual.api.v0.addon.LoVisualModuleLoadContext;

View file

@ -1,10 +1,3 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.addon.context; package dev.loki.lovisual.addon.context;
import dev.loki.lovisual.api.v0.module.ModuleExtensionContext; import dev.loki.lovisual.api.v0.module.ModuleExtensionContext;

View file

@ -1,10 +1,3 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.addon.core; package dev.loki.lovisual.addon.core;
import java.util.List; import java.util.List;

View file

@ -1,10 +1,3 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.addon.core; package dev.loki.lovisual.addon.core;
public record AddonIssue( public record AddonIssue(

View file

@ -1,24 +1,9 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.addon.core; package dev.loki.lovisual.addon.core;
import dev.loki.lovisual.api.v0.addon.LoVisualAddon; import dev.loki.lovisual.addon.pipeline.AddonSnapshot;
import dev.loki.lovisual.features.gui.hud.base.AbstractHudElement; import dev.loki.lovisual.addon.pipeline.AddonStateStore;
import dev.loki.lovisual.features.gui.hud.draggable.DraggableHudElement; import dev.loki.lovisual.addon.pipeline.AddonStatus;
import dev.loki.lovisual.features.gui.hud.draggable.DraggableHudElementRegistry;
import dev.loki.lovisual.features.gui.hud.nondraggable.StaticHudElementRegistry;
import dev.loki.lovisual.features.module.core.Module;
import dev.loki.lovisual.features.module.lifecycle.ModuleAutoLoader; import dev.loki.lovisual.features.module.lifecycle.ModuleAutoLoader;
import dev.loki.lovisual.features.module.core.ModuleManager;
import dev.loki.lovisual.util.logging.DebugLog;
import net.fabricmc.loader.api.FabricLoader;
import net.fabricmc.loader.api.metadata.CustomValue;
import net.fabricmc.loader.api.metadata.ModMetadata;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Comparator; import java.util.Comparator;
@ -26,30 +11,28 @@ import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Map; import java.util.Map;
import dev.loki.lovisual.addon.context.LoVisualAddonContextImpl;
import dev.loki.lovisual.addon.context.LoVisualAddonRuntimeContextImpl;
import dev.loki.lovisual.addon.context.LoVisualModuleLoadContextImpl;
import dev.loki.lovisual.addon.core.AddonDescriptor;
import dev.loki.lovisual.addon.core.AddonIssue;
import dev.loki.lovisual.addon.core.AddonRegistration;
import dev.loki.lovisual.addon.pipeline.AddonSnapshot;
import dev.loki.lovisual.addon.pipeline.AddonStateStore;
import dev.loki.lovisual.addon.pipeline.AddonStatus;
/**
* Singleton entry point for LoVisual add-on discovery and lifecycle orchestration. Keeps the
* addon registry ({@code ADDONS}), the persisted enabled-overrides and the client-ready flags, and
* drives discovery, init and the public lookup/enable API consumed by the ClickGUI and commands.
*
* <p>Delegates the heavy work within the same package: {@link AddonLifecycle} (per-registration
* callbacks), {@link AddonRuntime} (enable/disable + contribution suspend/resume),
* {@link AddonDescriptors} (metadata parsing + validation) and {@link AddonProfiles} (config-profile
* owner keys), keeping the public static API stable while each file stays under the size caps.</p>
*/
public enum AddonManager { public enum AddonManager {
; ;
public static final String ADDON_METADATA_KEY = "lovisual:addon"; public static final String ADDON_METADATA_KEY = "lovisual:addon";
private static final Map<String, AddonRegistration> ADDONS = new LinkedHashMap<>(); static final Map<String, AddonRegistration> ADDONS = new LinkedHashMap<>();
private static final Map<String, Boolean> ENABLED_OVERRIDES = new LinkedHashMap<>(); static final Map<String, Boolean> ENABLED_OVERRIDES = new LinkedHashMap<>();
private static final Map<String, String> MODULE_OWNERS = new LinkedHashMap<>();
private static final Map<String, String> DRAGGABLE_HUD_OWNERS = new LinkedHashMap<>();
private static final Map<String, String> STATIC_HUD_OWNERS = new LinkedHashMap<>();
private static boolean discovered; private static boolean discovered;
private static boolean moduleLoadingPrepared; private static boolean moduleLoadingPrepared;
private static boolean initialized; private static boolean initialized;
private static boolean clientReady; static boolean clientReady;
/** /**
* Discovers enabled addon entrypoints and lets them configure built-in module * Discovers enabled addon entrypoints and lets them configure built-in module
@ -59,7 +42,7 @@ public enum AddonManager {
ensureDiscovered(); ensureDiscovered();
if (moduleLoadingPrepared) return; if (moduleLoadingPrepared) return;
moduleLoadingPrepared = true; moduleLoadingPrepared = true;
configureEnabledAddons(); AddonLifecycle.configureEnabled();
} }
public static void init() { public static void init() {
@ -68,9 +51,9 @@ public enum AddonManager {
if (!ModuleAutoLoader.isDiscoveryFinished()) { if (!ModuleAutoLoader.isDiscoveryFinished()) {
return; return;
} }
reconcileModuleExclusions(); AddonLifecycle.reconcileExclusions();
initialized = true; initialized = true;
initializeEnabledAddons(); AddonLifecycle.initializeEnabled();
} }
/** /**
@ -81,7 +64,7 @@ public enum AddonManager {
if (!initialized) return; if (!initialized) return;
clientReady = true; clientReady = true;
for (AddonRegistration registration : ADDONS.values()) { for (AddonRegistration registration : ADDONS.values()) {
notifyClientReady(registration); AddonLifecycle.notifyClientReady(registration);
} }
} }
@ -90,7 +73,7 @@ public enum AddonManager {
discovered = true; discovered = true;
ENABLED_OVERRIDES.clear(); ENABLED_OVERRIDES.clear();
ENABLED_OVERRIDES.putAll(AddonStateStore.loadEnabledOverrides()); ENABLED_OVERRIDES.putAll(AddonStateStore.loadEnabledOverrides());
discoverEntrypoints(); AddonLifecycle.discover();
} }
public static List<AddonSnapshot> snapshots() { public static List<AddonSnapshot> snapshots() {
@ -117,7 +100,7 @@ public enum AddonManager {
init(); init();
List<AddonIssue> out = new ArrayList<>(); List<AddonIssue> out = new ArrayList<>();
for (AddonRegistration registration : ADDONS.values()) { for (AddonRegistration registration : ADDONS.values()) {
validateRegistration(registration); AddonDescriptors.validate(registration);
out.addAll(registration.issues); out.addAll(registration.issues);
} }
if (out.isEmpty()) { if (out.isEmpty()) {
@ -127,22 +110,19 @@ public enum AddonManager {
} }
public static String moduleProfileOwnerId(String moduleId) { public static String moduleProfileOwnerId(String moduleId) {
String addonId = MODULE_OWNERS.get(normalizeId(moduleId)); return AddonProfiles.moduleProfileOwnerId(moduleId);
return addonId == null ? "module:" + moduleId : "addon:" + addonId + ":module:" + moduleId;
} }
public static String draggableHudProfileOwnerId(String elementId) { public static String draggableHudProfileOwnerId(String elementId) {
String addonId = DRAGGABLE_HUD_OWNERS.get(normalizeId(elementId)); return AddonProfiles.draggableHudProfileOwnerId(elementId);
return addonId == null ? "hud.draggable:" + elementId : "addon:" + addonId + ":hud.draggable:" + elementId;
} }
public static String staticHudProfileOwnerId(String elementId) { public static String staticHudProfileOwnerId(String elementId) {
String addonId = STATIC_HUD_OWNERS.get(normalizeId(elementId)); return AddonProfiles.staticHudProfileOwnerId(elementId);
return addonId == null ? "hud.static:" + elementId : "addon:" + addonId + ":hud.static:" + elementId;
} }
public static String profileDisplayName(String ownerId, String displayName) { public static String profileDisplayName(String ownerId, String displayName) {
String addonId = addonIdFromProfileOwner(ownerId); String addonId = AddonProfiles.addonIdFromProfileOwner(ownerId);
if (addonId == null) return displayName; if (addonId == null) return displayName;
AddonRegistration registration = ADDONS.get(addonId); AddonRegistration registration = ADDONS.get(addonId);
String addonName = registration == null ? addonId : registration.descriptor.name(); String addonName = registration == null ? addonId : registration.descriptor.name();
@ -150,11 +130,7 @@ public enum AddonManager {
} }
public static String addonIdFromProfileOwner(String ownerId) { public static String addonIdFromProfileOwner(String ownerId) {
if (ownerId == null || !ownerId.startsWith("addon:")) return null; return AddonProfiles.addonIdFromProfileOwner(ownerId);
int start = "addon:".length();
int end = ownerId.indexOf(':', start);
if (end <= start) return null;
return normalizeId(ownerId.substring(start, end));
} }
public static boolean setEnabled(String id, boolean enabled) { public static boolean setEnabled(String id, boolean enabled) {
@ -170,33 +146,10 @@ public enum AddonManager {
AddonStateStore.saveEnabledOverrides(ENABLED_OVERRIDES); AddonStateStore.saveEnabledOverrides(ENABLED_OVERRIDES);
if (enabled) { if (enabled) {
if (!registration.moduleConfigurationApplied) { AddonRuntime.enable(registration, clientReady);
configureAddonModules(registration); } else {
} AddonRuntime.disable(registration);
if (!registration.initialized) {
initializeAddon(registration);
} else {
resumeRuntimeContributions(registration);
resumeAddon(registration, "enabled by user");
if (registration.status != AddonStatus.ERROR) {
registration.status = AddonStatus.ACTIVE;
}
}
if (clientReady) {
notifyClientReady(registration);
}
return true;
} }
registration.restartRequired = !registration.disabledBuiltInModules.isEmpty();
if (!registration.initialized) {
registration.status = AddonStatus.DISABLED;
return true;
}
suspendRuntimeContributions(registration);
suspendAddon(registration, "disabled by user");
registration.status = AddonStatus.DISABLED;
return true; return true;
} }
@ -208,279 +161,20 @@ public enum AddonManager {
return count; return count;
} }
private static void discoverEntrypoints() {
for (var entrypoint : FabricLoader.getInstance()
.getEntrypointContainers(LoVisualAddon.ENTRYPOINT_KEY, LoVisualAddon.class)) {
ModMetadata metadata = entrypoint.getProvider().getMetadata();
String id = normalizeId(metadata.getId());
AddonDescriptor descriptor = descriptor(metadata, entrypoint.getDefinition());
boolean enabled = ENABLED_OVERRIDES.getOrDefault(id, true);
AddonStatus status = enabled ? AddonStatus.DISCOVERED : AddonStatus.DISABLED;
AddonRegistration duplicate = ADDONS.get(id);
if (duplicate != null) {
duplicate.issue(AddonIssue.Severity.ERROR, "Duplicate addon id", metadata.getId());
continue;
}
AddonRegistration registration = new AddonRegistration(descriptor, entrypoint, enabled, status);
validateRegistration(registration);
ADDONS.put(id, registration);
}
}
private static void configureEnabledAddons() {
for (AddonRegistration registration : ADDONS.values()) {
if (registration.enabled) {
configureAddonModules(registration);
}
}
}
private static void configureAddonModules(AddonRegistration registration) {
if (registration.moduleConfigurationApplied || !registration.enabled || registration.status == AddonStatus.ERROR) {
return;
}
try {
LoVisualAddon addon = addonInstance(registration);
LoVisualModuleLoadContextImpl context = new LoVisualModuleLoadContextImpl(registration);
addon.onConfigureModules(context);
context.commit();
registration.moduleConfigurationApplied = true;
} catch (Throwable t) {
registration.status = AddonStatus.ERROR;
registration.issue(AddonIssue.Severity.ERROR, "Module configuration failed", t.toString());
DebugLog.error("Addon module configuration failed: %s", t, registration.descriptor.id());
}
}
private static void reconcileModuleExclusions() {
for (AddonRegistration registration : ADDONS.values()) {
if (registration.disabledBuiltInModules.isEmpty()) continue;
registration.disabledBuiltInModules.removeIf(reference -> {
if (ModuleAutoLoader.wasDisableRequestMatched(reference, registration.descriptor.id())) {
return false;
}
registration.issue(
AddonIssue.Severity.WARNING,
"Built-in module exclusion target was not found",
reference
);
return true;
});
}
}
private static void initializeEnabledAddons() {
for (AddonRegistration registration : ADDONS.values()) {
if (registration.enabled && registration.status != AddonStatus.ERROR) {
initializeAddon(registration);
}
}
}
private static void initializeAddon(AddonRegistration registration) {
if (registration.initialized || !registration.enabled || registration.status == AddonStatus.ERROR) return;
if (!registration.moduleConfigurationApplied) {
configureAddonModules(registration);
if (registration.status == AddonStatus.ERROR) return;
}
try {
LoVisualAddon addon = addonInstance(registration);
addon.onInitialize(new LoVisualAddonContextImpl(registration));
registration.initialized = true;
registration.status = AddonStatus.ACTIVE;
DebugLog.info("Addon initialized: %s", registration.descriptor.id());
if (clientReady) {
notifyClientReady(registration);
}
} catch (Throwable t) {
registration.status = AddonStatus.ERROR;
registration.issue(AddonIssue.Severity.ERROR, "Initialization failed", t.toString());
DebugLog.error("Addon initialization failed: %s", t, registration.descriptor.id());
}
}
private static void notifyClientReady(AddonRegistration registration) {
if (registration == null
|| registration.clientReadyNotified
|| !registration.initialized
|| !registration.enabled
|| registration.status != AddonStatus.ACTIVE) {
return;
}
try {
registration.addon.onClientReady(
new LoVisualAddonRuntimeContextImpl(registration.descriptor.id())
);
registration.clientReadyNotified = true;
} catch (Throwable t) {
registration.status = AddonStatus.ERROR;
registration.issue(AddonIssue.Severity.ERROR, "Client ready callback failed", t.toString());
DebugLog.error("Addon client ready callback failed: %s", t, registration.descriptor.id());
}
}
private static LoVisualAddon addonInstance(AddonRegistration registration) {
LoVisualAddon addon = registration.addon;
if (addon == null) {
addon = registration.entrypoint.getEntrypoint();
registration.addon = addon;
}
return addon;
}
private static void suspendAddon(AddonRegistration registration, String reason) {
if (registration.addon == null) return;
try {
registration.addon.onRuntimeSuspended(new LoVisualAddonRuntimeContextImpl(registration.descriptor.id()));
} catch (Throwable t) {
registration.status = AddonStatus.ERROR;
registration.issue(AddonIssue.Severity.ERROR, "Runtime suspend failed", t.toString());
DebugLog.error("Addon runtime suspend failed: %s", t, registration.descriptor.id());
}
}
private static void resumeAddon(AddonRegistration registration, String reason) {
if (registration.addon == null) return;
try {
registration.addon.onRuntimeResumed(new LoVisualAddonRuntimeContextImpl(registration.descriptor.id()));
} catch (Throwable t) {
registration.status = AddonStatus.ERROR;
registration.issue(AddonIssue.Severity.ERROR, "Runtime resume failed", t.toString());
DebugLog.error("Addon runtime resume failed: %s", t, registration.descriptor.id());
}
}
private static void shutdownAddon(AddonRegistration registration, String reason) {
if (registration.addon == null) return;
try {
registration.addon.onShutdown(new LoVisualAddonRuntimeContextImpl(registration.descriptor.id()));
} catch (Throwable t) {
registration.issue(AddonIssue.Severity.ERROR, "Shutdown failed", t.toString());
DebugLog.error("Addon shutdown failed: %s", t, registration.descriptor.id());
}
}
private static void suspendRuntimeContributions(AddonRegistration registration) {
registration.suspendedModules.clear();
registration.suspendedDraggableHudElements.clear();
registration.suspendedStaticHudElements.clear();
for (String id : registration.modules) {
Module module = ModuleManager.get(id);
if (module != null) {
registration.suspendedModules.put(id, module.isEnabled());
ModuleManager.setRuntimeEnabledTransient(id, false);
}
}
for (String id : registration.draggableHudElements) {
DraggableHudElement element = DraggableHudElementRegistry.getById(id);
if (element != null) {
registration.suspendedDraggableHudElements.put(id, element.isEnabled());
element.setRuntimeEnabledTransient(false);
}
}
for (String id : registration.staticHudElements) {
AbstractHudElement element = StaticHudElementRegistry.getById(id);
if (element != null) {
registration.suspendedStaticHudElements.put(id, element.isEnabled());
element.setRuntimeEnabledTransient(false);
}
}
}
private static void resumeRuntimeContributions(AddonRegistration registration) {
for (String id : registration.modules) {
Boolean enabled = registration.suspendedModules.get(id);
if (enabled != null) {
ModuleManager.setRuntimeEnabledTransient(id, enabled);
}
}
for (String id : registration.draggableHudElements) {
Boolean enabled = registration.suspendedDraggableHudElements.get(id);
DraggableHudElement element = DraggableHudElementRegistry.getById(id);
if (enabled != null && element != null) {
element.setRuntimeEnabledTransient(enabled);
}
}
for (String id : registration.staticHudElements) {
Boolean enabled = registration.suspendedStaticHudElements.get(id);
AbstractHudElement element = StaticHudElementRegistry.getById(id);
if (enabled != null && element != null) {
element.setRuntimeEnabledTransient(enabled);
}
}
registration.suspendedModules.clear();
registration.suspendedDraggableHudElements.clear();
registration.suspendedStaticHudElements.clear();
}
public static void noteModuleOwner(String addonId, String moduleId) { public static void noteModuleOwner(String addonId, String moduleId) {
AddonProfiles.noteModuleOwner(addonId, moduleId);
if (moduleId != null && !moduleId.isBlank()) {
MODULE_OWNERS.put(normalizeId(moduleId), normalizeId(addonId));
}
} }
public static void noteDraggableHudOwner(String addonId, String elementId) { public static void noteDraggableHudOwner(String addonId, String elementId) {
AddonProfiles.noteDraggableHudOwner(addonId, elementId);
if (elementId != null && !elementId.isBlank()) {
DRAGGABLE_HUD_OWNERS.put(normalizeId(elementId), normalizeId(addonId));
}
} }
public static void noteStaticHudOwner(String addonId, String elementId) { public static void noteStaticHudOwner(String addonId, String elementId) {
AddonProfiles.noteStaticHudOwner(addonId, elementId);
if (elementId != null && !elementId.isBlank()) {
STATIC_HUD_OWNERS.put(normalizeId(elementId), normalizeId(addonId));
}
} }
private static void validateRegistration(AddonRegistration registration) { /** Locale-independent lowercase key used for every addon / module / HUD id lookup. */
if (registration.descriptor.apiVersion() != LoVisualAddon.API_VERSION) { static String normalizeId(String id) {
registration.issue(
AddonIssue.Severity.WARNING,
"Addon API version differs from client API",
"addon=" + registration.descriptor.apiVersion() + ", client=" + LoVisualAddon.API_VERSION
);
}
if (registration.descriptor.iconPath() == null || registration.descriptor.iconPath().isBlank()) {
registration.issue(AddonIssue.Severity.WARNING, "Addon icon is missing", "Add icon in fabric.mod.json");
}
if (registration.descriptor.entrypointDefinition() == null
|| registration.descriptor.entrypointDefinition().isBlank()) {
registration.issue(AddonIssue.Severity.WARNING, "Entrypoint definition is unavailable", "");
}
}
private static AddonDescriptor descriptor(ModMetadata metadata, String entrypointDefinition) {
List<String> authors = metadata.getAuthors().stream()
.map(person -> person.getName() == null ? "" : person.getName())
.filter(name -> !name.isBlank())
.toList();
return new AddonDescriptor(
normalizeId(metadata.getId()),
blankFallback(metadata.getName(), metadata.getId()),
String.valueOf(metadata.getVersion()),
metadata.getDescription() == null ? "" : metadata.getDescription(),
authors,
metadata.getIconPath(64).or(() -> metadata.getIconPath(32)).orElse(""),
apiVersion(metadata),
entrypointDefinition
);
}
private static int apiVersion(ModMetadata metadata) {
CustomValue value = metadata.getCustomValue(ADDON_METADATA_KEY);
if (value == null || value.getType() != CustomValue.CvType.OBJECT) return LoVisualAddon.API_VERSION;
CustomValue api = value.getAsObject().get("api");
if (api == null || api.getType() != CustomValue.CvType.NUMBER) return LoVisualAddon.API_VERSION;
return api.getAsNumber().intValue();
}
private static String blankFallback(String value, String fallback) {
return value == null || value.isBlank() ? fallback : value;
}
private static String normalizeId(String id) {
return id == null ? "" : id.trim().toLowerCase(Locale.ROOT); return id == null ? "" : id.trim().toLowerCase(Locale.ROOT);
} }
} }

View file

@ -1,10 +1,3 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.addon.core; package dev.loki.lovisual.addon.core;
import dev.loki.lovisual.api.v0.addon.LoVisualAddon; import dev.loki.lovisual.api.v0.addon.LoVisualAddon;

View file

@ -0,0 +1,68 @@
package dev.loki.lovisual.addon.core;
import dev.loki.lovisual.api.v0.addon.LoVisualAddon;
import net.fabricmc.loader.api.metadata.CustomValue;
import net.fabricmc.loader.api.metadata.ModMetadata;
import java.util.List;
/**
* Builds an {@link AddonDescriptor} from Fabric mod metadata and validates a registration's
* sanity (API-version match, icon and entrypoint presence), recording findings as
* {@link AddonIssue}s on the registration. Pure helpers over data the caller already holds,
* extracted from {@link AddonManager} to keep it under the file-size cap. Shares
* {@code addon.core}'s package so {@link AddonManager#normalizeId} stays reachable.
*/
final class AddonDescriptors {
private AddonDescriptors() { }
/** Parse a descriptor from the mod's metadata plus its resolved entrypoint definition. */
static AddonDescriptor descriptor(ModMetadata metadata, String entrypointDefinition) {
List<String> authors = metadata.getAuthors().stream()
.map(person -> person.getName() == null ? "" : person.getName())
.filter(name -> !name.isBlank())
.toList();
return new AddonDescriptor(
AddonManager.normalizeId(metadata.getId()),
blankFallback(metadata.getName(), metadata.getId()),
String.valueOf(metadata.getVersion()),
metadata.getDescription() == null ? "" : metadata.getDescription(),
authors,
metadata.getIconPath(64).or(() -> metadata.getIconPath(32)).orElse(""),
apiVersion(metadata),
entrypointDefinition
);
}
/** Read the declared LoVisual API version from the {@code lovisual:addon} custom block. */
static int apiVersion(ModMetadata metadata) {
CustomValue value = metadata.getCustomValue(AddonManager.ADDON_METADATA_KEY);
if (value == null || value.getType() != CustomValue.CvType.OBJECT) return LoVisualAddon.API_VERSION;
CustomValue api = value.getAsObject().get("api");
if (api == null || api.getType() != CustomValue.CvType.NUMBER) return LoVisualAddon.API_VERSION;
return api.getAsNumber().intValue();
}
/** Flag API-version drift, a missing icon and an unavailable entrypoint definition. */
static void validate(AddonRegistration registration) {
if (registration.descriptor.apiVersion() != LoVisualAddon.API_VERSION) {
registration.issue(
AddonIssue.Severity.WARNING,
"Addon API version differs from client API",
"addon=" + registration.descriptor.apiVersion() + ", client=" + LoVisualAddon.API_VERSION
);
}
if (registration.descriptor.iconPath() == null || registration.descriptor.iconPath().isBlank()) {
registration.issue(AddonIssue.Severity.WARNING, "Addon icon is missing", "Add icon in fabric.mod.json");
}
if (registration.descriptor.entrypointDefinition() == null
|| registration.descriptor.entrypointDefinition().isBlank()) {
registration.issue(AddonIssue.Severity.WARNING, "Entrypoint definition is unavailable", "");
}
}
private static String blankFallback(String value, String fallback) {
return value == null || value.isBlank() ? fallback : value;
}
}

View file

@ -0,0 +1,188 @@
package dev.loki.lovisual.addon.core;
import dev.loki.lovisual.addon.context.LoVisualAddonContextImpl;
import dev.loki.lovisual.addon.context.LoVisualAddonRuntimeContextImpl;
import dev.loki.lovisual.addon.context.LoVisualModuleLoadContextImpl;
import dev.loki.lovisual.addon.pipeline.AddonStatus;
import dev.loki.lovisual.api.v0.addon.LoVisualAddon;
import dev.loki.lovisual.features.module.lifecycle.ModuleAutoLoader;
import dev.loki.lovisual.util.logging.DebugLog;
import net.fabricmc.loader.api.FabricLoader;
import net.fabricmc.loader.api.metadata.ModMetadata;
/**
* Per-registration lifecycle callbacks backing {@link AddonManager}: lazy entrypoint
* materialization plus the {@code onConfigureModules} / {@code onInitialize} /
* {@code onClientReady} / {@code onRuntimeSuspended} / {@code onRuntimeResumed} /
* {@code onShutdown} invocations, each guarded by the registration's flags and turning any
* thrown error into an {@link AddonIssue} + {@link AddonStatus#ERROR}. Shares {@code addon.core}'s
* package so the registration's package-private state stays reachable; lives in a subfolder to
* keep {@link AddonManager} under the file-size cap.
*/
final class AddonLifecycle {
private AddonLifecycle() { }
/** Discover addon entrypoints from the Fabric loader and register each as a new addon. */
static void discover() {
for (var entrypoint : FabricLoader.getInstance()
.getEntrypointContainers(LoVisualAddon.ENTRYPOINT_KEY, LoVisualAddon.class)) {
ModMetadata metadata = entrypoint.getProvider().getMetadata();
String id = AddonManager.normalizeId(metadata.getId());
AddonDescriptor descriptor = AddonDescriptors.descriptor(metadata, entrypoint.getDefinition());
boolean enabled = AddonManager.ENABLED_OVERRIDES.getOrDefault(id, true);
AddonStatus status = enabled ? AddonStatus.DISCOVERED : AddonStatus.DISABLED;
AddonRegistration duplicate = AddonManager.ADDONS.get(id);
if (duplicate != null) {
duplicate.issue(AddonIssue.Severity.ERROR, "Duplicate addon id", metadata.getId());
continue;
}
AddonRegistration registration = new AddonRegistration(descriptor, entrypoint, enabled, status);
AddonDescriptors.validate(registration);
AddonManager.ADDONS.put(id, registration);
}
}
/** Configure modules for every currently-enabled addon (pre-instantiation pass). */
static void configureEnabled() {
for (AddonRegistration registration : AddonManager.ADDONS.values()) {
if (registration.enabled) {
configure(registration);
}
}
}
/** Drop built-in module exclusions whose target was never found, warning about each. */
static void reconcileExclusions() {
for (AddonRegistration registration : AddonManager.ADDONS.values()) {
if (registration.disabledBuiltInModules.isEmpty()) continue;
registration.disabledBuiltInModules.removeIf(reference -> {
if (ModuleAutoLoader.wasDisableRequestMatched(reference, registration.descriptor.id())) {
return false;
}
registration.issue(
AddonIssue.Severity.WARNING,
"Built-in module exclusion target was not found",
reference
);
return true;
});
}
}
/** Initialize every enabled, non-errored addon. */
static void initializeEnabled() {
for (AddonRegistration registration : AddonManager.ADDONS.values()) {
if (registration.enabled && registration.status != AddonStatus.ERROR) {
initialize(registration, AddonManager.clientReady);
}
}
}
/** Let the addon configure built-in module exclusions once, before modules are instantiated. */
static void configure(AddonRegistration registration) {
if (registration.moduleConfigurationApplied || !registration.enabled || registration.status == AddonStatus.ERROR) {
return;
}
try {
LoVisualAddon addon = addonInstance(registration);
LoVisualModuleLoadContextImpl context = new LoVisualModuleLoadContextImpl(registration);
addon.onConfigureModules(context);
context.commit();
registration.moduleConfigurationApplied = true;
} catch (Throwable t) {
registration.status = AddonStatus.ERROR;
registration.issue(AddonIssue.Severity.ERROR, "Module configuration failed", t.toString());
DebugLog.error("Addon module configuration failed: %s", t, registration.descriptor.id());
}
}
/** Initialize the addon (configuring modules first if needed); notifies client-ready if up. */
static void initialize(AddonRegistration registration, boolean clientReady) {
if (registration.initialized || !registration.enabled || registration.status == AddonStatus.ERROR) return;
if (!registration.moduleConfigurationApplied) {
configure(registration);
if (registration.status == AddonStatus.ERROR) return;
}
try {
LoVisualAddon addon = addonInstance(registration);
addon.onInitialize(new LoVisualAddonContextImpl(registration));
registration.initialized = true;
registration.status = AddonStatus.ACTIVE;
DebugLog.info("Addon initialized: %s", registration.descriptor.id());
if (clientReady) {
notifyClientReady(registration);
}
} catch (Throwable t) {
registration.status = AddonStatus.ERROR;
registration.issue(AddonIssue.Severity.ERROR, "Initialization failed", t.toString());
DebugLog.error("Addon initialization failed: %s", t, registration.descriptor.id());
}
}
/** Fire {@code onClientReady} once, after module configs have been applied. */
static void notifyClientReady(AddonRegistration registration) {
if (registration == null
|| registration.clientReadyNotified
|| !registration.initialized
|| !registration.enabled
|| registration.status != AddonStatus.ACTIVE) {
return;
}
try {
registration.addon.onClientReady(
new LoVisualAddonRuntimeContextImpl(registration.descriptor.id())
);
registration.clientReadyNotified = true;
} catch (Throwable t) {
registration.status = AddonStatus.ERROR;
registration.issue(AddonIssue.Severity.ERROR, "Client ready callback failed", t.toString());
DebugLog.error("Addon client ready callback failed: %s", t, registration.descriptor.id());
}
}
/** Fire {@code onRuntimeSuspended} for an addon being toggled off at runtime. */
static void suspend(AddonRegistration registration, String reason) {
if (registration.addon == null) return;
try {
registration.addon.onRuntimeSuspended(new LoVisualAddonRuntimeContextImpl(registration.descriptor.id()));
} catch (Throwable t) {
registration.status = AddonStatus.ERROR;
registration.issue(AddonIssue.Severity.ERROR, "Runtime suspend failed", t.toString());
DebugLog.error("Addon runtime suspend failed: %s", t, registration.descriptor.id());
}
}
/** Fire {@code onRuntimeResumed} for an addon being toggled back on at runtime. */
static void resume(AddonRegistration registration, String reason) {
if (registration.addon == null) return;
try {
registration.addon.onRuntimeResumed(new LoVisualAddonRuntimeContextImpl(registration.descriptor.id()));
} catch (Throwable t) {
registration.status = AddonStatus.ERROR;
registration.issue(AddonIssue.Severity.ERROR, "Runtime resume failed", t.toString());
DebugLog.error("Addon runtime resume failed: %s", t, registration.descriptor.id());
}
}
/** Fire {@code onShutdown} on full addon teardown. */
static void shutdown(AddonRegistration registration, String reason) {
if (registration.addon == null) return;
try {
registration.addon.onShutdown(new LoVisualAddonRuntimeContextImpl(registration.descriptor.id()));
} catch (Throwable t) {
registration.issue(AddonIssue.Severity.ERROR, "Shutdown failed", t.toString());
DebugLog.error("Addon shutdown failed: %s", t, registration.descriptor.id());
}
}
/** Materialize (and memoize) the addon instance from its entrypoint container. */
static LoVisualAddon addonInstance(AddonRegistration registration) {
LoVisualAddon addon = registration.addon;
if (addon == null) {
addon = registration.entrypoint.getEntrypoint();
registration.addon = addon;
}
return addon;
}
}

View file

@ -0,0 +1,66 @@
package dev.loki.lovisual.addon.core;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* Owns the id → addon-id reverse indexes for contributed modules, draggable-HUD and static-HUD
* elements, and turns them into the namespaced config-profile owner keys the profile system uses
* ({@code addon:<addon>:module:<id>} when an addon owns the entry, plain otherwise). Extracted
* from {@link AddonManager} to keep it under the file-size cap; shares {@code addon.core}'s
* package so {@link AddonManager#normalizeId} stays reachable.
*/
final class AddonProfiles {
private static final Map<String, String> MODULE_OWNERS = new LinkedHashMap<>();
private static final Map<String, String> DRAGGABLE_HUD_OWNERS = new LinkedHashMap<>();
private static final Map<String, String> STATIC_HUD_OWNERS = new LinkedHashMap<>();
private AddonProfiles() { }
/** Record that {@code addonId} owns the given module / draggable-HUD / static-HUD element. */
static void noteModuleOwner(String addonId, String moduleId) {
if (moduleId != null && !moduleId.isBlank()) {
MODULE_OWNERS.put(AddonManager.normalizeId(moduleId), AddonManager.normalizeId(addonId));
}
}
static void noteDraggableHudOwner(String addonId, String elementId) {
if (elementId != null && !elementId.isBlank()) {
DRAGGABLE_HUD_OWNERS.put(AddonManager.normalizeId(elementId), AddonManager.normalizeId(addonId));
}
}
static void noteStaticHudOwner(String addonId, String elementId) {
if (elementId != null && !elementId.isBlank()) {
STATIC_HUD_OWNERS.put(AddonManager.normalizeId(elementId), AddonManager.normalizeId(addonId));
}
}
/** Namespaced config-profile owner key for a module. */
static String moduleProfileOwnerId(String moduleId) {
String addonId = MODULE_OWNERS.get(AddonManager.normalizeId(moduleId));
return addonId == null ? "module:" + moduleId : "addon:" + addonId + ":module:" + moduleId;
}
/** Namespaced config-profile owner key for a draggable HUD element. */
static String draggableHudProfileOwnerId(String elementId) {
String addonId = DRAGGABLE_HUD_OWNERS.get(AddonManager.normalizeId(elementId));
return addonId == null ? "hud.draggable:" + elementId : "addon:" + addonId + ":hud.draggable:" + elementId;
}
/** Namespaced config-profile owner key for a static HUD element. */
static String staticHudProfileOwnerId(String elementId) {
String addonId = STATIC_HUD_OWNERS.get(AddonManager.normalizeId(elementId));
return addonId == null ? "hud.static:" + elementId : "addon:" + addonId + ":hud.static:" + elementId;
}
/** Extract the owning addon id from an {@code addon:<id>:...} owner key, or {@code null}. */
static String addonIdFromProfileOwner(String ownerId) {
if (ownerId == null || !ownerId.startsWith("addon:")) return null;
int start = "addon:".length();
int end = ownerId.indexOf(':', start);
if (end <= start) return null;
return AddonManager.normalizeId(ownerId.substring(start, end));
}
}

View file

@ -0,0 +1,107 @@
package dev.loki.lovisual.addon.core;
import dev.loki.lovisual.addon.pipeline.AddonStatus;
import dev.loki.lovisual.features.gui.hud.base.AbstractHudElement;
import dev.loki.lovisual.features.gui.hud.draggable.DraggableHudElement;
import dev.loki.lovisual.features.gui.hud.draggable.DraggableHudElementRegistry;
import dev.loki.lovisual.features.gui.hud.nondraggable.StaticHudElementRegistry;
import dev.loki.lovisual.features.module.core.Module;
import dev.loki.lovisual.features.module.core.ModuleManager;
/**
* Runtime enable/disable transitions for a single addon, backing {@link AddonManager#setEnabled}.
* Applies the contributed modules / draggable-HUD / static-HUD elements transiently (recording
* their prior enabled state so it can be restored) and drives the {@code onRuntimeResumed} /
* {@code onRuntimeSuspended} callbacks via {@link AddonLifecycle}. Shares {@code addon.core}'s
* package for package-private registration access; lives in a subfolder to keep the manager small.
*/
final class AddonRuntime {
private AddonRuntime() { }
/** Bring a just-enabled addon up: configure, initialize-or-resume, and notify if client-ready. */
static void enable(AddonRegistration registration, boolean clientReady) {
if (!registration.moduleConfigurationApplied) {
AddonLifecycle.configure(registration);
}
if (!registration.initialized) {
AddonLifecycle.initialize(registration, clientReady);
} else {
resumeContributions(registration);
AddonLifecycle.resume(registration, "enabled by user");
if (registration.status != AddonStatus.ERROR) {
registration.status = AddonStatus.ACTIVE;
}
}
if (clientReady) {
AddonLifecycle.notifyClientReady(registration);
}
}
/** Take a just-disabled addon down: flag a restart if it excluded built-ins, then suspend. */
static void disable(AddonRegistration registration) {
registration.restartRequired = !registration.disabledBuiltInModules.isEmpty();
if (!registration.initialized) {
registration.status = AddonStatus.DISABLED;
return;
}
suspendContributions(registration);
AddonLifecycle.suspend(registration, "disabled by user");
registration.status = AddonStatus.DISABLED;
}
/** Record and transiently disable every module / HUD element the addon contributes. */
static void suspendContributions(AddonRegistration registration) {
registration.suspendedModules.clear();
registration.suspendedDraggableHudElements.clear();
registration.suspendedStaticHudElements.clear();
for (String id : registration.modules) {
Module module = ModuleManager.get(id);
if (module != null) {
registration.suspendedModules.put(id, module.isEnabled());
ModuleManager.setRuntimeEnabledTransient(id, false);
}
}
for (String id : registration.draggableHudElements) {
DraggableHudElement element = DraggableHudElementRegistry.getById(id);
if (element != null) {
registration.suspendedDraggableHudElements.put(id, element.isEnabled());
element.setRuntimeEnabledTransient(false);
}
}
for (String id : registration.staticHudElements) {
AbstractHudElement element = StaticHudElementRegistry.getById(id);
if (element != null) {
registration.suspendedStaticHudElements.put(id, element.isEnabled());
element.setRuntimeEnabledTransient(false);
}
}
}
/** Restore the pre-suspend enabled state of every contributed module / HUD element. */
static void resumeContributions(AddonRegistration registration) {
for (String id : registration.modules) {
Boolean enabled = registration.suspendedModules.get(id);
if (enabled != null) {
ModuleManager.setRuntimeEnabledTransient(id, enabled);
}
}
for (String id : registration.draggableHudElements) {
Boolean enabled = registration.suspendedDraggableHudElements.get(id);
DraggableHudElement element = DraggableHudElementRegistry.getById(id);
if (enabled != null && element != null) {
element.setRuntimeEnabledTransient(enabled);
}
}
for (String id : registration.staticHudElements) {
Boolean enabled = registration.suspendedStaticHudElements.get(id);
AbstractHudElement element = StaticHudElementRegistry.getById(id);
if (enabled != null && element != null) {
element.setRuntimeEnabledTransient(enabled);
}
}
registration.suspendedModules.clear();
registration.suspendedDraggableHudElements.clear();
registration.suspendedStaticHudElements.clear();
}
}

View file

@ -1,10 +1,3 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.addon.manager; package dev.loki.lovisual.addon.manager;
import dev.loki.lovisual.api.v0.clickgui.LoVisualClickGuiSection; import dev.loki.lovisual.api.v0.clickgui.LoVisualClickGuiSection;

View file

@ -1,10 +1,3 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.addon.manager; package dev.loki.lovisual.addon.manager;
import dev.loki.lovisual.api.v0.module.LoVisualModuleExtension; import dev.loki.lovisual.api.v0.module.LoVisualModuleExtension;

View file

@ -1,10 +1,3 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.addon.pipeline; package dev.loki.lovisual.addon.pipeline;
import com.mojang.blaze3d.textures.GpuTextureView; import com.mojang.blaze3d.textures.GpuTextureView;

View file

@ -1,10 +1,3 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.addon.pipeline; package dev.loki.lovisual.addon.pipeline;
import java.util.List; import java.util.List;

Some files were not shown because too many files have changed in this diff Show more