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 {children}; + case "system_setup_required": case "unauthenticated": return {children}; case "gateway_unavailable": diff --git a/frontend/src/app/(auth)/login/page.tsx b/frontend/src/app/(auth)/login/page.tsx index 90ca15238..82fcf8b90 100644 --- a/frontend/src/app/(auth)/login/page.tsx +++ b/frontend/src/app/(auth)/login/page.tsx @@ -2,9 +2,11 @@ import Link from "next/link"; import { useRouter, useSearchParams } from "next/navigation"; +import { useTheme } from "next-themes"; import { useEffect, useState } from "react"; import { Button } from "@/components/ui/button"; +import { FlickeringGrid } from "@/components/ui/flickering-grid"; import { Input } from "@/components/ui/input"; import { useAuth } from "@/core/auth/AuthProvider"; import { parseAuthError } from "@/core/auth/types"; @@ -46,6 +48,7 @@ export default function LoginPage() { const router = useRouter(); const searchParams = useSearchParams(); const { isAuthenticated } = useAuth(); + const { theme, resolvedTheme } = useTheme(); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); @@ -64,6 +67,26 @@ export default function LoginPage() { } }, [isAuthenticated, redirectPath, router]); + // Redirect to setup if the system has no users yet + useEffect(() => { + let cancelled = false; + + void fetch("/api/v1/auth/setup-status") + .then((r) => r.json()) + .then((data: { needs_setup?: boolean }) => { + if (!cancelled && data.needs_setup) { + router.push("/setup"); + } + }) + .catch(() => { + // Ignore errors; user stays on login page + }); + + return () => { + cancelled = true; + }; + }, [router]); + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(""); @@ -97,25 +120,35 @@ export default function LoginPage() { // Both login and register set a cookie — redirect to workspace router.push(redirectPath); - } catch (_err) { + } catch { setError("Network error. Please try again."); } finally { setLoading(false); } }; + const actualTheme = theme === "system" ? resolvedTheme : theme; + return ( -
-
+
+ +
-

DeerFlow

+

DeerFlow

{isLogin ? "Sign in to your account" : "Create a new account"}

