refactor(admin): remove the unreachable /api/setup endpoint

Generated-password bootstrap now sets an admin password on every fresh node, so
the /admin/api/setup success path is unreachable — it 409s ("already set") on
any booted node. No caller exists across the admin UI, routstr-cli, routstr-sdk,
routstrd, or routstr-chat, so the endpoint (and its SetupRequest model) are dead
surface. First-run is now: the generated password is printed once at boot, log
in via /admin, and change it from the dashboard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Jeroen Ubbink
2026-07-23 10:51:21 +02:00
co-authored by Claude Opus 4.8
parent eb6a612e5a
commit f47e16aa61
2 changed files with 18 additions and 61 deletions
-23
View File
@@ -310,29 +310,6 @@ async def update_nsec(request: Request, payload: NsecUpdate) -> dict[str, object
return {"ok": True, "npub": npub}
class SetupRequest(BaseModel):
password: str
@admin_router.post("/api/setup")
async def initial_setup(request: Request, payload: SetupRequest) -> dict[str, object]:
async with create_session() as session:
secret = await get_secret(session)
if secret.admin_password_hash:
raise HTTPException(status_code=409, detail="Admin password already set")
pw = (payload.password or "").strip()
if len(pw) < vault.MIN_PASSWORD_LENGTH:
raise HTTPException(
status_code=400,
detail=(
"Password must be at least "
f"{vault.MIN_PASSWORD_LENGTH} characters"
),
)
await set_admin_password(session, pw)
return {"ok": True}
class AdminLoginRequest(BaseModel):
password: str
+18 -38
View File
@@ -1,11 +1,11 @@
"""Tests for admin password auth backed by the hashed Secret store (issue #553).
Login, password change and first-run setup verify against the one-way
``Secret.admin_password_hash`` (scrypt) instead of a plaintext settings field,
which also closes the old ``!=`` timing-attack comparison. The flow is exercised
end-to-end through the public admin endpoints: setup writes the first hash,
login checks against it, and a password change re-hashes so the old password
stops working and the new one starts.
Login and password change verify against the one-way ``Secret.admin_password_hash``
(scrypt) instead of a plaintext settings field, which also closes the old ``!=``
timing-attack comparison. The first hash is written by ``bootstrap_secrets`` at
startup (generated, or migrated from a legacy password); here it is seeded
directly into the store, then login checks against it and a password change
re-hashes so the old password stops working and the new one starts.
"""
from __future__ import annotations
@@ -13,10 +13,14 @@ from __future__ import annotations
import pytest
from httpx import AsyncClient, Response
from routstr.core.db import create_session, set_admin_password
async def _setup_password(client: AsyncClient, password: str) -> None:
resp = await client.post("/admin/api/setup", json={"password": password})
assert resp.status_code == 200, resp.text
async def _seed_password(password: str) -> None:
# bootstrap_secrets owns first-password creation at startup; tests seed the
# hash straight into the store the same way, then exercise login/change.
async with create_session() as session:
await set_admin_password(session, password)
async def _login(client: AsyncClient, password: str) -> Response:
@@ -40,7 +44,7 @@ async def test_login_500_when_no_password_configured(
async def test_login_succeeds_with_correct_password(
integration_client: AsyncClient,
) -> None:
await _setup_password(integration_client, "correct horse")
await _seed_password("correct horse")
resp = await _login(integration_client, "correct horse")
assert resp.status_code == 200
body = resp.json()
@@ -53,35 +57,11 @@ async def test_login_succeeds_with_correct_password(
async def test_login_rejects_wrong_password(
integration_client: AsyncClient,
) -> None:
await _setup_password(integration_client, "correct horse")
await _seed_password("correct horse")
resp = await _login(integration_client, "wrong horse")
assert resp.status_code == 401
# --- first-run setup -------------------------------------------------------
@pytest.mark.integration
@pytest.mark.asyncio
async def test_setup_rejects_short_password(
integration_client: AsyncClient,
) -> None:
resp = await integration_client.post("/admin/api/setup", json={"password": "short"})
assert resp.status_code == 400
@pytest.mark.integration
@pytest.mark.asyncio
async def test_setup_409_when_password_already_set(
integration_client: AsyncClient,
) -> None:
await _setup_password(integration_client, "first password")
resp = await integration_client.post(
"/admin/api/setup", json={"password": "second password"}
)
assert resp.status_code == 409
# --- password change -------------------------------------------------------
@@ -90,7 +70,7 @@ async def test_setup_409_when_password_already_set(
async def test_update_password_rehashes_so_only_new_works(
integration_client: AsyncClient,
) -> None:
await _setup_password(integration_client, "old password")
await _seed_password("old password")
login = await _login(integration_client, "old password")
token = login.json()["token"]
integration_client.headers["Authorization"] = f"Bearer {token}"
@@ -112,7 +92,7 @@ async def test_update_password_rehashes_so_only_new_works(
async def test_update_password_rejects_wrong_current(
integration_client: AsyncClient,
) -> None:
await _setup_password(integration_client, "old password")
await _seed_password("old password")
login = await _login(integration_client, "old password")
token = login.json()["token"]
integration_client.headers["Authorization"] = f"Bearer {token}"
@@ -129,7 +109,7 @@ async def test_update_password_rejects_wrong_current(
async def test_update_password_rejects_short_new(
integration_client: AsyncClient,
) -> None:
await _setup_password(integration_client, "old password")
await _seed_password("old password")
login = await _login(integration_client, "old password")
token = login.json()["token"]
integration_client.headers["Authorization"] = f"Bearer {token}"