Serve bot_username at runtime instead of baking it into the SPA build

Auth.tsx and Profile.tsx read import.meta.env.VITE_BOT_USERNAME — a Vite
build-time constant. This ЛК frontend is a single static bundle built
once in CI and served identically by both prod and dev (no
server-rendering to re-inject a value per environment, unlike the
Next.js landing fix from earlier). So the Telegram Login Widget on the
dev stand was rendering with whatever bot the CI build happened to be
compiled with — in practice the fallback default, the prod bot's
username — and Telegram correctly refused it with "Bot domain invalid"
since that bot's registered domain is the prod domain, not
dev.netrunner-vpn.com.

Added GET /api/v1/config (unauthenticated, reads BOT_USERNAME from the
server's actual environment on every request) and switched both files to
fetch it at runtime instead of reading the baked-in env var. Verified
locally: the dev container's /api/v1/config correctly returns its own
dev bot's username, and a real headless Chrome run confirms the Login
Widget script tag gets that value, not the hardcoded fallback.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-05 14:21:55 +07:00
parent 4f1efc6ff9
commit 818d4fa384
5 changed files with 59 additions and 4 deletions
+17
View File
@@ -43,6 +43,15 @@ pub struct ApiState {
pub jwt_secret: String,
pub bot_token: String,
pub admin_tg_id: i64,
/// Юзернейм бота (без `@`) — отдаётся фронту через `GET /api/v1/config`.
/// Не публичный build-time env (`VITE_BOT_USERNAME`): фронт — статический
/// SPA-бандл, собираемый один раз в CI и одинаковый что для прод-, что для
/// dev-деплоя, значение зашилось бы намертво в конкретную сборку и не
/// подхватывало бы разные боты на разных стендах (ровно так и оказалось на
/// практике — dev-стенд показывал прод-бота, у которого домен в BotFather
/// не совпадает с dev-доменом → "Bot domain invalid"). Читается на сервере
/// заново на каждый запрос, поэтому корректно различается между стендами.
pub bot_username: String,
/// `Domain` для сессионных cookie. Пусто (по умолчанию, локальная разработка) →
/// cookie host-only. В проде — `.netrunner-vpn.com`, чтобы одна сессия работала
/// и на лендинге, и на ЛК (общий родительский домен, разные сабдомены).
@@ -72,6 +81,7 @@ impl Clone for ApiState {
jwt_secret: self.jwt_secret.clone(),
bot_token: self.bot_token.clone(),
admin_tg_id: self.admin_tg_id,
bot_username: self.bot_username.clone(),
cookie_domain: self.cookie_domain.clone(),
cookie_secure: self.cookie_secure,
csrf_allowed_origins: self.csrf_allowed_origins.clone(),
@@ -119,6 +129,7 @@ pub fn create_router(state: ApiState, frontend_dir: &str) -> Router {
.nest("/billing", bill_ctrl::router(state.clone()))
.nest("/nodes", node_ctrl::public_router(state.clone()))
.nest("/referrals", ref_ctrl::router(state.clone()))
.route("/config", get(get_public_config))
.layer(GovernorLayer::new(gov_conf));
let admin_routes_v1 = Router::new().nest("/nodes", node_ctrl::admin_router(state.clone()));
@@ -163,6 +174,12 @@ pub fn create_router(state: ApiState, frontend_dir: &str) -> Router {
.with_state(state)
}
/// Публичная (без auth_guard) рантайм-конфигурация для статического SPA —
/// см. `ApiState::bot_username` про то, почему это не build-time env фронта.
async fn get_public_config(State(state): State<ApiState>) -> impl IntoResponse {
Json(json!({ "bot_username": state.bot_username }))
}
/// Liveness: процесс жив и отвечает. Не трогает зависимости.
async fn health_live() -> impl IntoResponse {
(StatusCode::OK, Json(json!({ "status": "ok" })))