-
-
+ +
@@ -126,11 +159,9 @@ export default function LoginPage() { onChange={(e) => setEmail(e.target.value)} placeholder="you@example.com" required - className="mt-1 bg-white text-black" />
- -
+
@@ -142,7 +173,6 @@ export default function LoginPage() { placeholder="•••••••" required minLength={isLogin ? 6 : 8} - className="mt-1 bg-white text-black" />
diff --git a/frontend/src/app/(auth)/setup/page.tsx b/frontend/src/app/(auth)/setup/page.tsx index e70d1efc6..fb694e6a1 100644 --- a/frontend/src/app/(auth)/setup/page.tsx +++ b/frontend/src/app/(auth)/setup/page.tsx @@ -1,23 +1,108 @@ "use client"; import { useRouter } from "next/navigation"; -import { useState } from "react"; +import { useTheme } from "next-themes"; +import { useEffect, useState } from "react"; import { Button } from "@/components/ui/button"; +import { FlickeringGrid } from "@/components/ui/flickering-grid"; import { Input } from "@/components/ui/input"; import { getCsrfHeaders } from "@/core/api/fetcher"; +import { useAuth } from "@/core/auth/AuthProvider"; import { parseAuthError } from "@/core/auth/types"; +type SetupMode = "loading" | "init_admin" | "change_password"; + export default function SetupPage() { const router = useRouter(); + const { user, isAuthenticated } = useAuth(); + const { theme, resolvedTheme } = useTheme(); + const [mode, setMode] = useState("loading"); + + // --- Shared state --- const [email, setEmail] = useState(""); const [newPassword, setNewPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); - const [currentPassword, setCurrentPassword] = useState(""); const [error, setError] = useState(""); const [loading, setLoading] = useState(false); - const handleSetup = async (e: React.FormEvent) => { + // --- Init-admin mode only --- + const [initToken, setInitToken] = useState(""); + + // --- Change-password mode only --- + const [currentPassword, setCurrentPassword] = useState(""); + + useEffect(() => { + let cancelled = false; + + if (isAuthenticated && user?.needs_setup) { + setMode("change_password"); + } else if (!isAuthenticated) { + // Check if the system has no users yet + void fetch("/api/v1/auth/setup-status") + .then((r) => r.json()) + .then((data: { needs_setup?: boolean }) => { + if (cancelled) return; + if (data.needs_setup) { + setMode("init_admin"); + } else { + // System already set up and user is not logged in — go to login + router.push("/login"); + } + }) + .catch(() => { + if (!cancelled) router.push("/login"); + }); + } else { + // Authenticated but needs_setup is false — already set up + router.push("/workspace"); + } + + return () => { + cancelled = true; + }; + }, [isAuthenticated, user, router]); + + // ── Init-admin handler ───────────────────────────────────────────── + const handleInitAdmin = async (e: React.SubmitEvent) => { + e.preventDefault(); + setError(""); + + if (newPassword !== confirmPassword) { + setError("Passwords do not match"); + return; + } + + setLoading(true); + try { + const res = await fetch("/api/v1/auth/initialize", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ + email, + password: newPassword, + init_token: initToken, + }), + }); + + if (!res.ok) { + const data = await res.json(); + const authError = parseAuthError(data); + setError(authError.message); + return; + } + + router.push("/workspace"); + } catch { + setError("Network error. Please try again."); + } finally { + setLoading(false); + } + }; + + // ── Change-password handler ──────────────────────────────────────── + const handleChangePassword = async (e: React.SubmitEvent) => { e.preventDefault(); setError(""); @@ -61,9 +146,117 @@ export default function SetupPage() { } }; + const actualTheme = theme === "system" ? resolvedTheme : theme; + + if (mode === "loading") { + return ( +
+

Loading…

+
+ ); + } + + // ── Admin initialization form ────────────────────────────────────── + if (mode === "init_admin") { + return ( +
+ +
+
+

DeerFlow

+

Create admin account

+

+ Set up the administrator account to get started. +

+
+ +
+ + setEmail(e.target.value)} + required + /> +
+
+ + setInitToken(e.target.value)} + required + autoComplete="off" + /> +

+ Find the INIT TOKEN printed in the server startup logs. +

+
+
+ + setNewPassword(e.target.value)} + required + minLength={8} + /> +
+
+ + setConfirmPassword(e.target.value)} + required + minLength={8} + /> +
+ {error &&

{error}

} + + +
+
+ ); + } + + // ── Change-password form (needs_setup after login) ───────────────── return ( -
-
+
+ +

DeerFlow

@@ -73,7 +266,7 @@ export default function SetupPage() { Set your real email and a new password.

-
+ setCurrentPassword(e.target.value)} required @@ -106,7 +299,7 @@ export default function SetupPage() { /> {error &&

{error}

}
diff --git a/frontend/src/app/workspace/layout.tsx b/frontend/src/app/workspace/layout.tsx index fa19025a0..c2d567339 100644 --- a/frontend/src/app/workspace/layout.tsx +++ b/frontend/src/app/workspace/layout.tsx @@ -23,6 +23,8 @@ export default async function WorkspaceLayout({ ); case "needs_setup": redirect("/setup"); + case "system_setup_required": + redirect("/setup"); case "unauthenticated": redirect("/login"); case "gateway_unavailable": diff --git a/frontend/src/components/workspace/settings/account-settings-page.tsx b/frontend/src/components/workspace/settings/account-settings-page.tsx index 6382b8859..c00d6961e 100644 --- a/frontend/src/components/workspace/settings/account-settings-page.tsx +++ b/frontend/src/components/workspace/settings/account-settings-page.tsx @@ -69,12 +69,10 @@ export function AccountSettingsPage() { return (
-
-
+
+
Email {user?.email ?? "—"} -
-
Role {user?.system_role ?? "—"} @@ -83,7 +81,10 @@ export function AccountSettingsPage() {
- +
- +