diff --git a/backend/app/gateway/app.py b/backend/app/gateway/app.py
index 1d25e0113..5b2901107 100644
--- a/backend/app/gateway/app.py
+++ b/backend/app/gateway/app.py
@@ -2,7 +2,6 @@ import logging
import os
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
-from datetime import UTC
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
@@ -41,77 +40,62 @@ logger = logging.getLogger(__name__)
async def _ensure_admin_user(app: FastAPI) -> None:
- """Auto-create the admin user on first boot if no users exist.
+ """Startup hook: generate init token on first boot; migrate orphan threads otherwise.
- After admin creation, migrate orphan threads from the LangGraph
- store (metadata.owner_id unset) to the admin account. This is the
- "no-auth → with-auth" upgrade path: users who ran DeerFlow without
- authentication have existing LangGraph thread data that needs an
- owner assigned.
+ First boot (no admin exists):
+ - Generates a one-time ``init_token`` stored in ``app.state.init_token``
+ - Logs the token to stdout so the operator can copy-paste it into the
+ ``/setup`` form to create the first admin account interactively.
+ - Does NOT create any user accounts automatically.
+
+ Subsequent boots (admin already exists):
+ - Runs the one-time "no-auth → with-auth" orphan thread migration for
+ existing LangGraph thread metadata that has no owner_id.
No SQL persistence migration is needed: the four owner_id columns
(threads_meta, runs, run_events, feedback) only come into existence
alongside the auth module via create_all, so freshly created tables
- never contain NULL-owner rows. "Existing persistence DB + new auth"
- is not a supported upgrade path — fresh install or wipe-and-retry.
-
- Multi-worker safe: relies on SQLite UNIQUE constraint to resolve
- races during admin creation. Only the worker that successfully
- creates/updates the admin prints the password; losers silently skip.
+ never contain NULL-owner rows.
"""
import secrets
- from app.gateway.auth.credential_file import write_initial_credentials
- from app.gateway.deps import get_local_provider
+ from sqlalchemy import select
- def _announce_credentials(email: str, password: str, *, label: str, headline: str) -> None:
- """Write the password to a 0600 file and log the path (never the secret)."""
- cred_path = write_initial_credentials(email, password, label=label)
- logger.info("=" * 60)
- logger.info(" %s", headline)
- logger.info(" Credentials written to: %s (mode 0600)", cred_path)
- logger.info(" Change it after login: Settings -> Account")
- logger.info("=" * 60)
+ from app.gateway.deps import get_local_provider
+ from deerflow.persistence.engine import get_session_factory
+ from deerflow.persistence.user.model import UserRow
provider = get_local_provider()
- user_count = await provider.count_users()
+ admin_count = await provider.count_admin_users()
- admin = None
+ if admin_count == 0:
+ init_token = secrets.token_urlsafe(32)
+ app.state.init_token = init_token
+ logger.info("=" * 60)
+ logger.info(" First boot detected — no admin account exists.")
+ logger.info(" Use the one-time token below to create the admin account.")
+ logger.info(" Copy it into the /setup form when prompted.")
+ logger.info(" INIT TOKEN: %s", init_token)
+ logger.info(" Visit /setup to complete admin account creation.")
+ logger.info("=" * 60)
+ return
- if user_count == 0:
- password = secrets.token_urlsafe(16)
- try:
- admin = await provider.create_user(email="admin@deerflow.dev", password=password, system_role="admin", needs_setup=True)
- except ValueError:
- return # Another worker already created the admin.
- _announce_credentials(admin.email, password, label="initial", headline="Admin account created on first boot")
- else:
- # Admin exists but setup never completed — reset password so operator
- # can always find it in the console without needing the CLI.
- # Multi-worker guard: if admin was created less than 30s ago, another
- # worker just created it and will print the password — skip reset.
- admin = await provider.get_user_by_email("admin@deerflow.dev")
- if admin and admin.needs_setup:
- import time
+ # Admin already exists — run orphan thread migration for any
+ # LangGraph thread metadata that pre-dates the auth module.
+ sf = get_session_factory()
+ if sf is None:
+ return
- age = time.time() - admin.created_at.replace(tzinfo=UTC).timestamp()
- if age >= 30:
- from app.gateway.auth.password import hash_password_async
+ async with sf() as session:
+ stmt = select(UserRow).where(UserRow.system_role == "admin").limit(1)
+ row = (await session.execute(stmt)).scalar_one_or_none()
- password = secrets.token_urlsafe(16)
- admin.password_hash = await hash_password_async(password)
- admin.token_version += 1
- await provider.update_user(admin)
- _announce_credentials(admin.email, password, label="reset", headline="Admin account setup incomplete — password reset")
+ if row is None:
+ return # Should not happen (admin_count > 0 above), but be safe.
- if admin is None:
- return # Nothing to bind orphans to.
-
- admin_id = str(admin.id)
+ admin_id = str(row.id)
# LangGraph store orphan migration — non-fatal.
- # This covers the "no-auth → with-auth" upgrade path for users
- # whose existing LangGraph thread metadata has no owner_id set.
store = getattr(app.state, "store", None)
if store is not None:
try:
@@ -374,6 +358,11 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
"""
return {"status": "healthy", "service": "deer-flow-gateway"}
+ # Ensure init_token always exists on app.state (None until lifespan sets it
+ # if no admin is found). This prevents AttributeError in tests that don't
+ # run the full lifespan.
+ app.state.init_token = None
+
return app
diff --git a/backend/app/gateway/auth/errors.py b/backend/app/gateway/auth/errors.py
index 55ae004db..045d25fa1 100644
--- a/backend/app/gateway/auth/errors.py
+++ b/backend/app/gateway/auth/errors.py
@@ -20,6 +20,8 @@ class AuthErrorCode(StrEnum):
EMAIL_ALREADY_EXISTS = "email_already_exists"
PROVIDER_NOT_FOUND = "provider_not_found"
NOT_AUTHENTICATED = "not_authenticated"
+ SYSTEM_ALREADY_INITIALIZED = "system_already_initialized"
+ INVALID_INIT_TOKEN = "invalid_init_token"
class TokenError(StrEnum):
diff --git a/backend/app/gateway/auth/local_provider.py b/backend/app/gateway/auth/local_provider.py
index e051f982b..8bfd15e59 100644
--- a/backend/app/gateway/auth/local_provider.py
+++ b/backend/app/gateway/auth/local_provider.py
@@ -78,6 +78,10 @@ class LocalAuthProvider(AuthProvider):
"""Return total number of registered users."""
return await self._repo.count_users()
+ async def count_admin_users(self) -> int:
+ """Return number of admin users."""
+ return await self._repo.count_admin_users()
+
async def update_user(self, user: User) -> User:
"""Update an existing user."""
return await self._repo.update_user(user)
diff --git a/backend/app/gateway/auth/repositories/base.py b/backend/app/gateway/auth/repositories/base.py
index 57bc2c23b..d96753171 100644
--- a/backend/app/gateway/auth/repositories/base.py
+++ b/backend/app/gateway/auth/repositories/base.py
@@ -83,6 +83,11 @@ class UserRepository(ABC):
"""Return total number of registered users."""
...
+ @abstractmethod
+ async def count_admin_users(self) -> int:
+ """Return number of users with system_role == 'admin'."""
+ ...
+
@abstractmethod
async def get_user_by_oauth(self, provider: str, oauth_id: str) -> User | None:
"""Get user by OAuth provider and ID.
diff --git a/backend/app/gateway/auth/repositories/sqlite.py b/backend/app/gateway/auth/repositories/sqlite.py
index da2d8b9f7..3ee3978e3 100644
--- a/backend/app/gateway/auth/repositories/sqlite.py
+++ b/backend/app/gateway/auth/repositories/sqlite.py
@@ -114,6 +114,11 @@ class SQLiteUserRepository(UserRepository):
async with self._sf() as session:
return await session.scalar(stmt) or 0
+ async def count_admin_users(self) -> int:
+ stmt = select(func.count()).select_from(UserRow).where(UserRow.system_role == "admin")
+ async with self._sf() as session:
+ return await session.scalar(stmt) or 0
+
async def get_user_by_oauth(self, provider: str, oauth_id: str) -> User | None:
stmt = select(UserRow).where(UserRow.oauth_provider == provider, UserRow.oauth_id == oauth_id)
async with self._sf() as session:
diff --git a/backend/app/gateway/auth_middleware.py b/backend/app/gateway/auth_middleware.py
index b45591a30..fd982cd79 100644
--- a/backend/app/gateway/auth_middleware.py
+++ b/backend/app/gateway/auth_middleware.py
@@ -36,6 +36,7 @@ _PUBLIC_EXACT_PATHS: frozenset[str] = frozenset(
"/api/v1/auth/register",
"/api/v1/auth/logout",
"/api/v1/auth/setup-status",
+ "/api/v1/auth/initialize",
}
)
diff --git a/backend/app/gateway/csrf_middleware.py b/backend/app/gateway/csrf_middleware.py
index fc96878b6..4c9b0f36a 100644
--- a/backend/app/gateway/csrf_middleware.py
+++ b/backend/app/gateway/csrf_middleware.py
@@ -48,6 +48,7 @@ _AUTH_EXEMPT_PATHS: frozenset[str] = frozenset(
"/api/v1/auth/login/local",
"/api/v1/auth/logout",
"/api/v1/auth/register",
+ "/api/v1/auth/initialize",
}
)
diff --git a/backend/app/gateway/routers/auth.py b/backend/app/gateway/routers/auth.py
index 804a52ce9..a4c085a57 100644
--- a/backend/app/gateway/routers/auth.py
+++ b/backend/app/gateway/routers/auth.py
@@ -2,6 +2,7 @@
import logging
import os
+import secrets
import time
from ipaddress import ip_address, ip_network
@@ -378,9 +379,74 @@ async def get_me(request: Request):
@router.get("/setup-status")
async def setup_status():
- """Check if admin account exists. Always False after first boot."""
- user_count = await get_local_provider().count_users()
- return {"needs_setup": user_count == 0}
+ """Check if an admin account exists. Returns needs_setup=True when no admin exists."""
+ admin_count = await get_local_provider().count_admin_users()
+ return {"needs_setup": admin_count == 0}
+
+
+class InitializeAdminRequest(BaseModel):
+ """Request model for first-boot admin account creation."""
+
+ email: EmailStr
+ password: str = Field(..., min_length=8)
+ init_token: str | None = Field(default=None, description="One-time initialization token printed to server logs on first boot")
+
+ _strong_password = field_validator("password")(classmethod(lambda cls, v: _validate_strong_password(v)))
+
+
+@router.post("/initialize", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
+async def initialize_admin(request: Request, response: Response, body: InitializeAdminRequest):
+ """Create the first admin account on initial system setup.
+
+ Only callable when no admin exists. Returns 409 Conflict if an admin
+ already exists. Requires the one-time ``init_token`` that is logged to
+ stdout at startup whenever the system has no admin account.
+
+ On success the token is consumed (one-time use), the admin account is
+ created with ``needs_setup=False``, and the session cookie is set.
+ """
+ # Validate the one-time initialization token. The token is generated
+ # at startup and stored in app.state.init_token; it is consumed here on
+ # the first successful call so it cannot be replayed.
+ # Using str | None allows a missing/null token to return 403 (not 422),
+ # giving a consistent error response regardless of whether the token is
+ # absent or incorrect.
+ stored_token: str | None = getattr(request.app.state, "init_token", None)
+ provided_token: str = body.init_token or ""
+ if stored_token is None or not secrets.compare_digest(stored_token, provided_token):
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN,
+ detail=AuthErrorResponse(code=AuthErrorCode.INVALID_INIT_TOKEN, message="Invalid or expired initialization token").model_dump(),
+ )
+
+ admin_count = await get_local_provider().count_admin_users()
+ if admin_count > 0:
+ # Do NOT consume the token on this error path — consuming it here
+ # would allow an attacker to exhaust the token by calling with the
+ # correct token when admin already exists (denial-of-service).
+ raise HTTPException(
+ status_code=status.HTTP_409_CONFLICT,
+ detail=AuthErrorResponse(code=AuthErrorCode.SYSTEM_ALREADY_INITIALIZED, message="System already initialized").model_dump(),
+ )
+
+ try:
+ user = await get_local_provider().create_user(email=body.email, password=body.password, system_role="admin", needs_setup=False)
+ except ValueError:
+ # DB unique-constraint race: another concurrent request beat us.
+ # Do NOT consume the token here for the same reason as above.
+ raise HTTPException(
+ status_code=status.HTTP_409_CONFLICT,
+ detail=AuthErrorResponse(code=AuthErrorCode.SYSTEM_ALREADY_INITIALIZED, message="System already initialized").model_dump(),
+ )
+
+ # Consume the token only after successful initialization — this is the
+ # single place where one-time use is enforced.
+ request.app.state.init_token = None
+
+ token = create_access_token(str(user.id), token_version=user.token_version)
+ _set_session_cookie(response, token, request)
+
+ return UserResponse(id=str(user.id), email=user.email, system_role=user.system_role)
# ── OAuth Endpoints (Future/Placeholder) ─────────────────────────────────
diff --git a/backend/packages/harness/deerflow/agents/lead_agent/prompt.py b/backend/packages/harness/deerflow/agents/lead_agent/prompt.py
index 983ae873c..71af2e653 100644
--- a/backend/packages/harness/deerflow/agents/lead_agent/prompt.py
+++ b/backend/packages/harness/deerflow/agents/lead_agent/prompt.py
@@ -164,30 +164,6 @@ Skip simple one-off tasks.
"""
-def _skill_mutability_label(category: str) -> str:
- return "[custom, editable]" if category == "custom" else "[built-in]"
-
-
-def clear_skills_system_prompt_cache() -> None:
- _get_cached_skills_prompt_section.cache_clear()
-
-
-def _build_skill_evolution_section(skill_evolution_enabled: bool) -> str:
- if not skill_evolution_enabled:
- return ""
- return """
-## Skill Self-Evolution
-After completing a task, consider creating or updating a skill when:
-- The task required 5+ tool calls to resolve
-- You overcame non-obvious errors or pitfalls
-- The user corrected your approach and the corrected version worked
-- You discovered a non-trivial, recurring workflow
-If you used a skill and encountered issues not covered by it, patch it immediately.
-Prefer patch over edit. Before creating a new skill, confirm with the user first.
-Skip simple one-off tasks.
-"""
-
-
def _build_subagent_section(max_concurrent: int) -> str:
"""Build the subagent system prompt section with dynamic concurrency limit.
diff --git a/backend/packages/harness/deerflow/runtime/runs/worker.py b/backend/packages/harness/deerflow/runtime/runs/worker.py
index 4e042f256..efa306b0b 100644
--- a/backend/packages/harness/deerflow/runtime/runs/worker.py
+++ b/backend/packages/harness/deerflow/runtime/runs/worker.py
@@ -85,6 +85,35 @@ async def run_agent(
pre_run_snapshot: dict[str, Any] | None = None
snapshot_capture_failed = False
+ # Initialize RunJournal for event capture
+ journal = None
+ if event_store is not None:
+ from deerflow.runtime.journal import RunJournal
+
+ journal = RunJournal(
+ run_id=run_id,
+ thread_id=thread_id,
+ event_store=event_store,
+ track_token_usage=getattr(run_events_config, "track_token_usage", True),
+ )
+
+ # Write human_message event (model_dump format, aligned with checkpoint)
+ human_msg = _extract_human_message(graph_input)
+ if human_msg is not None:
+ msg_metadata = {}
+ if follow_up_to_run_id:
+ msg_metadata["follow_up_to_run_id"] = follow_up_to_run_id
+ await event_store.put(
+ thread_id=thread_id,
+ run_id=run_id,
+ event_type="human_message",
+ category="message",
+ content=human_msg.model_dump(),
+ metadata=msg_metadata or None,
+ )
+ content = human_msg.content
+ journal.set_first_human_message(content if isinstance(content, str) else str(content))
+
# Initialize RunJournal for event capture
journal = None
if event_store is not None:
diff --git a/backend/pyproject.toml b/backend/pyproject.toml
index a743d5e02..72804ce94 100644
--- a/backend/pyproject.toml
+++ b/backend/pyproject.toml
@@ -23,9 +23,7 @@ dependencies = [
]
[project.optional-dependencies]
-postgres = [
- "deerflow-harness[postgres]",
-]
+postgres = ["deerflow-harness[postgres]"]
[dependency-groups]
dev = ["pytest>=8.0.0", "ruff>=0.14.11"]
diff --git a/backend/tests/test_ensure_admin.py b/backend/tests/test_ensure_admin.py
index 1731455ef..5079c4cfb 100644
--- a/backend/tests/test_ensure_admin.py
+++ b/backend/tests/test_ensure_admin.py
@@ -1,21 +1,19 @@
"""Tests for _ensure_admin_user() in app.py.
-Covers: first-boot admin creation, auto-reset on needs_setup=True,
-no-op on needs_setup=False, migration, and edge cases.
+Covers: first-boot no-op (admin creation removed), orphan migration
+when admin exists, no-op on no admin found, and edge cases.
"""
import asyncio
import os
-from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
-from unittest.mock import AsyncMock, patch
+from unittest.mock import AsyncMock, MagicMock, patch
import pytest
os.environ.setdefault("AUTH_JWT_SECRET", "test-secret-key-ensure-admin-testing-min-32")
from app.gateway.auth.config import AuthConfig, set_auth_config
-from app.gateway.auth.models import User
_JWT_SECRET = "test-secret-key-ensure-admin-testing-min-32"
@@ -35,53 +33,90 @@ def _make_app_stub(store=None):
return app
-def _make_provider(user_count=0, admin_user=None):
+def _make_provider(admin_count=0):
p = AsyncMock()
- p.count_users = AsyncMock(return_value=user_count)
- p.create_user = AsyncMock(
- side_effect=lambda **kw: User(
- email=kw["email"],
- password_hash="hashed",
- system_role=kw.get("system_role", "user"),
- needs_setup=kw.get("needs_setup", False),
- )
- )
- p.get_user_by_email = AsyncMock(return_value=admin_user)
+ p.count_users = AsyncMock(return_value=admin_count)
+ p.count_admin_users = AsyncMock(return_value=admin_count)
+ p.create_user = AsyncMock()
p.update_user = AsyncMock(side_effect=lambda u: u)
return p
-# ── First boot: no users ─────────────────────────────────────────────────
+def _make_session_factory(admin_row=None):
+ """Build a mock async session factory that returns a row from execute()."""
+ row_result = MagicMock()
+ row_result.scalar_one_or_none.return_value = admin_row
+
+ execute_result = MagicMock()
+ execute_result.scalar_one_or_none.return_value = admin_row
+
+ session = AsyncMock()
+ session.execute = AsyncMock(return_value=execute_result)
+
+ # Async context manager
+ session_cm = AsyncMock()
+ session_cm.__aenter__ = AsyncMock(return_value=session)
+ session_cm.__aexit__ = AsyncMock(return_value=False)
+
+ sf = MagicMock()
+ sf.return_value = session_cm
+ return sf
-def test_first_boot_creates_admin():
- """count_users==0 → create admin with needs_setup=True."""
- provider = _make_provider(user_count=0)
+# ── First boot: no admin → generate init_token, return early ─────────────
+
+
+def test_first_boot_does_not_create_admin():
+ """admin_count==0 → generate init_token, do NOT create admin automatically."""
+ provider = _make_provider(admin_count=0)
app = _make_app_stub()
+ app.state.init_token = None # lifespan sets this
with patch("app.gateway.deps.get_local_provider", return_value=provider):
- with patch("app.gateway.auth.password.hash_password_async", new_callable=AsyncMock, return_value="hashed"):
- from app.gateway.app import _ensure_admin_user
+ from app.gateway.app import _ensure_admin_user
- asyncio.run(_ensure_admin_user(app))
+ asyncio.run(_ensure_admin_user(app))
- provider.create_user.assert_called_once()
- call_kwargs = provider.create_user.call_args[1]
- assert call_kwargs["email"] == "admin@deerflow.dev"
- assert call_kwargs["system_role"] == "admin"
- assert call_kwargs["needs_setup"] is True
- assert len(call_kwargs["password"]) > 10 # random password generated
+ provider.create_user.assert_not_called()
+ # init_token must have been set on app.state
+ assert app.state.init_token is not None
+ assert len(app.state.init_token) > 10
-def test_first_boot_triggers_migration_if_store_present():
- """First boot with store → _migrate_orphaned_threads called."""
- provider = _make_provider(user_count=0)
+def test_first_boot_skips_migration():
+ """No admin → return early before any migration attempt."""
+ provider = _make_provider(admin_count=0)
+ store = AsyncMock()
+ store.asearch = AsyncMock(return_value=[])
+ app = _make_app_stub(store=store)
+ app.state.init_token = None # lifespan sets this
+
+ with patch("app.gateway.deps.get_local_provider", return_value=provider):
+ from app.gateway.app import _ensure_admin_user
+
+ asyncio.run(_ensure_admin_user(app))
+
+ store.asearch.assert_not_called()
+
+
+# ── Admin exists: migration runs when admin row found ────────────────────
+
+
+def test_admin_exists_triggers_migration():
+ """Admin exists and admin row found → _migrate_orphaned_threads called."""
+ from uuid import uuid4
+
+ admin_row = MagicMock()
+ admin_row.id = uuid4()
+
+ provider = _make_provider(admin_count=1)
+ sf = _make_session_factory(admin_row=admin_row)
store = AsyncMock()
store.asearch = AsyncMock(return_value=[])
app = _make_app_stub(store=store)
with patch("app.gateway.deps.get_local_provider", return_value=provider):
- with patch("app.gateway.auth.password.hash_password_async", new_callable=AsyncMock, return_value="hashed"):
+ with patch("deerflow.persistence.engine.get_session_factory", return_value=sf):
from app.gateway.app import _ensure_admin_user
asyncio.run(_ensure_admin_user(app))
@@ -89,130 +124,77 @@ def test_first_boot_triggers_migration_if_store_present():
store.asearch.assert_called_once()
-def test_first_boot_no_store_skips_migration():
- """First boot without store → no crash, migration skipped."""
- provider = _make_provider(user_count=0)
+def test_admin_exists_no_admin_row_skips_migration():
+ """Admin count > 0 but DB row missing (edge case) → skip migration gracefully."""
+ provider = _make_provider(admin_count=2)
+ sf = _make_session_factory(admin_row=None)
+ store = AsyncMock()
+ app = _make_app_stub(store=store)
+
+ with patch("app.gateway.deps.get_local_provider", return_value=provider):
+ with patch("deerflow.persistence.engine.get_session_factory", return_value=sf):
+ from app.gateway.app import _ensure_admin_user
+
+ asyncio.run(_ensure_admin_user(app))
+
+ store.asearch.assert_not_called()
+
+
+def test_admin_exists_no_store_skips_migration():
+ """Admin exists, row found, but no store → no crash, no migration."""
+ from uuid import uuid4
+
+ admin_row = MagicMock()
+ admin_row.id = uuid4()
+
+ provider = _make_provider(admin_count=1)
+ sf = _make_session_factory(admin_row=admin_row)
app = _make_app_stub(store=None)
with patch("app.gateway.deps.get_local_provider", return_value=provider):
- with patch("app.gateway.auth.password.hash_password_async", new_callable=AsyncMock, return_value="hashed"):
+ with patch("deerflow.persistence.engine.get_session_factory", return_value=sf):
from app.gateway.app import _ensure_admin_user
asyncio.run(_ensure_admin_user(app))
- provider.create_user.assert_called_once()
+ # No assertion needed — just verify no crash
-# ── Subsequent boot: needs_setup=True → auto-reset ───────────────────────
-
-
-def test_needs_setup_true_resets_password():
- """Existing admin with needs_setup=True → password reset + token_version bumped."""
- admin = User(
- email="admin@deerflow.dev",
- password_hash="old-hash",
- system_role="admin",
- needs_setup=True,
- token_version=0,
- created_at=datetime.now(UTC) - timedelta(seconds=30),
- )
- provider = _make_provider(user_count=1, admin_user=admin)
- app = _make_app_stub()
+def test_admin_exists_session_factory_none_skips_migration():
+ """get_session_factory() returns None → return early, no crash."""
+ provider = _make_provider(admin_count=1)
+ store = AsyncMock()
+ app = _make_app_stub(store=store)
with patch("app.gateway.deps.get_local_provider", return_value=provider):
- with patch("app.gateway.auth.password.hash_password_async", new_callable=AsyncMock, return_value="new-hash"):
+ with patch("deerflow.persistence.engine.get_session_factory", return_value=None):
from app.gateway.app import _ensure_admin_user
asyncio.run(_ensure_admin_user(app))
- # Password was reset
- provider.update_user.assert_called_once()
- updated = provider.update_user.call_args[0][0]
- assert updated.password_hash == "new-hash"
- assert updated.token_version == 1
-
-
-def test_needs_setup_true_consecutive_resets_increment_version():
- """Two boots with needs_setup=True → token_version increments each time."""
- admin = User(
- email="admin@deerflow.dev",
- password_hash="hash",
- system_role="admin",
- needs_setup=True,
- token_version=3,
- created_at=datetime.now(UTC) - timedelta(seconds=30),
- )
- provider = _make_provider(user_count=1, admin_user=admin)
- app = _make_app_stub()
-
- with patch("app.gateway.deps.get_local_provider", return_value=provider):
- with patch("app.gateway.auth.password.hash_password_async", new_callable=AsyncMock, return_value="new-hash"):
- from app.gateway.app import _ensure_admin_user
-
- asyncio.run(_ensure_admin_user(app))
-
- updated = provider.update_user.call_args[0][0]
- assert updated.token_version == 4
-
-
-# ── Subsequent boot: needs_setup=False → no-op ──────────────────────────
-
-
-def test_needs_setup_false_no_reset():
- """Admin with needs_setup=False → no password reset, no update."""
- admin = User(
- email="admin@deerflow.dev",
- password_hash="stable-hash",
- system_role="admin",
- needs_setup=False,
- token_version=2,
- )
- provider = _make_provider(user_count=1, admin_user=admin)
- app = _make_app_stub()
-
- with patch("app.gateway.deps.get_local_provider", return_value=provider):
- from app.gateway.app import _ensure_admin_user
-
- asyncio.run(_ensure_admin_user(app))
-
- provider.update_user.assert_not_called()
- assert admin.password_hash == "stable-hash"
- assert admin.token_version == 2
-
-
-# ── Edge cases ───────────────────────────────────────────────────────────
-
-
-def test_no_admin_email_found_no_crash():
- """Users exist but no admin@deerflow.dev → no crash, no reset."""
- provider = _make_provider(user_count=3, admin_user=None)
- app = _make_app_stub()
-
- with patch("app.gateway.deps.get_local_provider", return_value=provider):
- from app.gateway.app import _ensure_admin_user
-
- asyncio.run(_ensure_admin_user(app))
-
- provider.update_user.assert_not_called()
- provider.create_user.assert_not_called()
+ store.asearch.assert_not_called()
def test_migration_failure_is_non_fatal():
"""_migrate_orphaned_threads exception is caught and logged."""
- provider = _make_provider(user_count=0)
+ from uuid import uuid4
+
+ admin_row = MagicMock()
+ admin_row.id = uuid4()
+
+ provider = _make_provider(admin_count=1)
+ sf = _make_session_factory(admin_row=admin_row)
store = AsyncMock()
store.asearch = AsyncMock(side_effect=RuntimeError("store crashed"))
app = _make_app_stub(store=store)
with patch("app.gateway.deps.get_local_provider", return_value=provider):
- with patch("app.gateway.auth.password.hash_password_async", new_callable=AsyncMock, return_value="hashed"):
+ with patch("deerflow.persistence.engine.get_session_factory", return_value=sf):
from app.gateway.app import _ensure_admin_user
# Should not raise
asyncio.run(_ensure_admin_user(app))
- provider.create_user.assert_called_once()
-
# ── Section 5.1-5.6 upgrade path: orphan thread migration ────────────────
diff --git a/backend/tests/test_initialize_admin.py b/backend/tests/test_initialize_admin.py
new file mode 100644
index 000000000..0e9335f05
--- /dev/null
+++ b/backend/tests/test_initialize_admin.py
@@ -0,0 +1,229 @@
+"""Tests for the POST /api/v1/auth/initialize endpoint.
+
+Covers: first-boot admin creation, rejection when system already
+initialized, invalid/missing init_token, password strength validation,
+and public accessibility (no auth cookie required).
+"""
+
+import asyncio
+import os
+
+import pytest
+from fastapi.testclient import TestClient
+
+os.environ.setdefault("AUTH_JWT_SECRET", "test-secret-key-initialize-admin-min-32")
+
+from app.gateway.auth.config import AuthConfig, set_auth_config
+
+_TEST_SECRET = "test-secret-key-initialize-admin-min-32"
+_INIT_TOKEN = "test-init-token-for-initialization-tests"
+
+
+@pytest.fixture(autouse=True)
+def _setup_auth(tmp_path):
+ """Fresh SQLite engine + auth config per test."""
+ from app.gateway import deps
+ from deerflow.persistence.engine import close_engine, init_engine
+
+ set_auth_config(AuthConfig(jwt_secret=_TEST_SECRET))
+ url = f"sqlite+aiosqlite:///{tmp_path}/init_admin.db"
+ asyncio.run(init_engine("sqlite", url=url, sqlite_dir=str(tmp_path)))
+ deps._cached_local_provider = None
+ deps._cached_repo = None
+ try:
+ yield
+ finally:
+ deps._cached_local_provider = None
+ deps._cached_repo = None
+ asyncio.run(close_engine())
+
+
+@pytest.fixture()
+def client(_setup_auth):
+ from app.gateway.app import create_app
+ from app.gateway.auth.config import AuthConfig, set_auth_config
+
+ set_auth_config(AuthConfig(jwt_secret=_TEST_SECRET))
+ app = create_app()
+ # Pre-set the init token on app.state (normally done by the lifespan on
+ # first boot; tests don't run the lifespan because it requires config.yaml).
+ app.state.init_token = _INIT_TOKEN
+ # Do NOT use TestClient as a context manager — that would trigger the
+ # full lifespan which requires config.yaml. The auth endpoints work
+ # without the lifespan (persistence engine is set up by _setup_auth).
+ yield TestClient(app)
+
+
+def _init_payload(**extra):
+ """Build a valid /initialize payload with the test init_token."""
+ return {
+ "email": "admin@example.com",
+ "password": "Str0ng!Pass99",
+ "init_token": _INIT_TOKEN,
+ **extra,
+ }
+
+
+# ── Happy path ────────────────────────────────────────────────────────────
+
+
+def test_initialize_creates_admin_and_sets_cookie(client):
+ """POST /initialize when no admin exists → 201, session cookie set."""
+ resp = client.post("/api/v1/auth/initialize", json=_init_payload())
+ assert resp.status_code == 201
+ data = resp.json()
+ assert data["email"] == "admin@example.com"
+ assert data["system_role"] == "admin"
+ assert "access_token" in resp.cookies
+
+
+def test_initialize_needs_setup_false(client):
+ """Newly created admin via /initialize has needs_setup=False."""
+ client.post("/api/v1/auth/initialize", json=_init_payload())
+ me = client.get("/api/v1/auth/me")
+ assert me.status_code == 200
+ assert me.json()["needs_setup"] is False
+
+
+# ── Token validation ──────────────────────────────────────────────────────
+
+
+def test_initialize_rejects_wrong_token(client):
+ """Wrong init_token → 403 invalid_init_token."""
+ resp = client.post(
+ "/api/v1/auth/initialize",
+ json={**_init_payload(), "init_token": "wrong-token"},
+ )
+ assert resp.status_code == 403
+ assert resp.json()["detail"]["code"] == "invalid_init_token"
+
+
+def test_initialize_rejects_empty_token(client):
+ """Empty init_token → 403 (fails constant-time comparison against stored token)."""
+ resp = client.post(
+ "/api/v1/auth/initialize",
+ json={**_init_payload(), "init_token": ""},
+ )
+ assert resp.status_code == 403
+
+
+def test_initialize_token_consumed_after_success(client):
+ """After a successful /initialize the token is consumed and cannot be reused."""
+ client.post("/api/v1/auth/initialize", json=_init_payload())
+ # The token is now None; any subsequent call with the old token must be rejected (403)
+ resp2 = client.post(
+ "/api/v1/auth/initialize",
+ json={**_init_payload(), "email": "other@example.com"},
+ )
+ assert resp2.status_code == 403
+
+
+# ── Rejection when already initialized ───────────────────────────────────
+
+
+def test_initialize_rejected_when_admin_exists(client):
+ """Second call to /initialize after admin exists → 409 system_already_initialized.
+
+ The first call consumes the token. Re-setting it on app.state simulates
+ what would happen if the operator somehow restarted or manually refreshed
+ the token (e.g., in testing).
+ """
+ client.post("/api/v1/auth/initialize", json=_init_payload())
+ # Re-set the token so the second attempt can pass token validation
+ # and reach the admin-exists check.
+ client.app.state.init_token = _INIT_TOKEN
+ resp2 = client.post(
+ "/api/v1/auth/initialize",
+ json={**_init_payload(), "email": "other@example.com"},
+ )
+ assert resp2.status_code == 409
+ body = resp2.json()
+ assert body["detail"]["code"] == "system_already_initialized"
+
+
+def test_initialize_token_not_consumed_on_admin_exists(client):
+ """Token is NOT consumed when the admin-exists guard rejects the request.
+
+ This prevents a DoS where an attacker calls with the correct token when
+ admin already exists and permanently burns the init token.
+ """
+ client.post("/api/v1/auth/initialize", json=_init_payload())
+ # Token consumed by success above; re-simulate the scenario:
+ # admin exists, token is still valid (re-set), call should 409 and NOT consume token.
+ client.app.state.init_token = _INIT_TOKEN
+ client.post(
+ "/api/v1/auth/initialize",
+ json={**_init_payload(), "email": "other@example.com"},
+ )
+ # Token must still be set (not consumed) after the 409 rejection.
+ assert client.app.state.init_token == _INIT_TOKEN
+
+
+def test_initialize_register_does_not_block_initialization(client):
+ """/register creating a user before /initialize doesn't block admin creation."""
+ # Register a regular user first
+ client.post("/api/v1/auth/register", json={"email": "regular@example.com", "password": "Tr0ub4dor3a"})
+ # /initialize should still succeed (checks admin_count, not total user_count)
+ resp = client.post("/api/v1/auth/initialize", json=_init_payload())
+ assert resp.status_code == 201
+ assert resp.json()["system_role"] == "admin"
+
+
+# ── Endpoint is public (no cookie required) ───────────────────────────────
+
+
+def test_initialize_accessible_without_cookie(client):
+ """No access_token cookie needed for /initialize."""
+ resp = client.post(
+ "/api/v1/auth/initialize",
+ json=_init_payload(),
+ cookies={},
+ )
+ assert resp.status_code == 201
+
+
+# ── Password validation ───────────────────────────────────────────────────
+
+
+def test_initialize_rejects_short_password(client):
+ """Password shorter than 8 chars → 422."""
+ resp = client.post(
+ "/api/v1/auth/initialize",
+ json={**_init_payload(), "password": "short"},
+ )
+ assert resp.status_code == 422
+
+
+def test_initialize_rejects_common_password(client):
+ """Common password → 422."""
+ resp = client.post(
+ "/api/v1/auth/initialize",
+ json={**_init_payload(), "password": "password123"},
+ )
+ assert resp.status_code == 422
+
+
+# ── setup-status reflects initialization ─────────────────────────────────
+
+
+def test_setup_status_before_initialization(client):
+ """setup-status returns needs_setup=True before /initialize is called."""
+ resp = client.get("/api/v1/auth/setup-status")
+ assert resp.status_code == 200
+ assert resp.json()["needs_setup"] is True
+
+
+def test_setup_status_after_initialization(client):
+ """setup-status returns needs_setup=False after /initialize succeeds."""
+ client.post("/api/v1/auth/initialize", json=_init_payload())
+ resp = client.get("/api/v1/auth/setup-status")
+ assert resp.status_code == 200
+ assert resp.json()["needs_setup"] is False
+
+
+def test_setup_status_false_when_only_regular_user_exists(client):
+ """setup-status returns needs_setup=True even when regular users exist (no admin)."""
+ client.post("/api/v1/auth/register", json={"email": "regular@example.com", "password": "Tr0ub4dor3a"})
+ resp = client.get("/api/v1/auth/setup-status")
+ assert resp.status_code == 200
+ assert resp.json()["needs_setup"] is True
diff --git a/frontend/src/app/(auth)/layout.tsx b/frontend/src/app/(auth)/layout.tsx
index b916def52..0b35d4ac1 100644
--- a/frontend/src/app/(auth)/layout.tsx
+++ b/frontend/src/app/(auth)/layout.tsx
@@ -21,6 +21,7 @@ export default async function AuthLayout({
case "needs_setup":
// Allow access to setup page
return
{isLogin ? "Sign in to your account" : "Create a new account"}