diff --git a/.env.example b/.env.example
index cf447e0..6d8ac36 100644
--- a/.env.example
+++ b/.env.example
@@ -11,3 +11,8 @@ DB_PATH=baton.db
# CORS
FRONTEND_ORIGIN=https://yourdomain.com
+
+# VAPID Push Notifications (generate with: python -c "from py_vapid import Vapid; v=Vapid(); v.generate_keys(); print(v.public_key, v.private_key)")
+VAPID_PUBLIC_KEY=
+VAPID_PRIVATE_KEY=
+VAPID_CLAIMS_EMAIL=
diff --git a/backend/main.py b/backend/main.py
index 7f17501..ebd2f07 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -132,6 +132,11 @@ async def health() -> dict[str, Any]:
return {"status": "ok"}
+@app.get("/api/vapid-public-key")
+async def vapid_public_key() -> dict[str, str]:
+ return {"vapid_public_key": config.VAPID_PUBLIC_KEY}
+
+
@app.post("/api/register", response_model=RegisterResponse)
async def register(body: RegisterRequest, _: None = Depends(rate_limit_register)) -> RegisterResponse:
api_key = secrets.token_hex(32)
diff --git a/tests/test_fix_016.py b/tests/test_fix_016.py
new file mode 100644
index 0000000..f91ac3e
--- /dev/null
+++ b/tests/test_fix_016.py
@@ -0,0 +1,163 @@
+"""
+Tests for BATON-FIX-016: VAPID public key — убедиться, что ключ не вшит
+как пустая строка в frontend-коде и читается через API.
+
+Acceptance criteria:
+1. В frontend-коде нет хардкода пустой строки в качестве VAPID key в -теге.
+2. frontend читает ключ через API /api/vapid-public-key (_fetchVapidPublicKey).
+3. GET /api/vapid-public-key возвращает HTTP 200.
+4. GET /api/vapid-public-key возвращает JSON с полем vapid_public_key.
+5. При наличии конфигурации VAPID_PUBLIC_KEY — ответ содержит непустое значение.
+"""
+from __future__ import annotations
+
+import os
+import re
+from pathlib import Path
+from unittest.mock import patch
+
+os.environ.setdefault("BOT_TOKEN", "test-bot-token")
+os.environ.setdefault("CHAT_ID", "-1001234567890")
+os.environ.setdefault("WEBHOOK_SECRET", "test-webhook-secret")
+os.environ.setdefault("WEBHOOK_URL", "https://example.com/api/webhook/telegram")
+os.environ.setdefault("FRONTEND_ORIGIN", "http://localhost:3000")
+os.environ.setdefault("ADMIN_TOKEN", "test-admin-token")
+os.environ.setdefault("ADMIN_CHAT_ID", "5694335584")
+
+import pytest
+
+from tests.conftest import make_app_client
+
+PROJECT_ROOT = Path(__file__).parent.parent
+FRONTEND_DIR = PROJECT_ROOT / "frontend"
+INDEX_HTML = FRONTEND_DIR / "index.html"
+APP_JS = FRONTEND_DIR / "app.js"
+
+_TEST_VAPID_PUBLIC_KEY = "BFakeVapidPublicKeyForTestingPurposesOnlyBase64UrlEncoded"
+
+
+# ---------------------------------------------------------------------------
+# Criterion 1 — AST: no hardcoded empty VAPID key in tag (index.html)
+# ---------------------------------------------------------------------------
+
+
+def test_index_html_has_no_vapid_meta_tag_with_empty_content() -> None:
+ """index.html не должен содержать -тег с application-server-key и пустым content."""
+ content = INDEX_HTML.read_text(encoding="utf-8")
+ match = re.search(
+ r']*(?:application-server-key|vapid)[^>]*content\s*=\s*["\']["\']',
+ content,
+ re.IGNORECASE,
+ )
+ assert match is None, (
+ f"index.html содержит -тег с пустым VAPID ключом: {match.group(0)!r}"
+ )
+
+
+def test_index_html_has_no_hardcoded_application_server_key_attribute() -> None:
+ """index.html не должен содержать атрибут application-server-key вообще."""
+ content = INDEX_HTML.read_text(encoding="utf-8")
+ assert "application-server-key" not in content.lower(), (
+ "index.html содержит атрибут 'application-server-key' — "
+ "VAPID ключ не должен быть вшит в HTML"
+ )
+
+
+# ---------------------------------------------------------------------------
+# Criterion 2 — AST: frontend reads key through API (app.js)
+# ---------------------------------------------------------------------------
+
+
+def test_app_js_contains_fetch_vapid_public_key_function() -> None:
+ """app.js должен содержать функцию _fetchVapidPublicKey."""
+ content = APP_JS.read_text(encoding="utf-8")
+ assert "_fetchVapidPublicKey" in content, (
+ "app.js не содержит функцию _fetchVapidPublicKey — "
+ "чтение VAPID ключа через API не реализовано"
+ )
+
+
+def test_app_js_fetch_vapid_calls_api_endpoint() -> None:
+ """_fetchVapidPublicKey в app.js должна обращаться к /api/vapid-public-key."""
+ content = APP_JS.read_text(encoding="utf-8")
+ assert "/api/vapid-public-key" in content, (
+ "app.js не содержит URL '/api/vapid-public-key' — VAPID ключ не читается через API"
+ )
+
+
+def test_app_js_init_push_subscription_has_null_guard() -> None:
+ """_initPushSubscription в app.js должна содержать guard против null ключа."""
+ content = APP_JS.read_text(encoding="utf-8")
+ assert re.search(r"if\s*\(\s*!\s*vapidPublicKey\s*\)", content), (
+ "app.js: _initPushSubscription не содержит guard 'if (!vapidPublicKey)' — "
+ "подписка может быть создана без ключа"
+ )
+
+
+def test_app_js_init_chains_fetch_vapid_then_init_subscription() -> None:
+ """_init() в app.js должна вызывать _fetchVapidPublicKey().then(_initPushSubscription)."""
+ content = APP_JS.read_text(encoding="utf-8")
+ assert re.search(
+ r"_fetchVapidPublicKey\(\)\s*\.\s*then\s*\(\s*_initPushSubscription\s*\)",
+ content,
+ ), (
+ "app.js: _init() не содержит цепочку _fetchVapidPublicKey().then(_initPushSubscription)"
+ )
+
+
+def test_app_js_has_no_empty_string_hardcoded_as_application_server_key() -> None:
+ """app.js не должен содержать хардкода пустой строки для applicationServerKey."""
+ content = APP_JS.read_text(encoding="utf-8")
+ match = re.search(r"applicationServerKey\s*[=:]\s*[\"']{2}", content)
+ assert match is None, (
+ f"app.js содержит хардкод пустой строки для applicationServerKey: {match.group(0)!r}"
+ )
+
+
+# ---------------------------------------------------------------------------
+# Criterion 3 — HTTP: GET /api/vapid-public-key returns 200
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_vapid_public_key_endpoint_returns_200() -> None:
+ """GET /api/vapid-public-key должен вернуть HTTP 200."""
+ async with make_app_client() as client:
+ response = await client.get("/api/vapid-public-key")
+ assert response.status_code == 200, (
+ f"GET /api/vapid-public-key вернул {response.status_code}, ожидался 200"
+ )
+
+
+# ---------------------------------------------------------------------------
+# Criterion 4 — HTTP: response JSON contains vapid_public_key field
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_vapid_public_key_endpoint_returns_json_with_field() -> None:
+ """GET /api/vapid-public-key должен вернуть JSON с полем vapid_public_key."""
+ async with make_app_client() as client:
+ response = await client.get("/api/vapid-public-key")
+ data = response.json()
+ assert "vapid_public_key" in data, (
+ f"Ответ /api/vapid-public-key не содержит поле 'vapid_public_key': {data!r}"
+ )
+
+
+# ---------------------------------------------------------------------------
+# Criterion 5 — HTTP: non-empty vapid_public_key when env var is configured
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_vapid_public_key_endpoint_returns_configured_value() -> None:
+ """GET /api/vapid-public-key возвращает непустой ключ, когда VAPID_PUBLIC_KEY задан."""
+ with patch("backend.config.VAPID_PUBLIC_KEY", _TEST_VAPID_PUBLIC_KEY):
+ async with make_app_client() as client:
+ response = await client.get("/api/vapid-public-key")
+ data = response.json()
+ assert data.get("vapid_public_key") == _TEST_VAPID_PUBLIC_KEY, (
+ f"vapid_public_key должен быть '{_TEST_VAPID_PUBLIC_KEY}', "
+ f"получили: {data.get('vapid_public_key')!r}"
+ )